From ec7c936deb806ca168e1dc3ed6a691655d4387c7 Mon Sep 17 00:00:00 2001 From: kvs-coder Date: Fri, 9 Oct 2026 03:48:16 -0500 Subject: [PATCH 01/12] refactor!: rebuild SamsungHealthReporter on the Samsung Health Data SDK Samsung deprecated the Samsung Health SDK for Android on 31.07.2025 (end of service 2028). This rebuilds the library on the Samsung Health Data SDK 1.1.0 with the HealthKitReporter architecture: one SamsungHealthReporter facade sharing one HealthDataStore between a reader, writer, observer and manager, library-owned types and @Serializable payloads, suspend-first API, and no SDK type in the public API. The SDK AAR is login-gated and not redistributable: the library compiles against library/libs/*.aar when present, otherwise against a never-published stub written from the API reference. Changes: - library/src/main/kotlin: facade, SamsungHealthException, services (read, readPage, userProfile, aggregate, insert, update, delete, changes, observe, permissions, resolve, devices), 25 HealthTypes, 20 Aggregations, 15 sample payloads + UserProfile, value enums, decorators - library/src/test/kotlin: 67 tests (payload round trips, type registry, services against a mocked HealthDataStore, Robolectric for writes) - library/api/library.api: public API dump - samsung-health-data-stub/: documented-API stand-in for the SDK AAR - app/: Jetpack Compose MVVM demo with a row per public library call - Gradle 8.14, AGP 8.13.2, Kotlin 2.2.21, Kotlin DSL, version catalog, ktlint, detekt, Kover, binary-compatibility-validator; drop jcenter/Bintray - .github/workflows: CI and release-please; jitpack.yml - AGENTS.md (+ CLAUDE.md symlink), README, CONTRIBUTING, PR template, docs/adr 0001-0003 - remove the 0.0.x sources, the 1.4.0 SDK jar and tracked .idea files Tests: 67 unit tests green against the real Samsung Health Data SDK 1.1.0 and against the stub; line coverage 98.3% (koverVerify minBound 98); ktlint, detekt, Android lint and apiCheck clean; library and Example app build. BREAKING CHANGE: 0.0.x API (resolvers, SessionType/DiscreteType, listener interfaces, Gson json) is replaced. Add the Samsung Health Data SDK AAR to your app, create SamsungHealthReporter(context), request permissions with manager.requestPermissions and read/write through reader/writer; see the README migration table. --- .editorconfig | 20 + .github/pull_request_template.md | 40 +- .github/workflows/ci.yml | 73 + .github/workflows/release.yml | 18 + .gitignore | 3 + .idea/codeStyles/Project.xml | 129 - .idea/codeStyles/codeStyleConfig.xml | 5 - .idea/compiler.xml | 6 - .idea/dictionaries/florian.xml | 8 - .idea/gradle.xml | 24 - .idea/misc.xml | 9 - .idea/runConfigurations.xml | 12 - .idea/vcs.xml | 6 - .release-please-manifest.json | 1 + AGENTS.md | 395 +++ CHANGELOG.md | 6 +- CLAUDE.md | 1 + CONTRIBUTING.md | 59 +- README.md | 296 ++- app/build.gradle | 37 - app/build.gradle.kts | 62 + .../example/ExampleInstrumentedTest.kt | 24 - app/src/main/AndroidManifest.xml | 11 +- .../example/MainActivity.kt | 196 -- .../example/MainActivity.kt | 31 + .../example/demo/DemoRow.kt | 20 + .../example/demo/DemoScreen.kt | 169 ++ .../example/demo/DemoState.kt | 38 + .../example/demo/DemoViewModel.kt | 77 + .../example/service/DemoPerformer.kt | 40 + .../example/service/ManagerDemos.kt | 91 + .../example/service/ObserverDemos.kt | 71 + .../example/service/ReaderDemos.kt | 89 + .../service/SamsungHealthReporterService.kt | 49 + .../example/service/WriterDemos.kt | 249 ++ app/src/main/res/layout/activity_main.xml | 18 - app/src/main/res/values/styles.xml | 12 +- .../example/ExampleUnitTest.kt | 17 - build.gradle | 35 - build.gradle.kts | 30 + detekt.yml | 30 + docs/adr/0001-samsung-health-data-sdk.md | 21 + docs/adr/0002-suspend-first-api.md | 19 + .../0003-sdk-aar-compile-only-with-stub.md | 33 + gradle.properties | 29 +- gradle/libs.versions.toml | 50 + gradle/wrapper/gradle-wrapper.jar | Bin 54329 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 5 +- gradlew | 305 ++- gradlew.bat | 80 +- jitpack.yml | 4 + library/api/library.api | 2339 +++++++++++++++++ library/bintray.gradle | 45 - library/build.gradle | 48 - library/build.gradle.kts | 100 + library/install.gradle | 39 - library/libs/samsung-health-data-v1.4.0.jar | Bin 284412 -> 0 bytes .../ExampleInstrumentedTest.kt | 24 - library/src/main/AndroidManifest.xml | 54 +- .../kvs/samsunghealthreporter/DiscreteType.kt | 18 - .../kvs/samsunghealthreporter/HealthType.kt | 41 - .../SamsungHealthConnectionListener.kt | 9 - .../SamsungHealthExceptions.kt | 6 - .../SamsungHealthReporter.kt | 76 - .../kvs/samsunghealthreporter/SessionType.kt | 14 - .../decorator/Extensions+Date.kt | 33 - .../Extensions+HealthDataResolver.kt | 119 - .../decorator/Extensions+List.kt | 7 - .../decorator/Extensions+Number.kt | 12 - .../decorator/Extensions+PermissionKey.kt | 8 - .../manager/SamsungHealthManager.kt | 67 - .../SamsungHealthPermissionListener.kt | 9 - .../model/AggregateFunction.kt | 11 - .../kvs/samsunghealthreporter/model/Common.kt | 39 - .../kvs/samsunghealthreporter/model/Time.kt | 12 - .../model/discrete/Albumin.kt | 114 - .../model/discrete/Alp.kt | 115 - .../model/discrete/Discrete.kt | 17 - .../model/session/Electrocardiogram.kt | 208 -- .../model/session/Exercise.kt | 619 ----- .../model/session/FloorsClimbed.kt | 112 - .../model/session/HeartRate.kt | 172 -- .../model/session/OxygenSaturation.kt | 141 - .../model/session/Session.kt | 23 - .../model/session/Sleep.kt | 96 - .../model/session/SleepStage.kt | 138 - .../model/session/StepCount.kt | 217 -- .../observer/SamsungHealthObserver.kt | 46 - .../resolver/CommonResolver.kt | 80 - .../resolver/ElectrocardiogramResolver.kt | 117 - .../resolver/ExerciseResolver.kt | 151 -- .../resolver/FloorsClimbedResolver.kt | 108 - .../resolver/HeartRateResolver.kt | 105 - .../resolver/OxygenSaturationResolver.kt | 112 - .../resolver/SamsungHealthResolver.kt | 22 - .../resolver/SleepResolver.kt | 105 - .../resolver/SleepStageResolver.kt | 108 - .../resolver/StepCountResolver.kt | 107 - .../SamsungHealthException.kt | 51 + .../SamsungHealthReporter.kt | 68 + .../decorator/Extensions+AggregatedData.kt | 27 + .../decorator/Extensions+Device.kt | 14 + .../decorator/Extensions+Enum.kt | 8 + .../Extensions+HealthDataException.kt | 24 + .../decorator/Extensions+HealthDataPoint.kt | 36 + .../decorator/Extensions+HealthType.kt | 192 ++ .../decorator/Extensions+Map.kt | 26 + .../decorator/Extensions+Ordering.kt | 11 + .../decorator/Extensions+Permission.kt | 30 + .../decorator/Extensions+Sample.kt | 23 + .../decorator/Extensions+SourceFilter.kt | 12 + .../decorator/Extensions+TimeRange.kt | 61 + .../decorator/Extensions+WritableSample.kt | 46 + .../samsunghealthreporter/model/Aggregate.kt | 27 + .../samsunghealthreporter/model/ChangeSet.kt | 33 + .../samsunghealthreporter/model/DataSource.kt | 15 + .../kvs/samsunghealthreporter/model/Device.kt | 27 + .../samsunghealthreporter/model/Ordering.kt | 13 + .../kvs/samsunghealthreporter/model/Page.kt | 12 + .../samsunghealthreporter/model/Payload.kt | 82 + .../samsunghealthreporter/model/Permission.kt | 17 + .../model/SourceFilter.kt | 15 + .../samsunghealthreporter/model/TimeGroup.kt | 22 + .../samsunghealthreporter/model/TimeRange.kt | 78 + .../model/payload/BloodGlucose.kt | 146 + .../model/payload/BloodOxygen.kt | 139 + .../model/payload/BloodPressure.kt | 99 + .../model/payload/BodyComposition.kt | 127 + .../model/payload/BodyTemperature.kt | 83 + .../model/payload/EnergyScore.kt | 75 + .../model/payload/Exercise.kt | 308 +++ .../model/payload/FloorsClimbed.kt | 83 + .../model/payload/HeartRate.kt | 139 + .../IrregularHeartRhythmNotification.kt | 83 + .../model/payload/Nutrition.kt | 159 ++ .../model/payload/Sample.kt | 39 + .../model/payload/SkinTemperature.kt | 115 + .../model/payload/Sleep.kt | 167 ++ .../model/payload/SleepApnea.kt | 77 + .../model/payload/UserProfile.kt | 42 + .../model/payload/WaterIntake.kt | 83 + .../model/type/AccessType.kt | 13 + .../model/type/Aggregation.kt | 96 + .../model/type/ExerciseCountType.kt | 17 + .../model/type/ExerciseType.kt | 125 + .../model/type/Gender.kt | 15 + .../model/type/GlucoseMeasurementType.kt | 16 + .../model/type/GlucoseSampleSource.kt | 15 + .../model/type/HealthType.kt | 113 + .../model/type/IrregularHeartRhythmStatus.kt | 15 + .../model/type/MealStatus.kt | 26 + .../model/type/MealType.kt | 19 + .../model/type/SleepApneaSign.kt | 15 + .../model/type/SleepStageType.kt | 17 + .../model/type/TimeGroupUnit.kt | 14 + .../service/SamsungHealthManager.kt | 114 + .../service/SamsungHealthObserver.kt | 120 + .../service/SamsungHealthReader+Aggregate.kt | 146 + .../service/SamsungHealthReader.kt | 127 + .../service/SamsungHealthWriter.kt | 155 ++ .../samsunghealthreporter/ExampleUnitTest.kt | 17 - .../com/kvs/samsunghealthreporter/Fixtures.kt | 300 +++ .../payload/ReadOnlySampleTest.kt | 126 + .../payload/SampleTest.kt | 148 ++ .../service/SamsungHealthManagerTest.kt | 145 + .../service/SamsungHealthObserverTest.kt | 86 + .../service/SamsungHealthReaderTest.kt | 179 ++ .../service/SamsungHealthWriterTest.kt | 108 + .../samsunghealthreporter/type/TypeTest.kt | 100 + .../type/ValueTypeTest.kt | 90 + release-please-config.json | 22 + samsung-health-data-stub/build.gradle.kts | 31 + .../sdk/health/data/HealthDataService.kt | 55 + .../android/sdk/health/data/data/Data.kt | 162 ++ .../sdk/health/data/data/entries/Entries.kt | 313 +++ .../android/sdk/health/data/device/Device.kt | 30 + .../health/data/error/HealthDataException.kt | 37 + .../sdk/health/data/permission/Permission.kt | 18 + .../sdk/health/data/request/DataType.kt | 356 +++ .../sdk/health/data/request/Requests.kt | 465 ++++ .../sdk/health/data/response/DataResponse.kt | 8 + settings.gradle | 3 - settings.gradle.kts | 20 + 183 files changed, 11642 insertions(+), 4505 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml delete mode 100644 .idea/codeStyles/Project.xml delete mode 100644 .idea/codeStyles/codeStyleConfig.xml delete mode 100644 .idea/compiler.xml delete mode 100644 .idea/dictionaries/florian.xml delete mode 100644 .idea/gradle.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/runConfigurations.xml delete mode 100644 .idea/vcs.xml create mode 100644 .release-please-manifest.json create mode 100644 AGENTS.md create mode 120000 CLAUDE.md delete mode 100644 app/build.gradle create mode 100644 app/build.gradle.kts delete mode 100644 app/src/androidTest/java/com/kvs/samsunghealthreporter/example/ExampleInstrumentedTest.kt delete mode 100644 app/src/main/java/com/kvs/samsunghealthreporter/example/MainActivity.kt create mode 100644 app/src/main/kotlin/com/kvs/samsunghealthreporter/example/MainActivity.kt create mode 100644 app/src/main/kotlin/com/kvs/samsunghealthreporter/example/demo/DemoRow.kt create mode 100644 app/src/main/kotlin/com/kvs/samsunghealthreporter/example/demo/DemoScreen.kt create mode 100644 app/src/main/kotlin/com/kvs/samsunghealthreporter/example/demo/DemoState.kt create mode 100644 app/src/main/kotlin/com/kvs/samsunghealthreporter/example/demo/DemoViewModel.kt create mode 100644 app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/DemoPerformer.kt create mode 100644 app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/ManagerDemos.kt create mode 100644 app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/ObserverDemos.kt create mode 100644 app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/ReaderDemos.kt create mode 100644 app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/SamsungHealthReporterService.kt create mode 100644 app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/WriterDemos.kt delete mode 100644 app/src/main/res/layout/activity_main.xml delete mode 100644 app/src/test/java/com/kvs/samsunghealthreporter/example/ExampleUnitTest.kt delete mode 100644 build.gradle create mode 100644 build.gradle.kts create mode 100644 detekt.yml create mode 100644 docs/adr/0001-samsung-health-data-sdk.md create mode 100644 docs/adr/0002-suspend-first-api.md create mode 100644 docs/adr/0003-sdk-aar-compile-only-with-stub.md create mode 100644 gradle/libs.versions.toml create mode 100644 jitpack.yml create mode 100644 library/api/library.api delete mode 100644 library/bintray.gradle delete mode 100644 library/build.gradle create mode 100644 library/build.gradle.kts delete mode 100644 library/install.gradle delete mode 100644 library/libs/samsung-health-data-v1.4.0.jar delete mode 100644 library/src/androidTest/java/com/kvs/samsunghealthreporter/ExampleInstrumentedTest.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/DiscreteType.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/HealthType.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/SamsungHealthConnectionListener.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/SamsungHealthExceptions.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/SamsungHealthReporter.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/SessionType.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/decorator/Extensions+Date.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/decorator/Extensions+HealthDataResolver.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/decorator/Extensions+List.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/decorator/Extensions+Number.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/decorator/Extensions+PermissionKey.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/manager/SamsungHealthManager.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/manager/SamsungHealthPermissionListener.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/model/AggregateFunction.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/model/Common.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/model/Time.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/model/discrete/Albumin.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/model/discrete/Alp.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/model/discrete/Discrete.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/model/session/Electrocardiogram.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/model/session/Exercise.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/model/session/FloorsClimbed.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/model/session/HeartRate.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/model/session/OxygenSaturation.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/model/session/Session.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/model/session/Sleep.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/model/session/SleepStage.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/model/session/StepCount.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/observer/SamsungHealthObserver.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/resolver/CommonResolver.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/resolver/ElectrocardiogramResolver.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/resolver/ExerciseResolver.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/resolver/FloorsClimbedResolver.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/resolver/HeartRateResolver.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/resolver/OxygenSaturationResolver.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/resolver/SamsungHealthResolver.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/resolver/SleepResolver.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/resolver/SleepStageResolver.kt delete mode 100644 library/src/main/java/com/kvs/samsunghealthreporter/resolver/StepCountResolver.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/SamsungHealthException.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/SamsungHealthReporter.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/decorator/Extensions+AggregatedData.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/decorator/Extensions+Device.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/decorator/Extensions+Enum.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/decorator/Extensions+HealthDataException.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/decorator/Extensions+HealthDataPoint.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/decorator/Extensions+HealthType.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/decorator/Extensions+Map.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/decorator/Extensions+Ordering.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/decorator/Extensions+Permission.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/decorator/Extensions+Sample.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/decorator/Extensions+SourceFilter.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/decorator/Extensions+TimeRange.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/decorator/Extensions+WritableSample.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/Aggregate.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/ChangeSet.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/DataSource.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/Device.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/Ordering.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/Page.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/Payload.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/Permission.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/SourceFilter.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/TimeGroup.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/TimeRange.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/payload/BloodGlucose.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/payload/BloodOxygen.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/payload/BloodPressure.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/payload/BodyComposition.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/payload/BodyTemperature.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/payload/EnergyScore.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/payload/Exercise.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/payload/FloorsClimbed.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/payload/HeartRate.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/payload/IrregularHeartRhythmNotification.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/payload/Nutrition.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/payload/Sample.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/payload/SkinTemperature.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/payload/Sleep.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/payload/SleepApnea.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/payload/UserProfile.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/payload/WaterIntake.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/type/AccessType.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/type/Aggregation.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/type/ExerciseCountType.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/type/ExerciseType.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/type/Gender.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/type/GlucoseMeasurementType.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/type/GlucoseSampleSource.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/type/HealthType.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/type/IrregularHeartRhythmStatus.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/type/MealStatus.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/type/MealType.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/type/SleepApneaSign.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/type/SleepStageType.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/model/type/TimeGroupUnit.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/service/SamsungHealthManager.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/service/SamsungHealthObserver.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/service/SamsungHealthReader+Aggregate.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/service/SamsungHealthReader.kt create mode 100644 library/src/main/kotlin/com/kvs/samsunghealthreporter/service/SamsungHealthWriter.kt delete mode 100644 library/src/test/java/com/kvs/samsunghealthreporter/ExampleUnitTest.kt create mode 100644 library/src/test/kotlin/com/kvs/samsunghealthreporter/Fixtures.kt create mode 100644 library/src/test/kotlin/com/kvs/samsunghealthreporter/payload/ReadOnlySampleTest.kt create mode 100644 library/src/test/kotlin/com/kvs/samsunghealthreporter/payload/SampleTest.kt create mode 100644 library/src/test/kotlin/com/kvs/samsunghealthreporter/service/SamsungHealthManagerTest.kt create mode 100644 library/src/test/kotlin/com/kvs/samsunghealthreporter/service/SamsungHealthObserverTest.kt create mode 100644 library/src/test/kotlin/com/kvs/samsunghealthreporter/service/SamsungHealthReaderTest.kt create mode 100644 library/src/test/kotlin/com/kvs/samsunghealthreporter/service/SamsungHealthWriterTest.kt create mode 100644 library/src/test/kotlin/com/kvs/samsunghealthreporter/type/TypeTest.kt create mode 100644 library/src/test/kotlin/com/kvs/samsunghealthreporter/type/ValueTypeTest.kt create mode 100644 release-please-config.json create mode 100644 samsung-health-data-stub/build.gradle.kts create mode 100644 samsung-health-data-stub/src/main/kotlin/com/samsung/android/sdk/health/data/HealthDataService.kt create mode 100644 samsung-health-data-stub/src/main/kotlin/com/samsung/android/sdk/health/data/data/Data.kt create mode 100644 samsung-health-data-stub/src/main/kotlin/com/samsung/android/sdk/health/data/data/entries/Entries.kt create mode 100644 samsung-health-data-stub/src/main/kotlin/com/samsung/android/sdk/health/data/device/Device.kt create mode 100644 samsung-health-data-stub/src/main/kotlin/com/samsung/android/sdk/health/data/error/HealthDataException.kt create mode 100644 samsung-health-data-stub/src/main/kotlin/com/samsung/android/sdk/health/data/permission/Permission.kt create mode 100644 samsung-health-data-stub/src/main/kotlin/com/samsung/android/sdk/health/data/request/DataType.kt create mode 100644 samsung-health-data-stub/src/main/kotlin/com/samsung/android/sdk/health/data/request/Requests.kt create mode 100644 samsung-health-data-stub/src/main/kotlin/com/samsung/android/sdk/health/data/response/DataResponse.kt delete mode 100644 settings.gradle create mode 100644 settings.gradle.kts 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..9321e16 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +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-latest + steps: + - uses: actions/checkout@v4 + - 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-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: corretto + java-version: 17 + - uses: gradle/actions/setup-gradle@v4 + # Samsung's license forbids redistributing the SDK, so the real AAR only comes from a secret. Without it + # (fork PRs, no secret yet) the build uses the samsung-health-data-stub module written from the API reference. + - name: Provide the Samsung Health Data SDK + env: + SAMSUNG_HEALTH_DATA_AAR_BASE64: ${{ secrets.SAMSUNG_HEALTH_DATA_AAR_BASE64 }} + run: | + if [ -n "$SAMSUNG_HEALTH_DATA_AAR_BASE64" ]; then + echo "$SAMSUNG_HEALTH_DATA_AAR_BASE64" | base64 --decode > library/libs/samsung-health-data-api.aar + echo "Building against the real Samsung Health Data SDK" + else + echo "::warning::SAMSUNG_HEALTH_DATA_AAR_BASE64 is not set; building against the SDK stub" + fi + - 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@v4 + 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..774b235 --- /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-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.gitignore b/.gitignore index 2a8980d..6c4ff0b 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,6 @@ lint/tmp/ # Android Profiling *.hprof .idea/jarRepositories.xml + +# Samsung Health Data SDK: license forbids redistribution, add it locally (see README) +library/libs/*.aar 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..c46823c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,395 @@ +# 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) + +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.NotAvailable(" needs Samsung Health Data SDK X.Y")` or degrades gracefully. +* ❌ **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 coverage in `library/build.gradle.kts` (`minBound(98)` as of 09.10.2026, measured 98.3%); quote the measured percentage. A PR that adds tests raises the bound to its new measured level (rounded down); never lower it. + +--- + +## 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. File one on GitHub with the `github-create` skill (or `gh issue create --body-file ISSUE-.md`), then delete the local file in the PR that references it. + +--- + +## 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..5e1188b 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,300 @@ # 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. Reading works in developer mode; **writing, and distributing your app, needs Samsung partner approval** ([partner request](https://developer.samsung.com/health/data/overview.html)). + +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 | +| :--- | :--- | +| `NotAvailable` | Samsung Health or the feature isn't available | +| `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). + +## 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..b480625 --- /dev/null +++ b/app/src/main/kotlin/com/kvs/samsunghealthreporter/example/service/ManagerDemos.kt @@ -0,0 +1,91 @@ +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.type.HealthType +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 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, 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 } + } + 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) + } + + private val com.kvs.samsunghealthreporter.model.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 + +