diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..73bfbc6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 + +[*.{kt,kts}] +max_line_length = 120 +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true +ktlint_code_style = ktlint_official +ktlint_standard_filename = disabled +ktlint_standard_function-naming = disabled +ktlint_function_naming_ignore_when_annotated_with = Composable + +[*.{yml,yaml,json,toml}] +indent_size = 2 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e039c72..e35fe42 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,33 +1,13 @@ -## Status -READY/IN DEVELOPMENT/HOLD +## Summary + -## Migrations -YES | NO +## Changes +- ``:
-## Description -A few sentences describing the overall goals of the pull request's commits. +## Tests +- Unit tests: , coverage +- ktlint / detekt / Android lint: clean +- apiCheck: +- Example app build: -## Related PRs -List related PRs against other branches: - -branch PR -other_pr_production link -other_pr_master link -Todos - Tests - Documentation - -## Deploy Notes -Notes regarding deployment the contained body of work. These should note any db migrations, etc. - -## Steps to Test or Reproduce -Outline the steps to test or reproduce the PR here. - -git pull --prune -git checkout - -## Impacted Areas in Application -Small description about what will be changed in the app if this PR will be merged - -## List general components of the application that this PR will affect: -The direct changes inside the app made by this PR +Refs: # diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2151697 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI + +on: + pull_request: + branches: [ master ] + push: + branches: [ master ] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + release-guard: + name: Version / Changelog + runs-on: ubuntu-26.04 + steps: + - uses: actions/checkout@v7 + - name: Top CHANGELOG.md entry matches the release manifest + run: | + version=$(jq -r '."."' .release-please-manifest.json) + header=$(grep -m1 '^## ' CHANGELOG.md) + echo "manifest: $version, changelog: $header" + [[ "$header" =~ ^"## [$version] - "[0-9]{2}\.[0-9]{2}\.[0-9]{4}\.$ ]] || { + echo "::error file=CHANGELOG.md,line=1::Expected '## [$version] - dd.MM.yyyy.', got '$header'" + exit 1 + } + + build: + name: Lint, test, API check, build + runs-on: ubuntu-26.04 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 + with: + distribution: corretto + java-version: 17 + - uses: gradle/actions/setup-gradle@v6 + # Samsung's license forbids redistributing the SDK, so the real AAR lives in the private repository + # kvs-coder/samsung-health-sdk, read with a read-only deploy key. Without the key (fork PRs), the build uses + # the samsung-health-data-stub module written from the SDK's API reference. + - name: Provide the Samsung Health Data SDK + env: + SAMSUNG_HEALTH_SDK_DEPLOY_KEY: ${{ secrets.SAMSUNG_HEALTH_SDK_DEPLOY_KEY }} + run: | + if [ -z "$SAMSUNG_HEALTH_SDK_DEPLOY_KEY" ]; then + echo "::warning::SAMSUNG_HEALTH_SDK_DEPLOY_KEY is not set; building against the SDK stub" + exit 0 + fi + key="$RUNNER_TEMP/samsung-health-sdk-key" + printf '%s\n' "$SAMSUNG_HEALTH_SDK_DEPLOY_KEY" > "$key" + chmod 600 "$key" + GIT_SSH_COMMAND="ssh -i $key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \ + git clone --depth 1 git@github.com:kvs-coder/samsung-health-sdk.git "$RUNNER_TEMP/samsung-health-sdk" + rm -f "$key" + mkdir -p library/libs + cp "$RUNNER_TEMP"/samsung-health-sdk/samsung-health-data-api-*.aar library/libs/ + ls library/libs + - name: Lint + run: ./gradlew ktlintCheck detekt :library:lintDebug + - name: Test with coverage gate + run: ./gradlew :library:testDebugUnitTest :library:koverXmlReport :library:koverVerify + - name: Test against the SDK stub too, so it keeps matching the real SDK + if: hashFiles('library/libs/*.aar') != '' + run: ./gradlew :library:testDebugUnitTest -PsamsungHealthDataStub + - name: Public API check + run: ./gradlew :library:apiCheck + - name: Build library and example app + run: ./gradlew :library:assembleRelease :app:assembleDebug + - name: Upload reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: reports + path: | + library/build/reports + library/build/test-results + if-no-files-found: ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..846d8f0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,18 @@ +name: Release + +on: + push: + branches: [ master ] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-26.04 + steps: + - uses: googleapis/release-please-action@v5 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.gitignore b/.gitignore index 2a8980d..97bdb3a 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,12 @@ lint/tmp/ # Android Profiling *.hprof .idea/jarRepositories.xml + +# Samsung Health Data SDK: license forbids redistribution, add it locally (see README) +library/libs/*.aar + +# IDE state +.idea/ + +# Local task notes, not for the repository +ISSUE-*.md diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index 7a748e4..0000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,129 +0,0 @@ - - - - - - - - - - -
- - - - xmlns:android - - ^$ - - - -
-
- - - - xmlns:.* - - ^$ - - - BY_NAME - -
-
- - - - .*:id - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:name - - http://schemas.android.com/apk/res/android - - - -
-
- - - - name - - ^$ - - - -
-
- - - - style - - ^$ - - - -
-
- - - - .* - - ^$ - - - BY_NAME - -
-
- - - - .* - - http://schemas.android.com/apk/res/android - - - ANDROID_ATTRIBUTE_ORDER - -
-
- - - - .* - - .* - - - BY_NAME - -
-
-
-
- - -
-
\ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index 79ee123..0000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index 61a9130..0000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/dictionaries/florian.xml b/.idea/dictionaries/florian.xml deleted file mode 100644 index d7b3b6a..0000000 --- a/.idea/dictionaries/florian.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - samsung - shealth - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index a7243f5..0000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index d5d35ec..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml deleted file mode 100644 index 7f68460..0000000 --- a/.idea/runConfigurations.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..b9532ba --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1 @@ +{ ".": "0.0.6" } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2079773 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,396 @@ +# AGENTS.md — System & AI Agent Directives + +> **Library Mission**: SamsungHealthReporter is a Kotlin wrapper around the **Samsung Health Data SDK** (`com.samsung.android.sdk.health.data`, Android 10 / API 29+, Samsung Health app 6.30.2+), distributed as an Android library (AAR) through **JitPack**. +> It turns SDK objects (`HealthDataPoint`, `AggregatedData`, `DataType`s) into plain, serializable payload data classes (and back), so consumers — including a future Flutter plugin — can read, write and observe Samsung Health data without touching SDK types directly. +> It mirrors the architecture of its sibling, [HealthKitReporter](https://github.com/kvs-coder/HealthKitReporter): one facade, four services (**Reader**, **Writer**, **Observer**, **Manager**), library-owned types and payloads. +> `app/` hosts an Android demo app (Jetpack Compose, MVVM with `StateFlow`) that exercises the public API end to end. + +> **SDK availability**: the Samsung Health Data SDK AAR is login-gated and not redistributable. The library compiles against `library/libs/samsung-health-data-api*.aar` when present (git-ignored), otherwise against `samsung-health-data-stub/`, a never-published stand-in copied from the SDK's 1.1.0 API reference (`docs/adr/0003`). + +Strict engineering invariants, architectural rules, and operational protocols for AI agents and human contributors. + +--- + +## 1. Persona & Core Principles + +You operate as a **Staff Software Engineer**. +* **Engineering Standards**: Apply **KISS**, **DRY**, **SOLID**, and strict **Test-First TDD**. +* **Zero Speculation**: No code without tests. No superfluous wrappers or unnecessary abstractions. +* **Refactoring Rule**: Verify or write tests *first*. Refactoring requires a green test suite at every step. +* **Match the Surroundings**: New code reads like the code next to it — same file layout, KDoc shape, naming and argument wrapping (§5). When in doubt, copy the nearest sibling payload, type or service. +* **API Design**: Follow the [Kotlin coding conventions](https://kotlinlang.org/docs/coding-conventions.html) and the [Kotlin library authors' guidelines](https://kotlinlang.org/docs/api-guidelines-introduction.html) (`CONTRIBUTING.md`). +* **Parity with HealthKitReporter**: When a concept exists in both libraries (roles, payload shape, `make(from:)`, error enum, demo app), keep the same name and shape unless Kotlin idiom or the SDK forces a difference; record such a difference in `docs/adr/`. + +--- + +## 2. Communication Protocols + +All proposals, comments, and PR descriptions must adhere to: +1. **BLUF (Bottom Line Up Front)**: Lead immediately with the core output or decision. +2. **Pyramid Principle**: Core conclusion first, followed by structured, logical arguments. +3. **Plain English / Gutes Deutsch**: Concise, technical, zero corporate fluff. +4. **Socratic Method**: Guide architectural trade-offs via targeted questions rather than guessing. +5. **Strictly 1-2 sentences**: Answer in 1-2 sentences and never add explanations, code walk-throughs or lists unless the prompt explicitly asks for them. + +--- + +## 3. High-Level Architecture & Directory Topology + +``` +SamsungHealthReporter (facade, one HealthDataStore per instance via HealthDataService.getStore(context)) + │ isAvailable(context) checks the Samsung Health package + ├──► SamsungHealthReader ─┐ + ├──► SamsungHealthWriter ─┤ Services (service/) — the only layer that calls HealthDataStore + ├──► SamsungHealthObserver ─┤ + └──► SamsungHealthManager ─┘ + │ + ▼ + Types (model/type/) library enums; SDK mapping lives in decorator/ (HealthType.STEPS → DataTypes.STEPS) + Payloads (model/payload/) @Serializable data classes ⇄ HealthDataPoint (HeartRate ⇄ HealthDataPoint), + sealed Sample / WritableSample hierarchy + │ + ▼ + Decorators (decorator/) Extensions+.kt — SDK → payload harmonization & helpers +``` + +### Directory Structure +`library/src/` MUST reach and keep this layout (package `com.kvs.samsunghealthreporter`): + +```text +library/src/main/kotlin/com/kvs/samsunghealthreporter/ +├── SamsungHealthReporter.kt (facade) +├── SamsungHealthException.kt (the single public sealed exception hierarchy) +├── decorator/ +│ └── Extensions+.kt (one extended type per file: HealthType registry, HealthDataPoint, +│ WritableSample dispatch, TimeRange filters, error wrapping, ...) +├── model/ +│ ├── Payload.kt (Payload interface + Payload.Factory: make / collect / decode) +│ ├── .kt (TimeRange, TimeGroup, Ordering, SourceFilter, Page, Permission, +│ │ DataSource, Device, Aggregate, ChangeSet) +│ ├── payload/ +│ │ ├── Sample.kt (sealed Sample / WritableSample; must share the package with payloads) +│ │ └── .kt (HeartRate, BloodPressure, Exercise, Sleep, UserProfile, ...) +│ └── type/ +│ └── .kt (HealthType, Aggregation, AccessType, TimeGroupUnit, value enums) +└── service/ + ├── SamsungHealth.kt (Reader, Writer, Observer, Manager) + └── SamsungHealth+.kt (one area of a role as extensions, e.g. SamsungHealthReader+Aggregate.kt) + +library/src/test/kotlin/com/kvs/samsunghealthreporter/ +├── payload/ (SampleTest: every payload table-driven; ReadOnlySampleTest) +├── type/ (TypeTest: HealthType registry + value enums; ValueTypeTest) +├── service/SamsungHealthTest.kt (services against a mocked HealthDataStore) +└── Fixtures.kt (one fixture per payload, allSamples, response(), asMap()) + +library/api/library.api (binary-compatibility-validator dump of the public API) +library/libs/ (Samsung Health Data SDK AAR — git-ignored, compileOnly, never bundled) +samsung-health-data-stub/ (documented-API stand-in for the AAR; never published) +docs/adr/ (architecture decisions, Nygard format) +docs/arc42/ (architecture documentation; update it with every structural change) + +app/ (Compose MVVM demo app) +└── src/main/kotlin/com/kvs/samsunghealthreporter/example/ + ├── MainActivity.kt (binds DemoViewModel.state to DemoScreen and events back) + ├── demo/ (DemoViewModel, DemoScreen, DemoState / RowResult / DemoEvent, DemoRow) + └── service/ (SamsungHealthReporterService and one DemoPerformer per library area) +``` + +### Layer Invariants +* **No SDK leakage**: Public API takes and returns library types (`HealthType`, `HeartRate`, `TimeRange`, `ChangeSet`). Raw `com.samsung.android.sdk.health.data.*` types stay `internal` behind `Original` / `Harmonizable`; a lint rule (detekt `ForbiddenImport` on public signatures, or a dedicated check) enforces it. +* **Identity**: a payload's `uid` names the stored Samsung Health data point (`HealthDataPoint.uid`); a payload created by the consumer has `uid == null`. `clientDataId` is the consumer's own key: `SamsungHealthWriter.insert` assigns a random UUID when it is missing and returns the samples with it, so update and delete work by `uid` or, without one, by `clientDataId` — never by matching values. +* **One store**: `SamsungHealthReporter(context)` obtains one `HealthDataStore` and injects it into every service. Services never call `HealthDataService.getStore` themselves. +* **Decorators are internal glue**: `Extensions+*.kt` hold conversions and helpers; they never call the store. +* **Suspend first**: every store call is a `suspend fun` on the SDK's suspend API, called with the caller's coroutine context. The library never blocks a thread (`runBlocking`, `.await()` on a result holder, `Looper.prepare()`), and never picks a dispatcher for the consumer. + +--- + +## 4. Strict Prohibitions & Bans + +### Architecture & Code Bans +* ❌ **Ban on Singletons**: No `object` holding state, `companion object` instances, global `lateinit` stores or service locators. Consumers instantiate `SamsungHealthReporter(context)`; dependencies are passed through constructors. + * **Not covered** — stateless platform queries (`SamsungHealthReporter.isAvailable(context)`) and factories on payloads/types (`HeartRate.make(from:)`, `HealthType.make(from:)`, `HeartRate.collect(...)`) in `companion object`s. +* ❌ **Ban on Static-Only Utility Types**: No `object Utils` / `class Helpers` that only namespace functions. Put behavior in an extension of the type it belongs to (`Extensions+Instant.kt` → `Instant.epochMillis`). +* ❌ **Ban on Exposing SDK Types in Public API**: Public classes, functions, properties and typealiases never mention `HealthDataPoint`, `DataType`, `DataTypes`, `Permission`, `LocalTimeFilter`, `HealthDataStore` or any other SDK type; mapping happens in the payloads' `asOriginal()` / `from(point)` and in `decorator/`. No public `typealias` to an SDK type. +* ❌ **Ban on Unguarded SDK Versions**: A data type or field that needs a newer SDK or Samsung Health version than the declared minimum (§7) is gated: the service throws `SamsungHealthException.InvalidType(" needs Samsung Health Data SDK X.Y")` or degrades gracefully. (There is deliberately no `NotAvailable` case: availability is `SamsungHealthReporter.isAvailable`, setup problems are `Resolvable`.) +* ❌ **Ban on Ad-hoc Errors**: Throw only `SamsungHealthException` subclasses with a descriptive message (`SamsungHealthException.InvalidType("Invalid HealthType: $type")`). SDK exceptions (`HealthDataException`, `ResolvablePlatformException`, ...) are caught at the service boundary and wrapped, keeping the original as `cause`. No new exception hierarchies, no bare `Exception` / `IllegalStateException`. +* ❌ **Ban on `!!`, `error()`, `TODO()` and Unchecked Casts**: Use `?: throw SamsungHealthException...`, `requireNotNull` is also banned in `main`; `as?` + throw instead of `as`. +* ❌ **Ban on Inputs the SDK Rejects at Runtime**: Validate in Kotlin before calling the SDK (end before start, writes to read-only types, empty permission sets, missing required fields such as `BloodGlucoseType.MEAL_STATUS`) and throw `SamsungHealthException.InvalidValue`. +* ❌ **Ban on `else` Branches over Library Enums**: A `when` over `HealthType`, `ExerciseType`, `SleepStageType`, etc. lists every entry; no `else ->`. SDK enums / int constants that may grow get an explicit fallback that throws or maps to an `UNKNOWN` entry. +* ❌ **Ban on Mutable Payloads**: Payload fields are `val`; payloads are `data class`es. Changes go through the generated `copy(...)`, Kotlin's equivalent of HealthKitReporter's `copyWith`; don't add a `copyWith`. +* ❌ **Ban on Breaking the Serialization Contract**: `Payload.make(from:)` map keys and `@SerialName`s are consumed by other runtimes (Flutter / JSON). Renaming one is a breaking change (`!` / `BREAKING CHANGE:` commit → major release). +* ❌ **Ban on Unit Conversion**: The SDK fixes one unit per field (bpm, mmHg, mmol/L, kg, °C, mL, kcal). Payload fields keep that unit, documented in their KDoc; only `Aggregation.unit` / `Aggregate.unit` carry a unit string. Durations are milliseconds (`…Millis`), instants epoch milliseconds (`…Timestamp`). +* ❌ **Ban on Gson in the Library API**: Payloads use `kotlinx.serialization`; `json` / `encoded()` come from the `Payload` interface. (The SDK itself may depend on Gson; that stays its business.) +* ❌ **Ban on Bundling the Samsung SDK**: The `samsung-health-data-api*.aar` is `compileOnly` and git-ignored; consumers add it themselves under Samsung's license. Never commit it, publish it inside our AAR or to any repository, and never publish `samsung-health-data-stub`. +* ❌ **Ban on Undocumented Stub API**: `samsung-health-data-stub` contains only signatures copied verbatim from the SDK API reference. Tests use only documented SDK API (builders, `of` factories, MockK for responses), so they also run against the real AAR. + +### Git & VCS Bans +* ❌ **Ban on `git commit --no-verify`**: Bypassing pre-commit git hooks, static analysis, or test suites is forbidden. +* ❌ **Ban on Blind Staging (`git add .` / `git add -A`)**: Run `git status` first and stage only relevant source, test, and config files explicitly. +* ❌ **Ban on Direct Force Pushing**: Force pushing to `master` is forbidden. Use `--force-with-lease` on isolated feature branches only when necessary. +* ❌ **Ban on Single-Line Shortcut Commits**: Omitting the detailed description, `Changes:`, and `Tests:` sections in commit messages is strictly forbidden. +* ❌ **Ban on AI Attribution Trailers**: A commit message MUST NEVER carry `Co-Authored-By: `, or any other trailer crediting an AI model or tool. +* ❌ **Ban on Committing User or Build State**: Never commit `local.properties`, `build/`, `.gradle/`, `*.iml`, `.idea/workspace.xml`, keystores, or `google-services.json`. + +--- + +## 5. Code Style & Layer Specifications + +### A. File Layout +* **Header**: every Kotlin file starts with the package declaration, then imports (no wildcard imports), then declarations. No author/date header block. SDK types whose simple name clashes with a library type are imported with an `Original` alias (`import ...entries.HeartRate as OriginalSeries`, `...Ordering as OriginalOrdering`). +* **Indentation**: 4 spaces. Line length ≤ 120 in `library/` and `app/` (`.editorconfig`, ktlint). +* **Wrapping**: once a declaration or call doesn't fit on one line, put **every** argument on its own line, a trailing comma after the last one, and the closing `)` on its own line. Builder chains put each `.call()` on its own line. +* **Sections**: the SDK conversion of a writable payload sits between `// region Original` and `// endregion`; dispatch over the sealed hierarchy lives in `decorator/` (`Extensions+WritableSample.kt`, `Extensions+HealthType.kt`). +* **Access Control**: `public` (implicit) only for consumer-facing API; SDK-backed constructors and plumbing (`Original`, `Harmonizable`, unit map) are `internal`. Stored service dependencies are `private val`. Enable `explicitApi()` in `library/build.gradle.kts`. + +### B. Documentation Comments +Every public declaration carries KDoc in this format — type names in **bold**, defaults spelled out, thrown exceptions listed: + +```kotlin +/** + * Reads heart rate data points. + * + * @param range **TimeRange** local time range to read + * @param ordering **Ordering** sort by start time. [Ordering.DESCENDING] by default + * @param limit maximum number of points. No limit by default + * @return **List** the stored points, empty when there is no data + * @throws SamsungHealthException.InvalidValue when `range.end` is before `range.start` + * @throws SamsungHealthException.NotAuthorized when read permission is missing + */ +``` + +Short one-liners use `/** **SamsungHealthWriter** class for Samsung Health writing operations */`. Typealiases in `SamsungHealthReporter.kt` document each parameter. + +### C. Types (`model/type/`) +* `HealthType` is a `@Serializable enum class` with one entry per SDK `DataTypes.*` constant: `identifier` (stable snake_case library id), `isReadable`, `isWritable`, `isObservable`, `aggregations`, and `make(from:)` throwing `SamsungHealthException.InvalidType`. It has no SDK import. +* The SDK side lives in `decorator/Extensions+HealthType.kt`: `original`, `dualTimeReadable`, `changeReadable`, `writeable` and `sample(point)` — exhaustive `when`s listing every entry, throwing `InvalidType` for unsupported ones. `TypeTest` checks that the flags and the registry agree. +* `Aggregation` lists every SDK `AggregateOperation` with its `healthType`, `unit` and `supportsTimeGrouping` (false for local-date operations, which group by day or longer only); `SamsungHealthReader+Aggregate.kt` maps each entry to its builder kind. +* Value enums mirror an SDK enum entry for entry and by name (`MealStatus`, `ExerciseType`, `SleepStageType`, …), converted with `mapped()`; `TypeTest.roundTrip` proves every entry maps both ways. +* Adding an entry means updating **every** exhaustive `when` over it (the compiler lists them), the stub if it needs new SDK API, the fixtures and the `README.md` type table. + +### D. Payloads (`model/payload/`) +A sample payload is a `@Serializable @SerialName("") data class` implementing `Sample` (read-only) or `WritableSample`. Writable payloads follow `HeartRate.kt` exactly: +1. Constructor order: `startTimestamp: Long`, `endTimestamp: Long` (or `Long? = null` for instantaneous measurements), `harmonized: Harmonized`, then `uid`, `clientDataId`, `zoneOffsetSeconds`, `dataSource`, all `= null`. +2. `init { validateInterval() }`; nested series/session classes validate their own intervals. +3. `override val healthType get() = HealthType.` (no backing field, so not serialized) and `override val json get() = encoded` — sample JSON carries the `type` discriminator, so `Sample.serializer()` decodes any of them. +4. Nested `@Serializable data class Harmonized` with the values (and nested `Series` / `Session` / `Stage` / `Log` / `Location` classes); KDoc names each field's unit and range. +5. `// region Original`: `internal fun asOriginal(): HealthDataPoint = originalBuilder()…addFieldData(…)…build()`; register the type in `Extensions+WritableSample.kt`. +6. `public companion object : Payload.Factory({ Name.serializer() })` — `make(from:)`, `collect(from:)` and `decode(json)` come from the factory; add only `internal fun from(point: HealthDataPoint): Name`, reading required fields with `point.require(field)` (throws `InvalidValue`) and optional ones with `getValue`. + +Read-only payloads (`SkinTemperature`, `EnergyScore`, `IrregularHeartRhythmNotification`, `SleepApnea`) implement `Sample` and leave out step 5. `UserProfile`, `Aggregate`, `ChangeSet`, `Device` and `TimeRange` are plain `Payload`s with a factory. Flat payloads (one `Harmonized` of scalar fields) can be regenerated from a field table; keep them byte-identical in shape. + +KDoc is required on every public type, constructor, function and interface. + +### E. Decorators (`decorator/`) +* One file per extended type: `Extensions+.kt`. +* Extensions hold conversions (`TimeRange.asLocalTimeFilter`, `Ordering.asOriginal`, `AggregatedData.harmonize`), the type registry, and `sdkCall { }`, which rethrows SDK exceptions as `SamsungHealthException` (`AuthorizationException` → `NotAuthorized`, `InvalidRequestException` → `InvalidValue`, `ResolvablePlatformException` → `Resolvable`, others → `Platform`). +* Pure conversions are extension properties (`asInstant`, `asLocalDateTime`, `asOriginal`). + +### F. Services (`service/`) +* `class SamsungHealth internal constructor(private val store: HealthDataStore)`; `internal val store` when `SamsungHealth+.kt` extensions share it. Test seams (`now`, `makeClientDataId`) are extra `internal` constructor parameters with defaults. +* Every store call **and the request building before it** goes through `sdkCall { }` — SDK builders throw `HealthDataException`s too. Validate input first and throw before calling the store. +* **Reader**: `read(type, range, ordering = DESCENDING, limit = null, source = null)` follows page tokens until `limit`; `readPage(type, range, pageSize = 100, pageToken = null, ordering, source)`; `userProfile()`; `aggregate(aggregation, range, group = null, ordering = ASCENDING)` in `SamsungHealthReader+Aggregate.kt`. Energy score reads by local date, everything else by local time. +* **Writer**: `insert` (list or one; per-type atomic requests; assigns missing client data ids), `update` (by `uid`, else `clientDataId`, else `InvalidValue`), `delete(type, uids)`, `deleteByClientDataIds(type, ids)`, `delete(sample)`. +* **Observer**: `changes(type, since, until = now)` returns a `ChangeSet` over the SDK's change-time window; `observe(type, since, interval = 1.minutes)` is a cold polling `Flow` that emits only non-empty sets. +* **Manager**: `requestPermissions(activity, read, write)` and `grantedPermissions(read, write)` return the granted subset of library `Permission`s; `isAuthorized`; `resolve(activity, Resolvable)`; `localDevice`, `devices`, `device(id)`. +* Empty results mean "no data"; data points that can't be converted are skipped (`collect`), never reported as empty. + +### G. Example App (`app/`) +* Jetpack Compose + Material 3, single `MainActivity` that binds `DemoViewModel.state` (`StateFlow`) to `DemoScreen` and its events back to `DemoViewModel.onEvent(DemoEvent)`. Composables render state and forward events; they never touch the library. +* `DemoViewModel` runs each tapped row in `viewModelScope` (one `Job` per row; tapping a running row stops it) and stores a `RowResult` (`Running`, `Success`, `Failure`) per row id. +* `SamsungHealthReporterService` creates the reporter lazily (connecting may throw `Resolvable`), exposes `sections` and `publisher(row, activity): Flow`, and owns one `DemoPerformer` per library area (`ManagerDemos`, `ReaderDemos`, `WriterDemos`, `ObserverDemos`), each keeping its state (inserted samples, last sync times) `private`. +* Rows are generated from `HealthType.entries` / `Aggregation.entries` where possible (a read row per readable type, an aggregate row per aggregation, an insert row per writable type, a changes row per observable type); permissions request read for every type and write for every `isWritable` type. +* Every new public library method gets a `DemoRow` in the matching performer and a usage snippet in `README.md`. + +--- + +## 6. Testing Strategy & Execution Protocol + +The codebase enforces test-first **TDD**. Code without tests will be rejected. + +### A. Testing Categories & Toolstack + +| Category | Target | Package | Purpose & Scope | +| :--- | :--- | :--- | :--- | +| **Payload Tests** | `model/payload/*`, `model/*` | JUnit 4 + kotlin-test | Every payload (table-driven over `allSamples`): `json` → `Sample.serializer()` round trip, `type` discriminator, `make(from:)` from a Flutter-shaped map, missing / mistyped keys → `InvalidValue`, `collect` skipping invalid maps. | +| **Conversion Tests** | `decorator/*` | JUnit 4 + SDK (AAR or stub) | Every writable payload → `HealthDataPoint` → payload; read-only payloads from points built with `HealthDataPoint.builder()`. | +| **Type Tests** | `model/type/*`, registry | JUnit 4 | `make(from:)`, registry ↔ flags, value enums ↔ SDK enums for every entry. | +| **Service Tests** | `service/*` | MockK + `kotlinx-coroutines-test` (+ Robolectric for the writer: the SDK parcels data points in write requests) | Public calls against a mocked `HealthDataStore`: paging, limits, validation before any store call, error wrapping, observer flow on virtual time. | +| **Example App** | `app/` | manual, device | Real data needs a phone with Samsung Health in developer mode; run the matching demo rows. | + +### B. Test Conventions +* Name the object under test `sut`. Test names describe the flow in backticks: `` `create then encode then decode` ``, `` `read stops at the limit` ``. +* Use the fixed fixtures in `Fixtures.kt` (`START = 1_626_884_800_000L`); a new payload adds a fixture to `allSamples`, which `SampleTest` checks covers every readable type. +* Mock SDK responses with `response(items, nextPageToken)`; build data points only through documented builders. +* No `!!`, no swallowing `try/catch`; use `assertFailsWith`. + +### C. Official CLI Commands + +```bash +# 1. Unit tests with coverage (Kover); report in library/build/reports/kover +./gradlew :library:testDebugUnitTest :library:koverXmlReport :library:koverVerify + +# 2. Lint and static analysis — every violation fails +./gradlew ktlintCheck detekt :library:lintDebug + +# 3. Public API dump check (binary-compatibility-validator); after a deliberate change: ./gradlew :library:apiDump +./gradlew :library:apiCheck + +# 4. Build the library AAR and the example app +./gradlew :library:assembleRelease :app:assembleDebug + +# 5. With the real AAR in library/libs: the tests must also pass against the stub +./gradlew :library:testDebugUnitTest -PsamsungHealthDataStub +``` + +`.github/workflows/ci.yml` runs all four on every PR and push to `master`, plus a version/changelog guard. +There is no lint baseline. An inline `@Suppress` carries a comment with its reason. + +### D. Test-First TDD Pipeline +1. **Coverage**: every payload, type, conversion and `make(from:)` path is covered. Behavior that needs a live Samsung Health app (permission UI, `resolve(activity)`, real data) is checked through the Example app on a device, against the real AAR. +2. **Cycle**: + * 🔴 **Red**: Write a failing test defining the specification *before* production code. + * 🟢 **Green**: Write minimal production code to pass the test. + * 🔵 **Refactor**: Clean up implementation details while ensuring tests stay green. + +### E. Quality Gate Requirements +Before any commit or PR creation, the codebase must pass all gates: +1. `ktlintCheck` + `detekt` + Android lint — **zero warnings or errors**. +2. `testDebugUnitTest` — **all tests green**; quote the executed/failed counts it prints. +3. `apiCheck` — passes; a deliberate public API change ships its regenerated `library/api/library.api` (`./gradlew apiDump`). +4. Example app builds — **required whenever public API changes**. Otherwise state "not applicable — no public API change" in the evidence line rather than omitting it: an unstated gate reads as a skipped one. +5. Coverage — `koverVerify` passes the minimum line and branch coverage in `library/build.gradle.kts` (`minBound(99, LINE)` and `minBound(74, BRANCH)` as of 09.10.2026, measured 99.94% / 74.5%; almost all missed branches are kotlinx.serialization-generated); quote the measured percentages. A PR that adds tests raises the bounds to its new measured levels (rounded down); never lower them. + +--- + +## 7. Release & Versioning + +* **Platform floor**: `minSdk 29`, `compileSdk` / `targetSdk` 36, JVM target 17, Samsung Health Data SDK `1.1.0`+, Samsung Health app `6.30.2`+. Raising any floor is a breaking change. +* **Before tagging**: run the test suite against the real AAR and the Example app on a device (the stub can't prove binary compatibility). +* **SemVer**: breaking public API or serialization-contract changes → major; new types/features → minor; fixes → patch. The migration to the Data SDK ships as `1.0.0`. +* Releases are automated by release-please (`.github/workflows/release.yml`, `release-please-config.json`). It derives the bump from Conventional Commits on `master` and keeps a `chore: release X.Y.Z` PR open that bumps `.release-please-manifest.json`, `VERSION_NAME` in `gradle.properties`, prepends the `## [X.Y.Z] - dd.MM.yyyy.` entry to `CHANGELOG.md` and updates the `x-release-please-version` line in `README.md`. +* Merging the release PR creates the bare tag `X.Y.Z` (no `v` prefix) and the GitHub Release — JitPack builds that tag (`jitpack.yml`). Never tag, bump versions or edit released `CHANGELOG.md` entries by hand. +* Commit messages are the changelog: write the summary for consumers. +* New public API is documented in `README.md` with a usage snippet; the README states how to obtain and add the Samsung SDK AAR and that writing data needs Samsung partner approval. + +--- + +## 8. Git & GitHub CLI (`gh`) Operational Protocols + +### Branch Naming Convention +All branches MUST follow the strict user initials and issue structure: + +```text +/issue- +``` + +* **Example**: `vk/issue-14` or `ab/issue-102` + +### GitHub CLI (`gh`) Operations + +```bash +# View assigned issue context +gh issue view + +# Create feature branch for issue (ALWAYS branch off a freshly pulled master) +git fetch origin +git switch master && git pull --ff-only origin master +git checkout -b /issue- + +# Create Pull Request using gh CLI +gh pr create \ + --title "()[!]: " \ + --body "## Summary + + +## Changes +- :
+ +## Tests +- Summary: unit tests green, ktlint/detekt/lint clean, apiCheck + Example build passing. + +Refs: #" + +# Check PR checks and review status +gh pr status +gh pr checks +``` + +### Mandatory Git Execution Sequence +Before committing or creating a PR, run this exact sequence: + +```bash +# 1. Lint +./gradlew ktlintCheck detekt :library:lintDebug + +# 2. Execute test suite with coverage +./gradlew :library:testDebugUnitTest :library:koverVerify + +# 3. API check, then build the Example app (public API changes) +./gradlew :library:apiCheck :app:assembleDebug + +# 4. Inspect file status before staging +git status + +# 5. Stage specific changed files intentionally (NO blind `git add .`) +git add library/src/main/kotlin/com/kvs/samsunghealthreporter/model/payload/.kt \ + library/src/test/kotlin/com/kvs/samsunghealthreporter/payload/Test.kt + +# 6. Commit using strict multi-paragraph format +git commit -m "()[!]: " \ + -m "" \ + -m "Changes: +- :
+- :
" \ + -m "Tests: " \ + -m "Refs: #" +``` + +### Commit Format Specification +```text +()[!]: + + + +Changes: +- :
+- :
+ +Tests: + +[BREAKING CHANGE: ] +Refs: # +``` + +* The header is a [Conventional Commit](https://www.conventionalcommits.org) — release-please parses it to pick the version bump and changelog section; a header in any other shape is silently left out of the release. +* ``: `feat` (→ minor), `fix` (→ patch), or `docs`, `test`, `refactor`, `ci`, `build`, `chore` (no release). `!` after the scope or a `BREAKING CHANGE:` footer → major. +* `Refs: #` is required whenever an issue exists. + +### Local Issues +Work not yet on GitHub lives in `ISSUE-.md` files at the repo root, titled with the Conventional Commit header the work will ship under. They are for local use only: git-ignored, never committed or referenced from committed files. File one on GitHub with the `github-create` skill (or `gh issue create --body-file ISSUE-.md`) when it should become public. + +--- + +## 9. AI Verification Checklist + +Before outputting code or submitting PRs, explicitly verify: +* [ ] Does every new file live under `library/src/main/kotlin/com/kvs/samsunghealthreporter/{decorator,model,service}` and match the sibling files' style? +* [ ] Is every SDK API the change uses documented in the 1.1.0 reference and present, verbatim, in `samsung-health-data-stub`? +* [ ] Are singletons, global state and static-only utility objects absent, with `HealthDataStore` injected via the constructor? +* [ ] Is new public API free of SDK types (no `com.samsung.android.sdk.health.data.*` in public signatures), with mapping in `Original` / `Harmonizable`, and does `apiCheck` pass? +* [ ] Is every store call `suspend`, with no `runBlocking`, `Looper.prepare()` or blocking `await()`? +* [ ] Do update/delete act on stored points by `uid`, and is every input the SDK rejects validated in Kotlin first? +* [ ] Are errors thrown only as `SamsungHealthException` subclasses with descriptive messages and the SDK exception as `cause`, without `!!`, `error()` or unchecked casts? +* [ ] Does every new payload follow `HeartRate.kt`: `@Serializable @SerialName` data class, `Harmonized`, `val` fields, `validateInterval()`, `json = encoded`, `// region Original`, `Payload.Factory` companion with `internal fun from(point)`, and registration in the `decorator/` dispatchers? +* [ ] Are new enum entries handled in every exhaustive `when`, with no `else ->` over library enums? +* [ ] Are `make(from:)` keys and `@SerialName`s unchanged, or is the break versioned as major? +* [ ] Does every public declaration carry KDoc in the `@param` / `@return` / `@throws` format? +* [ ] Are wrapped calls one-argument-per-line with trailing commas, and lines ≤ 120? +* [ ] Is the new payload in `Fixtures.allSamples`, so the table-driven round-trip tests cover it, and were they red first? +* [ ] Did lint, unit tests, `koverVerify`, `apiCheck` and the Example app build pass? +* [ ] Is the Samsung SDK AAR still `compileOnly` and absent from the published artifact? +* [ ] Were `README.md` and the Example app updated for user-visible changes, leaving `CHANGELOG.md` and versions to release-please? +* [ ] Does every new public method or type entry have a demo in the Example app (a `DemoRow` for methods, `entries`-driven permissions for types)? +* [ ] Is the branch named strictly `/issue-`? +* [ ] Are git commits made without `--no-verify` and staged without blind `git add .`? +* [ ] Was `gh pr create` used with structured title/body matching commit specs? +* [ ] Does the commit message carry the Conventional `()[!]: ` header, a description, `Changes:`, `Tests:` and `Refs: #` (§8), with no AI trailer? diff --git a/CHANGELOG.md b/CHANGELOG.md index d4778d6..5e4c61d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ -## [1.0.0] - 28.10.2020. +# Changelog -* Initial release. The SamsungHealthReporter library to make easy data reading and writing for SamsungHealth \ No newline at end of file +## [0.0.6] - 05.12.2020. + +* Pre-release on the Samsung Health SDK for Android 1.4.0 (steps, heart rate, sleep, exercise, oxygen saturation, ECG, floors climbed). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a438214..9fa88e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,21 +1,52 @@ # Contributing -When contributing to this repository, please first discuss the change you wish to make via issue, -email, or any other method with the owners of this repository before making a change. +When contributing to this repository, please first discuss the change you wish to make via an issue +with the owners of this repository before making a change. Please note we have a code of conduct, please follow it in all your interactions with the project. +The binding engineering rules are in [AGENTS.md](AGENTS.md); this page is the short version. + ## Pull Request Process -1. Ensure any install or build dependencies are removed before the end of the layer when doing a - build. -2. Update the README.md with details of changes to the interface, this includes new environment - variables, exposed ports, useful file locations and container parameters. -3. Increase the version numbers in any examples files and the README.md to the new version that this - Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). -4. You may merge the Pull Request in once you have the sign-off of one of other developers, or if you - do not have permission to do that, you may request the second reviewer to merge it for you. - -## Kotlin - -We use https://kotlinlang.org/docs/reference/coding-conventions.html to provide a good quality and easy readyble code. +1. Branch off a freshly pulled `master` as `/issue-` and keep one issue per pull request. +2. Write the tests first: every payload round-trips through `json` and `make(from:)`, every writable payload + through Samsung Health data points, and new service behavior gets a test against a mocked `HealthDataStore`. +3. Update `README.md` with a usage snippet for new public API, and add a demo row for it to the Example app. +4. Use a [Conventional Commit](https://www.conventionalcommits.org) title (`feat:`, `fix:`, `docs:`, …; + `!` for breaking changes). release-please derives the version and the `CHANGELOG.md` entry from it, + so don't bump versions or edit released changelog entries by hand. +5. You may merge the Pull Request once you have the sign-off of another developer, or if you do not + have permission to do that, you may request the second reviewer to merge it for you. + +## Samsung Health Data SDK + +Samsung's license doesn't allow committing the SDK. Download `samsung-health-data-api.aar` (1.1.0 or later) +with a Samsung account and put it into `library/libs/` (git-ignored). Without it the build uses +`samsung-health-data-stub/`, written from the SDK's API reference; keep the stub's signatures identical to the +reference when you use a new SDK API, and check both builds: `./gradlew :library:testDebugUnitTest` with the AAR +and again with `-PsamsungHealthDataStub`. + +## Kotlin + +We follow the [Kotlin coding conventions](https://kotlinlang.org/docs/coding-conventions.html) and the +[library authors' guidelines](https://kotlinlang.org/docs/api-guidelines-introduction.html). ktlint and detekt +enforce the style (`.editorconfig`, `detekt.yml`, lines ≤ 120 characters); every violation fails CI. + +## Checks + +CI runs these on every pull request; run them locally before pushing: + +```bash +# Lint +./gradlew ktlintCheck detekt :library:lintDebug + +# Unit tests with the coverage gate (minBound in library/build.gradle.kts) +./gradlew :library:testDebugUnitTest :library:koverVerify + +# Public API check; after a deliberate API change run ./gradlew :library:apiDump and commit library/api +./gradlew :library:apiCheck + +# Build the library and the Example app +./gradlew :library:assembleRelease :app:assembleDebug +``` diff --git a/README.md b/README.md index d017cfd..fc677d1 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,305 @@ # SamsungHealthReporter -**UNDER CONSTRUCTION** +## About -Project's **build.gradle** -```groovy -allprojects { +A Kotlin wrapper above the [Samsung Health Data SDK](https://developer.samsung.com/health/data/overview.html) for reading, writing and observing Samsung Health data. +The library turns Samsung Health data points into plain, `@Serializable` Kotlin data classes, so every result encodes to a simple JSON payload, and turns them back into Samsung Health data points when you write. +SDK types never appear in the public API: you work with `HealthType`, `HeartRate`, `TimeRange` and friends. + +It is the Android sibling of [HealthKitReporter](https://github.com/kvs-coder/HealthKitReporter) and follows the same design: one `SamsungHealthReporter` with a **reader**, a **writer**, an **observer** and a **manager**. + +> Version 1.0.0 moves from the deprecated Samsung Health SDK for Android (end of service 2028) to the Samsung Health Data SDK. See [Migrating from 0.0.x](#migrating-from-00x). + +## Start + +### Preparation + +1. Download the **Samsung Health Data SDK** (1.1.0 or later) from the [Samsung Developer site](https://developer.samsung.com/health/data/overview.html#SDK-download). It needs a Samsung account, and Samsung's license doesn't allow redistributing it, so this library can't ship it. +2. Put `samsung-health-data-api.aar` into your app's `libs/` and add it with the plugins and dependencies the SDK needs: + +```kotlin +// app/build.gradle.kts +plugins { + id("org.jetbrains.kotlin.plugin.parcelize") +} + +dependencies { + implementation(files("libs/samsung-health-data-api.aar")) + implementation("com.google.code.gson:gson:2.13.2") +} +``` + +3. To test, enable [developer mode](https://developer.samsung.com/health/data/guide/developer-mode.html) in the Samsung Health app: + 1. In Samsung Health, tap **⋮** › **Settings** › **About Samsung Health**. + 2. Tap the version line about 10 times until **Developer mode (Samsung Health Data SDK)** appears, open it and agree to the notice. + 3. Turn on **Developer Mode for Data Read**. + 4. For writes, enter your app's package name as **Client ID** and the partnership **Access Code** there. + + Developer mode is for testing only; don't ask your app's users to turn it on. Reading works in developer mode; **writing, and distributing your app, needs Samsung partner approval** ([partner request](https://developer.samsung.com/health/data/overview.html)). Until you enter the access code Samsung issues with the partnership on the developer mode page, every write permission request fails with `SamsungHealthException.NotAuthorized` (SDK error 2003, `ERR_ACCESS_CONTROL`: "Permission is not allowed due to SDK policy"), so request read and write permissions separately. The Example app's *Request each permission alone* row lists which permissions your setup allows. + +Samsung Health 6.30.2 or later must be installed. The SDK works on Samsung and non-Samsung phones with Android 10 or later, but not on emulators. + +### Common usage + +Create one `SamsungHealthReporter` and keep it. Every call is a `suspend` function that runs on your coroutine context, and every error is a `SamsungHealthException`. + +```kotlin +if (!SamsungHealthReporter.isAvailable(context)) { + // Samsung Health is not installed +} + +val reporter = SamsungHealthReporter(context) + +lifecycleScope.launch { + try { + val granted = reporter.manager.requestPermissions( + activity, + read = setOf(HealthType.HEART_RATE, HealthType.STEPS, HealthType.SLEEP), + write = setOf(HealthType.HEART_RATE), + ) + val heartRates = reporter.reader.read(HealthType.HEART_RATE, TimeRange.lastDays(7)) + heartRates.forEach { println(it.json) } + } catch (exception: SamsungHealthException.Resolvable) { + // Samsung Health must be installed, updated or set up: let it fix that + reporter.manager.resolve(activity, exception) + } catch (exception: SamsungHealthException) { + println(exception.message) + } +} +``` + +| Exception | Meaning | +| :--- | :--- | +| `NotAuthorized` | a permission is missing, or the data belongs to another app | +| `InvalidType` | the type doesn't support the operation (e.g. writing steps) | +| `InvalidValue` | an input is invalid, or Samsung Health rejected it | +| `Resolvable` | the user can fix it; pass it to `manager.resolve` | +| `Platform` | any other Samsung Health error, with its `errorCode` | + +## Reading Data + +`read` returns `Sample`s; check their type or filter with `filterIsInstance`. A `TimeRange` is in epoch milliseconds and is read in local time, the way Samsung Health shows data. + +```kotlin +val reader = reporter.reader + +val today = reader.read(HealthType.HEART_RATE, TimeRange.day()) + .filterIsInstance() + .map { it.harmonized.heartRate } + +val latestTen = reader.read( + HealthType.BLOOD_PRESSURE, + TimeRange.lastDays(30), + ordering = Ordering.DESCENDING, + limit = 10, + source = SourceFilter.SamsungHealth, // or SourceFilter.App("com.example"), SourceFilter.LocalDevice +) + +val profile: UserProfile? = reader.userProfile() +``` + +Page by page: + +```kotlin +var page = reader.readPage(HealthType.EXERCISE, TimeRange.lastDays(30), pageSize = 20) +while (true) { + page.items.forEach { println(it.json) } + val token = page.nextPageToken ?: break + page = reader.readPage(HealthType.EXERCISE, TimeRange.lastDays(30), pageSize = 20, pageToken = token) +} +``` + +### Aggregates + +Samsung Health computes totals, minimums, maximums and goals. Steps and the activity summary are only available as aggregates. + +```kotlin +val stepsPerHour: List = reader.aggregate( + Aggregation.STEPS_TOTAL, + TimeRange.day(), + group = TimeGroup(TimeGroupUnit.HOURLY), +) +stepsPerHour.forEach { println("${it.startTimestamp}: ${it.value} ${it.unit}") } + +val sleepPerNight = reader.aggregate(Aggregation.SLEEP_TOTAL_DURATION, TimeRange.lastDays(7), TimeGroup(TimeGroupUnit.DAILY)) +val stepGoal = reader.aggregate(Aggregation.STEPS_GOAL_LAST, TimeRange.day()).firstOrNull()?.value +``` + +Durations are milliseconds and times of day seconds since midnight. Date-based aggregations (`supportsTimeGrouping == false`) group by day, week, month or year only. + +## Writing Data + +Only types with `isWritable` can be written. Writes of one type are atomic, and an app can only update and delete data it inserted. + +```kotlin +val writer = reporter.writer +val now = System.currentTimeMillis() + +val inserted = writer.insert( + HeartRate( + startTimestamp = now - 60_000, + endTimestamp = now, + harmonized = HeartRate.Harmonized(heartRate = 72f, min = 65f, max = 80f), + ), +) as HeartRate +// A sample without clientDataId gets a random UUID, so you can update or delete it right away. + +val updated = inserted.copy(harmonized = inserted.harmonized.copy(heartRate = 75f)) +writer.update(updated) // by uid, or clientDataId without one +writer.delete(updated) +writer.delete(HealthType.HEART_RATE, uids = listOf("uid-1", "uid-2")) +writer.deleteByClientDataIds(HealthType.WATER_INTAKE, listOf("my-id")) +``` + +Several types at once: + +```kotlin +writer.insert( + listOf( + WaterIntake(now, harmonized = WaterIntake.Harmonized(amount = 250f)), + BloodPressure(now, harmonized = BloodPressure.Harmonized(systolic = 120f, diastolic = 80f, mean = 93f)), + Nutrition(now, harmonized = Nutrition.Harmonized(title = "Apple", mealType = MealType.MORNING_SNACK, calories = 95f)), + ), +) +``` + +## Observing Data + +Samsung Health records every insert, update and delete. Ask for the changes since your last sync and persist `until` for the next call: + +```kotlin +val changes: ChangeSet = reporter.observer.changes(HealthType.SLEEP, since = lastSync) +changes.upserted.forEach { println(it.json) } +changes.deletedUids.forEach { println("deleted $it") } +lastSync = changes.until +``` + +Or poll while your screen is visible; the flow emits only non-empty change sets and stops with its collector: + +```kotlin +lifecycleScope.launch { + reporter.observer + .observe(HealthType.HEART_RATE, since = System.currentTimeMillis(), interval = 30.seconds) + .collect { changeSet -> println(changeSet.json) } +} +``` + +For background sync, schedule `changes` with WorkManager. + +## Devices + +```kotlin +val phone: Device = reporter.manager.localDevice() +val devices: List = reporter.manager.devices() // phone, watch, ring, accessories +val source: Device? = reporter.manager.device(sample.dataSource!!.deviceId) +``` + +## JSON and maps + +Every payload has `json`, and every payload type has `decode(json)`, `make(map)` and `collect(maps)`, so results cross into Flutter, React Native or a backend unchanged. A sample's JSON carries a `type` discriminator, so a list of mixed samples decodes with `Sample.serializer()`. + +```kotlin +val json = heartRate.json +val same = HeartRate.decode(json) +val fromFlutter = HeartRate.make(mapOf("startTimestamp" to 0L, "endTimestamp" to 1L, "harmonized" to mapOf("heartRate" to 72))) +``` + +## Supported types + +| `HealthType` | Payload | Read | Write | Observe | Aggregations | +| :--- | :--- | :---: | :---: | :---: | :--- | +| `HEART_RATE` | `HeartRate` | ✅ | ✅ | ✅ | `HEART_RATE_MIN`, `HEART_RATE_MAX` | +| `BLOOD_OXYGEN` | `BloodOxygen` | ✅ | ✅ | ✅ | | +| `BLOOD_PRESSURE` | `BloodPressure` | ✅ | ✅ | ✅ | | +| `BLOOD_GLUCOSE` | `BloodGlucose` | ✅ | ✅ | ✅ | | +| `BODY_COMPOSITION` | `BodyComposition` | ✅ | ✅ | ✅ | | +| `BODY_TEMPERATURE` | `BodyTemperature` | ✅ | ✅ | ✅ | | +| `SKIN_TEMPERATURE` | `SkinTemperature` | ✅ | | ✅ | | +| `EXERCISE` | `Exercise` | ✅ | ✅ | ✅ | `EXERCISE_TOTAL_CALORIES`, `EXERCISE_TOTAL_DURATION` | +| `EXERCISE_LOCATION` | — (permission for `Exercise.Session.route`) | | | | | +| `FLOORS_CLIMBED` | `FloorsClimbed` | ✅ | ✅ | ✅ | `FLOORS_CLIMBED_TOTAL` | +| `SLEEP` | `Sleep` | ✅ | ✅ | ✅ | `SLEEP_TOTAL_DURATION` | +| `NUTRITION` | `Nutrition` | ✅ | ✅ | ✅ | `NUTRITION_TOTAL_CALORIES` | +| `WATER_INTAKE` | `WaterIntake` | ✅ | ✅ | ✅ | `WATER_INTAKE_TOTAL` | +| `ENERGY_SCORE` | `EnergyScore` | ✅ | | ✅ | | +| `IRREGULAR_HEART_RHYTHM_NOTIFICATION` | `IrregularHeartRhythmNotification` | ✅ | | ✅ | | +| `SLEEP_APNEA` | `SleepApnea` | ✅ | | ✅ | | +| `USER_PROFILE` | `UserProfile` (`reader.userProfile()`) | ✅ | | | | +| `STEPS` | — | | | | `STEPS_TOTAL` | +| `ACTIVITY_SUMMARY` | — | | | | `ACTIVITY_SUMMARY_TOTAL_*` (4) | +| `STEPS_GOAL`, `SLEEP_GOAL`, `NUTRITION_GOAL`, `WATER_INTAKE_GOAL`, `ACTIVE_CALORIES_BURNED_GOAL`, `ACTIVE_TIME_GOAL` | — | | | | `*_LAST` | + +Not supported, because the Samsung Health Data SDK doesn't offer them: electrocardiogram, albumin, ALP and the other lab values of the old SDK, and sleep score writes (Samsung Health computes it). + +## Example + +`app/` is a Jetpack Compose demo with one row per public library call: every manager call, a read of every readable type, every aggregation, paging, inserting, updating and deleting every writable type, one-shot changes for every observable type and a live observer. Tap a row to run it; tap a running observer to stop it. + +Put the SDK AAR into `library/libs/` and run: + +```bash +./gradlew :app:installDebug +``` + +## Migrating from 0.0.x + +1.0.0 is a rewrite on the Samsung Health Data SDK: + +| 0.0.x | 1.0.0 | +| :--- | :--- | +| `SamsungHealthReporter(context)` threw when Samsung Health was missing; `openConnection()` | `SamsungHealthReporter.isAvailable(context)`, then `SamsungHealthReporter(context)`; no explicit connection | +| `resolver.stepCount.read(start, end, filter, sort)` (blocking) | `reader.read(type, TimeRange, ordering, limit, source)` (`suspend`) | +| `resolver.stepCount.aggregate(..., Time.Group.DAILY, ...)` | `reader.aggregate(Aggregation.STEPS_TOTAL, range, TimeGroup(TimeGroupUnit.DAILY))` | +| `insert(StepCount(InsertResult(...)))` | `writer.insert(sample)`; steps are read-only in the Data SDK | +| `update(value, Filter)`, `delete(Filter)` | `writer.update(sample)`, `writer.delete(sample)` by uid / client data id | +| `observer.observe(type).subscribe(onNext, onError)` | `observer.changes(type, since)` / `observer.observe(type, since)` (`Flow`) | +| `manager.authorize(activity, read, write)` + listener | `manager.requestPermissions(activity, read, write)` returns the granted set | +| `readResult` / `aggregateResult` / `insertResult` with Gson `json` | one data class per type with `harmonized` values and `kotlinx.serialization` `json` | +| `SessionType`, `DiscreteType`, `HealthConstants` | `HealthType` | + +Samsung's approvals for the old SDK don't carry over: request partner access for the Data SDK. + +## Requirements + +Android 10 (API 29) or later, Samsung Health 6.30.2 or later, Samsung Health Data SDK 1.1.0 or later, Java 17. + +## Installation + +Project's **settings.gradle.kts** + +```kotlin +dependencyResolutionManagement { repositories { - maven { url 'https://jitpack.io' } + maven("https://jitpack.io") } } ``` -In app's **build.gradle** -```groovy +App's **build.gradle.kts** + +```kotlin dependencies { - implementation 'com.github.victorkachalov:samsunghealthreporter:0.0.1' + implementation("com.github.kvs-coder:SamsungHealthReporter:0.0.6") // x-release-please-version + implementation(files("libs/samsung-health-data-api.aar")) } ``` -## Sponsorhip +## Development + +The library compiles against the real SDK when `library/libs/samsung-health-data-api*.aar` exists, and otherwise against `samsung-health-data-stub/`, a stand-in written from the SDK's public API reference that is never published. Build, test and lint as described in [CONTRIBUTING.md](CONTRIBUTING.md) and [AGENTS.md](AGENTS.md); the architecture is documented with arc42 in [docs/arc42](docs/arc42/README.md). + +## Releasing + +Releases are automated by [release-please](https://github.com/googleapis/release-please): Conventional Commits on `master` keep a release PR open, and merging it tags `X.Y.Z`, which JitPack builds. + +## Author + +Victor Kachalov, victorkachalov@gmail.com + +## License + +SamsungHealthReporter is available under the MIT license. See the LICENSE file for more info. + +## Sponsorship + If you think that my repo helped you to solve the issues you struggle with, please don't be shy and sponsor :-) diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index 55d93d8..0000000 --- a/app/build.gradle +++ /dev/null @@ -1,37 +0,0 @@ -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-android-extensions' - -android { - compileSdkVersion 30 - buildToolsVersion "30.0.3" - - defaultConfig { - applicationId "com.kvs.samsunghealthreporter.example" - minSdkVersion 21 - targetSdkVersion 30 - versionCode 1 - versionName "1.0" - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } -} - -dependencies { - implementation project(path: ':library') - implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation 'androidx.core:core-ktx:1.5.0' - implementation 'androidx.appcompat:appcompat:1.3.0' - implementation 'androidx.constraintlayout:constraintlayout:2.0.4' - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.2' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' - -} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..2230746 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,62 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + // The Samsung Health Data SDK guide requires parcelize in the app that ships the SDK. + alias(libs.plugins.kotlin.parcelize) +} + +android { + namespace = "com.kvs.samsunghealthreporter.example" + compileSdk = 36 + + defaultConfig { + applicationId = "com.kvs.samsunghealthreporter.example" + minSdk = 29 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + implementation(project(":library")) + // The app ships the Samsung Health Data SDK; the library only compiles against it. + val samsungHealthDataAar = fileTree("../library/libs") { include("samsung-health-data-api*.aar") } + val useStub = samsungHealthDataAar.isEmpty || providers.gradleProperty("samsungHealthDataStub").isPresent + implementation(if (useStub) project(":samsung-health-data-stub") else samsungHealthDataAar) + implementation(libs.gson) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + implementation(libs.compose.material.icons) + debugImplementation(libs.compose.ui.tooling) +} diff --git a/app/src/androidTest/java/com/kvs/samsunghealthreporter/example/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/kvs/samsunghealthreporter/example/ExampleInstrumentedTest.kt deleted file mode 100644 index f226851..0000000 --- a/app/src/androidTest/java/com/kvs/samsunghealthreporter/example/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.kvs.samsunghealthreporter.example - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.kvs.samsunghealthreporter.example", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 919b5c5..907a1da 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,15 +1,16 @@ - + - + @@ -18,4 +19,4 @@ - \ No newline at end of file + diff --git a/app/src/main/java/com/kvs/samsunghealthreporter/example/MainActivity.kt b/app/src/main/java/com/kvs/samsunghealthreporter/example/MainActivity.kt deleted file mode 100644 index 606bbe7..0000000 --- a/app/src/main/java/com/kvs/samsunghealthreporter/example/MainActivity.kt +++ /dev/null @@ -1,196 +0,0 @@ -package com.kvs.samsunghealthreporter.example - -import androidx.appcompat.app.AppCompatActivity -import android.os.Bundle -import android.util.Log -import com.kvs.samsunghealthreporter.* -import com.kvs.samsunghealthreporter.SamsungHealthConnectionListener -import com.kvs.samsunghealthreporter.manager.SamsungHealthPermissionListener -import com.kvs.samsunghealthreporter.decorator.addMinutes -import com.kvs.samsunghealthreporter.decorator.dayEnd -import com.kvs.samsunghealthreporter.decorator.dayStart -import com.kvs.samsunghealthreporter.decorator.Filter -import com.kvs.samsunghealthreporter.decorator.toJson -import com.kvs.samsunghealthreporter.model.session.HeartRate -import com.kvs.samsunghealthreporter.model.session.StepCount -import com.kvs.samsunghealthreporter.model.Time -import com.kvs.samsunghealthreporter.resolver.SamsungHealthResolver -import com.samsung.android.sdk.healthdata.HealthConstants - -import java.lang.Exception -import java.util.* - -class MainActivity : AppCompatActivity() { - companion object { - private const val TAG = "SAMSUNG_HEALTH" - } - - private fun handleStepCount(resolver: SamsungHealthResolver) { - resolver.stepCount.let { r -> - val readResult = r.read(Date().dayStart, Date().dayEnd, null, null) - Log.i(TAG, readResult.toJson()) - val aggregateResult = r.aggregate( - Date().dayStart, - Date().dayEnd, - Time.Group.DAILY, - null, - null - ) - Log.i(TAG, aggregateResult.toJson()) - val stepCount = StepCount( - StepCount.InsertResult( - applicationContext.packageName, - Date(), - 7200000, - Date().addMinutes(1), - 1000, - 20.0f, - 2.5f, - 80.0f - ) - ) - val insertSuccess = r.insert(stepCount) - Log.w(TAG, "Insert success: $insertSuccess") - - val updateSuccess = r.update( - stepCount, - Filter.eq(HealthConstants.StepCount.COUNT, 999) - ) - Log.w(TAG, "Update success: $updateSuccess") - - val deleteSuccess = r.delete( - Filter.eq( - HealthConstants.StepCount.COUNT, - 1000 - ) - ) - Log.w(TAG, "Delete success: $deleteSuccess") - } - } - - private fun handleHeartRate(resolver: SamsungHealthResolver) { - resolver.heartRate.let { r -> - val readResult = r.read( - Date().dayStart.dayStart, - Date().dayEnd, - null, - null - ) - Log.i(TAG, readResult.toJson()) - val aggregateResult = r.aggregate( - Date().dayStart.dayStart, - Date().dayEnd, - Time.Group.DAILY, - null, - null - ) - Log.i(TAG, aggregateResult.toJson()) - val now = Date() - val heartRate = HeartRate( - HeartRate.InsertResult( - applicationContext.packageName, - now, - 3600000, - now, - 99.5f, - 1 - ) - ) - val insertSuccess = r.insert(heartRate) - Log.w(TAG, "Insert success: $insertSuccess") - - val updateSuccess = r.update( - heartRate, - Filter.eq(HealthConstants.HeartRate.HEART_RATE, 99.5f) - ) - Log.w(TAG, "Update success: $updateSuccess") - - val deleteSuccess = r.delete( - Filter.eq( - HealthConstants.HeartRate.HEART_RATE, - 100.0f - ) - ) - Log.w(TAG, "Delete success: $deleteSuccess") - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - try { - val reporter = SamsungHealthReporter(this) - reporter.connectionListener = object : SamsungHealthConnectionListener { - override fun onConnected() { - Log.i(TAG, "onConnected") - reporter.manager.authorize( - this@MainActivity, - toReadTypes = setOf( - SessionType.STEP_COUNT, - SessionType.HEART_RATE - ), - toWriteTypes = setOf( - SessionType.STEP_COUNT, - SessionType.HEART_RATE - ) - ) - } - - override fun onDisconnected() { - Log.i(TAG, "onDisconnected") - } - - override fun onConnectionFailed( - exception: SamsungHealthConnectionException - ) { - Log.i(TAG, "onConnectionFailed $exception") - } - } - reporter.manager.permissionListener = object : SamsungHealthPermissionListener { - override fun onAcquired(success: Boolean) { - Log.i(TAG, "onPermissionAcquired: $success") - if (success) { - Thread { - try { - val resolver = reporter.resolver - handleHeartRate(resolver) - handleStepCount(resolver) - } catch (exception: Exception) { - Log.e(TAG, exception.stackTraceToString()) - } - reporter.observer.apply { - observe(SessionType.STEP_COUNT) - .subscribe( - onNext = { - Log.d(TAG, "onNext: ${it.string}") - }, - onError = { - Log.e(TAG, "onError: $it") - } - ) - observe(SessionType.HEART_RATE) - .subscribe( - onNext = { - Log.d(TAG, "onNext: ${it.string}") - }, - onError = { - Log.e(TAG, "onError: $it") - } - ) - } - }.start() - } else { - Log.e(TAG, "onPermissionAcquired: FALSE") - } - } - - override fun onException(exception: Exception) { - Log.e(TAG, "onPermissionDeclined $exception") - } - } - reporter.openConnection() - } catch (exception: SamsungHealthInitializationException) { - Log.e(TAG, "onCreate $exception") - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/MainActivity.kt b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/MainActivity.kt new file mode 100644 index 0000000..ddbf3c9 --- /dev/null +++ b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/MainActivity.kt @@ -0,0 +1,31 @@ +package com.kvs.samsunghealthreporter.example + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.kvs.samsunghealthreporter.example.demo.DemoScreen +import com.kvs.samsunghealthreporter.example.demo.DemoViewModel + +/** Hosts the demo screen; binds the view model's state to it and its events to the view model. */ +class MainActivity : ComponentActivity() { + private val viewModel: DemoViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { + val state by viewModel.state.collectAsStateWithLifecycle() + DemoScreen(state, viewModel::onEvent) + } + } + } +} diff --git a/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/demo/DemoRow.kt b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/demo/DemoRow.kt new file mode 100644 index 0000000..724f619 --- /dev/null +++ b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/demo/DemoRow.kt @@ -0,0 +1,20 @@ +package com.kvs.samsunghealthreporter.example.demo + +/** + * One demo of one public library call. + * + * @param id unique id of the row + * @param title what the demo does + * @param call the library call it exercises + */ +data class DemoRow( + val id: String, + val title: String, + val call: String, +) + +/** The demos of one library area. */ +data class DemoSection( + val title: String, + val rows: List, +) diff --git a/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/demo/DemoScreen.kt b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/demo/DemoScreen.kt new file mode 100644 index 0000000..59d9818 --- /dev/null +++ b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/demo/DemoScreen.kt @@ -0,0 +1,169 @@ +package com.kvs.samsunghealthreporter.example.demo + +import android.app.Activity +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +private const val COLLAPSED_LINES = 8 + +/** The demo screen: one section per library service, one row per public call. Tap a row to run it. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DemoScreen( + state: DemoState, + onEvent: (DemoEvent) -> Unit, +) { + val activity = LocalContext.current as Activity + Scaffold( + topBar = { + TopAppBar( + title = { Text("SamsungHealthReporter") }, + actions = { TextButton(onClick = { onEvent(DemoEvent.ClearTapped) }) { Text("Clear") } }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = + PaddingValues( + start = 16.dp, + end = 16.dp, + top = padding.calculateTopPadding() + 8.dp, + bottom = 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { DemoBanner(state.isSamsungHealthAvailable) } + state.sections.forEach { section -> + item(key = section.title) { DemoHeader(section) } + items(section.rows, key = { it.id }) { row -> + DemoCell(row, state.results[row.id]) { onEvent(DemoEvent.RowTapped(row, activity)) } + } + } + } + } +} + +@Composable +private fun DemoBanner(isAvailable: Boolean) { + val text = + if (isAvailable) { + "Samsung Health is installed. Start with Manager › Request every permission. Writing needs developer mode." + } else { + "Samsung Health is not installed. Install it from the store, then reopen the app." + } + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(12.dp)) + .padding(12.dp), + ) +} + +@Composable +private fun DemoHeader(section: DemoSection) { + Row(Modifier.padding(top = 16.dp, bottom = 4.dp), verticalAlignment = Alignment.Bottom) { + Text(section.title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Spacer(Modifier.width(8.dp)) + Text("${section.rows.size} demos", style = MaterialTheme.typography.labelMedium, color = Color.Gray) + } +} + +@Composable +private fun DemoCell( + row: DemoRow, + result: RowResult?, + onTap: () -> Unit, +) { + var expanded by remember(row.id) { mutableStateOf(false) } + Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onTap)) { + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(row.title, style = MaterialTheme.typography.titleSmall) + Text( + row.call, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = Color.Gray, + ) + } + DemoStatus(result) + } + val output = + when (result) { + null -> null + is RowResult.Running -> result.output + is RowResult.Success -> result.output + is RowResult.Failure -> result.message + } + if (output != null) { + Text( + text = output, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + lineHeight = 14.sp, + color = if (result is RowResult.Failure) MaterialTheme.colorScheme.error else Color.Unspecified, + maxLines = if (expanded) Int.MAX_VALUE else COLLAPSED_LINES, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .padding(top = 8.dp) + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .clickable { expanded = !expanded }, + ) + } + } + } +} + +@Composable +private fun DemoStatus(result: RowResult?) { + when (result) { + null -> Text("Run", color = MaterialTheme.colorScheme.primary) + is RowResult.Running -> CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + is RowResult.Success -> Text("✓", color = Color(0xFF2E7D32), fontSize = 18.sp) + is RowResult.Failure -> Text("✕", color = MaterialTheme.colorScheme.error, fontSize = 18.sp) + } +} diff --git a/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/demo/DemoState.kt b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/demo/DemoState.kt new file mode 100644 index 0000000..b76d5a3 --- /dev/null +++ b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/demo/DemoState.kt @@ -0,0 +1,38 @@ +package com.kvs.samsunghealthreporter.example.demo + +import android.app.Activity + +/** Everything the demo screen shows. */ +data class DemoState( + val isSamsungHealthAvailable: Boolean, + val sections: List, + val results: Map = emptyMap(), +) + +/** The state of one demo row. */ +sealed interface RowResult { + /** The demo is running; [output] holds the latest result of a long-running demo. */ + data class Running( + val output: String? = null, + ) : RowResult + + data class Success( + val output: String, + ) : RowResult + + data class Failure( + val message: String, + ) : RowResult +} + +/** User intents. */ +sealed interface DemoEvent { + /** Runs the demo, or stops it when it is still running. */ + data class RowTapped( + val row: DemoRow, + val activity: Activity, + ) : DemoEvent + + /** Clears every result. */ + data object ClearTapped : DemoEvent +} diff --git a/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/demo/DemoViewModel.kt b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/demo/DemoViewModel.kt new file mode 100644 index 0000000..bbe6894 --- /dev/null +++ b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/demo/DemoViewModel.kt @@ -0,0 +1,77 @@ +package com.kvs.samsunghealthreporter.example.demo + +import android.app.Activity +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.kvs.samsunghealthreporter.example.service.SamsungHealthReporterService +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** Turns [DemoEvent]s into demo runs and exposes their results as [DemoState]. */ +class DemoViewModel( + application: Application, +) : AndroidViewModel(application) { + private val service = SamsungHealthReporterService(application) + private val jobs = mutableMapOf() + private val mutableState = + MutableStateFlow( + DemoState( + isSamsungHealthAvailable = service.isSamsungHealthAvailable, + sections = service.sections, + ), + ) + + val state: StateFlow = mutableState.asStateFlow() + + fun onEvent(event: DemoEvent) { + when (event) { + is DemoEvent.RowTapped -> toggle(event.row, event.activity) + DemoEvent.ClearTapped -> { + jobs.values.forEach { it.cancel() } + jobs.clear() + mutableState.update { it.copy(results = emptyMap()) } + } + } + } + + private fun toggle( + row: DemoRow, + activity: Activity, + ) { + jobs.remove(row.id)?.let { running -> + running.cancel() + setResult(row, RowResult.Success("Stopped")) + return + } + setResult(row, RowResult.Running()) + jobs[row.id] = + viewModelScope.launch { + var last: String? = null + service + .publisher(row, activity) + .onCompletion { error -> + jobs.remove(row.id) + if (error == null) setResult(row, RowResult.Success(last ?: "Done")) + }.catch { error -> + setResult(row, RowResult.Failure("${error::class.simpleName}: ${error.message}")) + }.collect { output -> + last = output + setResult(row, RowResult.Running(output)) + } + } + } + + private fun setResult( + row: DemoRow, + result: RowResult, + ) { + mutableState.update { it.copy(results = it.results + (row.id to result)) } + } +} diff --git a/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/DemoPerformer.kt b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/DemoPerformer.kt new file mode 100644 index 0000000..dff0216 --- /dev/null +++ b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/DemoPerformer.kt @@ -0,0 +1,40 @@ +package com.kvs.samsunghealthreporter.example.service + +import android.app.Activity +import com.kvs.samsunghealthreporter.example.demo.DemoRow +import com.kvs.samsunghealthreporter.example.demo.DemoSection +import kotlinx.coroutines.flow.Flow + +/** Runs the demos of one library area. */ +interface DemoPerformer { + val section: DemoSection + + /** + * Runs one demo of this performer's [section]. + * + * @return the demo's output; one value for one-shot calls, a stream for observers + */ + fun perform( + row: DemoRow, + activity: Activity, + ): Flow +} + +/** Pretty JSON for the result views. */ +internal fun String.pretty(): String = + prettyJson.encodeToString( + kotlinx.serialization.json.JsonElement + .serializer(), + prettyJson.parseToJsonElement(this), + ) + +internal fun List.prettyList(): String = + if (isEmpty()) { + "[] — no data" + } else { + joinToString(separator = ",\n", prefix = "[\n", postfix = "\n]") { + it.pretty() + } + } + +private val prettyJson = kotlinx.serialization.json.Json { prettyPrint = true } diff --git a/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/ManagerDemos.kt b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/ManagerDemos.kt new file mode 100644 index 0000000..f593670 --- /dev/null +++ b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/ManagerDemos.kt @@ -0,0 +1,140 @@ +package com.kvs.samsunghealthreporter.example.service + +import android.app.Activity +import android.content.Context +import com.kvs.samsunghealthreporter.SamsungHealthException +import com.kvs.samsunghealthreporter.SamsungHealthReporter +import com.kvs.samsunghealthreporter.example.demo.DemoRow +import com.kvs.samsunghealthreporter.example.demo.DemoSection +import com.kvs.samsunghealthreporter.model.Permission +import com.kvs.samsunghealthreporter.model.type.HealthType +import com.samsung.android.sdk.health.data.error.HealthDataException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** Demos of **SamsungHealthManager** and the reporter's availability check. */ +class ManagerDemos( + private val context: Context, + private val reporter: () -> SamsungHealthReporter, + private val errors: DemoErrors, +) : DemoPerformer { + private val read = HealthType.entries.toSet() + private val write = HealthType.entries.filter { it.isWritable }.toSet() + + private val available = + DemoRow("manager.available", "Is Samsung Health installed", "SamsungHealthReporter.isAvailable") + private val request = DemoRow("manager.request", "Request every permission", "manager.requestPermissions") + private val requestRead = + DemoRow("manager.requestRead", "Request every read permission", "manager.requestPermissions(read)") + private val requestEach = + DemoRow( + "manager.requestEach", + "Request each permission alone, list the rejected ones", + "manager.requestPermissions × 36", + ) + private val granted = DemoRow("manager.granted", "Granted permissions", "manager.grantedPermissions") + private val authorized = DemoRow("manager.authorized", "Is every permission granted", "manager.isAuthorized") + private val resolve = DemoRow("manager.resolve", "Resolve the last Samsung Health error", "manager.resolve") + private val localDevice = DemoRow("manager.localDevice", "This phone as a device", "manager.localDevice") + private val devices = DemoRow("manager.devices", "Own devices", "manager.devices") + private val device = DemoRow("manager.device", "Look up this phone by id", "manager.device") + + override val section = + DemoSection( + "Manager", + listOf( + available, + request, + requestRead, + requestEach, + granted, + authorized, + resolve, + localDevice, + devices, + device, + ), + ) + + override fun perform( + row: DemoRow, + activity: Activity, + ): Flow = + flow { + val output = + when (row) { + available -> "Samsung Health installed: ${SamsungHealthReporter.isAvailable(context)}" + request -> { + val result = reporter().manager.requestPermissions(activity, read, write) + "Granted ${result.size} of ${read.size + write.size}:\n" + + result.joinToString("\n") { it.label } + } + requestRead -> { + val result = reporter().manager.requestPermissions(activity, read) + "Granted ${result.size} of ${read.size}:\n" + result.joinToString("\n") { it.label } + } + requestEach -> requestEach(activity) + granted -> { + val result = reporter().manager.grantedPermissions(read, write) + "Granted ${result.size} of ${read.size + write.size}:\n" + + result.joinToString("\n") { it.label } + } + authorized -> "Authorized for everything: ${reporter().manager.isAuthorized(read, write)}" + resolve -> { + val error = errors.lastResolvable + when { + error == null -> "No resolvable error so far" + reporter().manager.resolve(activity, error) -> "Started the fix for: ${error.message}" + else -> "Samsung Health offers no fix for: ${error.message}" + } + } + localDevice -> + reporter() + .manager + .localDevice() + .json + .pretty() + devices -> + reporter() + .manager + .devices() + .map { it.json } + .prettyList() + device -> { + val id = reporter().manager.localDevice().id + reporter() + .manager + .device(id) + ?.json + ?.pretty() ?: "No device with id $id" + } + else -> error("Unknown row ${row.id}") + } + emit(output) + } + + /** Developer mode and partner approval allow only some permissions; this finds out which. */ + private suspend fun requestEach(activity: Activity): String { + val lines = + read.map { type -> + "read ${type.identifier}: " + + attempt { reporter().manager.requestPermissions(activity, setOf(type)) } + } + + write.map { type -> + "write ${type.identifier}: " + + attempt { reporter().manager.requestPermissions(activity, emptySet(), setOf(type)) } + } + return lines.joinToString("\n") + } + + private suspend fun attempt(block: suspend () -> Set): String = + try { + if (block().isEmpty()) "denied" else "granted" + } catch (exception: SamsungHealthException) { + val code = (exception.cause as? HealthDataException)?.errorCode ?: "" + "${exception::class.simpleName} $code ${exception.message}" + } + + private val Permission.label: String + get() = "${type.identifier} ${access.name.lowercase()}" +} diff --git a/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/ObserverDemos.kt b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/ObserverDemos.kt new file mode 100644 index 0000000..b18214a --- /dev/null +++ b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/ObserverDemos.kt @@ -0,0 +1,71 @@ +package com.kvs.samsunghealthreporter.example.service + +import android.app.Activity +import com.kvs.samsunghealthreporter.SamsungHealthReporter +import com.kvs.samsunghealthreporter.example.demo.DemoRow +import com.kvs.samsunghealthreporter.example.demo.DemoSection +import com.kvs.samsunghealthreporter.model.ChangeSet +import com.kvs.samsunghealthreporter.model.type.HealthType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlin.time.Duration.Companion.seconds + +/** Demos of **SamsungHealthObserver**: one-shot changes per type since the last sync, and a polling flow. */ +class ObserverDemos( + private val reporter: () -> SamsungHealthReporter, +) : DemoPerformer { + /** The last sync time per type; a real app persists it. */ + private val lastSync = mutableMapOf() + private var observed = 0 + + private val changeRows = + HealthType.entries.filter { it.isObservable }.associateBy { type -> + DemoRow( + "observer.changes.${type.identifier}", + "Changes of ${type.identifier} since last sync", + "observer.changes", + ) + } + private val observe = + DemoRow("observer.observe", "Observe heart rate every 10 s (tap again to stop)", "observer.observe") + + override val section = DemoSection("Observer", listOf(observe) + changeRows.keys) + + override fun perform( + row: DemoRow, + activity: Activity, + ): Flow = + when (row) { + observe -> { + observed = 0 + reporter() + .observer + .observe(HealthType.HEART_RATE, System.currentTimeMillis(), 10.seconds) + .map { changeSet -> + observed++ + "Change set #$observed\n${changeSet.summary}" + }.onStart { emit("Waiting for heart rate changes…") } + } + in changeRows -> + flow { + val type = changeRows.getValue(row) + val since = lastSync[type] ?: (System.currentTimeMillis() - DAY) + val changeSet = reporter().observer.changes(type, since) + lastSync[type] = changeSet.until + emit(changeSet.summary) + } + else -> error("Unknown row ${row.id}") + } + + private val ChangeSet.summary: String + get() = + "${upserted.size} upserted, ${deletedUids.size} deleted\n" + + "Next sync from $until\n" + + upserted.map { it.json }.prettyList() + + private companion object { + const val DAY = 86_400_000L + } +} diff --git a/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/ReaderDemos.kt b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/ReaderDemos.kt new file mode 100644 index 0000000..95011ab --- /dev/null +++ b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/ReaderDemos.kt @@ -0,0 +1,89 @@ +package com.kvs.samsunghealthreporter.example.service + +import android.app.Activity +import com.kvs.samsunghealthreporter.SamsungHealthReporter +import com.kvs.samsunghealthreporter.example.demo.DemoRow +import com.kvs.samsunghealthreporter.example.demo.DemoSection +import com.kvs.samsunghealthreporter.model.SourceFilter +import com.kvs.samsunghealthreporter.model.TimeGroup +import com.kvs.samsunghealthreporter.model.TimeRange +import com.kvs.samsunghealthreporter.model.type.Aggregation +import com.kvs.samsunghealthreporter.model.type.HealthType +import com.kvs.samsunghealthreporter.model.type.TimeGroupUnit +import com.kvs.samsunghealthreporter.service.aggregate +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** Demos of **SamsungHealthReader**: every readable type, every aggregation, paging and the user profile. */ +class ReaderDemos( + private val reporter: () -> SamsungHealthReporter, +) : DemoPerformer { + private val readRows = + HealthType.entries.filter { it.isReadable }.associateBy { type -> + DemoRow("reader.read.${type.identifier}", "Read ${type.identifier}, last 7 days", "reader.read") + } + private val aggregateRows = + Aggregation.entries.associateBy { aggregation -> + DemoRow( + "reader.aggregate.${aggregation.name}", + "${aggregation.name.lowercase()} per day, last 7 days", + "reader.aggregate", + ) + } + private val stepsHourly = DemoRow("reader.stepsHourly", "Steps per hour today", "reader.aggregate + TimeGroup") + private val samsungHealthOnly = + DemoRow("reader.source", "Heart rate measured by Samsung Health today", "reader.read + SourceFilter") + private val paging = DemoRow("reader.page", "Heart rate, two pages of 5", "reader.readPage") + private val userProfile = DemoRow("reader.userProfile", "User profile", "reader.userProfile") + + override val section = + DemoSection( + "Reader", + listOf(userProfile, paging, samsungHealthOnly, stepsHourly) + readRows.keys + aggregateRows.keys, + ) + + override fun perform( + row: DemoRow, + activity: Activity, + ): Flow = + flow { + val reader = reporter().reader + val week = TimeRange.lastDays(7) + val output = + when (row) { + userProfile -> reader.userProfile()?.json?.pretty() ?: "No profile" + paging -> { + val first = reader.readPage(HealthType.HEART_RATE, week, pageSize = 5) + val second = + first.nextPageToken?.let { reader.readPage(HealthType.HEART_RATE, week, 5, it) } + "Page 1 (${first.items.size}):\n${first.items.map { it.json }.prettyList()}\n\n" + + "Page 2 (${second?.items?.size ?: 0}):\n${second?.items.orEmpty().map { + it.json + }.prettyList()}" + } + samsungHealthOnly -> + reader + .read( + HealthType.HEART_RATE, + TimeRange.day(), + limit = 10, + source = SourceFilter.SamsungHealth, + ).map { it.json } + .prettyList() + stepsHourly -> + reader + .aggregate(Aggregation.STEPS_TOTAL, TimeRange.day(), TimeGroup(TimeGroupUnit.HOURLY)) + .filter { it.value != null } + .map { it.json } + .prettyList() + in readRows -> reader.read(readRows.getValue(row), week, limit = 20).map { it.json }.prettyList() + in aggregateRows -> + reader + .aggregate(aggregateRows.getValue(row), week, TimeGroup(TimeGroupUnit.DAILY)) + .map { it.json } + .prettyList() + else -> error("Unknown row ${row.id}") + } + emit(output) + } +} diff --git a/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/SamsungHealthReporterService.kt b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/SamsungHealthReporterService.kt new file mode 100644 index 0000000..e0f1e17 --- /dev/null +++ b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/SamsungHealthReporterService.kt @@ -0,0 +1,49 @@ +package com.kvs.samsunghealthreporter.example.service + +import android.app.Activity +import android.content.Context +import com.kvs.samsunghealthreporter.SamsungHealthException +import com.kvs.samsunghealthreporter.SamsungHealthReporter +import com.kvs.samsunghealthreporter.example.demo.DemoRow +import com.kvs.samsunghealthreporter.example.demo.DemoSection +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch + +/** Owns one **SamsungHealthReporter** and one performer per library area. */ +class SamsungHealthReporterService( + context: Context, +) { + private val appContext = context.applicationContext + + // Created on first use: connecting throws SamsungHealthException.Resolvable when Samsung Health needs setup. + private val reporter by lazy { SamsungHealthReporter(appContext) } + private val errors = DemoErrors() + private val performers: List = + listOf( + ManagerDemos(appContext, { reporter }, errors), + ReaderDemos({ reporter }), + WriterDemos(appContext, { reporter }), + ObserverDemos({ reporter }), + ) + + val isSamsungHealthAvailable: Boolean get() = SamsungHealthReporter.isAvailable(appContext) + + val sections: List get() = performers.map { it.section } + + fun publisher( + row: DemoRow, + activity: Activity, + ): Flow = + performers + .first { performer -> row in performer.section.rows } + .perform(row, activity) + .catch { error -> + if (error is SamsungHealthException.Resolvable) errors.lastResolvable = error + throw error + } +} + +/** The last error Samsung Health can fix, for the "Resolve" demo. */ +class DemoErrors { + var lastResolvable: SamsungHealthException.Resolvable? = null +} diff --git a/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/WriterDemos.kt b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/WriterDemos.kt new file mode 100644 index 0000000..852f8e0 --- /dev/null +++ b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/WriterDemos.kt @@ -0,0 +1,249 @@ +package com.kvs.samsunghealthreporter.example.service + +import android.app.Activity +import android.content.Context +import com.kvs.samsunghealthreporter.SamsungHealthReporter +import com.kvs.samsunghealthreporter.example.demo.DemoRow +import com.kvs.samsunghealthreporter.example.demo.DemoSection +import com.kvs.samsunghealthreporter.model.SourceFilter +import com.kvs.samsunghealthreporter.model.TimeRange +import com.kvs.samsunghealthreporter.model.payload.BloodGlucose +import com.kvs.samsunghealthreporter.model.payload.BloodOxygen +import com.kvs.samsunghealthreporter.model.payload.BloodPressure +import com.kvs.samsunghealthreporter.model.payload.BodyComposition +import com.kvs.samsunghealthreporter.model.payload.BodyTemperature +import com.kvs.samsunghealthreporter.model.payload.Exercise +import com.kvs.samsunghealthreporter.model.payload.FloorsClimbed +import com.kvs.samsunghealthreporter.model.payload.HeartRate +import com.kvs.samsunghealthreporter.model.payload.Nutrition +import com.kvs.samsunghealthreporter.model.payload.Sleep +import com.kvs.samsunghealthreporter.model.payload.WaterIntake +import com.kvs.samsunghealthreporter.model.payload.WritableSample +import com.kvs.samsunghealthreporter.model.type.ExerciseType +import com.kvs.samsunghealthreporter.model.type.GlucoseMeasurementType +import com.kvs.samsunghealthreporter.model.type.HealthType +import com.kvs.samsunghealthreporter.model.type.MealStatus +import com.kvs.samsunghealthreporter.model.type.MealType +import com.kvs.samsunghealthreporter.model.type.SleepStageType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** + * Demos of **SamsungHealthWriter**: insert one sample of every writable type, then update and delete them. + * + * Writing needs Samsung Health developer mode, or Samsung partner approval for a released app. + */ +class WriterDemos( + private val context: Context, + private val reporter: () -> SamsungHealthReporter, +) : DemoPerformer { + private val inserted = mutableMapOf() + + private val insertRows = + HealthType.entries.filter { it.isWritable }.associateBy { type -> + DemoRow("writer.insert.${type.identifier}", "Insert one ${type.identifier}", "writer.insert") + } + private val insertAll = + DemoRow("writer.insertAll", "Insert one sample of every writable type", "writer.insert(list)") + private val update = DemoRow("writer.update", "Update the inserted heart rate to 99 bpm", "writer.update") + private val deleteInserted = DemoRow("writer.delete", "Delete every inserted sample", "writer.delete(sample)") + private val deleteByClientDataIds = + DemoRow( + "writer.deleteByClientDataIds", + "Delete the inserted water intake by client data id", + "writer.deleteByClientDataIds", + ) + private val deleteByUid = + DemoRow( + "writer.deleteByUid", + "Delete today's heart rate written by this app, by uid", + "writer.delete(type, uids)", + ) + + override val section = + DemoSection( + "Writer", + listOf(insertAll) + insertRows.keys + listOf(update, deleteByClientDataIds, deleteByUid, deleteInserted), + ) + + override fun perform( + row: DemoRow, + activity: Activity, + ): Flow = + flow { + val writer = reporter().writer + val output = + when (row) { + insertAll -> { + val samples = writer.insert(insertRows.values.map { sample(it, System.currentTimeMillis()) }) + samples.forEach { inserted[it.healthType] = it } + "Inserted ${samples.size} samples:\n" + samples.map { it.json }.prettyList() + } + in insertRows -> { + val sample = writer.insert(sample(insertRows.getValue(row), System.currentTimeMillis())) + inserted[sample.healthType] = sample + "Inserted:\n" + sample.json.pretty() + } + update -> { + val heartRate = + inserted[HealthType.HEART_RATE] as? HeartRate + ?: return@flow emit("Insert a heart rate first") + val updated = heartRate.copy(harmonized = heartRate.harmonized.copy(heartRate = 99f)) + writer.update(updated) + inserted[HealthType.HEART_RATE] = updated + "Updated:\n" + updated.json.pretty() + } + deleteByClientDataIds -> { + val id = + inserted.remove(HealthType.WATER_INTAKE)?.clientDataId + ?: return@flow emit("Insert a water intake first") + writer.deleteByClientDataIds(HealthType.WATER_INTAKE, listOf(id)) + "Deleted water intake $id" + } + deleteByUid -> { + val own = + reporter().reader.read( + HealthType.HEART_RATE, + TimeRange.day(), + source = SourceFilter.App(context.packageName), + ) + val uids = own.mapNotNull { it.uid } + if (uids.isEmpty()) return@flow emit("No heart rate written by this app today") + writer.delete(HealthType.HEART_RATE, uids) + "Deleted ${uids.size} heart rate samples:\n" + uids.joinToString("\n") + } + deleteInserted -> { + val samples = inserted.values.toList() + samples.forEach { writer.delete(it) } + inserted.clear() + "Deleted ${samples.size} samples" + } + else -> error("Unknown row ${row.id}") + } + emit(output) + } + + /** A plausible sample of [type] that ends now. */ + private fun sample( + type: HealthType, + now: Long, + ): WritableSample { + val start = now - HOUR + return when (type) { + HealthType.HEART_RATE -> HeartRate(start, now, HeartRate.Harmonized(heartRate = 72f, min = 60f, max = 90f)) + HealthType.BLOOD_OXYGEN -> + BloodOxygen( + start, + now, + BloodOxygen.Harmonized(oxygenSaturation = 97f, min = 95f, max = 99f), + ) + HealthType.BLOOD_PRESSURE -> + BloodPressure( + now, + harmonized = BloodPressure.Harmonized(systolic = 120f, diastolic = 80f, mean = 93f, pulseRate = 65), + ) + HealthType.BLOOD_GLUCOSE -> + BloodGlucose( + now, + harmonized = + BloodGlucose.Harmonized( + glucoseLevel = 5.4f, + mealStatus = MealStatus.FASTING, + measurementType = GlucoseMeasurementType.WHOLE_BLOOD, + ), + ) + HealthType.BODY_COMPOSITION -> + BodyComposition( + now, + harmonized = BodyComposition.Harmonized(weight = 80f, height = 180f, bodyFat = 18f), + ) + HealthType.BODY_TEMPERATURE -> + BodyTemperature( + now, + harmonized = BodyTemperature.Harmonized(temperature = 36.6f), + ) + HealthType.EXERCISE -> exercise(start, now) + HealthType.FLOORS_CLIMBED -> FloorsClimbed(start, now, FloorsClimbed.Harmonized(floor = 3f)) + HealthType.SLEEP -> sleep(start, now) + HealthType.NUTRITION -> + Nutrition( + now, + harmonized = + Nutrition.Harmonized( + title = "Apple", + mealType = MealType.MORNING_SNACK, + calories = 95f, + ), + ) + HealthType.WATER_INTAKE -> WaterIntake(now, harmonized = WaterIntake.Harmonized(amount = 250f)) + HealthType.SKIN_TEMPERATURE, + HealthType.ENERGY_SCORE, + HealthType.IRREGULAR_HEART_RHYTHM_NOTIFICATION, + HealthType.SLEEP_APNEA, + HealthType.STEPS, + HealthType.STEPS_GOAL, + HealthType.ACTIVITY_SUMMARY, + HealthType.ACTIVE_CALORIES_BURNED_GOAL, + HealthType.ACTIVE_TIME_GOAL, + HealthType.EXERCISE_LOCATION, + HealthType.SLEEP_GOAL, + HealthType.NUTRITION_GOAL, + HealthType.WATER_INTAKE_GOAL, + HealthType.USER_PROFILE, + -> error("${type.identifier} is read-only") + } + } + + private fun exercise( + start: Long, + now: Long, + ): Exercise = + Exercise( + start, + now, + Exercise.Harmonized( + exerciseType = ExerciseType.RUNNING, + sessions = + listOf( + Exercise.Session( + exerciseType = ExerciseType.RUNNING, + startTimestamp = start, + endTimestamp = now, + durationMillis = HOUR, + calories = 600f, + distance = 10_000f, + meanHeartRate = 150f, + ), + ), + ), + ) + + private fun sleep( + start: Long, + now: Long, + ): Sleep = + Sleep( + start, + now, + Sleep.Harmonized( + durationMillis = HOUR, + sessions = + listOf( + Sleep.Session( + startTimestamp = start, + endTimestamp = now, + durationMillis = HOUR, + stages = + listOf( + Sleep.Stage(start, start + HOUR / 2, SleepStageType.LIGHT), + Sleep.Stage(start + HOUR / 2, now, SleepStageType.DEEP), + ), + ), + ), + ), + ) + + private companion object { + const val HOUR = 3_600_000L + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 4fc2444..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index fac9291..51ccb30 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,10 +1,4 @@ - - - - \ No newline at end of file + +