diff --git a/.agents/rules/antigravity-rtk-rules.md b/.agents/rules/antigravity-rtk-rules.md new file mode 100644 index 0000000..8ee44bc --- /dev/null +++ b/.agents/rules/antigravity-rtk-rules.md @@ -0,0 +1,32 @@ +# RTK - Rust Token Killer (Google Antigravity) + +**Usage**: Token-optimized CLI proxy for shell commands. + +## Rule + +Always prefix shell commands with `rtk` to minimize token consumption. + +Examples: + +```bash +rtk git status +rtk cargo test +rtk ls src/ +rtk grep "pattern" src/ +rtk find "*.rs" . +rtk docker ps +rtk gh pr list +``` + +## Meta Commands + +```bash +rtk gain # Show token savings +rtk gain --history # Command history with savings +rtk discover # Find missed RTK opportunities +rtk proxy # Run raw (no filtering, for debugging) +``` + +## Why + +RTK filters and compresses command output before it reaches the LLM context, saving 60-90% tokens on common operations. Always use `rtk ` instead of raw commands. diff --git a/.github/workflows/android-checks.yml b/.github/workflows/android-checks.yml new file mode 100644 index 0000000..10af957 --- /dev/null +++ b/.github/workflows/android-checks.yml @@ -0,0 +1,60 @@ +name: Android regression checks + +on: + push: + branches: [main, Fix-issue] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + publication-safety: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + persist-credentials: false + - name: Reject bundled SDKs, keys, recordings and generated files + env: + PUBLICATION_BASE: ${{ github.event.pull_request.base.sha || (github.ref == 'refs/heads/main' && github.event.before) || '' }} + run: | + python3 -m unittest discover -s scripts -p 'test_*.py' + if [ -n "$PUBLICATION_BASE" ] && [ "$PUBLICATION_BASE" != "0000000000000000000000000000000000000000" ]; then + python3 scripts/check_publication.py --base "$PUBLICATION_BASE" + else + python3 scripts/check_publication.py --base origin/main + fi + - name: Verify Gitleaks and scan full reachable history with redaction + run: | + curl --fail --silent --show-error --location --output "$RUNNER_TEMP/gitleaks.tar.gz" https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz + echo "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb $RUNNER_TEMP/gitleaks.tar.gz" | sha256sum --check --strict + tar -xzf "$RUNNER_TEMP/gitleaks.tar.gz" -C "$RUNNER_TEMP" gitleaks + "$RUNNER_TEMP/gitleaks" git . --log-opts='HEAD --full-history --diff-merges=first-parent' --redact --no-banner + + phone-and-shared: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + distribution: temurin + java-version: '17' + - uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3 + with: + packages: platform-tools + log-accepted-android-sdk-licenses: false + - name: Install public Android build components + run: sdkmanager 'platforms;android-34' 'build-tools;34.0.0' + - name: Test sensor contracts and phone; run Android lint and APK build + run: | + chmod +x gradlew + ./gradlew :shared:test :mobile:testDebugUnitTest :mobile:lintDebug :mobile:assembleDebug --no-daemon --console=plain + # Full Wear checks run locally with the private Samsung AAR. Never upload SDKs or recordings. diff --git a/.gitignore b/.gitignore index af17ccd..9cf1682 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,9 @@ key.properties /captures/ .externalNativeBuild/ .cxx/ +__pycache__/ +*.pyc +*.aab +*.p12 +*.pfx +*.pem diff --git a/AGENTS.md b/AGENTS.md index f5a2a80..35ad9ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,63 +1,83 @@ -# AI Agent Instructions for HeartRateMonitor Project - -You are acting as a **Senior Android Developer** and **Architect** assisting with the `HeartRateMonitor` multi-module Android application. Your primary goal is to produce high-quality, production-ready code that is robust, maintainable, and highly readable. - -When interacting with this repository, you must strictly adhere to the following guidelines and best practices. - ---- - -## 1. Core Engineering Principles - -### SOLID Principles -Every architectural decision and code modification must respect SOLID principles: -- **Single Responsibility Principle (SRP):** Classes and functions should have one reason to change. Example: Separate UI rendering (`Activity`/`Fragment`) from permission handling (`PermissionManager`) and sensor data collection (`Listeners`). -- **Open/Closed Principle (OCP):** Software entities should be open for extension but closed for modification. Example: Use interfaces and abstract classes for message routing so new message types can be added without modifying existing router logic. -- **Liskov Substitution Principle (LSP):** Subtypes must be substitutable for their base types. Ensure proper inheritance, especially when dealing with SDK listener callbacks. -- **Interface Segregation Principle (ISP):** Keep interfaces small and focused (e.g., `PermissionCallback`, `TrackerDataObserver`). Do not force classes to implement methods they do not use. -- **Dependency Inversion Principle (DIP):** High-level modules should not depend on low-level modules; both should depend on abstractions. Inject dependencies where possible. - -### DRY (Don't Repeat Yourself) -- Identify duplicated logic across the codebase and extract it into reusable helper functions, base classes, or extension functions. -- If code is shared between the `mobile` and `wear` modules, place it in the `shared` module to ensure consistency and single-source-of-truth. - -### Clean Code -- **Naming:** Use clear, descriptive, and unambiguous names for variables, functions, and classes. Avoid abbreviations. -- **Null Safety:** Leverage Kotlin's null safety. Avoid `lateinit` unless strictly necessary and guaranteed to be initialized. Prefer nullable types (`?`) and safe calls (`?.`) to prevent `NullPointerException` (NPE) crashes. -- **Immutability:** Prefer `val` over `var`. Use read-only collections (`List` instead of `MutableList`) for public API surfaces. -- **Comments & Documentation:** Code should be self-documenting. Use KDoc to explain the *why* behind complex logic, not the *what*. Document API surfaces, especially for SDK integrations. - ---- - -## 2. Project Architecture & Context - -This is a multi-module project consisting of: -- **`:mobile`**: The companion smartphone app. -- **`:wear`**: The Wear OS smartwatch app (handles raw sensor data collection). -- **`:shared`**: Common data models and constants (e.g., `Message`, `ActivityCode`) shared between mobile and wear. - -### Android & Wear OS Specifics -- **Wearable Data Layer:** Use modern asynchronous Google Play Services APIs (`MessageClient`, `DataClient`, `NodeClient`) with Kotlin Coroutines (`.await()`). Do *not* use the deprecated `GoogleApiClient` or `Wearable.MessageApi`. -- **Sensor SDKs:** The project relies on external SDKs like the **Samsung Health Sensor SDK** and **Polar SDK**. Ensure proper lifecycle management of these sensors to prevent memory leaks and battery drain. -- **Permissions:** Be hyper-aware of Android permission changes. For example, on Android 13+ (API 33), `BODY_SENSORS_BACKGROUND` must be requested sequentially *after* `BODY_SENSORS` has been granted. Do not batch incompatible permission requests. - ---- - -## 3. Execution Workflow & Testing - -### Refactoring & Implementation -1. **Analyze First:** Before making changes, analyze the current state of the file and its dependencies. Understand the blast radius of your changes. -2. **Step-by-Step:** Apply changes iteratively. Do not attempt to rewrite massive files in a single pass unless absolutely necessary. -3. **Coroutine Usage:** Offload heavy lifting (database, network, IPC) to background threads using Kotlin Coroutines (`lifecycleScope`, `viewModelScope`, `Dispatchers.IO`). Never block the main thread. - -### Testing Standard -- **Mandatory Unit Tests:** For every class or public function refactored or created, you must generate or update the corresponding Unit Tests. -- Use `JUnit4` and `Mockito` for testing. -- Test edge cases, null states, lifecycle changes, and error handling, not just the "happy path." - ---- - -## 4. Communication & Planning - -- When asked to implement a complex feature or major refactor, **always propose a structured plan first** and wait for user approval. -- Clearly list out the files that will be modified, created, or deleted. -- Explain *why* the changes are being made in the context of the principles outlined in this document. +# AGENTS.md — application contract and maintenance + +## Scope and authoring + +- Modules: `mobile` hub, `wear` Galaxy Watch acquisition, `shared` protocols/buffers/clocks/control. Both APKs: **1.3.1 / versionCode 5**. +- Apply existing RTK (`C:\Users\HP OMEN\.codex\RTK.md`), Caveman and Ponytail guidance; authoring only, no app dependencies or unavailable executable claims. +- Preserve dirty files, security/accessibility controls and scoped delivery. Do not fabricate samples, metrics or device receipts; deterministic test fixtures are not measurements. +- Before changing behavior read this file, [sensor spec](openspec/specs/01-watch-accelerometer-and-vitals.md) and [session spec](openspec/specs/02-session-control-and-polar-recovery.md). `PRD.md`/`DESIGN.md` are absent here. +- Use [session repair plan/checklist](SESSION_CONTROL_FIX_PLAN.md); historic v1.3.0 evidence remains in [SENSOR_FIX_PLAN.md](SENSOR_FIX_PLAN.md). +- Adapt [OpenSpec](https://github.com/Fission-AI/OpenSpec) proposal/spec/tasks and [BMAD](https://github.com/bmad-code-org/BMAD-METHOD) context/spec/build/review. Write BDD behavior, implement the smallest fix, verify, review and attach receipts; these CLIs are not installed dependencies. + +## Complete feature inventory + +| Feature | Implementation / contract | +| --- | --- | +| Watch acceleration | Native `Sensor.TYPE_ACCELEROMETER`; X/Y/Z in m/s², requested 25 Hz; 100-sample batches. Honor registration result; no Samsung connection requirement. | +| Watch PPG Green/IR/Red | One Samsung `PPG_CONTINUOUS` tracker with all three PPG types, **25 Hz each**, 300-sample batches. Only normal SDK status samples are accepted. | +| PPG channel controls | Phone status controls include/exclude individual channels from acquisition buffers within the active session. The shared hardware tracker remains active while any channel is enabled. | +| Polar connection | Manual device ID, Bluetooth permission/setup, connected/disconnected state, device name, firmware and battery information. | +| Polar ECG | ECG-ready H10 at 130 Hz; one owned pending/running subscription, timeout, two transient retries. Cancel pending start by disconnecting; wait for asynchronous stop before Resume. | +| Polar HR/RR | Record SDK HR independently of ECG. Optional `rrsMs` already uses milliseconds; unavailable RR stays empty. Verity Sense must not be described as an ECG source. | +| Clock conversion | Watch PPG retains SDK Unix milliseconds. Native acceleration maps elapsed-realtime event timestamps to Unix milliseconds from a captured clock anchor. | +| ECG timing | Preserve per-sample Polar nanoseconds and gaps. Anchor the last sample of the first packet to phone receipt time once per subscription. Clear the anchor on resume/reconnect. | +| HR/RR timing | HR rows use notification receipt time. RR plot intervals are back-calculated from that receipt and the SDK millisecond intervals; never add an epoch timestamp as a time correction. | +| QRS analysis | Existing derivative/adaptive-threshold detector, R-peak markers and derived HR/RR. Peak indices continue to address the complete ECG recording after resume. No measured clinical accuracy claim. | +| Start/Pause/Resume/End | Ordered revisions, idempotent requests, fresh peer lookup, ACK/retry. Pause/End confirm successful sensor release; Resume keeps identity and buffers; End is terminal. | +| Watch transport | Session ID, unique channel/session/batch paths, serialized uploads with retry, copied callback payloads, shape validation and duplicate suppression. Final counts cover Green/IR/Red/acceleration. | +| Transfer completion | Stop receipt and received counts must match before a Watch transfer is complete. Late flushes remain acceptable after pause/stop. Explicit partial export remains marked incomplete. | +| CSV export | SAF-selected directory and filename; ECG, each PPG channel, acceleration, Polar HR/RR, All PPG and All. Empty channels have zero rows, not padded samples. | +| Plotting | Separate ECG, Green/IR/Red, QRS and HR/RR graphs; pan while stopped, visibility/analysis preferences. Repeated redraw requests are coalesced through Choreographer. No measured 60 FPS claim. | +| Foreground lifecycle | Private phone/Watch services and ongoing notifications. Active acquisition holds CPU wake locks; Pause/End release them. Watch stops service after final uploads drain. | +| Reconfiguration | Plot setup refresh preserves samples. Polar API restart/device ID change ends an active session before replacing the connection, retaining received recordings for export. | +| Settings/help | Device ID, plot/analysis visibility, configured preferences, SAF directory selection, device information and actionable in-app help. | +| Distribution | Matching versioned debug APKs, immutable release tags, direct phone/Watch download links, preserved historical releases; Samsung AAR stays local/ignored. | +| Lost controller | Phone heartbeat every 3 s; Watch pauses sensors after 15 s without a valid command. Stop-release failure is retried, never acknowledged as off. Last control state persists to reject a delayed Start after Activity recreation. | + +## Invariants and limits + +- Values/timestamps stay paired. Validate size/shape/type/order/finite XYZ; reject bad packets and duplicates without synthetic zero rows. Accept late current-session application-buffer tails after Pause/End. +- Serialize control/acquisition on the main looper. Bind Samsung callbacks to registration generations; keep ownership after failed unregistration and retry release. Cancel pending/running ECG and obsolete API callbacks on lifecycle changes. +- End for a session is terminal. New Start needs a newer ID; Resume only follows Pause. Do not mark Watch off before matching revision and `sensorsStopped=true`; final counts must also match before transfer completion. +- Continuous Green/IR/Red uses one `PPG_CONTINUOUS`, **25 Hz each**. Legacy IR/Red 100 Hz trackers are on-demand; never substitute them for continuous acquisition. +- H10 supplies ECG; Verity Sense does not. Supported HR is independent of ECG. `rrsMs` is already milliseconds; no second scaling or epoch doubling. +- Preserve real Polar per-sample nanoseconds, dropped samples and pause gaps; reset receipt anchors only for a new subscription. Do not replace sensor time with sample-index clocks or require `setLocalTime` before ECG. +- CPU wake locks exist only for active recording. Watch lease expiration pauses acquisition; reconnection can resume the same in-memory session. Activity destruction ends Watch acquisition; a new session is required after recreation. +- Receipt anchoring approximates absolute alignment and includes BLE delay/clock drift. Requested accelerometer rate is not guaranteed; retain actual event times. +- Session samples/unsent queues are in memory; process-death recovery/durable recording is absent. Pause/End drain application buffers, not an independently proven Samsung SDK-internal tail. Save before closing; physical completeness needs hardware receipts. +- Unsupported capability/permission/policy, registration failure and final ECG failure stay visible. Never promote build/test/CI success to physiological, clinical, measured frame-rate or hardware acceptance. +- Legacy ECG image helper has no active export menu; do not advertise inactive helpers as delivered features. + +## Executable BDD mapping + +| Contract | Regression | +| --- | --- | +| Ordered commands, terminal End, lease/heartbeat, ACK and restored End | `shared/.../WatchControlTest.kt` | +| Failed/partial registration, retry ownership, callback invalidation | `shared/.../TrackerLifecycleTest.kt` | +| Pending/synchronous ECG startup, disposal, timeout, duplicate start, retry budget | `mobile/.../EcgStreamControllerTest.kt` | +| Clock anchoring, 130 Hz span/gaps, RR/native event times | `shared/.../TimestampHelperTest.kt` | +| Full/partial batches, no off-by-one drain or malformed data | `shared/.../RecordingBufferTest.kt`, `SensorBatchValidationTest.kt` | +| Late tails, duplicate suppression and final four-channel counts | `shared/.../WatchSessionTest.kt` | +| Missing Samsung readiness and continuous rate metadata | `wear/.../ListenerReadinessTest.kt`, `RecordingBufferTest.kt` | +| Phone controls/service/wake-lock release and frame scheduling | `mobile/src/androidTest/.../RecordingLifecycleTest.kt` | + +## Build and release gates + +- Gradle 8.6 / AGP 8.4.0 / Kotlin 2.1.0 / JDK 17 via `JAVA_HOME`; no absolute JDK path in `gradle.properties`. +- Samsung Health Sensor SDK 1.4.1 AAR belongs only in ignored `wear/libs/`. Public CI checks shared/mobile; full Wear gates must run locally with the private SDK. +- Focused regressions: `:shared:test --tests '*WatchControlTest'` or `:mobile:testDebugUnitTest --tests '*EcgStreamControllerTest'`; Android lint has module/task scope. +- Full required gates before release: + +```powershell +.\gradlew.bat :shared:test :mobile:testDebugUnitTest :wear:testDebugUnitTest :mobile:lintDebug :wear:lintDebug :mobile:assembleDebug :wear:assembleDebug :mobile:assembleDebugAndroidTest --no-daemon --console=plain +.\gradlew.bat :mobile:connectedDebugAndroidTest --no-daemon --console=plain +git diff --check +``` + +- Record exact test/error/warning counts and APK hashes in the plan. Explicitly allowlist staged paths, scan publication content, deliver only to the authorized fork/branches; update `main` through a tested PR and merge commit when requested. Preserve upstream and existing history/tags. +- Run `python -m unittest discover -s scripts -p 'test_*.py'` and `python scripts/check_publication.py --base origin/main` before publishing. Scan staged/current files and reachable history with redacted Gitleaks; CI repeats both gates on `main`, `Fix-issue`, and PRs. Keep action SHAs, tool/distribution checksums and read-only workflow permissions. +- Backup/device transfer must remain disabled; settings/services private. Do not log raw sensor values, device IDs or received message contents. SDK 34 Wear health service requires Body Sensors or granted Activity Recognition; denial stays visible. +- Existing Polar AAR is retained only in pre-existing Git history. No AAR may be added to current/incoming source; history cleanup requires explicit rewrite authorization. Merge evidence: [MAIN_MERGE_AUDIT.md](MAIN_MERGE_AUDIT.md). +- Keep AARs, real session CSVs, APKs, credentials and keystores out of commits. Publish only matching phone/Watch debug APK assets; preserve old releases and direct download links in README. +- AI commits include `Co-Authored-By: Codex (GPT-6) `. +- Physical Watch/H10 acceptance remains **unchecked** without the devices: record actual model/firmware/permissions, rates/counts/CSV hashes, timestamp gaps, repeated rapid controls, LEDs off, reconnect and absence/error behavior. diff --git a/MAIN_MERGE_AUDIT.md b/MAIN_MERGE_AUDIT.md new file mode 100644 index 0000000..8c7771c --- /dev/null +++ b/MAIN_MERGE_AUDIT.md @@ -0,0 +1,49 @@ +# Main/Fix-issue comparison and DevSecOps integration — 2026-10-13 + +## Target and plan + +- Fork: `Adrian463588/HeartRateMonitor`; upstream is not a write target. +- Initial main: `db5d56f60dc4ee71be4084623e643af872de984f`; Fix-issue: `713c2b93279430bfbba6fde7f6926128df766cb5`. +- Common ancestor: `c52ebe558b437f302139d9648b84df95501c24a2`; main-only **5** commits, Fix-only **7**. +- Preserve both histories. Integrate main into the candidate with a two-parent merge, resolve conflicts, test/audit before publishing, create PR base main/head Fix-issue, then use merge commit without squash/rebase/admin bypass or deleting branches. + +## Comparison and resulting behavior + +| Area | Main baseline | Fix-issue contribution / resolution | +| --- | --- | --- | +| Privacy | Disabled backup/device transfer, private settings/services, removal of raw signal/device-ID logs | Keep these controls while retaining the new sensor and transport lifecycle. No background-location request for modern Bluetooth. | +| Watch | Older individual tracking/control paths | Single continuous Green/IR/Red 25 Hz, native acceleration, ordered control/ACK/retry, 15-second lease, registration ownership and callback generations. | +| Polar | Older stream/timestamp handling | Pending/running ECG owner, capability/readiness gates, cancel/reconnect and bounded recovery, per-sample 130 Hz clock mapping, independent HR/RR. | +| Recording time | Millisecond display | Preserve three-component display using monotonic accumulated duration; keep Unix startTime/session identity unchanged across Pause/Resume/End. | +| Build/test | AGP 8.4.0, Kotlin 2.1.0, compile SDK 34, newer Wear libraries and legacy unit suites | Retain baseline tools/dependencies/SDK targets and tests. Both app versionCode 5/versionName 1.3.1; old on-demand 100 Hz assertions updated to the actual continuous 25 Hz contract. | +| Repository | Generated IDE/bin/build logs removed and SDK/key ignores | Preserve removal/ignores; add checked artifact/history policy, redacted Gitleaks and checks on main/Fix-issue/PRs. | + +## Security boundaries and checks + +- [x] Current source has no tracked AAR/APK/AAB, signing key, raw recording or machine/build output. Local private Samsung SDK only supplies Wear compilation. +- [x] Gitleaks **8.30.1**, verified Windows release SHA-256 `d29144deff3a68aa93ced33dddf84b7fdc26070add4aa0f4513094c8332afc4e`: initial main/Fix **112 commits / 1.25 MB**, all local branch/tag refs **153 commits / 2.12 MB**, no secret findings; output/reports redacted. +- [x] GitHub secret scanning/push protection enabled; no open secret alerts at inspection. Dependabot vulnerability alerts enabled for the fork; no open alerts returned at inspection. This is not a comprehensive dependency/SAST certification. +- [x] CI uses full action commit SHAs, `contents: read`, no persisted checkout credentials, bounded jobs, Gitleaks binary SHA-256 and Gradle distribution SHA-256. +- [x] Publication policy rejects current tracked forbidden files and every incoming commit's added/modified/renamed paths, including deleted-later bundles. Five policy tests passed. +- [x] Staged tracked-tree Gitleaks scan passed (no findings); publication policy passed for **201 paths / 7 incoming commits** before the integration commit. `git diff --cached --check` passed after normalizing the two inherited theme XML line endings; resource values are unchanged. +- [ ] Exact prepared-head and PR CI. +- [ ] PR creation, non-squash merge and resulting main/post-merge CI verification. + +No AAR exists in either initial source tree. Both histories already contain +Polar AAR `3526ed159b8c210662b0cc8bd904167f772eda3c` at historical +`mobile/libs/polar-ble-sdk.aar`. It is pre-existing destination history, not an +incoming SDK object. Existing branches/tags are not rewritten; this audit does +not claim complete erasure of historic binaries or undetectable secret formats. +No new Samsung/Polar AAR or recording may enter this PR. + +## Merged-candidate software gates + +- Full shared/mobile/wear unit, both lint/APK builds and phone test-APK assembly: **passed** (`build/merge-full-verification.log`). +- JVM results: shared **113**, mobile **142**, wear **38** = **293**, failures/errors/skips **0**. Five publication-policy tests also passed. +- Lint: phone **0 errors / 53 warnings**; Wear **0 errors / 34 warnings**. Existing/current warnings remain visible. +- Phone instrumentation: **4 passed, 0 failures/errors/skips** on Samsung SM-G988B / Android 13 (`build/merge-phone-verification.log`). No Watch/H10 is available; actual optical/native/ECG acquisition and sensor-off/BLE recovery remain **unverified**. +- Immutable releases `v1.3.1`, `v1.3.0`, `debug-2026-10-01` stay unchanged. Candidate builds are source verification, not a replacement of those APK assets. + +## Delivery receipt + +Pending exact candidate commit, PR and resulting main merge SHA/parents/checks. diff --git a/README.md b/README.md index 6c74c09..b57642c 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,115 @@ # HeartRateMonitor -Android phone and Galaxy Watch app for recording Polar ECG and Samsung Watch PPG. The watch collects green, infrared, and red PPG; the phone connects to Polar over Bluetooth, shows live plots, and can save recordings as CSV. This is a development tool for biosignal collection, not a medical diagnosis app. +Android phone + Galaxy Watch biosignal acquisition: real Watch acceleration, +continuous Green/IR/Red PPG, and supported Polar ECG/HR/RR data with SAF CSV export. -## Debug APKs +## Download and version history -| Device | Version | Download | -| --- | --- | --- | -| Android phone | `1.2.0` (`versionCode 3`) | [mobile-debug.apk](https://github.com/Adrian463588/HeartRateMonitor/releases/download/debug-2026-10-01/mobile-debug.apk) | -| Galaxy Watch | `1.1.0` (`versionCode 2`) | [wear-debug.apk](https://github.com/Adrian463588/HeartRateMonitor/releases/download/debug-2026-10-01/wear-debug.apk) | - -These are debug builds for development and testing. Install each APK on its corresponding device. Both APKs must be signed with the same key for Wear OS Data Layer communication; the linked pair is built together. The Samsung Health Sensor SDK works on supported Galaxy Watch4 or newer hardware, not a watch emulator. Sensor access may return `SDK_POLICY_ERROR` until Samsung authorizes the app's package and signing certificate. Do not treat APK installation alone as proof that PPG or ECG collection works. - -## Build from source - -1. Install Android Studio, Android SDK platform 34, and JDK 21. The project uses Gradle 8.6, Android Gradle Plugin 8.4.0, and Kotlin 2.1.0. Open the repository root in Android Studio. -2. Download **Samsung Health Sensor SDK v1.4.1** from the [Samsung Developer SDK page](https://developer.samsung.com/health/sensor/overview.html) under your own Samsung license. Extract `samsung-health-sensor-api.aar` and place it at `wear/libs/samsung-health-sensor-api-1.4.1.aar`. The existing `wear/build.gradle` loads `wear/libs/*.aar`. Do not commit the AAR, SDK archive, signing keys, or local configuration. See Samsung's [AAR import guide](https://developer.samsung.com/health/sensor/guide/app-module.html) and [app creation process](https://developer.samsung.com/health/sensor/process.html). -3. Build and check both modules: - - ```powershell - .\gradlew.bat :mobile:assembleDebug :wear:assembleDebug :mobile:lintDebug :wear:lintDebug :mobile:testDebugUnitTest :wear:testDebugUnitTest - ``` - -4. Find local outputs at `mobile/build/outputs/apk/debug/mobile-debug.apk` and `wear/build/outputs/apk/debug/wear-debug.apk`. Build them on the same machine, or use the linked pair, so their debug signing certificates match. - -The SDK AAR is intentionally absent from Git. Without it, the watch module cannot compile; obtain it from Samsung rather than copying an old AAR from Git history. Samsung requires partner approval and package/signature registration for public sensor access; its developer mode is for development testing. See the [Samsung app verification guide](https://developer.samsung.com/health/sensor/guide/app-verification.html). +Install **both matching 1.3.1 apps**; ordered controls and safety timeout require both updated apps. These are debug APKs, not Google Play builds. -## Run and use - -- **Phone:** Android 9+ (`minSdk 28`), Bluetooth enabled, and a compatible Polar ECG device such as Polar H10. Grant Bluetooth permissions; Android 11 and earlier also require location permission for BLE scanning. -- **Watch:** Samsung Galaxy Watch4 or newer with Wear OS (`minSdk 30`), paired with the phone. Grant Body Sensors and Activity Recognition permissions. On supported versions, background sensor access is requested separately. -- Select a Polar device on the phone, connect the watch, start recording, and inspect the live plots. Choose an export folder when saving CSV. Check sensor and connection status on both devices if data is missing. -- The watch SDK does not run on an emulator. A successful Gradle build verifies compilation, tests, and static checks; real PPG/ECG acquisition still needs physical devices and authorized sensor access. - -## Repository layout - -| Module | Purpose | -| --- | --- | -| `mobile/` | Phone UI, Polar BLE, plotting, CSV export, Wear Data Layer consumer | -| `wear/` | Galaxy Watch UI, Samsung Health Sensor SDK trackers, Wear Data Layer producer | -| `shared/` | Shared message and data types | +| Release | Phone APK | Watch APK | Notes | +| --- | --- | --- | --- | +| [1.3.1 — 2026-10-13](https://github.com/Adrian463588/HeartRateMonitor/releases/tag/v1.3.1) | [mobile-debug.apk](https://github.com/Adrian463588/HeartRateMonitor/releases/download/v1.3.1/mobile-debug.apk) | [wear-debug.apk](https://github.com/Adrian463588/HeartRateMonitor/releases/download/v1.3.1/wear-debug.apk) | Latest: confirmed session controls, Watch timeout, Polar startup/recovery fixes; hardware unverified. | +| [1.3.0 — 2026-10-12](https://github.com/Adrian463588/HeartRateMonitor/releases/tag/v1.3.0) | [Download phone](https://github.com/Adrian463588/HeartRateMonitor/releases/download/v1.3.0/mobile-debug.apk) | [Download Watch](https://github.com/Adrian463588/HeartRateMonitor/releases/download/v1.3.0/wear-debug.apk) | Both versionCode 4. Acquisition/lifecycle, late flush, duplicate/session isolation, Polar clocks, HR/RR CSV and frame redraw repairs. | +| [Historical debug — 2026-10-01](https://github.com/Adrian463588/HeartRateMonitor/releases/tag/debug-2026-10-01) | [Previous phone APK](https://github.com/Adrian463588/HeartRateMonitor/releases/download/debug-2026-10-01/mobile-debug.apk) | [Previous Watch APK](https://github.com/Adrian463588/HeartRateMonitor/releases/download/debug-2026-10-01/wear-debug.apk) | Existing release preserved. Legacy transport/rates differ from 1.3.0. | -## Version log +[All releases](https://github.com/Adrian463588/HeartRateMonitor/releases). +Each new repair must use a new release tag; do not overwrite historical APKs. -| Date | Phone | Watch | Changes | -| --- | --- | --- | --- | -| 2026-10-01 | `1.2.0` (3) | `1.1.0` (2) | Merged the checked-out `fix-issue-2` implementation into this fork's `main`; updated build tools and privacy settings; removed generated files and Samsung AAR from reachable Git history. | -| Before merge | `1.1` (2) | `1.0` (1) | Fork `main` baseline; see Git history for earlier changes. | +## Sensor contracts -For merge provenance, test results, and remaining device verification, see [MERGE_AUDIT.md](MERGE_AUDIT.md). Report bugs with device model, Android/Wear OS version, steps, and relevant error text. Do not include recordings, patient names, signing keys, or SDK AARs in issues. +| Source | Recorded data | Rate / timestamp | +| --- | --- | --- | +| Galaxy Watch native accelerometer | X/Y/Z, m/s² | Requested 25 Hz; actual elapsed-realtime sensor event time mapped to Unix ms | +| Samsung continuous PPG | Green, IR, Red ADC values, normal SDK status only | **25 Hz each**, SDK Unix ms | +| Polar H10 ECG | Voltage (SDK µV converted to mV), QRS markers | 130 Hz; per-sample device nanoseconds, receipt-anchored Unix ms | +| Polar HR / optional RR | Device BPM and `rrsMs` | Notification-driven; receipt time, RR intervals in ms | + +Simultaneous continuous PPG uses a single `PPG_CONTINUOUS` tracker. The legacy +100 Hz IR/Red on-demand trackers cannot satisfy that continuous three-channel +contract. See [Samsung's mode/rate specification](https://developer.samsung.com/health/sensor/api-reference/com/samsung/android/service/health/tracking/data/HealthTrackerType.html). + +Polar ECG capability is required: **Verity Sense is not an ECG source**. +Receipt anchoring preserves device intervals/gaps but does not prove exact +Watch–phone–Polar clock synchronization. Missing sensors/capabilities remain +unavailable, and empty channels contain no synthetic rows. + +## Use + +1. Install phone and Watch APKs; pair the Watch with the phone. Phone minimum + Android 9/API 28, Watch minimum Android 11/API 30. Hardware/service capability + and Samsung SDK access must also be satisfied. +2. On Watch, grant Body Sensors permission and configure Samsung Health Sensor + service/SDK access for the application's package/signature. Native motion + does not depend on Samsung PPG readiness. +3. On phone, grant required Bluetooth/location permissions, enable Bluetooth, + and enter a Polar device ID if recording that device. +4. Choose a data directory through the system SAF picker. +5. Start; optionally pause/resume. PPG status controls include/exclude channels + in the active session. HR still records if the device has no ECG. +6. Stop, then Save All or select ECG, PPG, acceleration, or Polar HR/RR export. + Pending final Watch batches remain ingestible. If you select **Save partial**, + Watch CSV metadata records `watch_transfer_complete=false`. +7. Wait for Watch Pause/End confirmation. Offline requests stay pending; an + unreachable controller makes updated Watch software pause after 15 seconds. + Active sessions send a heartbeat every 3 seconds. After Watch Activity + recreation, End and start a new session; old-session Start is rejected. +8. Polar pending startup is canceled by disconnecting. Resume reconnects; ECG + requires readiness, waits for asynchronous stop, and retries transient errors + at most twice. Final/unsupported errors require explicit Resume or restart. + HR notifications while paused/stopped are ignored, not recorded. + +CSV timestamps use Unix milliseconds. Acceleration metadata preserves the +nominal/requested rate; actual timestamps determine achieved rate. Export +includes only received samples. Data buffers are in memory; save before closing +the Activity. Plot setup refresh preserves recorded samples. Restarting Polar +or changing its ID ends the current session before replacing the connection. + +## Build and verify + +JDK 17, Android SDK platform 34, Build Tools 34.0.0. +Tooling: Gradle 8.6, AGP 8.4.0, Kotlin 2.1.0; Polar SDK 4.0.0. +Set `JAVA_HOME` to your JDK 17 installation. Android Studio Jellyfish or newer +can use the same Gradle/JDK configuration. + +Place the privately supplied **Samsung Health Sensor SDK 1.4.1 AAR** in +`wear/libs/`. It is ignored and is never a release asset or source commit. +See [Samsung SDK overview/setup](https://developer.samsung.com/health/sensor/overview.html). +Do not substitute synthetic trackers if the SDK/device is unavailable. + +```powershell +.\gradlew.bat :shared:test :mobile:testDebugUnitTest :wear:testDebugUnitTest :mobile:lintDebug :wear:lintDebug :mobile:assembleDebug :wear:assembleDebug :mobile:assembleDebugAndroidTest --no-daemon --console=plain +.\gradlew.bat :mobile:connectedDebugAndroidTest --no-daemon --console=plain +``` + +Outputs: `mobile/build/outputs/apk/debug/mobile-debug.apk` and +`wear/build/outputs/apk/debug/wear-debug.apk`. +Public CI checks shared/phone tests, phone lint and phone build. Full Wear +checks require the local private SDK and are required before each release. + +The merged source also retains `main` privacy controls: disabled backup/device +transfer, private services/settings, reduced Bluetooth/location permissions +and no raw signal/device-ID logs. Source changes are reviewed through a PR with +merge commit. [Main/Fix-issue merge audit](MAIN_MERGE_AUDIT.md). + +Before publishing source, run the artifact/history policy and a redacted +Gitleaks scan. CI runs these gates on `main`, `Fix-issue` and PRs, using pinned +actions/checksums and a read-only token. Private AARs/APKs/keys/raw CSVs must stay +untracked; existing release APKs are assets, not source files. + +```powershell +python -m unittest discover -s scripts -p 'test_*.py' +python scripts/check_publication.py --base origin/main +``` + +The old Polar SDK AAR remains in pre-existing Git history; this merge introduces +no AAR. Existing tags/releases are preserved. The v1.3.1 download is the immutable +previous release; merged-source verification is recorded separately in the audit. + +Software checks and warning counts: [session repair plan/evidence](SESSION_CONTROL_FIX_PLAN.md). +**Galaxy Watch and Polar physical sensor/session acceptance is unverified for +this release**: those devices were unavailable during this repair. Phone +instrumentation verifies controls/service/frame scheduling, not sensor accuracy. + +[Complete feature specification and agent guide](AGENTS.md). +[BDD sensor specification](openspec/specs/01-watch-accelerometer-and-vitals.md). diff --git a/SENSOR_FIX_PLAN.md b/SENSOR_FIX_PLAN.md new file mode 100644 index 0000000..2bcb438 --- /dev/null +++ b/SENSOR_FIX_PLAN.md @@ -0,0 +1,122 @@ +# Sensor acquisition repair — 2026-10-12 + +## Scope and evidence + +Repair the existing `mobile`, `wear`, and `shared` modules on `Fix-issue`. +The user explicitly confirmed this branch and accepted unavailable Watch/H10 hardware verification. +Keep Samsung SDK AARs local/ignored. Publish versioned debug APKs to the +user's fork, `Adrian463588/HeartRateMonitor`. Preserve previous releases. +No runtime dependency or CLI installation is needed for the authoring workflow. + +Investigation found: both foreground services are missing from manifests; +Watch commands depend on Samsung readiness even for native acceleration; +legacy IR/Red on-demand trackers violate the continuous acquisition contract; +resume clears recordings; pause does not flush; the phone drops final batches +as soon as it pauses/stops; ECG uses sample-count timestamps and a stale +session anchor instead of device sample timestamps. ECG setup also makes +streaming depend on the optional Polar clock-setting command. + +## Specification / BDD acceptance + +1. Given a Watch with a native accelerometer, when Start is received, register + the sensor independently of Samsung PPG readiness. Send real X/Y/Z in m/s², + using sensor event time converted from elapsed realtime to Unix milliseconds. + Missing sensors or failed registration must report unavailable, not success. +2. Given supported Samsung PPG channels and permission, Start records Green, + IR and Red independently. Never convert an empty callback into a zero sample. + Batch by buffered sample count, not lifetime counter modulo. +3. Given 42 buffered samples, Pause/Stop sends exactly 42. Resume preserves the + same session; a fresh Start resets it. Late final batches remain ingestible + on the phone. Callback state changes and buffers use one thread. +4. Given Polar ECG samples, preserve the SDK's per-sample nanosecond intervals + (epoch 2000) and gaps; map the first packet's last sample to phone receipt + time once per stream. Never increment timestamps twice or collapse pauses. + Unsupported clock-setting must not prevent ECG. H10 supports ECG; + Verity Sense supports HR but must not be advertised as an ECG source. +5. Given HR support without ECG, record real device HR while the session runs. + Do not mark ECG running until ECG data arrives; stop/disconnect/error clears + stream readiness. Export retains real sample values and timestamp units. +6. Given malformed/partial/duplicate transport data, do not crash or append + padded values. Batches use unique paths so final flush cannot overwrite the + previous batch. No clinical, fixed FPS, or physical-sensor acceptance claim + may be inferred from build/unit-test success. + +## Plan and checklist + +- [x] Inspect repository, SDK contracts, remote state, and device availability. +- [x] Write this plan before application changes. +- [x] Register services; repair permissions and native Watch acquisition. +- [x] Repair PPG lifecycle, batching, flush, and safe phone ingestion. +- [x] Repair Polar stream readiness, HR acquisition, and timestamp mapping. +- [x] Add focused executable regressions for these contracts. +- [x] Run all unit tests, Android lint, and both APK builds; resolve failures. +- [x] Verify the phone's install/start/pause/stop with available real hardware. +- [x] Replace `AGENTS.md` with the feature inventory, invariants, BDD contracts, + maintenance gates, and honest software/hardware acceptance status. +- [x] Bump both application versions; document current/historical download links. +- [x] Audit scoped diff/publication files, commit, push, publish APKs and verify assets. +- [ ] Physical Galaxy Watch PPG/accelerometer and Polar ECG/HR CSV acceptance. + +The last item requires those devices and real session exports. Leave it open +if unavailable; never fabricate a pass. Test fixtures are regression inputs, +not recorded physiological data. + +## Workflow sources + +Use the smallest investigation–plan–implementation–review loop, with BDD +requirements linked to executable tests and verification evidence: + +- [OpenSpec](https://github.com/Fission-AI/OpenSpec): proposal/spec/design/tasks/verification. +- [BMAD](https://github.com/bmad-code-org/BMAD-METHOD): scoped build and review workflow. +- [Spec Kit](https://github.com/github/spec-kit): specifications and interaction contracts before implementation. +- [Polar SDK](https://github.com/polarofficial/polar-ble-sdk): sample timestamp and device capability contract. + +## Verification results + +Final source verification on 2026-10-12: + +- `./gradlew.bat :shared:test :mobile:testDebugUnitTest :wear:testDebugUnitTest :mobile:lintDebug :wear:lintDebug :mobile:assembleDebug :wear:assembleDebug :mobile:connectedDebugAndroidTest --console=plain --no-daemon --max-workers=2`: **BUILD SUCCESSFUL**. +- Unit tests: **113**, shared **63**, phone **48**, Watch **2**; + failures/errors/skips **0**. +- Android lint: **0 errors**, phone **49 warnings**, Watch **31 warnings**. + AGP/Kotlin metadata compatibility repaired; no skipped-analysis compiler + metadata errors in the final log. +- Installed phone app with `adb install --user 0 -r` (no uninstall/data wipe); + observed versionCode **4**, versionName **1.3.0** on **SM-G988B / Android 13**. +- Phone instrumentation: **4 tests**, failures/errors/skips **0**: + app context, private declared service, Start/Pause/Resume/Stop, + 300 redraw requests coalesced into one per frame. +- Both debug APK signatures verified and match certificate SHA-256: + `fc6cc5cec1eb93984bc0cb0842b6d2f9c4bb6549f97617d42f457dc8983b55b4`. +- APK SHA-256: phone `50fd294dcd64fffc6827601c5c4f20c5378b75766a895918dd114d411187c337`; + Watch `4627f91428e186894cc9478e3ad65ee39975bb629d464abdb78027b854fefd88`. +- Publication tree/history scan: no tracked APK/AAR/keystore/real CSV; + Samsung SDK is local/ignored. Scoped staging and secret-pattern scan precede push. +- Public CI covers shared sensor clocks/buffers/session validation and phone + tests/lint/build; Watch SDK checks are local because the AAR must stay private. +- Physical Watch acceleration/PPG and Polar ECG/HR are **not verified**: + user confirmed those devices unavailable. No fabricated acquisition result. + +Local machine receipts (ignored, not source artifacts): +`build/release-acceptance.log`, `build/verification-summary.json`, +JUnit XML reports and Android lint XML/HTML reports under module build folders. +Publication verified: + +- Source fix commit: `efe9438ef7dbf360c987c1ef95542d3a52a52e4f`. +- Release target: `563d6c14b0892b3ae1e60ed9af9342f7407bac9d` on `Fix-issue`; + subsequent commits only provision/fix the public CI environment. +- Public shared/phone CI **passed**: + [run 38280205468](https://github.com/Adrian463588/HeartRateMonitor/actions/runs/38280205468). + Earlier SDK setup failures (`sdkmanager` missing, retired `tools` package) + were corrected; final job ran tests/lint/build successfully. +- Published prerelease [v1.3.0](https://github.com/Adrian463588/HeartRateMonitor/releases/tag/v1.3.0) + contains exactly `mobile-debug.apk` and `wear-debug.apk`. + Both GitHub asset digests/byte lengths match the local artifacts above. +- [Phone download](https://github.com/Adrian463588/HeartRateMonitor/releases/download/v1.3.0/mobile-debug.apk) + and [Watch download](https://github.com/Adrian463588/HeartRateMonitor/releases/download/v1.3.0/wear-debug.apk). +- Historical `debug-2026-10-01` release and both previous APKs retained. +- This final receipt update changes documentation only. App source remains the + validated/tagged release source. Source changes trigger CI; docs-only pushes + skip redundant application builds. +- The remaining unchecked hardware item is intentionally **unverified**, as + explicitly agreed with the user; it is not a fabricated pass. diff --git a/SESSION_CONTROL_FIX_PLAN.md b/SESSION_CONTROL_FIX_PLAN.md new file mode 100644 index 0000000..ab45606 --- /dev/null +++ b/SESSION_CONTROL_FIX_PLAN.md @@ -0,0 +1,91 @@ +# Session-control repair plan — 2026-10-13 + +## Scope and delivery + +- Preserve `Fix-issue`; publish matching phone/Watch debug APKs as v1.3.1 (versionCode 5). +- Galaxy Watch and Polar H10 are unavailable. Software checks do not count as physical acquisition acceptance. +- Keep existing sensor formats, native acceleration, continuous Green/IR/Red PPG at 25 Hz and per-sample Polar timestamps. No invented measurements or SDK AAR publication. + +## Verified starting defects + +- Phone control uses a cached node list and one-shot MessageApi sends without sensor-state acknowledgement or delivery retry. +- Watch accepts a delayed Start for the same session after Stop. Commands lack ordering; a mismatched Stop is silently ignored. +- ECG start/stop is implemented as a toggle. Inspect asynchronous settings/start/disposal, readiness and reconnection to prevent overlapping starts and callbacks after cancellation. +- Watch listener cleanup suppresses callbacks but currently discards unregistration errors; do not report confirmed sensor-off after a failed release. + +## Plan / acceptance checklist + +- [x] Inspect current source, caller paths, repository state and official SDK guidance. +- [x] Add ordered, idempotent session control: explicit Start/Pause/Resume/End intent, current peers on every send, acknowledgement/retry and visible pending/offline state. +- [x] Add a Watch safety timeout for a lost controller; expired or older commands cannot restart a stopped session. Release PPG/native sensors and wake lock on Pause/End/destruction. +- [x] Separate Polar start from stop; cancel pending startup, reject stale callbacks, prevent duplicate subscriptions and allow controlled restart after transient failure. Keep unsupported capabilities explicit. +- [x] Add executable BDD regressions covering reordered/duplicate commands, offline End, pause/resume retention, failed/pending Polar startup, cancel/reconnect and unchanged timing/batch contracts. +- [x] Update OpenSpec acceptance, AGENTS feature/maintenance guide, download history and limitations. +- [x] Pass all shared/mobile/wear unit tests, Android lint, both APK builds and available phone instrumentation. +- [x] Review scoped diff and publication contents; commit/push `Fix-issue`, verify CI and publish both v1.3.1 APKs with checksums/direct links. +- [ ] Physical Watch sensors-off/PPG/acceleration and real Polar ECG validation (pending devices; report explicitly). + +## Method + +Adapt [OpenSpec](https://github.com/Fission-AI/OpenSpec) proposal/spec/tasks and [BMAD](https://github.com/bmad-code-org/BMAD-METHOD) context/spec/build/review for a bounded bug fix. Write Given/When/Then behavior before implementation; attach executable checks and release evidence. RTK/Ponytail/Caveman guide authoring only. No framework installation or application runtime dependency is needed. + +## Planned BDD + +```gherkin +Given a Watch has received Start for session A +When Pause or End is requested and a message is lost +Then the phone retries its latest intent against currently connected nodes +And an acknowledgement identifies the same session and command revision +And an unreachable controller causes the Watch to stop acquisition within the documented safety timeout + +Given End has been applied to session A +When an older Start or duplicate End arrives +Then acquisition remains stopped and final counters are preserved + +Given Polar settings/start is pending or an ECG stream is active +When Pause, End, disconnect or Activity destruction occurs +Then its subscription is disposed and obsolete callbacks cannot restart or alter the current stream + +Given an active session and a transient Polar ECG error +When ECG capability remains ready +Then recovery is bounded and cannot create overlapping subscriptions +And an unsupported device or final failure stays visible without substitute data +``` + +## Evidence + +Historic v1.3.0 receipt remains in `SENSOR_FIX_PLAN.md`. + +- Final source verification: full required Gradle tasks passed (`build/session-full-verification.log`). The first lint run was invalidated by ongoing source edits; a stable-source rerun passed with no baseline/suppression added for lint errors. +- JVM unit results: shared **79**, mobile **59**, wear **2** = **140**, failures/errors/skips **0**. Added 27 regressions for ordered control/lease, tracker ownership and ECG cancellation/retry. +- Android lint: mobile **0 errors / 51 warnings**, wear **0 errors / 32 warnings**; existing and current warnings remain visible, total **83**. Passing lint is not a zero-warning claim. +- Both `assembleDebug` tasks and phone Android-test APK assembly passed. Both apps are **1.3.1 / versionCode 5**. +- `connectedDebugAndroidTest`: **4 passed**, zero failures/errors/skips on **SM-G988B / Android 13 / RRCN3008VYE** (`build/session-phone-verification.log`). Actual phone wake lock was asserted held on Start/Resume and released on Pause/End. No Watch/Polar data were generated or validated by these tests. +- Updated phone APK installed using `adb install --user 0 -r`; device package confirms versionCode 5 / versionName 1.3.1. Watch APK was built, not installed on unavailable hardware. +- Matching unchanged debug signer SHA-256: `fc6cc5cec1eb93984bc0cb0842b6d2f9c4bb6549f97617d42f457dc8983b55b4`. +- `git diff --check` passed. Current tree/staged source excludes APK/AAR/CSV/signing material; no new binaries or credentials are part of this repair. Selected HEAD contains no Samsung AAR; the pre-existing historic Polar AAR was not rewritten out of history. + +| Artifact | Size (bytes) | SHA-256 | +| --- | ---: | --- | +| `mobile-debug.apk` | 18,769,311 | `d9f81def5cf1700525536a1dd59f730f13b12bf9b5c38c73425b82877cefe5d3` | +| `wear-debug.apk` | 6,632,180 | `dcaec23a262d6346bc843d43669d8c78120a11138ab63415a9cd39b59e3a9657` | + +## Implementation decisions and regression boundary + +- Reused MessageApi, Gson, Handler, native SensorManager and installed RxJava; no new runtime dependency or methodology installer. Three small control/ownership classes carry the executable state invariants. +- Phone sends only its latest immutable intent after current-peer lookup; old ACKs cannot change newer intent. Active heartbeats run under the recording CPU wake lock; Pause/End awaiting confirmation stays visible. +- Watch stores control metadata only; End and Activity destruction close a session. Duplicate Start renews a live lease without clearing recordings. Listener registration runs on the controlling looper and callback wrappers retain their registration generation. +- Samsung release failure keeps its registered flag, disables acquisition callbacks and retries; off is acknowledged only after release succeeds. Native XYZ and all continuous PPG sample/batch formats are preserved. +- Polar API/device callbacks are identity-guarded and marshaled onto the UI looper. A pending SDK start is disconnected on cancellation because SDK 4.0 installs its stop finalizer after the start handshake. Active disposal is followed by a one-second restart delay. Two consecutive retries reset only on received ECG or explicit Start/Resume; persistent and unsupported failures remain errors. +- Tests protect the reported software failure paths. Hardware LEDs-off timing, sustained screen-off throughput, real PPG/XYZ/ECG, BLE recovery and SDK-internal final-tail completeness still need the physical Watch/H10. + +## Publication + +- Source commit [97a346948e1cf36d64a2ed207afa483f2c4dcbf7](https://github.com/Adrian463588/HeartRateMonitor/commit/97a346948e1cf36d64a2ed207afa483f2c4dcbf7) pushed to `Fix-issue`. +- [GitHub CI 38285970882](https://github.com/Adrian463588/HeartRateMonitor/actions/runs/38285970882) **passed** for that exact source commit (shared/mobile tests, phone lint/APK; full private-SDK Wear gates passed locally). +- [v1.3.1 release](https://github.com/Adrian463588/HeartRateMonitor/releases/tag/v1.3.1) published as a debug prerelease; immutable tag resolves to the same source commit. +- Exactly two uploaded APK assets; GitHub asset SHA-256 digests and sizes match the local table above. +- [Phone APK](https://github.com/Adrian463588/HeartRateMonitor/releases/download/v1.3.1/mobile-debug.apk) and [Watch APK](https://github.com/Adrian463588/HeartRateMonitor/releases/download/v1.3.1/wear-debug.apk): direct links verified **HTTP 200**. +- `v1.3.0` and `debug-2026-10-01` remain available unchanged. README lists all three historical downloads and matching-install instructions. +- This receipt is a documentation-only follow-up; tested application source and release tag stay at the source commit above. +- All requested software/delivery checklist items are complete. The sole unchecked hardware gate is explicit, as authorized while Watch/H10 are unavailable. diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f956e39..bb3fac7 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ #Sat Dec 31 23:44:41 ICT 2022 distributionBase=GRADLE_USER_HOME distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +distributionSha256Sum=9631d53cf3e74bfa726893aee1f8994fee4e060c401335946dba2156f440f24c distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/mobile/build.gradle b/mobile/build.gradle index 77a9c6e..d9b3051 100644 --- a/mobile/build.gradle +++ b/mobile/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.example.samplewearmobileapp" minSdk 28 targetSdk 33 - versionCode 3 - versionName "1.2.0" + versionCode 5 + versionName "1.3.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/mobile/src/androidTest/java/com/example/samplewearmobileapp/RecordingLifecycleTest.kt b/mobile/src/androidTest/java/com/example/samplewearmobileapp/RecordingLifecycleTest.kt new file mode 100644 index 0000000..26ecf91 --- /dev/null +++ b/mobile/src/androidTest/java/com/example/samplewearmobileapp/RecordingLifecycleTest.kt @@ -0,0 +1,97 @@ +package com.example.samplewearmobileapp + +import android.content.ComponentName +import android.content.Context +import android.view.Menu +import android.widget.PopupMenu +import com.androidplot.xy.XYPlot +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** Verifies Android lifecycle/control wiring; does not claim physical sensor acquisition. */ +@RunWith(AndroidJUnit4::class) +class RecordingLifecycleTest { + private class CountingPlot(context: Context) : XYPlot(context, "Redraw regression") { + var redrawCount = 0 + var afterRedraw: (() -> Unit)? = null + override fun redraw() { + redrawCount++ + afterRedraw?.invoke() + } + } + + @Test + fun sensorBurstRedrawsEachPlotOncePerFrame() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val rendered = CountDownLatch(1) + lateinit var plot: CountingPlot + lateinit var scheduler: PlotUpdateScheduler + var before = 0 + instrumentation.runOnMainSync { + plot = CountingPlot(instrumentation.targetContext) + scheduler = PlotUpdateScheduler() + before = plot.redrawCount + plot.afterRedraw = { rendered.countDown() } + repeat(300) { scheduler.requestRedraw(plot) } + } + assertTrue("A queued frame must render", rendered.await(5, TimeUnit.SECONDS)) + instrumentation.runOnMainSync { + assertEquals(1, plot.redrawCount - before) + scheduler.stop() + } + } + + @Test + fun foregroundRecordingServiceIsDeclaredAndPrivate() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val service = context.packageManager.getServiceInfo(ComponentName(context, MobileService::class.java), 0) + assertFalse(service.exported) + } + + @Test + fun startPauseResumeStopKeepTheSessionAndStopTheService() { + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + lateinit var menu: Menu + scenario.onActivity { activity -> + menu = PopupMenu(activity, activity.findViewById(android.R.id.content)).menu + activity.onCreateOptionsMenu(menu) + assertEquals("Start", menu.findItem(R.id.pause).title.toString()) + assertTrue(activity.onOptionsItemSelected(menu.findItem(R.id.pause))) + } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + assertTrue(MobileService.isServiceRunning()) + assertTrue(MobileService.isRecordingCpuAwake()) + scenario.onActivity { activity -> + activity.onCreateOptionsMenu(menu) + assertEquals("Pause", menu.findItem(R.id.pause).title.toString()) + activity.onOptionsItemSelected(menu.findItem(R.id.pause)) + } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + assertFalse("Pause must release the recording wake lock", MobileService.isRecordingCpuAwake()) + scenario.onActivity { activity -> + activity.onCreateOptionsMenu(menu) + assertEquals("Resume", menu.findItem(R.id.pause).title.toString()) + activity.onOptionsItemSelected(menu.findItem(R.id.pause)) + } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + assertTrue("Resume must restore heartbeat availability", MobileService.isRecordingCpuAwake()) + scenario.onActivity { activity -> + activity.onCreateOptionsMenu(menu) + assertEquals("Pause", menu.findItem(R.id.pause).title.toString()) + activity.onOptionsItemSelected(menu.findItem(R.id.stop_recording)) + activity.onCreateOptionsMenu(menu) + assertEquals("Start", menu.findItem(R.id.pause).title.toString()) + } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + assertFalse(MobileService.isServiceRunning()) + assertFalse(MobileService.isRecordingCpuAwake()) + } + } +} diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index 70b5b08..c1c4172 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + diff --git a/mobile/src/main/java/com/example/samplewearmobileapp/Constants.kt b/mobile/src/main/java/com/example/samplewearmobileapp/Constants.kt index 836d85c..ac2d6d5 100644 --- a/mobile/src/main/java/com/example/samplewearmobileapp/Constants.kt +++ b/mobile/src/main/java/com/example/samplewearmobileapp/Constants.kt @@ -31,7 +31,7 @@ object Constants { * @see PPG_GREEN_SAMPLE_RATE * @see ECG_SAMPLE_RATE */ - const val PPG_IR_RED_SAMPLE_RATE: Double = 100.0 + const val PPG_IR_RED_SAMPLE_RATE: Double = 25.0 // Samsung PPG_CONTINUOUS /** * Number of small boxes in a large box. @@ -175,4 +175,4 @@ object Constants { val A_DERIVATIVE = doubleArrayOf(12.0) val B_DERIVATIVE = doubleArrayOf(25.0, -48.0, 36.0, -16.0, 3.0) -} \ No newline at end of file +} diff --git a/mobile/src/main/java/com/example/samplewearmobileapp/EcgPlotter.kt b/mobile/src/main/java/com/example/samplewearmobileapp/EcgPlotter.kt index d0b1237..13a0965 100644 --- a/mobile/src/main/java/com/example/samplewearmobileapp/EcgPlotter.kt +++ b/mobile/src/main/java/com/example/samplewearmobileapp/EcgPlotter.kt @@ -162,7 +162,7 @@ class EcgPlotter: PlotterListener { /** * Stores timestamps computed during the last addValues() batch, * so QrsDetector can forward them to QrsPlotter without - * calling nextEcgTimestamp() a second time. + * converting the device timestamp a second time. */ private var lastBatchTimestamps = LongArray(0) @@ -179,6 +179,7 @@ class EcgPlotter: PlotterListener { fun addValues(polarEcgData: PolarEcgData) { val sampleCount = polarEcgData.samples.size if (sampleCount == 0) return + TimestampHelper.anchorPolarTimestamp(polarEcgData.samples.last().timeStamp, System.currentTimeMillis()) val batchTs = LongArray(sampleCount) var batchIdx = 0 @@ -193,9 +194,7 @@ class EcgPlotter: PlotterListener { seriesVisible.addLast(dataIndex, MICRO_TO_MILLI_VOLT * ecgDataSample.voltage) // Add the value to the all series as well seriesAll.addLast(dataIndex, MICRO_TO_MILLI_VOLT * ecgDataSample.voltage) - val normalizedTimestamp = TimestampHelper.nextEcgTimestamp( - ECG_SAMPLE_RATE.toDouble() - ) + val normalizedTimestamp = TimestampHelper.polarNanosToAnchoredUnixMillis(ecgDataSample.timeStamp) seriesTimestamp.addLast(dataIndex, normalizedTimestamp) batchTs[batchIdx++] = normalizedTimestamp dataIndex++ @@ -216,7 +215,7 @@ class EcgPlotter: PlotterListener { * Updates the plot. Runs on the UI thread. */ override fun update() { - parentActivity.runOnUiThread { plot.redraw() } + parentActivity.requestPlotRedraw(plot) } /** @@ -336,4 +335,4 @@ class EcgPlotter: PlotterListener { companion object { private const val TAG = "EcgPlotter" } -} \ No newline at end of file +} diff --git a/mobile/src/main/java/com/example/samplewearmobileapp/EcgStreamController.kt b/mobile/src/main/java/com/example/samplewearmobileapp/EcgStreamController.kt new file mode 100644 index 0000000..8eafff9 --- /dev/null +++ b/mobile/src/main/java/com/example/samplewearmobileapp/EcgStreamController.kt @@ -0,0 +1,63 @@ +package com.example.samplewearmobileapp + +import com.polar.sdk.api.model.PolarEcgData +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.disposables.SerialDisposable + +/** Owns pending startup as well as running ECG; callbacks from disposed attempts are ignored. */ +internal class EcgStreamController { + private var subscription: SerialDisposable? = null + private var receivedData = false + val isActive: Boolean get() = subscription != null + var failures = 0 + private set + + fun resetFailures() { failures = 0 } + + fun retryAfter(error: Throwable): Boolean { + failures++ + if (error is IllegalArgumentException || error is SecurityException || error is UnsupportedOperationException) { + failures = 3 + } + return failures <= 2 + } + + fun start(factory: () -> Flowable, onData: (PolarEcgData) -> Unit, + onError: (Throwable, Boolean) -> Unit, onComplete: () -> Unit) { + if (isActive) return + val owner = SerialDisposable() + subscription = owner // Set before subscribe: a synchronous error must not leave a stale handle. + receivedData = false + owner.set(Flowable.defer(factory).subscribe({ data -> + if (subscription === owner) { + if (data.samples.isNotEmpty()) { + receivedData = true + failures = 0 + } + onData(data) + } + }, { error -> + if (subscription === owner) { + val pending = !receivedData + subscription = null + owner.dispose() + onError(error, pending) + } + }, { + if (subscription === owner) { + subscription = null + owner.dispose() + onComplete() + } + })) + } + + /** SDK 4.0 cannot guarantee a stop command when canceled during startMeasurement. */ + fun stop(): Boolean { + val pending = isActive && !receivedData + val owner = subscription + subscription = null + owner?.dispose() + return pending + } +} diff --git a/mobile/src/main/java/com/example/samplewearmobileapp/HrPlotter.kt b/mobile/src/main/java/com/example/samplewearmobileapp/HrPlotter.kt index 49a5b94..e5d6842 100644 --- a/mobile/src/main/java/com/example/samplewearmobileapp/HrPlotter.kt +++ b/mobile/src/main/java/com/example/samplewearmobileapp/HrPlotter.kt @@ -9,6 +9,7 @@ import com.example.samplewearmobileapp.utils.AppUtils import java.text.* import java.util.* import kotlin.math.ceil +import com.example.samplewearmobileapp.utils.TimestampHelper import kotlin.math.roundToInt import kotlin.math.roundToLong @@ -176,7 +177,7 @@ class HrPlotter: PlotterListener { toAppendTo: StringBuffer, pos: FieldPosition ): StringBuffer { - val time = (obj as Number).toDouble().roundToInt() + val time = (obj as Number).toDouble().roundToLong() return dateFormat.format(time, toAppendTo, pos) } @@ -188,6 +189,8 @@ class HrPlotter: PlotterListener { } } +// // Allow panning +// PanZoom.attach(mPlot, PanZoom.Pan.HORIZONTAL, PanZoom.Zoom.NONE); update() } catch (ex: Exception) { val msg = """Error in HrPlotter.setupPlot: @@ -205,6 +208,15 @@ class HrPlotter: PlotterListener { // mHrSeries1.size()); if (plotHr1 || plotRr1) { hrRrList1.add(HrRrSessionData(time, hr, rrsMs)) + // StringBuilder sb = new StringBuilder(); +// sb.append("HRPlotter: addValues1"); +// sb.append(" time="); +// sb.append(X_AXIS_DATE_FORMAT.format(new Date(Math.round(time)))); +// sb.append(" hr=").append(Math.round(hr)).append(" rr="); +// for (int rr : rrsMs) { +// sb.append(rr).append(" "); +// } +// Log.d(TAG, sb.toString()); if (java.lang.Double.isNaN(startTime)) startTime = time if (java.lang.Double.isNaN(lastTime)) { lastTime = time @@ -219,61 +231,15 @@ class HrPlotter: PlotterListener { hrSeries1!!.addLast(time, hr) } - // RR - val rrValuesCount = rrsMs.size - if (plotRr1 && rrValuesCount > 0) { - val tValues = DoubleArray(rrValuesCount) - val rrValues = arrayOfNulls(rrValuesCount) - -// rrValues = rrsMs.toArray(rrValues) - // replicate toArray() function + // Polar SDK supplies milliseconds, not raw BLE 1/1024-second values. + if (plotRr1 && rrsMs.isNotEmpty() && rrsMs.all { it > 0 }) { + val timestamps = TimestampHelper.rrTimestampsFromReceipt(time.roundToLong(), rrsMs) for (i in rrsMs.indices) { - rrValues[i] = rrsMs[i] - } - - // Find the sum of the RR intervals - var totalRr = 0.0 - for (i in 0 until rrValuesCount) { - totalRr += rrValues[i]!!.toDouble() - } - // First time - if (java.lang.Double.isInfinite(startRrTime)) { - lastUpdateTime = time - totalRr - lastRrTime = lastUpdateTime - startRrTime = lastRrTime - totalRrTime = 0.0 - } - totalRrTime += totalRr - var rr: Double - var t = lastRrTime - for (i in 0 until rrValuesCount) { - rr = rrValues[i]!!.toDouble() - t += rr - tValues[i] = t - } - // Keep them in this interval - if (tValues[0] < lastUpdateTime) { - lastUpdateTime = tValues[0] - val deltaT = lastUpdateTime - t += deltaT - for (i in 0 until rrValuesCount) { - tValues[i] += deltaT - } - } - // Keep them from being in the future - if (t > time) { - val deltaT = t - time - for (i in 0 until rrValuesCount) { - tValues[i] -= deltaT - } - } - // Add to the series - for (i in 0 until rrValuesCount) { - rr = RR_SCALE * rrValues[i]!! - runningMax1.add(rr) - rrSeries1!!.addLast(tValues[i], rr) - lastRrTime = tValues[i] + val value = RR_SCALE * rrsMs[i] + runningMax1.add(value) + rrSeries1!!.addLast(timestamps[i], value) } + lastRrTime = timestamps.last().toDouble() lastUpdateTime = time } } @@ -284,6 +250,11 @@ class HrPlotter: PlotterListener { // + RR_SCALE * rr); if (plotHr2 || plotRr2) { hrRrList2.add(HrRrSessionData(time, hr, rr)) + // Log.d(TAG, "HRPlotter: addValues2" +// + " time=" + X_AXIS_DATE_FORMAT.format(new Date(Math +// .round(time))) +// + " hr=" + Math.round(hr) +// + " rr=" + Math.round(rr)); if (java.lang.Double.isNaN(startTime)) startTime = time if (java.lang.Double.isNaN(lastTime)) { lastTime = time @@ -301,9 +272,22 @@ class HrPlotter: PlotterListener { } } +// public String getRrInfo() { +// double elapsed = MS_TO_SEC * (mLastRrTime - mStartRrTime); +// double total = MS_TO_SEC * mTotalRrTime; +// double ratio = total / elapsed; +// return "Tot=" + String.format(Locale.US, "%.2f s", elapsed) +// + " RR=" + String.format(Locale.US, "%.2f s", total) +// + " (" + String.format(Locale.US, "%.2f", ratio) + ")"; +// } + private fun updateDomainRangeBoundaries() { var max: Double = runningMax1.max().coerceAtLeast(runningMax2.max()) if (java.lang.Double.isNaN(max) || max < 60) max = 60.0 +// Log.d(TAG, this.getClass().getSimpleName() + +// "updateDomainRangeBoundaries: startTime=" +// + startTime + " lastTime=" + lastTime +// + " max=" + max); if (!java.lang.Double.isNaN(lastTime) && !java.lang.Double.isNaN(startTime)) { if (lastTime - startTime > HR_PLOT_DOMAIN_INTERVAL) { plot.setDomainBoundaries( @@ -324,13 +308,19 @@ class HrPlotter: PlotterListener { } val upperBoundary: Number = ceil(max + 10).coerceAtMost(200.0) plot.setRangeBoundaries(0, upperBoundary, BoundaryMode.FIXED) +// RectRegion rgn= mPlot.getOuterLimits(); +// Log.d(TAG,"OuterLimits=" + rgn.getMinX() + "," + rgn.getMaxX()); +// mPlot.getOuterLimits().set(mStartTime, mLastTime, +// 0, Math.ceil(max + 10)); } /** * Updates the plot. Runs on the UI thread. */ override fun update() { - parentActivity.runOnUiThread { plot.redraw() } +// Log.d(TAG, "HRPlotter: update: dataList sizes=" + mHrRrList1.size() +// + "," + mHrRrList2.size()); + parentActivity.requestPlotRedraw(plot) } /** diff --git a/mobile/src/main/java/com/example/samplewearmobileapp/MainActivity.kt b/mobile/src/main/java/com/example/samplewearmobileapp/MainActivity.kt index a21536f..c26e1e1 100644 --- a/mobile/src/main/java/com/example/samplewearmobileapp/MainActivity.kt +++ b/mobile/src/main/java/com/example/samplewearmobileapp/MainActivity.kt @@ -1,5 +1,6 @@ package com.example.samplewearmobileapp +import android.Manifest import android.bluetooth.BluetoothAdapter.* import android.bluetooth.BluetoothManager import android.content.* @@ -65,7 +66,6 @@ import com.polar.sdk.api.model.PolarEcgData import com.polar.sdk.api.model.PolarHrData import com.polar.sdk.api.model.PolarSensorSetting import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.disposables.Disposable import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -74,6 +74,7 @@ import java.io.FileWriter import java.io.PrintWriter import java.text.SimpleDateFormat import java.util.* +import java.util.concurrent.TimeUnit import kotlin.math.roundToLong import kotlin.system.exitProcess @@ -82,6 +83,14 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, private lateinit var binding: ActivityMainBinding private lateinit var client: GoogleApiClient private lateinit var menu: Menu + private val plotUpdateScheduler by lazy { PlotUpdateScheduler() } + private var isUiActive = false + + internal fun requestPlotRedraw(plot: XYPlot) { + runOnUiThread { + if (isUiActive && !isDestroyed) plotUpdateScheduler.requestRedraw(plot) + } + } var ppgGreenPlotter: PpgPlotter? = null var ppgIrPlotter: PpgPlotter? = null @@ -89,7 +98,9 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, // private lateinit var acquaintedDevices: MutableList private var polarApi: PolarBleApi? = null - private var ecgDisposable: Disposable? = null + private val ecgStream = EcgStreamController() + private var ecgRetryJob: Job? = null + private var ecgStoppedAtMs = 0L var ecgPlotter: EcgPlotter? = null private var qrsDetector: QrsDetector? = null var qrsPlotter: QrsPlotter? = null @@ -104,24 +115,46 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, private var ppgGreenValueNumber = 0 private var ppgIrValueNumber = 0 private var ppgRedValueNumber = 0 + private var accValueNumber = 0 + private val accXList = mutableListOf() + private val accYList = mutableListOf() + private val accZList = mutableListOf() + private val accTimestampList = mutableListOf() + private data class DeviceHrSample(val timestamp: Long, val hr: Int, val rrMs: List) + private val deviceHrSamples = mutableListOf() /** * Whether to save as CSV, Plot, or both. */ private enum class SaveType { - ECG_DATA, PPG_GREEN_DATA, PPG_IR_DATA, PPG_RED_DATA, ALL_PPG, ALL + ECG_DATA, HR_DATA, PPG_GREEN_DATA, PPG_IR_DATA, PPG_RED_DATA, ACCELEROMETER_DATA, ALL_PPG, ALL // , PLOT, BOTH // , DEVICE_HR, QRS_HR, ALL } private var isRecording = false private var isPaused = false - /** Wall-clock time (Unix ms) when current recording segment started. */ - private var recordingStartMs: Long = 0L - /** Total elapsed ms accumulated across pause/resume cycles. */ - private var pausedElapsedMs: Long = 0L + private var recordingStartMs = 0L + private var pausedElapsedMs = 0L private var timerJob: Job? = null private var isPolarDeviceConnected = false + private var isEcgReady = false + private val watchSession = WatchSession() + private val watchControl = WatchControl() + private val controlHandler = Handler(Looper.getMainLooper()) + private var desiredWatchCommand: Message? = null + private var watchAcknowledged = false + private var destroying = false + private val watchRetry = object : Runnable { + override fun run() { + val command = desiredWatchCommand ?: return + if (destroying) return + sendMessage(command, MessagePath.COMMAND) + if (command.code == ActivityCode.START_ACTIVITY || !watchAcknowledged) { + controlHandler.postDelayed(this, WatchControl.HEARTBEAT_MS) + } + } + } private var isRestartingPolar = false private var isEcgRunning = false private var isPpgGreenRunning = false @@ -140,6 +173,7 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, private var deviceFirmware = "NA" private var deviceName = "NA" private var deviceAddress = "NA" + private var polarConnectedId = "" private var deviceBatteryLevel = "NA" //* Date when stopped playing. Updated whenever playing started or @@ -289,9 +323,6 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, PREF_DEVICE_ID, "" ).orEmpty() - Log.d( - TAG, "settingsLauncher: resultCode=$code deviceChanged=${oldDeviceId != deviceId}" - ) if (oldDeviceId != deviceId) { resetDeviceId(oldDeviceId) } @@ -427,7 +458,8 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, deviceId = sharedPreferences!!.getString(PREF_DEVICE_ID, "").orEmpty() // register bluetooth state broadcast receiver - registerBluetoothStateReceiver() + registerReceiver(bluetoothStateReceiver, BluetoothService.BLUETOOTH_STATE_FILTER) + isBluetoothReceiverRegistered = true // build Google API Client with access to Wearable API client = GoogleApiClient.Builder(this) @@ -488,19 +520,27 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, // === START RECORDING === MobileService.startService(this, "Start recording...") setLastHr() - recordingStartMs = System.currentTimeMillis() - pausedElapsedMs = 0L - // Pin ECG T0 to recording start so ECG and PPG timestamps - // share the same wall-clock reference point - TimestampHelper.setEcgAnchor(recordingStartMs) + startTime = Date(maxOf(System.currentTimeMillis(), watchSession.sessionId + 1L)) + stopTime = Date() + watchSession.start(startTime!!.time) + deviceHrSamples.clear() + qrsDetector = null isRecording = true isPaused = false + recordingStartMs = SystemClock.elapsedRealtime() + pausedElapsedMs = 0L setPanBehavior() - textEcgTime.text = "00:00.000" - // Clear the plots + updateTimerDisplay() + textEcgTime.text = getString(R.string.elapsed_time, 0.0) + // Clear the plots and accelerometer buffers ppgGreenValueNumber = 0 ppgIrValueNumber = 0 ppgRedValueNumber = 0 + accValueNumber = 0 + accXList.clear() + accYList.clear() + accZList.clear() + accTimestampList.clear() ppgGreenPlotter?.clear() ppgIrPlotter?.clear() ppgRedPlotter?.clear() @@ -508,10 +548,9 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, qrsPlotter?.clear() hrPlotter?.clear() // Start ECG stream only if Polar device is connected - if (isPolarDeviceConnected && ecgDisposable == null) { - toggleEcgStream() - isEcgRunning = true - } + ecgStream.resetFailures() + if (!isPolarDeviceConnected && polarApi != null) connectPolarDevice() + startEcgStream() togglePpgTracker() startTimer() // Update menu icons @@ -543,6 +582,9 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, stopRecording() } return true + } else if (id == R.id.save_device_hr_data) { + saveDataWithName(SaveType.HR_DATA) + return true } else if (id == R.id.save_all) { saveDataWithName(SaveType.ALL) return true @@ -567,6 +609,10 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, saveDataWithName(SaveType.PPG_RED_DATA) return true } + else if (id == R.id.save_accelerometer_data) { + saveDataWithName(SaveType.ACCELEROMETER_DATA) + return true + } // else if (id == R.id.save_plot) { // saveDataWithNote(SaveType.PLOT) // return true @@ -606,36 +652,9 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, // Timer & Recording State Helpers // ========================================================================= - /** - * Returns current total elapsed recording time in milliseconds, - * accounting for all pause/resume cycles. - * Computed from the wall clock — never drifts. - */ - private fun getElapsedMs(): Long = - pausedElapsedMs + if (!isPaused) System.currentTimeMillis() - recordingStartMs else 0L - - /** - * Decomposes elapsed milliseconds into displayable components. - */ - internal data class ElapsedTime( - val minutes: Int, - val seconds: Int, - val millis: Int - ) + private fun getElapsedMs(): Long = pausedElapsedMs + + if (isRecording && !isPaused) SystemClock.elapsedRealtime() - recordingStartMs else 0L - internal fun decomposeElapsedMs(elapsedMs: Long): ElapsedTime { - val totalSeconds = elapsedMs / 1000L - val ms = (elapsedMs % 1000L).toInt() - val sec = (totalSeconds % 60L).toInt() - val min = (totalSeconds / 60L).toInt() - return ElapsedTime(min, sec, ms) - } - - /** - * Starts the wall-clock-anchored timer coroutine. - * Refreshes the UI every 100 ms — no drift possible because - * the displayed time is always derived from [System.currentTimeMillis]. - */ private fun startTimer() { timerJob?.cancel() timerJob = lifecycleScope.launch { @@ -646,80 +665,55 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, } } - /** - * Cancels the running timer coroutine. - */ private fun stopTimer() { timerJob?.cancel() timerJob = null } - /** - * Updates the ECG-section timer display. - * Format: MM:SS.mmm (minutes : seconds . milliseconds) - */ private fun updateTimerDisplay() { - val (min, sec, ms) = decomposeElapsedMs(getElapsedMs()) - val formatted = getString(R.string.elapsed_time_formatted, min, sec, ms) - runOnUiThread { - textEcgTime.text = formatted - textStatusContainerTitle.text = formatted - } + val elapsed = getElapsedMs() + val minutes = (elapsed / 60_000L).toInt() + val seconds = ((elapsed / 1_000L) % 60L).toInt() + val millis = (elapsed % 1_000L).toInt() + val formatted = getString(R.string.elapsed_time_formatted, minutes, seconds, millis) + textStatusContainerTitle.text = formatted + textEcgTime.text = formatted } /** * Pauses the current recording session. - * Freezes the elapsed time into [pausedElapsedMs] so it - * continues correctly upon resume. + * Stops the timer, pauses data streams on wear, + * and pauses ECG stream. */ private fun pauseRecording() { - // Freeze elapsed time before stopping the timer pausedElapsedMs = getElapsedMs() isPaused = true + MobileService.startService(this, "Paused") stopTimer() // Send pause command to wear - if (!connectedNode.isNullOrEmpty()) { - val message = Message(NAME, ActivityCode.PAUSE_ACTIVITY) - sendMessage(message, MessagePath.COMMAND) - } + requestWatchControl(ActivityCode.PAUSE_ACTIVITY) // Pause ECG stream - if (ecgDisposable != null) { - toggleEcgStream() - isEcgRunning = false - } - // Update PPG status to Paused - runOnUiThread { - textPpgGreenStatus.text = getString(R.string.ppg_green_status, - getString(R.string.status_paused)) - textPpgIrStatus.text = getString(R.string.ppg_ir_status, - getString(R.string.status_paused)) - textPpgRedStatus.text = getString(R.string.ppg_red_status, - getString(R.string.status_paused)) - } - Log.i(TAG, "Recording paused at ${pausedElapsedMs}ms") + stopEcgStream() + Log.i(TAG, "Recording paused at ${getElapsedMs()}ms") } /** * Resumes the recording session from paused state. - * Re-anchors [recordingStartMs] to *now* so [getElapsedMs] - * continues counting from where [pausedElapsedMs] left off. + * Restarts the timer (continuing from current [pausedElapsedMs]), + * resumes data streams on wear, and resumes ECG stream. */ private fun resumeRecording() { - recordingStartMs = System.currentTimeMillis() // fresh wall-clock anchor + recordingStartMs = SystemClock.elapsedRealtime() isPaused = false + MobileService.startService(this, "Recording data") // Send resume (start) command to wear - if (!connectedNode.isNullOrEmpty()) { - val message = Message(NAME, ActivityCode.START_ACTIVITY) - sendMessage(message, MessagePath.COMMAND) - toggleState(true) - } + requestWatchControl(ActivityCode.START_ACTIVITY) // Resume ECG stream - if (ecgDisposable == null) { - toggleEcgStream() - isEcgRunning = true - } + ecgStream.resetFailures() + if (!isPolarDeviceConnected && polarApi != null) connectPolarDevice() + startEcgStream() startTimer() - Log.i(TAG, "Recording resumed at ${pausedElapsedMs}ms accumulated") + Log.i(TAG, "Recording resumed at ${getElapsedMs()}ms") } /** @@ -727,26 +721,18 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, * Stops timer, stops all data streams, updates UI. */ private fun stopRecording() { - val finalElapsedMs = getElapsedMs() + pausedElapsedMs = getElapsedMs() stopTimer() MobileService.stopService(this) setLastHr() - // Derive stopTime/startTime from wall-clock values for CSV/plot use stopTime = Date() - startTime = Date(stopTime!!.time - finalElapsedMs) isRecording = false isPaused = false - runOnUiThread { - textEcgTime.text = "00:00.000" - textStatusContainerTitle.text = getString(R.string.status_container_title) - } setPanBehavior() // Stop ECG stream - if (ecgDisposable != null) { - toggleEcgStream() - isEcgRunning = false - } - // Stop PPG stream + stopEcgStream() + // Keep accepting this session's final Watch batches after Stop. + watchSession.requestStop() togglePpgTracker() // Update menu menu.findItem(R.id.pause).icon = ResourcesCompat.getDrawable( @@ -755,7 +741,7 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, menu.findItem(R.id.pause).title = "Start" menu.findItem(R.id.stop_recording).isVisible = false menu.findItem(R.id.save).isVisible = true - Log.i(TAG, "Recording stopped. Total elapsed: ${finalElapsedMs}ms") + Log.i(TAG, "Recording stopped. Total elapsed: ${getElapsedMs()}ms") } private fun invalidatePpgState() { @@ -780,11 +766,11 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, runOnUiThread { textPpgGreenStatus.text = getString(R.string.ppg_green_status, - getString(R.string.status_connected)) + getString(if (connectedNode.isNullOrEmpty()) R.string.status_disconnected else R.string.status_connected)) textPpgIrStatus.text = getString(R.string.ppg_ir_status, - getString(R.string.status_connected)) + getString(if (connectedNode.isNullOrEmpty()) R.string.status_disconnected else R.string.status_connected)) textPpgRedStatus.text = getString(R.string.ppg_red_status, - getString(R.string.status_connected)) + getString(if (connectedNode.isNullOrEmpty()) R.string.status_disconnected else R.string.status_connected)) } // request Wear App current state @@ -795,14 +781,36 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, sendMessage(message, MessagePath.REQUEST) } Wearable.MessageApi.addListener(client) { messageEvent -> - wearMessage = Gson().fromJson(String(messageEvent.data), Message::class.java) - onMessageArrived(messageEvent.path) + val path = messageEvent.path + val bytes = messageEvent.data.copyOf() + runOnUiThread { + try { + wearMessage = Gson().fromJson(String(bytes, Charsets.UTF_8), Message::class.java) + onMessageArrived(path) + } catch (error: RuntimeException) { + Log.w(TAG, "Rejected malformed Watch message", error) + } + } } Wearable.DataApi.addListener(client) { data -> // Log.d(TAG, "Data count arrived : ${data.count}") for (dataEvent in data) { onDataArrived(dataEvent) } + }.setResultCallback { status -> + if (status.isSuccess) { + Wearable.DataApi.getDataItems(client).setResultCallback { items -> + try { + for (item in items) { + val uri = item.uri + val bytes = item.data?.copyOf() ?: continue + runOnUiThread { ingestWatchBatch(uri, bytes) } + } + } finally { + items.release() + } + } + } else Log.w(TAG, "Watch data listener registration failed") } } @@ -855,6 +863,7 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, Toast.LENGTH_LONG).show() } } + } } @@ -876,16 +885,15 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, return } isRestartingPolar = true + if (isRecording) stopRecording() + isEcgReady = false // Step 1: Reset state flags isPolarDeviceConnected = false isEcgRunning = false // Step 2: Dispose ECG stream - if (ecgDisposable != null) { - ecgDisposable!!.dispose() - ecgDisposable = null - } + stopEcgStream() // Step 3: Disconnect + Shutdown old API if (polarApi != null) { @@ -928,17 +936,12 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, * the Polar API, connection, and plot setup. */ private fun setupPolar() { - Log.d(TAG, "setupPolar: apiInitialized=${polarApi != null}") if (polarApi != null || deviceId.isEmpty()) { return } - if (ecgDisposable != null) { - // Turns it off - toggleEcgStream() - } - isRecording = false + stopEcgStream() + isEcgReady = false isEcgRunning = false - ecgPlotter?.clear() textEcgHr.text = "" textEcgInfo.text = "" textEcgTime.text = "" @@ -955,6 +958,7 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, ActivityCompat.requestPermissions( this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS ) + return } // setup Polar API @@ -970,35 +974,53 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, // Post a Runnable to have plots to be setup again in 1 sec // setup Polar API Callback - polarApi!!.setApiCallback(object: PolarBleApiCallback() { + val configuredApi = polarApi!! + val configuredId = deviceId + fun acceptsCallback(identifier: String) = !destroying && polarApi === configuredApi && + (identifier.equals(configuredId, true) || identifier.equals(polarConnectedId, true) || + identifier.equals(deviceAddress, true)) + configuredApi.setApiCallback(object: PolarBleApiCallback() { override fun blePowerStateChanged(powered: Boolean) { Log.d(TAG, "BluetoothStateChanged $powered") } override fun deviceConnected(polarDeviceInfo: PolarDeviceInfo) { - Log.d(TAG, "Polar device connected") - deviceAddress = polarDeviceInfo.address - deviceName = polarDeviceInfo.name - isPolarDeviceConnected = true runOnUiThread { + if (destroying || polarApi !== configuredApi || + (!polarDeviceInfo.deviceId.equals(configuredId, true) && + !polarDeviceInfo.address.equals(configuredId, true))) return@runOnUiThread + deviceAddress = polarDeviceInfo.address + polarConnectedId = polarDeviceInfo.deviceId + deviceName = polarDeviceInfo.name + isPolarDeviceConnected = true textEcgStatus.text = getString(R.string.ecg_status, getString(R.string.status_connected)) + Toast.makeText( + this@MainActivity, + getString(R.string.connected_string, polarDeviceInfo.name), + Toast.LENGTH_SHORT + ).show() + startEcgStream() } -// // Set the MRU preference here after we know the name -// setDevicePreferences(DeviceInfo(deviceName, deviceId)) - Toast.makeText( - this@MainActivity, - getString(R.string.connected_string, polarDeviceInfo.name), - Toast.LENGTH_SHORT - ).show() } override fun deviceDisconnected(polarDeviceInfo: PolarDeviceInfo) { - Log.d(TAG, "Polar device disconnected") - isPolarDeviceConnected = false runOnUiThread { + if (!acceptsCallback(polarDeviceInfo.deviceId) && + !acceptsCallback(polarDeviceInfo.address)) return@runOnUiThread + isPolarDeviceConnected = false + isEcgReady = false + stopEcgStream() textEcgStatus.text = getString(R.string.ecg_status, getString(R.string.status_disconnected)) + if (isRecording && !isPaused && ecgStream.failures <= 2) { + ecgRetryJob = lifecycleScope.launch { + delay(1_000L) + if (polarApi === configuredApi && isRecording && !isPaused && !isPolarDeviceConnected) { + connectPolarDevice() + } + } + } } } @@ -1006,25 +1028,16 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, identifier: String, features: Set ) { - for (feature in features) { - Log.d(TAG, "Streaming feature is ready for 1: $feature") - when (feature) { - DeviceStreamingFeature.ECG -> { - -// toggleEcgStream() - } -// DeviceStreamingFeature.PPI, -// DeviceStreamingFeature.ACC, -// DeviceStreamingFeature.MAGNETOMETER, -// DeviceStreamingFeature.GYRO, -// DeviceStreamingFeature.PPG -> {} - else -> {} - } + runOnUiThread { + if (!acceptsCallback(identifier)) return@runOnUiThread + isEcgReady = DeviceStreamingFeature.ECG in features + if (isEcgReady) startEcgStream() + if (!isEcgReady) textEcgStatus.text = "ECG unavailable on this Polar device; HR remains supported" } } + override fun hrFeatureReady(identifier: String) { - Log.d(TAG, "Polar HR feature ready") } override fun disInformationReceived( @@ -1032,48 +1045,42 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, uuid: UUID, value: String ) { - if (uuid == UUID.fromString( - "00002a28-0000-1000-8000" + - "-00805f9b34fb" - ) - ) { - deviceFirmware = value.trim { it <= ' ' } - Log.d(TAG, "Polar firmware received") - textEcgInfo.text = getString( - R.string.info_string, - deviceName, deviceBatteryLevel, deviceFirmware, deviceId - ) + runOnUiThread { + if (!acceptsCallback(identifier)) return@runOnUiThread + if (uuid == UUID.fromString("00002a28-0000-1000-8000-00805f9b34fb")) { + deviceFirmware = value.trim { it <= ' ' } + textEcgInfo.text = getString(R.string.info_string, + deviceName, deviceBatteryLevel, deviceFirmware, deviceId) + } } } override fun batteryLevelReceived(identifier: String, level: Int) { - deviceBatteryLevel = level.toString() - Log.d(TAG, "Polar battery level received") - textEcgInfo.text = getString( - R.string.info_string, - deviceName, deviceBatteryLevel, deviceFirmware, deviceId - ) + runOnUiThread { + if (!acceptsCallback(identifier)) return@runOnUiThread + deviceBatteryLevel = level.toString() + textEcgInfo.text = getString(R.string.info_string, + deviceName, deviceBatteryLevel, deviceFirmware, deviceId) + } } override fun hrNotificationReceived( identifier: String, data: PolarHrData ) { - if (isEcgRunning) { -// Log.d(TAG, -// "*HR " + polarHrData.hr + " mPlaying=" + -// mPlaying); + runOnUiThread { + if (!acceptsCallback(identifier) || !isRecording || isPaused || data.hr <= 0 || + (data.contactStatusSupported && !data.contactStatus)) return@runOnUiThread + val timestamp = System.currentTimeMillis() + val rrMs = if (data.rrAvailable) data.rrsMs.toList() else emptyList() + deviceHrSamples.add(DeviceHrSample(timestamp, data.hr, rrMs)) textEcgHr.text = data.hr.toString() -// // Add to HR plot - val time = Date().time - hrPlotter?.addValues1( - time.toDouble(), - data.hr.toDouble(), - data.rrsMs - ) + hrPlotter?.addValues1(timestamp.toDouble(), data.hr.toDouble(), rrMs) hrPlotter?.fullUpdate() } } + + }) // // try connect to device // try { @@ -1083,8 +1090,7 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, // stopTime = Date() // } catch (ex: PolarInvalidArgument) { // val msg = """ -// DeviceId=$deviceId -// ConnectToDevice: Bad argument: +//// ConnectToDevice: Bad argument: // """.trimIndent() // AppUtils.excMsg(this, msg, ex) // Log.d(TAG, "restart: $msg") @@ -1110,7 +1116,7 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, } val handler = Handler(Looper.getMainLooper()) handler.postDelayed({ - if (!isPolarDeviceConnected) { + if (!isDestroyed && !isFinishing && !isPolarDeviceConnected) { AppUtils.warnMsg( this@MainActivity, "No connection to " + deviceId + " after 1 minute" @@ -1125,7 +1131,6 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, // try connect to device try { polarApi!!.connectToDevice(deviceId) - Log.d(TAG, "Connecting to Polar device") runOnUiThread { textEcgStatus.text = getString(R.string.ecg_status, getString(R.string.status_connecting)) @@ -1139,7 +1144,9 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, setLastHr() stopTime = Date() } catch (ex: PolarInvalidArgument) { - val msg = "ConnectToDevice: Bad argument" + val msg = """ + ConnectToDevice: Bad argument: + """.trimIndent() AppUtils.excMsg(this, msg, ex) Log.d(TAG, "connectPolarDevice: $msg") // isPlaying = false @@ -1152,6 +1159,7 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, override fun onResume() { Log.d(TAG, this.javaClass.simpleName + " onResume:") super.onResume() + isUiActive = true // Check if PREF_TREE_URI is valid and remove it if not if (UriUtils.getNPersistedPermissions(this) <= 0) { @@ -1229,6 +1237,8 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, override fun onPause() { Log.v(TAG, this.javaClass.simpleName + " onPause") super.onPause() + isUiActive = false + plotUpdateScheduler.stop() polarApi?.backgroundEntered() } @@ -1247,20 +1257,11 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, Log.i(TAG,"Lifecycle: onRestart()") // re-register bluetooth state receiver (guard against duplicate registration) if (!isBluetoothReceiverRegistered) { - registerBluetoothStateReceiver() + registerReceiver(bluetoothStateReceiver, BluetoothService.BLUETOOTH_STATE_FILTER) + isBluetoothReceiverRegistered = true } } - private fun registerBluetoothStateReceiver() { - ContextCompat.registerReceiver( - this, - bluetoothStateReceiver, - BluetoothService.BLUETOOTH_STATE_FILTER, - ContextCompat.RECEIVER_EXPORTED - ) - isBluetoothReceiverRegistered = true - } - override fun onStop() { super.onStop() Log.i(TAG, "Lifecycle: onStop()") @@ -1274,7 +1275,21 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, override fun onDestroy() { super.onDestroy() Log.i(TAG,"Lifecycle: onDestroy()") + stopEcgStream() + stopTimer() + MobileService.stopService(this) + if (isRecording) requestWatchControl(ActivityCode.STOP_ACTIVITY) + desiredWatchCommand?.takeIf { it.code == ActivityCode.STOP_ACTIVITY && client.isConnected }?.let { command -> + val bytes = Gson().toJson(command).toByteArray(Charsets.UTF_8) + connectedNode?.forEach { Wearable.MessageApi.sendMessage(client, it.id, MessagePath.COMMAND, bytes) } + } + destroying = true + controlHandler.removeCallbacks(watchRetry) + isRecording = false + plotUpdateScheduler.stop() polarApi?.shutDown() + // Give the final best-effort End time to send; the Watch lease covers lost transport. + controlHandler.postDelayed({ client.disconnect() }, 500L) // unregister receiver if (isBluetoothReceiverRegistered) { unregisterReceiver(bluetoothStateReceiver) @@ -1300,20 +1315,12 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, * Show the help. */ private fun showHelp() { - Log.d(TAG, "showHelp") -// try { -// // Start theInfoActivity -// val intent = Intent() -// intent.setClass(this, InfoActivity::class.java) -// intent.addFlags( -// Intent.FLAG_ACTIVITY_NEW_TASK -// or Intent.FLAG_ACTIVITY_SINGLE_TOP -// ) -// intent.putExtra(INFO_URL, "file:///android_asset/kedotnetecg.html") -// startActivity(intent) -// } catch (ex: java.lang.Exception) { -// Utils.excMsg(this, getString(R.string.help_show_error), ex) -// } + AppUtils.infoMsg(this, "Install matching phone and Watch versions. Grant Watch Body Sensors permission " + + "and configure Samsung Health Sensor access. Start records native Watch acceleration and supported " + + "continuous Green/IR/Red PPG at 25 Hz. Enter a Polar device ID to connect; H10 ECG uses 130 Hz, " + + "while HR/RR depends on device support. Pause keeps the session; Resume continues it. " + + "Stop flushes remaining Watch samples. Choose a data directory before saving CSV. " + + "Pending Watch transfers are marked incomplete if you choose Save partial.") } /** @@ -1388,17 +1395,7 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, } private fun redoPlotSetup() { - // Clear old data - ppgGreenPlotter?.clear() - ppgIrPlotter?.clear() - ppgRedPlotter?.clear() - ecgPlotter?.clear() - qrsPlotter?.clear() - hrPlotter?.clear() - // Reset counters - ppgGreenValueNumber = 0 - ppgIrValueNumber = 0 - ppgRedValueNumber = 0 + // Reconfigure layout without erasing the recorded session. // Force layout recalculation so gridRect is available for setupPlot ppgGreenPlot.invalidate() ppgIrPlot.invalidate() @@ -1538,7 +1535,7 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, val dialog = AlertDialog.Builder(this) dialog.setTitle(R.string.device_id_item) - val viewInflated: View = LayoutInflater.from(this).inflate( + val viewInflated: View = LayoutInflater.from(applicationContext).inflate( R.layout.device_id_dialog, if (view == null) null else view.rootView as ViewGroup, false @@ -1555,7 +1552,6 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, ) { _, _ -> val oldDeviceId: String = deviceId deviceId = input.text.toString() - Log.d(TAG, "showDeviceIdDialog: deviceChanged=${oldDeviceId != deviceId}") val editor: SharedPreferences.Editor = sharedPreferences!!.edit() editor.putString(PREF_DEVICE_ID, deviceId) editor.apply() @@ -1572,7 +1568,6 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, dialog.setNegativeButton( R.string.cancel ) { dialog1, _ -> - Log.d(TAG, "showDeviceIdDialog: Cancel") dialog1.cancel() if (deviceId.isEmpty()) { Toast.makeText( @@ -1592,21 +1587,19 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, * @param oldDeviceId The previous deviceId. */ private fun resetDeviceId(oldDeviceId: String) { - Log.d(TAG, this.javaClass.simpleName + " resetDeviceId:") + if (isRecording) stopRecording() + isPolarDeviceConnected = false + isEcgReady = false + isEcgRunning = false if (polarApi != null) { - if (ecgDisposable != null) { - ecgDisposable!!.dispose() - ecgDisposable = null - } + stopEcgStream() try { polarApi!!.disconnectFromDevice(oldDeviceId) } catch (ex: PolarInvalidArgument) { - val msg = "DisconnectFromDevice: Bad argument" + val msg = """ + DisconnectFromDevice: Bad argument: + """.trimIndent() AppUtils.excMsg(this@MainActivity, msg, ex) - Log.d( - TAG, this.javaClass.simpleName - + " resetDeviceId: " + msg - ) } polarApi!!.shutDown() polarApi = null @@ -1621,7 +1614,18 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, * then calls the appropriate save method. * @param saveType The SaveType. */ - private fun saveDataWithName(saveType: SaveType) { + private fun saveDataWithName(saveType: SaveType, allowPartial: Boolean = false) { + if (startTime == null) { + AppUtils.warnMsg(this, "Record a session before saving") + return + } + if (!watchSession.isComplete && !allowPartial) { + AlertDialog.Builder(this) + .setMessage("Final Watch samples are still pending. Save currently received samples as an incomplete session?") + .setPositiveButton("Save partial") { _, _ -> saveDataWithName(saveType, true) } + .setNegativeButton(R.string.cancel, null).show() + return + } val msg: String val state = Environment.getExternalStorageState() if (Environment.MEDIA_MOUNTED != state) { @@ -1633,7 +1637,7 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, // Get file name val dialog = AlertDialog.Builder(this) dialog.setTitle(R.string.filename_dialog_title) - val viewInflated: View = LayoutInflater.from(this) + val viewInflated: View = LayoutInflater.from(applicationContext) .inflate(R.layout.device_id_dialog, null, false) val input = viewInflated.findViewById(R.id.input) input.inputType = (InputType.TYPE_CLASS_TEXT @@ -1646,14 +1650,18 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, when (saveType) { SaveType.ALL -> { saveEcgData(input.text.toString()) + saveDeviceHrData(input.text.toString()) savePpgData(input.text.toString(), ppgGreenPlotter!!) savePpgData(input.text.toString(), ppgIrPlotter!!) savePpgData(input.text.toString(), ppgRedPlotter!!) + saveAccelerometerData(input.text.toString()) } + SaveType.HR_DATA -> saveDeviceHrData(input.text.toString()) SaveType.ECG_DATA -> saveEcgData(input.text.toString()) SaveType.PPG_GREEN_DATA -> savePpgData(input.text.toString(), ppgGreenPlotter!!) SaveType.PPG_IR_DATA -> savePpgData(input.text.toString(), ppgIrPlotter!!) SaveType.PPG_RED_DATA -> savePpgData(input.text.toString(), ppgRedPlotter!!) + SaveType.ACCELEROMETER_DATA -> saveAccelerometerData(input.text.toString()) SaveType.ALL_PPG -> { savePpgData(input.text.toString(), ppgGreenPlotter!!) savePpgData(input.text.toString(), ppgIrPlotter!!) @@ -1774,10 +1782,8 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, AppUtils.errMsg(this, "There is no data directory set") return } - // Derive duration from wall-clock start/stop times - val finalElapsedMs = stopTime!!.time - startTime!!.time - val duration = finalElapsedMs / 1000.0 - val sessionStartMs = startTime!!.time + // Use the coroutine-based elapsed time instead of startTime/stopTime + val duration = getElapsedMs() / 1000.0 var msg: String val format = "yyyy-MM-dd_HH-mm" val df = SimpleDateFormat(format, Locale.US) @@ -1815,11 +1821,13 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, + AppUtils.getVersion(this)) + "\n" ) out.write("datatype=ECG\n") + out.write("timestamp_unit=unix_ms\n") + out.write("timestamp_source=polar_sample_ns_anchored_to_first_packet_receipt\n") out.write( """ stoptime=${stopTime.toString()} - """.trimIndent() + """.trimIndent() + "\n" ) out.write("duration=$durationString\n") out.write("samplescount=$sampleCount\n") @@ -1827,7 +1835,7 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, """ ${"samplingrate=$ECG_SAMPLE_RATE"} - """.trimIndent() + """.trimIndent() + "\n" ) out.write("stopdevicehr=$deviceStopHr\n") out.write("stopcalculatedhr=$calculatedStopHr\n") @@ -1840,18 +1848,13 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, // Write samples for (i in 0 until sampleCount) { - val sampleElapsedMs = timestamps[i] - sessionStartMs - val elMin = (sampleElapsedMs / 60000L).toInt() - val elSec = ((sampleElapsedMs % 60000L) / 1000L).toInt() - val elMs = (sampleElapsedMs % 1000L).toInt() out.write( String.format( - Locale.US, "%.3f,%d,%d,%s,%d,%d,%d\n", + Locale.US, "%.3f,%d,%d,%s\n", ecgValues[i], if (peakValues[i]) 1 else 0, timestamps[i], - timestampFormat.format(Date(timestamps[i])), - elMin, elSec, elMs + timestampFormat.format(Date(timestamps[i])) ) ) } @@ -1884,10 +1887,8 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, AppUtils.errMsg(this, "There is no data directory set") return } - // Derive duration from wall-clock start/stop times - val finalElapsedMs = stopTime!!.time - startTime!!.time - val duration = finalElapsedMs / 1000.0 - val sessionStartMs = startTime!!.time + // Use the coroutine-based elapsed time instead of startTime/stopTime + val duration = getElapsedMs() / 1000.0 var msg: String val format = "yyyy-MM-dd_HH-mm" val df = SimpleDateFormat(format, Locale.US) @@ -1931,12 +1932,14 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, ("application=" + "SamplingApp Version: " + AppUtils.getVersion(this)) + "\n" ) - out.write("datatype=$ppgTypeString") + out.write("datatype=$ppgTypeString\n") + out.write("timestamp_unit=unix_ms\n") + out.write("watch_transfer_complete=${watchSession.isComplete}\n") out.write( """ stoptime=${stopTime.toString()} - """.trimIndent() + """.trimIndent() + "\n" ) out.write("duration=$durationString\n") out.write("samplescount=$sampleCount\n") @@ -1944,24 +1947,19 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, """ ${"samplingrate=$sampleRate"} - """.trimIndent() + """.trimIndent() + "\n" ) out.write("stopcalculatedhr=$calculatedStopHr\n") // out.write("note=$filename\n") - // Write samples with elapsed time decomposition + // Write samples for (i in 0 until sampleCount) { - val sampleElapsedMs = timestamps[i] - sessionStartMs - val elMin = (sampleElapsedMs / 60000L).toInt() - val elSec = ((sampleElapsedMs % 60000L) / 1000L).toInt() - val elMs = (sampleElapsedMs % 1000L).toInt() out.write( String.format( - Locale.US, "%d,%d,%s,%d,%d,%d\n", + Locale.US, "%d,%d,%s\n", ppgValues[i], timestamps[i], - timestampFormat.format(Date(timestamps[i])), - elMin, elSec, elMs + timestampFormat.format(Date(timestamps[i])) ) ) } @@ -1980,6 +1978,115 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, } } + /** + * Finishes the saveData after getting the name. + * Only saves the Accelerometer recording data. + * @param filename The name. + */ + private fun saveDeviceHrData(filename: String) { + val treeUriStr = getPreferences(MODE_PRIVATE).getString(PREF_TREE_URI, null) + if (treeUriStr == null) { + AppUtils.errMsg(this, "There is no data directory set") + return + } + try { + val tree = Uri.parse(treeUriStr) + val directory = DocumentsContract.buildDocumentUriUsingTree(tree, DocumentsContract.getTreeDocumentId(tree)) + val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.US).format(stopTime!!) + val document = DocumentsContract.createDocument(contentResolver, directory, "text/csv", + "$filename-PolarHR-$stamp.csv") ?: error("Could not create HR CSV") + contentResolver.openFileDescriptor(document, "w")?.use { descriptor -> + FileWriter(descriptor.fileDescriptor).use { out -> + out.write("application=SamplingApp Version: ${AppUtils.getVersion(this)}\n") + out.write("datatype=PolarHR\nsamplescount=${deviceHrSamples.size}\n") + out.write("samplingrate=event_driven\ntimestamp_unit=unix_ms\n") + out.write("timestamp_source=phone_notification_receipt\n") + out.write("timestamp,hr_bpm,rr_ms,formatted_time\n") + for (sample in deviceHrSamples) { + out.write("${sample.timestamp},${sample.hr},${sample.rrMs.joinToString(";")}," + + "${timestampFormat.format(Date(sample.timestamp))}\n") + } + } + } ?: error("Could not open HR CSV") + AppUtils.infoMsg(this, "Wrote ${document.lastPathSegment}") + } catch (error: Exception) { + AppUtils.excMsg(this, "Error writing HR CSV", error) + } + } + + private fun saveAccelerometerData(filename: String) { + val prefs = getPreferences(MODE_PRIVATE) + val treeUriStr = prefs.getString(PREF_TREE_URI, null) + if (treeUriStr == null) { + AppUtils.errMsg(this, "There is no data directory set") + return + } + val duration = getElapsedMs() / 1000.0 + val format = "yyyy-MM-dd_HH-mm" + val df = SimpleDateFormat(format, Locale.US) + val fileName = filename + "-Accelerometer-" + df.format(stopTime!!) + ".csv" + try { + val treeUri = Uri.parse(treeUriStr) + val treeDocumentId = DocumentsContract.getTreeDocumentId(treeUri) + val docTreeUri = DocumentsContract.buildDocumentUriUsingTree( + treeUri, + treeDocumentId + ) + val resolver = this.contentResolver + val docUri = DocumentsContract.createDocument( + resolver, docTreeUri, + "text/csv", fileName + ) + val pfd = contentResolver.openFileDescriptor(docUri!!, "w") + FileWriter(pfd!!.fileDescriptor).use { writer -> + PrintWriter(writer).use { out -> + val sampleCount = accXList.size + val durationString = java.lang.String.format( + Locale.US, "%.1f sec", + duration + ) + out.write( + ("application=" + "SamplingApp Version: " + + AppUtils.getVersion(this)) + "\n" + ) + out.write("datatype=Accelerometer\n") + out.write("stoptime=${stopTime.toString()}\n") + out.write("duration=$durationString\n") + out.write("samplescount=$sampleCount\n") + out.write("samplingrate=25.0\n") + out.write("samplingrate_requested=25.0\n") + out.write("timestamp_unit=unix_ms\n") + out.write("watch_transfer_complete=${watchSession.isComplete}\n") + out.write("x,y,z,timestamp,formatted_time\n") + + // Write samples + for (i in 0 until sampleCount) { + out.write( + String.format( + Locale.US, "%.4f,%.4f,%.4f,%d,%s\n", + accXList[i], + accYList[i], + accZList[i], + accTimestampList[i], + timestampFormat.format(Date(accTimestampList[i])) + ) + ) + } + out.flush() + val msg = "Wrote " + docUri.lastPathSegment + Log.d(TAG, msg) + AppUtils.infoMsg(this, msg) + } + } + pfd.close() + } catch (ex: java.lang.Exception) { + val msg = "Error writing Accelerometer CSV file" + Log.e(TAG, msg) + Log.e(TAG, Log.getStackTraceString(ex)) + AppUtils.excMsg(this, msg, ex) + } + } + // /** // * Finishes the saveData. // * @@ -2143,9 +2250,10 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, val length = ppgValues.size val ppg = IntArray(length) val timestamp = LongArray(length) - for ((i, value) in ppgValues.withIndex()) { - ppg[i] = value.toInt() - timestamp[i] = timestampValues[i].toLong() + val indices = (0 until length).sortedBy { timestampValues[it].toLong() } + for ((i, source) in indices.withIndex()) { + ppg[i] = ppgValues[source].toInt() + timestamp[i] = timestampValues[source].toLong() } arrays = PpgPlotArrays(ppg, timestamp) return arrays @@ -2168,13 +2276,16 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, * @see PpgType */ private fun togglePpgTracker(ppgType: PpgType? = null) { + if (ppgType != null && (!isRecording || isPaused)) { + AppUtils.warnMsg(this, "Start or resume the recording before changing PPG channels") + return + } Log.d( TAG, this.javaClass.simpleName + " togglePpgTracker:" + " connectedNode=" + connectedNode ) - if (connectedNode.isNullOrEmpty()) { - AppUtils.errMsg(this, - "Samsung Watch is not connected. Please ensure watch is paired and connected.") + if (ppgType != null && connectedNode.isNullOrEmpty()) { + Toast.makeText(this, "Galaxy Watch unavailable; Polar recording can continue", Toast.LENGTH_LONG).show() return } @@ -2202,167 +2313,183 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, else -> {} } - if (isRecording) { - // if recording is on, turn on all ppg tracker - val message = Message(NAME, ActivityCode.START_ACTIVITY) - sendMessage(message, MessagePath.COMMAND) - toggleState(true) - } else { - // if not recording, turn off all ppg tracker - val message = Message(NAME, ActivityCode.STOP_ACTIVITY) - sendMessage(message, MessagePath.COMMAND) - toggleState(false) + requestWatchControl(if (isRecording) ActivityCode.START_ACTIVITY else ActivityCode.STOP_ACTIVITY) + } + + private fun stopEcgStream() { + ecgRetryJob?.cancel() + ecgRetryJob = null + if (ecgStream.isActive) ecgStoppedAtMs = SystemClock.elapsedRealtime() + val pending = ecgStream.stop() + isEcgRunning = false + qrsDetector = null + if (pending) disconnectPendingEcg() + if (::textEcgStatus.isInitialized) textEcgStatus.text = getString(R.string.ecg_status, + getString(if (isPaused) R.string.status_paused else R.string.status_stopped)) + } + + private fun disconnectPendingEcg() { + // SDK 4.0 attaches its stop doFinally after the start handshake. Closing the + // connection cancels a start that might already have reached the sensor. + val wasConnected = isPolarDeviceConnected + isEcgReady = false + isPolarDeviceConnected = false + if (!wasConnected) return + try { + polarApi?.disconnectFromDevice(deviceId) + } catch (error: Exception) { + Log.w(TAG, "Pending ECG disconnect failed", error) } } - /** - * Toggles streaming for ECG. - * - * Turns streaming on when not running. - * - * Turns streaming off when it is currently running. - */ - private fun toggleEcgStream() { - Log.d( - TAG, this.javaClass.simpleName + " streamECG:" - + " EcgDisposable=" + ecgDisposable - + " isConnected=" + isPolarDeviceConnected - ) - if (!isPolarDeviceConnected) { - AppUtils.errMsg(this, "streamECG: Device is not connected yet") + private fun startEcgStream() { + val api = polarApi ?: return + if (destroying || ecgStream.isActive || !isPolarDeviceConnected || !isEcgReady || + !isRecording || isPaused || ecgStream.failures > 2) return + // Polar sends stopMeasurement asynchronously on dispose; rapid Resume must wait. + val waitMs = 1_000L - (SystemClock.elapsedRealtime() - ecgStoppedAtMs) + if (waitMs > 0L) { + ecgRetryJob?.cancel() + ecgRetryJob = lifecycleScope.launch { delay(waitMs); startEcgStream() } return } - logEpochInfo("UTC") - if (ecgDisposable == null) { - // Reset ECG timestamps; anchor will be set on first data arrival - TimestampHelper.resetEcgTimestamps() - // Set the local time to get correct timestamps. Polar H10 apparently - // resets its time to 01:01:2019 00:00:00 when connected to strap - val timeZone = TimeZone.getTimeZone("UTC") - val calNow = Calendar.getInstance(timeZone) - Log.d(TAG, "setLocalTime to " + calNow.time) - ecgDisposable = polarApi!!.setLocalTime(deviceId, calNow) - .andThen( - polarApi!!.requestStreamSettings( - deviceId, - DeviceStreamingFeature.ECG - ) - ) + val identifier = deviceId + TimestampHelper.resetEcgTimestamps() + textEcgStatus.text = getString(R.string.ecg_status, "Starting ECG") + ecgStream.start({ + api.requestStreamSettings(identifier, DeviceStreamingFeature.ECG) .toFlowable() - .flatMap { sensorSetting: PolarSensorSetting -> - polarApi!!.startEcgStreaming( - deviceId, - sensorSetting.maxSettings() - ) - }.observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { polarEcgData: PolarEcgData -> -// logTimestampInfo(polarEcgData); - if (qrsDetector == null) { - qrsDetector = QrsDetector(this@MainActivity) + .flatMap { setting -> + val selected = setting.maxSettings() + require(selected.settings[PolarSensorSetting.SettingType.SAMPLE_RATE] == setOf(130)) { + "QRS processing requires the H10 ECG rate of 130 Hz" + } + api.startEcgStreaming(identifier, selected) + } + .timeout(10L, TimeUnit.SECONDS) + .observeOn(AndroidSchedulers.mainThread()) + }, { data -> + if (polarApi === api && identifier == deviceId && isRecording && !isPaused && data.samples.isNotEmpty()) { + isEcgRunning = true + textEcgStatus.text = getString(R.string.ecg_status, getString(R.string.status_measuring)) + if (qrsDetector == null) qrsDetector = QrsDetector(this) + qrsDetector?.process(data) + } + }, { error, pending -> + if (destroying || polarApi !== api || identifier != deviceId) return@start + isEcgRunning = false + qrsDetector = null + ecgStoppedAtMs = SystemClock.elapsedRealtime() + if (pending) disconnectPendingEcg() + Log.e(TAG, "ECG streaming failed", error) + val retry = ecgStream.retryAfter(error) + if (isRecording && !isPaused && retry) { + textEcgStatus.text = getString(R.string.ecg_status, "Retry ${ecgStream.failures}/2: ${error.message}") + ecgRetryJob = lifecycleScope.launch { + delay(1_000L) + if (polarApi === api && isRecording && !isPaused) { + if (!isPolarDeviceConnected) connectPolarDevice() else startEcgStream() } - // logEcgDataInfo(polarEcgData); - qrsDetector!!.process(polarEcgData) - // ECG elapsed time is handled by the shared coroutine timer - }, - { throwable: Throwable -> - Log.e( - TAG, "ECG Error: " - + throwable.localizedMessage, - throwable - ) - AppUtils.excMsg( - this@MainActivity, "ECG " + - "Error: ", - throwable - ) - ecgDisposable = null - }, - { - Log.d( - TAG, - "ECG streaming complete" - ) } - ) - } else { - // NOTE stops streaming if it is "running" - ecgDisposable?.dispose() - ecgDisposable = null - if (qrsDetector != null) qrsDetector = null - } + } else { + textEcgStatus.text = getString(R.string.ecg_status, "Error: ${error.message}") + AppUtils.excMsg(this, "ECG streaming failed; check H10 contact/connection, then Resume or restart Polar", error) + } + }, { + isEcgRunning = false + qrsDetector = null + textEcgStatus.text = getString(R.string.ecg_status, "ECG stream ended") + }) } private fun onDataArrived(dataEvent: DataEvent) { - // Guard: only process data when actively recording and not paused - if (!isRecording || isPaused) { - Log.d(TAG, "Data arrived but recording is " + - if (!isRecording) "stopped" else "paused" + - ". Ignoring.") - return - } - - when (dataEvent.dataItem.uri.path) { - MessagePath.DATA_HR -> { - Log.d(TAG, "Heart rate data received") - } - MessagePath.DATA_PPG_GREEN -> { - val ppgGreenData = Gson().fromJson(String(dataEvent.dataItem.data), - PpgData::class.java) - Log.d(TAG, "PPG Green batch received: ${ppgGreenData.size} samples") - for (i in 0 until ppgGreenData.size) { - ppgGreenPlotter?.addValues( - ppgGreenData.ppgValues[i], - ppgGreenData.timestamps[i]) - } - ppgGreenValueNumber += ppgGreenData.size - - // Update the status (elapsed time is handled by the coroutine timer) - runOnUiThread { - textPpgGreenStatus.text = getString(R.string.ppg_green_status, - getString(R.string.status_measuring)) - } - } - MessagePath.DATA_PPG_IR -> { - val ppgIrData = Gson().fromJson(String(dataEvent.dataItem.data), - PpgData::class.java) - Log.d(TAG, "PPG IR data batch received\n" + - "Data count: ${ppgIrData.size}") - for (i in 0 until ppgIrData.size) { - ppgIrPlotter?.addValues( - ppgIrData.ppgValues[i], - ppgIrData.timestamps[i]) - } - ppgIrValueNumber += ppgIrData.size + if (dataEvent.type != DataEvent.TYPE_CHANGED) return + // DataEventBuffer is released after its callback: copy before posting. + val uri = dataEvent.dataItem.uri + val bytes = dataEvent.dataItem.data?.copyOf() ?: return + runOnUiThread { ingestWatchBatch(uri, bytes) } + } - // Update the status (elapsed time is handled by the coroutine timer) - runOnUiThread { - textPpgIrStatus.text = getString(R.string.ppg_ir_status, - getString(R.string.status_measuring)) - } - } - MessagePath.DATA_PPG_RED -> { - val ppgRedData = Gson().fromJson(String(dataEvent.dataItem.data), - PpgData::class.java) - Log.d(TAG, "PPG Red batch received: ${ppgRedData.size} samples") - for (i in 0 until ppgRedData.size) { - ppgRedPlotter?.addValues( - ppgRedData.ppgValues[i], - ppgRedData.timestamps[i]) + private fun ingestWatchBatch(uri: Uri, bytes: ByteArray) { + val path = "/" + uri.path.orEmpty().trim('/').substringBefore('/') + try { + when (path) { + MessagePath.DATA_PPG_GREEN, MessagePath.DATA_PPG_IR, MessagePath.DATA_PPG_RED -> { + val batch = Gson().fromJson(String(bytes, Charsets.UTF_8), PpgData::class.java) ?: return + val type = when (path) { + MessagePath.DATA_PPG_GREEN -> PpgType.PPG_GREEN + MessagePath.DATA_PPG_IR -> PpgType.PPG_IR + else -> PpgType.PPG_RED + } + if (batch.ppgType != type || !batch.isValid()) return + val plotter = when (type) { + PpgType.PPG_GREEN -> ppgGreenPlotter + PpgType.PPG_IR -> ppgIrPlotter + PpgType.PPG_RED -> ppgRedPlotter + } ?: return + if (watchSession.acceptBatch(batch.sessionId, uri.toString(), type.ordinal, batch.size)) { + for (i in 0 until batch.size) plotter.addValues(batch.ppgValues[i], batch.timestamps[i]) + when (type) { + PpgType.PPG_GREEN -> ppgGreenValueNumber += batch.size + PpgType.PPG_IR -> ppgIrValueNumber += batch.size + PpgType.PPG_RED -> ppgRedValueNumber += batch.size + } + if (isRecording && !isPaused) { + val status = when (type) { + PpgType.PPG_GREEN -> textPpgGreenStatus + PpgType.PPG_IR -> textPpgIrStatus + PpgType.PPG_RED -> textPpgRedStatus + } + status.text = "${type.name}: ${getString(R.string.status_measuring)}" + } + } } - ppgRedValueNumber += ppgRedData.size - - // Update the status (elapsed time is handled by the coroutine timer) - runOnUiThread { - textPpgRedStatus.text = getString(R.string.ppg_red_status, - getString(R.string.status_measuring)) + MessagePath.DATA_ACCELEROMETER -> { + val batch = Gson().fromJson(String(bytes, Charsets.UTF_8), AccelerometerData::class.java) ?: return + if (!batch.isValid()) return + if (watchSession.acceptBatch(batch.sessionId, uri.toString(), 3, batch.size)) { + for (i in 0 until batch.size) { + accXList.add(batch.xValues[i]) + accYList.add(batch.yValues[i]) + accZList.add(batch.zValues[i]) + accTimestampList.add(batch.timestamps[i]) + } + accValueNumber += batch.size + } } + else -> return } + // Consumed/old unique batches can be removed; retries cannot double count them. + Wearable.DataApi.deleteDataItems(client, uri) + if (!isRecording) invalidateOptionsMenu() + } catch (error: RuntimeException) { + Log.w(TAG, "Rejected malformed Watch batch", error) } } private fun onMessageArrived(messagePath: String) { + if (destroying) return val message = wearMessage ?: return + if (message.sessionId != 0L && message.sessionId != watchSession.sessionId) return + if (messagePath == MessagePath.INFO && desiredWatchCommand != null) { + // An old acknowledgement must not overwrite a newer Pause/End request. + if (message.commandRevision < watchControl.revision) return + if (!watchControl.matches(message)) { + if (desiredWatchCommand?.code == ActivityCode.START_ACTIVITY && + message.code == ActivityCode.STOP_ACTIVITY && message.commandRevision >= watchControl.revision) { + textPpgGreenStatus.text = "Watch session ended; End and Start a new session" + textPpgIrStatus.text = textPpgGreenStatus.text + textPpgRedStatus.text = textPpgGreenStatus.text + } else showWatchControlPending() + return + } + watchAcknowledged = true + if (message.code != ActivityCode.START_ACTIVITY) controlHandler.removeCallbacks(watchRetry) + } + if (messagePath == MessagePath.INFO && + message.code == ActivityCode.STOP_ACTIVITY && message.sensorsStopped == true && message.sampleCounts != null) { + watchSession.reportStopped(message.sessionId, message.sampleCounts!!) + } when (messagePath) { MessagePath.COMMAND -> { if (message.code == ActivityCode.STOP_ACTIVITY) { @@ -2370,7 +2497,6 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, } } MessagePath.REQUEST -> { - Log.d(TAG, "Received REQUEST message from wear") } MessagePath.INFO -> { when (message.code) { @@ -2380,6 +2506,13 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, ActivityCode.DO_NOTHING -> toggleState(0) else -> Log.d(TAG, "Unknown activity code: ${message.code}") } + message.extraCode?.let { mask -> + isPpgGreenRunning = mask and PpgType.PPG_GREEN.mask != 0 + isPpgIrRunning = mask and PpgType.PPG_IR.mask != 0 + isPpgRedRunning = mask and PpgType.PPG_RED.mask != 0 + if (message.code == ActivityCode.START_ACTIVITY) invalidatePpgState() + } + invalidateOptionsMenu() } else -> { Log.d(TAG, "Unknown message path: $messagePath") @@ -2388,12 +2521,51 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, } private fun sendMessage(message: Message, path: String) { - val gson = Gson() - connectedNode?.forEach { node -> - val bytes = gson.toJson(message).toByteArray() - Wearable.MessageApi.sendMessage(client, node.id, path, bytes) -// Toast.makeText(applicationContext, "Message sent!", Toast.LENGTH_LONG).show() + if (!client.isConnected) { + if (path == MessagePath.COMMAND) showWatchControlPending() + return } + if (path != MessagePath.COMMAND) message.sessionId = watchSession.sessionId + val bytes = Gson().toJson(message).toByteArray(Charsets.UTF_8) + Wearable.NodeApi.getConnectedNodes(client).setResultCallback { result -> + runOnUiThread { + if (destroying || !client.isConnected) return@runOnUiThread + connectedNode = if (result.status.isSuccess) result.nodes else emptyList() + // Node lookup is asynchronous; discard obsolete requests before sending. + if (path == MessagePath.COMMAND && message !== desiredWatchCommand) return@runOnUiThread + if (connectedNode.isNullOrEmpty() && path == MessagePath.COMMAND && !watchAcknowledged) showWatchControlPending() + connectedNode?.forEach { node -> + Wearable.MessageApi.sendMessage(client, node.id, path, bytes).setResultCallback { sent -> + if (!sent.status.isSuccess) Log.w(TAG, "Watch command delivery failed: ${sent.status.statusCode}") + } + } + } + } + } + + private fun requestWatchControl(code: Int) { + val id = watchSession.sessionId + if (!watchControl.accept(id, watchControl.revision + 1L, code, SystemClock.elapsedRealtime())) return + desiredWatchCommand = Message(NAME, "Session control", code, null, id, + commandRevision = watchControl.revision) + watchAcknowledged = false + showWatchControlPending() + controlHandler.removeCallbacks(watchRetry) + watchRetry.run() + } + + private fun showWatchControlPending() { + if (destroying) return + watchAcknowledged = false + val action = when (desiredWatchCommand?.code) { + ActivityCode.PAUSE_ACTIVITY -> "Pause" + ActivityCode.STOP_ACTIVITY -> "End" + else -> "Start/Resume" + } + val status = "Watch $action awaiting confirmation; reconnect both apps if offline" + textPpgGreenStatus.text = status + textPpgIrStatus.text = status + textPpgRedStatus.text = status } private fun setMessageCode(message: Message, forceCode: Int = 99) { @@ -2556,7 +2728,7 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, msg.append("Polar API Connected: ").append(polarApi != null).append("\n") msg.append("Polar Device Connected: ").append(isPolarDeviceConnected).append("\n") msg.append("Recording: ").append(isRecording).append("\n") - msg.append("Receiving ECG: ").append(ecgDisposable != null) + msg.append("Receiving ECG: ").append(ecgStream.isActive) .append("\n") if (ecgPlotter != null && ecgPlotter!!.getVisibleSeries() != null && ecgPlotter!!.getVisibleSeries() .getyVals() != null @@ -2592,6 +2764,7 @@ class MainActivity : AppCompatActivity(), GoogleApiClient.ConnectionCallbacks, // private const val MAX_DEVICES = 3 const val REQUEST_CODE_PERMISSIONS = 10 private val REQUIRED_PERMISSIONS = requiredBluetoothPermissions(Build.VERSION.SDK_INT) + } private fun logEpochInfo(timeZoneString: String) { diff --git a/mobile/src/main/java/com/example/samplewearmobileapp/MobileService.kt b/mobile/src/main/java/com/example/samplewearmobileapp/MobileService.kt index 7d41b33..40e646f 100644 --- a/mobile/src/main/java/com/example/samplewearmobileapp/MobileService.kt +++ b/mobile/src/main/java/com/example/samplewearmobileapp/MobileService.kt @@ -1,5 +1,6 @@ package com.example.samplewearmobileapp +import android.annotation.SuppressLint import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent @@ -8,6 +9,7 @@ import android.content.Context import android.content.Intent import android.graphics.Color import android.os.IBinder +import android.os.PowerManager import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat @@ -25,6 +27,7 @@ class MobileService : Service() { */ @Volatile private var isRunning = false + private var wakeLock: PowerManager.WakeLock? = null fun startService(context: Context, message: String) { val intent = Intent(context, MobileService::class.java) @@ -38,8 +41,11 @@ class MobileService : Service() { } fun isServiceRunning(): Boolean = isRunning + internal fun isRecordingCpuAwake(): Boolean = wakeLock?.isHeld == true } + // Recording-scoped: the Watch lease needs heartbeats even when the phone screen is off. + @SuppressLint("WakelockTimeout") override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val message = intent?.getStringExtra("message") createNotificationChannel() @@ -53,17 +59,26 @@ class MobileService : Service() { val notification = NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) .setContentTitle("Main Foreground Service Sample App") - .setContentText("Recording...") + .setContentText(message ?: "Recording...") .setContentIntent(pendingIntent) .setOngoing(true) .build() startForeground(1, notification) + if (message == "Paused") { + if (wakeLock?.isHeld == true) wakeLock?.release() + } else { + if (wakeLock == null) wakeLock = getSystemService(PowerManager::class.java) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "HeartRateMonitor:PhoneRecording") + if (wakeLock?.isHeld != true) wakeLock?.acquire() + } isRunning = true - return START_STICKY + return START_NOT_STICKY } override fun onDestroy() { + if (wakeLock?.isHeld == true) wakeLock?.release() + wakeLock = null super.onDestroy() isRunning = false } @@ -84,4 +99,4 @@ class MobileService : Service() { val manager = getSystemService(NotificationManager::class.java) manager?.createNotificationChannel(serviceChannel) } -} \ No newline at end of file +} diff --git a/mobile/src/main/java/com/example/samplewearmobileapp/PlotUpdateScheduler.kt b/mobile/src/main/java/com/example/samplewearmobileapp/PlotUpdateScheduler.kt new file mode 100644 index 0000000..89f5992 --- /dev/null +++ b/mobile/src/main/java/com/example/samplewearmobileapp/PlotUpdateScheduler.kt @@ -0,0 +1,32 @@ +package com.example.samplewearmobileapp + +import android.view.Choreographer +import com.androidplot.xy.XYPlot + +/** Coalesces repeated sensor-triggered redraws into one redraw per plot per frame. */ +class PlotUpdateScheduler : Choreographer.FrameCallback { + private val choreographer = Choreographer.getInstance() + private val dirtyPlots = linkedSetOf() + private var framePending = false + + fun requestRedraw(plot: XYPlot) { + dirtyPlots.add(plot) + if (!framePending) { + framePending = true + choreographer.postFrameCallback(this) + } + } + + fun stop() { + choreographer.removeFrameCallback(this) + dirtyPlots.clear() + framePending = false + } + + override fun doFrame(frameTimeNanos: Long) { + framePending = false + val plots = dirtyPlots.toList() + dirtyPlots.clear() + plots.forEach { it.redraw() } + } +} diff --git a/mobile/src/main/java/com/example/samplewearmobileapp/PpgPlotter.kt b/mobile/src/main/java/com/example/samplewearmobileapp/PpgPlotter.kt index a6bbd90..387d1b5 100644 --- a/mobile/src/main/java/com/example/samplewearmobileapp/PpgPlotter.kt +++ b/mobile/src/main/java/com/example/samplewearmobileapp/PpgPlotter.kt @@ -203,7 +203,7 @@ class PpgPlotter: PlotterListener { * Updates the plot. Runs on the UI thread. */ override fun update() { - parentActivity.runOnUiThread { plot.redraw() } + parentActivity.requestPlotRedraw(plot) } /** diff --git a/mobile/src/main/java/com/example/samplewearmobileapp/QrsDetector.kt b/mobile/src/main/java/com/example/samplewearmobileapp/QrsDetector.kt index 946049f..544c8cc 100644 --- a/mobile/src/main/java/com/example/samplewearmobileapp/QrsDetector.kt +++ b/mobile/src/main/java/com/example/samplewearmobileapp/QrsDetector.kt @@ -9,7 +9,6 @@ import com.example.samplewearmobileapp.Constants.MICRO_TO_MILLI_VOLT import com.example.samplewearmobileapp.Constants.MOV_AVG_HR_WINDOW import com.example.samplewearmobileapp.models.FixedSizeList import com.example.samplewearmobileapp.models.RunningAverage -import com.example.samplewearmobileapp.utils.SignalFilter import com.polar.sdk.api.model.PolarEcgData import java.util.* import kotlin.math.sqrt @@ -31,7 +30,8 @@ class QrsDetector(activity: MainActivity) { private var threshold = mean + N_SIGMA * stdDev private var sampleCount = 0 - private var startTime = Double.NaN + private val plotIndexOffset = (activity.ecgPlotter?.getDataIndex() ?: 0L).toInt() + private val ecgTimestamps = mutableListOf() // These keep track of the lowest and highest ECG values in the scoring // window. The max should correspond to R and the min to S. The normal @@ -41,6 +41,8 @@ class QrsDetector(activity: MainActivity) { // 0.12 seconds is considered abnormal. 0.12 ms = 16 samples. 0.10 // samples = 13 samples. + // private final FixedSizeList curButterworth = + // new FixedSizeList<>(DATA_WINDOW); private val cursorDerivative: FixedSizeList = FixedSizeList(DATA_WINDOW) private val cursorScore: FixedSizeList = FixedSizeList(DATA_WINDOW) private val cursorEcg: FixedSizeList = FixedSizeList(DATA_WINDOW) @@ -54,11 +56,12 @@ class QrsDetector(activity: MainActivity) { fun process(polarEcgData: PolarEcgData) { // Update the ECG plot (this also computes normalized timestamps) - ecgPlotter()?.addValues(polarEcgData) + val plotter = ecgPlotter() ?: return + plotter.addValues(polarEcgData) // Retrieve the timestamps computed by EcgPlotter for this batch - val batchTimestamps = ecgPlotter()?.getLastBatchTimestamps() - ?: LongArray(polarEcgData.samples.size) + val batchTimestamps = plotter.getLastBatchTimestamps() + if (batchTimestamps.size != polarEcgData.samples.size) return // samples contains the ecgValues values in μV, mv = .001 * μV; for ((idx, ecgDataSample) in polarEcgData.samples.withIndex()) { @@ -73,18 +76,25 @@ class QrsDetector(activity: MainActivity) { */ private fun detectQrs(ecgDataSample: PolarEcgData.PolarEcgDataSample, normalizedTimestamp: Long) { val ecg = MICRO_TO_MILLI_VOLT * ecgDataSample.voltage - // Record the start time as now. - if (java.lang.Double.isNaN(startTime)) startTime = Date().time.toDouble() + ecgTimestamps.add(normalizedTimestamp) ecgValues.add(ecg) sampleCount++ cursorEcg.add(ecg) val hr: Double val rr: Double +// // Butterworth +// input = curEcg; +// if (curButterworth.size() == DATA_WINDOW) curButterworth.remove(0); +// curButterworth.add(0.); // Doesn't matter +// doubleVal = filter(A_BUTTERWORTH3, B_BUTTERWORTH3, input, +// curButterworth); +// curButterworth.set(curButterworth.size() - 1, doubleVal); + // Derivative (Only using positive part) var input: FixedSizeList = cursorEcg cursorDerivative.add(0.0) // Doesn't matter - var doubleValue: Double = SignalFilter.filter( + var doubleValue: Double = filter( A_DERIVATIVE, B_DERIVATIVE, input, cursorDerivative ) @@ -113,6 +123,12 @@ class QrsDetector(activity: MainActivity) { if (i % HR_200_INTERVAL == 0) { // End of interval, process this interval if (i > 0 && peakIndex != -1) { +// Log.d(TAG, "doAlgorithm: " + +// ".......... start processing interval i=" + i +// + " mPeakIndex=" + mPeakIndex +// + " mMinPeakIndex=" + mMinPeakIndex +// + " mMaxPeakIndex=" + mMaxPeakIndex +// ); // There is an mPeakIndex != -1 // Look between mMinPeakIndex and mMaxPeakIndex for a the // largest ecg value @@ -120,12 +136,23 @@ class QrsDetector(activity: MainActivity) { if (startSearch < 0) startSearch = 0 endSearch = i.coerceAtMost(maxPeakIndex + SEARCH_EXTEND) maxEcg = -Double.MAX_VALUE + // Log.d(TAG, "doAlgorithm: " + +// ".......... start searching: startSearch=" +// + startSearch +// + " endSearch=" + endSearch); for (i1 in startSearch until endSearch + 1) { if (ecgValues[i1] > maxEcg) { maxEcg = ecgValues[i1] peakIndex = i1 } } // End of search + // Log.d(TAG, "doAlgorithm: " + +// ".......... end searching: startSearch=" +// + startSearch +// + " endSearch=" + endSearch +// + " mPeakIndex=" + mPeakIndex +// + " maxEcg=" + maxEcg +// ); // Check if there is a close one in the previous interval if (peakIndices.size > 0) { lastIndex = peakIndices.size - 1 // last @@ -137,36 +164,51 @@ class QrsDetector(activity: MainActivity) { // Replace the old one peakIndices.last = peakIndex qrsPlotter()!!.replaceLastPeakValue( - peakIndex, + peakIndex + plotIndexOffset, maxEcg ) + // Log.d(TAG, "doAlgorithm: " + +// "replaceLastPeakValue:" +// + " mPeakIndex=" + mPeakIndex +// + ", maxEcg=" + maxEcg); } } else { // Is not near a previous one, add it peakIndices.add(peakIndex) qrsPlotter()!!.addPeakValue( - peakIndex, + peakIndex + plotIndexOffset, maxEcg ) + // Log.d(TAG, "doAlgorithm: " + +// "addPeakValue:" +// + " mPeakIndex=" + mPeakIndex +// + ", maxEcg=" + maxEcg); } } else { // First peak peakIndices.add(peakIndex) - qrsPlotter()!!.addPeakValue(peakIndex, maxEcg) + qrsPlotter()!!.addPeakValue(peakIndex + plotIndexOffset, maxEcg) + // Log.d(TAG, "doAlgorithm: " + +// "addPeakValue:" +// + " mPeakIndex=" + mPeakIndex +// + ", maxEcg=" + maxEcg); } // Do HR/RR plot if (peakIndices.size > 1) { - rr = 1000 / ECG_SAMPLE_RATE * - (peakIndex - peakIndices[peakIndices.size - 2]) + rr = (ecgTimestamps[peakIndex] - ecgTimestamps[peakIndices[peakIndices.size - 2]]).toDouble() hr = 60000.0 / rr - if (!java.lang.Double.isInfinite(hr)) { + if (rr > 0.0 && hr.isFinite()) { +// movingAverageHr.add(hr); movingAverageRr.add(rr) // Wait to start plotting until HR average is well // defined if (movingAverageRr.size() >= MOV_AVG_HR_WINDOW) { +// hrPlotter().addValues2(mStartTime + 1000 * +// mMaxIndex / FS, +// movingAverageHr.average(), rr); hrPlotter()!!.addValues2( - startTime + 1000 * peakIndex / ECG_SAMPLE_RATE, + ecgTimestamps[peakIndex].toDouble(), 60000.0 / movingAverageRr.average(), rr ) hrPlotter()!!.fullUpdate() @@ -174,9 +216,12 @@ class QrsDetector(activity: MainActivity) { } } } + // Start a new interval peakIndex = -1 minPeakIndex = -1 maxPeakIndex = -1 + // Log.d(TAG, "doAlgorithm: " + +// ".......... end processing interval i=" + i); } // End of end of process interval // Check for max ecg @@ -200,12 +245,48 @@ class QrsDetector(activity: MainActivity) { } /** - * Delegates to [SignalFilter.filter] for signal processing. + * Calculates a result for a generalized filter with coefficients a and b. + * Returns 0 if x and y are not long enough to provide sufficient values + * for the sums over the coefficients. + * + *``` + * y[n] = 1 / a[0] * (suma - sumb) + * suma = sum from 1 to q of a[j] * y[n-j], q = len(a) + * sumb = sum from 0 to p of b[j] * x[n-j], p = len(b) + * ``` + * Uses the values at the ends of x and y. + * + * @param a The A filter coefficients. + * @param b The B filter coefficients. + * @param x x values for the filter. + * @param y y values for the filter. + * @return The new value. */ private fun filter( a: DoubleArray, b: DoubleArray, x: List, y: List? - ): Double = SignalFilter.filter(a, b, x, y) + ): Double { + // TODO Consider handling lenx < lenb and leny < lena differently + // rather than exit + val lena = a.size + val lenb = b.size + val lenx = x.size + val leny: Int + if (lenx < lenb) return 0.0 + var suma = 0.0 + if (y != null) { + leny = y.size + if (leny < lena) return 0.0 + for (i in 0 until lena) { + suma += a[i] * y[leny - i - 1] + } + } + var sumb = 0.0 + for (i in 0 until lenb) { + sumb += b[i] * x[lenx - i - 1] + } + return (sumb - suma) / a[0] + } private fun ecgPlotter(): EcgPlotter? { return parentActivity.ecgPlotter @@ -226,4 +307,4 @@ class QrsDetector(activity: MainActivity) { private const val N_SIGMA = 1.0 private const val SEARCH_EXTEND = 2 } -} \ No newline at end of file +} diff --git a/mobile/src/main/java/com/example/samplewearmobileapp/QrsPlotter.kt b/mobile/src/main/java/com/example/samplewearmobileapp/QrsPlotter.kt index e64ef23..3a1f21a 100644 --- a/mobile/src/main/java/com/example/samplewearmobileapp/QrsPlotter.kt +++ b/mobile/src/main/java/com/example/samplewearmobileapp/QrsPlotter.kt @@ -285,7 +285,7 @@ class QrsPlotter: PlotterListener { override fun update() { if (plot.visibility == View.GONE) return if (dataIndex % 73 == 0L) { - parentActivity.runOnUiThread { plot.redraw() } + parentActivity.requestPlotRedraw(plot) } } diff --git a/mobile/src/main/res/menu/main_menu.xml b/mobile/src/main/res/menu/main_menu.xml index 526e47b..cb01858 100644 --- a/mobile/src/main/res/menu/main_menu.xml +++ b/mobile/src/main/res/menu/main_menu.xml @@ -16,6 +16,10 @@ android:id="@+id/save" android:title="@string/save_item"> + + - \ No newline at end of file + diff --git a/mobile/src/main/res/values-night/themes.xml b/mobile/src/main/res/values-night/themes.xml index 89329a5..0a01c91 100644 --- a/mobile/src/main/res/values-night/themes.xml +++ b/mobile/src/main/res/values-night/themes.xml @@ -1,50 +1,50 @@ - - - - - - - - - - \ No newline at end of file + + + + + + + + + + diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index 019ccfb..dd5a18e 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ + Save Polar HR and RR Data Sample Wear Mobile App @@ -72,6 +73,7 @@ Save PPG Green Data Save PPG IR Data Save PPG Red Data + Save Accelerometer Data Save All PPG Data Save Device HR Data Save Calculated HR Data @@ -140,4 +142,4 @@ Analysis Visibility Analysis plots will be shown Analysis plots will not be shown - \ No newline at end of file + diff --git a/mobile/src/main/res/values/themes.xml b/mobile/src/main/res/values/themes.xml index 7177fc0..80cd418 100644 --- a/mobile/src/main/res/values/themes.xml +++ b/mobile/src/main/res/values/themes.xml @@ -1,46 +1,46 @@ - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + diff --git a/mobile/src/test/java/com/example/samplewearmobileapp/EcgStreamControllerTest.kt b/mobile/src/test/java/com/example/samplewearmobileapp/EcgStreamControllerTest.kt new file mode 100644 index 0000000..a49bc39 --- /dev/null +++ b/mobile/src/test/java/com/example/samplewearmobileapp/EcgStreamControllerTest.kt @@ -0,0 +1,134 @@ +package com.example.samplewearmobileapp + +import com.polar.sdk.api.model.PolarEcgData +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.processors.PublishProcessor +import io.reactivex.rxjava3.schedulers.TestScheduler +import org.junit.Assert.* +import org.junit.Test +import java.util.concurrent.TimeUnit + +/** Deterministic subscription fixtures; no physical ECG acceptance or invented measurements. */ +class EcgStreamControllerTest { + private val packet = PolarEcgData(listOf(PolarEcgData.PolarEcgDataSample(1_000_000L, 1)), 1_000_000L) + + @Test fun `transient errors permit two retries and then require manual recovery`() { + val controller = EcgStreamController() + assertTrue(controller.retryAfter(IllegalStateException("Busy"))) + assertTrue(controller.retryAfter(IllegalStateException("Busy"))) + assertFalse(controller.retryAfter(IllegalStateException("Busy"))) + controller.resetFailures() + assertTrue(controller.retryAfter(IllegalStateException("Busy"))) + } + + @Test fun `unsupported settings and permission errors are not retried`() { + val controller = EcgStreamController() + assertFalse(controller.retryAfter(IllegalArgumentException("Unsupported rate"))) + controller.resetFailures() + assertFalse(controller.retryAfter(SecurityException("Permission denied"))) + controller.resetFailures() + assertFalse(controller.retryAfter(UnsupportedOperationException("No ECG"))) + } + + @Test fun `packet fixture resets recovery attempts but Stop alone does not`() { + val controller = EcgStreamController() + controller.retryAfter(IllegalStateException("Busy")) + controller.start({ Flowable.never() }, {}, { _, _ -> fail() }, {}) + controller.stop() + assertEquals(1, controller.failures) + controller.start({ Flowable.just(packet) }, {}, { _, _ -> fail() }, {}) + assertEquals(0, controller.failures) + } + + @Test fun `synchronous settings failure clears ownership and permits a new attempt`() { + val controller = EcgStreamController() + var failures = 0 + controller.start({ Flowable.error(IllegalStateException("Settings not ready")) }, {}, + { _, pending -> failures++; assertTrue(pending) }, {}) + assertFalse(controller.isActive) + controller.start({ Flowable.never() }, {}, { _, _ -> fail() }, {}) + assertTrue(controller.isActive) + assertTrue(controller.stop()) + assertEquals(1, failures) + } + + @Test fun `Pause cancels pending settings so late settings cannot start ECG`() { + val settings = PublishProcessor.create() + val controller = EcgStreamController() + var starts = 0 + controller.start({ settings.flatMap { starts++; Flowable.never() } }, {}, { _, _ -> fail() }, {}) + assertTrue(controller.stop()) + settings.onNext(130) + assertEquals(0, starts) + assertFalse(settings.hasSubscribers()) + } + + @Test fun `duplicate Start does not subscribe twice`() { + val controller = EcgStreamController() + var starts = 0 + val factory = { starts++; Flowable.never() } + controller.start(factory, {}, { _, _ -> fail() }, {}) + controller.start(factory, {}, { _, _ -> fail() }, {}) + assertEquals(1, starts) + controller.stop() + } + + @Test fun `End cancels queued data error and timeout before Resume`() { + val scheduler = TestScheduler() + val source = PublishProcessor.create() + val controller = EcgStreamController() + var callbacks = 0 + controller.start({ source.timeout(10, TimeUnit.SECONDS, scheduler).observeOn(scheduler) }, + { callbacks++ }, { _, _ -> callbacks++ }, { callbacks++ }) + source.onNext(packet) + controller.stop() + scheduler.advanceTimeBy(20, TimeUnit.SECONDS) + assertEquals(0, callbacks) + assertFalse(controller.isActive) + } + + @Test fun `Resume owns only the new stream and running Stop disposes it`() { + val old = PublishProcessor.create() + val resumed = PublishProcessor.create() + val controller = EcgStreamController() + var received = 0 + controller.start({ old }, { received++ }, { _, _ -> fail() }, {}) + old.onNext(packet) + assertFalse(controller.stop()) + controller.start({ resumed }, { received++ }, { _, _ -> fail() }, {}) + old.onNext(packet) + resumed.onNext(packet) + assertEquals(2, received) + assertFalse(old.hasSubscribers()) + assertFalse(controller.stop()) + assertFalse(resumed.hasSubscribers()) + } + + @Test fun `a thrown SDK call becomes a recoverable error rather than crashing`() { + val controller = EcgStreamController() + var failure: Throwable? = null + controller.start({ throw IllegalArgumentException("Invalid device") }, {}, { error, _ -> failure = error }, {}) + assertTrue(failure is IllegalArgumentException) + assertFalse(controller.isActive) + } + + @Test fun `completion clears the subscription even before subscribe returns`() { + val controller = EcgStreamController() + var completed = false + controller.start({ Flowable.empty() }, {}, { _, _ -> fail() }, { completed = true }) + assertTrue(completed) + assertFalse(controller.isActive) + assertFalse(controller.stop()) + } + + @Test fun `a stalled startup times out once and leaves no stale active handle`() { + val scheduler = TestScheduler() + val controller = EcgStreamController() + var failures = 0 + controller.start({ Flowable.never().timeout(10, TimeUnit.SECONDS, scheduler) }, {}, + { _, pending -> failures++; assertTrue(pending) }, {}) + scheduler.advanceTimeBy(10, TimeUnit.SECONDS) + assertEquals(1, failures) + assertFalse(controller.isActive) + } +} diff --git a/openspec/specs/01-watch-accelerometer-and-vitals.md b/openspec/specs/01-watch-accelerometer-and-vitals.md new file mode 100644 index 0000000..2dddcb1 --- /dev/null +++ b/openspec/specs/01-watch-accelerometer-and-vitals.md @@ -0,0 +1,102 @@ +# Watch/Polar acquisition specification + +Status: software contract for **1.3.1**; physical sensor acceptance +pending. Feature inventory: [AGENTS.md](../../AGENTS.md). +Current control/recovery plan: [SESSION_CONTROL_FIX_PLAN.md](../../SESSION_CONTROL_FIX_PLAN.md). +Historic acquisition evidence: [SENSOR_FIX_PLAN.md](../../SENSOR_FIX_PLAN.md). +Additional lifecycle requirements: [session control and Polar recovery](02-session-control-and-polar-recovery.md). + +## Requirements + +- SHALL acquire real native Watch acceleration independently of Samsung readiness, + honor registration failure, and retain event-time-based Unix milliseconds. +- SHALL use one `PPG_CONTINUOUS` tracker for supported Green/IR/Red at 25 Hz; + reject abnormal status/empty callbacks without zero substitution. +- SHALL batch 300 PPG or 100 acceleration samples by current buffer size; + final smaller batches have exactly their recorded samples. +- SHALL serialize callbacks/control/buffers, cancel old callback generations, + and separate sensor-triggered rendering through Choreographer coalescing. +- SHALL use one session ID across pause/resume, unique batch paths and counts + to distinguish replay, previous sessions and incomplete final transfers. +- SHALL retain late data for the current session after Stop/Pause. Explicit + partial export SHALL mark incomplete transfer; Stop alone is not a receipt. +- SHALL stream ECG only when connected, active and ECG-ready; preserve Polar + sample nanoseconds/gaps and anchor one clock per subscription. Optional + device clock setting SHALL NOT block ECG. +- SHALL acquire supported HR independently of ECG. RR uses SDK milliseconds; + packet receipt anchoring SHALL NOT add an epoch to itself or collapse pauses. +- SHALL retain raw samples across plot setup. Changing/restarting a Polar + connection SHALL end an active session safely before replacing its API. +- SHALL export real received rows via SAF with datatype, sample count, nominal + rate/units and clock/transfer metadata. Absence means an empty channel. +- SHALL publish matching versioned APKs while keeping Samsung AAR private. + +## BDD scenarios + +```gherkin +Feature: Independent Watch motion and continuous PPG + Scenario: Missing Samsung service + Given a Watch with a native accelerometer and no usable Samsung service + When a session starts + Then native acceleration registration is attempted + And PPG reports unavailable without fabricating values + + Scenario: Three supported continuous PPG channels + Given PPG_CONTINUOUS capability and Body Sensors permission + When a session starts + Then one tracker requests GREEN IR RED together + And only normal-status samples are recorded at the SDK's 25 Hz contract + +Feature: Batch lifecycle and delivery + Scenario: Flush after pause + Given 42 buffered samples + When Pause unregisters producers and drains their application buffers + Then exactly 42 samples enter transport + And the paused phone can accept the late batch + And Resume keeps its session and prior samples + + Scenario: No off-by-one drain + Given a full batch plus one buffered sample + When the full batch is removed + Then the next complete value and timestamp tuple remains + + Scenario: Stop received before tail batch + Given Stop expects 42 acceleration samples + When the count receipt arrives first + Then completion remains false until the unique tail arrives + And replay does not increase current counts + And a new session rejects the previous session's late batch + +Feature: Polar clocks and capabilities + Scenario: Unsupported ECG + Given a Polar device advertising HR without ECG + When the session records + Then HR notifications are retained + And ECG is reported unavailable without a fabricated stream + + Scenario: Correct ECG interval + Given 131 SDK timestamps over one second at 130 Hz + When the device nanoseconds are mapped to receipt-anchored Unix milliseconds + Then first-to-last span remains 1000 milliseconds + And dropped samples and resume gaps remain represented + + Scenario: RR clock after pause + Given millisecond RR intervals and a delayed notification + When beat plot times are derived + Then the packet's last beat uses receipt time + And absolute epoch values are not added together +``` + +## Verification layers + +- Source/JVM: clock/buffer/transport validation/session regressions. +- Android build: all modules' tests, phone/Watch lint and both debug APKs. +- Phone runtime: private service declaration, Start/Pause/Resume/Stop and one + redraw per plot/frame after a burst. No physical acquisition inference. +- Hardware (pending): actual Galaxy Watch PPG/acceleration and Polar H10/HR + sessions/CSVs, sample counts, measured intervals, pauses/tails, reconnect, + missing sensor/permission policy and SDK authorization. + +Never promote source/build/runtime checks to a physical acquisition pass. +Do not claim continuous 100 Hz IR/Red, exact synchronization, clinical accuracy +or process-death recovery under this contract. diff --git a/openspec/specs/02-session-control-and-polar-recovery.md b/openspec/specs/02-session-control-and-polar-recovery.md new file mode 100644 index 0000000..2120f43 --- /dev/null +++ b/openspec/specs/02-session-control-and-polar-recovery.md @@ -0,0 +1,117 @@ +# Session control and Polar recovery — v1.3.1 + +Plan/evidence: [SESSION_CONTROL_FIX_PLAN.md](../../SESSION_CONTROL_FIX_PLAN.md). +Scope: software behavior, with physical Watch/H10 acceptance pending. + +## Requirements and design + +- SHALL send explicit Start/Pause/Resume/End intent using a positive session ID + and increasing command revision; fresh Start uses a newer ID. End is terminal. +- SHALL query current peers for each send, retry the latest unacknowledged intent + every 3 seconds and discard obsolete asynchronous lookups/acknowledgements. + MessageApi send success alone is not sensor-state confirmation. +- SHALL acknowledge Pause/End only when the same session/revision/state reports + `sensorsStopped=true`. Failed unregistration retains ownership and is retried. + Final four-channel count reconciliation remains a separate transfer gate. +- SHALL heartbeat active acquisition every 3 seconds. A Watch with no valid + command for 15 seconds SHALL pause acquisition/drain application buffers. + The timeout uses monotonic time; rejected stale commands cannot extend it. + Actual timeout/LED behavior requires hardware verification. +- SHALL retain the last Watch control intent in private preferences. Activity + destruction closes that session; delayed Start cannot restart it after + recreation. Preferences do not provide durable biosignal storage. +- SHALL release native and Samsung listeners and CPU wake locks on Pause/End. + CPU stays awake during active acquisition to deliver screen-off heartbeats. + Bind SDK callbacks to their registration so canceled callbacks cannot enter + a resumed buffer. Keep service for paused UI/final transport as needed. +- SHALL own both pending and active ECG with one disposable and identity guard; + duplicate Start cannot overlap or toggle off a current subscription. A + synchronous settings/start error must not leave an active stale handle. +- SHALL capture the Polar API/device for each attempt and ignore obsolete API + callbacks after restart/device change. Accept a configured ID or address; + do not rely on exact case when interpreting SDK callback identity. +- SHALL dispose ECG on Pause/End/disconnect/destruction. In SDK 4.0, cancellation + before the start handshake completes SHALL close the BLE connection so a + pending sensor start cannot remain orphaned. Resume reconnects and waits for + readiness; allow one second for asynchronous stop before restarting. +- SHALL time out missing ECG delivery after 10 seconds. Permit at most two + consecutive transient-error retries, with a one-second delay; received ECG + or explicit Start/Resume resets the budget. Unsupported settings/permission + errors do not retry; final errors remain visible for manual recovery. +- SHALL preserve 130 Hz per-sample Polar timing, pause gaps, existing recordings + and independently acquired real HR/RR. HR emitted by the device while the + application is paused/stopped SHALL not enter the recording. + +## BDD and executable mapping + +```gherkin +Feature: Ordered Watch control + Scenario: Delayed Start after End + Given Start revision 1 and End revision 3 for session A were applied + When Start revision 1 or revision 4 arrives for session A + Then acquisition remains stopped + And a newer session B can start + + Scenario: Lost Start and offline End + Given Start was lost or Watch temporarily cannot be reached + When the phone requests End + Then it keeps the latest intent pending and queries current peers on retry + And End is accepted even when Start did not arrive + And sensor-off status waits for the matching successful-release ACK + + Scenario: Disconnected controller + Given a valid Start heartbeat was last received at monotonic time T + When no valid command is received for 15 seconds + Then Watch pauses and releases producers and wake lock + And stale commands do not postpone the deadline + And reconnection can resume the same still-live in-memory session + + Scenario: Failed sensor release + Given the SDK rejects an unregister call + When Pause or End is applied + Then callbacks are invalidated but registration ownership is retained + And release is retried and not acknowledged as off before success + +Feature: Polar stream ownership + Scenario: Pending start canceled + Given ECG settings or start is pending + When Pause or End occurs + Then subscription disposal prevents late settings from starting a stream + And a pending SDK start is canceled by closing its connection + And Resume reconnects only for an active unpaused session + + Scenario: Synchronous error and immediate retry + Given the SDK fails settings before subscribe returns + When error recovery is considered + Then no stale disposable blocks the next attempt + And retries are bounded and no subscriptions overlap + + Scenario: Late callback after Resume + Given a previous subscription or API was canceled + When its queued data or error arrives during a new attempt + Then the new stream and recordings remain unchanged +``` + +`WatchControlTest` covers ordering, restored End, heartbeat, lease and ACK +requirements. `TrackerLifecycleTest` covers registration/release/callback +ownership. `EcgStreamControllerTest` covers synchronous/pending startup, +disposal, completion, timeout and retry budget. Phone `RecordingLifecycleTest` +covers actual Android menu/service/wake-lock wiring. Existing timestamp, +recording-buffer, batch-validation and transfer tests remain required. + +## Physical acceptance (unverified) + +Use matching 1.3.1 APKs on actual Watch/H10. Repeat rapid Start/Pause/Resume/End; +observe PPG illumination and live native counts cease after acknowledged Pause/ +End, including a deliberately lost command and disconnected phone. Check the +15-second nominal lease deadline and screen-off recording. Interrupt Polar +during pending settings/start and active streaming; Resume/reconnect must not +produce overlapping streams or erase preceding samples. Export actual CSVs, +record per-channel counts/rates/timestamp gaps and compare final transfer counts. +No device outcomes are inferred from JVM or phone instrumentation. + +## Sources + +- [Samsung HealthTracker registration, unregistration and flush](https://developer.samsung.com/health/sensor/api-reference/com/samsung/android/service/health/tracking/HealthTracker.html). +- [Polar SDK 4.0 streaming implementation](https://github.com/polarofficial/polar-ble-sdk/blob/4.0.0/sources/Android/android-communications/library/src/sdk/java/com/polar/sdk/impl/BDBleApiImpl.kt): stop is attached after the start handshake and runs asynchronously. +- [OpenSpec implementation skill](https://github.com/Fission-AI/OpenSpec/blob/main/skills/openspec-apply-change/SKILL.md) and [BMAD build skill](https://github.com/bmad-code-org/BMAD-METHOD/blob/main/skills/bmad-build/SKILL.md): workflow research, adapted to this bounded repair; no CLI/framework installation claimed. diff --git a/project_report.md b/project_report.md new file mode 100644 index 0000000..58ee6e1 --- /dev/null +++ b/project_report.md @@ -0,0 +1,24 @@ +# HeartRateMonitor implementation report + +Current contract: [AGENTS.md](AGENTS.md). +Current repair and validation receipts: [SESSION_CONTROL_FIX_PLAN.md](SESSION_CONTROL_FIX_PLAN.md). +Historic acquisition repairs: [SENSOR_FIX_PLAN.md](SENSOR_FIX_PLAN.md). +Downloads/history: [README.md](README.md). + +The phone coordinates a session, receives Watch PPG/native motion, and connects +to supported Polar ECG/HR/RR features. Watch Green/IR/Red continuous acquisition +uses one Samsung tracker at 25 Hz, while H10 ECG uses 130 Hz. Device capability, +permission and SDK policy remain runtime gates. + +Version 1.3.0 repairs service registration, native motion independence, +pause/resume/drain semantics, unique/session-aware batch delivery, sample-clock +mapping, RR epoch correction, HR recording/export and frame redraw scheduling. +Raw acquisition is separate from the existing QRS heuristic and its derived HR. + +Version 1.3.1 adds ordered and acknowledged session control, Watch heartbeat +timeout and retryable sensor release, recording-scoped CPU wake locks, and +owned/cancelable Polar ECG startup with bounded error recovery. + +Receipts, software test counts and lint warnings must be read from the selected +repair evidence. No physical Watch/H10 acquisition, fixed frame rate, clinical +accuracy or exact inter-device synchronization result is claimed. diff --git a/scripts/check_publication.py b/scripts/check_publication.py new file mode 100644 index 0000000..9c5b493 --- /dev/null +++ b/scripts/check_publication.py @@ -0,0 +1,67 @@ +"""Reject tracked/incoming SDK bundles, signing material and raw recordings before publication.""" +import argparse +import subprocess +import sys +from pathlib import PurePosixPath + +BLOCKED_SUFFIXES = { + ".aar", ".apk", ".aab", ".jks", ".keystore", ".key", ".pem", ".p12", ".pfx", + ".csv", ".tsv", ".parquet", ".feather", ".h5", ".hdf5", ".npy", ".npz", + ".jsonl", ".edf", ".bdf", ".fif", ".mat", ".pyc", ".class", ".dex", ".so", +} +BLOCKED_NAMES = {"local.properties", "key.properties", "google-services.json", "credentials.json"} +BLOCKED_DIRS = {".idea", ".gradle", ".kotlin", "build", "bin", "__pycache__", "raw_data", "sensor_data", "recordings"} +BLOCKED_LOGS = {"build_output.txt", "compile_output.txt", "compile_output2.txt"} + + +def forbidden_path(path): + parsed = PurePosixPath(path.replace("\\", "/").lower()) + return (parsed.suffix in BLOCKED_SUFFIXES or parsed.name in BLOCKED_NAMES + or parsed.name in BLOCKED_LOGS or parsed.name == ".env" + or parsed.name.startswith(".env.") or bool(set(parsed.parts[:-1]) & BLOCKED_DIRS)) + + +def git(*args): + result = subprocess.run(["git", *args], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return result.stdout.decode("utf-8", errors="strict") + + +def audit(base=None, head="HEAD"): + current = {path for path in git("ls-files", "-z").split("\0") if path} + failures = [f"tracked:{path}" for path in sorted(current) if forbidden_path(path)] + commits = [] + if base: + base_sha = git("rev-parse", "--verify", f"{base}^{{commit}}").strip() + head_sha = git("rev-parse", "--verify", f"{head}^{{commit}}").strip() + commits = git("rev-list", f"{base_sha}..{head_sha}").splitlines() + for commit in commits: + # Inspect each merge parent: a deleted SDK in an incoming commit + # still enters history when the PR is merged without squash. + changed = git("diff-tree", "--root", "-r", "-m", "--no-commit-id", + "--name-only", "--diff-filter=ACMR", "-z", commit) + failures.extend(f"incoming:{commit}:{path}" for path in set(changed.split("\0")) + if path and forbidden_path(path)) + return sorted(set(failures)), len(current), len(commits) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", help="Existing destination commit/ref; scan all incoming history.") + parser.add_argument("--head", default="HEAD") + args = parser.parse_args() + try: + failures, file_count, commit_count = audit(args.base, args.head) + except (subprocess.CalledProcessError, UnicodeError) as error: + print(f"Publication audit could not finish: {type(error).__name__}", file=sys.stderr) + return 2 + if failures: + print("\n".join(failures), file=sys.stderr) + print(f"Publication audit FAILED: {len(failures)} prohibited paths.", file=sys.stderr) + return 1 + print(f"Publication audit passed: {file_count} tracked paths, {commit_count} incoming commits.") + print("SDKs and raw recordings must stay local/ignored. Existing destination history is not rewritten.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_check_publication.py b/scripts/test_check_publication.py new file mode 100644 index 0000000..7f551a8 --- /dev/null +++ b/scripts/test_check_publication.py @@ -0,0 +1,37 @@ +import unittest +from check_publication import forbidden_path + + +class PublicationPolicyTest(unittest.TestCase): + def test_sdk_and_apk_bundles_are_rejected_at_any_location(self): + for path in ("wear/libs/Samsung.AAR", "vendor/polar.aar", "app.aab", "mobile-debug.apk"): + with self.subTest(path=path): + self.assertTrue(forbidden_path(path)) + + def test_signing_and_configuration_secrets_are_rejected(self): + for path in (".env", ".env.production", "mobile/local.properties", "release.keystore", + "key.properties", "vendor/credentials.json", "private.pem"): + with self.subTest(path=path): + self.assertTrue(forbidden_path(path)) + + def test_raw_recordings_and_arrays_are_rejected(self): + for path in ("participant/ecg.csv", "ppg.npz", "acc.parquet", "sensor_data/trace.json", + "recordings/session.txt", "export.jsonl"): + with self.subTest(path=path): + self.assertTrue(forbidden_path(path)) + + def test_generated_machine_files_are_rejected(self): + for path in (".idea/gradle.xml", "shared/bin/Message.kt", "mobile/build/report.xml", "build_output.txt"): + with self.subTest(path=path): + self.assertTrue(forbidden_path(path)) + + def test_source_specs_tests_and_gradle_wrapper_remain_publishable(self): + for path in ("wear/src/main/java/Listener.kt", "shared/src/test/java/WatchControlTest.kt", + "openspec/specs/02-session-control-and-polar-recovery.md", + "gradle/wrapper/gradle-wrapper.jar", ".github/workflows/android-checks.yml"): + with self.subTest(path=path): + self.assertFalse(forbidden_path(path)) + + +if __name__ == "__main__": + unittest.main() diff --git a/shared/src/main/java/com/example/samplewearmobileapp/AccelerometerRecording.kt b/shared/src/main/java/com/example/samplewearmobileapp/AccelerometerRecording.kt new file mode 100644 index 0000000..4a6c3c2 --- /dev/null +++ b/shared/src/main/java/com/example/samplewearmobileapp/AccelerometerRecording.kt @@ -0,0 +1,35 @@ +package com.example.samplewearmobileapp + + +class AccelerometerRecording { + val xValues = mutableListOf() + val yValues = mutableListOf() + val zValues = mutableListOf() + val timestamps = mutableListOf() + + fun add(x: Float, y: Float, z: Float, timestamp: Long) { + xValues.add(x) + yValues.add(y) + zValues.add(z) + timestamps.add(timestamp) + } + + fun clearFromStartUntil(count: Int) { + val toRemove = minOf(count, xValues.size, yValues.size, zValues.size, timestamps.size) + repeat(toRemove) { + if (xValues.isNotEmpty()) xValues.removeAt(0) + if (yValues.isNotEmpty()) yValues.removeAt(0) + if (zValues.isNotEmpty()) zValues.removeAt(0) + if (timestamps.isNotEmpty()) timestamps.removeAt(0) + } + } + + fun clear() { + xValues.clear() + yValues.clear() + zValues.clear() + timestamps.clear() + } + + fun getSize(): Int = xValues.size +} diff --git a/shared/src/main/java/com/example/samplewearmobileapp/PpgRecording.kt b/shared/src/main/java/com/example/samplewearmobileapp/PpgRecording.kt new file mode 100644 index 0000000..b5ac97e --- /dev/null +++ b/shared/src/main/java/com/example/samplewearmobileapp/PpgRecording.kt @@ -0,0 +1,48 @@ +package com.example.samplewearmobileapp + +import com.example.samplewearmobileapp.models.PpgType + +class PpgRecording() { + val values = mutableListOf() + val timestamps = mutableListOf() + lateinit var ppgType: PpgType + + constructor(type: PpgType): this() { + this.ppgType = type + } + + constructor(values: List, timestamps: List, type: PpgType): this() { + for (value in values) { + this.values.add(value) + } + for (timestamp in timestamps) { + this.timestamps.add(timestamp) + } + this.ppgType = type + } + + fun add(value: Int, timestamp: Long) { + values.add(value) + timestamps.add(timestamp) + } + + fun clearFromStartUntil(count: Int) { + val toRemove = minOf(count, values.size, timestamps.size) + repeat(toRemove) { + if (values.isNotEmpty()) values.removeAt(0) + if (timestamps.isNotEmpty()) timestamps.removeAt(0) + } + } + + fun clear() { + values.clear() + timestamps.clear() + } + + fun getSize(): Int? { + if (values.size != timestamps.size) { + return null + } + return values.size + } +} diff --git a/shared/src/main/java/com/example/samplewearmobileapp/constants/MessagePath.kt b/shared/src/main/java/com/example/samplewearmobileapp/constants/MessagePath.kt index ac352eb..1223033 100644 --- a/shared/src/main/java/com/example/samplewearmobileapp/constants/MessagePath.kt +++ b/shared/src/main/java/com/example/samplewearmobileapp/constants/MessagePath.kt @@ -28,4 +28,5 @@ object MessagePath { const val DATA_PPG_GREEN = "/datappggreen" // for PPG Green Data traffic const val DATA_PPG_IR = "/datappgir" // for PPG IR Data traffic const val DATA_PPG_RED = "/datappgred" // for PPG Red Data traffic + const val DATA_ACCELEROMETER = "/dataaccelerometer" // for Accelerometer Data traffic } \ No newline at end of file diff --git a/shared/src/main/java/com/example/samplewearmobileapp/models/AccelerometerData.kt b/shared/src/main/java/com/example/samplewearmobileapp/models/AccelerometerData.kt new file mode 100644 index 0000000..2727079 --- /dev/null +++ b/shared/src/main/java/com/example/samplewearmobileapp/models/AccelerometerData.kt @@ -0,0 +1,83 @@ +package com.example.samplewearmobileapp.models + +/** + * Accelerometer Data class for transportation between modules. + * + * Each object represents a batch of 3-axis accelerometer readings. + * @param xValues FloatArray of X-axis acceleration values (m/s^2) + * @param yValues FloatArray of Y-axis acceleration values (m/s^2) + * @param zValues FloatArray of Z-axis acceleration values (m/s^2) + * @param timestamps LongArray of timestamps for each corresponding reading + * @param windowSize The maximum capacity of the batch arrays + * @param size The actual number of readings currently stored in this batch + */ +data class AccelerometerData( + var xValues: FloatArray, + var yValues: FloatArray, + var zValues: FloatArray, + var timestamps: LongArray, + var windowSize: Int, + var size: Int = 0, + var sessionId: Long = 0L +) { + fun isValid(): Boolean = size in 1..windowSize && + xValues.size == windowSize && yValues.size == windowSize && + zValues.size == windowSize && timestamps.size == windowSize && + (0 until size).all { xValues[it].isFinite() && yValues[it].isFinite() && + zValues[it].isFinite() && timestamps[it] > 0L && + (it == 0 || timestamps[it] > timestamps[it - 1]) } + + constructor(windowSize: Int) : this( + FloatArray(windowSize), + FloatArray(windowSize), + FloatArray(windowSize), + LongArray(windowSize), + windowSize, + 0 + ) + + /** + * Resets array contents and size counter. + */ + fun clear() { + for (i in 0 until windowSize) { + xValues[i] = 0f + yValues[i] = 0f + zValues[i] = 0f + timestamps[i] = 0L + } + size = 0 + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AccelerometerData + + if (!xValues.contentEquals(other.xValues)) return false + if (!yValues.contentEquals(other.yValues)) return false + if (!zValues.contentEquals(other.zValues)) return false + if (!timestamps.contentEquals(other.timestamps)) return false + if (windowSize != other.windowSize) return false + if (size != other.size) return false + if (sessionId != other.sessionId) return false + + return true + } + + override fun hashCode(): Int { + var result = xValues.contentHashCode() + result = 31 * result + yValues.contentHashCode() + result = 31 * result + zValues.contentHashCode() + result = 31 * result + timestamps.contentHashCode() + result = 31 * result + windowSize + result = 31 * result + size + result = 31 * result + sessionId.hashCode() + return result + } + + override fun toString(): String { + return "AccelerometerData(windowSize=$windowSize, size=$size)" + } +} diff --git a/shared/src/main/java/com/example/samplewearmobileapp/models/Message.kt b/shared/src/main/java/com/example/samplewearmobileapp/models/Message.kt index f468a18..76722af 100644 --- a/shared/src/main/java/com/example/samplewearmobileapp/models/Message.kt +++ b/shared/src/main/java/com/example/samplewearmobileapp/models/Message.kt @@ -6,7 +6,11 @@ data class Message( var sender: String, var content: String?, var code: Int, - var extraCode: Int? + var extraCode: Int?, + var sessionId: Long = 0L, + var sampleCounts: IntArray? = null, + var commandRevision: Long = 0L, + var sensorsStopped: Boolean? = null ) { /** * Empty/dummy constructor diff --git a/shared/src/main/java/com/example/samplewearmobileapp/models/PpgData.kt b/shared/src/main/java/com/example/samplewearmobileapp/models/PpgData.kt index 730ceee..9b6d2e0 100644 --- a/shared/src/main/java/com/example/samplewearmobileapp/models/PpgData.kt +++ b/shared/src/main/java/com/example/samplewearmobileapp/models/PpgData.kt @@ -19,8 +19,14 @@ data class PpgData( var timestamps : LongArray, var ppgType: PpgType, var windowSize: Int, - var size: Int = 0 + var size: Int = 0, + var sessionId: Long = 0L ) { + fun isValid(): Boolean = size in 1..windowSize && + ppgValues.size == windowSize && timestamps.size == windowSize && + (0 until size).all { timestamps[it] > 0L && + (it == 0 || timestamps[it] > timestamps[it - 1]) } + constructor(windowSize: Int, ppgType: PpgType): this( IntArray(windowSize), LongArray(windowSize), @@ -49,6 +55,7 @@ data class PpgData( if (!timestamps.contentEquals(other.timestamps)) return false if (ppgType != other.ppgType) return false if (windowSize != other.windowSize) return false + if (size != other.size || sessionId != other.sessionId) return false return true } @@ -58,6 +65,8 @@ data class PpgData( result = 31 * result + timestamps.contentHashCode() result = 31 * result + ppgType.hashCode() result = 31 * result + windowSize + result = 31 * result + size + result = 31 * result + sessionId.hashCode() return result } diff --git a/shared/src/main/java/com/example/samplewearmobileapp/models/PpgType.kt b/shared/src/main/java/com/example/samplewearmobileapp/models/PpgType.kt index 9d35795..e7f0686 100644 --- a/shared/src/main/java/com/example/samplewearmobileapp/models/PpgType.kt +++ b/shared/src/main/java/com/example/samplewearmobileapp/models/PpgType.kt @@ -4,4 +4,6 @@ enum class PpgType { PPG_GREEN, PPG_IR, PPG_RED; -} \ No newline at end of file + + val mask: Int get() = 1 shl ordinal +} diff --git a/shared/src/main/java/com/example/samplewearmobileapp/models/TrackerLifecycle.kt b/shared/src/main/java/com/example/samplewearmobileapp/models/TrackerLifecycle.kt new file mode 100644 index 0000000..93d2c63 --- /dev/null +++ b/shared/src/main/java/com/example/samplewearmobileapp/models/TrackerLifecycle.kt @@ -0,0 +1,38 @@ +package com.example.samplewearmobileapp.models + +/** Registration ownership survives an SDK error until unregistration succeeds. */ +class TrackerLifecycle { + @Volatile var requested = false + private set + @Volatile var registered = false + private set + @Volatile var generation = 0L + private set + + fun start(register: () -> Unit): Boolean { + if (requested) return true + check(!registered) { "Release the previous tracker before restarting" } + generation++ + requested = true + registered = true // Even partial registration failure must be cleaned up. + try { + register() + } catch (error: RuntimeException) { + requested = false + throw error + } + return true + } + + fun stop(unregister: () -> Unit): Boolean { + generation++ + requested = false + if (registered) { + unregister() // Keep ownership if this fails so the next Stop can retry. + registered = false + } + return true + } + + fun accepts(callbackGeneration: Long): Boolean = requested && generation == callbackGeneration +} diff --git a/shared/src/main/java/com/example/samplewearmobileapp/models/WatchControl.kt b/shared/src/main/java/com/example/samplewearmobileapp/models/WatchControl.kt new file mode 100644 index 0000000..58a9929 --- /dev/null +++ b/shared/src/main/java/com/example/samplewearmobileapp/models/WatchControl.kt @@ -0,0 +1,39 @@ +package com.example.samplewearmobileapp.models + +import com.example.samplewearmobileapp.constants.codes.ActivityCode + +/** Ordered controller intent; End is terminal until a newer session starts. */ +class WatchControl { + var sessionId = 0L + private set + var revision = 0L + private set + var code = ActivityCode.DO_NOTHING + private set + private var lastContactMs = 0L + + fun accept(id: Long, sequence: Long, state: Int, nowMs: Long): Boolean { + if (id <= 0L || sequence <= 0L || id < sessionId || + state !in setOf(ActivityCode.START_ACTIVITY, ActivityCode.PAUSE_ACTIVITY, ActivityCode.STOP_ACTIVITY)) return false + if (id == sessionId && (sequence < revision || + (sequence == revision && state != code) || + (code == ActivityCode.STOP_ACTIVITY && state != ActivityCode.STOP_ACTIVITY))) return false + sessionId = id + revision = sequence + code = state + lastContactMs = nowMs + return true // Duplicate intent renews the lease and receives another acknowledgement. + } + + fun isExpired(nowMs: Long): Boolean = code == ActivityCode.START_ACTIVITY && + nowMs - lastContactMs >= TIMEOUT_MS + + fun matches(message: Message): Boolean = message.sessionId == sessionId && + message.commandRevision == revision && message.code == code && + (code == ActivityCode.START_ACTIVITY || message.sensorsStopped == true) + + companion object { + const val HEARTBEAT_MS = 3_000L + const val TIMEOUT_MS = 15_000L + } +} diff --git a/shared/src/main/java/com/example/samplewearmobileapp/models/WatchSession.kt b/shared/src/main/java/com/example/samplewearmobileapp/models/WatchSession.kt new file mode 100644 index 0000000..5f8f914 --- /dev/null +++ b/shared/src/main/java/com/example/samplewearmobileapp/models/WatchSession.kt @@ -0,0 +1,41 @@ +package com.example.samplewearmobileapp.models + +/** Tracks delivered batches and the Watch's final counts, without discarding late flushes. */ +class WatchSession { + var sessionId = 0L + private set + private val received = mutableSetOf() + private val counts = IntArray(4) // Green, IR, Red, accelerometer. + private var expected: IntArray? = null + private var stopRequested = false + + val isComplete: Boolean get() = !stopRequested || expected?.contentEquals(counts) == true + + fun start(id: Long) { + require(id > 0L) + sessionId = id + received.clear() + counts.fill(0) + expected = null + stopRequested = false + } + + fun requestStop() { + stopRequested = true + expected = null + } + + fun reportStopped(id: Long, samples: IntArray) { + if (id == sessionId && samples.size == counts.size && samples.all { it >= 0 }) { + expected = samples.copyOf() + } + } + + fun acceptBatch(id: Long, key: String, channel: Int, size: Int): Boolean { + if (sessionId == 0L || id != sessionId || channel !in counts.indices || size <= 0 || + key in received) return false + received.add(key) + counts[channel] += size + return true + } +} diff --git a/shared/src/main/java/com/example/samplewearmobileapp/utils/TimestampHelper.kt b/shared/src/main/java/com/example/samplewearmobileapp/utils/TimestampHelper.kt index 10fd964..cb82edb 100644 --- a/shared/src/main/java/com/example/samplewearmobileapp/utils/TimestampHelper.kt +++ b/shared/src/main/java/com/example/samplewearmobileapp/utils/TimestampHelper.kt @@ -48,90 +48,48 @@ object TimestampHelper { return millisSince2000 + POLAR_EPOCH_OFFSET_MS } - // --- Phone-Clock ECG Timestamp Generator --- - // Bypasses unreliable Polar device timestamps entirely. - // Uses the phone's wall clock (same time domain as PPG) - // with Polar's precise sample rate for inter-sample intervals. + private var polarAnchorNanos: Long? = null + private var phoneAnchorMs = 0L - /** Phone wall clock time when ECG streaming started */ - private var ecgStreamStartMs: Long = 0L - /** Running sample counter since stream start */ - private var ecgSampleIndex: Long = 0L - private var isEcgStreamStarted = false - - /** - * Resets the ECG timestamp state so the phone-clock anchor - * will be captured lazily when the first ECG sample actually arrives - * (unless [setEcgAnchor] was called to pre-set the anchor). - * Call this when ECG streaming is about to begin (before the async setup). - */ + /** Reset on every new ECG subscription, including resume/reconnect. */ fun resetEcgTimestamps() { - ecgSampleIndex = 0L - isEcgStreamStarted = false - // Don't clear ecgStreamStartMs — it may have been pre-set by setEcgAnchor() - logger.info("ECG timestamps reset; anchor will be set on first sample arrival") + polarAnchorNanos = null + phoneAnchorMs = 0L } /** - * Pre-sets the ECG anchor to a specific wall-clock time. - * Use this to pin ECG T0 to the recording start moment, - * so ECG and PPG timestamps share the same reference point. - * - * Must be called **before** [resetEcgTimestamps] or [nextEcgTimestamp]. - * - * @param anchorMs Unix epoch millis to use as ECG T0 (e.g. `startTime.time`). + * Anchor the LAST sample of the first nonempty packet to receipt time. + * Later packets retain the device clock, including missing samples and gaps. + * Absolute alignment remains an approximation affected by BLE latency. */ - fun setEcgAnchor(anchorMs: Long) { - ecgStreamStartMs = anchorMs - ecgSampleIndex = 0L - isEcgStreamStarted = false - logger.info("ECG anchor pre-set to $anchorMs (recording start time)") + fun anchorPolarTimestamp(lastSampleNanos: Long, receiptMs: Long) { + require(lastSampleNanos >= 0L && receiptMs > 0L) + if (polarAnchorNanos == null) { + polarAnchorNanos = lastSampleNanos + phoneAnchorMs = receiptMs + } } - /** - * Returns the next ECG sample timestamp in Unix epoch millis, - * computed from the phone's wall clock + sample index. - * - * Formula: `startTime + (sampleIndex × 1000 / sampleRate)` - * - * This guarantees timestamps are in the same time domain as PPG - * (phone wall clock) while preserving precise inter-sample intervals. - * - * @param sampleRateHz The ECG sample rate (e.g. 130.0 for Polar H10). - * @return Timestamp in Unix epoch milliseconds. - */ - fun nextEcgTimestamp(sampleRateHz: Double): Long { - if (!isEcgStreamStarted) { - if (ecgStreamStartMs == 0L) { - // No pre-set anchor — fall back to current phone time - ecgStreamStartMs = System.currentTimeMillis() - logger.info("ECG phone-clock anchor set at $ecgStreamStartMs (first sample arrival, no pre-set anchor)") - } else { - logger.info("ECG streaming started with pre-set anchor at $ecgStreamStartMs") - } - ecgSampleIndex = 0L - isEcgStreamStarted = true - } - val ts = ecgStreamStartMs + (ecgSampleIndex * 1000.0 / sampleRateHz).toLong() - ecgSampleIndex++ - return ts + fun polarNanosToAnchoredUnixMillis(polarNanos: Long): Long { + require(polarNanos >= 0L) + val anchor = checkNotNull(polarAnchorNanos) { "ECG clock must be anchored first" } + // floorDiv also preserves ordering for samples earlier than the packet anchor. + return phoneAnchorMs + Math.floorDiv(polarNanos - anchor, 1_000_000L) } - /** - * @deprecated Use [nextEcgTimestamp] instead. - * Kept for backward compatibility. - */ - fun resetAnchor() { - resetEcgTimestamps() + /** Android SensorEvent.timestamp shares the elapsed-realtime nanosecond clock. */ + fun elapsedNanosToUnixMillis(eventNanos: Long, elapsedAnchorNanos: Long, unixAnchorMs: Long): Long { + return unixAnchorMs + Math.floorDiv(eventNanos - elapsedAnchorNanos, 1_000_000L) } - /** - * @deprecated Use [nextEcgTimestamp] instead. - * Kept for backward compatibility. - */ - @Suppress("UNUSED_PARAMETER") - fun polarNanosToAnchoredUnixMillis(polarNanos: Long): Long { - return nextEcgTimestamp(130.0) + /** RR intervals are already milliseconds in PolarHrData.rrsMs. Last beat uses packet receipt time. */ + fun rrTimestampsFromReceipt(receiptMs: Long, rrsMs: List): LongArray { + require(receiptMs > 0L && rrsMs.all { it > 0 }) + var timestamp = receiptMs - rrsMs.sumOf { it.toLong() } + return LongArray(rrsMs.size) { index -> + timestamp += rrsMs[index] + timestamp + } } /** diff --git a/shared/src/test/java/com/example/samplewearmobileapp/RecordingBufferTest.kt b/shared/src/test/java/com/example/samplewearmobileapp/RecordingBufferTest.kt new file mode 100644 index 0000000..103e748 --- /dev/null +++ b/shared/src/test/java/com/example/samplewearmobileapp/RecordingBufferTest.kt @@ -0,0 +1,59 @@ +package com.example.samplewearmobileapp + +import com.example.samplewearmobileapp.models.PpgType +import org.junit.Assert.* +import org.junit.Test + +class RecordingBufferTest { + @Test + fun `all PPG channels retain the sample after a full batch`() { + for (type in PpgType.values()) { + val buffer = PpgRecording(type) + repeat(301) { buffer.add(it, 1000L + it * 40L) } + buffer.clearFromStartUntil(300) + assertEquals(1, buffer.getSize()) + assertEquals(listOf(300), buffer.values) + assertEquals(listOf(13000L), buffer.timestamps) + } + } + + @Test + fun `42 sample final PPG flush drains exactly 42 for every channel`() { + for (type in PpgType.values()) { + val buffer = PpgRecording(type) + repeat(42) { buffer.add(it, 1000L + it * 40L) } + val finalValues = buffer.values.toList() + val finalTimestamps = buffer.timestamps.toList() + buffer.clearFromStartUntil(42) + assertEquals(42, finalValues.size) + assertEquals(42, finalTimestamps.size) + assertEquals(0, buffer.getSize()) + } + } + + @Test + fun `accelerometer batch leaves the next XYZ tuple intact`() { + val buffer = AccelerometerRecording() + repeat(101) { buffer.add(it.toFloat(), (it + 1).toFloat(), (it + 2).toFloat(), 1000L + it * 40L) } + buffer.clearFromStartUntil(100) + assertEquals(listOf(100f), buffer.xValues) + assertEquals(listOf(101f), buffer.yValues) + assertEquals(listOf(102f), buffer.zValues) + assertEquals(listOf(5000L), buffer.timestamps) + } + + @Test + fun `accelerometer partial flush and fresh reset leave no padded samples`() { + val buffer = AccelerometerRecording() + repeat(42) { buffer.add(1f, 2f, 3f, 1000L + it * 40L) } + buffer.clearFromStartUntil(42) + assertEquals(0, buffer.getSize()) + assertTrue(buffer.timestamps.isEmpty()) + buffer.add(4f, 5f, 6f, 5000L) + buffer.clear() + assertTrue(buffer.xValues.isEmpty()) + assertTrue(buffer.yValues.isEmpty()) + assertTrue(buffer.zValues.isEmpty()) + } + +} diff --git a/shared/src/test/java/com/example/samplewearmobileapp/models/AccelerometerDataTest.kt b/shared/src/test/java/com/example/samplewearmobileapp/models/AccelerometerDataTest.kt new file mode 100644 index 0000000..75572d7 --- /dev/null +++ b/shared/src/test/java/com/example/samplewearmobileapp/models/AccelerometerDataTest.kt @@ -0,0 +1,80 @@ +package com.example.samplewearmobileapp.models + +import org.junit.Assert.* +import org.junit.Test + +class AccelerometerDataTest { + + @Test + fun `constructor creates arrays of correct window size`() { + val data = AccelerometerData(10) + assertEquals(10, data.xValues.size) + assertEquals(10, data.yValues.size) + assertEquals(10, data.zValues.size) + assertEquals(10, data.timestamps.size) + assertEquals(10, data.windowSize) + assertEquals(0, data.size) + } + + @Test + fun `clear resets all values and timestamps to zero`() { + val data = AccelerometerData(3) + data.xValues[0] = 1.0f + data.yValues[0] = 2.0f + data.zValues[0] = 3.0f + data.timestamps[0] = 12345L + data.size = 1 + + data.clear() + + assertEquals(0, data.size) + assertArrayEquals(floatArrayOf(0f, 0f, 0f), data.xValues, 0.0001f) + assertArrayEquals(floatArrayOf(0f, 0f, 0f), data.yValues, 0.0001f) + assertArrayEquals(floatArrayOf(0f, 0f, 0f), data.zValues, 0.0001f) + assertArrayEquals(longArrayOf(0L, 0L, 0L), data.timestamps) + } + + @Test + fun `equals and hashCode return true for identical data`() { + val a = AccelerometerData( + floatArrayOf(1.0f, 2.0f), + floatArrayOf(3.0f, 4.0f), + floatArrayOf(5.0f, 6.0f), + longArrayOf(100L, 200L), + 2, + 2 + ) + val b = AccelerometerData( + floatArrayOf(1.0f, 2.0f), + floatArrayOf(3.0f, 4.0f), + floatArrayOf(5.0f, 6.0f), + longArrayOf(100L, 200L), + 2, + 2 + ) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun `equals returns false for different x values`() { + val a = AccelerometerData( + floatArrayOf(1.0f), floatArrayOf(2.0f), floatArrayOf(3.0f), + longArrayOf(100L), 1, 1 + ) + val b = AccelerometerData( + floatArrayOf(9.0f), floatArrayOf(2.0f), floatArrayOf(3.0f), + longArrayOf(100L), 1, 1 + ) + assertNotEquals(a, b) + } + + @Test + fun `toString contains windowSize and size`() { + val data = AccelerometerData(50) + data.size = 25 + val str = data.toString() + assertTrue(str.contains("windowSize=50")) + assertTrue(str.contains("size=25")) + } +} diff --git a/shared/src/test/java/com/example/samplewearmobileapp/models/SensorBatchValidationTest.kt b/shared/src/test/java/com/example/samplewearmobileapp/models/SensorBatchValidationTest.kt new file mode 100644 index 0000000..da6a559 --- /dev/null +++ b/shared/src/test/java/com/example/samplewearmobileapp/models/SensorBatchValidationTest.kt @@ -0,0 +1,57 @@ +package com.example.samplewearmobileapp.models + +import org.junit.Assert.* +import org.junit.Test + +/** Transport fixtures only; these values do not represent acquired physiological data. */ +class SensorBatchValidationTest { + @Test + fun `identical array capacities cannot equate different sessions or sample counts`() { + assertNotEquals(ppg(), ppg().copy(sessionId = 2000L)) + assertNotEquals(ppg(), ppg().copy(size = 1)) + assertNotEquals(acc(), acc().copy(sessionId = 2000L)) + } + + private fun ppg() = PpgData(intArrayOf(12, 13), longArrayOf(1000, 1040), PpgType.PPG_IR, 2, 2, 1000) + private fun acc() = AccelerometerData(floatArrayOf(1f, 2f), floatArrayOf(3f, 4f), + floatArrayOf(5f, 6f), longArrayOf(1000, 1040), 2, 2, 1000) + + @Test + fun `complete real-shaped batches pass for every PPG channel`() { + for (type in PpgType.values()) assertTrue(ppg().copy(ppgType = type).isValid()) + assertTrue(acc().isValid()) + } + + @Test + fun `array capacity never creates samples in an empty batch`() { + assertFalse(PpgData(300, PpgType.PPG_GREEN).isValid()) + assertFalse(AccelerometerData(100).isValid()) + } + + @Test + fun `partial final batch validates only its declared samples`() { + assertTrue(PpgData(intArrayOf(12, 0), longArrayOf(1000, 0), PpgType.PPG_GREEN, 2, 1).isValid()) + assertTrue(acc().copy(size = 1).isValid()) + } + + @Test + fun `oversize and mismatched arrays cannot cause receiver indexing crashes`() { + assertFalse(ppg().copy(size = 3).isValid()) + assertFalse(ppg().copy(timestamps = longArrayOf(1000)).isValid()) + assertFalse(acc().copy(xValues = floatArrayOf(1f)).isValid()) + assertFalse(acc().copy(size = 3).isValid()) + } + + @Test + fun `invalid clock order and synthetic zero timestamps are rejected`() { + assertFalse(ppg().copy(timestamps = longArrayOf(1000, 1000)).isValid()) + assertFalse(ppg().copy(timestamps = longArrayOf(1040, 1000)).isValid()) + assertFalse(acc().copy(timestamps = longArrayOf(0, 1040)).isValid()) + } + + @Test + fun `nonfinite accelerometer values are rejected without zero replacement`() { + assertFalse(acc().copy(zValues = floatArrayOf(Float.NaN, 6f)).isValid()) + assertFalse(acc().copy(yValues = floatArrayOf(Float.POSITIVE_INFINITY, 4f)).isValid()) + } +} diff --git a/shared/src/test/java/com/example/samplewearmobileapp/models/TrackerLifecycleTest.kt b/shared/src/test/java/com/example/samplewearmobileapp/models/TrackerLifecycleTest.kt new file mode 100644 index 0000000..0c84789 --- /dev/null +++ b/shared/src/test/java/com/example/samplewearmobileapp/models/TrackerLifecycleTest.kt @@ -0,0 +1,72 @@ +package com.example.samplewearmobileapp.models + +import org.junit.Assert.* +import org.junit.Test + +class TrackerLifecycleTest { + @Test fun `Pause releases once and invalidates queued callbacks before Resume`() { + val tracker = TrackerLifecycle() + var starts = 0 + var stops = 0 + tracker.start { starts++ } + val oldCallback = tracker.generation + tracker.start { starts++ } + tracker.stop { stops++ } + tracker.stop { stops++ } + assertFalse(tracker.accepts(oldCallback)) + tracker.start { starts++ } + assertFalse(tracker.accepts(oldCallback)) + assertTrue(tracker.accepts(tracker.generation)) + assertEquals(2, starts) + assertEquals(1, stops) + } + + @Test fun `failed unregister preserves ownership for a successful retry`() { + val tracker = TrackerLifecycle() + tracker.start {} + try { + tracker.stop { throw IllegalStateException("SDK busy") } + fail("Expected release failure") + } catch (_: IllegalStateException) { } + assertFalse(tracker.requested) + assertTrue(tracker.registered) + assertFalse(tracker.accepts(tracker.generation)) + tracker.stop {} + assertFalse(tracker.registered) + } + + @Test fun `partial registration failure still requires cleanup`() { + val tracker = TrackerLifecycle() + try { + tracker.start { throw IllegalStateException("Registration failed") } + fail("Expected registration failure") + } catch (_: IllegalStateException) { } + assertFalse(tracker.requested) + assertTrue(tracker.registered) + var released = false + tracker.stop { released = true } + assertTrue(released) + assertFalse(tracker.registered) + } + + @Test fun `Start cannot overlap a tracker whose release failed`() { + val tracker = TrackerLifecycle() + tracker.start {} + try { tracker.stop { throw IllegalStateException("SDK busy") } } catch (_: IllegalStateException) { } + var started = false + try { + tracker.start { started = true } + fail("Unreleased tracker must block Start") + } catch (_: IllegalStateException) { } + assertFalse(started) + } + + @Test fun `Stop without prior registration is safe`() { + val tracker = TrackerLifecycle() + var called = false + tracker.stop { called = true } + assertFalse(called) + assertFalse(tracker.registered) + assertFalse(tracker.requested) + } +} diff --git a/shared/src/test/java/com/example/samplewearmobileapp/models/WatchControlTest.kt b/shared/src/test/java/com/example/samplewearmobileapp/models/WatchControlTest.kt new file mode 100644 index 0000000..0f6e502 --- /dev/null +++ b/shared/src/test/java/com/example/samplewearmobileapp/models/WatchControlTest.kt @@ -0,0 +1,108 @@ +package com.example.samplewearmobileapp.models + +import com.example.samplewearmobileapp.constants.codes.ActivityCode.DO_NOTHING +import com.example.samplewearmobileapp.constants.codes.ActivityCode.START_ACTIVITY +import com.example.samplewearmobileapp.constants.codes.ActivityCode.PAUSE_ACTIVITY +import com.example.samplewearmobileapp.constants.codes.ActivityCode.STOP_ACTIVITY +import org.junit.Assert.* +import org.junit.Test + +class WatchControlTest { + @Test fun `End cannot be undone by delayed Start or a later Start in the same session`() { + val control = WatchControl() + assertTrue(control.accept(10, 1, START_ACTIVITY, 0)) + assertTrue(control.accept(10, 3, STOP_ACTIVITY, 1)) + assertFalse(control.accept(10, 1, START_ACTIVITY, 2)) + assertFalse(control.accept(10, 4, START_ACTIVITY, 3)) + assertEquals(STOP_ACTIVITY, control.code) + assertTrue(control.accept(11, 1, START_ACTIVITY, 4)) + } + + @Test fun `Pause and Resume preserve identity and reject reordered Pause`() { + val control = WatchControl() + control.accept(10, 1, START_ACTIVITY, 0) + assertTrue(control.accept(10, 2, PAUSE_ACTIVITY, 1)) + assertTrue(control.accept(10, 3, START_ACTIVITY, 2)) + assertFalse(control.accept(10, 2, PAUSE_ACTIVITY, 3)) + assertEquals(10L, control.sessionId) + assertEquals(3L, control.revision) + } + + @Test fun `duplicate End stays stopped and can be acknowledged again`() { + val control = WatchControl() + assertTrue(control.accept(10, 2, STOP_ACTIVITY, 0)) // Start may have been lost. + assertTrue(control.accept(10, 2, STOP_ACTIVITY, 1)) + assertFalse(control.accept(10, 2, PAUSE_ACTIVITY, 2)) + assertEquals(STOP_ACTIVITY, control.code) + } + + @Test fun `an older session cannot stop or start the current one`() { + val control = WatchControl() + control.accept(20, 1, START_ACTIVITY, 0) + assertFalse(control.accept(10, 99, STOP_ACTIVITY, 1)) + assertFalse(control.accept(10, 99, START_ACTIVITY, 1)) + assertEquals(20L, control.sessionId) + } + + @Test fun `missing controller expires at fifteen seconds and heartbeat renews lease`() { + val control = WatchControl() + control.accept(10, 1, START_ACTIVITY, 1_000) + assertFalse(control.isExpired(15_999)) + assertTrue(control.isExpired(16_000)) + assertTrue(control.accept(10, 1, START_ACTIVITY, 16_100)) + assertFalse(control.isExpired(16_101)) + } + + @Test fun `stale commands cannot keep sensors alive`() { + val control = WatchControl() + control.accept(10, 3, START_ACTIVITY, 0) + assertFalse(control.accept(10, 1, START_ACTIVITY, 14_000)) + assertTrue(control.isExpired(15_000)) + } + + @Test fun `Pause and End have no acquisition lease`() { + val control = WatchControl() + control.accept(10, 1, PAUSE_ACTIVITY, 0) + assertFalse(control.isExpired(100_000)) + control.accept(10, 2, STOP_ACTIVITY, 0) + assertFalse(control.isExpired(100_000)) + } + + @Test fun `sensor release failure cannot acknowledge End`() { + val control = WatchControl() + control.accept(10, 2, STOP_ACTIVITY, 0) + val ack = Message("Watch", null, STOP_ACTIVITY, null, 10, commandRevision = 2, sensorsStopped = false) + assertFalse(control.matches(ack)) + ack.sensorsStopped = true + assertTrue(control.matches(ack)) + } + + @Test fun `old acknowledgement cannot complete a newer command`() { + val control = WatchControl() + control.accept(10, 3, PAUSE_ACTIVITY, 0) + val ack = Message("Watch", null, START_ACTIVITY, null, 10, commandRevision = 1) + assertFalse(control.matches(ack)) + ack.code = PAUSE_ACTIVITY + ack.commandRevision = 3 + ack.sensorsStopped = true + assertTrue(control.matches(ack)) + ack.sessionId = 9 + assertFalse(control.matches(ack)) + } + + @Test fun `invalid or unversioned commands do not acquire sensors`() { + val control = WatchControl() + assertFalse(control.accept(0, 1, START_ACTIVITY, 0)) + assertFalse(control.accept(10, 0, START_ACTIVITY, 0)) + assertFalse(control.accept(10, 1, 99, 0)) + assertEquals(DO_NOTHING, control.code) + } + + @Test fun `restoring the last End rejects delayed Start after Watch activity recreation`() { + val restored = WatchControl() + restored.accept(10, 3, STOP_ACTIVITY, 0) + assertFalse(restored.accept(10, 1, START_ACTIVITY, 1)) + assertFalse(restored.accept(10, 4, START_ACTIVITY, 1)) + assertTrue(restored.accept(11, 1, START_ACTIVITY, 2)) + } +} diff --git a/shared/src/test/java/com/example/samplewearmobileapp/models/WatchSessionTest.kt b/shared/src/test/java/com/example/samplewearmobileapp/models/WatchSessionTest.kt new file mode 100644 index 0000000..454de60 --- /dev/null +++ b/shared/src/test/java/com/example/samplewearmobileapp/models/WatchSessionTest.kt @@ -0,0 +1,91 @@ +package com.example.samplewearmobileapp.models + +import org.junit.Assert.* +import org.junit.Test + +class WatchSessionTest { + @Test + fun `late 42 sample flush remains accepted after stop`() { + val session = WatchSession() + session.start(1000L) + session.requestStop() + session.reportStopped(1000L, intArrayOf(0, 0, 0, 42)) + assertFalse(session.isComplete) + assertTrue(session.acceptBatch(1000L, "/dataaccelerometer/1000/0", 3, 42)) + assertTrue(session.isComplete) + } + + @Test + fun `stop counts can arrive after the final batch`() { + val session = WatchSession() + session.start(1000L) + session.requestStop() + session.acceptBatch(1000L, "batch", 3, 42) + assertFalse(session.isComplete) + session.reportStopped(1000L, intArrayOf(0, 0, 0, 42)) + assertTrue(session.isComplete) + } + + @Test + fun `pause flush and resumed batches belong to the same session`() { + val session = WatchSession() + session.start(1000L) + assertTrue(session.acceptBatch(1000L, "pause-tail", 0, 42)) + assertTrue(session.acceptBatch(1000L, "resumed-batch", 0, 300)) + session.requestStop() + session.reportStopped(1000L, intArrayOf(342, 0, 0, 0)) + assertTrue(session.isComplete) + } + + @Test + fun `replayed batch cannot satisfy missing data twice`() { + val session = WatchSession() + session.start(1000L) + session.requestStop() + session.reportStopped(1000L, intArrayOf(0, 0, 0, 84)) + assertTrue(session.acceptBatch(1000L, "same-batch", 3, 42)) + assertFalse(session.acceptBatch(1000L, "same-batch", 3, 42)) + assertFalse(session.isComplete) + } + + @Test + fun `fresh session rejects previous recording and stop receipts`() { + val session = WatchSession() + session.start(1000L) + session.acceptBatch(1000L, "old", 3, 42) + session.start(2000L) + session.requestStop() + session.reportStopped(1000L, intArrayOf(0, 0, 0, 42)) + assertFalse(session.acceptBatch(1000L, "late-old", 3, 42)) + assertFalse(session.isComplete) + session.reportStopped(2000L, intArrayOf(0, 0, 0, 0)) + assertTrue(session.isComplete) + } + + @Test + fun `all four channels must be delivered before completion`() { + val session = WatchSession() + session.start(1000L) + session.requestStop() + session.reportStopped(1000L, intArrayOf(300, 300, 300, 100)) + for (channel in 0..2) { + session.acceptBatch(1000L, "ppg-$channel", channel, 300) + assertFalse(session.isComplete) + } + session.acceptBatch(1000L, "acc", 3, 100) + assertTrue(session.isComplete) + } + + @Test + fun `invalid counts and unknown sessions cannot claim completion`() { + val session = WatchSession() + assertFalse(session.acceptBatch(0L, "idle", 0, 1)) + session.start(1000L) + session.requestStop() + session.reportStopped(1000L, intArrayOf(-1, 0, 0, 0)) + session.reportStopped(1000L, intArrayOf(0)) + assertFalse(session.acceptBatch(1000L, "invalid-channel", 4, 1)) + assertFalse(session.acceptBatch(1000L, "empty", 0, 0)) + assertFalse(session.isComplete) + } +} diff --git a/shared/src/test/java/com/example/samplewearmobileapp/utils/TimestampHelperTest.kt b/shared/src/test/java/com/example/samplewearmobileapp/utils/TimestampHelperTest.kt index 4e946ae..50292a6 100644 --- a/shared/src/test/java/com/example/samplewearmobileapp/utils/TimestampHelperTest.kt +++ b/shared/src/test/java/com/example/samplewearmobileapp/utils/TimestampHelperTest.kt @@ -45,67 +45,76 @@ class TimestampHelperTest { assertEquals(TimestampHelper.POLAR_EPOCH_OFFSET_MS + 5000L, result) } - // --- nextEcgTimestamp with setEcgAnchor --- - + // Deterministic clock fixtures below are regression inputs, not sensor recordings. @Test - fun `nextEcgTimestamp returns anchor time for first sample`() { - val anchor = 1000000L - TimestampHelper.setEcgAnchor(anchor) - val ts = TimestampHelper.nextEcgTimestamp(130.0) - assertEquals(anchor, ts) + fun `first packet is anchored at its last sample`() { + TimestampHelper.anchorPolarTimestamp(1_000_000_000L, 10_000L) + assertEquals(10_000L, TimestampHelper.polarNanosToAnchoredUnixMillis(1_000_000_000L)) + assertEquals(9_992L, TimestampHelper.polarNanosToAnchoredUnixMillis(992_307_692L)) } @Test - fun `nextEcgTimestamp produces sequential timestamps at correct interval`() { - val anchor = 1000000L - val sampleRate = 130.0 - TimestampHelper.setEcgAnchor(anchor) + fun `130 Hz samples span one second without cumulative rounding drift`() { + TimestampHelper.anchorPolarTimestamp(0L, 10_000L) + val timestamps = (0..130).map { + TimestampHelper.polarNanosToAnchoredUnixMillis(it * 1_000_000_000L / 130) + } + assertEquals(1000L, timestamps.last() - timestamps.first()) + assertTrue(timestamps.zipWithNext().all { (a, b) -> b - a in 7L..8L }) + } - val ts0 = TimestampHelper.nextEcgTimestamp(sampleRate) // index 0 - val ts1 = TimestampHelper.nextEcgTimestamp(sampleRate) // index 1 - val ts2 = TimestampHelper.nextEcgTimestamp(sampleRate) // index 2 + @Test + fun `missing samples retain their device time gap`() { + TimestampHelper.anchorPolarTimestamp(1_000_000_000L, 10_000L) + assertEquals(15_000L, TimestampHelper.polarNanosToAnchoredUnixMillis(6_000_000_000L)) + } - assertEquals(anchor, ts0) - // ts1 = anchor + (1 * 1000 / 130) = anchor + 7 - val expectedInterval = (1000.0 / sampleRate).toLong() - assertEquals(anchor + expectedInterval, ts1) - assertEquals(anchor + (2 * 1000.0 / sampleRate).toLong(), ts2) + @Test + fun `packet delay cannot change an established clock anchor`() { + TimestampHelper.anchorPolarTimestamp(1_000_000_000L, 10_000L) + TimestampHelper.anchorPolarTimestamp(2_000_000_000L, 30_000L) + assertEquals(11_000L, TimestampHelper.polarNanosToAnchoredUnixMillis(2_000_000_000L)) } @Test - fun `nextEcgTimestamp without anchor uses system time`() { - // Explicitly clear any pre-set anchor from previous tests - TimestampHelper.setEcgAnchor(0L) + fun `resume clears the old anchor and retains the wall clock pause`() { + TimestampHelper.anchorPolarTimestamp(1_000_000_000L, 10_000L) TimestampHelper.resetEcgTimestamps() - val beforeMs = System.currentTimeMillis() - val ts = TimestampHelper.nextEcgTimestamp(130.0) - val afterMs = System.currentTimeMillis() - // Allow small tolerance for GC/scheduling jitter - assertTrue("Timestamp $ts should be near system time [$beforeMs..$afterMs]", - ts >= beforeMs - 10 && ts <= afterMs + 10) + TimestampHelper.anchorPolarTimestamp(20_000_000_000L, 29_000L) + assertEquals(29_000L, TimestampHelper.polarNanosToAnchoredUnixMillis(20_000_000_000L)) } - // --- resetEcgTimestamps --- + @Test(expected = IllegalStateException::class) + fun `unanchored ECG cannot silently invent a timestamp`() { + TimestampHelper.polarNanosToAnchoredUnixMillis(1L) + } @Test - fun `resetEcgTimestamps restarts sample index`() { - val anchor = 5000L - TimestampHelper.setEcgAnchor(anchor) - TimestampHelper.nextEcgTimestamp(130.0) // index 0 - TimestampHelper.nextEcgTimestamp(130.0) // index 1 - TimestampHelper.nextEcgTimestamp(130.0) // index 2 - - // After reset, the pre-set anchor should be cleared by resetEcgTimestamps - // and the next call should re-anchor - TimestampHelper.setEcgAnchor(anchor) - TimestampHelper.resetEcgTimestamps() - // The anchor was set before reset, so it's retained - val ts = TimestampHelper.nextEcgTimestamp(130.0) // index 0 again - assertEquals(anchor, ts) + fun `accelerometer timestamp uses event time rather than delayed receipt time`() { + assertEquals(5000L, TimestampHelper.elapsedNanosToUnixMillis(2_000_000_000L, 3_000_000_000L, 6000L)) } // --- validateTimestamp --- + @Test + fun `RR timestamps preserve millisecond intervals and packet receipt anchor`() { + assertArrayEquals(longArrayOf(9100L, 10000L), TimestampHelper.rrTimestampsFromReceipt(10000L, listOf(800, 900))) + } + + @Test + fun `RR timestamps cannot double the epoch or collapse a notification pause`() { + val epoch = 1_792_000_000_000L + val before = TimestampHelper.rrTimestampsFromReceipt(epoch, listOf(1000)).single() + val after = TimestampHelper.rrTimestampsFromReceipt(epoch + 10000L, listOf(1000)).single() + assertEquals(10000L, after - before) + assertEquals(epoch + 10000L, after) + } + + @Test(expected = IllegalArgumentException::class) + fun `invalid RR intervals cannot generate fabricated beat timing`() { + TimestampHelper.rrTimestampsFromReceipt(10000L, listOf(0)) + } + @Test fun `validateTimestamp passes when session is null`() { val result = TimestampHelper.validateTimestamp(1000L, null) diff --git a/wear/build.gradle b/wear/build.gradle index 58b6151..fa5ba1e 100644 --- a/wear/build.gradle +++ b/wear/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.example.samplewearmobileapp" minSdk 30 targetSdk 34 - versionCode 2 - versionName "1.1.0" + versionCode 5 + versionName "1.3.1" } compileOptions { diff --git a/wear/src/main/java/com/example/samplewearmobileapp/ConnectionManager.kt b/wear/src/main/java/com/example/samplewearmobileapp/ConnectionManager.kt index 24bbb19..693b2cb 100644 --- a/wear/src/main/java/com/example/samplewearmobileapp/ConnectionManager.kt +++ b/wear/src/main/java/com/example/samplewearmobileapp/ConnectionManager.kt @@ -3,128 +3,46 @@ package com.example.samplewearmobileapp import android.content.Context import android.os.Handler import android.os.Looper -import android.util.Log import com.example.samplewearmobileapp.trackers.Listener -import com.example.samplewearmobileapp.trackers.ppggreen.PpgGreenListener -import com.example.samplewearmobileapp.trackers.ppgir.PpgIrListener -import com.example.samplewearmobileapp.trackers.ppgred.PpgRedListener import com.samsung.android.service.health.tracking.ConnectionListener import com.samsung.android.service.health.tracking.HealthTrackerException import com.samsung.android.service.health.tracking.HealthTrackingService import com.samsung.android.service.health.tracking.data.HealthTrackerType +import com.samsung.android.service.health.tracking.data.PpgType -/** - * Manages the connection to the Samsung Health Tracking Service and - * the initialisation of individual sensor trackers. - * - * **SOLID compliance:** - * - SRP: only responsible for connecting to and disconnecting from the - * Samsung HealthTrackingService, and provisioning trackers. - * - DIP: depends on the [ConnectionObserver] abstraction, not the Activity. - */ class ConnectionManager(private val observer: ConnectionObserver) { + private lateinit var service: HealthTrackingService - private val tag = "ConnectionManager" - private lateinit var healthTrackingService: HealthTrackingService - - // ------------------------------------------------------------------------- - // Internal Samsung connection listener - // ------------------------------------------------------------------------- - - private val connectionListener = object : ConnectionListener { - - override fun onConnectionSuccess() { - Log.i(tag, "Samsung Health Service connected") - observer.onConnectionResult(R.string.ConnectedToHs) - logUnsupportedTrackers() - } - - override fun onConnectionEnded() { - Log.i(tag, "Samsung Health Service disconnected") - } - - override fun onConnectionFailed(e: HealthTrackerException?) { - Log.e(tag, "Samsung Health Service connection failed: ${e?.message}") - observer.onError(e) - } - } - - // ------------------------------------------------------------------------- - // Public API - // ------------------------------------------------------------------------- - - /** Initiates an async connection to the Samsung Health Tracking Service. */ fun connect(context: Context) { - healthTrackingService = HealthTrackingService(connectionListener, context) - healthTrackingService.connectService() - } - - /** Disconnects from the Samsung Health Tracking Service. */ - fun disconnect() { - if (::healthTrackingService.isInitialized) { - healthTrackingService.disconnectService() - } - } - - /** - * Initialises and provisions a [PpgGreenListener] with the PPG_GREEN tracker. - * Must be called only after [onConnectionSuccess]. - */ - fun initPpgGreen(listener: PpgGreenListener) { - initListener(listener, HealthTrackerType.PPG_GREEN) - } + service = HealthTrackingService(object : ConnectionListener { + override fun onConnectionSuccess() { + observer.onConnectionResult(R.string.ConnectedToHs) + } - /** - * Initialises and provisions a [PpgIrListener] with the PPG_IR tracker. - * Must be called only after [onConnectionSuccess]. - */ - fun initPpgIr(listener: PpgIrListener) { - initListener(listener, HealthTrackerType.PPG_IR) - } + override fun onConnectionEnded() { + observer.onConnectionResult(R.string.ConnectionError) + } - /** - * Initialises and provisions a [PpgRedListener] with the PPG_RED tracker. - * Must be called only after [onConnectionSuccess]. - */ - fun initPpgRed(listener: PpgRedListener) { - initListener(listener, HealthTrackerType.PPG_RED) + override fun onConnectionFailed(error: HealthTrackerException?) { + observer.onError(error) + } + }, context) + service.connectService() } - // ------------------------------------------------------------------------- - // Private helpers - // ------------------------------------------------------------------------- - - /** - * Generic helper that obtains a tracker of [type] from the service and - * configures the given [listener] with it and a main-thread [Handler]. - * - * DRY: all three initPpg*() calls delegate here instead of repeating - * the same two-line pattern. - */ - private fun initListener(listener: Listener, type: HealthTrackerType) { - val tracker = healthTrackingService.getHealthTracker(type) - listener.setHealthTracker(tracker) + fun initPpg(listener: Listener): Boolean { + if (HealthTrackerType.PPG_CONTINUOUS !in service.trackingCapability.supportHealthTrackerTypes) { + return false + } + // IR/Red on-demand trackers cannot run together as continuous recorders. + listener.setHealthTracker(service.getHealthTracker( + HealthTrackerType.PPG_CONTINUOUS, setOf(PpgType.GREEN, PpgType.IR, PpgType.RED) + )) listener.setHandler(Handler(Looper.getMainLooper())) + return true } - /** - * Logs a warning for every tracker type that is not available on this - * device, and notifies the observer so the UI can inform the user. - */ - private fun logUnsupportedTrackers() { - val supported = healthTrackingService.trackingCapability.supportHealthTrackerTypes - - val checks = mapOf( - HealthTrackerType.PPG_GREEN to R.string.NoPpgGreenSupport, - HealthTrackerType.PPG_IR to R.string.NoPpgIrSupport, - HealthTrackerType.PPG_RED to R.string.NoPpgRedSupport, - ) - - checks.forEach { (type, errorRes) -> - if (!supported.contains(type)) { - Log.w(tag, "Device does not support $type tracking") - observer.onConnectionResult(errorRes) - } - } + fun disconnect() { + if (::service.isInitialized) service.disconnectService() } -} \ No newline at end of file +} diff --git a/wear/src/main/java/com/example/samplewearmobileapp/Constants.kt b/wear/src/main/java/com/example/samplewearmobileapp/Constants.kt index 621756f..da1cd2d 100644 --- a/wear/src/main/java/com/example/samplewearmobileapp/Constants.kt +++ b/wear/src/main/java/com/example/samplewearmobileapp/Constants.kt @@ -22,7 +22,7 @@ object Constants { * Sampling rate for PPG IR and Red. * @see PPG_GREEN_SAMPLE_RATE */ - const val PPG_IR_RED_SAMPLE_RATE: Double = 100.0 + const val PPG_IR_RED_SAMPLE_RATE: Double = 25.0 // Samsung PPG_CONTINUOUS /** * The rate of PPG Green sampling event in ms. @@ -40,7 +40,7 @@ object Constants { /** * The rate of PPG IR and Red batching event batch in ms. */ - const val PPG_IR_RED_BATCH_TICK_RATE: Long = 3000 + const val PPG_IR_RED_BATCH_TICK_RATE: Long = 12000 // the same as PPG_GREEN_TICK_RATE = 12000 ms = 12 sec. // this is implemented due to flooding when data are sent each second // UPDATE: now using 3s @@ -69,4 +69,14 @@ object Constants { // so instead, safely use 12 sec (also to match the PPG Green batch) // UPDATE: now using 3s // 100 Hz * 3000 ms / 1000 = 300 data points -} \ No newline at end of file + + /** + * Sampling rate for Accelerometer in Hz. + */ + const val ACCELEROMETER_SAMPLE_RATE: Double = 25.0 + + /** + * Batch size for Accelerometer data transmissions. + */ + const val ACCELEROMETER_BATCH_SIZE: Int = 100 +} diff --git a/wear/src/main/java/com/example/samplewearmobileapp/MainActivity.kt b/wear/src/main/java/com/example/samplewearmobileapp/MainActivity.kt index 61e928d..d9264df 100644 --- a/wear/src/main/java/com/example/samplewearmobileapp/MainActivity.kt +++ b/wear/src/main/java/com/example/samplewearmobileapp/MainActivity.kt @@ -1,736 +1,496 @@ package com.example.samplewearmobileapp -import android.content.Intent -import android.net.Uri +import android.Manifest +import android.content.pm.PackageManager +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.os.Build import android.os.Bundle -import android.provider.Settings +import android.os.Handler +import android.os.Looper +import android.os.SystemClock import android.util.Log import android.view.View -import android.widget.LinearLayout -import android.widget.TextView import android.widget.Toast +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.lifecycleScope import androidx.wear.ambient.AmbientModeSupport -import com.example.samplewearmobileapp.Constants.PPG_GREEN_BATCH_SIZE -import com.example.samplewearmobileapp.Constants.PPG_IR_RED_BATCH_SIZE -import com.example.samplewearmobileapp.constants.codes.ActivityCode import com.example.samplewearmobileapp.constants.Entity import com.example.samplewearmobileapp.constants.MessagePath +import com.example.samplewearmobileapp.constants.codes.ActivityCode import com.example.samplewearmobileapp.constants.codes.ExtraCode.TOGGLE_ACTIVITY import com.example.samplewearmobileapp.databinding.ActivityMainBinding +import com.example.samplewearmobileapp.models.AccelerometerData import com.example.samplewearmobileapp.models.Message import com.example.samplewearmobileapp.models.PpgData import com.example.samplewearmobileapp.models.PpgType -import com.example.samplewearmobileapp.trackers.Listener -import com.example.samplewearmobileapp.trackers.ppggreen.PpgGreenData -import com.example.samplewearmobileapp.trackers.ppggreen.PpgGreenListener -import com.example.samplewearmobileapp.trackers.ppggreen.PpgGreenStatus -import com.example.samplewearmobileapp.trackers.ppgir.PpgIrData -import com.example.samplewearmobileapp.trackers.ppgir.PpgIrListener -import com.example.samplewearmobileapp.trackers.ppgred.PpgRedData -import com.example.samplewearmobileapp.trackers.ppgred.PpgRedListener -import com.google.android.gms.wearable.DataClient -import com.google.android.gms.wearable.MessageClient -import com.google.android.gms.wearable.MessageEvent -import com.google.android.gms.wearable.NodeClient +import com.example.samplewearmobileapp.models.WatchControl +import com.example.samplewearmobileapp.trackers.PpgContinuousListener +import com.example.samplewearmobileapp.utils.TimestampHelper +import com.google.android.gms.common.api.GoogleApiClient +import com.google.android.gms.wearable.MessageApi import com.google.android.gms.wearable.PutDataRequest import com.google.android.gms.wearable.Wearable import com.google.gson.Gson import com.samsung.android.service.health.tracking.HealthTrackerException -import kotlinx.coroutines.launch -import kotlinx.coroutines.tasks.await - -/** - * Main activity for the Wear OS module. - * - * Responsibilities: - * - Manages Samsung Health sensor lifecycle via [ConnectionManager]. - * - Communicates with the mobile module using the modern Wearable Data Layer - * ([MessageClient], [DataClient], [NodeClient]). - * - Batches PPG readings and sends them to the phone for plotting. - * - * **SOLID compliance:** - * - SRP: sensor-specific logic is in [PpgGreenListener] / [PpgIrListener] / - * [PpgRedListener]; this Activity only orchestrates. - * - OCP: new message paths can be added to [onMessageArrived] without - * modifying existing branches. - * - DIP: depends on [ConnectionObserver], [TrackerDataObserver], [PermissionManager.PermissionCallback]. - */ -class MainActivity : - FragmentActivity(), - MessageClient.OnMessageReceivedListener, - AmbientModeSupport.AmbientCallbackProvider, - PermissionManager.PermissionCallback -{ - private val tag = "Wear:MainActivity" - - // ------------------------------------------------------------------------- - // View binding - // ------------------------------------------------------------------------- +import java.util.ArrayDeque +class MainActivity : FragmentActivity(), GoogleApiClient.ConnectionCallbacks, + AmbientModeSupport.AmbientCallbackProvider { private lateinit var binding: ActivityMainBinding - private lateinit var ambientController: AmbientModeSupport.AmbientController - - // ------------------------------------------------------------------------- - // Permission manager (SRP — all permission logic lives here) - // ------------------------------------------------------------------------- - - private lateinit var permissionManager: PermissionManager - - // ------------------------------------------------------------------------- - // Wearable Data Layer (modern API — replaces deprecated GoogleApiClient) - // ------------------------------------------------------------------------- - - private lateinit var messageClient: MessageClient - private lateinit var dataClient: DataClient - private lateinit var nodeClient: NodeClient - - // ------------------------------------------------------------------------- - // Samsung Health SDK - // ------------------------------------------------------------------------- - + private lateinit var client: GoogleApiClient private lateinit var connectionManager: ConnectionManager - private lateinit var ppgGreenListener: PpgGreenListener - private lateinit var ppgIrListener: PpgIrListener - private lateinit var ppgRedListener: PpgRedListener - private var isSamsungHealthConnected = false - - // ------------------------------------------------------------------------- - // Session state - // ------------------------------------------------------------------------- - + private val mainHandler = Handler(Looper.getMainLooper()) private val gson = Gson() - private var currentMessage: Message? = null - private var currentState = 0 - - private var currentPpgGreenDataNumber = 0 - private var currentPpgIrDataNumber = 0 - private var currentPpgRedDataNumber = 0 - - private var ppgGreenRecording = PpgRecording(PpgType.PPG_GREEN) - private var ppgIrRecording = PpgRecording(PpgType.PPG_IR) - private var ppgRedRecording = PpgRecording(PpgType.PPG_RED) - - // ------------------------------------------------------------------------- - // UI references (bound in onCreate after setContentView) - // ------------------------------------------------------------------------- - - private lateinit var textStatus: TextView - private lateinit var textTip: TextView - - private lateinit var ppgGreenContainer: LinearLayout - private lateinit var textPpgGreen: TextView - private lateinit var textPpgGreenStatus: TextView - private lateinit var textPpgGreenNumber: TextView - private lateinit var textPpgGreenTimestamp: TextView - - private lateinit var ppgIrContainer: LinearLayout - private lateinit var textPpgIr: TextView - private lateinit var textPpgIrStatus: TextView - private lateinit var textPpgIrNumber: TextView - private lateinit var textPpgIrTimestamp: TextView - - private lateinit var ppgRedContainer: LinearLayout - private lateinit var textPpgRed: TextView - private lateinit var textPpgRedStatus: TextView - private lateinit var textPpgRedNumber: TextView - private lateinit var textPpgRedTimestamp: TextView - - // ------------------------------------------------------------------------- - // Samsung Health observer — receives sensor data from listener threads - // ------------------------------------------------------------------------- - - private val trackerDataObserver: TrackerDataObserver = object : TrackerDataObserver { - - override fun onPpgGreenTrackerDataChanged(ppgGreenData: PpgGreenData) { - Log.i(tag, "PPG Green status: ${ppgGreenData.status}") - when (ppgGreenData.status) { - PpgGreenStatus.PPG_GREEN_STATUS_GOOD.code -> { - currentPpgGreenDataNumber++ - runOnUiThread { - textPpgGreenStatus.text = getString(R.string.status_measuring) - textPpgGreen.text = ppgGreenData.ppgValue.toString() - textPpgGreenTimestamp.text = ppgGreenData.timestamp.toString() - textPpgGreenNumber.text = currentPpgGreenDataNumber.toString() - } - } - PpgGreenStatus.PPG_GREEN_STATUS_NONE.code -> { - Log.i(tag, "No PPG Green data this tick") - } - } - ppgGreenRecording.add(ppgGreenData.ppgValue, ppgGreenData.timestamp) - if (currentPpgGreenDataNumber % PPG_GREEN_BATCH_SIZE == 0) { - sendPpgData(ppgGreenRecording) - ppgGreenRecording.clearFromStartUntil(PPG_GREEN_BATCH_SIZE) - } + private var currentState = ActivityCode.DO_NOTHING + private var sessionId = 0L + private var batchNumber = 0L + private var destroying = false + private val control = WatchControl() + private val safetyTimeout = Runnable { + if (!destroying && currentState == ActivityCode.START_ACTIVITY && + control.isExpired(SystemClock.elapsedRealtime())) { + stopSession(ActivityCode.PAUSE_ACTIVITY) + binding.statusMsg.text = "Controller unavailable; sensors paused" } - - override fun onPpgIrTrackerDataChanged(ppgIrData: PpgIrData) { - currentPpgIrDataNumber++ - runOnUiThread { - textPpgIrStatus.text = getString(R.string.status_measuring) - textPpgIr.text = ppgIrData.ppgValue.toString() - textPpgIrTimestamp.text = ppgIrData.timestamp.toString() - textPpgIrNumber.text = currentPpgIrDataNumber.toString() - } - ppgIrRecording.add(ppgIrData.ppgValue, ppgIrData.timestamp) - if (currentPpgIrDataNumber % PPG_IR_RED_BATCH_SIZE == 0) { - sendPpgData(ppgIrRecording) - ppgIrRecording.clearFromStartUntil(PPG_IR_RED_BATCH_SIZE) + } + private val releaseRetry = Runnable { stopSession(currentState) } + private val recordings = PpgType.values().associateWith { PpgRecording(it) } + private val counts = PpgType.values().associateWith { 0 }.toMutableMap() + private val ppgListener = PpgContinuousListener(::recordPpg, ::showPpgError) + private var ppgAvailable = false + private var sensorManager: SensorManager? = null + private var accelerometer: Sensor? = null + private var isAccTracking = false + private var isAccRegistered = false + private var accCount = 0 + private var accUnixAnchorMs = 0L + private var accElapsedAnchorNanos = 0L + private val accRecording = AccelerometerRecording() + private val uploads = ArrayDeque>() + private var uploadInFlight = false + + private val accelerometerListener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + if (!isAccTracking || event.sensor.type != Sensor.TYPE_ACCELEROMETER || + event.values.size < 3 || event.values.take(3).any { !it.isFinite() }) return + val timestamp = TimestampHelper.elapsedNanosToUnixMillis( + event.timestamp, accElapsedAnchorNanos, accUnixAnchorMs + ) + if (accRecording.timestamps.lastOrNull()?.let { timestamp <= it } == true) return + accRecording.add(event.values[0], event.values[1], event.values[2], timestamp) + accCount++ + if (accRecording.getSize() >= Constants.ACCELEROMETER_BATCH_SIZE) { + sendAccelerometerData(Constants.ACCELEROMETER_BATCH_SIZE) + updateAccelerometerStatus() } } - override fun onPpgRedTrackerDataChanged(ppgRedData: PpgRedData) { - currentPpgRedDataNumber++ - runOnUiThread { - textPpgRedStatus.text = getString(R.string.status_measuring) - textPpgRed.text = ppgRedData.ppgValue.toString() - textPpgRedTimestamp.text = ppgRedData.timestamp.toString() - textPpgRedNumber.text = currentPpgRedDataNumber.toString() - } - ppgRedRecording.add(ppgRedData.ppgValue, ppgRedData.timestamp) - if (currentPpgRedDataNumber % PPG_IR_RED_BATCH_SIZE == 0) { - sendPpgData(ppgRedRecording) - ppgRedRecording.clearFromStartUntil(PPG_IR_RED_BATCH_SIZE) - } - } + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit + } - override fun onError(errorResourceId: Int) { - runOnUiThread { - Toast.makeText(applicationContext, getString(errorResourceId), Toast.LENGTH_LONG).show() + private val messageListener = MessageApi.MessageListener { event -> + val path = event.path + val bytes = event.data.copyOf() + runOnUiThread { + if (!destroying) { + try { + val message = gson.fromJson(String(bytes, Charsets.UTF_8), Message::class.java) + if (message != null) onMessageArrived(path, message) + } catch (error: RuntimeException) { + Log.w(TAG, "Rejected malformed control message", error) + } } } } - // ------------------------------------------------------------------------- - // Samsung Health connection observer - // ------------------------------------------------------------------------- - - private val connectionObserver: ConnectionObserver = object : ConnectionObserver { - + private val connectionObserver = object : ConnectionObserver { override fun onConnectionResult(stringResourceId: Int) { - if (stringResourceId != R.string.ConnectedToHs) { - runOnUiThread { - Toast.makeText(applicationContext, getString(stringResourceId), Toast.LENGTH_LONG).show() + runOnUiThread { + if (destroying) return@runOnUiThread + if (stringResourceId != R.string.ConnectedToHs) { + ppgAvailable = false + ppgListener.stopTracker() + showPpgError(stringResourceId) + return@runOnUiThread + } + try { + ppgAvailable = connectionManager.initPpg(ppgListener) + if (currentState == ActivityCode.START_ACTIVITY) startPpg() + if (!ppgAvailable) showPpgError(R.string.NoContinuousPpgSupport) + } catch (error: RuntimeException) { + ppgAvailable = false + Log.e(TAG, "PPG capability setup failed", error) + showPpgError(R.string.ConnectionError) } - return } - isSamsungHealthConnected = true - TrackerDataNotifier.instance?.addObserver(trackerDataObserver) - ppgGreenListener = PpgGreenListener() - ppgIrListener = PpgIrListener() - ppgRedListener = PpgRedListener() - connectionManager.initPpgGreen(ppgGreenListener) - connectionManager.initPpgIr(ppgIrListener) - connectionManager.initPpgRed(ppgRedListener) } - override fun onError(e: HealthTrackerException?) { - if (e == null) return - val messageRes = when { - e.errorCode == HealthTrackerException.OLD_PLATFORM_VERSION || - e.errorCode == HealthTrackerException.PACKAGE_NOT_INSTALLED -> - R.string.HealthPlatformVersionIsOutdated - else -> R.string.ConnectionError - } + override fun onError(error: HealthTrackerException?) { runOnUiThread { - Toast.makeText(applicationContext, getString(messageRes), Toast.LENGTH_LONG).show() - } - if (e.hasResolution()) { - e.resolve(this@MainActivity) - } else { - Log.e(tag, "Samsung Health connection error (no resolution): ${e.message}") - finish() + ppgAvailable = false + ppgListener.stopTracker() + showPpgError(R.string.ConnectionError) + if (error?.hasResolution() == true) error.resolve(this@MainActivity) } } } - // ------------------------------------------------------------------------- - // Ambient mode - // ------------------------------------------------------------------------- - - private class WearAmbientCallback : AmbientModeSupport.AmbientCallback() { - override fun onEnterAmbient(ambientDetails: Bundle?) { - Log.i("Wear:Ambient", "Entered ambient mode") - } - override fun onExitAmbient() { - Log.i("Wear:Ambient", "Exited ambient mode") - } - } - - override fun getAmbientCallback(): AmbientModeSupport.AmbientCallback = WearAmbientCallback() - - // ------------------------------------------------------------------------- - // Activity lifecycle - // ------------------------------------------------------------------------- - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - Log.d(tag, "onCreate") - binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) - ambientController = AmbientModeSupport.attach(this) - - bindViews() - resetUi() - - // Initialise modern Wearable Data Layer clients - messageClient = Wearable.getMessageClient(this) - dataClient = Wearable.getDataClient(this) - nodeClient = Wearable.getNodeClient(this) - - // Delegate all permission logic to PermissionManager (SRP) - permissionManager = PermissionManager(this, this) - permissionManager.startFlow() - } - - override fun onResume() { - super.onResume() - // Register for Wearable messages while the Activity is in the foreground - messageClient.addListener(this) - Log.d(tag, "MessageClient listener registered") - - // On resume, prompt the phone to reset its state (in case the watch - // was restarted mid-session) - val resetMsg = Message(Entity.WEAR_APP, ActivityCode.STOP_ACTIVITY).apply { - content = "Reset Phone App State" + getPreferences(MODE_PRIVATE).let { saved -> + control.accept(saved.getLong("session", 0L), saved.getLong("revision", 0L), + saved.getInt("state", ActivityCode.DO_NOTHING), SystemClock.elapsedRealtime()) } - sendMessageToPhone(resetMsg, MessagePath.COMMAND) + AmbientModeSupport.attach(this) + sensorManager = getSystemService(SensorManager::class.java) + accelerometer = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + connectionManager = ConnectionManager(connectionObserver) + client = GoogleApiClient.Builder(this) + .addConnectionCallbacks(this).addApi(Wearable.API).build() + client.connect() + updateAccelerometerStatus() + if (hasBodySensorPermission()) connectSamsung() + else ActivityCompat.requestPermissions(this, + if (Build.VERSION.SDK_INT >= 34) arrayOf(Manifest.permission.BODY_SENSORS, Manifest.permission.ACTIVITY_RECOGNITION) + else arrayOf(Manifest.permission.BODY_SENSORS), REQUEST_PERMISSIONS) } - override fun onPause() { - super.onPause() - messageClient.removeListener(this) - Log.d(tag, "MessageClient listener removed") - } - - override fun onDestroy() { - Log.d(tag, "onDestroy") - if (isSamsungHealthConnected) { - ppgGreenListener.stopTracker() - ppgIrListener.stopTracker() - ppgRedListener.stopTracker() - TrackerDataNotifier.instance?.removeObserver(trackerDataObserver) - connectionManager.disconnect() + private fun connectSamsung() { + try { + connectionManager.connect(applicationContext) + } catch (error: RuntimeException) { + Log.e(TAG, "Samsung service unavailable", error) + showPpgError(R.string.ConnectionError) } - super.onDestroy() } - // ------------------------------------------------------------------------- - // Permission handling — delegated to PermissionManager (SRP) - // ------------------------------------------------------------------------- - - override fun onRequestPermissionsResult( - requestCode: Int, - permissions: Array, - grantResults: IntArray - ) { + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) - permissionManager.onPermissionResult(requestCode, permissions, grantResults) - } - - // -- PermissionManager.PermissionCallback -- - - /** All required permissions granted; bootstrap Samsung Health. */ - override fun onAllPermissionsGranted() { - Log.d(tag, "All permissions granted — connecting to Samsung Health") - createConnectionManager() - } - - /** - * User denied at least one permission but can still be asked again. - * Show a rationale toast; do NOT close the app. - */ - override fun onPermissionsDenied() { - Log.w(tag, "Permissions denied — showing rationale") - Toast.makeText(this, getString(R.string.permission_rationale), Toast.LENGTH_LONG).show() - } - - /** - * Permission permanently denied or background sensor needs manual Settings toggle. - * Opens the app's Settings page so the user can grant it manually. - */ - override fun onPermissionsPermanentlyDenied() { - Log.w(tag, "Permissions permanently denied — opening Settings") - Toast.makeText(this, getString(R.string.permission_settings_guide), Toast.LENGTH_LONG).show() - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", packageName, null) + if (requestCode == REQUEST_PERMISSIONS) { + if (hasBodySensorPermission()) connectSamsung() + else showPpgError(R.string.NoPermission) } - startActivity(intent) } - // ------------------------------------------------------------------------- - // MessageClient.OnMessageReceivedListener — receives from phone - // ------------------------------------------------------------------------- - - /** - * Called on a background thread by the Wearable Data Layer whenever - * the phone sends a message to this watch app. - */ - override fun onMessageReceived(messageEvent: MessageEvent) { - currentMessage = gson.fromJson(String(messageEvent.data), Message::class.java) - onMessageArrived(messageEvent.path) + override fun onConnected(bundle: Bundle?) { + Wearable.MessageApi.addListener(client, messageListener) + sendState() + drainUploads() } - // ------------------------------------------------------------------------- - // Message dispatch - // ------------------------------------------------------------------------- - - private fun onMessageArrived(messagePath: String) { - if (!isSamsungHealthConnected) { - Log.w(tag, "onMessageArrived: Samsung Health not ready, ignoring '$messagePath'") - return - } - currentMessage?.let { msg -> - when (messagePath) { - MessagePath.COMMAND -> handleCommandMessage(msg) - MessagePath.REQUEST -> handleRequestMessage(msg) - MessagePath.INFO -> Log.d(tag, "INFO path received (not yet implemented)") - MessagePath.DATA_PPG_GREEN -> handlePpgMessage(msg, ppgGreenListener, - showContainer = { showPpgGreenContainer() }, - stopStatus = { setPpgGreenStatus(R.string.status_stopped) }) - MessagePath.DATA_PPG_IR -> handlePpgMessage(msg, ppgIrListener, - showContainer = { showPpgIrContainer() }, - stopStatus = { setPpgIrStatus(R.string.status_stopped) }) - MessagePath.DATA_PPG_RED -> handlePpgMessage(msg, ppgRedListener, - showContainer = { showPpgRedContainer() }, - stopStatus = { setPpgRedStatus(R.string.status_stopped) }) - else -> Log.w(tag, "Unknown message path: $messagePath") - } - } + override fun onConnectionSuspended(code: Int) { + Log.w(TAG, "Wearable transport suspended; unsent batches retained") } - // ------------------------------------------------------------------------- - // Command / Request handlers (DRY: each branch is a single function call) - // ------------------------------------------------------------------------- - - private fun handleCommandMessage(msg: Message) { - when (msg.code) { - ActivityCode.START_ACTIVITY -> { - runOnUiThread { - ppgGreenContainer.visibility = View.VISIBLE - ppgIrContainer.visibility = View.VISIBLE - ppgRedContainer.visibility = View.VISIBLE - textTip.visibility = View.GONE - textStatus.text = getString(R.string.status_running) + private fun onMessageArrived(path: String, message: Message) { + when (path) { + MessagePath.COMMAND -> { + if (!control.accept(message.sessionId, message.commandRevision, message.code, + SystemClock.elapsedRealtime())) { + sendState() + return } - switchState(ActivityCode.START_ACTIVITY) - startAllTrackers() - } - ActivityCode.STOP_ACTIVITY -> { - flushRemainingData() - runOnUiThread { - textStatus.text = getString(R.string.status_stopped) - textPpgGreenStatus.text = getString(R.string.status_stopped) - textPpgIrStatus.text = getString(R.string.status_stopped) - textPpgRedStatus.text = getString(R.string.status_stopped) + persistControl() + mainHandler.removeCallbacks(safetyTimeout) + if (message.code == ActivityCode.START_ACTIVITY) { + startSession(message.sessionId) + mainHandler.postDelayed(safetyTimeout, WatchControl.TIMEOUT_MS) + } else { + // End must also be acknowledged when its Start was lost in transit. + if (message.sessionId != sessionId) { + stopSession(message.code) + if (ppgListener.isRegistered() || isAccRegistered) return + resetSession(message.sessionId) + } + stopSession(message.code) } - switchState(ActivityCode.STOP_ACTIVITY) - stopAllTrackers() } - ActivityCode.PAUSE_ACTIVITY -> { - runOnUiThread { - textStatus.text = getString(R.string.status_paused) - textPpgGreenStatus.text = getString(R.string.status_paused) - textPpgIrStatus.text = getString(R.string.status_paused) - textPpgRedStatus.text = getString(R.string.status_paused) + MessagePath.REQUEST -> sendState() + MessagePath.INFO -> Unit + MessagePath.DATA_PPG_GREEN, MessagePath.DATA_PPG_IR, MessagePath.DATA_PPG_RED -> { + if (currentState != ActivityCode.START_ACTIVITY || message.sessionId != sessionId) return + val type = when (path) { + MessagePath.DATA_PPG_GREEN -> PpgType.PPG_GREEN + MessagePath.DATA_PPG_IR -> PpgType.PPG_IR + else -> PpgType.PPG_RED } - stopAllTrackers() - switchState(ActivityCode.PAUSE_ACTIVITY) - } - ActivityCode.DO_NOTHING -> { - runOnUiThread { - Toast.makeText(applicationContext, getString(R.string.no_content), Toast.LENGTH_LONG).show() + if (message.code == ActivityCode.STOP_ACTIVITY || + (message.extraCode == TOGGLE_ACTIVITY && type in ppgListener.channels)) { + ppgListener.channels.remove(type) + sendPpgData(type, recordings.getValue(type).getSize() ?: 0) + } else if (message.code == ActivityCode.START_ACTIVITY) { + ppgListener.channels.add(type) } + if (ppgListener.channels.isEmpty()) ppgListener.stopTracker() else startPpg() + updatePpgStatuses() + sendState() } } } - private fun handleRequestMessage(msg: Message) { - if (msg.code == ActivityCode.START_ACTIVITY) { - Log.i(tag, "State request received from phone") - switchState(currentState) + private fun startSession(id: Long) { + if (id <= 0L) { + Toast.makeText(this, R.string.UpdateBothApps, Toast.LENGTH_LONG).show() + return } - } - - /** - * DRY handler for individual PPG channel messages. - * All three channels (Green, IR, Red) share identical start/stop/toggle logic. - */ - private fun handlePpgMessage( - msg: Message, - listener: Listener, - showContainer: () -> Unit, - stopStatus: () -> Unit - ) { - when (msg.code) { - ActivityCode.START_ACTIVITY -> { - runOnUiThread { - showContainer() - textTip.visibility = View.GONE - textStatus.text = getString(R.string.status_running) - } - if (msg.extraCode == TOGGLE_ACTIVITY) { - toggleTracker(listener) - invalidateAppStatus() - } else { - startTracker(listener) - } + if (id == sessionId && currentState == ActivityCode.START_ACTIVITY) { + sendState() + return + } + mainHandler.removeCallbacks(releaseRetry) + if (id != sessionId) { + if (!stopTrackers()) { + stopSession(ActivityCode.PAUSE_ACTIVITY) + return } - ActivityCode.STOP_ACTIVITY -> { - stopTracker(listener) - runOnUiThread { stopStatus() } - invalidateAppStatus() + flushRemainingData() + resetSession(id) + } else if (ppgListener.isRegistered() || isAccRegistered) { + if (!stopTrackers()) { + stopSession(ActivityCode.PAUSE_ACTIVITY) + return } } + try { + MainService.startService(this, "Recording sensors") + } catch (error: RuntimeException) { + Log.e(TAG, "Foreground recording service could not start", error) + binding.statusMsg.setText(R.string.status_failed) + currentState = ActivityCode.STOP_ACTIVITY + sendState() + return + } + currentState = ActivityCode.START_ACTIVITY + startAccelerometerTracker() + startPpg() + binding.statusMsg.setText(R.string.status_running) + updatePpgStatuses() + sendState() } - // ------------------------------------------------------------------------- - // Tracker management - // ------------------------------------------------------------------------- - - private fun startAllTrackers() { - startTracker(ppgGreenListener) - startTracker(ppgIrListener) - startTracker(ppgRedListener) + private fun resetSession(id: Long) { + recordings.values.forEach { it.clear() } + counts.keys.forEach { counts[it] = 0 } + accRecording.clear() + accCount = 0 + sessionId = id + batchNumber = 0L + ppgListener.channels.clear() + ppgListener.channels.addAll(PpgType.values()) } - private fun stopAllTrackers() { - ppgGreenListener.stopTracker() - ppgIrListener.stopTracker() - ppgRedListener.stopTracker() + private fun persistControl() { + getPreferences(MODE_PRIVATE).edit().putLong("session", control.sessionId) + .putLong("revision", control.revision).putInt("state", control.code).apply() } - private fun startTracker(listener: Listener) { - listener.startTracker() - MainService.startService(this, "Tracker is running...") + private fun stopSession(state: Int) { + mainHandler.removeCallbacks(safetyTimeout) + mainHandler.removeCallbacks(releaseRetry) + currentState = state + val released = stopTrackers() + flushRemainingData() + binding.statusMsg.setText(if (!released) R.string.status_failed + else if (state == ActivityCode.PAUSE_ACTIVITY) R.string.status_paused else R.string.status_stopped) + updatePpgStatuses() + updateAccelerometerStatus() + sendState() + // Release the wake lock immediately, including while final uploads are pending. + if (state == ActivityCode.PAUSE_ACTIVITY || uploads.isNotEmpty() || !released) { + MainService.startService(this, "Paused") + } else MainService.stopService(this) + if (!released) mainHandler.postDelayed(releaseRetry, 1_000L) } - private fun stopTracker(listener: Listener) { - listener.stopTracker() + private fun startAccelerometerTracker() { + if (isAccTracking) return + accUnixAnchorMs = System.currentTimeMillis() + accElapsedAnchorNanos = SystemClock.elapsedRealtimeNanos() + isAccTracking = try { + accelerometer != null && sensorManager?.registerListener( + accelerometerListener, accelerometer, 40_000 + ) == true + } catch (error: RuntimeException) { + Log.e(TAG, "Accelerometer registration failed", error) + false + } + isAccRegistered = isAccTracking + updateAccelerometerStatus() } - private fun toggleTracker(listener: Listener) { - if (listener.isTracking()) listener.stopTracker() - else startTracker(listener) + private fun startPpg() { + if (ppgAvailable && hasBodySensorPermission() && ppgListener.channels.isNotEmpty()) { + ppgListener.startTracker() + } } - /** - * If all trackers have stopped, resets the UI status labels and - * stops the foreground [MainService]. - * - * @return `true` if invalidation occurred (all stopped). - */ - private fun invalidateAppStatus(): Boolean { - val allStopped = !ppgGreenListener.isTracking() && - !ppgIrListener.isTracking() && - !ppgRedListener.isTracking() - if (allStopped) { - runOnUiThread { - textStatus.text = getString(R.string.status_stopped) - textPpgGreenStatus.text = getString(R.string.status_stopped) - textPpgIrStatus.text = getString(R.string.status_stopped) - textPpgRedStatus.text = getString(R.string.status_stopped) - } - MainService.stopService(this) + private fun stopTrackers(): Boolean { + val ppgReleased = ppgListener.stopTracker() + isAccTracking = false + try { + sensorManager?.unregisterListener(accelerometerListener) + isAccRegistered = false + } catch (error: RuntimeException) { + Log.e(TAG, "Accelerometer release failed; retained for retry", error) } - return allStopped + return ppgReleased && !isAccRegistered } - // ------------------------------------------------------------------------- - // Data transmission — modern DataClient (replaces deprecated DataApi) - // ------------------------------------------------------------------------- - - /** - * Serialises a [PpgRecording] window and sends it to the phone - * via the Wearable Data Layer [DataClient]. - * - * @param recording The buffer to send. - * @param actualSize If non-null, overrides the default batch size - * (used for partial flushes on stop). - */ - private fun sendPpgData(recording: PpgRecording, actualSize: Int? = null) { - val (path, windowSize) = when (recording.ppgType) { - PpgType.PPG_GREEN -> MessagePath.DATA_PPG_GREEN to (actualSize ?: PPG_GREEN_BATCH_SIZE) - PpgType.PPG_IR -> MessagePath.DATA_PPG_IR to (actualSize ?: PPG_IR_RED_BATCH_SIZE) - PpgType.PPG_RED -> MessagePath.DATA_PPG_RED to (actualSize ?: PPG_IR_RED_BATCH_SIZE) + private fun recordPpg(type: PpgType, value: Int, timestamp: Long) { + if (currentState != ActivityCode.START_ACTIVITY) return + val recording = recordings.getValue(type) + if (recording.timestamps.lastOrNull()?.let { timestamp <= it } == true) return + recording.add(value, timestamp) + counts[type] = counts.getValue(type) + 1 + val fields = when (type) { + PpgType.PPG_GREEN -> listOf(binding.ppgGreen, binding.ppgGreenTimestamp, binding.ppgGreenNumber, binding.ppgGreenStatus) + PpgType.PPG_IR -> listOf(binding.ppgIr, binding.ppgIrTimestamp, binding.ppgIrNumber, binding.ppgIrStatus) + PpgType.PPG_RED -> listOf(binding.ppgRed, binding.ppgRedTimestamp, binding.ppgRedNumber, binding.ppgRedStatus) } - - val ppgData = PpgData(windowSize, recording.ppgType).apply { - for (i in 0 until windowSize) { - ppgValues[i] = recording.values[i] - timestamps[i] = recording.timestamps[i] - size++ - } + fields[0].text = value.toString() + fields[1].text = timestamp.toString() + fields[2].text = counts.getValue(type).toString() + fields[3].setText(R.string.status_measuring) + if ((recording.getSize() ?: 0) >= Constants.PPG_GREEN_BATCH_SIZE) { + sendPpgData(type, Constants.PPG_GREEN_BATCH_SIZE) } + } - val bytes = gson.toJson(ppgData).toByteArray() - lifecycleScope.launch { - runCatching { - dataClient.putDataItem( - PutDataRequest.create(path).setData(bytes).setUrgent() - ).await() - }.onFailure { e -> - Log.e(tag, "sendPpgData failed for $path: ${e.message}") - } + private fun sendPpgData(type: PpgType, size: Int) { + if (size <= 0) return + val recording = recordings.getValue(type) + val batch = PpgData( + recording.values.take(size).toIntArray(), recording.timestamps.take(size).toLongArray(), + type, size, size, sessionId + ) + val path = when (type) { + PpgType.PPG_GREEN -> MessagePath.DATA_PPG_GREEN + PpgType.PPG_IR -> MessagePath.DATA_PPG_IR + PpgType.PPG_RED -> MessagePath.DATA_PPG_RED } - Log.i(tag, "PPG Data sent via DataClient — path=$path size=$windowSize") + queueBatch(path, batch) + recording.clearFromStartUntil(size) + } + + private fun sendAccelerometerData(size: Int) { + if (size <= 0) return + val batch = AccelerometerData( + accRecording.xValues.take(size).toFloatArray(), accRecording.yValues.take(size).toFloatArray(), + accRecording.zValues.take(size).toFloatArray(), accRecording.timestamps.take(size).toLongArray(), + size, size, sessionId + ) + queueBatch(MessagePath.DATA_ACCELEROMETER, batch) + accRecording.clearFromStartUntil(size) } - /** - * Flushes any partial PPG data remaining in all three buffers. - * Called before stopping trackers to prevent data loss at the end - * of a recording session. - */ private fun flushRemainingData() { - listOf(ppgGreenRecording, ppgIrRecording, ppgRedRecording).forEach { recording -> - val remaining = recording.getSize() ?: 0 - if (remaining > 0) { - sendPpgData(recording, remaining) - recording.clearFromStartUntil(remaining) - Log.i(tag, "Flushed $remaining remaining ${recording.ppgType} data points") - } - } + for ((type, recording) in recordings) sendPpgData(type, recording.getSize() ?: 0) + sendAccelerometerData(accRecording.getSize()) } - // ------------------------------------------------------------------------- - // Message transmission — modern MessageClient (replaces deprecated MessageApi) - // ------------------------------------------------------------------------- - - /** - * Sends a [Message] to all connected phone nodes using [MessageClient]. - * - * Runs in a coroutine on [lifecycleScope] so it never blocks the UI thread. - */ - private fun sendMessageToPhone(message: Message, path: String) { - lifecycleScope.launch { - runCatching { - val nodes = nodeClient.connectedNodes.await() - if (nodes.isEmpty()) { - Log.w(tag, "sendMessageToPhone: no connected nodes for path=$path") - return@launch - } - val bytes = gson.toJson(message).toByteArray() - nodes.forEach { node -> - messageClient.sendMessage(node.id, path, bytes).await() - Log.i(tag, "Message sent to phone — path=$path") - } - }.onFailure { e -> - Log.e(tag, "sendMessageToPhone failed: ${e.message}") + private fun queueBatch(path: String, batch: Any) { + uploads.addLast(Pair("$path/$sessionId/${batchNumber++}", gson.toJson(batch).toByteArray(Charsets.UTF_8))) + drainUploads() + } + + private fun drainUploads() { + if (uploadInFlight || !client.isConnected) return + if (uploads.isEmpty()) { + if ((currentState == ActivityCode.STOP_ACTIVITY || currentState == ActivityCode.DO_NOTHING) && + !ppgListener.isRegistered() && !isAccRegistered) { + MainService.stopService(this) } + if (destroying) client.disconnect() + return } + uploadInFlight = true + val batch = uploads.first + Wearable.DataApi.putDataItem(client, PutDataRequest.create(batch.first).setData(batch.second).setUrgent()) + .setResultCallback { result -> + uploadInFlight = false + if (result.status.isSuccess) { + uploads.removeFirst() + drainUploads() + } else { + Log.e(TAG, "Batch upload failed; retained for retry: ${result.status.statusCode}") + mainHandler.postDelayed({ drainUploads() }, 1000L) + } + } } - // ------------------------------------------------------------------------- - // State machine - // ------------------------------------------------------------------------- - - /** - * Sets [currentState] and notifies the phone of the new state via an - * INFO message. - * - * Supported codes: - * - [ActivityCode.DO_NOTHING] / 0 — Stopped - * - [ActivityCode.START_ACTIVITY] / 1 — Running - * - [ActivityCode.STOP_ACTIVITY] / 2 — Stopped - * - [ActivityCode.PAUSE_ACTIVITY] / 3 — Paused - * - * @param forceCode When provided, sets the state directly. - * When omitted (default), toggles between 0 and 1. - */ - private fun switchState(forceCode: Int = STATE_TOGGLE) { - currentState = when { - forceCode != STATE_TOGGLE -> forceCode - currentState == 0 -> ActivityCode.START_ACTIVITY - else -> ActivityCode.DO_NOTHING + private fun sendState() { + if (!client.isConnected) return + val mask = if (ppgListener.isTracking()) ppgListener.channels.sumOf { it.mask } else 0 + val state = Message(Entity.WEAR_APP, "Watch sensor state", currentState, mask, sessionId, + intArrayOf(counts.getValue(PpgType.PPG_GREEN), counts.getValue(PpgType.PPG_IR), + counts.getValue(PpgType.PPG_RED), accCount), control.revision, + !ppgListener.isRegistered() && !isAccRegistered) + val bytes = gson.toJson(state).toByteArray(Charsets.UTF_8) + Wearable.NodeApi.getConnectedNodes(client).setResultCallback { nodes -> + for (node in nodes.nodes) Wearable.MessageApi.sendMessage(client, node.id, MessagePath.INFO, bytes) } - val stateMessage = Message(Entity.WEAR_APP, currentState).apply { - content = "Wear state: $currentState" - } - sendMessageToPhone(stateMessage, MessagePath.INFO) - Log.d(tag, "State changed to: $currentState") } - // ------------------------------------------------------------------------- - // Samsung Health connection bootstrap - // ------------------------------------------------------------------------- - - private fun createConnectionManager() { - try { - connectionManager = ConnectionManager(connectionObserver) - connectionManager.connect(applicationContext) - } catch (t: Throwable) { - Log.e(tag, "Failed to create ConnectionManager: ${t.message ?: "unknown error"}") - } + private fun showPpgError(resource: Int) { + Toast.makeText(this, resource, Toast.LENGTH_LONG).show() + binding.ppgGreenStatus.setText(resource) + binding.ppgIrStatus.setText(resource) + binding.ppgRedStatus.setText(resource) + sendState() } - // ------------------------------------------------------------------------- - // UI helpers - // ------------------------------------------------------------------------- - - private fun bindViews() { - textStatus = binding.statusMsg - textTip = binding.message - - ppgGreenContainer = binding.ppgGreenContainer - textPpgGreenStatus = binding.ppgGreenStatus - textPpgGreenNumber = binding.ppgGreenNumber - textPpgGreen = binding.ppgGreen - textPpgGreenTimestamp = binding.ppgGreenTimestamp - - ppgIrContainer = binding.ppgIrContainer - textPpgIrStatus = binding.ppgIrStatus - textPpgIrNumber = binding.ppgIrNumber - textPpgIr = binding.ppgIr - textPpgIrTimestamp = binding.ppgIrTimestamp - - ppgRedContainer = binding.ppgRedContainer - textPpgRedStatus = binding.ppgRedStatus - textPpgRedNumber = binding.ppgRedNumber - textPpgRed = binding.ppgRed - textPpgRedTimestamp = binding.ppgRedTimestamp + private fun updatePpgStatuses() { + binding.ppgGreenContainer.visibility = View.VISIBLE + binding.ppgIrContainer.visibility = View.VISIBLE + binding.ppgRedContainer.visibility = View.VISIBLE + val statuses = listOf(binding.ppgGreenStatus, binding.ppgIrStatus, binding.ppgRedStatus) + for (type in PpgType.values()) { + statuses[type.ordinal].setText(when { + !ppgAvailable || !hasBodySensorPermission() -> R.string.NoContinuousPpgSupport + currentState == ActivityCode.PAUSE_ACTIVITY -> R.string.status_paused + currentState != ActivityCode.START_ACTIVITY || type !in ppgListener.channels -> R.string.status_stopped + else -> R.string.status_waiting + }) + } } - private fun resetUi() { - textStatus.text = getString(R.string.default_status) - textTip.text = getString(R.string.message_placeholder) - textPpgGreenStatus.text = getString(R.string.default_status) - textPpgGreenNumber.text = getString(R.string.default_value) - textPpgGreen.text = getString(R.string.default_value) - textPpgGreenTimestamp.text = getString(R.string.default_value) - textPpgIrStatus.text = getString(R.string.default_status) - textPpgIrNumber.text = getString(R.string.default_value) - textPpgIr.text = getString(R.string.default_value) - textPpgIrTimestamp.text = getString(R.string.default_value) - textPpgRedStatus.text = getString(R.string.default_status) - textPpgRedNumber.text = getString(R.string.default_value) - textPpgRed.text = getString(R.string.default_value) - textPpgRedTimestamp.text = getString(R.string.default_value) - - textTip.visibility = View.VISIBLE - ppgGreenContainer.visibility = View.GONE - ppgIrContainer.visibility = View.GONE - ppgRedContainer.visibility = View.GONE + private fun updateAccelerometerStatus() { + binding.message.visibility = View.VISIBLE + binding.message.text = when { + accelerometer == null -> getString(R.string.NoAccelerometerSupport) + currentState == ActivityCode.START_ACTIVITY && !isAccTracking -> getString(R.string.AccelerometerFailed) + else -> getString(R.string.accelerometer_samples, accCount) + } } - // DRY: per-container show helpers (used in handlePpgMessage lambdas) - private fun showPpgGreenContainer() { ppgGreenContainer.visibility = View.VISIBLE } - private fun showPpgIrContainer() { ppgIrContainer.visibility = View.VISIBLE } - private fun showPpgRedContainer() { ppgRedContainer.visibility = View.VISIBLE } + private fun hasBodySensorPermission() = ContextCompat.checkSelfPermission( + this, Manifest.permission.BODY_SENSORS + ) == PackageManager.PERMISSION_GRANTED - // DRY: per-channel stopped-status helpers - private fun setPpgGreenStatus(res: Int) { textPpgGreenStatus.text = getString(res) } - private fun setPpgIrStatus(res: Int) { textPpgIrStatus.text = getString(res) } - private fun setPpgRedStatus(res: Int) { textPpgRedStatus.text = getString(res) } + override fun onDestroy() { + destroying = true + control.accept(control.sessionId, control.revision + 1L, ActivityCode.STOP_ACTIVITY, + SystemClock.elapsedRealtime()) + persistControl() + stopSession(ActivityCode.STOP_ACTIVITY) + connectionManager.disconnect() + mainHandler.removeCallbacks(releaseRetry) + Wearable.MessageApi.removeListener(client, messageListener) + drainUploads() + if (uploads.isEmpty()) MainService.stopService(this) + super.onDestroy() + } - // ------------------------------------------------------------------------- - // Companion — constants - // ------------------------------------------------------------------------- + override fun getAmbientCallback() = object : AmbientModeSupport.AmbientCallback() {} companion object { - /** Sentinel value: when passed to [switchState], toggles 0↔1. */ - private const val STATE_TOGGLE = -1 + private const val TAG = "Wear: MainActivity" + private const val REQUEST_PERMISSIONS = 10 } } diff --git a/wear/src/main/java/com/example/samplewearmobileapp/MainService.kt b/wear/src/main/java/com/example/samplewearmobileapp/MainService.kt index 27b89d3..e1f998e 100644 --- a/wear/src/main/java/com/example/samplewearmobileapp/MainService.kt +++ b/wear/src/main/java/com/example/samplewearmobileapp/MainService.kt @@ -1,5 +1,6 @@ package com.example.samplewearmobileapp +import android.annotation.SuppressLint import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent @@ -7,106 +8,59 @@ import android.app.Service import android.content.Context import android.content.Intent import android.os.IBinder +import android.os.PowerManager import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat.startForegroundService +import androidx.core.content.ContextCompat -/** - * Foreground service that keeps the wear app alive during sensor recording. - * - * **Why a companion-object boolean for [isRunning]?** - * `ContextCompat.getSystemService(context, MainService::class.java)` always - * returns `null` for custom [Service] subclasses — it only works for system - * services (e.g. [NotificationManager]). The workaround is a `@Volatile` - * flag set in [onStartCommand] / [onDestroy]. - * - * **SOLID compliance:** - * - SRP: only responsibilities are starting/stopping the foreground notification. - * - Static helpers follow the same pattern as the mobile [MobileService]. - */ +/** Keeps the sensor process/CPU available; ownership ends on explicit stop or Activity destruction. */ class MainService : Service() { + private var wakeLock: PowerManager.WakeLock? = null - // ------------------------------------------------------------------------- - // Internal state - // ------------------------------------------------------------------------- - - private val channelId = "MainServiceChannel" - - // ------------------------------------------------------------------------- - // Service lifecycle - // ------------------------------------------------------------------------- + companion object { + private const val CHANNEL_ID = "MainService" + @Volatile var isRunning = false + private set - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - isRunning = true - createNotificationChannel() + fun startService(context: Context, message: String) { + ContextCompat.startForegroundService(context, + Intent(context, MainService::class.java).putExtra("message", message)) + } - val pendingIntent = Intent(this, MainActivity::class.java).let { notifyIntent -> - PendingIntent.getActivity( - this, 0, notifyIntent, - PendingIntent.FLAG_IMMUTABLE - ) + fun stopService(context: Context) { + context.stopService(Intent(context, MainService::class.java)) } + } - val notification = NotificationCompat.Builder(this, channelId) - .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.notification_tracking)) + // Session-scoped: release on Pause and onDestroy; a timeout would truncate an active recording. + @SuppressLint("WakelockTimeout") + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val message = intent?.getStringExtra("message") ?: "Recording sensors" + getSystemService(NotificationManager::class.java).createNotificationChannel( + NotificationChannel(CHANNEL_ID, "Watch recording", NotificationManager.IMPORTANCE_LOW)) + val pending = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE) + startForeground(1, NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Watch sensor recording") + .setContentText(message) .setSmallIcon(R.drawable.ic_notification) - .setContentIntent(pendingIntent) - .setOngoing(true) - .build() - - startForeground(NOTIFICATION_ID, notification) - return START_STICKY + .setContentIntent(pending) + .setOngoing(true).build()) + if (message == "Paused") { + if (wakeLock?.isHeld == true) wakeLock?.release() + } else { + if (wakeLock == null) wakeLock = getSystemService(PowerManager::class.java) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "HeartRateMonitor:WatchRecording") + if (wakeLock?.isHeld != true) wakeLock?.acquire() + } + isRunning = true + return START_NOT_STICKY } override fun onDestroy() { + if (wakeLock?.isHeld == true) wakeLock?.release() isRunning = false super.onDestroy() } override fun onBind(intent: Intent?): IBinder? = null - - // ------------------------------------------------------------------------- - // Notification channel - // ------------------------------------------------------------------------- - - private fun createNotificationChannel() { - val channel = NotificationChannel( - channelId, - "Wear Tracking Service", - NotificationManager.IMPORTANCE_LOW // LOW avoids sound on every update - ) - getSystemService(NotificationManager::class.java) - ?.createNotificationChannel(channel) - } - - // ------------------------------------------------------------------------- - // Companion — static helpers - // ------------------------------------------------------------------------- - - companion object { - private const val NOTIFICATION_ID = 1 - - /** - * `true` while [MainService] is actively running. - * - * Updated atomically in [onStartCommand] and [onDestroy]. - * Volatile ensures visibility across threads without locking. - */ - @Volatile - var isRunning: Boolean = false - private set - - /** Starts the foreground service from any [Context]. */ - fun startService(context: Context, message: String) { - val intent = Intent(context, MainService::class.java).apply { - putExtra("message", message) - } - startForegroundService(context, intent) - } - - /** Stops the foreground service from any [Context]. */ - fun stopService(context: Context) { - context.stopService(Intent(context, MainService::class.java)) - } - } -} \ No newline at end of file +} diff --git a/wear/src/main/java/com/example/samplewearmobileapp/PpgRecording.kt b/wear/src/main/java/com/example/samplewearmobileapp/PpgRecording.kt deleted file mode 100644 index dbfd384..0000000 --- a/wear/src/main/java/com/example/samplewearmobileapp/PpgRecording.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.example.samplewearmobileapp - -import com.example.samplewearmobileapp.models.PpgType - -/** - * Buffers PPG data points (value + timestamp) for batch transmission - * to the mobile module. - * - * @property ppgType The type of PPG data (Green, IR, or Red). - */ -class PpgRecording() { - val values = mutableListOf() - val timestamps = mutableListOf() - lateinit var ppgType: PpgType - - constructor(type: PpgType) : this() { - this.ppgType = type - } - - constructor(values: List, timestamps: List, type: PpgType) : this() { - this.values.addAll(values) - this.timestamps.addAll(timestamps) - this.ppgType = type - } - - /** Appends a single PPG value and its timestamp to the buffer. */ - fun add(value: Int, timestamp: Long) { - values.add(value) - timestamps.add(timestamp) - } - - /** - * Removes the first [count] elements from both buffers. - * - * @param count Number of elements to remove from the front. - */ - fun clearFromStartUntil(count: Int) { - for (i in 0 until count) { - values.removeFirst() - timestamps.removeFirst() - } - } - - /** - * Returns the current buffer size, or null if values and timestamps - * have become desynchronized (indicating a bug). - */ - fun getSize(): Int? { - if (values.size != timestamps.size) { - return null - } - return values.size - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/example/samplewearmobileapp/trackers/Listener.kt b/wear/src/main/java/com/example/samplewearmobileapp/trackers/Listener.kt index b0effd3..c7bc928 100644 --- a/wear/src/main/java/com/example/samplewearmobileapp/trackers/Listener.kt +++ b/wear/src/main/java/com/example/samplewearmobileapp/trackers/Listener.kt @@ -2,42 +2,19 @@ package com.example.samplewearmobileapp.trackers import android.os.Handler import android.util.Log +import com.example.samplewearmobileapp.models.TrackerLifecycle import com.samsung.android.service.health.tracking.HealthTracker import com.samsung.android.service.health.tracking.HealthTracker.TrackerEventListener +import com.samsung.android.service.health.tracking.data.DataPoint -/** - * Base class for all Samsung Health sensor listeners. - * - * Manages the lifecycle of a [HealthTracker] by coordinating a - * [Handler] (on the main looper) and a [TrackerEventListener]. - * - * **Usage pattern:** - * 1. Call [setHealthTracker] after obtaining one from [ConnectionManager]. - * 2. Call [setHandler] with a main-thread [Handler]. - * 3. Call [setTrackerEventListener] from the concrete subclass `init {}`. - * 4. Call [startTracker] / [stopTracker] in response to UX events. - * - * **SOLID compliance:** - * - SRP: handles only lifecycle; data parsing is in each concrete subclass. - * - OCP: subclasses extend behaviour via [setTrackerEventListener]. - */ open class Listener { + private val tag = "Listener Base Class" - private val tag = "Listener" + private lateinit var handler: Handler + private lateinit var healthTracker: HealthTracker + private val lifecycle = TrackerLifecycle() - private var handler: Handler? = null - private var healthTracker: HealthTracker? = null - private var trackerEventListener: TrackerEventListener? = null - - /** True while the handler has been posted and the tracker is active. */ - private var isHandlerRunning = false - - /** Public read-only tracking state. */ - private var isTracking = false - - // ------------------------------------------------------------------------- - // Configuration setters (called once, by ConnectionManager / subclass init) - // ------------------------------------------------------------------------- + private lateinit var trackerEventListener: TrackerEventListener fun setHealthTracker(tracker: HealthTracker) { healthTracker = tracker @@ -48,68 +25,67 @@ open class Listener { } fun setHandlerRunning(handlerRunning: Boolean) { - isHandlerRunning = handlerRunning + if (!handlerRunning) stopTracker() } - fun setTrackerEventListener(listener: TrackerEventListener) { - trackerEventListener = listener + fun setTrackerEventListener(tracker: TrackerEventListener) { + trackerEventListener = tracker } - // ------------------------------------------------------------------------- - // Lifecycle - // ------------------------------------------------------------------------- - - /** - * Starts the tracker by posting the [healthTracker.setEventListener] call - * to the main-thread [Handler]. - * - * Guards against starting twice and against calling before the tracker and - * handler have been configured. - */ - fun startTracker() { - val tracker = healthTracker - val h = handler - val listener = trackerEventListener - - if (tracker == null || h == null || listener == null) { - Log.e(tag, "startTracker: tracker, handler, or listener not initialised — aborting") - return - } - - if (isHandlerRunning) { - Log.w(tag, "startTracker: already running, ignoring duplicate call") - return + fun startTracker(): Boolean { + if (!::healthTracker.isInitialized || !::handler.isInitialized || + !::trackerEventListener.isInitialized) return false + return try { + lifecycle.start { + val registration = lifecycle.generation + // Bind callbacks to their registration, including callbacks delivered after Resume. + healthTracker.setEventListener(object : TrackerEventListener { + override fun onDataReceived(points: List) { + val copy = points.toList() + deliver(registration) { trackerEventListener.onDataReceived(copy) } + } + override fun onFlushCompleted() { + deliver(registration) { trackerEventListener.onFlushCompleted() } + } + override fun onError(error: HealthTracker.TrackerError) { + deliver(registration) { trackerEventListener.onError(error) } + } + }) + } + } catch (error: RuntimeException) { + stopTracker() + Log.e(tag, "Tracker registration failed", error) + onRegistrationFailed(error) + false } + } - h.post { - tracker.setEventListener(listener) - setHandlerRunning(true) + fun stopTracker(): Boolean { + if (::handler.isInitialized) handler.removeCallbacksAndMessages(null) + return try { + lifecycle.stop { healthTracker.unsetEventListener() } + } catch (error: RuntimeException) { + Log.w(tag, "Tracker release failed; Stop must retry", error) + false } - isTracking = true - Log.i(tag, "startTracker: started successfully") } - /** - * Stops the tracker and removes all pending handler callbacks. - * - * Safe to call even if the tracker was never started. - */ - fun stopTracker() { - val tracker = healthTracker - val h = handler + protected fun postTrackerData(block: () -> Unit) { + if (!::handler.isInitialized) return + deliver(lifecycle.generation, block) + } - if (!isHandlerRunning) { - Log.d(tag, "stopTracker: not running, nothing to stop") - return + private fun deliver(callbackGeneration: Long, block: () -> Unit) { + handler.post { + if (lifecycle.accepts(callbackGeneration)) block() } + } + + protected open fun onRegistrationFailed(error: RuntimeException) = Unit - tracker?.unsetEventListener() - h?.removeCallbacksAndMessages(null) - setHandlerRunning(false) - isTracking = false - Log.i(tag, "stopTracker: stopped successfully") + fun isTracking(): Boolean { + return lifecycle.requested } - /** Returns `true` while sensor data is actively being delivered. */ - fun isTracking(): Boolean = isTracking -} \ No newline at end of file + fun isRegistered(): Boolean = lifecycle.registered +} diff --git a/wear/src/main/java/com/example/samplewearmobileapp/trackers/PpgContinuousListener.kt b/wear/src/main/java/com/example/samplewearmobileapp/trackers/PpgContinuousListener.kt new file mode 100644 index 0000000..215630b --- /dev/null +++ b/wear/src/main/java/com/example/samplewearmobileapp/trackers/PpgContinuousListener.kt @@ -0,0 +1,55 @@ +package com.example.samplewearmobileapp.trackers + +import com.example.samplewearmobileapp.R +import com.example.samplewearmobileapp.models.PpgType +import com.samsung.android.service.health.tracking.HealthTracker +import com.samsung.android.service.health.tracking.data.DataPoint +import com.samsung.android.service.health.tracking.data.ValueKey + +/** One Samsung continuous tracker, three real channels at the SDK's 25 Hz. */ +class PpgContinuousListener( + private val onSample: (PpgType, Int, Long) -> Unit, + private val onFailure: (Int) -> Unit +) : Listener() { + val channels = mutableSetOf() + + override fun onRegistrationFailed(error: RuntimeException) { + onFailure(R.string.ConnectionError) + } + + init { + setTrackerEventListener(object : HealthTracker.TrackerEventListener { + override fun onDataReceived(points: List) { + postTrackerData { + for (point in points) { + if (point.timestamp <= 0L) continue + if (PpgType.PPG_GREEN in channels && point.getValue(ValueKey.PpgSet.GREEN_STATUS) == 0) { + onSample(PpgType.PPG_GREEN, point.getValue(ValueKey.PpgSet.PPG_GREEN), point.timestamp) + } + if (PpgType.PPG_IR in channels && point.getValue(ValueKey.PpgSet.IR_STATUS) == 0) { + onSample(PpgType.PPG_IR, point.getValue(ValueKey.PpgSet.PPG_IR), point.timestamp) + } + if (PpgType.PPG_RED in channels && point.getValue(ValueKey.PpgSet.RED_STATUS) == 0) { + onSample(PpgType.PPG_RED, point.getValue(ValueKey.PpgSet.PPG_RED), point.timestamp) + } + } + } + } + + override fun onFlushCompleted() = Unit + + override fun onError(error: HealthTracker.TrackerError) { + postTrackerData { + stopTracker() + onFailure(if (error == HealthTracker.TrackerError.PERMISSION_ERROR) { + R.string.NoPermission + } else if (error == HealthTracker.TrackerError.SDK_POLICY_ERROR) { + R.string.SdkPolicyError + } else { + R.string.ConnectionError + }) + } + } + }) + } +} diff --git a/wear/src/main/java/com/example/samplewearmobileapp/trackers/heartrate/HeartRateListener.kt b/wear/src/main/java/com/example/samplewearmobileapp/trackers/heartrate/HeartRateListener.kt deleted file mode 100644 index 91e844f..0000000 --- a/wear/src/main/java/com/example/samplewearmobileapp/trackers/heartrate/HeartRateListener.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.example.samplewearmobileapp.trackers.heartrate - -import android.util.Log -import com.example.samplewearmobileapp.R -import com.example.samplewearmobileapp.TrackerDataNotifier -import com.example.samplewearmobileapp.trackers.Listener -import com.samsung.android.service.health.tracking.HealthTracker -import com.samsung.android.service.health.tracking.data.DataPoint -import com.samsung.android.service.health.tracking.data.ValueKey - -/** - * Listener for Heart Rate data from the Samsung Health Sensor SDK. - * - * **SDK 1.4.1 note:** The following `ValueKey` names changed from SDK 1.2: - * - `ValueKey.HeartRateSet.STATUS` → `ValueKey.HeartRateSet.HEART_RATE_STATUS` - * - `ValueKey.HeartRateSet.HEART_RATE_IBI` → `ValueKey.HeartRateSet.IBI_LIST` - * (IBI is now returned as a List rather than a packed Int) - */ -class HeartRateListener internal constructor() : Listener() { - - private val tag = "HeartRateListener" - - init { - val trackerEventListener = object : HealthTracker.TrackerEventListener { - - override fun onDataReceived(list: List) { - for (dataPoint in list) { - readValuesFromDataPoint(dataPoint) - } - } - - override fun onFlushCompleted() { - Log.i(tag, "onFlushCompleted called") - } - - override fun onError(trackerError: HealthTracker.TrackerError) { - Log.e(tag, "onError called: $trackerError") - setHandlerRunning(false) - when (trackerError) { - HealthTracker.TrackerError.PERMISSION_ERROR -> - TrackerDataNotifier.instance?.notifyError(R.string.NoPermission) - HealthTracker.TrackerError.SDK_POLICY_ERROR -> - TrackerDataNotifier.instance?.notifyError(R.string.SdkPolicyError) - else -> Log.w(tag, "Unhandled TrackerError: $trackerError") - } - } - } - setTrackerEventListener(trackerEventListener) - } - - fun readValuesFromDataPoint(dataPoint: DataPoint) { - val hrData = HeartRateData() - - // SDK 1.4.1: HEART_RATE_STATUS replaces STATUS - hrData.status = dataPoint.getValue(ValueKey.HeartRateSet.HEART_RATE_STATUS) - hrData.hr = dataPoint.getValue(ValueKey.HeartRateSet.HEART_RATE) - - // SDK 1.4.1: IBI_LIST replaces the packed-Int HEART_RATE_IBI. - // We take the first IBI value if the list is non-empty. - val ibiList: List = dataPoint.getValue(ValueKey.HeartRateSet.IBI_LIST) - val rawIbi = ibiList.firstOrNull() ?: 0 - hrData.qIbi = (rawIbi shr HeartRateData.IBI_QUALITY_SHIFT) and HeartRateData.IBI_QUALITY_MASK - hrData.ibi = rawIbi and HeartRateData.IBI_MASK - - } -} diff --git a/wear/src/main/java/com/example/samplewearmobileapp/trackers/ppggreen/PpgGreenListener.kt b/wear/src/main/java/com/example/samplewearmobileapp/trackers/ppggreen/PpgGreenListener.kt index 317340b..5a99acd 100644 --- a/wear/src/main/java/com/example/samplewearmobileapp/trackers/ppggreen/PpgGreenListener.kt +++ b/wear/src/main/java/com/example/samplewearmobileapp/trackers/ppggreen/PpgGreenListener.kt @@ -22,7 +22,6 @@ class PpgGreenListener internal constructor() : Listener() { } } else { Log.i(tag, "onDataReceived List is empty") - readZeroValue() } } @@ -45,6 +44,7 @@ class PpgGreenListener internal constructor() : Listener() { } fun readValuesFromDataPoint(dataPoint: DataPoint) { + val ppgGreenData = PpgGreenData() ppgGreenData.status = PpgGreenStatus.PPG_GREEN_STATUS_GOOD.code ppgGreenData.ppgValue = dataPoint.getValue(ValueKey.PpgGreenSet.PPG_GREEN) @@ -53,13 +53,4 @@ class PpgGreenListener internal constructor() : Listener() { TrackerDataNotifier.instance?.notifyPpgGreenTrackerObservers(ppgGreenData) } - fun readZeroValue() { - val zeroPpgGreenData = PpgGreenData( - PpgGreenStatus.PPG_GREEN_STATUS_NONE.code, - 0, - 0 - ) - TrackerDataNotifier.instance?.notifyPpgGreenTrackerObservers(zeroPpgGreenData) - Log.d(tag, "Zero PPG Value notified") - } } diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml index 4dafb62..2da90df 100644 --- a/wear/src/main/res/values/strings.xml +++ b/wear/src/main/res/values/strings.xml @@ -56,12 +56,10 @@ SDK Policy denied Health Platform is outdated or not installed Could not connect to Health Tracking Service. Check error log. - - - Tracking sensor data… - - - Body sensor access is required to record PPG data. Please grant the permission when prompted. - Background sensor access is required. Please enable "Body sensors → Allow all the time" in Settings. - - \ No newline at end of file + Continuous PPG unavailable. Check Samsung Health Sensor service and permission. + Accelerometer unavailable on this Watch. + Accelerometer registration failed. + Install the current phone and Watch apps together. + Accelerometer: %1$d samples + Waiting for samples + diff --git a/wear/src/test/java/com/example/samplewearmobileapp/ListenerReadinessTest.kt b/wear/src/test/java/com/example/samplewearmobileapp/ListenerReadinessTest.kt new file mode 100644 index 0000000..03d0c18 --- /dev/null +++ b/wear/src/test/java/com/example/samplewearmobileapp/ListenerReadinessTest.kt @@ -0,0 +1,16 @@ +package com.example.samplewearmobileapp + +import com.example.samplewearmobileapp.trackers.Listener +import org.junit.Assert.* +import org.junit.Test + +class ListenerReadinessTest { + @Test + fun `missing Samsung connection cannot crash or report recording`() { + val listener = Listener() + assertFalse(listener.startTracker()) + listener.stopTracker() + listener.stopTracker() + assertFalse(listener.isTracking()) + } +} diff --git a/wear/src/test/java/com/example/samplewearmobileapp/RecordingBufferTest.kt b/wear/src/test/java/com/example/samplewearmobileapp/RecordingBufferTest.kt new file mode 100644 index 0000000..cc74de9 --- /dev/null +++ b/wear/src/test/java/com/example/samplewearmobileapp/RecordingBufferTest.kt @@ -0,0 +1,15 @@ +package com.example.samplewearmobileapp + +import org.junit.Assert.* +import org.junit.Test + +class RecordingBufferTest { + @Test + fun `continuous PPG metadata and buffers match the SDK 25 Hz contract`() { + assertEquals(25.0, Constants.PPG_GREEN_SAMPLE_RATE, 0.0) + assertEquals(25.0, Constants.PPG_IR_RED_SAMPLE_RATE, 0.0) + assertEquals(300, Constants.PPG_GREEN_BATCH_SIZE) + assertEquals(300, Constants.PPG_IR_RED_BATCH_SIZE) + assertEquals(100, Constants.ACCELEROMETER_BATCH_SIZE) + } +} diff --git a/wear/src/test/java/com/example/samplewearmobileapp/WearConstantsTest.kt b/wear/src/test/java/com/example/samplewearmobileapp/WearConstantsTest.kt index 01c6447..ff18324 100644 --- a/wear/src/test/java/com/example/samplewearmobileapp/WearConstantsTest.kt +++ b/wear/src/test/java/com/example/samplewearmobileapp/WearConstantsTest.kt @@ -16,8 +16,8 @@ class WearConstantsTest { } @Test - fun `PPG_IR_RED_SAMPLE_RATE is 100 Hz`() { - assertEquals(100.0, Constants.PPG_IR_RED_SAMPLE_RATE, 1e-10) + fun `PPG_IR_RED_SAMPLE_RATE is 25 Hz`() { + assertEquals(25.0, Constants.PPG_IR_RED_SAMPLE_RATE, 1e-10) } @Test @@ -31,7 +31,7 @@ class WearConstantsTest { @Test fun `PPG_IR_RED_BATCH_SIZE equals rate times batch tick`() { - // 100 Hz * 3s = 300 + // 25 Hz * 12s = 300 val expected = (Constants.PPG_IR_RED_SAMPLE_RATE * Constants.PPG_IR_RED_BATCH_TICK_RATE * Constants.MS_TO_SEC).toInt() assertEquals(expected, Constants.PPG_IR_RED_BATCH_SIZE) @@ -49,14 +49,14 @@ class WearConstantsTest { } @Test - fun `PPG_IR_RED_TICK_RATE is 10ms`() { - // 1000 / 100 = 10 - assertEquals(10L, Constants.PPG_IR_RED_TICK_RATE) + fun `PPG_IR_RED_TICK_RATE is 40ms`() { + // 1000 / 25 = 40 + assertEquals(40L, Constants.PPG_IR_RED_TICK_RATE) } @Test - fun `PPG_IR_RED_BATCH_TICK_RATE is 3 seconds`() { - assertEquals(3000L, Constants.PPG_IR_RED_BATCH_TICK_RATE) + fun `PPG_IR_RED_BATCH_TICK_RATE is 12 seconds`() { + assertEquals(12000L, Constants.PPG_IR_RED_BATCH_TICK_RATE) } @Test