diff --git a/.github/workflows/health-android.yml b/.github/workflows/health-android.yml new file mode 100644 index 00000000000..bed703caaf8 --- /dev/null +++ b/.github/workflows/health-android.yml @@ -0,0 +1,162 @@ +--- +name: Health (Health Connect) integration + +# Scoped to the health feature. The load-bearing artifact here is +# CN1HealthConnectBridge.kt: it is a plugin *resource* compiled by the app's +# own Gradle build, so no job in this repository compiles it. Without this +# workflow a Kotlin syntax error, a wrong androidx.health.connect API or a +# record class that does not exist ships straight to customers. +# +# The health smoke source deliberately lives outside the Hello Codename One +# source root and is copied in here. Referencing com.codename1.health raises +# minSdkVersion to 26, and at 26 the platform supplies java.time instead of +# the desugared library -- which changes behaviour for unrelated tests. The +# shared conformance suite must keep running at the framework's normal floor. +'on': + pull_request: + paths: + - '.github/workflows/health-android.yml' + - 'CodenameOne/src/com/codename1/health/**' + - 'CodenameOne/src/com/codename1/impl/health/**' + - 'Ports/Android/src/com/codename1/impl/android/AndroidHealth*.java' + - 'Ports/Android/src/com/codename1/impl/android/HealthConnectDelegate.java' + - 'Ports/Android/src/com/codename1/health/**' + - 'maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/health/**' + - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthManifestFragments.java' + - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthListenerBindings.java' + - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java' + - 'scripts/hellocodenameone/health-smoke/**' + push: + branches: + - master + paths: + - '.github/workflows/health-android.yml' + - 'CodenameOne/src/com/codename1/health/**' + - 'CodenameOne/src/com/codename1/impl/health/**' + - 'Ports/Android/src/com/codename1/impl/android/AndroidHealth*.java' + - 'Ports/Android/src/com/codename1/impl/android/HealthConnectDelegate.java' + - 'Ports/Android/src/com/codename1/health/**' + - 'maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/health/**' + - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthManifestFragments.java' + - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthListenerBindings.java' + - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java' + - 'scripts/hellocodenameone/health-smoke/**' + +jobs: + health-android: + name: Health Connect Android + permissions: + contents: read + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + - name: Set TMPDIR + run: echo "TMPDIR=${{ runner.temp }}" >> $GITHUB_ENV + - name: Cache codenameone-tools + uses: actions/cache@v5 + with: + path: ${{ runner.temp }}/codenameone-tools + key: ${{ runner.os }}-cn1-tools-${{ hashFiles('scripts/setup-workspace.sh') }} + restore-keys: | + ${{ runner.os }}-cn1-tools- + - name: Cache Maven repository + uses: actions/cache@v5 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-health-android-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2-health-android- + ${{ runner.os }}-m2- + - name: Cache Gradle + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-health-${{ hashFiles('scripts/hellocodenameone/**/build.gradle*', 'Ports/Android/build.gradle*') }} + restore-keys: | + ${{ runner.os }}-gradle-health- + ${{ runner.os }}-gradle- + - name: Setup workspace + run: ./scripts/setup-workspace.sh -q -DskipTests + - name: Build Android port + run: ./scripts/build-android-port.sh -q -DskipTests + - name: Make the smoke app a health app + run: | + set -euo pipefail + dest=scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone + mkdir -p "$dest" + cp scripts/hellocodenameone/health-smoke/HealthSmokeDemo.java "$dest/" + # The android.health.* hints are already in the checked-in + # settings and stay inert until this file is copied in: the + # builder only demands them once the class scanner sees a + # com.codename1.health reference. They cannot be derived from + # bytecode -- the scanner never sees a field read, so + # HealthDataType.STEPS is invisible to it. + # This is the step that earns the workflow. build-android-app.sh runs + # gradlew assembleDebug, which is the only place + # CN1HealthConnectBridge.kt is ever compiled against a real + # androidx.health.connect artifact. + - name: Build Hello Codename One Android app (health-enabled) + id: build-android-app + run: | + mkdir -p ~/.codenameone + cp maven/UpdateCodenameOne.jar ~/.codenameone/ + ./scripts/build-android-app.sh -q -DskipTests + - name: Assert Health Connect wiring was injected + run: | + set -euo pipefail + dir="${{ steps.build-android-app.outputs.gradle_project_dir }}" + echo "Gradle project: $dir" + find "$dir" -name 'CN1HealthConnectBridge.kt' | grep -q . \ + || { echo "::error::Health Connect bridge not copied into project"; exit 1; } + grep -Rqs --include='build.gradle*' "androidx.health.connect:connect-client" "$dir" \ + || { echo "::error::connect-client dependency not injected"; exit 1; } + grep -Rqs --include=AndroidManifest.xml \ + "android.permission.health.READ_STEPS" "$dir" \ + || { echo "::error::per-type read permission missing from manifest"; exit 1; } + grep -Rqs --include=AndroidManifest.xml \ + "android.permission.health.WRITE_STEPS" "$dir" \ + || { echo "::error::per-type write permission missing from manifest"; exit 1; } + grep -Rqs --include=AndroidManifest.xml \ + "com.google.android.apps.healthdata" "$dir" \ + || { echo "::error::Health Connect package query missing from manifest"; exit 1; } + # READ_HEART_RATE is a prefix of READ_HEART_RATE_VARIABILITY, so a + # substring match would pass even when dedup is broken. Match the + # closing quote to prove the exact permission was emitted. + grep -Rqs --include=AndroidManifest.xml \ + 'android.permission.health.READ_HEART_RATE"' "$dir" \ + || { echo "::error::exact READ_HEART_RATE permission missing"; exit 1; } + echo "Health Connect wiring present." + - name: Assert the bridge reached the dex + run: | + set -euo pipefail + dir="${{ steps.build-android-app.outputs.gradle_project_dir }}" + apk="$(find "$dir" -name '*debug*.apk' | head -1)" + [ -n "$apk" ] || { echo "::error::no debug APK produced"; exit 1; } + work="$(mktemp -d)" + unzip -qo "$apk" -d "$work" + dexdump="$(find "$ANDROID_HOME/build-tools" -name dexdump | sort | tail -1)" + found=0 + for dex in "$work"/*.dex; do + if "$dexdump" -f "$dex" 2>/dev/null \ + | grep -q "Lcom/codename1/health/CN1HealthConnectBridge;"; then + found=1 + break + fi + done + [ "$found" = 1 ] \ + || { echo "::error::bridge compiled but was shrunk out of the dex"; exit 1; } + echo "Bridge present in the dex." + - name: Upload build output + if: always() + uses: actions/upload-artifact@v7 + with: + name: health-android-artifacts + path: | + ${{ steps.build-android-app.outputs.gradle_project_dir }}/app/build/outputs/apk/debug/*.apk + ${{ steps.build-android-app.outputs.gradle_project_dir }}/app/src/main/AndroidManifest.xml + ${{ steps.build-android-app.outputs.gradle_project_dir }}/app/src/main/java/com/codename1/health/CN1HealthConnectBridge.kt + if-no-files-found: warn + retention-days: 14 diff --git a/CodenameOne/src/com/codename1/health/AggregateMetric.java b/CodenameOne/src/com/codename1/health/AggregateMetric.java new file mode 100644 index 00000000000..36a918d93b7 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/AggregateMetric.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// What to compute over an aggregation bucket. Which metrics are +/// meaningful depends on the type's [HealthAggregationStyle]; asking for a +/// nonsensical combination -- the total of a body-mass series, say -- +/// fails with [HealthError#INVALID_ARGUMENT] rather than returning a +/// number nobody should trust. +public enum AggregateMetric { + + /// The sum across the bucket. Meaningful for + /// [HealthAggregationStyle#CUMULATIVE] types only. + TOTAL, + + /// The mean across the bucket. Meaningful for + /// [HealthAggregationStyle#DISCRETE] types. + /// + /// Interval samples are weighted by duration and instantaneous samples + /// are weighted equally, so a heart rate held for ten minutes counts + /// for more than a single spot reading. + AVERAGE, + + /// The smallest value in the bucket. [HealthAggregationStyle#DISCRETE] + /// types. + MINIMUM, + + /// The largest value in the bucket. [HealthAggregationStyle#DISCRETE] + /// types. + MAXIMUM, + + /// How many samples fell in the bucket. Meaningful for every type. + COUNT, + + /// The total time covered by samples in the bucket, in milliseconds. + /// Meaningful for every type; the natural metric for sessions. + DURATION, + + /// The most recent value in the bucket. + /// [HealthAggregationStyle#DISCRETE] types. + LATEST +} diff --git a/CodenameOne/src/com/codename1/health/AggregateQuery.java b/CodenameOne/src/com/codename1/health/AggregateQuery.java new file mode 100644 index 00000000000..954ec5477ef --- /dev/null +++ b/CodenameOne/src/com/codename1/health/AggregateQuery.java @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// Describes a bucketed summary read against [HealthStore]. +/// +/// ```java +/// AggregateQuery q = new AggregateQuery() +/// .addType(HealthDataType.STEPS) +/// .addMetric(AggregateMetric.TOTAL) +/// .setTimeRange(HealthTimeRange.calendarDays(7, TimeZone.getDefault())) +/// .setBucket(HealthInterval.calendarDays(1, TimeZone.getDefault())); +/// ``` +/// +/// #### Overlapping sources are counted twice, on every platform +/// +/// When a phone and a watch both record steps for the same walk, the store +/// holds two overlapping sets of samples, and a total over them counts the +/// walk twice. +/// +/// **This includes iOS.** HealthKit's statistics engine does de-duplicate +/// overlapping sources -- but no port uses it in this release. Every +/// metric here is computed by shared code from raw samples read back +/// through an ordinary query, so that the bucket arithmetic has one +/// implementation rather than one per platform that can drift. iOS +/// therefore double-counts exactly as Android does. A port that grows a +/// native aggregate path would change that, and this note with it. +/// +/// This API does not paper over the overlap with a heuristic +/// de-duplicator -- guessing which of two overlapping sources is +/// authoritative is exactly the kind of silent wrongness health data +/// cannot afford. Use [#addSource(String)] to pin the query to the source +/// you trust, and tell the user which device a figure came from. +public final class AggregateQuery { + + private final List types = new ArrayList(); + private final List metrics = + new ArrayList(); + private final List sources = new ArrayList(); + private HealthTimeRange timeRange; + private HealthInterval bucket; + private HealthUnit unit; + + /// Adds a type to summarize. At least one is required. + public AggregateQuery addType(HealthDataType type) { + if (type != null && !types.contains(type)) { + types.add(type); + } + return this; + } + + /// The types this query summarizes. + public List getTypes() { + return Collections.unmodifiableList(types); + } + + /// Adds a metric to compute. At least one is required. + public AggregateQuery addMetric(AggregateMetric metric) { + if (metric != null && !metrics.contains(metric)) { + metrics.add(metric); + } + return this; + } + + /// The metrics this query computes. + public List getMetrics() { + return Collections.unmodifiableList(metrics); + } + + /// Restricts the summary to one writing app -- see the double-counting + /// warning on this class. + public AggregateQuery addSource(String bundleId) { + if (bundleId != null && !sources.contains(bundleId)) { + sources.add(bundleId); + } + return this; + } + + /// The source filter, empty when unrestricted. + public List getSources() { + return Collections.unmodifiableList(sources); + } + + /// The span to summarize. Required. + public AggregateQuery setTimeRange(HealthTimeRange timeRange) { + this.timeRange = timeRange; + return this; + } + + /// The span this query summarizes. + public HealthTimeRange getTimeRange() { + return timeRange; + } + + /// Splits the range into buckets of this width. Leave unset for a + /// single bucket covering the whole range. + /// + /// Prefer [HealthInterval#calendarDays(int,java.util.TimeZone)] over a + /// fixed 24-hour width whenever the buckets are labelled with dates in + /// your UI -- see [HealthInterval]. + public AggregateQuery setBucket(HealthInterval bucket) { + this.bucket = bucket; + return this; + } + + /// The bucket width, or null for one bucket over the whole range. + public HealthInterval getBucket() { + return bucket; + } + + /// Returns aggregated values in `unit` rather than each type's + /// canonical unit. + public AggregateQuery setUnit(HealthUnit unit) { + this.unit = unit; + return this; + } + + /// The requested unit, or null for canonical units. + public HealthUnit getUnit() { + return unit; + } + + /// Validates the query and throws if it cannot be run. + /// + /// #### Throws + /// + /// - `HealthException`: [HealthError#INVALID_ARGUMENT] for a missing + /// type, metric or range, or for a metric that is meaningless for + /// the requested type; [HealthError#UNIT_MISMATCH] for an + /// incompatible unit. + public void validate() throws HealthException { + if (types.isEmpty()) { + throw new HealthException(HealthError.INVALID_ARGUMENT, + "an aggregate query needs at least one data type"); + } + if (metrics.isEmpty()) { + throw new HealthException(HealthError.INVALID_ARGUMENT, + "an aggregate query needs at least one metric"); + } + if (timeRange == null) { + throw new HealthException(HealthError.INVALID_ARGUMENT, + "an aggregate query needs a time range"); + } + for (HealthDataType t : types) { + for (AggregateMetric m : metrics) { + if (!isMeaningful(t, m)) { + throw new HealthException(HealthError.INVALID_ARGUMENT, + m + " is not meaningful for " + t.getId() + + ", which aggregates as " + + t.getAggregationStyle()); + } + } + if (unit != null) { + HealthUnit canonical = t.getCanonicalUnit(); + if (canonical == null || !canonical.isCompatibleWith(unit)) { + throw new HealthException(HealthError.UNIT_MISMATCH, + "cannot express " + t.getId() + " in " + + unit.getSymbol()); + } + } + } + } + + /// Whether `metric` says anything true about `type`. + /// + /// [AggregateMetric#COUNT] and [AggregateMetric#DURATION] apply to + /// everything. Summing a discrete series -- the total of every body + /// mass ever recorded -- and averaging a cumulative one -- the mean of + /// arbitrarily-chunked step totals -- are both meaningless, so they + /// are rejected rather than answered. + @SuppressWarnings("PMD.CompareObjectsWithEquals") + public static boolean isMeaningful(HealthDataType type, + AggregateMetric metric) { + if (type == null || metric == null) { + return false; + } + if (metric == AggregateMetric.COUNT + || metric == AggregateMetric.DURATION) { + return true; + } + // Composite types carry more than one number, so there is no single + // value to average or compare. Accepting the metric and returning + // null looked like "no data" for a bucket that genuinely had + // readings, which is the worse of the two answers. + if (type == HealthDataType.BLOOD_PRESSURE) { + return false; + } + HealthAggregationStyle style = type.getAggregationStyle(); + if (style == HealthAggregationStyle.CUMULATIVE) { + return metric == AggregateMetric.TOTAL; + } + if (style == HealthAggregationStyle.DISCRETE) { + return metric == AggregateMetric.AVERAGE + || metric == AggregateMetric.MINIMUM + || metric == AggregateMetric.MAXIMUM + || metric == AggregateMetric.LATEST; + } + return false; + } +} diff --git a/CodenameOne/src/com/codename1/health/AggregateResult.java b/CodenameOne/src/com/codename1/health/AggregateResult.java new file mode 100644 index 00000000000..b84b1a8be03 Binary files /dev/null and b/CodenameOne/src/com/codename1/health/AggregateResult.java differ diff --git a/CodenameOne/src/com/codename1/health/BloodPressureSample.java b/CodenameOne/src/com/codename1/health/BloodPressureSample.java new file mode 100644 index 00000000000..0d03ab7c459 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/BloodPressureSample.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// A blood-pressure reading: systolic and diastolic together, plus an +/// optional pulse. +/// +/// #### One sample, not a correlation +/// +/// HealthKit models blood pressure as an `HKCorrelation` wrapping two +/// separate quantity samples; Health Connect has a single +/// `BloodPressureRecord` and no correlation concept at all. This API +/// follows Health Connect, because a portable `Correlation` type would be +/// a fiction that only one platform actually has -- Android would have to +/// synthesize it and every caller would have to destructure it. +/// +/// #### Local and simulator only in this release +/// +/// Neither phone carries this shape. The iOS type map has no +/// `blood_pressure` entry, so the correlation assembly the port will need +/// is not written, and Health Connect's record is not mapped either: a +/// read or write of [HealthDataType#BLOOD_PRESSURE] on a device is +/// refused with [HealthError#TYPE_NOT_SUPPORTED] rather than dropped +/// without a word. It works fully against the local and simulator +/// stores. Until that changes, take the reading off the cuff's +/// [com.codename1.health.sensors.SensorSession] and keep it yourself. +public final class BloodPressureSample extends HealthSample { + + /// The body position was not recorded. + public static final int POSITION_UNKNOWN = 0; + /// Measured while standing. + public static final int POSITION_STANDING = 1; + /// Measured while sitting. + public static final int POSITION_SITTING = 2; + /// Measured while lying down. + public static final int POSITION_LYING_DOWN = 3; + /// Measured while reclining. + public static final int POSITION_RECLINING = 4; + + /// The measurement site was not recorded. + public static final int LOCATION_UNKNOWN = 0; + /// Left upper arm. + public static final int LOCATION_LEFT_UPPER_ARM = 1; + /// Right upper arm. + public static final int LOCATION_RIGHT_UPPER_ARM = 2; + /// Left wrist. + public static final int LOCATION_LEFT_WRIST = 3; + /// Right wrist. + public static final int LOCATION_RIGHT_WRIST = 4; + + private final HealthQuantity systolic; + private final HealthQuantity diastolic; + private HealthQuantity pulse; + private int bodyPosition = POSITION_UNKNOWN; + private int measurementLocation = LOCATION_UNKNOWN; + + private BloodPressureSample(HealthQuantity systolic, + HealthQuantity diastolic, long instantMillis) { + super(HealthDataType.BLOOD_PRESSURE, instantMillis, instantMillis); + if (systolic == null || diastolic == null) { + throw new IllegalArgumentException( + "blood pressure requires both systolic and diastolic"); + } + this.systolic = systolic; + this.diastolic = diastolic; + } + + /// A reading in millimetres of mercury, the unit both platforms and + /// every cuff use. + public static BloodPressureSample create(double systolicMmHg, + double diastolicMmHg, long instantMillis) { + return new BloodPressureSample( + new HealthQuantity(systolicMmHg, + HealthUnit.MILLIMETER_OF_MERCURY), + new HealthQuantity(diastolicMmHg, + HealthUnit.MILLIMETER_OF_MERCURY), + instantMillis); + } + + /// A reading given as explicit pressure quantities. + public static BloodPressureSample create(HealthQuantity systolic, + HealthQuantity diastolic, long instantMillis) { + // Dimension-checked here, because nothing downstream will. Shared + // write validation only converts QuantitySample, so a reading + // given in kilograms was stored by the local and simulator stores + // exactly as handed in, and only blew up later when something + // asked for it in mmHg. + requirePressure(systolic, "systolic"); + requirePressure(diastolic, "diastolic"); + return new BloodPressureSample(systolic, diastolic, instantMillis); + } + + private static void requirePressure(HealthQuantity q, String which) { + if (q == null) { + throw new IllegalArgumentException( + "a blood pressure reading needs a " + which + " value"); + } + if (!q.getUnit().isCompatibleWith( + HealthUnit.MILLIMETER_OF_MERCURY)) { + throw new IllegalArgumentException(which + " must be a pressure," + + " but " + q.getUnit().getSymbol() + " measures " + + q.getUnit().getDimension()); + } + } + + /// The systolic pressure. + public HealthQuantity getSystolic() { + return systolic; + } + + /// The diastolic pressure. + public HealthQuantity getDiastolic() { + return diastolic; + } + + /// The pulse recorded with this reading, or null. Most cuffs report + /// one; neither store requires it. + public HealthQuantity getPulse() { + return pulse; + } + + /// Attaches the pulse recorded alongside the reading. + public void setPulse(HealthQuantity pulse) { + if (pulse != null && !pulse.getUnit().isCompatibleWith( + HealthUnit.COUNT_PER_MINUTE)) { + throw new IllegalArgumentException("a pulse must be a frequency," + + " but " + pulse.getUnit().getSymbol() + " measures " + + pulse.getUnit().getDimension()); + } + this.pulse = pulse; + } + + /// One of the `POSITION_` constants. + public int getBodyPosition() { + return bodyPosition; + } + + /// Records the body position, using a `POSITION_` constant. + public void setBodyPosition(int bodyPosition) { + this.bodyPosition = bodyPosition; + } + + /// One of the `LOCATION_` constants. + public int getMeasurementLocation() { + return measurementLocation; + } + + /// Records the measurement site, using a `LOCATION_` constant. + public void setMeasurementLocation(int measurementLocation) { + this.measurementLocation = measurementLocation; + } + + @Override + public String toString() { + return "BloodPressureSample[" + + (int) systolic.getValue(HealthUnit.MILLIMETER_OF_MERCURY) + + "/" + + (int) diastolic.getValue(HealthUnit.MILLIMETER_OF_MERCURY) + + " mmHg " + getStartMillis() + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/CategorySample.java b/CodenameOne/src/com/codename1/health/CategorySample.java new file mode 100644 index 00000000000..d1939ed0411 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/CategorySample.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// An enumerated observation with no natural unit -- menstrual flow, for +/// example. +/// +/// The value is a small integer whose meaning depends on the type. Use the +/// constants each type documents rather than raw numbers; the ports map +/// them onto the platform's own encoding. +public final class CategorySample extends HealthSample { + + /// [HealthDataType#MENSTRUATION_FLOW]: the platform reported a flow + /// but not its intensity. + public static final int FLOW_UNSPECIFIED = 1; + + /// [HealthDataType#MENSTRUATION_FLOW]: light flow. + public static final int FLOW_LIGHT = 2; + + /// [HealthDataType#MENSTRUATION_FLOW]: medium flow. + public static final int FLOW_MEDIUM = 3; + + /// [HealthDataType#MENSTRUATION_FLOW]: heavy flow. + public static final int FLOW_HEAVY = 4; + + private final int value; + + private CategorySample(HealthDataType type, int value, long startMillis, + long endMillis) { + super(type, startMillis, endMillis); + if (type.getKind() != HealthDataKind.CATEGORY) { + throw new IllegalArgumentException(type.getId() + " is a " + + type.getKind() + + " type and cannot be a CategorySample"); + } + this.value = value; + } + + /// An instantaneous observation. + public static CategorySample create(HealthDataType type, int value, + long instantMillis) { + return new CategorySample(type, value, instantMillis, instantMillis); + } + + /// An observation spanning an interval. + public static CategorySample create(HealthDataType type, int value, + long startMillis, long endMillis) { + return new CategorySample(type, value, startMillis, endMillis); + } + + /// The enumerated value, interpreted per this sample's type. + public int getValue() { + return value; + } + + @Override + public String toString() { + return "CategorySample[" + getType().getId() + "=" + value + " " + + getStartMillis() + ".." + getEndMillis() + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/Health.java b/CodenameOne/src/com/codename1/health/Health.java new file mode 100644 index 00000000000..c6f42ffbcaa --- /dev/null +++ b/CodenameOne/src/com/codename1/health/Health.java @@ -0,0 +1,246 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import com.codename1.health.sensors.HealthSensors; +import com.codename1.health.workout.WorkoutManager; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.List; + +/// Entry point for the Codename One health API -- reading and writing +/// health data, watching it for changes, recording workouts, and streaming +/// from Bluetooth health sensors. +/// +/// Obtain the platform implementation via [#getInstance()]; the returned +/// object is owned by the active port and is never `null`. +/// +/// The API is split by role: +/// - [#getStore()] -- the platform health store: samples, aggregates, +/// writes and change subscriptions. +/// - [#getWorkouts()] -- live and recorded workout sessions. +/// - [#getSensors()] -- standard Bluetooth GATT health sensors: heart-rate +/// straps, power meters, scales, blood-pressure cuffs, glucose meters. +/// +/// #### Quick start +/// +/// ```java +/// Health health = Health.getInstance(); +/// if (health.getAvailability() != HealthAvailability.AVAILABLE) { +/// health.openProviderSetup(); +/// return; +/// } +/// HealthStore store = health.getStore(); +/// store.requestAuthorization(HealthAccess.read(HealthDataType.STEPS)) +/// .onResult((asked, err) -> { +/// if (err == null) { +/// readTodaysSteps(store); +/// } +/// }); +/// ``` +/// +/// #### Two things that will surprise you +/// +/// **You cannot ask whether you may read.** HealthKit deliberately refuses +/// to disclose read authorization -- a denied read looks exactly like an +/// empty result. So `requestAuthorization` resolving `true` means the user +/// was asked, not that they agreed, and an empty query result means +/// "denied or no data" with no way to tell which. Never render that to a +/// user as "you denied access"; say "no data available". See +/// [HealthStore#getReadAuthorizationStatus(HealthDataType)]. +/// +/// **Android never wakes your app for new data.** Health Connect has no +/// push mechanism, so subscriptions there are drained when your app runs. +/// Check [HealthSubscription#isPushDelivery()] rather than assuming. +/// +/// #### Threading +/// +/// Every method may be called from the EDT and returns immediately. +/// +/// Change deliveries -- [HealthChangeListener] and +/// [HealthBackgroundListener] -- always arrive on the EDT. A background +/// delivery may run with no visible UI, after the OS relaunched your app. +/// +/// Results of the operations you start arrive on the EDT **on iOS and +/// Android**. Both ports marshal every platform answer onto it, and the +/// shared post-processing of a large read hops back to it when it is +/// done, so a query started from a worker thread still calls you back on +/// the EDT there. +/// +/// **The local-backed ports do not carry that guarantee.** On desktop, +/// the simulator and JavaScript the store is local rather than +/// platform-backed, and a result is delivered on the very thread that +/// asked for it -- so a query started off the EDT resolves off the EDT. +/// Touch components from those callbacks through +/// `Display.getInstance().callSerially(...)`, or start the operation from +/// the EDT in the first place, which is the usual case and where the +/// distinction never arises. +/// +/// The asymmetry is a gap rather than a design, and it is written down +/// here so that nobody has to discover it from a repaint glitch. Code +/// written for the local ports is correct everywhere; code written for +/// the mobile guarantee alone is not. +/// +/// #### Platform support +/// +/// - **iOS / watchOS** -- HealthKit. Requires the HealthKit entitlement +/// and privacy usage strings; the build fails with an actionable message +/// if they are missing. +/// - **Android** -- Health Connect. Requires the provider app, a +/// privacy-policy declaration and per-type permissions declared through +/// build hints. +/// +/// Two capabilities are deliberately **not** claimed by either mobile +/// store in this release, and both stores answer `false` rather than +/// pretending: +/// +/// - **Background delivery.** Nothing relaunches or wakes your app for +/// new data on either platform, so subscriptions deliver when you call +/// [HealthStore#drainChanges()] and at no other time. Wire that into +/// your foreground path, or into `BackgroundFetch`. Ask +/// [HealthStore#isBackgroundDeliverySupported()] rather than assuming; +/// HealthKit's `HKObserverQuery` would change this answer on iOS, and +/// nothing here registers one yet. +/// - **Live workout sessions.** Workouts are recorded rather than +/// OS-owned everywhere: your app feeds samples in and the session is +/// written on `end()`. This is what Google documents for Android +/// phones, and it is what iOS does here too even though `HKWorkoutSession` +/// exists on watchOS and iOS 26. Check +/// [com.codename1.health.workout.WorkoutManager#isLiveSessionSupported()] +/// and +/// [com.codename1.health.workout.WorkoutManager#isSensorCollectionSupported()] +/// before designing around either. +/// - **JavaSE simulator** -- a scriptable virtual health store +/// (Simulate -> Health Simulation) with synthetic data, permission +/// scripting and fault injection. +/// - **Desktop and JavaScript** -- no platform store exists, so these +/// report [HealthAvailability#LOCAL_ONLY] and keep data locally. The +/// sensor API works fully wherever Bluetooth LE does. +/// - **tvOS and all other ports** -- this base class is returned as-is and +/// reports health as unsupported; operations fail fast with +/// [HealthError#NOT_SUPPORTED]. +public class Health { + + /// Ports construct subclasses. Application code obtains the active + /// instance via [#getInstance()]. + protected Health() { + } + + /// Returns the platform-specific singleton owned by the current port. + /// On ports without health support this returns a base [Health] + /// instance that reports the feature as unsupported, so calling code + /// never needs a `null` check or a platform-specific `if`. + public static Health getInstance() { + Health h = Display.getInstance().getHealth(); + return h != null ? h : DEFAULT; + } + + private static final Health DEFAULT = new Health(); + + /// `true` when this port exposes health data in any form. `false` on + /// the fallback base class. + public boolean isSupported() { + return false; + } + + /// Whether a health store is usable right now, and if not, why. Check + /// this before anything else -- on Android the provider app may be + /// missing or out of date, which the user can fix. + public HealthAvailability getAvailability() { + return HealthAvailability.NOT_SUPPORTED; + } + + /// The platform health store. Never null: on ports without one this + /// returns a no-op store whose every operation fails with + /// [HealthError#NOT_SUPPORTED]. Branch on + /// [HealthStore#isSupported()]. + public HealthStore getStore() { + return DefaultStore.INSTANCE; + } + + /// Workout recording. Never null. + public WorkoutManager getWorkouts() { + return DefaultWorkouts.INSTANCE; + } + + /// Bluetooth health sensors. Never null, and -- unlike the other two + /// -- not a no-op on ports without a health store: the sensor layer is + /// built entirely on `com.codename1.bluetooth.le`, so it works + /// anywhere Bluetooth LE does, including the desktop and JavaScript + /// ports. + public HealthSensors getSensors() { + return SharedSensors.INSTANCE; + } + + /// Opens the operating system screen where the user can change health + /// permissions -- Settings on iOS, the Health Connect permission + /// screen on Android. Resolves `false` where no such screen exists. + /// + /// This is the right response to a query that came back empty when you + /// expected data, since on iOS you cannot tell denial from absence. + public AsyncResource openHealthSettings() { + AsyncResource out = new AsyncResource(); + out.complete(Boolean.FALSE); + return out; + } + + /// Sends the user to install or update the Health Connect provider. + /// Meaningful when [#getAvailability()] is + /// [HealthAvailability#PROVIDER_NOT_INSTALLED] or + /// [HealthAvailability#PROVIDER_UPDATE_REQUIRED]; resolves `false` + /// elsewhere. + public AsyncResource openProviderSetup() { + AsyncResource out = new AsyncResource(); + out.complete(Boolean.FALSE); + return out; + } + + /// Build-configuration problems detected at runtime -- a missing + /// `ios.NSHealthShareUsageDescription` build hint, an absent Health + /// Connect privacy-policy declaration. Empty when the app is + /// configured correctly. + /// + /// Operations that need a missing entry throw + /// [HealthConfigurationException] carrying the same text. This method + /// reports the same diagnostics without throwing, so a diagnostics + /// screen or a test can assert on them. + public List getConfigurationProblems() { + return new ArrayList(); + } + + /// Lazily allocated so a port that never touches the store does not + /// pay for one. + private static final class DefaultStore { + static final HealthStore INSTANCE = new HealthStore(); + } + + private static final class DefaultWorkouts { + static final WorkoutManager INSTANCE = new WorkoutManager(); + } + + private static final class SharedSensors { + static final HealthSensors INSTANCE = new HealthSensors(); + } +} diff --git a/CodenameOne/src/com/codename1/health/HealthAccess.java b/CodenameOne/src/com/codename1/health/HealthAccess.java new file mode 100644 index 00000000000..bee77472d8a --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthAccess.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// One data type paired with one direction of access. The unit of +/// authorization on both platforms. +/// +/// ```java +/// store.requestAuthorization( +/// HealthAccess.read(HealthDataType.STEPS), +/// HealthAccess.read(HealthDataType.HEART_RATE), +/// HealthAccess.write(HealthDataType.BODY_MASS)); +/// ``` +/// +/// Request the narrowest set you actually need, at the moment you need it. +/// Both stores show the user every type you ask for in one sheet, and a +/// long list at first launch is the most common reason people decline. +public final class HealthAccess { + + private final HealthDataType type; + private final boolean write; + + private HealthAccess(HealthDataType type, boolean write) { + if (type == null) { + throw new IllegalArgumentException("access requires a data type"); + } + this.type = type; + this.write = write; + } + + /// Permission to read this type. + public static HealthAccess read(HealthDataType type) { + return new HealthAccess(type, false); + } + + /// Permission to write this type. + public static HealthAccess write(HealthDataType type) { + return new HealthAccess(type, true); + } + + /// Both directions for this type, as a two-element array suitable for + /// spreading into + /// [HealthStore#requestAuthorization(HealthAccess...)]. + public static HealthAccess[] readWrite(HealthDataType type) { + return new HealthAccess[] { read(type), write(type) }; + } + + /// The data type this access applies to. + public HealthDataType getType() { + return type; + } + + /// `true` when this is read access. + public boolean isRead() { + return !write; + } + + /// `true` when this is write access. + public boolean isWrite() { + return write; + } + + @Override + @SuppressWarnings("PMD.CompareObjectsWithEquals") + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof HealthAccess)) { + return false; + } + HealthAccess other = (HealthAccess) o; + return type == other.type && write == other.write; + } + + @Override + public int hashCode() { + return type.getId().hashCode() * 31 + (write ? 1 : 0); + } + + @Override + public String toString() { + return (write ? "write " : "read ") + type.getId(); + } +} diff --git a/CodenameOne/src/com/codename1/health/HealthAggregationStyle.java b/CodenameOne/src/com/codename1/health/HealthAggregationStyle.java new file mode 100644 index 00000000000..ed7c3f60259 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthAggregationStyle.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// How a [HealthDataType] combines over a time bucket. Determines which +/// [AggregateMetric] values are meaningful for the type. +public enum HealthAggregationStyle { + + /// Values accumulate over an interval and are summed -- steps, + /// distance, energy burned. [AggregateMetric#TOTAL] is meaningful; + /// averaging raw samples is not. + /// + /// Cumulative types are always interval samples: a step count belongs + /// to a span of time, never to an instant. See + /// [HealthDataType#isIntervalOnly()]. + CUMULATIVE, + + /// Values are point-in-time observations that are averaged or reduced + /// -- heart rate, body mass, blood glucose. [AggregateMetric#AVERAGE], + /// [AggregateMetric#MINIMUM], [AggregateMetric#MAXIMUM] and + /// [AggregateMetric#LATEST] are meaningful; totalling them is not. + DISCRETE, + + /// The type does not aggregate numerically at all -- sleep sessions, + /// workouts, category observations. Only [AggregateMetric#COUNT] and + /// [AggregateMetric#DURATION] apply. + NONE +} diff --git a/CodenameOne/src/com/codename1/health/HealthAnchor.java b/CodenameOne/src/com/codename1/health/HealthAnchor.java new file mode 100644 index 00000000000..b8bfb0330ad --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthAnchor.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// An opaque cursor marking how far through a data type's change history +/// the app has read. Wraps HealthKit's `HKQueryAnchor` and Health +/// Connect's changes token. +/// +/// You normally never touch this: [HealthStore] persists the anchor for +/// each subscription itself, which is what lets a subscription survive the +/// app being killed. It is exposed only so that an app syncing to its own +/// server can checkpoint the cursor alongside its upload watermark and +/// keep the two consistent. +/// +/// Anchors expire. Health Connect change tokens are invalid after 30 days, +/// and an iOS anchor can be rejected after a restore from backup. When +/// that happens the next batch reports +/// [HealthChangeBatch#isResyncRequired()] and the app must fall back to a +/// full time-range read. +public final class HealthAnchor { + + private final String token; + + private HealthAnchor(String token) { + this.token = token; + } + + /// Wraps a platform cursor. Called by ports. + public static HealthAnchor of(String token) { + return token == null ? null : new HealthAnchor(token); + } + + /// Restores an anchor previously written out with + /// [#toStorableString()]. Returns null for null or empty input, which + /// callers should treat as "start from the beginning". + public static HealthAnchor fromStorableString(String stored) { + if (stored == null || stored.length() == 0) { + return null; + } + return new HealthAnchor(stored); + } + + /// A form of this anchor safe to persist in `Storage`, `Preferences` + /// or your own database, and to pass back to + /// [#fromStorableString(String)] on a later launch. + /// + /// The contents are platform-specific and must not be parsed, compared + /// for ordering, or sent to a different device. + public String toStorableString() { + return token; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof HealthAnchor)) { + return false; + } + HealthAnchor other = (HealthAnchor) o; + return token == null ? other.token == null : token.equals(other.token); + } + + @Override + public int hashCode() { + return token == null ? 0 : token.hashCode(); + } + + @Override + public String toString() { + return "HealthAnchor[" + (token == null ? "none" + : token.length() + " chars") + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/HealthAuthorizationStatus.java b/CodenameOne/src/com/codename1/health/HealthAuthorizationStatus.java new file mode 100644 index 00000000000..6dc711cb87e --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthAuthorizationStatus.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// The app's authorization for one data type and one direction. +/// +/// #### The asymmetry you must handle +/// +/// Write authorization is truthfully reportable on both platforms. **Read +/// authorization is not.** HealthKit deliberately refuses to disclose it, +/// because telling an app "the user denied you access to pregnancy data" +/// leaks the very thing the user was hiding. So +/// [HealthStore#getReadAuthorizationStatus(HealthDataType)] returns +/// [#UNKNOWN] on iOS regardless of what the user actually chose, and any +/// code that branches on it must handle that value. +public enum HealthAuthorizationStatus { + + /// The user has not been asked yet. + NOT_DETERMINED, + + /// The app holds the access. + AUTHORIZED, + + /// The user explicitly refused the access. + DENIED, + + /// Access is blocked by something outside the user's control at this + /// moment -- a device management policy, or a data type the platform + /// does not permit this app to touch. + RESTRICTED, + + /// The platform will not say. Returned for **read** access on iOS in + /// every case, by design; it means neither "granted" nor "denied" and + /// must not be rendered to the user as either. + /// + /// The only honest way to find out whether reads work is to run a + /// query -- see [HealthStore#hasAnyData(HealthDataType,HealthTimeRange)]. + UNKNOWN, + + /// This platform has no health store, so the question does not apply. + NOT_SUPPORTED +} diff --git a/CodenameOne/src/com/codename1/health/HealthAvailability.java b/CodenameOne/src/com/codename1/health/HealthAvailability.java new file mode 100644 index 00000000000..75063e99331 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthAvailability.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// Whether a platform health store is usable right now, and if not, why. +/// Check this before anything else -- on Android the provider app may be +/// missing or out of date, which is recoverable by the user. +public enum HealthAvailability { + + /// A platform health store is present and usable. + AVAILABLE, + + /// No platform store, but this port keeps health data locally (the + /// simulator, and the desktop and JavaScript ports). Reads and writes + /// work and are durable, but the data is this app's own -- nothing is + /// shared with other apps, and no device or third-party app writes + /// into it. + /// + /// Apps that only aggregate their own measurements can treat this as + /// working. Apps whose whole purpose is reading what other apps + /// recorded should tell the user the feature needs a phone. + LOCAL_ONLY, + + /// Android only: the Health Connect provider is installed but too old. + /// Send the user to [Health#openProviderSetup()]. + PROVIDER_UPDATE_REQUIRED, + + /// Android only: the Health Connect provider app is not installed. + /// Send the user to [Health#openProviderSetup()]. + PROVIDER_NOT_INSTALLED, + + /// This platform has no health support at all. + NOT_SUPPORTED +} diff --git a/CodenameOne/src/com/codename1/health/HealthBackgroundListener.java b/CodenameOne/src/com/codename1/health/HealthBackgroundListener.java new file mode 100644 index 00000000000..0e24e930e78 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthBackgroundListener.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// Receives health-store changes even after the app's process has been +/// killed and relaunched. Registered by class with +/// [HealthStore#subscribe(SubscriptionRequest,Class)], mirroring +/// `LocationManager.setBackgroundLocationListener(Class)`. +/// +/// #### Requirements on the implementing class +/// +/// It must be a **public top-level class with a public no-argument +/// constructor**. The framework instantiates it fresh for every delivery, +/// including after a cold relaunch, so do not keep state in fields and do +/// not assume any of your app's initialization has run. +/// +/// #### What you may do inside +/// +/// You are called on the EDT, but on iOS there may be no visible UI at all +/// -- the OS woke the app purely to hand you data. Check +/// `Display.getInstance().isMinimized()` before touching anything visual, +/// and keep the work small: [HealthChangeBatch#getDeadlineMillis()] is +/// around five seconds on a background relaunch. Accumulate into +/// `Preferences` or `Storage` and do the real work on next foreground. +/// +/// ```java +/// public class StepWatcher implements HealthBackgroundListener { +/// public void healthDataChanged(HealthChangeBatch batch) { +/// if (batch.isResyncRequired()) { +/// Preferences.set("stepsWatermark", 0L); +/// return; +/// } +/// long added = 0; +/// for (HealthSample s : batch.getAdded()) { +/// added += (long) ((QuantitySample) s).getValue(HealthUnit.COUNT); +/// } +/// Preferences.set("pendingSteps", +/// Preferences.get("pendingSteps", 0L) + added); +/// } +/// } +/// ``` +/// +/// #### You do not need to keep the class reachable +/// +/// The build server scans for implementations of this interface and +/// generates a factory that constructs each one with a direct `new` +/// expression, then registers it during app startup. Because the generated +/// binding is a real reference rather than a string, dead-code elimination +/// and obfuscation both follow it correctly -- so unlike some older +/// callback APIs in this framework, there is nothing you have to do to +/// stop your listener being stripped from a release build. +/// +/// See [HealthBackgroundListenerFactory]. +public interface HealthBackgroundListener { + + /// Called with the changes accumulated since the last delivery. + void healthDataChanged(HealthChangeBatch batch); +} diff --git a/CodenameOne/src/com/codename1/health/HealthBackgroundListenerFactory.java b/CodenameOne/src/com/codename1/health/HealthBackgroundListenerFactory.java new file mode 100644 index 00000000000..01261347b58 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthBackgroundListenerFactory.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// Creates [HealthBackgroundListener] instances after the app's process has +/// been killed and relaunched. +/// +/// #### Why this exists rather than reflection +/// +/// When the operating system relaunches an app in the background to deliver +/// health data, the only durable record of which listener to invoke is +/// whatever was persisted -- a name, not a `Class`. Resolving that name +/// reflectively is the obvious approach and the wrong one on this +/// framework's targets: +/// +/// - A class referenced only by a string is invisible to the iOS and +/// JavaScript translators' dead-code elimination, so it can be stripped +/// out of the very build that needs it. +/// - Obfuscation renames the class, so a name persisted by an earlier +/// version of the app no longer resolves. +/// +/// So the binding is produced at **build time** instead. The build server +/// scans for implementations of [HealthBackgroundListener], generates a +/// factory that constructs each one with a direct `new` expression -- a +/// real reference, which dead-code elimination and obfuscation both follow +/// correctly -- and registers it through +/// [HealthStore#setBackgroundListenerFactory(HealthBackgroundListenerFactory)] +/// during app startup. +/// +/// Application code never implements or calls this. +public interface HealthBackgroundListenerFactory { + + /// Creates the listener registered under `className`, or returns null + /// when this build has no such listener. + /// + /// `className` is the source-level class name recorded when the + /// subscription was registered. It stays valid across obfuscation + /// because the build server emits a keep rule for every class it + /// generates a binding for. + HealthBackgroundListener create(String className); +} diff --git a/CodenameOne/src/com/codename1/health/HealthChangeBatch.java b/CodenameOne/src/com/codename1/health/HealthChangeBatch.java new file mode 100644 index 00000000000..5ff0e0c46bc --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthChangeBatch.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// A set of changes since the last time a subscription was drained. +/// Delivered to [HealthChangeListener] in the foreground and to +/// [HealthBackgroundListener] after a background relaunch. +public final class HealthChangeBatch { + + private final String subscriptionId; + private final List types; + private final List added; + private final List deletedSampleIds; + private final boolean resyncRequired; + private final HealthAnchor anchor; + private final long deadlineMillis; + private final boolean more; + + /// Creates a batch. Called by [HealthStore]; the lists are copied + /// defensively. + public HealthChangeBatch(String subscriptionId, + List types, List added, + List deletedSampleIds, boolean resyncRequired, + HealthAnchor anchor, long deadlineMillis, boolean more) { + this.subscriptionId = subscriptionId; + this.types = copyTypes(types); + this.added = copySamples(added); + this.deletedSampleIds = copyIds(deletedSampleIds); + this.resyncRequired = resyncRequired; + this.anchor = anchor; + this.deadlineMillis = deadlineMillis; + this.more = more; + } + + private static List copyTypes(List in) { + List out = new ArrayList(); + if (in != null) { + out.addAll(in); + } + return out; + } + + private static List copySamples(List in) { + List out = new ArrayList(); + if (in != null) { + out.addAll(in); + } + return out; + } + + private static List copyIds(List in) { + List out = new ArrayList(); + if (in != null) { + out.addAll(in); + } + return out; + } + + /// The subscription this batch belongs to. + public String getSubscriptionId() { + return subscriptionId; + } + + /// The types covered by this batch. + public List getTypes() { + return Collections.unmodifiableList(types); + } + + /// Samples added or updated since the last drain. Empty when the + /// subscription set [SubscriptionRequest#setDeliverSamples(boolean)] + /// to false, and empty when [#isResyncRequired()]. + public List getAdded() { + return Collections.unmodifiableList(added); + } + + /// Identifiers of samples removed since the last drain. + public List getDeletedSampleIds() { + return Collections.unmodifiableList(deletedSampleIds); + } + + /// `true` when the platform cursor was lost and incremental delivery + /// cannot continue -- a Health Connect change token older than 30 + /// days, or an iOS anchor rejected after a restore from backup. + /// + /// [#getAdded()] is empty in that case. The app must do a full + /// time-range read to catch up; nothing else will deliver the missed + /// data. + public boolean isResyncRequired() { + return resyncRequired; + } + + /// The cursor after this batch. Persisted by [HealthStore] + /// automatically; exposed for apps that checkpoint against their own + /// server -- see [HealthAnchor]. + public HealthAnchor getAnchor() { + return anchor; + } + + /// Roughly how much wall-clock time the listener has before the OS may + /// suspend the process, in milliseconds. About 5000 on an iOS + /// background relaunch; `Long.MAX_VALUE` in the foreground. + /// + /// Do cheap bookkeeping inside a background delivery -- accumulate a + /// counter, stash an identifier -- and leave network calls for the + /// next foreground. + public long getDeadlineMillis() { + return deadlineMillis; + } + + /// `true` when more changes were available than + /// [SubscriptionRequest#getMaxSamplesPerBatch()] allowed. The + /// remainder arrives in the next delivery, or immediately on + /// [HealthStore#drainChanges()]. + public boolean hasMore() { + return more; + } + + /// `true` when nothing changed and no resync is needed. + public boolean isEmpty() { + return !resyncRequired && added.isEmpty() + && deletedSampleIds.isEmpty(); + } + + @Override + public String toString() { + return "HealthChangeBatch[" + subscriptionId + " +" + added.size() + + " -" + deletedSampleIds.size() + + (resyncRequired ? " RESYNC" : "") + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/HealthChangeListener.java b/CodenameOne/src/com/codename1/health/HealthChangeListener.java new file mode 100644 index 00000000000..9cb9ed0a70a --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthChangeListener.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// Receives health-store changes while the app is running. Registered with +/// [HealthStore#subscribe(SubscriptionRequest,HealthChangeListener)]. +/// +/// This is a live object held in memory, so it is dropped when the process +/// dies. To keep receiving changes after the OS relaunches your app in the +/// background, register a [HealthBackgroundListener] class instead. +/// +/// Called on the EDT. +public interface HealthChangeListener { + + /// Called when the store reports changes for a subscription. + void healthDataChanged(HealthChangeBatch batch); +} diff --git a/CodenameOne/src/com/codename1/health/HealthConfigurationException.java b/CodenameOne/src/com/codename1/health/HealthConfigurationException.java new file mode 100644 index 00000000000..3afd5fb3dd6 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthConfigurationException.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// Thrown when the app is missing build configuration that the health APIs +/// cannot work without -- an absent `ios.NSHealthShareUsageDescription` +/// build hint, a missing Health Connect privacy-policy declaration. +/// +/// #### Why this is unchecked, when everything else is an AsyncResource failure +/// +/// An unsupported platform is a runtime fact that a correct app must +/// handle gracefully, so it arrives as a [HealthException] through an +/// `AsyncResource`. A missing usage description is a **developer bug** +/// that gets the app rejected from the App Store, and it must therefore be +/// impossible to swallow into an error callback nobody logs. It follows +/// the precedent set by `LocationManager` on iOS, which throws with the +/// exact name of the build hint to add. +/// +/// [Health#getConfigurationProblems()] returns the same diagnostics +/// without throwing, so a diagnostics screen or a unit test can assert on +/// them. +public class HealthConfigurationException extends RuntimeException { + + /// Creates an exception whose message should name the exact build hint + /// to add and describe what to put in it. + public HealthConfigurationException(String message) { + super(message); + } +} diff --git a/CodenameOne/src/com/codename1/health/HealthDataKind.java b/CodenameOne/src/com/codename1/health/HealthDataKind.java new file mode 100644 index 00000000000..d6f43002e21 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthDataKind.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// What shape of data a [HealthDataType] produces, and therefore which +/// [HealthSample] subclass a query for it returns. +public enum HealthDataKind { + + /// A numeric measurement with a unit -- steps, heart rate, body mass. + /// Produces [QuantitySample]. Maps to `HKQuantitySample` and to Health + /// Connect's numeric record types. + QUANTITY, + + /// An enumerated observation with no natural unit -- menstrual flow, + /// a single sleep stage. Produces [CategorySample]. Maps to + /// `HKCategorySample`. + CATEGORY, + + /// A run of measurements sharing one record identity -- a + /// beat-to-beat heart-rate trace. Produces [SeriesSample]. + /// + /// This kind exists because the two platforms disagree: Health + /// Connect's `HeartRateRecord` is one record containing N samples, + /// while HealthKit returns N independent samples. See + /// [SampleQuery#setFlattenSeries(boolean)]. + SERIES, + + /// A bounded activity with child data -- a workout, a night's sleep. + /// Produces a [SessionSample] subclass. + SESSION +} diff --git a/CodenameOne/src/com/codename1/health/HealthDataType.java b/CodenameOne/src/com/codename1/health/HealthDataType.java new file mode 100644 index 00000000000..105d6b3c089 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthDataType.java @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// A kind of health data -- steps, heart rate, sleep, body mass. Instances +/// are interned constants, so `==` is a valid identity test. +/// +/// #### Why this is not an enum +/// +/// Three reasons, in order of how much trouble each would cause: +/// +/// 1. Each constant carries a [HealthUnit], and a Java enum constructor +/// that references another class's statics is an initialization-order +/// hazard -- the unit would be null for whichever class loaded first. +/// 2. [#forId(String)] has to be total across framework versions. An app +/// that persisted `"vo2Max"` and is later restored by an older runtime +/// must get `null`, not the `IllegalArgumentException` that +/// `Enum.valueOf` throws. +/// 3. Ports need to attach platform metadata to types without the core +/// knowing about it. +/// +/// #### There is no custom type +/// +/// Deliberately no `HealthDataType.custom(String)`. A custom type would +/// have no canonical unit, no aggregation style and no cross-platform +/// mapping -- an untyped string passthrough that works on exactly one +/// platform, which is the failure mode this API exists to prevent. Adding +/// a type is a change to this class. +/// +/// #### Permissions are declared, not inferred +/// +/// The build server cannot see which of these constants an app touches: +/// constant references compile to field reads, and the class scanner only +/// records type and method references. Android's per-type +/// `android.permission.health.*` set therefore comes from the +/// `android.health.read` and `android.health.write` build hints, which use +/// the [#getId()] values below as tokens. This also matches Google Play +/// policy, which requires declaring exactly the data types you use. +public final class HealthDataType { + + private static final Map BY_ID = + new HashMap(); + private static final List ALL = + new ArrayList(); + + private final String id; + private final HealthDataKind kind; + private final HealthUnit canonicalUnit; + private final HealthAggregationStyle aggregationStyle; + private final boolean intervalOnly; + + private HealthDataType(String id, HealthDataKind kind, + HealthUnit canonicalUnit, HealthAggregationStyle aggregationStyle, + boolean intervalOnly) { + this.id = id; + this.kind = kind; + this.canonicalUnit = canonicalUnit; + this.aggregationStyle = aggregationStyle; + this.intervalOnly = intervalOnly; + } + + private static HealthDataType define(String id, HealthDataKind kind, + HealthUnit unit, HealthAggregationStyle style, + boolean intervalOnly) { + HealthDataType t = new HealthDataType(id, kind, unit, style, + intervalOnly); + BY_ID.put(id, t); + ALL.add(t); + return t; + } + + /// A cumulative quantity: accumulates over an interval, summed when + /// aggregated, and never instantaneous. + private static HealthDataType cumulative(String id, HealthUnit unit) { + return define(id, HealthDataKind.QUANTITY, unit, + HealthAggregationStyle.CUMULATIVE, true); + } + + /// A discrete quantity: a point-in-time reading, averaged when + /// aggregated. May still carry a span (a heart-rate average over a + /// minute), so it is not interval-only. + private static HealthDataType discrete(String id, HealthUnit unit) { + return define(id, HealthDataKind.QUANTITY, unit, + HealthAggregationStyle.DISCRETE, false); + } + + // ------------------------------------------------------------------ + // activity + // ------------------------------------------------------------------ + + public static final HealthDataType STEPS = + cumulative("steps", HealthUnit.COUNT); + public static final HealthDataType DISTANCE_WALKING_RUNNING = + cumulative("distance_walking_running", HealthUnit.METER); + public static final HealthDataType DISTANCE_CYCLING = + cumulative("distance_cycling", HealthUnit.METER); + public static final HealthDataType DISTANCE_SWIMMING = + cumulative("distance_swimming", HealthUnit.METER); + public static final HealthDataType FLIGHTS_CLIMBED = + cumulative("flights_climbed", HealthUnit.COUNT); + public static final HealthDataType ELEVATION_GAINED = + cumulative("elevation_gained", HealthUnit.METER); + public static final HealthDataType ACTIVE_ENERGY = + cumulative("active_energy", HealthUnit.KILOCALORIE); + public static final HealthDataType BASAL_ENERGY = + cumulative("basal_energy", HealthUnit.KILOCALORIE); + public static final HealthDataType EXERCISE_TIME = + cumulative("exercise_time", HealthUnit.MINUTE); + public static final HealthDataType WHEELCHAIR_PUSHES = + cumulative("wheelchair_pushes", HealthUnit.COUNT); + + // ------------------------------------------------------------------ + // vitals + // ------------------------------------------------------------------ + + public static final HealthDataType HEART_RATE = + discrete("heart_rate", HealthUnit.COUNT_PER_MINUTE); + public static final HealthDataType RESTING_HEART_RATE = + discrete("resting_heart_rate", HealthUnit.COUNT_PER_MINUTE); + public static final HealthDataType WALKING_HEART_RATE_AVERAGE = + discrete("walking_heart_rate_average", HealthUnit.COUNT_PER_MINUTE); + + /// Heart-rate variability as the standard deviation of NN intervals, + /// the metric both platforms expose (`HKQuantityTypeIdentifier` `... + /// HeartRateVariabilitySDNN`). Other HRV metrics -- RMSSD, frequency + /// domain -- are not stored by either platform; derive them yourself + /// from the RR intervals a chest strap reports through + /// `com.codename1.health.sensors`. + public static final HealthDataType HEART_RATE_VARIABILITY_SDNN = + discrete("heart_rate_variability_sdnn", HealthUnit.MILLISECOND); + + public static final HealthDataType OXYGEN_SATURATION = + discrete("oxygen_saturation", HealthUnit.PERCENT); + public static final HealthDataType RESPIRATORY_RATE = + discrete("respiratory_rate", HealthUnit.COUNT_PER_MINUTE); + public static final HealthDataType BODY_TEMPERATURE = + discrete("body_temperature", HealthUnit.DEGREE_CELSIUS); + public static final HealthDataType BASAL_BODY_TEMPERATURE = + discrete("basal_body_temperature", HealthUnit.DEGREE_CELSIUS); + public static final HealthDataType VO2_MAX = + discrete("vo2_max", HealthUnit.ML_PER_KG_PER_MINUTE); + + /// Blood pressure, as a single sample carrying both systolic and + /// diastolic values -- see [BloodPressureSample]. + /// + /// HealthKit models this as an `HKCorrelation` of two separate + /// quantity samples; Health Connect has a single `BloodPressureRecord` + /// and no correlation concept at all. This API follows Health Connect. + /// There is deliberately no portable `Correlation` type, because it + /// would be a fiction maintained in one port only. + /// + /// **Local and simulator only in this release.** Neither phone carries + /// this type: the iOS map has no `blood_pressure` entry, so the + /// correlation assembly the port will need is not written yet, and + /// Health Connect's record is not mapped either. A read or write of it + /// on a device is refused with [HealthError#TYPE_NOT_SUPPORTED] rather + /// than dropped silently. Take the reading off the sensor session and + /// keep it yourself until then -- see [BloodPressureSample]. + public static final HealthDataType BLOOD_PRESSURE = + define("blood_pressure", HealthDataKind.QUANTITY, + HealthUnit.MILLIMETER_OF_MERCURY, + HealthAggregationStyle.DISCRETE, false); + + public static final HealthDataType BLOOD_GLUCOSE = + discrete("blood_glucose", HealthUnit.MILLIMOLE_PER_LITER); + + // ------------------------------------------------------------------ + // body measurements + // ------------------------------------------------------------------ + + public static final HealthDataType BODY_MASS = + discrete("body_mass", HealthUnit.KILOGRAM); + public static final HealthDataType LEAN_BODY_MASS = + discrete("lean_body_mass", HealthUnit.KILOGRAM); + public static final HealthDataType BONE_MASS = + discrete("bone_mass", HealthUnit.KILOGRAM); + public static final HealthDataType BODY_FAT_PERCENTAGE = + discrete("body_fat_percentage", HealthUnit.PERCENT); + public static final HealthDataType BODY_MASS_INDEX = + discrete("body_mass_index", HealthUnit.COUNT); + public static final HealthDataType HEIGHT = + discrete("height", HealthUnit.METER); + public static final HealthDataType WAIST_CIRCUMFERENCE = + discrete("waist_circumference", HealthUnit.METER); + + // ------------------------------------------------------------------ + // performance + // ------------------------------------------------------------------ + + public static final HealthDataType POWER = + discrete("power", HealthUnit.WATT); + public static final HealthDataType SPEED = + discrete("speed", HealthUnit.METER_PER_SECOND); + public static final HealthDataType CYCLING_CADENCE = + discrete("cycling_cadence", HealthUnit.COUNT_PER_MINUTE); + public static final HealthDataType RUNNING_CADENCE = + discrete("running_cadence", HealthUnit.COUNT_PER_MINUTE); + + // ------------------------------------------------------------------ + // intake + // ------------------------------------------------------------------ + + public static final HealthDataType HYDRATION = + cumulative("hydration", HealthUnit.LITER); + public static final HealthDataType DIETARY_ENERGY = + cumulative("dietary_energy", HealthUnit.KILOCALORIE); + + /// A logged food or meal carrying an arbitrary set of nutrients -- + /// see `com.codename1.health.nutrition`. Produces a + /// `NutritionSample` rather than a plain quantity, because a single + /// entry sets many nutrient fields at once. + public static final HealthDataType NUTRITION = + define("nutrition", HealthDataKind.SESSION, null, + HealthAggregationStyle.NONE, true); + + // ------------------------------------------------------------------ + // sessions and categories + // ------------------------------------------------------------------ + + /// A sleep session with optional stage detail -- see [SleepSample]. + public static final HealthDataType SLEEP = + define("sleep", HealthDataKind.SESSION, null, + HealthAggregationStyle.NONE, true); + + /// A workout or exercise session -- see [WorkoutSample]. + public static final HealthDataType WORKOUT = + define("workout", HealthDataKind.SESSION, null, + HealthAggregationStyle.NONE, true); + + /// A mindfulness or meditation session. + public static final HealthDataType MINDFUL_SESSION = + define("mindful_session", HealthDataKind.SESSION, null, + HealthAggregationStyle.NONE, true); + + public static final HealthDataType MENSTRUATION_FLOW = + define("menstruation_flow", HealthDataKind.CATEGORY, null, + HealthAggregationStyle.NONE, false); + public static final HealthDataType INTERMENSTRUAL_BLEEDING = + define("intermenstrual_bleeding", HealthDataKind.CATEGORY, null, + HealthAggregationStyle.NONE, false); + + // ------------------------------------------------------------------ + + /// The stable, portable identifier for this type. Safe to persist and + /// to send to a server; also the token used by the + /// `android.health.read` and `android.health.write` build hints. + public String getId() { + return id; + } + + /// What shape of sample a query for this type returns. + public HealthDataKind getKind() { + return kind; + } + + /// The unit that samples of this type are normalized to when a query + /// does not request a specific one. Null for types with no natural + /// unit -- sessions and categories. + public HealthUnit getCanonicalUnit() { + return canonicalUnit; + } + + /// How this type combines over an aggregation bucket. + public HealthAggregationStyle getAggregationStyle() { + return aggregationStyle; + } + + /// `true` when a sample of this type must span a time range rather + /// than mark an instant. A step count belongs to an interval; a body + /// mass reading belongs to a moment. + /// + /// [HealthStore] rejects an instantaneous write of an interval-only + /// type with [HealthError#INVALID_ARGUMENT], rather than letting the + /// platform throw something opaque later. + public boolean isIntervalOnly() { + return intervalOnly; + } + + /// Looks a type up by [#getId()], or `null` when this version of the + /// framework does not know it. Total by design -- see the class + /// documentation. + public static HealthDataType forId(String id) { + if (id == null) { + return null; + } + return BY_ID.get(id); + } + + /// Every type known to this version of the framework. Note that a + /// given platform supports a subset -- check + /// [HealthStore#isTypeSupported(HealthDataType)]. + public static List values() { + return Collections.unmodifiableList(ALL); + } + + @Override + public String toString() { + return id; + } +} diff --git a/CodenameOne/src/com/codename1/health/HealthDeleteRequest.java b/CodenameOne/src/com/codename1/health/HealthDeleteRequest.java new file mode 100644 index 00000000000..5b595219c72 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthDeleteRequest.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// Describes what to delete from [HealthStore], either by identifier or +/// by type and time range. +/// +/// #### You can only delete your own data +/// +/// Both platforms restrict deletion to samples your app wrote. A request +/// covering another app's data silently deletes nothing rather than +/// failing, which is a deliberate platform choice: an app must not be able +/// to discover what other apps recorded by watching which deletes +/// succeed. +public final class HealthDeleteRequest { + + private final List sampleIds = new ArrayList(); + private final List types = new ArrayList(); + private HealthTimeRange timeRange; + + /// Deletes specific samples of `type` by their platform identifiers. + /// + /// The type is required, not redundant: Health Connect deletes by + /// record class plus id and cannot resolve an id on its own, so a + /// request without one could only be honoured by guessing. Asking for + /// it here means the caller states what they are deleting rather than + /// the platform silently deleting nothing. + public static HealthDeleteRequest byIds(HealthDataType type, + List sampleIds) { + HealthDeleteRequest r = new HealthDeleteRequest(); + if (type != null) { + r.types.add(type); + } + if (sampleIds != null) { + r.sampleIds.addAll(sampleIds); + } + return r; + } + + /// Deletes one sample of `type` by its platform identifier. + public static HealthDeleteRequest byId(HealthDataType type, + String sampleId) { + HealthDeleteRequest r = new HealthDeleteRequest(); + if (type != null) { + r.types.add(type); + } + if (sampleId != null) { + r.sampleIds.add(sampleId); + } + return r; + } + + /// Deletes everything of `type` that this app wrote inside `range`. + public static HealthDeleteRequest byRange(HealthDataType type, + HealthTimeRange range) { + HealthDeleteRequest r = new HealthDeleteRequest(); + if (type != null) { + r.types.add(type); + } + r.timeRange = range; + return r; + } + + private HealthDeleteRequest() { + } + + /// Adds another type to a range-based request. + public HealthDeleteRequest addType(HealthDataType type) { + if (type != null && !types.contains(type)) { + types.add(type); + } + return this; + } + + /// The identifiers to delete, empty for a range-based request. + public List getSampleIds() { + return Collections.unmodifiableList(sampleIds); + } + + /// The types to delete, empty for an identifier-based request. + public List getTypes() { + return Collections.unmodifiableList(types); + } + + /// The span to delete within, null for an identifier-based request. + public HealthTimeRange getTimeRange() { + return timeRange; + } + + /// `true` when this request names specific samples rather than a span. + public boolean isById() { + return !sampleIds.isEmpty(); + } + + /// Validates the request. + /// + /// #### Throws + /// + /// - `HealthException`: [HealthError#INVALID_ARGUMENT] when the + /// request names neither identifiers nor a type and range, or + /// ambiguously names both. + public void validate() throws HealthException { + boolean hasIds = !sampleIds.isEmpty(); + boolean hasRange = timeRange != null; + if (!hasIds && !hasRange) { + throw new HealthException(HealthError.INVALID_ARGUMENT, + "a delete request needs either sample ids or a type" + + " and a time range"); + } + if (hasIds && hasRange) { + throw new HealthException(HealthError.INVALID_ARGUMENT, + "a delete request must name either sample ids or a" + + " type and range, not both"); + } + if (types.isEmpty()) { + throw new HealthException(HealthError.INVALID_ARGUMENT, + "a delete request needs a data type: Health Connect" + + " deletes by record class, so an id alone" + + " cannot be resolved"); + } + } +} diff --git a/CodenameOne/src/com/codename1/health/HealthError.java b/CodenameOne/src/com/codename1/health/HealthError.java new file mode 100644 index 00000000000..b70e45a0be3 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthError.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// Typed failure reasons carried by [HealthException]. Ports map their +/// platform error codes onto these so cross-platform code can branch on a +/// stable value instead of parsing messages. +public enum HealthError { + + /// The port, device or OS version has no health support at all, or the + /// requested capability is unavailable on this platform. Returned by + /// every operation on the fallback [Health] base class. + NOT_SUPPORTED, + + /// The requested data type is not available on this platform, even + /// though health support in general is. + TYPE_NOT_SUPPORTED, + + /// The Health Connect provider app is not installed. Recoverable by + /// sending the user to [Health#openProviderSetup()]. + PROVIDER_UNAVAILABLE, + + /// The installed Health Connect provider is too old. Also recoverable + /// via [Health#openProviderSetup()]. + PROVIDER_UPDATE_REQUIRED, + + /// The operation was refused for lack of authorization. + /// + /// Note that a **read** denial does not reliably produce this error: + /// HealthKit reports a denied read as an empty result, by design. See + /// [HealthStore#getReadAuthorizationStatus(HealthDataType)]. + UNAUTHORIZED, + + /// The user dismissed a platform authorization or setup flow. Only + /// reported where the platform distinguishes cancellation from denial. + USER_CANCELED, + + /// A query or write was rejected before reaching the platform because + /// the request itself was malformed -- an inverted time range, an + /// instantaneous write of a cumulative type, a negative limit. + INVALID_ARGUMENT, + + /// A unit was supplied that measures a different dimension than the + /// data type requires. + UNIT_MISMATCH, + + /// A payload could not be decoded -- a truncated or malformed GATT + /// characteristic value, or a platform record the port could not map. + /// Never surfaces as an unchecked exception from a parser. + INVALID_DATA, + + /// The platform's health database could not be opened. On iOS this is + /// `HKErrorDatabaseInaccessible`, raised while the device is locked -- + /// which is exactly when a background observer fires. **Retryable**: + /// callers should try again once the device is unlocked rather than + /// treating it as "no data". + DATABASE_INACCESSIBLE, + + /// A stored anchor or change token was rejected by the platform, + /// usually because it aged out. The caller must resynchronize with a + /// full time-range read; see [HealthChangeBatch#isResyncRequired()]. + ANCHOR_EXPIRED, + + /// The operation would return or write more data than the platform + /// permits in one call. Use paging or a smaller batch. + QUOTA_EXCEEDED, + + /// The platform rate-limited the request. + RATE_LIMITED, + + /// A workout session method was called in a state that does not allow + /// it -- pausing a session that never started, ending one twice. + SESSION_STATE, + + /// A live BLE sensor dropped its connection mid-session. + SENSOR_DISCONNECTED, + + /// The operation did not complete within its safety timeout. + TIMEOUT, + + /// Anything the port could not classify. The message carries the + /// platform's own text. + UNKNOWN +} diff --git a/CodenameOne/src/com/codename1/health/HealthException.java b/CodenameOne/src/com/codename1/health/HealthException.java new file mode 100644 index 00000000000..87bab4a0a13 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthException.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// The failure delivered through an `AsyncResource` when a health +/// operation does not succeed. Branch on [#getError()] rather than on the +/// message. +/// +/// ```java +/// store.write(sample).onResult((res, err) -> { +/// if (err instanceof HealthException) { +/// HealthError e = ((HealthException) err).getError(); +/// if (e == HealthError.UNAUTHORIZED) { +/// Health.getInstance().openHealthSettings(); +/// } +/// } +/// }); +/// ``` +public class HealthException extends Exception { + + private final HealthError error; + + /// Creates an exception carrying a typed reason. + public HealthException(HealthError error, String message) { + super(message); + this.error = error == null ? HealthError.UNKNOWN : error; + } + + /// Creates an exception carrying a typed reason and an underlying + /// cause. + public HealthException(HealthError error, String message, Throwable cause) { + super(message, cause); + this.error = error == null ? HealthError.UNKNOWN : error; + } + + /// The typed reason this operation failed. + /// Transient: an exception is Serializable, the write result is not, + /// and a partial result has no meaning once it has crossed a process + /// boundary anyway -- the ids it names are scoped to the store that + /// issued them. + private transient HealthWriteResult partialResult; + + /// Samples already committed before the failure, when a chunked write + /// fails partway. + /// + /// A write larger than [HealthStore#getMaxWriteBatchSize()] is sent in + /// chunks, and an earlier chunk can be stored before a later one + /// fails. Without this the caller sees only the failure, retries the + /// whole batch, and writes the committed samples a second time -- + /// duplicate records in the user's health store. Null when the failure + /// was not a partial write. + public HealthWriteResult getPartialResult() { + return partialResult; + } + + void setPartialResult(HealthWriteResult partialResult) { + this.partialResult = partialResult; + } + + public HealthError getError() { + return error; + } +} diff --git a/CodenameOne/src/com/codename1/health/HealthInterval.java b/CodenameOne/src/com/codename1/health/HealthInterval.java new file mode 100644 index 00000000000..e3eaf449792 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthInterval.java @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import java.util.Calendar; +import java.util.Date; +import java.util.TimeZone; + +/// The bucket width of an [AggregateQuery] -- "per hour", "per calendar +/// day", "per calendar month". +/// +/// #### Fixed durations and calendar periods are not the same thing +/// +/// A calendar day is 23 or 25 hours across a daylight-saving transition, +/// and a month is 28 to 31 days. Bucketing by a fixed `86400000` therefore +/// drifts against the dates a user sees in your UI, silently, twice a +/// year. Both platforms model the distinction natively -- Health Connect +/// splits `aggregateGroupByDuration` from `aggregateGroupByPeriod`, and +/// HealthKit takes `DateComponents` -- so this API keeps it too. +/// +/// Calendar-based intervals require an explicit [TimeZone]. Nothing here +/// reads the JVM default, because a server-side default of UTC would put a +/// user's evening walk into the wrong day. +public final class HealthInterval { + + private final long fixedMillis; + private final int calendarField; + private final int calendarAmount; + private final TimeZone timeZone; + private final int firstDayOfWeek; + + private HealthInterval(long fixedMillis, int calendarField, + int calendarAmount, TimeZone timeZone, int firstDayOfWeek) { + this.fixedMillis = fixedMillis; + this.calendarField = calendarField; + this.calendarAmount = calendarAmount; + this.timeZone = timeZone; + this.firstDayOfWeek = firstDayOfWeek; + } + + private static void requirePositive(long amount, String what) { + if (amount < 1) { + throw new IllegalArgumentException( + what + " must be positive, got " + amount); + } + } + + /// A fixed number of milliseconds. + public static HealthInterval millis(long millis) { + requirePositive(millis, "interval"); + return new HealthInterval(millis, -1, 0, null, 0); + } + + /// A fixed number of minutes. + public static HealthInterval minutes(int minutes) { + requirePositive(minutes, "minutes"); + return millis(minutes * 60000L); + } + + /// A fixed number of hours. + public static HealthInterval hours(int hours) { + requirePositive(hours, "hours"); + return millis(hours * 3600000L); + } + + /// Calendar days in `tz`, aligned to local midnight. Correct across + /// daylight-saving transitions. + public static HealthInterval calendarDays(int days, TimeZone tz) { + requirePositive(days, "days"); + requireZone(tz); + return new HealthInterval(0, Calendar.DAY_OF_MONTH, days, tz, 0); + } + + /// Calendar weeks in `tz`, aligned to `firstDayOfWeek` (a + /// `java.util.Calendar` day constant such as `Calendar.MONDAY`). + /// The first day of the week is explicit because it differs by locale + /// and silently guessing it shifts every bucket boundary. + public static HealthInterval calendarWeeks(int weeks, TimeZone tz, + int firstDayOfWeek) { + requirePositive(weeks, "weeks"); + requireZone(tz); + if (firstDayOfWeek < Calendar.SUNDAY + || firstDayOfWeek > Calendar.SATURDAY) { + throw new IllegalArgumentException( + "firstDayOfWeek must be a java.util.Calendar day" + + " constant, got " + firstDayOfWeek); + } + return new HealthInterval(0, Calendar.WEEK_OF_YEAR, weeks, tz, + firstDayOfWeek); + } + + /// Calendar months in `tz`, aligned to local midnight on the first of + /// the month. + public static HealthInterval calendarMonths(int months, TimeZone tz) { + requirePositive(months, "months"); + requireZone(tz); + return new HealthInterval(0, Calendar.MONTH, months, tz, 0); + } + + private static void requireZone(TimeZone tz) { + if (tz == null) { + throw new IllegalArgumentException( + "a calendar-based interval requires an explicit TimeZone"); + } + } + + /// `true` when this interval follows the calendar rather than a fixed + /// number of milliseconds. + public boolean isCalendarBased() { + return calendarField >= 0; + } + + /// The fixed width in milliseconds, or 0 when [#isCalendarBased()]. + public long getFixedMillis() { + return fixedMillis; + } + + /// The time zone calendar boundaries are computed in, or null for a + /// fixed-duration interval. + public TimeZone getTimeZone() { + return timeZone; + } + + /// The start of the bucket that contains `millis`. For a fixed + /// interval this is anchored on `anchorMillis`; for a calendar + /// interval it snaps to the local period boundary and `anchorMillis` + /// is ignored. + public long bucketStart(long millis, long anchorMillis) { + if (!isCalendarBased()) { + long delta = millis - anchorMillis; + long floored = delta - floorMod(delta, fixedMillis); + return anchorMillis + floored; + } + Calendar c = Calendar.getInstance(timeZone); + c.setTime(new Date(millis)); + if (calendarField == Calendar.MONTH) { + c.set(Calendar.DAY_OF_MONTH, 1); + } else if (calendarField == Calendar.WEEK_OF_YEAR) { + // Walk back to the configured first day. This is done by hand + // rather than with setFirstDayOfWeek + set(DAY_OF_WEEK, ...): + // the CLDC Calendar has no setFirstDayOfWeek, and set(DAY_OF_WEEK) + // can jump forward rather than back. + while (c.get(Calendar.DAY_OF_WEEK) != firstDayOfWeek) { + c.add(Calendar.DAY_OF_MONTH, -1); + } + } + c.set(Calendar.HOUR_OF_DAY, 0); + c.set(Calendar.MINUTE, 0); + c.set(Calendar.SECOND, 0); + c.set(Calendar.MILLISECOND, 0); + return c.getTime().getTime(); + } + + /// The start of the bucket after the one starting at + /// `bucketStartMillis`. Bucket `n`'s exclusive end is bucket `n+1`'s + /// inclusive start, so buckets tile with no gap and no overlap even + /// when their widths differ. + public long nextBoundary(long bucketStartMillis) { + if (!isCalendarBased()) { + return bucketStartMillis + fixedMillis; + } + Calendar c = Calendar.getInstance(timeZone); + c.setTime(new Date(bucketStartMillis)); + if (calendarField == Calendar.WEEK_OF_YEAR) { + c.add(Calendar.DAY_OF_MONTH, 7 * calendarAmount); + } else { + c.add(calendarField, calendarAmount); + } + return c.getTime().getTime(); + } + + private static long floorMod(long x, long y) { + long r = x % y; + return r < 0 ? r + y : r; + } + + @Override + public String toString() { + if (!isCalendarBased()) { + return fixedMillis + "ms"; + } + String field = calendarField == Calendar.MONTH ? "month" + : calendarField == Calendar.WEEK_OF_YEAR ? "week" : "day"; + return calendarAmount + " " + field + "(s) in " + timeZone.getID(); + } +} diff --git a/CodenameOne/src/com/codename1/health/HealthQuantity.java b/CodenameOne/src/com/codename1/health/HealthQuantity.java new file mode 100644 index 00000000000..eb1cdfcb02b --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthQuantity.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// An immutable value paired with the [HealthUnit] it is expressed in. +/// +/// There is deliberately **no zero-argument `getValue()`**. Reading a +/// number out of a quantity forces the caller to name the unit they want +/// it in, which removes an entire class of bug -- the pounds-read-as- +/// kilograms mistake that silently scales every downstream chart by 2.2. +/// +/// ```java +/// HealthQuantity weight = sample.getQuantity(); +/// double kg = weight.getValue(HealthUnit.KILOGRAM); +/// double lb = weight.getValue(HealthUnit.POUND); +/// ``` +public final class HealthQuantity { + + private final double value; + private final HealthUnit unit; + + /// Creates a quantity. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: if `unit` is null. + public HealthQuantity(double value, HealthUnit unit) { + // NaN and the infinities are not measurements. The local store + // persisted them happily and every later total, average, minimum + // and maximum inherited them, while a mobile store would have + // rejected the same write -- so the simulator was the one place a + // malformed number could get in and look fine. + if (Double.isNaN(value) || Double.isInfinite(value)) { + throw new IllegalArgumentException( + "a health quantity must be a finite number, got " + + value); + } + if (unit == null) { + throw new IllegalArgumentException("a quantity requires a unit"); + } + this.value = value; + this.unit = unit; + } + + /// The unit this quantity was created with. + public HealthUnit getUnit() { + return unit; + } + + /// The value converted into `in`. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: if `in` measures a different dimension. + public double getValue(HealthUnit in) { + return HealthUnit.convert(value, unit, in); + } + + /// The value in [#getUnit()], without conversion. Named so that it + /// cannot be reached for by accident when [#getValue(HealthUnit)] is + /// what the caller actually wants. + public double getRawValue() { + return value; + } + + /// This quantity re-expressed in `unit`. Returns `this` when the unit + /// already matches. + @SuppressWarnings("PMD.CompareObjectsWithEquals") + public HealthQuantity in(HealthUnit unit) { + if (unit == this.unit) { + return this; + } + return new HealthQuantity(getValue(unit), unit); + } + + /// Two quantities are equal when they carry the same raw value and the + /// same unit. Deliberately **not** conversion-aware: 1000 g and 1 kg + /// are not `equals`, because making them so would give a class with + /// floating-point-dependent equality and a hash code that cannot be + /// consistent with it. + @Override + @SuppressWarnings("PMD.CompareObjectsWithEquals") + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof HealthQuantity)) { + return false; + } + HealthQuantity other = (HealthQuantity) o; + return unit == other.unit + && Double.doubleToLongBits(value) + == Double.doubleToLongBits(other.value); + } + + @Override + public int hashCode() { + long bits = Double.doubleToLongBits(value); + return 31 * ((int) (bits ^ (bits >>> 32))) + unit.getSymbol().hashCode(); + } + + @Override + public String toString() { + return value + " " + unit.getSymbol(); + } +} diff --git a/CodenameOne/src/com/codename1/health/HealthRequestStatus.java b/CodenameOne/src/com/codename1/health/HealthRequestStatus.java new file mode 100644 index 00000000000..cd3ba2f7703 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthRequestStatus.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// Whether presenting the authorization sheet would actually show the user +/// anything. Maps `HKHealthStore.getRequestStatusForAuthorization` on iOS +/// and a permission-set comparison on Android. +/// +/// This is the **only** read-related signal iOS offers, and it is one-way: +/// [#UNNECESSARY] means "the user has already been asked", never "the user +/// said yes". Use it to decide whether to show a pre-permission explainer +/// screen, not to decide whether reads will return data. +public enum HealthRequestStatus { + + /// At least one requested access has never been presented; showing the + /// sheet will prompt the user. + SHOULD_REQUEST, + + /// Every requested access has already been presented. The user may + /// have granted or denied any of them; this value does not say which, + /// and showing the sheet again would display nothing. + UNNECESSARY, + + /// The platform could not determine the status. + UNKNOWN +} diff --git a/CodenameOne/src/com/codename1/health/HealthSample.java b/CodenameOne/src/com/codename1/health/HealthSample.java new file mode 100644 index 00000000000..2070b9221a6 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthSample.java @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/// The base of every health record: what it measures and when. +/// +/// Concrete subclasses correspond to [HealthDataKind] -- +/// [QuantitySample], [CategorySample], [SeriesSample] and the +/// [SessionSample] family. +/// +/// #### Instants and intervals +/// +/// Every sample carries both a start and an end. When they are equal the +/// sample marks an instant -- see [#isInstantaneous()]. Cumulative types +/// such as steps are always intervals; see +/// [HealthDataType#isIntervalOnly()]. +/// +/// #### Identity +/// +/// [#getId()] is assigned by the platform on write. It is scoped to this +/// platform and this installation and does **not** survive a reinstall, so +/// it must not be used as a primary key on your server. +/// +/// [#getMetadata()] is **not persisted to HealthKit or Health Connect in +/// this release.** It round-trips through the local store used by the +/// simulator, desktop and JavaScript, and it travels with a sample you +/// hold in memory, but a sample written to a mobile platform and read back +/// comes back without it. Health Connect has no arbitrary metadata -- +/// only a single `clientRecordId` -- so a general map cannot be carried +/// there at all. Correlate on your own identifier held in your own +/// storage, keyed by whatever you can reconstruct from the sample's type, +/// time range and value. +/// +/// #### Mutability +/// +/// Samples you build for a write are mutable so optional fields can be +/// filled in before handing them to [HealthStore]. Samples returned from a +/// query are snapshots: changing one does not change the store, and to +/// modify stored data you delete and re-write it. +public abstract class HealthSample { + + private final HealthDataType type; + private final long startMillis; + private final long endMillis; + + private String id; + private HealthSource source; + private RecordingMethod recordingMethod = RecordingMethod.UNKNOWN; + private Map metadata; + + /// Creates a sample spanning `[startMillis, endMillis]`. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: if `type` is null or the end precedes + /// the start. + protected HealthSample(HealthDataType type, long startMillis, + long endMillis) { + if (type == null) { + throw new IllegalArgumentException("a sample requires a data type"); + } + if (endMillis < startMillis) { + throw new IllegalArgumentException( + "sample ends before it starts: " + startMillis + " .. " + + endMillis); + } + this.type = type; + this.startMillis = startMillis; + this.endMillis = endMillis; + } + + /// What this sample measures. + public final HealthDataType getType() { + return type; + } + + /// Inclusive start, epoch millis UTC. + public final long getStartMillis() { + return startMillis; + } + + /// End, epoch millis UTC. Equal to the start for an instantaneous + /// sample. + public final long getEndMillis() { + return endMillis; + } + + /// `true` when this sample marks a moment rather than a span. + public final boolean isInstantaneous() { + return startMillis == endMillis; + } + + /// The span in milliseconds; 0 for an instantaneous sample. + public final long getDurationMillis() { + return endMillis - startMillis; + } + + /// The platform-assigned identifier, or null for a sample that has not + /// been written yet. See the class documentation on identity before + /// persisting this anywhere. + public final String getId() { + return id; + } + + /// Sets the platform identifier. Called by ports when reading; setting + /// it on a sample you are about to write has no effect. + public final void setId(String id) { + this.id = id; + } + + /// Which app and device produced this sample, or null when the + /// platform did not report it. + public final HealthSource getSource() { + return source; + } + + /// Sets the originating source. Called by ports when reading. + public final void setSource(HealthSource source) { + this.source = source; + } + + /// How this sample was recorded. Never null; defaults to + /// [RecordingMethod#UNKNOWN]. + public final RecordingMethod getRecordingMethod() { + return recordingMethod; + } + + /// Declares how this sample was recorded. Worth setting on writes -- + /// other apps use it to decide how much to trust a value. + public final void setRecordingMethod(RecordingMethod recordingMethod) { + this.recordingMethod = recordingMethod == null + ? RecordingMethod.UNKNOWN : recordingMethod; + } + + /// Free-form metadata carried alongside the sample, never null. + /// + /// **Not a place for a correlation identifier**, tempting as it + /// looks. It round-trips through the local and simulator stores but + /// is not written to HealthKit or Health Connect in this release, so + /// on a phone it is gone the moment the sample is read back -- and + /// [#getId()] is no substitute either, being platform-assigned and + /// unstable across a reinstall. Keep the identifier in your own + /// storage and correlate on that. + public final Map getMetadata() { + if (metadata == null) { + return Collections.emptyMap(); + } + return Collections.unmodifiableMap(metadata); + } + + /// Attaches a metadata entry. A null value removes the key. + public final void putMetadata(String key, String value) { + if (key == null) { + return; + } + if (value == null) { + if (metadata != null) { + metadata.remove(key); + } + return; + } + if (metadata == null) { + metadata = new HashMap(); + } + metadata.put(key, value); + } + + /// Two samples are equal when they carry the same platform + /// identifier. Samples that have not been written yet have no + /// identifier and are therefore only equal to themselves. + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof HealthSample)) { + return false; + } + HealthSample other = (HealthSample) o; + return id != null && id.equals(other.id); + } + + @Override + public int hashCode() { + return id == null ? System.identityHashCode(this) : id.hashCode(); + } + + @Override + public String toString() { + return getClass().getName() + "[" + type.getId() + " " + + startMillis + ".." + endMillis + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/HealthSource.java b/CodenameOne/src/com/codename1/health/HealthSource.java new file mode 100644 index 00000000000..89b508ee704 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthSource.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// Where a sample came from -- which app wrote it and, when known, which +/// device produced it. +/// +/// This matters more than it looks. When a phone and a watch both record +/// steps for the same walk, the store holds two overlapping sets of +/// samples, and every platform counts the walk twice: aggregation is done +/// in shared code from raw samples, so HealthKit's own de-duplicating +/// statistics engine is not in play. Filtering a query by source via +/// [AggregateQuery#addSource(String)] is the way to avoid double-counting +/// -- see the warning on [AggregateQuery]. +public final class HealthSource { + + private final String bundleId; + private final String name; + private final String deviceName; + private final String deviceModel; + private final String deviceManufacturer; + + /// Creates a source descriptor. Only `bundleId` is required; the rest + /// may be null where the platform does not report them. + public HealthSource(String bundleId, String name, String deviceName, + String deviceModel, String deviceManufacturer) { + this.bundleId = bundleId; + this.name = name; + this.deviceName = deviceName; + this.deviceModel = deviceModel; + this.deviceManufacturer = deviceManufacturer; + } + + /// The writing app's bundle identifier (iOS) or package name + /// (Android). This is the value to pass to + /// [AggregateQuery#addSource(String)]. + public String getBundleId() { + return bundleId; + } + + /// The writing app's display name, or null. + public String getName() { + return name; + } + + /// The producing device's name, or null -- for example the user's + /// watch. Frequently null for manually entered data. + public String getDeviceName() { + return deviceName; + } + + /// The producing device's model, or null. + public String getDeviceModel() { + return deviceModel; + } + + /// The producing device's manufacturer, or null. + public String getDeviceManufacturer() { + return deviceManufacturer; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof HealthSource)) { + return false; + } + HealthSource other = (HealthSource) o; + return bundleId == null ? other.bundleId == null + : bundleId.equals(other.bundleId); + } + + @Override + public int hashCode() { + return bundleId == null ? 0 : bundleId.hashCode(); + } + + @Override + public String toString() { + return name == null ? String.valueOf(bundleId) + : name + " (" + bundleId + ")"; + } +} diff --git a/CodenameOne/src/com/codename1/health/HealthStore.java b/CodenameOne/src/com/codename1/health/HealthStore.java new file mode 100644 index 00000000000..11f9e81467e --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthStore.java @@ -0,0 +1,3011 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import com.codename1.io.Preferences; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; +import com.codename1.util.AsyncResult; +import com.codename1.util.EasyThread; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Reads, writes and watches the platform health store -- HealthKit on +/// iOS, Health Connect on Android, a local store elsewhere. +/// +/// Obtain one from [Health#getStore()]; it is never null. On a port with +/// no health support this base class is returned as-is and every operation +/// fails fast with [HealthError#NOT_SUPPORTED]. +/// +/// #### Division of labour +/// +/// The public methods here are `final`. They validate requests, normalize +/// units, page through results, compute bucket boundaries, own the +/// subscription registry and persist cursors -- all of it once, in shared +/// code. Ports implement only the `do*` methods, which receive +/// already-validated input and hand back raw platform data. +/// +/// That split is deliberate: it is why a malformed query fails the same +/// way on every platform, why unit conversion cannot drift between ports, +/// and why no port can forget to check that the health provider is +/// actually available. +/// +/// #### Threading +/// +/// Every method may be called from the EDT and returns immediately. +/// +/// Change deliveries -- [HealthChangeListener] and +/// [HealthBackgroundListener] -- always arrive on the EDT. +/// +/// Results of the operations you start arrive on the EDT **on iOS and +/// Android**: both ports marshal every platform answer onto it, and the +/// post-processing below hops back to it when it is done. +/// +/// **The local-backed ports do not carry that guarantee.** On desktop, +/// JavaScript and the simulator a result is delivered on the thread that +/// asked for it, so a read started from a worker calls you back on that +/// worker and touching the UI from there needs your own `callSerially`. +/// [Health] and the developer guide say the same. This section has been +/// wrong in both directions -- it claimed an unconditional EDT guarantee +/// the local store never offered, and then denied the one the mobile +/// ports do make. +/// +/// Post-processing of large result sets -- unit conversion across a +/// hundred thousand samples -- happens on a shared background thread, so +/// nothing here blocks the UI. +public class HealthStore { + + private static final String PREF_SUBS = "cn1$health$subs"; + private static final String PREF_ANCHOR = "cn1$health$anchor$"; + private static final String PREF_LISTENER = "cn1$health$listener$"; + + /// Default per-operation safety timeout for reads and writes. + protected static final int DEFAULT_OPERATION_TIMEOUT = 60000; + + /// Default safety timeout for authorization flows, which involve a + /// user tapping through a sheet. + protected static final int DEFAULT_AUTHORIZATION_TIMEOUT = 300000; + + private final Map subscriptions = + new HashMap(); + private final Map liveListeners = + new HashMap(); + private int operationTimeout = DEFAULT_OPERATION_TIMEOUT; + /// Authorization waits on a person, not on a platform call. + private int authorizationTimeout = DEFAULT_AUTHORIZATION_TIMEOUT; + + /// Ports construct subclasses. Application code obtains the active + /// store from [Health#getStore()]. + protected HealthStore() { + } + + // ================================================================== + // capability queries -- ports override + // ================================================================== + + /// `true` when this store can do anything at all. `false` on the + /// fallback base class. + public boolean isSupported() { + return false; + } + + /// Whether this platform exposes `type`. Even a fully supported + /// platform covers a subset of [HealthDataType#values()]. + public boolean isTypeSupported(HealthDataType type) { + return false; + } + + /// The types this platform can read. Empty on the fallback. + public List getSupportedTypes() { + return Collections.emptyList(); + } + + /// Whether this app may write `type`. Some types are read-only on + /// some platforms -- derived metrics the OS computes itself. + public boolean isWritable(HealthDataType type) { + return false; + } + + /// Whether samples of `type` can be deleted. Both platforms restrict + /// deletion to data this app wrote. + public boolean isDeletable(HealthDataType type) { + return false; + } + + /// The metrics this platform can compute natively for `type`. Metrics + /// outside this set are computed in shared code from raw samples, + /// which is slower but portable. + public List getSupportedMetrics(HealthDataType type) { + return Collections.emptyList(); + } + + /// Whether this platform can deliver changes without the app asking. + public boolean isBackgroundDeliverySupported() { + return false; + } + + /// Whether the OS wakes the app on new data -- see + /// [HealthSubscription#isPushDelivery()]. + public boolean isPushDelivery() { + return false; + } + + /// The largest number of samples one platform write call accepts. + /// Health Connect caps this at 1000; the base class chunks larger + /// writes automatically. + public int getMaxWriteBatchSize() { + return 1000; + } + + /// The unit this platform prefers to receive `type` in. The base class + /// converts before calling [#doWrite(List,AsyncResource)], so ports + /// never do unit arithmetic. + public HealthUnit getPreferredWriteUnit(HealthDataType type) { + return type == null ? null : type.getCanonicalUnit(); + } + + // ================================================================== + // authorization + // ================================================================== + + /// Presents the platform authorization UI for the requested access. + /// + /// **Resolving `true` means the flow completed, not that access was + /// granted.** On iOS the sheet completes identically whether the user + /// enabled every switch or none of them, and HealthKit will not say + /// which. Treat `true` as "the user has now been asked". + /// + /// Only Android distinguishes an explicit dismissal, which fails with + /// [HealthError#USER_CANCELED]. + public final AsyncResource requestAuthorization( + HealthAccess... access) { + AsyncResource out = new OneShot(); + if (failIfUnsupported(out)) { + return out; + } + if (access == null || access.length == 0) { + fail(out, HealthError.INVALID_ARGUMENT, + "requestAuthorization needs at least one access"); + return out; + } + List deduped = new ArrayList(); + for (HealthAccess a : access) { + if (a == null) { + continue; + } + if (!isTypeSupported(a.getType())) { + fail(out, HealthError.TYPE_NOT_SUPPORTED, + a.getType().getId() + + " is not available on this platform"); + return out; + } + if (a.isWrite() && !isWritable(a.getType())) { + // Presenting a write permission this store can never use + // asks the user to grant something for nothing, and the + // flow resolves successfully while every later write is + // rejected locally. + fail(out, HealthError.TYPE_NOT_SUPPORTED, + a.getType().getId() + + " cannot be written on this platform"); + return out; + } + if (!deduped.contains(a)) { + deduped.add(a); + } + } + if (deduped.isEmpty()) { + fail(out, HealthError.INVALID_ARGUMENT, + "requestAuthorization needs at least one access"); + return out; + } + // Authorization waits on a human reading a permission sheet, so + // it gets its own much longer budget -- the operation timeout + // would fail the request while the UI was still legitimately up. + armTimeout(out, authorizationTimeout); + doRequestAuthorization(deduped, out); + return out; + } + + /// This app's write authorization for `type`. Truthful on both + /// platforms. + public HealthAuthorizationStatus getWriteAuthorizationStatus( + HealthDataType type) { + return HealthAuthorizationStatus.NOT_SUPPORTED; + } + + /// This app's read authorization for `type`. + /// + /// **Returns [HealthAuthorizationStatus#UNKNOWN] on iOS in every + /// case.** HealthKit deliberately refuses to disclose read + /// authorization, because an app that could tell the difference + /// between "denied" and "no data" could infer that a user is hiding a + /// pregnancy or a prescription. Android answers truthfully, because + /// its read permissions are ordinary runtime grants. + /// + /// The Android port does not paper over the difference by pretending + /// iOS behaves the same way, and neither should your UI: never tell a + /// user "you denied access" on the strength of this. Say "no data + /// available" and offer [Health#openHealthSettings()]. + /// + /// See [#hasAnyData(HealthDataType,HealthTimeRange)] for the only + /// honest probe. + public HealthAuthorizationStatus getReadAuthorizationStatus( + HealthDataType type) { + return HealthAuthorizationStatus.NOT_SUPPORTED; + } + + /// Whether presenting the authorization sheet would show the user + /// anything -- see [HealthRequestStatus]. Useful for deciding whether + /// to show your own explainer screen first. + /// + /// **Answers [HealthRequestStatus#UNKNOWN] on both phones in this + /// release.** Neither port implements the query yet -- HealthKit's + /// `getRequestStatusForAuthorization` and a Health Connect grant + /// comparison are what would answer it -- so the documented + /// `SHOULD_REQUEST` and `UNNECESSARY` answers never arrive there and + /// an explainer gated on them would never show. Show the explainer on + /// your own terms until this reports something else, and treat + /// `UNKNOWN` as "ask anyway": requesting authorization that is + /// already granted is harmless on both platforms. + public final AsyncResource + getAuthorizationRequestStatus(HealthAccess... access) { + AsyncResource out = + new OneShot(); + if (!isSupported()) { + out.complete(HealthRequestStatus.UNKNOWN); + return out; + } + List list = new ArrayList(); + if (access != null) { + for (HealthAccess a : access) { + if (a != null) { + list.add(a); + } + } + } + if (list.isEmpty()) { + out.complete(HealthRequestStatus.UNNECESSARY); + return out; + } + doGetAuthorizationRequestStatus(list, out); + return out; + } + + /// Runs a bounded query and reports whether any sample came back. + /// + /// This measures **data presence, not permission**, and the name says + /// so on purpose. On iOS a `false` means "denied, or genuinely no + /// data" and the two are indistinguishable -- that is a platform + /// privacy guarantee, not a gap in this API. + /// + /// Note also that on iOS your own writes remain readable to you even + /// when read access was denied, so `true` does not prove you can see + /// *other* apps' data. + public final AsyncResource hasAnyData(final HealthDataType type, + HealthTimeRange range) { + final AsyncResource out = new AsyncResource(); + if (failIfUnsupported(out)) { + return out; + } + SampleQuery q = new SampleQuery().addType(type) + .setTimeRange(range).setLimit(1); + readSamplePage(q).onResult(makeAnyDataCallback(out)); + return out; + } + + /// Built in a static method so the callback carries no synthetic + /// reference to the enclosing store (SpotBugs + /// `SIC_INNER_SHOULD_BE_STATIC_ANON`). + private static AsyncResult + makeAnyDataCallback(final AsyncResource out) { + return new AsyncResult() { + @Override + public void onReady(SamplePage value, Throwable err) { + if (err != null) { + out.error(err); + } else { + out.complete(Boolean.valueOf(!value.isEmpty())); + } + } + }; + } + + // ================================================================== + // reads + // ================================================================== + + /// Reads samples, following paging until the query's limit is reached + /// or the data runs out. + /// + /// Results are normalized: values arrive in the query's unit or the + /// type's canonical unit, and series are flattened unless + /// [SampleQuery#setFlattenSeries(boolean)] says otherwise. + public final AsyncResource> readSamples( + final SampleQuery query) { + final AsyncResource> out = + new AsyncResource>(); + if (failIfUnsupported(out)) { + return out; + } + try { + query.validate(); + requireSupportedTypes(query.getTypes()); + } catch (HealthException ex) { + out.error(ex); + return out; + } + final List collected = new ArrayList(); + // Page through a copy. Paging has to carry a token on the query, + // and mutating the caller's object would leave the last token + // stuck on it -- so a query reused for a second read would + // silently resume mid-way through the first. + // + // Seeded from the caller's token, not from null. A continuation + // built the documented way -- take getNextPageToken() from a page + // and hand it back through setPageToken() -- restarted at the + // first page instead, so the caller got the data it already had + // and never reached the remainder it asked for. + readPageInto(copyForPaging(query), collected, out, + query.getPageToken()); + return out; + } + + /// A shallow copy of `query` that paging may mutate freely. + private static SampleQuery copyForPaging(SampleQuery query) { + SampleQuery copy = new SampleQuery() + .setTimeRange(query.getTimeRange()) + .setLimit(query.getLimit()) + .setSortDescending(query.isSortDescending()) + .setUnit(query.getUnit()) + .setFlattenSeries(query.isFlattenSeries()) + .setSleepSessionGapMillis(query.getSleepSessionGapMillis()); + List types = query.getTypes(); + for (HealthDataType type : types) { + copy.addType(type); + } + List sources = query.getSources(); + for (String source : sources) { + copy.addSource(source); + } + return copy; + } + + private void readPageInto(final SampleQuery query, + final List collected, + final AsyncResource> out, String pageToken) { + query.setPageToken(pageToken); + readSamplePage(query).onResult( + new AsyncResult() { + @Override + public void onReady(SamplePage page, Throwable err) { + if (err != null) { + out.error(err); + return; + } + collected.addAll(page.getSamples()); + String next = page.getNextPageToken(); + int limit = query.getLimit(); + if (next == null || collected.size() >= limit) { + // Trim only when this is genuinely the end. + // Trimming a page that has a continuation + // token would discard samples the token + // resumes past, losing them for good; the + // caller asked for a limit, not for a hole. + // + // And never inside a record. One series record + // flattens into many samples sharing an id, so + // cutting through it drops measurements that no + // token and no repeat of this same query would + // bring back -- a heart-rate record holding + // more points than the limit would come back + // permanently docked. Whole records only: the + // last one is either kept entire, overshooting + // the limit, or dropped entire. + if (next == null && collected.size() > limit) { + trimToRecordBoundary(collected, limit); + } + out.complete(collected); + return; + } + readPageInto(query, collected, out, next); + } + }); + } + + /// Drops whole records from the tail until at most `limit` samples + /// remain, keeping the last record entire when cutting it would be + /// the only way to reach the limit. + /// + /// Samples flattened out of one series record share its identifier, + /// and nothing can return the half that a mid-record cut discards. + private static void trimToRecordBoundary(List collected, + int limit) { + while (collected.size() > limit) { + String id = collected.get(collected.size() - 1).getId(); + int from = collected.size() - 1; + while (from > 0 && sameRecord(collected.get(from - 1), id)) { + from--; + } + if (from < limit) { + // Dropping this record would take us under the limit, so + // it is the record the cut would fall inside. Keep it. + return; + } + while (collected.size() > from) { + collected.remove(collected.size() - 1); + } + } + } + + private static boolean sameRecord(HealthSample s, String id) { + return id != null && id.equals(s.getId()); + } + + /// Reads a single page of samples. Prefer this over + /// [#readSamples(SampleQuery)] for high-frequency types, so peak + /// memory stays bounded regardless of how much history exists. + public final AsyncResource readSamplePage( + final SampleQuery query) { + final AsyncResource out = new AsyncResource(); + if (failIfUnsupported(out)) { + return out; + } + try { + query.validate(); + requireSupportedTypes(query.getTypes()); + } catch (HealthException ex) { + out.error(ex); + return out; + } + final AsyncResource raw = new OneShot(); + raw.onResult(new AsyncResult() { + @Override + public void onReady(SamplePage page, Throwable err) { + if (err != null) { + out.error(err); + return; + } + // Onto the worker. Both mobile ports deliberately + // complete the raw resource on the EDT, so flattening, + // unit conversion, source filtering and the sort all ran + // there -- and this class promises in as many words that + // they do not. A hundred-thousand-point heart-rate page + // froze rendering for exactly as long as it took to + // convert. + postProcessOnWorker(page, query, out, + Display.getInstance().isEdt()); + } + }); + armTimeout(raw); + doReadSamples(query, raw); + return out; + } + + /// The background thread heavy read post-processing runs on, and how + /// many tasks are still to run on it. + /// + /// One thread for all of them rather than one per store: a store is + /// per-port and there is only ever one in an app, but tests build + /// several. + /// + /// It lives only while there is work. A thread started through + /// [Display#startThread(Runnable,String)] is not a daemon, so a + /// permanent one keeps the whole process alive after everything else + /// has finished -- which in an app is invisible and under a test + /// runner is a JVM that never exits. Starting one costs + /// microseconds against a platform query that costs milliseconds, + /// so the burst is the right unit to hold it for. + private static EasyThread worker; + private static int workerTasks; + + private static synchronized EasyThread acquireWorker() { + workerTasks++; + if (worker == null) { + worker = EasyThread.start("CN1 Health"); + } + return worker; + } + + /// Called by a worker task as it finishes, from the worker itself. + /// The thread stops after the task that released the last claim. + private static synchronized void releaseWorker() { + workerTasks--; + if (workerTasks <= 0 && worker != null) { + worker.kill(); + worker = null; + workerTasks = 0; + } + } + + /// Post-processes off the EDT and completes back on it. + private void postProcessOnWorker(final SamplePage page, + final SampleQuery query, final AsyncResource out, + final boolean wasEdt) { + acquireWorker().run(new PostProcess(this, page, query, out, wasEdt)); + } + + /// Named rather than anonymous so the hop carries no synthetic + /// reference to the enclosing store + /// (SpotBugs `SIC_INNER_SHOULD_BE_STATIC_ANON`). + private static final class PostProcess implements Runnable { + + private final HealthStore store; + private final SamplePage page; + private final SampleQuery query; + private final AsyncResource out; + private final boolean wasEdt; + + PostProcess(HealthStore store, SamplePage page, SampleQuery query, + AsyncResource out, boolean wasEdt) { + this.store = store; + this.page = page; + this.query = query; + this.out = out; + this.wasEdt = wasEdt; + } + + @Override + public void run() { + SamplePage done = null; + Throwable failed = null; + try { + done = store.postProcess(page, query); + } catch (Throwable t) { + failed = t; + } + try { + // Released after the hand-back, not before: when that + // runs inline it is what starts the next page, and + // releasing first would retire the thread and start + // another one for every page of a long read. + completeBack(wasEdt, out, done, failed); + } finally { + releaseWorker(); + } + } + } + + /// Hands a result back on the thread it would have arrived on had + /// the work been done inline. + /// + /// `wasEdt` is captured where the raw platform result landed, not + /// here -- here is always the worker. Both mobile ports complete on + /// the EDT and their callers rely on it, so those hop back; a store + /// that completes on a background thread of its own keeps doing that + /// rather than acquiring a dependency on the event loop being pumped. + private static void completeBack(final boolean wasEdt, + final AsyncResource out, final T value, + final Throwable failed) { + Runnable finish = new CompleteOnEdt(out, value, failed); + if (wasEdt) { + Display.getInstance().callSerially(finish); + } else { + finish.run(); + } + } + + /// The [#completeBack] body, named for the same SpotBugs reason. + private static final class CompleteOnEdt implements Runnable { + + private final AsyncResource out; + private final T value; + private final Throwable failed; + + CompleteOnEdt(AsyncResource out, T value, Throwable failed) { + this.out = out; + this.value = value; + this.failed = failed; + } + + @Override + public void run() { + if (failed != null) { + out.error(failed); + } else { + out.complete(value); + } + } + } + + /// True while `sub` is still the registered, active instance. + boolean isStillRegistered(HealthSubscription sub) { + if (sub == null || !sub.isActive()) { + return false; + } + synchronized (subscriptions) { + // Identity on purpose: the question is whether *this* handle + // is the one still registered, not whether an equal one is. + return subscriptions.get(sub.getId()) == sub; //NOPMD CompareObjectsWithEquals + } + } + + /// Records that one queued delivery has finished. + /// + /// `abandoned` is how many later chunks will never be queued, which + /// happens when a listener throws and the rest of the page is dropped. + void noteDeliveryDone(int abandoned) { + synchronized (subscriptions) { + // This delivery, plus the chunks that will never be queued + // because a listener threw. Counting only this one left the + // gate waiting on deliveries that could never arrive, so the + // drain never resolved at all. + pendingDeliveries -= 1 + Math.max(0, abandoned); + if (pendingDeliveries < 0) { + pendingDeliveries = 0; + } + } + releaseDrainGates(); + } + + /// Fails `resource` with [HealthError#TIMEOUT] if the platform never + /// answers. + /// + /// [#getOperationTimeout()] was settable and documented and nothing + /// ever armed a timer, so a native call that lost its callback left + /// the operation pending forever -- which is precisely the case the + /// setting exists for. + protected final void armTimeout(final AsyncResource resource) { + armTimeout(resource, operationTimeout); + } + + protected final void armTimeout(final AsyncResource resource, + int millis) { + if (millis <= 0 || resource == null) { + return; + } + // CLDC's Timer has no named/daemon constructor, and a Timer keeps + // a live thread until it is cancelled -- so it is cancelled when + // the operation finishes, not merely when the deadline fires. + // Otherwise a burst of paged reads left one thread per call + // sitting around for the whole timeout. + final java.util.Timer timer = new java.util.Timer(); + timer.schedule(new TimeoutTask(resource, timer), millis); + resource.onResult(new CancelTimer(timer)); + } + + /// Delivers the timeout failure on the EDT. + /// An [AsyncResource] that keeps the first outcome and ignores the + /// rest. + /// + /// Every operation here is armed with a timeout, and `AsyncResource` + /// itself allows a resource to be completed more than once -- so a + /// platform call that answered after its timeout had fired ran the + /// callbacks a second time. A write reported TIMEOUT and then + /// reported success; a caller that retried on the timeout had both + /// inserts commit, which is a duplicate record in the user's health + /// data. A late drain re-ran the gate and cleared the in-flight state + /// of whichever drain was running by then. + /// + /// The port is not asked to be careful about this. It gets one of + /// these and may answer whenever it likes; a late answer is dropped. + static final class OneShot extends AsyncResource { + + @Override + public synchronized void complete(T value) { + if (isDone()) { + return; + } + super.complete(value); + } + + @Override + public synchronized void error(Throwable t) { + if (isDone()) { + return; + } + super.error(t); + } + } + + private static final class FailTimedOut implements Runnable { + private final AsyncResource resource; + + FailTimedOut(AsyncResource resource) { + this.resource = resource; + } + + @Override + public void run() { + if (resource.isDone()) { + return; + } + resource.error(new HealthException(HealthError.TIMEOUT, + "the platform did not answer within the configured" + + " timeout")); + } + } + + /// Cancels a timeout timer once its operation has finished. + private static final class CancelTimer implements AsyncResult { + private final java.util.Timer timer; + + CancelTimer(java.util.Timer timer) { + this.timer = timer; + } + + @Override + public void onReady(Object value, Throwable error) { + timer.cancel(); + } + } + + /// Named rather than anonymous so the timer holds no synthetic + /// reference to the store. + private static final class TimeoutTask extends java.util.TimerTask { + private final AsyncResource resource; + private final java.util.Timer timer; + + TimeoutTask(AsyncResource resource, java.util.Timer timer) { + this.resource = resource; + this.timer = timer; + } + + @Override + public void run() { + timer.cancel(); + if (resource.isDone()) { + return; + } + // Failed on the EDT like every other completion. Erroring from + // the timer thread handed the application callback a thread it + // is documented never to see. + Display.getInstance().callSerially(new FailTimedOut(resource)); + } + } + + /// Normalizes a raw platform page: flattens series when asked, and + /// converts every value into the requested or canonical unit. + /// + /// Doing this in shared code is what makes the two ports return + /// byte-identical objects. Left to the ports, HealthKit would return + /// whatever unit the query happened to ask for and Health Connect its + /// own fixed unit, and the difference would surface as a chart that + /// looks right in the simulator and wrong on a device. + private SamplePage postProcess(SamplePage page, SampleQuery query) + throws HealthException { + List in = page.getSamples(); + List outSamples = new ArrayList(in.size()); + List wanted = query.getSources(); + boolean flattened = false; + for (HealthSample s : in) { + // Applied here, not only in the ports, because a port that + // cannot express the filter natively would otherwise return + // every app's data and silently double-count phone and watch. + // A port that does filter natively simply has nothing left to + // drop. + if (!wanted.isEmpty() && (s.getSource() == null + || !wanted.contains(s.getSource().getBundleId()))) { + continue; + } + if (query.isFlattenSeries() && s instanceof SeriesSample) { + SeriesSample series = (SeriesSample) s; + HealthTimeRange asked = query.getTimeRange() == null ? null + : query.getTimeRange() + .resolve(System.currentTimeMillis()); + for (int j = 0; j < series.size(); j++) { + // Each measurement against the requested range, not + // just the record. A store matches the enclosing span, + // so a one-minute query over an hour-long heart-rate + // record returned the whole hour -- every point + // outside the range the caller asked for. + if (!inRange(asked, series.getSampleStartMillis(j), + series.getSampleEndMillis(j))) { + continue; + } + outSamples.add(normalize(series.toQuantitySample(j), + query)); + flattened = true; + } + } else { + outSamples.add(normalize(s, query)); + } + } + if (flattened) { + // Sorted by measurement, because flattening changed what a + // "sample" is. A port orders records, and a series carries its + // own points in whatever order it was built with -- so an + // expanded page followed neither the requested direction nor + // any other, on the local store and on Health Connect alike. + Collections.sort(outSamples, + new ByStart(query.isSortDescending())); + } + return new SamplePage(outSamples, page.getNextPageToken(), + page.isTruncated()); + } + + /// Orders samples by when they were measured. + private static final class ByStart + implements java.util.Comparator { + private final boolean descending; + + ByStart(boolean descending) { + this.descending = descending; + } + + @Override + public int compare(HealthSample a, HealthSample b) { + long x = a.getStartMillis(); + long y = b.getStartMillis(); + if (x == y) { + return 0; + } + return (x < y) != descending ? -1 : 1; + } + } + + /// Half-open membership, the same rule the stores match records by: + /// an instant at the start is inside, an interval ending there is not. + private static boolean inRange(HealthTimeRange range, long start, + long end) { + if (range == null) { + return true; + } + if (start >= range.getEndMillis()) { + return false; + } + return start == end ? end >= range.getStartMillis() + : end > range.getStartMillis(); + } + + /// Converts the two pressures a blood-pressure reading carries. + /// + /// It is not a QuantitySample -- it holds two quantities rather than + /// one -- so it fell straight through the normalizer and came back in + /// whatever unit it was stored in. `readSamples` documents its + /// results as normalized, and validation accepts a pressure unit on + /// the query because BLOOD_PRESSURE is a pressure type, so a caller + /// asking for mmHg against a store holding kPa was answered in kPa + /// with nothing to say so. The same reading then measured differently + /// depending on which unit it happened to be written in. + /// + /// The pulse is left alone: it is a frequency, and the query's unit + /// is a pressure. Converting it would need a second unit the query + /// has no way to express. + @SuppressWarnings("PMD.CompareObjectsWithEquals") + private HealthSample normalizeBloodPressure(BloodPressureSample bp, + SampleQuery query) throws HealthException { + HealthUnit target = query.getUnit(); + if (target == null) { + target = bp.getType().getCanonicalUnit(); + } + if (target == null + || (bp.getSystolic().getUnit() == target + && bp.getDiastolic().getUnit() == target)) { + return bp; + } + BloodPressureSample converted = BloodPressureSample.create( + bp.getSystolic().in(target), bp.getDiastolic().in(target), + bp.getStartMillis()); + converted.setPulse(bp.getPulse()); + converted.setBodyPosition(bp.getBodyPosition()); + converted.setMeasurementLocation(bp.getMeasurementLocation()); + converted.setId(bp.getId()); + converted.setSource(bp.getSource()); + converted.setRecordingMethod(bp.getRecordingMethod()); + for (Map.Entry e : bp.getMetadata().entrySet()) { + converted.putMetadata(e.getKey(), e.getValue()); + } + return converted; + } + + @SuppressWarnings("PMD.CompareObjectsWithEquals") + private HealthSample normalize(HealthSample s, SampleQuery query) + throws HealthException { + if (s instanceof SeriesSample) { + return normalizeSeries((SeriesSample) s, query); + } + if (s instanceof BloodPressureSample) { + return normalizeBloodPressure((BloodPressureSample) s, query); + } + if (!(s instanceof QuantitySample)) { + return s; + } + QuantitySample q = (QuantitySample) s; + HealthUnit target = query.getUnit(); + if (target == null) { + target = q.getType().getCanonicalUnit(); + } + if (target == null || q.getQuantity().getUnit() == target) { + return s; + } + QuantitySample converted = q.isInstantaneous() + ? QuantitySample.create(q.getType(), + q.getQuantity().in(target), q.getStartMillis()) + : QuantitySample.create(q.getType(), + q.getQuantity().in(target), q.getStartMillis(), + q.getEndMillis()); + converted.setId(q.getId()); + converted.setSource(q.getSource()); + converted.setRecordingMethod(q.getRecordingMethod()); + // Metadata travels with the sample. Losing it merely because the + // caller asked for pounds instead of kilograms would drop + // whatever the caller put there, on one unit and not another. + // + // Copied entry by entry: getMetadata() hands back an unmodifiable + // view, so putAll on it throws and would have failed the whole + // write rather than merely losing the metadata. + for (Map.Entry e : q.getMetadata().entrySet()) { + converted.putMetadata(e.getKey(), e.getValue()); + } + return converted; + } + + /// Converts a whole series into the query's unit. + /// + /// A series reaches here only when the caller turned flattening off, + /// which is the one path where the values are not repackaged as + /// [QuantitySample]s on the way out. Returning it untouched left + /// `getUnit()` reporting whatever the platform stored while + /// [#readSamplePage(SampleQuery)] promises the requested unit, so the + /// same query answered in two different units depending on a flag that + /// has nothing to do with units. + @SuppressWarnings("PMD.CompareObjectsWithEquals") + private static SeriesSample normalizeSeries(SeriesSample series, + SampleQuery query) { + HealthUnit target = query.getUnit(); + if (target == null) { + target = series.getType().getCanonicalUnit(); + } + if (target == null || series.getUnit() == target) { + return series; + } + int n = series.size(); + long[] starts = new long[n]; + long[] ends = new long[n]; + double[] values = new double[n]; + for (int i = 0; i < n; i++) { + starts[i] = series.getSampleStartMillis(i); + ends[i] = series.getSampleEndMillis(i); + values[i] = series.getSampleValue(i, target); + } + SeriesSample converted = SeriesSample.create(series.getType(), + series.getStartMillis(), series.getEndMillis(), starts, ends, + values, target); + converted.setId(series.getId()); + converted.setSource(series.getSource()); + converted.setRecordingMethod(series.getRecordingMethod()); + for (Map.Entry e : series.getMetadata().entrySet()) { + converted.putMetadata(e.getKey(), e.getValue()); + } + return converted; + } + + /// Checks and converts a series exactly as a scalar write is checked. + /// + /// A series used to be returned untouched, skipping both the + /// dimension check and the conversion to the port's preferred write + /// unit. The bridges read the value and ignore the unit that travels + /// beside it, so a heart-rate series in `COUNT_PER_SECOND` was stored + /// as the same numbers in `COUNT_PER_MINUTE` -- 2 Hz became 2 bpm + /// rather than 120 -- and a series in an entirely wrong dimension was + /// accepted without complaint. + private SeriesSample validateSeriesForWrite(SeriesSample series, + HealthDataType type) throws HealthException { + // Every measurement, not only the enclosing span. The wire expands + // a series point by point, so an interval-only type whose points + // are instants produced zero-duration StepsRecords that Health + // Connect rejects at runtime -- after the outer span had passed + // the check just above. + if (type.isIntervalOnly()) { + for (int i = 0; i < series.size(); i++) { + if (series.getSampleStartMillis(i) + >= series.getSampleEndMillis(i)) { + throw new HealthException(HealthError.INVALID_ARGUMENT, + type.getId() + " accumulates over time, but" + + " measurement " + i + " of this series" + + " marks a single instant"); + } + } + } + HealthUnit preferred = getPreferredWriteUnit(type); + if (preferred == null) { + return series; + } + if (!series.getUnit().isCompatibleWith(preferred)) { + throw new HealthException(HealthError.UNIT_MISMATCH, + type.getId() + " is measured in " + + preferred.getDimension() + " but the series is" + + " in " + series.getUnit().getSymbol()); + } + // Units are interned, so identity is the intended test. + if (series.getUnit() == preferred) { //NOPMD CompareObjectsWithEquals + return series; + } + int n = series.size(); + long[] starts = new long[n]; + long[] ends = new long[n]; + double[] values = new double[n]; + for (int i = 0; i < n; i++) { + starts[i] = series.getSampleStartMillis(i); + ends[i] = series.getSampleEndMillis(i); + values[i] = series.getSampleValue(i, preferred); + } + SeriesSample converted = SeriesSample.create(type, + series.getStartMillis(), series.getEndMillis(), starts, ends, + values, preferred); + converted.setId(series.getId()); + converted.setSource(series.getSource()); + converted.setRecordingMethod(series.getRecordingMethod()); + for (Map.Entry e : series.getMetadata().entrySet()) { + converted.putMetadata(e.getKey(), e.getValue()); + } + return converted; + } + + // ================================================================== + // aggregates + // ================================================================== + + /// Summarizes data into time buckets. + /// + /// Returns one [AggregateResult] per bucket, in chronological order, + /// including buckets that held no data -- those come back empty rather + /// than being omitted, so a chart can render a gap where a gap + /// belongs. Read the double-counting warning on [AggregateQuery] + /// before trusting a cross-platform total. + public final AsyncResource> aggregate( + AggregateQuery query) { + AsyncResource> out = + new OneShot>(); + if (failIfUnsupported(out)) { + return out; + } + try { + query.validate(); + requireSupportedTypes(query.getTypes()); + } catch (HealthException ex) { + out.error(ex); + return out; + } + doAggregate(query, bucketBoundaries(query), out); + return out; + } + + /// Aggregates raw samples into the query's buckets. + /// + /// This is the one implementation of the arithmetic. Ports that have a + /// native aggregation API can override [#doAggregate] and use it; ports + /// that do not get this for free, which is what stops a total computed + /// on iOS from disagreeing with the same total computed on Android + /// because two implementations rounded differently. + protected final List aggregateSamples( + AggregateQuery query, long[] boundaries, + List samples) { + HealthTimeRange range = + query.getTimeRange().resolve(System.currentTimeMillis()); + List results = new ArrayList(); + for (int b = 0; b + 1 < boundaries.length; b++) { + long start = boundaries[b]; + long end = boundaries[b + 1]; + AggregateResult bucket = new AggregateResult(start, end); + for (HealthDataType type : query.getTypes()) { + aggregateInto(bucket, query, type, start, end, range, + samples); + } + results.add(bucket); + } + return results; + } + + /// Computes one type's metrics over one bucket. + /// + /// Buckets with no data are left empty rather than filled with zeros, + /// which is what lets a chart draw a gap where the user's phone was in + /// a drawer instead of a flat line at zero. + @SuppressWarnings("PMD.CompareObjectsWithEquals") + private static void aggregateInto(AggregateResult bucket, + AggregateQuery query, HealthDataType type, long start, long end, + HealthTimeRange range, List samples) { + HealthUnit unit = query.getUnit() != null ? query.getUnit() + : type.getCanonicalUnit(); + List inBucket = new ArrayList(); + List weights = new ArrayList(); + List durations = new ArrayList(); + // The clipped spans themselves, not a running total. DURATION is + // documented as time covered, and two samples of one type really + // do overlap -- a phone and a watch both recording, or a series + // whose measurements are intervals. Added up, `[0,10]` beside + // `[5,15]` reported 20ms of a 15ms bucket, and a bucket can no + // more hold more time than it is wide than a night can hold more + // sleep than it lasted. + List covered = new ArrayList(); + int count = 0; + for (HealthSample s : samples) { + if (s.getType() != type) { + continue; + } + // Half-open, matching the range contract. An interval whose + // end lands exactly on this bucket's start has zero overlap + // with it and belongs entirely to the previous bucket; + // counting it in both double-counted every record sitting on a + // boundary. An instant at the inclusive start still belongs + // here, hence the isInstantaneous allowance. + if (s.getStartMillis() >= end) { + continue; + } + if (s.isInstantaneous() ? s.getEndMillis() < start + : s.getEndMillis() <= start) { + continue; + } + // Overlap, matching sample queries. Filtering on start time + // dropped an interval that began before the range and ended + // inside it, so steps recorded 11:55-12:05 contributed nothing + // to an aggregate starting at noon instead of their + // proportional five minutes. The bucket arithmetic below + // already clips, so keeping it here is safe. + // Half-open, matching the bucket test above and the sample + // query. A `<` test let an interval ending exactly at the + // range start through: with a calendar bucket that begins + // before the requested range -- a daily bucket on a + // noon-to-midnight query -- it was counted, and contributed to + // the average, minimum and maximum with zero overlap. An + // instant at the start is inside; an interval ending there is + // not. + if ((s.isInstantaneous() + ? s.getEndMillis() < range.getStartMillis() + : s.getEndMillis() <= range.getStartMillis()) + || s.getStartMillis() >= range.getEndMillis()) { + continue; + } + if (!sourceAllowed(s, query)) { + continue; + } + // Counted after the shape is expanded, not here. A series is + // one record holding many measurements, and counting the + // container reported COUNT as 1 for a three-point series while + // the same data read through a flattening store reported 3. + // Clip to the bucket. A two-hour workout spanning two hourly + // buckets contributes one hour to each, not two hours to both; + // adding the whole span to every bucket it touches inflates + // every summary around a boundary. + // Clipped to the bucket *and* the query range. A calendar + // bucket can extend past the requested range -- a daily bucket + // on a noon-to-midnight query starts at midnight -- so + // clipping only to the bucket counted time the caller never + // asked about. + long from = Math.max(start, range.getStartMillis()); + long to = Math.min(end, range.getEndMillis()); + long overlap = Math.min(to, s.getEndMillis()) + - Math.max(from, s.getStartMillis()); + if (overlap < 0) { + overlap = 0; + } + long span = s.getDurationMillis(); + // An instantaneous sample covers no time. The one-millisecond + // substitute below is an averaging weight, not a duration -- + // adding it here made "total time covered" grow with the + // number of spot readings. + // + // A series is accounted for measurement by measurement in the + // branch below, so its enclosing span must not be added here + // as well. + if (!(s instanceof SeriesSample) && span > 0 && overlap > 0) { + covered.add(new long[] { + Math.max(from, s.getStartMillis()), + Math.min(to, s.getEndMillis()), + }); + } + if (s instanceof SeriesSample) { + // A series reaches here whole -- the local and simulator + // stores hold it that way and aggregate their records + // directly, without the read path's flattening. Counting + // it while contributing none of its measurements produced + // a null AVERAGE, MINIMUM, MAXIMUM and LATEST for a record + // full of values. + SeriesSample series = (SeriesSample) s; + boolean cumulativeSeries = type.getAggregationStyle() + == HealthAggregationStyle.CUMULATIVE; + for (int i = 0; i < series.size(); i++) { + long at = series.getSampleStartMillis(i); + long until = series.getSampleEndMillis(i); + // Overlap, not start-only. A measurement may be an + // interval -- an interval-only type requires it -- and + // testing its start alone dropped one running + // 11:55-12:05 from a noon bucket entirely, while the + // same span as a scalar sample contributed its five + // minutes. + if (at == until + ? (at < from || at >= to) + : (until <= from || at >= to)) { + continue; + } + long pointOverlap = Math.min(to, until) + - Math.max(from, at); + if (pointOverlap < 0) { + pointOverlap = 0; + } + long pointSpan = until - at; + count++; + inBucket.add(series.toQuantitySample(i)); + // Same rule as a scalar sample: a cumulative value + // straddling the boundary contributes in proportion, a + // discrete one counts whole. + weights.add(Double.valueOf( + cumulativeSeries && pointSpan > 0 + ? (double) pointOverlap + / (double) pointSpan + : 1.0)); + durations.add(Double.valueOf( + pointSpan <= 0 ? 1 : pointOverlap)); + if (pointSpan > 0 && pointOverlap > 0) { + covered.add(new long[] { + Math.max(from, at), Math.min(to, until), + }); + } + } + } else { + count++; + } + if (s instanceof QuantitySample) { + inBucket.add((QuantitySample) s); + // Cumulative quantities are divisible, so a sample that + // straddles a boundary contributes in proportion. Discrete + // ones are not -- half a heart rate is not a heart rate -- + // so they count whole and only weight the average. + boolean cumulative = span > 0 + && type.getAggregationStyle() + == HealthAggregationStyle.CUMULATIVE; + weights.add(Double.valueOf(cumulative + ? (double) overlap / (double) span : 1.0)); + durations.add(Double.valueOf(span <= 0 ? 1 : overlap)); + } + } + bucket.setSampleCount(type, count); + if (count == 0) { + return; + } + List metrics = query.getMetrics(); + for (AggregateMetric metric : metrics) { + if (metric == AggregateMetric.COUNT) { + bucket.put(type, metric, + new HealthQuantity(count, HealthUnit.COUNT)); + continue; + } + if (metric == AggregateMetric.DURATION) { + bucket.put(type, metric, + new HealthQuantity(coveredMillis(covered), + HealthUnit.MILLISECOND)); + continue; + } + if (inBucket.isEmpty() || unit == null) { + continue; + } + bucket.put(type, metric, new HealthQuantity( + compute(metric, inBucket, unit, weights, durations), + unit)); + } + } + + /// Time actually covered by a set of spans, counting an overlap once. + /// + /// Both mobile stores fall back to this shared aggregation, so summing + /// the spans instead put the error everywhere: two sources recording + /// the same walk, or one series whose measurements are intervals, and + /// DURATION exceeded the bucket it was measured over. Clamping to the + /// bucket width would have hidden that rather than fixed it -- the + /// figure would still be wrong for every bucket wide enough not to hit + /// the clamp. + private static long coveredMillis(List spans) { + if (spans.isEmpty()) { + return 0; + } + Collections.sort(spans, ByFrom.INSTANCE); + long total = 0; + long openFrom = spans.get(0)[0]; + long openTo = spans.get(0)[1]; + for (int iter = 1; iter < spans.size(); iter++) { + long[] span = spans.get(iter); + if (span[0] > openTo) { + total += openTo - openFrom; + openFrom = span[0]; + openTo = span[1]; + } else if (span[1] > openTo) { + openTo = span[1]; + } + } + return total + openTo - openFrom; + } + + /// Sorts spans by start so the merge above only has to look at the one + /// span it currently has open. + private static final class ByFrom implements java.util.Comparator { + + static final ByFrom INSTANCE = new ByFrom(); + + @Override + public int compare(long[] a, long[] b) { + if (a[0] == b[0]) { + return 0; + } + return a[0] < b[0] ? -1 : 1; + } + } + + private static boolean sourceAllowed(HealthSample s, + AggregateQuery query) { + List sources = query.getSources(); + if (sources.isEmpty()) { + return true; + } + return s.getSource() != null + && sources.contains(s.getSource().getBundleId()); + } + + /// Applies one metric to the samples in a bucket. + /// + /// The average is duration-weighted, so a heart rate held across ten + /// minutes counts for more than a single spot reading -- an + /// unweighted mean over irregularly-sampled data is the classic way to + /// make a chart disagree with the platform's own summary. + private static double compute(AggregateMetric metric, + List in, HealthUnit unit, List weights, + List durations) { + if (metric == AggregateMetric.TOTAL) { + double sum = 0; + for (int i = 0; i < in.size(); i++) { + sum += in.get(i).getValue(unit) + * weights.get(i).doubleValue(); + } + return sum; + } + if (metric == AggregateMetric.MINIMUM) { + double min = Double.MAX_VALUE; + for (QuantitySample inItem : in) { + min = Math.min(min, inItem.getValue(unit)); + } + return min; + } + if (metric == AggregateMetric.MAXIMUM) { + double max = -Double.MAX_VALUE; + for (QuantitySample inItem : in) { + max = Math.max(max, inItem.getValue(unit)); + } + return max; + } + if (metric == AggregateMetric.LATEST) { + QuantitySample latest = in.get(0); + for (int i = 1; i < in.size(); i++) { + if (in.get(i).getStartMillis() > latest.getStartMillis()) { + latest = in.get(i); + } + } + return latest.getValue(unit); + } + // AVERAGE, weighted by the time each sample spent in this bucket. + double weighted = 0; + double totalWeight = 0; + for (int i = 0; i < in.size(); i++) { + double weight = Math.max(1, durations.get(i).doubleValue()); + weighted += in.get(i).getValue(unit) * weight; + totalWeight += weight; + } + return totalWeight == 0 ? 0 : weighted / totalWeight; + } + + /// Computes the bucket boundaries for a query, as `n+1` timestamps + /// bounding `n` buckets. + /// + /// Exposed to ports because both platforms want the boundaries up + /// front, and because getting daylight-saving right exactly once is + /// better than getting it wrong twice. + protected final long[] bucketBoundaries(AggregateQuery query) { + HealthTimeRange range = + query.getTimeRange().resolve(System.currentTimeMillis()); + HealthInterval bucket = query.getBucket(); + if (bucket == null) { + return new long[] { range.getStartMillis(), + range.getEndMillis() }; + } + List bounds = new ArrayList(); + long cursor = bucket.bucketStart(range.getStartMillis(), + range.getStartMillis()); + bounds.add(Long.valueOf(cursor)); + while (cursor < range.getEndMillis()) { + long next = bucket.nextBoundary(cursor); + if (next <= cursor) { + // A calendar interval that fails to advance would spin + // forever; stop rather than hang the app. + break; + } + cursor = next; + bounds.add(Long.valueOf(cursor)); + } + long[] out = new long[bounds.size()]; + for (int i = 0; i < out.length; i++) { + out[i] = bounds.get(i).longValue(); + } + return out; + } + + // ================================================================== + // writes + // ================================================================== + + /// Writes one sample. + public final AsyncResource write(HealthSample sample) { + List one = new ArrayList(1); + one.add(sample); + return write(one); + } + + /// Writes several samples, chunking to [#getMaxWriteBatchSize()]. + /// + /// Rejects before touching the platform: types this app cannot write, + /// instantaneous samples of interval-only types such as + /// [HealthDataType#STEPS], and quantities whose unit measures the + /// wrong dimension. Catching these here turns what would be an opaque + /// platform exception into a message that names the offending sample. + public final AsyncResource write( + final List samples) { + final AsyncResource out = + new AsyncResource(); + if (failIfUnsupported(out)) { + return out; + } + if (samples == null || samples.isEmpty()) { + fail(out, HealthError.INVALID_ARGUMENT, + "write needs at least one sample"); + return out; + } + List prepared = new ArrayList(); + try { + for (HealthSample sample : samples) { + prepared.add(validateForWrite(sample)); + } + } catch (HealthException ex) { + out.error(ex); + return out; + } + writeChunk(splitOversizedSeries(prepared, + Math.max(1, getMaxWriteBatchSize())), 0, + new HealthWriteResult(), out); + return out; + } + + /// One record per measurement, so the chunker's budget is honest. + /// + /// A series reaches the platform as one record per point -- see + /// `HealthWire.encodeSamples` -- while the chunker counted it as a + /// single sample. A 5,000-point series therefore went to Health + /// Connect as 5,000 records in one call, past its 1,000-record cap, + /// and the whole write was rejected even though `write` documents + /// automatic chunking. Anything longer than a batch is split into + /// several series first, so no single one can overflow a chunk on its + /// own. + /// + /// The pieces share the original's identifier: they came from one + /// record and there is nothing else to call them. + private static List splitOversizedSeries( + List samples, int max) { + List out = new ArrayList(); + for (HealthSample sample : samples) { + if (!(sample instanceof SeriesSample) + || ((SeriesSample) sample).size() <= max) { + out.add(sample); + continue; + } + SeriesSample series = (SeriesSample) sample; + for (int from = 0; from < series.size(); from += max) { + out.add(slice(series, from, + Math.min(series.size(), from + max))); + } + } + return out; + } + + /// Measurements `[from,to)` of `series` as a series of their own. + private static SeriesSample slice(SeriesSample series, int from, int to) { + int n = to - from; + long[] starts = new long[n]; + long[] ends = new long[n]; + double[] values = new double[n]; + for (int i = 0; i < n; i++) { + starts[i] = series.getSampleStartMillis(from + i); + ends[i] = series.getSampleEndMillis(from + i); + values[i] = series.getSampleValue(from + i, series.getUnit()); + } + // The span is the extent of the slice, not its first start and + // last end. Nothing documents the arrays as chronological, and on + // an unordered series those two are not the bounds -- the factory + // would reject the slice outright when the last point is the + // earliest, or accept a span that excludes points it contains. + long spanStart = starts[0]; + long spanEnd = ends[0]; + for (int i = 1; i < n; i++) { + spanStart = Math.min(spanStart, starts[i]); + spanEnd = Math.max(spanEnd, ends[i]); + } + SeriesSample part = SeriesSample.create(series.getType(), spanStart, + spanEnd, starts, ends, values, series.getUnit()); + part.setId(series.getId()); + part.setSource(series.getSource()); + part.setRecordingMethod(series.getRecordingMethod()); + for (Map.Entry e : series.getMetadata().entrySet()) { + part.putMetadata(e.getKey(), e.getValue()); + } + return part; + } + + /// How many platform records a sample becomes. + private static int recordCost(HealthSample sample) { + return sample instanceof SeriesSample + ? Math.max(1, ((SeriesSample) sample).size()) : 1; + } + + private void writeChunk(final List all, final int from, + final HealthWriteResult accumulated, + final AsyncResource out) { + if (from >= all.size()) { + out.complete(accumulated); + return; + } + // Counted in records rather than in samples, because that is what + // the platform's batch cap counts. + int max = Math.max(1, getMaxWriteBatchSize()); + int to = from; + int cost = 0; + while (to < all.size()) { + int next = recordCost(all.get(to)); + if (to > from && cost + next > max) { + break; + } + cost += next; + to++; + } + List chunk = new ArrayList( + all.subList(from, to)); + final int nextFrom = to; + AsyncResource chunkResult = + new OneShot(); + chunkResult.onResult( + new AsyncResult() { + @Override + public void onReady(HealthWriteResult value, + Throwable err) { + if (err != null) { + // Earlier chunks are already in the store. + // Reporting only the failure makes a caller + // retry the whole batch and duplicate them. + HealthException wrapped = + err instanceof HealthException + ? (HealthException) err + : new HealthException( + HealthError.UNKNOWN, + "the write failed partway", + err); + if (!accumulated.getSampleIds().isEmpty()) { + // Only when a chunk actually committed. + // getPartialResult() is documented as null + // unless an earlier chunk went in, and an + // empty-but-present result reads as "some + // of this is already stored" -- so a + // caller written to avoid duplicates + // suppressed a retry that was perfectly + // safe. Every single-sample write failure + // took that branch, because the first + // chunk is the only chunk. + wrapped.setPartialResult(accumulated); + } + out.error(wrapped); + return; + } + List ids = value.getSampleIds(); + for (String id : ids) { + accumulated.addSampleId(id); + } + List rejects = value.getRejections(); + for (String reject : rejects) { + accumulated.addRejection(reject); + } + writeChunk(all, nextFrom, accumulated, out); + } + }); + armTimeout(chunkResult); + doWrite(chunk, chunkResult); + } + + @SuppressWarnings("PMD.CompareObjectsWithEquals") + private HealthSample validateForWrite(HealthSample sample) + throws HealthException { + if (sample == null) { + throw new HealthException(HealthError.INVALID_ARGUMENT, + "cannot write a null sample"); + } + HealthDataType type = sample.getType(); + if (!isTypeSupported(type)) { + throw new HealthException(HealthError.TYPE_NOT_SUPPORTED, + type.getId() + " is not available on this platform"); + } + if (!isWritable(type)) { + throw new HealthException(HealthError.UNAUTHORIZED, + type.getId() + " is read-only on this platform"); + } + if (type.isIntervalOnly() && sample.isInstantaneous()) { + throw new HealthException(HealthError.INVALID_ARGUMENT, + type.getId() + " accumulates over time and needs a" + + " start and an end, but this sample marks a" + + " single instant"); + } + if (sample instanceof SeriesSample) { + return validateSeriesForWrite((SeriesSample) sample, type); + } + if (!(sample instanceof QuantitySample)) { + return sample; + } + QuantitySample q = (QuantitySample) sample; + HealthUnit preferred = getPreferredWriteUnit(type); + if (preferred == null) { + return sample; + } + if (!q.getQuantity().getUnit().isCompatibleWith(preferred)) { + throw new HealthException(HealthError.UNIT_MISMATCH, + type.getId() + " is measured in " + + preferred.getDimension() + " but the sample is" + + " in " + q.getQuantity().getUnit().getSymbol()); + } + if (q.getQuantity().getUnit() == preferred) { + return sample; + } + QuantitySample converted = q.isInstantaneous() + ? QuantitySample.create(type, q.getQuantity().in(preferred), + q.getStartMillis()) + : QuantitySample.create(type, q.getQuantity().in(preferred), + q.getStartMillis(), q.getEndMillis()); + converted.setRecordingMethod(q.getRecordingMethod()); + // The source travels too, as it already does on the read-side + // conversion. Dropping it here made the same measurement + // filterable or not depending on which unit the caller happened + // to write it in: a weight in kilograms kept its source and one + // in pounds came back excluded from every addSource() query and + // every source-filtered aggregate. + converted.setSource(q.getSource()); + // Metadata travels with the sample. Losing it merely because the + // caller asked for pounds instead of kilograms would drop + // whatever the caller put there, on one unit and not another. + // + // Copied entry by entry: getMetadata() hands back an unmodifiable + // view, so putAll on it throws and would have failed the whole + // write rather than merely losing the metadata. + for (Map.Entry e : q.getMetadata().entrySet()) { + converted.putMetadata(e.getKey(), e.getValue()); + } + return converted; + } + + /// Deletes samples this app wrote. See [HealthDeleteRequest]. + public final AsyncResource delete(HealthDeleteRequest request) { + AsyncResource out = new OneShot(); + if (failIfUnsupported(out)) { + return out; + } + if (request == null) { + fail(out, HealthError.INVALID_ARGUMENT, + "delete needs a request"); + return out; + } + try { + request.validate(); + requireSupportedTypes(request.getTypes()); + } catch (HealthException ex) { + out.error(ex); + return out; + } + armTimeout(out); + doDelete(request, out); + return out; + } + + // ================================================================== + // subscriptions + // ================================================================== + + /// Subscribes with a listener that survives the app being killed. + /// + /// `backgroundListenerClass` must be a public top-level class with a + /// public no-argument constructor implementing + /// [HealthBackgroundListener] -- see that interface for the full + /// contract. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: if the class is null or does not + /// implement [HealthBackgroundListener]. This is checked eagerly, + /// because the alternative is discovering the mistake weeks later + /// when a background relaunch silently does nothing. + public final HealthSubscription subscribe(SubscriptionRequest request, + Class backgroundListenerClass) { + if (backgroundListenerClass == null) { + throw new IllegalArgumentException( + "subscribe needs a background listener class"); + } + if (!HealthBackgroundListener.class + .isAssignableFrom(backgroundListenerClass)) { + throw new IllegalArgumentException(backgroundListenerClass.getName() + + " does not implement HealthBackgroundListener"); + } + return register(request, null, backgroundListenerClass.getName()); + } + + /// Subscribes with an in-memory listener, dropped when the process + /// ends. Use the `Class` overload if you need delivery after the app + /// has been killed. + public final HealthSubscription subscribe(SubscriptionRequest request, + HealthChangeListener listener) { + if (listener == null) { + throw new IllegalArgumentException( + "subscribe needs a listener"); + } + return register(request, listener, null); + } + + /// Registers, publishing the handle, its persisted request and its + /// listener binding as one transaction. + /// + /// `backgroundListenerClass` is the class name to persist, or null + /// for an in-memory listener, whose binding is deleted instead. It + /// used to be written by the caller after this returned: a drain + /// arriving in between delivered the new subscription to the class it + /// had just replaced, and two registrations of one id could write + /// their bindings in either order, so the wrong one survived a + /// restart. + private HealthSubscription register(SubscriptionRequest request, + HealthChangeListener listener, + String backgroundListenerClass) { + ensureSubscriptionsRestored(); + if (request == null) { + throw new IllegalArgumentException( + "subscribe needs a request"); + } + // Reads validate this; subscriptions did not, so an unsupported + // store handed back an active handle that delivered nothing + // forever, and a supported one accepted a type whose every later + // drain failed. + if (!isSupported()) { + throw new IllegalStateException( + "health data is not available on this platform"); + } + for (HealthDataType t : request.getTypes()) { + if (!isTypeSupported(t)) { + throw new IllegalArgumentException(t.getId() + + " is not available on this platform, so a" + + " subscription for it could never deliver"); + } + } + try { + request.validate(); + } catch (HealthException ex) { + IllegalArgumentException wrapped = + new IllegalArgumentException(ex.getMessage()); + wrapped.initCause(ex); + throw wrapped; + } + HealthSubscription sub = new HealthSubscription(this, + request.getId(), request.getTypes(), isPushDelivery(), + request.isDeliverSamples(), request.isIncludeDeletions(), + request.getMaxSamplesPerBatch()); + HealthAnchor stored; + synchronized (subscriptions) { + // The cursor is read inside the transaction that publishes + // the handle. Read before it, a cancellation arriving in the + // gap deleted the anchor and this registration then published + // a handle already carrying it -- so restarting under an id + // resumed from the cursor stop() had promised to discard. + stored = loadAnchor(request.getId()); + // Only when the types are the same. A Health Connect change + // token is issued for a *type set*: re-registering an id with + // different types and keeping the old token means doSubscribe + // sees a non-null anchor, takes no new baseline, and the + // subscription goes on reporting changes for the types it + // used to watch while every change to the new ones is missed + // silently. Dropping it costs one empty baseline drain; + // keeping it costs the data. + if (stored != null && !sameTypes(request)) { + stored = null; + storeAnchor(request.getId(), null); + } + // The new handle carries the persisted cursor. + // + // The anchor used to reach doSubscribe() alone, which neither + // mobile store overrides, while both drains read + // sub.getAnchor(). So re-registering an id -- replacing a + // listener, or restoring a subscription at launch -- started + // the next drain from a fresh baseline and silently discarded + // everything accumulated since the last one. + if (stored != null) { + sub.noteDelivery(stored, 0L); + } + HealthSubscription existing = subscriptions.get(request.getId()); + if (existing != null) { + existing.markInactive(); + } + subscriptions.put(request.getId(), sub); + if (listener == null) { + liveListeners.remove(request.getId()); + } else { + liveListeners.put(request.getId(), listener); + } + if (backgroundListenerClass != null) { + Preferences.set(PREF_LISTENER + request.getId(), + backgroundListenerClass); + } else { + // An id previously bound to a background listener class + // keeps that binding in Preferences, and restoration + // after a relaunch resolves it. Replacing such a + // subscription with an in-memory listener would then + // deliver its changes to the very class the app had just + // replaced. + Preferences.delete(PREF_LISTENER + request.getId()); + } + // Persisted inside the same critical section that published + // the handle. Between the two, another thread could + // unsubscribe this id or replace it -- and this registration, + // already stale, then wrote its request back over the + // cancellation. The live registry and PREF_SUBS disagreed + // from that moment, and the cancelled subscription, or the + // replaced one's old types and options, came back at the next + // launch. + rememberSubscriptionLocked(request); + } + if (isSupported()) { + doSubscribe(request, sub); + } + return sub; + } + + /// Restores persisted subscriptions once, before anything reads or + /// mutates the registry. + /// + /// Deliberately lazy rather than eager: restoring calls into + /// `doSubscribe`, so it cannot run until the port is fully built. + private void ensureSubscriptionsRestored() { + boolean interrupted = false; + synchronized (subscriptions) { + if (restoringThread == Thread.currentThread()) { + // Restoration itself calls doSubscribe, and a port is + // free to look at the registry from there. The thread + // doing the work passes straight through rather than + // waiting for itself. + return; + } + while (restoringThread != null) { + // The flag used to be published before the work was done, + // so a second thread walked on into a half-filled + // registry: it could unsubscribe an id restoration had + // read but not yet inserted, deleting the preferences + // while the restoring thread went on to put that very + // subscription back and call doSubscribe for it. A + // cancelled subscription resurrected, watching data the + // app had told it to stop watching. + try { + subscriptions.wait(); + } catch (InterruptedException ex) { + // Interrupted, but still waiting. Returning here let + // the caller carry on against the half-filled + // registry -- an interrupted unsubscribe could delete + // an entry restoration had read and not yet inserted, + // and the restoring thread then put it back and + // subscribed it. That is the resurrection this wait + // exists to prevent, reachable again through the one + // exit that skipped it. The flag is preserved for the + // caller to act on once the registry is whole. + interrupted = true; + } + } + if (subscriptionsRestored) { + if (interrupted) { + Thread.currentThread().interrupt(); + } + return; + } + restoringThread = Thread.currentThread(); + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + try { + restoreSubscriptions(); + } finally { + synchronized (subscriptions) { + subscriptionsRestored = true; + restoringThread = null; + subscriptions.notifyAll(); + } + } + } + + /// Cancels a subscription and discards its persisted cursor. + /// Idempotent. + public final void unsubscribe(String subscriptionId) { + unsubscribe(subscriptionId, null); + } + + /// Cancels `subscriptionId`, but only when the registered handle is + /// `expected` -- or unconditionally when that is null, which is what + /// the public call means. + /// + /// [HealthSubscription#stop()] passes its own handle. Cancelling by + /// id alone let a stale handle, stopped after its id had been + /// re-registered, remove and tear down its successor instead of + /// itself. + void unsubscribe(String subscriptionId, HealthSubscription expected) { + if (subscriptionId == null) { + return; + } + ensureSubscriptionsRestored(); + HealthSubscription sub; + synchronized (subscriptions) { + if (expected != null + && subscriptions.get(subscriptionId) != expected) { //NOPMD CompareObjectsWithEquals + return; + } + sub = subscriptions.remove(subscriptionId); + liveListeners.remove(subscriptionId); + // The removal and the persisted cleanup are one transaction, + // the mirror of what register() does. Released between them, + // a registration of the same id that arrived in the gap had + // its request deleted from PREF_SUBS by this older + // unsubscribe: the replacement stayed live in memory and was + // gone at the next launch. + forgetSubscriptionLocked(subscriptionId); + Preferences.delete(PREF_ANCHOR + subscriptionId); + Preferences.delete(PREF_LISTENER + subscriptionId); + } + if (sub != null) { + sub.markInactive(); + } + if (isSupported() && sub != null) { + // Outside the lock: this calls into the port, and holding + // the registry monitor across a platform call orders it + // against every lock a port happens to take. + // + // The handle rather than the id, so the port tears down the + // generation that was cancelled. Checking beforehand that + // nothing had re-registered the id was not enough: a + // registration landing between that test and this call owned + // the platform state, and the teardown dismantled it -- on + // Android by dropping the new handle from baselinesInFlight, + // after which its first drain took a second baseline token + // and lost the window between them. A test cannot close that + // gap; naming the generation removes it. + doUnsubscribe(sub); + } + } + + /// Every currently registered subscription. + public final List getSubscriptions() { + ensureSubscriptionsRestored(); + synchronized (subscriptions) { + return new ArrayList(subscriptions.values()); + } + } + + /// Drains pending changes for every active subscription, resolving + /// with the number of batches delivered. + /// + /// **Your app decides when this happens.** Nothing here hooks the + /// application lifecycle, so call it when you come to the foreground, + /// and from your background-fetch handler -- Health Connect never + /// wakes the app on its own, and this release registers no + /// `HKObserverQuery` either, so nothing else will notice new data + /// while your app is closed. [HealthSubscription#isPushDelivery()] + /// answers false everywhere for the same reason. + /// + /// Overlapping calls are coalesced: a drain started while one is + /// already running does not read the same change window a second + /// time, it resolves alongside the one in flight. + public final AsyncResource drainChanges() { + AsyncResource out = new AsyncResource(); + ensureSubscriptionsRestored(); + // A second drain would snapshot the same anchors and read the + // same window, delivering every batch twice and letting two + // callbacks persist cursors in whatever order they finished. + synchronized (subscriptions) { + if (drainInFlight) { + pendingDrains.add(out); + return out; + } + drainInFlight = true; + } + if (!isSupported()) { + finishDrain(out, Integer.valueOf(0), null); + return out; + } + List subs = getSubscriptions(); + if (subs.isEmpty()) { + finishDrain(out, Integer.valueOf(0), null); + return out; + } + // The public resource resolves only once the deliveries this drain + // queued have actually run. The ports complete their own resource + // as soon as they have handed the batches over, but delivery hops + // through callSerially -- so an app polling again from the + // completion callback used to find the cursor unmoved and fetch + // the same page a second time. + AsyncResource portResult = new OneShot(); + portResult.onResult(new DrainGate(this, out)); + // Armed like every other platform call. Without it a delegate that + // never calls back leaves DrainGate unrun and `drainInFlight` set + // for the life of the process, so this drain and every coalesced + // one behind it wait forever -- the coalescing turning one lost + // callback into a permanently dead subscription. + armTimeout(portResult); + doDrainChanges(subs, portResult); + return out; + } + + /// Resolves this drain and every call that arrived while it ran. + /// + /// The coalesced callers get the same answer as the drain they waited + /// on, because it is the same work: reading the window a second time + /// would deliver every batch twice. + void finishDrain(AsyncResource out, Integer value, + Throwable error) { + List> waiting; + synchronized (subscriptions) { + drainInFlight = false; + waiting = new ArrayList>(pendingDrains); + pendingDrains.clear(); + } + resolve(out, value, error); + for (AsyncResource pending : waiting) { + resolve(pending, value, error); + } + } + + private static void resolve(AsyncResource out, Integer value, + Throwable error) { + if (out.isDone()) { + return; + } + if (error != null) { + out.error(error); + } else { + out.complete(value); + } + } + + /// Holds a drain's completion until its queued deliveries have run. + private static final class DrainGate + implements AsyncResult { + private final HealthStore store; + private final AsyncResource out; + + DrainGate(HealthStore store, AsyncResource out) { + this.store = store; + this.out = out; + } + + @Override + public void onReady(Integer value, Throwable error) { + // Errors wait on the delivery queue exactly as successes do. + // A drain can fail on its last subscription having already + // queued batches for the healthy ones, and finishing straight + // away let the error callback start the next drain before + // those had persisted their cursors -- so the window they + // covered was read and delivered a second time. That became + // ordinary the moment the ports started reporting a skipped + // subscription instead of swallowing it. + store.whenDeliveriesDrain(out, value, error); + } + } + + /// Completes `out` once no delivery is outstanding, with `value` or + /// with `error`. + void whenDeliveriesDrain(final AsyncResource out, + final Integer value, final Throwable error) { + boolean now; + synchronized (subscriptions) { + now = pendingDeliveries <= 0; + if (!now) { + drainGates.add(new Object[] { out, value, error }); + } + } + if (now) { + finishDrain(out, error == null ? value : null, error); + } + } + + /// Releases any drain waiting on the delivery queue. + private void releaseDrainGates() { + List ready = null; + synchronized (subscriptions) { + if (pendingDeliveries > 0 || drainGates.isEmpty()) { + return; + } + ready = new ArrayList(drainGates); + drainGates.clear(); + } + for (Object[] g : ready) { + // Through finishDrain, so the coalesced callers waiting behind + // this drain are released with it. + finishDrain((AsyncResource) g[0], (Integer) g[1], + (Throwable) g[2]); + } + } + + // ================================================================== + // protected helpers for ports + // ================================================================== + + /// Delivers a batch to whichever listener the subscription registered, + /// on the EDT. + /// + /// **The cursor is persisted only after the listener returns.** That + /// ordering means a crash inside a listener costs one redelivered + /// batch rather than losing the data permanently -- the opposite + /// ordering would advance past data the app never actually processed. + /// + /// #### Returns + /// + /// How many deliveries were queued -- `0` when nothing was, and more + /// than one when the subscription's per-batch cap split the page. + /// Ports add this to the count they resolve `drainChanges` with, which + /// is documented as a number of batches: returning a yes/no counted a + /// page of 250 additions capped at 100 as one batch while the listener + /// was called three times. + protected final int fireChanges(final HealthChangeBatch batch) { + if (batch == null) { + return 0; + } + final String id = batch.getSubscriptionId(); + HealthChangeListener live; + HealthSubscription sub; + synchronized (subscriptions) { + live = liveListeners.get(id); + sub = subscriptions.get(id); + } + if (sub == null) { + return 0; + } + final HealthChangeListener target = live != null ? live + : resolveBackgroundListener(id); + if (target == null) { + // Nothing to hand it to -- a subscription restored after a + // relaunch with no live listener and no persisted class. The + // caller counts what it delivers, and a drain reporting + // batches nobody received reads as handled when it was not. + return 0; + } + final HealthSubscription subscription = sub; + // A cap splits the batch into successive deliveries rather than + // discarding the tail. Every chunk but the last carries no anchor, + // so the cursor only advances once the whole platform page has + // been handed over -- withholding it outright, as an earlier fix + // did, simply re-read the same page forever and the samples past + // the cap were never reachable at all. + // Queued one at a time, not all at once. Enqueuing the whole + // sequence up front meant a listener that threw on an early chunk + // still had the final chunk run and persist the page anchor, + // skipping the failed chunk for good. + List chunks = applyOptions(batch, sub); + synchronized (subscriptions) { + pendingDeliveries += chunks.size(); + } + Display.getInstance().callSerially( + makeDeliveryRunnable(this, target, chunks, 0, + subscription)); + return chunks.size(); + } + + /// Built in a static method so the `Runnable` carries no synthetic + /// reference to the enclosing store (SpotBugs + /// `SIC_INNER_SHOULD_BE_STATIC_ANON`). + private static Runnable makeDeliveryRunnable(final HealthStore store, + final HealthChangeListener listener, + final List chunks, final int index, + final HealthSubscription subscription) { + final HealthChangeBatch batch = chunks.get(index); + return new Runnable() { + @Override + public void run() { + boolean queuedNext = false; + try { + queuedNext = runDelivery(); + } finally { + // Whatever stops this chunk -- a listener that threw, + // or a subscription cancelled before the chunk ran -- + // also stops every chunk behind it, and those will + // never be queued. They are accounted for here or the + // drain waiting on them never resolves, and every + // later drain stays gated behind it too. + store.noteDeliveryDone(queuedNext ? 0 + : chunks.size() - index - 1); + } + } + + private boolean runDelivery() { + // The subscription can be stopped between queuing this and + // running it. Delivering then would call a listener the + // app has cancelled, and persisting the cursor would undo + // what unsubscribe() promised to discard -- or overwrite + // the cursor of a new subscription reusing the same id. + // + // Reported as "nothing queued", because nothing was: an + // earlier version answered as though the tail were still + // coming, and the chunks it never queued stayed counted + // for the life of the process. + if (!store.isStillRegistered(subscription)) { + return false; + } + try { + listener.healthDataChanged(batch); + } catch (Throwable t) { + // A listener that throws must not cost us the cursor + // advance for every *later* batch, but it also must + // not advance past data it failed on -- so log and + // leave the anchor where it was. The rest of the page + // is abandoned too: delivering past a chunk the app + // could not handle would strand it permanently. + com.codename1.io.Log.e(t); + return false; + } + // Checked after the listener returned, and before the + // successor is queued. A listener is free to call + // unsubscribe() -- or re-register its id -- from inside + // healthDataChanged, and persisting this batch's cursor + // afterwards recreated the very cursor unsubscribe() had + // just discarded. Queuing the next chunk first counted + // the abandoned tail twice: once by this runnable and + // once by the one it had already scheduled. + if (!store.isStillRegistered(subscription)) { + return false; + } + if (index + 1 < chunks.size()) { + Display.getInstance().callSerially( + makeDeliveryRunnable(store, listener, chunks, + index + 1, subscription)); + } + if (batch.isResyncRequired()) { + // The cursor the platform rejected is dropped here, + // after the listener has been told to resynchronise -- + // not when the port noticed. Dropping it earlier means + // a listener that threw, or a process that died before + // the queued delivery ran, loses the notification *and* + // the expired token, so the next drain quietly starts a + // fresh baseline and the missed history is never read. + store.clearAnchorIfCurrent(subscription); + } else if (batch.getAnchor() != null) { + store.storeAnchorIfCurrent(subscription, + batch.getAnchor()); + } else { + // A batch with no anchor is one we deliberately did not + // advance past -- a truncated page. Overwriting the + // in-memory cursor with null would send the next drain + // back to a fresh baseline, which on Android means + // discarding the change token entirely. + store.touchDeliveryIfCurrent(subscription); + } + return true; + } + }; + } + + private HealthChangeListener resolveBackgroundListener(String id) { + String className = Preferences.get(PREF_LISTENER + id, null); + if (className == null) { + return null; + } + HealthBackgroundListenerFactory f = backgroundListenerFactory; + if (f == null) { + // No generated bindings in this build. Nothing is lost: the + // caller returns without advancing the anchor, so the changes + // are redelivered once a listener is registered. + com.codename1.io.Log.p("Health: subscription " + id + " has a" + + " background listener but this build contains no" + + " generated bindings, so the delivery is deferred." + + " This is expected in the simulator and in unit" + + " tests; on device the build server generates them."); + return null; + } + final HealthBackgroundListener bg = f.create(className); + if (bg == null) { + com.codename1.io.Log.p("Health: no generated binding for" + + " background listener " + className + " (subscription " + + id + "). It must be a public top-level class with a" + + " public no-argument constructor implementing" + + " HealthBackgroundListener."); + return null; + } + return adaptBackgroundListener(bg); + } + + /// Wraps a background listener as a change listener from a static + /// method, so the adapter holds no reference to the store (SpotBugs + /// `SIC_INNER_SHOULD_BE_STATIC_ANON`). + private static HealthChangeListener adaptBackgroundListener( + final HealthBackgroundListener bg) { + return new HealthChangeListener() { + @Override + public void healthDataChanged(HealthChangeBatch batch) { + bg.healthDataChanged(batch); + } + }; + } + + /// Guards the one-shot restore in [#ensureSubscriptionsRestored]. + private boolean subscriptionsRestored; + /// The thread currently restoring, so others wait for it and it does + /// not wait for itself. Guarded by `subscriptions`. + private Thread restoringThread; + /// Whether a drain has started and not yet resolved. Guarded by + /// `subscriptions`. + private boolean drainInFlight; + /// Drains that arrived while one was already running. + private final List> pendingDrains = + new ArrayList>(); + /// Deliveries queued but not yet run; a drain waits for zero. + private int pendingDeliveries; + private final List drainGates = new ArrayList(); + + /// Volatile because the build-generated factory is installed once + /// during startup and then read from whatever thread the platform + /// delivers a background batch on. A lock here would serialise + /// delivery for a field that is written exactly once. + @SuppressWarnings("PMD.AvoidUsingVolatile") + private static volatile HealthBackgroundListenerFactory + backgroundListenerFactory; + + /// Installs the build-generated factory that constructs background + /// listeners after a process relaunch. + /// + /// Called by code the build server injects into app startup, in the + /// same way the Android port's native bridges are registered. There is + /// deliberately no reflective fallback -- see + /// [HealthBackgroundListenerFactory] for why resolving a class by name + /// is the wrong mechanism on these targets. + public static void setBackgroundListenerFactory( + HealthBackgroundListenerFactory factory) { + backgroundListenerFactory = factory; + } + + /// The persisted cursor for a subscription, or null to start fresh. + protected final HealthAnchor loadAnchor(String subscriptionId) { + return HealthAnchor.fromStorableString( + Preferences.get(PREF_ANCHOR + subscriptionId, null)); + } + + /// Seeds a subscription's starting cursor in memory *and* on disk, + /// for a port that establishes one at registration -- the Health + /// Connect baseline token, say. + /// + /// Both halves are needed. Persisting alone looked correct and did + /// nothing: the next drain still found a null anchor on the handle, + /// took a fresh cursor, and skipped exactly the window the seed + /// existed to cover. + /// + /// The seed is *dropped*, not applied, in two cases, because the + /// cursor it carries comes from a platform call that started earlier + /// and may land arbitrarily late: + /// + /// - the handle is no longer the registered one. It was stopped, or + /// replaced by a new subscription reusing the id. Applying it would + /// restore a cursor `unsubscribe()` promised to discard, or hand a + /// fresh subscription a cursor issued for a different set of types. + /// - the handle already has a cursor. Something has advanced past + /// this seed -- a drain that ran while it was in flight, or a + /// restored cursor from a previous launch -- and rewinding to it + /// would re-deliver changes the app has already been told about. + /// + /// Returns whether the seed was applied, so a port can tell a dropped + /// seed from an accepted one. + protected final boolean seedAnchor(HealthSubscription subscription, + HealthAnchor anchor) { + if (subscription == null || anchor == null) { + return false; + } + // Check and both writes in one critical section. A cancellation + // landing between them removed the handle and deleted its + // preference, and this callback -- which had already passed the + // check -- then seeded the dead handle and wrote its token back, + // for a later registration under that id to load a cursor issued + // for a different type set. + synchronized (subscriptions) { + if (!isStillRegistered(subscription) + || subscription.getAnchor() != null) { + return false; + } + subscription.seedAnchor(anchor); + storeAnchor(subscription.getId(), anchor); + return true; + } + } + + /// Persists `anchor` for `subscription`, in memory and on disk, but + /// only while that handle is still the registered one. + /// + /// The check and the write are one critical section on purpose. Held + /// apart, a cancellation landing between them deleted the cursor and + /// this delivery recreated it a moment later -- so a later + /// registration under the same id resumed from a token issued for a + /// subscription the app had stopped, and skipped its own baseline. + void storeAnchorIfCurrent(HealthSubscription subscription, + HealthAnchor anchor) { + synchronized (subscriptions) { + if (!isStillRegistered(subscription)) { + return; + } + storeAnchor(subscription.getId(), anchor); + subscription.noteDelivery(anchor, System.currentTimeMillis()); + } + } + + /// Drops `subscription`'s cursor if that handle is still current. + void clearAnchorIfCurrent(HealthSubscription subscription) { + synchronized (subscriptions) { + if (!isStillRegistered(subscription)) { + return; + } + clearAnchor(subscription.getId()); + } + } + + /// Records a delivery that deliberately did not move the cursor. + void touchDeliveryIfCurrent(HealthSubscription subscription) { + synchronized (subscriptions) { + if (!isStillRegistered(subscription)) { + return; + } + subscription.noteDelivery(subscription.getAnchor(), + System.currentTimeMillis()); + } + } + + /// Whether the persisted subscription for `request`'s id watches + /// exactly the types it asks for. **Caller holds the registry lock.** + /// + /// Order is not significant -- a subscription is a set of types -- + /// so this compares membership both ways rather than sequence. + private boolean sameTypes(SubscriptionRequest request) { + String stored = Preferences.get(PREF_SUBS, ""); + if (stored.length() == 0) { + return false; + } + List entries = com.codename1.util.StringUtil + .tokenize(stored, '\n'); + SubscriptionRequest previous = null; + for (String entry : entries) { + SubscriptionRequest parsed = parseSubscription(entry); + if (parsed != null + && parsed.getId().equals(request.getId())) { + previous = parsed; + } + } + if (previous == null) { + return false; + } + List was = previous.getTypes(); + List now = request.getTypes(); + return was.size() == now.size() && was.containsAll(now); + } + + /// Persists a cursor. Called by [#fireChanges(HealthChangeBatch)] + /// after successful delivery; ports rarely need it directly. + protected final void storeAnchor(String subscriptionId, + HealthAnchor anchor) { + if (anchor == null) { + Preferences.delete(PREF_ANCHOR + subscriptionId); + } else { + Preferences.set(PREF_ANCHOR + subscriptionId, + anchor.toStorableString()); + } + } + + /// Drops a subscription's cursor, in memory and on disk. + /// + /// For a cursor the platform has rejected outright -- a Health Connect + /// change token that has aged out, say. Clearing only the persisted + /// copy is not enough: the drains read the live handle, so the next + /// one would resend the very token that just failed and keep failing + /// identically forever. + protected final void clearAnchor(String subscriptionId) { + storeAnchor(subscriptionId, null); + HealthSubscription sub; + synchronized (subscriptions) { + sub = subscriptions.get(subscriptionId); + } + if (sub != null) { + sub.noteDelivery(null, System.currentTimeMillis()); + } + } + + /// Applies the subscription's delivery options to a batch. + /// + /// These were settable on SubscriptionRequest and read by nothing, so + /// a notify-only subscription still received full sample payloads, an + /// excluded deletion was still delivered, and a per-batch cap was + /// ignored. Applied here so every port obeys them without knowing they + /// exist. + private static List applyOptions( + HealthChangeBatch batch, HealthSubscription sub) { + List out = new ArrayList(); + List added = batch.getAdded(); + List deleted = batch.getDeletedSampleIds(); + boolean changed = false; + if (!sub.isDeliverSamples() && !added.isEmpty()) { + added = new ArrayList(); + changed = true; + } + if (!sub.isIncludeDeletions() && !deleted.isEmpty()) { + deleted = new ArrayList(); + changed = true; + } + int cap = sub.getMaxSamplesPerBatch(); + if (cap > 0 && added.size() > cap) { + for (int i = 0; i < added.size(); i += cap) { + int to = Math.min(added.size(), i + cap); + boolean last = to == added.size(); + out.add(new HealthChangeBatch(batch.getSubscriptionId(), + batch.getTypes(), + new ArrayList(added.subList(i, to)), + last ? deleted : new ArrayList(), + batch.isResyncRequired(), + last ? batch.getAnchor() : null, + batch.getDeadlineMillis(), + !last || batch.hasMore())); + } + return out; + } + if (!changed) { + out.add(batch); + return out; + } + out.add(new HealthChangeBatch(batch.getSubscriptionId(), + batch.getTypes(), added, deleted, batch.isResyncRequired(), + batch.getAnchor(), batch.getDeadlineMillis(), + batch.hasMore())); + return out; + } + + /// Re-registers every subscription persisted by a previous launch. + /// + /// This is what makes a subscription survive the process being killed: + /// without it, an app relaunched into the background has no idea it was + /// ever watching anything. + /// + /// It runs on its own, the first time anything touches subscriptions -- + /// see [#ensureSubscriptionsRestored]. It used to be documented as + /// something ports had to call from their constructor, which no port + /// did, so persisted subscriptions were silently never restored on any + /// platform. A constructor could not have called it safely anyway: it + /// dispatches to `doSubscribe`, which a subclass has not finished + /// initialising at that point. + protected final void restoreSubscriptions() { + String stored = Preferences.get(PREF_SUBS, ""); + if (stored.length() == 0) { + return; + } + String[] entries = com.codename1.util.StringUtil + .tokenize(stored, '\n').toArray(new String[0]); + for (String entrie : entries) { + SubscriptionRequest req = parseSubscription(entrie); + if (req == null) { + continue; + } + HealthSubscription sub = new HealthSubscription(this, + req.getId(), req.getTypes(), isPushDelivery(), + req.isDeliverSamples(), req.isIncludeDeletions(), + req.getMaxSamplesPerBatch()); + HealthAnchor restored = loadAnchor(req.getId()); + sub.seedAnchor(restored); + synchronized (subscriptions) { + subscriptions.put(req.getId(), sub); + } + if (isSupported()) { + doSubscribe(req, sub); + } + } + } + + private SubscriptionRequest parseSubscription(String entry) { + if (entry == null || entry.trim().length() == 0) { + return null; + } + List parts = com.codename1.util.StringUtil + .tokenize(entry, '\t'); + if (parts.isEmpty()) { + return null; + } + SubscriptionRequest req; + try { + req = new SubscriptionRequest(parts.get(0)); + } catch (IllegalArgumentException ex) { + return null; + } + for (int i = 1; i < parts.size(); i++) { + String part = parts.get(i); + if (part.startsWith(OPTIONS_PREFIX)) { + applyStoredOptions(req, part); + continue; + } + HealthDataType t = HealthDataType.forId(part); + // A type this build no longer knows is skipped rather than + // failing the whole restore -- a downgraded app should keep + // the subscriptions it still understands. + if (t != null) { + req.addType(t); + } + } + return req.getTypes().isEmpty() ? null : req; + } + + /// Marks the field carrying the delivery options. + /// + /// A prefix rather than a fixed position, so an entry written by a + /// build that did not persist them still parses -- its types simply + /// follow the id as before and the defaults apply. + private static final String OPTIONS_PREFIX = "o:"; + + private static void applyStoredOptions(SubscriptionRequest req, + String field) { + String v = field.substring(OPTIONS_PREFIX.length()); + if (v.length() < 2) { + return; + } + req.setDeliverSamples(v.charAt(0) == '1'); + req.setIncludeDeletions(v.charAt(1) == '1'); + try { + req.setMaxSamplesPerBatch(Integer.parseInt(v.substring(2))); + } catch (NumberFormatException ex) { + // Leave the default rather than failing the whole restore. + } + } + + /// Adds `request` to the persisted list. **The caller must hold the + /// registry lock.** + /// + /// It is a read-modify-write over one preference, so two threads + /// subscribing under different ids read the same old value and the + /// second write drops the first -- both live in memory, one gone at + /// the next launch and never drained again. Under the same lock as + /// the map, and in the same critical section that publishes the + /// handle, so the registry and the persisted list cannot disagree + /// about what is registered. + private void rememberSubscriptionLocked(SubscriptionRequest request) { + StringBuilder sb = new StringBuilder(); + List kept = readStoredEntries(request.getId()); + for (String keptItem : kept) { + sb.append(keptItem).append('\n'); + } + sb.append(request.getId()); + // The delivery options ride along in a field the older format did + // not have. Without them a restored notify-only subscription began + // delivering full payloads after a restart, excluded deletions + // came back, and a configured cap was silently lost. + sb.append('\t').append(OPTIONS_PREFIX) + .append(request.isDeliverSamples() ? '1' : '0') + .append(request.isIncludeDeletions() ? '1' : '0') + .append(request.getMaxSamplesPerBatch()); + List types = request.getTypes(); + for (HealthDataType type : types) { + sb.append('\t').append(type.getId()); + } + Preferences.set(PREF_SUBS, sb.toString()); + } + + /// Drops `subscriptionId` from the persisted list. **The caller must + /// hold the registry lock**, for the same reason as + /// [#rememberSubscriptionLocked]. + private void forgetSubscriptionLocked(String subscriptionId) { + List kept = readStoredEntries(subscriptionId); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < kept.size(); i++) { + if (i > 0) { + sb.append('\n'); + } + sb.append(kept.get(i)); + } + Preferences.set(PREF_SUBS, sb.toString()); + } + + private List readStoredEntries(String excludingId) { + List kept = new ArrayList(); + String stored = Preferences.get(PREF_SUBS, ""); + if (stored.length() == 0) { + return kept; + } + List entries = com.codename1.util.StringUtil + .tokenize(stored, '\n'); + for (String e : entries) { + if (e.trim().length() == 0) { + continue; + } + if (e.equals(excludingId) || e.startsWith(excludingId + "\t")) { + continue; + } + kept.add(e); + } + return kept; + } + + /// Sets the per-operation safety timeout in milliseconds. Ports use it + /// so that a platform callback that never arrives surfaces as + /// [HealthError#TIMEOUT] instead of an operation that hangs forever. + protected final void setOperationTimeout(int millis) { + this.operationTimeout = millis; + } + + /// The current per-operation safety timeout. + protected final int getOperationTimeout() { + return operationTimeout; + } + + // ================================================================== + // port SPI -- every method defaults to NOT_SUPPORTED + // ================================================================== + + /// Reads one page of raw platform samples. Input is already validated; + /// units and series flattening are handled by the caller. + protected void doReadSamples(SampleQuery query, + AsyncResource out) { + failNotSupported(out); + } + + /// Computes bucketed aggregates. `boundaries` holds `n+1` timestamps + /// bounding `n` buckets, already daylight-saving correct. + /// + /// Unlike the rest of the port SPI this does **not** default to + /// NOT_SUPPORTED. Neither HealthKit nor Health Connect gives us an + /// aggregation we can use directly for every metric this API exposes, + /// so the default reads the raw samples and runs the shared arithmetic + /// in [#aggregateSamples]. A port only overrides this when its native + /// aggregation is genuinely better -- and if it does, it owes the same + /// answers. + protected void doAggregate(AggregateQuery query, long[] boundaries, + AsyncResource> out) { + aggregateByReadingSamples(query, boundaries, out); + } + + /// The fallback aggregation: read every requested type over the query's + /// range, then bucket the samples locally. + /// + /// Reads one type at a time because HealthKit accepts only one type per + /// query, and it is the narrower contract of the two. + protected final void aggregateByReadingSamples(AggregateQuery query, + long[] boundaries, AsyncResource> out) { + if (boundaries.length < 2) { + out.complete(new ArrayList()); + return; + } + HealthTimeRange range = + query.getTimeRange().resolve(System.currentTimeMillis()); + new AggregateFallback(this, query, boundaries, out, + new ArrayList(query.getTypes()), range) + .next(); + } + + /// Drives the fallback one type at a time, accumulating as it goes. + /// + /// Named rather than anonymous so the EDT hop carries no implicit + /// reference to the enclosing store. + private static final class AggregateFallback + implements AsyncResult> { + + private final HealthStore store; + private final AggregateQuery query; + private final long[] boundaries; + private final AsyncResource> out; + private final List types; + private final HealthTimeRange range; + private final List collected = + new ArrayList(); + private int index; + + AggregateFallback(HealthStore store, AggregateQuery query, + long[] boundaries, AsyncResource> out, + List types, HealthTimeRange range) { + this.store = store; + this.query = query; + this.boundaries = boundaries; + this.out = out; + this.types = types; + this.range = range; + } + + void next() { + if (index >= types.size()) { + // On the worker for the same reason the read + // post-processing is: bucketing a year of heart rate is + // arithmetic over every sample, and the reads that feed + // it deliberately lift their page limit. + acquireWorker().run(new RollUp(store, query, boundaries, + collected, out, + Display.getInstance().isEdt())); + return; + } + SampleQuery q = new SampleQuery() + .addType(types.get(index)) + .setTimeRange(HealthTimeRange.between( + range.getStartMillis(), range.getEndMillis())) + // Without this the fallback inherits SampleQuery's + // default page limit and quietly computes a month of + // heart rate from its first 10,000 samples, reporting + // a total that looks complete and is not. + .setLimit(Integer.MAX_VALUE); + for (String source : query.getSources()) { + q.addSource(source); + } + index++; + store.readSamples(q).onResult(this); + } + + @Override + public void onReady(List value, + Throwable error) { + if (error != null) { + // One unreadable type must not silently produce a total + // that looks complete. The whole aggregate fails. + out.error(error); + return; + } + if (value != null) { + collected.addAll(value); + } + next(); + } + } + + /// The fallback's final bucketing, run off the EDT. + private static final class RollUp implements Runnable { + + private final HealthStore store; + private final AggregateQuery query; + private final long[] boundaries; + private final List collected; + private final AsyncResource> out; + private final boolean wasEdt; + + RollUp(HealthStore store, AggregateQuery query, long[] boundaries, + List collected, + AsyncResource> out, boolean wasEdt) { + this.store = store; + this.query = query; + this.boundaries = boundaries; + this.collected = collected; + this.out = out; + this.wasEdt = wasEdt; + } + + @Override + public void run() { + List done = null; + Throwable failed = null; + try { + done = store.aggregateSamples(query, boundaries, collected); + } catch (Throwable t) { + failed = t; + } + try { + // Released after the hand-back, not before: when that + // runs inline it is what starts the next page, and + // releasing first would retire the thread and start + // another one for every page of a long read. + completeBack(wasEdt, out, done, failed); + } finally { + releaseWorker(); + } + } + } + + /// Writes a chunk no larger than [#getMaxWriteBatchSize()], already + /// validated and converted into [#getPreferredWriteUnit(HealthDataType)]. + protected void doWrite(List samples, + AsyncResource out) { + failNotSupported(out); + } + + /// Deletes samples matching an already-validated request. + protected void doDelete(HealthDeleteRequest request, + AsyncResource out) { + failNotSupported(out); + } + + /// Presents the platform authorization UI. + protected void doRequestAuthorization(List access, + AsyncResource out) { + failNotSupported(out); + } + + /// Reports whether the authorization sheet would show anything. + /// + /// No mobile port overrides this yet, so both phones answer + /// `UNKNOWN`; the public method says so. HealthKit answers it through + /// `getRequestStatusForAuthorization`, and Health Connect through + /// comparing the granted permission set against the requested one -- + /// each of which is a real piece of work rather than a mapping. + protected void doGetAuthorizationRequestStatus(List access, + AsyncResource out) { + out.complete(HealthRequestStatus.UNKNOWN); + } + + /// Registers a platform observer for `subscription`, resuming from + /// [HealthSubscription#getAnchor()] when a cursor was persisted. + /// Called on registration and again on [#restoreSubscriptions()]. + /// + /// The live handle is passed rather than the bare anchor because a + /// port that establishes a starting cursor asynchronously has to be + /// able to tell, when its answer arrives, whether the registration it + /// answers for is still the current one -- see + /// [#seedAnchor(HealthSubscription,HealthAnchor)]. + protected void doSubscribe(SubscriptionRequest request, + HealthSubscription subscription) { + } + + /// Tears down the platform observer belonging to `subscription`. + /// + /// The handle, not the id: another registration under the same id can + /// land between the removal and this call, and a teardown that works + /// by id alone then dismantles the replacement's platform state. A + /// port must key its own bookkeeping on this instance so it tears + /// down the generation that was actually cancelled. + protected void doUnsubscribe(HealthSubscription subscription) { + } + + /// Polls for pending changes and delivers them through + /// [#fireChanges(HealthChangeBatch)], resolving with the number of + /// batches delivered. + protected void doDrainChanges(List subscriptions, + AsyncResource out) { + out.complete(Integer.valueOf(0)); + } + + // ================================================================== + // internals + // ================================================================== + + private void requireSupportedTypes(List types) + throws HealthException { + for (HealthDataType t : types) { + if (!isTypeSupported(t)) { + throw new HealthException(HealthError.TYPE_NOT_SUPPORTED, + t.getId() + " is not available on this platform"); + } + } + } + + private boolean failIfUnsupported(AsyncResource out) { + if (isSupported()) { + return false; + } + failNotSupported(out); + return true; + } + + private static void failNotSupported(AsyncResource out) { + fail(out, HealthError.NOT_SUPPORTED, + "health data is not available on this platform"); + } + + /// Fails inline, on the calling thread. + /// + /// Matches [com.codename1.impl.health.LocalHealthStore] rather than the + /// mobile ports, which marshal to the EDT. Both are documented; see the + /// note there for why the hop is not in place yet. + private static void fail(AsyncResource out, HealthError error, + String message) { + out.error(new HealthException(error, message)); + } +} diff --git a/CodenameOne/src/com/codename1/health/HealthSubscription.java b/CodenameOne/src/com/codename1/health/HealthSubscription.java new file mode 100644 index 00000000000..b9b036f84ac --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthSubscription.java @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// A live handle on a registered subscription. +public final class HealthSubscription { + + private final String id; + private final List types; + private final HealthStore store; + private final boolean pushDelivery; + private final boolean deliverSamples; + private final boolean includeDeletions; + private final int maxSamplesPerBatch; + /// Guards the three mutable fields below, which cross threads: the + /// cursor is written on the EDT -- by a delivery, or by a port + /// seeding a starting cursor -- and read by `drainChanges()` on + /// whatever thread the app called it from. + /// + /// A stale read here is not a stale number, it is lost data: the + /// Android drain reads this anchor and takes a fresh baseline token + /// when it finds null, so a cursor that had been seeded but not yet + /// published meant a second token, and every change between the two + /// permanently unreported. The synchronization the store does around + /// its registry does not help -- the anchor is read before any of it, + /// and a later monitor cannot make an earlier read current. + /// + /// A lock rather than `volatile`, which this codebase does not use. + private final Object lock = new Object(); + /// Guarded by [#lock]. + private HealthAnchor anchor; + /// Guarded by [#lock]. + private long lastDeliveryMillis; + /// Guarded by [#lock]. + private boolean active = true; + + /// Creates a handle. Called by [HealthStore]. + HealthSubscription(HealthStore store, String id, + List types, boolean pushDelivery) { + this(store, id, types, pushDelivery, true, true, 0); + } + + /// Creates a handle carrying the request's delivery options. + HealthSubscription(HealthStore store, String id, + List types, boolean pushDelivery, + boolean deliverSamples, boolean includeDeletions, + int maxSamplesPerBatch) { + this.deliverSamples = deliverSamples; + this.includeDeletions = includeDeletions; + this.maxSamplesPerBatch = maxSamplesPerBatch; + this.store = store; + this.id = id; + this.pushDelivery = pushDelivery; + List copy = new ArrayList(); + if (types != null) { + copy.addAll(types); + } + this.types = copy; + } + + /// Seeds the cursor a previous launch persisted. + /// + /// Restoration without this leaves the handle with a null anchor, and + /// both drain implementations read the handle rather than the store's + /// preference, so Android would discard the persisted change token and + /// start a fresh baseline -- losing everything that accumulated while + /// the process was dead -- and iOS would resume with no window at all. + void seedAnchor(HealthAnchor restored) { + synchronized (lock) { + this.anchor = restored; + } + } + + /// Whether batches carry sample payloads, from the request. + boolean isDeliverSamples() { + return deliverSamples; + } + + /// Whether batches carry deleted ids, from the request. + boolean isIncludeDeletions() { + return includeDeletions; + } + + /// The request's per-batch sample cap, or 0 for no cap. + int getMaxSamplesPerBatch() { + return maxSamplesPerBatch; + } + + /// The identifier this subscription was registered under. + public String getId() { + return id; + } + + /// The types being watched. + public List getTypes() { + return Collections.unmodifiableList(types); + } + + /// `true` until [#stop()] is called. + public boolean isActive() { + synchronized (lock) { + return active; + } + } + + /// Whether the operating system wakes the app when new data arrives. + /// + /// **`false` on every platform in this release.** Health Connect has + /// no push mechanism at all -- Google's own guidance is to poll -- and + /// while HealthKit does offer `HKObserverQuery`, this release registers + /// none. Nothing here hooks the application lifecycle either, so + /// changes arrive exactly when you call [HealthStore#drainChanges()] + /// and at no other time: call it when you come to the foreground and + /// from your background-fetch handler. + /// + /// This is exposed as a queryable fact rather than hidden, because an + /// app that assumes push will silently miss data for days and its + /// authors will never know why -- no changes and no new data look + /// identical from the outside. + public boolean isPushDelivery() { + return pushDelivery; + } + + /// The cursor reached by the most recent delivery, or null before the + /// first one. + public HealthAnchor getAnchor() { + synchronized (lock) { + return anchor; + } + } + + /// When the last batch was delivered, epoch millis, or 0 if never. + public long getLastDeliveryMillis() { + synchronized (lock) { + return lastDeliveryMillis; + } + } + + /// Cancels the subscription and discards its persisted cursor. + /// Idempotent. + /// + /// Restarting later under the same id resynchronizes from scratch, + /// because the cursor is gone. To pause without losing your place, + /// simply stop calling [HealthStore#drainChanges()]. + public void stop() { + boolean wasActive; + synchronized (lock) { + wasActive = active; + active = false; + } + if (wasActive) { + // Outside the lock. unsubscribe() takes the store's registry + // monitor and then reaches back here through markInactive(); + // holding this one across the call would take the two in the + // opposite order from every other path, which is a deadlock. + // This handle, not merely this id: stopped after the id had + // been re-registered, cancelling by id alone removed and tore + // down the replacement instead of the handle being stopped. + store.unsubscribe(id, this); + } + } + + /// Records progress. Called by [HealthStore] after a delivery. + void noteDelivery(HealthAnchor anchor, long whenMillis) { + synchronized (lock) { + this.anchor = anchor; + this.lastDeliveryMillis = whenMillis; + } + } + + /// Marks this handle inactive without re-entering + /// [HealthStore#unsubscribe(String)]. Called by the store when it + /// tears the subscription down itself. + void markInactive() { + synchronized (lock) { + active = false; + } + } + + @Override + public String toString() { + return "HealthSubscription[" + id + " " + types.size() + " types" + + (pushDelivery ? ", push" : ", poll") + + (isActive() ? "" : ", stopped") + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/HealthTimeRange.java b/CodenameOne/src/com/codename1/health/HealthTimeRange.java new file mode 100644 index 00000000000..56ba6a2844f --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthTimeRange.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import java.util.Calendar; +import java.util.TimeZone; + +/// A half-open span of time, `[start, end)`, in epoch milliseconds UTC. +/// +/// #### Why epoch millis rather than java.time +/// +/// `java.time` is available in Codename One, and the calendar API uses it. +/// Health does not, for three reasons: a month of continuous heart rate is +/// tens of thousands of points and one `Instant` per point is real memory +/// pressure on a phone; `com.codename1.location` and +/// `com.codename1.bluetooth` already speak `long` millis and health data +/// flows between all three; and both platform SDKs are millisecond-based +/// at the boundary anyway. +/// +/// Where calendar semantics genuinely matter -- "the last seven days" is +/// not "the last 604800000 milliseconds" across a daylight-saving +/// transition -- the factory takes an explicit [TimeZone]. Nothing in this +/// API silently reads the JVM default. +public final class HealthTimeRange { + + private final long startMillis; + private final long endMillis; + private final boolean openEnded; + + private HealthTimeRange(long startMillis, long endMillis, + boolean openEnded) { + if (endMillis < startMillis) { + throw new IllegalArgumentException( + "time range ends before it starts: " + startMillis + + " .. " + endMillis); + } + this.startMillis = startMillis; + this.endMillis = endMillis; + this.openEnded = openEnded; + } + + /// An explicit span. `end` is exclusive: a sample stamped exactly at + /// `end` belongs to the next range, which is what makes adjacent + /// buckets tile without double-counting. + public static HealthTimeRange between(long startMillis, long endMillis) { + return new HealthTimeRange(startMillis, endMillis, false); + } + + /// A range covering exactly one instant. + /// + /// One millisecond wide rather than zero: ranges are half-open, so + /// `[t,t)` contains nothing at all and the factory documented for + /// querying a single moment returned an empty result for every input, + /// including a sample stamped exactly at `t`. + public static HealthTimeRange at(long instantMillis) { + return new HealthTimeRange(instantMillis, instantMillis + 1, false); + } + + /// The last `durationMillis` milliseconds, ending now. + public static HealthTimeRange lastMillis(long durationMillis) { + long now = System.currentTimeMillis(); + return new HealthTimeRange(now - durationMillis, now, false); + } + + /// The last `hours` hours, ending now. + public static HealthTimeRange lastHours(int hours) { + return lastMillis(hours * 3600000L); + } + + /// The last `days` times 24 hours, ending now. This is a rolling + /// window, **not** a calendar span -- for "the last seven calendar + /// days" use [#calendarDays(int,TimeZone)]. + public static HealthTimeRange lastDays(int days) { + return lastMillis(days * 86400000L); + } + + /// Today, from local midnight to now, in `tz`. + public static HealthTimeRange today(TimeZone tz) { + Calendar c = Calendar.getInstance(tz); + long now = millisOf(c); + startOfDay(c); + return new HealthTimeRange(millisOf(c), now, false); + } + + /// The last `count` calendar days in `tz`, from local midnight at the + /// start of the first day through now. Correct across daylight-saving + /// transitions, where a day is 23 or 25 hours. + public static HealthTimeRange calendarDays(int count, TimeZone tz) { + if (count < 1) { + throw new IllegalArgumentException( + "calendarDays requires a positive count, got " + count); + } + Calendar c = Calendar.getInstance(tz); + long now = millisOf(c); + startOfDay(c); + c.add(Calendar.DAY_OF_MONTH, -(count - 1)); + return new HealthTimeRange(millisOf(c), now, false); + } + + /// Everything from `startMillis` onwards. The end is resolved to the + /// wall clock at the moment the query executes. + public static HealthTimeRange since(long startMillis) { + return new HealthTimeRange(startMillis, Long.MAX_VALUE, true); + } + + /// Reads a calendar's instant. + /// + /// `Calendar.getTimeInMillis()` is protected in the CLDC profile this + /// framework targets, so the value has to come through `getTime()`. + /// The desktop JDK exposes it publicly, which is why this only shows up + /// in a device build. + private static long millisOf(Calendar c) { + return c.getTime().getTime(); + } + + private static void startOfDay(Calendar c) { + c.set(Calendar.HOUR_OF_DAY, 0); + c.set(Calendar.MINUTE, 0); + c.set(Calendar.SECOND, 0); + c.set(Calendar.MILLISECOND, 0); + } + + /// Inclusive start, epoch millis UTC. + public long getStartMillis() { + return startMillis; + } + + /// Exclusive end, epoch millis UTC. `Long.MAX_VALUE` when + /// [#isOpenEnded()]. + public long getEndMillis() { + return endMillis; + } + + /// `true` when this range was built by [#since(long)] and its end is + /// resolved at query time rather than fixed. + public boolean isOpenEnded() { + return openEnded; + } + + /// The span in milliseconds, or `Long.MAX_VALUE` when open-ended. + public long getDurationMillis() { + return openEnded ? Long.MAX_VALUE : endMillis - startMillis; + } + + /// `true` when `millis` falls in `[start, end)`. A zero-width range + /// contains nothing. + public boolean contains(long millis) { + return millis >= startMillis && millis < endMillis; + } + + /// This range with its open end resolved to `nowMillis`. Returns + /// `this` when the range is already closed. + public HealthTimeRange resolve(long nowMillis) { + if (!openEnded) { + return this; + } + return new HealthTimeRange(startMillis, + Math.max(startMillis, nowMillis), false); + } + + @Override + public String toString() { + return "[" + startMillis + ", " + + (openEnded ? "now" : String.valueOf(endMillis)) + ")"; + } +} diff --git a/CodenameOne/src/com/codename1/health/HealthUnit.java b/CodenameOne/src/com/codename1/health/HealthUnit.java new file mode 100644 index 00000000000..23a5dd28dad --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthUnit.java @@ -0,0 +1,333 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// A unit of measure for health data. Instances are interned constants, so +/// `==` is a valid identity test and is used throughout the API. +/// +/// #### The symbol is the wire format +/// +/// [#getSymbol()] is public API rather than an internal detail because the +/// string genuinely has to cross the platform boundary: Apple's +/// `HKUnit(from:)` parses exactly this syntax (`"count/min"`, `"kg"`, +/// `"kcal"`, `"mmHg"`, `"degC"`, `"mg/dL"`, `"mL/(kg*min)"`), so the iOS +/// port passes it straight through with no mapping table. The Android port +/// maps it to the matching `androidx.health.connect.client.units` type. +/// Making that contract visible and documented is better than smuggling it +/// through an implementation class. +/// +/// #### Conversion +/// +/// Conversion is affine (`canonical = value * scale + offset`) rather than +/// a simple ratio, because temperature needs the offset. Converting between +/// units of different dimensions throws [IllegalArgumentException]: that is +/// a bug in the calling code, not a condition to be reported through an +/// `AsyncResource`. +/// +/// ```java +/// double lb = HealthUnit.convert(80, HealthUnit.KILOGRAM, HealthUnit.POUND); +/// ``` +public final class HealthUnit { + + private static final Map BY_SYMBOL = + new HashMap(); + private static final List ALL = new ArrayList(); + + private final String symbol; + private final HealthUnitDimension dimension; + private final double scale; + private final double offset; + + private HealthUnit(String symbol, HealthUnitDimension dimension, + double scale, double offset) { + this.symbol = symbol; + this.dimension = dimension; + this.scale = scale; + this.offset = offset; + } + + private static HealthUnit define(String symbol, + HealthUnitDimension dimension, double scale, double offset) { + HealthUnit u = new HealthUnit(symbol, dimension, scale, offset); + BY_SYMBOL.put(symbol, u); + ALL.add(u); + return u; + } + + private static HealthUnit define(String symbol, + HealthUnitDimension dimension, double scale) { + return define(symbol, dimension, scale, 0); + } + + // ------------------------------------------------------------------ + // count / frequency + // ------------------------------------------------------------------ + + /// A plain tally. Canonical unit of [HealthUnitDimension#COUNT]. + public static final HealthUnit COUNT = + define("count", HealthUnitDimension.COUNT, 1); + + /// Beats, breaths or steps per minute. Canonical unit of + /// [HealthUnitDimension#FREQUENCY]. + public static final HealthUnit COUNT_PER_MINUTE = + define("count/min", HealthUnitDimension.FREQUENCY, 1); + + /// Counts per second. + public static final HealthUnit COUNT_PER_SECOND = + define("count/s", HealthUnitDimension.FREQUENCY, 60); + + /// A ratio expressed as a percentage. Canonical unit of + /// [HealthUnitDimension#PERCENT]. Note that HealthKit represents + /// oxygen saturation and body-fat percentage as 0..1 fractions + /// natively; the iOS port scales them into this unit. + public static final HealthUnit PERCENT = + define("%", HealthUnitDimension.PERCENT, 1); + + // ------------------------------------------------------------------ + // mass + // ------------------------------------------------------------------ + + /// Canonical unit of [HealthUnitDimension#MASS]. + public static final HealthUnit KILOGRAM = + define("kg", HealthUnitDimension.MASS, 1); + public static final HealthUnit GRAM = + define("g", HealthUnitDimension.MASS, 0.001); + public static final HealthUnit MILLIGRAM = + define("mg", HealthUnitDimension.MASS, 0.000001); + public static final HealthUnit MICROGRAM = + define("mcg", HealthUnitDimension.MASS, 0.000000001); + public static final HealthUnit POUND = + define("lb", HealthUnitDimension.MASS, 0.45359237); + public static final HealthUnit OUNCE = + define("oz", HealthUnitDimension.MASS, 0.028349523125); + public static final HealthUnit STONE = + define("st", HealthUnitDimension.MASS, 6.35029318); + + // ------------------------------------------------------------------ + // length + // ------------------------------------------------------------------ + + /// Canonical unit of [HealthUnitDimension#LENGTH]. + public static final HealthUnit METER = + define("m", HealthUnitDimension.LENGTH, 1); + public static final HealthUnit KILOMETER = + define("km", HealthUnitDimension.LENGTH, 1000); + public static final HealthUnit CENTIMETER = + define("cm", HealthUnitDimension.LENGTH, 0.01); + public static final HealthUnit MILE = + define("mi", HealthUnitDimension.LENGTH, 1609.344); + public static final HealthUnit FOOT = + define("ft", HealthUnitDimension.LENGTH, 0.3048); + public static final HealthUnit INCH = + define("in", HealthUnitDimension.LENGTH, 0.0254); + public static final HealthUnit YARD = + define("yd", HealthUnitDimension.LENGTH, 0.9144); + + // ------------------------------------------------------------------ + // energy + // ------------------------------------------------------------------ + + /// Canonical unit of [HealthUnitDimension#ENERGY]. Kilocalories rather + /// than joules because it is the idiomatic unit on both platforms and + /// in every consumer health UI. + public static final HealthUnit KILOCALORIE = + define("kcal", HealthUnitDimension.ENERGY, 1); + public static final HealthUnit KILOJOULE = + define("kJ", HealthUnitDimension.ENERGY, 0.2390057361); + public static final HealthUnit JOULE = + define("J", HealthUnitDimension.ENERGY, 0.0002390057361); + + // ------------------------------------------------------------------ + // time + // ------------------------------------------------------------------ + + /// Canonical unit of [HealthUnitDimension#TIME]. Milliseconds because + /// heart-rate variability is reported in them and the rest of the API + /// speaks epoch millis. + public static final HealthUnit MILLISECOND = + define("ms", HealthUnitDimension.TIME, 1); + public static final HealthUnit SECOND = + define("s", HealthUnitDimension.TIME, 1000); + public static final HealthUnit MINUTE = + define("min", HealthUnitDimension.TIME, 60000); + public static final HealthUnit HOUR = + define("hr", HealthUnitDimension.TIME, 3600000); + + // ------------------------------------------------------------------ + // pressure + // ------------------------------------------------------------------ + + /// Canonical unit of [HealthUnitDimension#PRESSURE]. + public static final HealthUnit MILLIMETER_OF_MERCURY = + define("mmHg", HealthUnitDimension.PRESSURE, 1); + public static final HealthUnit KILOPASCAL = + define("kPa", HealthUnitDimension.PRESSURE, 7.50061683); + + // ------------------------------------------------------------------ + // temperature -- the reason conversion is affine + // ------------------------------------------------------------------ + + /// Canonical unit of [HealthUnitDimension#TEMPERATURE]. + public static final HealthUnit DEGREE_CELSIUS = + define("degC", HealthUnitDimension.TEMPERATURE, 1, 0); + + /// Fahrenheit. `C = F * 5/9 - 160/9`, which is why [HealthUnit] + /// conversion carries an offset rather than being a plain ratio. + public static final HealthUnit DEGREE_FAHRENHEIT = + define("degF", HealthUnitDimension.TEMPERATURE, + 5.0 / 9.0, -160.0 / 9.0); + + // ------------------------------------------------------------------ + // volume + // ------------------------------------------------------------------ + + /// Canonical unit of [HealthUnitDimension#VOLUME]. + public static final HealthUnit LITER = + define("L", HealthUnitDimension.VOLUME, 1); + public static final HealthUnit MILLILITER = + define("mL", HealthUnitDimension.VOLUME, 0.001); + public static final HealthUnit FLUID_OUNCE_US = + define("fl_oz_us", HealthUnitDimension.VOLUME, 0.0295735295625); + public static final HealthUnit CUP_US = + define("cup_us", HealthUnitDimension.VOLUME, 0.2365882365); + + // ------------------------------------------------------------------ + // power / velocity / oxygen uptake + // ------------------------------------------------------------------ + + /// Canonical unit of [HealthUnitDimension#POWER]. + public static final HealthUnit WATT = + define("W", HealthUnitDimension.POWER, 1); + + /// Canonical unit of [HealthUnitDimension#VELOCITY]. + public static final HealthUnit METER_PER_SECOND = + define("m/s", HealthUnitDimension.VELOCITY, 1); + public static final HealthUnit KILOMETER_PER_HOUR = + define("km/hr", HealthUnitDimension.VELOCITY, 1.0 / 3.6); + public static final HealthUnit MILE_PER_HOUR = + define("mi/hr", HealthUnitDimension.VELOCITY, 0.44704); + + /// Canonical unit of [HealthUnitDimension#OXYGEN_UPTAKE]; the standard + /// VO2-max unit. The symbol is the exact `HKUnit` spelling. + public static final HealthUnit ML_PER_KG_PER_MINUTE = + define("mL/(kg*min)", HealthUnitDimension.OXYGEN_UPTAKE, 1); + + // ------------------------------------------------------------------ + // glucose -- see HealthUnitDimension.GLUCOSE_CONCENTRATION + // ------------------------------------------------------------------ + + /// Canonical unit of [HealthUnitDimension#GLUCOSE_CONCENTRATION]; the + /// SI unit, used across most of the world. + public static final HealthUnit MILLIMOLE_PER_LITER = + define("mmol/L", HealthUnitDimension.GLUCOSE_CONCENTRATION, 1); + + /// The unit used clinically in the United States. The 18.0182 factor is + /// the molar mass of **glucose** -- it is not a general mg/dL to mmol/L + /// conversion and must not be reused for another analyte. + public static final HealthUnit MILLIGRAM_PER_DECILITER = + define("mg/dL", HealthUnitDimension.GLUCOSE_CONCENTRATION, + 1.0 / 18.0182); + + // ------------------------------------------------------------------ + + /// The unit symbol, in the exact syntax Apple's `HKUnit(from:)` + /// accepts. See the class documentation for why this is public API. + public String getSymbol() { + return symbol; + } + + /// The physical dimension this unit measures. + public HealthUnitDimension getDimension() { + return dimension; + } + + /// `true` when `other` measures the same dimension and a conversion + /// between the two is therefore meaningful. + public boolean isCompatibleWith(HealthUnit other) { + return other != null && other.dimension == dimension; + } + + /// Converts `value`, expressed in this unit, into the canonical unit of + /// this unit's dimension. + public double toCanonical(double value) { + return value * scale + offset; + } + + /// Converts `value`, expressed in the canonical unit of this unit's + /// dimension, into this unit. + public double fromCanonical(double value) { + return (value - offset) / scale; + } + + /// Converts a value between two units of the same dimension. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: if either unit is null or the two + /// measure different dimensions. Crossing dimensions is a coding + /// error and is surfaced as one. + @SuppressWarnings("PMD.CompareObjectsWithEquals") + public static double convert(double value, HealthUnit from, HealthUnit to) { + if (from == null || to == null) { + throw new IllegalArgumentException("null unit in conversion"); + } + if (from == to) { + return value; + } + if (from.dimension != to.dimension) { + throw new IllegalArgumentException("cannot convert " + + from.symbol + " to " + to.symbol + + ": " + from.dimension + " is not " + to.dimension); + } + return to.fromCanonical(from.toCanonical(value)); + } + + /// Looks a unit up by its symbol, or `null` when the symbol is unknown + /// to this version of the framework. Total by design: a persisted + /// symbol read back by an older runtime yields `null` rather than an + /// exception. + public static HealthUnit forSymbol(String symbol) { + if (symbol == null) { + return null; + } + return BY_SYMBOL.get(symbol); + } + + /// Every unit known to this version of the framework. + public static List values() { + return Collections.unmodifiableList(ALL); + } + + /// Returns [#getSymbol()], so string concatenation in log statements + /// and error messages reads naturally. + @Override + public String toString() { + return symbol; + } +} diff --git a/CodenameOne/src/com/codename1/health/HealthUnitDimension.java b/CodenameOne/src/com/codename1/health/HealthUnitDimension.java new file mode 100644 index 00000000000..4a002a9c7a6 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthUnitDimension.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// The physical dimension of a [HealthUnit]. Two units can be converted +/// into one another exactly when they share a dimension; a conversion +/// across dimensions is a programming error, not a runtime condition. +public enum HealthUnitDimension { + + /// Dimensionless tallies -- steps, flights climbed, repetitions. + COUNT, + + /// Counts per unit time -- heart rate, respiratory rate, cadence. + FREQUENCY, + + /// Body mass, lean mass, bone mass, food mass. + MASS, + + /// Height, distance travelled, elevation. + LENGTH, + + /// Energy burned or consumed. + ENERGY, + + /// Elapsed durations and inter-beat intervals. + TIME, + + /// Blood pressure. + PRESSURE, + + /// Body and basal temperature. + TEMPERATURE, + + /// Liquid volume -- hydration. + VOLUME, + + /// Instantaneous mechanical power. + POWER, + + /// Movement speed. + VELOCITY, + + /// Maximal oxygen uptake. + OXYGEN_UPTAKE, + + /// Ratios expressed as a percentage -- oxygen saturation, body fat. + PERCENT, + + /// **Glucose concentration only.** Deliberately not a general + /// `CONCENTRATION` dimension: converting mg/dL to mmol/L divides by the + /// molar mass of the analyte (18.0182 for glucose), so a shared + /// concentration dimension would silently produce wrong numbers the + /// first time another analyte -- cholesterol, creatinine -- is added in + /// mg/dL. If such a type is ever introduced it must get its own + /// dimension rather than reuse this one. + GLUCOSE_CONCENTRATION +} diff --git a/CodenameOne/src/com/codename1/health/HealthWriteResult.java b/CodenameOne/src/com/codename1/health/HealthWriteResult.java new file mode 100644 index 00000000000..cb12ef24d42 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/HealthWriteResult.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// The outcome of a write. A write can partially succeed -- a batch of a +/// thousand samples with three bad timestamps writes 997 -- so this +/// reports per-sample results rather than a single boolean. +public final class HealthWriteResult { + + private final List sampleIds = new ArrayList(); + private final List rejections = new ArrayList(); + + /// How many samples were stored. + public int getWrittenCount() { + return sampleIds.size(); + } + + /// The platform identifiers assigned to the stored samples, in write + /// order. Read the warning on [HealthSample#getId()] before persisting + /// any of them. + public List getSampleIds() { + return Collections.unmodifiableList(sampleIds); + } + + /// One human-readable message per rejected sample, empty when + /// everything was stored. Surface these rather than discarding them -- + /// a silently dropped sample is the hardest kind of health bug to + /// track down later. + public List getRejections() { + return Collections.unmodifiableList(rejections); + } + + /// `true` when at least one sample was rejected. + public boolean hasRejections() { + return !rejections.isEmpty(); + } + + /// Records a stored sample. Called by [HealthStore] and ports. + public void addSampleId(String id) { + sampleIds.add(id); + } + + /// Records a rejected sample. Called by [HealthStore] and ports. + public void addRejection(String reason) { + if (reason != null) { + rejections.add(reason); + } + } + + @Override + public String toString() { + return "HealthWriteResult[" + sampleIds.size() + " written, " + + rejections.size() + " rejected]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/QuantitySample.java b/CodenameOne/src/com/codename1/health/QuantitySample.java new file mode 100644 index 00000000000..86dd21ffa7e --- /dev/null +++ b/CodenameOne/src/com/codename1/health/QuantitySample.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// A numeric measurement with a unit -- the most common kind of health +/// sample. +/// +/// ```java +/// QuantitySample weight = QuantitySample.create( +/// HealthDataType.BODY_MASS, +/// new HealthQuantity(72.5, HealthUnit.KILOGRAM), +/// System.currentTimeMillis()); +/// weight.setRecordingMethod(RecordingMethod.MANUAL_ENTRY); +/// ``` +public final class QuantitySample extends HealthSample { + + private final HealthQuantity quantity; + + private QuantitySample(HealthDataType type, HealthQuantity quantity, + long startMillis, long endMillis) { + super(type, startMillis, endMillis); + if (quantity == null) { + throw new IllegalArgumentException( + "a quantity sample requires a quantity"); + } + if (type.getKind() != HealthDataKind.QUANTITY) { + throw new IllegalArgumentException(type.getId() + + " is a " + type.getKind() + + " type and cannot be a QuantitySample"); + } + this.quantity = quantity; + } + + /// An instantaneous measurement. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: if the type is interval-only, such as + /// [HealthDataType#STEPS] -- a step count belongs to a span of time, + /// never to a moment. + public static QuantitySample create(HealthDataType type, + HealthQuantity quantity, long instantMillis) { + if (type != null && type.isIntervalOnly()) { + throw new IllegalArgumentException(type.getId() + + " accumulates over time and needs a start and an end;" + + " use create(type, quantity, start, end)"); + } + return new QuantitySample(type, quantity, instantMillis, + instantMillis); + } + + /// A measurement spanning an interval. + public static QuantitySample create(HealthDataType type, + HealthQuantity quantity, long startMillis, long endMillis) { + return new QuantitySample(type, quantity, startMillis, endMillis); + } + + /// The measured value together with its unit. Read a number out of it + /// with [HealthQuantity#getValue(HealthUnit)], which forces the unit + /// to be named at the call site. + public HealthQuantity getQuantity() { + return quantity; + } + + /// Shorthand for `getQuantity().getValue(unit)`. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: if `unit` measures a different + /// dimension than this sample's value. + public double getValue(HealthUnit unit) { + return quantity.getValue(unit); + } + + @Override + public String toString() { + return "QuantitySample[" + getType().getId() + " " + quantity + + " " + getStartMillis() + ".." + getEndMillis() + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/RecordingMethod.java b/CodenameOne/src/com/codename1/health/RecordingMethod.java new file mode 100644 index 00000000000..36e06a5afbf --- /dev/null +++ b/CodenameOne/src/com/codename1/health/RecordingMethod.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// How a sample came to exist. Health Connect records this explicitly; +/// HealthKit infers it from sample metadata. Worth setting on writes -- +/// downstream apps use it to decide whether to trust a value. +public enum RecordingMethod { + + /// The platform did not say. + UNKNOWN, + + /// A human typed it in. + MANUAL_ENTRY, + + /// Recorded passively in the background by a device or app, without + /// the user starting anything. + AUTOMATIC, + + /// Recorded while the user was deliberately tracking -- during a + /// workout they started, or a measurement they triggered. + ACTIVELY_RECORDED +} diff --git a/CodenameOne/src/com/codename1/health/SamplePage.java b/CodenameOne/src/com/codename1/health/SamplePage.java new file mode 100644 index 00000000000..d242407a62f --- /dev/null +++ b/CodenameOne/src/com/codename1/health/SamplePage.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// One page of samples, plus the token needed to fetch the next. +/// +/// ```java +/// String token = null; +/// do { +/// SamplePage page = store.readSamplePage(query.setPageToken(token)).get(); +/// process(page.getSamples()); +/// token = page.getNextPageToken(); +/// } while (token != null); +/// ``` +public final class SamplePage { + + private final List samples; + private final String nextPageToken; + private final boolean truncated; + + /// Creates a page. The sample list is copied defensively. + public SamplePage(List samples, String nextPageToken, + boolean truncated) { + List copy = new ArrayList(); + if (samples != null) { + copy.addAll(samples); + } + this.samples = copy; + this.nextPageToken = nextPageToken; + this.truncated = truncated; + } + + /// The samples in this page, never null. + public List getSamples() { + return Collections.unmodifiableList(samples); + } + + /// The token that fetches the following page, or null when this is the + /// last one. Pass it to [SampleQuery#setPageToken(String)]. + public String getNextPageToken() { + return nextPageToken; + } + + /// `true` when the query's limit cut the result short and more data + /// matched than was returned. + /// + /// **Not a paging instruction.** Page on + /// [#getNextPageToken()] and nothing else. Truncated with a null token + /// is the store saying the rest is unreachable through this query -- + /// Health Connect resumes per record type, so a page merged from + /// several types has no token that could return what the merge + /// dropped. Looping on this flag instead re-issues the same first page + /// forever. Narrow the range, raise the limit, or query one type at a + /// time. + /// + /// A page can also be the last one and still be truncated, which means + /// the store stopped early rather than running out of data -- worth + /// surfacing rather than quietly showing a partial total. + public boolean isTruncated() { + return truncated; + } + + /// How many samples this page holds. + public int size() { + return samples.size(); + } + + /// `true` when this page holds no samples. + public boolean isEmpty() { + return samples.isEmpty(); + } + + @Override + public String toString() { + return "SamplePage[" + samples.size() + " samples" + + (nextPageToken == null ? "" : ", more") + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/SampleQuery.java b/CodenameOne/src/com/codename1/health/SampleQuery.java new file mode 100644 index 00000000000..429f1115e71 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/SampleQuery.java @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// Describes a read against [HealthStore]. Fluent setters return `this`. +/// +/// ```java +/// SampleQuery q = new SampleQuery() +/// .addType(HealthDataType.HEART_RATE) +/// .setTimeRange(HealthTimeRange.lastHours(24)) +/// .setSortDescending(true) +/// .setLimit(500); +/// ``` +/// +/// #### Always set a limit for high-frequency types +/// +/// A year of continuous heart rate is on the order of half a million +/// samples. The default limit of 10,000 exists so that a naive query +/// cannot exhaust the heap on a phone; raise it deliberately, and prefer +/// paging through [HealthStore#readSamplePage(SampleQuery)] over asking +/// for everything at once. +public final class SampleQuery { + + /// The limit applied when none is set. + public static final int DEFAULT_LIMIT = 10000; + + private final List types = new ArrayList(); + private final List sources = new ArrayList(); + private HealthTimeRange timeRange; + private int limit = DEFAULT_LIMIT; + private boolean sortDescending; + private HealthUnit unit; + private boolean flattenSeries = true; + private long sleepSessionGapMillis = 15 * 60 * 1000L; + private String pageToken; + + /// Adds a type to read. At least one is required. + public SampleQuery addType(HealthDataType type) { + if (type != null && !types.contains(type)) { + types.add(type); + } + return this; + } + + /// The types this query reads. + public List getTypes() { + return Collections.unmodifiableList(types); + } + + /// Restricts the query to samples written by one app, identified by + /// its bundle id or package name -- see [HealthSource#getBundleId()]. + /// Call more than once to allow several. + /// + /// Worth doing when a phone and a watch both record the same activity: + /// see the double-counting warning on [AggregateQuery]. + public SampleQuery addSource(String bundleId) { + if (bundleId != null && !sources.contains(bundleId)) { + sources.add(bundleId); + } + return this; + } + + /// The source filter, empty when unrestricted. + public List getSources() { + return Collections.unmodifiableList(sources); + } + + /// The span to read. Required. + public SampleQuery setTimeRange(HealthTimeRange timeRange) { + this.timeRange = timeRange; + return this; + } + + /// The span this query reads, or null when unset. + public HealthTimeRange getTimeRange() { + return timeRange; + } + + /// Caps how many samples come back. Must be positive. + /// + /// **On Android the cap is per data type when a query names several.** + /// Health Connect pages per record type, so a limit of ten over two + /// types can return twenty. Neither alternative works: dividing the + /// budget cannot find a newest-ten that all lives in one type, and + /// trimming the merged page discards records the per-type + /// continuation tokens have already moved past. Honouring it exactly + /// needs an incremental k-way merge across the types, which is not + /// implemented yet. Query one type at a time where the cap has to be + /// exact -- which is also what iOS does, since `HKSampleQuery` reads + /// one type per query. + public SampleQuery setLimit(int limit) { + this.limit = limit; + return this; + } + + /// The sample cap. + public int getLimit() { + return limit; + } + + /// Returns the newest samples first. Default is oldest first. + public SampleQuery setSortDescending(boolean sortDescending) { + this.sortDescending = sortDescending; + return this; + } + + /// `true` when results come back newest first. + public boolean isSortDescending() { + return sortDescending; + } + + /// Returns values in `unit` instead of the type's canonical unit. + /// + /// #### Throws + /// + /// The unit is validated when the query runs, not here: a unit that + /// measures the wrong dimension fails with + /// [HealthError#UNIT_MISMATCH] before the platform is touched. + public SampleQuery setUnit(HealthUnit unit) { + this.unit = unit; + return this; + } + + /// The requested unit, or null for each type's canonical unit. + public HealthUnit getUnit() { + return unit; + } + + /// Whether to expand [SeriesSample] records into individual + /// [QuantitySample] objects. Defaults to `true`. + /// + /// Leave it on and both platforms return the same thing, so your code + /// is identical across them. Turn it off when you need a series' + /// record identity -- to delete it, for instance. + /// + /// **Only Health Connect and the local stores group measurements into + /// records**, so only they have anything to withhold. HealthKit + /// stores each measurement separately and the iOS port returns + /// ordinary [QuantitySample] objects whichever way this is set -- not + /// one-point series -- so code that turns flattening off and then + /// casts to [SeriesSample] fails there. Test the type rather than + /// assuming it. + public SampleQuery setFlattenSeries(boolean flattenSeries) { + this.flattenSeries = flattenSeries; + return this; + } + + /// `true` when series are expanded into individual samples. + public boolean isFlattenSeries() { + return flattenSeries; + } + + /// The gap that separates two sleep sessions, for the iOS port's + /// session reassembly. Defaults to 15 minutes. + /// + /// HealthKit stores sleep as a run of category samples with no session + /// object, so the port is meant to group samples separated by less + /// than this gap into one [SleepSample]. Raise it if your users nap; + /// lower it if you would rather see brief wakings split the night. + /// Ignored on Android, where sessions are stored natively. + /// + /// #### It does nothing in this release + /// + /// The reassembly it configures is not implemented. The iOS type map + /// carries quantity types only, so [HealthDataType#SLEEP] is refused + /// before a query runs and there is no run of category samples for + /// this gap to group. The value is stored and returned faithfully and + /// changes no behaviour anywhere; it is kept rather than removed + /// because the grouping is the shape the port will need, and a + /// setting that silently did nothing without saying so is what this + /// note exists to prevent. + public SampleQuery setSleepSessionGapMillis(long gapMillis) { + this.sleepSessionGapMillis = gapMillis; + return this; + } + + /// The sleep-session grouping gap in milliseconds. + public long getSleepSessionGapMillis() { + return sleepSessionGapMillis; + } + + /// Continues a previous read from [SamplePage#getNextPageToken()]. + public SampleQuery setPageToken(String pageToken) { + this.pageToken = pageToken; + return this; + } + + /// The continuation token, or null to start from the beginning. + public String getPageToken() { + return pageToken; + } + + /// Validates the query and throws if it cannot be run. + /// + /// Called by [HealthStore] before the platform is touched, so a + /// malformed query fails immediately and locally rather than as an + /// opaque platform error later. + /// + /// #### Throws + /// + /// - `HealthException`: with [HealthError#INVALID_ARGUMENT] for a + /// missing type or range or a non-positive limit, and + /// [HealthError#UNIT_MISMATCH] when the requested unit does not + /// match a requested type's dimension. + public void validate() throws HealthException { + if (types.isEmpty()) { + throw new HealthException(HealthError.INVALID_ARGUMENT, + "a sample query needs at least one data type"); + } + if (timeRange == null) { + throw new HealthException(HealthError.INVALID_ARGUMENT, + "a sample query needs a time range"); + } + if (limit < 1) { + throw new HealthException(HealthError.INVALID_ARGUMENT, + "limit must be positive, got " + limit); + } + if (unit != null) { + for (HealthDataType t : types) { + HealthUnit canonical = t.getCanonicalUnit(); + if (canonical == null) { + throw new HealthException(HealthError.UNIT_MISMATCH, + t.getId() + " has no numeric value, so the query" + + " unit " + unit.getSymbol() + + " does not apply to it"); + } + if (!canonical.isCompatibleWith(unit)) { + throw new HealthException(HealthError.UNIT_MISMATCH, + t.getId() + " is measured in " + + canonical.getDimension() + " but " + + unit.getSymbol() + " measures " + + unit.getDimension()); + } + } + } + } +} diff --git a/CodenameOne/src/com/codename1/health/SeriesSample.java b/CodenameOne/src/com/codename1/health/SeriesSample.java new file mode 100644 index 00000000000..e8361cca1b6 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/SeriesSample.java @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// A run of measurements that share one record identity -- a beat-to-beat +/// heart-rate trace, a cadence series. +/// +/// #### Why this type exists +/// +/// The two platforms disagree about grouping. Health Connect's +/// `HeartRateRecord` is a single record containing many samples, and +/// deleting it means deleting the record as a whole. HealthKit returns the +/// same data as many independent samples with no grouping at all. +/// +/// Flattening Health Connect into individual samples loses the identity +/// you need in order to delete; inventing a series on iOS would claim a +/// grouping that is not there. So the choice is yours: +/// [SampleQuery#setFlattenSeries(boolean)] defaults to `true`, which gives +/// both platforms plain [QuantitySample] objects and lets cross-platform +/// code be identical. Turn it off when you need record identity -- and +/// note that **iOS returns [QuantitySample] objects either way**, not +/// one-point series, because HealthKit stores each measurement +/// separately and has no record to preserve. Test the type rather than +/// casting. +/// +/// #### Storage +/// +/// Values are held in parallel primitive arrays rather than a list of +/// objects. A month of continuous heart rate is tens of thousands of +/// points, and boxing each one is real memory pressure on a phone. +public final class SeriesSample extends HealthSample { + + private final long[] sampleStarts; + private final long[] sampleEnds; + private final double[] values; + private final HealthUnit unit; + + private SeriesSample(HealthDataType type, long startMillis, + long endMillis, long[] sampleStarts, long[] sampleEnds, + double[] values, HealthUnit unit) { + super(type, startMillis, endMillis); + this.sampleStarts = sampleStarts; + this.sampleEnds = sampleEnds; + this.values = values; + this.unit = unit; + } + + /// Creates a series. The three arrays must be the same length and are + /// copied defensively. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: if the arrays are null, differ in + /// length, or `unit` is null. + public static SeriesSample create(HealthDataType type, long startMillis, + long endMillis, long[] sampleStarts, long[] sampleEnds, + double[] values, HealthUnit unit) { + if (sampleStarts == null || sampleEnds == null || values == null) { + throw new IllegalArgumentException( + "a series requires start, end and value arrays"); + } + if (sampleStarts.length != values.length + || sampleEnds.length != values.length) { + throw new IllegalArgumentException( + "series arrays differ in length: starts=" + + sampleStarts.length + " ends=" + sampleEnds.length + + " values=" + values.length); + } + if (unit == null) { + throw new IllegalArgumentException("a series requires a unit"); + } + // A series with nothing in it is not a record, it is an absence of + // one. Writing it expanded to no wire records at all, and an empty + // batch is reported by both bridges as a successful write of + // nothing -- the caller was told it worked while the store never + // heard of it. + // A series is a run of measurements, and flattening one -- the + // default read shape -- turns each into a QuantitySample. A + // category or session type has no numeric value, so such a series + // could be written to the local store and then threw on the next + // ordinary read. + if (type == null || type.getCanonicalUnit() == null) { + throw new IllegalArgumentException((type == null ? "null" + : type.getId()) + " has no numeric value, so it cannot" + + " be recorded as a series"); + } + if (values.length == 0) { + throw new IllegalArgumentException( + "a series needs at least one measurement"); + } + // Every measurement, not just the enclosing span. A point whose + // end precedes its start was accepted here and written to the + // local store, and then the default flattened read threw out of + // toQuantitySample() -- inside the read callback, where it takes + // the whole page with it. + for (int i = 0; i < values.length; i++) { + // Same rule as HealthQuantity: a series carries raw doubles + // rather than quantities, so nothing else would have caught a + // NaN on the way into a store. + if (Double.isNaN(values[i]) || Double.isInfinite(values[i])) { + throw new IllegalArgumentException("measurement " + i + + " of this series is not a finite number: " + + values[i]); + } + if (sampleEnds[i] < sampleStarts[i]) { + throw new IllegalArgumentException("measurement " + i + + " of this series ends before it starts: " + + sampleStarts[i] + " > " + sampleEnds[i]); + } + // Inside the record, too. Reads match the enclosing span + // first, so a point outside it is unreachable by a query + // around itself and yet gets flattened into the answer for a + // query around the record -- returning a sample the caller's + // range does not contain. + if (sampleStarts[i] < startMillis || sampleEnds[i] > endMillis) { + throw new IllegalArgumentException("measurement " + i + + " of this series falls outside the record's span " + + startMillis + ".." + endMillis + ": " + + sampleStarts[i] + ".." + sampleEnds[i]); + } + } + long[] s = new long[sampleStarts.length]; + long[] e = new long[sampleEnds.length]; + double[] v = new double[values.length]; + System.arraycopy(sampleStarts, 0, s, 0, s.length); + System.arraycopy(sampleEnds, 0, e, 0, e.length); + System.arraycopy(values, 0, v, 0, v.length); + return new SeriesSample(type, startMillis, endMillis, s, e, v, unit); + } + + /// The number of measurements in this series. + public int size() { + return values.length; + } + + /// The start of measurement `i`, epoch millis UTC. + public long getSampleStartMillis(int i) { + return sampleStarts[i]; + } + + /// The end of measurement `i`, epoch millis UTC. Equal to the start + /// for an instantaneous measurement. + public long getSampleEndMillis(int i) { + return sampleEnds[i]; + } + + /// Measurement `i` converted into `in`. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: if `in` measures a different + /// dimension. + public double getSampleValue(int i, HealthUnit in) { + return HealthUnit.convert(values[i], unit, in); + } + + /// The unit every measurement in this series is expressed in. + public HealthUnit getUnit() { + return unit; + } + + /// Measurement `i` as a standalone [QuantitySample]. Allocates, so + /// prefer the indexed accessors when walking the whole series. + /// + /// The returned sample inherits this series' source, recording method + /// and identifier -- meaning several extracted samples share one + /// identifier, since they came from one record. + public QuantitySample toQuantitySample(int i) { + QuantitySample q = QuantitySample.create(getType(), + new HealthQuantity(values[i], unit), + sampleStarts[i], sampleEnds[i]); + q.setId(getId()); + q.setSource(getSource()); + q.setRecordingMethod(getRecordingMethod()); + // Metadata too. Flattening is the default read shape, so dropping + // it here meant a series written to the local store with metadata + // came back as quantities carrying none -- while the same sample + // written whole round-tripped fine. + for (java.util.Map.Entry e + : getMetadata().entrySet()) { + q.putMetadata(e.getKey(), e.getValue()); + } + return q; + } + + @Override + public String toString() { + return "SeriesSample[" + getType().getId() + " x" + values.length + + " " + unit.getSymbol() + " " + getStartMillis() + ".." + + getEndMillis() + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/SessionSample.java b/CodenameOne/src/com/codename1/health/SessionSample.java new file mode 100644 index 00000000000..c1420f7c0fe --- /dev/null +++ b/CodenameOne/src/com/codename1/health/SessionSample.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// A bounded activity that groups child data -- a workout, a night's +/// sleep, a logged meal. +/// +/// Sessions are always intervals: [HealthSample#isInstantaneous()] is +/// false for every subclass. +public abstract class SessionSample extends HealthSample { + + private String title; + private String notes; + + /// Creates a session spanning `[startMillis, endMillis]`. + protected SessionSample(HealthDataType type, long startMillis, + long endMillis) { + super(type, startMillis, endMillis); + } + + /// A user-visible title, or null. Health Connect stores this natively; + /// on iOS it is carried in sample metadata. + public final String getTitle() { + return title; + } + + /// Sets the user-visible title. + public final void setTitle(String title) { + this.title = title; + } + + /// Free-form user notes, or null. + public final String getNotes() { + return notes; + } + + /// Sets free-form user notes. + public final void setNotes(String notes) { + this.notes = notes; + } +} diff --git a/CodenameOne/src/com/codename1/health/SleepSample.java b/CodenameOne/src/com/codename1/health/SleepSample.java new file mode 100644 index 00000000000..e3a19fe7a30 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/SleepSample.java @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// A sleep session, optionally broken into [SleepStageInterval] spans. +/// +/// #### Stage detail is not guaranteed +/// +/// A session recorded by a watch usually carries stages; one inferred by a +/// phone usually does not, and neither did iOS before version 16. Check +/// [#hasStageDetail()] and fall back to showing the total duration -- +/// drawing a hypnogram from a single "asleep" span produces an empty chart +/// that looks like a bug. +/// +/// ```java +/// if (night.hasStageDetail()) { +/// for (SleepStageInterval iv : night.getStages()) { +/// drawBand(iv.getStage(), iv.getStartMillis(), iv.getEndMillis()); +/// } +/// } else { +/// showTotal(night.getDurationMillis()); +/// } +/// ``` +/// +/// #### Neither phone reads sleep in this release +/// +/// Sleep is available on the simulator, the desktop and the JavaScript +/// port, and on none of the mobile ones: a sleep query is refused on iOS +/// and Android alike, so no session of this shape ever comes back from a +/// device. Build against it by all means -- but do not build a mobile +/// screen around it yet. +/// +/// The reason it is unfinished on iOS is worth knowing, because it shapes +/// what finishing it will look like. HealthKit has no sleep-session +/// object at all, only a run of overlapping category samples, so the port +/// has to group them into sessions itself -- Apple's own convention is to +/// split on a gap, which is what [SampleQuery#setSleepSessionGapMillis(long)] +/// is there to tune. That grouping is a heuristic, which is why it +/// belongs in the port rather than baked into the shared code. +public final class SleepSample extends SessionSample { + + private final List stages; + + private SleepSample(long startMillis, long endMillis, + List stages) { + super(HealthDataType.SLEEP, startMillis, endMillis); + this.stages = stages; + } + + /// A session with no stage breakdown. + public static SleepSample create(long startMillis, long endMillis) { + return new SleepSample(startMillis, endMillis, + new ArrayList()); + } + + /// A session with stage detail. The list is copied defensively. + public static SleepSample create(long startMillis, long endMillis, + List stages) { + List copy = new ArrayList(); + if (stages != null) { + for (SleepStageInterval interval : stages) { + // Skipping the null during validation and then copying the + // whole list anyway meant it was stored unvalidated, and + // every accessor that walks the stages -- hasStageDetail, + // the two duration roll-ups -- dereferenced it. Dropping it + // here instead matches addStage(null), which has always + // ignored one. + if (interval != null) { + requireInsideSession(interval, startMillis, endMillis); + copy.add(interval); + } + } + } + return new SleepSample(startMillis, endMillis, copy); + } + + /// The stage breakdown, in the order the platform reported it. Empty + /// when no breakdown is available. + public List getStages() { + return Collections.unmodifiableList(stages); + } + + /// Appends a stage span. Used when building a session to write. + public void addStage(SleepStageInterval interval) { + if (interval != null) { + requireInsideSession(interval, getStartMillis(), getEndMillis()); + stages.add(interval); + } + } + + /// A stage outside the session it belongs to is not a stage of it. + /// + /// Unchecked, a session could report more time asleep than it lasted, + /// and carry stage data outside the very span the record is queried + /// by -- so a query that found the session would return detail from + /// outside its own range. + private static void requireInsideSession(SleepStageInterval interval, + long startMillis, long endMillis) { + if (interval.getStartMillis() < startMillis + || interval.getEndMillis() > endMillis) { + throw new IllegalArgumentException("sleep stage " + + interval.getStartMillis() + ".." + + interval.getEndMillis() + + " falls outside the session's span " + startMillis + + ".." + endMillis); + } + } + + /// `true` when this session carries a real stage breakdown -- that is, + /// at least one span classified as something more specific than + /// "asleep", "in bed" or "unknown". + /// + /// Returns `false` for an empty stage list and for a list containing + /// only [SleepStage#ASLEEP_UNSPECIFIED], [SleepStage#AWAKE_IN_BED] and + /// [SleepStage#UNKNOWN], because none of those tell you anything a + /// hypnogram could show. + public boolean hasStageDetail() { + for (SleepStageInterval stage : stages) { + SleepStage s = stage.getStage(); + if (s == SleepStage.LIGHT || s == SleepStage.DEEP + || s == SleepStage.REM || s == SleepStage.AWAKE + || s == SleepStage.OUT_OF_BED) { + return true; + } + } + return false; + } + + /// The total time in `stage` -- the time actually covered by its + /// spans, so two overlapping spans of the same stage count the overlap + /// once. + public long getDurationMillis(SleepStage stage) { + List matching = + new ArrayList(); + for (SleepStageInterval iv : stages) { + if (iv.getStage() == stage) { + matching.add(iv); + } + } + return coveredMillis(matching); + } + + /// The total time spent asleep -- every span except [SleepStage#AWAKE], + /// [SleepStage#AWAKE_IN_BED] and [SleepStage#OUT_OF_BED]. + /// + /// Falls back to the whole session duration when no stages are + /// present, which is the best available answer for a source that only + /// reported "asleep from X to Y". + public long getAsleepDurationMillis() { + if (stages.isEmpty()) { + return getDurationMillis(); + } + List asleep = + new ArrayList(); + for (SleepStageInterval iv : stages) { + SleepStage s = iv.getStage(); + if (s != SleepStage.AWAKE && s != SleepStage.AWAKE_IN_BED + && s != SleepStage.OUT_OF_BED) { + asleep.add(iv); + } + } + return coveredMillis(asleep); + } + + /// Time actually covered by a set of spans, counting an overlap once. + /// + /// Summing the spans instead was wrong for the data both platforms + /// really produce: this class documents platform sleep categories as + /// overlapping, and a LIGHT span of `[0,10]` beside a DEEP span of + /// `[5,15]` summed to 20 in a session that lasted 15. A total time + /// asleep longer than the night it happened in is the kind of number + /// that gets drawn straight into a chart. + /// + /// Note this cannot be replaced by clamping to the session length -- + /// that would hide the overlap while still reporting the wrong figure + /// for every night short enough not to hit the clamp. + private static long coveredMillis(List spans) { + if (spans.isEmpty()) { + return 0; + } + Collections.sort(spans, ByStart.INSTANCE); + long covered = 0; + long openFrom = spans.get(0).getStartMillis(); + long openTo = spans.get(0).getEndMillis(); + for (int iter = 1; iter < spans.size(); iter++) { + SleepStageInterval iv = spans.get(iter); + if (iv.getStartMillis() > openTo) { + covered += openTo - openFrom; + openFrom = iv.getStartMillis(); + openTo = iv.getEndMillis(); + } else if (iv.getEndMillis() > openTo) { + openTo = iv.getEndMillis(); + } + } + return covered + openTo - openFrom; + } + + /// Sorts spans by start so the merge above only has to look at the one + /// span it currently has open. + private static final class ByStart + implements java.util.Comparator { + + static final ByStart INSTANCE = new ByStart(); + + @Override + public int compare(SleepStageInterval a, SleepStageInterval b) { + long left = a.getStartMillis(); + long right = b.getStartMillis(); + if (left == right) { + return 0; + } + return left < right ? -1 : 1; + } + } + + @Override + public String toString() { + return "SleepSample[" + getStartMillis() + ".." + getEndMillis() + + " stages=" + stages.size() + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/SleepStage.java b/CodenameOne/src/com/codename1/health/SleepStage.java new file mode 100644 index 00000000000..bd46f5bfa51 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/SleepStage.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// One stage within a [SleepSample]. The values mirror Health Connect's +/// eight stage constants; the iOS mapping is noted per value. +/// +/// Not every source reports stages. Check +/// [SleepSample#hasStageDetail()] before drawing a hypnogram -- a session +/// recorded by a phone rather than a watch will only say "asleep". +public enum SleepStage { + + /// The source did not classify this span. + UNKNOWN, + + /// Awake during the sleep session. HealthKit `awake`. + AWAKE, + + /// Awake but in bed, before or after sleeping. HealthKit `inBed`. + AWAKE_IN_BED, + + /// Out of bed during the session. **Android only** -- HealthKit has no + /// equivalent and never reports it. + OUT_OF_BED, + + /// Asleep, with no stage breakdown available. HealthKit + /// `asleepUnspecified`, and the only asleep value iOS reported before + /// iOS 16. + ASLEEP_UNSPECIFIED, + + /// Light sleep. Mapped from HealthKit's `asleepCore`. + /// + /// This mapping is an **approximation**: Apple's "core" is that + /// vendor's own classification and is not clinically identical to the + /// N1+N2 light-sleep stages the Health Connect value denotes. Treat it + /// as indicative rather than diagnostic. + LIGHT, + + /// Deep sleep. HealthKit `asleepDeep`, iOS 16+. + DEEP, + + /// REM sleep. HealthKit `asleepREM`, iOS 16+. + REM +} diff --git a/CodenameOne/src/com/codename1/health/SleepStageInterval.java b/CodenameOne/src/com/codename1/health/SleepStageInterval.java new file mode 100644 index 00000000000..e999d6e00b6 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/SleepStageInterval.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// A contiguous span of one [SleepStage] inside a [SleepSample]. +public final class SleepStageInterval { + + private final SleepStage stage; + private final long startMillis; + private final long endMillis; + + /// Creates a stage interval. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: if `stage` is null or the end + /// precedes the start. + public SleepStageInterval(SleepStage stage, long startMillis, + long endMillis) { + if (stage == null) { + throw new IllegalArgumentException( + "a stage interval requires a stage"); + } + if (endMillis < startMillis) { + throw new IllegalArgumentException( + "stage interval ends before it starts"); + } + this.stage = stage; + this.startMillis = startMillis; + this.endMillis = endMillis; + } + + /// The stage this span was classified as. + public SleepStage getStage() { + return stage; + } + + /// Inclusive start, epoch millis UTC. + public long getStartMillis() { + return startMillis; + } + + /// Exclusive end, epoch millis UTC. + public long getEndMillis() { + return endMillis; + } + + /// The length of this span in milliseconds. + public long getDurationMillis() { + return endMillis - startMillis; + } + + @Override + public String toString() { + return stage + "[" + startMillis + ".." + endMillis + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/SubscriptionRequest.java b/CodenameOne/src/com/codename1/health/SubscriptionRequest.java new file mode 100644 index 00000000000..b9938c75f2f --- /dev/null +++ b/CodenameOne/src/com/codename1/health/SubscriptionRequest.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// Describes a standing subscription to changes in the health store. +/// +/// #### The id must never change +/// +/// The identifier keys the persisted cursor that says how far the app has +/// already read. Reuse it across launches and app updates and you resume +/// exactly where you left off; change it -- even to fix a typo -- and the +/// framework treats it as a brand-new subscription and resynchronizes from +/// scratch. Hard-code it as a constant, and version it deliberately +/// (`"steps-v1"`) if you ever need a clean start. +public final class SubscriptionRequest { + + /// The default cap on how many samples one change batch carries. + public static final int DEFAULT_MAX_SAMPLES_PER_BATCH = 1000; + + private final String id; + private final List types = new ArrayList(); + private boolean includeDeletions = true; + private boolean deliverSamples = true; + private int minIntervalSeconds; + private int maxSamplesPerBatch = DEFAULT_MAX_SAMPLES_PER_BATCH; + + /// Creates a request under a stable identifier. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: if `id` is null, blank, or contains a + /// control character. + public SubscriptionRequest(String id) { + if (id == null || id.trim().length() == 0) { + throw new IllegalArgumentException( + "a subscription needs a stable, non-blank id"); + } + this.id = id.trim(); + requireNoControlCharacters(this.id); + } + + /// The registry that survives process death is newline-delimited by + /// record and tab-delimited by field, and it is written without + /// escaping. + /// + /// An id carrying either delimiter therefore came back as a *different* + /// id after a restart -- or as two entries, or as none -- and the + /// symptom was a subscription that simply stopped delivering with no + /// error anywhere. Rejecting the character at the point it is supplied + /// turns a silent, restart-delayed data loss into an immediate + /// programming error. Every control character is refused rather than + /// just the two delimiters, because none of them belong in an + /// identifier and the persisted format is free to grow another one. + private static void requireNoControlCharacters(String id) { + for (int iter = 0; iter < id.length(); iter++) { + char c = id.charAt(iter); + if (c < ' ' || c == 0x7f) { + throw new IllegalArgumentException("a subscription id is" + + " persisted in a line-and-tab delimited registry" + + " and cannot contain control characters; found" + + " 0x" + Integer.toHexString(c) + " at offset " + + iter); + } + } + } + + /// The stable identifier for this subscription. + public String getId() { + return id; + } + + /// Adds a type to watch. At least one is required. + public SubscriptionRequest addType(HealthDataType type) { + if (type != null && !types.contains(type)) { + types.add(type); + } + return this; + } + + /// The types being watched. + public List getTypes() { + return Collections.unmodifiableList(types); + } + + /// Whether deletions are reported alongside additions. Defaults to + /// `true`; turn it off only if your app genuinely never needs to + /// un-count something a user removed. + public SubscriptionRequest setIncludeDeletions(boolean includeDeletions) { + this.includeDeletions = includeDeletions; + return this; + } + + /// `true` when deletions are reported. + public boolean isIncludeDeletions() { + return includeDeletions; + } + + /// Whether batches carry the changed samples themselves. Defaults to + /// `true`. Set `false` for a notify-only subscription -- cheaper when + /// all you want is a signal to refresh a screen. + public SubscriptionRequest setDeliverSamples(boolean deliverSamples) { + this.deliverSamples = deliverSamples; + return this; + } + + /// `true` when batches carry samples. + public boolean isDeliverSamples() { + return deliverSamples; + } + + /// Asks the platform not to deliver more often than this. A hint only: + /// iOS honours a coarse frequency per type and Android polls whenever + /// the app asks it to, so neither treats this as a guarantee. + public SubscriptionRequest setMinIntervalSeconds(int minIntervalSeconds) { + this.minIntervalSeconds = minIntervalSeconds; + return this; + } + + /// The requested minimum delivery interval in seconds, 0 for none. + public int getMinIntervalSeconds() { + return minIntervalSeconds; + } + + /// Caps how many samples one batch carries. Batches beyond the cap are + /// reported through [HealthChangeBatch#hasMore()]. + /// + /// The default exists because a background delivery has only a few + /// seconds of wall clock before the OS suspends the process; handing + /// the listener fifty thousand samples guarantees it does not finish. + public SubscriptionRequest setMaxSamplesPerBatch(int maxSamplesPerBatch) { + this.maxSamplesPerBatch = maxSamplesPerBatch; + return this; + } + + /// The per-batch sample cap. + public int getMaxSamplesPerBatch() { + return maxSamplesPerBatch; + } + + /// Validates the request. + /// + /// #### Throws + /// + /// - `HealthException`: [HealthError#INVALID_ARGUMENT] when no type is + /// named or the batch cap is not positive. + public void validate() throws HealthException { + if (types.isEmpty()) { + throw new HealthException(HealthError.INVALID_ARGUMENT, + "a subscription needs at least one data type"); + } + if (maxSamplesPerBatch < 1) { + throw new HealthException(HealthError.INVALID_ARGUMENT, + "maxSamplesPerBatch must be positive, got " + + maxSamplesPerBatch); + } + } +} diff --git a/CodenameOne/src/com/codename1/health/WorkoutActivityType.java b/CodenameOne/src/com/codename1/health/WorkoutActivityType.java new file mode 100644 index 00000000000..ef10038cc14 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/WorkoutActivityType.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// What kind of exercise a workout records. +/// +/// #### A deliberate subset +/// +/// HealthKit defines around eighty activity types and Health Connect +/// around ninety, and the two lists do not line up. An exhaustive mapping +/// table would be large, unverifiable, and wrong in ways nobody would +/// notice until a user's yoga session showed up as pilates. +/// +/// So this enum covers the common activities honestly and offers [#OTHER] +/// plus [WorkoutSample#getPlatformCode()] as the escape hatch for apps +/// that need exact platform fidelity. Reading `getPlatformCode()` gives +/// you the raw `HKWorkoutActivityType` or `ExerciseSessionRecord` constant +/// the platform actually recorded. +public enum WorkoutActivityType { + + /// An activity this enum does not name. Check + /// [WorkoutSample#getPlatformCode()] for the platform's own value. + OTHER, + + WALKING, + RUNNING, + HIKING, + CYCLING, + MOUNTAIN_BIKING, + SWIMMING, + ROWING, + ELLIPTICAL, + STAIR_CLIMBING, + HIGH_INTENSITY_INTERVAL_TRAINING, + STRENGTH_TRAINING, + CORE_TRAINING, + FUNCTIONAL_TRAINING, + YOGA, + PILATES, + DANCE, + MARTIAL_ARTS, + BOXING, + CLIMBING, + SKIING, + SNOWBOARDING, + SKATING, + SURFING, + PADDLING, + GOLF, + TENNIS, + BADMINTON, + SQUASH, + TABLE_TENNIS, + BASKETBALL, + FOOTBALL, + AMERICAN_FOOTBALL, + BASEBALL, + VOLLEYBALL, + CRICKET, + RUGBY, + WHEELCHAIR, + COOLDOWN, + STRETCHING +} diff --git a/CodenameOne/src/com/codename1/health/WorkoutSample.java b/CodenameOne/src/com/codename1/health/WorkoutSample.java new file mode 100644 index 00000000000..e05181ee447 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/WorkoutSample.java @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +/// A completed workout: what kind, how long, and the totals the platform +/// computed for it. +/// +/// Totals are nullable on purpose. A workout recorded without a heart-rate +/// sensor has no energy total, and reporting that as zero would put a +/// false 0 kcal into every summary. `null` means "not measured"; zero +/// means "measured, and it was zero". +public final class WorkoutSample extends SessionSample { + + private final WorkoutActivityType activityType; + private int platformCode = -1; + private HealthQuantity totalEnergy; + private HealthQuantity totalDistance; + private long activeDurationMillis = -1; + + private WorkoutSample(WorkoutActivityType activityType, long startMillis, + long endMillis) { + super(HealthDataType.WORKOUT, startMillis, endMillis); + this.activityType = activityType == null + ? WorkoutActivityType.OTHER : activityType; + } + + /// Creates a workout spanning `[startMillis, endMillis]`. + /// Metadata key set on a workout the platform could not store as a + /// session record of its own. + /// + /// Neither HealthKit nor the Health Connect bridge accepts a workout + /// through the sample write path in this release. The child + /// measurements are persisted; the workout comes back for you to keep + /// or upload. Check for this rather than assuming [#getId()] is + /// populated. + /// + /// ```java + /// if (workout.getMetadata().containsKey( + /// WorkoutSample.WORKOUT_NOT_PERSISTED)) { + /// uploadToMyServer(workout); + /// } + /// ``` + public static final String WORKOUT_NOT_PERSISTED = + "cn1.workoutNotPersisted"; + + /// Metadata key naming the data types the platform refused to store, + /// comma separated, or absent when everything fed in was persisted. + /// + /// Health Connect has no single-value write form for the + /// series-shaped types -- power, speed and both cadences -- which is + /// exactly what a bike or foot pod feeds into a workout. Those samples + /// cannot be stored there, so the workout names them rather than + /// dropping them silently and resolving as though nothing had + /// happened. + public static final String SAMPLES_NOT_PERSISTED = + "cn1.workout.samplesNotPersisted"; + + public static WorkoutSample create(WorkoutActivityType activityType, + long startMillis, long endMillis) { + return new WorkoutSample(activityType, startMillis, endMillis); + } + + /// The activity, mapped onto this API's shared vocabulary. See + /// [#getPlatformCode()] when you need exactly what the platform said. + public WorkoutActivityType getActivityType() { + return activityType; + } + + /// The raw platform activity constant -- an `HKWorkoutActivityType` on + /// iOS or an `ExerciseSessionRecord` exercise type on Android -- or + /// `-1` when unknown. + /// + /// This is the fidelity escape hatch for the deliberately partial + /// [WorkoutActivityType] vocabulary. It is platform-specific by + /// definition: the same integer means different things on the two + /// platforms, so branch on [#getActivityType()] first and only reach + /// for this when you must. + public int getPlatformCode() { + return platformCode; + } + + /// Records the raw platform activity constant. Called by ports. + public void setPlatformCode(int platformCode) { + this.platformCode = platformCode; + } + + /// Energy burned across the workout, or null when not measured. + public HealthQuantity getTotalEnergy() { + return totalEnergy; + } + + /// Sets the energy total. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: if the quantity does not measure + /// energy. + public void setTotalEnergy(HealthQuantity totalEnergy) { + requireDimension(totalEnergy, HealthUnit.KILOCALORIE, "energy"); + this.totalEnergy = totalEnergy; + } + + /// Distance covered, or null when not measured. + public HealthQuantity getTotalDistance() { + return totalDistance; + } + + /// Sets the distance total. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: if the quantity does not measure + /// length. + public void setTotalDistance(HealthQuantity totalDistance) { + requireDimension(totalDistance, HealthUnit.METER, "distance"); + this.totalDistance = totalDistance; + } + + /// A workout total in the wrong dimension is not caught anywhere else. + /// + /// The store writes whatever it is handed, so a total energy given in + /// kilograms round-tripped through the local and simulator stores + /// intact and only failed much later, in the caller that did the + /// documented thing and asked for the value in kilocalories. Rejecting + /// it at the setter puts the exception on the line that made the + /// mistake -- the same reason [BloodPressureSample] checks its + /// readings. Null stays legal: it is how a workout says the total was + /// never measured. + private static void requireDimension(HealthQuantity q, HealthUnit expected, + String which) { + if (q != null && !q.getUnit().isCompatibleWith(expected)) { + throw new IllegalArgumentException("a workout " + which + + " total must measure " + expected.getDimension() + + ", but " + q.getUnit().getSymbol() + " measures " + + q.getUnit().getDimension()); + } + } + + /// Time actually exercising, excluding pauses. Falls back to the wall + /// duration when the platform did not report it separately. + public long getActiveDurationMillis() { + return activeDurationMillis < 0 + ? getDurationMillis() : activeDurationMillis; + } + + /// Sets the paused-time-excluded duration. + /// + /// Any negative value means "not reported separately", which is what + /// [#getActiveDurationMillis()] answers with the wall duration. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: if the value exceeds the workout's + /// own duration. + public void setActiveDurationMillis(long activeDurationMillis) { + // Active time excludes pauses, so it cannot exceed the span it is + // a subset of. Unchecked, the local and simulator stores kept a + // workout claiming more time exercising than it lasted, and every + // pace, average and "time moving" drawn from it was wrong in a way + // that looks like a units bug somewhere else entirely. + if (activeDurationMillis > getDurationMillis()) { + throw new IllegalArgumentException("a workout's active duration" + + " excludes pauses and cannot exceed its own duration" + + " of " + getDurationMillis() + "ms, got " + + activeDurationMillis + "ms"); + } + this.activeDurationMillis = activeDurationMillis; + } + + @Override + public String toString() { + return "WorkoutSample[" + activityType + " " + getStartMillis() + + ".." + getEndMillis() + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/nutrition/Nutrient.java b/CodenameOne/src/com/codename1/health/nutrition/Nutrient.java new file mode 100644 index 00000000000..65a7759af4a --- /dev/null +++ b/CodenameOne/src/com/codename1/health/nutrition/Nutrient.java @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.nutrition; + +import com.codename1.health.HealthUnit; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// A single nutrient that a [NutritionSample] can carry -- a macronutrient, +/// a vitamin, a mineral. +/// +/// Interned constants, like [com.codename1.health.HealthDataType], so `==` +/// is a valid identity test and [#forId(String)] stays total across +/// framework versions. +/// +/// Each nutrient declares the unit its amounts are expressed in. That is +/// not cosmetic: sodium is conventionally logged in milligrams and protein +/// in grams, and a food database that mixes them up is off by a thousand. +public final class Nutrient { + + private static final Map BY_ID = + new HashMap(); + private static final List ALL = new ArrayList(); + + private final String id; + private final HealthUnit unit; + + private Nutrient(String id, HealthUnit unit) { + this.id = id; + this.unit = unit; + } + + private static Nutrient define(String id, HealthUnit unit) { + Nutrient n = new Nutrient(id, unit); + BY_ID.put(id, n); + ALL.add(n); + return n; + } + + // --- energy and macronutrients --- + + /// Food energy. Note that this is also available as a standalone type, + /// [com.codename1.health.HealthDataType#DIETARY_ENERGY]; writing a + /// [NutritionSample] that carries it makes it part of a logged meal + /// rather than a bare total. + public static final Nutrient ENERGY = + define("energy", HealthUnit.KILOCALORIE); + public static final Nutrient PROTEIN = define("protein", HealthUnit.GRAM); + public static final Nutrient TOTAL_FAT = + define("total_fat", HealthUnit.GRAM); + public static final Nutrient SATURATED_FAT = + define("saturated_fat", HealthUnit.GRAM); + public static final Nutrient MONOUNSATURATED_FAT = + define("monounsaturated_fat", HealthUnit.GRAM); + public static final Nutrient POLYUNSATURATED_FAT = + define("polyunsaturated_fat", HealthUnit.GRAM); + public static final Nutrient TRANS_FAT = + define("trans_fat", HealthUnit.GRAM); + public static final Nutrient CHOLESTEROL = + define("cholesterol", HealthUnit.MILLIGRAM); + public static final Nutrient TOTAL_CARBOHYDRATE = + define("total_carbohydrate", HealthUnit.GRAM); + public static final Nutrient DIETARY_FIBER = + define("dietary_fiber", HealthUnit.GRAM); + public static final Nutrient SUGAR = define("sugar", HealthUnit.GRAM); + public static final Nutrient WATER = define("water", HealthUnit.LITER); + + // --- minerals --- + + public static final Nutrient SODIUM = + define("sodium", HealthUnit.MILLIGRAM); + public static final Nutrient POTASSIUM = + define("potassium", HealthUnit.MILLIGRAM); + public static final Nutrient CALCIUM = + define("calcium", HealthUnit.MILLIGRAM); + public static final Nutrient IRON = define("iron", HealthUnit.MILLIGRAM); + public static final Nutrient MAGNESIUM = + define("magnesium", HealthUnit.MILLIGRAM); + public static final Nutrient ZINC = define("zinc", HealthUnit.MILLIGRAM); + public static final Nutrient PHOSPHORUS = + define("phosphorus", HealthUnit.MILLIGRAM); + public static final Nutrient IODINE = + define("iodine", HealthUnit.MICROGRAM); + public static final Nutrient SELENIUM = + define("selenium", HealthUnit.MICROGRAM); + public static final Nutrient COPPER = + define("copper", HealthUnit.MILLIGRAM); + public static final Nutrient MANGANESE = + define("manganese", HealthUnit.MILLIGRAM); + public static final Nutrient CHROMIUM = + define("chromium", HealthUnit.MICROGRAM); + public static final Nutrient MOLYBDENUM = + define("molybdenum", HealthUnit.MICROGRAM); + public static final Nutrient CHLORIDE = + define("chloride", HealthUnit.MILLIGRAM); + + // --- vitamins --- + + public static final Nutrient VITAMIN_A = + define("vitamin_a", HealthUnit.MICROGRAM); + public static final Nutrient THIAMIN = + define("thiamin", HealthUnit.MILLIGRAM); + public static final Nutrient RIBOFLAVIN = + define("riboflavin", HealthUnit.MILLIGRAM); + public static final Nutrient NIACIN = + define("niacin", HealthUnit.MILLIGRAM); + public static final Nutrient PANTOTHENIC_ACID = + define("pantothenic_acid", HealthUnit.MILLIGRAM); + public static final Nutrient VITAMIN_B6 = + define("vitamin_b6", HealthUnit.MILLIGRAM); + public static final Nutrient BIOTIN = + define("biotin", HealthUnit.MICROGRAM); + public static final Nutrient VITAMIN_B12 = + define("vitamin_b12", HealthUnit.MICROGRAM); + public static final Nutrient VITAMIN_C = + define("vitamin_c", HealthUnit.MILLIGRAM); + public static final Nutrient VITAMIN_D = + define("vitamin_d", HealthUnit.MICROGRAM); + public static final Nutrient VITAMIN_E = + define("vitamin_e", HealthUnit.MILLIGRAM); + public static final Nutrient VITAMIN_K = + define("vitamin_k", HealthUnit.MICROGRAM); + public static final Nutrient FOLATE = + define("folate", HealthUnit.MICROGRAM); + + // --- other --- + + public static final Nutrient CAFFEINE = + define("caffeine", HealthUnit.MILLIGRAM); + + /// The stable, portable identifier for this nutrient. + public String getId() { + return id; + } + + /// The unit amounts of this nutrient are expressed in. + public HealthUnit getUnit() { + return unit; + } + + /// Looks a nutrient up by [#getId()], or `null` when unknown to this + /// version of the framework. + public static Nutrient forId(String id) { + return id == null ? null : BY_ID.get(id); + } + + /// Every nutrient this framework understands. + public static List values() { + return Collections.unmodifiableList(ALL); + } + + @Override + public String toString() { + return id; + } +} diff --git a/CodenameOne/src/com/codename1/health/nutrition/NutritionSample.java b/CodenameOne/src/com/codename1/health/nutrition/NutritionSample.java new file mode 100644 index 00000000000..f3cf5928d10 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/nutrition/NutritionSample.java @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.nutrition; + +import com.codename1.health.HealthDataType; +import com.codename1.health.HealthQuantity; +import com.codename1.health.HealthUnit; +import com.codename1.health.SessionSample; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// A logged food or meal, carrying whichever nutrients are known about it. +/// +/// #### Sparse by design +/// +/// A [NutritionSample] holds only the nutrients actually set. A logged +/// apple sets four fields; a packaged food scanned from a barcode might set +/// thirty. Modelling this as forty nullable fields -- which is roughly what +/// both platforms do -- would make every consumer write forty null checks, +/// so this exposes a map instead and [#getNutrients()] returns only what is +/// present. +/// +/// ```java +/// NutritionSample lunch = NutritionSample.create(start, end); +/// lunch.setTitle("Chicken salad"); +/// lunch.setNutrient(Nutrient.ENERGY, 420); +/// lunch.setNutrient(Nutrient.PROTEIN, 35); +/// lunch.setNutrient(Nutrient.SODIUM, 610); +/// ``` +/// +/// #### Meal type +/// +/// Health Connect records which meal an entry belongs to and HealthKit +/// does not, so [#getMealType()] round-trips through the local store and +/// would ride in sample metadata on iOS once that mapping exists. +/// +/// #### Local and simulator only in this release +/// +/// Neither phone carries this shape yet -- see the package +/// documentation. A read or write of +/// [HealthDataType#NUTRITION] on iOS or Android is refused with +/// [HealthError#TYPE_NOT_SUPPORTED]; everything here works against the +/// local store. +public final class NutritionSample extends SessionSample { + + /// The entry is not attributed to a particular meal. + public static final int MEAL_UNKNOWN = 0; + /// Breakfast. + public static final int MEAL_BREAKFAST = 1; + /// Lunch. + public static final int MEAL_LUNCH = 2; + /// Dinner. + public static final int MEAL_DINNER = 3; + /// A snack between meals. + public static final int MEAL_SNACK = 4; + + private final Map nutrients = + new HashMap(); + private int mealType = MEAL_UNKNOWN; + private String foodName; + + private NutritionSample(long startMillis, long endMillis) { + super(HealthDataType.NUTRITION, startMillis, endMillis); + } + + /// A food or meal consumed over `[startMillis, endMillis]`. + public static NutritionSample create(long startMillis, long endMillis) { + return new NutritionSample(startMillis, endMillis); + } + + /// A food or meal logged at one moment. Nutrition is interval-only, so + /// this records a one-second span rather than a zero-width one. + public static NutritionSample create(long atMillis) { + return new NutritionSample(atMillis, atMillis + 1000); + } + + /// Sets a nutrient amount, expressed in that nutrient's own unit -- + /// see [Nutrient#getUnit()]. + public NutritionSample setNutrient(Nutrient nutrient, double amount) { + if (nutrient == null) { + return this; + } + nutrients.put(nutrient.getId(), + new HealthQuantity(amount, nutrient.getUnit())); + return this; + } + + /// Sets a nutrient amount in an explicit unit, converting into the + /// nutrient's own unit. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: if `unit` measures a different + /// dimension than the nutrient -- protein in millilitres, say. + public NutritionSample setNutrient(Nutrient nutrient, double amount, + HealthUnit unit) { + if (nutrient == null) { + return this; + } + double converted = + HealthUnit.convert(amount, unit, nutrient.getUnit()); + nutrients.put(nutrient.getId(), + new HealthQuantity(converted, nutrient.getUnit())); + return this; + } + + /// The amount of `nutrient`, or `null` when this entry does not record + /// it. + /// + /// Null rather than zero, for the same reason aggregate buckets return + /// null: "this food's sodium was never measured" and "this food + /// contains no sodium" are different claims, and only one of them is + /// safe to show someone managing their intake. + public HealthQuantity getNutrient(Nutrient nutrient) { + return nutrient == null ? null : nutrients.get(nutrient.getId()); + } + + /// Whether `nutrient` is recorded on this entry. + public boolean hasNutrient(Nutrient nutrient) { + return nutrient != null && nutrients.containsKey(nutrient.getId()); + } + + /// The nutrients actually recorded, in no particular order. + public List getNutrients() { + List out = new ArrayList(); + for (String id : nutrients.keySet()) { + Nutrient n = Nutrient.forId(id); + if (n != null) { + out.add(n); + } + } + return Collections.unmodifiableList(out); + } + + /// How many nutrients this entry records. + public int getNutrientCount() { + return nutrients.size(); + } + + /// Which meal this belongs to, as a `MEAL_` constant. + public int getMealType() { + return mealType; + } + + /// Attributes this entry to a meal, using a `MEAL_` constant. + public NutritionSample setMealType(int mealType) { + this.mealType = mealType; + return this; + } + + /// The food's name, or null. + public String getFoodName() { + return foodName; + } + + /// Names the food. + public NutritionSample setFoodName(String foodName) { + this.foodName = foodName; + return this; + } + + @Override + public String toString() { + return "NutritionSample[" + (foodName == null ? "food" : foodName) + + ", " + nutrients.size() + " nutrients]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/nutrition/package-info.java b/CodenameOne/src/com/codename1/health/nutrition/package-info.java new file mode 100644 index 00000000000..c6067888a74 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/nutrition/package-info.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +/// Logged food and drink, as a sparse set of nutrient amounts. +/// +/// Start at [com.codename1.health.nutrition.NutritionSample], written and +/// read through the ordinary [com.codename1.health.HealthStore] using +/// [com.codename1.health.HealthDataType#NUTRITION]. +/// +/// Both platforms model nutrition as a record with several dozen optional +/// nutrient fields. This package keeps that sparseness explicit rather than +/// exposing forty nullable getters: an entry carries only the nutrients +/// that were actually measured, and a nutrient that was never measured +/// reads back as null rather than zero. +/// +/// #### Local and simulator only in this release +/// +/// [com.codename1.health.HealthDataType#NUTRITION] is **not** carried to +/// HealthKit or Health Connect yet: neither the wire format nor either +/// port's type map knows the multi-nutrient record shape, so a read or a +/// write of it on a phone is refused with +/// [com.codename1.health.HealthError#TYPE_NOT_SUPPORTED] before it +/// reaches the platform. It works fully against the local store -- the +/// simulator, and the desktop, Windows, Linux and JavaScript ports. +/// +/// Individual dietary quantities do reach the phones and are ordinary +/// [com.codename1.health.QuantitySample]s rather than entries here: +/// `HYDRATION` on both platforms, and `DIETARY_ENERGY` on iOS. Use those +/// when the number has to land in the user's real health store. +package com.codename1.health.nutrition; diff --git a/CodenameOne/src/com/codename1/health/package-info.java b/CodenameOne/src/com/codename1/health/package-info.java new file mode 100644 index 00000000000..24a79202bc7 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/package-info.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +/// Cross-platform health data: reading and writing samples, aggregating +/// them over time, watching the store for changes, and recording workouts. +/// +/// Start at [com.codename1.health.Health], which is never null on any +/// port. Backed by HealthKit on iOS and watchOS, by Health Connect on +/// Android, by a scriptable virtual store in the simulator, and by a local +/// store on the desktop and JavaScript ports. +/// +/// #### Two platform truths this API does not hide +/// +/// **Read authorization is unknowable on iOS.** HealthKit reports a denied +/// read as an empty result, deliberately, so that an app cannot infer what +/// a user is choosing to hide. There is therefore no `hasReadPermission` +/// anywhere in this package, and your UI must say "no data available" +/// rather than "you denied access". +/// +/// **Android never wakes your app for new data.** Health Connect has no +/// push mechanism, so subscriptions there are polled when your app runs. +/// +/// #### Related packages +/// +/// - `com.codename1.health.workout` -- live and recorded workout sessions. +/// - `com.codename1.health.sensors` -- Bluetooth GATT health sensors, +/// which work on every port with Bluetooth LE regardless of whether a +/// health store exists. +package com.codename1.health; diff --git a/CodenameOne/src/com/codename1/health/sensors/BleSensorSession.java b/CodenameOne/src/com/codename1/health/sensors/BleSensorSession.java new file mode 100644 index 00000000000..70f229bc434 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/BleSensorSession.java @@ -0,0 +1,519 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattNotificationListener; +import com.codename1.bluetooth.gatt.GattService; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.ConnectionEvent; +import com.codename1.bluetooth.le.ConnectionListener; +import com.codename1.bluetooth.le.ConnectionState; +import com.codename1.health.HealthError; +import com.codename1.health.HealthException; +import com.codename1.util.AsyncResource; +import com.codename1.util.AsyncResult; + +import java.util.List; + +/// The GATT transport behind [SensorSession]: connects, discovers the +/// profile's service, subscribes to its measurement characteristic and +/// feeds raw values into the shared decoder. +/// +/// Package-private -- apps see only [SensorSession]. +final class BleSensorSession extends SensorSession { + + /// Battery Service, `0x180F`, and its Battery Level characteristic. + private static final BluetoothUuid BATTERY_SERVICE = + BluetoothUuid.fromShort(0x180F); + private static final BluetoothUuid BATTERY_LEVEL = + BluetoothUuid.fromShort(0x2A19); + + /// Body Sensor Location, `0x2A38`, on the Heart Rate service. + private static final BluetoothUuid BODY_SENSOR_LOCATION = + BluetoothUuid.fromShort(0x2A38); + + /// Heart Rate Control Point, `0x2A39`. Writing `0x01` resets the + /// accumulated energy-expended counter. + private static final BluetoothUuid HEART_RATE_CONTROL_POINT = + BluetoothUuid.fromShort(0x2A39); + + /// How many times the reconnect ladder may stumble on discovery or + /// subscription before the session is retired. Bounded so a device + /// that has genuinely changed does not get reconnected at forever; + /// more than one so a single transient failure does not end a session + /// the caller still wants. + private static final int MAX_RECONNECT_FAILURES = 3; + + private final BlePeripheral peripheral; + private GattCharacteristic measurement; + /// Read and written from connection callbacks and from stop(), which + /// are not the same thread. Guarded by [#reconnectLock], like the + /// count below -- a lock rather than `volatile`, which this codebase + /// does not use. + private Reconnector reconnectListener; + + /// Guards the failure count. Volatile is not enough for it: the + /// increment and the test against the limit are one decision, and + /// two callbacks racing through a volatile `++` can lose a count and + /// let the ladder run past the bound it exists to enforce. + private final Object reconnectLock = new Object(); + /// Guarded by [#reconnectLock]. + private int reconnectFailures; + + BleSensorSession(String sensorId, HealthSensorProfile profile, + SensorSessionOptions options, BlePeripheral peripheral) { + super(sensorId, profile, options); + this.peripheral = peripheral; + } + + /// Connects, discovers and subscribes, resolving `out` once + /// measurements can start arriving. + void start(final AsyncResource out) { + setState(SensorSessionState.CONNECTING); + boolean needsListener; + synchronized (reconnectLock) { + needsListener = getOptions().isAutoReconnect() + && reconnectListener == null; + } + if (needsListener) { + // A strap that walks out of range mid-workout otherwise leaves + // the session marked STREAMING forever, receiving nothing and + // never retrying, which is the opposite of what the default + // promises. + Reconnector listener = new Reconnector(this); + synchronized (reconnectLock) { + reconnectListener = listener; + } + peripheral.addConnectionListener(listener); + } + peripheral.connect().onResult(new AsyncResult() { + @Override + public void onReady(BlePeripheral value, Throwable err) { + if (err != null) { + failStart(out, err); + return; + } + discover(out); + } + }); + } + + /// Re-runs discovery and subscription after an unexpected drop. + void reconnect() { + // Either terminal state. A DISCONNECTED listener runs on the EDT + // and can pass its own state check while a connect, discovery or + // subscribe callback on another thread is exhausting the retry + // budget -- so a stopped-only guard moved a session the ladder + // had already retired as FAILED back to CONNECTING, and + // reconnected a handle the manager no longer holds. + if (isTerminal() || !getOptions().isAutoReconnect()) { + return; + } + setState(SensorSessionState.CONNECTING); + // The cumulative trackers hold the previous connection's baseline. + // A sensor that power-cycled while away restarts its counters at + // zero, and even one that did not will have wrapped its 16-bit + // event timer several times across the gap, so the first delta + // after reconnecting would publish a large fabricated cadence into + // the live workout and into anything persisted from it. + resetCounters(); + peripheral.connect().onResult(new AsyncResult() { + @Override + public void onReady(BlePeripheral value, Throwable err) { + if (err == null) { + discover(null); + return; + } + // Counted, not ignored. A failed connect publishes + // DISCONNECTED, the listener fires on that transition and + // reconnects immediately, so a sensor that has gone for + // good span the whole ladder at full speed and never + // reached the three-failure limit -- which only discovery + // and subscribe failures were incrementing. + failReconnect(wrapStartFailure(err)); + } + }); + } + + /// Watches for the link dropping while the session is still wanted. + /// + /// Named rather than anonymous so it can be removed again on stop and + /// so SpotBugs sees no synthetic outer reference. + private static final class Reconnector implements ConnectionListener { + private final BleSensorSession session; + + Reconnector(BleSensorSession session) { + this.session = session; + } + + @Override + public void connectionStateChanged(ConnectionEvent event) { + // Only a session that actually got streaming reconnects. A + // failed initial connect publishes DISCONNECTED too, and + // retrying from there resurrected a session the caller had + // already been told had failed -- streaming and routing + // samples while absent from getActiveSessions(). + if (event.getState() == ConnectionState.DISCONNECTED + && (session.getState() == SensorSessionState.STREAMING + || session.getState() + == SensorSessionState.CONNECTING) + && session.hasStreamed()) { + session.reconnect(); + } + } + } + + private void discover(final AsyncResource out) { + peripheral.discoverServices().onResult( + new AsyncResult>() { + @Override + public void onReady(List value, + Throwable err) { + if (err != null) { + failStart(out, err); + return; + } + GattService service = peripheral.getService( + getProfile().getServiceUuid()); + if (service == null) { + failStart(out, new HealthException( + HealthError.TYPE_NOT_SUPPORTED, + "this device does not expose the " + + getProfile().getName() + + " service")); + return; + } + measurement = service.getCharacteristic( + getProfile().getMeasurementUuid()); + if (measurement == null) { + failStart(out, new HealthException( + HealthError.TYPE_NOT_SUPPORTED, + "this device exposes the " + + getProfile().getName() + + " service but not its measurement" + + " characteristic")); + return; + } + readOptionalMetadata(service); + subscribe(out); + } + }); + } + + /// Reads the extras a device may or may not expose. Failures here are + /// deliberately ignored: a strap without a battery service is still a + /// perfectly good strap, and refusing to stream over a missing + /// optional characteristic would be worse than not knowing the + /// battery level. + @SuppressWarnings("PMD.CompareObjectsWithEquals") + private void readOptionalMetadata(GattService service) { + GattService battery = peripheral.getService(BATTERY_SERVICE); + if (battery != null) { + GattCharacteristic level = + battery.getCharacteristic(BATTERY_LEVEL); + if (level != null) { + peripheral.readCharacteristic(level).onResult( + new AsyncResult() { + @Override + public void onReady(byte[] value, Throwable err) { + if (err == null && value != null + && value.length > 0) { + // 0 to 100, and nothing else. The + // Battery Level characteristic + // reserves everything above 100, and + // a peripheral that answers 0xFF for + // "unknown" was reported to the app + // as a 255% battery. Left unset + // instead, which getBatteryPercent + // already documents as null when the + // device does not report one. + int percent = value[0] & 0xFF; + if (percent <= 100) { + setBatteryPercent( + Integer.valueOf(percent)); + } + } + } + }); + } + } + if (getProfile() == HealthSensorProfile.HEART_RATE) { + GattCharacteristic loc = + service.getCharacteristic(BODY_SENSOR_LOCATION); + if (loc != null) { + peripheral.readCharacteristic(loc).onResult( + new AsyncResult() { + @Override + public void onReady(byte[] value, Throwable err) { + if (err == null && value != null + && value.length > 0) { + setBodySensorLocation(value[0] & 0xFF); + } + } + }); + } + } + } + + private void subscribe(final AsyncResource out) { + // The Bluetooth layer picks notify or indicate from the + // characteristic's own properties, which matters here: blood + // pressure, weight and temperature are indicate-only. + final GattNotificationListener listener = + new GattNotificationListener() { + @Override + public void valueChanged( + GattCharacteristic characteristic, + byte[] value) { + // A notification can still arrive between stop() + // and the CCCD actually being disarmed. Dropping + // it here keeps a finished session from + // delivering one more reading. + if (isStopped()) { + return; + } + onMeasurement(value, System.currentTimeMillis()); + } + }; + peripheral.subscribe(measurement, listener) + .onResult(new AsyncResult() { + @Override + public void onReady(Boolean value, Throwable err) { + // Stopping does not cancel a subscribe already in + // flight, and the reconnect path issues one + // without anybody waiting on it. A completion + // arriving after stop() used to move the session + // from STOPPED back to STREAMING -- already + // unregistered from the manager and disconnected, + // yet reporting itself live and delivering + // notifications. Stopped is terminal. + if (isStopped()) { + unsubscribeLate(listener); + return; + } + if (err != null) { + failStart(out, err); + return; + } + setState(SensorSessionState.STREAMING); + // A run of failures only retires the session when + // it is uninterrupted: a strap that reconnects + // cleanly has spent whatever budget it used + // getting there. + synchronized (reconnectLock) { + reconnectFailures = 0; + } + if (out != null) { + out.complete(BleSensorSession.this); + } + } + }); + } + + /// Whether this session has ended, however it ended. + /// + /// Both terminal states, not STOPPED alone. The reconnect ladder + /// retires a session as FAILED, and an indication the transport had + /// already queued then passed a stopped-only check: it reached the + /// listeners after the session was over, and with write-through on it + /// landed in a buffer whose final flush had already run and whose + /// timer is disarmed -- so it could never be persisted either. + private boolean isStopped() { + return isTerminal(); + } + + /// Tears down a subscription that completed after the session stopped. + /// + /// Best effort: the peripheral is normally already disconnected by + /// then, in which case this fails harmlessly. Leaving it is the worse + /// option -- a live characteristic notification on a session the app + /// has finished with. + private void unsubscribeLate(GattNotificationListener listener) { + try { + peripheral.unsubscribe(measurement, listener); + } catch (Throwable t) { + com.codename1.io.Log.p("[health] late unsubscribe after stop: " + + t); + } + } + + /// Reports a failed start. + /// + /// `out` is null on the reconnect path: nobody is waiting on a + /// resource that was already resolved when the session first started, + /// and completing it a second time -- or dereferencing it -- would + /// throw on the BLE callback thread. + private void failStart(AsyncResource out, Throwable err) { + HealthException wrapped = wrapStartFailure(err); + if (out == null && !isStopped()) { + // No caller waiting means this came from the reconnect ladder, + // and a session that has streamed before is one the caller + // still wants. Failing it here retired the whole session over + // one transient discovery or subscribe error: FAILED is not a + // state the reconnect listener retries from, and the session + // had already been dropped from the active registry, so the + // documented auto-reconnect stopped for good after a single + // stumble. + failReconnect(wrapped); + return; + } + endSession(); + if (out != null) { + out.error(wrapped); + } else { + fireError(wrapped); + } + } + + /// Terminal teardown for a session that has failed. + /// + /// Every step matters and each was missing at some point. The listener + /// goes first so the disconnect below is not mistaken for a dropped + /// link and retried. The link is dropped because a failure after a + /// successful connect -- discovery or subscribe, on the first attempt + /// or the last reconnect -- otherwise left a live, unusable connection + /// and a registered listener behind a handle the caller had been told + /// was finished, with nothing but an explicit `stop()` to reclaim + /// them. + private void endSession() { + if (isTerminal()) { + // Two paths reach here -- a discovery or subscribe failure, + // and the reconnect ladder giving up -- and a session tears + // down once. The second pass used to flush again, which is + // how a buffer refilled by a failed final write went out a + // second time. + return; + } + Reconnector listener = takeReconnectListener(); + if (listener != null) { + peripheral.removeConnectionListener(listener); + } + setState(SensorSessionState.FAILED); + // The buffered readings go to the store, exactly as stop() does + // with them. A session that gave up reconnecting still holds + // whatever arrived since the last batch boundary -- up to a full + // storeBatchMillis of it, sixty seconds by default -- and the + // transition above cancels the timer that would have written it. + // Without this they sat in the buffer until the process ended and + // were never seen again, silently. + flushPendingWrites(); + forgetFromManager(); + peripheral.disconnect(); + } + + private HealthException wrapStartFailure(Throwable err) { + return err instanceof HealthException + ? (HealthException) err + : new HealthException(HealthError.SENSOR_DISCONNECTED, + "could not start the " + getProfile().getName() + + " session", err); + } + + /// Reports a reconnect-path failure and lets the ladder try again. + /// + /// The link is dropped deliberately. Leaving a connected-but-unusable + /// peripheral in place would be a quieter failure than the one this + /// replaces: the reconnect listener only fires on a disconnection, so + /// a session left sitting on a live link with no subscription would + /// never retry and never report itself broken either. + /// + /// Attempts are counted so this cannot cycle forever against a device + /// that has genuinely changed -- a peripheral reflashed with different + /// services will never expose the measurement characteristic again, + /// and the session should end rather than reconnect at it all day. + /// Clears the reconnect listener and hands it back, so a caller can + /// deregister it without holding the lock across a peripheral call. + private Reconnector takeReconnectListener() { + synchronized (reconnectLock) { + Reconnector listener = reconnectListener; + reconnectListener = null; + return listener; + } + } + + private void failReconnect(HealthException wrapped) { + boolean giveUp; + synchronized (reconnectLock) { + reconnectFailures++; + giveUp = reconnectFailures >= MAX_RECONNECT_FAILURES; + } + if (giveUp) { + endSession(); + fireError(wrapped); + return; + } + fireError(wrapped); + peripheral.disconnect(); + } + + @Override + @SuppressWarnings("PMD.CompareObjectsWithEquals") + public AsyncResource resetEnergyExpended() { + AsyncResource out = new AsyncResource(); + if (getProfile() != HealthSensorProfile.HEART_RATE) { + out.error(new HealthException(HealthError.NOT_SUPPORTED, + "energy expended applies to the heart rate profile")); + return out; + } + GattService service = + peripheral.getService(getProfile().getServiceUuid()); + GattCharacteristic cp = service == null ? null + : service.getCharacteristic(HEART_RATE_CONTROL_POINT); + if (cp == null) { + out.error(new HealthException(HealthError.NOT_SUPPORTED, + "this strap does not expose the heart rate control" + + " point")); + return out; + } + // The control point is a reliable write: the strap acknowledges + // it, and a silently dropped reset would leave the energy counter + // accumulating across what the user thinks are separate workouts. + return peripheral.writeCharacteristic(cp, new byte[] { 0x01 }, true); + } + + @Override + public void stop() { + if (isTerminal()) { + // Either terminal state, not STOPPED alone. A session the + // reconnect ladder had already retired as FAILED went through + // the whole teardown a second time -- flushing again, and + // reporting itself as STOPPED, which is a different account + // of how it ended than the one its listeners were given. + return; + } + Reconnector listener = takeReconnectListener(); + if (listener != null) { + // Removed before the state changes, so the disconnect this + // method causes is not mistaken for a dropped link. + peripheral.removeConnectionListener(listener); + } + setState(SensorSessionState.STOPPED); + // A short ride would otherwise lose whatever had not reached a + // batch boundary yet. + flushPendingWrites(); + forgetFromManager(); + peripheral.disconnect(); + } +} diff --git a/CodenameOne/src/com/codename1/health/sensors/BloodPressureMeasurement.java b/CodenameOne/src/com/codename1/health/sensors/BloodPressureMeasurement.java new file mode 100644 index 00000000000..1e5b331dc9b --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/BloodPressureMeasurement.java @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +/// A decoded Blood Pressure Measurement, characteristic `0x2A35`, or the +/// Intermediate Cuff Pressure, `0x2A36`, which shares the same layout. +/// +/// #### Wire format +/// +/// Byte 0 is a flags field: +/// +/// | Bit | Meaning | +/// |-----|---------| +/// | 0 | units: 0 = mmHg, 1 = kPa | +/// | 1 | timestamp present | +/// | 2 | pulse rate present | +/// | 3 | user id present | +/// | 4 | measurement status present | +/// +/// It is followed by three 16-bit IEEE-11073 SFLOAT values -- systolic, +/// diastolic and mean arterial pressure -- then the optional fields. +/// +/// #### Two traps +/// +/// **This characteristic is indicated, not notified.** Its client +/// configuration descriptor takes `0x0002`. The Bluetooth layer handles +/// that automatically, but a hand-rolled GATT client that writes `0x0001` +/// will subscribe successfully and then never receive anything. +/// +/// **A cuff that fails to get a reading sends a reserved SFLOAT.** Those +/// decode to `Double.NaN` here, and [#parse(byte[])] returns `null` when +/// the systolic or diastolic value is one, so a failed measurement can +/// never reach your UI as 2047 mmHg. +public final class BloodPressureMeasurement { + + private static final int FLAG_KPA = 0x01; + private static final int FLAG_TIMESTAMP = 0x02; + private static final int FLAG_PULSE = 0x04; + private static final int FLAG_USER_ID = 0x08; + private static final int FLAG_STATUS = 0x10; + + /// kPa to mmHg. + private static final double KPA_TO_MMHG = 7.50061683; + + private final double systolicMmHg; + private final double diastolicMmHg; + private final double meanArterialMmHg; + private final double pulseBpm; + private final int userId; + private final long timestampMillis; + + private BloodPressureMeasurement(double systolicMmHg, + double diastolicMmHg, double meanArterialMmHg, double pulseBpm, + int userId, long timestampMillis) { + this.systolicMmHg = systolicMmHg; + this.diastolicMmHg = diastolicMmHg; + this.meanArterialMmHg = meanArterialMmHg; + this.pulseBpm = pulseBpm; + this.userId = userId; + this.timestampMillis = timestampMillis; + } + + /// Decodes a `0x2A35` or `0x2A36` value. + /// + /// Returns `null` for a malformed or truncated payload, and also when + /// the device signalled a failed measurement through a reserved + /// SFLOAT. Never throws. + public static BloodPressureMeasurement parse(byte[] value) { + if (value == null || value.length < 7) { + return null; + } + GattReader r = new GattReader(value); + int flags = r.uint8(); + boolean kpa = (flags & FLAG_KPA) != 0; + + double systolic = convert(Ieee11073.sfloat(r.uint16()), kpa); + double diastolic = convert(Ieee11073.sfloat(r.uint16()), kpa); + double mean = convert(Ieee11073.sfloat(r.uint16()), kpa); + + long timestamp = -1; + if ((flags & FLAG_TIMESTAMP) != 0) { + timestamp = GattDateTime.read(r); + } + double pulse = Double.NaN; + if ((flags & FLAG_PULSE) != 0) { + pulse = Ieee11073.sfloat(r.uint16()); + } + int user = -1; + if ((flags & FLAG_USER_ID) != 0) { + user = r.uint8(); + } + if ((flags & FLAG_STATUS) != 0) { + r.skip(2); + } + + if (!r.isValid()) { + return null; + } + if (Double.isNaN(systolic) || Double.isNaN(diastolic)) { + // The cuff reported that it could not obtain a reading. + return null; + } + return new BloodPressureMeasurement(systolic, diastolic, mean, pulse, + user, timestamp); + } + + private static double convert(double v, boolean kpa) { + return kpa ? v * KPA_TO_MMHG : v; + } + + /// Systolic pressure in mmHg, converted from kPa when the device + /// reported those. + public double getSystolicMmHg() { + return systolicMmHg; + } + + /// Diastolic pressure in mmHg. + public double getDiastolicMmHg() { + return diastolicMmHg; + } + + /// Mean arterial pressure in mmHg, or `Double.NaN` when the device did + /// not compute one. + public double getMeanArterialMmHg() { + return meanArterialMmHg; + } + + /// Pulse in beats per minute, or `Double.NaN` when absent. + public double getPulseBpm() { + return pulseBpm; + } + + /// `true` when this reading carried a pulse. + public boolean hasPulse() { + return !Double.isNaN(pulseBpm); + } + + /// The device's user-profile index, or `-1` when absent. Cuffs shared + /// by a household use it to attribute a reading. + public int getUserId() { + return userId; + } + + /// The device's own timestamp for this reading in epoch millis, or + /// `-1` when absent. + /// + /// Worth preferring over the time of receipt: cuffs store readings and + /// upload them in a burst when they next connect, so several + /// measurements taken hours apart can arrive within a second. + public long getTimestampMillis() { + return timestampMillis; + } + + @Override + public String toString() { + return "BloodPressureMeasurement[" + (int) systolicMmHg + "/" + + (int) diastolicMmHg + " mmHg]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/sensors/BodySensorLocation.java b/CodenameOne/src/com/codename1/health/sensors/BodySensorLocation.java new file mode 100644 index 00000000000..fdbf7ade3bb --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/BodySensorLocation.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +/// The values of the Body Sensor Location characteristic, `0x2A38`, as +/// reported by [SensorSession#getBodySensorLocation()]. +/// +/// Worth surfacing: a wrist-worn optical sensor and a chest strap have +/// materially different accuracy during hard efforts, and telling the user +/// which one is feeding a reading is more honest than presenting both as +/// equivalent. +public final class BodySensorLocation { + + /// Somewhere the profile does not name. + public static final int OTHER = 0; + /// Chest -- a strap; the most accurate of these placements. + public static final int CHEST = 1; + /// Wrist -- an optical sensor in a watch or band. + public static final int WRIST = 2; + /// Finger. + public static final int FINGER = 3; + /// Hand. + public static final int HAND = 4; + /// Earlobe. + public static final int EAR_LOBE = 5; + /// Foot. + public static final int FOOT = 6; + + private BodySensorLocation() { + } + + /// A human-readable name for a location constant. + public static String describe(int location) { + switch (location) { + case CHEST: return "Chest"; + case WRIST: return "Wrist"; + case FINGER: return "Finger"; + case HAND: return "Hand"; + case EAR_LOBE: return "Ear lobe"; + case FOOT: return "Foot"; + case OTHER: return "Other"; + default: return "Unknown"; + } + } +} diff --git a/CodenameOne/src/com/codename1/health/sensors/CscMeasurement.java b/CodenameOne/src/com/codename1/health/sensors/CscMeasurement.java new file mode 100644 index 00000000000..db885bc4f9d --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/CscMeasurement.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +/// A decoded Cycling Speed and Cadence Measurement, characteristic +/// `0x2A5B`. +/// +/// #### Wire format +/// +/// A single flags byte -- bit 0 wheel data present, bit 1 crank data +/// present -- followed by whichever blocks it selected: a `uint32` +/// revolution count plus a `uint16` event time for the wheel, and a +/// `uint16` count plus `uint16` event time for the crank. +/// +/// #### These are cumulative counters, not rates +/// +/// Neither speed nor cadence is transmitted. What arrives is a running +/// total and a timestamp, and both wrap -- the event times roughly every +/// 64 seconds. Differencing them correctly is what +/// [CumulativeCounterTracker] exists for, and [SensorSession] applies it +/// automatically, emitting derived speed and cadence samples. Use this +/// parser directly only if you are doing that arithmetic yourself. +public final class CscMeasurement { + + private static final int FLAG_WHEEL = 0x01; + private static final int FLAG_CRANK = 0x02; + + private final long wheelRevolutions; + private final int lastWheelEventTime; + private final int crankRevolutions; + private final int lastCrankEventTime; + + private CscMeasurement(long wheelRevolutions, int lastWheelEventTime, + int crankRevolutions, int lastCrankEventTime) { + this.wheelRevolutions = wheelRevolutions; + this.lastWheelEventTime = lastWheelEventTime; + this.crankRevolutions = crankRevolutions; + this.lastCrankEventTime = lastCrankEventTime; + } + + /// Decodes a `0x2A5B` value. Returns `null` for a malformed or + /// truncated payload; never throws. + public static CscMeasurement parse(byte[] value) { + if (value == null || value.length < 2) { + return null; + } + GattReader r = new GattReader(value); + int flags = r.uint8(); + long wheelRevs = -1; + int wheelTime = -1; + if ((flags & FLAG_WHEEL) != 0) { + wheelRevs = r.uint32(); + wheelTime = r.uint16(); + } + int crankRevs = -1; + int crankTime = -1; + if ((flags & FLAG_CRANK) != 0) { + crankRevs = r.uint16(); + crankTime = r.uint16(); + } + if (!r.isValid()) { + return null; + } + return new CscMeasurement(wheelRevs, wheelTime, crankRevs, crankTime); + } + + /// Cumulative wheel revolutions, or `-1` when absent. Wraps at 2^32. + public long getWheelRevolutions() { + return wheelRevolutions; + } + + /// The wheel-event timestamp in 1/1024-second units, or `-1` when + /// absent. Wraps roughly every 64 seconds. + public int getLastWheelEventTime() { + return lastWheelEventTime; + } + + /// Cumulative crank revolutions, or `-1` when absent. Wraps at 2^16. + public int getCrankRevolutions() { + return crankRevolutions; + } + + /// The crank-event timestamp in 1/1024-second units, or `-1` when + /// absent. Wraps roughly every 64 seconds. + public int getLastCrankEventTime() { + return lastCrankEventTime; + } + + /// `true` when this notification carried wheel data. + public boolean hasWheelData() { + return wheelRevolutions >= 0; + } + + /// `true` when this notification carried crank data. + public boolean hasCrankData() { + return crankRevolutions >= 0; + } + + @Override + public String toString() { + return "CscMeasurement[wheel=" + wheelRevolutions + " crank=" + + crankRevolutions + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/sensors/CumulativeCounterTracker.java b/CodenameOne/src/com/codename1/health/sensors/CumulativeCounterTracker.java new file mode 100644 index 00000000000..242f34682a8 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/CumulativeCounterTracker.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +/// Turns the cumulative revolution counters that cycling and running +/// sensors broadcast into an instantaneous rate. +/// +/// #### Why this is not left to the app +/// +/// Speed and cadence sensors do not transmit speed or cadence. They +/// transmit a running total of revolutions and the timestamp of the most +/// recent one, and the reader is expected to difference consecutive +/// notifications. Doing that correctly means handling three things that +/// are individually easy to miss and collectively guarantee wrong numbers: +/// +/// 1. **The event timer wraps.** It is a `uint16` counting 1/1024- or +/// 1/2048-second ticks, so it rolls over roughly every 64 or 32 +/// seconds -- during every single ride, many times. An unguarded +/// subtraction produces a large negative interval and therefore a +/// spike of tens of thousands of rpm. +/// 2. **The revolution counter wraps too**, at 2^16 or 2^32. +/// 3. **The first notification has no predecessor.** Differencing against +/// zero reports the sensor's lifetime total as though it happened in +/// one instant. +/// +/// Shipping this wrong in every app that touches a cadence sensor is not +/// an acceptable outcome, so it lives here and is exercised by tests. +/// +/// Not thread-safe; each instance belongs to one sensor session. +final class CumulativeCounterTracker { + + private final long revolutionModulus; + private final int timeTicksPerSecond; + + private long lastRevolutions = -1; + private int lastEventTime = -1; + private int duplicates; + + /// Creates a tracker. + /// + /// - `revolutionModulus`: `0x10000` for a `uint16` counter (cranks), + /// `0x100000000L` for a `uint32` one (wheels). + /// - `timeTicksPerSecond`: 1024 for cycling speed and cadence and for + /// crank data, 2048 for cycling-power wheel data. + CumulativeCounterTracker(long revolutionModulus, int timeTicksPerSecond) { + this.revolutionModulus = revolutionModulus; + this.timeTicksPerSecond = timeTicksPerSecond; + } + + /// Feeds one notification and returns the rate in revolutions per + /// minute, or `Double.NaN` when no rate can be derived yet. + /// + /// `NaN` is returned for the first notification (no baseline), when + /// the event time has not advanced (a duplicate notification, or a + /// stationary sensor), and when the input is absent. It is deliberately + /// not zero: "we cannot tell yet" and "the rider has stopped" are + /// different, and only the sensor's own repeated identical timestamps + /// establish the latter. + double update(long revolutions, int eventTime) { + if (revolutions < 0 || eventTime < 0) { + return Double.NaN; + } + if (lastRevolutions < 0) { + lastRevolutions = revolutions; + lastEventTime = eventTime; + return Double.NaN; + } + + long revDelta = revolutions - lastRevolutions; + if (revDelta < 0) { + revDelta += revolutionModulus; + } + int timeDelta = eventTime - lastEventTime; + if (timeDelta < 0) { + timeDelta += 0x10000; + } + + lastRevolutions = revolutions; + lastEventTime = eventTime; + + if (timeDelta == 0) { + // A stopped wheel or crank repeats the same counter and the + // same event time. Suppressing the sample indefinitely left a + // live metric frozen at whatever it last read -- a rider who + // stops at a light kept showing 90 rpm. After enough repeats + // to be sure it is a stop rather than a duplicate packet, say + // zero, which is the truth. + duplicates++; + return duplicates >= STOPPED_AFTER_DUPLICATES ? 0.0 + : Double.NaN; + } + duplicates = 0; + double seconds = timeDelta / (double) timeTicksPerSecond; + return revDelta / seconds * 60.0; + } + + /// Consecutive identical notifications before the sensor is treated + /// as stopped rather than merely repeating a packet. + /// + /// Two is enough to distinguish the two cases at the roughly 1 Hz + /// these profiles notify at, and short enough that a live display + /// reaches zero while the rider is still at the light. + private static final int STOPPED_AFTER_DUPLICATES = 2; + + /// Forgets the baseline, so the next notification establishes a new + /// one. Called on reconnect: a sensor that power-cycled restarts its + /// counters from zero, and differencing across that gap would report a + /// single enormous negative-then-wrapped interval. + void reset() { + lastRevolutions = -1; + lastEventTime = -1; + duplicates = 0; + } +} diff --git a/CodenameOne/src/com/codename1/health/sensors/CyclingPowerMeasurement.java b/CodenameOne/src/com/codename1/health/sensors/CyclingPowerMeasurement.java new file mode 100644 index 00000000000..10523712643 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/CyclingPowerMeasurement.java @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +/// A decoded Cycling Power Measurement, characteristic `0x2A63`. +/// +/// #### Wire format +/// +/// Unlike most SIG characteristics the flags field here is a **`uint16`**, +/// not a single byte, because the profile defines sixteen optional fields. +/// It is followed immediately by the instantaneous power. +/// +/// #### Instantaneous power is signed +/// +/// The power field is `sint16`, and negative values are real: a power +/// meter reports negative watts when the rider is back-pedalling or the +/// drivetrain is driving the cranks on a descent. Reading it as unsigned +/// turns a brief -5 W into 65531 W, which then poisons every average and +/// maximum for the whole ride. +/// +/// This parser exposes the common fields -- instantaneous power, +/// accumulated energy, pedal balance, crank revolutions. The remaining +/// optional fields are skipped in order so that the fields after them +/// still decode correctly. +public final class CyclingPowerMeasurement { + + private static final int FLAG_PEDAL_BALANCE = 0x0001; + /// Set when the balance is measured against the left pedal; clear + /// means the reference is unknown, which is a different reading. + private static final int FLAG_BALANCE_REFERENCE_LEFT = 0x0002; + // 0x0002 pedal-power-balance reference and 0x0008 accumulated-torque + // source are single-bit qualifiers on the two fields below rather than + // presence flags, so they shift nothing and are not read here. + private static final int FLAG_ACCUMULATED_TORQUE = 0x0004; + private static final int FLAG_WHEEL_REVOLUTIONS = 0x0010; + private static final int FLAG_CRANK_REVOLUTIONS = 0x0020; + private static final int FLAG_EXTREME_FORCE = 0x0040; + private static final int FLAG_EXTREME_TORQUE = 0x0080; + private static final int FLAG_EXTREME_ANGLES = 0x0100; + private static final int FLAG_TOP_DEAD_SPOT = 0x0200; + private static final int FLAG_BOTTOM_DEAD_SPOT = 0x0400; + private static final int FLAG_ACCUMULATED_ENERGY = 0x0800; + + private final int instantaneousPowerWatts; + private final double pedalPowerBalancePercent; + private final boolean pedalPowerBalanceLeft; + private final long wheelRevolutions; + private final int lastWheelEventTime; + private final int crankRevolutions; + private final int lastCrankEventTime; + private final int accumulatedEnergyKilojoules; + + private CyclingPowerMeasurement(int instantaneousPowerWatts, + double pedalPowerBalancePercent, boolean pedalPowerBalanceLeft, + long wheelRevolutions, + int lastWheelEventTime, int crankRevolutions, + int lastCrankEventTime, int accumulatedEnergyKilojoules) { + this.instantaneousPowerWatts = instantaneousPowerWatts; + this.pedalPowerBalancePercent = pedalPowerBalancePercent; + this.pedalPowerBalanceLeft = pedalPowerBalanceLeft; + this.wheelRevolutions = wheelRevolutions; + this.lastWheelEventTime = lastWheelEventTime; + this.crankRevolutions = crankRevolutions; + this.lastCrankEventTime = lastCrankEventTime; + this.accumulatedEnergyKilojoules = accumulatedEnergyKilojoules; + } + + /// Decodes a `0x2A63` value. Returns `null` for a malformed or + /// truncated payload; never throws. + public static CyclingPowerMeasurement parse(byte[] value) { + if (value == null || value.length < 4) { + return null; + } + GattReader r = new GattReader(value); + int flags = r.uint16(); + int power = r.sint16(); + + double balance = Double.NaN; + boolean balanceLeft = false; + if ((flags & FLAG_PEDAL_BALANCE) != 0) { + // Transmitted in halves of a percent. + balance = r.uint8() * 0.5; + // The reference qualifier travels with it. Dropping it made a + // left-referenced reading and an unknown-reference one + // indistinguishable, while the getter told callers to consult + // a bit they had no way to see. + balanceLeft = (flags & FLAG_BALANCE_REFERENCE_LEFT) != 0; + } + if ((flags & FLAG_ACCUMULATED_TORQUE) != 0) { + r.skip(2); + } + long wheelRevs = -1; + int wheelTime = -1; + if ((flags & FLAG_WHEEL_REVOLUTIONS) != 0) { + wheelRevs = r.uint32(); + wheelTime = r.uint16(); + } + int crankRevs = -1; + int crankTime = -1; + if ((flags & FLAG_CRANK_REVOLUTIONS) != 0) { + crankRevs = r.uint16(); + crankTime = r.uint16(); + } + if ((flags & FLAG_EXTREME_FORCE) != 0) { + r.skip(4); + } + if ((flags & FLAG_EXTREME_TORQUE) != 0) { + r.skip(4); + } + if ((flags & FLAG_EXTREME_ANGLES) != 0) { + r.skip(3); + } + if ((flags & FLAG_TOP_DEAD_SPOT) != 0) { + r.skip(2); + } + if ((flags & FLAG_BOTTOM_DEAD_SPOT) != 0) { + r.skip(2); + } + int energy = -1; + if ((flags & FLAG_ACCUMULATED_ENERGY) != 0) { + energy = r.uint16(); + } + + if (!r.isValid()) { + return null; + } + return new CyclingPowerMeasurement(power, balance, balanceLeft, + wheelRevs, wheelTime, crankRevs, crankTime, energy); + } + + /// Instantaneous power in watts. **May be negative** -- see the class + /// documentation. + public int getInstantaneousPowerWatts() { + return instantaneousPowerWatts; + } + + /// Whether [#getPedalPowerBalancePercent()] is measured against the + /// left pedal. + /// + /// `false` also means "the meter did not say", which is not the same + /// as "the right pedal" -- check + /// [#getPedalPowerBalancePercent()] for `NaN` first to tell an absent + /// reading from an unqualified one. + public boolean isPedalPowerBalanceLeft() { + return pedalPowerBalanceLeft; + } + + /// The share of power contributed by one pedal, as a percentage, or + /// `Double.NaN` when absent. Which pedal it refers to is reported by + /// [#isPedalPowerBalanceLeft()]; most meters report the left. + public double getPedalPowerBalancePercent() { + return pedalPowerBalancePercent; + } + + /// Cumulative wheel revolutions since the meter powered on, or `-1` + /// when absent. Wraps at 2^32. + public long getWheelRevolutions() { + return wheelRevolutions; + } + + /// The wheel-event timestamp in 1/2048-second units, or `-1` when + /// absent. Wraps roughly every 32 seconds. + public int getLastWheelEventTime() { + return lastWheelEventTime; + } + + /// Cumulative crank revolutions, or `-1` when absent. Wraps at 2^16. + public int getCrankRevolutions() { + return crankRevolutions; + } + + /// The crank-event timestamp in 1/1024-second units, or `-1` when + /// absent. Wraps roughly every 64 seconds. + public int getLastCrankEventTime() { + return lastCrankEventTime; + } + + /// Total energy in kilojoules since the meter powered on, or `-1` when + /// absent. + public int getAccumulatedEnergyKilojoules() { + return accumulatedEnergyKilojoules; + } + + /// `true` when this notification carried crank data, from which + /// cadence can be derived. + public boolean hasCrankData() { + return crankRevolutions >= 0; + } + + @Override + public String toString() { + return "CyclingPowerMeasurement[" + instantaneousPowerWatts + " W]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/sensors/GattDateTime.java b/CodenameOne/src/com/codename1/health/sensors/GattDateTime.java new file mode 100644 index 00000000000..057fe93134d --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/GattDateTime.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +import java.util.Calendar; +import java.util.TimeZone; + +/// Reader for the Bluetooth SIG `Date Time` structure (7 bytes: `uint16` +/// year, then `uint8` month, day, hour, minute, second). +/// +/// The structure carries no time zone. Devices set their clock from the +/// phone during pairing and then report **local** wall-clock time, so the +/// fields are interpreted in the device's default zone -- the same one the +/// user saw when they took the measurement. +final class GattDateTime { + + /// Days in a month, so an impossible date is rejected rather than + /// rolled forward. + /// + /// The CLDC Calendar this framework targets is always lenient and has + /// no setLenient, so 31 February would otherwise be normalised into + /// March and stored as a plausible but wrong measurement date. + private static int daysInMonth(int year, int month) { + switch (month) { + case 2: + boolean leap = (year % 4 == 0 && year % 100 != 0) + || year % 400 == 0; + return leap ? 29 : 28; + case 4: + case 6: + case 9: + case 11: + return 30; + default: + return 31; + } + } + + /// The first and last years the Date Time characteristic defines. + /// Everything else is reserved, including the 0 a device reports + /// when it has no idea what the date is -- after a battery change, + /// say -- so that case falls out of the same range test. + private static final int YEAR_MIN = 1582; + private static final int YEAR_MAX = 9999; + + private GattDateTime() { + } + + /// Reads seven bytes and returns epoch millis, or `-1` when the device + /// reported an unknown or nonsensical date. + static long read(GattReader r) { + int year = r.uint16(); + int month = r.uint8(); + int day = r.uint8(); + int hour = r.uint8(); + int minute = r.uint8(); + int second = r.uint8(); + // The profile defines the year as 1582-9999 and reserves + // everything else, 0 included. A malformed 10000 used to become a + // perfectly ordinary future timestamp: the session published the + // reading under a date the device never claimed, and getLatest() + // treated it as permanently fresh because its age came out + // negative. + if (!r.isValid() || year < YEAR_MIN || year > YEAR_MAX + || month < 1 || month > 12 + || day < 1 || day > daysInMonth(year, month) + || hour > 23 || minute > 59 || second > 59) { + return -1; + } + // Field-at-a-time rather than clear() plus the six-argument set(): + // the CLDC Calendar this framework targets has neither, and reads + // its instant through getTime(). + Calendar c = Calendar.getInstance(TimeZone.getDefault()); + c.set(Calendar.YEAR, year); + c.set(Calendar.MONTH, month - 1); + c.set(Calendar.DAY_OF_MONTH, day); + c.set(Calendar.HOUR_OF_DAY, hour); + c.set(Calendar.MINUTE, minute); + c.set(Calendar.SECOND, second); + c.set(Calendar.MILLISECOND, 0); + long stamped = c.getTime().getTime(); + if (stamped - System.currentTimeMillis() > MAX_SKEW_MILLIS) { + // A structurally valid date the reading cannot have been + // taken at. A cuff or scale with its clock left at 2099 -- + // out of the box, or after a battery change -- passes every + // field check above, and the session then prefers that stamp + // over the moment the notification arrived, stores the + // reading under it, and getLatest() reports it as the + // freshest there is because its age comes out negative. + // + // Refused rather than clamped, so the caller falls back to + // receipt time instead of being handed a fabricated one. Same + // bound and same reasoning as the glucose time offset. + return -1; + } + return stamped; + } + + /// How far ahead of now a device may stamp a reading before the + /// timestamp is refused: a day, which is slack for a clock that + /// disagrees with the phone's rather than a licence to record in the + /// future. + private static final long MAX_SKEW_MILLIS = 24L * 60 * 60 * 1000; +} diff --git a/CodenameOne/src/com/codename1/health/sensors/GattReader.java b/CodenameOne/src/com/codename1/health/sensors/GattReader.java new file mode 100644 index 00000000000..2db6484708b --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/GattReader.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +/// A bounds-checked, little-endian cursor over a GATT characteristic +/// value. +/// +/// Every Bluetooth SIG health characteristic is little-endian with +/// variable-length optional fields selected by a flags byte, so a parser +/// that reads a field the device did not send walks off the end of the +/// array. Rather than let that surface as an +/// `ArrayIndexOutOfBoundsException` from inside a notification callback, +/// this reader records an underflow and every subsequent read returns +/// zero; the parser checks [#isValid()] once at the end and returns null. +/// +/// The other trap this exists to close is sign. Java bytes are signed, so +/// a heart rate of 200 read as a raw `byte` is -56. Every accessor here +/// masks explicitly. +final class GattReader { + + private final byte[] data; + private int offset; + private boolean underflow; + + GattReader(byte[] data) { + this.data = data == null ? new byte[0] : data; + } + + /// `true` while every read so far has stayed inside the payload. + boolean isValid() { + return !underflow; + } + + /// Bytes not yet consumed. + int remaining() { + return underflow ? 0 : data.length - offset; + } + + /// Advances past `count` bytes. + void skip(int count) { + if (!require(count)) { + return; + } + offset += count; + } + + /// An unsigned 8-bit value, 0..255. + int uint8() { + if (!require(1)) { + return 0; + } + return data[offset++] & 0xFF; + } + + /// A signed 8-bit value, -128..127. + int sint8() { + if (!require(1)) { + return 0; + } + return data[offset++]; + } + + /// An unsigned 16-bit little-endian value, 0..65535. + int uint16() { + if (!require(2)) { + return 0; + } + int lo = data[offset++] & 0xFF; + int hi = data[offset++] & 0xFF; + return lo | (hi << 8); + } + + /// A signed 16-bit little-endian value. + int sint16() { + int v = uint16(); + return (v & 0x8000) != 0 ? v - 0x10000 : v; + } + + /// An unsigned 24-bit little-endian value. + int uint24() { + if (!require(3)) { + return 0; + } + int b0 = data[offset++] & 0xFF; + int b1 = data[offset++] & 0xFF; + int b2 = data[offset++] & 0xFF; + return b0 | (b1 << 8) | (b2 << 16); + } + + /// An unsigned 32-bit little-endian value, widened to a `long` so that + /// values above 2^31 -- a wheel-revolution counter, for instance -- + /// stay positive. + long uint32() { + if (!require(4)) { + return 0; + } + long b0 = data[offset++] & 0xFF; + long b1 = data[offset++] & 0xFF; + long b2 = data[offset++] & 0xFF; + long b3 = data[offset++] & 0xFF; + return b0 | (b1 << 8) | (b2 << 16) | (b3 << 24); + } + + private boolean require(int count) { + if (underflow) { + return false; + } + if (offset + count > data.length) { + underflow = true; + return false; + } + return true; + } +} diff --git a/CodenameOne/src/com/codename1/health/sensors/GlucoseMeasurement.java b/CodenameOne/src/com/codename1/health/sensors/GlucoseMeasurement.java new file mode 100644 index 00000000000..e400fe783ce --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/GlucoseMeasurement.java @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +/// A decoded Glucose Measurement, characteristic `0x2A18`. +/// +/// #### Wire format +/// +/// A flags byte, a `uint16` sequence number, a 7-byte base time, then the +/// optional fields the flags select: +/// +/// | Bit | Meaning | +/// |-----|---------| +/// | 0 | time offset present (`sint16` minutes) | +/// | 1 | concentration, type and location present | +/// | 2 | concentration units: 0 = kg/L, 1 = mol/L | +/// | 3 | sensor status annunciation present | +/// | 4 | context information will follow on `0x2A34` | +/// +/// #### The units bit selects a different quantity, not a different scale +/// +/// A meter reporting kg/L transmits a mass concentration; one reporting +/// mol/L transmits a molar concentration. Converting between them divides +/// by the molar mass of glucose, 18.0182 -- it is not a generic unit +/// conversion, and applying it to any other analyte would be wrong. This +/// parser normalizes to mmol/L, the SI clinical unit, and exposes +/// [#getMilligramsPerDeciliter()] for the convention used in the United +/// States. +/// +/// #### Sequence numbers and stored records +/// +/// Meters store readings and upload them in a burst, so the sequence +/// number rather than the arrival order is what identifies a measurement. +/// Retrieving stored records means driving the Record Access Control Point +/// -- see [GlucoseRecordFilter] and +/// [SensorSession#requestStoredRecords(GlucoseRecordFilter)]. +public final class GlucoseMeasurement { + + private static final int FLAG_TIME_OFFSET = 0x01; + + /// The value the profile reserves for "the offset is not known". Every + /// other `sint16` is a real number of minutes, which is why nothing + /// narrower is rejected: a meter that stores a base time once and + /// offsets every record from it legitimately reports offsets months + /// wide. + private static final int TIME_OFFSET_UNKNOWN = -32768; + + /// How far ahead of now a decoded measurement time may land before + /// the offset that produced it is treated as junk. + /// + /// A day, which is slack for a meter whose clock disagrees with the + /// phone's rather than a licence to stamp readings in the future. + private static final long MAX_SKEW_MILLIS = 24L * 60 * 60 * 1000; + private static final int FLAG_CONCENTRATION = 0x02; + private static final int FLAG_UNITS_MOL_PER_L = 0x04; + private static final int FLAG_STATUS = 0x08; + private static final int FLAG_CONTEXT_FOLLOWS = 0x10; + + /// Molar mass of glucose, used to convert mg/dL to mmol/L. Specific to + /// glucose -- see the class documentation. + private static final double MG_PER_DL_PER_MMOL_PER_L = 18.0182; + + /// The sample came from somewhere this profile does not name. + public static final int SAMPLE_LOCATION_UNKNOWN = 0; + /// Fingertip. + public static final int SAMPLE_LOCATION_FINGER = 1; + /// Alternate site test, such as the forearm. + public static final int SAMPLE_LOCATION_ALTERNATE_SITE = 2; + /// Earlobe. + public static final int SAMPLE_LOCATION_EARLOBE = 3; + /// Control solution rather than blood -- a calibration check, and + /// **not** a reading to store as the user's glucose. + public static final int SAMPLE_LOCATION_CONTROL_SOLUTION = 4; + + private final int sequenceNumber; + private final long timestampMillis; + private final double mmolPerLiter; + private final int sampleLocation; + private final int sampleType; + private final boolean contextFollows; + private final boolean hasConcentration; + + private GlucoseMeasurement(int sequenceNumber, long timestampMillis, + double mmolPerLiter, int sampleLocation, int sampleType, + boolean contextFollows, boolean hasConcentration) { + this.sequenceNumber = sequenceNumber; + this.timestampMillis = timestampMillis; + this.mmolPerLiter = mmolPerLiter; + this.sampleLocation = sampleLocation; + this.sampleType = sampleType; + this.contextFollows = contextFollows; + this.hasConcentration = hasConcentration; + } + + /// Decodes a `0x2A18` value. Returns `null` for a malformed or + /// truncated payload; never throws. + public static GlucoseMeasurement parse(byte[] value) { + if (value == null || value.length < 10) { + return null; + } + GattReader r = new GattReader(value); + int flags = r.uint8(); + int sequence = r.uint16(); + long baseTime = GattDateTime.read(r); + + long timestamp = baseTime; + if ((flags & FLAG_TIME_OFFSET) != 0) { + int offsetMinutes = r.sint16(); + // 0x8000 is the profile's "time offset unknown" value, not an + // offset of -32768 minutes. Applied as a number it moved the + // reading back about 22.8 days, and the session then published + // -- and on some paths stored -- a real glucose value under a + // date the meter never claimed. Unknown means the base time is + // the best answer available, so that is what is used. + // + // An offset that lands in the future is refused for a + // narrower reason: the measurement already happened, so a + // time after now is not a time it can have been taken at. A + // junk offset from a meter with a recent base time -- 32767 + // moves it three weeks ahead -- otherwise produced a reading + // that getLatest() reported as the freshest there was, + // because its age came out negative. + // + // Deliberately *not* a bound on the offset itself. The + // Glucose Service recommends that a device restrict what a + // user can enter to a day either way, and says in the same + // breath that the field carries the full signed range, so a + // large offset is not by itself wrong -- only one that + // arrives at an impossible time is. + if (baseTime >= 0 && offsetMinutes != TIME_OFFSET_UNKNOWN) { + long shifted = baseTime + offsetMinutes * 60000L; + if (shifted - System.currentTimeMillis() <= MAX_SKEW_MILLIS) { + timestamp = shifted; + } + } + } + + double mmol = Double.NaN; + int location = SAMPLE_LOCATION_UNKNOWN; + int type = 0; + boolean hasConcentration = (flags & FLAG_CONCENTRATION) != 0; + if (hasConcentration) { + double raw = Ieee11073.sfloat(r.uint16()); + int typeAndLocation = r.uint8(); + type = typeAndLocation & 0x0F; + location = (typeAndLocation >> 4) & 0x0F; + if ((flags & FLAG_UNITS_MOL_PER_L) != 0) { + // Transmitted in mol/L; 1 mol/L is 1000 mmol/L. + mmol = raw * 1000.0; + } else { + // Transmitted in kg/L. 1 kg/L is 100000 mg/dL. + double mgPerDl = raw * 100000.0; + mmol = mgPerDl / MG_PER_DL_PER_MMOL_PER_L; + } + } + if ((flags & FLAG_STATUS) != 0) { + r.skip(2); + } + + if (!r.isValid()) { + return null; + } + if (hasConcentration && Double.isNaN(mmol)) { + // The meter signalled that it could not obtain a reading. + return null; + } + return new GlucoseMeasurement(sequence, timestamp, mmol, location, + type, (flags & FLAG_CONTEXT_FOLLOWS) != 0, hasConcentration); + } + + /// The meter's sequence number for this reading. Stable across + /// re-transmission, and the right key for de-duplicating a burst of + /// stored records. + public int getSequenceNumber() { + return sequenceNumber; + } + + /// When the reading was taken, epoch millis, or `-1` when the meter's + /// clock was unset. Prefer this over the time of receipt -- stored + /// records arrive long after the fact. + public long getTimestampMillis() { + return timestampMillis; + } + + /// The concentration in mmol/L, or `Double.NaN` when this record + /// carried none. + public double getMillimolesPerLiter() { + return mmolPerLiter; + } + + /// The concentration in mg/dL, the unit used clinically in the United + /// States, or `Double.NaN` when absent. + public double getMilligramsPerDeciliter() { + return mmolPerLiter * MG_PER_DL_PER_MMOL_PER_L; + } + + /// `true` when this record carried a concentration. A record without + /// one is a placeholder the meter kept for a failed test. + public boolean hasConcentration() { + return hasConcentration; + } + + /// Where the sample was taken, as a `SAMPLE_LOCATION_` constant. + /// + /// Check for [#SAMPLE_LOCATION_CONTROL_SOLUTION] before storing a + /// reading as the user's glucose: that value means the meter was being + /// calibrated against a test fluid, and recording it as a blood + /// measurement puts a fictitious reading into their medical history. + public int getSampleLocation() { + return sampleLocation; + } + + /// The fluid type code the meter reported -- capillary whole blood, + /// venous plasma, interstitial fluid and so on. + public int getSampleType() { + return sampleType; + } + + /// `true` when the meter will follow this record with a context + /// notification on `0x2A34` carrying meal, exercise and medication + /// annotations. + public boolean isContextFollowing() { + return contextFollows; + } + + /// `true` when this reading came from control solution rather than + /// blood and must not be stored as a glucose measurement. + public boolean isControlSolution() { + return sampleLocation == SAMPLE_LOCATION_CONTROL_SOLUTION; + } + + @Override + public String toString() { + return "GlucoseMeasurement[#" + sequenceNumber + " " + mmolPerLiter + + " mmol/L]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/sensors/GlucoseRecordFilter.java b/CodenameOne/src/com/codename1/health/sensors/GlucoseRecordFilter.java new file mode 100644 index 00000000000..0d76fa21aad --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/GlucoseRecordFilter.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +/// Selects which stored records a glucose meter should replay, for +/// [SensorSession#requestStoredRecords(GlucoseRecordFilter)]. +/// +/// Glucose meters keep hundreds of readings and hand them over only on +/// request, through the Record Access Control Point (`0x2A52`) -- a small +/// request/response protocol layered on top of GATT. This class is the +/// portable description of such a request; the session drives the protocol +/// and delivers each retrieved record through the normal sample listener. +public final class GlucoseRecordFilter { + + /// What a filter selects on. + public enum Kind { + /// Every record the meter holds. + ALL, + /// Records with a sequence number at or above a bound. + SEQUENCE_GREATER_OR_EQUAL, + /// Records within an inclusive sequence-number range. + SEQUENCE_RANGE, + /// Only the record with the highest sequence number. + LAST, + /// Only the record with the lowest sequence number. + FIRST + } + + private final Kind kind; + private final int from; + private final int to; + + private GlucoseRecordFilter(Kind kind, int from, int to) { + this.kind = kind; + this.from = from; + this.to = to; + } + + /// Every stored record. On a meter that has been in use for months + /// this can be several hundred notifications, so prefer + /// [#sinceSequence(int)] once you have seen a record before. + public static GlucoseRecordFilter all() { + return new GlucoseRecordFilter(Kind.ALL, 0, 0); + } + + /// Records at or after `sequenceNumber`. + /// + /// This is the incremental-sync filter: remember the highest + /// [GlucoseMeasurement#getSequenceNumber()] you have stored and ask + /// for everything after it. + public static GlucoseRecordFilter sinceSequence(int sequenceNumber) { + return new GlucoseRecordFilter(Kind.SEQUENCE_GREATER_OR_EQUAL, + sequenceNumber, 0); + } + + /// Records with sequence numbers in `[from, to]`. + public static GlucoseRecordFilter sequenceRange(int from, int to) { + return new GlucoseRecordFilter(Kind.SEQUENCE_RANGE, from, to); + } + + /// The most recent record only. + public static GlucoseRecordFilter last() { + return new GlucoseRecordFilter(Kind.LAST, 0, 0); + } + + /// The oldest record only. + public static GlucoseRecordFilter first() { + return new GlucoseRecordFilter(Kind.FIRST, 0, 0); + } + + /// What this filter selects on. + public Kind getKind() { + return kind; + } + + /// The lower sequence bound, for the sequence-based kinds. + public int getFromSequence() { + return from; + } + + /// The upper sequence bound, for [Kind#SEQUENCE_RANGE]. + public int getToSequence() { + return to; + } +} diff --git a/CodenameOne/src/com/codename1/health/sensors/HealthSensor.java b/CodenameOne/src/com/codename1/health/sensors/HealthSensor.java new file mode 100644 index 00000000000..564cfc26c75 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/HealthSensor.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// A discovered health sensor: what it is, what it advertises, and how +/// strong its signal was. +public final class HealthSensor { + + private final String id; + private final String name; + private final List profiles; + private final int rssi; + + /// Creates a descriptor. Called by [HealthSensors] during discovery. + HealthSensor(String id, String name, List profiles, + int rssi) { + this.id = id; + this.name = name; + List copy = + new ArrayList(); + if (profiles != null) { + copy.addAll(profiles); + } + this.profiles = copy; + this.rssi = rssi; + } + + /// A stable identifier for this device. + /// + /// Persist it and pass it to + /// [HealthSensors#connect(String,HealthSensorProfile,SensorSessionOptions)] + /// to reconnect to the user's own strap on a later launch without + /// making them pick it out of a scan list again. + /// + /// The value is platform-scoped: a MAC address on Android and an + /// opaque per-installation UUID on iOS. It is stable on one device but + /// meaningless on another, so do not sync it between a user's phone + /// and tablet. + public String getId() { + return id; + } + + /// The advertised device name, or null. + public String getName() { + return name; + } + + /// The health profiles this device advertised. A dual-purpose device + /// such as a bike computer may report several. + public List getProfiles() { + return Collections.unmodifiableList(profiles); + } + + /// The received signal strength in dBm at discovery. Useful for + /// sorting a picker so the strap the user is wearing appears first. + public int getRssi() { + return rssi; + } + + /// `true` when this device advertised `profile`. + public boolean supports(HealthSensorProfile profile) { + return profiles.contains(profile); + } + + @Override + public String toString() { + return "HealthSensor[" + (name == null ? id : name) + " " + + profiles + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/sensors/HealthSensorProfile.java b/CodenameOne/src/com/codename1/health/sensors/HealthSensorProfile.java new file mode 100644 index 00000000000..18d21ba0eda --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/HealthSensorProfile.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.health.HealthDataType; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/// A standard Bluetooth SIG health sensor profile -- the service a device +/// advertises and the measurements it produces. +/// +/// These are the adopted profiles, so any conforming device works without +/// per-vendor code: a heart-rate strap from any manufacturer speaks +/// `0x180D`. +public final class HealthSensorProfile { + + private static final List ALL = + new ArrayList(); + + private final String name; + private final BluetoothUuid serviceUuid; + private final BluetoothUuid measurementUuid; + private final List producedTypes; + private final boolean streaming; + + private HealthSensorProfile(String name, int serviceShortUuid, + int measurementShortUuid, boolean streaming, + HealthDataType[] producedTypes) { + this.name = name; + this.serviceUuid = BluetoothUuid.fromShort(serviceShortUuid); + this.measurementUuid = BluetoothUuid.fromShort(measurementShortUuid); + this.streaming = streaming; + this.producedTypes = Collections.unmodifiableList( + new ArrayList(Arrays.asList(producedTypes))); + ALL.add(this); + } + + /// Heart Rate, service `0x180D`. Streams heart rate and, on most + /// straps, RR intervals for heart-rate variability. + public static final HealthSensorProfile HEART_RATE = + new HealthSensorProfile("Heart Rate", 0x180D, 0x2A37, true, + new HealthDataType[] { HealthDataType.HEART_RATE }); + + /// Cycling Power, service `0x1818`. Streams power and, when the meter + /// reports crank data, cadence. + public static final HealthSensorProfile CYCLING_POWER = + new HealthSensorProfile("Cycling Power", 0x1818, 0x2A63, true, + new HealthDataType[] { HealthDataType.POWER, + HealthDataType.CYCLING_CADENCE }); + + /// Cycling Speed and Cadence, service `0x1816`. Streams derived speed + /// and cadence -- the raw characteristic carries cumulative counters, + /// which [SensorSession] differences for you. + /// Cycling Speed and Cadence, service `0x1816`. + /// + /// Only cadence is produced. Speed would need a wheel circumference + /// the profile does not carry, and guessing a tyre size scales every + /// distance the app derives -- so the wheel block is decoded and + /// discarded rather than published. Advertising SPEED here left apps + /// waiting forever for a sample that never came. + public static final HealthSensorProfile CYCLING_SPEED_CADENCE = + new HealthSensorProfile("Cycling Speed and Cadence", 0x1816, + 0x2A5B, true, + new HealthDataType[] { + HealthDataType.CYCLING_CADENCE }); + + /// Running Speed and Cadence, service `0x1814`. Streams speed and step + /// cadence from a foot pod. + public static final HealthSensorProfile RUNNING_SPEED_CADENCE = + new HealthSensorProfile("Running Speed and Cadence", 0x1814, + 0x2A53, true, + new HealthDataType[] { HealthDataType.SPEED, + HealthDataType.RUNNING_CADENCE }); + + /// Health Thermometer, service `0x1809`. Reports one measurement at a + /// time rather than streaming. + public static final HealthSensorProfile HEALTH_THERMOMETER = + new HealthSensorProfile("Health Thermometer", 0x1809, 0x2A1C, + false, + new HealthDataType[] { HealthDataType.BODY_TEMPERATURE }); + + /// Weight Scale, service `0x181D`. Reports one measurement per + /// weighing. + public static final HealthSensorProfile WEIGHT_SCALE = + new HealthSensorProfile("Weight Scale", 0x181D, 0x2A9D, false, + new HealthDataType[] { HealthDataType.BODY_MASS }); + + /// Blood Pressure, service `0x1810`. Reports one measurement per + /// inflation cycle. + public static final HealthSensorProfile BLOOD_PRESSURE = + new HealthSensorProfile("Blood Pressure", 0x1810, 0x2A35, false, + new HealthDataType[] { HealthDataType.BLOOD_PRESSURE }); + + /// Glucose, service `0x1808`. Reports a measurement when the user + /// tests, and can replay stored records -- see + /// [SensorSession#requestStoredRecords(GlucoseRecordFilter)]. + public static final HealthSensorProfile GLUCOSE = + new HealthSensorProfile("Glucose", 0x1808, 0x2A18, false, + new HealthDataType[] { HealthDataType.BLOOD_GLUCOSE }); + + /// The profile's human-readable name. + public String getName() { + return name; + } + + /// The GATT service UUID a conforming device advertises. + public BluetoothUuid getServiceUuid() { + return serviceUuid; + } + + /// The characteristic carrying this profile's measurements. + public BluetoothUuid getMeasurementUuid() { + return measurementUuid; + } + + /// The health data types a session with this profile can produce. + public List getProducedTypes() { + return producedTypes; + } + + /// `true` for profiles that notify continuously -- heart rate, power, + /// cadence. `false` for episodic ones that report a single reading per + /// measurement, such as a scale or a blood-pressure cuff. + /// + /// Worth branching on in UI: a streaming sensor should show a live + /// value, while an episodic one should show "waiting for a + /// measurement" until the user actually takes one. + public boolean isStreaming() { + return streaming; + } + + /// Every profile this framework understands. + public static List values() { + return Collections.unmodifiableList(ALL); + } + + @Override + public String toString() { + return name; + } +} diff --git a/CodenameOne/src/com/codename1/health/sensors/HealthSensors.java b/CodenameOne/src/com/codename1/health/sensors/HealthSensors.java new file mode 100644 index 00000000000..55d440c2606 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/HealthSensors.java @@ -0,0 +1,289 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +import com.codename1.bluetooth.Bluetooth; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.BleScan; +import com.codename1.util.AsyncResult; +import com.codename1.bluetooth.le.ScanFilter; +import com.codename1.bluetooth.le.ScanListener; +import com.codename1.bluetooth.le.ScanResult; +import com.codename1.bluetooth.le.ScanSettings; +import com.codename1.health.HealthError; +import com.codename1.health.HealthException; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// Discovers and streams from standard Bluetooth SIG health sensors -- +/// heart-rate straps, power meters, speed and cadence sensors, foot pods, +/// thermometers, scales, blood-pressure cuffs and glucose meters. +/// +/// Obtain one from [com.codename1.health.Health#getSensors()]. +/// +/// #### This works everywhere Bluetooth LE does +/// +/// Unlike the rest of the health API, this layer needs no platform health +/// store and no per-port implementation: it is built entirely on +/// `com.codename1.bluetooth.le`, so it behaves identically on iOS, +/// Android, the simulator, the desktop ports and -- where the browser +/// exposes Web Bluetooth -- JavaScript. A desktop app that reports +/// [com.codename1.health.HealthAvailability#LOCAL_ONLY] for the store can +/// still stream a chest strap. +/// +/// #### Quick start +/// +/// ```java +/// HealthSensors sensors = Health.getInstance().getSensors(); +/// SensorScanSettings settings = new SensorScanSettings() +/// .addProfile(HealthSensorProfile.HEART_RATE) +/// .setTimeoutMillis(15000); +/// SensorScan scan = sensors.startScan(settings, new SensorDiscoveryListener() { +/// public void sensorDiscovered(HealthSensor sensor) { +/// scan.stop(); +/// sensors.connect(sensor, HealthSensorProfile.HEART_RATE, +/// new SensorSessionOptions()) +/// .onResult((session, err) -> { ... }); +/// } +/// public void scanFailed(HealthException e) { Log.e(e); } +/// }); +/// ``` +/// +/// #### Permissions +/// +/// These are Bluetooth operations, not health-store operations, so they +/// need Bluetooth permission -- `BluetoothPermission.SCAN` and +/// `CONNECT` -- and **not** a HealthKit entitlement or a Health Connect +/// declaration. The build server makes the same distinction: an app that +/// only uses this package is not treated as a health-data app. +public class HealthSensors { + + private final List active = new ArrayList(); + + /// `true` when this device can talk to BLE sensors at all -- that is, + /// when Bluetooth LE is supported by the port and the hardware. + public boolean isSupported() { + return Bluetooth.getInstance().isLeSupported(); + } + + /// Scans for sensors matching `settings`, reporting each discovery to + /// `listener`. + /// + /// Returns a handle even when scanning cannot start; the failure + /// arrives through [SensorDiscoveryListener#scanFailed(HealthException)] + /// rather than as a null return, so callers never need a null check. + public final SensorScan startScan(SensorScanSettings settings, + final SensorDiscoveryListener listener) { + if (listener == null) { + throw new IllegalArgumentException("startScan needs a listener"); + } + if (!isSupported()) { + notifyScanFailed(listener, new HealthException( + HealthError.NOT_SUPPORTED, + "Bluetooth LE is not available on this device")); + return new SensorScan(null); + } + SensorScanSettings s = settings == null + ? new SensorScanSettings() : settings; + final List wanted = s.getProfiles().isEmpty() + ? HealthSensorProfile.values() : s.getProfiles(); + + ScanSettings bleSettings = new ScanSettings(); + for (HealthSensorProfile wantedItem : wanted) { + bleSettings.addFilter(new ScanFilter() + .setServiceUuid(wantedItem.getServiceUuid())); + } + BleScan scan = Bluetooth.getInstance().getLE().startScan(bleSettings, + makeScanListener(wanted, listener)); + // A scan can fail without anything being thrown -- a missing + // BLUETOOTH_SCAN grant, a platform abort -- and that failure + // reaches the caller only through the BleScan. Callers here are + // handed a SensorScan, which deliberately does not expose the + // delegate, so without this the documented scanFailed() callback + // never fired and the scan just quietly was not running. + scan.onResult(new ScanFailure(listener)); + SensorScan out = new SensorScan(scan); + out.scheduleTimeout(s.getTimeoutMillis()); + return out; + } + + /// Translates a failed BLE scan into the health listener's own + /// callback. + /// + /// Named rather than anonymous so it carries no synthetic reference + /// to the enclosing object (SpotBugs + /// `SIC_INNER_SHOULD_BE_STATIC_ANON`). + private static final class ScanFailure + implements AsyncResult { + + private final SensorDiscoveryListener listener; + + ScanFailure(SensorDiscoveryListener listener) { + this.listener = listener; + } + + @Override + public void onReady(Boolean value, Throwable error) { + if (error == null || listener == null) { + return; + } + // Through the same hop the unsupported-BLE path uses. This + // callback runs wherever the scan failed -- the caller's + // thread for a synchronous start failure, the platform's for + // a later onScanFailed -- and SensorDiscoveryListener + // promises the EDT, so a listener that touched the UI here + // would have raced. + notifyScanFailed(listener, error instanceof HealthException + ? (HealthException) error + : new HealthException(HealthError.SENSOR_DISCONNECTED, + "the Bluetooth scan could not be started", + error)); + } + } + + /// Built in a static method so the listener carries no synthetic + /// reference to this object (SpotBugs + /// `SIC_INNER_SHOULD_BE_STATIC_ANON`). + private static ScanListener makeScanListener( + final List wanted, + final SensorDiscoveryListener listener) { + return new ScanListener() { + @Override + public void peripheralDiscovered(ScanResult result) { + HealthSensor sensor = toSensor(result, wanted); + if (sensor != null) { + listener.sensorDiscovered(sensor); + } + } + }; + } + + /// Which of the wanted profiles a discovered device actually + /// advertises. Returns null when it advertises none of them, which + /// happens when a platform scan filter is coarser than requested. + private static HealthSensor toSensor(ScanResult result, + List wanted) { + if (result == null || result.getPeripheral() == null) { + return null; + } + List advertised = result.getAdvertisementData() == null + ? Collections.emptyList() + : result.getAdvertisementData().getServiceUuids(); + List matched = + new ArrayList(); + for (HealthSensorProfile p : wanted) { + if (advertised.contains(p.getServiceUuid())) { + matched.add(p); + } + } + if (matched.isEmpty()) { + return null; + } + BlePeripheral peripheral = result.getPeripheral(); + return new HealthSensor(peripheral.getAddress(), peripheral.getName(), + matched, result.getRssi()); + } + + /// Connects to a discovered sensor and starts streaming. + public final AsyncResource connect(HealthSensor sensor, + HealthSensorProfile profile, SensorSessionOptions options) { + if (sensor == null) { + AsyncResource out = + new AsyncResource(); + out.error(new HealthException(HealthError.INVALID_ARGUMENT, + "connect needs a sensor")); + return out; + } + return connect(sensor.getId(), profile, options); + } + + /// Reconnects to a sensor by the identifier persisted from an earlier + /// session, without scanning first. + /// + /// This is how a fitness app should reconnect to the user's own strap: + /// remember [HealthSensor#getId()] the first time and go straight to + /// it afterwards, rather than making them pick from a list every + /// session. + public final AsyncResource connect(final String sensorId, + final HealthSensorProfile profile, + final SensorSessionOptions options) { + final AsyncResource out = + new AsyncResource(); + if (sensorId == null || profile == null) { + out.error(new HealthException(HealthError.INVALID_ARGUMENT, + "connect needs a sensor id and a profile")); + return out; + } + if (!isSupported()) { + out.error(new HealthException(HealthError.NOT_SUPPORTED, + "Bluetooth LE is not available on this device")); + return out; + } + BlePeripheral peripheral = + Bluetooth.getInstance().getLE().getPeripheral(sensorId); + if (peripheral == null) { + out.error(new HealthException(HealthError.SENSOR_DISCONNECTED, + "no sensor known with id " + sensorId)); + return out; + } + BleSensorSession session = new BleSensorSession(sensorId, profile, + options, peripheral); + synchronized (active) { + active.add(session); + } + session.start(out); + return out; + } + + /// Every session currently connected or reconnecting. + public final List getActiveSessions() { + synchronized (active) { + return new ArrayList(active); + } + } + + /// Forgets a session that has stopped. Called by the session itself. + final void sessionEnded(SensorSession session) { + synchronized (active) { + active.remove(session); + } + } + + /// Built in a static method so the `Runnable` carries no synthetic + /// reference to this object (SpotBugs + /// `SIC_INNER_SHOULD_BE_STATIC_ANON`). + private static void notifyScanFailed( + final SensorDiscoveryListener listener, + final HealthException error) { + com.codename1.ui.Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + listener.scanFailed(error); + } + }); + } +} diff --git a/CodenameOne/src/com/codename1/health/sensors/HeartRateMeasurement.java b/CodenameOne/src/com/codename1/health/sensors/HeartRateMeasurement.java new file mode 100644 index 00000000000..5281ff865c3 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/HeartRateMeasurement.java @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +/// A decoded Heart Rate Measurement, characteristic `0x2A37`. +/// +/// Parsers in this package are public and static so they can be unit +/// tested without a radio and reused by apps doing their own GATT work. +/// +/// #### Wire format +/// +/// Byte 0 is a flags field; the payload that follows is variable-length +/// and entirely determined by it. +/// +/// | Bit | Meaning | +/// |-----|---------| +/// | 0 | value format: 0 = `uint8`, 1 = `uint16` little-endian | +/// | 1 | sensor contact **detected** | +/// | 2 | sensor contact **supported** | +/// | 3 | energy expended field present | +/// | 4 | RR-interval field present | +/// | 5-7 | reserved -- ignored, not asserted zero | +/// +/// #### Three things that are easy to get wrong +/// +/// **Sensor contact.** Bit 1 is meaningful only when bit 2 is set. A strap +/// that does not implement contact detection sends both bits clear, which +/// means *unsupported* -- not *not touching skin*. Reporting "poor contact" +/// for such a device is a very common bug, so +/// [#isSensorContactDetected()] is documented to be read only alongside +/// [#isSensorContactSupported()]. +/// +/// **Energy expended is kilojoules**, not kilocalories, despite most +/// fitness UIs showing kcal. Divide by 4.184. +/// +/// **RR intervals arrive in batches.** The field is not one value but the +/// entire remainder of the payload, as many `uint16` values as fit -- a +/// strap notifying at 1 Hz while the heart beats faster sends two or three +/// per notification. Dropping any of them silently corrupts every +/// heart-rate-variability calculation downstream, so [#getRrIntervalCount()] +/// exists and callers must loop. +public final class HeartRateMeasurement { + + private static final int FLAG_UINT16 = 0x01; + private static final int FLAG_CONTACT_DETECTED = 0x02; + private static final int FLAG_CONTACT_SUPPORTED = 0x04; + private static final int FLAG_ENERGY_EXPENDED = 0x08; + private static final int FLAG_RR_INTERVALS = 0x10; + + /// RR intervals are transmitted in units of 1/1024 second. + private static final double RR_UNIT_MILLIS = 1000.0 / 1024.0; + + private final int heartRate; + private final boolean contactSupported; + private final boolean contactDetected; + private final boolean hasEnergyExpended; + private final int energyExpendedKilojoules; + private final int[] rrIntervalsRaw; + + private HeartRateMeasurement(int heartRate, boolean contactSupported, + boolean contactDetected, boolean hasEnergyExpended, + int energyExpendedKilojoules, int[] rrIntervalsRaw) { + this.heartRate = heartRate; + this.contactSupported = contactSupported; + this.contactDetected = contactDetected; + this.hasEnergyExpended = hasEnergyExpended; + this.energyExpendedKilojoules = energyExpendedKilojoules; + this.rrIntervalsRaw = rrIntervalsRaw; + } + + /// Decodes a `0x2A37` value. + /// + /// Returns `null` for a null, empty, truncated or otherwise malformed + /// payload -- **never throws**. A notification callback is the wrong + /// place to discover that a device sent a short packet, and one + /// misbehaving strap should not take down the app. + public static HeartRateMeasurement parse(byte[] value) { + if (value == null || value.length < 2) { + return null; + } + GattReader r = new GattReader(value); + int flags = r.uint8(); + int bpm = (flags & FLAG_UINT16) != 0 ? r.uint16() : r.uint8(); + + boolean supported = (flags & FLAG_CONTACT_SUPPORTED) != 0; + boolean detected = supported && (flags & FLAG_CONTACT_DETECTED) != 0; + + boolean hasEnergy = (flags & FLAG_ENERGY_EXPENDED) != 0; + int energy = hasEnergy ? r.uint16() : 0; + + int[] rr; + if ((flags & FLAG_RR_INTERVALS) != 0) { + // The count is not transmitted: whatever remains is RR data. + // An odd remainder is a truncated notification, not a run of + // pairs with a spare byte. Rounding down accepted the packet + // and silently dropped half an interval -- which this parser + // exists to feed into an HRV calculation, where a missing beat + // interval is not a rounding error. + if ((r.remaining() & 1) != 0) { + return null; + } + int count = r.remaining() / 2; + rr = new int[count]; + for (int i = 0; i < count; i++) { + rr[i] = r.uint16(); + } + } else { + rr = new int[0]; + } + + if (!r.isValid()) { + return null; + } + return new HeartRateMeasurement(bpm, supported, detected, hasEnergy, + energy, rr); + } + + /// The heart rate in beats per minute. + public int getHeartRate() { + return heartRate; + } + + /// Whether this device reports skin contact at all. When `false`, + /// [#isSensorContactDetected()] carries no information. + public boolean isSensorContactSupported() { + return contactSupported; + } + + /// Whether the sensor is in contact with skin. Only meaningful when + /// [#isSensorContactSupported()] is `true`; always `false` otherwise, + /// which must not be shown to the user as poor contact. + public boolean isSensorContactDetected() { + return contactDetected; + } + + /// Whether this notification carried the energy-expended field. + /// Devices typically include it only every few notifications. + public boolean hasEnergyExpended() { + return hasEnergyExpended; + } + + /// Energy expended since the last reset, in **kilojoules**. Zero when + /// [#hasEnergyExpended()] is false. + /// + /// Reset with [SensorSession#resetEnergyExpended()]. + public int getEnergyExpendedKilojoules() { + return energyExpendedKilojoules; + } + + /// Energy expended since the last reset, in kilocalories -- the + /// kilojoule value divided by 4.184. + public double getEnergyExpendedKilocalories() { + return energyExpendedKilojoules / 4.184; + } + + /// How many RR intervals this notification carried. Often more than + /// one, and zero when the device does not report them. + public int getRrIntervalCount() { + return rrIntervalsRaw.length; + } + + /// RR interval `i` in milliseconds, converted from the wire's + /// 1/1024-second units. + /// + /// #### Throws + /// + /// - `ArrayIndexOutOfBoundsException`: if `i` is outside + /// `[0, getRrIntervalCount())`. + public double getRrIntervalMillis(int i) { + return rrIntervalsRaw[i] * RR_UNIT_MILLIS; + } + + @Override + public String toString() { + return "HeartRateMeasurement[" + heartRate + " bpm, rr=" + + rrIntervalsRaw.length + + (contactSupported ? (contactDetected ? ", contact" + : ", no contact") : "") + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/sensors/Ieee11073.java b/CodenameOne/src/com/codename1/health/sensors/Ieee11073.java new file mode 100644 index 00000000000..59c7760c0eb --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/Ieee11073.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +import com.codename1.util.MathUtil; + +/// Decoders for the two IEEE 11073-20601 floating-point formats used by +/// Bluetooth SIG medical characteristics. +/// +/// The two formats are **not** interchangeable and appear on different +/// characteristics: blood pressure (0x2A35) and glucose (0x2A18) use the +/// 16-bit SFLOAT, while the health thermometer (0x2A1C) uses the 32-bit +/// FLOAT. Decoding one as the other yields plausible-looking nonsense +/// rather than an obvious failure, which is why they live here together +/// and are named for exactly what they are. +/// +/// Both formats reserve values for "not a number", "not at this +/// resolution" and the infinities. A device that cannot obtain a reading +/// sends one of them, and a decoder that ignores that returns a blood +/// pressure of 2047 mmHg. Both methods here map every reserved value to +/// `Double.NaN`, so a caller that checks `isNaN` -- as the parsers in this +/// package do -- cannot propagate one. +final class Ieee11073 { + + private Ieee11073() { + } + + /// Decodes a 16-bit SFLOAT: a signed 4-bit exponent in the top nibble + /// and a signed 12-bit mantissa in the remainder. + /// + /// Returns `Double.NaN` for the five reserved mantissa values + /// (0x07FF NaN, 0x0800 NRes, 0x07FE +INFINITY, 0x0802 -INFINITY, + /// 0x0801 reserved). + static double sfloat(int raw) { + int mantissa = raw & 0x0FFF; + int exponent = (raw >> 12) & 0x0F; + if (mantissa >= 0x07FE && mantissa <= 0x0802) { + return Double.NaN; + } + if (exponent > 7) { + exponent -= 16; + } + if (mantissa > 0x07FF) { + mantissa -= 0x1000; + } + return mantissa * MathUtil.pow(10, exponent); + } + + /// Decodes a 32-bit FLOAT: a signed 8-bit exponent in the top byte and + /// a signed 24-bit mantissa in the remainder. + /// + /// Returns `Double.NaN` for the reserved mantissa values + /// (0x007FFFFF NaN, 0x00800000 NRes, 0x007FFFFE +INFINITY, + /// 0x00800002 -INFINITY, 0x00800001 reserved). + static double float32(long raw) { + int mantissa = (int) (raw & 0x00FFFFFFL); + int exponent = (int) ((raw >> 24) & 0xFF); + if (mantissa >= 0x007FFFFE && mantissa <= 0x00800002) { + return Double.NaN; + } + if (exponent > 127) { + exponent -= 256; + } + if (mantissa > 0x007FFFFF) { + mantissa -= 0x01000000; + } + return mantissa * MathUtil.pow(10, exponent); + } +} diff --git a/CodenameOne/src/com/codename1/health/sensors/RscMeasurement.java b/CodenameOne/src/com/codename1/health/sensors/RscMeasurement.java new file mode 100644 index 00000000000..64991fae696 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/RscMeasurement.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +/// A decoded Running Speed and Cadence Measurement, characteristic +/// `0x2A53`. +/// +/// #### Wire format +/// +/// A flags byte -- bit 0 stride length present, bit 1 total distance +/// present, bit 2 running rather than walking -- then a `uint16` speed and +/// a `uint8` cadence, then the optional fields. +/// +/// #### Cadence is in steps per minute, not strides +/// +/// The profile transmits **steps**, so a runner at a typical 90 strides +/// per minute reports 180. Foot pods differ in which they display, and +/// halving the value to get strides is a decision for your UI, not +/// something this parser does silently. [#getStrideRatePerMinute()] is +/// provided for when you want the other convention explicitly. +/// +/// Speed arrives in 1/256 m/s and total distance in tenths of a metre. +public final class RscMeasurement { + + private static final int FLAG_STRIDE_LENGTH = 0x01; + private static final int FLAG_TOTAL_DISTANCE = 0x02; + private static final int FLAG_RUNNING = 0x04; + + private static final double SPEED_RESOLUTION = 1.0 / 256.0; + private static final double STRIDE_RESOLUTION_M = 0.01; + private static final double DISTANCE_RESOLUTION_M = 0.1; + + private final double speedMetersPerSecond; + private final int cadenceStepsPerMinute; + private final double strideLengthMeters; + private final double totalDistanceMeters; + private final boolean running; + + private RscMeasurement(double speedMetersPerSecond, + int cadenceStepsPerMinute, double strideLengthMeters, + double totalDistanceMeters, boolean running) { + this.speedMetersPerSecond = speedMetersPerSecond; + this.cadenceStepsPerMinute = cadenceStepsPerMinute; + this.strideLengthMeters = strideLengthMeters; + this.totalDistanceMeters = totalDistanceMeters; + this.running = running; + } + + /// Decodes a `0x2A53` value. Returns `null` for a malformed or + /// truncated payload; never throws. + public static RscMeasurement parse(byte[] value) { + if (value == null || value.length < 4) { + return null; + } + GattReader r = new GattReader(value); + int flags = r.uint8(); + double speed = r.uint16() * SPEED_RESOLUTION; + int cadence = r.uint8(); + + double stride = Double.NaN; + if ((flags & FLAG_STRIDE_LENGTH) != 0) { + stride = r.uint16() * STRIDE_RESOLUTION_M; + } + double distance = Double.NaN; + if ((flags & FLAG_TOTAL_DISTANCE) != 0) { + distance = r.uint32() * DISTANCE_RESOLUTION_M; + } + + if (!r.isValid()) { + return null; + } + return new RscMeasurement(speed, cadence, stride, distance, + (flags & FLAG_RUNNING) != 0); + } + + /// Instantaneous speed in metres per second. + public double getSpeedMetersPerSecond() { + return speedMetersPerSecond; + } + + /// Cadence in **steps** per minute, as transmitted. + public int getCadenceStepsPerMinute() { + return cadenceStepsPerMinute; + } + + /// Cadence expressed in strides per minute -- half the step rate. + /// Provided so the conversion is explicit at the call site rather than + /// guessed. + public double getStrideRatePerMinute() { + return cadenceStepsPerMinute / 2.0; + } + + /// Stride length in metres, or `Double.NaN` when absent. + public double getStrideLengthMeters() { + return strideLengthMeters; + } + + /// Cumulative distance in metres since the pod powered on, or + /// `Double.NaN` when absent. + public double getTotalDistanceMeters() { + return totalDistanceMeters; + } + + /// `true` when the pod classified the motion as running rather than + /// walking. + public boolean isRunning() { + return running; + } + + @Override + public String toString() { + return "RscMeasurement[" + speedMetersPerSecond + " m/s, " + + cadenceStepsPerMinute + " spm]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/sensors/SensorDiscoveryListener.java b/CodenameOne/src/com/codename1/health/sensors/SensorDiscoveryListener.java new file mode 100644 index 00000000000..756f349a2c4 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/SensorDiscoveryListener.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +import com.codename1.health.HealthException; + +/// Receives sensors found during a scan. Callbacks arrive on the EDT. +public interface SensorDiscoveryListener { + + /// A health sensor was discovered. May be called more than once for + /// the same device as its signal strength changes. + void sensorDiscovered(HealthSensor sensor); + + /// The scan could not start, or ended in error. + void scanFailed(HealthException error); +} diff --git a/CodenameOne/src/com/codename1/health/sensors/SensorSampleListener.java b/CodenameOne/src/com/codename1/health/sensors/SensorSampleListener.java new file mode 100644 index 00000000000..a29de6f2f56 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/SensorSampleListener.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +import com.codename1.health.HealthException; +import com.codename1.health.HealthSample; + +/// Receives measurements and state changes from a [SensorSession]. All +/// callbacks arrive on the EDT. +public interface SensorSampleListener { + + /// A new measurement arrived, already decoded into a health sample in + /// its canonical unit. + /// + /// A single notification can produce more than one sample -- a cycling + /// power meter reports power and cadence together -- so this is called + /// once per sample, not once per notification. + void sensorSample(SensorSession session, HealthSample sample); + + /// The session changed state. + void sensorStateChanged(SensorSession session, SensorSessionState state); + + /// An error occurred. The session may still recover if its state is + /// [SensorSessionState#RECONNECTING]. + void sensorError(SensorSession session, HealthException error); +} diff --git a/CodenameOne/src/com/codename1/health/sensors/SensorScan.java b/CodenameOne/src/com/codename1/health/sensors/SensorScan.java new file mode 100644 index 00000000000..d472f34900b --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/SensorScan.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +import com.codename1.bluetooth.le.BleScan; + +/// A running sensor scan. Stop it as soon as the user has chosen a device +/// -- scanning is expensive and both platforms throttle apps that leave it +/// running. +public final class SensorScan { + + private final BleScan delegate; + private java.util.Timer timeout; + private boolean stopped; + + /// Wraps an underlying BLE scan. Created by [HealthSensors]. + SensorScan(BleScan delegate) { + this.delegate = delegate; + } + + /// Stops the scan automatically after `millis`. + /// + /// A BLE scan is one of the most expensive things an app can leave + /// running, and neither [com.codename1.bluetooth.le.BleScan] nor the + /// platform stops one on its own. Without this the documented + /// [SensorScanSettings#getTimeoutMillis()] -- including its default -- + /// did nothing, and a scan ran until the app was killed. + void scheduleTimeout(long millis) { + if (millis <= 0) { + return; + } + // CLDC's Timer has no named/daemon constructor. + timeout = new java.util.Timer(); + timeout.schedule(new StopTask(this), millis); + } + + /// Stops the scan. Idempotent. + public void stop() { + if (stopped) { + return; + } + stopped = true; + if (timeout != null) { + timeout.cancel(); + timeout = null; + } + if (delegate != null) { + delegate.stop(); + } + } + + /// Named rather than anonymous so the timer holds no synthetic + /// reference beyond the scan it stops. + private static final class StopTask extends java.util.TimerTask { + private final SensorScan scan; + + StopTask(SensorScan scan) { + this.scan = scan; + } + + @Override + public void run() { + scan.stop(); + } + } + + /// `true` until [#stop()] is called or the scan times out. + public boolean isScanning() { + return !stopped && delegate != null && delegate.isActive(); + } +} diff --git a/CodenameOne/src/com/codename1/health/sensors/SensorScanSettings.java b/CodenameOne/src/com/codename1/health/sensors/SensorScanSettings.java new file mode 100644 index 00000000000..491e71db838 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/SensorScanSettings.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// Which sensors to look for and for how long. +public final class SensorScanSettings { + + private final List profiles = + new ArrayList(); + private int timeoutMillis = 15000; + private boolean lowPower; + + /// Looks for devices advertising this profile. Add several to scan for + /// more than one kind at once; adding none scans for every profile + /// this framework understands. + public SensorScanSettings addProfile(HealthSensorProfile profile) { + if (profile != null && !profiles.contains(profile)) { + profiles.add(profile); + } + return this; + } + + /// The profiles being scanned for, empty meaning all of them. + public List getProfiles() { + return Collections.unmodifiableList(profiles); + } + + /// Stops the scan after this many milliseconds. Defaults to 15 + /// seconds. + /// + /// A bounded scan is not a convenience: BLE scanning is one of the + /// most expensive things an app can do to a phone's battery, and both + /// platforms throttle or silently stop an app that scans + /// indefinitely. + public SensorScanSettings setTimeoutMillis(int timeoutMillis) { + this.timeoutMillis = timeoutMillis; + return this; + } + + /// The scan timeout in milliseconds. + public int getTimeoutMillis() { + return timeoutMillis; + } + + /// Scans at a lower duty cycle, trading discovery latency for battery. + /// Worth setting when scanning in the background or for a long time. + public SensorScanSettings setLowPower(boolean lowPower) { + this.lowPower = lowPower; + return this; + } + + /// `true` when a low-power scan was requested. + public boolean isLowPower() { + return lowPower; + } +} diff --git a/CodenameOne/src/com/codename1/health/sensors/SensorSession.java b/CodenameOne/src/com/codename1/health/sensors/SensorSession.java new file mode 100644 index 00000000000..b40a37f9a56 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/SensorSession.java @@ -0,0 +1,837 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +import com.codename1.health.BloodPressureSample; +import com.codename1.health.HealthDataType; +import com.codename1.health.HealthError; +import com.codename1.health.Health; +import com.codename1.health.workout.WorkoutSession; +import com.codename1.health.HealthException; +import com.codename1.health.HealthQuantity; +import com.codename1.health.HealthSample; +import com.codename1.health.HealthStore; +import com.codename1.health.HealthWriteResult; +import com.codename1.health.HealthUnit; +import com.codename1.health.QuantitySample; +import com.codename1.health.RecordingMethod; +import com.codename1.health.SeriesSample; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// A live connection to one health sensor, decoding its notifications into +/// health samples. +/// +/// Obtained from +/// [HealthSensors#connect(HealthSensor,HealthSensorProfile,SensorSessionOptions)]. +/// All listener callbacks arrive on the EDT. +/// +/// #### Derived values +/// +/// Speed and cadence sensors transmit cumulative counters rather than +/// rates, so this class differences consecutive notifications -- handling +/// the counter and event-timer wraps that would otherwise produce spikes +/// of tens of thousands of rpm. See [CumulativeCounterTracker]. +public class SensorSession { + + private final String sensorId; + private final HealthSensorProfile profile; + private final SensorSessionOptions options; + private final List pendingWrites = + new ArrayList(); + private java.util.Timer flushTimer; + private final List listeners = + new ArrayList(); + private final Map latest = + new HashMap(); + + private final CumulativeCounterTracker wheelTracker = + new CumulativeCounterTracker(0x100000000L, 1024); + private final CumulativeCounterTracker crankTracker = + new CumulativeCounterTracker(0x10000L, 1024); + + /// Guards the cross-thread session fields. They are written on the + /// EDT and read from the flush timer's thread and from store + /// callbacks; without publication those threads could go on seeing a + /// running session after it had ended. + /// + /// A lock rather than `volatile`, which this codebase does not use. + private final Object stateLock = new Object(); + /// Guarded by [#stateLock]. + private SensorSessionState state = SensorSessionState.CONNECTING; + /// All three set as notifications decode -- on whichever thread the + /// transport delivers on -- and read by the app through the getters; + /// `streamed` also decides whether a reconnect is worth attempting. + /// Guarded by [#stateLock], found by going through the package for + /// the rest of this shape rather than waiting for each to be + /// reported. + private boolean streamed; + private Integer batteryPercent; + private int bodySensorLocation = -1; + + /// Ports and [HealthSensors] construct sessions. + protected SensorSession(String sensorId, HealthSensorProfile profile, + SensorSessionOptions options) { + this.sensorId = sensorId; + this.profile = profile; + this.options = options == null ? new SensorSessionOptions() : options; + } + + /// The stable identifier of the connected device -- see + /// [HealthSensor#getId()]. + public final String getSensorId() { + return sensorId; + } + + /// The profile this session is speaking. + public final HealthSensorProfile getProfile() { + return profile; + } + + /// The options this session was created with. + public final SensorSessionOptions getOptions() { + return options; + } + + /// The current lifecycle state. + public final SensorSessionState getState() { + synchronized (stateLock) { + return state; + } + } + + /// Registers a listener for measurements and state changes. + public final void addListener(SensorSampleListener listener) { + if (listener == null) { + return; + } + synchronized (listeners) { + if (!listeners.contains(listener)) { + listeners.add(listener); + } + } + } + + /// Removes a previously registered listener. + public final void removeListener(SensorSampleListener listener) { + synchronized (listeners) { + listeners.remove(listener); + } + } + + /// The most recent sample of `type`, or `null` when none has arrived + /// or the last one is older than + /// [SensorSessionOptions#getStaleSampleMillis()]. + /// + /// Returning null rather than a stale value is deliberate: a UI bound + /// to this shows a dash when the strap falls off, instead of + /// continuing to display the wearer's last heart rate as though it + /// were current. + public final HealthSample getLatest(HealthDataType type) { + if (type == null) { + return null; + } + HealthSample s; + synchronized (latest) { + s = latest.get(type.getId()); + } + if (s == null) { + return null; + } + long age = System.currentTimeMillis() - s.getEndMillis(); + return age > options.getStaleSampleMillis() ? null : s; + } + + /// The device's battery level as a percentage, or `null` when it does + /// not report one. + public final Integer getBatteryPercent() { + synchronized (stateLock) { + return batteryPercent; + } + } + + /// Where a heart-rate sensor is worn, as a [BodySensorLocation] + /// constant, or `-1` when unreported. + public final int getBodySensorLocation() { + synchronized (stateLock) { + return bodySensorLocation; + } + } + + /// Resets a heart-rate strap's accumulated energy-expended counter, by + /// writing `0x01` to the Heart Rate Control Point (`0x2A39`). + /// + /// Fails with [HealthError#NOT_SUPPORTED] on other profiles and on + /// straps that do not expose the control point. + public AsyncResource resetEnergyExpended() { + AsyncResource out = new AsyncResource(); + out.error(new HealthException(HealthError.NOT_SUPPORTED, + "this session does not support resetting energy expended")); + return out; + } + + /// Asks a glucose meter to replay stored records, delivering each one + /// through the normal sample listeners, and resolving with how many + /// were retrieved. + /// + /// **Not implemented in this release: this always fails with + /// [HealthError#NOT_SUPPORTED], on the glucose profile as well as any + /// other.** Replay runs over the Record Access Control Point, a + /// stateful request/response protocol on a second characteristic with + /// its own operators, filters and abort semantics, and shipping it + /// untested against real meters would be worse than saying plainly + /// that it is absent. [GlucoseRecordFilter] and the rest of the + /// vocabulary are here so the call site does not have to change when + /// it lands. + /// + /// Live glucose notifications are unaffected and work today. + public AsyncResource requestStoredRecords( + GlucoseRecordFilter filter) { + AsyncResource out = new AsyncResource(); + out.error(new HealthException(HealthError.NOT_SUPPORTED, + "replaying stored glucose records is not implemented in" + + " this release; live measurements still arrive" + + " through the sample listeners")); + return out; + } + + /// Disconnects and stops delivering measurements. Idempotent. + public void stop() { + setState(SensorSessionState.STOPPED); + } + + /// Drops this session from [HealthSensors#getActiveSessions()]. + /// + /// Without it a stopped or failed session stayed in the registry for + /// the manager's lifetime, so the list documented as "connected or + /// reconnecting" filled up with dead sessions and kept their + /// peripherals and listeners alive with them. + protected final void forgetFromManager() { + Health.getInstance().getSensors().sessionEnded(this); + } + + // ------------------------------------------------------------------ + // decoding -- shared by every transport + // ------------------------------------------------------------------ + + /// Decodes one raw characteristic value and emits the resulting + /// samples. Called by the transport when a notification arrives. + /// + /// A malformed payload emits nothing and reports + /// [HealthError#INVALID_DATA] rather than throwing: one misbehaving + /// device must not take down the app. + protected final void onMeasurement(byte[] value, long receivedAtMillis) { + List samples = decode(value, receivedAtMillis); + if (samples == null) { + fireError(new HealthException(HealthError.INVALID_DATA, + profile.getName() + " sensor sent a malformed " + + "measurement")); + return; + } + for (HealthSample s : samples) { + synchronized (latest) { + latest.put(s.getType().getId(), s); + } + fireSample(s); + } + route(samples); + } + + /// Sends decoded samples wherever the options said they should go. + /// + /// Both destinations were configurable and neither was read, so an + /// attached workout never saw a single reading and a write-through + /// session persisted nothing while reporting success. + private void route(List samples) { + if (samples.isEmpty()) { + return; + } + WorkoutSession workout = options.getWorkoutSession(); + if (workout != null) { + workout.addSamples(samples); + } + if (!options.isWriteToStore()) { + return; + } + // Batched rather than written per notification: a strap notifies + // about once a second, and a store round trip per reading would + // spend more time in the platform than in the app. + boolean startTimer = false; + synchronized (pendingWrites) { + startTimer = pendingWrites.isEmpty(); + pendingWrites.addAll(samples); + } + if (startTimer) { + // Armed when the batch opens rather than checked when the next + // measurement arrives. A scale or a blood-pressure cuff sends + // one reading and then stays quiet, so a deadline that only + // advances on the next notification never fires and the reading + // is written only if the app happens to call stop() -- losing it + // outright if the process exits first. + scheduleFlush(options.getStoreBatchMillis()); + } + } + + private void scheduleFlush(int delayMillis) { + cancelFlush(); + // CLDC's Timer has no named/daemon constructor. + java.util.Timer t = new java.util.Timer(); + synchronized (pendingWrites) { + flushTimer = t; + } + t.schedule(new FlushTask(this), Math.max(1, delayMillis)); + } + + private void cancelFlush() { + java.util.Timer t; + synchronized (pendingWrites) { + t = flushTimer; + flushTimer = null; + } + if (t != null) { + t.cancel(); + } + } + + /// Named rather than anonymous so the timer holds no synthetic + /// reference beyond the session it flushes. + private static final class FlushTask extends java.util.TimerTask { + private final SensorSession session; + + FlushTask(SensorSession session) { + this.session = session; + } + + @Override + public void run() { + session.flushIfRunning(); + } + } + + /// True once the session has ended. Guarded by `pendingWrites`, not + /// by being volatile, because the point is not visibility but + /// atomicity: a timer tick has to decide whether to claim the buffer + /// in the same breath as the session decides it has stopped. + private boolean flushingStopped; + + /// The timer's flush: claims the buffer only while the session runs. + /// + /// Checking the state at the top of the tick was not enough. The + /// check passed, the session ended, and the write went out anyway -- + /// one write from a session nobody holds a handle to, every time the + /// end landed inside that gap. The check and the claim are one + /// critical section now, and ending the session takes the same lock, + /// so a batch is either claimed while the session is still running or + /// never claimed at all. + private void flushIfRunning() { + cancelFlush(); + List batch; + synchronized (pendingWrites) { + if (flushingStopped || pendingWrites.isEmpty()) { + return; + } + batch = new ArrayList(pendingWrites); + pendingWrites.clear(); + } + writeBatch(batch); + } + + /// Writes anything still buffered. Called when the session stops so a + /// short ride does not lose its last partial batch. + protected final void flushPendingWrites() { + cancelFlush(); + List batch; + synchronized (pendingWrites) { + if (pendingWrites.isEmpty()) { + return; + } + batch = new ArrayList(pendingWrites); + pendingWrites.clear(); + } + writeBatch(batch); + } + + /// Sends a claimed batch to the store. + /// + /// The samples stay accounted for until the store confirms them. + /// Removing them from the buffer and ignoring the result meant a + /// revoked permission or an unavailable provider silently discarded a + /// scale or cuff reading the caller explicitly asked to persist. + private void writeBatch(List batch) { + Health.getInstance().getStore().write(batch) + .onResult(new WriteBack(this, batch)); + } + + /// Puts a failed batch back and tells the app it failed. + private static final class WriteBack + implements com.codename1.util.AsyncResult { + private final SensorSession session; + private final List batch; + + WriteBack(SensorSession session, List batch) { + this.session = session; + this.batch = batch; + } + + /// The tail of `batch` that the failed write did not commit. + /// + /// [HealthException#getPartialResult()] names the records that + /// went in, and the store writes chunks in order, so the + /// committed ones are a prefix. Counted in records rather than + /// samples because a series is one sample and many records. + /// + /// A series straddling the boundary is retried whole and may + /// duplicate the part of it that landed. That is the same trade + /// the write path already makes for one sample, rather than the + /// alternative of dropping measurements that were never stored. + private static List uncommitted( + List batch, Throwable error) { + if (!(error instanceof HealthException)) { + return batch; + } + HealthWriteResult partial = + ((HealthException) error).getPartialResult(); + if (partial == null) { + return batch; + } + int committed = partial.getSampleIds().size(); + if (committed <= 0) { + return batch; + } + int records = 0; + int from = 0; + for (int i = 0; i < batch.size(); i++) { + HealthSample s = batch.get(i); + records += s instanceof SeriesSample + ? ((SeriesSample) s).size() : 1; + if (records > committed) { + break; + } + from = i + 1; + } + return new ArrayList( + batch.subList(from, batch.size())); + } + + @Override + public void onReady(HealthWriteResult value, Throwable error) { + if (error == null) { + return; + } + // Samples of a type the store will never accept are dropped + // rather than requeued. An Android cycling-power or cadence + // session produces types Health Connect can read but not + // write, so every batch failed validation and the retry below + // resent the identical batch for as long as the session + // streamed -- an error every storeBatchMillis, for ever, and + // a buffer that only grew. Retrying is right for a store that + // is busy or locked; it is pointless for one that has said + // no, and the answer does not change with time. + // Whatever a partly-successful write already committed is + // not sent again. A buffered batch larger than the platform's + // chunk can fail on a later chunk with the earlier ones + // already in the store, and requeuing the whole batch wrote + // those a second time -- duplicate records in the user's + // health data and every aggregate over them inflated, for as + // long as the session kept streaming. + List outstanding = uncommitted(batch, error); + List retryable = new ArrayList( + outstanding.size()); + List refused = new ArrayList(); + HealthStore store = Health.getInstance().getStore(); + for (HealthSample s : outstanding) { + if (store.isWritable(s.getType())) { + retryable.add(s); + } else if (!refused.contains(s.getType())) { + refused.add(s.getType()); + } + } + if (!refused.isEmpty()) { + com.codename1.io.Log.p("CN1 Health: this platform cannot" + + " write " + refused + ", so those sensor samples" + + " are dropped rather than retried. Turn" + + " setWriteToStore off for this session, or route" + + " the readings to a workout instead."); + } + if (retryable.isEmpty() || session.isTerminal()) { + // Nothing is requeued once the session has ended. It + // could never be flushed by the session itself -- the + // re-arm below is guarded and the timer is cancelled -- + // so it only sat there waiting for something else to + // drain it, and something else did: endSession() flushes + // unconditionally and has more than one caller, so the + // failed final write refilled the buffer and the next + // teardown wrote it again. That is the "issued one more + // write" failure, and it survived both the state check + // and making the claim atomic because neither of them is + // on this path. + session.fireError(asHealthException(error)); + return; + } + synchronized (session.pendingWrites) { + session.pendingWrites.addAll(0, retryable); + } + // Re-armed explicitly, but only while the session is still + // running. Rescheduling from a session that has ended -- + // where a permanent failure like a revoked permission or an + // unavailable store lands -- retried forever, keeping the + // dead session alive and firing store writes and errors after + // shutdown. + // + // Both terminal states, not just STOPPED: the reconnect + // ladder retires a session as FAILED, which passed a + // stopped-only check and left exactly the session nobody + // holds a handle to retrying on a timer. + if (!session.isTerminal()) { + session.scheduleFlush(session.getOptions() + .getStoreBatchMillis()); + } + session.fireError(asHealthException(error)); + } + + private static HealthException asHealthException(Throwable error) { + return error instanceof HealthException + ? (HealthException) error + : new HealthException(HealthError.UNKNOWN, + "could not persist sensor samples", error); + } + } + + /// Whether this session has ended, however it ended. + /// + /// `STOPPED` is the caller asking; `FAILED` is the framework giving + /// up. Nothing may be scheduled from either -- the session is gone + /// from the manager's registry, so a timer armed here is one nobody + /// can cancel. + final boolean isTerminal() { + SensorSessionState s = getState(); + return s == SensorSessionState.STOPPED + || s == SensorSessionState.FAILED; + } + + /// Decodes a payload into zero or more samples, or `null` when the + /// payload is malformed. + /// + /// A single notification can yield several samples: a cycling power + /// meter reports power and crank data in one packet, from which both + /// power and cadence are derived. + @SuppressWarnings("PMD.CompareObjectsWithEquals") + private List decode(byte[] value, long at) { + List out = new ArrayList(2); + if (profile == HealthSensorProfile.HEART_RATE) { + HeartRateMeasurement m = HeartRateMeasurement.parse(value); + if (m == null) { + return null; + } + // Only the rate becomes a sample. RR intervals are decoded by + // the parser and have no health data type to be a sample of -- + // they are the input to HRV, not a measurement of it, and + // inventing a type for them would put a number in the store + // that no platform can read back. An app that needs them + // subscribes to 0x2A37 through com.codename1.bluetooth.le and + // calls HeartRateMeasurement.parse itself; both are public, + // and SensorSessionOptions#setWriteToStore documents the + // route. See HeartRateMeasurement#getRrIntervalCount(). + out.add(quantity(HealthDataType.HEART_RATE, m.getHeartRate(), + HealthUnit.COUNT_PER_MINUTE, at)); + return out; + } + if (profile == HealthSensorProfile.CYCLING_POWER) { + CyclingPowerMeasurement m = CyclingPowerMeasurement.parse(value); + if (m == null) { + return null; + } + out.add(quantity(HealthDataType.POWER, + m.getInstantaneousPowerWatts(), HealthUnit.WATT, at)); + if (m.hasCrankData()) { + double rpm = crankTracker.update(m.getCrankRevolutions(), + m.getLastCrankEventTime()); + if (!Double.isNaN(rpm)) { + out.add(quantity(HealthDataType.CYCLING_CADENCE, rpm, + HealthUnit.COUNT_PER_MINUTE, at)); + } + } + return out; + } + if (profile == HealthSensorProfile.CYCLING_SPEED_CADENCE) { + CscMeasurement m = CscMeasurement.parse(value); + if (m == null) { + return null; + } + if (m.hasCrankData()) { + double rpm = crankTracker.update(m.getCrankRevolutions(), + m.getLastCrankEventTime()); + if (!Double.isNaN(rpm)) { + out.add(quantity(HealthDataType.CYCLING_CADENCE, rpm, + HealthUnit.COUNT_PER_MINUTE, at)); + } + } + if (m.hasWheelData()) { + // The wheel rate is deliberately not published. It is not + // cadence -- cadence is the crank, reported in the block + // below -- and a combination sensor sending both would + // otherwise emit two CYCLING_CADENCE samples per + // notification with very different values. Turning it into + // speed or distance needs a wheel circumference we do not + // have, and guessing the tyre size scales every distance + // the app ever reports. + wheelTracker.update(m.getWheelRevolutions(), + m.getLastWheelEventTime()); + } + return out; + } + if (profile == HealthSensorProfile.RUNNING_SPEED_CADENCE) { + RscMeasurement m = RscMeasurement.parse(value); + if (m == null) { + return null; + } + out.add(quantity(HealthDataType.SPEED, + m.getSpeedMetersPerSecond(), HealthUnit.METER_PER_SECOND, + at)); + out.add(quantity(HealthDataType.RUNNING_CADENCE, + m.getCadenceStepsPerMinute(), HealthUnit.COUNT_PER_MINUTE, + at)); + return out; + } + if (profile == HealthSensorProfile.HEALTH_THERMOMETER) { + TemperatureMeasurement m = TemperatureMeasurement.parse(value); + if (m == null) { + return null; + } + out.add(quantity(HealthDataType.BODY_TEMPERATURE, m.getCelsius(), + HealthUnit.DEGREE_CELSIUS, + m.getTimestampMillis() > 0 ? m.getTimestampMillis() : at)); + return out; + } + if (profile == HealthSensorProfile.WEIGHT_SCALE) { + WeightMeasurement m = WeightMeasurement.parse(value); + if (m == null) { + return null; + } + out.add(quantity(HealthDataType.BODY_MASS, m.getWeightKg(), + HealthUnit.KILOGRAM, + m.getTimestampMillis() > 0 ? m.getTimestampMillis() : at)); + return out; + } + if (profile == HealthSensorProfile.BLOOD_PRESSURE) { + BloodPressureMeasurement m = + BloodPressureMeasurement.parse(value); + if (m == null) { + return null; + } + long when = m.getTimestampMillis() > 0 + ? m.getTimestampMillis() : at; + BloodPressureSample s = BloodPressureSample.create( + m.getSystolicMmHg(), m.getDiastolicMmHg(), when); + if (m.hasPulse()) { + s.setPulse(new HealthQuantity(m.getPulseBpm(), + HealthUnit.COUNT_PER_MINUTE)); + } + s.setRecordingMethod(RecordingMethod.AUTOMATIC); + out.add(s); + return out; + } + if (profile == HealthSensorProfile.GLUCOSE) { + GlucoseMeasurement m = GlucoseMeasurement.parse(value); + if (m == null) { + return null; + } + if (!m.hasConcentration() || m.isControlSolution()) { + // A failed test, or a calibration against control fluid. + // Neither is the user's blood glucose and neither belongs + // in their record. + return out; + } + long when = m.getTimestampMillis() > 0 + ? m.getTimestampMillis() : at; + out.add(quantity(HealthDataType.BLOOD_GLUCOSE, + m.getMillimolesPerLiter(), HealthUnit.MILLIMOLE_PER_LITER, + when)); + return out; + } + return out; + } + + private static QuantitySample quantity(HealthDataType type, double value, + HealthUnit unit, long at) { + QuantitySample s = QuantitySample.create(type, + new HealthQuantity(value, unit), at); + s.setRecordingMethod(RecordingMethod.AUTOMATIC); + return s; + } + + // ------------------------------------------------------------------ + // event plumbing + // ------------------------------------------------------------------ + + /// Records the device's battery level. Called by the transport. + protected final void setBatteryPercent(Integer percent) { + synchronized (stateLock) { + this.batteryPercent = percent; + } + } + + /// Records where a heart-rate sensor is worn. Called by the transport. + protected final void setBodySensorLocation(int location) { + synchronized (stateLock) { + this.bodySensorLocation = location; + } + } + + /// Forgets the cumulative-counter baselines, so the next notification + /// establishes a new one. Called by the transport on reconnect: a + /// sensor that power-cycled restarts its counters from zero. + protected final void resetCounters() { + wheelTracker.reset(); + crankTracker.reset(); + } + + /// Moves the session to a new state and notifies listeners. + /// True once this session has actually streamed. + /// + /// A failed initial connect also publishes DISCONNECTED, and + /// reconnecting from that resurrects a session the caller was already + /// told had failed. + protected final boolean hasStreamed() { + synchronized (stateLock) { + return streamed; + } + } + + protected final void setState(SensorSessionState newState) { + synchronized (stateLock) { + if (newState == SensorSessionState.STREAMING) { + streamed = true; + } + if (newState == null || newState == state) { + return; + } + state = newState; + } + // Everything below runs outside the lock. The teardown it + // triggers takes the buffer's monitor and the listener snapshot, + // and holding this one across them would order the two locks + // against every other path that touches both. + if (isTerminal()) { + // Nothing may be scheduled from a terminal state, and that + // includes what was scheduled just before reaching one: the + // session is gone from the manager's registry, so a timer + // left armed is one nobody can cancel. + // + // Under the buffer's lock so a timer tick cannot be midway + // through claiming it -- see flushIfRunning. The explicit + // flush endSession() performs next does not consult this + // flag, so a short ride still keeps its final partial batch. + synchronized (pendingWrites) { + flushingStopped = true; + } + cancelFlush(); + } + Object[] snapshot = listenerSnapshot(); + if (snapshot != null) { + Display.getInstance().callSerially( + makeStateRunnable(this, snapshot, newState)); + } + } + + /// Delivers a sample to listeners on the EDT. + protected final void fireSample(HealthSample sample) { + Object[] snapshot = listenerSnapshot(); + if (snapshot != null) { + Display.getInstance().callSerially( + makeSampleRunnable(this, snapshot, sample)); + } + } + + /// Delivers an error to listeners on the EDT. + protected final void fireError(HealthException error) { + Object[] snapshot = listenerSnapshot(); + if (snapshot != null) { + Display.getInstance().callSerially( + makeErrorRunnable(this, snapshot, error)); + } + } + + private Object[] listenerSnapshot() { + synchronized (listeners) { + if (listeners.isEmpty()) { + return null; + } + return listeners.toArray(); + } + } + + // The dispatch runnables are built in static methods so they carry no + // synthetic reference to the enclosing session (SpotBugs + // SIC_INNER_SHOULD_BE_STATIC_ANON). + + private static Runnable makeSampleRunnable(final SensorSession session, + final Object[] listeners, final HealthSample sample) { + return new Runnable() { + @Override + public void run() { + for (Object listener : listeners) { + ((SensorSampleListener) listener) + .sensorSample(session, sample); + } + } + }; + } + + private static Runnable makeStateRunnable(final SensorSession session, + final Object[] listeners, final SensorSessionState state) { + return new Runnable() { + @Override + public void run() { + for (Object listener : listeners) { + ((SensorSampleListener) listener) + .sensorStateChanged(session, state); + } + } + }; + } + + private static Runnable makeErrorRunnable(final SensorSession session, + final Object[] listeners, final HealthException error) { + return new Runnable() { + @Override + public void run() { + for (Object listener : listeners) { + ((SensorSampleListener) listener) + .sensorError(session, error); + } + } + }; + } + + @Override + public String toString() { + return "SensorSession[" + profile.getName() + " " + sensorId + " " + + state + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/sensors/SensorSessionOptions.java b/CodenameOne/src/com/codename1/health/sensors/SensorSessionOptions.java new file mode 100644 index 00000000000..8a87d1a10f9 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/SensorSessionOptions.java @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +import com.codename1.health.workout.WorkoutSession; + +/// How a [SensorSession] should behave: reconnection, where samples go, +/// and how stale a cached reading may be. +public final class SensorSessionOptions { + + private boolean autoReconnect = true; + private boolean writeToStore; + private int storeBatchMillis = 60000; + private WorkoutSession workoutSession; + private int staleSampleMillis = 10000; + + /// Whether the session re-establishes a dropped link on its own. + /// Defaults to `true`, which is almost always right -- chest straps + /// drop out routinely as the wearer moves. + public SensorSessionOptions setAutoReconnect(boolean autoReconnect) { + this.autoReconnect = autoReconnect; + return this; + } + + /// `true` when the session reconnects automatically. + public boolean isAutoReconnect() { + return autoReconnect; + } + + /// Whether received samples are written through to the platform health + /// store. **Defaults to `false`, and that default is deliberate.** + /// + /// During a workout on watchOS -- and on iOS 26 and later -- the + /// operating system records heart rate into HealthKit itself. A strap + /// that also writes its own heart-rate samples then produces two + /// overlapping sets for the same minutes, and every downstream + /// average, maximum and chart is computed over double-counted data. + /// + /// This release drives no live workout session, so that overlap comes + /// from a workout the user started elsewhere -- in Apple's own Workout + /// app, say -- rather than from one of yours. The default stays + /// `false` regardless: turning it on is a decision about somebody + /// else's data as much as your own. + /// + /// If you are recording a workout, attach the session to it with + /// [#setWorkoutSession(WorkoutSession)] instead: samples land in the + /// workout's statistics and are persisted once, as part of the + /// workout, when it ends. + /// + /// Turning this on is right when you are logging a standalone + /// measurement the OS knows nothing about -- a weight from a scale, a + /// body temperature from a thermometer, a glucose reading from a + /// meter. + /// + /// **Blood pressure is not among them on mobile in this release.** It + /// is two values in one reading, which HealthKit models as a + /// correlation and Health Connect as its own record type, and neither + /// is implemented here -- the sample line this API writes over carries + /// a single value. A cuff reading routed to the store therefore fails + /// with [HealthError#TYPE_NOT_SUPPORTED] on both platforms rather than + /// being quietly dropped, and the local and simulator stores keep it + /// fine. Read it off the session and persist it yourself until the + /// correlation paths land. + public SensorSessionOptions setWriteToStore(boolean writeToStore) { + this.writeToStore = writeToStore; + return this; + } + + /// `true` when samples are written through to the health store. + public boolean isWriteToStore() { + return writeToStore; + } + + /// How long samples are batched before being written to the store. + /// Defaults to one minute. Writing every notification individually + /// would mean a store round trip per second per sensor. + public SensorSessionOptions setStoreBatchMillis(int storeBatchMillis) { + this.storeBatchMillis = storeBatchMillis; + return this; + } + + /// The store write-batch interval in milliseconds. + public int getStoreBatchMillis() { + return storeBatchMillis; + } + + /// Attaches this sensor to a workout, so its samples become part of + /// that workout's statistics and are persisted with it. + /// + /// This is the right way to feed a heart-rate strap into a recorded + /// workout -- see the warning on [#setWriteToStore(boolean)]. + public SensorSessionOptions setWorkoutSession( + WorkoutSession workoutSession) { + this.workoutSession = workoutSession; + return this; + } + + /// The attached workout, or null. + public WorkoutSession getWorkoutSession() { + return workoutSession; + } + + /// How long a cached reading stays current, for + /// [SensorSession#getLatest(com.codename1.health.HealthDataType)]. + /// Defaults to ten seconds. + /// + /// Past this age `getLatest` returns null rather than a stale value, + /// so a UI bound to it shows a dash instead of silently displaying the + /// heart rate from before the strap fell off. + public SensorSessionOptions setStaleSampleMillis(int staleSampleMillis) { + this.staleSampleMillis = staleSampleMillis; + return this; + } + + /// The staleness threshold in milliseconds. + public int getStaleSampleMillis() { + return staleSampleMillis; + } +} diff --git a/CodenameOne/src/com/codename1/health/sensors/SensorSessionState.java b/CodenameOne/src/com/codename1/health/sensors/SensorSessionState.java new file mode 100644 index 00000000000..20b97e7aedf --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/SensorSessionState.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +/// The lifecycle of a [SensorSession]. +public enum SensorSessionState { + + /// Connecting and discovering services. + CONNECTING, + + /// Connected and subscribed; measurements are arriving. + STREAMING, + + /// The link dropped and the session is trying to re-establish it. + /// Reached only when [SensorSessionOptions#setAutoReconnect(boolean)] + /// is on; the session recovers on its own and returns to + /// [#STREAMING]. + /// + /// Straps routinely drop out for a few seconds when a rider's chest + /// moves the sensor, so treat this as a transient display state rather + /// than an error. + RECONNECTING, + + /// The session was stopped by the app. + STOPPED, + + /// The session ended with an error and will not recover. + FAILED +} diff --git a/CodenameOne/src/com/codename1/health/sensors/TemperatureMeasurement.java b/CodenameOne/src/com/codename1/health/sensors/TemperatureMeasurement.java new file mode 100644 index 00000000000..172a4ebb3ae --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/TemperatureMeasurement.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +/// A decoded Temperature Measurement, characteristic `0x2A1C`. +/// +/// #### Wire format +/// +/// A flags byte -- bit 0 Fahrenheit rather than Celsius, bit 1 timestamp +/// present, bit 2 temperature type present -- followed by a **32-bit +/// IEEE-11073 FLOAT**. +/// +/// Note that this is the 32-bit format, whereas blood pressure and glucose +/// use the 16-bit SFLOAT. They are different encodings with different +/// reserved values, and decoding one as the other produces numbers that +/// look plausible enough to ship. A thermometer that cannot obtain a +/// reading sends a reserved value; this parser returns `null` rather than +/// letting it through. +public final class TemperatureMeasurement { + + private static final int FLAG_FAHRENHEIT = 0x01; + private static final int FLAG_TIMESTAMP = 0x02; + private static final int FLAG_TYPE = 0x04; + + /// The temperature was measured somewhere this profile does not name. + public static final int SITE_UNKNOWN = 0; + /// Armpit. + public static final int SITE_ARMPIT = 1; + /// Body, general. + public static final int SITE_BODY = 2; + /// Ear. + public static final int SITE_EAR = 3; + /// Finger. + public static final int SITE_FINGER = 4; + /// Gastrointestinal tract. + public static final int SITE_GASTROINTESTINAL = 5; + /// Mouth. + public static final int SITE_MOUTH = 6; + /// Rectum. + public static final int SITE_RECTUM = 7; + /// Toe. + public static final int SITE_TOE = 8; + /// Tympanum, the ear drum. + public static final int SITE_TYMPANUM = 9; + + private final double celsius; + private final long timestampMillis; + private final int site; + + private TemperatureMeasurement(double celsius, long timestampMillis, + int site) { + this.celsius = celsius; + this.timestampMillis = timestampMillis; + this.site = site; + } + + /// Decodes a `0x2A1C` value. Returns `null` for a malformed or + /// truncated payload, or when the device signalled that it could not + /// obtain a reading. Never throws. + public static TemperatureMeasurement parse(byte[] value) { + if (value == null || value.length < 5) { + return null; + } + GattReader r = new GattReader(value); + int flags = r.uint8(); + double raw = Ieee11073.float32(r.uint32()); + double celsius = (flags & FLAG_FAHRENHEIT) != 0 + ? (raw - 32.0) * 5.0 / 9.0 : raw; + + long timestamp = -1; + if ((flags & FLAG_TIMESTAMP) != 0) { + timestamp = GattDateTime.read(r); + } + int site = SITE_UNKNOWN; + if ((flags & FLAG_TYPE) != 0) { + site = r.uint8(); + } + + if (!r.isValid() || Double.isNaN(celsius)) { + return null; + } + return new TemperatureMeasurement(celsius, timestamp, site); + } + + /// The temperature in degrees Celsius, converted from Fahrenheit when + /// the device reported those. + public double getCelsius() { + return celsius; + } + + /// The temperature in degrees Fahrenheit. + public double getFahrenheit() { + return celsius * 9.0 / 5.0 + 32.0; + } + + /// Where on the body the measurement was taken, as a `SITE_` constant. + public int getSite() { + return site; + } + + /// The device's own timestamp in epoch millis, or `-1` when absent. + public long getTimestampMillis() { + return timestampMillis; + } + + @Override + public String toString() { + return "TemperatureMeasurement[" + celsius + " degC]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/sensors/WeightMeasurement.java b/CodenameOne/src/com/codename1/health/sensors/WeightMeasurement.java new file mode 100644 index 00000000000..9a84e38a9e4 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/WeightMeasurement.java @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +/// A decoded Weight Measurement, characteristic `0x2A9D`. +/// +/// #### Wire format +/// +/// Byte 0 is a flags field: +/// +/// | Bit | Meaning | +/// |-----|---------| +/// | 0 | units: 0 = SI (kg), 1 = Imperial (lb) | +/// | 1 | timestamp present | +/// | 2 | user id present | +/// | 3 | BMI and height present | +/// +/// followed by a `uint16` weight and the optional fields. +/// +/// #### The resolution depends on the unit +/// +/// The raw `uint16` is scaled by **0.005 kg** in SI mode and **0.01 lb** +/// in Imperial mode -- not the same multiplier, and not a simple unit +/// conversion applied afterwards. Getting this wrong yields a weight that +/// is wrong by a factor of about two, which is exactly close enough to +/// look plausible. Height carries the same split: 0.001 m against 0.1 in. +/// +/// Like all characteristics here this one is **indicated, not notified**. +public final class WeightMeasurement { + + private static final int FLAG_IMPERIAL = 0x01; + private static final int FLAG_TIMESTAMP = 0x02; + private static final int FLAG_USER_ID = 0x04; + private static final int FLAG_BMI_HEIGHT = 0x08; + + /// The value the profile reserves, in every one of these fields, for + /// "the scale could not measure this". + private static final int UNAVAILABLE = 0xFFFF; + + private static final double WEIGHT_RESOLUTION_KG = 0.005; + private static final double WEIGHT_RESOLUTION_LB = 0.01; + private static final double HEIGHT_RESOLUTION_M = 0.001; + private static final double HEIGHT_RESOLUTION_IN = 0.1; + + private static final double LB_TO_KG = 0.45359237; + private static final double IN_TO_M = 0.0254; + + private final double weightKg; + private final double heightMeters; + private final double bmi; + private final int userId; + private final long timestampMillis; + private final boolean imperial; + + private WeightMeasurement(double weightKg, double heightMeters, double bmi, + int userId, long timestampMillis, boolean imperial) { + this.weightKg = weightKg; + this.heightMeters = heightMeters; + this.bmi = bmi; + this.userId = userId; + this.timestampMillis = timestampMillis; + this.imperial = imperial; + } + + /// Decodes a `0x2A9D` value. Returns `null` for a malformed or + /// truncated payload; never throws. + public static WeightMeasurement parse(byte[] value) { + if (value == null || value.length < 3) { + return null; + } + GattReader r = new GattReader(value); + int flags = r.uint8(); + boolean imperial = (flags & FLAG_IMPERIAL) != 0; + + int rawWeight = r.uint16(); + // 0xFFFF is the Weight Scale Service's "measurement unsuccessful" + // sentinel, not a weight. Scaling it produced 327.675 kg in SI + // mode and about 297 kg imperial, which the session then published + // and could write to the store as a real body mass -- the other + // medical parsers here already reject their failure values. + if (rawWeight == UNAVAILABLE) { + return null; + } + double weightKg = imperial + ? rawWeight * WEIGHT_RESOLUTION_LB * LB_TO_KG + : rawWeight * WEIGHT_RESOLUTION_KG; + + long timestamp = -1; + if ((flags & FLAG_TIMESTAMP) != 0) { + timestamp = GattDateTime.read(r); + } + int user = -1; + if ((flags & FLAG_USER_ID) != 0) { + user = r.uint8(); + } + double bmi = Double.NaN; + double heightM = Double.NaN; + if ((flags & FLAG_BMI_HEIGHT) != 0) { + // Each field carries its own 0xFFFF "unavailable" value, and a + // scale that measured one but not the other still sends both. + // Scaled rather than checked, they surfaced as a BMI of 6553.5 + // and a height of about 6.5 kilometres -- numbers an app has no + // reason to distrust, next to a weight that is perfectly real. + // NaN is what the getters already document for absent. + int rawBmi = r.uint16(); + // BMI is always transmitted with a resolution of 0.1, + // regardless of the unit flag. + if (rawBmi != UNAVAILABLE) { + bmi = rawBmi * 0.1; + } + int rawHeight = r.uint16(); + if (rawHeight != UNAVAILABLE) { + heightM = imperial + ? rawHeight * HEIGHT_RESOLUTION_IN * IN_TO_M + : rawHeight * HEIGHT_RESOLUTION_M; + } + } + + if (!r.isValid()) { + return null; + } + return new WeightMeasurement(weightKg, heightM, bmi, user, timestamp, + imperial); + } + + /// The weight in kilograms, converted from pounds when the scale + /// reported Imperial units. + public double getWeightKg() { + return weightKg; + } + + /// `true` when the scale transmitted Imperial units. Useful for + /// matching the user's own display preference; the value returned by + /// [#getWeightKg()] is already normalized either way. + public boolean isImperial() { + return imperial; + } + + /// The height in metres, or `Double.NaN` when absent. + public double getHeightMeters() { + return heightMeters; + } + + /// The body mass index the scale computed, or `Double.NaN` when + /// absent. + public double getBmi() { + return bmi; + } + + /// `true` when this reading carried BMI **and** height. + /// + /// Both, as the name says. The two fields are optional + /// independently -- each carries its own `0xFFFF` unavailable value + /// and a scale that measured one but not the other sends both -- so + /// testing only the BMI let a caller that gated on this go on to use + /// a height of `NaN`. + public boolean hasBmiAndHeight() { + return !Double.isNaN(bmi) && !Double.isNaN(heightMeters); + } + + /// The scale's user-profile index, or `-1` when absent. Family scales + /// use it to attribute a reading. + public int getUserId() { + return userId; + } + + /// The scale's own timestamp in epoch millis, or `-1` when absent. + public long getTimestampMillis() { + return timestampMillis; + } + + @Override + public String toString() { + return "WeightMeasurement[" + weightKg + " kg]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/sensors/package-info.java b/CodenameOne/src/com/codename1/health/sensors/package-info.java new file mode 100644 index 00000000000..cfb47a2be99 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/sensors/package-info.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +/// Standard Bluetooth SIG health sensors -- heart-rate straps, power +/// meters, speed and cadence sensors, foot pods, thermometers, scales, +/// blood-pressure cuffs and glucose meters. +/// +/// Start at [com.codename1.health.sensors.HealthSensors], reached from +/// [com.codename1.health.Health#getSensors()]. +/// +/// This layer is built entirely on `com.codename1.bluetooth.le` and needs +/// no platform health store, so it behaves identically on every port with +/// Bluetooth LE -- including the desktop and JavaScript ports, where no +/// health store exists at all. It needs Bluetooth permissions rather than +/// health ones, and an app that uses only this package is not treated as a +/// health-data app by the build server. +/// +/// The measurement parsers are public and static so they can be unit +/// tested without hardware and reused by apps doing their own GATT work. +/// Each returns null rather than throwing on a malformed payload. +package com.codename1.health.sensors; diff --git a/CodenameOne/src/com/codename1/health/workout/RecordedWorkoutSession.java b/CodenameOne/src/com/codename1/health/workout/RecordedWorkoutSession.java new file mode 100644 index 00000000000..630d99c16e3 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/workout/RecordedWorkoutSession.java @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.workout; + +import com.codename1.health.AggregateMetric; +import com.codename1.health.Health; +import com.codename1.health.HealthDataType; +import com.codename1.health.HealthQuantity; +import com.codename1.health.HealthSample; +import com.codename1.health.HealthStore; +import com.codename1.health.HealthUnit; +import com.codename1.health.HealthWriteResult; +import com.codename1.health.WorkoutSample; +import com.codename1.util.AsyncResource; +import com.codename1.util.AsyncResult; + +import java.util.ArrayList; +import java.util.List; + +/// A workout recorded by the framework rather than by the operating +/// system. +/// +/// Used wherever the platform has no live workout session -- Android +/// phones, and iOS before version 26. The state machine, the elapsed clock +/// and the statistics rollup all come from [WorkoutSession]; this class +/// adds the part that is genuinely different: it accumulates the samples +/// the app feeds in and, on [WorkoutSession#end()], writes them to the +/// health store together with the workout record. +/// +/// That is precisely the flow Google documents for Health Connect on +/// phones -- record the session yourself, batch the associated data, write +/// it when the session ends -- so on Android this is the correct design +/// rather than a fallback. +final class RecordedWorkoutSession extends WorkoutSession { + + private final List collected = new ArrayList(); + + RecordedWorkoutSession(WorkoutConfiguration configuration) { + super(configuration); + } + + @Override + protected void doStart(AsyncResource out) { + out.complete(Boolean.TRUE); + } + + @Override + protected void doPause(AsyncResource out) { + out.complete(Boolean.TRUE); + } + + @Override + protected void doResume(AsyncResource out) { + out.complete(Boolean.TRUE); + } + + @Override + protected void doAddSamples(List samples, + AsyncResource out) { + synchronized (collected) { + collected.addAll(samples); + } + out.complete(Boolean.TRUE); + } + + @Override + protected void doDiscard() { + synchronized (collected) { + collected.clear(); + } + } + + /// The markers live on [WorkoutSample] because this class is not + /// public: an app told to check for one has to be able to name it. + private static final String SAMPLES_NOT_PERSISTED = + WorkoutSample.SAMPLES_NOT_PERSISTED; + + private static final String WORKOUT_NOT_PERSISTED = + WorkoutSample.WORKOUT_NOT_PERSISTED; + + @Override + protected void doEnd(final AsyncResource out) { + final WorkoutSample workout = WorkoutSample.create( + getConfiguration().getActivityType(), getStartedAtMillis(), + getEndedAtMillis()); + // Clamped only against the two clock reads inside end(): the + // accumulated total and the end timestamp are taken a few + // instructions apart, and a wall clock that steps backwards + // between them can make the first exceed the second by a + // millisecond. The setter rejects anything larger, which is the + // right answer for a caller supplying its own figure and the + // wrong one for losing a real workout to an NTP correction. + workout.setActiveDurationMillis(Math.min(getElapsedMillis(), + workout.getDurationMillis())); + workout.setTitle(getConfiguration().getTitle()); + applyTotals(workout); + + final HealthStore store = Health.getInstance().getStore(); + // Only what this platform can actually store. Neither HealthKit nor + // the Health Connect bridge accepts a workout *session* through the + // sample write path yet, and sending one made shared validation + // reject the whole batch -- failing the session and discarding the + // child samples that would have been written perfectly well. + List toWrite = new ArrayList(); + if (store.isWritable(HealthDataType.WORKOUT)) { + toWrite.add(workout); + } + StringBuilder rejected = new StringBuilder(); + synchronized (collected) { + for (HealthSample s : collected) { + if (store.isWritable(s.getType())) { + toWrite.add(s); + } else { + noteRejected(rejected, s.getType().getId()); + } + } + } + if (rejected.length() > 0) { + workout.putMetadata(SAMPLES_NOT_PERSISTED, rejected.toString()); + } + + if (!store.isSupported() || toWrite.isEmpty()) { + // Nowhere to persist it, but the record itself is still real + // and the caller may want to upload it themselves. Report the + // workout rather than failing the whole session. + // + // The marker goes on here too. A workout with no writable + // child samples -- the ordinary no-sensor case on both mobile + // platforms -- took this path and came back with neither an id + // nor the signal that says why, which is the one thing a + // caller needs in order to keep the record itself. + if (!store.isWritable(HealthDataType.WORKOUT)) { + workout.putMetadata(WORKOUT_NOT_PERSISTED, "true"); + } + setState(WorkoutSessionState.ENDED); + out.complete(workout); + return; + } + store.write(toWrite).onResult(new AsyncResult() { + @Override + public void onReady(HealthWriteResult value, Throwable err) { + if (err != null) { + setState(WorkoutSessionState.FAILED); + out.error(err); + return; + } + if (!value.getSampleIds().isEmpty() + && store.isWritable(HealthDataType.WORKOUT)) { + // The first id belongs to the workout only when the + // workout was part of the batch. + workout.setId(value.getSampleIds().get(0)); + } else if (!store.isWritable(HealthDataType.WORKOUT)) { + // Neither mobile platform accepts a workout through + // the sample write path in this release, so the child + // measurements are stored and the session record is + // not. Saying so through the returned sample beats + // resolving as though the workout had been persisted. + workout.putMetadata(WORKOUT_NOT_PERSISTED, "true"); + } + setState(WorkoutSessionState.ENDED); + out.complete(workout); + } + }); + } + + /// Adds `typeId` to the comma-separated rejected list, once. + private static void noteRejected(StringBuilder rejected, String typeId) { + String needle = "," + typeId + ","; + if (("," + rejected.toString() + ",").indexOf(needle) >= 0) { + return; + } + if (rejected.length() > 0) { + rejected.append(','); + } + rejected.append(typeId); + } + + /// Copies the rolled-up energy and distance onto the record, leaving + /// them null when nothing was fed in -- see + /// [WorkoutSession#getStatistic(HealthDataType,AggregateMetric)]. + private void applyTotals(WorkoutSample workout) { + HealthQuantity energy = getStatistic(HealthDataType.ACTIVE_ENERGY, + AggregateMetric.TOTAL); + if (energy != null) { + workout.setTotalEnergy(energy.in(HealthUnit.KILOCALORIE)); + } + // Summed across every distance category, not the first one that + // answers. Picking one dropped a swim entirely -- nothing here + // looked at DISTANCE_SWIMMING at all, so a recorded swim came + // back with no total despite the samples that produced it being + // right there -- and in a triathlon it reported the walk and + // silently discarded the ride. + double metres = 0; + boolean any = false; + HealthDataType[] categories = { + HealthDataType.DISTANCE_WALKING_RUNNING, + HealthDataType.DISTANCE_CYCLING, + HealthDataType.DISTANCE_SWIMMING, + }; + for (HealthDataType category : categories) { + HealthQuantity leg = getStatistic(category, + AggregateMetric.TOTAL); + if (leg != null) { + metres += leg.getValue(HealthUnit.METER); + any = true; + } + } + // Still null when nothing was fed in: a workout with no distance + // samples and one covering zero metres are different facts. + if (any) { + workout.setTotalDistance( + new HealthQuantity(metres, HealthUnit.METER)); + } + } +} diff --git a/CodenameOne/src/com/codename1/health/workout/WorkoutConfiguration.java b/CodenameOne/src/com/codename1/health/workout/WorkoutConfiguration.java new file mode 100644 index 00000000000..32f5e8ee844 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/workout/WorkoutConfiguration.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.workout; + +import com.codename1.health.HealthDataType; +import com.codename1.health.WorkoutActivityType; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// Describes a workout about to be started. +public final class WorkoutConfiguration { + + private WorkoutActivityType activityType = WorkoutActivityType.OTHER; + private WorkoutLocationType locationType = WorkoutLocationType.UNKNOWN; + private boolean keepAliveInBackground; + private final List collectedTypes = + new ArrayList(); + private String title; + + /// The kind of exercise. Defaults to [WorkoutActivityType#OTHER]. + public WorkoutConfiguration setActivityType( + WorkoutActivityType activityType) { + this.activityType = activityType == null + ? WorkoutActivityType.OTHER : activityType; + return this; + } + + /// The configured activity. + public WorkoutActivityType getActivityType() { + return activityType; + } + + /// Indoors or outdoors -- see [WorkoutLocationType], which affects + /// whether GPS is used. + public WorkoutConfiguration setLocationType( + WorkoutLocationType locationType) { + this.locationType = locationType == null + ? WorkoutLocationType.UNKNOWN : locationType; + return this; + } + + /// The configured location type. + public WorkoutLocationType getLocationType() { + return locationType; + } + + /// Asks the operating system to keep the app running while the workout + /// records. **Not available in this release** -- passing `true` + /// throws. + /// + /// Nothing honours it anywhere yet. Keeping the process alive needs + /// either a live `HKWorkoutSession`, which + /// [WorkoutManager#isLiveSessionSupported()] reports false for on + /// every platform here, or an Android foreground service, which the + /// build does not emit and for which no build hint exists. A setter + /// that stored the request and let the workout be killed mid-run + /// would be worse than one that refuses it: the app could not tell + /// the difference until a user lost a workout. + /// + /// Until then, record with the app in the foreground, and write what + /// you have collected rather than assuming the session will still be + /// running when the user comes back. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: if `keepAliveInBackground` is `true`. + public WorkoutConfiguration setKeepAliveInBackground( + boolean keepAliveInBackground) { + if (keepAliveInBackground) { + throw new IllegalArgumentException("keeping the app alive" + + " during a workout is not implemented on any" + + " platform in this release; nothing would honour the" + + " request and the workout would be lost when the OS" + + " suspended the process"); + } + this.keepAliveInBackground = false; + return this; + } + + /// Always `false` in this release -- see + /// [#setKeepAliveInBackground(boolean)]. + public boolean isKeepAliveInBackground() { + return keepAliveInBackground; + } + + /// Asks the platform to collect this type automatically during the + /// workout. Honoured only where + /// [WorkoutManager#isSensorCollectionSupported()] is `true`; elsewhere + /// you must feed samples in yourself with + /// [WorkoutSession#addSamples(java.util.List)]. + public WorkoutConfiguration addCollectedType(HealthDataType type) { + if (type != null && !collectedTypes.contains(type)) { + collectedTypes.add(type); + } + return this; + } + + /// The types requested for automatic collection. + public List getCollectedTypes() { + return Collections.unmodifiableList(collectedTypes); + } + + /// A user-visible title for the workout. + public WorkoutConfiguration setTitle(String title) { + this.title = title; + return this; + } + + /// The configured title, or null. + public String getTitle() { + return title; + } +} diff --git a/CodenameOne/src/com/codename1/health/workout/WorkoutEvent.java b/CodenameOne/src/com/codename1/health/workout/WorkoutEvent.java new file mode 100644 index 00000000000..8a9523c07c1 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/workout/WorkoutEvent.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.workout; + +/// A marked moment inside a workout -- a lap, a segment boundary, a +/// user-placed marker. +public final class WorkoutEvent { + + /// What kind of moment an event marks. + public enum Kind { + /// A lap boundary. + LAP, + /// A user-placed marker. + MARKER, + /// The start or end of a named segment. + SEGMENT, + /// The session paused. + PAUSE, + /// The session resumed. + RESUME, + /// The platform detected that the user stopped moving. + MOTION_PAUSED, + /// The platform detected that the user resumed moving. + MOTION_RESUMED + } + + private final Kind kind; + private final long timestampMillis; + private final String label; + + /// Creates an event at a moment. + public WorkoutEvent(Kind kind, long timestampMillis, String label) { + if (kind == null) { + throw new IllegalArgumentException("an event requires a kind"); + } + this.kind = kind; + this.timestampMillis = timestampMillis; + this.label = label; + } + + /// A lap boundary at `timestampMillis`. + public static WorkoutEvent lap(long timestampMillis) { + return new WorkoutEvent(Kind.LAP, timestampMillis, null); + } + + /// A user-placed marker at `timestampMillis`. + public static WorkoutEvent marker(long timestampMillis, String label) { + return new WorkoutEvent(Kind.MARKER, timestampMillis, label); + } + + /// What this event marks. + public Kind getKind() { + return kind; + } + + /// When it happened, epoch millis UTC. + public long getTimestampMillis() { + return timestampMillis; + } + + /// A user-visible label, or null. + public String getLabel() { + return label; + } + + @Override + public String toString() { + return "WorkoutEvent[" + kind + " @" + timestampMillis + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/health/workout/WorkoutLocationType.java b/CodenameOne/src/com/codename1/health/workout/WorkoutLocationType.java new file mode 100644 index 00000000000..9c8b3394ad5 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/workout/WorkoutLocationType.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.workout; + +/// Whether a workout happens indoors or outdoors. +/// +/// A label in this release: nothing here drives a live workout session, +/// so it is recorded with the workout and read back rather than acted on. +/// It is more than a label on a live session -- on watchOS it selects +/// whether GPS is used, which materially changes both the distance +/// calculation and battery drain -- so set it correctly now and the +/// behaviour follows when live sessions arrive. +public enum WorkoutLocationType { + + /// Not specified; the platform decides. + UNKNOWN, + + /// Indoors -- distance comes from motion sensors rather than GPS. + INDOOR, + + /// Outdoors -- GPS is used where available. + OUTDOOR +} diff --git a/CodenameOne/src/com/codename1/health/workout/WorkoutManager.java b/CodenameOne/src/com/codename1/health/workout/WorkoutManager.java new file mode 100644 index 00000000000..374c0b41d8d --- /dev/null +++ b/CodenameOne/src/com/codename1/health/workout/WorkoutManager.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.workout; + +import com.codename1.health.HealthError; +import com.codename1.health.HealthException; +import com.codename1.util.AsyncResource; + +/// Starts and tracks workout recordings. +/// +/// Obtain one from [com.codename1.health.Health#getWorkouts()]; it is +/// never null. +/// +/// #### Check what the platform will actually do for you +/// +/// Two capabilities that are easy to conflate, and that this API keeps +/// separate on purpose: +/// +/// - [#isLiveSessionSupported()] -- the OS runs a real session, keeping +/// the app alive while it records. +/// - [#isSensorCollectionSupported()] -- the OS also gathers heart rate +/// and energy into that session by itself. +/// +/// **Both are `false` on every platform in this release.** No port +/// implements an OS-owned session yet, so every workout is recorded: the +/// framework keeps the clock and the rollup, and persists what you feed +/// it when the session ends. That is exactly the flow Google documents +/// for Android phones, and it is why these are queryable facts rather +/// than assumptions -- code that branches on them today keeps working +/// unchanged when a port starts answering true. +/// +/// Where the second is false, a workout records only what you feed it. +/// Building a UI with a live heart-rate readout without checking would +/// produce an app that works on a watch and shows a permanent dash on a +/// phone. +/// +/// ```java +/// WorkoutManager workouts = Health.getInstance().getWorkouts(); +/// workouts.startSession(new WorkoutConfiguration() +/// .setActivityType(WorkoutActivityType.RUNNING) +/// .setLocationType(WorkoutLocationType.OUTDOOR)) +/// .onResult((session, err) -> { +/// if (err != null) { Log.e(err); return; } +/// if (!session.isLive()) { +/// status.setText("Recording - connect a strap for heart rate"); +/// } +/// }); +/// ``` +public class WorkoutManager { + + private WorkoutSession activeSession; + + /// Whether the operating system provides a real workout session that + /// keeps the app alive and owns the recording. + /// + /// `false` everywhere in this release: no port overrides this, so a + /// workout is always recorded by the framework rather than owned by + /// the OS. Health Connect has no such concept at all on phones, and + /// `androidx.health.services` is Wear OS only; HealthKit does have + /// `HKWorkoutSession`, but nothing here drives it yet. + /// [#startSession(WorkoutConfiguration)] works regardless, in + /// recorded mode. + public boolean isLiveSessionSupported() { + return false; + } + + /// Whether the operating system collects sensor data into the session + /// on its own, without the app feeding samples in. + /// + /// `false` on every platform in this release. It is the OS-owned + /// session that would do the collecting, and no port runs one yet -- + /// so a workout contains what you fed it through + /// [WorkoutSession#addSamples(java.util.List)], and nothing else. + public boolean isSensorCollectionSupported() { + return false; + } + + /// Starts a workout. + /// + /// Only one session may run at a time; starting another while one is + /// active fails with [HealthError#SESSION_STATE] rather than silently + /// abandoning the first, because an abandoned workout is data the user + /// believed was being recorded. + public final AsyncResource startSession( + WorkoutConfiguration configuration) { + AsyncResource out = + new AsyncResource(); + WorkoutSession existing = activeSession; + if (existing != null && isRunning(existing)) { + out.error(new HealthException(HealthError.SESSION_STATE, + "a workout is already in progress; end or discard it" + + " before starting another")); + return out; + } + WorkoutSession session = createSession( + configuration == null ? new WorkoutConfiguration() + : configuration); + activeSession = session; + out.complete(session); + return out; + } + + /// The session currently running or paused, or null. + public final WorkoutSession getActiveSession() { + WorkoutSession s = activeSession; + return s != null && isRunning(s) ? s : null; + } + + private static boolean isRunning(WorkoutSession s) { + WorkoutSessionState state = s.getState(); + return state == WorkoutSessionState.NOT_STARTED + || state == WorkoutSessionState.PREPARING + || state == WorkoutSessionState.RUNNING + || state == WorkoutSessionState.PAUSED; + } + + /// Creates the session object. Ports override to return a session + /// backed by a real platform workout; the default records in shared + /// code and writes on end. + protected WorkoutSession createSession(WorkoutConfiguration config) { + return new RecordedWorkoutSession(config); + } +} diff --git a/CodenameOne/src/com/codename1/health/workout/WorkoutSession.java b/CodenameOne/src/com/codename1/health/workout/WorkoutSession.java new file mode 100644 index 00000000000..441cb2b33fb --- /dev/null +++ b/CodenameOne/src/com/codename1/health/workout/WorkoutSession.java @@ -0,0 +1,623 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.workout; + +import com.codename1.health.AggregateMetric; +import com.codename1.health.HealthDataType; +import com.codename1.health.HealthError; +import com.codename1.health.HealthException; +import com.codename1.health.HealthQuantity; +import com.codename1.health.HealthSample; +import com.codename1.health.HealthUnit; +import com.codename1.health.QuantitySample; +import com.codename1.health.SeriesSample; +import com.codename1.health.WorkoutSample; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// A workout being recorded. +/// +/// Obtained from +/// [WorkoutManager#startSession(WorkoutConfiguration)]. The state machine, +/// the elapsed clock and the rollup of fed samples live here in shared +/// code; ports implement only the `do*` methods. +/// +/// #### Live and recorded sessions +/// +/// [#isLive()] tells you whether the operating system is running a real +/// workout session -- keeping the app alive and collecting sensor data +/// itself. **It is false everywhere in this release**: the only +/// implementation here is a recorded session, and +/// [WorkoutManager#isLiveSessionSupported()] says so on every platform. +/// `HKWorkoutSession` on watchOS and iOS 26, and the Wear OS exercise +/// client, are what would change that answer; nothing here drives them +/// yet. Do not assume the OS keeps your app alive or collects data for +/// you -- backgrounding can end the process and take the session with it. +/// +/// On an Android phone it could not be live in any case, because Health +/// Connect has no such concept: Google's documented approach there is to +/// record the session yourself and write it when it ends, which is exactly +/// what a recorded session does. So a recorded session is not a degraded +/// shim -- it is the platform-correct design -- but it does mean **nothing is collected +/// unless you feed it**, through [#addSamples(List)] or by attaching a +/// Bluetooth sensor with +/// [com.codename1.health.sensors.SensorSessionOptions#setWorkoutSession(WorkoutSession)]. +/// +/// Check [WorkoutManager#isSensorCollectionSupported()] before building UI +/// that assumes a heart rate will appear on its own. +/// +/// #### A killed workout is over +/// +/// Sessions are deliberately not restored after the process dies. `end()` +/// was never called, so nothing is written. An app that needs +/// crash-resilient workouts should write partial records as it goes -- +/// which is what the recorded path does anyway. +public abstract class WorkoutSession { + + private final WorkoutConfiguration configuration; + private final List listeners = + new ArrayList(); + private final Map statistics = + new HashMap(); + private final Map counts = new HashMap(); + private final List events = new ArrayList(); + + private WorkoutSessionState state = WorkoutSessionState.NOT_STARTED; + private long startedAtMillis; + private long endedAtMillis; + private long accumulatedMillis; + private long resumedAtMillis; + + /// Ports and the framework construct sessions. + protected WorkoutSession(WorkoutConfiguration configuration) { + this.configuration = configuration == null + ? new WorkoutConfiguration() : configuration; + } + + /// The configuration this session was started with. + public final WorkoutConfiguration getConfiguration() { + return configuration; + } + + /// The current state. + public final WorkoutSessionState getState() { + return state; + } + + /// Whether the operating system is running a real workout session -- + /// see the class documentation. `false` means the clock and the saved + /// record are real but collection is entirely up to you. + public boolean isLive() { + return false; + } + + /// Warms up sensors ahead of [#start()], for apps that show a + /// countdown. Optional; `start()` works without it. + public final AsyncResource prepare() { + AsyncResource out = new AsyncResource(); + if (!requireState(out, WorkoutSessionState.NOT_STARTED, "prepare")) { + return out; + } + setState(WorkoutSessionState.PREPARING); + doPrepare(out); + return out; + } + + /// Starts recording. + public final AsyncResource start() { + AsyncResource out = new AsyncResource(); + if (state != WorkoutSessionState.NOT_STARTED + && state != WorkoutSessionState.PREPARING) { + failState(out, "start"); + return out; + } + startedAtMillis = System.currentTimeMillis(); + resumedAtMillis = startedAtMillis; + accumulatedMillis = 0; + setState(WorkoutSessionState.RUNNING); + doStart(out); + return out; + } + + /// Pauses recording. The elapsed clock stops. + public final AsyncResource pause() { + AsyncResource out = new AsyncResource(); + if (!requireState(out, WorkoutSessionState.RUNNING, "pause")) { + return out; + } + accumulatedMillis += System.currentTimeMillis() - resumedAtMillis; + setState(WorkoutSessionState.PAUSED); + addEvent(new WorkoutEvent(WorkoutEvent.Kind.PAUSE, + System.currentTimeMillis(), null)); + doPause(out); + return out; + } + + /// Resumes after a pause. + public final AsyncResource resume() { + AsyncResource out = new AsyncResource(); + if (!requireState(out, WorkoutSessionState.PAUSED, "resume")) { + return out; + } + resumedAtMillis = System.currentTimeMillis(); + setState(WorkoutSessionState.RUNNING); + addEvent(new WorkoutEvent(WorkoutEvent.Kind.RESUME, + resumedAtMillis, null)); + doResume(out); + return out; + } + + /// Ends the workout and writes it to the health store, resolving with + /// the persisted record. + public final AsyncResource end() { + AsyncResource out = new AsyncResource(); + if (state != WorkoutSessionState.RUNNING + && state != WorkoutSessionState.PAUSED) { + out.error(new HealthException(HealthError.SESSION_STATE, + "cannot end a workout in state " + state)); + return out; + } + if (state == WorkoutSessionState.RUNNING) { + accumulatedMillis += System.currentTimeMillis() - resumedAtMillis; + } + endedAtMillis = System.currentTimeMillis(); + setState(WorkoutSessionState.STOPPED); + doEnd(out); + return out; + } + + /// Abandons the workout without writing anything. + /// + /// A no-op once the workout has ended, and once [#end()] has been + /// called it is too late: the store write is already in flight, and + /// claiming to have abandoned the session while that write lands -- + /// then having its callback move the state back to `ENDED` -- would + /// break the one promise in this method's name. Discard before ending, + /// or delete the samples afterwards through the identifiers the write + /// reports. + public final void discard() { + if (state == WorkoutSessionState.ENDED + || state == WorkoutSessionState.STOPPED) { + return; + } + setState(WorkoutSessionState.FAILED); + doDiscard(); + } + + /// Time spent recording, excluding pauses. + public final long getElapsedMillis() { + if (state == WorkoutSessionState.RUNNING) { + return accumulatedMillis + + (System.currentTimeMillis() - resumedAtMillis); + } + return accumulatedMillis; + } + + /// When the workout started, epoch millis, or 0 before [#start()]. + public final long getStartedAtMillis() { + return startedAtMillis; + } + + /// When it ended, epoch millis, or 0 while still running. + public final long getEndedAtMillis() { + return endedAtMillis; + } + + /// A live statistic, or **`null`** when the platform does not compute + /// it and nothing has been fed in. + /// + /// Never fabricates a zero. On a recorded session with no sensor + /// attached every statistic is null, and showing "0 bpm" instead would + /// be a claim the app cannot support. + public final HealthQuantity getStatistic(HealthDataType type, + AggregateMetric metric) { + if (type == null || metric == null) { + return null; + } + synchronized (statistics) { + return statistics.get(key(type, metric)); + } + } + + /// Feeds samples into the workout. On a recorded session this is the + /// only way anything is collected. + public final AsyncResource addSamples(List samples) { + AsyncResource out = new AsyncResource(); + if (state != WorkoutSessionState.RUNNING + && state != WorkoutSessionState.PAUSED) { + failState(out, "addSamples"); + return out; + } + if (state == WorkoutSessionState.PAUSED) { + // Accepted but dropped. A strap stays connected across a pause + // and keeps notifying, and the elapsed clock already excludes + // the paused span -- so rolling these in produced a saved + // workout whose totals covered time its own duration says did + // not happen. Callers explicitly feeding history can add it + // after resuming. + out.complete(Boolean.TRUE); + return out; + } + if (samples == null || samples.isEmpty()) { + out.complete(Boolean.TRUE); + return out; + } + // Copied and filtered rather than passed through. rollUp ignores + // a null, but the list went on to be stored whole, and doEnd + // dereferences what it stored -- so a single null entry threw out + // of end() after the session had already moved to STOPPED, + // leaving the caller with neither a result nor a session it could + // retry. + List kept = new ArrayList( + samples.size()); + for (HealthSample sample : samples) { + if (sample != null) { + rollUp(sample); + kept.add(sample); + } + } + if (kept.isEmpty()) { + out.complete(Boolean.TRUE); + return out; + } + doAddSamples(kept, out); + return out; + } + + /// Records an event -- a lap, a marker, a segment boundary. + public final AsyncResource addEvent(WorkoutEvent event) { + AsyncResource out = new AsyncResource(); + if (event == null) { + out.complete(Boolean.FALSE); + return out; + } + synchronized (events) { + events.add(event); + } + fireEvent(event); + out.complete(Boolean.TRUE); + return out; + } + + /// Every event recorded so far. + public final List getEvents() { + synchronized (events) { + return new ArrayList(events); + } + } + + /// Registers a listener for state, statistics and events. + public final void addListener(WorkoutSessionListener listener) { + if (listener == null) { + return; + } + synchronized (listeners) { + if (!listeners.contains(listener)) { + listeners.add(listener); + } + } + } + + /// Removes a listener. + public final void removeListener(WorkoutSessionListener listener) { + synchronized (listeners) { + listeners.remove(listener); + } + } + + // ------------------------------------------------------------------ + // statistics rollup + // ------------------------------------------------------------------ + + /// Folds one sample into the running totals, minima, maxima, averages + /// and latest values. Shared by every port, so a session reports its + /// statistics the same way wherever it runs -- and would go on doing + /// so if a live session were feeding it instead of the app. + private void rollUp(HealthSample sample) { + if (sample instanceof SeriesSample) { + // A heart-rate trace fed in whole is workout data like any + // other. Returning here left AVERAGE, MINIMUM, MAXIMUM and + // LATEST null or stale for measurements the session was + // collecting and would go on to persist. + SeriesSample series = (SeriesSample) sample; + for (int i = 0; i < series.size(); i++) { + rollUp(series.toQuantitySample(i)); + } + return; + } + if (!(sample instanceof QuantitySample)) { + return; + } + QuantitySample q = (QuantitySample) sample; + HealthDataType type = q.getType(); + HealthUnit unit = type.getCanonicalUnit(); + if (unit == null) { + return; + } + double v = q.getValue(unit); + synchronized (statistics) { + Integer nBox = counts.get(type.getId()); + int n = nBox == null ? 0 : nBox.intValue(); + counts.put(type.getId(), Integer.valueOf(n + 1)); + + switch (type.getAggregationStyle()) { + case CUMULATIVE: + put(type, AggregateMetric.TOTAL, + valueOf(type, AggregateMetric.TOTAL, unit) + v, + unit); + break; + case DISCRETE: + double sum = valueOf(type, AggregateMetric.TOTAL, unit) + v; + put(type, AggregateMetric.TOTAL, sum, unit); + // Weighted by how long each sample covers, matching the + // aggregate semantics. An arithmetic mean reported ten + // minutes at 60 bpm followed by one minute at 120 as + // 90 bpm rather than about 65. + double w = Math.max(1, sample.getDurationMillis()); + double weighted = weightedSum(type) + v * w; + double totalWeight = weightOf(type) + w; + setWeightedSum(type, weighted); + setWeight(type, totalWeight); + put(type, AggregateMetric.AVERAGE, + totalWeight == 0 ? v : weighted / totalWeight, + unit); + HealthQuantity min = + statistics.get(key(type, AggregateMetric.MINIMUM)); + if (min == null || v < min.getValue(unit)) { + put(type, AggregateMetric.MINIMUM, v, unit); + } + HealthQuantity max = + statistics.get(key(type, AggregateMetric.MAXIMUM)); + if (max == null || v > max.getValue(unit)) { + put(type, AggregateMetric.MAXIMUM, v, unit); + } + // Latest by timestamp, not by arrival. A delayed + // device reading or an unsorted history batch made an + // older value overwrite a newer one, so the workout + // reported a stale number as the current one. + Long seen = latestAt.get(type.getId()); + if (seen == null + || q.getStartMillis() >= seen.longValue()) { + latestAt.put(type.getId(), + Long.valueOf(q.getStartMillis())); + put(type, AggregateMetric.LATEST, v, unit); + } + break; + default: + break; + } + } + fireStatisticsUpdated(type); + } + + /// When each type's LATEST value was measured, so a sample arriving + /// late cannot pass itself off as the newest. + private final Map latestAt = new HashMap(); + + private final Map weightedSums = + new HashMap(); + private final Map weights = + new HashMap(); + + private double weightedSum(HealthDataType type) { + Double v = weightedSums.get(type.getId()); + return v == null ? 0 : v.doubleValue(); + } + + private void setWeightedSum(HealthDataType type, double v) { + weightedSums.put(type.getId(), Double.valueOf(v)); + } + + private double weightOf(HealthDataType type) { + Double v = weights.get(type.getId()); + return v == null ? 0 : v.doubleValue(); + } + + private void setWeight(HealthDataType type, double v) { + weights.put(type.getId(), Double.valueOf(v)); + } + + private double valueOf(HealthDataType type, AggregateMetric metric, + HealthUnit unit) { + HealthQuantity existing = statistics.get(key(type, metric)); + return existing == null ? 0 : existing.getValue(unit); + } + + private void put(HealthDataType type, AggregateMetric metric, double value, + HealthUnit unit) { + statistics.put(key(type, metric), new HealthQuantity(value, unit)); + } + + private static String key(HealthDataType type, AggregateMetric metric) { + return type.getId() + ' ' + metric.name(); + } + + /// The accumulated statistics, for ports assembling the final record. + protected final Map getStatisticsSnapshot() { + synchronized (statistics) { + return new HashMap(statistics); + } + } + + // ------------------------------------------------------------------ + // port SPI + // ------------------------------------------------------------------ + + /// Warms up sensors. Default completes immediately. + protected void doPrepare(AsyncResource out) { + out.complete(Boolean.TRUE); + } + + /// Starts platform collection. + protected abstract void doStart(AsyncResource out); + + /// Pauses platform collection. + protected abstract void doPause(AsyncResource out); + + /// Resumes platform collection. + protected abstract void doResume(AsyncResource out); + + /// Stops collection and persists the workout. + protected abstract void doEnd(AsyncResource out); + + /// Abandons the workout without persisting. + protected abstract void doDiscard(); + + /// Hands fed samples to the platform. Default accepts them into the + /// shared rollup only. + protected void doAddSamples(List samples, + AsyncResource out) { + out.complete(Boolean.TRUE); + } + + // ------------------------------------------------------------------ + // event plumbing + // ------------------------------------------------------------------ + + /// Moves to a new state and notifies listeners. Ports call this for + /// transitions the platform initiated. + protected final void setState(WorkoutSessionState newState) { + if (newState == null || newState == state) { + return; + } + state = newState; + Object[] snapshot = listenerSnapshot(); + if (snapshot != null) { + Display.getInstance().callSerially( + makeStateRunnable(this, snapshot, newState)); + } + } + + /// Notifies listeners that a statistic changed. + protected final void fireStatisticsUpdated(HealthDataType type) { + Object[] snapshot = listenerSnapshot(); + if (snapshot != null) { + Display.getInstance().callSerially( + makeStatsRunnable(this, snapshot, type)); + } + } + + /// Notifies listeners of an event. + protected final void fireEvent(WorkoutEvent event) { + Object[] snapshot = listenerSnapshot(); + if (snapshot != null) { + Display.getInstance().callSerially( + makeEventRunnable(this, snapshot, event)); + } + } + + /// Reports an unrecoverable failure and moves to + /// [WorkoutSessionState#FAILED]. + protected final void fireFailed(HealthException error) { + setState(WorkoutSessionState.FAILED); + Object[] snapshot = listenerSnapshot(); + if (snapshot != null) { + Display.getInstance().callSerially( + makeFailedRunnable(this, snapshot, error)); + } + } + + private Object[] listenerSnapshot() { + synchronized (listeners) { + if (listeners.isEmpty()) { + return null; + } + return listeners.toArray(); + } + } + + private boolean requireState(AsyncResource out, + WorkoutSessionState required, String what) { + if (state == required) { + return true; + } + failState(out, what); + return false; + } + + private void failState(AsyncResource out, String what) { + out.error(new HealthException(HealthError.SESSION_STATE, + "cannot " + what + " a workout in state " + state)); + } + + // Dispatch runnables are built in static methods so they carry no + // synthetic reference to the enclosing session (SpotBugs + // SIC_INNER_SHOULD_BE_STATIC_ANON). + + private static Runnable makeStateRunnable(final WorkoutSession session, + final Object[] listeners, final WorkoutSessionState state) { + return new Runnable() { + @Override + public void run() { + for (Object listener : listeners) { + ((WorkoutSessionListener) listener) + .workoutStateChanged(session, state); + } + } + }; + } + + private static Runnable makeStatsRunnable(final WorkoutSession session, + final Object[] listeners, final HealthDataType type) { + return new Runnable() { + @Override + public void run() { + for (Object listener : listeners) { + ((WorkoutSessionListener) listener) + .workoutStatisticsUpdated(session, type); + } + } + }; + } + + private static Runnable makeEventRunnable(final WorkoutSession session, + final Object[] listeners, final WorkoutEvent event) { + return new Runnable() { + @Override + public void run() { + for (Object listener : listeners) { + ((WorkoutSessionListener) listener) + .workoutEvent(session, event); + } + } + }; + } + + private static Runnable makeFailedRunnable(final WorkoutSession session, + final Object[] listeners, final HealthException error) { + return new Runnable() { + @Override + public void run() { + for (Object listener : listeners) { + ((WorkoutSessionListener) listener) + .workoutFailed(session, error); + } + } + }; + } +} diff --git a/CodenameOne/src/com/codename1/health/workout/WorkoutSessionListener.java b/CodenameOne/src/com/codename1/health/workout/WorkoutSessionListener.java new file mode 100644 index 00000000000..7a785477c19 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/workout/WorkoutSessionListener.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.workout; + +import com.codename1.health.HealthDataType; +import com.codename1.health.HealthException; + +/// Receives workout lifecycle and statistics updates. All callbacks arrive +/// on the EDT. +public interface WorkoutSessionListener { + + /// The session moved to a new state. + /// + /// Every transition is one the app asked for in this release, since + /// recorded sessions are the only kind here. The callback exists for + /// the unrequested ones a live session brings -- watchOS ends one when + /// the wearer starts another workout -- so that code written against + /// it keeps working when that arrives. + void workoutStateChanged(WorkoutSession session, + WorkoutSessionState state); + + /// A live statistic changed. Read the new value with + /// [WorkoutSession#getStatistic(HealthDataType, + /// com.codename1.health.AggregateMetric)]. + void workoutStatisticsUpdated(WorkoutSession session, + HealthDataType type); + + /// An event was recorded, whether by the app or detected by the + /// platform. + void workoutEvent(WorkoutSession session, WorkoutEvent event); + + /// The session failed and will not continue. + void workoutFailed(WorkoutSession session, HealthException error); +} diff --git a/CodenameOne/src/com/codename1/health/workout/WorkoutSessionState.java b/CodenameOne/src/com/codename1/health/workout/WorkoutSessionState.java new file mode 100644 index 00000000000..764c91c28e4 --- /dev/null +++ b/CodenameOne/src/com/codename1/health/workout/WorkoutSessionState.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.workout; + +/// The lifecycle of a [WorkoutSession]. +/// +/// Transitions are enforced: calling a method that does not apply to the +/// current state fails with +/// [com.codename1.health.HealthError#SESSION_STATE] rather than silently +/// doing nothing or throwing. +public enum WorkoutSessionState { + + /// Created but not yet prepared or started. + NOT_STARTED, + + /// Sensors are warming up. Optional -- see + /// [WorkoutSession#prepare()] -- and worth using if you show a + /// countdown, because heart rate is not immediately available when a + /// session begins. + PREPARING, + + /// Recording. + RUNNING, + + /// Paused. The elapsed clock stops; wall-clock time keeps passing and + /// is excluded from [WorkoutSession#getElapsedMillis()]. + PAUSED, + + /// Stopping: collection has ceased but the workout has not been + /// written yet. + STOPPED, + + /// Ended and persisted. + ENDED, + + /// Ended in error, or discarded. + FAILED +} diff --git a/CodenameOne/src/com/codename1/health/workout/package-info.java b/CodenameOne/src/com/codename1/health/workout/package-info.java new file mode 100644 index 00000000000..a636290824b --- /dev/null +++ b/CodenameOne/src/com/codename1/health/workout/package-info.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +/// Workout recording, live where the operating system supports it and +/// framework-recorded everywhere else. +/// +/// Start at [com.codename1.health.workout.WorkoutManager], reached from +/// [com.codename1.health.Health#getWorkouts()]. +/// +/// Check [com.codename1.health.workout.WorkoutManager#isLiveSessionSupported()] +/// and +/// [com.codename1.health.workout.WorkoutManager#isSensorCollectionSupported()] +/// before building UI around live statistics: on an Android phone a +/// workout records only the samples the app feeds it. +package com.codename1.health.workout; diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index a7df0eeff34..2bb4609352f 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -7373,6 +7373,17 @@ public com.codename1.bluetooth.Bluetooth getBluetooth() { return null; } + /// Returns the port-specific health entry point. Default + /// implementation returns {@code null}; ports that implement + /// {@link com.codename1.health.Health} override this to return a + /// cached singleton. Application code should use + /// {@link com.codename1.health.Health#getInstance()} instead of + /// calling this directly --- it transparently substitutes a no-op + /// fallback when the port returns {@code null}. + public com.codename1.health.Health getHealth() { + return null; + } + /// Allows buggy implementations (Android) to release image objects /// /// #### Parameters diff --git a/CodenameOne/src/com/codename1/impl/health/HealthChangePage.java b/CodenameOne/src/com/codename1/impl/health/HealthChangePage.java new file mode 100644 index 00000000000..fb043b7b37c --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/health/HealthChangePage.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.health; + +import com.codename1.health.HealthSample; + +import java.util.ArrayList; +import java.util.List; + +/// One page of changes drained from a polling backend. +/// +/// This is the decoded form of what the Health Connect bridge returns for +/// a change token. It is deliberately a plain carrier rather than a public +/// type: the portable vocabulary for a change is +/// [com.codename1.health.HealthChangeBatch], which this is turned into +/// once the subscription it belongs to is known. +public final class HealthChangePage { + + private final String nextToken; + private final boolean expired; + private final boolean more; + + final List added = new ArrayList(); + final List deletedIds = new ArrayList(); + + HealthChangePage(String nextToken, boolean expired, boolean more) { + this.nextToken = nextToken; + this.expired = expired; + this.more = more; + } + + /// The token to poll with next. Never null, though it may be empty + /// when the backend declined to issue one. + public String getNextToken() { + return nextToken == null ? "" : nextToken; + } + + /// True when the token used for this poll had aged out, in which case + /// the changes are incomplete and a full resync is required. Health + /// Connect expires tokens after 30 days. + public boolean isExpired() { + return expired; + } + + /// True when the backend has further pages queued behind this one. + public boolean hasMore() { + return more; + } + + /// Samples added or updated since the previous poll. + public List getAdded() { + return added; + } + + /// Identifiers of records deleted since the previous poll. + public List getDeletedIds() { + return deletedIds; + } +} diff --git a/CodenameOne/src/com/codename1/impl/health/HealthWire.java b/CodenameOne/src/com/codename1/impl/health/HealthWire.java new file mode 100644 index 00000000000..59758192aa1 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/health/HealthWire.java @@ -0,0 +1,707 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.health; + +import com.codename1.health.AggregateMetric; +import com.codename1.health.AggregateQuery; +import com.codename1.health.AggregateResult; +import com.codename1.health.HealthDataType; +import com.codename1.health.HealthDeleteRequest; +import com.codename1.health.HealthQuantity; +import com.codename1.health.HealthSample; +import com.codename1.health.HealthSource; +import com.codename1.health.HealthTimeRange; +import com.codename1.health.HealthUnit; +import com.codename1.health.HealthWriteResult; +import com.codename1.health.QuantitySample; +import com.codename1.health.SampleQuery; +import com.codename1.health.RecordingMethod; +import com.codename1.health.SamplePage; +import com.codename1.health.SeriesSample; + +import java.util.ArrayList; +import java.util.List; + +/// The wire format shared by the native health bridges. +/// +/// #### Why line-delimited text rather than JSON +/// +/// A year of continuous heart rate is on the order of half a million +/// samples. Parsing that as JSON on ParparVM allocates an object graph +/// several times the size of the data and will exhaust the heap on a +/// phone. Samples therefore cross the boundary as tab-separated lines, +/// which stream and parse with a bounded working set. JSON is used only +/// for cold, irregular payloads -- a query descriptor, per-sample metadata +/// -- where the volume is a handful of fields rather than a year of them. +/// +/// Both the Android bridge and the iOS native layer speak this format, so +/// the encoding and decoding live here rather than being written twice. +/// +/// #### Sample line +/// +/// ``` +/// id \t typeId \t startMillis \t endMillis \t value \t unitSymbol +/// \t sourceBundleId \t sourceName \t deviceName +/// ``` +/// +/// Fields after the unit are optional and may be empty. Unknown type ids +/// and unit symbols cause the line to be skipped rather than failing the +/// whole page, so a platform that gains a new record type does not break +/// an older app. +public final class HealthWire { + + private static final char FIELD = '\t'; + private static final char LINE = '\n'; + + private HealthWire() { + } + + /// Whether Health Connect has a record type for this data type. + /// + /// Kept alongside the wire format because the two travel together: a + /// type with no Health Connect equivalent can never appear in a + /// payload, and claiming to support it would produce queries the + /// bridge cannot answer. + /// Types the Health Connect bridge can actually read. + /// + /// Not every portable type: having a Health Connect *permission* is not + /// the same as having a record shape the bridge can turn into a sample. + /// Reporting the wider set made the store advertise types that passed + /// validation and then failed at read time with an invalid-argument + /// error, which is a worse answer than "not supported here". + /// + /// Kept in step with `CN1HealthConnectBridge.recordClassFor` by + /// `HealthBridgeTokenTableTest`, which parses the Kotlin and fails the + /// build when the two drift. + private static final String ANDROID_READABLE = + ",steps,distance_walking_running," + + "flights_climbed,elevation_gained," + + "active_energy,wheelchair_pushes,hydration,heart_rate," + + "resting_heart_rate,oxygen_saturation,respiratory_rate," + + "body_temperature,basal_body_temperature,vo2_max," + + "blood_glucose,body_mass,lean_body_mass,bone_mass," + + "body_fat_percentage,height,power,speed,cycling_cadence," + + "running_cadence,"; + + /// Types the bridge can write. Narrower than the readable set: the + /// series-shaped types have no single-value write form. + private static final String ANDROID_WRITABLE = + ",steps,distance_walking_running," + + "flights_climbed,elevation_gained," + + "active_energy,wheelchair_pushes,hydration,body_mass," + + "lean_body_mass,bone_mass,body_fat_percentage,height," + + "resting_heart_rate,oxygen_saturation,respiratory_rate," + + "body_temperature,basal_body_temperature,vo2_max," + + "blood_glucose,heart_rate,"; + + public static boolean isAndroidSupported(HealthDataType type) { + return type != null + && ANDROID_READABLE.indexOf("," + type.getId() + ",") >= 0; + } + + /// Whether the Health Connect bridge can write `type`. + public static boolean isAndroidWritable(HealthDataType type) { + return type != null + && ANDROID_WRITABLE.indexOf("," + type.getId() + ",") >= 0; + } + + /// Whether the Health Connect bridge can delete records of `type`. + /// + /// The same set it can read. Deletion goes by record class plus + /// identifier or range, so it needs a mapped record class and nothing + /// more -- unlike a write, which also needs a single-value form the + /// series-shaped types do not have. + public static boolean isAndroidDeletable(HealthDataType type) { + return isAndroidSupported(type); + } + + // ------------------------------------------------------------------ + // samples + // ------------------------------------------------------------------ + + /// Encodes samples as tab-separated lines for a write. + /// + /// A [SeriesSample] is written out point by point. Neither platform + /// accepts a series through this payload -- HealthKit has no series + /// save at all here, and the Health Connect bridge inserts discrete + /// records -- so the choice was between persisting the measurements + /// and persisting nothing. Skipping the sample outright is what it did + /// before, and because both ports hand this payload straight to their + /// bridge, an all-series write produced an empty batch that completed + /// successfully with no identifiers: the caller was told the write + /// worked while none of the data reached the store. The points survive; + /// the record identity does not, which is why the returned result + /// carries one identifier per point rather than one per series. + /// + /// Shapes this cannot carry at all are reported by + /// [#unsupportedForWrite(List)] so a port can refuse the write rather + /// than report a success it did not perform. + public static String encodeSamples(List samples) { + StringBuilder sb = new StringBuilder(); + for (HealthSample s : samples) { + if (s instanceof SeriesSample) { + SeriesSample series = (SeriesSample) s; + for (int i = 0; i < series.size(); i++) { + appendSample(sb, series.toQuantitySample(i)); + } + continue; + } + if (!(s instanceof QuantitySample)) { + continue; + } + appendSample(sb, (QuantitySample) s); + } + return sb.toString(); + } + + private static void appendSample(StringBuilder sb, QuantitySample q) { + HealthUnit unit = q.getQuantity().getUnit(); + sb.append(nullToEmpty(q.getId())).append(FIELD) + .append(q.getType().getId()).append(FIELD) + .append(q.getStartMillis()).append(FIELD) + .append(q.getEndMillis()).append(FIELD) + .append(q.getQuantity().getRawValue()).append(FIELD) + .append(unit.getSymbol()).append(FIELD) + .append(q.getRecordingMethod().name()).append(LINE); + } + + /// The first sample [#encodeSamples(List)] would drop, or null. + /// + /// Sessions and categories have no line in this format. They were + /// documented as being encoded by the port-specific bridges, which is + /// not what either port does -- both pass this payload through + /// unchanged -- so a sleep or workout sample handed to a mobile write + /// vanished and the write still resolved successfully. A port calls + /// this first and fails the write instead. + public static HealthSample unsupportedForWrite( + List samples) { + for (HealthSample s : samples) { + if (!(s instanceof QuantitySample) + && !(s instanceof SeriesSample)) { + return s; + } + } + return null; + } + + /// Decodes a page of tab-separated sample lines. + /// + /// Malformed and unrecognised lines are skipped rather than failing + /// the page: one unparseable record out of fifty thousand should cost + /// that record, not the whole read. + public static SamplePage decodeSamplePage(String payload) { + List out = new ArrayList(); + if (payload == null || payload.length() == 0) { + return new SamplePage(out, null, false); + } + String nextToken = null; + boolean truncated = false; + int start = 0; + while (start < payload.length()) { + int end = payload.indexOf(LINE, start); + if (end < 0) { + end = payload.length(); + } + String line = payload.substring(start, end); + start = end + 1; + if (line.length() > 0 && line.charAt(0) == PAGE_MARKER) { + // Trailer: the platform's continuation state. Without it + // every page claimed to be the last complete one, so the + // documented paging loop could never fetch the rest and a + // long history was silently cut off at the first limit. + String[] f = split(line.substring(1)); + if (f.length > 0 && f[0].length() > 0) { + nextToken = f[0]; + } + truncated = f.length > 1 && "1".equals(f[1]); + continue; + } + HealthSample s = line.length() > 0 + && line.charAt(0) == SERIES_MARKER + ? decodeSeriesLine(line.substring(1)) + : decodeSampleLine(line); + if (s != null) { + out.add(s); + } + } + return new SamplePage(out, nextToken, truncated); + } + + /// First character of a line carrying a whole series record. + /// + /// A bridge emits one only when the query turned flattening off. The + /// default stays one line per measurement, which is both the common + /// case and the cheaper one to parse. + public static final char SERIES_MARKER = '~'; + + /// Separates the measurements of a series. + private static final char POINT = ','; + + /// Separates a measurement's start, end and value. + private static final char POINT_FIELD = ':'; + + /// Appends a series as a single line: the shared fields, then the + /// measurements packed into the last one. + /// + /// The values ride in one field rather than in extra columns because a + /// series can hold tens of thousands of points and the decoder walks + /// them without splitting the line into that many strings. + public static void appendSeries(StringBuilder sb, SeriesSample series, + String sourceBundleId, String sourceName, String deviceName) { + sb.append(SERIES_MARKER) + .append(nullToEmpty(series.getId())).append(FIELD) + .append(series.getType().getId()).append(FIELD) + .append(series.getStartMillis()).append(FIELD) + .append(series.getEndMillis()).append(FIELD) + .append(series.size()).append(FIELD) + .append(series.getUnit().getSymbol()).append(FIELD) + .append(nullToEmpty(sourceBundleId)).append(FIELD) + .append(nullToEmpty(sourceName)).append(FIELD) + .append(nullToEmpty(deviceName)).append(FIELD) + .append(series.getRecordingMethod().name()).append(FIELD); + for (int i = 0; i < series.size(); i++) { + if (i > 0) { + sb.append(POINT); + } + sb.append(series.getSampleStartMillis(i)).append(POINT_FIELD) + .append(series.getSampleEndMillis(i)).append(POINT_FIELD) + .append(series.getSampleValue(i, series.getUnit())); + } + sb.append(LINE); + } + + private static HealthSample decodeSeriesLine(String line) { + String[] f = split(line); + if (f.length < 11) { + return null; + } + HealthDataType type = HealthDataType.forId(f[1]); + HealthUnit unit = HealthUnit.forSymbol(f[5]); + if (type == null || unit == null) { + return null; + } + try { + int count = Integer.parseInt(f[4]); + long[] starts = new long[count]; + long[] ends = new long[count]; + double[] values = new double[count]; + if (!parsePoints(f[10], starts, ends, values)) { + return null; + } + SeriesSample series = SeriesSample.create(type, + Long.parseLong(f[2]), Long.parseLong(f[3]), starts, ends, + values, unit); + if (f[0].length() > 0) { + series.setId(f[0]); + } + if (f[6].length() > 0) { + series.setSource(new HealthSource(f[6], emptyToNull(f[7]), + emptyToNull(f[8]), null, null)); + } + RecordingMethod m = recordingMethod(f[9]); + if (m != null) { + series.setRecordingMethod(m); + } + return series; + } catch (NumberFormatException ex) { + return null; + } catch (IllegalArgumentException ex) { + return null; + } + } + + /// Fills the arrays from the packed measurement field. + /// + /// Returns false when the field holds a different number of points + /// than the count column claimed, which is the one inconsistency that + /// would otherwise produce a series whose tail is silently zeroes. + private static boolean parsePoints(String packed, long[] starts, + long[] ends, double[] values) { + int index = 0; + int at = 0; + while (at < packed.length() && index < starts.length) { + int next = packed.indexOf(POINT, at); + if (next < 0) { + next = packed.length(); + } + int a = packed.indexOf(POINT_FIELD, at); + if (a < 0 || a > next) { + return false; + } + int b = packed.indexOf(POINT_FIELD, a + 1); + if (b < 0 || b > next) { + return false; + } + starts[index] = Long.parseLong(packed.substring(at, a)); + ends[index] = Long.parseLong(packed.substring(a + 1, b)); + values[index] = Double.parseDouble(packed.substring(b + 1, next)); + index++; + at = next + 1; + } + return index == starts.length && at >= packed.length(); + } + + /// First character of the optional trailer line carrying paging state. + /// + /// A marker rather than a fixed position because ports stream sample + /// lines as they read them and only learn the continuation state at + /// the end. + public static final char PAGE_MARKER = '#'; + + /// Decodes the change page produced by the Health Connect bridge. + /// + /// The first line carries the next token, whether the previous token + /// had expired and whether more pages remain. Every line after it is a + /// change: `+` followed by an ordinary sample line, or `-` followed by + /// the identifier of a deleted record. + /// + /// A change line this build cannot decode is skipped rather than + /// failing the page, matching [#decodeSamplePage(String)]. The page is + /// never silently emptied, though: an unreadable header yields a null + /// return so the caller can leave its cursor where it was rather than + /// advancing past changes it never saw. + public static HealthChangePage decodeChangePage(String payload) { + if (payload == null || payload.length() == 0) { + return null; + } + int nl = payload.indexOf(LINE); + if (nl < 0) { + return null; + } + String[] head = split(payload.substring(0, nl)); + if (head.length < 3) { + return null; + } + HealthChangePage page = new HealthChangePage(head[0], + "1".equals(head[1]), "1".equals(head[2])); + int start = nl + 1; + while (start < payload.length()) { + int end = payload.indexOf(LINE, start); + if (end < 0) { + end = payload.length(); + } + String line = payload.substring(start, end); + start = end + 1; + if (line.length() < 2) { + continue; + } + char op = line.charAt(0); + String body = line.substring(2); + if (op == '-') { + if (body.trim().length() > 0) { + page.deletedIds.add(body.trim()); + } + } else if (op == '+') { + HealthSample s = decodeSampleLine(body); + if (s != null) { + page.added.add(s); + } + } + } + return page; + } + + private static RecordingMethod recordingMethod(String token) { + if ("MANUAL_ENTRY".equals(token)) { + return RecordingMethod.MANUAL_ENTRY; + } + if ("AUTOMATIC".equals(token)) { + return RecordingMethod.AUTOMATIC; + } + if ("ACTIVE".equals(token)) { + return RecordingMethod.ACTIVELY_RECORDED; + } + return null; + } + + private static HealthSample decodeSampleLine(String line) { + if (line == null || line.trim().length() == 0) { + return null; + } + String[] f = split(line); + if (f.length < 6) { + return null; + } + HealthDataType type = HealthDataType.forId(f[1]); + HealthUnit unit = HealthUnit.forSymbol(f[5]); + if (type == null || unit == null) { + // A record type or unit this build does not know. Skipping is + // the right call: an older app reading a newer store should + // lose the unfamiliar rows, not the familiar ones. + return null; + } + try { + long startMillis = Long.parseLong(f[2]); + long endMillis = Long.parseLong(f[3]); + double value = Double.parseDouble(f[4]); + QuantitySample s = startMillis == endMillis + ? QuantitySample.create(type, + new HealthQuantity(value, unit), startMillis) + : QuantitySample.create(type, + new HealthQuantity(value, unit), startMillis, + endMillis); + if (f[0].length() > 0) { + s.setId(f[0]); + } + if (f.length > 6 && f[6].length() > 0) { + s.setSource(new HealthSource(f[6], + f.length > 7 ? emptyToNull(f[7]) : null, + f.length > 8 ? emptyToNull(f[8]) : null, null, + null)); + } + if (f.length > 9) { + // Field 9, its own column. Overloading field 7 -- the + // source display name -- made getSource().getName() report + // "AUTOMATIC" as the app that wrote the sample. + RecordingMethod m = recordingMethod(f[9]); + if (m != null) { + s.setRecordingMethod(m); + } + } + return s; + } catch (NumberFormatException ex) { + return null; + } catch (IllegalArgumentException ex) { + // An interval-only type sent as an instant, for example. + return null; + } + } + + // ------------------------------------------------------------------ + // requests + // ------------------------------------------------------------------ + + /// Encodes a read as a small JSON object. Cold and bounded, so JSON is + /// the right trade here. + public static String encodeSampleQuery(SampleQuery query) { + HealthTimeRange range = query.getTimeRange() + .resolve(System.currentTimeMillis()); + StringBuilder sb = new StringBuilder("{\"types\":["); + List types = query.getTypes(); + for (int i = 0; i < types.size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append('"').append(types.get(i).getId()).append('"'); + } + sb.append("],\"start\":").append(range.getStartMillis()) + .append(",\"end\":").append(range.getEndMillis()) + .append(",\"limit\":").append(query.getLimit()) + .append(",\"descending\":").append(query.isSortDescending()) + // The bridge decides record shape, so it needs this. Leaving it + // out meant a query that asked to keep its series whole got one + // scalar line per measurement anyway, and the option the API + // documents was honoured nowhere. + .append(",\"flatten\":").append(query.isFlattenSeries()); + if (query.getPageToken() != null) { + sb.append(",\"pageToken\":\"") + .append(escape(query.getPageToken())).append('"'); + } + List sources = query.getSources(); + if (!sources.isEmpty()) { + sb.append(",\"sources\":["); + for (int i = 0; i < sources.size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append('"').append(escape(sources.get(i))).append('"'); + } + sb.append(']'); + } + return sb.append('}').toString(); + } + + /// Encodes an aggregate request, including the already-computed bucket + /// boundaries so the bridge never has to reason about calendars. + public static String encodeAggregateQuery(AggregateQuery query, + long[] boundaries) { + StringBuilder sb = new StringBuilder("{\"types\":["); + List types = query.getTypes(); + for (int i = 0; i < types.size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append('"').append(types.get(i).getId()).append('"'); + } + sb.append("],\"metrics\":["); + List metrics = query.getMetrics(); + for (int i = 0; i < metrics.size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append('"').append(metrics.get(i).name()).append('"'); + } + sb.append("],\"buckets\":["); + for (int i = 0; i < boundaries.length; i++) { + if (i > 0) { + sb.append(','); + } + sb.append(boundaries[i]); + } + return sb.append("]}").toString(); + } + + /// Encodes a delete request. + public static String encodeDeleteRequest(HealthDeleteRequest request) { + // The type list is emitted for both shapes. Health Connect deletes by + // record class plus id and cannot resolve a bare id, so the id form + // needs the type just as much as the range form does. + StringBuilder sb = new StringBuilder("{\"types\":["); + List types = request.getTypes(); + for (int i = 0; i < types.size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append('"').append(types.get(i).getId()).append('"'); + } + sb.append(']'); + if (request.isById()) { + sb.append(",\"ids\":["); + List ids = request.getSampleIds(); + for (int i = 0; i < ids.size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append('"').append(escape(ids.get(i))).append('"'); + } + sb.append(']'); + } else { + HealthTimeRange range = request.getTimeRange() + .resolve(System.currentTimeMillis()); + sb.append(",\"start\":").append(range.getStartMillis()) + .append(",\"end\":").append(range.getEndMillis()); + } + return sb.append('}').toString(); + } + + // ------------------------------------------------------------------ + // results + // ------------------------------------------------------------------ + + /// Decodes the ids assigned by a write, one per line. + public static HealthWriteResult decodeWriteResult(String payload) { + HealthWriteResult result = new HealthWriteResult(); + if (payload == null) { + return result; + } + int start = 0; + while (start < payload.length()) { + int end = payload.indexOf(LINE, start); + if (end < 0) { + end = payload.length(); + } + String id = payload.substring(start, end).trim(); + if (id.length() > 0) { + result.addSampleId(id); + } + start = end + 1; + } + return result; + } + + /// Decodes aggregate results, one line per bucket: + /// `bucketIndex \t typeId \t metric \t value \t unitSymbol`. + /// + /// Buckets the platform reported nothing for are left empty rather + /// than being filled with zeros -- the distinction the whole aggregate + /// contract rests on. + public static List decodeAggregates(String payload, + AggregateQuery query, long[] boundaries) { + List out = new ArrayList(); + for (int i = 0; i + 1 < boundaries.length; i++) { + out.add(new AggregateResult(boundaries[i], boundaries[i + 1])); + } + if (payload == null || out.isEmpty()) { + return out; + } + int start = 0; + while (start < payload.length()) { + int end = payload.indexOf(LINE, start); + if (end < 0) { + end = payload.length(); + } + applyAggregateLine(out, payload.substring(start, end)); + start = end + 1; + } + return out; + } + + private static void applyAggregateLine(List buckets, + String line) { + if (line == null || line.trim().length() == 0) { + return; + } + String[] f = split(line); + if (f.length < 5) { + return; + } + try { + int index = Integer.parseInt(f[0]); + if (index < 0 || index >= buckets.size()) { + return; + } + HealthDataType type = HealthDataType.forId(f[1]); + HealthUnit unit = HealthUnit.forSymbol(f[4]); + if (type == null || unit == null) { + return; + } + AggregateMetric metric = AggregateMetric.valueOf(f[2]); + buckets.get(index).put(type, metric, + new HealthQuantity(Double.parseDouble(f[3]), unit)); + } catch (RuntimeException ex) { + // Unknown metric name, unparseable number: skip this line. + } + } + + // ------------------------------------------------------------------ + + private static String[] split(String line) { + List parts = new ArrayList(); + int start = 0; + while (true) { + int idx = line.indexOf(FIELD, start); + if (idx < 0) { + parts.add(line.substring(start)); + break; + } + parts.add(line.substring(start, idx)); + start = idx + 1; + } + return parts.toArray(new String[parts.size()]); + } + + private static String nullToEmpty(String s) { + return s == null ? "" : s; + } + + private static String emptyToNull(String s) { + return s == null || s.length() == 0 ? null : s; + } + + private static String escape(String s) { + if (s == null) { + return ""; + } + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/CodenameOne/src/com/codename1/impl/health/LocalHealth.java b/CodenameOne/src/com/codename1/impl/health/LocalHealth.java new file mode 100644 index 00000000000..b2b89d6b742 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/health/LocalHealth.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.health; + +import com.codename1.health.Health; +import com.codename1.health.HealthAvailability; +import com.codename1.health.HealthStore; + +/// The health entry point for ports with no platform health provider: the +/// desktop ports, the JavaScript port, and the simulator's default state. +/// +/// Reports [HealthAvailability#LOCAL_ONLY] rather than pretending to be a +/// platform store, so an app can tell the difference between "this data is +/// what other apps and devices recorded" and "this data is only what I +/// wrote myself". +/// +/// Workouts and Bluetooth sensors come from the shared base class +/// unchanged: recorded workouts already work everywhere, and the sensor +/// layer rides on `com.codename1.bluetooth.le`, which these ports do have. +public class LocalHealth extends Health { + + private final LocalHealthStore store; + + /// Creates a local health entry point backed by a store that survives + /// a restart. + /// + /// Durability is what [HealthAvailability#LOCAL_ONLY] promises on the + /// ports that use this constructor -- the data is only ever this app's + /// own, which is not the same as being gone next launch. The simulator + /// passes its own scripted store to the other constructor precisely so + /// it does *not* get this. + public LocalHealth() { + this(new StoredHealthStore()); + } + + /// Creates a local health entry point backed by `store`, so a port can + /// supply a persisting or scriptable subclass. + public LocalHealth(LocalHealthStore store) { + this.store = store == null ? new LocalHealthStore() : store; + } + + /// Answered by the store rather than hard-coded, so the simulator's + /// "make health unavailable" action is visible where an app actually + /// checks. Hard-coding it meant an app that branched on + /// [#getAvailability()] -- which the guide tells it to do first -- + /// never reached its unavailable-provider path and discovered the + /// simulation only when an operation failed. + @Override + public boolean isSupported() { + return store.isSupported(); + } + + @Override + public HealthAvailability getAvailability() { + return store.isSupported() ? HealthAvailability.LOCAL_ONLY + : HealthAvailability.NOT_SUPPORTED; + } + + @Override + public HealthStore getStore() { + return store; + } +} diff --git a/CodenameOne/src/com/codename1/impl/health/LocalHealthCodec.java b/CodenameOne/src/com/codename1/impl/health/LocalHealthCodec.java new file mode 100644 index 00000000000..f33a43552e7 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/health/LocalHealthCodec.java @@ -0,0 +1,542 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.health; + +import com.codename1.health.BloodPressureSample; +import com.codename1.health.CategorySample; +import com.codename1.health.HealthDataType; +import com.codename1.health.HealthQuantity; +import com.codename1.health.HealthSample; +import com.codename1.health.HealthSource; +import com.codename1.health.HealthUnit; +import com.codename1.health.QuantitySample; +import com.codename1.health.RecordingMethod; +import com.codename1.health.SeriesSample; +import com.codename1.health.SessionSample; +import com.codename1.health.SleepSample; +import com.codename1.health.SleepStage; +import com.codename1.health.SleepStageInterval; +import com.codename1.health.WorkoutActivityType; +import com.codename1.health.WorkoutSample; +import com.codename1.health.nutrition.Nutrient; +import com.codename1.health.nutrition.NutritionSample; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Serializes the local store's samples so they survive a restart. +/// +/// #### Why not the wire format +/// +/// [HealthWire] exists to cross a native boundary and carries only what +/// HealthKit and Health Connect accept through the sample write path: +/// quantities and series, with series flattened to their measurements. A +/// local store holds more than that -- workouts, sleep sessions with their +/// stages, nutrition, categories, blood pressure -- and persisting through +/// the wire format would have quietly dropped every one of those and +/// dissolved each series record into loose points. Silently losing the +/// shapes that only work locally is worse than not persisting at all, +/// because the app is told the write succeeded. +/// +/// #### Format +/// +/// One line per record, tab-separated, with a version marker on the first +/// line so a later format can be told apart from this one rather than +/// misread as it. Every string leaf is escaped, because titles, notes and +/// metadata are free text and a tab in a note would otherwise shift every +/// field after it. +/// +/// A line this build cannot read -- an unknown shape, a data type dropped +/// from a later release -- is skipped rather than aborting the restore, so +/// one bad record costs one record. +final class LocalHealthCodec { + + /// Bumped only when a line stops meaning what it used to. A reader + /// that does not recognise the marker restores nothing rather than + /// guessing. + static final String VERSION = "cn1health/1"; + + private static final char FIELD = '\t'; + private static final char ITEM = ','; + private static final char PART = ':'; + + private LocalHealthCodec() { + } + + // ------------------------------------------------------------------ + // encoding + // ------------------------------------------------------------------ + + static String encode(List samples) { + StringBuilder sb = new StringBuilder(); + sb.append(VERSION).append('\n'); + for (HealthSample s : samples) { + String line = encodeOne(s); + if (line != null) { + sb.append(line).append('\n'); + } + } + return sb.toString(); + } + + private static String encodeOne(HealthSample s) { + String shape = shapeOf(s); + if (shape == null || s.getType() == null) { + return null; + } + StringBuilder sb = new StringBuilder(); + sb.append(shape).append(FIELD); + sb.append(esc(s.getType().getId())).append(FIELD); + sb.append(s.getStartMillis()).append(FIELD); + sb.append(s.getEndMillis()).append(FIELD); + sb.append(esc(s.getId())).append(FIELD); + HealthSource src = s.getSource(); + sb.append(esc(src == null ? null : src.getBundleId())).append(FIELD); + sb.append(esc(src == null ? null : src.getName())).append(FIELD); + sb.append(esc(src == null ? null : src.getDeviceName())).append(FIELD); + sb.append(esc(src == null ? null : src.getDeviceModel())).append(FIELD); + sb.append(esc(src == null ? null : src.getDeviceManufacturer())) + .append(FIELD); + sb.append(s.getRecordingMethod() == null + ? "" : s.getRecordingMethod().name()).append(FIELD); + appendMetadata(sb, s); + sb.append(FIELD); + appendPayload(sb, s, shape); + return sb.toString(); + } + + private static void appendMetadata(StringBuilder sb, HealthSample s) { + boolean first = true; + for (Map.Entry e : s.getMetadata().entrySet()) { + if (!first) { + sb.append(ITEM); + } + first = false; + sb.append(esc(e.getKey())).append(PART).append(esc(e.getValue())); + } + } + + private static void appendPayload(StringBuilder sb, HealthSample s, + String shape) { + if ("Q".equals(shape)) { + HealthQuantity q = ((QuantitySample) s).getQuantity(); + sb.append(esc(q.getUnit().getSymbol())).append(FIELD) + .append(q.getRawValue()); + return; + } + if ("C".equals(shape)) { + sb.append(((CategorySample) s).getValue()); + return; + } + if ("S".equals(shape)) { + SeriesSample series = (SeriesSample) s; + HealthUnit unit = series.getUnit(); + sb.append(esc(unit.getSymbol())).append(FIELD); + for (int i = 0; i < series.size(); i++) { + if (i > 0) { + sb.append(ITEM); + } + sb.append(series.getSampleStartMillis(i)).append(PART) + .append(series.getSampleEndMillis(i)).append(PART) + .append(series.getSampleValue(i, unit)); + } + return; + } + if ("SL".equals(shape)) { + SleepSample sleep = (SleepSample) s; + appendSessionText(sb, sleep); + List stages = sleep.getStages(); + for (int i = 0; i < stages.size(); i++) { + if (i > 0) { + sb.append(ITEM); + } + SleepStageInterval iv = stages.get(i); + sb.append(iv.getStage().name()).append(PART) + .append(iv.getStartMillis()).append(PART) + .append(iv.getEndMillis()); + } + return; + } + if ("W".equals(shape)) { + WorkoutSample w = (WorkoutSample) s; + appendSessionText(sb, w); + sb.append(w.getActivityType() == null + ? "" : w.getActivityType().name()).append(FIELD); + sb.append(w.getPlatformCode()).append(FIELD); + // The raw field, not the getter: the getter substitutes the + // wall duration when nothing was reported, and persisting that + // would turn "not measured" into a measurement on the way back. + sb.append(w.getActiveDurationMillis() == w.getDurationMillis() + ? -1 : w.getActiveDurationMillis()).append(FIELD); + appendQuantity(sb, w.getTotalEnergy()); + sb.append(FIELD); + appendQuantity(sb, w.getTotalDistance()); + return; + } + if ("N".equals(shape)) { + NutritionSample n = (NutritionSample) s; + appendSessionText(sb, n); + sb.append(n.getMealType()).append(FIELD); + List nutrients = n.getNutrients(); + for (int i = 0; i < nutrients.size(); i++) { + if (i > 0) { + sb.append(ITEM); + } + Nutrient nut = nutrients.get(i); + HealthQuantity q = n.getNutrient(nut); + sb.append(esc(nut.getId())).append(PART) + .append(esc(q.getUnit().getSymbol())).append(PART) + .append(q.getRawValue()); + } + // Appended after the nutrients rather than beside the meal + // type, so a store written before this field existed still + // decodes -- it simply has no food name, which is what it had. + sb.append(FIELD).append(esc(n.getFoodName())); + return; + } + if ("BP".equals(shape)) { + BloodPressureSample bp = (BloodPressureSample) s; + appendQuantity(sb, bp.getSystolic()); + sb.append(FIELD); + appendQuantity(sb, bp.getDiastolic()); + sb.append(FIELD); + appendQuantity(sb, bp.getPulse()); + sb.append(FIELD).append(bp.getBodyPosition()) + .append(FIELD).append(bp.getMeasurementLocation()); + } + } + + private static void appendSessionText(StringBuilder sb, + SessionSample s) { + sb.append(esc(s.getTitle())).append(FIELD) + .append(esc(s.getNotes())).append(FIELD); + } + + private static void appendQuantity(StringBuilder sb, HealthQuantity q) { + if (q == null) { + sb.append("").append(FIELD).append(""); + return; + } + sb.append(esc(q.getUnit().getSymbol())).append(FIELD) + .append(q.getRawValue()); + } + + private static String shapeOf(HealthSample s) { + if (s instanceof BloodPressureSample) { + return "BP"; + } + if (s instanceof NutritionSample) { + return "N"; + } + if (s instanceof WorkoutSample) { + return "W"; + } + if (s instanceof SleepSample) { + return "SL"; + } + if (s instanceof SeriesSample) { + return "S"; + } + if (s instanceof CategorySample) { + return "C"; + } + if (s instanceof QuantitySample) { + return "Q"; + } + return null; + } + + // ------------------------------------------------------------------ + // decoding + // ------------------------------------------------------------------ + + static List decode(String blob) { + List out = new ArrayList(); + if (blob == null || blob.length() == 0) { + return out; + } + List lines = split(blob, '\n'); + if (lines.isEmpty() || !VERSION.equals(lines.get(0))) { + return out; + } + for (int i = 1; i < lines.size(); i++) { + String line = lines.get(i); + if (line.length() == 0) { + continue; + } + HealthSample s = decodeOne(line); + if (s != null) { + out.add(s); + } + } + return out; + } + + private static HealthSample decodeOne(String line) { + try { + List f = split(line, FIELD); + if (f.size() < 12) { + return null; + } + String shape = f.get(0); + HealthDataType type = HealthDataType.forId(unesc(f.get(1))); + if (type == null) { + return null; + } + long start = Long.parseLong(f.get(2)); + long end = Long.parseLong(f.get(3)); + HealthSample s = decodeShape(shape, type, start, end, f); + if (s == null) { + return null; + } + s.setId(unesc(f.get(4))); + String bundle = unesc(f.get(5)); + if (bundle != null) { + s.setSource(new HealthSource(bundle, unesc(f.get(6)), + unesc(f.get(7)), unesc(f.get(8)), unesc(f.get(9)))); + } + if (f.get(10).length() > 0) { + s.setRecordingMethod(RecordingMethod.valueOf(f.get(10))); + } + for (Map.Entry e + : parsePairs(f.get(11)).entrySet()) { + s.putMetadata(e.getKey(), e.getValue()); + } + return s; + } catch (RuntimeException ex) { + // One unreadable record costs one record. Restoring is a + // best-effort recovery of the app's own data, and refusing the + // whole file over a single bad line would turn a small loss + // into a total one. + return null; + } + } + + private static HealthSample decodeShape(String shape, + HealthDataType type, long start, long end, List f) { + if ("Q".equals(shape)) { + return QuantitySample.create(type, quantity(f, 12), start, end); + } + if ("C".equals(shape)) { + return CategorySample.create(type, Integer.parseInt(f.get(12)), + start, end); + } + if ("S".equals(shape)) { + return decodeSeries(type, start, end, f); + } + if ("SL".equals(shape)) { + SleepSample sleep = SleepSample.create(start, end); + applySessionText(sleep, f); + for (String item : split(f.get(14), ITEM)) { + List p = split(item, PART); + if (p.size() == 3) { + sleep.addStage(new SleepStageInterval( + SleepStage.valueOf(p.get(0)), + Long.parseLong(p.get(1)), + Long.parseLong(p.get(2)))); + } + } + return sleep; + } + if ("W".equals(shape)) { + WorkoutSample w = WorkoutSample.create( + WorkoutActivityType.valueOf(f.get(14)), start, end); + applySessionText(w, f); + w.setPlatformCode(Integer.parseInt(f.get(15))); + w.setActiveDurationMillis(Long.parseLong(f.get(16))); + w.setTotalEnergy(quantity(f, 17)); + w.setTotalDistance(quantity(f, 19)); + return w; + } + if ("N".equals(shape)) { + NutritionSample n = NutritionSample.create(start, end); + applySessionText(n, f); + n.setMealType(Integer.parseInt(f.get(14))); + for (String item : split(f.get(15), ITEM)) { + List p = split(item, PART); + if (p.size() == 3) { + Nutrient nut = Nutrient.forId(unesc(p.get(0))); + HealthUnit unit = HealthUnit.forSymbol(unesc(p.get(1))); + if (nut != null && unit != null) { + n.setNutrient(nut, Double.parseDouble(p.get(2)), + unit); + } + } + } + if (f.size() > 16) { + n.setFoodName(unesc(f.get(16))); + } + return n; + } + if ("BP".equals(shape)) { + BloodPressureSample bp = BloodPressureSample.create( + quantity(f, 12), quantity(f, 14), start); + bp.setPulse(quantity(f, 16)); + bp.setBodyPosition(Integer.parseInt(f.get(18))); + bp.setMeasurementLocation(Integer.parseInt(f.get(19))); + return bp; + } + return null; + } + + private static HealthSample decodeSeries(HealthDataType type, long start, + long end, List f) { + HealthUnit unit = HealthUnit.forSymbol(unesc(f.get(12))); + if (unit == null) { + return null; + } + List points = split(f.get(13), ITEM); + List spans = new ArrayList(); + List values = new ArrayList(); + for (String point : points) { + List p = split(point, PART); + if (p.size() != 3) { + continue; + } + spans.add(new long[] { + Long.parseLong(p.get(0)), Long.parseLong(p.get(1)), + }); + values.add(Double.valueOf(Double.parseDouble(p.get(2)))); + } + if (spans.isEmpty()) { + return null; + } + long[] starts = new long[spans.size()]; + long[] ends = new long[spans.size()]; + double[] vals = new double[spans.size()]; + for (int i = 0; i < spans.size(); i++) { + starts[i] = spans.get(i)[0]; + ends[i] = spans.get(i)[1]; + vals[i] = values.get(i).doubleValue(); + } + return SeriesSample.create(type, start, end, starts, ends, vals, + unit); + } + + private static void applySessionText(SessionSample s, List f) { + s.setTitle(unesc(f.get(12))); + s.setNotes(unesc(f.get(13))); + } + + /// A quantity written as a unit field followed by a value field, or + /// two empty fields when it was never measured. + private static HealthQuantity quantity(List f, int at) { + if (at + 1 >= f.size() || f.get(at).length() == 0) { + return null; + } + HealthUnit unit = HealthUnit.forSymbol(unesc(f.get(at))); + if (unit == null) { + return null; + } + return new HealthQuantity(Double.parseDouble(f.get(at + 1)), unit); + } + + private static Map parsePairs(String field) { + Map out = new HashMap(); + for (String item : split(field, ITEM)) { + List p = split(item, PART); + if (p.size() == 2) { + out.put(unesc(p.get(0)), unesc(p.get(1))); + } + } + return out; + } + + // ------------------------------------------------------------------ + // escaping + // ------------------------------------------------------------------ + + /// Escapes every separator this format uses, plus the escape character + /// itself. A null becomes the empty string and reads back as null, + /// which is why an empty string cannot be stored as a title -- it is + /// indistinguishable from not having one, and no caller can tell the + /// difference either. + private static String esc(String s) { + if (s == null || s.length() == 0) { + return ""; + } + StringBuilder sb = new StringBuilder(s.length() + 8); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '\\': sb.append("\\\\"); break; + case '\t': sb.append("\\t"); break; + case '\n': sb.append("\\n"); break; + case '\r': sb.append("\\r"); break; + case ',': sb.append("\\c"); break; + case ':': sb.append("\\o"); break; + default: sb.append(c); + } + } + return sb.toString(); + } + + private static String unesc(String s) { + if (s == null || s.length() == 0) { + return null; + } + StringBuilder sb = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c != '\\' || i + 1 >= s.length()) { + sb.append(c); + continue; + } + char n = s.charAt(++i); + switch (n) { + case 't': sb.append('\t'); break; + case 'n': sb.append('\n'); break; + case 'r': sb.append('\r'); break; + case 'c': sb.append(','); break; + case 'o': sb.append(':'); break; + default: sb.append(n); + } + } + return sb.toString(); + } + + /// Splits on a separator without treating an escaped one as a break, + /// and keeps trailing empty fields -- which `String.split` drops and + /// a record whose last field is empty depends on. + private static List split(String s, char sep) { + List out = new ArrayList(); + StringBuilder cur = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '\\' && i + 1 < s.length()) { + cur.append(c).append(s.charAt(++i)); + continue; + } + if (c == sep) { + out.add(cur.toString()); + cur.setLength(0); + continue; + } + cur.append(c); + } + out.add(cur.toString()); + return out; + } +} diff --git a/CodenameOne/src/com/codename1/impl/health/LocalHealthStore.java b/CodenameOne/src/com/codename1/impl/health/LocalHealthStore.java new file mode 100644 index 00000000000..9fce0ed989a --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/health/LocalHealthStore.java @@ -0,0 +1,610 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.health; + +import com.codename1.health.AggregateMetric; +import com.codename1.health.AggregateQuery; +import com.codename1.health.AggregateResult; +import com.codename1.health.HealthAggregationStyle; +import com.codename1.health.HealthAuthorizationStatus; +import com.codename1.health.HealthDataType; +import com.codename1.health.HealthException; +import com.codename1.health.HealthError; +import com.codename1.health.HealthDeleteRequest; +import com.codename1.health.HealthSample; +import com.codename1.health.HealthStore; +import com.codename1.health.HealthTimeRange; +import com.codename1.health.HealthWriteResult; +import com.codename1.health.BloodPressureSample; +import com.codename1.health.CategorySample; +import com.codename1.health.HealthQuantity; +import com.codename1.health.QuantitySample; +import com.codename1.health.SeriesSample; +import com.codename1.health.SessionSample; +import com.codename1.health.SleepSample; +import com.codename1.health.WorkoutSample; +import com.codename1.health.nutrition.Nutrient; +import com.codename1.health.nutrition.NutritionSample; +import com.codename1.health.SampleQuery; +import com.codename1.health.SamplePage; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +/// A health store held in the app's own process, for ports with no +/// platform health provider -- the desktop ports, the JavaScript port, and +/// the simulator's starting state. +/// +/// #### What this is and is not +/// +/// It is a real store: reads, writes, deletes and aggregates all work, and +/// on ports that persist it the data survives a restart. That makes it +/// genuinely useful -- aggregation logic, chart code and unit handling can +/// all be developed and unit-tested on a laptop. +/// +/// It is **not** a platform health store, and it reports +/// [com.codename1.health.HealthAvailability#LOCAL_ONLY] so apps can tell. +/// Nothing else writes into it: no watch, no scale, no other app. An app +/// whose whole purpose is reading what other apps recorded should tell the +/// user the feature needs a phone rather than showing an empty chart. +/// +/// Subclasses supply persistence; this base keeps everything in memory, +/// which is the right behaviour for a simulator that should start clean. +public class LocalHealthStore extends HealthStore { + + /// Held across a mutation *and* the persist it triggers, so the two + /// are one transaction. + /// + /// `samples` alone is not enough: it is released between changing the + /// list and encoding it, so two concurrent mutations could each + /// snapshot, then write in the opposite order, and the older snapshot + /// landed last. Both callers were told they had succeeded and a + /// deleted record came back on the next launch. + /// + /// Always taken *before* `samples`, never the other way round. Reads + /// take `samples` on its own and do not come here, so they are not + /// blocked by a save. + private final Object mutationLock = new Object(); + + private final List samples = new ArrayList(); + private long nextId = 1; + + @Override + public boolean isSupported() { + return true; + } + + /// Every type is available locally: there is no platform to restrict + /// what can be stored. + @Override + public boolean isTypeSupported(HealthDataType type) { + return type != null; + } + + @Override + public List getSupportedTypes() { + return HealthDataType.values(); + } + + @Override + public boolean isWritable(HealthDataType type) { + return type != null; + } + + @Override + public boolean isDeletable(HealthDataType type) { + return type != null; + } + + /// Every metric is computed here in shared code rather than delegated + /// to a platform engine. + @Override + public List getSupportedMetrics(HealthDataType type) { + List out = new ArrayList(); + if (type == null) { + return out; + } + out.add(AggregateMetric.COUNT); + out.add(AggregateMetric.DURATION); + if (type.getAggregationStyle() == HealthAggregationStyle.CUMULATIVE) { + out.add(AggregateMetric.TOTAL); + } else if (type.getAggregationStyle() + == HealthAggregationStyle.DISCRETE) { + out.add(AggregateMetric.AVERAGE); + out.add(AggregateMetric.MINIMUM); + out.add(AggregateMetric.MAXIMUM); + out.add(AggregateMetric.LATEST); + } + return out; + } + + /// There is no permission model on a local store, so both directions + /// are honestly reported as authorized rather than as unknown. + @Override + public HealthAuthorizationStatus getReadAuthorizationStatus( + HealthDataType type) { + return HealthAuthorizationStatus.AUTHORIZED; + } + + @Override + public HealthAuthorizationStatus getWriteAuthorizationStatus( + HealthDataType type) { + return HealthAuthorizationStatus.AUTHORIZED; + } + + @Override + protected void doRequestAuthorization( + List access, + AsyncResource out) { + completeInline(out, Boolean.TRUE); + } + + // ------------------------------------------------------------------ + // reads + // ------------------------------------------------------------------ + + @Override + protected void doReadSamples(SampleQuery query, + AsyncResource out) { + HealthTimeRange range = + query.getTimeRange().resolve(System.currentTimeMillis()); + List matched = new ArrayList(); + synchronized (samples) { + for (HealthSample s : samples) { + if (isVisible(s) && matches(s, query, range)) { + // A copy, not the stored object. Query results are + // snapshots: handing out the live record let caller + // code mutate the store through setId/setSource/ + // putMetadata and see the change on later reads. + matched.add(snapshot(s)); + } + } + } + sort(matched, query.isSortDescending()); + boolean truncated = matched.size() > query.getLimit(); + while (matched.size() > query.getLimit()) { + matched.remove(matched.size() - 1); + } + // Everything is in memory, so a single page always satisfies the + // query; there is no continuation token to hand back. + completeInline(out, new SamplePage(matched, null, truncated)); + } + + private boolean matches(HealthSample s, SampleQuery query, + HealthTimeRange range) { + if (!query.getTypes().contains(s.getType())) { + return false; + } + // A sample belongs to the range when it overlaps it, so an + // interval straddling the boundary is not silently dropped. + // + // Half-open at both ends: an interval ending exactly at the start + // has zero overlap and belongs to the previous range, so steps + // ending at midnight are yesterday's. An instantaneous sample at + // the inclusive start is still inside. + if (s.getStartMillis() >= range.getEndMillis()) { + return false; + } + if (s.isInstantaneous() ? s.getEndMillis() < range.getStartMillis() + : s.getEndMillis() <= range.getStartMillis()) { + return false; + } + List sources = query.getSources(); + if (!sources.isEmpty()) { + if (s.getSource() == null + || !sources.contains(s.getSource().getBundleId())) { + return false; + } + } + return true; + } + + /// Completes inline, on the calling thread. + /// + /// **This does not match the mobile ports, and that is a known gap.** + /// `IOSHealth` marshals through `Display.callSerially` and + /// `AndroidHealthStore` through `AndroidHealth.onEdt`, so on a phone + /// every callback does arrive on the EDT as + /// [com.codename1.health.Health] promises. Here it arrives on whichever + /// thread called. + /// + /// Marshalling this store the same way is written and reverted: moving + /// the completion onto the EDT makes the whole result chain -- + /// paging, unit normalization, series flattening -- run there too, and + /// anything those throw escapes the EDT runnable instead of failing + /// the resource, so the caller waits forever. That is a real defect in + /// the completion path rather than in the hop, and it wants fixing + /// first. Until then, inline delivery is the behaviour that cannot + /// hang, and the developer guide says so rather than promising the + /// EDT everywhere. + private static void completeInline(AsyncResource out, Object value) { + out.complete(value); + } + + /// Copies a stored sample so callers cannot mutate the store. + /// + /// Quantity samples are the only shape this store holds; anything else + /// is returned as-is, which is still an improvement on handing back + /// the stored instance for the common case. + private static HealthSample snapshot(HealthSample s) { + HealthSample copy = copyShape(s); + if (copy == null) { + // A shape this build cannot copy. Sharing the caller's object + // is worse than refusing the write: it would let a later edit + // silently rewrite stored data, including the identifier a + // delete matches on. + throw new IllegalArgumentException(s.getClass().getName() + + " cannot be stored locally: this build has no copy" + + " for that sample shape"); + } + copy.setId(s.getId()); + copy.setSource(s.getSource()); + copy.setRecordingMethod(s.getRecordingMethod()); + // Title and notes live on SessionSample, above the shapes that + // carry them, so every per-shape branch missed them and a titled + // workout or sleep session lost both the moment it was written. + if (s instanceof SessionSample && copy instanceof SessionSample) { + SessionSample from = (SessionSample) s; + SessionSample to = (SessionSample) copy; + to.setTitle(from.getTitle()); + to.setNotes(from.getNotes()); + } + for (Map.Entry e : s.getMetadata().entrySet()) { + copy.putMetadata(e.getKey(), e.getValue()); + } + return copy; + } + + /// A copy of `s` carrying its shape-specific state, or null when the + /// shape is unknown to this build. + /// + /// Every shape, not only [QuantitySample]. The common setters -- + /// identifier, source, metadata -- exist on all of them, and a series, + /// a workout or a nutrition entry has mutable state of its own on top. + /// Returning the caller's object left the store aliased to it, so + /// editing a sample after writing it silently changed what was stored, + /// and editing one that came back from a read did the same. Losing the + /// identifier that way breaks deletion by id. + private static HealthSample copyShape(HealthSample s) { + if (s instanceof QuantitySample) { + QuantitySample q = (QuantitySample) s; + return q.isInstantaneous() + ? QuantitySample.create(q.getType(), q.getQuantity(), + q.getStartMillis()) + : QuantitySample.create(q.getType(), q.getQuantity(), + q.getStartMillis(), q.getEndMillis()); + } + if (s instanceof SeriesSample) { + SeriesSample series = (SeriesSample) s; + int n = series.size(); + long[] starts = new long[n]; + long[] ends = new long[n]; + double[] values = new double[n]; + for (int i = 0; i < n; i++) { + starts[i] = series.getSampleStartMillis(i); + ends[i] = series.getSampleEndMillis(i); + values[i] = series.getSampleValue(i, series.getUnit()); + } + return SeriesSample.create(series.getType(), + series.getStartMillis(), series.getEndMillis(), starts, + ends, values, series.getUnit()); + } + if (s instanceof BloodPressureSample) { + BloodPressureSample bp = (BloodPressureSample) s; + BloodPressureSample copy = BloodPressureSample.create( + bp.getSystolic(), bp.getDiastolic(), bp.getStartMillis()); + copy.setPulse(bp.getPulse()); + copy.setBodyPosition(bp.getBodyPosition()); + copy.setMeasurementLocation(bp.getMeasurementLocation()); + return copy; + } + if (s instanceof CategorySample) { + CategorySample c = (CategorySample) s; + return c.isInstantaneous() + ? CategorySample.create(c.getType(), c.getValue(), + c.getStartMillis()) + : CategorySample.create(c.getType(), c.getValue(), + c.getStartMillis(), c.getEndMillis()); + } + if (s instanceof SleepSample) { + SleepSample sleep = (SleepSample) s; + return SleepSample.create(sleep.getStartMillis(), + sleep.getEndMillis(), sleep.getStages()); + } + if (s instanceof WorkoutSample) { + WorkoutSample w = (WorkoutSample) s; + WorkoutSample copy = WorkoutSample.create(w.getActivityType(), + w.getStartMillis(), w.getEndMillis()); + copy.setPlatformCode(w.getPlatformCode()); + copy.setTotalEnergy(w.getTotalEnergy()); + copy.setTotalDistance(w.getTotalDistance()); + copy.setActiveDurationMillis(w.getActiveDurationMillis()); + return copy; + } + if (s instanceof NutritionSample) { + NutritionSample n = (NutritionSample) s; + NutritionSample copy = n.isInstantaneous() + ? NutritionSample.create(n.getStartMillis()) + : NutritionSample.create(n.getStartMillis(), + n.getEndMillis()); + for (Nutrient nutrient : n.getNutrients()) { + HealthQuantity amount = n.getNutrient(nutrient); + if (amount != null) { + copy.setNutrient(nutrient, amount.getRawValue(), + amount.getUnit()); + } + } + copy.setMealType(n.getMealType()); + copy.setFoodName(n.getFoodName()); + return copy; + } + return null; + } + + private static void sort(List list, + final boolean descending) { + Collections.sort(list, new Comparator() { + @Override + public int compare(HealthSample a, HealthSample b) { + long d = a.getStartMillis() - b.getStartMillis(); + int r = d < 0 ? -1 : (d > 0 ? 1 : 0); + return descending ? -r : r; + } + }); + } + + // ------------------------------------------------------------------ + // aggregates + // ------------------------------------------------------------------ + + @Override + protected void doAggregate(AggregateQuery query, long[] boundaries, + AsyncResource> out) { + List snapshot = new ArrayList(); + synchronized (samples) { + for (HealthSample s : samples) { + if (isVisible(s)) { + snapshot.add(s); + } + } + } + completeInline(out, aggregateSamples(query, boundaries, snapshot)); + } + + + // ------------------------------------------------------------------ + // writes + // ------------------------------------------------------------------ + + @Override + protected void doWrite(List toWrite, + AsyncResource out) { + HealthWriteResult result = new HealthWriteResult(); + List added = new ArrayList(); + boolean failed = false; + synchronized (mutationLock) { + synchronized (samples) { + for (HealthSample s : toWrite) { + String id = "local-" + (nextId++); + // The identifier goes on the stored copy only. Stamping + // the caller's object made this store the one place a + // write mutated its input -- and HealthSample.hashCode() + // switches from identity to the id once it is set, so a + // sample already in a HashSet or used as a map key became + // unreachable the moment it was written. The id reaches + // the caller through HealthWriteResult, as it does on + // every other platform. + // + // Stored as a copy: keeping the caller's object meant a + // later setId/setSource/putMetadata on it silently rewrote + // the stored record, and could make deleting by the + // returned id fail. + HealthSample stored = snapshot(s); + stored.setId(id); + samples.add(stored); + added.add(stored); + result.addSampleId(id); + } + } + if (!persist()) { + // Rolled back rather than kept. `Storage.writeObject` reports + // a full or unwritable store by returning false, and a caller + // told the write succeeded would have a record that exists + // only until the process exits -- on the very ports whose + // whole claim is durability. Undoing it keeps memory and disk + // saying the same thing. + synchronized (samples) { + samples.removeAll(added); + } + failed = true; + } + } + if (failed) { + failStorage(out); + return; + } + completeInline(out, result); + } + + /// Fails an operation that could not be made durable. + /// + /// `DATABASE_INACCESSIBLE` because that is the retryable "the store + /// would not take it" answer callers already handle from the phones; + /// a local store that is full or read-only is the same situation. + private static void failStorage(AsyncResource out) { + out.error(new HealthException(HealthError.DATABASE_INACCESSIBLE, + "the local health store could not be written; the change" + + " was rolled back")); + } + + @Override + protected void doDelete(HealthDeleteRequest request, + AsyncResource out) { + int removed = 0; + boolean failed = false; + List dropped = new ArrayList(); + synchronized (mutationLock) { + synchronized (samples) { + if (request.isById()) { + List ids = request.getSampleIds(); + // The type is part of the request, and matching on the + // identifier alone ignored it. Health Connect deletes + // against a record class, so a stale or mispaired type and + // id removes nothing there while this store removed the + // record anyway -- the simulator being more destructive + // than the platform it stands in for. + List types = request.getTypes(); + for (int i = samples.size() - 1; i >= 0; i--) { + HealthSample s = samples.get(i); + if (ids.contains(s.getId()) + && (types.isEmpty() + || types.contains(s.getType()))) { + dropped.add(samples.remove(i)); + removed++; + } + } + } else { + HealthTimeRange range = request.getTimeRange() + .resolve(System.currentTimeMillis()); + for (int i = samples.size() - 1; i >= 0; i--) { + HealthSample s = samples.get(i); + if (request.getTypes().contains(s.getType()) + && s.getStartMillis() >= range.getStartMillis() + && s.getStartMillis() < range.getEndMillis()) { + dropped.add(samples.remove(i)); + removed++; + } + } + } + } + if (removed > 0 && !persist()) { + synchronized (samples) { + samples.addAll(dropped); + } + failed = true; + } + } + if (failed) { + failStorage(out); + return; + } + completeInline(out, Integer.valueOf(removed)); + } + + // ------------------------------------------------------------------ + // extension points + // ------------------------------------------------------------------ + + /// Called after every mutation. + /// + /// #### Returns + /// + /// Whether the store is durable as a result. The in-memory base has + /// nothing to write and nothing to promise, so it answers `true`; a + /// persisting subclass answers `false` when the write did not land, + /// and the mutation that triggered it is rolled back and reported as + /// a failure rather than acknowledged as stored. + protected boolean persist() { + return true; + } + + /// Whether `s` is visible to reads and aggregates at all. + /// + /// The simulator hides the records of a type scripted to yield + /// nothing, and it has to happen *here* rather than by filtering the + /// answer: a page is sorted and cut to the limit before it is + /// returned, so a hidden record that sorts first would otherwise eat + /// a slot and a limit-one query could come back empty while a visible + /// record sat behind it. Records nobody can see must not compete for + /// the budget. + protected boolean isVisible(HealthSample s) { + return true; + } + + /// Inserts a sample without going through validation, for a subclass + /// restoring persisted data or a simulator seeding a scripted dataset. + protected final void addSampleDirect(HealthSample sample) { + if (sample == null) { + return; + } + synchronized (samples) { + if (sample.getId() == null) { + sample.setId("local-" + (nextId++)); + } else { + // A restored sample brings its own identifier back, and + // the counter has to clear it or the first write after a + // restart hands out an id something already holds -- + // making a delete-by-id remove the wrong record. + reserveId(sample.getId()); + } + samples.add(sample); + } + } + + /// Moves the generator past an identifier that already exists. + private void reserveId(String id) { + if (!id.startsWith("local-")) { + return; + } + try { + long n = Long.parseLong(id.substring("local-".length())); + if (n >= nextId) { + nextId = n + 1; + } + } catch (NumberFormatException ex) { + // Not one of ours; nothing to reserve. + } + } + + /// Every stored sample, for subclasses persisting the store. + protected final List getAllSamples() { + synchronized (samples) { + return new ArrayList(samples); + } + } + + /// Discards everything. + public final void clear() { + List before; + synchronized (mutationLock) { + synchronized (samples) { + before = new ArrayList(samples); + samples.clear(); + } + if (!persist()) { + // No result to fail -- this returns void -- so the least + // dishonest answer is to leave the store as it was rather + // than empty in memory and full on disk. + synchronized (samples) { + samples.addAll(before); + } + com.codename1.io.Log.p("CN1 Health: the local store could not" + + " be cleared on disk, so it was left as it was"); + } + } + } +} diff --git a/CodenameOne/src/com/codename1/impl/health/StoredHealthStore.java b/CodenameOne/src/com/codename1/impl/health/StoredHealthStore.java new file mode 100644 index 00000000000..5d873cf54d3 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/health/StoredHealthStore.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.health; + +import com.codename1.health.HealthSample; +import com.codename1.io.Log; +import com.codename1.io.Storage; + +import java.util.List; + +/// The local store, kept across restarts. +/// +/// This is what the Windows, Linux and JavaScript ports get. Those report +/// [com.codename1.health.HealthAvailability#LOCAL_ONLY], which says the +/// data is only ever this app's own -- not that it evaporates when the +/// process exits. Without this the base class's `persist` hook was a no-op +/// and every write on those ports was lost on the next launch, while the +/// developer guide promised durability. +/// +/// The simulator deliberately does not use this: its dataset is scripted, +/// and a scripted run that leaked into the next one would make the +/// simulator's whole point -- a known starting state -- untrue. +/// +/// #### It is not encrypted +/// +/// `Storage` is plain on-device storage. There is no OS-level protected +/// health store to hand this to on a desktop or in a browser -- that is +/// precisely why these ports report `LOCAL_ONLY` rather than pretending to +/// be HealthKit. Treat it as suitable for an app's own recorded data, not +/// as a place to mirror somebody's medical history. +public class StoredHealthStore extends LocalHealthStore { + + /// The whole store rides in one entry. It is rewritten on every + /// mutation, which is the right trade for a store that exists so + /// desktop and browser apps can develop against real aggregation: + /// datasets are small, and a per-record scheme would have to solve + /// compaction and partial-write recovery for no benefit at this size. + private static final String KEY = "cn1$health$local"; + + private boolean loaded; + + /// The last blob known to be on disk. + /// + /// Held because `Storage.writeObject` *deletes* the entry when the + /// write fails -- so a failed save does not leave the previous + /// contents in place, it destroys them. Without this, one full disk + /// took every older record with it while the caller was told only + /// that the latest write had failed. + private String lastGood; + + public StoredHealthStore() { + restore(); + } + + private void restore() { + String blob = null; + try { + Object stored = Storage.getInstance().readObject(KEY); + if (stored instanceof String) { + blob = (String) stored; + } + } catch (RuntimeException ex) { + // A store that cannot be read is an empty one. Failing + // construction here would take the whole Health facade down + // with it, which is a worse answer than starting fresh. + Log.p("CN1 Health: could not read the local store, starting" + + " empty (" + ex + ")"); + } + List restored = LocalHealthCodec.decode(blob); + for (HealthSample s : restored) { + addSampleDirect(s); + } + lastGood = blob; + // Only after the restore, so the writes it performs do not each + // rewrite the file they are being read from. + loaded = true; + } + + @Override + protected boolean persist() { + if (!loaded) { + return true; + } + String blob = null; + try { + // The return value matters: Storage reports a full or + // unwritable store by answering false rather than throwing, + // and ignoring it let a write be acknowledged as durable when + // it only ever reached memory. The caller sees the failure + // now, and the change is rolled back. + blob = LocalHealthCodec.encode(getAllSamples()); + if (writeBlob(blob)) { + lastGood = blob; + return true; + } + } catch (RuntimeException ex) { + Log.p("CN1 Health: could not persist the local store (" + ex + + ")"); + } + restoreLastGood(); + return false; + } + + /// The single call that touches storage. + /// + /// Isolated so the failure this class exists to survive can be + /// reproduced in a test: `Storage.writeObject` deletes the entry and + /// answers false when it cannot write, and that combination is the + /// whole reason the previous blob has to be put back. + protected boolean writeBlob(String blob) { + return Storage.getInstance().writeObject(KEY, blob); + } + + /// Puts the previous contents back after a failed save. + /// + /// `Storage.writeObject` deletes the entry when it fails, so by the + /// time it answers false the older data is already gone -- rolling + /// the in-memory change back is not enough on its own, and one full + /// disk would otherwise cost every record the app had ever written + /// rather than the one write that failed. + private void restoreLastGood() { + if (lastGood == null) { + return; + } + try { + if (writeBlob(lastGood)) { + Log.p("CN1 Health: the local store could not be written;" + + " the previous contents were put back"); + return; + } + } catch (RuntimeException ex) { + // Falls through to the warning below. + } + Log.p("CN1 Health: the local store could not be written and the" + + " previous contents could not be put back either; the" + + " app's health data is now only in memory"); + } +} diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 83e61ccb8e3..9e49f217241 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -4828,6 +4828,14 @@ public com.codename1.bluetooth.Bluetooth getBluetooth() { return impl.getBluetooth(); } + /// Returns the platform health entry point. Prefer + /// {@link com.codename1.health.Health#getInstance()} in application + /// code --- it handles the fallback to a no-op stub when the current + /// port does not implement health data. + public com.codename1.health.Health getHealth() { + return impl.getHealth(); + } + /// This method tries to invoke the device native camera to capture images. /// The method returns immediately and the response will be sent asynchronously /// to the given ActionListener Object @@ -7036,7 +7044,11 @@ public Throwable getCause() { return cause; } - public void setCause(Throwable t) { + // Overrides nothing -- Throwable has no setCause, and adding the + // annotation PMD asks for does not compile. Suppressed rather than + // "fixed": this file only reaches the analyser at all because the + // health entry point was added to it. + public void setCause(Throwable t) { //NOPMD MissingOverride this.cause = t; } diff --git a/CodenameOne/src/com/codename1/util/AsyncResource.java b/CodenameOne/src/com/codename1/util/AsyncResource.java index bb82c9b8373..38848ea69b2 100644 --- a/CodenameOne/src/com/codename1/util/AsyncResource.java +++ b/CodenameOne/src/com/codename1/util/AsyncResource.java @@ -306,14 +306,26 @@ public V get(final int timeout) throws InterruptedException { @Override public void update(Observable obj, Object arg) { if (isDone()) { - complete[0] = true; + // The flag is set inside the monitor, not beside it. + // Set outside, a waiter could read it as false, and + // only then be beaten to the monitor by this notify -- + // which it never hears, because it is not waiting yet. synchronized (complete) { + complete[0] = true; complete.notifyAll(); } } } }; addObserver(observer); + if (isDone()) { + // Completed between the check at the top and the observer + // being attached. That notification went to nobody, so + // without this the wait below has nothing left to wake it. + synchronized (complete) { + complete[0] = true; + } + } while (!complete[0]) { if (timeout > 0 && System.currentTimeMillis() > startTime + timeout) { @@ -324,6 +336,15 @@ public void update(Observable obj, Object arg) { @Override public void run() { synchronized (complete) { + // Re-checked holding the monitor. The test in + // the while condition is made without it, so + // between that test and this wait the resource + // can complete and notify an empty monitor -- + // and with no timeout the wait is then + // permanent. + if (complete[0]) { + return; + } if (timeout > 0) { Util.wait(complete, (int) Math.max(1, timeout - (System.currentTimeMillis() - startTime))); } else { @@ -334,6 +355,9 @@ public void run() { }); } else { synchronized (complete) { + if (complete[0]) { + break; + } if (timeout > 0) { Util.wait(complete, (int) Math.max(1, timeout - (System.currentTimeMillis() - startTime))); } else { diff --git a/Ports/Android/spotbugs-exclude.xml b/Ports/Android/spotbugs-exclude.xml index 927f5fa7eb5..19fb16632a5 100644 --- a/Ports/Android/spotbugs-exclude.xml +++ b/Ports/Android/spotbugs-exclude.xml @@ -481,4 +481,20 @@ + + + + + + + + + diff --git a/Ports/Android/src/com/codename1/health/HealthPermissionsRationaleActivity.java b/Ports/Android/src/com/codename1/health/HealthPermissionsRationaleActivity.java new file mode 100644 index 00000000000..3256b051c67 --- /dev/null +++ b/Ports/Android/src/com/codename1/health/HealthPermissionsRationaleActivity.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.widget.Toast; + +/// Shows the app's privacy policy when the user asks why it wants health +/// data. +/// +/// #### Not optional +/// +/// Health Connect will not present its consent dialog at all to an app +/// that does not declare an activity handling +/// `androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE`. An app missing this +/// does not get a rejected permission request -- it gets a permission +/// request that silently never appears, which is considerably harder to +/// diagnose. Codename One declares it automatically whenever the build +/// detects health usage. +/// +/// #### Why it lives in `com.codename1.health` rather than `impl.android` +/// +/// It is referenced by name from the manifest, so it needs a stable, +/// declared class name -- the same reason +/// `com.codename1.location.CodenameOneBackgroundLocationActivity` sits +/// outside the implementation package. +/// +/// The policy URL comes from the `android.health.privacyPolicyUrl` build +/// hint, which the Android builder requires. +public class HealthPermissionsRationaleActivity extends Activity { + + /// The generated string resource holding the configured policy URL. + private static final String URL_RESOURCE = "cn1_health_privacy_policy"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + String url = resolvePolicyUrl(); + if (url != null && url.length() > 0) { + try { + startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); + } catch (Throwable t) { + // No browser, or a malformed URL. Telling the user + // something is better than a blank screen -- they arrived + // here by explicitly asking why we want their health data. + Toast.makeText(this, url, Toast.LENGTH_LONG).show(); + } + } else { + Toast.makeText(this, + "This app reads health data to provide its features.", + Toast.LENGTH_LONG).show(); + } + finish(); + } + + /// Reads the configured policy URL from generated resources, or null. + private String resolvePolicyUrl() { + try { + int id = getResources().getIdentifier(URL_RESOURCE, "string", + getPackageName()); + if (id != 0) { + return getString(id); + } + } catch (Throwable ignored) { + // Fall through to the generic message below. + } + return null; + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidHealth.java b/Ports/Android/src/com/codename1/impl/android/AndroidHealth.java new file mode 100644 index 00000000000..38a92dd963a --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AndroidHealth.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android; + +import android.content.Intent; +import android.net.Uri; + +import com.codename1.health.Health; +import com.codename1.health.HealthAvailability; +import com.codename1.health.HealthStore; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +/// The Android health entry point, backed by Health Connect through the +/// injected [HealthConnectDelegate]. +/// +/// Reports the provider's real state, which matters more on Android than +/// elsewhere: Health Connect ships as a separate app on Android 13 and +/// below and can be missing or out of date, and both are recoverable by +/// the user through [#openProviderSetup()]. +class AndroidHealth extends Health { + + private final AndroidHealthStore store = new AndroidHealthStore(); + + public boolean isSupported() { + return AndroidHealthSupport.getDelegate() != null; + } + + public HealthAvailability getAvailability() { + HealthConnectDelegate d = AndroidHealthSupport.getDelegate(); + if (d == null) { + return HealthAvailability.NOT_SUPPORTED; + } + switch (d.sdkStatus()) { + case HealthConnectDelegate.SDK_AVAILABLE: + return HealthAvailability.AVAILABLE; + case HealthConnectDelegate.SDK_UPDATE_REQUIRED: + return HealthAvailability.PROVIDER_UPDATE_REQUIRED; + default: + return HealthAvailability.PROVIDER_NOT_INSTALLED; + } + } + + public HealthStore getStore() { + return store; + } + + /// Opens Health Connect's own permission screen for this app. + public AsyncResource openHealthSettings() { + AsyncResource out = new AsyncResource(); + try { + Intent i = new Intent( + "androidx.health.ACTION_HEALTH_CONNECT_SETTINGS"); + AndroidNativeUtil.getContext().startActivity( + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + out.complete(Boolean.TRUE); + } catch (Throwable t) { + out.complete(Boolean.FALSE); + } + return out; + } + + /// Sends the user to install or update the Health Connect provider. + /// + /// The `healthconnect://onboarding` referrer is what makes the Play + /// listing land on the provider's setup flow rather than a bare app + /// page. + public AsyncResource openProviderSetup() { + AsyncResource out = new AsyncResource(); + HealthConnectDelegate d = AndroidHealthSupport.getDelegate(); + String pkg = d == null ? "com.google.android.apps.healthdata" + : d.providerPackageName(); + try { + Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse( + "market://details?id=" + pkg + + "&url=healthconnect%3A%2F%2Fonboarding")); + i.setPackage("com.android.vending"); + i.putExtra("overlay", true); + i.putExtra("callerId", + AndroidNativeUtil.getContext().getPackageName()); + AndroidNativeUtil.getContext().startActivity( + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + out.complete(Boolean.TRUE); + } catch (Throwable t) { + out.complete(Boolean.FALSE); + } + return out; + } + + public java.util.List getConfigurationProblems() { + java.util.List problems = new java.util.ArrayList(); + if (AndroidHealthSupport.getDelegate() == null) { + problems.add("No Health Connect bridge is registered. The build " + + "server injects one when the app references " + + "com.codename1.health; if you are seeing this on a " + + "device build, check that android.health.read or " + + "android.health.write is declared."); + } + return problems; + } + + /// Marshals a bridge callback onto the EDT, since the bridge completes + /// on a coroutine dispatcher. + static void onEdt(Runnable r) { + Display.getInstance().callSerially(r); + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidHealthStore.java b/Ports/Android/src/com/codename1/impl/android/AndroidHealthStore.java new file mode 100644 index 00000000000..c4ede5f000c --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AndroidHealthStore.java @@ -0,0 +1,950 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android; + +import com.codename1.health.AggregateMetric; +import com.codename1.health.AggregateQuery; +import com.codename1.health.AggregateResult; +import com.codename1.health.HealthAccess; +import com.codename1.health.HealthAnchor; +import com.codename1.health.HealthChangeBatch; +import com.codename1.health.HealthAuthorizationStatus; +import com.codename1.health.HealthDataType; +import com.codename1.health.SubscriptionRequest; +import com.codename1.health.HealthDeleteRequest; +import com.codename1.health.HealthError; +import com.codename1.health.HealthException; +import com.codename1.health.HealthSample; +import com.codename1.health.HealthStore; +import com.codename1.health.HealthSubscription; +import com.codename1.health.HealthWriteResult; +import com.codename1.health.SampleQuery; +import com.codename1.health.SamplePage; +import com.codename1.impl.health.HealthChangePage; +import com.codename1.impl.health.HealthWire; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/// The Health Connect-backed store, driving the injected +/// [HealthConnectDelegate]. +/// +/// Everything crossing the bridge is a String or a primitive, so no +/// AndroidX or Kotlin type reaches the port -- see +/// [HealthConnectDelegate] for why. Results come back on a coroutine +/// dispatcher and are marshalled onto the EDT here, so the shared +/// [HealthStore] contract holds on Android as everywhere else. +class AndroidHealthStore extends HealthStore { + + /// Refreshed after every authorization flow, and once at startup + /// from Health Connect itself. + private final Set granted = new HashSet(); + + /// True once the delegate has answered, so an empty set can be told + /// apart from "nothing granted". + private boolean grantsLoaded; + private boolean grantsRequested; + + private HealthConnectDelegate delegate() { + return AndroidHealthSupport.getDelegate(); + } + + public boolean isSupported() { + HealthConnectDelegate d = delegate(); + return d != null + && d.sdkStatus() == HealthConnectDelegate.SDK_AVAILABLE; + } + + public boolean isTypeSupported(HealthDataType type) { + return type != null && isSupported() + && HealthWire.isAndroidSupported(type); + } + + public List getSupportedTypes() { + List out = new ArrayList(); + for (HealthDataType t : HealthDataType.values()) { + if (isTypeSupported(t)) { + out.add(t); + } + } + return out; + } + + /// Narrower than readable: the series-shaped types have no + /// single-value write form in the bridge. + public boolean isWritable(HealthDataType type) { + return isTypeSupported(type) && HealthWire.isAndroidWritable(type); + } + + /// Deletion is not writing. + /// + /// `deleteRecords` removes by record class and identifier or range, + /// and the bridge maps a record class for every readable type -- so + /// the four series-shaped types have no single-value write form yet + /// are perfectly deletable. Answering from the write table made + /// capability-gated code refuse to delete exactly the record + /// identities `setFlattenSeries(false)` exists to hand out. + public boolean isDeletable(HealthDataType type) { + return isTypeSupported(type) && HealthWire.isAndroidDeletable(type); + } + + /// Empty: nothing is computed natively yet. + /// + /// Health Connect does have an aggregate API, and this listed what it + /// could compute -- but `doAggregate` is deliberately not overridden + /// (see the note below it), so every one of those metrics is actually + /// derived by the shared base class from raw samples read back with + /// an effectively unbounded limit. A caller using this to decide that + /// a year-wide aggregate would stay platform-side got a large read + /// and the allocation that comes with it. + /// + /// The iOS store answers the same way, for the same reason. When the + /// bridge grows a real aggregate path this is where it gets + /// advertised, and not before. + public List getSupportedMetrics(HealthDataType type) { + return new ArrayList(); + } + + /// Health Connect caps a single insert at 1000 records. + public int getMaxWriteBatchSize() { + return 1000; + } + + /// Health Connect never wakes the app -- there is no push mechanism at + /// all, so subscriptions are drained when the app runs. + public boolean isPushDelivery() { + return false; + } + + /// False, like [#isPushDelivery()]. + /// + /// The base method's contract is that changes arrive without the app + /// asking. Nothing here does that: Health Connect has no push at all, + /// and no lifecycle hook drains on the app's behalf, so a subscription + /// delivers exactly when `drainChanges()` is called and never + /// otherwise. Answering true let an app branch away from scheduling + /// its own drain and then receive nothing -- the same silence as + /// having no data, which is the confusion this API exists to avoid. + public boolean isBackgroundDeliverySupported() { + return false; + } + + /// Unlike iOS, Android can answer this honestly: read access is an + /// ordinary runtime grant. + public HealthAuthorizationStatus getReadAuthorizationStatus( + HealthDataType type) { + return authStatus(type, false); + } + + public HealthAuthorizationStatus getWriteAuthorizationStatus( + HealthDataType type) { + return authStatus(type, true); + } + + private HealthAuthorizationStatus authStatus(HealthDataType type, + boolean write) { + if (!isSupported() || type == null) { + return HealthAuthorizationStatus.NOT_SUPPORTED; + } + // Capability first. One Health Connect permission maps back to + // several portable tokens, so a granted READ_DISTANCE made + // DISTANCE_CYCLING look AUTHORIZED even though the bridge has no + // record class for it and every read of it is refused -- and the + // readable-but-unwritable series types answered about a write they + // could never perform. + if (write ? !isWritable(type) : !isTypeSupported(type)) { + return HealthAuthorizationStatus.NOT_SUPPORTED; + } + refreshGrants(); + synchronized (granted) { + if (!grantsLoaded) { + // The delegate has not answered yet. Saying DENIED here + // would be a guess; NOT_DETERMINED is the honest state. + return HealthAuthorizationStatus.NOT_DETERMINED; + } + return granted.contains((write ? "w:" : "r:") + type.getId()) + ? HealthAuthorizationStatus.AUTHORIZED + : HealthAuthorizationStatus.DENIED; + } + } + + /// Asks Health Connect what is already granted, once per process. + /// + /// Without this the set starts empty after every restart and the + /// synchronous status API reports NOT_DETERMINED for permissions the + /// user granted long ago -- becoming accurate only after the app + /// presents the authorization flow again. + private void refreshGrants() { + HealthConnectDelegate d = delegate(); + synchronized (granted) { + if (grantsRequested || d == null) { + return; + } + grantsRequested = true; + } + d.grantedPermissions(new GrantsCallback(this)); + } + + /// Refreshes the grant snapshot and resolves `out` once it has landed. + /// + /// Falls back to resolving immediately when there is no delegate to + /// ask: the flow did complete, and blocking on a refresh that can + /// never arrive would be worse than an unrefreshed cache. + private void refreshGrantsThen(final AsyncResource out) { + HealthConnectDelegate d = delegate(); + if (d == null) { + out.complete(Boolean.TRUE); + return; + } + synchronized (granted) { + grantsRequested = true; + } + d.grantedPermissions(new GrantsThenComplete(this, out)); + } + + /// Records the refreshed grants, then resolves the authorization. + /// + /// Named rather than anonymous so it carries no synthetic reference + /// (SpotBugs `SIC_INNER_SHOULD_BE_STATIC_ANON`). + private static final class GrantsThenComplete + implements HealthConnectDelegate.Callback { + private final AndroidHealthStore store; + private final AsyncResource out; + + GrantsThenComplete(AndroidHealthStore store, + AsyncResource out) { + this.store = store; + this.out = out; + } + + public void onSuccess(final String payload) { + AndroidHealth.onEdt(new Runnable() { + public void run() { + store.rememberGrants(payload); + store.clearGrantsRequest(); + out.complete(Boolean.TRUE); + } + }); + } + + public void onError(final int code, final String message) { + AndroidHealth.onEdt(new Runnable() { + public void run() { + // The sheet still completed. An unreadable grant list + // leaves the cache as it was rather than failing an + // authorization the user did go through. + store.clearGrantsRequest(); + out.complete(Boolean.TRUE); + } + }); + } + } + + private void rememberGrants(String csv) { + synchronized (granted) { + granted.clear(); + grantsLoaded = true; + if (csv == null) { + return; + } + String[] parts = csv.split(","); + for (int i = 0; i < parts.length; i++) { + String t = parts[i].trim(); + if (t.length() > 0) { + granted.add(t); + } + } + } + } + + /// Receives the startup grant snapshot. + /// + /// Named rather than anonymous so SpotBugs does not flag an inner + /// class holding the enclosing reference. + private static final class GrantsCallback + implements HealthConnectDelegate.Callback { + private final AndroidHealthStore store; + + GrantsCallback(AndroidHealthStore store) { + this.store = store; + } + + public void onSuccess(String payload) { + store.rememberGrants(payload); + } + + public void onError(int code, String message) { + // Leave the set unloaded so a later call retries rather than + // reporting DENIED for a grant we simply failed to read. + store.clearGrantsRequest(); + } + } + + void clearGrantsRequest() { + synchronized (granted) { + grantsRequested = false; + } + } + + /// Maps a bridge error code onto the portable vocabulary. + private static HealthException toException(int code, String message) { + HealthError error; + switch (code) { + case HealthConnectDelegate.ERR_AUTH_DENIED: + error = HealthError.UNAUTHORIZED; + break; + case HealthConnectDelegate.ERR_PROVIDER: + error = HealthError.PROVIDER_UNAVAILABLE; + break; + case HealthConnectDelegate.ERR_INVALID_ARGUMENT: + error = HealthError.INVALID_ARGUMENT; + break; + case HealthConnectDelegate.ERR_TOKEN_EXPIRED: + error = HealthError.ANCHOR_EXPIRED; + break; + default: + error = HealthError.UNKNOWN; + break; + } + return new HealthException(error, message == null + ? "Health Connect call failed" : message); + } + + /// Bridges one asynchronous call, marshalling the outcome to the EDT. + private abstract static class Bridged + implements HealthConnectDelegate.Callback { + private final AsyncResource out; + + Bridged(AsyncResource out) { + this.out = out; + } + + abstract T convert(String payload) throws Exception; + + public void onSuccess(final String payload) { + AndroidHealth.onEdt(new Runnable() { + public void run() { + try { + out.complete(convert(payload)); + } catch (Exception ex) { + out.error(new HealthException( + HealthError.INVALID_DATA, + "could not decode the Health Connect " + + "response", ex)); + } + } + }); + } + + public void onError(final int code, final String message) { + AndroidHealth.onEdt(new Runnable() { + public void run() { + out.error(toException(code, message)); + } + }); + } + } + + private boolean failIfNoBridge(AsyncResource out) { + if (delegate() != null) { + return false; + } + out.error(new HealthException(HealthError.NOT_SUPPORTED, + "Health Connect is not available on this device")); + return true; + } + + // ------------------------------------------------------------------ + + protected void doRequestAuthorization(final List access, + final AsyncResource out) { + if (failIfNoBridge(out)) { + return; + } + final HealthConnectDelegate d = delegate(); + final StringBuilder csv = new StringBuilder(); + for (int i = 0; i < access.size(); i++) { + HealthAccess a = access.get(i); + if (csv.length() > 0) { + csv.append(','); + } + csv.append(a.isWrite() ? "w:" : "r:").append( + a.getType().getId()); + } + // Health permissions go through Health Connect's own UI, not + // ActivityCompat.requestPermissions. startActivityForResult must be + // called from the EDT. + AndroidHealth.onEdt(new Runnable() { + public void run() { + try { + AndroidNativeUtil.startActivityForResult( + d.permissionIntent(csv.toString()), + new IntentResultListener() { + public void onActivityResult(int requestCode, + int resultCode, android.content.Intent data) { + if (resultCode == android.app.Activity + .RESULT_CANCELED) { + // Dismissal is not completion. Reporting + // success here left callers unable to tell + // a backed-out sheet from a finished flow, + // which is the one distinction this API + // promises to make on Android. + out.error(new HealthException( + HealthError.USER_CANCELED, + "the Health Connect permission " + + "screen was dismissed")); + return; + } + // The result only lists what this intent + // asked for. Replacing the cached snapshot + // with it made a previously granted type read + // as DENIED after a second, unrelated + // authorization -- so re-read the full set + // from Health Connect instead. + d.parsePermissionResult(resultCode, data); + clearGrantsRequest(); + synchronized (granted) { + grantsLoaded = false; + } + // Resolved only once the refreshed snapshot has + // landed. Completing first meant an app that + // checked getReadAuthorizationStatus from this + // very callback saw NOT_DETERMINED -- the cache + // having just been cleared -- with nothing to + // tell it when to look again. True because the + // flow completed, matching the documented + // contract, not because anything was granted. + refreshGrantsThen(out); + } + }); + } catch (Throwable t) { + out.error(new HealthException(HealthError.UNKNOWN, + "could not present the Health Connect " + + "permission screen", t)); + } + } + }); + } + + protected void doReadSamples(SampleQuery query, + AsyncResource out) { + if (failIfNoBridge(out)) { + return; + } + delegate().readRecords(HealthWire.encodeSampleQuery(query), + new Bridged(out) { + SamplePage convert(String payload) throws Exception { + return HealthWire.decodeSamplePage(payload); + } + }); + } + + // doAggregate is deliberately not overridden. The bridge returned an + // empty payload here and relied on the base class to aggregate the raw + // samples instead -- but an empty payload decodes to buckets with no + // values, so the fallback never ran and every aggregate came back + // empty. Inheriting the base implementation is what actually gets the + // shared arithmetic, and it keeps one implementation of it rather than + // two that can disagree. + + protected void doWrite(List samples, + AsyncResource out) { + if (failIfNoBridge(out)) { + return; + } + HealthSample rejected = HealthWire.unsupportedForWrite(samples); + if (rejected != null) { + // The payload cannot carry this shape, and the bridge reports + // an empty batch as a successful insert of nothing. + out.error(new HealthException(HealthError.TYPE_NOT_SUPPORTED, + rejected.getType().getId() + " cannot be written to" + + " Health Connect through this API")); + return; + } + delegate().insertRecords(HealthWire.encodeSamples(samples), + new Bridged(out) { + HealthWriteResult convert(String payload) { + return HealthWire.decodeWriteResult(payload); + } + }); + } + + protected void doDelete(HealthDeleteRequest request, + AsyncResource out) { + if (failIfNoBridge(out)) { + return; + } + delegate().deleteRecords(HealthWire.encodeDeleteRequest(request), + new Bridged(out) { + Integer convert(String payload) { + try { + return Integer.valueOf(payload.trim()); + } catch (NumberFormatException ex) { + return Integer.valueOf(0); + } + } + }); + } + + // ================================================================== + // change draining + // ================================================================== + + /// Health Connect never wakes the app, so a subscription is only ever + /// as current as the last drain. Subscriptions are drained one at a + /// time rather than concurrently: each owns a change token that the + /// base class persists once its batch has been handled, and running + /// them in parallel would interleave those writes. + protected void doDrainChanges(List subscriptions, + AsyncResource out) { + if (failIfNoBridge(out)) { + return; + } + synchronized (drainLock) { + drainFailure = null; + } + drainFrom(new ArrayList(subscriptions), 0, 0, + out); + } + + /// The first failure this drain hit, or null. + /// + /// One field rather than state threaded through the recursion because + /// the shared layer runs one drain at a time -- a second call while + /// one is in flight is coalesced onto it rather than started. + /// + /// Guarded by [#drainLock] because the two ends are on different + /// threads: it is set from the bridge's callback and read on the EDT, + /// after the hop SkipOne makes. One drain at a time makes the field + /// safe to share; it does not publish the write. + private final Object drainLock = new Object(); + /// Guarded by [#drainLock]. + private Throwable drainFailure; + + /// Remembers a failure so the drain can report it once it has given + /// the healthy subscriptions their turn. The first one wins: it is + /// the one with a cause the caller can still act on. + private void noteDrainFailure(Throwable error) { + synchronized (drainLock) { + if (drainFailure == null) { + drainFailure = error; + } + } + } + + private void drainFrom(List subs, int index, + int delivered, AsyncResource out) { + if (index >= subs.size()) { + Throwable failed; + synchronized (drainLock) { + failed = drainFailure; + drainFailure = null; + } + if (failed != null) { + // Surfaced, not swallowed. A subscription skipped because + // Health Connect refused the read otherwise resolved as a + // clean drain with nothing to report, which a caller + // cannot tell from genuinely no changes -- so nothing ever + // retried, and the skipped window was only picked up if + // the app happened to drain again for its own reasons. + out.error(failed); + return; + } + out.complete(Integer.valueOf(delivered)); + return; + } + HealthSubscription sub = subs.get(index); + HealthAnchor anchor = sub.getAnchor(); + String token = anchor == null ? null : anchor.toStorableString(); + if ((token == null || token.length() == 0) + && !isBaselineInFlight(sub)) { + // Re-read after the check, not before it. The seed can land + // between the two, and then the in-flight entry is gone while + // this token is still the null read a moment earlier -- so + // the drain took a second baseline and lost everything + // between the two issuances, which is the very hole the + // waiting exists to close. + anchor = sub.getAnchor(); + token = anchor == null ? null : anchor.toStorableString(); + } + if ((token == null || token.length() == 0) + && isBaselineInFlight(sub)) { + // Its starting cursor is still being issued. Taking a second + // one here is what makes the window real: two baselines then + // race, the later one wins, and every change between them is + // lost for good. There is nothing to report from a baseline + // in any case, so skip to the next subscription and let the + // one in flight land. + drainFrom(subs, index + 1, delivered, out); + return; + } + if (token == null || token.length() == 0) { + // The first drain only establishes a baseline. A Health + // Connect token describes changes from the moment it is + // issued, so there is nothing yet to report; firing the empty + // batch is what gets the token persisted for the next poll. + delegate().getChangesToken(typesCsv(sub), + new DrainStep(subs, index, delivered, out, true)); + return; + } + delegate().getChanges(token, + new DrainStep(subs, index, delivered, out, false)); + } + + /// Takes the baseline token when the subscription is registered, not + /// when it is first drained. + /// + /// A Health Connect token describes changes from the moment it is + /// issued. Waiting for the first drain meant everything written + /// between `subscribe()` and that drain fell before the token, and + /// the empty baseline delivery then advanced the cursor past changes + /// that would never be reported -- an app that subscribed at launch + /// and drained on the next foreground silently missed the interval in + /// between. + /// + /// Failure is not fatal: the token stays absent and the first drain + /// establishes it exactly as before, which is worse than this but + /// better than a subscription that never works. + /// + /// The token is issued asynchronously, so a small window remains + /// between `subscribe()` returning and the token existing. Health + /// Connect cannot close it -- a token describes changes from the + /// moment it is issued and there is no way to ask for one as of an + /// earlier instant -- so the window is made as small as the platform + /// allows and the subscription is held un-established until the token + /// lands, which is what stops a drain from racing it. + @Override + protected void doSubscribe(SubscriptionRequest request, + HealthSubscription subscription) { + if (subscription == null || subscription.getAnchor() != null + || delegate() == null) { + return; + } + markBaselineInFlight(subscription); + delegate().getChangesToken(typesCsv(request.getTypes()), + new BaselineToken(this, subscription)); + } + + /// Subscriptions whose starting cursor is still being issued, and + /// when the request went out. + /// + /// Keyed on the live handle rather than the id: an id can be + /// re-registered while the first request is still in flight, and the + /// two answers must not be confused for one another. + private final Map baselinesInFlight = + new HashMap(); + + /// How long a baseline request is believed in flight before a drain + /// stops waiting for it. A subscription whose token request was lost + /// -- a bridge that never calls back either way -- would otherwise + /// never drain again, which is a worse failure than the duplicate + /// baseline the waiting exists to prevent. + private static final long BASELINE_TIMEOUT_MILLIS = 30000L; + + private void markBaselineInFlight(HealthSubscription sub) { + synchronized (baselinesInFlight) { + baselinesInFlight.put(sub, + Long.valueOf(System.currentTimeMillis())); + } + } + + private void clearBaselineInFlight(HealthSubscription sub) { + synchronized (baselinesInFlight) { + baselinesInFlight.remove(sub); + } + } + + /// A subscription that is torn down while its baseline is in flight + /// is forgotten here too. The callback would drop its seed anyway -- + /// the handle is no longer registered -- but nothing else would ever + /// look at the entry again, so it would sit in the map for the life + /// of the process. + @Override + protected void doUnsubscribe(HealthSubscription subscription) { + synchronized (baselinesInFlight) { + // Keyed on the handle, so only the generation that was + // cancelled is forgotten. Removing every entry with a + // matching id dropped a *replacement* whose baseline was + // still in flight, and its first drain then took a second + // token and lost the window between them. + baselinesInFlight.remove(subscription); + } + } + + private boolean isBaselineInFlight(HealthSubscription sub) { + synchronized (baselinesInFlight) { + Long since = baselinesInFlight.get(sub); + if (since == null) { + return false; + } + if (System.currentTimeMillis() - since.longValue() + > BASELINE_TIMEOUT_MILLIS) { + baselinesInFlight.remove(sub); + com.codename1.io.Log.p("CN1 Health: the baseline change" + + " token for " + sub.getId() + " never arrived;" + + " this drain establishes one instead"); + return false; + } + return true; + } + } + + /// Seeds the token a subscription starts from. + /// + /// Holds the live handle, not the id: by the time this answers, the + /// subscription may have been stopped or replaced by a new one under + /// the same id, and `seedAnchor` drops the seed in both cases rather + /// than restoring a cursor `unsubscribe()` discarded or handing a + /// fresh subscription a token issued for a different type set. + /// + /// Named rather than anonymous so it carries no synthetic reference + /// to the enclosing store (SpotBugs `SIC_INNER_SHOULD_BE_STATIC_ANON`). + private static final class BaselineToken + implements HealthConnectDelegate.Callback { + + private final AndroidHealthStore store; + private final HealthSubscription subscription; + + BaselineToken(AndroidHealthStore store, + HealthSubscription subscription) { + this.store = store; + this.subscription = subscription; + } + + @Override + public void onSuccess(final String payload) { + // Onto the EDT: the cursor on the handle is read there by + // every drain, and this answer arrives on whatever thread the + // bridge's coroutine finished on. + AndroidHealth.onEdt(new SeedBaseline(store, subscription, + payload)); + } + + @Override + public void onError(int code, String message) { + store.clearBaselineInFlight(subscription); + com.codename1.io.Log.p("CN1 Health: could not take a baseline" + + " change token for " + subscription.getId() + " (" + + message + "); the first drain will establish one," + + " and changes before it are not reported"); + } + } + + /// Applies a baseline token on the EDT. + private static final class SeedBaseline implements Runnable { + + private final AndroidHealthStore store; + private final HealthSubscription subscription; + private final String payload; + + SeedBaseline(AndroidHealthStore store, + HealthSubscription subscription, String payload) { + this.store = store; + this.subscription = subscription; + this.payload = payload; + } + + public void run() { + try { + if (payload == null || payload.trim().length() == 0) { + return; + } + // The live handle as well as the persisted copy: the + // drains read the handle, so persisting alone left the + // next drain taking a fresh baseline and skipping the + // very window this token exists to cover. + store.seedAnchor(subscription, + HealthAnchor.of(payload.trim())); + } finally { + // Cleared last, and whatever happened: a subscription + // left marked in flight is one no drain ever advances. + store.clearBaselineInFlight(subscription); + } + } + } + + private static String typesCsv(HealthSubscription sub) { + return typesCsv(sub.getTypes()); + } + + private static String typesCsv(List types) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < types.size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append(types.get(i).getId()); + } + return sb.toString(); + } + + /// One step of the sequential drain: handles a single subscription's + /// bridge reply and then moves on to the next one. + private final class DrainStep + implements HealthConnectDelegate.Callback { + + private final List subs; + private final int index; + private final int delivered; + private final AsyncResource out; + private final boolean baseline; + + DrainStep(List subs, int index, int delivered, + AsyncResource out, boolean baseline) { + this.subs = subs; + this.index = index; + this.delivered = delivered; + this.out = out; + this.baseline = baseline; + } + + public void onSuccess(final String payload) { + AndroidHealth.onEdt(new DeliverBatch(this, payload)); + } + + public void onError(final int code, final String message) { + if (code == HealthConnectDelegate.ERR_TOKEN_EXPIRED) { + // Not transient. A Health Connect token ages out after + // about 30 days, and retrying it fails identically + // forever -- so treating it like any other error left the + // subscription retrying an unusable token and silently + // never advancing again. Tell the app to resynchronise and + // start a fresh baseline, which is what + // isResyncRequired() exists to say. + AndroidHealth.onEdt(new ResyncOne(this)); + return; + } + // One failing subscription must not strand the others, and it + // must not advance its own cursor. Skipping it leaves the + // token where it was so the next drain retries the same range + // -- and the failure is reported once the rest have had their + // turn, so the caller knows there is something to retry. + noteDrainFailure(toException(code, message)); + AndroidHealth.onEdt(new SkipOne(this)); + } + + void resync() { + HealthSubscription sub = subs.get(index); + // No anchor: the batch carries the resync flag and nothing + // else, and the cursor is dropped so the next drain asks for a + // fresh token rather than resending the expired one. + // The cursor is dropped by the shared delivery path once the + // listener has actually been told to resynchronise; doing it + // here would lose the expired token to a listener that threw. + int sent = fireChanges(new HealthChangeBatch(sub.getId(), + sub.getTypes(), null, null, true, null, Long.MAX_VALUE, + false)); + drainFrom(subs, index + 1, delivered + sent, out); + } + + void deliver(String payload) { + int count = delivered; + HealthSubscription sub = subs.get(index); + if (baseline) { + // Counted per delivery a listener actually received: a + // subscription restored with no live listener and no + // persisted class queues nothing, and a page split by the + // per-batch cap arrives as several deliveries, which is + // what drainChanges reports. + count += fireChanges(new HealthChangeBatch(sub.getId(), + sub.getTypes(), null, null, false, + // Foreground: no OS deadline. Reporting 0 made a listener + // that budgets against getDeadlineMillis() treat every + // ordinary drain as already out of time. + HealthAnchor.of(payload.trim()), Long.MAX_VALUE, false)); + } else { + HealthChangePage page = + HealthWire.decodeChangePage(payload); + if (page == null) { + // An unreadable reply leaves the cursor alone rather + // than advancing past changes that were never seen. + drainFrom(subs, index + 1, count, out); + return; + } + count += fireChanges(new HealthChangeBatch(sub.getId(), + sub.getTypes(), page.getAdded(), + page.getDeletedIds(), page.isExpired(), + // Foreground: no OS deadline. Reporting 0 made a listener + // that budgets against getDeadlineMillis() treat every + // ordinary drain as already out of time. + HealthAnchor.of(page.getNextToken()), Long.MAX_VALUE, + page.hasMore())); + } + drainFrom(subs, index + 1, count, out); + } + + void skip() { + drainFrom(subs, index + 1, delivered, out); + } + } + + /// Named rather than anonymous so the EDT hop carries no implicit + /// reference to the enclosing store. + private static final class DeliverBatch implements Runnable { + private final DrainStep step; + private final String payload; + + DeliverBatch(DrainStep step, String payload) { + this.step = step; + this.payload = payload; + } + + public void run() { + step.deliver(payload); + } + } + + /// Reports an expired token and clears the cursor. + /// + /// Named rather than anonymous so it carries no synthetic reference + /// (SpotBugs `SIC_INNER_SHOULD_BE_STATIC_ANON`). + private static final class ResyncOne implements Runnable { + private final DrainStep step; + + ResyncOne(DrainStep step) { + this.step = step; + } + + @Override + public void run() { + step.resync(); + } + } + + private static final class SkipOne implements Runnable { + private final DrainStep step; + + SkipOne(DrainStep step) { + this.step = step; + } + + public void run() { + step.skip(); + } + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidHealthSupport.java b/Ports/Android/src/com/codename1/impl/android/AndroidHealthSupport.java new file mode 100644 index 00000000000..e5109532090 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/AndroidHealthSupport.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android; + +/// Registry that links the Android port to the injected Health Connect +/// bridge. +/// +/// The runtime Android port carries no compile-time dependency on +/// `androidx.health.connect` -- it is only on the classpath when the app +/// actually uses `com.codename1.health`. The build injects a Kotlin +/// implementation of [HealthConnectDelegate] into the generated project and +/// registers it here during app startup; [AndroidHealth] reads it back. +/// +/// When health is not bundled this stays null and the health API degrades +/// to reporting itself unsupported, exactly as +/// [AndroidCarSupport] does when Android Auto is absent. +public final class AndroidHealthSupport { + + private static volatile HealthConnectDelegate delegate; + + private AndroidHealthSupport() { + } + + /// Called by the injected bridge to publish itself at app startup. + public static void setDelegate(HealthConnectDelegate d) { + delegate = d; + } + + /// The registered bridge, or null when Health Connect is not bundled. + public static HealthConnectDelegate getDelegate() { + return delegate; + } +} diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index e2daab73bef..b45bddddf99 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -7837,6 +7837,20 @@ public com.codename1.bluetooth.Bluetooth getBluetooth() { return bluetooth; } + private com.codename1.health.Health health; + + /// Returns the Health Connect-backed health entry point. The store + /// degrades to reporting itself unsupported when no bridge has been + /// injected, which is the case for apps that never reference + /// com.codename1.health. + @Override + public com.codename1.health.Health getHealth() { + if (health == null) { + health = new AndroidHealth(); + } + return health; + } + /** * This method returns the platform Location Control * diff --git a/Ports/Android/src/com/codename1/impl/android/HealthConnectDelegate.java b/Ports/Android/src/com/codename1/impl/android/HealthConnectDelegate.java new file mode 100644 index 00000000000..83cc4d7c51d --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/HealthConnectDelegate.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.android; + +/// The seam between the Android port and Health Connect. +/// +/// #### Why the port does not call Health Connect directly +/// +/// `androidx.health.connect` is an AndroidX library whose API surface is +/// Kotlin `suspend` functions, and the Codename One Android port compiles +/// against a fixed, old `android.jar` with no AndroidX, no Kotlin and no +/// coroutines on its classpath. So the port cannot reference it. +/// +/// It does not need to. The port ships to app builds as **source**, which +/// the app's own Gradle compiles at a modern `compileSdkVersion` -- and +/// Kotlin sources in that tree are compiled too. So the build server drops +/// a Kotlin implementation of this interface into the app and registers it +/// through [AndroidHealthSupport], exactly as the Android Auto glue is +/// injected for `com.codename1.car`. +/// +/// Every method on this boundary therefore speaks only primitives and +/// `String`: no AndroidX type, no Kotlin type and no coroutine escapes into +/// the port. Payloads use the same tab-separated line format the iOS bridge +/// uses, because a year of heart rate is hundreds of thousands of samples +/// and parsing that as JSON is real memory pressure on a phone. +/// +/// When no bridge is registered -- an app that never referenced +/// `com.codename1.health`, or a build predating the generator -- +/// [AndroidHealthSupport#getDelegate()] returns null and the health API +/// degrades to reporting itself unsupported, exactly as +/// `com.codename1.car` does without Android Auto. +public interface HealthConnectDelegate { + + /// Health Connect is not usable on this device. + int SDK_UNAVAILABLE = 0; + /// The provider is installed but too old. + int SDK_UPDATE_REQUIRED = 1; + /// The provider is present and usable. + int SDK_AVAILABLE = 2; + + /// Receives the result of an asynchronous bridge call. The bridge + /// invokes exactly one of these methods, on an unspecified thread; the + /// port marshals to the EDT. + interface Callback { + /// The call succeeded, carrying whatever payload it produces. + void onSuccess(String payload); + + /// The call failed. `code` is one of the `ERR_` constants. + void onError(int code, String message); + } + + /// The bridge could not classify the failure. + int ERR_UNKNOWN = 0; + /// A permission was missing or refused. + int ERR_AUTH_DENIED = 1; + /// The provider app was unreachable. + int ERR_PROVIDER = 2; + /// The request was rejected as malformed. + int ERR_INVALID_ARGUMENT = 3; + /// A change token had expired and the caller must resynchronize. + int ERR_TOKEN_EXPIRED = 4; + + /// Whether Health Connect is available, as one of the `SDK_` constants. + int sdkStatus(); + + /// The provider package name, for sending the user to install it. + String providerPackageName(); + + /// Reports the permissions currently granted, as a comma-separated + /// list of portable data-type tokens prefixed `r:` or `w:`. + void grantedPermissions(Callback cb); + + /// Builds the intent that launches Health Connect's own permission UI. + /// + /// Health permissions are not ordinary runtime permissions and must not + /// go through `ActivityCompat.requestPermissions`. + android.content.Intent permissionIntent(String permissionsCsv); + + /// Interprets the result of the permission intent, returning the + /// granted set in the same format as [#grantedPermissions(Callback)]. + String parsePermissionResult(int resultCode, + android.content.Intent data); + + /// Reads samples. `requestJson` describes types, range, limit and sort; + /// the payload is tab-separated sample lines. + void readRecords(String requestJson, Callback cb); + + /// Computes aggregates over the supplied bucket boundaries. + void aggregate(String requestJson, Callback cb); + + /// Writes samples supplied as tab-separated lines; the payload is the + /// assigned record ids. + void insertRecords(String recordsTsv, Callback cb); + + /// Deletes records matching the request; the payload is the count. + void deleteRecords(String requestJson, Callback cb); + + /// Obtains a changes token for the given types. + void getChangesToken(String typesCsv, Callback cb); + + /// Drains changes since a token. Fails with [#ERR_TOKEN_EXPIRED] when + /// the token has aged out, which the port maps to a resync request. + void getChanges(String token, Callback cb); +} diff --git a/Ports/JavaSE/src/META-INF/codenameone/simulator-hooks.properties b/Ports/JavaSE/src/META-INF/codenameone/simulator-hooks.properties index b6a76b53e35..eb6233f5cc3 100644 --- a/Ports/JavaSE/src/META-INF/codenameone/simulator-hooks.properties +++ b/Ports/JavaSE/src/META-INF/codenameone/simulator-hooks.properties @@ -1,31 +1,72 @@ -# Simulator hooks of the JavaSE port's simulated Bluetooth stack. +# Simulator hooks of the JavaSE port's simulated subsystems. # Loaded by com.codename1.impl.javase.simulator.SimulatorHookLoader; the -# labeled items surface as the simulator's "Bluetooth" menu, every item is -# also callable cross-platform via CN.execute("bluetooth:itemN"). -name=Bluetooth -namespace=bluetooth +# labeled items surface as simulator menus, and every item -- labeled or +# not -- is callable cross-platform via CN.execute(":itemN"). +# +# A classpath entry can only carry one copy of this resource, so the two +# subsystems are declared as prefixed groups rather than as separate files. +groups=bluetooth,health -item1=com.codename1.impl.javase.BluetoothSimulatorHooks#toggleAdapter -label1=Toggle Adapter +bluetooth.name=Bluetooth +bluetooth.namespace=bluetooth -item2=com.codename1.impl.javase.BluetoothSimulatorHooks#addDemoPeripheral -label2=Add Demo Peripheral +bluetooth.item1=com.codename1.impl.javase.BluetoothSimulatorHooks#toggleAdapter +bluetooth.label1=Toggle Adapter -item3=com.codename1.impl.javase.BluetoothSimulatorHooks#pushDemoNotification -label3=Push Demo Notification +bluetooth.item2=com.codename1.impl.javase.BluetoothSimulatorHooks#addDemoPeripheral +bluetooth.label2=Add Demo Peripheral -item4=com.codename1.impl.javase.BluetoothSimulatorHooks#disconnectAll -label4=Disconnect All +bluetooth.item3=com.codename1.impl.javase.BluetoothSimulatorHooks#pushDemoNotification +bluetooth.label3=Push Demo Notification -item5=com.codename1.impl.javase.BluetoothSimulatorHooks#clearPeripherals -label5=Clear Peripherals +bluetooth.item4=com.codename1.impl.javase.BluetoothSimulatorHooks#disconnectAll +bluetooth.label4=Disconnect All -item6=com.codename1.impl.javase.BluetoothSimulatorHooks#switchToSimulatorBackend -label6=Use Simulator Backend +bluetooth.item5=com.codename1.impl.javase.BluetoothSimulatorHooks#clearPeripherals +bluetooth.label5=Clear Peripherals -item7=com.codename1.impl.javase.BluetoothSimulatorHooks#switchToNativeBackend -label7=Use Native Backend +bluetooth.item6=com.codename1.impl.javase.BluetoothSimulatorHooks#switchToSimulatorBackend +bluetooth.label6=Use Simulator Backend + +bluetooth.item7=com.codename1.impl.javase.BluetoothSimulatorHooks#switchToNativeBackend +bluetooth.label7=Use Native Backend # API-only (no label): arms the next GATT read to fail. For tests/scripts: # CN.execute("bluetooth:item8") -item8=com.codename1.impl.javase.BluetoothSimulatorHooks#primeReadFailure +bluetooth.item8=com.codename1.impl.javase.BluetoothSimulatorHooks#primeReadFailure + +health.name=Health +health.namespace=health + +health.item1=com.codename1.impl.javase.HealthSimulatorHooks#grantAllPermissions +health.label1=Grant All Permissions + +health.item2=com.codename1.impl.javase.HealthSimulatorHooks#denyAllPermissions +health.label2=Deny All Permissions + +# The trap worth clicking before shipping: authorization appears to +# succeed, the status reads UNKNOWN, and every query comes back empty with +# no error -- exactly what HealthKit does when a user declines a read. +health.item3=com.codename1.impl.javase.HealthSimulatorHooks#grantWriteDenyReadSilently +health.label3=Grant Write, Deny Read Without Error + +health.item4=com.codename1.impl.javase.HealthSimulatorHooks#loadDemoDataset +health.label4=Load Demo Dataset (7 days) + +health.item5=com.codename1.impl.javase.HealthSimulatorHooks#clearAllData +health.label5=Clear All Health Data + +health.item6=com.codename1.impl.javase.HealthSimulatorHooks#useIosPermissionBehaviour +health.label6=Emulate HealthKit Permissions + +health.item7=com.codename1.impl.javase.HealthSimulatorHooks#useAndroidPermissionBehaviour +health.label7=Emulate Health Connect Permissions + +health.item8=com.codename1.impl.javase.HealthSimulatorHooks#setHealthUnavailable +health.label8=Make Health Unavailable + +# API-only items below (no label): for tests and scripts. +health.item9=com.codename1.impl.javase.HealthSimulatorHooks#setHealthAvailable +health.item10=com.codename1.impl.javase.HealthSimulatorHooks#primeQueryFailure +health.item11=com.codename1.impl.javase.HealthSimulatorHooks#primeSaveFailure +health.item12=com.codename1.impl.javase.HealthSimulatorHooks#resetScripts diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/HealthSimulatorHooks.java b/Ports/JavaSE/src/com/codename1/impl/javase/HealthSimulatorHooks.java new file mode 100644 index 00000000000..6e1eac8a0d2 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/HealthSimulatorHooks.java @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.javase; + +import com.codename1.health.Health; +import com.codename1.health.HealthAuthorizationStatus; +import com.codename1.health.HealthError; +import com.codename1.health.HealthSample; +import com.codename1.health.HealthStore; +import com.codename1.impl.health.LocalHealth; +import com.codename1.impl.javase.health.SimulatedHealthStore; +import com.codename1.impl.javase.health.SyntheticHealthData; + +import java.util.List; + +/// Simulate-menu actions for the health API, also callable from tests as +/// `CN.execute("health:itemN")`. +/// +/// Every method is `public static void` with no arguments -- the contract +/// {@code SimulatorHookLoader} enforces -- and runs on the EDT. +public class HealthSimulatorHooks { + + /// A fixed seed so a scripted dataset is identical run to run, which is + /// what lets a test assert an exact total. + private static final long DEMO_SEED = 20260101L; + + private HealthSimulatorHooks() { + } + + /// The simulated store behind the current health entry point, or null + /// when the port is not using one. + private static SimulatedHealthStore store() { + Health h = Health.getInstance(); + HealthStore s = h.getStore(); + if (s instanceof SimulatedHealthStore) { + return (SimulatedHealthStore) s; + } + System.err.println("Health simulation: the active port is not using" + + " a simulated health store"); + return null; + } + + /// Grants every read and write, the permissive case. + public static void grantAllPermissions() { + SimulatedHealthStore s = store(); + if (s != null) { + s.setAllReadPermissions( + SimulatedHealthStore.ReadAuthScript.GRANTED); + s.setAllWritePermissions(HealthAuthorizationStatus.AUTHORIZED); + } + } + + /// Denies every read and write. + public static void denyAllPermissions() { + SimulatedHealthStore s = store(); + if (s != null) { + s.setAllReadPermissions( + SimulatedHealthStore.ReadAuthScript.DENIED_SILENT); + s.setAllWritePermissions(HealthAuthorizationStatus.DENIED); + } + } + + /// **The trap.** Grants writes, silently refuses reads, and emulates + /// iOS -- so authorization appears to succeed, the status reads + /// UNKNOWN, and every query returns empty with no error. + /// + /// This is the single most useful thing to click before shipping a + /// health app, because it is the behaviour real users will produce and + /// the one a permissive simulator never shows you. + public static void grantWriteDenyReadSilently() { + SimulatedHealthStore s = store(); + if (s != null) { + s.setReadAuthorizationPolicy( + SimulatedHealthStore.ReadAuthPolicy.IOS_OPAQUE); + s.setAllReadPermissions( + SimulatedHealthStore.ReadAuthScript.DENIED_SILENT); + s.setAllWritePermissions(HealthAuthorizationStatus.AUTHORIZED); + } + } + + /// Emulates Health Connect instead: read refusals fail loudly. + public static void useAndroidPermissionBehaviour() { + SimulatedHealthStore s = store(); + if (s != null) { + s.setReadAuthorizationPolicy( + SimulatedHealthStore.ReadAuthPolicy.ANDROID_EXPLICIT); + } + } + + /// Emulates HealthKit: read refusals are indistinguishable from having + /// no data. The default. + public static void useIosPermissionBehaviour() { + SimulatedHealthStore s = store(); + if (s != null) { + s.setReadAuthorizationPolicy( + SimulatedHealthStore.ReadAuthPolicy.IOS_OPAQUE); + } + } + + /// Loads a week of synthetic steps, heart rate, sleep and weight. + public static void loadDemoDataset() { + SimulatedHealthStore s = store(); + if (s == null) { + return; + } + List samples = new SyntheticHealthData(DEMO_SEED) + .generateWeek(System.currentTimeMillis()); + s.seed(samples); + System.out.println("Health simulation: loaded " + samples.size() + + " synthetic samples"); + } + + /// Removes every stored sample. + public static void clearAllData() { + SimulatedHealthStore s = store(); + if (s != null) { + s.clear(); + } + } + + /// Clears scripted permissions, faults and availability, leaving data + /// alone. + public static void resetScripts() { + SimulatedHealthStore s = store(); + if (s != null) { + s.resetScripts(); + } + } + + /// Makes the store report itself unavailable, as a missing or disabled + /// Health Connect provider would. + public static void setHealthUnavailable() { + SimulatedHealthStore s = store(); + if (s != null) { + s.setAvailable(false); + } + } + + /// Makes the store available again. + public static void setHealthAvailable() { + SimulatedHealthStore s = store(); + if (s != null) { + s.setAvailable(true); + } + } + + /// Fails the next query once, then recovers -- so a test can assert + /// that the app retries rather than wedging. + public static void primeQueryFailure() { + SimulatedHealthStore s = store(); + if (s != null) { + s.failNext("query", HealthError.DATABASE_INACCESSIBLE, + "simulated locked-device read failure"); + } + } + + /// Fails the next write once, then recovers. + public static void primeSaveFailure() { + SimulatedHealthStore s = store(); + if (s != null) { + s.failNext("save", HealthError.UNAUTHORIZED, + "simulated write authorization failure"); + } + } + + /// Creates the health entry point the JavaSE port installs. + static Health createSimulatedHealth() { + return new LocalHealth(new SimulatedHealthStore()); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 32820be36e2..b09c153ec6f 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -14931,6 +14931,22 @@ public com.codename1.bluetooth.Bluetooth getBluetooth() { return bluetooth; } + private com.codename1.health.Health health; + + /// Returns the simulator's health entry point. There is no desktop + /// HealthKit or Health Connect, so this is a local store reporting + /// {@code HealthAvailability.LOCAL_ONLY} -- reads, writes and + /// aggregates all work and are this app's own data. The Bluetooth + /// sensor layer is unaffected and works against both the simulated + /// stack and real hardware. + @Override + public com.codename1.health.Health getHealth() { + if (health == null) { + health = HealthSimulatorHooks.createSimulatedHealth(); + } + return health; + } + /** * The first time the app reaches the NFC API in the simulator, write * placeholders for ios.NFCReaderUsageDescription if the developer has diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/BluetoothSimulator.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/BluetoothSimulator.java index ba2b45ebe9d..25606b1300f 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/BluetoothSimulator.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/BluetoothSimulator.java @@ -24,6 +24,9 @@ import com.codename1.bluetooth.BluetoothError; import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.impl.javase.simulator.AutoScheduler; +import com.codename1.impl.javase.simulator.ManualScheduler; +import com.codename1.impl.javase.simulator.SimScheduler; /** * The scriptable facade of the JavaSE simulator's virtual Bluetooth world diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimulatedBluetoothStack.java b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimulatedBluetoothStack.java index a439b5c6adf..d258e5e19e4 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimulatedBluetoothStack.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimulatedBluetoothStack.java @@ -27,6 +27,9 @@ import com.codename1.bluetooth.gatt.GattStatus; import com.codename1.bluetooth.le.server.GattLocalCharacteristic; import com.codename1.bluetooth.le.server.GattLocalService; +import com.codename1.impl.javase.simulator.AutoScheduler; +import com.codename1.impl.javase.simulator.ManualScheduler; +import com.codename1.impl.javase.simulator.SimScheduler; import java.util.ArrayDeque; import java.util.ArrayList; diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/health/SimulatedHealthStore.java b/Ports/JavaSE/src/com/codename1/impl/javase/health/SimulatedHealthStore.java new file mode 100644 index 00000000000..f0cb3c550e2 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/health/SimulatedHealthStore.java @@ -0,0 +1,415 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.javase.health; + +import com.codename1.health.HealthAccess; +import com.codename1.health.HealthAuthorizationStatus; +import com.codename1.health.HealthDataType; +import com.codename1.health.HealthDeleteRequest; +import com.codename1.health.HealthError; +import com.codename1.health.HealthException; +import com.codename1.health.HealthSample; +import com.codename1.health.HealthWriteResult; +import com.codename1.health.SampleQuery; +import com.codename1.health.SamplePage; +import com.codename1.impl.health.LocalHealthStore; +import com.codename1.util.AsyncResource; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The simulator's health store: a real local store with scripted +/// permissions and fault injection layered on top. +/// +/// #### The read-authorization trap +/// +/// This is the most valuable thing the simulator does. HealthKit +/// deliberately refuses to disclose read authorization: a denied read +/// returns an empty result, indistinguishable from having no data, so that +/// an app cannot infer what a user is choosing to hide. A developer who +/// only ever tests against a permissive store will not discover this until +/// their app is in review or in a user's hands. +/// +/// So [ReadAuthPolicy#IOS_OPAQUE] is the **default** here, and +/// [ReadAuthScript#DENIED_SILENT] reproduces the exact trap: authorization +/// appears to succeed, the status reads UNKNOWN, and queries return empty +/// with no error. Switching to [ReadAuthPolicy#ANDROID_EXPLICIT] makes the +/// same script fail loudly instead, which is what Health Connect does. +/// +/// Drive it from the Simulate menu, or from a test with +/// `CN.execute("health:item3")`. +public class SimulatedHealthStore extends LocalHealthStore { + + /// How a platform behaves when read access has been refused. + public enum ReadAuthPolicy { + /// HealthKit's behaviour: read authorization is never disclosed and + /// a denied read is silently empty. The default, because it is the + /// more surprising of the two and the one worth discovering early. + IOS_OPAQUE, + /// Health Connect's behaviour: read permission is an ordinary + /// runtime grant and a denied read fails. + ANDROID_EXPLICIT + } + + /// What the user is pretending to have chosen for one data type. + public enum ReadAuthScript { + /// Granted; queries return data. + GRANTED, + /// Refused, and the platform hides the refusal -- queries return + /// empty with no error. The trap. + DENIED_SILENT, + /// Refused, and the platform says so. + DENIED_ERROR, + /// Never asked. + NOT_DETERMINED, + /// Granted, but the user genuinely has no data of this type. Looks + /// identical to DENIED_SILENT from inside the app, which is the + /// point of having both. + GRANTED_BUT_NO_DATA + } + + private final Map readScripts = + new HashMap(); + private final Map writeScripts = + new HashMap(); + + private ReadAuthPolicy policy = ReadAuthPolicy.IOS_OPAQUE; + private boolean available = true; + private String failNextOp; + private HealthError failNextError; + private String failNextMessage; + + /// Which platform's read-authorization behaviour to emulate. + public ReadAuthPolicy getReadAuthorizationPolicy() { + return policy; + } + + /// Switches the emulated read-authorization behaviour. + public void setReadAuthorizationPolicy(ReadAuthPolicy policy) { + this.policy = policy == null ? ReadAuthPolicy.IOS_OPAQUE : policy; + } + + /// Scripts what the user chose for reading `type`. + public void setReadPermission(HealthDataType type, ReadAuthScript script) { + if (type != null) { + readScripts.put(type.getId(), + script == null ? ReadAuthScript.GRANTED : script); + } + } + + /// Scripts what the user chose for writing `type`. + public void setWritePermission(HealthDataType type, + HealthAuthorizationStatus status) { + if (type != null) { + writeScripts.put(type.getId(), + status == null ? HealthAuthorizationStatus.AUTHORIZED + : status); + } + } + + /// Applies one read script to every known type. + public void setAllReadPermissions(ReadAuthScript script) { + for (HealthDataType t : HealthDataType.values()) { + setReadPermission(t, script); + } + } + + /// Applies one write status to every known type. + public void setAllWritePermissions(HealthAuthorizationStatus status) { + for (HealthDataType t : HealthDataType.values()) { + setWritePermission(t, status); + } + } + + /// Whether the store is reachable at all, emulating a missing or + /// disabled provider. + public void setAvailable(boolean available) { + this.available = available; + } + + /// `true` when the store is currently reachable. + public boolean isAvailable() { + return available; + } + + /// Makes the next occurrence of `op` fail. + /// + /// Recognised ops: `requestAuthorization`, `query`, `aggregate`, + /// `save`, `delete`. One-shot -- the following call succeeds -- so a + /// test can assert that an app recovers rather than wedges. + public void failNext(String op, HealthError error, String message) { + this.failNextOp = op; + this.failNextError = error == null ? HealthError.UNKNOWN : error; + this.failNextMessage = message == null + ? "simulated " + op + " failure" : message; + } + + /// Seeds samples straight into the store, bypassing permission and + /// validation checks. + /// + /// This is how a scripted dataset is loaded: the data is meant to look + /// as though it had been there all along, written by other apps and + /// devices, so it must not be subject to this app's write permissions. + public void seed(List samples) { + if (samples == null) { + return; + } + for (int i = 0; i < samples.size(); i++) { + addSampleDirect(samples.get(i)); + } + } + + /// Clears scripted permissions, faults and availability. Data is left + /// alone; use `clear()` for that. + public void resetScripts() { + readScripts.clear(); + writeScripts.clear(); + policy = ReadAuthPolicy.IOS_OPAQUE; + available = true; + failNextOp = null; + } + + private boolean consumeFailure(String op, AsyncResource out) { + if (failNextOp == null || !failNextOp.equals(op)) { + return false; + } + HealthError error = failNextError; + String message = failNextMessage; + failNextOp = null; + out.error(new HealthException(error, message)); + return true; + } + + private ReadAuthScript scriptFor(HealthDataType type) { + ReadAuthScript s = readScripts.get(type.getId()); + return s == null ? ReadAuthScript.GRANTED : s; + } + + // ------------------------------------------------------------------ + + public boolean isSupported() { + return available; + } + + /// Reports what the emulated platform is willing to say. + /// + /// Under [ReadAuthPolicy#IOS_OPAQUE] this is always + /// [HealthAuthorizationStatus#UNKNOWN] regardless of the script -- + /// that is the whole point, and code that branches on it must handle + /// the value rather than assume it means "denied". + public HealthAuthorizationStatus getReadAuthorizationStatus( + HealthDataType type) { + if (!available) { + return HealthAuthorizationStatus.NOT_SUPPORTED; + } + if (policy == ReadAuthPolicy.IOS_OPAQUE) { + return HealthAuthorizationStatus.UNKNOWN; + } + switch (scriptFor(type)) { + case GRANTED: + case GRANTED_BUT_NO_DATA: + return HealthAuthorizationStatus.AUTHORIZED; + case NOT_DETERMINED: + return HealthAuthorizationStatus.NOT_DETERMINED; + default: + return HealthAuthorizationStatus.DENIED; + } + } + + public HealthAuthorizationStatus getWriteAuthorizationStatus( + HealthDataType type) { + if (!available) { + return HealthAuthorizationStatus.NOT_SUPPORTED; + } + HealthAuthorizationStatus s = writeScripts.get(type.getId()); + return s == null ? HealthAuthorizationStatus.AUTHORIZED : s; + } + + /// A capability, not a grant. + /// + /// Both mobile stores answer this from what the platform can store, + /// independently of what the user has allowed -- and the shared layer + /// rejects an unwritable type as TYPE_NOT_SUPPORTED before any + /// authorization flow runs. Folding the scripted grant in here meant a + /// test scripting DENIED never reached the authorization path at all: + /// it got "this platform cannot store that" instead of "you are not + /// allowed to", which is a different bug for a developer to chase. The + /// scripted status is enforced in [#doWrite(List, AsyncResource)], + /// where a real store enforces it. + public boolean isWritable(HealthDataType type) { + return type != null && available; + } + + protected void doRequestAuthorization(List access, + AsyncResource out) { + if (consumeFailure("requestAuthorization", out)) { + return; + } + // Resolves true because the *flow completed*, not because anything + // was granted -- exactly as iOS behaves when the user declines + // every switch on the sheet. + out.complete(Boolean.TRUE); + } + + protected void doReadSamples(SampleQuery query, + AsyncResource out) { + if (consumeFailure("query", out)) { + return; + } + List types = query.getTypes(); + for (int i = 0; i < types.size(); i++) { + if (failsRead(types.get(i))) { + out.error(new HealthException(HealthError.UNAUTHORIZED, + "read access to " + types.get(i).getId() + + " was refused")); + return; + } + } + // Emptiness is per type, not per query. A type scripted to yield + // nothing -- GRANTED_BUT_NO_DATA, or a silent denial under the + // iOS policy -- used to empty the whole page, so adding one such + // type to a query made unrelated steps and heart rate disappear + // too. That is not what either platform does, and it turned the + // simulator's most valuable script into a misleading one. + // + // Withheld through the store's visibility hook rather than by + // filtering the finished page, which is where the first attempt + // at this went wrong: the shared store sorts and cuts to the + // limit before returning, so a hidden record that sorted first + // ate a slot and a limit-one query came back empty with a visible + // record sitting right behind it. + super.doReadSamples(query, out); + } + + @Override + protected boolean isVisible(HealthSample s) { + return !hidesData(s.getType()); + } + + /// Whether a read of `type` fails outright rather than coming back + /// empty. An error is an exception, not an absence, so it is the one + /// case that still fails the whole query. + private boolean failsRead(HealthDataType type) { + ReadAuthScript script = scriptFor(type); + if (script == ReadAuthScript.GRANTED + || script == ReadAuthScript.GRANTED_BUT_NO_DATA) { + return false; + } + return script == ReadAuthScript.DENIED_ERROR + || policy == ReadAuthPolicy.ANDROID_EXPLICIT; + } + + /// Whether `type` contributes nothing while the rest of the query + /// proceeds -- scripted as granted-but-empty, or silently denied + /// under the iOS policy, which an app cannot tell apart and neither + /// can this. + private boolean hidesData(HealthDataType type) { + ReadAuthScript script = scriptFor(type); + if (script == ReadAuthScript.GRANTED_BUT_NO_DATA) { + return true; + } + return script != ReadAuthScript.GRANTED + && policy != ReadAuthPolicy.ANDROID_EXPLICIT; + } + + /// Aggregates obey the read script too. + /// + /// Aggregation reads the local records directly rather than going + /// through doReadSamples, so a type scripted DENIED_SILENT returned a + /// real total while the matching sample read came back empty -- the + /// developer got a chart from data the simulator was pretending they + /// could not see, which is precisely the trap this store exists to + /// spring. + protected void doAggregate(com.codename1.health.AggregateQuery query, + long[] boundaries, + AsyncResource> out) { + if (consumeFailure("aggregate", out)) { + return; + } + List types = query.getTypes(); + for (int i = 0; i < types.size(); i++) { + if (failsRead(types.get(i))) { + out.error(new HealthException(HealthError.UNAUTHORIZED, + "read access to " + types.get(i).getId() + + " was refused")); + return; + } + } + // Per type, exactly as the read is, and through the same + // visibility hook. Emptying every metric because one type in the + // query was scripted to yield nothing made a scripted heart-rate + // gap erase the step total beside it, and a developer would + // reasonably read that as a bug in their own aggregation rather + // than as the script doing its job. + super.doAggregate(query, boundaries, out); + } + + protected void doWrite(List samples, + AsyncResource out) { + if (consumeFailure("save", out)) { + return; + } + for (HealthSample sample : samples) { + HealthAuthorizationStatus status = + getWriteAuthorizationStatus(sample.getType()); + // NOT_DETERMINED fails too. A write before the user has been + // asked is refused by both platforms, and letting it succeed + // here would let an app ship having never exercised its own + // authorization flow. + if (status == HealthAuthorizationStatus.DENIED + || status == HealthAuthorizationStatus.NOT_DETERMINED) { + out.error(new HealthException(HealthError.UNAUTHORIZED, + "write access to " + sample.getType().getId() + + " is " + status)); + return; + } + } + super.doWrite(samples, out); + } + + /// Deleting needs write authorization, as it does on Health Connect. + /// + /// Writes honoured the scripted status while deletes went straight + /// through, so a simulator test could delete records it was supposedly + /// not allowed to touch. + protected void doDelete(HealthDeleteRequest request, + AsyncResource out) { + if (consumeFailure("delete", out)) { + return; + } + List types = request.getTypes(); + for (int i = 0; i < types.size(); i++) { + HealthAuthorizationStatus status = + getWriteAuthorizationStatus(types.get(i)); + if (status == HealthAuthorizationStatus.DENIED + || status == HealthAuthorizationStatus.NOT_DETERMINED) { + out.error(new HealthException(HealthError.UNAUTHORIZED, + "write access to " + types.get(i).getId() + + " is " + status)); + return; + } + } + super.doDelete(request, out); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/health/SyntheticHealthData.java b/Ports/JavaSE/src/com/codename1/impl/javase/health/SyntheticHealthData.java new file mode 100644 index 00000000000..235f35728a5 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/health/SyntheticHealthData.java @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.javase.health; + +import com.codename1.health.HealthDataType; +import com.codename1.health.HealthQuantity; +import com.codename1.health.HealthSample; +import com.codename1.health.HealthUnit; +import com.codename1.health.QuantitySample; +import com.codename1.health.RecordingMethod; +import com.codename1.health.SleepSample; +import com.codename1.health.SleepStage; +import com.codename1.health.SleepStageInterval; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +/// Generates physiologically plausible health data for the simulator. +/// +/// #### Why generated rather than recorded +/// +/// The Bluetooth simulator replays recorded traces, scrubbed by +/// `FixtureScrambler`. That approach does not transfer to health data, and +/// the reason is not squeamishness: for BLE the sensitive parts are +/// *identifiers*, so scrubbing MAC addresses leaves a trace that is still +/// useful because tests assert on structure. For health, the sensitive part +/// **is** the value being asserted on. Scrubbing it either destroys the +/// signal, making the fixture worthless, or preserves it -- and a resting +/// heart rate, sleep timing and step cadence together are quasi-identifying +/// biometric data, committed to a public repository, in git history, +/// forever. +/// +/// There is also nothing to record from: there is no desktop HealthKit. +/// +/// So the simulator fabricates instead. Everything here is seeded, so a +/// test asserting an exact total is stable forever, and what would be +/// committed is a persona's parameters rather than anyone's data. +public final class SyntheticHealthData { + + private final Random random; + + /// Creates a generator with a fixed seed, so the same seed always + /// produces the same dataset. + public SyntheticHealthData(long seed) { + this.random = new Random(seed); + } + + /// A week of plausible data for a moderately active adult, ending at + /// `endMillis`. + public List generateWeek(long endMillis) { + return generate(endMillis, 7); + } + + /// `days` of data ending at `endMillis`: hourly step totals, resting + /// and active heart rate, a nightly sleep session with stages, and a + /// morning weight. + public List generate(long endMillis, int days) { + List out = new ArrayList(); + long dayMillis = 24L * 3600 * 1000; + long startOfWindow = endMillis - days * dayMillis; + double weightKg = 72 + random.nextDouble() * 4; + + for (int d = 0; d < days; d++) { + long dayStart = startOfWindow + d * dayMillis; + generateSteps(out, dayStart); + generateHeartRate(out, dayStart); + generateSleep(out, dayStart); + + // Weight drifts slowly with daily noise, the way a real scale + // reading does -- a flat line would make trend UI look broken. + weightKg += (random.nextDouble() - 0.5) * 0.3; + QuantitySample w = QuantitySample.create(HealthDataType.BODY_MASS, + new HealthQuantity(weightKg + random.nextDouble() * 0.4, + HealthUnit.KILOGRAM), + dayStart + 7 * 3600000L); + w.setRecordingMethod(RecordingMethod.MANUAL_ENTRY); + out.add(w); + } + return out; + } + + /// Hourly step totals shaped like a real day: nothing overnight, a + /// commute bump, a lunch walk and an evening peak. + private void generateSteps(List out, long dayStart) { + int[] hourlyMean = { + 0, 0, 0, 0, 0, 0, 120, 900, 1400, 400, 300, 350, + 1100, 700, 300, 350, 400, 1200, 1500, 800, 400, 200, 80, 0 + }; + for (int h = 0; h < 24; h++) { + if (hourlyMean[h] == 0) { + continue; + } + int steps = (int) (hourlyMean[h] + * (0.6 + random.nextDouble() * 0.8)); + long start = dayStart + h * 3600000L; + out.add(QuantitySample.create(HealthDataType.STEPS, + new HealthQuantity(steps, HealthUnit.COUNT), + start, start + 3600000L)); + } + } + + /// A circadian heart-rate curve: a resting floor overnight, a daytime + /// baseline, and excursions during the active hours. + private void generateHeartRate(List out, long dayStart) { + double resting = 52 + random.nextDouble() * 8; + for (int h = 0; h < 24; h++) { + boolean asleep = h < 6 || h >= 23; + boolean active = h == 7 || h == 12 || h == 17 || h == 18; + double base = asleep ? resting + : (active ? resting + 45 : resting + 18); + for (int q = 0; q < 4; q++) { + double bpm = base + (random.nextDouble() - 0.5) * 10; + bpm = Math.max(35, Math.min(205, bpm)); + long at = dayStart + h * 3600000L + q * 900000L; + out.add(QuantitySample.create(HealthDataType.HEART_RATE, + new HealthQuantity(Math.round(bpm), + HealthUnit.COUNT_PER_MINUTE), at)); + } + } + out.add(QuantitySample.create(HealthDataType.RESTING_HEART_RATE, + new HealthQuantity(Math.round(resting), + HealthUnit.COUNT_PER_MINUTE), + dayStart + 8 * 3600000L)); + } + + /// A night's sleep with realistic stage proportions and one or two + /// brief wakings. + private void generateSleep(List out, long dayStart) { + long onset = dayStart - 1 * 3600000L + + (long) (random.nextDouble() * 3600000L); + long duration = (long) ((6.5 + random.nextDouble() * 1.5) + * 3600000L); + long end = onset + duration; + + List stages = new ArrayList(); + long cursor = onset; + // Roughly 5% awake, 50% light, 20% deep, 25% REM -- the usual adult + // proportions, cycling rather than in one block. + while (cursor < end) { + long cycle = Math.min(90 * 60000L, end - cursor); + long light = (long) (cycle * 0.5); + long deep = (long) (cycle * 0.2); + long rem = cycle - light - deep; + stages.add(new SleepStageInterval(SleepStage.LIGHT, cursor, + cursor + light)); + cursor += light; + stages.add(new SleepStageInterval(SleepStage.DEEP, cursor, + cursor + deep)); + cursor += deep; + stages.add(new SleepStageInterval(SleepStage.REM, cursor, + Math.min(end, cursor + rem))); + cursor += rem; + if (cursor < end && random.nextDouble() < 0.4) { + long awake = Math.min(4 * 60000L, end - cursor); + stages.add(new SleepStageInterval(SleepStage.AWAKE, cursor, + cursor + awake)); + cursor += awake; + } + } + out.add(SleepSample.create(onset, end, stages)); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/AutoScheduler.java b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/AutoScheduler.java similarity index 66% rename from Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/AutoScheduler.java rename to Ports/JavaSE/src/com/codename1/impl/javase/simulator/AutoScheduler.java index f81d562a6fa..a74dd08ec11 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/AutoScheduler.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/AutoScheduler.java @@ -20,7 +20,7 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.impl.javase.bluetooth; +package com.codename1.impl.javase.simulator; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledThreadPoolExecutor; @@ -28,20 +28,39 @@ import java.util.concurrent.TimeUnit; /** - * The wall-clock {@link SimScheduler} used when the simulator actually - * runs: a single daemon thread named {@code cn1-bluetooth-sim}. This class - * is the only place in the simulated stack that touches real time -- all - * stack logic expresses time exclusively through - * {@link SimScheduler#postDelayed(Runnable, long)}. + * The wall-clock {@link SimScheduler} used when a simulated subsystem + * actually runs: a single daemon thread. This class is the only place in a + * simulated stack that touches real time -- all stack logic expresses time + * exclusively through {@link SimScheduler#postDelayed(Runnable, long)}. + * + *

The thread name is a constructor argument so that a stack trace or a + * profiler names the subsystem that owns the lane; each simulated subsystem + * gets its own scheduler and therefore its own thread.

*/ public final class AutoScheduler implements SimScheduler { + private static final String DEFAULT_THREAD_NAME = "cn1-bluetooth-sim"; + private final ScheduledThreadPoolExecutor executor; + /** + * Creates a scheduler on the default {@code cn1-bluetooth-sim} thread. + * Retained so existing Bluetooth callers keep their thread name. + */ public AutoScheduler() { + this(DEFAULT_THREAD_NAME); + } + + /** + * Creates a scheduler whose daemon thread carries {@code threadName}. + * A null or blank name falls back to the default. + */ + public AutoScheduler(String threadName) { + final String name = (threadName == null || threadName.trim().length() == 0) + ? DEFAULT_THREAD_NAME : threadName.trim(); executor = new ScheduledThreadPoolExecutor(1, new ThreadFactory() { public Thread newThread(Runnable r) { - Thread t = new Thread(r, "cn1-bluetooth-sim"); + Thread t = new Thread(r, name); t.setDaemon(true); return t; } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/ManualScheduler.java b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/ManualScheduler.java similarity index 99% rename from Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/ManualScheduler.java rename to Ports/JavaSE/src/com/codename1/impl/javase/simulator/ManualScheduler.java index b71c2ee76c0..876391e2751 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/ManualScheduler.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/ManualScheduler.java @@ -20,7 +20,7 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.impl.javase.bluetooth; +package com.codename1.impl.javase.simulator; import java.util.PriorityQueue; diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimScheduler.java b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimScheduler.java similarity index 80% rename from Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimScheduler.java rename to Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimScheduler.java index 4e461b4d6b9..174ea6ccbad 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/bluetooth/SimScheduler.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimScheduler.java @@ -20,18 +20,21 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.impl.javase.bluetooth; +package com.codename1.impl.javase.simulator; /** - * The single execution lane of the simulated Bluetooth stack. Every state - * mutation and every callback of {@link SimulatedBluetoothStack} runs - * through one scheduler, which makes the whole simulation single-threaded - * and deterministic. + * The single execution lane of a simulated subsystem. Every state mutation + * and every callback of the simulated stack runs through one scheduler, + * which makes the whole simulation single-threaded and deterministic. * *

Two implementations exist: {@link AutoScheduler} (a real daemon * thread, used by the running simulator) and {@link ManualScheduler} * (a virtual clock pumped explicitly, used by unit tests and anything * else that needs full determinism).

+ * + *

Shared by the simulated Bluetooth stack and the simulated health + * store; anything else that needs deterministic callback ordering in the + * simulator should reuse it rather than growing its own lane.

*/ public interface SimScheduler { diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java index 7f6e6828749..7448ad82984 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java @@ -63,6 +63,30 @@ * {@code itemN} value is a {@code fqcn#staticMethodName} reference; the * matching {@code labelN} (optional) becomes the menu item text.

* + *

Multiple groups per file. One resource can only exist once per + * classpath entry, so a port that ships more than one simulated subsystem + * cannot express them as separate files. Such a file declares a + * {@code groups=} list and prefixes every key with the group token:

+ * + *
+ * groups=bluetooth,health
+ *
+ * bluetooth.name=Bluetooth
+ * bluetooth.namespace=bluetooth
+ * bluetooth.item1=com.example.bt.sim.Hooks#toggleAdapter
+ * bluetooth.label1=Toggle adapter
+ *
+ * health.name=Health
+ * health.namespace=health
+ * health.item1=com.example.health.sim.Hooks#grantAllPermissions
+ * health.label1=Grant all permissions
+ * 
+ * + *

The two forms are mutually exclusive per file and the prefixed form is + * opt-in: a file without {@code groups=} is parsed exactly as before, so + * every existing cn1lib keeps working untouched. A group token that declares + * no {@code name} is skipped with a warning rather than failing the file.

+ * *

Actions are resolved to {@code public static void method()} via reflection * using the same classloader that loaded {@link Display}. Invocations are * dispatched via {@link Display#callSeriallyAndWait(Runnable)} so menu clicks @@ -137,13 +161,37 @@ private static void loadOne(URL url, ClassLoader cl, List out) th } finally { in.close(); } - String menuName = props.getProperty("name"); + String groups = props.getProperty("groups"); + if (groups == null || groups.trim().length() == 0) { + // Legacy single-group form: keys are unprefixed. + loadGroup(props, "", url, cl, out); + return; + } + String[] tokens = groups.split(","); + for (int i = 0; i < tokens.length; i++) { + String token = tokens[i].trim(); + if (token.length() == 0) { + continue; + } + loadGroup(props, token + ".", url, cl, out); + } + } + + /** + * Reads one group of hooks. {@code prefix} is either the empty string + * (legacy single-group file) or {@code "."}. + */ + private static void loadGroup(Properties props, String prefix, URL url, + ClassLoader cl, List out) { + String menuName = props.getProperty(prefix + "name"); if (menuName == null || menuName.trim().length() == 0) { - System.err.println("SimulatorHookLoader: " + url + " is missing required 'name' property; skipping"); + System.err.println("SimulatorHookLoader: " + url + " is missing required '" + + prefix + "name' property; skipping" + + (prefix.length() == 0 ? "" : " group '" + prefix + "'")); return; } menuName = menuName.trim(); - String namespace = props.getProperty("namespace"); + String namespace = props.getProperty(prefix + "namespace"); if (namespace == null || namespace.trim().length() == 0) { namespace = slugify(menuName); } else { @@ -154,11 +202,11 @@ private static void loadOne(URL url, ClassLoader cl, List out) th // first missing N. labelN is optional (no label = API-only hook). int index = 1; while (true) { - String action = props.getProperty("item" + index); + String action = props.getProperty(prefix + "item" + index); if (action == null || action.trim().length() == 0) { break; } - String label = props.getProperty("label" + index); + String label = props.getProperty(prefix + "label" + index); if (label != null) { label = label.trim(); if (label.length() == 0) { diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java index cb99941fd43..b77762ae6cc 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java @@ -6448,6 +6448,21 @@ public com.codename1.bluetooth.Bluetooth getBluetooth() { return bluetooth; } + private com.codename1.health.Health health; + + /// Returns a local health store. There is no platform health provider + /// on this port, so the store reports + /// {@code HealthAvailability.LOCAL_ONLY}: reads and writes work and + /// are this app's own, but nothing else writes into it. The Bluetooth + /// sensor layer is unaffected and works fully. + @Override + public com.codename1.health.Health getHealth() { + if (health == null) { + health = new com.codename1.impl.health.LocalHealth(); + } + return health; + } + private com.codename1.media.VideoIO videoIO; private boolean videoIOResolved; diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 55113c7f22c..279138c9cfb 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -429,6 +429,21 @@ public com.codename1.bluetooth.Bluetooth getBluetooth() { return bluetooth; } + private com.codename1.health.Health health; + + /// Returns a local health store. There is no platform health provider + /// on this port, so the store reports + /// {@code HealthAvailability.LOCAL_ONLY}: reads and writes work and + /// are this app's own, but nothing else writes into it. The Bluetooth + /// sensor layer is unaffected and works fully. + @Override + public com.codename1.health.Health getHealth() { + if (health == null) { + health = new com.codename1.impl.health.LocalHealth(); + } + return health; + } + // WinRT Geolocator-backed location. getCurrentLocation reports OUT_OF_SERVICE // honestly when Linux location is disabled / denied. private com.codename1.location.LocationManager locationManager; diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index 499bfb45617..2e401c380de 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -419,6 +419,21 @@ public com.codename1.bluetooth.Bluetooth getBluetooth() { return bluetooth; } + private com.codename1.health.Health health; + + /// Returns a local health store. There is no platform health provider + /// on this port, so the store reports + /// {@code HealthAvailability.LOCAL_ONLY}: reads and writes work and + /// are this app's own, but nothing else writes into it. The Bluetooth + /// sensor layer is unaffected and works fully. + @Override + public com.codename1.health.Health getHealth() { + if (health == null) { + health = new com.codename1.impl.health.LocalHealth(); + } + return health; + } + // WinRT Geolocator-backed location. getCurrentLocation reports OUT_OF_SERVICE // honestly when Windows location is disabled / denied. private com.codename1.location.LocationManager locationManager; diff --git a/Ports/iOSPort/nativeSources/CN1Health.h b/Ports/iOSPort/nativeSources/CN1Health.h new file mode 100644 index 00000000000..b82f712be02 --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1Health.h @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +// +// CN1Health.h +// The HealthKit bridge behind com.codename1.health. +// +// Everything here is gated on CN1_INCLUDE_HEALTH, which IPhoneBuilder +// uncomments in CodenameOne_GLViewController.h only for apps that use a +// health store. The store, the serial queue and the query registry are all +// file-static in CN1Health.m -- nothing is exported -- so this header +// exists only to give the translation unit the feature gate and the shared +// error codes. +// +// The #else branch of CN1Health.m provides no-op trampolines for every +// native declared in IOSNative.java, so a health-free app still links. +// + +#ifndef CN1_HEALTH_H +#define CN1_HEALTH_H + +#import + +// Error codes handed back to IOSHealth.nativeHkRequestError. Keep in sync +// with the constants of the same names in IOSHealth.java. +#define CN1_HK_ERR_UNKNOWN 0 +#define CN1_HK_ERR_NOT_AVAILABLE 1 +#define CN1_HK_ERR_AUTH_DENIED 2 +#define CN1_HK_ERR_AUTH_NOT_DETERMINED 3 +#define CN1_HK_ERR_INVALID_ARGUMENT 4 +#define CN1_HK_ERR_NO_DATA 5 +#define CN1_HK_ERR_DATABASE_INACCESSIBLE 6 +#define CN1_HK_ERR_USER_CANCELED 7 +#define CN1_HK_ERR_NOT_SUPPORTED 8 +#define CN1_HK_ERR_ANCHOR_INVALID 9 +#define CN1_HK_ERR_RATE_LIMITED 10 + +#endif diff --git a/Ports/iOSPort/nativeSources/CN1Health.m b/Ports/iOSPort/nativeSources/CN1Health.m new file mode 100644 index 00000000000..05869426755 --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1Health.m @@ -0,0 +1,612 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +#import "CodenameOne_GLViewController.h" +#import "CN1Health.h" + +#ifdef CN1_INCLUDE_HEALTH + +#import +#include "com_codename1_impl_ios_IOSHealth.h" + +// --------------------------------------------------------------------- +// Shared state +// +// One store for the app's lifetime: HKObserverQuery and background +// delivery are bound to the store instance and die with it, so a +// short-lived store would silently stop delivering. +// --------------------------------------------------------------------- + +static HKHealthStore *cn1hkStore = nil; +static dispatch_queue_t cn1hkQueue = nil; +static void *cn1hkQueueKey = &cn1hkQueueKey; + +static void cn1hkInit(void) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + cn1hkStore = [[HKHealthStore alloc] init]; + cn1hkQueue = dispatch_queue_create("com.codename1.health", + DISPATCH_QUEUE_SERIAL); + dispatch_queue_set_specific(cn1hkQueue, cn1hkQueueKey, + cn1hkQueueKey, NULL); + }); +} + +// Runs a block on the health queue. Executes inline when already on it, +// so a Java callback that re-enters the bridge cannot deadlock -- the same +// pattern CN1Bluetooth.m uses. +static void cn1hkAsync(void (^block)(void)) { + cn1hkInit(); + if (dispatch_get_specific(cn1hkQueueKey) == cn1hkQueueKey) { + block(); + } else { + dispatch_async(cn1hkQueue, block); + } +} + +// --------------------------------------------------------------------- +// Type mapping +// +// Portable data-type ids to HealthKit identifiers. Returns nil for a type +// this iOS version does not know, which the callers report as +// NOT_SUPPORTED rather than crashing. +// --------------------------------------------------------------------- + +static NSDictionary *cn1hkTypeMap(void) { + static NSDictionary *map = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + map = [@{ + @"steps": HKQuantityTypeIdentifierStepCount, + @"distance_walking_running": + HKQuantityTypeIdentifierDistanceWalkingRunning, + @"distance_cycling": HKQuantityTypeIdentifierDistanceCycling, + @"flights_climbed": HKQuantityTypeIdentifierFlightsClimbed, + @"active_energy": HKQuantityTypeIdentifierActiveEnergyBurned, + @"basal_energy": HKQuantityTypeIdentifierBasalEnergyBurned, + @"heart_rate": HKQuantityTypeIdentifierHeartRate, + @"resting_heart_rate": HKQuantityTypeIdentifierRestingHeartRate, + @"heart_rate_variability_sdnn": + HKQuantityTypeIdentifierHeartRateVariabilitySDNN, + @"oxygen_saturation": HKQuantityTypeIdentifierOxygenSaturation, + @"respiratory_rate": HKQuantityTypeIdentifierRespiratoryRate, + @"body_temperature": HKQuantityTypeIdentifierBodyTemperature, + @"vo2_max": HKQuantityTypeIdentifierVO2Max, + @"blood_glucose": HKQuantityTypeIdentifierBloodGlucose, + @"body_mass": HKQuantityTypeIdentifierBodyMass, + @"lean_body_mass": HKQuantityTypeIdentifierLeanBodyMass, + @"body_fat_percentage": + HKQuantityTypeIdentifierBodyFatPercentage, + @"body_mass_index": HKQuantityTypeIdentifierBodyMassIndex, + @"height": HKQuantityTypeIdentifierHeight, + @"hydration": HKQuantityTypeIdentifierDietaryWater, + @"dietary_energy": HKQuantityTypeIdentifierDietaryEnergyConsumed + } retain]; + }); + return map; +} + +static HKQuantityType *cn1hkQuantityType(NSString *portableId) { + NSString *identifier = [cn1hkTypeMap() objectForKey:portableId]; + if (identifier == nil) { + return nil; + } + return [HKQuantityType quantityTypeForIdentifier:identifier]; +} + +// The unit each portable type is normalised to. These are the canonical +// units declared by HealthDataType, and HKUnit parses the same symbols the +// Java side uses -- which is why the symbol is public API there. +static HKUnit *cn1hkUnit(NSString *portableId) { + if ([portableId isEqualToString:@"steps"] + || [portableId isEqualToString:@"flights_climbed"]) { + return [HKUnit countUnit]; + } + if ([portableId isEqualToString:@"heart_rate"] + || [portableId isEqualToString:@"resting_heart_rate"] + || [portableId isEqualToString:@"respiratory_rate"]) { + return [[HKUnit countUnit] unitDividedByUnit:[HKUnit minuteUnit]]; + } + if ([portableId hasPrefix:@"distance"] + || [portableId isEqualToString:@"height"]) { + return [HKUnit meterUnit]; + } + if ([portableId isEqualToString:@"body_mass"] + || [portableId isEqualToString:@"lean_body_mass"]) { + return [HKUnit gramUnitWithMetricPrefix:HKMetricPrefixKilo]; + } + if ([portableId hasSuffix:@"energy"]) { + return [HKUnit kilocalorieUnit]; + } + if ([portableId isEqualToString:@"body_temperature"]) { + return [HKUnit degreeCelsiusUnit]; + } + if ([portableId isEqualToString:@"heart_rate_variability_sdnn"]) { + return [HKUnit secondUnitWithMetricPrefix:HKMetricPrefixMilli]; + } + if ([portableId isEqualToString:@"oxygen_saturation"] + || [portableId isEqualToString:@"body_fat_percentage"]) { + return [HKUnit percentUnit]; + } + if ([portableId isEqualToString:@"blood_glucose"]) { + return [[HKUnit moleUnitWithMetricPrefix:HKMetricPrefixMilli + molarMass:HKUnitMolarMassBloodGlucose] + unitDividedByUnit:[HKUnit literUnit]]; + } + if ([portableId isEqualToString:@"hydration"]) { + return [HKUnit literUnit]; + } + if ([portableId isEqualToString:@"vo2_max"]) { + return [[[HKUnit literUnitWithMetricPrefix:HKMetricPrefixMilli] + unitDividedByUnit:[HKUnit gramUnitWithMetricPrefix: + HKMetricPrefixKilo]] + unitDividedByUnit:[HKUnit minuteUnit]]; + } + if ([portableId isEqualToString:@"body_mass_index"]) { + return [HKUnit countUnit]; + } + return [HKUnit countUnit]; +} + +// The symbol string the Java side expects back, matching HealthUnit. +static NSString *cn1hkUnitSymbol(NSString *portableId) { + if ([portableId isEqualToString:@"heart_rate"] + || [portableId isEqualToString:@"resting_heart_rate"] + || [portableId isEqualToString:@"respiratory_rate"]) { + return @"count/min"; + } + if ([portableId hasPrefix:@"distance"] + || [portableId isEqualToString:@"height"]) { + return @"m"; + } + if ([portableId isEqualToString:@"body_mass"] + || [portableId isEqualToString:@"lean_body_mass"]) { + return @"kg"; + } + if ([portableId hasSuffix:@"energy"]) { + return @"kcal"; + } + if ([portableId isEqualToString:@"body_temperature"]) { + return @"degC"; + } + if ([portableId isEqualToString:@"heart_rate_variability_sdnn"]) { + return @"ms"; + } + if ([portableId isEqualToString:@"oxygen_saturation"] + || [portableId isEqualToString:@"body_fat_percentage"]) { + return @"%"; + } + if ([portableId isEqualToString:@"blood_glucose"]) { + return @"mmol/L"; + } + if ([portableId isEqualToString:@"hydration"]) { + return @"L"; + } + if ([portableId isEqualToString:@"vo2_max"]) { + return @"mL/(kg*min)"; + } + return @"count"; +} + +static void cn1hkReportError(JAVA_INT requestId, int code, NSString *msg) { + com_codename1_impl_ios_IOSHealth_nativeHkRequestError___int_int_java_lang_String( + getThreadLocalData(), requestId, code, + fromNSString(getThreadLocalData(), msg)); +} + +// The portable code for a HealthKit error. +// +// The distinction that matters most here is retryable versus not. The +// store is encrypted at rest and unreadable before the device's first +// unlock -- exactly when a background drain runs -- and reporting that as +// a denial sends the user to the permission screen for a condition that +// will clear on its own, or makes an app discard buffered sensor and +// workout data it should simply have written again a moment later. +// +// Anything unrecognised stays UNKNOWN rather than being guessed at: the +// shared layer treats that as a plain failure, which is the honest answer +// for an error this build has never seen. +static int cn1hkErrorCode(NSError *error) { + if (error == nil) { + return CN1_HK_ERR_UNKNOWN; + } + switch ([error code]) { + case HKErrorDatabaseInaccessible: + return CN1_HK_ERR_DATABASE_INACCESSIBLE; + case HKErrorAuthorizationDenied: + case HKErrorAuthorizationNotDetermined: + return CN1_HK_ERR_AUTH_DENIED; + default: + return CN1_HK_ERR_UNKNOWN; + } +} + +// --------------------------------------------------------------------- +// Natives +// --------------------------------------------------------------------- + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_hkIsAvailable___R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return [HKHealthStore isHealthDataAvailable] ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_BOOLEAN +com_codename1_impl_ios_IOSNative_hkIsTypeSupported___java_lang_String_R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT typeId) { + if (typeId == JAVA_NULL) { + return JAVA_FALSE; + } + // The map is the authority. Deriving support from the portable type's + // canonical unit instead would advertise types HealthKit has never + // heard of, and the failure would surface only at query time. + NSString *portableId = toNSString(threadStateData, typeId); + return [cn1hkTypeMap() objectForKey:portableId] != nil + ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_INT +com_codename1_impl_ios_IOSNative_hkShareAuthorizationStatus___java_lang_String_R_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT typeId) { + cn1hkInit(); + NSString *portableId = toNSString(threadStateData, typeId); + HKQuantityType *type = cn1hkQuantityType(portableId); + if (type == nil) { + return 0; + } + // Meaningful for share (write) types only. HealthKit deliberately + // reports nothing useful for read types, which is why there is no + // corresponding read query -- adding one would require lying. + return (JAVA_INT)[cn1hkStore authorizationStatusForType:type]; +} + +void +com_codename1_impl_ios_IOSNative_hkRequestAuthorization___int_java_lang_String_1ARRAY_java_lang_String_1ARRAY( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT readTypes, JAVA_OBJECT shareTypes) { + cn1hkInit(); + // Convert while the Java frame is still live: once it returns, the + // JAVA_OBJECTs are no longer rooted and must not be touched from the + // dispatched block. + NSMutableSet *readSet = [NSMutableSet set]; + NSMutableSet *shareSet = [NSMutableSet set]; + if (readTypes != JAVA_NULL) { + JAVA_ARRAY arr = (JAVA_ARRAY)readTypes; + JAVA_ARRAY_OBJECT *data = (JAVA_ARRAY_OBJECT *)arr->data; + for (int i = 0; i < arr->length; i++) { + HKQuantityType *t = cn1hkQuantityType( + toNSString(threadStateData, (JAVA_OBJECT)data[i])); + if (t != nil) { + [readSet addObject:t]; + } + } + } + if (shareTypes != JAVA_NULL) { + JAVA_ARRAY arr = (JAVA_ARRAY)shareTypes; + JAVA_ARRAY_OBJECT *data = (JAVA_ARRAY_OBJECT *)arr->data; + for (int i = 0; i < arr->length; i++) { + HKQuantityType *t = cn1hkQuantityType( + toNSString(threadStateData, (JAVA_OBJECT)data[i])); + if (t != nil) { + [shareSet addObject:t]; + } + } + } + JAVA_INT rid = requestId; + cn1hkAsync(^{ + [cn1hkStore requestAuthorizationToShareTypes:shareSet + readTypes:readSet + completion:^(BOOL success, NSError *error) { + // success means the sheet completed, NOT that anything was + // granted -- the portable contract says the same. + com_codename1_impl_ios_IOSHealth_nativeHkAuthorizationResult___int_boolean_int_java_lang_String( + getThreadLocalData(), rid, + success ? JAVA_TRUE : JAVA_FALSE, + error == nil ? -1 : cn1hkErrorCode(error), + error == nil ? JAVA_NULL + : fromNSString(getThreadLocalData(), + [error localizedDescription])); + }]; + }); +} + +/// Runs the sample query and reports the page. Takes ownership of +/// `portableId`, releasing it once the results handler has run. +static void cn1hkRunSampleQuery(JAVA_INT rid, NSString *portableId, + HKQuantityType *type, NSPredicate *pred, int lim, BOOL asc) { + NSSortDescriptor *sort = [NSSortDescriptor + sortDescriptorWithKey:HKSampleSortIdentifierStartDate + ascending:asc]; + HKSampleQuery *q = [[HKSampleQuery alloc] + initWithSampleType:type predicate:pred limit:lim + sortDescriptors:@[sort] + resultsHandler:^(HKSampleQuery *query, NSArray *results, + NSError *error) { + if (error != nil) { + // A locked device makes the store unreadable, which is + // exactly when a background observer fires. It must stay + // distinguishable from "no data" so callers can retry. + cn1hkReportError(rid, cn1hkErrorCode(error), + [error localizedDescription]); + [portableId release]; + return; + } + HKUnit *unit = cn1hkUnit(portableId); + NSString *symbol = cn1hkUnitSymbol(portableId); + NSMutableString *tsv = [NSMutableString string]; + for (HKQuantitySample *sample in results) { + double value = [[sample quantity] doubleValueForUnit:unit]; + if ([unit isEqual:[HKUnit percentUnit]]) { + // HKUnit percent is a 0..1 fraction -- Apple defines it + // as "valid values from 0.0-1.0 inclusive" -- while + // HealthUnit.PERCENT is 0..100. Without this a 97% + // oxygen saturation reads as 0.97. + // + // This scaling was here, was removed in 8e2d009832 on + // the belief that percentUnit already answers in + // percent, and is back because that belief was wrong. + // Nothing in this repository compiles or runs this file, + // so the "observation" that argued it away was not one. + // Check Apple's definition before touching it again. + value *= 100.0; + } + HKSource *src = [[sample sourceRevision] source]; + // Only the user-entered flag is reported. HealthKit does + // not distinguish an automatic reading from an actively + // recorded one, and answering AUTOMATIC for everything + // else would be a guess dressed as a fact -- an empty + // field decodes to UNKNOWN, which is what we know. + NSNumber *entered = + [[sample metadata] objectForKey:HKMetadataKeyWasUserEntered]; + [tsv appendFormat:@"%@\t%@\t%lld\t%lld\t%f\t%@\t%@\t%@\t%@\t%@\n", + [[sample UUID] UUIDString], portableId, + (long long)([[sample startDate] timeIntervalSince1970] + * 1000.0), + (long long)([[sample endDate] timeIntervalSince1970] + * 1000.0), + value, symbol, + src ? [src bundleIdentifier] : @"", + src && [src name] ? [src name] : @"", + [sample device] && [[sample device] name] + ? [[sample device] name] : @"", + (entered && [entered boolValue]) ? @"MANUAL_ENTRY" : @""]; + } + com_codename1_impl_ios_IOSHealth_nativeHkSamples___int_java_lang_String( + getThreadLocalData(), rid, + fromNSString(getThreadLocalData(), tsv)); + [portableId release]; + }]; + [cn1hkStore executeQuery:q]; + [q release]; +} + +void +com_codename1_impl_ios_IOSNative_hkQuerySamples___int_java_lang_String_double_double_int_boolean_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT typeId, JAVA_DOUBLE startMs, JAVA_DOUBLE endMs, + JAVA_INT limit, JAVA_BOOLEAN ascending, JAVA_OBJECT sourceIds) { + cn1hkInit(); + NSString *portableId = [toNSString(threadStateData, typeId) retain]; + NSString *wantedSources = sourceIds == JAVA_NULL ? nil + : [toNSString(threadStateData, sourceIds) retain]; + JAVA_INT rid = requestId; + double s = startMs, e = endMs; + int lim = limit; + BOOL asc = ascending == JAVA_TRUE; + + cn1hkAsync(^{ + HKQuantityType *type = cn1hkQuantityType(portableId); + if (type == nil) { + cn1hkReportError(rid, CN1_HK_ERR_NOT_SUPPORTED, + @"type unavailable on this iOS version"); + [portableId release]; + [wantedSources release]; + return; + } + NSDate *from = [NSDate dateWithTimeIntervalSince1970:s / 1000.0]; + NSDate *to = [NSDate dateWithTimeIntervalSince1970:e / 1000.0]; + NSPredicate *pred = [HKQuery predicateForSamplesWithStartDate:from + endDate:to + options: + // Overlap, not strict-start. The portable contract -- and + // LocalHealthStore.matches() -- includes an interval that + // straddles a boundary, so steps recorded 11:55-12:05 belong to + // a query starting at noon. HKQueryOptionStrictStartDate would + // drop them and make the same SampleQuery mean different things + // on iOS than everywhere else. + HKQueryOptionNone]; + if (wantedSources == nil || [wantedSources length] == 0) { + [wantedSources release]; + cn1hkRunSampleQuery(rid, portableId, type, pred, lim, asc); + return; + } + // Source filtering has to be part of the query. HealthKit applies + // the limit itself, so filtering the returned page afterwards -- + // which is what the shared post-filter does -- discards records + // that were already counted against the limit, and with no + // continuation token the ones behind them are unreachable. A read + // whose first page happened to be another app's data came back + // empty even though matching samples existed. + // + // Sources are named by bundle identifier, and only HKSourceQuery + // can turn one into the HKSource that a predicate needs. + NSSet *wanted = [NSSet setWithArray: + [wantedSources componentsSeparatedByString:@"\t"]]; + HKSourceQuery *sq = [[HKSourceQuery alloc] + initWithSampleType:type samplePredicate:pred + completionHandler:^(HKSourceQuery *q, NSSet *sources, + NSError *error) { + if (error != nil) { + cn1hkReportError(rid, cn1hkErrorCode(error), + [error localizedDescription]); + [portableId release]; + [wantedSources release]; + return; + } + NSMutableSet *matched = [NSMutableSet set]; + for (HKSource *src in sources) { + if ([wanted containsObject:[src bundleIdentifier]]) { + [matched addObject:src]; + } + } + [wantedSources release]; + if ([matched count] == 0) { + // No source in this store matches, so the answer is an + // empty page rather than an unfiltered one. + com_codename1_impl_ios_IOSHealth_nativeHkSamples___int_java_lang_String( + getThreadLocalData(), rid, + fromNSString(getThreadLocalData(), @"")); + [portableId release]; + return; + } + NSPredicate *both = [NSCompoundPredicate + andPredicateWithSubpredicates:@[pred, + [HKQuery predicateForObjectsFromSources:matched]]]; + cn1hkRunSampleQuery(rid, portableId, type, both, lim, asc); + }]; + [cn1hkStore executeQuery:sq]; + [sq release]; + }); +} + +void com_codename1_impl_ios_IOSNative_hkSaveSamples___int_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT tsv) { + cn1hkInit(); + NSString *payload = [toNSString(threadStateData, tsv) retain]; + JAVA_INT rid = requestId; + cn1hkAsync(^{ + NSMutableArray *samples = [NSMutableArray array]; + NSMutableString *ids = [NSMutableString string]; + for (NSString *line in [payload componentsSeparatedByString:@"\n"]) { + if ([line length] == 0) { + continue; + } + NSArray *f = [line componentsSeparatedByString:@"\t"]; + if ([f count] < 6) { + continue; + } + NSString *portableId = [f objectAtIndex:1]; + HKQuantityType *type = cn1hkQuantityType(portableId); + if (type == nil) { + continue; + } + double value = [[f objectAtIndex:4] doubleValue]; + HKUnit *writeUnit = cn1hkUnit(portableId); + if ([writeUnit isEqual:[HKUnit percentUnit]]) { + // The read path's inverse: HealthUnit.PERCENT is 0..100 + // and HKUnit percent is a 0..1 fraction, so 97% has to + // be stored as 0.97. See the note there. + value /= 100.0; + } + HKQuantity *quantity = [HKQuantity + quantityWithUnit:writeUnit doubleValue:value]; + NSDate *from = [NSDate dateWithTimeIntervalSince1970: + [[f objectAtIndex:2] doubleValue] / 1000.0]; + NSDate *to = [NSDate dateWithTimeIntervalSince1970: + [[f objectAtIndex:3] doubleValue] / 1000.0]; + // Field 6 is the recording method. HealthKit has no general + // notion of one, but it does have the single distinction that + // matters to every other app reading the sample: whether a + // person typed it in. Dropping it made a hand-entered weight + // indistinguishable from a scale reading, and a later read of + // our own sample reported UNKNOWN. + NSDictionary *metadata = nil; + if ([f count] > 6 + && [[f objectAtIndex:6] isEqualToString:@"MANUAL_ENTRY"]) { + metadata = @{HKMetadataKeyWasUserEntered: @YES}; + } + HKQuantitySample *sample = [HKQuantitySample + quantitySampleWithType:type quantity:quantity + startDate:from endDate:to + metadata:metadata]; + [samples addObject:sample]; + [ids appendFormat:@"%@\n", [[sample UUID] UUIDString]]; + } + [cn1hkStore saveObjects:samples withCompletion: + ^(BOOL success, NSError *error) { + if (!success) { + // Every failure used to be reported as a denial, so a + // save attempted before first unlock sent the app to the + // permission screen -- or made it discard buffered sensor + // and workout data -- for a condition that clears itself. + cn1hkReportError(rid, cn1hkErrorCode(error), + error ? [error localizedDescription] : @"save failed"); + } else { + com_codename1_impl_ios_IOSHealth_nativeHkSaveResult___int_java_lang_String( + getThreadLocalData(), rid, + fromNSString(getThreadLocalData(), ids)); + } + [payload release]; + }]; + }); +} + +#else + +// --------------------------------------------------------------------- +// Health compiled out. +// +// ParparVM links every native declared in IOSNative.java, so each one +// needs a symbol even when the feature is absent. These trampolines let a +// health-free app -- and the tvOS and Mac Catalyst slices, where HealthKit +// does not exist -- link cleanly. The Java side never calls them, because +// Health.getInstance() reports the feature unsupported first. +// --------------------------------------------------------------------- + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_hkIsAvailable___R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { + return JAVA_FALSE; +} + +JAVA_BOOLEAN +com_codename1_impl_ios_IOSNative_hkIsTypeSupported___java_lang_String_R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT typeId) { + return JAVA_FALSE; +} + + +JAVA_INT +com_codename1_impl_ios_IOSNative_hkShareAuthorizationStatus___java_lang_String_R_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT typeId) { + return 0; +} + +void +com_codename1_impl_ios_IOSNative_hkRequestAuthorization___int_java_lang_String_1ARRAY_java_lang_String_1ARRAY( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT readTypes, JAVA_OBJECT shareTypes) { +} + +void +com_codename1_impl_ios_IOSNative_hkQuerySamples___int_java_lang_String_double_double_int_boolean_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT typeId, JAVA_DOUBLE startMs, JAVA_DOUBLE endMs, + JAVA_INT limit, JAVA_BOOLEAN ascending, JAVA_OBJECT sourceIds) { +} + +void com_codename1_impl_ios_IOSNative_hkSaveSamples___int_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_INT requestId, + JAVA_OBJECT tsv) { +} + +#endif diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h index b22fa97e66e..59da6345629 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -86,6 +86,11 @@ void cn1RunSyncOnMainQueue(void (^block)(void)); //#define INCLUDE_SIRI_USAGE //#define INCLUDE_SPEECHRECOGNITION_USAGE //#define INCLUDE_NFCREADER_USAGE +// Flipped automatically by IPhoneBuilder from the matching +// ios.NSHealth*UsageDescription build hints, via the generic +// NSUsageDescription -> INCLUDE__USAGE rule. +//#define INCLUDE_HEALTHSHARE_USAGE +//#define INCLUDE_HEALTHUPDATE_USAGE // CN1_INCLUDE_NFC gates the com.codename1.nfc native bridge (CoreNFC.framework // import, NFCNDEFReaderSession / NFCTagReaderSession code). IPhoneBuilder @@ -181,6 +186,29 @@ void cn1RunSyncOnMainQueue(void (^block)(void)); // per target slice and isBlePeripheralSupported reports false there. //#define CN1_INCLUDE_BLUETOOTH +// CN1_INCLUDE_HEALTH gates the com.codename1.health native bridge +// (CN1Health.{h,m}: HKHealthStore queries, HKObserverQuery background +// delivery and, on the watch slice, HKWorkoutSession). IPhoneBuilder +// uncomments this only when the classpath scanner saw health classes +// OUTSIDE com.codename1.health.sensors -- the sensor layer is ordinary +// CoreBluetooth and must not pull HealthKit in. Apps that never touch +// health data therefore ship without HealthKit symbols, need no +// com.apple.developer.healthkit entitlement, and are not subject to +// Apple's health-data review. +//#define CN1_INCLUDE_HEALTH +// HealthKit does not exist on tvOS and is unavailable to Mac Catalyst +// apps. Undoing the define here, in the header every health translation +// unit includes first, compiles the whole path out on those slices. +#if TARGET_OS_TV || TARGET_OS_MACCATALYST +#undef CN1_INCLUDE_HEALTH +#endif +// HKWorkoutSession and HKLiveWorkoutBuilder are watchOS-only. The iOS +// slice records workouts through the portable RecordedWorkoutSession +// instead, and isLiveSessionSupported() reports false there. +#if TARGET_OS_WATCH && defined(CN1_INCLUDE_HEALTH) +#define CN1_HEALTH_WORKOUT_SESSION 1 +#endif + //#define INCLUDE_CN1_BACKGROUND_FETCH //#define INCLUDE_FACEBOOK_CONNECT //#define USE_FACEBOOK_CONNECT_PODS diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index fdbb4cc31d5..2f9ba9471b5 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -8116,6 +8116,22 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_checkLocationUsage___R_boolean(CN1 return JAVA_FALSE; #endif } +//native boolean checkHealthShareUsage(); +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_checkHealthShareUsage___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +#ifdef INCLUDE_HEALTHSHARE_USAGE + return JAVA_TRUE; +#else + return JAVA_FALSE; +#endif +} +//native boolean checkHealthUpdateUsage(); +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_checkHealthUpdateUsage___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { +#ifdef INCLUDE_HEALTHUPDATE_USAGE + return JAVA_TRUE; +#else + return JAVA_FALSE; +#endif +} //native boolean checkMicrophoneUsage(); JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_checkMicrophoneUsage___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) { #ifdef INCLUDE_MICROPHONE_USAGE diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSHealth.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSHealth.java new file mode 100644 index 00000000000..f7d46d03ddf --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSHealth.java @@ -0,0 +1,316 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.ios; + +import com.codename1.health.Health; +import com.codename1.health.HealthAvailability; +import com.codename1.health.HealthSample; +import com.codename1.health.SamplePage; +import com.codename1.health.HealthStore; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The HealthKit-backed health entry point. +/// +/// Native results arrive on a dedicated serial GCD queue and are matched +/// back to their caller through a request-id registry, the same shape +/// [IOSBluetooth] uses. +public final class IOSHealth extends Health { + + private static final Map LIMITS = + new HashMap(); + private static final Map REQUESTS = + new HashMap(); + private static int nextRequestId = 1; + + private final IOSNative nativeInstance; + private final IOSHealthStore store; + + IOSHealth(IOSNative nativeInstance) { + this.nativeInstance = nativeInstance; + this.store = new IOSHealthStore(nativeInstance); + } + + public boolean isSupported() { + return nativeInstance.hkIsAvailable(); + } + + public HealthAvailability getAvailability() { + // HealthKit is part of the OS: it is either present or the device + // is an iPad too old to have it. There is no separate provider to + // install, unlike Android. + return nativeInstance.hkIsAvailable() + ? HealthAvailability.AVAILABLE + : HealthAvailability.NOT_SUPPORTED; + } + + /// The HealthKit-backed store. + /// + /// This is where the missing-privacy-string diagnostic is thrown, + /// rather than on the way into the facade: reaching the facade is + /// also how an app gets to [#getSensors()], which is pure Bluetooth + /// LE and needs no HealthKit at all. Throwing earlier made that + /// supported path unusable on iOS without declaring disclosures the + /// app does not need. + /// + /// Still thrown rather than reported: a missing usage description is + /// a developer bug that gets the app rejected from the App Store, so + /// it must not be swallowed into an AsyncResource error nobody reads. + /// [#getConfigurationProblems()] answers the same question without + /// throwing, for a diagnostics screen. + public HealthStore getStore() { + requireUsageStrings(); + return store; + } + + /// Recorded workouts, which persist through the store. + public com.codename1.health.workout.WorkoutManager getWorkouts() { + requireUsageStrings(); + return super.getWorkouts(); + } + + private void requireUsageStrings() { + if (!nativeInstance.checkHealthShareUsage() + && !nativeInstance.checkHealthUpdateUsage()) { + throw new com.codename1.health.HealthConfigurationException( + MISSING_USAGE_MESSAGE); + } + } + + public AsyncResource openHealthSettings() { + AsyncResource out = new AsyncResource(); + // iOS has no deep link to an app's Health permissions; the closest + // is the app's own Settings page. Reporting false rather than + // opening something misleading lets the caller say so. + out.complete(Boolean.FALSE); + return out; + } + + public List getConfigurationProblems() { + List problems = new ArrayList(); + if (!nativeInstance.checkHealthShareUsage() + && !nativeInstance.checkHealthUpdateUsage()) { + problems.add(MISSING_USAGE_MESSAGE); + } + return problems; + } + + /// The message used both by [#getConfigurationProblems()] and by the + /// build-hint diagnostic thrown from [#getStore()], so a developer + /// sees the same wording from a diagnostics screen and from the + /// exception. + static final String MISSING_USAGE_MESSAGE = + "This app uses com.codename1.health but declares no HealthKit " + + "privacy strings. Add the ios.NSHealthShareUsageDescription " + + "build hint (to read health data) and/or " + + "ios.NSHealthUpdateUsageDescription (to write it). The text " + + "must describe your app's specific use -- Codename One does " + + "not inject a placeholder, because Apple reviews it against " + + "your app's behaviour and rejects generic copy."; + + // ------------------------------------------------------------------ + // request registry + // ------------------------------------------------------------------ + + static synchronized int takeId(AsyncResource resource) { + int id = nextRequestId++; + REQUESTS.put(Integer.valueOf(id), resource); + // Every cleanup path used to sit inside a native callback, so a + // native call that never called back -- the shared timeout fires + // and completes the resource instead -- left its entry, and its + // LIMITS entry, in the registry for the life of the process. + // Completion is the one event that always happens, whichever side + // produced it. + resource.onResult(new Forget(id)); + return id; + } + + /// Drops a request's registry state once its resource is done, + /// whatever finished it. + /// + /// Built as a named static class so it carries no synthetic reference + /// to anything (SpotBugs `SIC_INNER_SHOULD_BE_STATIC_ANON`). + private static final class Forget + implements com.codename1.util.AsyncResult { + private final int requestId; + + Forget(int requestId) { + this.requestId = requestId; + } + + @Override + public void onReady(Object value, Throwable error) { + forget(requestId); + } + } + + /// Removes a request's registry entries. Safe to call twice: the + /// native callback path takes the resource out first, and this runs + /// afterwards on the completion it caused. + static synchronized void forget(int requestId) { + REQUESTS.remove(Integer.valueOf(requestId)); + LIMITS.remove(Integer.valueOf(requestId)); + } + + /// Registers a read whose page must report truncation. + /// + /// HealthKit has no continuation token, so the only honest thing a + /// capped read can say is "there was more". The requested limit is + /// remembered here and compared against what came back. + static synchronized int takeId(AsyncResource resource, int limit) { + int id = takeId(resource); + LIMITS.put(Integer.valueOf(id), Integer.valueOf(limit)); + return id; + } + + static synchronized AsyncResource take(int requestId) { + return REQUESTS.remove(Integer.valueOf(requestId)); + } + + static synchronized int takeLimit(int requestId) { + Integer v = LIMITS.remove(Integer.valueOf(requestId)); + return v == null ? Integer.MAX_VALUE : v.intValue(); + } + + // ---- Callbacks invoked from native code (do not rename) ---- + + static void nativeHkAuthorizationResult(int requestId, boolean granted, + int errorCode, String message) { + AsyncResource r = take(requestId); + if (r == null) { + return; + } + if (errorCode >= 0) { + // HealthKit failed the request outright rather than the user + // declining. Completing with `false` and no error would let the + // caller carry on as though the sheet had simply been answered. + failOnEdt(r, IOSHealthStore.toException(errorCode, message)); + return; + } + // granted reflects only that the sheet completed. HealthKit will + // not say what the user chose for reads, so neither do we. + completeOnEdt(r, Boolean.valueOf(granted)); + } + + static void nativeHkSamples(int requestId, String tsv) { + AsyncResource r = take(requestId); + int limit = takeLimit(requestId); + if (r == null) { + return; + } + SamplePage page = com.codename1.impl.health.HealthWire + .decodeSamplePage(tsv); + if (limit != Integer.MAX_VALUE && page.size() > limit) { + // One more came back than was asked for, which is how a capped + // read learns there is more. Trim to the limit and say so. + List kept = new ArrayList( + page.getSamples().subList(0, limit)); + page = new SamplePage(kept, null, true); + } + completeOnEdt(r, page); + } + + static void nativeHkSaveResult(int requestId, String uuids) { + AsyncResource r = take(requestId); + if (r == null) { + return; + } + completeOnEdt(r, com.codename1.impl.health.HealthWire + .decodeWriteResult(uuids)); + } + + static void nativeHkRequestError(int requestId, int errorCode, + String message) { + AsyncResource r = take(requestId); + // Dropped here too, not only on the success path: a read that + // failed -- the locked-device case, which is exactly when + // background queries run -- otherwise left its entry in the static + // map for the life of the process. + takeLimit(requestId); + if (r == null) { + return; + } + failOnEdt(r, IOSHealthStore.toException(errorCode, message)); + } + + /// Completes on the EDT. + /// + /// These callbacks arrive on CN1Health.m's own GCD health queue, and + /// AsyncResource runs its callbacks on whatever thread completes it. + /// Completing directly would hand every application handler a + /// background thread, breaking the guarantee HealthStore makes that + /// results arrive on the EDT -- and any handler touching a Component + /// would race the renderer rather than fail loudly. + private static void completeOnEdt(AsyncResource r, Object value) { + Display.getInstance().callSerially(new Complete(r, value)); + } + + private static void failOnEdt(AsyncResource r, Throwable error) { + Display.getInstance().callSerially(new Fail(r, error)); + } + + /// Named rather than anonymous so SpotBugs does not see an inner class + /// holding the enclosing reference, and so the translator keeps it. + private static final class Complete implements Runnable { + private final AsyncResource resource; + private final Object value; + + Complete(AsyncResource resource, Object value) { + this.resource = resource; + this.value = value; + } + + public void run() { + resource.complete(value); + } + } + + private static final class Fail implements Runnable { + private final AsyncResource resource; + private final Throwable error; + + Fail(AsyncResource resource, Throwable error) { + this.resource = resource; + this.error = error; + } + + public void run() { + resource.error(error); + } + } + + static { + // ---- do not remove: defeats ParparVM dead-code elimination ---- + // These are only ever called from C, so nothing in the Java graph + // references them and the translator would otherwise strip them. + nativeHkAuthorizationResult(-1, false, -1, null); + nativeHkSamples(-1, null); + nativeHkSaveResult(-1, null); + nativeHkRequestError(-1, 0, null); + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSHealthStore.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSHealthStore.java new file mode 100644 index 00000000000..ae5f7dd9155 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSHealthStore.java @@ -0,0 +1,736 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.ios; + +import com.codename1.health.AggregateMetric; +import com.codename1.health.HealthAccess; +import com.codename1.health.HealthAuthorizationStatus; +import com.codename1.health.HealthDataType; +import com.codename1.health.HealthError; +import com.codename1.health.HealthAnchor; +import com.codename1.health.HealthChangeBatch; +import com.codename1.health.HealthDataType; +import com.codename1.health.HealthException; +import com.codename1.health.HealthSample; +import com.codename1.health.HealthStore; +import com.codename1.health.HealthSubscription; +import com.codename1.health.HealthTimeRange; +import com.codename1.health.HealthWriteResult; +import com.codename1.health.SampleQuery; +import com.codename1.health.SamplePage; +import com.codename1.impl.health.HealthWire; +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.List; + +/// The HealthKit-backed store. +/// +/// Samples cross the native boundary as tab-separated lines rather than +/// JSON -- see [HealthWire] -- because a year of heart rate is hundreds of +/// thousands of records and a JSON object graph of that size will exhaust +/// the ParparVM heap. +class IOSHealthStore extends HealthStore { + + /// How many records sharing a single millisecond the drain is willing + /// to pull in one read -- see [#readTiedInstant]. Bounded rather than + /// unlimited because a background delivery has a few seconds of wall + /// clock and a ParparVM heap to stay inside; generous because the + /// records beyond it cannot be paged to and are lost. + private static final int TIED_INSTANT_LIMIT = 50000; + + private final IOSNative nativeInstance; + + IOSHealthStore(IOSNative nativeInstance) { + this.nativeInstance = nativeInstance; + } + + public boolean isSupported() { + return nativeInstance.hkIsAvailable(); + } + + /// Asks the native type map rather than inferring from the portable + /// type. Several quantity types carry a canonical unit but have no + /// HealthKit identifier, and advertising those made them pass shared + /// validation only for the query to fail as unsupported. + public boolean isTypeSupported(HealthDataType type) { + return type != null && isSupported() + && nativeInstance.hkIsTypeSupported(type.getId()); + } + + public List getSupportedTypes() { + List out = new ArrayList(); + for (HealthDataType t : HealthDataType.values()) { + if (isTypeSupported(t)) { + out.add(t); + } + } + return out; + } + + public boolean isWritable(HealthDataType type) { + return isTypeSupported(type); + } + + /// False: this build has no HealthKit delete native, so the store SPI + /// falls through to NOT_SUPPORTED. Saying otherwise made a + /// capability-gated delete fail for every valid input, which is worse + /// than the caller knowing up front. + public boolean isDeletable(HealthDataType type) { + return false; + } + + public List getSupportedMetrics(HealthDataType type) { + // Aggregation is done in shared code from raw samples so that the + // bucket arithmetic -- daylight-saving boundaries, the + // duration-weighted average -- has exactly one implementation + // rather than one per platform that can drift apart. + return new ArrayList(); + } + + /// HealthKit can wake the app through `HKObserverQuery`, but this + /// build does not register one yet, so nothing arrives while the app + /// is closed. Reporting false is the honest answer: an app that + /// branches on this needs to know it must drain for itself, and + /// claiming push here would make it skip exactly that. + public boolean isPushDelivery() { + return false; + } + + /// Changes are delivered by draining, not by the OS relaunching the + /// app, so background delivery is not available on iOS in this build. + public boolean isBackgroundDeliverySupported() { + return false; + } + + /// Truthful: HealthKit reports share (write) authorization. + public HealthAuthorizationStatus getWriteAuthorizationStatus( + HealthDataType type) { + if (!isTypeSupported(type)) { + return HealthAuthorizationStatus.NOT_SUPPORTED; + } + switch (nativeInstance.hkShareAuthorizationStatus(type.getId())) { + case 2: + return HealthAuthorizationStatus.AUTHORIZED; + case 1: + return HealthAuthorizationStatus.DENIED; + default: + return HealthAuthorizationStatus.NOT_DETERMINED; + } + } + + /// Always UNKNOWN, and deliberately so. + /// + /// HealthKit does not expose read authorization at all, because an app + /// able to distinguish "denied" from "no data" could infer that a user + /// is hiding a pregnancy or a prescription. Returning anything else + /// here would be inventing an answer. + public HealthAuthorizationStatus getReadAuthorizationStatus( + HealthDataType type) { + return isSupported() ? HealthAuthorizationStatus.UNKNOWN + : HealthAuthorizationStatus.NOT_SUPPORTED; + } + + static HealthException toException(int code, String message) { + HealthError error; + switch (code) { + case 1: + error = HealthError.NOT_SUPPORTED; + break; + case 2: + error = HealthError.UNAUTHORIZED; + break; + case 4: + error = HealthError.INVALID_ARGUMENT; + break; + case 6: + // The store is encrypted at rest and unreadable before + // first unlock -- exactly when a background observer + // fires. Retryable, and must never be collapsed into + // "no data". + error = HealthError.DATABASE_INACCESSIBLE; + break; + case 7: + error = HealthError.USER_CANCELED; + break; + case 8: + error = HealthError.NOT_SUPPORTED; + break; + case 9: + error = HealthError.ANCHOR_EXPIRED; + break; + case 10: + error = HealthError.RATE_LIMITED; + break; + default: + error = HealthError.UNKNOWN; + break; + } + return new HealthException(error, + message == null ? "HealthKit call failed" : message); + } + + protected void doRequestAuthorization(List access, + AsyncResource out) { + List read = new ArrayList(); + List share = new ArrayList(); + for (int i = 0; i < access.size(); i++) { + HealthAccess a = access.get(i); + if (a.isWrite()) { + share.add(a.getType().getId()); + } else { + read.add(a.getType().getId()); + } + } + int id = IOSHealth.takeId(out); + nativeInstance.hkRequestAuthorization(id, + read.toArray(new String[read.size()]), + share.toArray(new String[share.size()])); + } + + protected void doReadSamples(SampleQuery query, + AsyncResource out) { + List types = query.getTypes(); + if (types.size() != 1) { + // HKSampleQuery is per sample type. The shared layer could be + // taught to fan out, but until it is, saying so beats silently + // reading only the first type. + out.error(new HealthException(HealthError.INVALID_ARGUMENT, + "HealthKit reads one data type per query; issue a" + + " separate query per type")); + return; + } + HealthTimeRange range = + query.getTimeRange().resolve(System.currentTimeMillis()); + // The limit is asked for one higher than the caller wanted, so a + // full page can be told apart from a page that merely ended. There + // is no HealthKit continuation token here, so the honest signal is + // SamplePage.isTruncated() -- claiming a partial history was + // complete is what silently lost everything past the first page. + // Sources go to HealthKit, not to the shared post-filter. The + // limit is applied by the query, so a page whose first records all + // belong to another app was filtered down to nothing afterwards -- + // and with no continuation token there was no way to reach the + // matching records behind them. + StringBuilder sources = new StringBuilder(); + for (String bundleId : query.getSources()) { + if (sources.length() > 0) { + sources.append('\t'); + } + sources.append(bundleId); + } + int id = IOSHealth.takeId(out, query.getLimit()); + nativeInstance.hkQuerySamples(id, types.get(0).getId(), + range.getStartMillis(), range.getEndMillis(), + query.getLimit() == Integer.MAX_VALUE ? query.getLimit() + : query.getLimit() + 1, + !query.isSortDescending(), sources.toString()); + } + + protected void doWrite(List samples, + AsyncResource out) { + HealthSample rejected = HealthWire.unsupportedForWrite(samples); + if (rejected != null) { + // The payload cannot carry this shape, and the native side + // reports an empty batch as a successful write of nothing. + out.error(new HealthException(HealthError.TYPE_NOT_SUPPORTED, + rejected.getType().getId() + " cannot be written to" + + " HealthKit through this API")); + return; + } + int id = IOSHealth.takeId(out); + nativeInstance.hkSaveSamples(id, HealthWire.encodeSamples(samples)); + } + + // ================================================================== + // change draining + // ================================================================== + + /// Drains by re-reading each subscription's types over the window that + /// has elapsed since its last drain, with the anchor holding that + /// timestamp. + /// + /// This is not `HKAnchoredObjectQuery`, and the difference is more + /// than efficiency. The cursor is a *measurement* timestamp, so it + /// finds samples by when they were recorded rather than by when they + /// reached the store: a watch that syncs an hour late, or a reading + /// the user backdates, lands entirely behind the cursor and no later + /// drain will ever return it. A real anchor is ordered by insertion, + /// which is what makes it immune to that -- and it would report + /// deletions and stop re-reading samples already seen at the same + /// time. Until it lands, an app that must be complete rather than + /// merely current has to re-read its range periodically; the + /// developer guide says so where subscriptions are introduced. + /// + /// This is + /// what makes subscriptions work at all on iOS today, and the window + /// is closed only after the batch has been handled, so a crash re-reads + /// rather than skips. Deletions are not reported -- + /// [#isPushDelivery()] and [#isBackgroundDeliverySupported()] both + /// answer false so an app can tell how much this is worth. + protected void doDrainChanges(List subscriptions, + AsyncResource out) { + synchronized (drainLock) { + drainFailure = null; + } + drainFrom(new ArrayList(subscriptions), 0, 0, + out); + } + + /// Whether the subscription being drained hit a window it could not + /// page through, so the batch has to ask the app to resynchronise. + /// + /// Reset per subscription in [#drainFrom], and safe as a field for + /// the same reason [#drainFailure] is: the shared layer runs one + /// drain at a time and this drain reads its subscriptions in turn. + /// + /// Guarded by [#drainLock] because a read's post-processing runs on + /// the shared health worker, so the thread that sets this is not + /// always the one that reads it. One drain at a time makes the field + /// safe to share; it does not publish the write. + private final Object drainLock = new Object(); + /// Guarded by [#drainLock]. + private boolean resyncRequired; + + void noteResyncRequired() { + synchronized (drainLock) { + resyncRequired = true; + } + } + + /// The first failure this drain hit, or null. + /// + /// One field rather than state threaded through the recursion because + /// the shared layer runs one drain at a time -- a second call while + /// one is in flight is coalesced onto it rather than started. + /// + /// Guarded by [#drainLock], for the same reason as [#resyncRequired]. + private Throwable drainFailure; + + /// Remembers a failure so the drain can report it once it has given + /// the healthy subscriptions their turn. The first one wins: it is + /// the one with a cause the caller can still act on. + void noteDrainFailure(Throwable error) { + synchronized (drainLock) { + if (drainFailure == null) { + drainFailure = error; + } + } + } + + void drainFrom(final List subs, + final int index, final int delivered, + final AsyncResource out) { + if (index >= subs.size()) { + Throwable failed; + synchronized (drainLock) { + failed = drainFailure; + drainFailure = null; + } + if (failed != null) { + // Surfaced, not swallowed. A subscription skipped for + // HKErrorDatabaseInaccessible -- the device locked, before + // its first unlock, which is exactly when a background + // fetch runs -- otherwise resolved as a clean drain with + // nothing to report. The caller could not tell that from + // genuinely no changes, so the one error the API documents + // as retryable was the one error nobody ever retried. + out.error(failed); + return; + } + out.complete(Integer.valueOf(delivered)); + return; + } + final HealthSubscription sub = subs.get(index); + final long now = System.currentTimeMillis(); + long since = anchorMillis(sub, now); + List types = sub.getTypes(); + if (types.isEmpty()) { + drainFrom(subs, index + 1, delivered, out); + return; + } + if (since >= now) { + // A subscription with no cursor yet. Fire an empty batch so the + // anchor gets persisted and the next drain has a window to read + // over -- without this the branch repeats forever and the + // subscription never delivers anything at all. + int sent = fireChanges(new HealthChangeBatch(sub.getId(), sub.getTypes(), + null, null, false, + // Foreground: no OS deadline. Reporting 0 made a listener + // that budgets against getDeadlineMillis() treat every + // ordinary drain as already out of time. + HealthAnchor.of(String.valueOf(now)), Long.MAX_VALUE, false));; + // Counted per delivery a listener actually received: a + // subscription restored with no live listener and no + // persisted class queues nothing, and a page split by the + // per-batch cap arrives as several deliveries, which is what + // drainChanges reports. + drainFrom(subs, index + 1, delivered + sent, out); + return; + } + synchronized (drainLock) { + resyncRequired = false; + } + readTypes(subs, index, delivered, out, types, 0, + new ArrayList(), since, now, now); + } + + /// HealthKit queries one type at a time, so a subscription over three + /// types is three reads accumulated into one batch. + /// + /// `safeUntil` is how far the cursor may move. It is `now` until a + /// type comes back truncated, at which point it drops to the last + /// sample that type actually produced -- see [#advanceTo(long, long, + /// SamplePage)]. + private void readTypes(final List subs, + final int index, final int delivered, + final AsyncResource out, + final List types, final int typeIndex, + final List collected, final long since, + final long now, final long safeUntil) { + if (typeIndex >= types.size()) { + HealthSubscription sub = subs.get(index); + // The cursor moves to the end of what was read, not to the end + // of the window that was asked for. HealthKit hands back no + // continuation token, so a window holding more samples than one + // query returns is only ever read once -- anchoring at `now` + // regardless meant everything past the limit was skipped for + // good, silently, on exactly the busiest subscriptions. + // + // And the batch is trimmed to match, so that it holds exactly + // what falls before the cursor it carries. The types are read + // one at a time over the same window: a busy one truncating + // pulls the cursor back, and the sparse ones read after it -- + // or already read before it -- kept their samples from the + // rest of the window. Those sit beyond the persisted anchor, + // so the next drain reads the same range and delivers them a + // second time. Trimming defers them by one drain instead. + dropAtOrAfter(collected, safeUntil, now); + int sent = fireChanges(new HealthChangeBatch(sub.getId(), sub.getTypes(), + collected, null, false, + // Foreground: no OS deadline. Reporting 0 made a listener + // that budgets against getDeadlineMillis() treat every + // ordinary drain as already out of time. + HealthAnchor.of(String.valueOf(safeUntil)), Long.MAX_VALUE, + safeUntil < now));; + boolean resync; + synchronized (drainLock) { + resync = resyncRequired; + } + if (resync) { + // A second, empty batch rather than a flag on the first. + // getAdded() is documented as empty when a resynchronisation + // is required, and the samples above were genuinely read + // and are genuinely the app's -- so they are delivered as + // themselves, and the instruction to go and re-read the + // range follows them. Delivered in that order because the + // shared layer drops the cursor on a resync batch, and + // dropping it first would discard the anchor the samples + // just earned. + sent += fireChanges(new HealthChangeBatch(sub.getId(), + sub.getTypes(), null, null, true, null, + Long.MAX_VALUE, false)); + } + // Counted per delivery a listener actually received: a + // subscription restored with no live listener and no + // persisted class queues nothing, and a page split by the + // per-batch cap arrives as several deliveries, which is what + // drainChanges reports. + drainFrom(subs, index + 1, delivered + sent, out); + return; + } + SampleQuery q = new SampleQuery() + .addType(types.get(typeIndex)) + .setTimeRange(HealthTimeRange.between(since, now)); + // The page, not the accumulating read: only the page carries the + // truncation flag, and that flag is the whole point here. + readSamplePage(q).onResult(new ChangeRead(this, subs, index, + delivered, out, types, typeIndex, collected, since, now, + safeUntil)); + } + + /// Drops everything at or after the cursor this batch will persist, + /// so a batch holds exactly the samples that fall before its own + /// anchor. + /// + /// A sample starting exactly at the cursor goes too. The next window + /// begins there and is inclusive of it, so keeping it would guarantee + /// the duplicate rather than risk one -- and nothing is lost, because + /// that same next window returns it. + /// + /// Only when the cursor was pulled back. An untruncated drain anchors + /// at `now` and everything it read already starts before that. + /// + /// The one duplicate this cannot remove is an interval that starts + /// before the cursor and ends after it: the next window matches it on + /// its end, and dropping it here would starve a long-running record + /// that overlaps every cursor it ever meets. That is the timestamp + /// cursor's own limitation, not this trim's. + private static void dropAtOrAfter(List collected, + long safeUntil, long now) { + if (safeUntil >= now) { + return; + } + for (int i = collected.size() - 1; i >= 0; i--) { + if (collected.get(i).getStartMillis() >= safeUntil) { + collected.remove(i); + } + } + } + + /// Re-reads the single instant a page turned out to be entirely made + /// of, so the cursor can step over it without losing what did not fit. + /// + /// The window is read oldest-first, so a truncated page whose *last* + /// record starts at the cursor is a page in which every record starts + /// there. That is the one shape a timestamp cursor cannot page + /// through: the resume point is the last record read, which is where + /// the cursor already was, so the next drain gets the identical prefix + /// and the one after that too. + /// + /// The floor of `since + 1` used to break that loop by stepping over + /// the whole instant -- discarding every record in it that had not + /// been read, silently and for good. Draining the instant first makes + /// the same step safe, because nothing is left behind to skip. + /// + /// A large instant is still deliverable: the base class splits a batch + /// over the subscription's cap into cap-sized deliveries and anchors + /// only the last, so this arrives as several handled deliveries rather + /// than one unbounded payload. + void readTiedInstant(final List subs, + final int index, final int delivered, + final AsyncResource out, + final List types, final int typeIndex, + final List collected, final long since, + final long now, final long safeUntil) { + SampleQuery q = new SampleQuery() + .addType(types.get(typeIndex)) + .setTimeRange(HealthTimeRange.between(since, since + 1)) + .setLimit(TIED_INSTANT_LIMIT); + readSamplePage(q).onResult(new TiedInstantRead(this, subs, index, + delivered, out, types, typeIndex, collected, since, now, + safeUntil)); + } + + /// `true` when a truncated page holds nothing but records starting at + /// the cursor, which is what makes the page unpageable. + private static boolean isWhollyTied(SamplePage page, long since) { + if (page == null || !page.isTruncated() || page.isEmpty()) { + return false; + } + List samples = page.getSamples(); + return samples.get(samples.size() - 1).getStartMillis() <= since; + } + + /// How far the cursor may move given one type's page. + /// + /// A complete page leaves it where it was. A truncated one pulls it + /// back to the last sample read, so the next drain resumes there + /// rather than past the samples that did not fit. The window is read + /// oldest-first, so the last sample is the newest one delivered. + /// + /// The resume point is that sample's **start**, not its end, because + /// start is what HealthKit sorted the page by. A steps interval + /// running 10:00-12:00 can be the last one returned while a record + /// starting at 10:30 sorts after it and did not fit; resuming at 12:00 + /// would step straight over that record and lose it for good. + /// Resuming at the start re-reads the samples already delivered in + /// that final instant, which is the same duplicate-rather-than-skip + /// trade the drain makes everywhere else. + /// + /// Never earlier than `since + 1`: a window whose very first sample + /// overflows the limit would otherwise re-read the same page forever. + /// That floor is a backstop only -- the case it guards against is + /// handled before this is reached, by [#readTiedInstant], which drains + /// the instant so that stepping over it loses nothing. + private static long advanceTo(long safeUntil, long since, + SamplePage page) { + if (page == null || !page.isTruncated() || page.isEmpty()) { + return safeUntil; + } + List samples = page.getSamples(); + long last = samples.get(samples.size() - 1).getStartMillis(); + if (last <= since) { + last = since + 1; + } + return Math.min(safeUntil, last); + } + + private static long anchorMillis(HealthSubscription sub, long now) { + HealthAnchor anchor = sub.getAnchor(); + if (anchor == null) { + return now; + } + try { + return Long.parseLong(anchor.toStorableString().trim()); + } catch (NumberFormatException ex) { + // A cursor written by an earlier build in another shape. Start + // the window here rather than re-reading the whole history. + return now; + } + } + + /// Named rather than anonymous so the callback carries no implicit + /// reference to the enclosing store. + private static final class ChangeRead + implements com.codename1.util.AsyncResult { + + private final IOSHealthStore store; + private final List subs; + private final int index; + private final int delivered; + private final AsyncResource out; + private final List types; + private final int typeIndex; + private final List collected; + private final long since; + private final long now; + private final long safeUntil; + + ChangeRead(IOSHealthStore store, List subs, + int index, int delivered, AsyncResource out, + List types, int typeIndex, + List collected, long since, long now, + long safeUntil) { + this.store = store; + this.subs = subs; + this.index = index; + this.delivered = delivered; + this.out = out; + this.types = types; + this.typeIndex = typeIndex; + this.collected = collected; + this.since = since; + this.now = now; + this.safeUntil = safeUntil; + } + + public void onReady(SamplePage page, Throwable error) { + if (error != null) { + // Abandon this subscription's drain without firing a + // batch. Continuing would deliver a partial result + // anchored at `now`, and persisting that anchor would skip + // everything the failed type produced in this window -- + // permanently. HealthKit reporting + // HKErrorDatabaseInaccessible because the device is locked + // is exactly when this happens, and it is retryable -- so + // the failure is kept and reported once the remaining + // subscriptions have had their turn. + store.noteDrainFailure(error); + store.drainFrom(subs, index + 1, delivered, out); + return; + } + if (isWhollyTied(page, since)) { + // Every record in a truncated page shares the cursor + // instant, so paging cannot get past it -- see + // readTiedInstant. The page is discarded rather than + // collected because the re-read returns a superset of it. + store.readTiedInstant(subs, index, delivered, out, types, + typeIndex, collected, since, now, safeUntil); + return; + } + if (page != null) { + collected.addAll(page.getSamples()); + } + store.readTypes(subs, index, delivered, out, types, + typeIndex + 1, collected, since, now, + advanceTo(safeUntil, since, page)); + } + } + + /// Collects one instant's records after a page turned out to hold + /// nothing else, then lets the cursor step over that instant. + private static final class TiedInstantRead + implements com.codename1.util.AsyncResult { + + private final IOSHealthStore store; + private final List subs; + private final int index; + private final int delivered; + private final AsyncResource out; + private final List types; + private final int typeIndex; + private final List collected; + private final long since; + private final long now; + private final long safeUntil; + + TiedInstantRead(IOSHealthStore store, List subs, + int index, int delivered, AsyncResource out, + List types, int typeIndex, + List collected, long since, long now, + long safeUntil) { + this.store = store; + this.subs = subs; + this.index = index; + this.delivered = delivered; + this.out = out; + this.types = types; + this.typeIndex = typeIndex; + this.collected = collected; + this.since = since; + this.now = now; + this.safeUntil = safeUntil; + } + + public void onReady(SamplePage page, Throwable error) { + if (error != null) { + // Same reasoning as the ordinary page read: firing a + // partial batch would persist an anchor past records this + // read never returned, and the failure is reported once + // the other subscriptions have had their turn. + store.noteDrainFailure(error); + store.drainFrom(subs, index + 1, delivered, out); + return; + } + long cursor = since + 1; + if (page != null) { + collected.addAll(page.getSamples()); + if (page.isTruncated()) { + // Beyond TIED_INSTANT_LIMIT records at one millisecond + // the cursor has to step over the remainder anyway -- + // the alternative is a drain that never terminates. + // + // Told to the app, not only to the log. Stepping over + // it puts those records permanently outside every + // later window, and a log line is not something an + // app can act on: the batch is marked as needing a + // resynchronisation, which is the signal this API + // already defines for "your cursor cannot be trusted, + // read the range yourself". The log stays for whoever + // is looking at why. + store.noteResyncRequired(); + com.codename1.io.Log.p("CN1 Health: more than " + + TIED_INSTANT_LIMIT + " records share the" + + " timestamp " + since + " for " + + types.get(typeIndex).getId() + "; the" + + " remainder cannot be paged, so the batch" + + " asks the app to resynchronise"); + } + } + store.readTypes(subs, index, delivered, out, types, + typeIndex + 1, collected, since, now, + Math.min(safeUntil, cursor)); + } + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 3477a8874ac..563cc89a37a 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -4612,6 +4612,32 @@ public com.codename1.bluetooth.Bluetooth getBluetooth() { } } + private static IOSHealth health; + + /// Returns the health entry point. + /// + /// The missing-privacy-string diagnostic is *not* thrown here, and + /// that is deliberate. `Health.getInstance()` is also how an app + /// reaches `getSensors()`, which is pure Bluetooth LE and touches no + /// HealthKit at all -- the iOS builder knows this and injects neither + /// the framework nor the usage strings for a sensor-only app. Throwing + /// on the way in made that supported path impossible to use without + /// declaring HealthKit disclosures the app has no business declaring, + /// and which App Review would ask it to justify. + /// + /// It is thrown from [IOSHealth#getStore()] instead: that is the + /// first thing that actually needs HealthKit, and it is still before + /// anything can be swallowed into an AsyncResource error nobody reads. + @Override + public com.codename1.health.Health getHealth() { + synchronized (IOSImplementation.class) { + if (health == null) { + health = new IOSHealth(nativeInstance); + } + return health; + } + } + public LocationManager getLocationManager() { if (!nativeInstance.checkLocationUsage()) { throw new RuntimeException("Please add the ios.NSLocationUsageDescription or ios.NSLocationAlwaysUsageDescription build hint"); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 4e8afbc50ac..41490e43d6a 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -1316,6 +1316,33 @@ native void btRespondToReadRequest(long requestHandle, byte[] value, native int btL2capRead(long channelHandle, byte[] buffer, int offset, int len); + // --- Health (HealthKit) --- + // Implemented in nativeSources/CN1Health.m, gated on CN1_INCLUDE_HEALTH. + // The #else branch there provides no-op trampolines so a health-free + // app -- and the tvOS / Mac Catalyst slices, where HealthKit does not + // exist -- still link. + native boolean hkIsAvailable(); + /// Whether the native layer can map this portable type onto a + /// HealthKit type at all. Asked rather than assumed: a type with a + /// canonical unit is not necessarily one HealthKit knows, and + /// advertising it means a query that passes validation and then fails. + native boolean hkIsTypeSupported(String typeIdentifier); + /// Meaningful for WRITE types only. HealthKit deliberately never + /// discloses read authorization, so there is no read equivalent -- + /// adding one would require inventing an answer. + native int hkShareAuthorizationStatus(String typeIdentifier); + native void hkRequestAuthorization(int requestId, String[] readTypes, + String[] shareTypes); + /// `sourceBundleIds` is tab-separated and may be empty for "any + /// source". It has to reach HealthKit rather than being filtered after + /// the fact: the limit is applied by the query, so filtering the + /// results afterwards can return nothing at all while matching data + /// sits just past the cut. + native void hkQuerySamples(int requestId, String typeIdentifier, + double startEpochMs, double endEpochMs, int limit, + boolean ascending, String sourceBundleIds); + native void hkSaveSamples(int requestId, String samplesTsv); + /** Blocking write to an open L2CAP channel. Returns the byte count * written (possibly short), or -2 on error. */ native int btL2capWrite(long channelHandle, byte[] buffer, int offset, @@ -1399,6 +1426,13 @@ native int btL2capWrite(long channelHandle, byte[] buffer, int offset, native boolean checkCameraUsage(); native boolean checkFaceIDUsage(); native boolean checkLocationUsage(); + /// Whether ios.NSHealthShareUsageDescription was declared. Lives here + /// rather than in CN1Health.m because it must answer even when health + /// is compiled out, so IOSImplementation can throw a build-hint + /// diagnostic instead of failing later in App Review. + native boolean checkHealthShareUsage(); + /// Whether ios.NSHealthUpdateUsageDescription was declared. + native boolean checkHealthUpdateUsage(); native boolean checkMicrophoneUsage(); native boolean checkMotionUsage(); native boolean checkPhotoLibraryAddUsage(); diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava001Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava001Snippet.java new file mode 100644 index 00000000000..1ca003a49ca --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava001Snippet.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; +import com.codename1.health.*; +import com.codename1.health.nutrition.*; +import com.codename1.health.sensors.*; +import com.codename1.health.workout.*; +import com.codename1.util.AsyncResource; + +class HealthJava001Snippet { + + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::health-java-001[] + Health health = Health.getInstance(); + if (health.getAvailability() != HealthAvailability.AVAILABLE) { + // On Android the provider app may be missing or out of date, which + // the user can fix; elsewhere this simply means no health support. + health.openProviderSetup(); + return; + } + HealthStore store = health.getStore(); + store.requestAuthorization( + HealthAccess.read(HealthDataType.STEPS), + HealthAccess.read(HealthDataType.HEART_RATE)) + .onResult((asked, err) -> { + if (err != null) { + Log.e(err); + return; + } + // asked == true means the user has now been ASKED, not that + // anything was granted. On iOS the sheet completes identically + // whether they enabled every switch or none. + }); + // end::health-java-001[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava002Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava002Snippet.java new file mode 100644 index 00000000000..a67d53371e5 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava002Snippet.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; +import com.codename1.health.*; +import com.codename1.health.nutrition.*; +import com.codename1.health.sensors.*; +import com.codename1.health.workout.*; +import com.codename1.util.AsyncResource; + +class HealthJava002Snippet { + + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::health-java-002[] + HealthStore store = Health.getInstance().getStore(); + + // Write authorization is answerable on both platforms. + HealthAuthorizationStatus write = + store.getWriteAuthorizationStatus(HealthDataType.BODY_MASS); + + // Read authorization is NOT. On iOS this is always UNKNOWN, by design. + HealthAuthorizationStatus read = + store.getReadAuthorizationStatus(HealthDataType.STEPS); + if (read == HealthAuthorizationStatus.UNKNOWN) { + // The only honest probe: ask whether any data actually came back. + store.hasAnyData(HealthDataType.STEPS, HealthTimeRange.lastDays(7)) + .onResult((hasData, err) -> { + if (err == null && !hasData.booleanValue()) { + // Denied OR genuinely empty -- indistinguishable. + // Say "no data available", never "you denied access". + Health.getInstance().openHealthSettings(); + } + }); + } + // end::health-java-002[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava003Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava003Snippet.java new file mode 100644 index 00000000000..3cd138e284b --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava003Snippet.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; +import com.codename1.health.*; +import com.codename1.health.nutrition.*; +import com.codename1.health.sensors.*; +import com.codename1.health.workout.*; +import com.codename1.util.AsyncResource; + +class HealthJava003Snippet { + + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::health-java-003[] + SampleQuery query = new SampleQuery() + .addType(HealthDataType.HEART_RATE) + .setTimeRange(HealthTimeRange.lastHours(24)) + .setSortDescending(true) + .setLimit(500); + + Health.getInstance().getStore().readSamples(query) + .onResult((samples, err) -> { + if (err != null) { + Log.e(err); + return; + } + for (HealthSample s : samples) { + QuantitySample hr = (QuantitySample) s; + // Reading a value forces you to name the unit, which is + // what removes the whole wrong-unit class of bug. + double bpm = hr.getValue(HealthUnit.COUNT_PER_MINUTE); + Log.p(hr.getStartMillis() + ": " + bpm + " bpm"); + } + }); + // end::health-java-003[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava004Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava004Snippet.java new file mode 100644 index 00000000000..a26f56ef04b --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava004Snippet.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; +import com.codename1.health.*; +import com.codename1.health.nutrition.*; +import com.codename1.health.sensors.*; +import com.codename1.health.workout.*; +import com.codename1.util.AsyncResource; + +class HealthJava004Snippet { + + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::health-java-004[] + AggregateQuery query = new AggregateQuery() + .addType(HealthDataType.STEPS) + .addMetric(AggregateMetric.TOTAL) + .setTimeRange(HealthTimeRange.calendarDays(7, TimeZone.getDefault())) + // Calendar days, not a fixed 24 hours: a day is 23 or 25 hours + // across a daylight-saving transition. + .setBucket(HealthInterval.calendarDays(1, TimeZone.getDefault())); + + Health.getInstance().getStore().aggregate(query) + .onResult((buckets, err) -> { + if (err != null) { + Log.e(err); + return; + } + for (AggregateResult bucket : buckets) { + HealthQuantity total = bucket.get(HealthDataType.STEPS, + AggregateMetric.TOTAL); + if (total == null) { + // No data for that day. NOT the same as zero steps -- + // render a gap rather than a bar at zero. + continue; + } + Log.p(bucket.getBucketStartMillis() + ": " + + (long) total.getValue(HealthUnit.COUNT)); + } + }); + // end::health-java-004[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava005Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava005Snippet.java new file mode 100644 index 00000000000..2550d493101 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava005Snippet.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; +import com.codename1.health.*; +import com.codename1.health.nutrition.*; +import com.codename1.health.sensors.*; +import com.codename1.health.workout.*; +import com.codename1.util.AsyncResource; + +class HealthJava005Snippet { + + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::health-java-005[] + QuantitySample weight = QuantitySample.create( + HealthDataType.BODY_MASS, + new HealthQuantity(178.4, HealthUnit.POUND), + System.currentTimeMillis()); + weight.setRecordingMethod(RecordingMethod.MANUAL_ENTRY); + + Health.getInstance().getStore().write(weight) + .onResult((result, err) -> { + if (err instanceof HealthException + && ((HealthException) err).getError() + == HealthError.UNAUTHORIZED) { + Health.getInstance().openHealthSettings(); + return; + } + if (result != null && result.hasRejections()) { + // A batch can partially succeed; surface what was dropped + // rather than discarding it silently. + for (String reason : result.getRejections()) { + Log.p("rejected: " + reason); + } + } + }); + // end::health-java-005[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava006Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava006Snippet.java new file mode 100644 index 00000000000..0263796659d --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava006Snippet.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; +import com.codename1.health.*; +import com.codename1.health.nutrition.*; +import com.codename1.health.sensors.*; +import com.codename1.health.workout.*; +import com.codename1.util.AsyncResource; + +class HealthJava006Snippet { + + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + static class StepWatcher implements HealthBackgroundListener { + public void healthDataChanged(HealthChangeBatch batch) { } + } + void snippet() throws Exception { + // tag::health-java-006[] + SubscriptionRequest request = new SubscriptionRequest("steps-v1") + .addType(HealthDataType.STEPS) + .setIncludeDeletions(true); + + HealthSubscription sub = Health.getInstance().getStore() + .subscribe(request, StepWatcher.class); + + if (!sub.isPushDelivery()) { + // Android: Health Connect never wakes the app. Drain from your + // background-fetch handler instead of assuming push. + Display.getInstance().setPreferredBackgroundFetchInterval(900); + } + // end::health-java-006[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava007Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava007Snippet.java new file mode 100644 index 00000000000..93ee61cf03f --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava007Snippet.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; +import com.codename1.health.*; +import com.codename1.health.nutrition.*; +import com.codename1.health.sensors.*; +import com.codename1.health.workout.*; +import com.codename1.util.AsyncResource; + +class HealthJava007Snippet { + + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::health-java-007[] + WorkoutManager workouts = Health.getInstance().getWorkouts(); + workouts.startSession(new WorkoutConfiguration() + .setActivityType(WorkoutActivityType.RUNNING) + .setLocationType(WorkoutLocationType.OUTDOOR)) + .onResult((session, err) -> { + if (err != null) { + Log.e(err); + return; + } + if (!session.isLive()) { + // Android phones and iOS before 26: the clock and the saved + // record are real, but nothing is collected unless you feed + // it -- from a sensor, from location, or by hand. + Log.p("Recording; connect a strap for heart rate"); + } + session.start(); + }); + // end::health-java-007[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava008Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava008Snippet.java new file mode 100644 index 00000000000..8a041251172 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava008Snippet.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; +import com.codename1.health.*; +import com.codename1.health.nutrition.*; +import com.codename1.health.sensors.*; +import com.codename1.health.workout.*; +import com.codename1.util.AsyncResource; + +class HealthJava008Snippet { + + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::health-java-008[] + HealthSensors sensors = Health.getInstance().getSensors(); + if (!sensors.isSupported()) { + return; + } + SensorScanSettings settings = new SensorScanSettings() + .addProfile(HealthSensorProfile.HEART_RATE) + .setTimeoutMillis(15000); + + sensors.startScan(settings, new SensorDiscoveryListener() { + public void sensorDiscovered(HealthSensor sensor) { + sensors.connect(sensor, HealthSensorProfile.HEART_RATE, + new SensorSessionOptions().setAutoReconnect(true)) + .onResult((session, err) -> { + if (err != null) { + return; + } + session.addListener(new SensorSampleListener() { + public void sensorSample(SensorSession s, + HealthSample sample) { + Log.p("bpm: " + ((QuantitySample) sample) + .getValue(HealthUnit.COUNT_PER_MINUTE)); + } + public void sensorStateChanged(SensorSession s, + SensorSessionState state) { } + public void sensorError(SensorSession s, + HealthException e) { } + }); + }); + } + public void scanFailed(HealthException e) { + Log.e(e); + } + }); + // end::health-java-008[] + } +} diff --git a/docs/developer-guide/Health.asciidoc b/docs/developer-guide/Health.asciidoc new file mode 100644 index 00000000000..d9214f7c9c3 --- /dev/null +++ b/docs/developer-guide/Health.asciidoc @@ -0,0 +1,278 @@ +== Health + +Codename One ships a cross-platform health API under `com.codename1.health` that reads and writes the platform health store -- HealthKit on iOS and watchOS, Health Connect on Android -- summarizes data over time, watches it for changes, records workouts, and streams live measurements from standard Bluetooth health sensors. + +`Health.getInstance()` is the single entry point and never returns `null`: ports without health support return a fallback whose operations fail fast with `HealthError.NOT_SUPPORTED`, so calling code needs no platform-specific `if`. + +On iOS and Android every callback -- `AsyncResource` results, sensor samples, workout events, change batches -- is delivered on the EDT. On the local-backed ports (simulator, desktop, JavaScript) a store result is delivered on whichever thread made the call, and change delivery still hops to the EDT everywhere. If you start a read from a worker thread on the desktop and update the UI from its callback, wrap that in `callSerially` yourself. This asymmetry is a gap rather than a design, and it's called out here so nobody discovers it from a repaint glitch. + +[options="header"] +|=== +| Capability | iOS 26+ | iOS < 26 | watchOS | Android | Simulator | Desktop / JavaScript +| Read samples and aggregates | yes | yes | yes | yes | yes (scripted) | local only +| Write samples | yes | yes | yes | yes | yes | local only +| Read-authorization introspection | no | no | no | yes | yes | n/a +| Background push delivery | *no* | *no* | *no* | *no* | *no* | no +| Change delivery while running | yes | yes | yes | yes | *no* | *no* +| Live workout session | *recorded* | recorded | *recorded* | recorded | recorded | recorded +| Automatic sensor collection in a workout | *no* | no | *no* | no | no | no +| Bluetooth health sensors | yes | yes | limited | yes | yes | yes +|=== + +Subscriptions are the one place the local-backed ports aren't a +substitute for a device. `subscribe()` registers, persists its cursor and +restores across launches everywhere, but nothing on the local store records +a mutation as a change, so `drainChanges()` there resolves with zero and no +listener ever fires. Subscription code can be written and its registration +exercised on the desktop; whether it actually delivers has to be tested on +a phone. + +Branch through the capability queries -- `Health.getAvailability()`, `HealthStore.isTypeSupported(...)`, `WorkoutManager.isLiveSessionSupported()`, `HealthSubscription.isPushDelivery()` -- rather than through platform detection. + +=== Quick start + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava001Snippet.java[tag=health-java-001,indent=0] +---- + +Check `getAvailability()` before anything else. On Android it distinguishes a working provider from one that's missing or out of date, and both of those are recoverable by the user through `openProviderSetup()`. On the desktop and JavaScript ports it returns `LOCAL_ONLY`, meaning reads and writes work and are durable but the data is only ever your own app's. + +=== Permissions, and the one thing that will surprise you + +**You can't ask whether you are allowed to read.** + +HealthKit doesn't disclose read authorization. A read the user denied returns an empty result, indistinguishable from having no data at all, because an app that could tell the two apart could infer that a user is hiding a pregnancy or a prescription. That's a privacy guarantee, not a gap in this API. + +Three consequences follow, and all three are load-bearing: + +. `requestAuthorization(...)` resolving `true` means the user *has been asked*, not that anything was granted. On iOS the sheet completes identically whether they enabled every switch or none of them. +. `getReadAuthorizationStatus(...)` returns `UNKNOWN` on iOS in every case. Android answers, because its read permissions are ordinary runtime grants; the Android port doesn't pretend otherwise, and neither should your UI. +. There is no `hasReadPermission()` anywhere in this API, because on iOS it would be a lie. + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava002Snippet.java[tag=health-java-002,indent=0] +---- + +`hasAnyData(...)` is the only honest probe, and its name says exactly what it measures. Note also that on iOS your own writes stay readable to you even when read access was denied, so a `true` here doesn't prove you can see other apps' data. + +**Never tell a user "you denied access" on the strength of an empty result.** Say "no data available," and offer `openHealthSettings()`. Getting this wrong produces an app that accuses users of something they didn't do. + +Request the narrowest set of types you actually need, at the moment you need them. Both stores present every requested type on one sheet, and a long list at first launch is the most common reason people decline. + +=== Reading samples + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava003Snippet.java[tag=health-java-003,indent=0] +---- + +Values always come back in the type's canonical unit unless the query asks for another, so the two ports return identical objects. Reading a number out of a `HealthQuantity` requires naming the unit at the call site -- there is no zero-argument `getValue()` -- which removes the entire class of bug where pounds are read as kilograms and every downstream chart is wrong by a factor of 2.2. + +On Android a limit applies per data type when a query names several: Health Connect pages per record type, so a limit of ten over two types can return twenty. Query one type per call when the cap has to be exact, which is what iOS does anyway -- `HKSampleQuery` reads one type per query. + +Always set a limit for high-frequency types. A year of continuous heart rate is on the order of half a million samples; the default cap of 10,000 exists so a naive query can't exhaust the heap, and `readSamplePage(...)` is there for walking more than that. + +Health Connect stores a heart-rate series as one record containing many samples, while HealthKit returns many independent samples. `SampleQuery.setFlattenSeries(boolean)` defaults to `true` so both platforms hand back plain `QuantitySample` objects. Turn it off to keep a Health Connect record whole, which is what you want when you intend to delete it: you get one `SeriesSample` carrying the record identifier and every measurement in it. HealthKit has no series records at all, so iOS answers with plain samples whichever way the flag is set. Every flattened measurement still carries its record identifier, so record-level deletion works either way -- and `isDeletable(...)` is a separate question from `isWritable(...)`, so the series-shaped types Health Connect won't let you write can still be deleted. + +Writing a `SeriesSample` to a phone stores its measurements individually -- neither platform accepts a series through this API -- so the write result carries one identifier per measurement rather than one for the record. A series is unit-checked and converted exactly like a scalar write, and one longer than the platform's batch limit is split across several calls. Sleep and workout samples can't be written to HealthKit or Health Connect at all; writing one is refused with `TYPE_NOT_SUPPORTED` rather than reported as a success that stored nothing. + +Sleep isn't readable on either phone in this release. The iOS type map carries quantity types only, so `HealthDataType.SLEEP` is refused before a query runs, and `SampleQuery.setSleepSessionGapMillis(long)` -- which configures the session reassembly that port will eventually need -- changes nothing today. Android refuses it too: sleep is absent from the readable type set and the bridge has no record class for it, so a query fails with `TYPE_NOT_SUPPORTED` rather than returning nothing. Sleep works fully against the local and simulator stores, which is where to develop against it for now. + +=== Aggregates and time buckets + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava004Snippet.java[tag=health-java-004,indent=0] +---- + +Two rules here are easy to get wrong and hard to notice. + +**A bucket with no data returns `null`, never zero.** A day on which no data was recorded and a day on which the user genuinely took no steps are different facts. Substituting zero turns an absence of data into a claim that nothing happened and draws a flat line through every day the phone stayed in a drawer. + +**Use calendar buckets when your UI labels them with dates.** `HealthInterval.calendarDays(1, tz)` follows the calendar; a fixed `86_400_000` doesn't, and drifts against the dates the user sees twice a year at each daylight-saving transition. Calendar intervals require an explicit `TimeZone` -- nothing in this API reads the JVM default, because a server-side default of UTC would file a user's evening walk under the wrong day. + +WARNING: Overlapping sources are counted twice, on every platform including iOS. When a phone and a watch both record steps for one walk, a total over them counts the walk twice. HealthKit's statistics engine does de-duplicate overlapping sources, but no port uses it in this release -- every metric is computed by shared code from raw samples, so the bucket arithmetic has one implementation rather than one per platform that can drift, and iOS double-counts exactly as Android does. This API leaves that visible rather than papering over it with a heuristic, because guessing which of two overlapping sources is authoritative is exactly the kind of quiet wrongness health data can't afford. Use `addSource(...)` to pin a query to the source you trust, and tell the user which device a figure came from. + +=== Writing samples + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava005Snippet.java[tag=health-java-005,indent=0] +---- + +Writes are validated before the platform is touched: a type this app can't write, an instantaneous sample of a cumulative type such as `STEPS`, or a quantity whose unit measures the wrong dimension all fail immediately with a message naming the offending sample, rather than as an opaque platform error later. Large batches are chunked automatically to the platform's limit. + +Set `RecordingMethod` on what you write. Other apps use it to decide how much to trust a value, and a manually typed weight is a different kind of evidence from one a scale reported. + +`HealthSample.getId()` is assigned by the platform, is scoped to this install, and won't survive a reinstall, so don't use it as a primary key on your server. Keep your own identifier in your own storage and correlate on that. `getMetadata()` looks like the natural home for it and isn't: metadata round-trips through the local and simulator stores but isn't written to HealthKit or Health Connect in this release, so on a phone the identifier is gone the moment you read the sample back. + +=== Background delivery + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava006Snippet.java[tag=health-java-006,indent=0] +---- + +The subscription id keys the persisted cursor that records how far you have read. Reuse it across launches and app updates and you resume exactly where you left off; change it and the framework treats it as new and resynchronizes from scratch. Hard-code it, and version it explicitly (`"steps-v1"`) if you ever want a clean start. + +**No platform wakes your app for health data today.** `isPushDelivery()` answers `false` everywhere, and that's the honest answer rather than a placeholder: nothing arrives while your app is closed, so your app has to ask. + +On Android this is permanent. Health Connect has no push mechanism at all and Google's own guidance is to poll. On iOS, HealthKit does offer `HKObserverQuery` with background relaunch, but this release doesn't register one, so iOS behaves the same way as Android for now. `isPushDelivery()` is the query to branch on; it will start answering `true` on iOS when observers land, and code that already polls will simply poll less often. + +**Draining is what delivers changes, and your app has to ask for it.** Nothing here hooks the application lifecycle: call `drainChanges()` when you come to the foreground, and from your background-fetch handler. Omit it and a subscription delivers nothing at all, however much data arrives. This is the one place where forgetting a call looks exactly like having no new data. + +The two backends drain differently, and the difference is visible to you. Android uses Health Connect change tokens, so a drain reports deletions as well as additions and never re-reports a sample you have already seen. iOS re-reads each subscribed type over the window since the last drain: additions arrive, deletions don't, and a sample edited in place looks like a new one. Both advance their cursor only after your listener returns, so a crash re-reads rather than skips. + +**On iOS a subscription can miss backdated data, and there is no way around it in this release.** The cursor is a timestamp, so it finds samples by when they were *measured*, not by when they arrived in the store. A watch that syncs an hour late, or a reading the user types in for yesterday, lands entirely behind the cursor and no later drain will ever see it. HealthKit's own `HKAnchoredObjectQuery` is what solves this -- it's ordered by insertion and would fix deletions and edit-detection at the same time -- and this release doesn't use it. If your app has to be complete rather than merely current on iOS, do a full `readSamples` over the range you care about on a schedule of your own, and treat the subscription as a prompt to refresh rather than as the source of truth. Android is unaffected: change tokens are insertion-ordered already. + +A busy window on iOS can hold more samples than one HealthKit query returns, and HealthKit offers no continuation token. When that happens the cursor advances only as far as the samples actually delivered and the batch reports `hasMore()`, so the next drain picks up the remainder rather than skipping it. Draining more often keeps each window small; a subscription left undrained for weeks will take several drains to catch up. + +Handle `isResyncRequired()`. A Health Connect change token expires after 30 days and an iOS anchor can be rejected after a restore from backup; when that happens the batch carries no data and you must do a full time-range read to catch up. + +A `HealthBackgroundListener` must be a public top-level class with a public no-argument constructor. Unlike some older callback APIs in this framework there is **no** need to do anything to keep it from being stripped: the build server scans for implementations and generates a factory that constructs each one with a direct `new`, so shrinking and obfuscation both follow the reference correctly. + +=== Workouts + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava007Snippet.java[tag=health-java-007,indent=0] +---- + +Two capabilities that are easy to conflate and that this API keeps separate: + +* `isLiveSessionSupported()` -- the OS runs a real session and keeps the app alive. **False everywhere in this release.** `HKWorkoutSession` exists on watchOS and on iOS 26 and later, and `androidx.health.services` exists on Wear OS, but neither is wired up yet, so every platform currently uses the recorded session described below. Branch on this query rather than on the OS version and your code will pick up native sessions when they land. +* `isSensorCollectionSupported()` -- the OS also gathers heart rate and energy into that session by itself. **False everywhere in this release**, for the same reason: the live session is what collects, and there isn't one yet. + +The second being false everywhere today, **a workout records only what you feed it**. On an Android phone that isn't a degraded fallback: Health Connect has no live-session concept, and inserting a session, batching your own data and updating it at the end is precisely the flow Google documents. Feed it with `addSamples(...)`, or by attaching a Bluetooth sensor. + +`getStatistic(...)` returns `null` rather than a fabricated zero when nothing has been collected. Sessions aren't restored after the process dies -- a killed workout is over, `end()` was never called, and nothing is written. + +Ending a workout tells you what the platform would not keep. `WorkoutSample.WORKOUT_NOT_PERSISTED` is set when the session record itself has no write form -- neither mobile platform accepts one through this API -- and `WorkoutSample.SAMPLES_NOT_PERSISTED` names the data types whose samples were refused, comma separated. Power, speed and both cadences are the ones that bite: they're exactly what a bike or foot pod feeds in, and Health Connect has no single-value write form for any of them. Check both and upload what matters to you; the workout object itself is complete either way. + +=== Bluetooth health sensors + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/HealthJava008Snippet.java[tag=health-java-008,indent=0] +---- + +`com.codename1.health.sensors` covers the adopted Bluetooth SIG profiles -- Heart Rate, Cycling Power, Cycling and Running Speed & Cadence, Health Thermometer, Weight Scale, Blood Pressure and Glucose -- so any conforming device works without per-vendor code. + +This layer is built entirely on `com.codename1.bluetooth.le` and needs no platform health store, so it works identically everywhere Bluetooth LE does, including the desktop and JavaScript ports where no health store exists at all. It needs **Bluetooth** permissions, not health ones, and the build server makes the same distinction: an app that uses only this package isn't treated as a health-data app and gets neither a HealthKit entitlement nor a Google Play health-permissions review. + +Speed and cadence sensors transmit cumulative counters rather than rates, with counters and event timers that wrap every 32 or 64 seconds. The framework differences them for you; hand-rolling that arithmetic is the most commonly botched part of these profiles. + +`SensorSessionOptions.setWriteToStore(...)` defaults to `false` on purpose. When the OS is recording heart rate into HealthKit during a workout, a strap that also writes its own samples double-counts in every downstream average. Attach the session to the workout with `setWorkoutSession(...)` instead; turn write-through on for standalone measurements the OS knows nothing about, such as a weight from a scale. + +A heart-rate session reports the rate. RR intervals are decoded by `HeartRateMeasurement` but aren't delivered as samples -- they're the input to an HRV calculation, not a measurement of it, and no platform has a data type that would store them. If you need HRV, subscribe to characteristic `0x2A37` through `com.codename1.bluetooth.le` and call `HeartRateMeasurement.parse(...)` on the raw value yourself; both are public API. + +Blood pressure is the exception: it's two values in one reading, which HealthKit models as a correlation and Health Connect as its own record type, and neither is implemented in this release. A cuff reading routed to the store fails with `TYPE_NOT_SUPPORTED` on both phones rather than being dropped without a word; the local and simulator stores keep it fine. Read it off the session and persist it yourself for now. + +=== Nutrition + +`com.codename1.health.nutrition` logs food and drink as a sparse set of nutrient amounts. A logged apple sets four fields and a scanned packaged food might set thirty; both platforms model this as a record with several dozen optional fields, and `NutritionSample` keeps that sparseness explicit rather than exposing forty nullable getters. A nutrient that was never measured reads back as `null`, not zero -- the same distinction aggregate buckets make, and one that matters to someone managing their sodium. + +`HealthDataType.NUTRITION` is local and simulator only in this release. Neither the wire format nor either port's type map knows the multi-nutrient record shape, so a read or write of it on a phone is refused with `TYPE_NOT_SUPPORTED` before it reaches the platform rather than being dropped without a word. Individual dietary quantities do reach the phones as ordinary quantity samples -- `HYDRATION` on both, `DIETARY_ENERGY` on iOS -- so use those when the number has to land in the user's real health store. + +=== Build configuration + +Health data is the most regulated data these APIs touch, and the build is strict about it. + +.iOS build hints +[options="header"] +|=== +| Hint | Effect +| `ios.NSHealthShareUsageDescription` | Why your app reads health data. **Required** if you touch the store; the build fails without it. +| `ios.NSHealthUpdateUsageDescription` | Why your app writes health data. Required if you write. +| `ios.health.backgroundDelivery` | Adds the background-delivery entitlement. Inferred from observer usage. +| `ios.health.recalibrateEstimates` | Adds the estimate-recalibration entitlement. +| `ios.health.required` | Adds `healthkit` to `UIRequiredDeviceCapabilities`. +|=== + +.Android build hints +[options="header"] +|=== +| Hint | Effect +| `android.health.read` | Comma-separated data types to read. **Required.** +| `android.health.write` | Comma-separated data types to write. +| `android.health.background` | Adds `READ_HEALTH_DATA_IN_BACKGROUND`. +| `android.health.history` | Adds `READ_HEALTH_DATA_HISTORY`. +| `android.health.privacyPolicyUrl` | Your privacy policy. **Required.** +| `android.health.connectVersion` | Overrides the Health Connect client version. +|=== + +Codename One **doesn't** inject a placeholder privacy string for health, unlike camera or Bluetooth. Apple reviews health purpose strings against what the app actually does, so a generic placeholder is precisely what gets an app rejected -- it would not even achieve the "keeps the build working" goal. The build fails with a message naming the hint instead. + +The Android data types can't be inferred. A data type is referenced as a constant, which compiles to a field read, and the build server's class scanner records only type and method references -- so the permission set genuinely can't be derived from your bytecode. Declaring it explicitly also matches Google Play policy, which requires you to request exactly what you use. Health Connect additionally raises `minSdkVersion` to 26 and requires `targetSdkVersion` 30 or higher. + +NOTE: HealthKit is entitlement-gated, unlike CoreBluetooth. The `com.apple.developer.healthkit` capability must be enabled for your App ID and present in the provisioning profile you build with, or the build fails at code-signing. + +An app that only uses `Health.getSensors()` needs none of this. The sensor +layer is built on `com.codename1.bluetooth.le` and touches no HealthKit, so +the build injects neither the framework nor the entitlement nor the usage +strings, and none of the hints above apply -- it needs +`ios.NSBluetoothAlwaysUsageDescription` instead. Reaching for the store is +what pulls HealthKit in, and it's `getStore()` rather than +`Health.getInstance()` that reports a missing usage string, so the +sensor-only path stays clear of the whole apparatus. + +=== Privacy and store policy + +Read this section before shipping. Both stores enforce policies that go well beyond the technical permissions. + +**Apple.** HealthKit data may not be used for advertising or use-based data mining beyond health, fitness and medical research, and may not be shared with third parties without explicit consent for that specific sharing. A privacy policy URL in App Store Connect is mandatory -- HealthKit apps are rejected without one. HealthKit data may not be stored in iCloud. Purpose strings must be specific: "Reads your step count to show weekly activity trends," not "Needs health access." + +**Google.** Health Connect access is gated by the Play Console health apps declaration form, and each permission is approved individually. Your app must implement a privacy-policy activity that's shown *before* the permission dialog -- Codename One declares it for you, and this isn't optional: Health Connect declines to show its consent dialog to an app that lacks one. Play's Health Apps policy prohibits using the data for ads, for credit, insurance or employment decisions, or transferring it to a data broker. + +**What Codename One itself does.** The framework never uploads health data anywhere. The simulator's event log records data types and counts but never sample values. And the build fails rather than inventing a purpose string on your behalf. + +**Practical rules.** Request the narrowest type set, at the moment of use rather than at launch. Never persist samples to unencrypted `Storage`. Treat the on-device store as the source of truth and avoid server round-trips. Delete health data on account deletion. + +Finally, the read-authorization asymmetry is a privacy *feature*, not an obstacle. iOS hides read denial so that an app can't infer what a user is concealing. Design your UI to respect that. + +=== Simulating health + +The simulator ships a scriptable health store under Simulate -> Health, and every action is also callable from a test with `CN.execute("health:itemN")`. + +[options="header"] +|=== +| Action | Effect +| Grant / Deny All Permissions | The permissive and fully denied cases +| *Grant Write, Deny Read Without Error* | Reproduces the iOS trap exactly +| Load Demo Dataset (7 days) | Synthetic steps, heart rate, sleep and weight +| Emulate HealthKit / Health Connect Permissions | Switches read-denial behaviour +| Make Health Unavailable | Emulates a missing provider +| `health:item10` / `health:item11` | Arm a one-shot query or save failure +|=== + +**Click "Grant Write, Deny Read Without Error" before you ship.** It's the default-adjacent case real users will produce and the one a permissive store never shows you: authorization appears to succeed, the status reads `UNKNOWN`, and every query comes back empty with no error. If your UI shows an error or accuses the user of denying access, it's wrong. + +The simulator's data is synthetic and seeded, not recorded from anyone. That's intentional: unlike Bluetooth traces, where the sensitive parts are identifiers that can be scrubbed, the sensitive part of a health sample *is* the value being asserted on, and a resting heart rate together with sleep timing and step cadence is quasi-identifying. + +=== Troubleshooting + +*A query returns empty even though permission was granted.* On iOS you can't distinguish a denied read from an empty store -- this is by design. Use `hasAnyData(...)`, show "no data available," rather than an error, and offer `openHealthSettings()`. Reproduce it in the simulator with "Grant Write, Deny Read Without Error." + +*The chart shows zero for days the user was active.* You are substituting zero for a `null` aggregate. An empty bucket means no data, not no steps. + +*Totals are double.* A phone and a watch are both writing the same type, on iOS as much as on Android -- aggregation runs on raw samples in shared code, so HealthKit's de-duplicating statistics engine never sees the query. Pin it with `addSource(...)`. + +*Background delivery never fires.* No platform pushes health data to a closed app in this release -- see the subscriptions section. Call `drainChanges()` from your background-fetch handler and branch on `isPushDelivery()` rather than assuming. + +*Replaying stored glucose records isn't implemented.* `SensorSession.requestStoredRecords(...)` always fails with `NOT_SUPPORTED` in this release. Replay runs over the Record Access Control Point -- a stateful protocol on a second characteristic -- and shipping it untested against real meters would be worse than saying so. Live glucose notifications work normally. + +*Sample metadata doesn't reach the phone's health store.* `HealthSample.getMetadata()` round-trips locally -- the simulator, desktop and JavaScript store -- but isn't persisted to HealthKit or Health Connect in this release, so a sample written and read back on a device returns without it. Health Connect has no arbitrary metadata at all, only a single client record id. Keep your correlation identifier in your own storage. + +*Recorded workouts aren't kept alive on Android.* The manifest declares no foreground service, so a recorded workout lives exactly as long as your process does. If you need one to survive backgrounding, keep the app alive yourself. + +*`BODY_MASS_INDEX` is rejected on Android.* Health Connect has no BMI permission or record -- BMI is derived, not stored -- so the build fails rather than requesting an unrelated body-composition permission on your behalf. Read `BODY_MASS` and `HEIGHT` and compute it. HealthKit does store BMI, so iOS is unaffected. + +*Deleting a sample by id does nothing.* Health Connect deletes by record class plus id, so `HealthDeleteRequest.byId(...)` takes the data type alongside the identifier. Naming the wrong type deletes nothing and still reports success. + +*The weekly chart is off by a day twice a year.* You are bucketing by a fixed 24 hours across a daylight-saving transition. Use `HealthInterval.calendarDays(1, tz)`. + +*The iOS build fails complaining about a usage description.* That's intentional -- add `ios.NSHealthShareUsageDescription` with text describing what your app actually does with the data. + +*The Health Connect permission dialog never appears.* Health Connect requires the permissions-rationale activity, which Codename One declares only when it detects health usage. Check that the build log mentions the health manifest fragments. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index f146cdf92ae..8df4a6f2f89 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -149,6 +149,8 @@ include::Near-Field-Communication.asciidoc[] include::Bluetooth.asciidoc[] +include::Health.asciidoc[] + include::Printing.asciidoc[] include::Network-Connectivity.asciidoc[] diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AiDependencyTable.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AiDependencyTable.java index 589034db84d..576b96c6e8a 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AiDependencyTable.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AiDependencyTable.java @@ -198,6 +198,63 @@ public final class AiDependencyTable { "Communicates with nearby Bluetooth accessories.") .description("Cross-platform Bluetooth (BLE central/peripheral, L2CAP, classic RFCOMM)")); + // First-class health data (com.codename1.health.*): HealthKit on + // iOS, Health Connect on Android. + // + // NOTE the two deliberate omissions, both of which a future + // maintainer will be tempted to "fix" because every neighbouring + // entry has them: + // + // - NO iosPlist defaults. Unlike camera or bluetooth we do NOT + // inject placeholder NSHealth*UsageDescription strings. Apple + // reviews health privacy copy against what the app actually + // does, so a generic placeholder is precisely what gets an app + // rejected -- it would not even achieve the "keeps the build + // working" goal it was added for. IPhoneBuilder fails the build + // with an actionable message instead. + // - NO androidPermissions. Health Connect permissions are + // per-data-type and the type an app uses is a field reference, + // which the class scanner cannot see (Executor.visitFieldInsn is + // an empty override). They come from the android.health.read / + // android.health.write build hints via HealthManifestFragments. + // - NO androidGradle either, and this one is subtle. Entries match + // with startsWith, so this prefix ALSO matches + // com/codename1/health/sensors/ -- there is no way to say + // "except that subpackage". The sensor layer is pure + // com.codename1.bluetooth.le and needs no androidx.health at + // all, so putting the dependency here would add Health Connect + // (and, on the Play side, a health-permissions review) to an app + // that only reads a heart-rate strap. AndroidGradleBuilder adds + // it instead, gated on the scanner having seen health classes + // OUTSIDE the sensors subpackage. + // + // The HealthKit framework linkage and the CN1_INCLUDE_HEALTH define + // flip likewise happen in IPhoneBuilder, gated the same way; + // iosFrameworks here is documentation-only, matching the bluetooth + // entry above. + e.add(new Entry("com/codename1/health/") + .iosFrameworks("HealthKit") + .description("Cross-platform health data (samples, aggregates, background observers, workouts)")); + + // The health sensor layer talks to standard GATT devices over + // com.codename1.bluetooth.le, so it needs the Bluetooth privacy + // string whether or not the app also uses a health store. This + // entry is additive on top of the one above and is safe for both + // cases, unlike anything HealthKit- or Health-Connect-specific. + e.add(new Entry("com/codename1/health/sensors/") + .iosFrameworks("CoreBluetooth") + .iosPlist("NSBluetoothAlwaysUsageDescription", + "Communicates with nearby heart rate and fitness sensors.") + // The iOS 12 key as well, exactly as the Bluetooth entry + // above carries it. NSBluetoothAlwaysUsageDescription + // arrived in iOS 13, and iOS 12 checks the older key + // before letting CoreBluetooth start -- so a sensor-only + // app naming this facade rather than com.codename1.bluetooth + // was terminated on the supported floor. + .iosPlist("NSBluetoothPeripheralUsageDescription", + "Communicates with nearby heart rate and fitness sensors.") + .description("Bluetooth health sensors (heart rate, power, cadence, scales, cuffs, glucose)")); + // On-device Stable Diffusion: bundled Core ML model on iOS, // ONNX runtime on Android. Flag the >2 GB upload concern so // the cloud build server can abort early with a helpful diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 7c541eb89b8..f061662bc4b 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -411,6 +411,33 @@ static String pendingPushReplayCode(int detectedPushVersion) { private boolean usesBluetoothPeripheral; private boolean usesBluetoothClassic; + // Health. usesHealthStore is deliberately distinct from usesHealth: + // com.codename1.health.sensors is pure com.codename1.bluetooth.le and + // needs no Health Connect at all, so a heart-rate-strap app must not be + // treated as a health-data app. + private boolean usesHealth; + private boolean usesHealthStore; + private boolean usesHealthData; + /// Whether a read-direction store call was seen. Tracked apart from + /// [#usesHealthWrite] because Health Connect permissions are + /// directional: an app that only reads but declares only + /// android.health.write passed validation and shipped a manifest with + /// no read permission, so every read failed at runtime with nothing in + /// the build log to explain it. + private boolean usesHealthRead; + private boolean usesHealthWrite; + private boolean usesHealthWorkout; + /// What the scan saw about health background listeners, and which of + /// them the generated factory can actually construct. + private final HealthListenerScan healthScan = new HealthListenerScan(); + + // Computed while the permissions are assembled but consumed later, at + // the points where , the body and the gradle + // dependency list are actually built. + private String healthQueriesFragment = ""; + private String healthApplicationFragment = ""; + private String healthGradleDependency = ""; + private boolean integrateMoPub = false; private static final boolean isMac; @@ -1364,6 +1391,27 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc try { scanClassesForPermissions(dummyClassesDir, new Executor.ClassScanner() { + @Override + public void implementsInterface(String cls, String iface) { + healthScan.implementsInterface(cls, iface); + } + + @Override + public void declaresEnclosedBy(String cls, String outer) { + healthScan.declaresEnclosedBy(cls, outer); + } + + @Override + public void declaresPublicType(String cls) { + healthScan.declaresPublicType(cls); + } + + @Override + public void declaresType(String cls, String superName, + boolean isConcrete) { + healthScan.declaresType(cls, superName, isConcrete); + } + @Override @@ -1519,6 +1567,43 @@ public void usesClass(String cls) { // (Scan* handles vs the GATT/stream types); the // usesClassMethod hook below catches facade-only // callers. + if (cls.indexOf("com/codename1/health/") == 0) { + usesHealth = true; + // The facade itself is not evidence of the store: + // the documented sensor-only flow is + // Health.getInstance().getSensors(), which the + // scanner reports with com/codename1/health/Health + // as the owner. Treating that as store usage failed + // the build for BLE-only apps over Health Connect + // hints they have no use for. The usesClassMethod + // hook below decides for the facade. + // Naming a class is not using the store. A + // sensor-only app implements SensorSampleListener, + // which puts HealthSample in its signature, and the + // documented heart-rate example reads a value off a + // QuantitySample -- both live outside the sensors + // subpackage, so demanding Health Connect types and + // a privacy-policy URL of a BLE-only app was the + // exact failure the sensors exemption exists to + // prevent. Store *calls* count, and the + // usesClassMethod hook below sees those. + if (cls.indexOf("com/codename1/health/sensors/") != 0 + && !"com/codename1/health/Health".equals(cls) + && !isSharedHealthModel(cls)) { + usesHealthStore = true; + } + if (cls.indexOf("com/codename1/health/workout/") == 0) { + usesHealthWorkout = true; + // Write only, as the getWorkouts() hook is. + // Naming WorkoutConfiguration or WorkoutSession + // says no more about reading than calling + // getWorkouts() does, and nothing in the package + // reads -- so this branch went on demanding + // android.health.read of an app that had + // correctly declared only a write. + usesHealthWrite = true; + } + } if (cls.indexOf("com/codename1/bluetooth/") == 0) { usesBluetooth = true; if (cls.indexOf("com/codename1/bluetooth/le/server/") == 0) { @@ -1548,6 +1633,90 @@ public void usesClass(String cls) { @Override public void usesClassMethod(String cls, String method) { + // Health.getStore()/getWorkouts() mean a real platform + // store; Health.getSensors() means BLE only. The class + // reference alone cannot tell them apart, so the facade + // is decided here. + // A store method reached through a passed-in HealthStore + // never names Health at all, so the facade hook below + // cannot see it -- and without this the build skipped the + // type validation and shipped a manifest with no per-type + // permissions, leaving those calls unauthorized. + // Sensor write-through is store use. An app can enable it + // with SensorSessionOptions.setWriteToStore(true) and never + // name HealthStore, so the sensors-package exemption -- which + // exists so a BLE-only app is not dragged into Health Connect + // -- hid the one call that genuinely needs it, and the build + // shipped no bridge and no permissions for a documented flow. + if ("com/codename1/health/sensors/SensorSessionOptions" + .equals(cls) + && method.startsWith("setWriteToStore")) { + usesHealth = true; + usesHealthStore = true; + usesHealthData = true; + usesHealthWrite = true; + } + if (cls.indexOf("com/codename1/health/HealthStore") == 0) { + usesHealth = true; + usesHealthStore = true; + usesHealthWrite |= + HealthManifestFragments.isWriteCall(method); + usesHealthRead |= + HealthManifestFragments.isReadCall(method); + // Data access, not merely touching the store. The + // capability probes -- isSupported, isTypeSupported, + // isWritable, getSupportedTypes -- read no records and + // need no permission, so demanding declared types for + // them made a build that only asks "is this available" + // request permissions it never uses. + usesHealthData |= usesHealthRead || usesHealthWrite; + } + if ("com/codename1/health/Health".equals(cls)) { + usesHealth = true; + if (method.startsWith("getStore") + || method.startsWith("getWorkouts") + || method.startsWith("openHealthSettings") + || method.startsWith("openProviderSetup") + || method.startsWith("getAvailability")) { + usesHealthStore = true; + } + // getStore() installs the bridge but is not itself + // data access: an app that takes the handle to probe + // isTypeSupported reads nothing, and availability and + // the settings shortcuts read nothing either. The + // HealthStore method hook above sets usesHealthData + // when a real read or write is called. + if (method.startsWith("getWorkouts")) { + usesHealthWorkout = true; + // A recorded workout writes: end() stores the + // child samples it was fed. Without this the + // manifest carried only ACTIVITY_RECOGNITION + // and every one of those writes was + // unauthorized at runtime. + // + // It does not read. Nothing in the workout + // package calls readSamples or aggregate -- + // the rollup is computed from the samples the + // app fed in -- so demanding a read token + // forced a workout-only app to request a + // sensitive permission it never uses, which + // Play policy asks you not to do and which the + // build then refused to proceed without. + usesHealthData = true; + usesHealthWrite = true; + } + if (method.startsWith("getSensors")) { + // The BLE sensor layer runs entirely on the + // public bluetooth API, and an app that only + // calls getSensors() never names that package + // itself -- so without this the manifest got no + // BLUETOOTH_SCAN/CONNECT and discovery failed on + // Android 12+. + usesBluetooth = true; + usesBluetoothScan = true; + usesBluetoothConnect = true; + } + } if (cls.indexOf("com/codename1/calendar/LocalCalendarSource") == 0 || (cls.indexOf("com/codename1/calendar/CalendarManager") == 0 && (method.indexOf("getLocalSource") >= 0 @@ -1865,6 +2034,183 @@ public void usesClassMethod(String cls, String method) { neverForLocation, bleRequired, targetSDKVersionInt); } + // First-class health (com.codename1.health.*). Gated on + // usesHealthStore, NOT usesHealth: com.codename1.health.sensors is + // pure BLE and must not drag in Health Connect or a Google Play + // health-permissions review. + if (usesHealthStore) { + String readHint = request.getArg("android.health.read", ""); + String writeHint = request.getArg("android.health.write", ""); + List readTypes = + HealthManifestFragments.parseTypeList(readHint); + List writeTypes = + HealthManifestFragments.parseTypeList(writeHint); + // Checked after parsing, so " , " counts as empty rather than + // passing a raw length test and installing the bridge with no + // permissions at all. Availability-only apps are exempt: they + // touch no data type, and demanding one would put a permission + // in the manifest the app never uses. + if (usesHealthData && readTypes.isEmpty() + && writeTypes.isEmpty()) { + // The scanner cannot infer these: a data type is referenced + // as a constant, and field reads are not recorded. Play + // also requires declaring exactly the types you use. + error("This app uses com.codename1.health but declares no " + + "health data types. Health Connect permissions are " + + "per-data-type and cannot be inferred from " + + "bytecode, so list them explicitly:\n" + + " android.health.read=steps,heart_rate,sleep\n" + + " android.health.write=steps\n" + + "Known tokens: " + + HealthManifestFragments.knownTokens(), + new RuntimeException("android.health.read unset")); + } + if (usesHealthRead && readTypes.isEmpty()) { + error("This app reads health data but android.health.read is " + + "empty. Health Connect permissions are directional, " + + "so a write declaration does not authorize a read:\n" + + " android.health.read=steps,heart_rate\n" + + "Known tokens: " + + HealthManifestFragments.knownTokens(), + new RuntimeException("android.health.read unset")); + } + if (usesHealthWrite && writeTypes.isEmpty()) { + error("This app writes or deletes health data but " + + "android.health.write is empty. Health Connect " + + "permissions are directional, so a read declaration " + + "does not authorize a write:\n" + + " android.health.write=steps\n" + + "Known tokens: " + + HealthManifestFragments.knownTokens(), + new RuntimeException("android.health.write unset")); + } + // No workout-token requirement. Android never reads or writes + // the WORKOUT type in this release -- HealthWire excludes it + // from both capability tables, and a recorded workout persists + // only its writable child samples and marks the session record + // as not persisted. Demanding READ_EXERCISE and WRITE_EXERCISE + // would make apps request permissions no runtime path uses, and + // invite a Play health-data declaration for the same nothing. + java.util.List readTokens = + HealthManifestFragments.parseTypeList(readHint); + java.util.List writeTokens = + HealthManifestFragments.parseTypeList(writeHint); + // Declared, permitted, and unusable. Thirteen tokens have a + // Health Connect permission but no record class the bridge can + // read or delete, so an app declaring one shipped a health + // permission it could never exercise -- and Play asks what + // every health permission is for. + // One list for both directions. A permission covers more than + // one operation -- write authorises inserts and deletes, read + // covers reads and change subscriptions -- and deletes and + // subscriptions go through the wider recordClassFor gate. So + // the union for each direction is recordClassFor, and the + // build cannot know which operation an app will use. Rejecting + // per direction refused an app that only deletes power records + // and one that only subscribes to sleep changes; both work. + java.util.List unreadable = + HealthManifestFragments.unsupportedTokens(readTokens); + unreadable.addAll( + HealthManifestFragments.unsupportedTokens(writeTokens)); + if (!unreadable.isEmpty()) { + error("Health Connect support for " + unreadable + + " is not implemented in this build, so declaring" + + " it would request a permission the app cannot" + + " use. Remove it from android.health.read /" + + " android.health.write.", + new RuntimeException("unsupported health token")); + } + java.util.List unknown = + HealthManifestFragments.unknownTokens(readTokens); + unknown.addAll(HealthManifestFragments.unknownTokens(writeTokens)); + if (!unknown.isEmpty()) { + error("Unknown health data type(s) " + unknown + + " in android.health.read / android.health.write. " + + "Known tokens: " + + HealthManifestFragments.knownTokens(), + new RuntimeException("unknown health token")); + } + // Only when the app actually requests a Health Connect + // permission. An availability-only app never presents the + // rationale screen, so demanding a policy URL rejected a + // harmless flow for a hint it would never use. + // The two special reads count as permissions in their own + // right: they are injected into the manifest whether or not + // any type list is populated, so a build declaring only + // android.health.background shipped READ_HEALTH_DATA_IN_ + // BACKGROUND with a rationale screen that had no policy link + // to show -- exactly the Play rejection this gate exists to + // prevent. + boolean specialReads = "true".equalsIgnoreCase(request.getArg( + "android.health.background", "false")) + || "true".equalsIgnoreCase(request.getArg( + "android.health.history", "false")); + boolean requestsPermissions = + !readTypes.isEmpty() || !writeTypes.isEmpty() + || specialReads; + // Trimmed before it is judged, and the trimmed value is what + // gets emitted. A raw length test accepted " " and wrote a + // whitespace-only resource, so the rationale screen had no + // usable link while the build reported the policy requirement + // as satisfied. + String policyUrl = request.getArg( + "android.health.privacyPolicyUrl", "").trim(); + if (requestsPermissions && policyUrl.length() == 0) { + // Play requires a privacy policy for health permissions and + // the rationale screen has to link to it, so a missing URL + // means a rejected app rather than a broken build later. + error("Google Play requires a privacy policy for apps that " + + "request Health Connect permissions, and the " + + "permissions-rationale screen must link to it. Set " + + "android.health.privacyPolicyUrl=.", + new RuntimeException("privacy policy url unset")); + } else { + // Emit the URL as the string resource + // HealthPermissionsRationaleActivity looks up. Validating + // the hint without emitting it would leave the rationale + // screen with nothing to show, which is the exact + // disclosure the check above claims to enforce. + additionalKeyVals += " " + + xmlize(policyUrl) + + "\n"; + } + if (targetSDKVersionInt < 30) { + // is only emitted from API 30, and without it the + // provider is invisible to package visibility. + error("Health Connect requires android.targetSDKVersion 30 " + + "or higher so the provider entry is " + + "emitted; this app targets " + + targetSDKVersionInt + ".", + new RuntimeException("targetSdk too low")); + } + log("Health Connect fragments version " + + HealthManifestFragments.FRAGMENT_VERSION); + xPermissions = HealthManifestFragments.injectPermissions( + xPermissions, readTokens, writeTokens, + "true".equalsIgnoreCase(request.getArg( + "android.health.background", "false")), + "true".equalsIgnoreCase(request.getArg( + "android.health.history", "false")), + targetSDKVersionInt); + healthQueriesFragment = + HealthManifestFragments.injectQueries(""); + healthApplicationFragment = + HealthManifestFragments.injectApplicationEntries("", + targetSDKVersionInt); + + // Health Connect ships as an AndroidX library with a Kotlin + // coroutine API; the port itself never references it (see + // HealthConnectDelegate) but the app module must resolve it. + healthGradleDependency = " implementation " + + "'androidx.health.connect:connect-client:" + + request.getArg("android.health.connectVersion", + "1.1.0-alpha07") + "'\n"; + minSDK = maxInt("26", minSDK); + log("Health Connect raises minSdkVersion to " + minSDK); + } + String messagingService = request.getArg("android.messagingService", pushVersion == 3 ? "auto" : "fcm"); boolean automaticPush = pushVersion == 3 @@ -2245,6 +2591,154 @@ public void usesClassMethod(String cls, String method) { throw new BuildException("Failed to extract android port sources from "+androidPortSrcJar, ex); } + // Health background-listener bindings: generated rather than + // resolved reflectively, so each listener is reached through a + // direct constructor call that shrinking and obfuscation follow. + for (String warning : healthScan.warnings()) { + log("WARNING: " + warning); + } + String healthBindingsSource = + HealthListenerBindings.generate(healthScan.resolve()); + if (healthBindingsSource != null) { + File bindingsFile = new File(srcDir, + HealthListenerBindings.sourcePath()); + bindingsFile.getParentFile().mkdirs(); + try { + createFile(bindingsFile, healthBindingsSource.getBytes( + StandardCharsets.UTF_8)); + } catch (Exception ex) { + throw new BuildException( + "Failed to write the health listener bindings", ex); + } + log("Generated health background-listener bindings for " + + healthScan.resolve().keySet()); + } + + // Health Connect bridge: Kotlin, because androidx.health.connect + // exposes only suspend functions and the Android port compiles + // against an old android.jar with no AndroidX or Kotlin. Same + // pattern as the Android Auto glue below -- a real source resource + // copied into the generated project, never reflection. + if (usesHealthStore) { + File healthDir = new File(srcDir, "com/codename1/health"); + healthDir.mkdirs(); + InputStream hin = getResourceAsStream( + "/com/codename1/builders/health/CN1HealthConnectBridge.kt"); + if (hin == null) { + throw new BuildException( + "Missing Health Connect bridge resource"); + } + try { + copy(hin, new FileOutputStream(new File(healthDir, + "CN1HealthConnectBridge.kt"))); + } catch (IOException ex) { + throw new BuildException( + "Failed to write the Health Connect bridge", ex); + } + // connect-client needs Kotlin 1.9+; the builder's default of + // 1.7.22 will not compile it. + // + // The argument raised here is requireKotlinStdlib, because that + // is the one the Gradle generator actually reads. Setting + // android.kotlinVersion looks right and does nothing: the + // generator never consults it, so a non-Gradle-8 project would + // still be pinned to 1.7.22 and fail compiling the bridge. + String kotlinFloor = "1.9.22"; + // Checked before, and independently of, whether this builder + // has to raise the version: a project that already sets + // requireKotlinStdlib=1.9.22 with useGradle8=false is just as + // broken, and the earlier nesting let it through. + // + // The version that will actually run, not the flag that + // usually selects it. android.gradleVersion overrides the + // choice, so useGradle8=true with gradleVersion=6.5 satisfied + // the flag and then failed compiling the bridge with the very + // requirement this message states. + if (!useGradle8 || gradleVersionInt < 7) { + throw new BuildException( + "Health Connect requires Kotlin " + kotlinFloor + + ", whose Gradle plugin needs Gradle 6.8.3 or" + + " newer, but this build would use Gradle " + + gradleVersion + " (android.useGradle8=" + + useGradle8 + "). Set android.useGradle8=true and" + + " leave android.gradleVersion unset to build a" + + " health-enabled app."); + } + // androidx.health.connect is an AndroidX artifact. Without + // AndroidX the generator writes neither android.useAndroidX + // nor Jetifier, and Gradle's own dependency check rejects the + // project rather than building it. Forcing it on would change + // how every other dependency in the app resolves, which is not + // this block's call to make. + if (!useAndroidX) { + throw new BuildException( + "Health Connect ships as an AndroidX library, but" + + " this build sets android.useAndroidX=false." + + " Remove that hint to build a health-enabled" + + " app."); + } + String declaredKotlin = + request.getArg("requireKotlinStdlib", "").trim(); + // Compared on the numeric prefix. A qualified version like + // 1.9.22-RC2 is perfectly acceptable, and feeding it to + // compareVersions -- which parses each segment as an int -- + // threw and failed the build instead of accepting it. + String comparableKotlin = HealthManifestFragments + .numericVersionPrefix(declaredKotlin); + if (comparableKotlin == null + || compareVersions(comparableKotlin, kotlinFloor) < 0) { + request.putArgument("requireKotlinStdlib", kotlinFloor); + log("Health Connect requires Kotlin " + kotlinFloor + + " or newer; raising requireKotlinStdlib from " + + (declaredKotlin.length() == 0 + ? "the default" : declaredKotlin)); + } + // A kotlin-gradle-plugin the app declares for itself wins: + // the Gradle generator skips adding its own line when it sees + // one, so raising requireKotlinStdlib above changed nothing + // and the bridge was compiled by whatever compiler was pinned. + // Overriding a version the app asked for would break whatever + // it wanted that compiler for, so say so instead. + String topDependency = + request.getArg("android.topDependency", ""); + String declaredPlugin = + HealthManifestFragments.declaredKotlinPluginVersion( + topDependency); + if (HealthManifestFragments.declaresKotlinPlugin(topDependency) + && declaredPlugin == null) { + // A version this build cannot read -- a Gradle variable, + // typically. The generator suppresses its own plugin line + // on the bare substring, so the declaration takes effect + // while the floor check below sees nothing to check, and a + // variable resolving to 1.7.x compiled the bridge with an + // incompatible compiler. Nothing here can resolve it, so + // ask for a literal rather than guess. + error("This app declares kotlin-gradle-plugin in " + + "android.topDependency with a version this build " + + "cannot read, and that declaration replaces the " + + "plugin the build would otherwise add. Health " + + "Connect needs Kotlin " + kotlinFloor + " or " + + "newer to compile the bridge, so state the " + + "version literally -- " + + "org.jetbrains.kotlin:kotlin-gradle-plugin:" + + kotlinFloor + " -- or drop the declaration.", + new RuntimeException("kotlin plugin version " + + "unreadable")); + } + if (declaredPlugin != null + && compareVersions(declaredPlugin, kotlinFloor) < 0) { + error("This app declares kotlin-gradle-plugin " + + declaredPlugin + " in android.topDependency, but " + + "Health Connect needs Kotlin " + kotlinFloor + + " or newer to compile the bridge. A plugin " + + "declared there replaces the one this build would " + + "otherwise add, so raise it to " + kotlinFloor + + " or drop the declaration.", + new RuntimeException("kotlin plugin below the " + + "Health Connect floor")); + } + } + // Android Auto glue: when the app references com.codename1.car, copy the injected // CarAppService / Session / Screen + the CarBridge converter (typed against androidx.car.app) // into the generated project and add the car-app gradle dependency. These ship as real .java @@ -2494,7 +2988,9 @@ public void usesClassMethod(String cls, String method) { } xQueries = ""; if (targetSDKVersionInt >= 30) { - xQueries = "\n" + request.getArg("android.manifest.queries", "") + "\n"; + xQueries = "\n" + + request.getArg("android.manifest.queries", "") + + healthQueriesFragment + "\n"; } //Delete the Facebook implemetation if this app does not use FB. @@ -3583,6 +4079,7 @@ public void usesClassMethod(String cls, String method) { + foregroundServiceEntry + shareReceiverActivity + locationServices + + healthApplicationFragment + mediaService + remoteControlService + hceService @@ -4828,6 +5325,11 @@ public void usesClassMethod(String cls, String method) { : "") + facebookProguard + " " + request.getArg("android.proguardKeep", "") + "\n" + + (usesHealthStore + ? HealthManifestFragments.proguardKeepRules( + new java.util.ArrayList( + healthScan.resolve().keySet())) + : "") + googlePlayObfuscation + "-keep class com.google.mygson.**{\n" + "*;\n" @@ -4859,6 +5361,7 @@ public void usesClassMethod(String cls, String method) { request.putArgument("var.android.playServicesVersion", playServicesVersion); String additionalDependencies = request.getArg("gradleDependencies", ""); + additionalDependencies += healthGradleDependency; if (facebookSupported) { minSDK = maxInt("15", minSDK); @@ -5579,6 +6082,27 @@ private String createPostInitCode(BuildRequest request) { private String createOnCreateCode(BuildRequest request) { String retVal = ""; + // Install the generated health background-listener bindings. These + // construct each listener with a direct `new`, so nothing is + // resolved reflectively after the OS relaunches the app in the + // background -- see HealthListenerBindings for why that matters on + // shrunk and obfuscated builds. + String healthBindings = HealthListenerBindings.installStatement( + healthScan.resolve()); + if (healthBindings != null) { + retVal += healthBindings; + } + if (usesHealthStore) { + // Publish the Kotlin bridge so AndroidHealth can reach Health + // Connect. Without this the health API degrades to reporting + // itself unsupported, exactly as com.codename1.car does when + // Android Auto is not bundled. + retVal += "com.codename1.impl.android.AndroidHealthSupport" + + ".setDelegate(new com.codename1.health" + + ".CN1HealthConnectBridge(com.codename1.impl.android" + + ".AndroidNativeUtil.getContext()));\n"; + } + if (request.getArg("android.includeGPlayServices", "true").equals("true") || playServicesLocation) { retVal += "Display.getInstance().setProperty(\"IncludeGPlayServices\", \"true\");\n"; } @@ -6122,6 +6646,35 @@ private static String maxInt(String a, String b) { return String.valueOf(Math.max(Integer.parseInt(a), Integer.parseInt(b))); } + /// Whether `cls` is a health value type rather than the store. + /// + /// These travel through the BLE sensor layer, which needs no Health + /// Connect permission at all: a sensor callback is handed a + /// HealthSample, and reading a number off it names QuantitySample and + /// HealthQuantity. Counting those as store access made every + /// documented sensor-only app fail the health-hint gate. + private static boolean isSharedHealthModel(String cls) { + return "com/codename1/health/HealthSample".equals(cls) + || "com/codename1/health/QuantitySample".equals(cls) + || "com/codename1/health/SeriesSample".equals(cls) + || "com/codename1/health/CategorySample".equals(cls) + || "com/codename1/health/HealthQuantity".equals(cls) + || "com/codename1/health/HealthUnit".equals(cls) + || "com/codename1/health/HealthDataType".equals(cls) + || "com/codename1/health/HealthSource".equals(cls) + || "com/codename1/health/RecordingMethod".equals(cls) + || "com/codename1/health/BloodPressureSample".equals(cls) + // The error types travel the same way. A sensor callback + // is handed a HealthException, and asking it what went + // wrong names HealthError -- so a sensor-only app that + // handled its errors was read as touching the store, and + // got the Health Connect bridge bundled and its + // minSdkVersion raised to 26, cutting off the API 21-25 + // devices the BLE-only flow is documented to support. + || "com/codename1/health/HealthException".equals(cls) + || "com/codename1/health/HealthError".equals(cls); + } + static int compareVersions(String v1, String v2) { String v1p1 = v1.indexOf(".") == -1 ? v1 : v1.substring(0, v1.indexOf(".")); String v2p1 = v2.indexOf(".") == -1 ? v2 : v2.substring(0, v2.indexOf(".")); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index 8bc5d568271..056ce9946d0 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -99,6 +99,26 @@ public abstract class Executor { private Class[] nativeInterfaces; private boolean unitTestMode; static boolean IS_MAC; + /** + * The internal class name inside a JVM field descriptor. + * + *

{@code Lcom/foo/Bar;} carries one character of punctuation at + * each end. Cutting two off the tail dropped the last letter of every + * class name reported from a field or a local variable, so any check + * comparing a whole name silently missed -- which is how a BLE-only + * app declaring a {@code HealthSample} field was still classified as + * using the health store.

+ */ + public static String descriptorToInternalName(String descriptor) { + if (descriptor == null || descriptor.length() < 3 + || descriptor.charAt(0) != 'L') { + return descriptor; + } + int end = descriptor.charAt(descriptor.length() - 1) == ';' + ? descriptor.length() - 1 : descriptor.length(); + return descriptor.substring(1, end); + } + protected final Map defaultEnvironment = new HashMap(); private Properties localBuilderProperties; @@ -394,6 +414,52 @@ public static interface ClassScanner { public void usesClass(String cls); public void usesClassMethod(String cls, String method); + + /** + * Reports that {@code cls} declares {@code iface} among its + * implemented interfaces. + * + *

{@link #usesClass(String)} is also called for the interface, + * but it only says that the interface was referenced somewhere -- + * it drops which class did the implementing. Builders that need to + * generate a binding to an app-supplied callback need the + * implementor, so that a direct constructor call can be emitted + * instead of resolving a name reflectively at runtime.

+ */ + public void implementsInterface(String cls, String iface); + + /** + * Reports that {@code cls} is a member of {@code outer}, as the + * class file's own {@code InnerClasses} attribute states it. + * + *

Nesting cannot be inferred from the binary name: a dollar is + * a legal Java identifier character, so a top-level + * {@code app.Step$Listener} is a class somebody may really have + * written and a generated {@code new app.Step.Listener()} would + * not compile. Only the attribute knows which dollars separate + * anything.

+ */ + public default void declaresEnclosedBy(String cls, String outer) { + } + + /** + * Reports a type, its superclass, and whether the generated + * bindings could build it with {@code new X()} -- that is, it is + * a public, non-abstract, non-interface class with a public + * no-argument constructor. + * + *

Needed because the class that declares a listener + * interface is often not the one to bind: an abstract base cannot + * be constructed, and the concrete subclass never names the + * interface itself.

+ */ + public default void declaresType(String cls, String superName, + boolean isConstructible) { + } + + /** Reports that {@code cls} is public. */ + public default void declaresPublicType(String cls) { + } } public static interface InternalClassRemapper { @@ -468,11 +534,45 @@ protected void scanClassesForPermissions(File directory, final ClassScanner scan is.close(); ClassVisitor classVisitor = new ClassVisitor(Opcodes.ASM9) { + private String scannedName; + private String scannedSuper; + private boolean scannedPublic; + private boolean scannedConcrete; + private boolean scannedHasPublicNoArgCtor; + @Override - public void visit(int i, int i1, String string, String string1, String superName, String[] interfaces) { + public void visit(int i, int accessFlags, String string, String string1, String superName, String[] interfaces) { + scannedName = string; + scannedSuper = superName; + // ACC_PUBLIC 0x0001, ACC_INTERFACE 0x0200, + // ACC_ABSTRACT 0x0400. A class the generated + // bindings construct from another package has + // to be public and buildable; the constructor + // is checked in visitMethod. + scannedPublic = (accessFlags & 0x0001) != 0; + scannedConcrete = (accessFlags & 0x0200) == 0 + && (accessFlags & 0x0400) == 0 + && scannedPublic; + scannedHasPublicNoArgCtor = false; scanner.usesClass(superName); for (String s : interfaces) { scanner.usesClass(s); + scanner.implementsInterface(string, s); + } + } + + @Override + public void visitEnd() { + // Reported here rather than in visit(): the + // InnerClasses attribute arrives in between + // and can still rule the class out. + if (scannedName != null) { + if (scannedPublic) { + scanner.declaresPublicType(scannedName); + } + scanner.declaresType(scannedName, + scannedSuper, scannedConcrete + && scannedHasPublicNoArgCtor); } } @@ -495,18 +595,42 @@ public void visitAttribute(Attribute atrbt) { @Override public void visitInnerClass(String string, String string1, String string2, int i) { + // Only the entry naming this very class, and + // only when it has an outer -- a null one is an + // anonymous or local class, which is neither + // nameable nor a member of anything. + if (string != null && string.equals(scannedName) + && string1 != null) { + scanner.declaresEnclosedBy(string, string1); + // ACC_STATIC 0x0008. A non-static member + // class needs an enclosing instance, so + // `new Outer.Inner()` would not compile. + if ((i & 0x0008) == 0) { + scannedConcrete = false; + } + } } @Override public FieldVisitor visitField(int i, String string, String type, String string2, Object o) { if (type.startsWith("L")) { - scanner.usesClass(type.substring(1, type.length() - 2)); + scanner.usesClass(descriptorToInternalName(type)); } return null; } @Override public MethodVisitor visitMethod(int i, final String methodName, String string1, String string2, String[] strings) { + // ACC_PUBLIC 0x0001. The generated bindings + // call `new Listener()` from another package, + // so anything less than a public no-argument + // constructor produces source that does not + // compile. + if ("".equals(methodName) + && "()V".equals(string1) + && (i & 0x0001) != 0) { + scannedHasPublicNoArgCtor = true; + } return new MethodVisitor(Opcodes.ASM9) { @Override public AnnotationVisitor visitAnnotationDefault() { @@ -572,6 +696,40 @@ public void visitMethodInsn(int opcode, String owner, String name, String descri } } + @Override + public void visitInvokeDynamicInsn(String name, + String descriptor, Handle bootstrap, + Object... args) { + // A method reference -- store::readSamples, + // or a lambda body -- is an invokedynamic + // whose real target sits in the bootstrap + // arguments as a Handle. visitMethodInsn + // never sees it, so every such call was + // invisible to feature detection: obtaining + // the store registered, and the read it was + // obtained for did not. + if (args == null) { + return; + } + for (Object a : args) { + if (!(a instanceof Handle)) { + continue; + } + Handle h = (Handle) a; + if (h.getOwner() == null) { + continue; + } + scanner.usesClass(h.getOwner()); + if (h.getName() != null + && !"".equals(h.getName()) + && !"".equals( + h.getName())) { + scanner.usesClassMethod( + h.getOwner(), h.getName()); + } + } + } + @Override public void visitJumpInsn(int i, Label label) { } @@ -610,7 +768,7 @@ public void visitTryCatchBlock(Label label, Label label1, Label label2, String s @Override public void visitLocalVariable(String string, String classType, String string2, Label label, Label label1, int i) { if (classType.startsWith("L")) { - scanner.usesClass(classType.substring(1, classType.length() - 2)); + scanner.usesClass(descriptorToInternalName(classType)); } } @@ -628,9 +786,6 @@ public void visitEnd() { }; } - @Override - public void visitEnd() { - } }; try { r.accept(classVisitor, ClassReader.EXPAND_FRAMES); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthListenerBindings.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthListenerBindings.java new file mode 100644 index 00000000000..f65b28c89e5 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthListenerBindings.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Generates the source of the health background-listener factory that the + * runtime consults after the OS relaunches an app to deliver health data. + * + *

Why generated rather than reflective. A subscription persists + * the name of its listener class, and after a process relaunch that string + * is all that survives. Resolving it with {@code Class.forName} is the + * obvious approach and is wrong on these targets: a class referenced only + * by a string is invisible to the iOS and JavaScript translators' dead-code + * elimination, so it can be stripped from the very build that needs it, and + * obfuscation renames it so a name persisted by an earlier version stops + * resolving.

+ * + *

So the binding is decided here, at build time. The class scanner + * already records implemented interfaces, so implementations of + * {@code com.codename1.health.HealthBackgroundListener} are detectable; for + * each one this emits a direct {@code new} expression. That is a real + * reference, which shrinking and obfuscation both follow correctly, and it + * needs no reflection at runtime. The name keys stay stable because + * {@link HealthManifestFragments#proguardKeepRules(List)} emits + * {@code -keepnames} for the same classes.

+ * + *

Keep this file in sync with the BuildDaemon copy.

+ */ +final class HealthListenerBindings { + + /** Package and class of the generated factory. */ + static final String PACKAGE = "com.codename1.health.generated"; + static final String CLASS_NAME = "CN1HealthListenerBindings"; + static final String FQCN = PACKAGE + "." + CLASS_NAME; + + private HealthListenerBindings() { + } + + /** + * Returns the Java source of the factory, or {@code null} when the app + * declares no background listeners -- in which case nothing is + * generated and nothing is registered, and the runtime simply defers + * any background delivery rather than losing it. + * + * @param listenerClassNames fully qualified names of classes + * implementing + * {@code HealthBackgroundListener} + */ + static String generate(Map sourceByBinaryName) { + if (sourceByBinaryName == null || sourceByBinaryName.isEmpty()) { + return null; + } + List sorted = + new ArrayList(sourceByBinaryName.keySet()); + Collections.sort(sorted); + + StringBuilder sb = new StringBuilder(); + sb.append("package ").append(PACKAGE).append(";\n\n"); + sb.append("import com.codename1.health.HealthBackgroundListener;\n"); + sb.append("import com.codename1.health") + .append(".HealthBackgroundListenerFactory;\n"); + sb.append("import com.codename1.health.HealthStore;\n\n"); + sb.append("/**\n"); + sb.append(" * Generated by the Codename One build server. Binds the") + .append(" health background\n"); + sb.append(" * listeners this app declares to direct constructor") + .append(" calls, so that no\n"); + sb.append(" * reflection is needed after a background relaunch and") + .append(" the classes\n"); + sb.append(" * survive shrinking and obfuscation. Do not edit.\n"); + sb.append(" */\n"); + sb.append("public final class ").append(CLASS_NAME) + .append(" implements HealthBackgroundListenerFactory {\n\n"); + sb.append(" public static void install() {\n"); + sb.append(" HealthStore.setBackgroundListenerFactory(new ") + .append(CLASS_NAME).append("());\n"); + sb.append(" }\n\n"); + sb.append(" public HealthBackgroundListener create(String") + .append(" className) {\n"); + for (int i = 0; i < sorted.size(); i++) { + String binaryName = sorted.get(i); + // The key is the binary name, because that is what + // Class.getName() returns at runtime and what the subscription + // persisted. The constructor call needs the SOURCE name, where + // a nested class is separated by a dot rather than a dollar -- + // `new Outer$Inner()` is not valid Java. + // + // The two are supplied separately rather than derived from one + // another. Translating every dollar was wrong in both + // directions: a dollar is a legal Java identifier character, so + // a top-level `app.Step$Listener` became an uncompilable + // `new app.Step.Listener()`, and a nested class whose own + // simple name contains one lost it. Only the caller, which has + // the class files' InnerClasses metadata, can tell which + // dollars separate anything. + String sourceName = sourceByBinaryName.get(binaryName); + if (sourceName == null) { + continue; + } + sb.append(" if (\"").append(binaryName) + .append("\".equals(className)) {\n"); + sb.append(" return new ").append(sourceName) + .append("();\n"); + sb.append(" }\n"); + } + sb.append(" return null;\n"); + sb.append(" }\n"); + sb.append("}\n"); + return sb.toString(); + } + + /** + * The statement injected into app startup to install the factory, or + * {@code null} when nothing was generated. + */ + static String installStatement(Map sourceByBinaryName) { + if (sourceByBinaryName == null || sourceByBinaryName.isEmpty()) { + return null; + } + return FQCN + ".install();\n"; + } + + /** The path the generated source is written to, relative to a source root. */ + static String sourcePath() { + return PACKAGE.replace('.', '/') + "/" + CLASS_NAME + ".java"; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthListenerScan.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthListenerScan.java new file mode 100644 index 00000000000..1162a6294d4 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthListenerScan.java @@ -0,0 +1,262 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Works out which classes the generated health listener factory can + * actually construct, from what the class scanner saw. + * + *

It exists because the naive answer is wrong in three ways at once, + * and each was found the hard way. The class that declares + * {@code HealthBackgroundListener} may be an abstract base or an + * intermediate interface the app wrote, so binding the direct implementor + * emits a {@code new Base()} that does not compile while the usable + * subclass is never bound. A listener with no public no-argument + * constructor cannot be built at all. And a public nested class inside a + * package-private outer one cannot be named from the generated package, + * however public it is itself.

+ * + *

Shared rather than copied because it had been copied: the Android + * builder resolved properly and the iOS one bound direct implementors, so + * the same app produced different bindings on the two platforms.

+ * + *

Keep this file in sync with the BuildDaemon copy.

+ */ +class HealthListenerScan { + + /** The interface a background listener implements. */ + static final String LISTENER = + "com/codename1/health/HealthBackgroundListener"; + + /** Types that declare the listener interface themselves. */ + private final Set declarers = new HashSet<>(); + /** Superclass edges, so an inherited declaration is reachable. */ + private final Map supers = new HashMap<>(); + /** Interface edges, including the app's own intermediate interfaces. */ + private final Map> interfaces = new HashMap<>(); + /** Types that are public. */ + private final Set publicTypes = new HashSet<>(); + /** Types that can be built with {@code new X()}. */ + private final Set constructible = new HashSet<>(); + /** What encloses each nested type, from its InnerClasses attribute. */ + private final Map enclosing = new HashMap<>(); + + /** Records an implemented interface, framework or otherwise. */ + void implementsInterface(String cls, String iface) { + if (cls == null || iface == null) { + return; + } + if (LISTENER.equals(iface)) { + declarers.add(cls); + } + // Every edge is kept, not only the framework one: an app may + // declare `interface AppListener extends HealthBackgroundListener` + // and implement that, in which case the concrete class never names + // the framework interface at all. + Set known = interfaces.get(cls); + if (known == null) { + known = new HashSet<>(); + interfaces.put(cls, known); + } + known.add(iface); + } + + /** Records a type, its superclass, and whether it can be built. */ + void declaresType(String cls, String superName, boolean isConstructible) { + if (cls == null) { + return; + } + if (superName != null) { + supers.put(cls, superName); + } + if (isConstructible) { + constructible.add(cls); + } + } + + /** Records that a type is public. */ + void declaresPublicType(String cls) { + if (cls != null) { + publicTypes.add(cls); + } + } + + /** Records what a nested type is a member of. */ + void declaresEnclosedBy(String cls, String outer) { + if (cls != null && outer != null) { + enclosing.put(cls, outer); + } + } + + /** Whether anything at all declared the listener interface. */ + boolean sawAnyListener() { + return !declarers.isEmpty(); + } + + /** + * The bindable listeners, binary name to the name a generated + * {@code new} expression must use. + * + *

Ordered by binary name so the generated source is stable build to + * build.

+ */ + Map resolve() { + List ordered = new ArrayList<>(constructible); + java.util.Collections.sort(ordered); + Map out = new LinkedHashMap<>(); + for (String cls : ordered) { + if (findListenerAncestor(cls) == null) { + continue; + } + if (!enclosingChainIsPublic(cls)) { + continue; + } + out.put(cls.replace('/', '.'), sourceNameOf(cls)); + } + return out; + } + + /** + * Listeners the app declared that nothing can construct, and the + * public-nesting failures, as messages worth putting in the build log. + * + *

Said out loud because the alternative is a listener that never + * fires and a build that looked fine -- which is the failure this + * whole generator exists to avoid.

+ */ + List warnings() { + List out = new ArrayList<>(); + for (String cls : constructible) { + if (findListenerAncestor(cls) != null + && !enclosingChainIsPublic(cls)) { + out.add(cls.replace('/', '.') + " is nested in a non-public" + + " class, so the generated binding could not name" + + " it; make every enclosing class public or move" + + " the listener to its own file"); + } + } + Set covered = new HashSet<>(); + for (String cls : constructible) { + String hit = findListenerAncestor(cls); + if (hit != null && enclosingChainIsPublic(cls)) { + covered.add(hit); + } + } + for (String declarer : declarers) { + if (!covered.contains(declarer)) { + out.add(declarer.replace('/', '.') + " implements" + + " HealthBackgroundListener but cannot be" + + " constructed by the generated bindings. A" + + " background listener must be a top-level or" + + " static nested class with a public no-argument" + + " constructor; an abstract base needs a concrete" + + " subclass, which this app does not appear to" + + " have."); + } + } + return out; + } + + /** + * The type through which {@code cls} is a listener, or null when it is + * not one. + * + *

Both edges matter: the interface can be inherited through an + * abstract base, and it can equally be reached through an intermediate + * interface the app declared itself.

+ */ + private String findListenerAncestor(String cls) { + List queue = new ArrayList<>(); + Set seen = new HashSet<>(); + queue.add(cls); + while (!queue.isEmpty()) { + String current = queue.remove(0); + if (current == null || !seen.add(current) || seen.size() > 512) { + continue; + } + if (declarers.contains(current)) { + return current; + } + String parent = supers.get(current); + if (parent != null) { + queue.add(parent); + } + Set known = interfaces.get(current); + if (known != null) { + queue.addAll(known); + } + } + return null; + } + + /** + * Whether every class enclosing {@code cls} is public. + * + *

A class the generated bindings construct has to be nameable from + * the generated package, and that means the whole dotted path, not + * only the last segment.

+ */ + private boolean enclosingChainIsPublic(String cls) { + // Walked through the InnerClasses metadata, not by splitting the + // binary name on '$': a dollar is a legal Java identifier + // character, so a top-level `app.Step$Listener` is a class + // somebody may really have written. + Set seen = new HashSet<>(); + String outer = enclosing.get(cls); + while (outer != null && seen.add(outer)) { + if (!publicTypes.contains(outer)) { + return false; + } + outer = enclosing.get(outer); + } + return true; + } + + /** + * The name a generated constructor call has to use, built from the + * InnerClasses metadata for the same reason. + */ + private String sourceNameOf(String cls) { + Set seen = new HashSet<>(); + String simpleNames = ""; + String current = cls; + while (seen.add(current)) { + String outer = enclosing.get(current); + if (outer == null || !current.startsWith(outer + "$")) { + return (current + simpleNames).replace('/', '.'); + } + simpleNames = "." + current.substring(outer.length() + 1) + + simpleNames; + current = outer; + } + return cls.replace('/', '.'); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthManifestFragments.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthManifestFragments.java new file mode 100644 index 00000000000..8d1aa472543 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/HealthManifestFragments.java @@ -0,0 +1,587 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +/** + * Builds the AndroidManifest fragments injected when an app uses + * {@code com.codename1.health} on Android -- Health Connect permissions, + * the provider {@code } entry, and the permissions-rationale + * activity that Health Connect refuses to show its consent dialog without. + * + *

Extracted into a pure static helper so the per-type permission mapping + * is unit-testable and so the BuildDaemon copy stays trivially diffable -- + * keep this file in sync with + * {@code com.codename1.build.daemon.HealthManifestFragments}. + * {@link #FRAGMENT_VERSION} is logged during the build so a stale daemon is + * visible in the log rather than silently producing an APK with no health + * permissions.

+ * + *

Why the permissions come from build hints rather than the bytecode + * scanner. Health Connect permissions are per data type, and the type + * an app uses is expressed as a reference to a {@code HealthDataType} + * constant -- that is, a field read. The scanner's {@code visitFieldInsn} + * is an empty override, so field reads are invisible to it; only type and + * method references are recorded. The set therefore cannot be inferred and + * is declared through {@code android.health.read} and + * {@code android.health.write}. That also matches Google Play policy, which + * requires declaring exactly the types you use rather than requesting a + * superset.

+ * + *

Duplicate suppression uses quote-delimited tokens, and the + * hazard is worse here than for Bluetooth: {@code READ_HEART_RATE} is a + * strict prefix of {@code READ_HEART_RATE_VARIABILITY}, + * {@code READ_EXERCISE} of {@code READ_EXERCISE_ROUTE}, and + * {@code READ_HEALTH_DATA} of {@code READ_HEALTH_DATA_IN_BACKGROUND}. A + * plain {@code contains()} would silently drop permissions an app + * declared.

+ */ +final class HealthManifestFragments { + + /** + * Bumped whenever the emitted fragments change. Logged by the Android + * builder so a BuildDaemon running an older copy of this class is + * apparent from the build log. + */ + static final String FRAGMENT_VERSION = "health-2"; + + /** The Health Connect provider package. */ + static final String PROVIDER_PACKAGE = + "com.google.android.apps.healthdata"; + + /** Fully qualified name of the rationale activity in the Android port. */ + static final String RATIONALE_ACTIVITY = + "com.codename1.health.HealthPermissionsRationaleActivity"; + + /** + * Portable data-type token to Health Connect permission suffix. + * + *

Keys are the {@code HealthDataType.getId()} values from the core + * framework, which are also the tokens developers write in the + * {@code android.health.read} / {@code android.health.write} build + * hints. Types with no Health Connect equivalent are deliberately + * absent, so declaring one is an error naming the unknown token rather + * than a silently missing permission.

+ * + *

Keep in sync with {@code com.codename1.health.HealthDataType}. + * The builder cannot depend on the core jar, so this table is a + * duplicate; {@code HealthManifestFragmentsTest} pins the token set + * against a golden list so a core-side addition that is not mirrored + * here fails CI.

+ */ + private static final Map PERMISSION_SUFFIX = + new LinkedHashMap(); + + static { + PERMISSION_SUFFIX.put("steps", "STEPS"); + PERMISSION_SUFFIX.put("distance_walking_running", "DISTANCE"); + PERMISSION_SUFFIX.put("distance_cycling", "DISTANCE"); + PERMISSION_SUFFIX.put("distance_swimming", "DISTANCE"); + PERMISSION_SUFFIX.put("flights_climbed", "FLOORS_CLIMBED"); + PERMISSION_SUFFIX.put("elevation_gained", "ELEVATION_GAINED"); + PERMISSION_SUFFIX.put("active_energy", "ACTIVE_CALORIES_BURNED"); + PERMISSION_SUFFIX.put("basal_energy", "BASAL_METABOLIC_RATE"); + PERMISSION_SUFFIX.put("exercise_time", "EXERCISE"); + PERMISSION_SUFFIX.put("wheelchair_pushes", "WHEELCHAIR_PUSHES"); + PERMISSION_SUFFIX.put("heart_rate", "HEART_RATE"); + PERMISSION_SUFFIX.put("resting_heart_rate", "RESTING_HEART_RATE"); + PERMISSION_SUFFIX.put("walking_heart_rate_average", "HEART_RATE"); + PERMISSION_SUFFIX.put("heart_rate_variability_sdnn", + "HEART_RATE_VARIABILITY"); + PERMISSION_SUFFIX.put("oxygen_saturation", "OXYGEN_SATURATION"); + PERMISSION_SUFFIX.put("respiratory_rate", "RESPIRATORY_RATE"); + PERMISSION_SUFFIX.put("body_temperature", "BODY_TEMPERATURE"); + PERMISSION_SUFFIX.put("basal_body_temperature", + "BASAL_BODY_TEMPERATURE"); + PERMISSION_SUFFIX.put("vo2_max", "VO2_MAX"); + PERMISSION_SUFFIX.put("blood_pressure", "BLOOD_PRESSURE"); + PERMISSION_SUFFIX.put("blood_glucose", "BLOOD_GLUCOSE"); + PERMISSION_SUFFIX.put("body_mass", "WEIGHT"); + PERMISSION_SUFFIX.put("lean_body_mass", "LEAN_BODY_MASS"); + PERMISSION_SUFFIX.put("bone_mass", "BONE_MASS"); + PERMISSION_SUFFIX.put("body_fat_percentage", "BODY_FAT"); + // body_mass_index is deliberately absent. Health Connect has no BMI + // permission or record -- BMI is derived from weight and height -- + // and the nearest-looking suffix, BODY_WATER_MASS, is an unrelated + // datum. Declaring it asked users for sensitive body-water access, + // granted neither input BMI actually needs, and misstated the Play + // health-data declaration. Leaving it out makes the builder reject + // the token with the list of ones that work, which is the honest + // answer: read weight and height and compute it. + PERMISSION_SUFFIX.put("height", "HEIGHT"); + PERMISSION_SUFFIX.put("waist_circumference", "BODY_MEASUREMENTS"); + PERMISSION_SUFFIX.put("power", "POWER"); + PERMISSION_SUFFIX.put("speed", "SPEED"); + PERMISSION_SUFFIX.put("cycling_cadence", "CYCLING_PEDALING_CADENCE"); + PERMISSION_SUFFIX.put("running_cadence", "STEPS_CADENCE"); + PERMISSION_SUFFIX.put("hydration", "HYDRATION"); + PERMISSION_SUFFIX.put("dietary_energy", "NUTRITION"); + PERMISSION_SUFFIX.put("nutrition", "NUTRITION"); + PERMISSION_SUFFIX.put("sleep", "SLEEP"); + PERMISSION_SUFFIX.put("workout", "EXERCISE"); + PERMISSION_SUFFIX.put("mindful_session", "MINDFULNESS"); + PERMISSION_SUFFIX.put("menstruation_flow", "MENSTRUATION"); + PERMISSION_SUFFIX.put("intermenstrual_bleeding", + "INTERMENSTRUAL_BLEEDING"); + } + + private HealthManifestFragments() { + } + + /** Every data-type token this builder understands. */ + static Set knownTokens() { + return Collections.unmodifiableSet( + new TreeSet(PERMISSION_SUFFIX.keySet())); + } + + /** + * Splits a comma-separated build-hint value into tokens, trimming and + * dropping blanks. Tolerates whitespace and trailing commas, since the + * value is hand-written in a properties file. + */ + /// Tokens with a Health Connect permission that no Android operation + /// can service. + /// + /// The authority is `readableRecordClassFor`, not `recordClassFor`, + /// because the portable layer gates *everything* on + /// `HealthStore.isTypeSupported`, which on Android answers from the + /// same narrower set. A read, a delete and a change subscription all + /// pass through it, so a type outside that set cannot be used for any + /// of them -- `subscribe()` throws in `register()` before the bridge + /// is ever asked for a token. + /// + /// Still one list rather than one per direction: within that set, a + /// permission covers more than one operation -- write authorises + /// inserts and deletes, read covers reads and subscriptions -- and a + /// build cannot know which an app will use. Splitting it refused + /// `write=power` from an app that only deletes power records, which + /// works. + /// + /// Kept in step with the Kotlin by `HealthBridgeTokenTableTest`, which + /// parses it and fails the build when the two drift. + private static final Set NO_RECORD_CLASS = + new HashSet(); + + static { + NO_RECORD_CLASS.add("basal_energy"); + NO_RECORD_CLASS.add("blood_pressure"); + NO_RECORD_CLASS.add("dietary_energy"); + NO_RECORD_CLASS.add("distance_cycling"); + NO_RECORD_CLASS.add("distance_swimming"); + NO_RECORD_CLASS.add("exercise_time"); + NO_RECORD_CLASS.add("heart_rate_variability_sdnn"); + NO_RECORD_CLASS.add("intermenstrual_bleeding"); + NO_RECORD_CLASS.add("menstruation_flow"); + NO_RECORD_CLASS.add("mindful_session"); + NO_RECORD_CLASS.add("nutrition"); + NO_RECORD_CLASS.add("sleep"); + NO_RECORD_CLASS.add("waist_circumference"); + NO_RECORD_CLASS.add("walking_heart_rate_average"); + NO_RECORD_CLASS.add("workout"); + } + + /// The declared tokens no Android health operation can service. + static List unsupportedTokens(List tokens) { + List out = new ArrayList(); + for (String t : tokens) { + if (NO_RECORD_CLASS.contains(t) && !out.contains(t)) { + out.add(t); + } + } + return out; + } + + /// Whether `android.topDependency` declares a `kotlin-gradle-plugin` + /// at all, however it spells the version. + /// + /// This is the condition the Gradle generator actually branches on -- + /// a bare substring match -- so it is what decides whether the + /// generated 1.9.22 plugin line is suppressed. A declaration whose + /// version is a Gradle variable (`kotlin-gradle-plugin:$kotlin_version`) + /// suppresses it just the same while + /// [#declaredKotlinPluginVersion(String)] can read nothing, so the two + /// questions have to be asked separately. + static boolean declaresKotlinPlugin(String topDependency) { + return topDependency != null + && topDependency.indexOf(KOTLIN_PLUGIN) >= 0; + } + + /// The `kotlin-gradle-plugin` version an app declares for itself in + /// `android.topDependency`, or null when it declares none. + /// + /// A declaration there wins outright: the Gradle generator sees a + /// custom plugin line and skips adding its own, so raising + /// `requireKotlinStdlib` to the Health Connect floor changed nothing + /// and the bridge was compiled by whatever compiler the app pinned. + /// The build has to look at what was actually declared. + /// + /// Only the numeric prefix is returned: `2.0.0-Beta1` comes back as + /// `2.0.0`. The caller compares this segment by segment with + /// `Integer.parseInt`, so a qualifier left on the end would throw and + /// fail the build on a version that is perfectly acceptable. Dropping + /// it can only round a prerelease up to its release, which is the + /// forgiving direction for a floor check. + /// The leading numeric part of a version, or null when there is none. + /// + /// `1.9.22-RC2` comes back as `1.9.22`. Callers compare segment by + /// segment with `Integer.parseInt`, which throws on a qualifier and + /// fails the build over a version that is perfectly acceptable -- + /// dropping it can only round a prerelease up to its release, which + /// is the forgiving direction for a floor check. + static String numericVersionPrefix(String version) { + if (version == null) { + return null; + } + int to = 0; + while (to < version.length() + && "0123456789.".indexOf(version.charAt(to)) >= 0) { + to++; + } + while (to > 0 && version.charAt(to - 1) == '.') { + to--; + } + return to > 0 ? version.substring(0, to) : null; + } + + static String declaredKotlinPluginVersion(String topDependency) { + if (topDependency == null) { + return null; + } + int at = topDependency.indexOf(KOTLIN_PLUGIN); + if (at < 0) { + return null; + } + int from = at + KOTLIN_PLUGIN.length(); + int to = from; + while (to < topDependency.length() + && "0123456789.".indexOf(topDependency.charAt(to)) >= 0) { + to++; + } + while (to > from && topDependency.charAt(to - 1) == '.') { + to--; + } + return to > from ? topDependency.substring(from, to) : null; + } + + private static final String KOTLIN_PLUGIN = + "org.jetbrains.kotlin:kotlin-gradle-plugin:"; + + /// Whether a `HealthStore` method reads data. + /// + /// Health Connect permissions are directional, so the build has to know + /// which way an app actually uses the store. Collapsing both directions + /// into one flag let an app that only reads satisfy the check with an + /// `android.health.write` declaration alone, and ship a manifest whose + /// every read then failed at runtime. + /// + /// Lives here rather than in the builder so the two builder twins share + /// one classification instead of two that can drift apart. + static boolean isReadCall(String method) { + return method != null + && (method.startsWith("read") + || method.startsWith("aggregate") + || method.startsWith("subscribe") + || method.startsWith("hasAnyData") + || method.startsWith("drainChanges") + || isBothDirections(method)); + } + + /// Calls that could be asking for either direction, so they count as + /// both. + /// + /// `requestAuthorization` takes a list of `HealthAccess` values, and a + /// list built at runtime is invisible to bytecode scanning -- the iOS + /// builder already treats this call as needing both purpose strings + /// for exactly that reason. Classifying it as neither let an app that + /// asks for write access while declaring only `android.health.read` + /// pass the build and ship a manifest without the permission it + /// requested. + private static boolean isBothDirections(String method) { + return method.startsWith("requestAuthorization"); + } + + /// Whether a `HealthStore` method changes stored data. A delete counts: + /// Health Connect authorizes it with the write permission. + static boolean isWriteCall(String method) { + return method != null + && (method.startsWith("write") || method.startsWith("delete") + || isBothDirections(method)); + } + + static List parseTypeList(String hintValue) { + List out = new ArrayList(); + if (hintValue == null) { + return out; + } + String[] parts = hintValue.split(","); + for (int i = 0; i < parts.length; i++) { + String t = parts[i].trim(); + if (t.length() > 0 && !out.contains(t)) { + out.add(t); + } + } + return out; + } + + /** + * Returns the tokens in {@code tokens} that this builder does not + * recognise, so the caller can fail with a message naming them. + */ + static List unknownTokens(List tokens) { + List out = new ArrayList(); + for (int i = 0; i < tokens.size(); i++) { + if (!PERMISSION_SUFFIX.containsKey(tokens.get(i))) { + out.add(tokens.get(i)); + } + } + return out; + } + + /** + * Name of the generated string resource carrying the privacy policy + * URL. + * + *

Shared with {@code HealthPermissionsRationaleActivity}, which + * resolves it by name at runtime. A rename on one side alone leaves the + * rationale screen blank, so the name lives here rather than being + * spelled out in two places.

+ */ + static final String POLICY_URL_RESOURCE = "cn1_health_privacy_policy"; + + /** + * The Health Connect permission suffix for one token, or {@code null} + * when the token is unknown. Exposed so the Kotlin bridge's own copy of + * this table can be pinned against it -- see + * {@code HealthBridgeTokenTableTest}. + */ + static String permissionSuffix(String token) { + return PERMISSION_SUFFIX.get(token); + } + + /** + * The full Health Connect permission name for one token, or + * {@code null} when the token is unknown. + */ + static String permissionFor(String token, boolean write) { + String suffix = PERMISSION_SUFFIX.get(token); + if (suffix == null) { + return null; + } + return "android.permission.health." + (write ? "WRITE_" : "READ_") + + suffix; + } + + /** + * Returns {@code xPermissions} with the health permissions appended. + * + *

Several tokens map to one Health Connect permission -- the three + * distance types all map to {@code READ_DISTANCE} -- so the emitted set + * is deduplicated. Permissions the developer already declared through + * {@code android.xpermissions} are left alone.

+ * + * @param xPermissions current accumulated manifest fragment + * @param readTokens {@code android.health.read} tokens + * @param writeTokens {@code android.health.write} tokens + * @param backgroundRead {@code android.health.background} hint + * @param history {@code android.health.history} hint + * @param targetSdkVersion the app's target SDK + */ + static String injectPermissions(String xPermissions, + List readTokens, List writeTokens, + boolean backgroundRead, boolean history, + int targetSdkVersion) { + StringBuilder sb = new StringBuilder( + xPermissions == null ? "" : xPermissions); + Set emitted = new TreeSet(); + for (int i = 0; i < readTokens.size(); i++) { + addPermission(sb, emitted, permissionFor(readTokens.get(i), + false)); + } + for (int i = 0; i < writeTokens.size(); i++) { + addPermission(sb, emitted, permissionFor(writeTokens.get(i), + true)); + } + if (backgroundRead) { + addPermission(sb, emitted, + "android.permission.health.READ_HEALTH_DATA_IN_BACKGROUND"); + } + if (history) { + addPermission(sb, emitted, + "android.permission.health.READ_HEALTH_DATA_HISTORY"); + } + // Nothing is emitted for workouts. + // + // ACTIVITY_RECOGNITION used to be, on the stated grounds that a + // recorded workout reads step and exercise detection. It does + // not: the only session this release implements rolls up samples + // the app hands it through addSamples(), and both + // sensor-collection capability queries answer false everywhere. + // So a workout-only app declared a sensitive permission nothing + // in it could exercise, and invited the Play disclosure that + // comes with it. + // + // The foreground-service permissions are omitted for the same + // kind of reason. They only matter alongside a foreground + // service, and this release ships none -- the manifest used to + // declare com.codename1.health.HealthWorkoutService, a class that + // does not exist, so the promised keepalive was never there. + return sb.toString(); + } + + private static void addPermission(StringBuilder sb, Set emitted, + String name) { + if (name == null || emitted.contains(name)) { + return; + } + emitted.add(name); + // Quote-delimited so that READ_HEART_RATE does not suppress + // READ_HEART_RATE_VARIABILITY -- see the class documentation. + if (sb.indexOf("\"" + name + "\"") >= 0) { + return; + } + sb.append(" \n"); + } + + /** + * Returns {@code xQueries} with the Health Connect provider declared. + * + *

Without this the provider is invisible to package visibility on + * API 30+, and every Health Connect call fails as though the app were + * not installed. The Android builder only emits {@code } at + * {@code targetSdkVersion >= 30}, which is why it asserts that + * separately.

+ */ + static String injectQueries(String xQueries) { + String entry = " \n"; + if (xQueries != null && xQueries.contains(PROVIDER_PACKAGE)) { + return xQueries; + } + return (xQueries == null ? "" : xQueries) + entry; + } + + /** + * Returns {@code xApplication} with the health application entries + * appended: the permissions-rationale activity, the API 34+ + * view-permission-usage alias, and -- when workouts are used -- the + * foreground service that keeps a recording session alive. + * + *

The rationale activity is not optional. Health Connect will not + * present its consent dialog to an app that does not declare one, so + * omitting it produces a permission request that silently never + * appears.

+ */ + static String injectApplicationEntries(String xApplication, + int targetSdkVersion) { + StringBuilder sb = new StringBuilder( + xApplication == null ? "" : xApplication); + if (sb.indexOf(RATIONALE_ACTIVITY) < 0) { + sb.append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n"); + if (targetSdkVersion >= 34) { + // API 34 routes the rationale through the platform + // permission-usage intent instead of the AndroidX action. + sb.append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n"); + } + } + // No is emitted for workouts. The declaration used to + // name com.codename1.health.HealthWorkoutService, which does not + // exist and was never started, so it bought nothing while implying + // a recorded workout survives backgrounding. See the developer + // guide: on Android a recorded workout lives as long as the process + // does, and the app is responsible for keeping itself alive. + return sb.toString(); + } + + /** + * ProGuard keep rules for the health classes that are reached by name + * or by the platform rather than by ordinary Java references. + * + *

The generated background-listener factory constructs listeners + * with a direct {@code new}, so the listener classes themselves survive + * shrinking; the keep rules exist so that their names stay + * stable under obfuscation, since a subscription persists the name it + * was registered under and must still match after an app update.

+ */ + static String proguardKeepRules(List listenerClassNames) { + StringBuilder sb = new StringBuilder(); + sb.append("-keep class ").append(RATIONALE_ACTIVITY) + .append(" { *; }\n"); + sb.append("-keep class com.codename1.health.CN1HealthConnectBridge") + .append(" { *; }\n"); + sb.append("-keep interface ") + .append("com.codename1.impl.android.HealthConnectDelegate") + .append(" { *; }\n"); + List sorted = new ArrayList(listenerClassNames); + Collections.sort(sorted); + for (int i = 0; i < sorted.size(); i++) { + sb.append("-keepnames class ").append(sorted.get(i)) + .append("\n"); + } + return sb.toString(); + } + + /** Convenience for callers holding an array. */ + static List asList(String... values) { + return new ArrayList(Arrays.asList(values)); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index c74849d71de..540c6fdb3a9 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -115,6 +115,28 @@ public class IPhoneBuilder extends Executor { private boolean usesNfc; private boolean usesBluetooth; private boolean usesBluetoothPeripheral; + + // See AndroidGradleBuilder for why the store flag is separate from the + // umbrella one: com.codename1.health.sensors is pure BLE and must not + // pull in HealthKit or its entitlement. + private boolean usesHealth; + private boolean usesHealthStore; + /// Treats a blank hint as missing. + private static String trimToNull(String v) { + if (v == null) { + return null; + } + String t = v.trim(); + return t.length() == 0 ? null : t; + } + + /// What the scan saw about health background listeners, and which of + /// them the generated factory can actually construct. + private final HealthListenerScan healthScan = new HealthListenerScan(); + + private boolean usesHealthRead; + private boolean usesHealthWrite; + private boolean usesHealthWorkout; private boolean usesCn1Camera; private boolean usesCn1Ar; // Set when the app references com.codename1.car.* (Apple CarPlay support). Gates the @@ -798,6 +820,33 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException try { scanClassesForPermissions(classesDir, new Executor.ClassScanner() { + // iOS has no OS-relaunch delivery, but it does have cold + // launches, and that is enough to need these. A restored + // subscription carries only the listener's class *name*; + // without generated bindings the runtime cannot turn that + // back into an instance, so even a manual drainChanges() + // after a restart delivered nothing. + @Override + public void implementsInterface(String cls, String iface) { + healthScan.implementsInterface(cls, iface); + } + + @Override + public void declaresEnclosedBy(String cls, String outer) { + healthScan.declaresEnclosedBy(cls, outer); + } + + @Override + public void declaresPublicType(String cls) { + healthScan.declaresPublicType(cls); + } + + @Override + public void declaresType(String cls, String superName, + boolean isConcrete) { + healthScan.declaresType(cls, superName, isConcrete); + } + @Override public void usesClass(String cls) { if (cls == null) return; @@ -860,6 +909,47 @@ public void usesClass(String cls) { usesBluetoothPeripheral = true; } } + // First-class health (com.codename1.health.*). The + // store flag is what gates HealthKit, its entitlement + // and the privacy-string requirement; the sensors + // subpackage is ordinary BLE and must not trigger any + // of that. + if (cls.indexOf("com/codename1/health/") == 0) { + usesHealth = true; + // The facade itself is not evidence of the store: + // the documented sensor-only flow is + // Health.getInstance().getSensors(), which the + // scanner reports with com/codename1/health/Health + // as the owner. Treating that as store usage failed + // the build for BLE-only apps over Health Connect + // hints they have no use for. The usesClassMethod + // hook below decides for the facade. + // The value types are exempt for the same reason the + // sensors package is: a sensor callback is handed a + // HealthSample and reading a number off it names + // QuantitySample, so counting those as store use linked + // HealthKit into a BLE-only binary and put it through + // health processing it never asked for. + if (cls.indexOf("com/codename1/health/sensors/") != 0 + && !"com/codename1/health/Health".equals(cls) + && !isSharedHealthModel(cls)) { + usesHealthStore = true; + } + if (cls.indexOf("com/codename1/health/workout/") == 0) { + usesHealthWorkout = true; + // The update string, matching the getWorkouts() + // hook. usesHealthWorkout alone drove nothing, + // and a workout-only app shipped without any + // purpose string and was refused when it asked + // HealthKit for access. + // + // Not the share string: naming + // WorkoutConfiguration says no more about + // reading than calling getWorkouts() does, and + // nothing in the package reads. + usesHealthWrite = true; + } + } // Low-level camera API (com.codename1.camera.*). Gated on // actual usage -- NOT on the camera privacy description -- // so the old modal Capture API (which only sets @@ -927,6 +1017,104 @@ public void usesClass(String cls) { @Override public void usesClassMethod(String cls, String method) { + // Health.getStore()/getWorkouts() mean a real platform + // store; Health.getSensors() means BLE only. The class + // reference alone cannot tell them apart, so the facade + // is decided here. + // Sensor write-through is HealthKit use. An app enables + // it with SensorSessionOptions.setWriteToStore(true) and + // need never name HealthStore, so the sensors-package + // exemption -- there so a BLE-only app is not linked + // against HealthKit -- hid the one call in that package + // that genuinely needs it, and the build omitted the + // framework, the entitlement and the purpose-string check. + if ("com/codename1/health/sensors/SensorSessionOptions" + .equals(cls) + && method.startsWith("setWriteToStore")) { + usesHealth = true; + usesHealthStore = true; + usesHealthWrite = true; + } + if ("com/codename1/health/Health".equals(cls)) { + usesHealth = true; + if (method.startsWith("getStore") + || method.startsWith("getWorkouts") + || method.startsWith("openHealthSettings") + || method.startsWith("openProviderSetup") + || method.startsWith("getAvailability")) { + usesHealthStore = true; + } + if (method.startsWith("getWorkouts")) { + usesHealthWorkout = true; + // The update string, as the Android hook demands the + // write direction. The class-reference branch sets this + // when a workout type is named, but an app that calls + // getWorkouts() and passes the facade around as Object + // never names one -- and was entitled for HealthKit with + // no string at all, so its first authorization request + // was refused. + // + // Not the share string: nothing in the workout package + // reads. The rollup is computed from the samples the app + // fed in, and end() writes them. Asking for a read + // purpose string an app cannot justify is the kind of + // over-declaration App Review pushes back on, and the + // build refused to proceed without it. + usesHealthWrite = true; + } + if (method.startsWith("getSensors")) { + // Same reason as Android: the sensor layer is + // built on the public bluetooth API, so an app + // that only calls getSensors() would otherwise + // ship without CoreBluetooth linked or + // CN1_INCLUDE_BLUETOOTH set. + usesBluetooth = true; + } + } + if (cls.indexOf("com/codename1/health/HealthStore") == 0) { + // Writing needs a separate privacy string from + // reading, and observers need a separate + // entitlement, so both are detected rather than + // assumed from mere health usage. + if (method.startsWith("write") + || method.startsWith("delete")) { + usesHealthWrite = true; + } + // Reading needs NSHealthShareUsageDescription and + // writing needs NSHealthUpdateUsageDescription. + // They are not interchangeable: iOS kills the app + // when it reads without the share string, so a + // read-only app that declared only the update + // string must not be waved through. + if (method.startsWith("read") + || method.startsWith("aggregate") + || method.startsWith("subscribe") + || method.startsWith("drainChanges") + || method.startsWith("hasAnyData") + || method.startsWith("requestAuthorization")) { + usesHealthRead = true; + } + // Deliberately not set from subscribe() or + // drainChanges(). Neither registers an + // HKObserverQuery -- IOSHealthStore.doDrainChanges + // polls with sample queries -- so the + // background-delivery entitlement they used to + // trigger bought nothing, while demanding a + // provisioning-profile capability that a + // polling-only app has no reason to hold. Getting + // that wrong fails codesign with an opaque + // message. The explicit build hint still turns it + // on, for an app that knows it wants it. + // requestAuthorization takes a HealthAccess list whose + // contents the scanner cannot see, and asking for any + // write access needs the update string. Requiring both + // descriptions is the conservative reading; the + // alternative is a build that passes and an app that + // iOS kills the moment it requests share access. + if (method.startsWith("requestAuthorization")) { + usesHealthWrite = true; + } + } if (cls.indexOf("com/codename1/calendar/LocalCalendarSource") == 0 || (cls.indexOf("com/codename1/calendar/CalendarManager") == 0 && (method.indexOf("getLocalSource") >= 0 @@ -1493,6 +1681,14 @@ public void usesClassMethod(String cls, String method) { // its installGlobal() call into the Stub right before the first // init(Object) so theme.getImage("foo.svg") returns the transcoded // SVG immediately. Skipped silently for apps that have no SVGs. + String healthBindingsInstall = ""; + String healthInstallStatement = HealthListenerBindings + .installStatement(healthScan.resolve()); + if (healthInstallStatement != null) { + healthBindingsInstall = " " + + healthInstallStatement; + } + String svgRegistryInstall = ""; File svgRegistryClassFile = new File(classesDir, "com/codename1/generated/svg/SVGRegistry.class"); @@ -1664,6 +1860,7 @@ public void usesClassMethod(String cls, String method) { + " initialized = true;\n" + firebaseRegisterInstall + svgRegistryInstall + + healthBindingsInstall + " i.init(this);\n" + createStartInvocation(request, "i") + " } else {\n" @@ -1941,6 +2138,30 @@ public void usesClassMethod(String cls, String method) { } } } + // Health background-listener bindings, written where the stub + // sources are compiled from so javac picks them up and ParparVM + // translates the result. Generated rather than resolved + // reflectively: a direct constructor call is a reference the + // translator follows, and a name passed to Class.forName is not. + for (String warning : healthScan.warnings()) { + log("WARNING: " + warning); + } + String healthBindingsSource = + HealthListenerBindings.generate(healthScan.resolve()); + if (healthBindingsSource != null) { + File healthBindingsFile = new File(stubSource, + HealthListenerBindings.sourcePath()); + healthBindingsFile.getParentFile().mkdirs(); + try (OutputStream bindings = + new FileOutputStream(healthBindingsFile)) { + bindings.write(healthBindingsSource.getBytes("UTF-8")); + } catch (Exception ex) { + throw new BuildException( + "Failed to write the health listener bindings", ex); + } + log("Generated health background-listener bindings for " + + healthScan.resolve().keySet()); + } String javacPath = System.getProperty("java.home") + "/../bin/javac"; if (!new File(javacPath).exists()) { javacPath = System.getProperty("java.home") + "/bin/javac"; @@ -2446,6 +2667,197 @@ public void usesClassMethod(String cls, String method) { } } + // First-class health (com.codename1.health.*). Gated on + // usesHealthStore, NOT usesHealth: an app that only streams a + // heart-rate strap through com.codename1.health.sensors is + // doing ordinary BLE and must not acquire HealthKit, its + // entitlement, or an App Store health-data review. + if (usesHealthStore) { + // Trimmed, and blank counts as absent. A hint present + // but empty produced an empty purpose string, which is + // exactly what App Review rejects and what iOS enforces at + // runtime -- the validation existed to prevent that. + String share = trimToNull(request.getArg( + "ios.NSHealthShareUsageDescription", null)); + String update = trimToNull(request.getArg( + "ios.NSHealthUpdateUsageDescription", null)); + if (share != null) { + request.putArgument("ios.NSHealthShareUsageDescription", + share); + } + if (update != null) { + request.putArgument("ios.NSHealthUpdateUsageDescription", + update); + } + + // Deliberately a hard failure rather than a defaulted + // placeholder. Apple reviews health purpose strings against + // what the app actually does, so a generic string is + // precisely what gets the app rejected -- injecting one + // would also be a privacy claim made in the developer's + // name. Compare the camera/bluetooth entries in + // AiDependencyTable, which do default their strings. + // Availability alone needs no purpose string: checking + // whether HKHealthStore exists reads nothing, so there is + // no truthful text to demand. HealthKit still links. + if (!usesHealthRead && !usesHealthWrite) { + // nothing to validate + } else if (share == null && update == null) { + throw new BuildException( + "This app uses com.codename1.health but declares no " + + "HealthKit privacy strings.\n" + + " Add ios.NSHealthShareUsageDescription=\n" + + " and/or ios.NSHealthUpdateUsageDescription=\n" + + "to codenameone_settings.properties. Codename One " + + "does not inject a placeholder: Apple reviews this " + + "text against your app's behaviour and rejects " + + "generic copy."); + } + if (usesHealthRead && share == null) { + throw new BuildException( + "This app reads health data (com.codename1.health " + + "read/aggregate/subscribe) but declares no " + + "ios.NSHealthShareUsageDescription build hint. " + + "iOS terminates the app when it reads HealthKit " + + "without it; the update string does not cover " + + "reads."); + } + if (usesHealthWrite && update == null) { + throw new BuildException( + "This app writes health data (com.codename1.health " + + "write/delete) but declares no " + + "ios.NSHealthUpdateUsageDescription build hint. " + + "HealthKit write authorization cannot be requested " + + "without it."); + } + + String hk = "HealthKit.framework"; + if (addLibs == null || addLibs.length() == 0) { + addLibs = hk; + } else if (!addLibs.toLowerCase().contains("healthkit")) { + addLibs = addLibs + ";" + hk; + } + try { + replaceInFile(new File(buildinRes, + "CodenameOne_GLViewController.h"), + "//#define CN1_INCLUDE_HEALTH", + "#define CN1_INCLUDE_HEALTH"); + } catch (Exception ex) { + throw new BuildException( + "Failed to enable CN1_INCLUDE_HEALTH", ex); + } + + // HealthKit is entitlement-gated, unlike CoreBluetooth -- + // this has no Bluetooth precedent. The profile must also + // carry the capability; without it the failure surfaces + // much later as an opaque codesign error. + // + // Only for an app that touches health *data*. An + // availability-only app is deliberately allowed to build + // without purpose strings, because it accesses nothing -- + // so entitling it demanded a provisioning profile with a + // capability its App ID may never have enabled, and an + // otherwise harmless getAvailability() call failed + // codesigning. + // The explicit sub-capability hints count as usage too. + // Each block below emits its sub-entitlement from the hint + // alone, and a HealthKit sub-capability without + // com.apple.developer.healthkit beneath it is not a + // capability Apple can enable -- so an availability-only + // app asking for background delivery produced an + // entitlement set that could not be signed against a + // profile and would not have worked if it had been. + // The long-form keys count too. A sub-entitlement can be + // asked for either through the ios.health.* alias or + // written out in the ios.entitlements.* namespace, and + // the generic renderer emits whatever is in that + // namespace -- so an availability-only app that spelled + // it out in full got its sub-capability emitted while + // this gate, reading only the aliases, left + // com.apple.developer.healthkit off. That is the same + // unsignable entitlement set the aliases were fixed to + // avoid, reachable by the other spelling. + boolean entitleHealthKit = usesHealthRead || usesHealthWrite + || usesHealthWorkout + || "true".equalsIgnoreCase(request.getArg( + "ios.health.backgroundDelivery", "false")) + || "true".equalsIgnoreCase(request.getArg( + "ios.health.recalibrateEstimates", "false")) + || "true".equalsIgnoreCase(request.getArg( + "ios.entitlements.com.apple.developer" + + ".healthkit.background-delivery", + "false")) + || "true".equalsIgnoreCase(request.getArg( + "ios.entitlements.com.apple.developer" + + ".healthkit.recalibrate-estimates", + "false")); + String healthKitEntitlement = request.getArg( + "ios.entitlements.com.apple.developer.healthkit", + null); + if (entitleHealthKit && healthKitEntitlement != null + && !"true".equalsIgnoreCase(healthKitEntitlement)) { + // Refused rather than overridden. The app calls the + // health store and the hint says not to entitle it, + // and neither reading wins on its own: silently + // forcing the entitlement on contradicts an explicit + // instruction, while honouring it signs the app with + // and every authorization request fails at + // runtime with the build having looked perfectly + // healthy. A missing HealthKit capability is a + // developer bug this feature already fails the build + // over -- see the usage strings -- so it fails here + // too, saying which two things disagree. + error("This app uses com.codename1.health but sets " + + "ios.entitlements.com.apple.developer" + + ".healthkit=" + healthKitEntitlement + + ". HealthKit cannot be used without that " + + "entitlement: the app would be signed " + + "without the capability and every " + + "authorization request would fail at " + + "runtime. Remove the hint to have it added " + + "for you, set it to true, or stop calling " + + "the health store.", + new RuntimeException( + "healthkit entitlement disabled")); + } + if (entitleHealthKit && healthKitEntitlement == null) { + request.putArgument( + "ios.entitlements.com.apple.developer.healthkit", + "true"); + } + boolean bgDelivery = "true".equalsIgnoreCase( + request.getArg("ios.health.backgroundDelivery", + "false")); + if (bgDelivery && request.getArg( + "ios.entitlements.com.apple.developer.healthkit" + + ".background-delivery", null) == null) { + request.putArgument( + "ios.entitlements.com.apple.developer.healthkit" + + ".background-delivery", "true"); + } + if ("true".equalsIgnoreCase(request.getArg( + "ios.health.recalibrateEstimates", "false")) + && request.getArg( + "ios.entitlements.com.apple.developer.healthkit" + + ".recalibrate-estimates", null) == null) { + request.putArgument( + "ios.entitlements.com.apple.developer.healthkit" + + ".recalibrate-estimates", "true"); + } + if ("true".equalsIgnoreCase( + request.getArg("ios.health.required", "false"))) { + String caps = request.getArg( + "ios.UIRequiredDeviceCapabilities", ""); + if (!caps.contains("healthkit")) { + request.putArgument("ios.UIRequiredDeviceCapabilities", + caps.length() == 0 ? "healthkit" + : caps + "," + "healthkit"); + } + } + } + // Uncomment INCLUDE_CN1_CAMERA in CodenameOne_GLViewController.h // so the com.codename1.camera native bridge (CN1Camera.{h,m}) // compiles in. This is deliberately independent of @@ -3616,6 +4028,32 @@ public void usesClassMethod(String cls, String method) { private File xcodeProjectDir; + /// Whether `cls` is a health value type rather than the store. + /// + /// These travel through the BLE sensor layer, which needs no HealthKit + /// entitlement, no framework and no purpose string. + private static boolean isSharedHealthModel(String cls) { + return "com/codename1/health/HealthSample".equals(cls) + || "com/codename1/health/QuantitySample".equals(cls) + || "com/codename1/health/SeriesSample".equals(cls) + || "com/codename1/health/CategorySample".equals(cls) + || "com/codename1/health/HealthQuantity".equals(cls) + || "com/codename1/health/HealthUnit".equals(cls) + || "com/codename1/health/HealthDataType".equals(cls) + || "com/codename1/health/HealthSource".equals(cls) + || "com/codename1/health/RecordingMethod".equals(cls) + || "com/codename1/health/BloodPressureSample".equals(cls) + // The error types travel the same way. A sensor callback + // is handed a HealthException, and asking it what went + // wrong names HealthError -- so a sensor-only app that + // handled its errors was read as touching the store, and + // got the Health Connect bridge bundled and its + // minSdkVersion raised to 26, cutting off the API 21-25 + // devices the BLE-only flow is documented to support. + || "com/codename1/health/HealthException".equals(cls) + || "com/codename1/health/HealthError".equals(cls); + } + public File getXcodeProjectDir() { return xcodeProjectDir; } @@ -5482,4 +5920,5 @@ private static String join(String[] strs, String sep) { return out.toString(); } + } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java index 7804ffc0665..3ba93e1c679 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/LinuxNativeBuilder.java @@ -266,6 +266,10 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException final boolean[] usesBluetoothHolder = {false}; try { scanClassesForPermissions(classesDir, new Executor.ClassScanner() { + @Override + public void implementsInterface(String cls, String iface) { + } + @Override public void usesClass(String cls) { if (cls != null && cls.startsWith("com/codename1/bluetooth/")) { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java index c4077d31985..62b9bdc2683 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java @@ -90,7 +90,11 @@ class TvNativeBuilder { + "CarPlay.framework;" // ARKit is iOS-only; it is linked on the iOS slice when the app references // com.codename1.ar, so weak-link it for the tvOS slice. - + "ARKit.framework"; + + "ARKit.framework;" + // HealthKit does not exist on tvOS at all. The iOS slice links it when the app + // references com.codename1.health, so weak-link it here or the tvOS slice fails + // to link. CN1Health.m additionally compiles itself out via TARGET_OS_TV. + + "HealthKit.framework"; TvNativeBuilder(IPhoneBuilder owner) { this.owner = owner; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java index 5a097e6cc8b..96ff7c17384 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WatchNativeBuilder.java @@ -65,6 +65,16 @@ class WatchNativeBuilder { // shake from its own root. Empty when neither watchMain nor an explicit // watchNative.mainClass hint is set (then we fall back to the phone main). private String watchMain; + // Whether the watch shakes from its own root rather than the phone's. + // A distinct root means the phone's health usage says nothing about + // the watch's, so the HealthKit entitlement cannot be inferred from + // the phone's privacy strings. + private boolean distinctWatchMain; + // Explicit opt-in/out for HealthKit on the watch bundle. + private String healthHint; + // watchNative.health.workoutProcessing, kept so the entitlement + // decision can read it too -- a workout session is HealthKit. + private String workoutProcessingHint; // GL/Metal-only source files with no watchOS substitute. Excluded from the // watch target; the CG backend (CN1CGGraphics/CN1WatchRenderingView) and the @@ -141,11 +151,16 @@ void parseHints(BuildRequest request) { if (!enabled) { return; } + distinctWatchMain = watchMain.length() > 0 + && !watchMain.equals(request.getMainClass()); if (watchMain.length() == 0) { // No distinct watch entry: reuse the phone main class as the watch // lifecycle root. watchMain = request.getMainClass(); } + healthHint = request.getArg("watchNative.health", "").trim(); + workoutProcessingHint = request.getArg( + "watchNative.health.workoutProcessing", "false").trim(); distribution = request.getArg("watchNative.distribution", "companion"); bundleId = request.getArg("watchNative.bundleId", request.getPackageName() + ".watchkitapp"); @@ -419,14 +434,207 @@ void writeWatchInfoPlist(BuildRequest request, File appSrcDir) throws IOExceptio if (!isStandalone()) { plistString(sb, "WKCompanionAppBundleIdentifier", request.getPackageName()); } + // HealthKit privacy strings. The watch slice has its own Info.plist and + // previously emitted none, so a health-enabled watch app would fail at + // runtime on the richest HealthKit target of all -- the watch is where + // heart rate and workouts actually come from. + // Trimmed, and empty counts as absent -- the phone builder already + // works this way. A whitespace-only hint used to emit a blank + // purpose string that satisfied the check below, producing an + // entitled watch bundle whose disclosure said nothing. + String healthShare = trimToNull(request.getArg( + "ios.NSHealthShareUsageDescription", null)); + if (healthShare != null) { + plistString(sb, "NSHealthShareUsageDescription", healthShare); + } + String healthUpdate = trimToNull(request.getArg( + "ios.NSHealthUpdateUsageDescription", null)); + if (healthUpdate != null) { + plistString(sb, "NSHealthUpdateUsageDescription", healthUpdate); + } + // HKWorkoutSession keeps the app running while a workout records; + // without this background mode watchOS suspends it mid-run. + if ("true".equalsIgnoreCase(workoutProcessingHint)) { + sb.append(" WKBackgroundModes\n \n") + .append(" workout-processing\n") + .append(" \n"); + } sb.append("\n\n"); + // A workout session is HealthKit, so asking for one while opting + // out of HealthKit cannot be honoured either way round: the plist + // still declares the workout-processing background mode while the + // bundle goes unentitled, and the session fails at runtime. + if ("false".equalsIgnoreCase(healthHint) + && "true".equalsIgnoreCase(workoutProcessingHint)) { + owner.error("watchNative.health=false contradicts" + + " watchNative.health.workoutProcessing=true. A" + + " workout session is HealthKit, so the watch cannot" + + " record one without the entitlement. Drop one of" + + " the two hints.", + new RuntimeException("contradictory watch health hints")); + } + boolean watchHealth = + watchUsesHealth(healthShare != null || healthUpdate != null); + if (needsPurposeString(watchHealth, healthShare, healthUpdate)) { + // Entitled but with no purpose string in its own Info.plist, + // which builds cleanly and then fails the moment the watch asks + // for authorization. Apple requires a specific string and this + // build never invents one, so the developer has to supply it. + owner.error("This app enables HealthKit on the watch" + + " (watchNative.health), but declares neither" + + " ios.NSHealthShareUsageDescription nor" + + " ios.NSHealthUpdateUsageDescription. The watch has" + + " its own Info.plist, and watchOS refuses a HealthKit" + + " authorization request from a bundle with no purpose" + + " string. Set the one that matches what the watch" + + " does.", + new RuntimeException("watch health usage string unset")); + } + writeWatchEntitlements(request, appSrcDir, watchHealth); File plist = new File(appSrcDir, request.getMainClass() + "-Watch-Info.plist"); owner.createFile(plist, sb.toString().getBytes(StandardCharsets.UTF_8)); } + /** The value with surrounding space removed, or null when empty. */ + static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.length() == 0 ? null : trimmed; + } + + /** + * Whether the build has to stop for a missing watch purpose string. + * + *

An entitled bundle with no purpose string in its own Info.plist + * builds cleanly and then fails the moment it asks for authorization. + * Only reachable through {@code watchNative.health}: every other route + * to an entitled watch runs through the phone's strings, which are + * copied into the watch plist.

+ */ + static boolean needsPurposeString(boolean watchUsesHealth, + String healthShare, String healthUpdate) { + return watchUsesHealth && healthShare == null && healthUpdate == null; + } + + /** + * Whether the watch bundle itself uses HealthKit. + * + *

The phone's privacy strings are evidence about the phone. When + * the watch shares the phone's main class it runs the same code, so + * they are evidence about the watch too. When the watch has its own + * {@code watchMain} it shakes from its own root and the phone's usage + * says nothing -- entitling that bundle anyway made codesigning fail + * for an ordinary non-health watch app whose App ID has no HealthKit + * capability, with nothing in the output to explain it.

+ * + *

{@code watchNative.health} settles it either way, and + * {@code watchNative.health.workoutProcessing} implies it: a workout + * session is HealthKit.

+ */ + boolean watchUsesHealth(boolean phoneUsesHealth) { + if ("true".equalsIgnoreCase(healthHint)) { + return true; + } + if ("false".equalsIgnoreCase(healthHint)) { + return false; + } + if ("true".equalsIgnoreCase(workoutProcessingHint)) { + return true; + } + return phoneUsesHealth && !distinctWatchMain; + } + + /** + * Writes the watch target's own entitlements file when the watch uses + * HealthKit. + * + *

The watch app is signed independently of the phone app, so the + * phone's entitlement does not reach it. Emitting the privacy strings + * alone produces a watch build that asks for HealthKit authorization + * and is refused, with nothing in the build output to explain why.

+ */ + private void writeWatchEntitlements(BuildRequest request, File appSrcDir, + boolean usesHealth) throws IOException { + if (!usesHealth) { + return; + } + StringBuilder sb = new StringBuilder(); + sb.append("\n") + .append("\n") + .append("\n\n") + .append(" com.apple.developer.healthkit\n") + .append(" \n"); + if ("true".equalsIgnoreCase(request.getArg( + "ios.health.backgroundDelivery", "false")) + || "true".equalsIgnoreCase(workoutProcessingHint)) { + sb.append(" com.apple.developer.healthkit") + .append(".background-delivery\n \n"); + } + sb.append("\n\n"); + owner.createFile(new File(appSrcDir, + request.getMainClass() + "-Watch.entitlements"), + sb.toString().getBytes(StandardCharsets.UTF_8)); + } + + /** + * The CODE_SIGN_ENTITLEMENTS setting for the watch target, or an empty + * string when the watch does not use HealthKit. + */ + private String watchEntitlementsSetting(BuildRequest request, + String mainClass) { + // The same gate the entitlements file itself uses. Pointing the + // target at a file that is not written, or writing one the target + // never signs with, are two different ways to be wrong. + // Trimmed, exactly as writeWatchInfoPlist trims them. A raw null + // check here saw a whitespace-only hint as health usage and + // pointed CODE_SIGN_ENTITLEMENTS at an entitlements file the plist + // pass had decided not to write, so Xcode failed on a missing + // file. + boolean phoneUsesHealth = trimToNull(request.getArg( + "ios.NSHealthShareUsageDescription", null)) != null + || trimToNull(request.getArg( + "ios.NSHealthUpdateUsageDescription", null)) != null; + if (!watchUsesHealth(phoneUsesHealth)) { + return ""; + } + return " bs['CODE_SIGN_ENTITLEMENTS'] = '" + + IPhoneBuilder.escapeRubyStr(mainClass + "-src/" + mainClass + + "-Watch.entitlements") + "'\n"; + } + private static void plistString(StringBuilder sb, String key, String value) { sb.append(" ").append(key).append("\n ") - .append(value == null ? "" : value).append("\n"); + .append(escapeXml(value)).append("
\n"); + } + + /** + * Escapes a value for XML content. + * + *

These strings come from build hints, so they are whatever the + * developer wrote. A perfectly reasonable purpose string such as + * "Reads & analyzes workouts" produced a malformed plist and an + * Xcode failure with no obvious cause.

+ */ + private static String escapeXml(String value) { + if (value == null || value.length() == 0) { + return ""; + } + StringBuilder out = new StringBuilder(value.length() + 16); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '&': out.append("&"); break; + case '<': out.append("<"); break; + case '>': out.append(">"); break; + case '"': out.append("""); break; + case '\'': out.append("'"); break; + default: out.append(c); + } + } + return out.toString(); } /** @@ -516,6 +724,7 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) .append(" bs['PRODUCT_NAME'] = '$(TARGET_NAME)'\n") .append(" bs['INFOPLIST_FILE'] = '") .append(IPhoneBuilder.escapeRubyStr(infoPlistPath)).append("'\n") + .append(watchEntitlementsSetting(request, mainClass)) .append(" bs['MARKETING_VERSION'] = '") .append(IPhoneBuilder.escapeRubyStr(request.getVersion() == null ? "1.0" : request.getVersion())).append("'\n") .append(" bs['CURRENT_PROJECT_VERSION'] = '") diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java index d0dd1df6be7..fc4175e4cd6 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/WindowsNativeBuilder.java @@ -249,6 +249,10 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException final boolean[] usesBluetoothHolder = {false}; try { scanClassesForPermissions(classesDir, new Executor.ClassScanner() { + @Override + public void implementsInterface(String cls, String iface) { + } + @Override public void usesClass(String cls) { if (cls != null && cls.startsWith("com/codename1/bluetooth/")) { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateNativeInterfaces.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateNativeInterfaces.java index 4efae04750b..d44b96e9a51 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateNativeInterfaces.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateNativeInterfaces.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.maven; @@ -214,7 +236,7 @@ public void visitInnerClass(String string, String string1, String string2, int i @Override public FieldVisitor visitField(int i, String string, String type, String string2, Object o) { if (type.startsWith("L")) { - scanner.usesClass(type.substring(1, type.length() - 2)); + scanner.usesClass(com.codename1.builders.Executor.descriptorToInternalName(type)); } return null; } @@ -316,7 +338,7 @@ public void visitTryCatchBlock(Label label, Label label1, Label label2, String s @Override public void visitLocalVariable(String string, String classType, String string2, Label label, Label label1, int i) { if (classType.startsWith("L")) { - scanner.usesClass(classType.substring(1, classType.length() - 2)); + scanner.usesClass(com.codename1.builders.Executor.descriptorToInternalName(classType)); } } diff --git a/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/health/CN1HealthConnectBridge.kt b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/health/CN1HealthConnectBridge.kt new file mode 100644 index 00000000000..ef9a174fa6c --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/com/codename1/builders/health/CN1HealthConnectBridge.kt @@ -0,0 +1,1581 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health + +import android.content.Context +import android.content.Intent +import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.PermissionController +import androidx.health.connect.client.changes.DeletionChange +import androidx.health.connect.client.changes.UpsertionChange +import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord +import androidx.health.connect.client.records.BasalBodyTemperatureRecord +import androidx.health.connect.client.records.BloodGlucoseRecord +import androidx.health.connect.client.records.BodyFatRecord +import androidx.health.connect.client.records.BodyTemperatureRecord +import androidx.health.connect.client.records.BoneMassRecord +import androidx.health.connect.client.records.CyclingPedalingCadenceRecord +import androidx.health.connect.client.records.DistanceRecord +import androidx.health.connect.client.records.ElevationGainedRecord +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.FloorsClimbedRecord +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.HeightRecord +import androidx.health.connect.client.records.HydrationRecord +import androidx.health.connect.client.records.LeanBodyMassRecord +import androidx.health.connect.client.records.OxygenSaturationRecord +import androidx.health.connect.client.records.PowerRecord +import androidx.health.connect.client.records.Record +import androidx.health.connect.client.records.RespiratoryRateRecord +import androidx.health.connect.client.records.RestingHeartRateRecord +import androidx.health.connect.client.records.SleepSessionRecord +import androidx.health.connect.client.records.SpeedRecord +import androidx.health.connect.client.records.StepsCadenceRecord +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.records.Vo2MaxRecord +import androidx.health.connect.client.records.WeightRecord +import androidx.health.connect.client.records.WheelchairPushesRecord +import androidx.health.connect.client.records.metadata.DataOrigin +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.request.ChangesTokenRequest +import androidx.health.connect.client.request.ReadRecordsRequest +import androidx.health.connect.client.time.TimeRangeFilter +import androidx.health.connect.client.units.BloodGlucose +import androidx.health.connect.client.units.Energy +import androidx.health.connect.client.units.Length +import androidx.health.connect.client.units.Mass +import androidx.health.connect.client.units.Percentage +import androidx.health.connect.client.units.Temperature +import androidx.health.connect.client.units.Volume +import com.codename1.impl.android.HealthConnectDelegate +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.json.JSONObject +import java.time.Instant + +/** + * Injected by the Codename One build server when an app references + * `com.codename1.health`. Implements the pure-Java + * [HealthConnectDelegate] seam in terms of Health Connect's Kotlin + * coroutine API. + * + * This file is Kotlin because `androidx.health.connect` exposes only + * `suspend` functions. It lives here, in the generated app project, + * rather than in the Codename One Android port, because the port compiles + * against an old `android.jar` with no AndroidX and no Kotlin on its + * classpath -- the same reason the Android Auto glue is injected rather + * than shipped. + * + * Every `suspend` call is wrapped so no coroutine escapes across the + * boundary: the delegate interface speaks only `String` and primitives. + * + * Keep this file in sync with the BuildDaemon copy. + */ +class CN1HealthConnectBridge(private val context: Context) + : HealthConnectDelegate { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private var cachedClient: HealthConnectClient? = null + + /** + * The client, created on first successful attempt. + * + * Only a *successful* client is cached. A lazy property remembered the + * null from a first attempt made before Health Connect was installed + * or updated, so a user who completed provider setup and came back -- + * without the process being killed -- had `sdkStatus()` report + * availability while every call still failed, until they force-quit + * the app. + */ + private val client: HealthConnectClient? + get() { + cachedClient?.let { return it } + return try { + if (HealthConnectClient.getSdkStatus(context) + == HealthConnectClient.SDK_AVAILABLE) { + HealthConnectClient.getOrCreate(context) + .also { cachedClient = it } + } else { + null + } + } catch (t: Throwable) { + null + } + } + + override fun sdkStatus(): Int = try { + when (HealthConnectClient.getSdkStatus(context)) { + HealthConnectClient.SDK_AVAILABLE -> + HealthConnectDelegate.SDK_AVAILABLE + HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> + HealthConnectDelegate.SDK_UPDATE_REQUIRED + else -> HealthConnectDelegate.SDK_UNAVAILABLE + } + } catch (t: Throwable) { + HealthConnectDelegate.SDK_UNAVAILABLE + } + + override fun providerPackageName(): String = + "com.google.android.apps.healthdata" + + /** + * Runs a suspending block and reports the outcome through the + * callback, translating the exceptions Health Connect throws into the + * delegate's small error vocabulary. + */ + private fun run(cb: HealthConnectDelegate.Callback, + block: suspend () -> String) { + scope.launch { + try { + cb.onSuccess(block()) + } catch (e: SecurityException) { + cb.onError(HealthConnectDelegate.ERR_AUTH_DENIED, + e.message ?: "permission denied") + } catch (e: IllegalStateException) { + cb.onError(HealthConnectDelegate.ERR_PROVIDER, + e.message ?: "Health Connect unavailable") + } catch (e: IllegalArgumentException) { + cb.onError(HealthConnectDelegate.ERR_INVALID_ARGUMENT, + e.message ?: "invalid request") + } catch (t: Throwable) { + // A token that has aged out surfaces as a provider-defined + // exception; the portable layer maps this onto a resync. + val expired = t.javaClass.name.contains("ChangesTokenExpired") + cb.onError( + if (expired) HealthConnectDelegate.ERR_TOKEN_EXPIRED + else HealthConnectDelegate.ERR_UNKNOWN, + t.message ?: t.javaClass.simpleName) + } + } + } + + private fun requireClient(): HealthConnectClient = + client ?: throw IllegalStateException( + "Health Connect is not available on this device") + + override fun grantedPermissions(cb: HealthConnectDelegate.Callback) { + run(cb) { + val granted = requireClient().permissionController + .getGrantedPermissions() + granted.flatMap { toTokens(it) }.distinct() + .joinToString(",") + } + } + + override fun permissionIntent(permissionsCsv: String): Intent { + val perms = permissionsCsv.split(",") + .filter { it.isNotBlank() } + .mapNotNull { toHealthPermission(it.trim()) } + .toMutableSet() + perms.addAll(declaredSpecialPermissions()) + return PermissionController.createRequestPermissionResultContract() + .createIntent(context, perms) + } + + /** + * The background and history permissions the manifest declares. + * + * These are not per-type, so they never appear in a HealthAccess list + * and cannot come through the token table. Declaring them without ever + * requesting them -- which is what the builder used to produce -- gives + * an app that is configured for background or historical reads and is + * never authorized for either. + * + * Read back from the manifest rather than passed in, so the bridge + * asks for exactly what the build declared. + */ + private fun declaredSpecialPermissions(): Set { + val special = setOf( + "android.permission.health.READ_HEALTH_DATA_IN_BACKGROUND", + "android.permission.health.READ_HEALTH_DATA_HISTORY") + return try { + val info = context.packageManager.getPackageInfo( + context.packageName, + android.content.pm.PackageManager.GET_PERMISSIONS) + val declared = info.requestedPermissions ?: emptyArray() + declared.filter { special.contains(it) }.toSet() + } catch (t: Throwable) { + emptySet() + } + } + + override fun parsePermissionResult(resultCode: Int, + data: Intent?): String { + return try { + val granted = PermissionController + .createRequestPermissionResultContract() + .parseResult(resultCode, data) + granted.flatMap { toTokens(it) }.distinct() + .joinToString(",") + } catch (t: Throwable) { + "" + } + } + + override fun readRecords(requestJson: String, + cb: HealthConnectDelegate.Callback) { + run(cb) { + val json = JSONObject(requestJson) + val start = Instant.ofEpochMilli(json.getLong("start")) + val end = Instant.ofEpochMilli(json.getLong("end")) + val filter = TimeRangeFilter.between(start, end) + val types = json.getJSONArray("types") + val sources = json.optJSONArray("sources") + val origins = if (sources == null) emptySet() else + (0 until sources.length()) + .map { DataOrigin(sources.getString(it)) }.toSet() + val sb = StringBuilder() + // The limit is the caller's budget for the whole query, not per + // type. Spending it again on each type lets the reply exceed the + // advertised limit, and the shared layer then trims the combined + // block -- which can discard every record after the first type. + var budget = json.optInt("limit", 0) + if (budget <= 0) { + budget = Int.MAX_VALUE + } + // Descending matters for the common "latest reading" shape: a + // limit-1 ascending query returns the oldest record, which is + // the opposite of what the caller asked for. + val ascending = !json.optBoolean("descending", false) + // One continuation token per type, not one for the query. A + // single token is opaque to a record class other than the one + // that issued it, so carrying just the first type's token + // restarted every other type from the beginning on page two -- + // or handed a steps token to HeartRateRecord. + // Whether a series record may stay whole. Defaults to true so + // an older descriptor, or one from a caller that never touched + // the option, keeps the flattened shape it has always had. + val flatten = json.optBoolean("flatten", true) + val inTokens = parseTokens(json.optString("pageToken", "")) + val outTokens = LinkedHashMap() + // Every type is read to the full budget and merged afterwards. + // Spending the budget type by type made the answer depend on + // the order of `types` rather than on time: a descending limit-1 + // query over [steps, heart_rate] always returned the newest + // step even when a heart-rate sample was newer. + // Every type reads to the full budget, and the merge picks the + // newest across all of them. + // + // Dividing it bounded memory but changed the answer: all ten + // newest samples of a descending limit-10 query can belong to + // one type, and a fifth of the budget cannot see them. Any of + // the K results may come from any one type, so guaranteeing + // the global top-K needs up to K candidates from each -- short + // of an incremental k-way merge, which would mean per-type + // page cursors held across the whole read. + // + // So the cost is K per type rather than K overall, and the + // reply can exceed the caller's limit by a factor of the type + // count: the shared layer trims only when no continuation + // token remains, and these types still have theirs. + // + // Neither static split is right. Dividing the budget cannot + // see a top-K that lives in one type; giving each the whole + // budget cannot honour the cap. The answer is an incremental + // k-way merge -- fetch a page per type, emit the newest across + // them until K, and report for any partially-emitted type the + // token *before* its last page so the remainder is re-read + // rather than skipped. That is a real rewrite of this path and + // is not something to land unverified; see the note in + // SampleQuery#setLimit. + // + // Until it lands, the limit is per type here. That is the side + // to err on: an oversized reply is bounded, predictable and + // trimmable, while one built from the wrong candidates is + // simply wrong. + val perTypeBudget = budget + val perType = ArrayList() + // Set when a type returned less than its range held. It is + // not a continuation: SamplePage carries the two separately, + // and a page can be the last one and still be truncated. + var anyTruncated = false + for (i in 0 until types.length()) { + val token = types.getString(i) + val block = StringBuilder() + val r = appendRecords(block, token, filter, + perTypeBudget, origins, ascending, inTokens[token], + flatten) + perType.add(block.toString()) + anyTruncated = anyTruncated || r.truncated + // Exhausted types are recorded with an empty token, not + // omitted. Omitting them made the next page see no token + // for that type and read it from the beginning again, + // duplicating records while the other types advanced. + outTokens[token] = r.token ?: "" + } + // Nothing left anywhere means no trailer token at all. + if (outTokens.values.all { it.isEmpty() }) { + outTokens.clear() + } + // A single type is never trimmed here. + // + // Its token resumes after the records already fetched, so a + // record dropped by the trim is behind the token and gone for + // good -- and clearing the token to signal that only made the + // next call restart from the beginning and fetch the same page + // again. appendRecords already bounds the read by the limit at + // page granularity, so the overshoot is at most the tail of one + // page and the shared layer trims it for the caller while the + // token still points at the right place. + // + // Trimming and paging genuinely cannot both be honoured across + // several types: a Health Connect token advances its own type, + // so a sample dropped by the merge has no token that would + // return it. There the limit wins, because it is what bounds + // memory, and the reply says so rather than pretending to be + // complete -- with no token, because there is no next page to + // ask for. Multi-type queries are single-page. + // Nothing is trimmed here, for either shape. Each type's + // token points after the records that type actually emitted, + // so dropping lines to meet a budget discards samples the + // token has already moved past -- and clearing the tokens to + // admit that produced the worst answer of all: a continuation + // flag with no continuation, which reads as a new query and + // repeats the first page forever. Every type is emitted whole + // in time order, the reply overshoots the limit by at most the + // tail of one page per type, and the shared layer trims for + // the caller on a record boundary. + // Single-type output goes through the same merge. Health + // Connect orders *records*, and a series record orders its own + // samples, but two overlapping series -- a watch and a phone + // both recording heart rate -- flatten into samples that are + // globally out of order: one record emits 100 and a + // later-starting one then emits 60. A "latest N" query trimmed + // on that keeps the wrong N. + // + // With one block this is just a global sort by sample time, + // which is what the flattened output should have been all + // along. + val merged = mergeByTime(perType, ascending) + sb.append(merged) + // Trailer: what is left on the platform side. Reporting nothing + // made every page look complete, so the caller's paging loop + // stopped at the first limit and lost the rest. + sb.append('#').append(encodeTokens(outTokens)).append('\t') + .append(if (outTokens.isEmpty() && !anyTruncated) "0" + else "1") + .append('\n') + sb.toString() + } + } + + /** Decodes the per-type continuation tokens from a page token. */ + private fun parseTokens(encoded: String): Map { + val out = LinkedHashMap() + if (encoded.isEmpty()) { + return out + } + for (part in encoded.split(TOKEN_SEPARATOR)) { + val eq = part.indexOf('=') + if (eq > 0) { + // An empty value means that type is exhausted, which is + // deliberately different from the type being absent. + out[part.substring(0, eq)] = part.substring(eq + 1) + } + } + return out + } + + private fun encodeTokens(tokens: Map): String { + val sb = StringBuilder() + for ((type, token) in tokens) { + if (sb.isNotEmpty()) { + sb.append(TOKEN_SEPARATOR) + } + sb.append(type).append('=').append(token) + } + return sb.toString() + } + + /// Neither a portable type id nor a Health Connect token contains it. + private val TOKEN_SEPARATOR = '\u0001' + + /** + * Merges per-type blocks in time order and applies the shared limit. + * + * The line format leads with id, type, start -- start is field 2 -- so + * the merge sorts on that rather than re-parsing into records. + */ + private fun mergeByTime(blocks: List, + ascending: Boolean): String { + val lines = ArrayList() + for (block in blocks) { + for (line in block.split('\n')) { + if (line.isNotBlank()) { + lines.add(line) + } + } + } + val keyed = lines.sortedBy { + val f = it.split('\t') + if (f.size > 2) f[2].toLongOrNull() ?: 0L else 0L + } + val ordered = if (ascending) keyed else keyed.asReversed() + val out = StringBuilder() + // Everything, in time order. Trimming here is what stranded + // records behind an already-advanced token; the shared layer + // applies the caller's limit, and does it on a record boundary. + for (line in ordered) { + out.append(line).append('\n') + } + return out.toString() + } + + /** + * Reads one portable type and appends it in the shared line format. + * + * Returns how many records were emitted so the caller can spend one + * shared limit across every requested type. + */ + private suspend fun appendRecords(sb: StringBuilder, token: String, + filter: TimeRangeFilter, limit: Int, + origins: Set, + ascending: Boolean, + resumeToken: String?, + flatten: Boolean): Read { + // A capped series read writes into its own buffer and hands back + // only the requested number of lines. + // + // The scan has to cover the range -- the newest sample can be in + // a record that starts long before the ones already read -- but + // what it scans is candidates, not the answer. Emitting all of + // them returned twenty thousand samples to a caller who asked for + // one, which is the heap guard the limit exists to be. So the + // candidates are gathered here, the top N by time are selected, + // and the rest are dropped. + // + // Such a reply carries no continuation token and no has-more: + // the scan passed over records a token would have to return to, + // so there is nothing to resume from. The caller asked for N and + // gets N, which is the answer to the question it put; the + // multi-type path is single-page for the same reason. + // Only a limit smaller than the scan itself caps anything. A + // read asking for the ceiling or more -- the aggregate fallback + // asks for Integer.MAX_VALUE -- wants everything it can get and + // must keep paging with a real token, so it takes the ordinary + // path. + val capped = isSeriesToken(token) && limit > 0 + && limit < SERIES_SCAN_CEILING + val out = if (capped) StringBuilder() else sb + // A type this bridge cannot read is rejected rather than returned + // as an empty page: the caller cannot tell an empty page apart from + // "you have no data", which is the one answer a health API must + // never guess at. + val type = readableRecordClassFor(token) + ?: throw IllegalArgumentException( + "Health Connect reads are not implemented for '" + token + + "' in this build") + if (limit <= 0) { + return Read(0, null) + } + if (resumeToken != null && resumeToken.isEmpty()) { + return Read(0, null) + } + // Health Connect rejects a pageSize above MAX_PAGE_SIZE outright, so + // a caller wanting more records than one page holds is served by + // walking pages rather than by failing the read. Without this an + // ordinary unbounded query throws before it reads anything. + // A series read is not bounded by the caller's limit. + // + // A sample lies anywhere inside its record's span and records + // arrive ordered by start, so the newest sample can sit in a + // record that starts arbitrarily earlier than the ones already + // read. Letting a limit of one fetch one record -- or eight, or + // any constant -- answers "the latest reading" from a prefix, and + // no fixed lookahead makes that sound: it only moves the number + // of overlapping recordings needed to break it. + // + // So the range is read through, bounded by a ceiling on samples + // rather than by the caller's limit, and the shared layer picks + // the top N out of what comes back. That is exact whenever the + // requested range holds fewer than SERIES_SCAN_CEILING samples. + // Past that the reply keeps its continuation token, so the answer + // is a bounded prefix that says it is one rather than a wrong + // answer that claims to be complete. + var remaining = if (isSeriesToken(token)) SERIES_SCAN_CEILING + else if (limit > 0) limit else Int.MAX_VALUE + var emitted = 0 + var pageToken: String? = resumeToken + // Samples seen per record so far. The caller's limit counts + // samples while pageSize counts records, and one series record + // holds many samples -- so asking for `remaining` records fetched + // far more than was wanted. Nothing fetched is ever discarded (the + // page token has already moved past it), which makes the overshoot + // real memory rather than a rounding error. This is measured, not + // guessed: it starts at one sample per record, which is exactly + // right for every scalar type, and the first page of a series type + // corrects it for every page after. + // Series density is unknown until a page has been read, so the + // first one is deliberately a small probe: an over-large first + // page is memory spent that no later estimate can give back. + var samplesPerRecord = if (isSeriesToken(token)) + SERIES_SCAN_CEILING / SERIES_PROBE_RECORDS else 1 + // How many records a series page may ask for, grown gradually. + // + // The measured density bounds the *next* page only if the next + // page is like the last one, and it need not be: four sparse + // records say nothing about the sixty-four dense ones behind + // them, so an estimate alone let one page serialize hundreds of + // thousands of points. Growing by a fixed factor caps what a + // density jump can cost at one page of this size, and the size + // only rises while the records actually are light. + // + // No estimate can bound a *single* record -- Health Connect does + // not say how many points one holds until it is fetched -- so the + // guarantee is one page's worth of overshoot, with the page kept + // small, rather than a hard ceiling on materialization. + var seriesPage = SERIES_PROBE_RECORDS + while (remaining > 0) { + // A series page is sized from measured density, not from a + // constant. Asking for a fixed 64 records materialized the + // whole page before the ceiling was consulted, so 64 dense + // records -- ten thousand points each is an ordinary day of + // heart rate -- serialized far past the bound the ceiling + // exists to be. The first page is a small probe precisely + // because nothing is known about density yet; every page + // after it is sized by what that measured. + val wantRecords = if (isSeriesToken(token)) + minOf(seriesPage, maxOf(1, remaining / samplesPerRecord)) + else maxOf(1, remaining / samplesPerRecord) + val page = requireClient().readRecords( + ReadRecordsRequest(type, timeRangeFilter = filter, + // Source filtering happens here rather than after the + // fact: SampleQuery.addSource is how an app avoids + // counting the same steps from both phone and watch, + // and ignoring it silently inflates every total. + dataOriginFilter = origins, + ascendingOrder = ascending, + pageSize = minOf(wantRecords, MAX_PAGE_SIZE), + pageToken = pageToken)) + // Counted in emitted lines, not records. One heart-rate record + // holds many samples and appendOne flattens them, so a + // limit-1 request could otherwise return hundreds of lines. + // Every record in the fetched page is emitted. pageSize is + // counted in records while the budget is counted in samples, + // so breaking out mid-page abandoned records already fetched -- + // and the page token resumes after the whole page, so they + // could never be read again. Overshooting the caller's limit + // is recoverable; skipping records is not. + // + // A fetched page is always finished, ceiling or no ceiling. + // Stopping partway looks like it protects the bound and does + // not: the platform token resumes after the *whole* page, so + // the records left unprocessed could never be read again -- + // and on a final page it would hand topByTime a fragment to + // select from while treating it as the complete range. The + // ceiling is held by how large a page is asked for, which is + // decided before anything is fetched. + var lines = 0 + var points = 0 + for (record in page.records) { + val w = appendOne(out, record, token, flatten, ascending) + lines += w.lines + points += w.points + } + if (page.records.isNotEmpty()) { + // Measured in points for a series, lines for everything + // else. An unflattened series emits one line per record + // however many points it holds, so measuring lines put + // the density at about one and the next page asked for + // sixty-four dense records -- sizing the page by the + // number that was not the one being bounded. + samplesPerRecord = maxOf(1, + (if (isSeriesToken(token)) points else lines) + / page.records.size) + } + // The ceiling counts points and the caller's limit counts + // lines. For a flattened read they are the same number; for + // an unflattened one a single line carries a whole record's + // measurements, so counting lines against the ceiling let it + // admit twenty thousand records holding millions of points. + remaining -= if (isSeriesToken(token)) points else lines + emitted += lines + seriesPage = minOf(SERIES_PAGE_RECORDS, + seriesPage * SERIES_PAGE_GROWTH) + pageToken = page.pageToken + if (pageToken == null || page.records.isEmpty()) { + break + } + } + if (!capped) { + return Read(emitted, pageToken) + } + if (pageToken != null) { + // A capped scan that could not finish cannot answer at all. + // + // Selecting is only sound over the whole range: stopped at + // the ceiling, the newest sample may be among the records not + // yet read. And handing back the candidate buffer instead is + // no answer either -- the shared layer does not trim a page + // carrying a token, so a limit of one would return twenty + // thousand samples and the cap that exists to bound the heap + // would be gone. Both of those have been tried here. + // + // So it fails, and says what to do: a smaller range needs no + // scan this large, and a limit at or above the ceiling asks + // for paging instead of selection and is served normally. + // An argument problem, not a provider one. Mapped to + // ERR_PROVIDER the caller is told Health Connect is + // unavailable and may go off to launch provider setup, when + // what is wrong is the range and the limit it asked with. + throw IllegalArgumentException("A limited read of '" + token + + "' spans more than " + SERIES_SCAN_CEILING + + " measurements. Selecting the " + limit + + " you asked for would mean reading past that bound, so" + + " this cannot be answered exactly: narrow the time" + + " range, or raise the limit to " + SERIES_SCAN_CEILING + + " or more to page through the range instead.") + } + val kept = topByTime(out.toString(), limit, ascending) + sb.append(kept.first) + // No token, but truncated when the selection dropped anything. + // + // These are two different statements and conflating them is what + // went wrong here before. There is nothing to resume from -- the + // scan walked past records a continuation would have to come back + // to -- so the token stays null, and a caller that loops on the + // token cannot spin. But the range did hold more than was + // returned, and SamplePage.isTruncated() exists to say exactly + // that: it documents a page that is both the last one and + // incomplete, which is precisely this reply. + return Read(kept.second, null, kept.second < emitted) + } + + /** + * Keeps the `limit` lines closest to the end the caller asked for. + * + * Returns the kept text and how many lines it holds. + */ + private fun topByTime(block: String, limit: Int, + ascending: Boolean): Pair { + val lines = ArrayList() + for (line in block.split('\n')) { + if (line.isNotBlank()) { + lines.add(line) + } + } + val byTime = lines.sortedBy { + val f = it.split('\t') + if (f.size > 2) f[2].toLongOrNull() ?: 0L else 0L + } + // Ascending keeps the oldest, descending the newest; the block is + // then written back in the order the caller asked for. + val chosen = if (ascending) byTime.take(limit) + else byTime.takeLast(limit).asReversed() + val sb = StringBuilder() + for (line in chosen) { + sb.append(line).append('\n') + } + return Pair(sb.toString(), chosen.size) + } + + /** + * One type's contribution to a read: the lines emitted and the token + * to resume with, if there is one. + */ + private class Read(val emitted: Int, val token: String?, + val truncated: Boolean = false) + + /** + * Emits one record in the shared line format, tagged with `token`. + * + * The token is supplied rather than derived because the relation is + * many-to-one: Health Connect has a single `DistanceRecord` behind the + * walking, cycling and swimming distance types, so a read must label + * its lines with the type that was actually asked for or the portable + * layer discards them as the wrong type. + * + * Returns how many lines were written, which is zero for record + * shapes with no single-value form -- the caller reports those rather + * than silently dropping them. + */ + private fun appendOne(sb: StringBuilder, record: Record, + token: String, flatten: Boolean, + ascending: Boolean = true): Written { + // Only when flattening is off, because this *writes*. Hoisting + // the call out of the condition to read its count emitted the + // whole-record line and then fell through to the per-sample + // branch, so every flattened read carried the record and its + // samples both -- the same measurements twice, with the count + // reporting only half of them. + if (!flatten) { + val wholeSeries = appendWholeSeries(sb, record, token, + ascending) + if (wholeSeries >= 0) { + // One line, and as many points as it actually + // serialized. The two are counted separately because the + // caller's limit is in lines while the memory ceiling is + // in points, and a single unflattened record can carry + // thousands. + return Written(1, wholeSeries) + } + } + when (record) { + is StepsRecord -> interval(sb, record, token, + record.startTime, record.endTime, + record.count.toDouble(), "count") + + is DistanceRecord -> interval(sb, record, token, + record.startTime, record.endTime, + record.distance.inMeters, "m") + + is FloorsClimbedRecord -> interval(sb, record, token, + record.startTime, record.endTime, + record.floors, "count") + + is ElevationGainedRecord -> interval(sb, record, token, + record.startTime, record.endTime, + record.elevation.inMeters, "m") + + is ActiveCaloriesBurnedRecord -> interval(sb, record, token, + record.startTime, record.endTime, + record.energy.inKilocalories, "kcal") + + is WheelchairPushesRecord -> interval(sb, record, token, + record.startTime, record.endTime, + record.count.toDouble(), "count") + + is HydrationRecord -> interval(sb, record, token, + record.startTime, record.endTime, + record.volume.inLiters, "L") + + is WeightRecord -> instant(sb, record, token, record.time, + record.weight.inKilograms, "kg") + + is LeanBodyMassRecord -> instant(sb, record, token, record.time, + record.mass.inKilograms, "kg") + + is BoneMassRecord -> instant(sb, record, token, record.time, + record.mass.inKilograms, "kg") + + is BodyFatRecord -> instant(sb, record, token, record.time, + record.percentage.value, "%") + + is HeightRecord -> instant(sb, record, token, record.time, + record.height.inMeters, "m") + + is RestingHeartRateRecord -> instant(sb, record, token, + record.time, + record.beatsPerMinute.toDouble(), "count/min") + + is OxygenSaturationRecord -> instant(sb, record, token, + record.time, + record.percentage.value, "%") + + is RespiratoryRateRecord -> instant(sb, record, token, record.time, + record.rate, "count/min") + + is BasalBodyTemperatureRecord -> instant(sb, record, token, + record.time, + record.temperature.inCelsius, "degC") + + is BodyTemperatureRecord -> instant(sb, record, token, record.time, + record.temperature.inCelsius, "degC") + + is Vo2MaxRecord -> instant(sb, record, token, record.time, + record.vo2MillilitersPerMinuteKilogram, "mL/(kg*min)") + + is BloodGlucoseRecord -> instant(sb, record, token, record.time, + record.level.inMillimolesPerLiter, "mmol/L") + + // Series records hold many samples in one record and the + // portable layer flattens by default, so emit one line per + // sample rather than per record. + is HeartRateRecord -> ordered(record.samples, ascending).forEach { + sample(sb, record, token, it.time.toEpochMilli(), + it.beatsPerMinute.toDouble(), "count/min") + } + + is PowerRecord -> ordered(record.samples, ascending).forEach { + sample(sb, record, token, it.time.toEpochMilli(), + it.power.inWatts, "W") + } + + is SpeedRecord -> ordered(record.samples, ascending).forEach { + sample(sb, record, token, it.time.toEpochMilli(), + it.speed.inMetersPerSecond, "m/s") + } + + is CyclingPedalingCadenceRecord -> ordered(record.samples, ascending).forEach { + sample(sb, record, token, it.time.toEpochMilli(), + it.revolutionsPerMinute, "count/min") + } + + is StepsCadenceRecord -> ordered(record.samples, ascending).forEach { + sample(sb, record, token, it.time.toEpochMilli(), + it.rate, "count/min") + } + + else -> return Written(0, 0) + } + // One line per scalar record; a flattened series contributes one + // per sample, so here the two counts coincide. + // + // A series record is never split. Health Connect's page token + // resumes after a whole record, so emitting a prefix would strand + // the rest with no token that could ever reach it. The page may + // therefore overshoot the limit by less than one record, which the + // shared layer trims for the caller while the token still resumes + // at the right place. + val n = when (record) { + is HeartRateRecord -> record.samples.size + is PowerRecord -> record.samples.size + is SpeedRecord -> record.samples.size + is CyclingPedalingCadenceRecord -> record.samples.size + is StepsCadenceRecord -> record.samples.size + else -> 1 + } + return Written(n, n) + } + + /** + * What one record contributed: lines, which the caller's limit counts, + * and points, which the memory ceiling counts. + * + * They differ only for an unflattened series, where one line carries + * a whole record's measurements. + */ + private class Written(val lines: Int, val points: Int) + + // The record timestamps are passed in rather than read off a shared + // supertype: androidx.health.connect marks IntervalRecord and + // InstantaneousRecord internal, so they cannot be named here. Each + // branch above knows its concrete type, which is where the times come + // from. + private fun interval(sb: StringBuilder, r: Record, token: String, + start: Instant, end: Instant, value: Double, + unit: String) { + line(sb, r.metadata.id, token, start.toEpochMilli(), + end.toEpochMilli(), value, unit, originOf(r), r) + } + + private fun instant(sb: StringBuilder, r: Record, token: String, + at: Instant, value: Double, unit: String) { + line(sb, r.metadata.id, token, at.toEpochMilli(), + at.toEpochMilli(), value, unit, originOf(r), r) + } + + private fun sample(sb: StringBuilder, r: Record, token: String, + at: Long, value: Double, unit: String) { + line(sb, r.metadata.id, token, at, at, value, unit, originOf(r), r) + } + + private fun originOf(r: Record): String { + val pkg = r.metadata.dataOrigin.packageName + return if (pkg.isNullOrEmpty()) "UNKNOWN" else pkg + } + + private fun line(sb: StringBuilder, id: String, type: String, + start: Long, end: Long, value: Double, unit: String, + origin: String, r: Record) { + sb.append(id).append('\t').append(type).append('\t') + .append(start).append('\t').append(end).append('\t') + .append(value).append('\t').append(unit).append('\t') + // The real originating package, because the shared layer reads + // this field as the source bundle id and filters on it. Writing + // a placeholder here made every source-filtered read come back + // empty, even though the native request had filtered correctly. + .append(origin).append('\t') + // Fields 7 and 8 are the source display name and device name, + // which Health Connect does not give us. They stay empty so + // the recording method lands in field 9 rather than being read + // back as the name of the app that wrote the sample. + .append('\t').append('\t') + .append(recordingMethodName(r)).append('\n') + } + + /** + * Emits a series record whole, as one line, when the caller asked to + * keep it intact. Returns false for anything that is not a series, so + * the ordinary per-measurement path handles it. + * + * Health Connect is the only platform here that has series records at + * all, so this is the only place `setFlattenSeries(false)` can be + * honoured. Ignoring it meant the option did nothing anywhere and the + * caller got scalar samples whatever it asked for. + */ + /** + * The samples of a series in the direction the query asked for. + * + * `ascendingOrder` on the request orders the *records*; the samples + * inside one were always emitted oldest-first. A single-type response + * is not passed through `mergeByTime`, so a descending read came back + * with each record's points in ascending order -- and a small portable + * limit then kept the oldest readings when the caller had asked for + * the newest. + */ + private fun ordered(samples: List, ascending: Boolean): List = + if (ascending) samples else samples.asReversed() + + /// Returns the number of points serialized, or -1 when `record` is + /// not a series shape and the caller should fall through. + /// + /// A count rather than a flag because one of these lines carries a + /// whole record's measurements: counting it as one against a memory + /// ceiling let twenty thousand records through, and each of them can + /// hold thousands of points. + private fun appendWholeSeries(sb: StringBuilder, record: Record, + token: String, + ascending: Boolean): Int { + return when (record) { + is HeartRateRecord -> series(sb, record, token, "count/min", + record.startTime, record.endTime, + ordered(record.samples, ascending).map { + Pair(it.time.toEpochMilli(), it.beatsPerMinute.toDouble()) + }) + + is PowerRecord -> series(sb, record, token, "W", + record.startTime, record.endTime, + ordered(record.samples, ascending).map { + Pair(it.time.toEpochMilli(), it.power.inWatts) + }) + + is SpeedRecord -> series(sb, record, token, "m/s", + record.startTime, record.endTime, + ordered(record.samples, ascending).map { + Pair(it.time.toEpochMilli(), it.speed.inMetersPerSecond) + }) + + is CyclingPedalingCadenceRecord -> series(sb, record, token, + "count/min", record.startTime, record.endTime, + ordered(record.samples, ascending).map { + Pair(it.time.toEpochMilli(), it.revolutionsPerMinute) + }) + + is StepsCadenceRecord -> series(sb, record, token, "count/min", + record.startTime, record.endTime, + ordered(record.samples, ascending).map { + Pair(it.time.toEpochMilli(), it.rate) + }) + + else -> -1 + } + } + + /** Returns how many points were serialized, which may be zero. */ + private fun series(sb: StringBuilder, r: Record, token: String, + unit: String, recordStart: Instant, recordEnd: Instant, + points: List>): Int { + // An empty series would decode to a record with no measurements, + // which is indistinguishable from a decode failure. There is + // nothing to report, so nothing is written. + if (points.isEmpty()) { + return 0 + } + // The record's own interval, not the extent of its measurements. + // Disabling flattening is a request for record identity, and a + // record whose first sample lands a minute after it opened does + // not start at that sample: reported that way, a read could return + // a series whose stated span did not overlap the query that + // selected it. The point extent is still folded in so a record + // whose samples somehow sit outside its own bounds stays + // decodable rather than being rejected as malformed. + val start = minOf(recordStart.toEpochMilli(), points.minOf { it.first }) + val end = maxOf(recordEnd.toEpochMilli(), points.maxOf { it.first }) + sb.append('~').append(r.metadata.id).append('\t') + .append(token).append('\t') + .append(start).append('\t').append(end).append('\t') + .append(points.size).append('\t').append(unit).append('\t') + .append(originOf(r)).append('\t') + // Source display name and device name, which Health Connect + // does not give us, then the recording method -- the same + // column order the scalar line uses. + .append('\t').append('\t') + .append(recordingMethodName(r)).append('\t') + for ((i, p) in points.withIndex()) { + if (i > 0) { + sb.append(',') + } + // Start and end are the same instant: every Health Connect + // series measurement is a point reading, not an interval. + sb.append(p.first).append(':').append(p.first).append(':') + .append(p.second) + } + sb.append('\n') + return points.size + } + + private fun recordingMethodName(r: Record): String { + return when (r.metadata.recordingMethod) { + Metadata.RECORDING_METHOD_MANUAL_ENTRY -> "MANUAL_ENTRY" + Metadata.RECORDING_METHOD_AUTOMATICALLY_RECORDED -> "AUTOMATIC" + Metadata.RECORDING_METHOD_ACTIVELY_RECORDED -> "ACTIVE" + else -> "UNKNOWN" + } + } + + override fun aggregate(requestJson: String, + cb: HealthConnectDelegate.Callback) { + // Aggregation is intentionally delegated to the shared code, which + // already computes daylight-saving-correct bucket boundaries and + // the duration-weighted average. Returning nothing here makes the + // portable layer fall back to aggregating the raw samples it read, + // which keeps one implementation of the arithmetic rather than two + // that can disagree. + run(cb) { "" } + } + + override fun insertRecords(recordsTsv: String, + cb: HealthConnectDelegate.Callback) { + run(cb) { + val records = mutableListOf() + recordsTsv.split('\n').forEach { raw -> + toRecord(raw)?.let { records.add(it) } + } + if (records.isEmpty()) { + "" + } else { + requireClient().insertRecords(records) + .recordIdsList.joinToString("\n") + } + } + } + + private fun toRecord(line: String): Record? { + if (line.isBlank()) { + return null + } + val f = line.split('\t') + if (f.size < 6) { + return null + } + val token = f[1] + val start = Instant.ofEpochMilli(f[2].toLong()) + val end = Instant.ofEpochMilli(f[3].toLong()) + val value = f[4].toDouble() + // One offset per endpoint. Deriving both from `start` gave an + // interval crossing a daylight-saving transition the wrong local + // time at its end, which is exactly the metadata Health Connect + // keeps these for. + val rules = java.time.ZoneId.systemDefault().rules + val zone = rules.getOffset(start) + val endZone = rules.getOffset(end) + // Field 6 is the portable RecordingMethod. Dropping it stored every + // sample with the platform default, so other health apps could not + // tell a value the user typed from one a device measured -- which + // is the whole point of the field. + val meta = metadataFor(if (f.size > 6) f[6] else "") + return when (token) { + "steps" -> StepsRecord(startTime = start, endTime = end, + startZoneOffset = zone, endZoneOffset = endZone, + count = wholeCount(token, value), metadata = meta) + + // Only the generic distance type is written. Writing a + // cycling distance as a bare DistanceRecord would erase the + // modality, and it would read back as walking distance. + "distance_walking_running" -> DistanceRecord(startTime = start, + endTime = end, startZoneOffset = zone, endZoneOffset = endZone, + distance = Length.meters(value), metadata = meta) + + "flights_climbed" -> FloorsClimbedRecord(startTime = start, + endTime = end, startZoneOffset = zone, endZoneOffset = endZone, + floors = value, metadata = meta) + + "elevation_gained" -> ElevationGainedRecord(startTime = start, + endTime = end, startZoneOffset = zone, endZoneOffset = endZone, + elevation = Length.meters(value), metadata = meta) + + "active_energy" -> ActiveCaloriesBurnedRecord(startTime = start, + endTime = end, startZoneOffset = zone, endZoneOffset = endZone, + energy = Energy.kilocalories(value), metadata = meta) + + "wheelchair_pushes" -> WheelchairPushesRecord(startTime = start, + endTime = end, startZoneOffset = zone, endZoneOffset = endZone, + count = wholeCount(token, value), metadata = meta) + + "hydration" -> HydrationRecord(startTime = start, endTime = end, + startZoneOffset = zone, endZoneOffset = endZone, + volume = Volume.liters(value), metadata = meta) + + "body_mass" -> WeightRecord(time = start, zoneOffset = zone, + weight = Mass.kilograms(value), metadata = meta) + + "lean_body_mass" -> LeanBodyMassRecord(time = start, + zoneOffset = zone, mass = Mass.kilograms(value), + metadata = meta) + + "bone_mass" -> BoneMassRecord(time = start, zoneOffset = zone, + mass = Mass.kilograms(value), metadata = meta) + + "body_fat_percentage" -> BodyFatRecord(time = start, + zoneOffset = zone, percentage = Percentage(value), + metadata = meta) + + "height" -> HeightRecord(time = start, zoneOffset = zone, + height = Length.meters(value), metadata = meta) + + "resting_heart_rate" -> RestingHeartRateRecord(time = start, + zoneOffset = zone, beatsPerMinute = wholeCount(token, value), + metadata = meta) + + "oxygen_saturation" -> OxygenSaturationRecord(time = start, + zoneOffset = zone, percentage = Percentage(value), + metadata = meta) + + "respiratory_rate" -> RespiratoryRateRecord(time = start, + zoneOffset = zone, rate = value, metadata = meta) + + "body_temperature" -> BodyTemperatureRecord(time = start, + zoneOffset = zone, temperature = Temperature.celsius(value), + metadata = meta) + + "basal_body_temperature" -> BasalBodyTemperatureRecord( + time = start, zoneOffset = zone, + temperature = Temperature.celsius(value), metadata = meta) + + "vo2_max" -> Vo2MaxRecord(time = start, zoneOffset = zone, + vo2MillilitersPerMinuteKilogram = value, metadata = meta) + + "blood_glucose" -> BloodGlucoseRecord(time = start, + zoneOffset = zone, + level = BloodGlucose.millimolesPerLiter(value), metadata = meta) + + // HeartRateRecord is an interval record and rejects + // start == end in its constructor. A heart rate is an instant + // reading, and every flattened series point is one too, so + // without a span of its own every ordinary Android heart-rate + // write threw before it reached the insert. + "heart_rate" -> { + val hrEnd = if (end.isAfter(start)) end + else start.plusMillis(1) + HeartRateRecord(startTime = start, endTime = hrEnd, + startZoneOffset = zone, + // From the end this record actually uses, not from + // the end that was passed in: the synthetic end above + // is computed after both offsets were, so a reading + // written just before a daylight-saving change got a + // record crossing the transition with the start + // side's offset at its end. + endZoneOffset = rules.getOffset(hrEnd), + samples = listOf(HeartRateRecord.Sample(time = start, + beatsPerMinute = wholeCount(token, value))), + metadata = meta) + } + + else -> throw IllegalArgumentException( + "Health Connect writes are not implemented for '" + token + + "' in this build") + } + } + + /** + * Builds Health Connect metadata carrying the portable recording + * method. + * + * Falls back to unspecified metadata when this connect-client release + * does not expose the matching factory, which is better than refusing + * the write. + */ + private fun metadataFor(recordingMethod: String): Metadata { + // connect-client models this as an int on Metadata rather than as + // named factories, and the constructor's other parameters have + // defaults, so only the recording method is supplied here. + val method = when (recordingMethod) { + "MANUAL_ENTRY" -> Metadata.RECORDING_METHOD_MANUAL_ENTRY + "AUTOMATIC", "AUTOMATICALLY_RECORDED" -> + Metadata.RECORDING_METHOD_AUTOMATICALLY_RECORDED + "ACTIVE", "ACTIVELY_RECORDED" -> + Metadata.RECORDING_METHOD_ACTIVELY_RECORDED + else -> Metadata.RECORDING_METHOD_UNKNOWN + } + return Metadata(recordingMethod = method) + } + + override fun deleteRecords(requestJson: String, + cb: HealthConnectDelegate.Callback) { + run(cb) { + val json = JSONObject(requestJson) + val types = json.getJSONArray("types") + // Health Connect deletes by record class, so the portable layer + // sends the type alongside the ids. Deleting the ids under a + // guessed class would delete nothing and still report success. + val classes = (0 until types.length()) + .map { types.getString(it) } + .map { token -> + recordClassFor(token) ?: throw IllegalArgumentException( + "Health Connect deletes are not implemented for '" + + token + "' in this build") + } + if (json.has("ids")) { + val ids = json.getJSONArray("ids") + val list = (0 until ids.length()).map { ids.getString(it) } + classes.forEach { + requireClient().deleteRecords(it, list, emptyList()) + } + // Health Connect reports no affected-row count, and an id + // that is stale or owned by another app deletes nothing + // while still succeeding. Returning the id count claimed + // deletions that may not have happened. + "-1" + } else { + val filter = TimeRangeFilter.between( + Instant.ofEpochMilli(json.getLong("start")), + Instant.ofEpochMilli(json.getLong("end"))) + classes.forEach { + requireClient().deleteRecords(it, filter) + } + // Health Connect does not report how many records a range + // delete removed, so report the honest unknown rather than + // a fabricated count. + "-1" + } + } + } + + override fun getChangesToken(typesCsv: String, + cb: HealthConnectDelegate.Callback) { + run(cb) { + // Every requested type must map. Dropping the unmapped ones + // returned a valid-looking token subscribed to a subset, so an + // app that asked for steps and blood pressure was told the + // subscription succeeded and then never saw a blood-pressure + // change -- the silent nothing this API refuses to produce. + val requested = typesCsv.split(",").filter { it.isNotBlank() } + .map { it.trim() } + val unmapped = requested.filter { recordClassFor(it) == null } + if (unmapped.isNotEmpty()) { + throw IllegalArgumentException( + "Health Connect change subscriptions are not" + + " implemented for " + unmapped.joinToString(", ") + + " in this build") + } + val types = requested.mapNotNull { recordClassFor(it) }.toSet() + if (types.isEmpty()) { + "" + } else { + requireClient().getChangesToken(ChangesTokenRequest(types)) + } + } + } + + override fun getChanges(token: String, + cb: HealthConnectDelegate.Callback) { + run(cb) { + val changes = requireClient().getChanges(token) + val sb = StringBuilder() + sb.append(changes.nextChangesToken).append('\t') + .append(if (changes.changesTokenExpired) "1" else "0") + .append('\t') + .append(if (changes.hasMore) "1" else "0").append('\n') + // The page body was previously discarded, which silently threw + // away every change in the batch while still advancing the + // token, so the data could never be recovered on a later poll. + changes.changes.forEach { change -> + when (change) { + is UpsertionChange -> appendChangedRecord(sb, "+", + change.record) + + is DeletionChange -> sb.append("-").append('\t') + .append(change.recordId).append('\n') + + else -> {} + } + } + sb.toString() + } + } + + /** + * Emits one upserted record as ordinary sample lines prefixed with + * `op`, so a drained change carries its values rather than only an id + * the caller would have to re-query for. + * + * A record shape with no single-value form is still reported, as an + * identity-only line, so the caller learns something changed instead + * of the change being dropped. + */ + private fun appendChangedRecord(sb: StringBuilder, op: String, + record: Record) { + val token = TOKEN_FOR_RECORD[record.javaClass.simpleName] + val body = StringBuilder() + // Always flattened. A subscription has no query on it to carry the + // option, and the change page is a notification of what moved + // rather than a shaped read. + if (token == null + || appendOne(body, record, token, true).lines == 0) { + sb.append(op).append("\t").append(record.metadata.id) + .append('\t').append(token ?: "").append('\n') + return + } + body.toString().split('\n').forEach { + if (it.isNotBlank()) { + sb.append(op).append('\t').append(it).append('\n') + } + } + } + + /** + * Record classes the line format can carry values for. + * + * Session-shaped records are deliberately absent: a sleep session or a + * workout is not a single number and squeezing one into a value line + * would misreport it. They are still deletable and still register for + * change notifications, which is why the two maps differ. + */ + private fun readableRecordClassFor(token: String) = when (token) { + "sleep", "workout" -> null + else -> recordClassFor(token) + } + + /** + * Health Connect's own ceiling on `ReadRecordsRequest.pageSize`. A + * larger value is not clamped by the library, it is rejected. + */ + private val MAX_PAGE_SIZE = 5000 + + /** The most series records one round trip will ask for. */ + private val SERIES_PAGE_RECORDS = 64 + + /** + * How fast a series page may grow towards that maximum. + * + * A page is sized from the density the *previous* one measured, and + * density can jump -- sparse records say nothing about the dense ones + * behind them. Growing gradually caps what such a jump costs at one + * page of the current size instead of one page of the maximum. + */ + private val SERIES_PAGE_GROWTH = 2 + + /** + * How many series records the first page asks for, before anything + * is known about how many points they hold. + * + * Every record fetched is serialized before the ceiling is next + * consulted, so this is the one page whose size cannot be derived + * from measurement -- and the one that has to be small. + */ + private val SERIES_PROBE_RECORDS = 4 + + /** + * The most samples a single series read will gather before it stops + * and hands back a continuation token. + * + * This bounds memory, and nothing else: the caller's limit cannot, + * because the newest sample may sit in a record that starts long + * before the ones already read, so a read that stopped at the limit + * would answer from a prefix. Below this ceiling the answer is exact; + * above it the reply keeps its token and is a bounded prefix that + * says so. + */ + private val SERIES_SCAN_CEILING = 20000 + + /// Whether this type's records hold many samples each. + /// + /// A fact about the record class, not an estimate: these five are the + /// series-shaped types, the same set `appendWholeSeries` handles. + /** + * A count Health Connect can store, or a refusal. + * + * These records take a Long. `toLong()` truncates, so writing 1.9 + * steps stored 1 and reported success -- silently changing health data + * on its way to disk, which is worse than refusing it. Whole values + * pass; anything else is rejected by name so the caller can decide how + * to round. + */ + private fun wholeCount(token: String, value: Double): Long { + if (value != Math.floor(value) || value.isInfinite()) { + throw IllegalArgumentException( + "Health Connect stores " + token + " as a whole number, but" + + " this sample is " + value + + ". Round it before writing.") + } + // Range too, not only integrality. Double.toLong() saturates at the + // bounds rather than overflowing, so a value past them stored a + // different number and reported success -- the same silent rewrite + // the fractional check exists to prevent. + if (value >= LONG_MAX_AS_DOUBLE || value < LONG_MIN_AS_DOUBLE) { + throw IllegalArgumentException( + "Health Connect cannot store " + token + " = " + value + + ": it is outside the range of a 64-bit integer.") + } + return value.toLong() + } + + /// 2^63 exactly, which is one past Long.MAX_VALUE: a double cannot + /// represent Long.MAX_VALUE itself, so the comparison has to exclude + /// this bound rather than include it. + private val LONG_MAX_AS_DOUBLE = Long.MAX_VALUE.toDouble() + + private val LONG_MIN_AS_DOUBLE = Long.MIN_VALUE.toDouble() + + private fun isSeriesToken(token: String) = when (token) { + "heart_rate", "power", "speed", "cycling_cadence", + "running_cadence" -> true + else -> false + } + + private fun recordClassFor(token: String) = when (token) { + "steps" -> StepsRecord::class + // Health Connect has a single DistanceRecord with no modality + // field, so only the generic distance type maps onto it. Aliasing + // the cycling and swimming types here would return the same records + // under whichever label was asked for -- duplicating them when both + // are queried, and reporting a cycle as a run when only one is. + "distance_walking_running" -> DistanceRecord::class + "flights_climbed" -> FloorsClimbedRecord::class + "elevation_gained" -> ElevationGainedRecord::class + "active_energy" -> ActiveCaloriesBurnedRecord::class + "wheelchair_pushes" -> WheelchairPushesRecord::class + "hydration" -> HydrationRecord::class + "heart_rate" -> HeartRateRecord::class + "resting_heart_rate" -> RestingHeartRateRecord::class + "oxygen_saturation" -> OxygenSaturationRecord::class + "respiratory_rate" -> RespiratoryRateRecord::class + "body_temperature" -> BodyTemperatureRecord::class + "basal_body_temperature" -> BasalBodyTemperatureRecord::class + "vo2_max" -> Vo2MaxRecord::class + "blood_glucose" -> BloodGlucoseRecord::class + "body_mass" -> WeightRecord::class + "lean_body_mass" -> LeanBodyMassRecord::class + "bone_mass" -> BoneMassRecord::class + "body_fat_percentage" -> BodyFatRecord::class + "height" -> HeightRecord::class + "power" -> PowerRecord::class + "speed" -> SpeedRecord::class + "cycling_cadence" -> CyclingPedalingCadenceRecord::class + "running_cadence" -> StepsCadenceRecord::class + "sleep" -> SleepSessionRecord::class + "workout" -> ExerciseSessionRecord::class + else -> null + } + + private val TOKEN_FOR_RECORD: Map = mapOf( + "StepsRecord" to "steps", + "DistanceRecord" to "distance_walking_running", + "FloorsClimbedRecord" to "flights_climbed", + "ElevationGainedRecord" to "elevation_gained", + "ActiveCaloriesBurnedRecord" to "active_energy", + "WheelchairPushesRecord" to "wheelchair_pushes", + "HydrationRecord" to "hydration", + "HeartRateRecord" to "heart_rate", + "RestingHeartRateRecord" to "resting_heart_rate", + "OxygenSaturationRecord" to "oxygen_saturation", + "RespiratoryRateRecord" to "respiratory_rate", + "BodyTemperatureRecord" to "body_temperature", + "BasalBodyTemperatureRecord" to "basal_body_temperature", + "Vo2MaxRecord" to "vo2_max", + "BloodGlucoseRecord" to "blood_glucose", + "WeightRecord" to "body_mass", + "LeanBodyMassRecord" to "lean_body_mass", + "BoneMassRecord" to "bone_mass", + "BodyFatRecord" to "body_fat_percentage", + "HeightRecord" to "height", + "PowerRecord" to "power", + "SpeedRecord" to "speed", + "CyclingPedalingCadenceRecord" to "cycling_cadence", + "StepsCadenceRecord" to "running_cadence", + "SleepSessionRecord" to "sleep", + "ExerciseSessionRecord" to "workout") + + /** + * Portable token to Health Connect permission suffix. + * + * This table must stay identical to `PERMISSION_SUFFIX` in + * `HealthManifestFragments`, which decides what the manifest declares. + * A token missing here but present there produces an app that holds a + * permission it can never ask for; the reverse produces a request for + * a permission the manifest never declared, which Health Connect + * rejects outright. `HealthBridgeTokenTableTest` parses this file and + * fails the build when the two drift. + */ + private val PERMISSION_SUFFIX: Map = mapOf( + "steps" to "STEPS", + "distance_walking_running" to "DISTANCE", + "distance_cycling" to "DISTANCE", + "distance_swimming" to "DISTANCE", + "flights_climbed" to "FLOORS_CLIMBED", + "elevation_gained" to "ELEVATION_GAINED", + "active_energy" to "ACTIVE_CALORIES_BURNED", + "basal_energy" to "BASAL_METABOLIC_RATE", + "exercise_time" to "EXERCISE", + "wheelchair_pushes" to "WHEELCHAIR_PUSHES", + "heart_rate" to "HEART_RATE", + "resting_heart_rate" to "RESTING_HEART_RATE", + "walking_heart_rate_average" to "HEART_RATE", + "heart_rate_variability_sdnn" to "HEART_RATE_VARIABILITY", + "oxygen_saturation" to "OXYGEN_SATURATION", + "respiratory_rate" to "RESPIRATORY_RATE", + "body_temperature" to "BODY_TEMPERATURE", + "basal_body_temperature" to "BASAL_BODY_TEMPERATURE", + "vo2_max" to "VO2_MAX", + "blood_pressure" to "BLOOD_PRESSURE", + "blood_glucose" to "BLOOD_GLUCOSE", + "body_mass" to "WEIGHT", + "lean_body_mass" to "LEAN_BODY_MASS", + "bone_mass" to "BONE_MASS", + "body_fat_percentage" to "BODY_FAT", + "height" to "HEIGHT", + "waist_circumference" to "BODY_MEASUREMENTS", + "power" to "POWER", + "speed" to "SPEED", + "cycling_cadence" to "CYCLING_PEDALING_CADENCE", + "running_cadence" to "STEPS_CADENCE", + "hydration" to "HYDRATION", + "dietary_energy" to "NUTRITION", + "nutrition" to "NUTRITION", + "sleep" to "SLEEP", + "workout" to "EXERCISE", + "mindful_session" to "MINDFULNESS", + "menstruation_flow" to "MENSTRUATION", + "intermenstrual_bleeding" to "INTERMENSTRUAL_BLEEDING") + + /** + * Health Connect permission suffix back to every portable token that + * maps onto it. + * + * The relation is many-to-one: one `READ_DISTANCE` grant covers the + * walking, cycling and swimming distance types. Reporting only one of + * them would make an app that asked for `distance_cycling` believe its + * request was refused, so a grant is reported for all of them. + */ + private val TOKENS_FOR_SUFFIX: Map> = + PERMISSION_SUFFIX.entries.groupBy({ it.value }, { it.key }) + + /** Portable token to Health Connect permission string. */ + private fun toHealthPermission(token: String): String? { + val write = token.startsWith("w:") + val name = token.removePrefix("r:").removePrefix("w:") + val suffix = PERMISSION_SUFFIX[name] ?: return null + return "android.permission.health." + + (if (write) "WRITE_" else "READ_") + suffix + } + + /** + * Health Connect permission string back to portable tokens. + * + * The suffix is taken by stripping the direction prefix, not by + * splitting on the last underscore: `READ_ACTIVE_CALORIES_BURNED` + * would otherwise yield `burned`. + */ + private fun toTokens(permission: String): List { + val name = permission.substringAfterLast('.') + val write = name.startsWith("WRITE_") + val suffix = name.removePrefix("READ_").removePrefix("WRITE_") + val prefix = if (write) "w:" else "r:" + val tokens = TOKENS_FOR_SUFFIX[suffix] ?: return emptyList() + return tokens.map { prefix + it } + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AiDependencyTableTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AiDependencyTableTest.java index f3d291de18b..0bdb16e6b38 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AiDependencyTableTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AiDependencyTableTest.java @@ -272,4 +272,70 @@ private static String findPlistDefault(AiDependencyTable.Entry e, String key) { } return null; } + + /** + * Entries match with startsWith, so the com/codename1/health/ prefix + * also matches the sensors subpackage. Anything Health-Connect- or + * HealthKit-specific therefore must NOT live on that entry: an app that + * only reads a heart-rate strap would otherwise pick up the + * androidx.health dependency and, on the Play side, a health-permissions + * review it has no business needing. The gradle dependency lives in + * AndroidGradleBuilder, gated on health usage outside the sensors + * subpackage. + */ + @Test + void healthSensorsDoNotDragInHealthConnect() { + AiDependencyTable.Accumulator acc = new AiDependencyTable.Accumulator(); + acc.consume("com/codename1/health/sensors/HealthSensors"); + for (AiDependencyTable.Entry entry : acc.hits()) { + for (String gav : entry.androidGradleDeps()) { + assertFalse(gav.contains("androidx.health"), + "a sensors-only app must not pull in Health Connect," + + " but matched entry contributed " + gav); + } + } + } + + /** + * The health store entry carries no placeholder privacy strings. + * Apple reviews health purpose strings against what the app actually + * does, so a generic default is what gets an app rejected -- the build + * fails with an actionable message instead of inventing copy. + */ + @Test + void healthEntryShipsNoPlaceholderPrivacyStrings() { + AiDependencyTable.Accumulator acc = new AiDependencyTable.Accumulator(); + acc.consume("com/codename1/health/HealthStore"); + boolean sawHealthEntry = false; + for (AiDependencyTable.Entry entry : acc.hits()) { + for (String[] plist : entry.iosPlistEntries()) { + assertFalse(plist[0].startsWith("NSHealth"), + "no NSHealth* usage description may be defaulted," + + " but found " + plist[0]); + } + sawHealthEntry = true; + } + assertTrue(sawHealthEntry, "the health prefix should match an entry"); + } + + /** + * A sensors app still needs the Bluetooth privacy string, since it is + * doing ordinary BLE. + */ + @Test + void healthSensorsStillGetBluetoothPrivacyString() { + AiDependencyTable.Accumulator acc = new AiDependencyTable.Accumulator(); + acc.consume("com/codename1/health/sensors/HealthSensors"); + boolean found = false; + for (AiDependencyTable.Entry entry : acc.hits()) { + for (String[] plist : entry.iosPlistEntries()) { + if ("NSBluetoothAlwaysUsageDescription".equals(plist[0])) { + found = true; + } + } + } + assertTrue(found, + "a heart-rate-strap app performs BLE and needs the Bluetooth" + + " usage description"); + } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/DescriptorNameTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/DescriptorNameTest.java new file mode 100644 index 00000000000..6ad143734f2 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/DescriptorNameTest.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Class names read out of JVM field descriptors. + * + *

The scanner reports every class a field or local variable is typed + * as, and it read those names out of the descriptor by cutting two + * characters off the tail -- one too many, so every such name lost its + * last letter. Prefix checks still matched, which is why it went + * unnoticed for so long; any check comparing a whole name silently did + * not, and that is how a Bluetooth-only app declaring a + * {@code HealthSample} field was classified as using the health store and + * had Health Connect injected into its build.

+ */ +public class DescriptorNameTest { + + @Test + void aDescriptorYieldsTheWholeClassName() { + assertEquals("com/codename1/health/HealthSample", + Executor.descriptorToInternalName( + "Lcom/codename1/health/HealthSample;")); + assertEquals("com/codename1/health/QuantitySample", + Executor.descriptorToInternalName( + "Lcom/codename1/health/QuantitySample;")); + // Single-letter class, the shortest thing this has to handle. + assertEquals("A", Executor.descriptorToInternalName("LA;")); + } + + /** Primitives and arrays are not class descriptors; pass them through. */ + @Test + void nonClassDescriptorsAreUntouched() { + assertEquals("I", Executor.descriptorToInternalName("I")); + assertEquals("[Lcom/foo/Bar;", + Executor.descriptorToInternalName("[Lcom/foo/Bar;")); + assertNull(Executor.descriptorToInternalName(null)); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthBridgeTokenTableTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthBridgeTokenTableTest.java new file mode 100644 index 00000000000..a7e7d3baf06 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthBridgeTokenTableTest.java @@ -0,0 +1,323 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.Test; + +import java.io.InputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Pins the Kotlin bridge's permission table to the manifest generator's. + * + *

These two tables sit on opposite sides of the build: one decides what + * the manifest declares, the other decides what the app asks for at + * runtime. They are written in different languages so no compiler can + * relate them, and when they drift the failure is silent and + * asymmetric -- a token only the manifest knows produces a permission the + * app can never request, and a token only the bridge knows produces a + * request for a permission that was never declared, which Health Connect + * refuses outright. Neither shows up until an app is on a device.

+ * + *

Reading the Kotlin source is deliberate. The bridge is a resource + * compiled by the app's own Gradle build, so nothing in this repository + * can load it as a class.

+ */ +public class HealthBridgeTokenTableTest { + + private static final String RESOURCE = + "/com/codename1/builders/health/CN1HealthConnectBridge.kt"; + + private static String source() throws Exception { + InputStream in = + HealthBridgeTokenTableTest.class.getResourceAsStream(RESOURCE); + assertNotNull("the Kotlin bridge resource must be on the test" + + " classpath", in); + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int r; + while ((r = in.read(buf)) > 0) { + out.write(buf, 0, r); + } + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } finally { + in.close(); + } + } + + /** Extracts the {@code "token" to "SUFFIX"} pairs from the bridge. */ + private static Map bridgeTable() throws Exception { + String src = source(); + int start = src.indexOf("private val PERMISSION_SUFFIX"); + assertTrue("the bridge must declare PERMISSION_SUFFIX", start > 0); + int end = src.indexOf("private val TOKENS_FOR_SUFFIX", start); + assertTrue("PERMISSION_SUFFIX must be followed by the reverse map", + end > start); + Matcher m = Pattern.compile("\"([a-z0-9_]+)\"\\s+to\\s+\"([A-Z0-9_]+)\"") + .matcher(src.substring(start, end)); + Map out = new TreeMap(); + while (m.find()) { + out.put(m.group(1), m.group(2)); + } + return out; + } + + @Test + public void bridgeAndManifestAgreeOnEveryTokenAndSuffix() + throws Exception { + Map bridge = bridgeTable(); + Map manifest = new TreeMap(); + for (String token : HealthManifestFragments.knownTokens()) { + manifest.put(token, + HealthManifestFragments.permissionSuffix(token)); + } + assertEquals("the bridge and the manifest generator must map the" + + " same tokens onto the same Health Connect permissions", + manifest, bridge); + } + + /** + * The reverse mapping is what a granted permission is reported through. + * Deriving the suffix by splitting on the last underscore -- which is + * the obvious thing to write -- turns READ_ACTIVE_CALORIES_BURNED into + * "burned" and silently reports the grant as unknown. + */ + @Test + public void reverseMappingStripsTheDirectionRatherThanSplitting() + throws Exception { + String src = source(); + int start = src.indexOf("private fun toTokens("); + assertTrue("the bridge must map permissions back to tokens", + start > 0); + String body = src.substring(start, src.indexOf("\n }", start)); + assertTrue("the suffix must come from stripping READ_/WRITE_", + body.contains("removePrefix(\"READ_\")") + && body.contains("removePrefix(\"WRITE_\")")); + assertFalse("substringAfterLast('_') mangles every multi-word" + + " permission, e.g. READ_ACTIVE_CALORIES_BURNED", + body.contains("substringAfterLast('_')")); + } + + /** + * A read the bridge cannot perform has to fail. Falling through to an + * empty result would be indistinguishable from the user genuinely + * having no data, which is the one answer a health API must not guess. + */ + @Test + public void unsupportedReadsAreRejectedRatherThanReturningNothing() + throws Exception { + String src = source(); + int start = src.indexOf("private suspend fun appendRecords("); + assertTrue(start > 0); + String body = src.substring(start, src.indexOf("\n }", start)); + assertTrue("an unreadable type must throw, not return empty", + body.contains("IllegalArgumentException")); + } + + /** + * Health Connect deletes by record class plus id, so a delete that + * hard-codes one class deletes nothing for every other type while + * still reporting the id count as if it had succeeded. + */ + @Test + public void deleteResolvesTheRecordClassFromTheRequest() + throws Exception { + String src = source(); + int start = src.indexOf("override fun deleteRecords("); + assertTrue(start > 0); + String body = src.substring(start, src.indexOf("\n }", start)); + assertTrue("the delete must read the types out of the request", + body.contains("getJSONArray(\"types\")")); + assertTrue("and resolve each one to a record class", + body.contains("recordClassFor")); + } + + /** + * Advancing the change token while dropping the page it came with + * loses those changes permanently: the next poll starts after them. + */ + @Test + public void changeDrainEmitsThePageAndNotJustTheToken() + throws Exception { + String src = source(); + int start = src.indexOf("override fun getChanges("); + assertTrue(start > 0); + String body = src.substring(start, src.indexOf("\n }\n", start)); + assertTrue("the drained changes must be emitted", + body.contains("changes.changes")); + assertTrue("an expired token must be reported so the caller can" + + " resync rather than silently miss data", + body.contains("changesTokenExpired")); + } + + /** + * Health Connect rejects a page size above 5000 rather than clamping + * it, so an unbounded read that passed its own default straight + * through would throw before reading anything. + */ + @Test + public void readsStayWithinTheHealthConnectPageLimit() throws Exception { + String src = source(); + assertTrue("the page ceiling must be declared", + src.contains("MAX_PAGE_SIZE = 5000")); + int start = src.indexOf("private suspend fun appendRecords("); + assertTrue(start > 0); + String body = src.substring(start, src.indexOf("\n }", start)); + assertTrue("the page size must be capped", + body.contains(", MAX_PAGE_SIZE)")); + // pageSize counts records while the caller's limit counts samples, + // and one series record holds many. Asking for `remaining` records + // over-fetched by that factor, and nothing fetched can be given + // back -- the token has already moved past it. + assertTrue("the record budget must be converted from the sample" + + " budget rather than used as-is", + body.contains("samplesPerRecord")); + assertTrue("and further pages must be followed, or a caller asking" + + " for more than one page silently loses the rest", + body.contains("pageToken")); + } + + /** + * Extracts the tokens one Kotlin {@code when} maps to a record class. + */ + private static java.util.Set whenTokens(String fn) + throws Exception { + String src = source(); + int start = src.indexOf(fn); + assertTrue(fn + " must exist in the bridge", start > 0); + String body = src.substring(start, src.indexOf("\n }", start)); + java.util.Set out = new java.util.TreeSet(); + Matcher m = Pattern.compile("\"([a-z0-9_]+)\"").matcher(body); + while (m.find()) { + out.add(m.group(1)); + } + return out; + } + + /** + * The portable layer decides what to advertise as supported on Android + * from a list in HealthWire, and the bridge decides what it can + * actually do from its own {@code when}. When those drift the store + * advertises a type, passes validation, and then fails at read time + * with an invalid-argument error -- which is a worse answer than + * saying the type is unsupported up front. + */ + @Test + public void healthWireAdvertisesExactlyWhatTheBridgeCanRead() + throws Exception { + java.util.Set bridge = whenTokens("private fun recordClassFor("); + // recordClassFor also carries the session types, which register for + // change notifications and can be deleted but have no value form. + bridge.remove("sleep"); + bridge.remove("workout"); + assertEquals("HealthWire.ANDROID_READABLE must match the bridge", + bridge, wireTokens("ANDROID_READABLE")); + } + + @Test + public void healthWireAdvertisesExactlyWhatTheBridgeCanWrite() + throws Exception { + assertEquals("HealthWire.ANDROID_WRITABLE must match the bridge", + whenTokens("private fun toRecord("), + wireTokens("ANDROID_WRITABLE")); + } + + /** Reads one of HealthWire's comma-delimited capability lists. */ + private static java.util.Set wireTokens(String field) + throws Exception { + java.io.File f = new java.io.File("../../CodenameOne/src/com/" + + "codename1/impl/health/HealthWire.java"); + assertTrue("HealthWire.java must be readable at " + f.getPath(), + f.isFile()); + byte[] buf = new byte[(int) f.length()]; + java.io.DataInputStream in = + new java.io.DataInputStream(new java.io.FileInputStream(f)); + try { + in.readFully(buf); + } finally { + in.close(); + } + String src = new String(buf, StandardCharsets.UTF_8); + int start = src.indexOf("ANDROID_" + field.substring(8)); + assertTrue(field + " must exist", start > 0); + String body = src.substring(start, src.indexOf(";", start)); + java.util.Set out = new java.util.TreeSet(); + Matcher m = Pattern.compile("([a-z0-9_]{3,})").matcher(body); + while (m.find()) { + out.add(m.group(1)); + } + return out; + } + + /** + * The rejection list matches what the bridge can service at all. + * + *

Deliberately the union, not a per-direction split. A permission + * covers more than one operation -- write authorises inserts and + * deletes, read covers reads and change subscriptions -- and deletes + * and subscriptions go through {@code recordClassFor}, which is wider + * than the insert and read gates. So each direction's union of + * capabilities is {@code recordClassFor}, and a build cannot know + * which operation an app will use: splitting it refused an app that + * only deletes power records and one that only subscribes to sleep + * changes.

+ * + *

This keeps the list honest in both directions -- adding a + * {@code recordClassFor} branch without removing the token fails the + * build, and so does the reverse.

+ */ + @Test + public void theRejectionListMatchesTheBridge() throws Exception { + String src = source(); + int start = src.indexOf("private fun recordClassFor("); + assertTrue("the bridge must declare recordClassFor", start > 0); + String records = src.substring(start, src.indexOf("\n }", start)); + // readableRecordClassFor is the gate, not recordClassFor: the + // portable layer routes reads, deletes and subscriptions alike + // through isTypeSupported, which answers from the same narrower + // set, so a type it excludes cannot be used for anything. + java.util.List excluded = java.util.Arrays.asList( + "sleep", "workout"); + for (String token : bridgeTable().keySet()) { + boolean serviceable = records.contains("\"" + token + "\"") + && !excluded.contains(token); + assertEquals(token + ": a token no operation can service must be" + + " rejected by the build, and one that any operation" + + " can service must not be", !serviceable, + !HealthManifestFragments.unsupportedTokens( + java.util.Collections.singletonList(token)) + .isEmpty()); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthListenerBindingsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthListenerBindingsTest.java new file mode 100644 index 00000000000..5d1e03ae369 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthListenerBindingsTest.java @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The generated background-listener factory. The point of generating it is + * that the binding is a direct {@code new} rather than a reflective lookup, + * so these cases assert on exactly that. + */ +class HealthListenerBindingsTest { + + /** + * Binary names whose source names need no separator translation -- + * top-level classes, where the two are the same string. + */ + private static Map list(String... values) { + Map out = new LinkedHashMap(); + for (String v : values) { + out.put(v, v); + } + return out; + } + + /** One listener whose binary and source names differ. */ + private static Map named(String binary, String source) { + Map out = new LinkedHashMap(); + out.put(binary, source); + return out; + } + + /** + * The whole reason this generator exists: a direct constructor call is + * a real reference that shrinking and obfuscation follow, whereas a + * Class.forName on a persisted name is not. + */ + @Test + void generatedFactoryConstructsListenersDirectly() { + String src = HealthListenerBindings.generate( + list("com.example.StepWatcher")); + assertNotNull(src); + assertTrue(src.contains("return new com.example.StepWatcher();"), + "the binding must be a direct constructor call"); + assertFalse(src.contains("Class.forName"), + "the generated factory must never reflect"); + assertTrue(src.contains( + "\"com.example.StepWatcher\".equals(className)")); + } + + @Test + void generatedFactoryImplementsTheRuntimeInterface() { + String src = HealthListenerBindings.generate( + list("com.example.StepWatcher")); + assertTrue(src.contains( + "implements HealthBackgroundListenerFactory")); + assertTrue(src.contains( + "HealthStore.setBackgroundListenerFactory")); + assertTrue(src.contains("public static void install()")); + assertTrue(src.startsWith("package " + + HealthListenerBindings.PACKAGE + ";")); + } + + @Test + void severalListenersEachGetABinding() { + String src = HealthListenerBindings.generate( + list("com.example.B", "com.example.A")); + assertTrue(src.contains("return new com.example.A();")); + assertTrue(src.contains("return new com.example.B();")); + // Sorted, so the generated source is stable build to build. + assertTrue(src.indexOf("com.example.A") < src.indexOf("com.example.B")); + } + + @Test + void unknownClassNameReturnsNullRatherThanThrowing() { + String src = HealthListenerBindings.generate( + list("com.example.StepWatcher")); + assertTrue(src.contains("return null;")); + } + + /** + * An app with no background listeners generates nothing and registers + * nothing. The runtime then defers any background delivery instead of + * losing it, so this is a safe no-op rather than a broken build. + */ + @Test + void noListenersGeneratesNothing() { + assertNull(HealthListenerBindings.generate(null)); + assertNull(HealthListenerBindings.generate( + new LinkedHashMap())); + assertNull(HealthListenerBindings.installStatement( + new LinkedHashMap())); + } + + @Test + void installStatementTargetsTheGeneratedClass() { + String stmt = HealthListenerBindings.installStatement( + list("com.example.StepWatcher")); + assertNotNull(stmt); + assertTrue(stmt.startsWith(HealthListenerBindings.FQCN + ".install()")); + } + + @Test + void sourcePathMatchesThePackage() { + assertEquals("com/codename1/health/generated/" + + "CN1HealthListenerBindings.java", + HealthListenerBindings.sourcePath()); + } + + /** + * A nested listener is the common case -- developers put it inside the + * class that subscribes. The persisted key must stay the binary name, + * since that is what Class.getName() returns, but the constructor call + * needs the source name: `new Outer$Inner()` does not compile. + */ + @Test + void nestedListenersUseSourceNamesInTheConstructorCall() { + String src = HealthListenerBindings.generate( + named("com.example.Outer$Inner", "com.example.Outer.Inner")); + assertNotNull(src); + assertTrue(src.contains("\"com.example.Outer$Inner\".equals(className)"), + "the key must be the binary name Class.getName() returns"); + assertTrue(src.contains("return new com.example.Outer.Inner();"), + "the constructor call must use the dotted source name"); + assertFalse(src.contains("new com.example.Outer$Inner()"), + "new Outer$Inner() is not valid Java source"); + } + + @Test + void dollarsInATopLevelNameAreNotSeparators() { + // A dollar is a legal Java identifier character, so this is an + // ordinary top-level class -- and translating the dollar emitted + // `new app.Step.Listener()`, naming a type that does not exist and + // failing javac. The caller supplies the source name from the + // class file's InnerClasses metadata; the generator does not + // guess. + String src = HealthListenerBindings.generate( + named("app.Step$Listener", "app.Step$Listener")); + assertNotNull(src); + assertTrue(src.contains("\"app.Step$Listener\".equals(className)"), + "the key must be the binary name"); + assertTrue(src.contains("return new app.Step$Listener();"), + "a dollar that separates nothing must survive"); + assertFalse(src.contains("new app.Step.Listener()"), + "app.Step is not a type"); + } + + /** A nested class may itself carry a dollar in its simple name. */ + @Test + void aNestedNameKeepsItsOwnDollar() { + String src = HealthListenerBindings.generate( + named("app.Outer$Step$Listener", "app.Outer.Step$Listener")); + assertTrue(src.contains("return new app.Outer.Step$Listener();")); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthListenerScanTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthListenerScanTest.java new file mode 100644 index 00000000000..2e7e484e955 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthListenerScanTest.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Which listeners the generated factory can actually construct. + * + *

Every case here is a way the naive answer -- bind whatever declared + * the interface -- produces source that does not compile, or drops a + * listener that would have worked.

+ */ +class HealthListenerScanTest { + + private static HealthListenerScan scan() { + return new HealthListenerScan(); + } + + /** The ordinary case: a public top-level class implementing it. */ + @Test + void aDirectPublicImplementorIsBound() { + HealthListenerScan s = scan(); + s.implementsInterface("app/Watcher", HealthListenerScan.LISTENER); + s.declaresPublicType("app/Watcher"); + s.declaresType("app/Watcher", "java/lang/Object", true); + + Map out = s.resolve(); + assertEquals(1, out.size()); + assertEquals("app.Watcher", out.get("app.Watcher")); + } + + /** + * The interface declared on an abstract base: the base cannot be + * built, and the concrete subclass never names the interface. + */ + @Test + void aSubclassOfAnAbstractDeclarerIsBoundInstead() { + HealthListenerScan s = scan(); + s.implementsInterface("app/Base", HealthListenerScan.LISTENER); + s.declaresPublicType("app/Base"); + s.declaresType("app/Base", "java/lang/Object", false); + s.declaresPublicType("app/Concrete"); + s.declaresType("app/Concrete", "app/Base", true); + + Map out = s.resolve(); + assertEquals(1, out.size(), "the abstract base must not be bound"); + assertEquals("app.Concrete", out.get("app.Concrete")); + } + + /** The interface reached through the app's own intermediate one. */ + @Test + void anIntermediateInterfaceIsFollowed() { + HealthListenerScan s = scan(); + s.implementsInterface("app/AppListener", + HealthListenerScan.LISTENER); + s.implementsInterface("app/Watcher", "app/AppListener"); + s.declaresPublicType("app/Watcher"); + s.declaresType("app/Watcher", "java/lang/Object", true); + + assertEquals("app.Watcher", s.resolve().get("app.Watcher")); + } + + /** Nothing that cannot be built with new X() is bound. */ + @Test + void aClassWithoutANoArgConstructorIsNotBound() { + HealthListenerScan s = scan(); + s.implementsInterface("app/Watcher", HealthListenerScan.LISTENER); + s.declaresPublicType("app/Watcher"); + s.declaresType("app/Watcher", "java/lang/Object", false); + + assertTrue(s.resolve().isEmpty()); + assertFalse(s.warnings().isEmpty(), + "a listener nothing can construct must be reported"); + } + + /** + * A public nested class inside a package-private outer one cannot be + * named from the generated package, however public it is itself. + */ + @Test + void aListenerInsideANonPublicOuterClassIsNotBound() { + HealthListenerScan s = scan(); + s.implementsInterface("app/Outer$Watcher", + HealthListenerScan.LISTENER); + s.declaresPublicType("app/Outer$Watcher"); + s.declaresType("app/Outer$Watcher", "java/lang/Object", true); + s.declaresEnclosedBy("app/Outer$Watcher", "app/Outer"); + // app/Outer is deliberately not declared public. + + assertTrue(s.resolve().isEmpty()); + assertFalse(s.warnings().isEmpty()); + } + + /** A nested listener whose whole chain is public is named with dots. */ + @Test + void aNestedListenerIsNamedThroughItsOuterClass() { + HealthListenerScan s = scan(); + s.implementsInterface("app/Outer$Watcher", + HealthListenerScan.LISTENER); + s.declaresPublicType("app/Outer$Watcher"); + s.declaresPublicType("app/Outer"); + s.declaresType("app/Outer$Watcher", "java/lang/Object", true); + s.declaresEnclosedBy("app/Outer$Watcher", "app/Outer"); + + assertEquals("app.Outer.Watcher", s.resolve().get("app.Outer$Watcher")); + } + + /** + * A dollar that separates nothing survives: it is a legal Java + * identifier character and this class is top-level. + */ + @Test + void aDollarInATopLevelNameIsNotASeparator() { + HealthListenerScan s = scan(); + s.implementsInterface("app/Step$Watcher", + HealthListenerScan.LISTENER); + s.declaresPublicType("app/Step$Watcher"); + s.declaresType("app/Step$Watcher", "java/lang/Object", true); + + assertEquals("app.Step$Watcher", + s.resolve().get("app.Step$Watcher")); + } + + /** An app with no listeners produces nothing and complains about it. */ + @Test + void anAppWithoutListenersResolvesToNothing() { + HealthListenerScan s = scan(); + s.declaresType("app/Something", "java/lang/Object", true); + + assertFalse(s.sawAnyListener()); + assertTrue(s.resolve().isEmpty()); + assertTrue(s.warnings().isEmpty()); + } + + /** Two listeners come back in a stable order. */ + @Test + void severalListenersAreOrdered() { + HealthListenerScan s = scan(); + for (String cls : new String[] {"app/B", "app/A"}) { + s.implementsInterface(cls, HealthListenerScan.LISTENER); + s.declaresPublicType(cls); + s.declaresType(cls, "java/lang/Object", true); + } + assertEquals("[app.A, app.B]", + s.resolve().keySet().toString()); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthManifestFragmentsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthManifestFragmentsTest.java new file mode 100644 index 00000000000..5cdb1b94016 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthManifestFragmentsTest.java @@ -0,0 +1,441 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The Health Connect manifest fragments. The prefix hazards get dedicated + * cases because a loose duplicate check silently drops a permission, which + * surfaces only as a runtime SecurityException on a user's device. + */ +class HealthManifestFragmentsTest { + + private static List list(String... values) { + List out = new ArrayList(); + for (String v : values) { + out.add(v); + } + return out; + } + + private static int count(String haystack, String needle) { + int n = 0; + int i = haystack.indexOf(needle); + while (i >= 0) { + n++; + i = haystack.indexOf(needle, i + needle.length()); + } + return n; + } + + @Test + void readTokensBecomeReadPermissions() { + String out = HealthManifestFragments.injectPermissions("", + list("steps", "heart_rate"), list(), false, false, 34); + assertTrue(out.contains("android.permission.health.READ_STEPS")); + assertTrue(out.contains("android.permission.health.READ_HEART_RATE")); + assertFalse(out.contains("WRITE_STEPS")); + } + + @Test + void writeTokensBecomeWritePermissions() { + String out = HealthManifestFragments.injectPermissions("", + list(), list("steps"), false, false, 34); + assertTrue(out.contains("android.permission.health.WRITE_STEPS")); + assertFalse(out.contains("READ_STEPS")); + } + + /** + * READ_HEART_RATE is a strict prefix of READ_HEART_RATE_VARIABILITY. + * A substring-based duplicate check would emit only one of them. + */ + @Test + void heartRatePrefixDoesNotSuppressHeartRateVariability() { + String out = HealthManifestFragments.injectPermissions("", + list("heart_rate", "heart_rate_variability_sdnn"), + list(), false, false, 34); + assertTrue(out.contains( + "\"android.permission.health.READ_HEART_RATE\"")); + assertTrue(out.contains( + "\"android.permission.health.READ_HEART_RATE_VARIABILITY\"")); + } + + /** READ_EXERCISE is a strict prefix of READ_EXERCISE_ROUTE. */ + @Test + void exercisePrefixDoesNotSuppressLongerPermissions() { + String out = HealthManifestFragments.injectPermissions("", + list("workout"), list(), false, false, 34); + assertTrue(out.contains("\"android.permission.health.READ_EXERCISE\"")); + } + + /** + * READ_HEALTH_DATA is a strict prefix of + * READ_HEALTH_DATA_IN_BACKGROUND and READ_HEALTH_DATA_HISTORY. + */ + @Test + void backgroundAndHistoryPermissionsCoexist() { + String out = HealthManifestFragments.injectPermissions("", + list("steps"), list(), true, true, 34); + assertTrue(out.contains( + "READ_HEALTH_DATA_IN_BACKGROUND")); + assertTrue(out.contains("READ_HEALTH_DATA_HISTORY")); + } + + /** + * A developer who already declared a permission through + * android.xpermissions must not get a duplicate. + */ + @Test + void alreadyDeclaredPermissionIsNotDuplicated() { + String existing = " \n"; + String out = HealthManifestFragments.injectPermissions(existing, + list("steps"), list(), false, false, 34); + assertEquals(1, count(out, + "\"android.permission.health.READ_STEPS\"")); + } + + /** + * Three distance tokens map to one Health Connect permission, so the + * emitted set must be deduplicated. + */ + @Test + void tokensSharingAPermissionEmitItOnce() { + String out = HealthManifestFragments.injectPermissions("", + list("distance_walking_running", "distance_cycling", + "distance_swimming"), + list(), false, false, 34); + assertEquals(1, count(out, + "\"android.permission.health.READ_DISTANCE\"")); + } + + /** + * A workout asks for no permission of its own. + * + *

This asserted that ACTIVITY_RECOGNITION was emitted, on the + * stated grounds that a recorded workout reads step and exercise + * detection. It does not: the only session this release implements + * rolls up samples the app hands it, and both sensor-collection + * capability queries answer false everywhere. So the assertion was + * defending a sensitive permission that no workout-only app could + * ever exercise, and the Play disclosure that comes with it.

+ */ + @Test + void aWorkoutAsksForNoPermissionOfItsOwn() { + String out = HealthManifestFragments.injectPermissions("", + list("workout"), list(), false, false, 34); + assertFalse(out.contains("android.permission.ACTIVITY_RECOGNITION"), + "nothing in a recorded workout reads activity recognition"); + // No foreground-service permissions either: this release ships no + // foreground service, and requesting permissions the app cannot + // use invites a Play review question with no good answer. + assertFalse(out.contains("android.permission.FOREGROUND_SERVICE")); + assertFalse(out.contains( + "android.permission.FOREGROUND_SERVICE_HEALTH")); + } + + @Test + void foregroundServicePermissionsAreNeverRequested() { + String out = HealthManifestFragments.injectPermissions("", + list("workout"), list(), false, false, 33); + assertFalse(out.contains("android.permission.FOREGROUND_SERVICE")); + assertFalse(out.contains( + "android.permission.FOREGROUND_SERVICE_HEALTH")); + } + + @Test + void queriesDeclareTheProviderExactlyOnce() { + String once = HealthManifestFragments.injectQueries(""); + assertTrue(once.contains(HealthManifestFragments.PROVIDER_PACKAGE)); + String twice = HealthManifestFragments.injectQueries(once); + assertEquals(1, count(twice, + HealthManifestFragments.PROVIDER_PACKAGE)); + } + + /** + * Health Connect refuses to show its consent dialog to an app with no + * rationale activity, so omitting this produces a permission request + * that silently never appears. + */ + @Test + void rationaleActivityIsAlwaysDeclared() { + String out = HealthManifestFragments.injectApplicationEntries("", + 34); + assertTrue(out.contains( + HealthManifestFragments.RATIONALE_ACTIVITY)); + assertTrue(out.contains( + "androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE")); + } + + @Test + void permissionUsageAliasIsApi34Only() { + String modern = HealthManifestFragments.injectApplicationEntries("", + 34); + assertTrue(modern.contains("ViewPermissionUsageActivity")); + String older = HealthManifestFragments.injectApplicationEntries("", 33); + assertFalse(older.contains("ViewPermissionUsageActivity")); + } + + /** + * No workout service is declared, in either configuration. + * + * The manifest used to name a class that does not exist and was never + * started, so it advertised a keepalive the app never had. Declaring a + * missing service is worse than declaring none: it reads as protection + * in a review of the manifest. + */ + @Test + void noWorkoutServiceIsDeclared() { + assertFalse(HealthManifestFragments.injectApplicationEntries("", 34).contains("HealthWorkoutService")); + String withWorkouts = HealthManifestFragments + .injectApplicationEntries("", 34); + assertFalse(withWorkouts.contains("HealthWorkoutService")); + assertFalse(withWorkouts.contains("foregroundServiceType")); + } + + /** + * The activity name legitimately appears twice in a single emission -- + * once as the activity's own name and once as the alias's + * targetActivity -- so idempotence is that a second pass adds nothing, + * not that the name occurs once. + */ + @Test + void applicationEntriesAreNotDuplicated() { + String once = HealthManifestFragments.injectApplicationEntries("", + 34); + String twice = HealthManifestFragments.injectApplicationEntries(once, + 34); + assertEquals(once, twice, "a second pass must add nothing"); + assertEquals(1, count(twice, " unknown = HealthManifestFragments.unknownTokens( + list("steps", "telepathy")); + assertEquals(1, unknown.size()); + assertEquals("telepathy", unknown.get(0)); + assertNull(HealthManifestFragments.permissionFor("telepathy", false)); + } + + @Test + void typeListParsingToleratesWhitespaceAndBlanks() { + List parsed = HealthManifestFragments.parseTypeList( + " steps , heart_rate ,, "); + assertEquals(2, parsed.size()); + assertEquals("steps", parsed.get(0)); + assertEquals("heart_rate", parsed.get(1)); + assertTrue(HealthManifestFragments.parseTypeList(null).isEmpty()); + } + + /** + * The token table duplicates HealthDataType, which the builder cannot + * depend on. This golden list is what makes a core-side addition that + * was not mirrored here fail CI rather than silently produce an app + * missing a permission. + */ + @Test + void tokenSetMatchesTheGoldenList() { + String[] golden = { + "active_energy", "basal_body_temperature", "basal_energy", + "blood_glucose", "blood_pressure", "body_fat_percentage", + "body_mass", "body_temperature", "bone_mass", + "cycling_cadence", "dietary_energy", "distance_cycling", + "distance_swimming", "distance_walking_running", + "elevation_gained", "exercise_time", "flights_climbed", + "heart_rate", "heart_rate_variability_sdnn", "height", + "hydration", "intermenstrual_bleeding", "lean_body_mass", + "menstruation_flow", "mindful_session", "nutrition", + "oxygen_saturation", "power", "respiratory_rate", + "resting_heart_rate", "running_cadence", "sleep", "speed", + "steps", "vo2_max", "waist_circumference", + "walking_heart_rate_average", "wheelchair_pushes", "workout" + }; + List expected = list(golden); + List actual = new ArrayList( + HealthManifestFragments.knownTokens()); + assertEquals(expected, actual, + "HealthDataType and this builder table have diverged; add" + + " the new token here and to the golden list"); + } + + /** + * Health Connect has no BMI permission or record -- BMI is derived from + * weight and height, not stored. The table used to map it onto + * BODY_WATER_MASS, which is an unrelated body-composition datum: the + * app asked users for sensitive data it had no use for, still could not + * read either BMI input, and declared the wrong thing to Play. Leaving + * the token out makes the builder reject it with the list of tokens + * that do work. + */ + @Test + void bodyMassIndexIsNotOfferedOnAndroid() { + assertFalse(HealthManifestFragments.knownTokens() + .contains("body_mass_index")); + assertNull(HealthManifestFragments.permissionSuffix( + "body_mass_index")); + assertEquals(list(new String[] { "body_mass_index" }), + HealthManifestFragments.unknownTokens( + list(new String[] { "steps", "body_mass_index" })), + "the builder must reject it rather than silently declaring" + + " an unrelated permission"); + } + + @Test + void keepRulesCoverGeneratedAndPlatformReachedClasses() { + String rules = HealthManifestFragments.proguardKeepRules( + list("com.example.StepWatcher")); + assertTrue(rules.contains( + HealthManifestFragments.RATIONALE_ACTIVITY)); + assertTrue(rules.contains("CN1HealthConnectBridge")); + assertTrue(rules.contains("HealthConnectDelegate")); + assertTrue(rules.contains("-keepnames class com.example.StepWatcher")); + } + + /** + * The rationale activity resolves this resource by name at runtime, so + * the two spellings have to agree. When they drift the screen a user + * reaches by asking why the app wants their health data comes up blank, + * and nothing fails until then. + */ + @Test + public void policyUrlResourceMatchesTheRationaleActivity() { + assertEquals("cn1_health_privacy_policy", + HealthManifestFragments.POLICY_URL_RESOURCE); + } + + /** + * The read and write directions are classified apart. + * + *

Health Connect permissions are directional. Collapsing both into a + * single "uses health data" flag let an app that only reads satisfy the + * build check with an {@code android.health.write} declaration alone; + * the manifest then carried no read permission and every read failed at + * runtime with nothing in the build log to explain it.

+ */ + @Test + void storeCallsAreClassifiedByDirection() { + for (String read : new String[] {"readSamples", "readWorkouts", + "aggregate", "subscribe", "hasAnyData", "drainChanges"}) { + assertTrue(HealthManifestFragments.isReadCall(read), + read + " reads"); + assertFalse(HealthManifestFragments.isWriteCall(read), + read + " does not write"); + } + for (String write : new String[] {"write", "writeAll", "delete", + "deleteByRange"}) { + assertTrue(HealthManifestFragments.isWriteCall(write), + write + " writes"); + assertFalse(HealthManifestFragments.isReadCall(write), + write + " does not read"); + } + // A delete needs the write permission, not a third kind. + assertTrue(HealthManifestFragments.isWriteCall("delete")); + // Neither direction, so neither hint is demanded. + assertFalse(HealthManifestFragments.isReadCall("isSupported")); + assertFalse(HealthManifestFragments.isWriteCall("isSupported")); + assertFalse(HealthManifestFragments.isReadCall(null)); + assertFalse(HealthManifestFragments.isWriteCall(null)); + } + + /** + * A `kotlin-gradle-plugin` the app declares for itself is visible to + * the build. + * + *

The Gradle generator skips adding its own plugin line when + * `android.topDependency` already declares one, so raising + * `requireKotlinStdlib` to the Health Connect floor changed nothing + * and the bridge was compiled by whatever compiler the app pinned. + * The build has to read what was actually declared.

+ */ + @Test + void declaredKotlinPluginVersionIsReadFromTopDependency() { + assertEquals("1.7.22", + HealthManifestFragments.declaredKotlinPluginVersion( + "classpath 'org.jetbrains.kotlin:" + + "kotlin-gradle-plugin:1.7.22'\n")); + assertEquals("1.9.22", + HealthManifestFragments.declaredKotlinPluginVersion( + "classpath \"org.jetbrains.kotlin:" + + "kotlin-gradle-plugin:1.9.22\"\n")); + // The qualifier is dropped: the caller parses each segment as an + // int, so leaving it on would throw on a version that is fine. + assertEquals("2.0.0", + HealthManifestFragments.declaredKotlinPluginVersion( + "classpath 'org.jetbrains.kotlin:" + + "kotlin-gradle-plugin:2.0.0-Beta1'")); + // No declaration at all, so nothing to enforce against. + assertNull(HealthManifestFragments.declaredKotlinPluginVersion( + "classpath 'com.google.gms:google-services:4.3.15'")); + assertNull(HealthManifestFragments.declaredKotlinPluginVersion("")); + assertNull(HealthManifestFragments.declaredKotlinPluginVersion(null)); + } + + /** + * {@code requestAuthorization} counts as both directions. + * + *

Its {@code HealthAccess} list is built at runtime and is opaque + * to bytecode scanning -- the iOS builder already demands both purpose + * strings for exactly that reason. Matching neither classifier let an + * app that asks for write access while declaring only + * {@code android.health.read} pass the build and ship a manifest + * without the permission it had just requested.

+ */ + @Test + void requestAuthorizationCountsAsBothDirections() { + assertTrue(HealthManifestFragments.isReadCall( + "requestAuthorization")); + assertTrue(HealthManifestFragments.isWriteCall( + "requestAuthorization")); + } + + /** + * A qualified version compares on its numeric prefix. + * + *

{@code 1.9.22-RC2} is a perfectly acceptable Kotlin version, and + * feeding it to a segment-by-segment {@code Integer.parseInt} + * comparison threw and failed the build instead of accepting it.

+ */ + @Test + void qualifiedVersionsCompareOnTheirNumericPrefix() { + assertEquals("1.9.22", + HealthManifestFragments.numericVersionPrefix("1.9.22-RC2")); + assertEquals("2.0.0", + HealthManifestFragments.numericVersionPrefix("2.0.0-Beta1")); + assertEquals("1.9.22", + HealthManifestFragments.numericVersionPrefix("1.9.22")); + assertEquals("1.9", + HealthManifestFragments.numericVersionPrefix("1.9.")); + assertNull(HealthManifestFragments.numericVersionPrefix("RC2")); + assertNull(HealthManifestFragments.numericVersionPrefix("")); + assertNull(HealthManifestFragments.numericVersionPrefix(null)); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthScannerParityTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthScannerParityTest.java new file mode 100644 index 00000000000..783f623d083 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/HealthScannerParityTest.java @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The two platform scanners classify health usage the same way. + * + *

They answer different questions -- one picks Health Connect and + * per-type permissions, the other HealthKit and purpose strings -- but + * they read the same bytecode and must agree on what the app is + * doing. Three separate defects on this branch were the same shape: + * a classification rule fixed in the Android scanner and never mirrored, + * so the iOS build kept the old behaviour until somebody noticed + * independently. This fails the build instead.

+ * + *

It matches on source text rather than behaviour, which is crude; the + * rules live in anonymous visitor callbacks with no seam to call. Crude + * and load-bearing beats absent.

+ */ +public class HealthScannerParityTest { + + private static String source(String simpleName) throws Exception { + File f = new File("src/main/java/com/codename1/builders/" + + simpleName + ".java"); + assertTrue(f.exists(), "scanner source must be readable: " + + f.getAbsolutePath()); + return new String(Files.readAllBytes(f.toPath()), + StandardCharsets.UTF_8); + } + + /** + * Enabling sensor write-through is store use on both platforms. It is + * the one call inside the sensors package that needs the health + * stack, and the package-wide exemption hides it by default. + */ + @Test + void bothScannersTreatSensorWriteThroughAsStoreUse() throws Exception { + for (String builder : new String[] {"AndroidGradleBuilder", + "IPhoneBuilder"}) { + String src = source(builder); + assertTrue(src.contains( + "com/codename1/health/sensors/SensorSessionOptions"), + builder + " must classify setWriteToStore as store use;" + + " the sensors exemption hides it otherwise"); + assertTrue(src.contains("setWriteToStore"), builder); + } + } + + /** + * The shared value types are not store use on either platform. A + * sensor callback names {@code HealthSample} whatever else it does. + */ + @Test + void bothScannersExemptTheSharedModelTypes() throws Exception { + for (String builder : new String[] {"AndroidGradleBuilder", + "IPhoneBuilder"}) { + String src = source(builder); + assertTrue(src.contains("isSharedHealthModel"), + builder + " must exempt the health value types, or a" + + " BLE-only app is dragged into the health" + + " stack by a listener signature"); + } + } + + /** + * Obtaining the workout manager is a write and not a read, on both + * scanners. + * + *

This test asserted the opposite, on the assumption that a + * workout reads sensor data. It does not: nothing in + * {@code com.codename1.health.workout} calls {@code readSamples} or + * {@code aggregate}, the rollup is computed from the samples the app + * fed in, and {@code end()} writes them. Demanding the read direction + * meant a workout-only app could not build without declaring a + * sensitive read permission it never exercises -- which Play policy + * asks you not to request and App Review questions on iOS.

+ */ + @Test + void bothScannersTreatWorkoutsAsAWriteOnly() throws Exception { + for (String builder : new String[] {"AndroidGradleBuilder", + "IPhoneBuilder"}) { + String src = source(builder); + // The classification block, which is the last mention: the + // earlier ones are the facade condition and its comments. + int at = src.lastIndexOf("startsWith(\"getWorkouts\")"); + assertTrue(at > 0, builder + " must classify getWorkouts"); + int end = src.indexOf("\n }", at); + if (end < 0) { + end = Math.min(src.length(), at + 1600); + } + String after = src.substring(at, end); + assertTrue(after.contains("usesHealthWrite = true"), + builder + " must set the write direction at" + + " getWorkouts"); + assertFalse(after.contains("usesHealthRead = true"), + builder + " must not claim a read at getWorkouts; no" + + " workout path reads the store"); + } + } + + /** + * And the same for naming a workout type, which is the other half of + * the classification and drifted from the first. + * + *

Correcting {@code getWorkouts()} alone left this branch marking + * the whole {@code com.codename1.health.workout} package as a read, + * so an app that referenced {@code WorkoutConfiguration} -- which any + * workout app does -- still could not build with only + * {@code android.health.write}. Both branches are checked here now, + * because one of them being right is what made the other look + * right.

+ */ + @Test + void namingAWorkoutTypeIsAWriteOnly() throws Exception { + for (String builder : new String[] {"AndroidGradleBuilder", + "IPhoneBuilder"}) { + String src = source(builder); + int at = src.indexOf("\"com/codename1/health/workout/\") == 0"); + assertTrue(at > 0, + builder + " must classify the workout package"); + int end = src.indexOf("\n }", at); + if (end < 0) { + end = Math.min(src.length(), at + 1600); + } + String after = src.substring(at, end); + assertTrue(after.contains("usesHealthWrite = true"), + builder + " must set the write direction for the" + + " workout package"); + assertFalse(after.contains("usesHealthRead = true"), + builder + " must not claim a read for the workout" + + " package; no workout path reads the store"); + } + } + + /** + * Naming a store type is not using it, so the class-name branch must + * not set {@code usesHealthData} -- that flag is what demands + * {@code android.health.read}/{@code write} hints and fails the build + * without them. + * + *

This is a twin-drift guard rather than a rule the reviewer + * questioned: the BuildDaemon copy of this scanner had already been + * corrected while the plugin copy still set the flag here, so the same + * app built locally demanded hints that the cloud build did not. The + * flag belongs on the real read and write calls, which the + * {@code usesClassMethod} hook sees.

+ */ + @Test + void namingAStoreTypeDoesNotDemandDirectionHints() throws Exception { + String src = source("AndroidGradleBuilder"); + int at = src.indexOf("!isSharedHealthModel(cls)"); + assertTrue(at > 0, "the class-name branch must still be there"); + String branch = src.substring(at, + Math.min(src.length(), at + 200)); + assertFalse(branch.contains("usesHealthData"), + "the class-name branch must not set usesHealthData; it is" + + " set by the read and write call hooks"); + } + + /** + * Both builders generate background-listener bindings. + * + *

iOS left {@code implementsInterface} empty on the reasoning that + * only Health Connect delivery survives process death. Cold launches + * do too: a restored subscription carries only the listener's class + * name, and without generated bindings the runtime cannot turn that + * back into an instance -- so even a manual {@code drainChanges()} + * after a restart delivered nothing at all.

+ */ + @Test + void bothBuildersGenerateListenerBindings() throws Exception { + for (String builder : new String[] {"AndroidGradleBuilder", + "IPhoneBuilder"}) { + String src = source(builder); + assertTrue(src.contains("healthScan.implementsInterface("), + builder + " must feed the shared listener scan"); + assertTrue(src.contains("healthScan.declaresType("), + builder + " must report constructibility, or an" + + " abstract declarer gets bound instead of" + + " its usable subclass"); + assertTrue(src.contains( + "HealthListenerBindings.generate(healthScan.resolve())"), + builder + " must generate from the resolved set"); + assertTrue(src.contains("installStatement("), + builder + " must install them at startup"); + } + } + + /** + * The HealthKit background-delivery entitlement is not inferred from + * polling calls. + * + *

Neither {@code subscribe()} nor {@code drainChanges()} registers + * an {@code HKObserverQuery}, so the entitlement they used to trigger + * bought nothing while demanding a provisioning-profile capability -- + * and getting that wrong fails codesign with an opaque message.

+ */ + @Test + void backgroundDeliveryEntitlementComesOnlyFromTheHint() + throws Exception { + String src = source("IPhoneBuilder"); + assertFalse(src.contains("usesHealthObserver"), + "the inferred observer flag should be gone"); + assertTrue(src.contains("ios.health.backgroundDelivery"), + "the explicit hint still turns it on"); + } + + /** + * A HealthKit sub-capability is read by its value, not by being + * present, and a contradictory base entitlement stops the build. + * + *

Both halves were wrong in the same expression. The long-form + * {@code ios.entitlements.*} keys were tested with {@code != null} + * while the short {@code ios.health.*} aliases beside them tested for + * {@code true} -- so declining a sub-capability read as asking for + * it. And a detected use combined with an explicit + * {@code healthkit=false} injected nothing, signing the app with the + * capability disabled: the build looked healthy and every + * authorization request failed at runtime.

+ */ + @Test + public void theHealthKitGateReadsEntitlementValuesAndRefusesConflict() + throws Exception { + String src = source("IPhoneBuilder"); + int at = src.indexOf("boolean entitleHealthKit"); + assertTrue(at > 0, "the base entitlement gate must exist"); + String gate = src.substring(at, + Math.min(src.length(), at + 1400)); + assertFalse(gate.contains("healthkit.background-delivery\",\n" + + " null) != null"), + "a sub-capability must be read by value, not presence"); + int conflict = src.indexOf("healthKitEntitlement != null"); + assertTrue(conflict > at, + "a detected use with the entitlement explicitly disabled" + + " must be refused rather than silently honoured" + + " or silently overridden"); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchHealthEntitlementTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchHealthEntitlementTest.java new file mode 100644 index 00000000000..a5ec275829e --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/WatchHealthEntitlementTest.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import com.codename1.builders.BuildRequest; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Who gets the HealthKit entitlement on the watch bundle. + * + *

The watch app is signed on its own App ID. Entitling it for HealthKit + * when it never touches health data makes codesigning fail against a + * profile without the capability -- and failing to entitle a watch app + * that does use health makes its authorization request be refused at + * runtime. The phone's privacy strings answer this only when the watch + * runs the phone's code.

+ */ +public class WatchHealthEntitlementTest { + + private static WatchNativeBuilder builder(String watchMain, + String phoneMain, String healthHint, String workoutHint) { + BuildRequest r = new BuildRequest(); + r.setMainClass(phoneMain); + if (watchMain != null) { + r.putArgument("watchMain", watchMain); + } else { + r.putArgument("watchNative.enabled", "true"); + } + if (healthHint != null) { + r.putArgument("watchNative.health", healthHint); + } + if (workoutHint != null) { + r.putArgument("watchNative.health.workoutProcessing", workoutHint); + } + WatchNativeBuilder b = new WatchNativeBuilder(null); + b.parseHints(r); + return b; + } + + /** Sharing the phone's main class means sharing its health usage. */ + @Test + void sharedMainInheritsPhoneHealthUsage() { + assertTrue(builder(null, "com.acme.MyApp", null, null) + .watchUsesHealth(true)); + assertFalse(builder(null, "com.acme.MyApp", null, null) + .watchUsesHealth(false)); + } + + /** + * A watch with its own root shakes its own class graph, so the phone's + * purpose strings say nothing about it. Entitling it anyway is what + * broke codesigning for an ordinary non-health watch app. + */ + @Test + void aDistinctWatchMainIsNotEntitledFromThePhone() { + assertFalse(builder("com.acme.WatchApp", "com.acme.MyApp", null, null) + .watchUsesHealth(true)); + } + + /** The explicit hint settles it in both directions. */ + @Test + void theExplicitHintWins() { + assertTrue(builder("com.acme.WatchApp", "com.acme.MyApp", "true", null) + .watchUsesHealth(false)); + assertFalse(builder(null, "com.acme.MyApp", "false", null) + .watchUsesHealth(true)); + } + + /** A workout session is HealthKit, so it implies the entitlement. */ + @Test + void workoutProcessingImpliesHealth() { + assertTrue(builder("com.acme.WatchApp", "com.acme.MyApp", null, "true") + .watchUsesHealth(false)); + } + + /** + * An entitled watch must have a purpose string of its own. + * + *

{@code watchNative.health=true} on a watch with no phone + * HealthKit hints entitled and signed the bundle while its Info.plist + * carried neither purpose string. The build passed and the watch was + * refused the moment it asked for authorization -- and this build + * never invents a purpose string, so it has to stop instead.

+ */ + @Test + void anEntitledWatchNeedsAPurposeString() { + assertTrue(WatchNativeBuilder.needsPurposeString(true, null, null)); + assertFalse(WatchNativeBuilder.needsPurposeString(true, "Reads", null)); + assertFalse(WatchNativeBuilder.needsPurposeString(true, null, "Writes")); + // Not entitled, so nothing to disclose. + assertFalse(WatchNativeBuilder.needsPurposeString(false, null, null)); + } + + /** + * A whitespace-only purpose string is no purpose string. + * + *

The phone builder already trims these. Here a blank hint emitted + * an empty {@code NSHealthShareUsageDescription} and satisfied the + * check that exists to stop an entitled watch shipping without a + * disclosure.

+ */ + @Test + void aBlankPurposeStringCountsAsMissing() { + assertNull(WatchNativeBuilder.trimToNull(" ")); + assertNull(WatchNativeBuilder.trimToNull("")); + assertNull(WatchNativeBuilder.trimToNull(null)); + assertEquals("Reads workouts", + WatchNativeBuilder.trimToNull(" Reads workouts ")); + assertTrue(WatchNativeBuilder.needsPurposeString(true, + WatchNativeBuilder.trimToNull(" "), + WatchNativeBuilder.trimToNull(null))); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/health/BackgroundListenerFactoryTest.java b/maven/core-unittests/src/test/java/com/codename1/health/BackgroundListenerFactoryTest.java new file mode 100644 index 00000000000..af9c1f9894d --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/health/BackgroundListenerFactoryTest.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import com.codename1.impl.health.LocalHealthStore; +import com.codename1.junit.UITestBase; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Background listeners are bound by a build-generated factory rather than by + * resolving a class name reflectively. Reflection would be invisible to the + * iOS and JavaScript translators' dead-code elimination and would break under + * obfuscation -- on exactly the platform where background delivery is the + * point. These cases pin the resulting contract. + */ +class BackgroundListenerFactoryTest extends UITestBase { + + /** Records deliveries so a test can see the listener actually ran. */ + public static class RecordingListener + implements HealthBackgroundListener { + static final List DELIVERED = + new ArrayList(); + static CountDownLatch latch = new CountDownLatch(1); + + public void healthDataChanged(HealthChangeBatch batch) { + DELIVERED.add(batch); + latch.countDown(); + } + } + + /** A store that lets a test drive fireChanges directly. */ + private static class DrivableStore extends LocalHealthStore { + void deliver(HealthChangeBatch batch) { + fireChanges(batch); + } + } + + @AfterEach + void clearFactory() { + HealthStore.setBackgroundListenerFactory(null); + RecordingListener.DELIVERED.clear(); + RecordingListener.latch = new CountDownLatch(1); + } + + private static HealthChangeBatch batchFor(String subscriptionId) { + return new HealthChangeBatch(subscriptionId, + new ArrayList(), + new ArrayList(), new ArrayList(), + false, HealthAnchor.of("anchor-1"), 5000L, false); + } + + private static SubscriptionRequest request(String id) { + return new SubscriptionRequest(id).addType(HealthDataType.STEPS); + } + + /** + * The generated factory is what binds a persisted subscription to code. + * This is the shape the build server emits: a direct construction, keyed + * on the source class name. + */ + @Test + void generatedFactoryResolvesTheListener() throws Exception { + DrivableStore store = new DrivableStore(); + store.subscribe(request("steps-v1"), RecordingListener.class); + + final String expected = RecordingListener.class.getName(); + HealthStore.setBackgroundListenerFactory( + className -> expected.equals(className) + ? new RecordingListener() : null); + + store.deliver(batchFor("steps-v1")); + waitFor(RecordingListener.latch, 2000); + + assertEquals(1, RecordingListener.DELIVERED.size(), + "the generated binding should have been used"); + store.unsubscribe("steps-v1"); + } + + /** + * With no generated bindings -- the simulator, unit tests, or a build + * predating the generator -- delivery is skipped rather than throwing. + * Crucially the anchor is not advanced, so the data is redelivered later + * rather than lost. + */ + @Test + void missingFactoryDefersDeliveryWithoutLosingData() throws Exception { + DrivableStore store = new DrivableStore(); + store.subscribe(request("steps-v2"), RecordingListener.class); + HealthStore.setBackgroundListenerFactory(null); + + store.deliver(batchFor("steps-v2")); + drainEdt(); + + assertEquals(0, RecordingListener.DELIVERED.size()); + assertNull(com.codename1.io.Preferences.get( + "cn1$health$anchor$steps-v2", null), + "the cursor must not advance past data nobody received"); + store.unsubscribe("steps-v2"); + } + + /** A factory that does not know the class behaves the same way. */ + @Test + void unknownClassDefersRatherThanFailing() throws Exception { + DrivableStore store = new DrivableStore(); + store.subscribe(request("steps-v3"), RecordingListener.class); + HealthStore.setBackgroundListenerFactory(className -> null); + + store.deliver(batchFor("steps-v3")); + drainEdt(); + + assertEquals(0, RecordingListener.DELIVERED.size()); + assertNull(com.codename1.io.Preferences.get( + "cn1$health$anchor$steps-v3", null)); + store.unsubscribe("steps-v3"); + } + + /** + * An in-memory listener needs no factory at all -- it is only the + * after-process-death path that requires a generated binding. + */ + @Test + void inMemoryListenerNeedsNoFactory() throws Exception { + DrivableStore store = new DrivableStore(); + final int[] delivered = new int[1]; + final CountDownLatch latch = new CountDownLatch(1); + store.subscribe(request("steps-v4"), + (HealthChangeListener) batch -> { + delivered[0]++; + latch.countDown(); + }); + + store.deliver(batchFor("steps-v4")); + waitFor(latch, 2000); + + assertEquals(1, delivered[0]); + store.unsubscribe("steps-v4"); + } + + @Test + void subscribingWithANonListenerClassIsRejectedEagerly() { + DrivableStore store = new DrivableStore(); + assertThrows(IllegalArgumentException.class, + () -> store.subscribe(request("bad"), String.class), + "a class that cannot receive callbacks must be rejected at" + + " registration, not discovered weeks later when a" + + " background delivery silently does nothing"); + } + + /** + * Pushes a task through the EDT and waits for it, so anything the store + * queued before it has certainly run. Used by the negative cases, which + * have no delivery of their own to wait on. + */ + private void drainEdt() { + CountDownLatch latch = new CountDownLatch(1); + com.codename1.ui.Display.getInstance().callSerially(latch::countDown); + waitFor(latch, 2000); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/health/FakeHealthStore.java b/maven/core-unittests/src/test/java/com/codename1/health/FakeHealthStore.java new file mode 100644 index 00000000000..df0b061a288 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/health/FakeHealthStore.java @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import com.codename1.util.AsyncResource; + +import java.util.ArrayList; +import java.util.List; + +/** + * A scriptable {@link HealthStore} for testing the shared machinery. + * + *

The port SPI is what the base class drives, so a fake port is the only + * way to test the parts that have historically broken: cursor advancement, + * batch capping, paging and chunked writes. Everything here is + * deterministic and synchronous -- no threads, no clock reads -- so a + * failure points at the logic rather than at timing.

+ */ +public class FakeHealthStore extends HealthStore { + + /** Pages returned by successive doReadSamples calls. */ + public final List pages = new ArrayList(); + /// Queries the base class actually issued, in order. + /// + /// Synchronized, both of them: paging calls doReadSamples for the + /// first page on the caller's thread and for every page after it on + /// the health worker, so an unsynchronized ArrayList is appended from + /// two threads and loses or misplaces entries. That is a race in the + /// fake, and it reads exactly like a race in the code under test -- + /// which is how it presented. + public final List queriesSeen = + java.util.Collections.synchronizedList( + new ArrayList()); + /** The thread each of those queries arrived on, in order. */ + public final List readThreads = + java.util.Collections.synchronizedList( + new ArrayList()); + /** Chunks the base class actually issued to doWrite, in order. */ + public final List> writeChunks = + new ArrayList>(); + /** Index of the write chunk that should fail, or -1 for none. */ + public int failWriteChunk = -1; + /** Batches the port hands to fireChanges when drainChanges runs. */ + public final List batchesToFire = + new ArrayList(); + /** When set, the port drain fails after firing its batches. */ + public HealthException failDrainAfterFiring; + /** Run inside doSubscribe, so a test can hold restoration open. */ + public Runnable duringSubscribe; + /** Captured so a test can answer after the timeout has fired. */ + public AsyncResource lateWrite; + /** When set, doWrite keeps the resource instead of answering. */ + public boolean holdWrite; + + /** Shortens the per-operation safety timeout for a test. */ + public void setOperationTimeoutForTest(int millis) { + setOperationTimeout(millis); + } + + @Override + protected void doSubscribe(SubscriptionRequest request, + HealthSubscription subscription) { + if (duringSubscribe != null) { + duringSubscribe.run(); + } + } + + public int maxWriteBatch = 1000; + public boolean supported = true; + private int pageIndex; + private int writeIndex; + + @Override + public boolean isSupported() { + return supported; + } + + @Override + public boolean isTypeSupported(HealthDataType type) { + return supported && type != null; + } + + /// Types this fake refuses to write, so a caller can reproduce the + /// Health Connect shape where a readable type has no write form. + public final List unwritable = + new ArrayList(); + + @Override + public boolean isWritable(HealthDataType type) { + return isTypeSupported(type) && !unwritable.contains(type); + } + + @Override + public int getMaxWriteBatchSize() { + return maxWriteBatch; + } + + @Override + protected void doReadSamples(SampleQuery query, + AsyncResource out) { + queriesSeen.add(query); + readThreads.add(Thread.currentThread().getName()); + if (pageIndex >= pages.size()) { + out.complete(new SamplePage(new ArrayList(), null, + false)); + return; + } + out.complete(pages.get(pageIndex++)); + } + + @Override + protected void doWrite(List samples, + AsyncResource out) { + int index = writeIndex++; + writeChunks.add(new ArrayList(samples)); + if (holdWrite) { + lateWrite = out; + return; + } + if (index == failWriteChunk) { + out.error(new HealthException(HealthError.UNKNOWN, + "scripted failure on chunk " + index)); + return; + } + HealthWriteResult r = new HealthWriteResult(); + for (int i = 0; i < samples.size(); i++) { + r.addSampleId("chunk" + index + "-" + i); + } + out.complete(r); + } + + /// How many times the port's drain was actually entered. + public int drainCount; + /// Run on entry to the port drain, so a test can overlap two calls. + public Runnable beforeDrain; + + /** Anchors the live handles carried into the last drain. */ + public final List anchorsSeen = + new ArrayList(); + + /** Seeds a cursor the way a port does at registration. */ + public boolean seedForTest(HealthSubscription sub, HealthAnchor anchor) { + return seedAnchor(sub, anchor); + } + + @Override + protected void doDrainChanges(List subscriptions, + AsyncResource out) { + drainCount++; + anchorsSeen.clear(); + for (HealthSubscription sub : subscriptions) { + anchorsSeen.add(sub.getAnchor()); + } + if (beforeDrain != null) { + Runnable r = beforeDrain; + beforeDrain = null; + r.run(); + } + int n = 0; + for (HealthChangeBatch b : batchesToFire) { + // Per delivery queued, not per batch handed in: the shared + // layer splits a batch over the subscription's cap, and + // drainChanges is documented as a count of batches the + // listener received. Counting the input here made the fake + // disagree with both real ports. + n += fireChanges(b); + } + batchesToFire.clear(); + if (failDrainAfterFiring != null) { + // The shape a real port produces when one subscription is + // refused after the healthy ones have already queued their + // batches. + out.error(failDrainAfterFiring); + return; + } + out.complete(Integer.valueOf(n)); + } + + /** A quantity sample of `type` over `[start,end)` with `value`. */ + public static QuantitySample sample(HealthDataType type, long start, + long end, double value) { + HealthQuantity q = new HealthQuantity(value, + type.getCanonicalUnit()); + return start == end ? QuantitySample.create(type, q, start) + : QuantitySample.create(type, q, start, end); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/health/HealthAggregateMathTest.java b/maven/core-unittests/src/test/java/com/codename1/health/HealthAggregateMathTest.java new file mode 100644 index 00000000000..0c2c3e4dbf9 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/health/HealthAggregateMathTest.java @@ -0,0 +1,391 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import com.codename1.impl.health.LocalHealthStore; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Bucket arithmetic. + * + *

Every one of these assertions corresponds to a way the totals have + * been wrong: a workout counted whole in two buckets, a spot reading adding + * a millisecond of "time covered", a calendar bucket reaching outside the + * query and counting time nobody asked for. The arithmetic is shared by + * every platform, so getting it wrong is wrong everywhere at once.

+ */ +class HealthAggregateMathTest { + + private static final long NOON = 1767268800000L; + private static final long HOUR = 3600000L; + private static final long MINUTE = 60000L; + + private static LocalHealthStore store(HealthSample... samples) + throws Exception { + LocalHealthStore s = new LocalHealthStore(); + List in = new ArrayList(); + for (HealthSample sample : samples) { + in.add(sample); + } + s.write(in).get(); + return s; + } + + private static List hourly(LocalHealthStore s, + HealthDataType type, AggregateMetric metric, long from, long to) + throws Exception { + return s.aggregate(new AggregateQuery().addType(type) + .addMetric(metric) + .setTimeRange(HealthTimeRange.between(from, to)) + .setBucket(HealthInterval.hours(1))).get(); + } + + private static double value(AggregateResult r, HealthDataType t, + AggregateMetric m) { + HealthQuantity q = r.get(t, m); + return q == null ? -1 : q.getValue(t.getCanonicalUnit()); + } + + /** + * A two-hour workout across two hourly buckets is one hour in each, + * not two in both. + */ + @Test + void intervalDurationIsClippedToItsBucket() throws Exception { + LocalHealthStore s = store(FakeHealthStore.sample( + HealthDataType.EXERCISE_TIME, NOON, NOON + 2 * HOUR, 120)); + List b = hourly(s, HealthDataType.EXERCISE_TIME, + AggregateMetric.DURATION, NOON, NOON + 2 * HOUR); + assertEquals(2, b.size()); + assertEquals(HOUR, b.get(0).get(HealthDataType.EXERCISE_TIME, + AggregateMetric.DURATION).getValue(HealthUnit.MILLISECOND), + 1.0); + assertEquals(HOUR, b.get(1).get(HealthDataType.EXERCISE_TIME, + AggregateMetric.DURATION).getValue(HealthUnit.MILLISECOND), + 1.0); + } + + /** + * DURATION is time covered, so two samples that overlap count the + * overlap once. Summed instead, a bucket reported more time than it + * is wide -- and both mobile stores fall back to this same shared + * aggregation, so the error was everywhere at once. + */ + @Test + void overlappingIntervalsCountTheOverlapOnce() throws Exception { + LocalHealthStore s = store( + FakeHealthStore.sample(HealthDataType.EXERCISE_TIME, + NOON, NOON + 10 * MINUTE, 10), + FakeHealthStore.sample(HealthDataType.EXERCISE_TIME, + NOON + 5 * MINUTE, NOON + 15 * MINUTE, 10)); + List b = hourly(s, HealthDataType.EXERCISE_TIME, + AggregateMetric.DURATION, NOON, NOON + HOUR); + assertEquals(1, b.size()); + assertEquals(15 * MINUTE, + b.get(0).get(HealthDataType.EXERCISE_TIME, + AggregateMetric.DURATION) + .getValue(HealthUnit.MILLISECOND), 1.0); + } + + /** Disjoint intervals in one bucket still add up. */ + @Test + void disjointIntervalsStillSum() throws Exception { + LocalHealthStore s = store( + FakeHealthStore.sample(HealthDataType.EXERCISE_TIME, + NOON, NOON + 10 * MINUTE, 10), + FakeHealthStore.sample(HealthDataType.EXERCISE_TIME, + NOON + 20 * MINUTE, NOON + 25 * MINUTE, 5)); + List b = hourly(s, HealthDataType.EXERCISE_TIME, + AggregateMetric.DURATION, NOON, NOON + HOUR); + assertEquals(15 * MINUTE, + b.get(0).get(HealthDataType.EXERCISE_TIME, + AggregateMetric.DURATION) + .getValue(HealthUnit.MILLISECOND), 1.0); + } + + /** One interval wholly inside another is not extra time. */ + @Test + void anIntervalContainedInAnotherAddsNothing() throws Exception { + LocalHealthStore s = store( + FakeHealthStore.sample(HealthDataType.EXERCISE_TIME, + NOON, NOON + 30 * MINUTE, 30), + FakeHealthStore.sample(HealthDataType.EXERCISE_TIME, + NOON + 5 * MINUTE, NOON + 10 * MINUTE, 5)); + List b = hourly(s, HealthDataType.EXERCISE_TIME, + AggregateMetric.DURATION, NOON, NOON + HOUR); + assertEquals(30 * MINUTE, + b.get(0).get(HealthDataType.EXERCISE_TIME, + AggregateMetric.DURATION) + .getValue(HealthUnit.MILLISECOND), 1.0); + } + + /** + * A cumulative value spanning two buckets is split in proportion, so + * the buckets still sum to the original. + */ + @Test + void cumulativeValueIsProRatedAcrossBuckets() throws Exception { + LocalHealthStore s = store(FakeHealthStore.sample( + HealthDataType.STEPS, NOON, NOON + 2 * HOUR, 100)); + List b = hourly(s, HealthDataType.STEPS, + AggregateMetric.TOTAL, NOON, NOON + 2 * HOUR); + assertEquals(2, b.size()); + double first = value(b.get(0), HealthDataType.STEPS, + AggregateMetric.TOTAL); + double second = value(b.get(1), HealthDataType.STEPS, + AggregateMetric.TOTAL); + assertEquals(50.0, first, 0.5); + assertEquals(50.0, second, 0.5); + assertEquals(100.0, first + second, 0.5, + "pro-rating must conserve the total"); + } + + /** + * An interval ending exactly on a bucket boundary belongs to the + * earlier bucket only. Counting it in both is how boundary totals + * inflated. + */ + @Test + void intervalEndingOnABoundaryCountsOnce() throws Exception { + LocalHealthStore s = store(FakeHealthStore.sample( + HealthDataType.STEPS, NOON, NOON + HOUR, 60)); + List b = hourly(s, HealthDataType.STEPS, + AggregateMetric.TOTAL, NOON, NOON + 2 * HOUR); + assertEquals(60.0, value(b.get(0), HealthDataType.STEPS, + AggregateMetric.TOTAL), 0.5); + assertNull(b.get(1).get(HealthDataType.STEPS, + AggregateMetric.TOTAL), + "the second bucket saw none of it"); + } + + /** + * Instantaneous samples cover no time. The one-millisecond averaging + * weight is not a duration, and adding it made "total time covered" + * grow with the number of spot readings. + */ + @Test + void instantaneousSamplesContributeNoDuration() throws Exception { + LocalHealthStore s = store( + FakeHealthStore.sample(HealthDataType.BODY_MASS, + NOON + MINUTE, NOON + MINUTE, 70), + FakeHealthStore.sample(HealthDataType.BODY_MASS, + NOON + 2 * MINUTE, NOON + 2 * MINUTE, 71), + FakeHealthStore.sample(HealthDataType.BODY_MASS, + NOON + 3 * MINUTE, NOON + 3 * MINUTE, 72)); + List b = hourly(s, HealthDataType.BODY_MASS, + AggregateMetric.DURATION, NOON, NOON + HOUR); + assertEquals(0.0, b.get(0).get(HealthDataType.BODY_MASS, + AggregateMetric.DURATION).getValue(HealthUnit.MILLISECOND), + 0.001); + } + + /** Spot readings still average, weighted equally. */ + @Test + void instantaneousSamplesStillAverage() throws Exception { + LocalHealthStore s = store( + FakeHealthStore.sample(HealthDataType.BODY_MASS, + NOON + MINUTE, NOON + MINUTE, 70), + FakeHealthStore.sample(HealthDataType.BODY_MASS, + NOON + 2 * MINUTE, NOON + 2 * MINUTE, 72)); + List b = hourly(s, HealthDataType.BODY_MASS, + AggregateMetric.AVERAGE, NOON, NOON + HOUR); + assertEquals(71.0, value(b.get(0), HealthDataType.BODY_MASS, + AggregateMetric.AVERAGE), 0.001); + } + + /** + * A bucket wider than the query must not count time outside the query. + * A daily bucket on a one-hour query is the case that caught this. + */ + @Test + void overlapIsClippedToTheQueryRangeNotJustTheBucket() + throws Exception { + LocalHealthStore s = store(FakeHealthStore.sample( + HealthDataType.STEPS, NOON - 5 * MINUTE, NOON + 5 * MINUTE, + 10)); + // One bucket covering the whole query, but the query starts at noon + // while the sample starts five minutes earlier. + List b = s.aggregate(new AggregateQuery() + .addType(HealthDataType.STEPS) + .addMetric(AggregateMetric.TOTAL) + .setTimeRange(HealthTimeRange.between(NOON, + NOON + 10 * MINUTE))).get(); + assertEquals(1, b.size()); + assertEquals(5.0, value(b.get(0), HealthDataType.STEPS, + AggregateMetric.TOTAL), 0.5, + "only the five minutes inside the query count"); + } + + /** A bucket with no data stays empty rather than reporting zero. */ + @Test + void emptyBucketsAreNullNotZero() throws Exception { + LocalHealthStore s = store(FakeHealthStore.sample( + HealthDataType.STEPS, NOON, NOON + MINUTE, 10)); + List b = hourly(s, HealthDataType.STEPS, + AggregateMetric.TOTAL, NOON, NOON + 2 * HOUR); + assertNotNull(b.get(0).get(HealthDataType.STEPS, + AggregateMetric.TOTAL)); + assertNull(b.get(1).get(HealthDataType.STEPS, + AggregateMetric.TOTAL), + "no data and zero data are different facts"); + } + + /** + * A series contributes its measurements, not just its existence. + * + *

The local and simulator stores hold a series whole and aggregate + * their records directly, without the read path's flattening. Counting + * the record while contributing none of its points produced a null + * AVERAGE, MINIMUM and MAXIMUM for a record full of values -- which + * reads as "no data" rather than as the bug it was.

+ */ + @Test + void aSeriesContributesItsMeasurementsToTheBucket() throws Exception { + long[] at = {NOON + MINUTE, NOON + 2 * MINUTE, NOON + 3 * MINUTE}; + double[] bpm = {60, 70, 80}; + SeriesSample series = SeriesSample.create(HealthDataType.HEART_RATE, + at[0], at[2], at, at, bpm, HealthUnit.COUNT_PER_MINUTE); + + LocalHealthStore s = new LocalHealthStore(); + List in = new ArrayList(); + in.add(series); + s.write(in).get(); + + List b = s.aggregate(new AggregateQuery() + .addType(HealthDataType.HEART_RATE) + .addMetric(AggregateMetric.AVERAGE) + .addMetric(AggregateMetric.MINIMUM) + .addMetric(AggregateMetric.MAXIMUM) + .addMetric(AggregateMetric.COUNT) + .setTimeRange(HealthTimeRange.between(NOON, NOON + HOUR)) + .setBucket(HealthInterval.hours(1))).get(); + + assertEquals(1, b.size()); + assertEquals(70.0, value(b.get(0), HealthDataType.HEART_RATE, + AggregateMetric.AVERAGE), 1e-9); + assertEquals(60.0, value(b.get(0), HealthDataType.HEART_RATE, + AggregateMetric.MINIMUM), 1e-9); + assertEquals(80.0, value(b.get(0), HealthDataType.HEART_RATE, + AggregateMetric.MAXIMUM), 1e-9); + // Three measurements, not one container. The same data read + // through a flattening store reports three, and a count that + // disagreed with the average it was computed from is worse than + // either number alone. + assertEquals(3.0, b.get(0).get(HealthDataType.HEART_RATE, + AggregateMetric.COUNT).getValue(HealthUnit.COUNT), 1e-9); + assertEquals(3, b.get(0).getSampleCount(HealthDataType.HEART_RATE)); + } + + /** + * A series with no measurements is not a record. Writing one expanded + * to no wire records at all, and an empty batch is reported by both + * bridges as a successful write of nothing. + */ + @Test + void anEmptySeriesIsRefused() { + assertThrows(IllegalArgumentException.class, + new org.junit.jupiter.api.function.Executable() { + public void execute() { + SeriesSample.create(HealthDataType.HEART_RATE, + NOON, NOON, new long[0], new long[0], + new double[0], + HealthUnit.COUNT_PER_MINUTE); + } + }); + } + + /** + * An interval ending exactly at the range start is outside it. + * + *

A calendar bucket can begin before the requested range -- a daily + * bucket on a noon-to-midnight query starts at midnight -- and a `<` + * test let an interval ending at noon through. It was then counted and + * fed into the average, minimum and maximum while contributing zero + * overlap, so the first bucket was contaminated by data from outside + * the query.

+ */ + @Test + void anIntervalEndingAtTheRangeStartIsExcluded() throws Exception { + LocalHealthStore s = store( + FakeHealthStore.sample(HealthDataType.HEART_RATE, + NOON - HOUR, NOON, 200), + FakeHealthStore.sample(HealthDataType.HEART_RATE, + NOON + MINUTE, NOON + MINUTE, 60)); + + // A calendar bucket starts at midnight, before the query does, + // which is what exposes the boundary. + List b = s.aggregate(new AggregateQuery() + .addType(HealthDataType.HEART_RATE) + .addMetric(AggregateMetric.MAXIMUM) + .addMetric(AggregateMetric.COUNT) + .setTimeRange(HealthTimeRange.between(NOON, NOON + HOUR)) + .setBucket(HealthInterval.calendarDays(1, + java.util.TimeZone.getTimeZone("UTC")))).get(); + + assertEquals(60.0, value(b.get(0), HealthDataType.HEART_RATE, + AggregateMetric.MAXIMUM), 1e-9, + "the reading that ended at the range start is outside it"); + assertEquals(1.0, b.get(0).get(HealthDataType.HEART_RATE, + AggregateMetric.COUNT).getValue(HealthUnit.COUNT), 1e-9, + "and it is not counted either"); + } + + /** + * A series measurement that straddles the bucket start counts for the + * part inside it. + * + *

Series points were tested on their start alone, so an interval + * measurement running 11:55-12:05 contributed nothing to a noon + * bucket -- while the identical span as a scalar sample contributed + * its five minutes. An interval-only type requires interval + * measurements, so this is the ordinary shape, not an exotic one.

+ */ + @Test + void seriesMeasurementsAreProRatedLikeScalarSamples() throws Exception { + long[] starts = {NOON - 5 * MINUTE}; + long[] ends = {NOON + 5 * MINUTE}; + double[] values = {10}; + SeriesSample series = SeriesSample.create(HealthDataType.STEPS, + starts[0], ends[0], starts, ends, values, HealthUnit.COUNT); + + LocalHealthStore s = new LocalHealthStore(); + List in = new ArrayList(); + in.add(series); + s.write(in).get(); + + List b = s.aggregate(new AggregateQuery() + .addType(HealthDataType.STEPS) + .addMetric(AggregateMetric.TOTAL) + .setTimeRange(HealthTimeRange.between(NOON, + NOON + 10 * MINUTE))).get(); + assertEquals(5.0, value(b.get(0), HealthDataType.STEPS, + AggregateMetric.TOTAL), 0.5, + "only the five minutes inside the query count, as they" + + " would for a scalar sample"); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/health/HealthFallbackTest.java b/maven/core-unittests/src/test/java/com/codename1/health/HealthFallbackTest.java new file mode 100644 index 00000000000..f546da2e31f --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/health/HealthFallbackTest.java @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import com.codename1.junit.UITestBase; +import com.codename1.util.AsyncResource; +import org.junit.jupiter.api.Test; + +import java.util.TimeZone; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The no-op contract of the fallback classes returned on ports without a + * health store. {@code TestCodenameOneImplementation.getHealth()} returns + * {@code null} by default, so {@link Health#getInstance()} must substitute a + * stable non-null instance whose capability queries are all {@code false} and + * whose operations fail fast with {@link HealthError#NOT_SUPPORTED} rather + * than throwing or hanging. + */ +class HealthFallbackTest extends UITestBase { + + @Test + void getInstanceIsNeverNullAndStableWhenPortHasNoHealth() { + assertNull(display.getHealth(), + "test impl should report no port health by default"); + Health a = Health.getInstance(); + Health b = Health.getInstance(); + assertNotNull(a); + assertSame(a, b); + } + + @Test + void fallbackCapabilityQueriesAreAllFalse() { + Health h = Health.getInstance(); + assertFalse(h.isSupported()); + assertEquals(HealthAvailability.NOT_SUPPORTED, h.getAvailability()); + assertTrue(h.getConfigurationProblems().isEmpty()); + } + + @Test + void subFacadesAreNeverNullAndStable() { + Health h = Health.getInstance(); + assertNotNull(h.getStore()); + assertSame(h.getStore(), h.getStore()); + assertNotNull(h.getWorkouts()); + assertSame(h.getWorkouts(), h.getWorkouts()); + assertNotNull(h.getSensors()); + assertSame(h.getSensors(), h.getSensors()); + } + + @Test + void fallbackStoreReportsNoSupportAndNoTypes() { + HealthStore store = Health.getInstance().getStore(); + assertFalse(store.isSupported()); + assertFalse(store.isTypeSupported(HealthDataType.STEPS)); + assertFalse(store.isWritable(HealthDataType.STEPS)); + assertFalse(store.isDeletable(HealthDataType.STEPS)); + assertFalse(store.isBackgroundDeliverySupported()); + assertFalse(store.isPushDelivery()); + assertTrue(store.getSupportedTypes().isEmpty()); + assertTrue(store.getSupportedMetrics(HealthDataType.STEPS).isEmpty()); + assertTrue(store.getSubscriptions().isEmpty()); + } + + @Test + void fallbackAuthorizationStatusIsNotSupportedRatherThanAGuess() { + HealthStore store = Health.getInstance().getStore(); + assertEquals(HealthAuthorizationStatus.NOT_SUPPORTED, + store.getReadAuthorizationStatus(HealthDataType.STEPS)); + assertEquals(HealthAuthorizationStatus.NOT_SUPPORTED, + store.getWriteAuthorizationStatus(HealthDataType.STEPS)); + } + + @Test + void fallbackReadFailsWithNotSupported() { + HealthStore store = Health.getInstance().getStore(); + SampleQuery q = new SampleQuery() + .addType(HealthDataType.STEPS) + .setTimeRange(HealthTimeRange.lastHours(1)); + assertFailedWith(HealthError.NOT_SUPPORTED, store.readSamples(q)); + assertFailedWith(HealthError.NOT_SUPPORTED, store.readSamplePage(q)); + } + + @Test + void fallbackAggregateFailsWithNotSupported() { + HealthStore store = Health.getInstance().getStore(); + AggregateQuery q = new AggregateQuery() + .addType(HealthDataType.STEPS) + .addMetric(AggregateMetric.TOTAL) + .setTimeRange(HealthTimeRange.calendarDays(7, + TimeZone.getTimeZone("UTC"))) + .setBucket(HealthInterval.calendarDays(1, + TimeZone.getTimeZone("UTC"))); + assertFailedWith(HealthError.NOT_SUPPORTED, store.aggregate(q)); + } + + @Test + void fallbackWriteAndDeleteFailWithNotSupported() { + HealthStore store = Health.getInstance().getStore(); + QuantitySample s = QuantitySample.create(HealthDataType.BODY_MASS, + new HealthQuantity(70, HealthUnit.KILOGRAM), 1000L); + assertFailedWith(HealthError.NOT_SUPPORTED, store.write(s)); + assertFailedWith(HealthError.NOT_SUPPORTED, + store.delete(HealthDeleteRequest.byId(HealthDataType.STEPS, "x"))); + } + + @Test + void fallbackAuthorizationRequestFailsWithNotSupported() { + HealthStore store = Health.getInstance().getStore(); + assertFailedWith(HealthError.NOT_SUPPORTED, + store.requestAuthorization( + HealthAccess.read(HealthDataType.STEPS))); + } + + /** + * Unlike the failing operations, the request-status probe resolves rather + * than erroring: it answers a question about the UI ("would a sheet show + * anything?") that has a sensible answer on a port with no health store. + */ + @Test + void fallbackRequestStatusResolvesUnknownRatherThanFailing() { + HealthStore store = Health.getInstance().getStore(); + AsyncResource r = + store.getAuthorizationRequestStatus( + HealthAccess.read(HealthDataType.STEPS)); + assertTrue(r.isDone()); + assertEquals(HealthRequestStatus.UNKNOWN, r.get()); + } + + /** + * {@code drainChanges} is called by the framework on every foreground + * transition, so on a port without health it must be a cheap no-op rather + * than an error the app would have to filter out on every resume. + */ + @Test + void fallbackDrainChangesResolvesZero() { + AsyncResource r = + Health.getInstance().getStore().drainChanges(); + assertTrue(r.isDone()); + assertEquals(Integer.valueOf(0), r.get()); + } + + @Test + void fallbackWorkoutManagerReportsNoLiveSupportButStillRecords() { + assertFalse(Health.getInstance().getWorkouts() + .isLiveSessionSupported()); + assertFalse(Health.getInstance().getWorkouts() + .isSensorCollectionSupported()); + assertNull(Health.getInstance().getWorkouts().getActiveSession()); + } + + @Test + void fallbackOpenersResolveFalseRatherThanFailing() { + AsyncResource settings = + Health.getInstance().openHealthSettings(); + assertTrue(settings.isDone()); + assertEquals(Boolean.FALSE, settings.get()); + + AsyncResource setup = + Health.getInstance().openProviderSetup(); + assertTrue(setup.isDone()); + assertEquals(Boolean.FALSE, setup.get()); + } + + private static void assertFailedWith(HealthError expected, + AsyncResource result) { + assertTrue(result.isDone(), + "fallback operations must fail immediately, not hang"); + Throwable err = errorOf(result); + assertNotNull(err, "expected " + expected + " but the call succeeded"); + assertTrue(err instanceof HealthException, + "expected a HealthException but got " + err); + assertEquals(expected, ((HealthException) err).getError()); + } + + /** + * An {@code except} callback registered on an already-failed resource + * fires synchronously, so the error can be read out without waiting -- + * the same trick {@code BtTestUtil} uses. + */ + private static Throwable errorOf(AsyncResource r) { + final Throwable[] err = new Throwable[1]; + r.except(new com.codename1.util.SuccessCallback() { + public void onSucess(Throwable t) { + err[0] = t; + } + }); + return err[0]; + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/health/HealthRangeSemanticsTest.java b/maven/core-unittests/src/test/java/com/codename1/health/HealthRangeSemanticsTest.java new file mode 100644 index 00000000000..15daf173b44 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/health/HealthRangeSemanticsTest.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import com.codename1.impl.health.LocalHealthStore; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Range membership, which must mean the same thing everywhere. + * + *

Ranges are half-open: `[start,end)`. That single sentence has been + * violated independently in the local store, the aggregate filter, the iOS + * predicate and the single-instant factory, each time producing data that + * silently moved between days or got counted twice. These tests pin the + * rule at the boundary, which is the only place it is ever wrong.

+ */ +class HealthRangeSemanticsTest { + + private static final long NOON = 1767268800000L; + private static final long HOUR = 3600000L; + + private static LocalHealthStore store(HealthSample... samples) + throws Exception { + LocalHealthStore s = new LocalHealthStore(); + List in = new java.util.ArrayList(); + for (HealthSample sample : samples) { + in.add(sample); + } + s.write(in).get(); + return s; + } + + private static List read(LocalHealthStore s, + HealthDataType type, long from, long to) throws Exception { + return s.readSamples(new SampleQuery().addType(type) + .setTimeRange(HealthTimeRange.between(from, to))).get(); + } + + /** + * An interval ending exactly on the start has zero overlap and belongs + * to the previous range. Steps ending at midnight are yesterday's. + */ + @Test + void intervalEndingAtTheStartIsExcluded() throws Exception { + LocalHealthStore s = store(FakeHealthStore.sample( + HealthDataType.STEPS, NOON - HOUR, NOON, 100)); + assertEquals(0, read(s, HealthDataType.STEPS, NOON, + NOON + HOUR).size()); + } + + /** The same interval is inside the range it actually ends within. */ + @Test + void intervalEndingAtTheStartBelongsToThePreviousRange() + throws Exception { + LocalHealthStore s = store(FakeHealthStore.sample( + HealthDataType.STEPS, NOON - HOUR, NOON, 100)); + assertEquals(1, read(s, HealthDataType.STEPS, NOON - HOUR, + NOON).size()); + } + + /** An interval straddling the start overlaps and is included. */ + @Test + void intervalStraddlingTheStartIsIncluded() throws Exception { + LocalHealthStore s = store(FakeHealthStore.sample( + HealthDataType.STEPS, NOON - 300000L, NOON + 300000L, 100)); + assertEquals(1, read(s, HealthDataType.STEPS, NOON, + NOON + HOUR).size()); + } + + /** An instant exactly at the inclusive start is inside. */ + @Test + void instantAtTheStartIsIncluded() throws Exception { + LocalHealthStore s = store(FakeHealthStore.sample( + HealthDataType.BODY_MASS, NOON, NOON, 70)); + assertEquals(1, read(s, HealthDataType.BODY_MASS, NOON, + NOON + HOUR).size()); + } + + /** An instant at the exclusive end is outside. */ + @Test + void instantAtTheEndIsExcluded() throws Exception { + LocalHealthStore s = store(FakeHealthStore.sample( + HealthDataType.BODY_MASS, NOON + HOUR, NOON + HOUR, 70)); + assertEquals(0, read(s, HealthDataType.BODY_MASS, NOON, + NOON + HOUR).size()); + } + + /** + * `at(t)` is documented for querying a single moment, so it has to + * contain that moment. Built as `[t,t)` it contained nothing at all and + * every such query came back empty. + */ + @Test + void singleInstantRangeContainsItsInstant() throws Exception { + HealthTimeRange r = HealthTimeRange.at(NOON).resolve(NOON); + assertTrue(r.getEndMillis() > r.getStartMillis(), + "a zero-width half-open range can never contain anything"); + + LocalHealthStore s = store(FakeHealthStore.sample( + HealthDataType.BODY_MASS, NOON, NOON, 70)); + assertEquals(1, s.readSamples(new SampleQuery() + .addType(HealthDataType.BODY_MASS) + .setTimeRange(HealthTimeRange.at(NOON))).get().size()); + } + + /** + * Adjacent ranges partition the timeline: every sample lands in exactly + * one of them. This is the property all the boundary bugs broke. + */ + @Test + void adjacentRangesNeitherDropNorDuplicate() throws Exception { + LocalHealthStore s = store( + FakeHealthStore.sample(HealthDataType.STEPS, + NOON - HOUR, NOON, 10), + FakeHealthStore.sample(HealthDataType.STEPS, + NOON, NOON + HOUR, 20), + FakeHealthStore.sample(HealthDataType.BODY_MASS, + NOON, NOON, 70)); + int first = read(s, HealthDataType.STEPS, NOON - HOUR, NOON).size(); + int second = read(s, HealthDataType.STEPS, NOON, NOON + HOUR).size(); + assertEquals(2, first + second, + "each interval belongs to exactly one of two adjacent" + + " ranges"); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/health/HealthReadPagingTest.java b/maven/core-unittests/src/test/java/com/codename1/health/HealthReadPagingTest.java new file mode 100644 index 00000000000..9b9d60adc90 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/health/HealthReadPagingTest.java @@ -0,0 +1,269 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import com.codename1.junit.UITestBase; +import com.codename1.util.AsyncResult; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * How a read pages, and where it does its work. + * + *

Both are promises this API makes in writing and neither was kept: + * the documented way to continue a read silently restarted it, and the + * post-processing the class says runs in the background ran on the + * EDT.

+ */ +class HealthReadPagingTest extends UITestBase { + + /** + * A read resumed from a page token starts where the token points. + * + *

The documented continuation is to take + * {@code SamplePage.getNextPageToken()} off a page and hand it back + * through {@code SampleQuery.setPageToken()}. The paging copy dropped + * it and paging then seeded itself with null, so the read restarted + * at the first page -- returning the data the caller already had and + * never reaching the remainder it asked for.

+ */ + @Test + void aReadResumesFromTheSuppliedPageToken() throws Exception { + FakeHealthStore store = new FakeHealthStore(); + List tail = new ArrayList(); + tail.add(FakeHealthStore.sample(HealthDataType.STEPS, + 4000L, 5000L, 9)); + store.pages.add(new SamplePage(tail, null, false)); + + SampleQuery q = new SampleQuery() + .addType(HealthDataType.STEPS) + .setTimeRange(HealthTimeRange.between(0L, 10000L)) + .setPageToken("resume-here"); + store.readSamples(q).get(); + + assertEquals(1, store.queriesSeen.size()); + assertEquals("resume-here", + store.queriesSeen.get(0).getPageToken(), + "the caller's token must reach the port's first page"); + } + + /** + * Post-processing does not run on the thread the port completed on. + * + *

Both mobile ports deliberately complete the raw resource on the + * EDT, so flattening, unit conversion, source filtering and the sort + * all ran there -- and this class promises in as many words that they + * do not. A hundred-thousand-point heart-rate page froze rendering + * for exactly as long as it took to convert.

+ * + *

Observed through where the second page is asked for, + * which is the one thing here that is not a race: the request for it + * is made by the code that finishes post-processing the first, so the + * thread it arrives on is the thread that did that work.

+ */ + @Test + void postProcessingLeavesTheThreadThePortCompletedOn() + throws Exception { + FakeHealthStore store = new FakeHealthStore(); + List first = new ArrayList(); + first.add(FakeHealthStore.sample(HealthDataType.STEPS, + 1000L, 2000L, 3)); + store.pages.add(new SamplePage(first, "page-2", false)); + List second = new ArrayList(); + second.add(FakeHealthStore.sample(HealthDataType.STEPS, + 2000L, 3000L, 4)); + store.pages.add(new SamplePage(second, null, false)); + + String caller = Thread.currentThread().getName(); + List all = store.readSamples(new SampleQuery() + .addType(HealthDataType.STEPS) + .setTimeRange(HealthTimeRange.between(0L, 10000L))).get(); + + assertEquals(2, all.size(), "both pages must be collected"); + assertEquals(2, store.readThreads.size()); + assertEquals(caller, store.readThreads.get(0), + "the first page is asked for by the caller"); + assertEquals("CN1 Health", store.readThreads.get(1), + "the second is asked for by whatever post-processed the" + + " first, and that must not be the caller"); + } + + /** + * A write that fails on its first chunk reports no partial result. + * + *

{@code getPartialResult()} is documented as null unless an + * earlier chunk was committed, and it exists so a caller can retry + * without duplicating what is already stored. An empty-but-present + * result reads as "some of this went in", so a caller written to + * avoid duplicates suppressed a retry that was perfectly safe -- + * and every ordinary single-sample write took that branch, because + * the first chunk is the only chunk.

+ */ + @Test + void aWriteThatFailsOnItsFirstChunkHasNoPartialResult() { + FakeHealthStore store = new FakeHealthStore(); + store.failWriteChunk = 0; + List one = new ArrayList(); + one.add(FakeHealthStore.sample(HealthDataType.STEPS, + 1000L, 2000L, 5)); + + HealthException failed = assertThrows(HealthException.class, + () -> { + try { + store.write(one).get(); + } catch (Throwable t) { + throw t.getCause() instanceof HealthException + ? (HealthException) t.getCause() : t; + } + }); + assertNull(failed.getPartialResult(), + "nothing was committed, so there is nothing partial"); + } + + /** + * A write that fails after a chunk went in reports what did. + * + *

The other half of the same contract: without this the caller + * sees only the failure, retries the whole batch, and writes the + * committed samples a second time.

+ */ + @Test + void aWriteThatFailsLaterReportsTheCommittedChunk() { + FakeHealthStore store = new FakeHealthStore(); + store.maxWriteBatch = 1; + store.failWriteChunk = 1; + List two = new ArrayList(); + two.add(FakeHealthStore.sample(HealthDataType.STEPS, + 1000L, 2000L, 5)); + two.add(FakeHealthStore.sample(HealthDataType.STEPS, + 2000L, 3000L, 6)); + + HealthException failed = assertThrows(HealthException.class, + () -> { + try { + store.write(two).get(); + } catch (Throwable t) { + throw t.getCause() instanceof HealthException + ? (HealthException) t.getCause() : t; + } + }); + assertNotNull(failed.getPartialResult(), + "the first chunk is in the store and must be reported"); + assertEquals(1, + failed.getPartialResult().getSampleIds().size()); + } + + /** + * A blood-pressure reading is converted into the unit the query + * asked for. + * + *

It is not a {@code QuantitySample} -- it carries two quantities + * rather than one -- so it fell straight through the normalizer and + * came back in whatever unit it was stored in. {@code readSamples} + * documents its results as normalized, and validation accepts a + * pressure unit on the query because {@code BLOOD_PRESSURE} is a + * pressure type, so a caller asking for mmHg against a store holding + * kPa was answered in kPa with nothing to say so.

+ */ + @Test + void aBloodPressureReadingIsNormalizedToTheQueryUnit() throws Exception { + FakeHealthStore store = new FakeHealthStore(); + BloodPressureSample stored = BloodPressureSample.create( + new HealthQuantity(16.0, HealthUnit.KILOPASCAL), + new HealthQuantity(10.6666, HealthUnit.KILOPASCAL), + 1000L); + stored.setBodyPosition(BloodPressureSample.POSITION_SITTING); + stored.putMetadata("cuff", "left"); + List page = new ArrayList(); + page.add(stored); + store.pages.add(new SamplePage(page, null, false)); + + List read = store.readSamples(new SampleQuery() + .addType(HealthDataType.BLOOD_PRESSURE) + .setUnit(HealthUnit.MILLIMETER_OF_MERCURY) + .setTimeRange(HealthTimeRange.between(0L, 10000L))).get(); + + assertEquals(1, read.size()); + BloodPressureSample bp = (BloodPressureSample) read.get(0); + assertEquals(120.0, + bp.getSystolic().getValue(HealthUnit.MILLIMETER_OF_MERCURY), + 0.01, "16 kPa is 120 mmHg"); + assertSame(HealthUnit.MILLIMETER_OF_MERCURY, + bp.getSystolic().getUnit(), + "and it must be carried in the unit that was asked for"); + assertSame(HealthUnit.MILLIMETER_OF_MERCURY, + bp.getDiastolic().getUnit()); + assertEquals(BloodPressureSample.POSITION_SITTING, + bp.getBodyPosition(), + "converting must not drop the rest of the reading"); + assertEquals("left", bp.getMetadata().get("cuff")); + } + + /** + * A platform answer that arrives after the timeout is ignored. + * + *

{@code AsyncResource} allows a resource to be completed more + * than once, and every operation here is armed with a timeout -- so a + * write reported TIMEOUT and then reported success on the same + * resource. A caller that retried on the timeout had both inserts + * commit, which is a duplicate record in someone's health data.

+ */ + @Test + void aPlatformAnswerAfterTheTimeoutIsIgnored() throws Exception { + FakeHealthStore store = new FakeHealthStore(); + store.holdWrite = true; + store.setOperationTimeoutForTest(120); + List one = new ArrayList(); + one.add(FakeHealthStore.sample(HealthDataType.STEPS, + 1000L, 2000L, 5)); + + final Throwable[] first = new Throwable[1]; + final int[] outcomes = new int[1]; + final CountDownLatch timedOut = new CountDownLatch(1); + store.write(one).onResult(new AsyncResult() { + public void onReady(HealthWriteResult value, Throwable err) { + outcomes[0]++; + if (first[0] == null) { + first[0] = err; + } + timedOut.countDown(); + } + }); + assertTrue(timedOut.await(5, TimeUnit.SECONDS), + "the timeout must fire"); + assertNotNull(first[0], "and it must arrive as an error"); + + // The platform answers late, as a slow bridge does. + assertNotNull(store.lateWrite); + store.lateWrite.complete(new HealthWriteResult()); + flushSerialCalls(); + assertEquals(1, outcomes[0], + "a late answer must not report a second outcome"); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/health/HealthSubscriptionCursorTest.java b/maven/core-unittests/src/test/java/com/codename1/health/HealthSubscriptionCursorTest.java new file mode 100644 index 00000000000..ece8f880239 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/health/HealthSubscriptionCursorTest.java @@ -0,0 +1,904 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import com.codename1.junit.UITestBase; +import com.codename1.util.AsyncResource; +import com.codename1.util.AsyncResult; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The subscription cursor. + * + *

This is the piece that has broken most often, and always in the same + * shape: a fix that stops data being skipped starts data being repeated, + * or the reverse. The invariant underneath both is simple and is what these + * tests state directly -- every sample is delivered exactly once, and + * the cursor advances only after the last of them has been handed over. + *

+ */ +class HealthSubscriptionCursorTest extends UITestBase { + + private static final long T0 = 1767268800000L; + + /// Subscriptions persist into Preferences, which is process-global, so + /// a test that leaves one behind is seen by every later test -- the + /// fallback store restores it and stops reporting an empty registry. + private final List touched = new ArrayList(); + + @AfterEach + void forgetSubscriptions() { + for (HealthStore store : touched) { + for (HealthSubscription sub : store.getSubscriptions()) { + store.unsubscribe(sub.getId()); + } + } + touched.clear(); + } + + private FakeHealthStore newStore() { + FakeHealthStore store = new FakeHealthStore(); + touched.add(store); + return store; + } + + private static List samples(int n) { + List out = new ArrayList(); + for (int i = 0; i < n; i++) { + out.add(FakeHealthStore.sample(HealthDataType.STEPS, + T0 + i * 1000L, T0 + i * 1000L + 500L, i + 1)); + } + return out; + } + + /// Collects everything a listener is handed, in order. + /// + /// The latch is what the test waits on: delivery hops through + /// callSerially, so counting down here is the only honest signal that + /// the batch actually arrived. + private static class Collector implements HealthChangeListener { + final List seen = new ArrayList(); + final List anchors = new ArrayList(); + final CountDownLatch latch; + int batches; + + Collector(int expectedBatches) { + this.latch = new CountDownLatch(expectedBatches); + } + + public void healthDataChanged(HealthChangeBatch batch) { + batches++; + seen.addAll(batch.getAdded()); + anchors.add(batch.getAnchor()); + latch.countDown(); + } + } + + /** + * A batch larger than the cap is delivered in full across several + * batches, and only the final one carries the anchor. + * + *

Truncating and keeping the anchor lost the tail for good; + * truncating and withholding the anchor re-read the same page forever + * and never reached the tail either. Both passed a naive "does it + * deliver something" check, which is why this asserts on the whole + * sequence.

+ */ + @Test + void aCappedBatchDeliversEverySampleAndAdvancesOnce() { + FakeHealthStore store = newStore(); + Collector listener = new Collector(3); + SubscriptionRequest req = new SubscriptionRequest("cap-test") + .addType(HealthDataType.STEPS) + .setMaxSamplesPerBatch(2); + store.subscribe(req, listener); + + List all = samples(5); + store.batchesToFire.add(new HealthChangeBatch("cap-test", + req.getTypes(), all, null, false, + HealthAnchor.of("cursor-after-page"), 0L, false)); + store.drainChanges(); + waitFor(listener.latch, 5000); + + assertEquals(5, listener.seen.size(), + "every sample in the page must reach the listener"); + assertEquals(3, listener.batches, + "5 samples at a cap of 2 is three batches"); + + int withAnchor = 0; + for (HealthAnchor a : listener.anchors) { + if (a != null) { + withAnchor++; + } + } + assertEquals(1, withAnchor, + "exactly one batch may advance the cursor"); + assertNotNull(listener.anchors.get(listener.anchors.size() - 1), + "and it must be the last one"); + } + + /** + * `drainChanges` resolves with the number of batches the listener + * received, so a capped page counts once per delivery. + * + *

It used to count once per page handed to `fireChanges`, which + * meant three callbacks were reported as one -- the one number a + * caller has to reconcile against what its own listener saw.

+ */ + @Test + void theDrainCountMatchesTheDeliveriesTheListenerSaw() { + FakeHealthStore store = newStore(); + Collector listener = new Collector(3); + SubscriptionRequest req = new SubscriptionRequest("cap-count") + .addType(HealthDataType.STEPS) + .setMaxSamplesPerBatch(2); + store.subscribe(req, listener); + + store.batchesToFire.add(new HealthChangeBatch("cap-count", + req.getTypes(), samples(5), null, false, + HealthAnchor.of("cursor-after-page"), 0L, false)); + AsyncResource drained = store.drainChanges(); + waitFor(listener.latch, 5000); + + assertEquals(3, listener.batches); + assertEquals(Integer.valueOf(3), drained.get(), + "the drain must report what the listener actually got"); + } + + /** An uncapped batch is delivered as one, carrying its anchor. */ + @Test + void anUncappedBatchIsDeliveredWhole() { + FakeHealthStore store = newStore(); + Collector listener = new Collector(1); + SubscriptionRequest req = new SubscriptionRequest("plain") + .addType(HealthDataType.STEPS); + store.subscribe(req, listener); + + store.batchesToFire.add(new HealthChangeBatch("plain", + req.getTypes(), samples(4), null, false, + HealthAnchor.of("c1"), 0L, false)); + store.drainChanges(); + waitFor(listener.latch, 5000); + + assertEquals(1, listener.batches); + assertEquals(4, listener.seen.size()); + assertNotNull(listener.anchors.get(0)); + } + + /** + * A notify-only subscription gets the notification without the + * payload, and still advances. + */ + @Test + void notifyOnlySubscriptionsDropSamplesButKeepTheCursor() { + FakeHealthStore store = newStore(); + Collector listener = new Collector(1); + SubscriptionRequest req = new SubscriptionRequest("quiet") + .addType(HealthDataType.STEPS) + .setDeliverSamples(false); + store.subscribe(req, listener); + + store.batchesToFire.add(new HealthChangeBatch("quiet", + req.getTypes(), samples(3), null, false, + HealthAnchor.of("c1"), 0L, false)); + store.drainChanges(); + waitFor(listener.latch, 5000); + + assertEquals(1, listener.batches); + assertEquals(0, listener.seen.size()); + assertNotNull(listener.anchors.get(0), + "withholding the payload must not stall the cursor"); + } + + /** + * A subscription for a type this store cannot service is refused + * rather than registered and left to fail on every drain. + */ + @Test + void unsupportedStoreRefusesSubscriptions() { + FakeHealthStore store = newStore(); + store.supported = false; + assertThrows(IllegalStateException.class, + new org.junit.jupiter.api.function.Executable() { + public void execute() { + store.subscribe(new SubscriptionRequest("nope") + .addType(HealthDataType.STEPS), + new Collector(1)); + } + }); + } + + /** + * A listener that throws on an early chunk stops the whole page. + * + *

Queuing every chunk up front meant the final one still ran and + * persisted the page anchor, so the chunk the app failed on was + * skipped for good. The cursor must not move past data the listener + * could not handle.

+ */ + @Test + void aThrowingListenerStopsTheRestOfThePage() { + FakeHealthStore store = newStore(); + final CountDownLatch first = new CountDownLatch(1); + final int[] calls = new int[1]; + HealthChangeListener thrower = new HealthChangeListener() { + public void healthDataChanged(HealthChangeBatch batch) { + calls[0]++; + first.countDown(); + throw new IllegalStateException("app cannot handle this"); + } + }; + SubscriptionRequest req = new SubscriptionRequest("throwing") + .addType(HealthDataType.STEPS) + .setMaxSamplesPerBatch(2); + store.subscribe(req, thrower); + + store.batchesToFire.add(new HealthChangeBatch("throwing", + req.getTypes(), samples(5), null, false, + HealthAnchor.of("after-page"), 0L, false)); + store.drainChanges(); + waitFor(first, 5000); + // Then settle, so a wrongly-queued later chunk would have run. + com.codename1.testing.TestUtils.waitFor(300); + + assertEquals(1, calls[0], + "delivery stops at the chunk the listener rejected"); + assertNull(store.getSubscriptions().get(0).getAnchor(), + "and the cursor must not have advanced"); + } + + /** + * Delivery options survive a restart. A notify-only subscription that + * starts delivering full payloads after a relaunch is a privacy + * surprise, not just a performance one. + */ + @Test + void deliveryOptionsSurviveRestore() { + FakeHealthStore first = newStore(); + first.subscribe(new SubscriptionRequest("persisted") + .addType(HealthDataType.STEPS) + .setDeliverSamples(false) + .setIncludeDeletions(false) + .setMaxSamplesPerBatch(7), new Collector(1)); + + // A fresh store restores from the same preferences. + FakeHealthStore restored = newStore(); + List subs = restored.getSubscriptions(); + HealthSubscription found = null; + for (HealthSubscription sub : subs) { + if ("persisted".equals(sub.getId())) { + found = sub; + } + } + assertNotNull(found, "the subscription must come back at all"); + + Collector listener = new Collector(1); + restored.subscribe(new SubscriptionRequest("persisted") + .addType(HealthDataType.STEPS) + .setDeliverSamples(false) + .setIncludeDeletions(false) + .setMaxSamplesPerBatch(7), listener); + restored.batchesToFire.add(new HealthChangeBatch("persisted", + new java.util.ArrayList(), samples(3), null, + false, HealthAnchor.of("c"), 0L, false)); + restored.drainChanges(); + waitFor(listener.latch, 5000); + assertEquals(0, listener.seen.size(), + "restored notify-only must stay notify-only"); + } + + /** + * Cancelling a subscription mid-page does not gate every later drain. + * + *

Delivery is queued one chunk at a time and each chunk reports + * whether it queued the next, so a drain can wait for the page to + * finish before it resolves. A chunk that finds its subscription + * cancelled queues nothing -- but it used to report otherwise, so the + * chunks behind it stayed counted as outstanding for the life of the + * process, and every later drain waited on deliveries that would never + * happen.

+ * + *

The listener cancels itself, which places the cancellation + * exactly between two chunks rather than leaving it to a race. A + * second subscription stays registered, because a drain with nothing + * registered resolves without consulting the counter at all and would + * hide the leak.

+ */ + @Test + void cancellingMidPageDoesNotStallLaterDrains() throws Exception { + final FakeHealthStore store = newStore(); + store.subscribe(new SubscriptionRequest("keeper") + .addType(HealthDataType.STEPS), new Collector(1)); + + SubscriptionRequest req = new SubscriptionRequest("cancelled") + .addType(HealthDataType.STEPS) + .setMaxSamplesPerBatch(1); + final CountDownLatch first = new CountDownLatch(1); + store.subscribe(req, new HealthChangeListener() { + public void healthDataChanged(HealthChangeBatch batch) { + store.unsubscribe("cancelled"); + first.countDown(); + } + }); + + // Five chunks; the second finds the subscription gone and the + // three behind it are never queued at all. + store.batchesToFire.add(new HealthChangeBatch("cancelled", + req.getTypes(), samples(5), null, false, + HealthAnchor.of("c1"), 0L, false)); + store.drainChanges(); + assertTrue(first.await(5, java.util.concurrent.TimeUnit.SECONDS), + "the first chunk must be delivered"); + + final CountDownLatch drained = new CountDownLatch(1); + store.drainChanges().onResult( + new com.codename1.util.AsyncResult() { + public void onReady(Integer value, Throwable err) { + drained.countDown(); + } + }); + assertTrue(drained.await(5, java.util.concurrent.TimeUnit.SECONDS), + "a drain must not wait on chunks that were never queued"); + } + + /** + * Re-registering an id resumes from the persisted cursor. + * + *

The saved anchor reached `doSubscribe` alone, which neither + * mobile store overrides, while both drains read `sub.getAnchor()`. + * Replacing a listener -- or restoring a subscription at launch -- + * therefore started the next drain from a fresh baseline and silently + * discarded every change accumulated since the last one.

+ */ + @Test + void replacingASubscriptionKeepsItsCursor() { + FakeHealthStore store = newStore(); + SubscriptionRequest req = new SubscriptionRequest("resumed") + .addType(HealthDataType.STEPS); + Collector first = new Collector(1); + store.subscribe(req, first); + store.batchesToFire.add(new HealthChangeBatch("resumed", + req.getTypes(), samples(1), null, false, + HealthAnchor.of("cursor-1"), 0L, false)); + store.drainChanges(); + waitFor(first.latch, 5000); + + HealthSubscription replaced = store.subscribe(req, new Collector(1)); + assertNotNull(replaced.getAnchor(), + "a replacement must not start from a fresh baseline"); + assertEquals("cursor-1", replaced.getAnchor().toStorableString()); + } + + /** + * Replacing a background subscription with an in-memory one drops the + * persisted binding. + * + *

Otherwise the next launch restores the old background listener + * class and delivers the replacement's changes to exactly the listener + * the app had replaced.

+ */ + @Test + void anInMemoryListenerClearsAStaleBackgroundBinding() { + FakeHealthStore store = newStore(); + SubscriptionRequest req = new SubscriptionRequest("rebound") + .addType(HealthDataType.STEPS); + store.subscribe(req, TestBackgroundListener.class); + assertNotNull(com.codename1.io.Preferences.get( + "cn1$health$listener$rebound", null), + "the background binding is persisted"); + + store.subscribe(req, new Collector(1)); + assertNull(com.codename1.io.Preferences.get( + "cn1$health$listener$rebound", null), + "an in-memory listener must not leave the old class bound"); + } + + /** A no-op background listener, only ever named. */ + public static class TestBackgroundListener + implements HealthBackgroundListener { + public void healthDataChanged(HealthChangeBatch batch) { + } + } + + /** + * Overlapping drains read the change window once. + * + *

Both callers used to snapshot the same anchors and enter the + * port's drain, so the platform read the same window twice, every + * batch was delivered twice, and two callbacks raced to persist the + * cursor. The second call now resolves alongside the first rather + * than starting work of its own.

+ */ + @Test + void overlappingDrainsAreCoalesced() throws Exception { + FakeHealthStore store = newStore(); + SubscriptionRequest req = new SubscriptionRequest("coalesce") + .addType(HealthDataType.STEPS); + Collector listener = new Collector(1); + store.subscribe(req, listener); + store.batchesToFire.add(new HealthChangeBatch("coalesce", + req.getTypes(), samples(2), null, false, + HealthAnchor.of("c1"), 0L, false)); + + // The fake completes its drain synchronously, so the second call + // has to be made from inside the first to overlap it at all. + store.beforeDrain = new Runnable() { + public void run() { + store.drainChanges(); + } + }; + store.drainChanges(); + waitFor(listener.latch, 5000); + + assertEquals(1, store.drainCount, + "the second drain must not reach the port"); + assertEquals(2, listener.seen.size(), + "and the batch is delivered once, not twice"); + } + + /** + * A cursor seeded at registration reaches the live handle, not only + * Preferences. + * + *

The drains read the handle. Persisting alone looked correct and + * did nothing: the next drain still found a null anchor, took a fresh + * cursor, and skipped exactly the window the seed existed to + * cover.

+ */ + @Test + void aSeededCursorReachesTheLiveSubscription() { + FakeHealthStore store = newStore(); + Collector listener = new Collector(1); + // A fresh id every run: the persisted cursor outlives the JVM's + // Preferences, and subscribe() seeds a new handle from it -- so a + // reused id made this pass on a cursor left by an earlier run + // rather than on the one seeded here. + String id = "seeded-" + System.nanoTime(); + SubscriptionRequest req = new SubscriptionRequest(id) + .addType(HealthDataType.STEPS); + HealthSubscription sub = store.subscribe(req, listener); + store.drainChanges(); + assertEquals(1, store.anchorsSeen.size()); + assertNull(store.anchorsSeen.get(0), + "a new subscription starts with no cursor"); + + assertTrue(store.seedForTest(sub, HealthAnchor.of("baseline-token"))); + store.drainChanges(); + + assertEquals(1, store.anchorsSeen.size()); + assertNotNull(store.anchorsSeen.get(0), + "the drain must see the seeded cursor on the handle"); + assertEquals("baseline-token", + store.anchorsSeen.get(0).toStorableString()); + store.unsubscribe(id); + } + + /** + * A cursor issued for a subscription that has since been cancelled is + * dropped, not applied. + * + *

The platform call that produces it starts before the + * cancellation and can land arbitrarily late. Applying it then would + * restore a cursor {@code unsubscribe()} promised to discard, and -- + * worse -- hand it to a fresh subscription that happens to reuse the + * id, which may be watching an entirely different set of types.

+ */ + @Test + void aCursorSeededForACancelledSubscriptionIsDropped() { + FakeHealthStore store = newStore(); + String id = "stale-" + System.nanoTime(); + SubscriptionRequest req = new SubscriptionRequest(id) + .addType(HealthDataType.STEPS); + HealthSubscription first = store.subscribe(req, new Collector(1)); + store.unsubscribe(id); + + assertFalse(store.seedForTest(first, HealthAnchor.of("stale-token")), + "a seed for a stopped subscription must be refused"); + assertNull(com.codename1.io.Preferences.get( + "cn1$health$anchor$" + id, null), + "and it must not reach the persisted cursor either"); + + // The same id, registered again while that answer was in flight. + HealthSubscription second = store.subscribe( + new SubscriptionRequest(id).addType(HealthDataType.BODY_MASS), + new Collector(1)); + assertFalse(store.seedForTest(first, HealthAnchor.of("stale-token")), + "and the replacement must not inherit it"); + assertNull(second.getAnchor()); + store.unsubscribe(id); + } + + /** + * A seed never rewinds a cursor that has already moved. + * + *

A drain that runs while the starting cursor is being issued + * establishes one of its own. Letting the late answer overwrite it + * would send the next drain back to an earlier point and re-deliver + * changes the app has already been told about.

+ */ + @Test + void aSeedDoesNotRewindACursorThatHasAdvanced() { + FakeHealthStore store = newStore(); + String id = "advanced-" + System.nanoTime(); + HealthSubscription sub; + + Collector waiting = new Collector(1); + store.subscribe(new SubscriptionRequest(id) + .addType(HealthDataType.STEPS), waiting); + sub = store.getSubscriptions().get(0); + List one = new ArrayList(); + one.add(FakeHealthStore.sample(HealthDataType.STEPS, 1000L, 2000L, + 7)); + store.batchesToFire.add(new HealthChangeBatch(id, + sub.getTypes(), one, null, false, + HealthAnchor.of("live-token"), 0L, false)); + store.drainChanges(); + waitFor(waiting.latch, 5000); + // The latch counts down inside the listener, and the cursor is + // stored by the rest of that same runnable -- so waiting on the + // latch alone races the store rather than following it. + flushSerialCalls(); + assertEquals("live-token", sub.getAnchor().toStorableString()); + + assertFalse(store.seedForTest(sub, HealthAnchor.of("baseline-token")), + "a baseline that lands late must not rewind the cursor"); + assertEquals("live-token", sub.getAnchor().toStorableString()); + store.unsubscribe(id); + } + + /** + * A drain that fails still waits for the deliveries it already + * queued. + * + *

A drain can fail on its last subscription having already handed + * batches over for the healthy ones. Completing straight away let the + * error callback start the next drain before those had persisted + * their cursors, so the same window was read and delivered twice. + * That went from a corner case to the ordinary one when the ports + * stopped swallowing a skipped subscription.

+ * + *

Held deterministically rather than raced: the listener blocks + * until this test releases it, so the delivery is unambiguously + * outstanding at the moment the port reports its failure.

+ */ + @Test + void aFailedDrainWaitsForTheBatchesItAlreadyQueued() throws Exception { + final FakeHealthStore store = newStore(); + String id = "failed-drain-" + System.nanoTime(); + final CountDownLatch hold = new CountDownLatch(1); + final CountDownLatch delivering = new CountDownLatch(1); + HealthSubscription sub = store.subscribe( + new SubscriptionRequest(id).addType(HealthDataType.STEPS), + new HealthChangeListener() { + public void healthDataChanged(HealthChangeBatch b) { + delivering.countDown(); + try { + hold.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ex) { + throw new RuntimeException(ex); + } + } + }); + + store.batchesToFire.add(new HealthChangeBatch(id, sub.getTypes(), + samples(2), null, false, HealthAnchor.of("after-batch"), + 0L, false)); + store.failDrainAfterFiring = new HealthException( + HealthError.DATABASE_INACCESSIBLE, "device locked"); + + final Throwable[] reported = new Throwable[1]; + final CountDownLatch done = new CountDownLatch(1); + AsyncResource drain = store.drainChanges(); + drain.onResult(new AsyncResult() { + public void onReady(Integer value, Throwable err) { + reported[0] = err; + done.countDown(); + } + }); + + assertTrue(delivering.await(5, TimeUnit.SECONDS), + "the batch must reach the listener"); + assertFalse(drain.isDone(), + "the drain must not report its failure while a delivery" + + " it queued is still running"); + + hold.countDown(); + assertTrue(done.await(5, TimeUnit.SECONDS), + "and it must report once that delivery has finished"); + assertNotNull(reported[0], "the failure must reach the caller"); + assertEquals("after-batch", + store.getSubscriptions().get(0).getAnchor() + .toStorableString(), + "with the delivered batch's cursor persisted"); + store.unsubscribe(id); + } + + /** + * A second thread waits for restoration rather than walking into a + * half-filled registry. + * + *

The restored flag used to be published before the work was + * done, so a concurrent caller saw "restored" against a registry + * still being filled. It could unsubscribe an id restoration had read + * but not yet inserted -- deleting the persisted cursor -- and the + * restoring thread would then put that subscription back and call + * {@code doSubscribe} for it: a cancelled subscription resurrected, + * watching data the app had told it to stop watching.

+ */ + @Test + void aConcurrentCallerWaitsForRestorationToFinish() throws Exception { + String id = "restore-race-" + System.nanoTime(); + FakeHealthStore first = newStore(); + first.subscribe(new SubscriptionRequest(id) + .addType(HealthDataType.STEPS), new Collector(1)); + + // A fresh store restores that persisted subscription on first + // touch, and is held inside doSubscribe until this test lets go. + final FakeHealthStore restoring = new FakeHealthStore(); + final CountDownLatch held = new CountDownLatch(1); + final CountDownLatch release = new CountDownLatch(1); + restoring.duringSubscribe = new Runnable() { + public void run() { + held.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + }; + Thread restorer = new Thread(new Runnable() { + public void run() { + restoring.getSubscriptions(); + } + }); + restorer.start(); + assertTrue(held.await(5, TimeUnit.SECONDS), + "restoration must reach doSubscribe"); + + final CountDownLatch second = new CountDownLatch(1); + Thread other = new Thread(new Runnable() { + public void run() { + restoring.getSubscriptions(); + second.countDown(); + } + }); + other.start(); + assertFalse(second.await(400, TimeUnit.MILLISECONDS), + "a second caller must not proceed against a registry that" + + " is still being restored"); + + release.countDown(); + assertTrue(second.await(5, TimeUnit.SECONDS), + "and it must proceed once restoration is done"); + restorer.join(5000); + other.join(5000); + restoring.unsubscribe(id); + first.unsubscribe(id); + } + + /** + * Two threads subscribing at once both survive a restart. + * + *

The persisted list is one preference, and remembering a + * subscription is a read-modify-write over it. Outside a lock, two + * threads read the same old value and the second write dropped the + * first: both subscriptions were live in memory, and only one came + * back after the next launch. The one that vanished simply stopped + * being drained, at a restart nowhere near the call that lost it.

+ */ + @Test + void concurrentSubscribesBothSurviveARestart() throws Exception { + final FakeHealthStore store = newStore(); + final String base = "concurrent-" + System.nanoTime(); + final int threads = 8; + final CountDownLatch go = new CountDownLatch(1); + final CountDownLatch done = new CountDownLatch(threads); + for (int i = 0; i < threads; i++) { + final String id = base + "-" + i; + new Thread(new Runnable() { + public void run() { + try { + go.await(5, TimeUnit.SECONDS); + store.subscribe(new SubscriptionRequest(id) + .addType(HealthDataType.STEPS), + new Collector(1)); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + } + }).start(); + } + go.countDown(); + assertTrue(done.await(10, TimeUnit.SECONDS)); + + // What a relaunch would see. + String stored = com.codename1.io.Preferences.get( + "cn1$health$subs", ""); + for (int i = 0; i < threads; i++) { + assertTrue(stored.indexOf(base + "-" + i) >= 0, + base + "-" + i + " must survive into the persisted" + + " list; a concurrent subscribe overwrote it"); + } + for (int i = 0; i < threads; i++) { + store.unsubscribe(base + "-" + i); + } + } + + /** + * A listener that unsubscribes from inside the delivery does not get + * its cursor written back. + * + *

The registration check ran before the listener and not after, + * so a listener calling {@code unsubscribe()} from inside + * {@code healthDataChanged} had this batch's anchor persisted once it + * returned -- recreating the cursor that call had just discarded, for + * the next registration under the same id to load and resume from.

+ */ + @Test + void aListenerThatUnsubscribesLeavesNoCursorBehind() throws Exception { + final FakeHealthStore store = newStore(); + final String id = "self-cancel-" + System.nanoTime(); + final CountDownLatch delivered = new CountDownLatch(1); + HealthSubscription sub = store.subscribe( + new SubscriptionRequest(id).addType(HealthDataType.STEPS), + new HealthChangeListener() { + public void healthDataChanged(HealthChangeBatch b) { + store.unsubscribe(id); + delivered.countDown(); + } + }); + + store.batchesToFire.add(new HealthChangeBatch(id, sub.getTypes(), + samples(2), null, false, HealthAnchor.of("after-cancel"), + 0L, false)); + store.drainChanges(); + assertTrue(delivered.await(5, TimeUnit.SECONDS), + "the batch must reach the listener"); + flushSerialCalls(); + + assertNull(com.codename1.io.Preferences.get( + "cn1$health$anchor$" + id, null), + "unsubscribe discarded the cursor; the delivery that was" + + " already running must not write it back"); + } + + /** + * Replacing a background subscription with an in-memory one publishes + * the handle and the binding together. + * + *

The binding used to be written by the caller after + * {@code register()} returned, so a drain arriving in between + * delivered the new subscription to the class it had just replaced, + * and two registrations of one id could write their bindings in + * either order.

+ */ + @Test + void aRegistrationPublishesItsListenerBindingWithItself() { + FakeHealthStore store = newStore(); + String id = "binding-" + System.nanoTime(); + SubscriptionRequest req = new SubscriptionRequest(id) + .addType(HealthDataType.STEPS); + + store.subscribe(req, TestBackgroundListener.class); + assertEquals(TestBackgroundListener.class.getName(), + com.codename1.io.Preferences.get( + "cn1$health$listener$" + id, null), + "the class binding is persisted by the registration"); + + store.subscribe(req, new Collector(1)); + assertNull(com.codename1.io.Preferences.get( + "cn1$health$listener$" + id, null), + "and replacing it with an in-memory listener clears the" + + " binding as part of the same registration"); + store.unsubscribe(id); + } + + /** + * Re-registering an id with different types drops the old cursor. + * + *

A Health Connect change token is issued for a type set. Keeping + * it across a change of types meant {@code doSubscribe} saw a + * non-null anchor, took no new baseline, and the subscription went on + * reporting changes for the types it used to watch while every change + * to the new ones was missed -- silently, since the drain looked + * perfectly healthy.

+ */ + @Test + void changingTheTypesOfASubscriptionDropsItsCursor() { + FakeHealthStore store = newStore(); + String id = "retyped-" + System.nanoTime(); + HealthSubscription first = store.subscribe( + new SubscriptionRequest(id).addType(HealthDataType.STEPS), + new Collector(1)); + assertTrue(store.seedForTest(first, HealthAnchor.of("steps-token"))); + assertEquals("steps-token", com.codename1.io.Preferences.get( + "cn1$health$anchor$" + id, null)); + + HealthSubscription sameTypes = store.subscribe( + new SubscriptionRequest(id).addType(HealthDataType.STEPS), + new Collector(1)); + assertNotNull(sameTypes.getAnchor(), + "an unchanged type set keeps its place"); + + HealthSubscription retyped = store.subscribe( + new SubscriptionRequest(id).addType(HealthDataType.STEPS) + .addType(HealthDataType.HEART_RATE), + new Collector(1)); + assertNull(retyped.getAnchor(), + "a token issued for the old types cannot serve the new" + + " ones"); + assertNull(com.codename1.io.Preferences.get( + "cn1$health$anchor$" + id, null), + "and the persisted copy goes with it"); + store.unsubscribe(id); + } + + /** + * A stale handle cannot cancel its successor. + * + *

{@code stop()} cancelled by id, so a handle stopped after its id + * had been re-registered removed and tore down the replacement + * instead of itself. The race it needs -- {@code stop()} reading its + * active flag, the replacement registering, and only then the + * cancellation running -- is not reproducible from a test, so this + * drives the mechanism that closes it: a cancellation that names a + * handle must do nothing when that handle is not the registered + * one.

+ */ + @Test + void aCancellationNamingAStaleHandleDoesNothing() { + FakeHealthStore store = newStore(); + String id = "successor-" + System.nanoTime(); + HealthSubscription old = store.subscribe( + new SubscriptionRequest(id).addType(HealthDataType.STEPS), + new Collector(1)); + HealthSubscription replacement = store.subscribe( + new SubscriptionRequest(id).addType(HealthDataType.STEPS), + new Collector(1)); + assertNotSame(old, replacement); + + store.unsubscribe(id, old); + + assertEquals(1, store.getSubscriptions().size(), + "cancelling the old handle must not remove the new one"); + assertSame(replacement, store.getSubscriptions().get(0)); + assertTrue(replacement.isActive()); + + // Naming the current handle still cancels, and so does the + // public by-id call the app makes. + store.unsubscribe(id, replacement); + assertTrue(store.getSubscriptions().isEmpty()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/health/HealthUnitConversionTest.java b/maven/core-unittests/src/test/java/com/codename1/health/HealthUnitConversionTest.java new file mode 100644 index 00000000000..7b43a988199 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/health/HealthUnitConversionTest.java @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit conversion. Health data carries units that differ by region and by + * device, and a silent factor-of-two or factor-of-18 error is the most + * consequential bug class in this API -- a mis-converted glucose reading is a + * patient-safety issue, not a rendering glitch. + */ +class HealthUnitConversionTest { + + private static final double EPS = 1e-9; + + @Test + void massConvertsBetweenMetricAndImperial() { + assertEquals(1.0, + HealthUnit.convert(1000, HealthUnit.GRAM, + HealthUnit.KILOGRAM), EPS); + assertEquals(0.45359237, + HealthUnit.convert(1, HealthUnit.POUND, HealthUnit.KILOGRAM), + EPS); + assertEquals(14.0, + HealthUnit.convert(1, HealthUnit.STONE, HealthUnit.POUND), + 1e-6); + } + + /** + * Temperature is why conversion is affine rather than a plain ratio: a + * multiplicative-only converter puts freezing point at 0 degrees F. + */ + @Test + void temperatureConversionAppliesTheOffset() { + assertEquals(32.0, + HealthUnit.convert(0, HealthUnit.DEGREE_CELSIUS, + HealthUnit.DEGREE_FAHRENHEIT), 1e-9); + assertEquals(212.0, + HealthUnit.convert(100, HealthUnit.DEGREE_CELSIUS, + HealthUnit.DEGREE_FAHRENHEIT), 1e-9); + assertEquals(37.0, + HealthUnit.convert(98.6, HealthUnit.DEGREE_FAHRENHEIT, + HealthUnit.DEGREE_CELSIUS), 1e-9); + assertEquals(-40.0, + HealthUnit.convert(-40, HealthUnit.DEGREE_FAHRENHEIT, + HealthUnit.DEGREE_CELSIUS), 1e-9); + } + + /** + * The clinically important one: 100 mg/dL is 5.55 mmol/L. Getting the + * 18.0182 factor wrong -- or inverting it -- turns a normal fasting + * glucose into either a hypoglycaemic emergency or diabetic ketoacidosis. + */ + @Test + void glucoseConvertsBetweenMgPerDlAndMmolPerL() { + assertEquals(5.5499, + HealthUnit.convert(100, HealthUnit.MILLIGRAM_PER_DECILITER, + HealthUnit.MILLIMOLE_PER_LITER), 1e-3); + assertEquals(126.0, + HealthUnit.convert(6.9930, + HealthUnit.MILLIMOLE_PER_LITER, + HealthUnit.MILLIGRAM_PER_DECILITER), 1e-2); + } + + @Test + void energyConvertsBetweenCaloriesAndJoules() { + assertEquals(4.184, + HealthUnit.convert(1, HealthUnit.KILOCALORIE, + HealthUnit.KILOJOULE), 1e-9); + assertEquals(1000.0, + HealthUnit.convert(1, HealthUnit.KILOJOULE, + HealthUnit.JOULE), 1e-6); + } + + @Test + void speedConvertsBetweenMetricAndImperial() { + assertEquals(3.6, + HealthUnit.convert(1, HealthUnit.METER_PER_SECOND, + HealthUnit.KILOMETER_PER_HOUR), 1e-9); + assertEquals(26.8224, + HealthUnit.convert(60, HealthUnit.MILE_PER_HOUR, + HealthUnit.METER_PER_SECOND), 1e-6); + } + + @Test + void distanceConvertsBetweenMetricAndImperial() { + assertEquals(1609.344, + HealthUnit.convert(1, HealthUnit.MILE, HealthUnit.METER), + 1e-9); + assertEquals(12.0, + HealthUnit.convert(1, HealthUnit.FOOT, HealthUnit.INCH), + 1e-9); + } + + @Test + void convertingAcrossDimensionsIsARejectedProgrammingError() { + assertThrows(IllegalArgumentException.class, + () -> HealthUnit.convert(1, HealthUnit.KILOGRAM, + HealthUnit.METER)); + } + + @Test + void nullUnitsAreRejected() { + assertThrows(IllegalArgumentException.class, + () -> HealthUnit.convert(1, null, HealthUnit.METER)); + } + + /** + * The mass and glucose dimensions are deliberately separate. If they + * shared a "concentration" dimension, adding cholesterol in mg/dL later + * would silently reuse glucose's molar mass. + */ + @Test + void glucoseConcentrationIsNotCompatibleWithPlainMass() { + assertFalse(HealthUnit.MILLIGRAM_PER_DECILITER + .isCompatibleWith(HealthUnit.MILLIGRAM)); + assertNotSame(HealthUnit.MILLIGRAM_PER_DECILITER.getDimension(), + HealthUnit.MILLIGRAM.getDimension()); + } + + @Test + void symbolsAreTheHealthKitWireFormat() { + assertEquals("count/min", HealthUnit.COUNT_PER_MINUTE.getSymbol()); + assertEquals("kg", HealthUnit.KILOGRAM.getSymbol()); + assertEquals("kcal", HealthUnit.KILOCALORIE.getSymbol()); + assertEquals("mmHg", + HealthUnit.MILLIMETER_OF_MERCURY.getSymbol()); + assertEquals("degC", HealthUnit.DEGREE_CELSIUS.getSymbol()); + assertEquals("mg/dL", + HealthUnit.MILLIGRAM_PER_DECILITER.getSymbol()); + assertEquals("mL/(kg*min)", + HealthUnit.ML_PER_KG_PER_MINUTE.getSymbol()); + } + + @Test + void lookupBySymbolRoundTripsAndIsTotal() { + for (HealthUnit u : HealthUnit.values()) { + assertSame(u, HealthUnit.forSymbol(u.getSymbol())); + } + assertNull(HealthUnit.forSymbol("furlongs/fortnight")); + assertNull(HealthUnit.forSymbol(null)); + } + + /** + * Reading a value out of a quantity requires naming the unit, which is + * what removes the whole pounds-read-as-kilograms bug class. + */ + @Test + void quantityConvertsOnRead() { + HealthQuantity weight = + new HealthQuantity(154, HealthUnit.POUND); + assertEquals(69.85, weight.getValue(HealthUnit.KILOGRAM), 1e-2); + assertEquals(154, weight.getRawValue(), EPS); + assertSame(HealthUnit.POUND, weight.getUnit()); + } + + @Test + void quantityInReturnsSelfWhenUnitAlreadyMatches() { + HealthQuantity q = new HealthQuantity(70, HealthUnit.KILOGRAM); + assertSame(q, q.in(HealthUnit.KILOGRAM)); + } + + @Test + void quantityRequiresAUnit() { + assertThrows(IllegalArgumentException.class, + () -> new HealthQuantity(1, null)); + } + + /** + * A series the caller asked to keep whole still answers in the unit + * the query asked for. + * + *

Only {@link QuantitySample} was normalized, so turning flattening + * off -- an option about record shape, not about units -- silently + * changed which unit the values came back in. The same query then + * meant two different things depending on a flag that has nothing to + * do with measurement.

+ */ + @Test + void anUnflattenedSeriesIsConvertedToTheRequestedUnit() + throws Exception { + long[] at = {1000L, 2000L}; + long[] ends = {1000L, 2000L}; + double[] metresPerSecond = {10.0, 20.0}; + SeriesSample series = SeriesSample.create(HealthDataType.SPEED, + 1000L, 2000L, at, ends, metresPerSecond, + HealthUnit.METER_PER_SECOND); + + FakeHealthStore store = new FakeHealthStore(); + java.util.List page = + new java.util.ArrayList(); + page.add(series); + store.pages.add(new SamplePage(page, null, false)); + + java.util.List read = store.readSamples( + new SampleQuery().addType(HealthDataType.SPEED) + .setFlattenSeries(false) + .setUnit(HealthUnit.KILOMETER_PER_HOUR) + .setTimeRange(HealthTimeRange.between(0L, 5000L))) + .get(); + + assertEquals(1, read.size(), "the record stays whole"); + SeriesSample out = (SeriesSample) read.get(0); + assertSame(HealthUnit.KILOMETER_PER_HOUR, out.getUnit(), + "the series reports the unit that was asked for"); + assertEquals(36.0, out.getSampleValue(0, + HealthUnit.KILOMETER_PER_HOUR), 1e-9); + assertEquals(72.0, out.getSampleValue(1, + HealthUnit.KILOMETER_PER_HOUR), 1e-9); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/health/HealthWireTest.java b/maven/core-unittests/src/test/java/com/codename1/health/HealthWireTest.java new file mode 100644 index 00000000000..181a12b89c3 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/health/HealthWireTest.java @@ -0,0 +1,561 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import com.codename1.impl.health.HealthChangePage; +import com.codename1.impl.health.HealthWire; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The wire format shared by the Android bridge and the iOS native layer. + * + *

The behaviour that matters most is what happens to a line this build + * cannot understand: it is skipped, not fatal. An older app reading a store + * a newer OS has added record types to must lose the unfamiliar rows and + * keep the familiar ones, rather than failing the whole page.

+ */ +class HealthWireTest { + + private static QuantitySample steps(long start, long end, double count) { + return QuantitySample.create(HealthDataType.STEPS, + new HealthQuantity(count, HealthUnit.COUNT), start, end); + } + + @Test + void samplesRoundTripThroughTheWireFormat() { + List out = new ArrayList(); + out.add(steps(1000L, 2000L, 250)); + + String encoded = HealthWire.encodeSamples(out); + SamplePage page = HealthWire.decodeSamplePage(encoded); + + assertEquals(1, page.size()); + QuantitySample s = (QuantitySample) page.getSamples().get(0); + assertSame(HealthDataType.STEPS, s.getType()); + assertEquals(250, s.getValue(HealthUnit.COUNT), 1e-9); + assertEquals(1000L, s.getStartMillis()); + assertEquals(2000L, s.getEndMillis()); + } + + @Test + void instantaneousSamplesRoundTrip() { + List out = new ArrayList(); + out.add(QuantitySample.create(HealthDataType.BODY_MASS, + new HealthQuantity(70.5, HealthUnit.KILOGRAM), 5000L)); + + SamplePage page = HealthWire.decodeSamplePage( + HealthWire.encodeSamples(out)); + QuantitySample s = (QuantitySample) page.getSamples().get(0); + assertTrue(s.isInstantaneous()); + assertEquals(70.5, s.getValue(HealthUnit.KILOGRAM), 1e-9); + } + + /** + * A record type this build has never heard of. Skipping the line is + * what lets an older app keep working against a newer store. + */ + @Test + void unknownTypeSkipsTheLineRatherThanFailingThePage() { + String payload = "id1\tsteps\t1000\t2000\t100\tcount\t\n" + + "id2\tblood_unicorn\t1000\t2000\t7\tcount\t\n" + + "id3\tsteps\t3000\t4000\t200\tcount\t\n"; + SamplePage page = HealthWire.decodeSamplePage(payload); + assertEquals(2, page.size(), "the two known lines must survive"); + } + + @Test + void unknownUnitSkipsTheLine() { + String payload = "id1\tsteps\t1000\t2000\t100\tfurlongs\t\n"; + assertEquals(0, HealthWire.decodeSamplePage(payload).size()); + } + + @Test + void malformedLinesAreSkippedAndNeverThrow() { + String payload = "\n" + + "not-enough-fields\n" + + "id\tsteps\tnot-a-number\t2000\t100\tcount\t\n" + + "id\tsteps\t1000\t2000\tnot-a-number\tcount\t\n" + + "id\tsteps\t1000\t2000\t100\tcount\t\n"; + SamplePage page = HealthWire.decodeSamplePage(payload); + assertEquals(1, page.size()); + } + + /** + * An interval-only type sent as an instant is rejected by the sample + * constructor; the decoder must absorb that rather than propagate it. + */ + @Test + void invalidTypeShapeSkipsTheLine() { + String payload = "id\tsteps\t1000\t1000\t100\tcount\t\n"; + assertEquals(0, HealthWire.decodeSamplePage(payload).size(), + "an instantaneous step count is not a valid sample"); + } + + @Test + void emptyAndNullPayloadsDecodeToAnEmptyPage() { + assertEquals(0, HealthWire.decodeSamplePage(null).size()); + assertEquals(0, HealthWire.decodeSamplePage("").size()); + } + + @Test + void sourceIsCarriedWhenPresent() { + String payload = + "id\tsteps\t1000\t2000\t100\tcount\tcom.example.app\tExample" + + "\tPixel\n"; + SamplePage page = HealthWire.decodeSamplePage(payload); + assertEquals(1, page.size()); + HealthSource src = page.getSamples().get(0).getSource(); + assertNotNull(src); + assertEquals("com.example.app", src.getBundleId()); + assertEquals("Example", src.getName()); + assertEquals("Pixel", src.getDeviceName()); + } + + @Test + void writeResultDecodesOneIdPerLine() { + HealthWriteResult r = HealthWire.decodeWriteResult("a\nb\n\nc\n"); + assertEquals(3, r.getWrittenCount()); + assertEquals("a", r.getSampleIds().get(0)); + assertEquals("c", r.getSampleIds().get(2)); + } + + @Test + void sampleQueryEncodesTheRangeAndLimit() { + String json = HealthWire.encodeSampleQuery(new SampleQuery() + .addType(HealthDataType.STEPS) + .setTimeRange(HealthTimeRange.between(100L, 200L)) + .setLimit(42)); + assertTrue(json.contains("\"steps\"")); + assertTrue(json.contains("\"start\":100")); + assertTrue(json.contains("\"end\":200")); + assertTrue(json.contains("\"limit\":42")); + } + + @Test + void deleteRequestEncodesEitherIdsOrARange() { + String byId = HealthWire.encodeDeleteRequest( + HealthDeleteRequest.byId(HealthDataType.STEPS, "abc")); + assertTrue(byId.contains("\"ids\"")); + assertTrue(byId.contains("abc")); + assertTrue(byId.contains("\"steps\""), + "the id form carries its type too: Health Connect deletes" + + " by record class and cannot resolve a bare id"); + + String byRange = HealthWire.encodeDeleteRequest( + HealthDeleteRequest.byRange(HealthDataType.STEPS, + HealthTimeRange.between(1L, 2L))); + assertTrue(byRange.contains("\"types\"")); + assertTrue(byRange.contains("\"start\":1")); + } + + /** + * Aggregate buckets the platform said nothing about stay empty rather + * than being filled in, preserving the null-not-zero contract across + * the native boundary. + */ + @Test + void aggregateDecodingLeavesUnreportedBucketsEmpty() { + long[] boundaries = { 0L, 100L, 200L, 300L }; + AggregateQuery q = new AggregateQuery() + .addType(HealthDataType.STEPS) + .addMetric(AggregateMetric.TOTAL) + .setTimeRange(HealthTimeRange.between(0L, 300L)); + String payload = "0\tsteps\tTOTAL\t500.0\tcount\n"; + + List buckets = + HealthWire.decodeAggregates(payload, q, boundaries); + assertEquals(3, buckets.size()); + assertEquals(500.0, buckets.get(0) + .get(HealthDataType.STEPS, AggregateMetric.TOTAL) + .getValue(HealthUnit.COUNT), 1e-9); + assertNull(buckets.get(1) + .get(HealthDataType.STEPS, AggregateMetric.TOTAL)); + assertNull(buckets.get(2) + .get(HealthDataType.STEPS, AggregateMetric.TOTAL)); + } + + @Test + void aggregateLinesOutsideTheBucketRangeAreIgnored() { + long[] boundaries = { 0L, 100L }; + AggregateQuery q = new AggregateQuery() + .addType(HealthDataType.STEPS) + .addMetric(AggregateMetric.TOTAL) + .setTimeRange(HealthTimeRange.between(0L, 100L)); + String payload = "9\tsteps\tTOTAL\t500.0\tcount\n" + + "-1\tsteps\tTOTAL\t500.0\tcount\n" + + "0\tsteps\tNONSENSE\t500.0\tcount\n"; + List buckets = + HealthWire.decodeAggregates(payload, q, boundaries); + assertEquals(1, buckets.size()); + assertTrue(buckets.get(0).isEmpty()); + } + + // ------------------------------------------------------------------ + // change pages + // ------------------------------------------------------------------ + + @Test + void changePageCarriesAddedSamplesAndDeletedIds() { + String payload = "tok2\t0\t1\n" + + "+\tid1\tsteps\t1000\t2000\t100\tcount\t\n" + + "-\tid9\n" + + "+\tid2\tsteps\t3000\t4000\t200\tcount\t\n"; + HealthChangePage page = HealthWire.decodeChangePage(payload); + assertNotNull(page); + assertEquals("tok2", page.getNextToken()); + assertFalse(page.isExpired()); + assertTrue(page.hasMore()); + assertEquals(2, page.getAdded().size()); + assertEquals(1, page.getDeletedIds().size()); + assertEquals("id9", page.getDeletedIds().get(0)); + } + + /** + * The whole point of the header flag: a token that aged out means the + * changes are incomplete, and the caller has to resync rather than + * treat an empty-looking page as "nothing happened". + */ + @Test + void expiredTokenIsReported() { + HealthChangePage page = HealthWire.decodeChangePage("tok\t1\t0\n"); + assertNotNull(page); + assertTrue(page.isExpired()); + assertTrue(page.getAdded().isEmpty()); + } + + /** + * An unreadable reply must not decode to an empty page. An empty page + * would advance the cursor past changes that were never seen, losing + * them permanently; a null tells the caller to leave the cursor alone. + */ + @Test + void unreadableChangePayloadYieldsNullRatherThanAnEmptyPage() { + assertNull(HealthWire.decodeChangePage(null)); + assertNull(HealthWire.decodeChangePage("")); + assertNull(HealthWire.decodeChangePage("no-newline-header")); + assertNull(HealthWire.decodeChangePage("tok\t0\n"), + "a header missing the hasMore field is not a valid page"); + } + + @Test + void undecodableChangeLinesAreSkippedButThePageSurvives() { + String payload = "tok\t0\t0\n" + + "+\tid1\tblood_unicorn\t1\t2\t7\tcount\t\n" + + "?\tid2\n" + + "+\tid3\tsteps\t1000\t2000\t100\tcount\t\n"; + HealthChangePage page = HealthWire.decodeChangePage(payload); + assertNotNull(page); + assertEquals(1, page.getAdded().size()); + assertTrue(page.getDeletedIds().isEmpty()); + } + + /** + * A delete needs a type because Health Connect deletes by record class + * plus id. Without one the request could only be honoured by guessing, + * which would delete nothing and still report success. + */ + @Test + void deleteWithoutATypeIsRejected() { + HealthDeleteRequest r = HealthDeleteRequest.byId(null, "abc"); + HealthException ex = assertThrows(HealthException.class, + new org.junit.jupiter.api.function.Executable() { + public void execute() throws Throwable { + r.validate(); + } + }); + assertSame(HealthError.INVALID_ARGUMENT, ex.getError()); + } + + private static SeriesSample heartRate(long base, int n) { + long[] starts = new long[n]; + long[] ends = new long[n]; + double[] values = new double[n]; + for (int i = 0; i < n; i++) { + starts[i] = base + i * 1000L; + ends[i] = starts[i]; + values[i] = 60 + i; + } + return SeriesSample.create(HealthDataType.HEART_RATE, base, + base + (n - 1) * 1000L, starts, ends, values, + HealthUnit.COUNT_PER_MINUTE); + } + + /** + * A series write reaches the platform as its measurements rather than + * as nothing at all. + * + *

The encoder used to skip any sample that was not a + * {@link QuantitySample}, and both mobile ports hand this payload + * straight to their bridge -- so a write of nothing but series data + * produced an empty batch, which completes successfully with no + * identifiers. The caller was told the write worked while none of it + * was stored.

+ */ + @Test + void aSeriesWriteCarriesItsMeasurements() { + List out = new ArrayList(); + out.add(heartRate(10_000L, 3)); + + SamplePage page = HealthWire.decodeSamplePage( + HealthWire.encodeSamples(out)); + + assertEquals(3, page.size(), + "every measurement must reach the platform"); + for (int i = 0; i < 3; i++) { + QuantitySample s = (QuantitySample) page.getSamples().get(i); + assertSame(HealthDataType.HEART_RATE, s.getType()); + assertEquals(60 + i, + s.getValue(HealthUnit.COUNT_PER_MINUTE), 1e-9); + } + } + + /** + * A shape the payload cannot carry is reported rather than dropped. + * + *

This is what stops the ports reporting a successful write of + * nothing: they refuse the write instead.

+ */ + @Test + void shapesTheWireCannotCarryAreReported() { + List series = new ArrayList(); + series.add(heartRate(10_000L, 2)); + series.add(steps(1000L, 2000L, 10)); + assertNull(HealthWire.unsupportedForWrite(series), + "quantities and series both encode"); + + List withSleep = new ArrayList(); + withSleep.add(steps(1000L, 2000L, 10)); + SleepSample sleep = SleepSample.create(1000L, 2000L); + withSleep.add(sleep); + HealthSample rejected = HealthWire.unsupportedForWrite(withSleep); + assertNotNull(rejected, "a shape with no line must be reported"); + assertSame(sleep, rejected); + } + + /** + * A series survives the wire whole when the caller asked to keep it. + * + *

`setFlattenSeries(false)` promises record identity, and the + * descriptor never carried the option so the bridge flattened + * regardless -- the option was honoured nowhere.

+ */ + @Test + void anUnflattenedSeriesRoundTripsAsOneRecord() { + SeriesSample original = heartRate(50_000L, 4); + original.setId("hc-record-1"); + original.setRecordingMethod(RecordingMethod.AUTOMATIC); + StringBuilder sb = new StringBuilder(); + HealthWire.appendSeries(sb, original, "com.example.app", "Example", + "Strap"); + + SamplePage page = HealthWire.decodeSamplePage(sb.toString()); + assertEquals(1, page.size(), "one record, not four samples"); + SeriesSample s = (SeriesSample) page.getSamples().get(0); + assertEquals("hc-record-1", s.getId(), + "record identity is the reason for asking"); + assertEquals(4, s.size()); + assertSame(RecordingMethod.AUTOMATIC, s.getRecordingMethod()); + assertEquals("com.example.app", s.getSource().getBundleId()); + for (int i = 0; i < 4; i++) { + assertEquals(50_000L + i * 1000L, s.getSampleStartMillis(i)); + assertEquals(60 + i, + s.getSampleValue(i, HealthUnit.COUNT_PER_MINUTE), 1e-9); + } + } + + /** + * A series line whose measurements disagree with its count is dropped. + * + *

Trusting the count would produce a record padded with zeroes, + * which reads as real data at a plausible-looking timestamp.

+ */ + @Test + void aSeriesLineWithATruncatedTailIsSkipped() { + String line = "~id\theart_rate\t1000\t3000\t3\tcount/min\t\t\t\t" + + "UNKNOWN\t1000:1000:60.0,2000:2000:61.0\n"; + assertEquals(0, HealthWire.decodeSamplePage(line).size(), + "two measurements cannot answer a claim of three"); + } + + /** The query descriptor carries the flattening option. */ + @Test + void theQueryDescriptorCarriesTheFlatteningOption() { + SampleQuery q = new SampleQuery() + .addType(HealthDataType.HEART_RATE) + .setTimeRange(HealthTimeRange.between(0L, 1000L)); + assertTrue(HealthWire.encodeSampleQuery(q).indexOf( + "\"flatten\":true") >= 0); + assertTrue(HealthWire.encodeSampleQuery(q.setFlattenSeries(false)) + .indexOf("\"flatten\":false") >= 0, + "a bridge cannot honour an option it is never told about"); + } + + /** + * A series is checked and converted like a scalar write. + * + *

The wire carries the raw value and the bridges read it in the + * type's canonical unit, so a series that skipped the conversion was + * stored as the wrong numbers entirely -- 2 Hz persisted as 2 bpm + * rather than 120 -- and an incompatible dimension was accepted + * without complaint.

+ */ + @Test + void aSeriesWriteIsConvertedToTheCanonicalUnit() throws Exception { + long[] at = {1000L, 2000L}; + long[] ends = {1000L, 2000L}; + double[] hertz = {2.0, 3.0}; + SeriesSample series = SeriesSample.create(HealthDataType.HEART_RATE, + 1000L, 2000L, at, ends, hertz, HealthUnit.COUNT_PER_SECOND); + + FakeHealthStore store = new FakeHealthStore(); + store.write(series).get(); + + assertEquals(1, store.writeChunks.size()); + SeriesSample written = + (SeriesSample) store.writeChunks.get(0).get(0); + assertSame(HealthUnit.COUNT_PER_MINUTE, written.getUnit(), + "the port receives the unit it writes in"); + assertEquals(120.0, + written.getSampleValue(0, HealthUnit.COUNT_PER_MINUTE), 1e-9); + assertEquals(180.0, + written.getSampleValue(1, HealthUnit.COUNT_PER_MINUTE), 1e-9); + } + + /** A series in the wrong dimension is refused, not silently stored. */ + @Test + void aSeriesInTheWrongDimensionIsRejected() { + long[] at = {1000L}; + double[] kg = {70.0}; + SeriesSample series = SeriesSample.create(HealthDataType.HEART_RATE, + 1000L, 1000L, at, at, kg, HealthUnit.KILOGRAM); + FakeHealthStore store = new FakeHealthStore(); + Throwable err = errorOf(store.write(series)); + assertNotNull(err, "an incompatible unit must not reach the port"); + assertSame(HealthError.UNIT_MISMATCH, + ((HealthException) err).getError()); + } + + /** + * A series longer than one batch is split so no single platform call + * exceeds the record cap. + * + *

The chunker counted a series as one sample while the wire expands + * it to one record per point, so a 5,000-point series went to Health + * Connect as 5,000 records in one call -- past its 1,000-record cap, + * rejecting the whole write that `write` documents as chunked.

+ */ + @Test + void anOversizedSeriesIsSplitAcrossWrites() throws Exception { + int n = 250; + long[] at = new long[n]; + long[] ends = new long[n]; + double[] values = new double[n]; + for (int i = 0; i < n; i++) { + at[i] = 1000L + i; + ends[i] = at[i]; + values[i] = 60 + i % 10; + } + SeriesSample series = SeriesSample.create(HealthDataType.HEART_RATE, + at[0], at[n - 1], at, ends, values, + HealthUnit.COUNT_PER_MINUTE); + + FakeHealthStore store = new FakeHealthStore(); + store.maxWriteBatch = 100; + store.write(series).get(); + + int records = 0; + for (List chunk : store.writeChunks) { + int cost = 0; + for (HealthSample s : chunk) { + cost += s instanceof SeriesSample + ? ((SeriesSample) s).size() : 1; + } + assertTrue(cost <= 100, + "no call may exceed the platform record cap, got " + cost); + records += cost; + } + assertEquals(n, records, "every measurement is still written"); + } + + /** + * Deleting is not writing. The series-shaped types have no + * single-value write form on Health Connect but delete by record id + * perfectly well -- which is the whole point of asking for record + * identity in the first place. + */ + @Test + void seriesTypesAreDeletableOnAndroidEvenThoughTheyAreNotWritable() { + assertFalse(HealthWire.isAndroidWritable(HealthDataType.POWER)); + assertTrue(HealthWire.isAndroidDeletable(HealthDataType.POWER)); + assertTrue(HealthWire.isAndroidDeletable(HealthDataType.SPEED)); + assertTrue(HealthWire.isAndroidDeletable( + HealthDataType.CYCLING_CADENCE)); + assertTrue(HealthWire.isAndroidDeletable( + HealthDataType.RUNNING_CADENCE)); + // Still bounded by what the bridge maps at all. + assertFalse(HealthWire.isAndroidDeletable( + HealthDataType.SLEEP)); + } + + private static Throwable errorOf( + com.codename1.util.AsyncResource r) { + final Throwable[] err = new Throwable[1]; + r.except(new com.codename1.util.SuccessCallback() { + public void onSucess(Throwable t) { + err[0] = t; + } + }); + return err[0]; + } + + /** + * Nutrition is local and simulator only in this release, and the + * documentation now says so. This pins the two gates both ports + * consult, so the claim cannot quietly stop being true. + */ + @Test + void nutritionIsNotCarriedToEitherPhone() { + assertFalse(HealthWire.isAndroidSupported(HealthDataType.NUTRITION), + "Health Connect has no mapping for the record shape"); + assertFalse(HealthWire.isAndroidWritable(HealthDataType.NUTRITION)); + + java.util.List batch = + new java.util.ArrayList(); + batch.add(com.codename1.health.nutrition.NutritionSample.create( + 1767225600000L)); + assertNotNull(HealthWire.unsupportedForWrite(batch), + "a nutrition entry must be refused, not written as" + + " something else"); + } + + /** The dietary quantities that do reach the phones still do. */ + @Test + void dietaryQuantitiesAreStillCarried() { + assertTrue(HealthWire.isAndroidSupported(HealthDataType.HYDRATION)); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthPersistenceTest.java b/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthPersistenceTest.java new file mode 100644 index 00000000000..1eead8824c3 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthPersistenceTest.java @@ -0,0 +1,563 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import com.codename1.health.nutrition.Nutrient; +import com.codename1.health.nutrition.NutritionSample; +import com.codename1.impl.health.StoredHealthStore; +import com.codename1.util.AsyncResource; +import com.codename1.io.Storage; +import com.codename1.junit.UITestBase; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The local store on the ports that have no platform health store at all + * -- Windows, Linux and JavaScript. + * + *

Those report {@code LOCAL_ONLY}, which says the data is only ever + * this app's own; it does not say the data is gone next launch. Until this + * existed the base class's persistence hook was a no-op, so every write on + * those ports was silently lost on restart while the developer guide + * promised durability.

+ * + *

Each case writes through one store and reads back through a second, + * which is what a restart actually looks like from the store's point of + * view.

+ */ +class LocalHealthPersistenceTest extends UITestBase { + + private static final long T0 = 1767225600000L; + private static final long MINUTE = 60000L; + + @BeforeEach + void clearStorage() { + Storage.getInstance().deleteStorageFile("cn1$health$local"); + } + + private static List one(HealthSample s) { + List out = new ArrayList(); + out.add(s); + return out; + } + + /** Writes through one store, reads back through a fresh one. */ + private static List roundTrip(HealthSample written, + HealthDataType type) throws Exception { + StoredHealthStore first = new StoredHealthStore(); + first.write(one(written)).get(); + StoredHealthStore second = new StoredHealthStore(); + return second.readSamples(new SampleQuery().addType(type) + .setTimeRange(HealthTimeRange.between(T0 - MINUTE, + T0 + 100 * MINUTE))).get(); + } + + @Test + void aQuantitySurvivesARestart() throws Exception { + QuantitySample q = QuantitySample.create(HealthDataType.STEPS, + new HealthQuantity(1234, HealthUnit.COUNT), T0, + T0 + MINUTE); + q.putMetadata("note", "written\tbefore\nthe restart"); + List back = roundTrip(q, HealthDataType.STEPS); + + assertEquals(1, back.size()); + QuantitySample r = (QuantitySample) back.get(0); + assertEquals(1234, r.getValue(HealthUnit.COUNT), 0.001); + assertEquals(T0, r.getStartMillis()); + assertEquals(T0 + MINUTE, r.getEndMillis()); + // Tabs and newlines are the format's own separators, so free text + // is exactly where an unescaped codec falls apart. + assertEquals("written\tbefore\nthe restart", + r.getMetadata().get("note")); + } + + @Test + void aCategorySurvivesARestart() throws Exception { + List back = roundTrip( + CategorySample.create(HealthDataType.MENSTRUATION_FLOW, 3, + T0, T0 + MINUTE), + HealthDataType.MENSTRUATION_FLOW); + assertEquals(1, back.size()); + assertEquals(3, ((CategorySample) back.get(0)).getValue()); + } + + /** + * A series is one record holding many measurements. The wire format + * flattens it, which is why persisting through that would have been + * the wrong reuse: the record would come back as loose points. + */ + @Test + void aSeriesKeepsItsRecordAndItsPoints() throws Exception { + SeriesSample series = SeriesSample.create(HealthDataType.HEART_RATE, + T0, T0 + 2 * MINUTE, + new long[] { T0, T0 + MINUTE }, + new long[] { T0, T0 + MINUTE }, + new double[] { 61, 74 }, HealthUnit.COUNT_PER_MINUTE); + List back = roundTrip(series, + HealthDataType.HEART_RATE); + + // Read unflattened, which is what asks for record identity -- + // the default flattens, and a flattened read of this record is two + // points either way. + assertEquals(2, back.size(), "flattened, this is two measurements"); + StoredHealthStore third = new StoredHealthStore(); + List whole = third.readSamples(new SampleQuery() + .addType(HealthDataType.HEART_RATE) + .setFlattenSeries(false) + .setTimeRange(HealthTimeRange.between(T0 - MINUTE, + T0 + 100 * MINUTE))).get(); + assertEquals(1, whole.size(), "the record, not its measurements"); + SeriesSample r = (SeriesSample) whole.get(0); + assertEquals(2, r.size()); + assertEquals(61, r.getSampleValue(0, HealthUnit.COUNT_PER_MINUTE), + 0.001); + assertEquals(74, r.getSampleValue(1, HealthUnit.COUNT_PER_MINUTE), + 0.001); + assertEquals(T0 + MINUTE, r.getSampleStartMillis(1)); + } + + @Test + void aSleepSessionKeepsItsStages() throws Exception { + SleepSample sleep = SleepSample.create(T0, T0 + 60 * MINUTE); + sleep.addStage(new SleepStageInterval(SleepStage.LIGHT, T0, + T0 + 20 * MINUTE)); + sleep.addStage(new SleepStageInterval(SleepStage.DEEP, + T0 + 20 * MINUTE, T0 + 60 * MINUTE)); + sleep.setTitle("night one"); + + List back = roundTrip(sleep, HealthDataType.SLEEP); + assertEquals(1, back.size()); + SleepSample r = (SleepSample) back.get(0); + assertEquals(2, r.getStages().size()); + assertEquals(SleepStage.DEEP, r.getStages().get(1).getStage()); + assertEquals("night one", r.getTitle()); + assertEquals(60 * MINUTE, r.getAsleepDurationMillis()); + } + + @Test + void aWorkoutKeepsItsTotals() throws Exception { + WorkoutSample w = WorkoutSample.create(WorkoutActivityType.RUNNING, + T0, T0 + 30 * MINUTE); + w.setTotalEnergy(new HealthQuantity(300, HealthUnit.KILOCALORIE)); + w.setTotalDistance(new HealthQuantity(5, HealthUnit.KILOMETER)); + w.setActiveDurationMillis(25 * MINUTE); + w.setTitle("morning run"); + + List back = roundTrip(w, HealthDataType.WORKOUT); + assertEquals(1, back.size()); + WorkoutSample r = (WorkoutSample) back.get(0); + assertEquals(WorkoutActivityType.RUNNING, r.getActivityType()); + assertEquals(300, r.getTotalEnergy().getValue( + HealthUnit.KILOCALORIE), 0.001); + assertEquals(5000, r.getTotalDistance().getValue(HealthUnit.METER), + 0.001); + assertEquals(25 * MINUTE, r.getActiveDurationMillis()); + assertEquals("morning run", r.getTitle()); + } + + /** A workout that never measured its totals reads back without them. */ + @Test + void aWorkoutWithoutTotalsStaysWithoutThem() throws Exception { + WorkoutSample w = WorkoutSample.create(WorkoutActivityType.YOGA, T0, + T0 + 30 * MINUTE); + List back = roundTrip(w, HealthDataType.WORKOUT); + WorkoutSample r = (WorkoutSample) back.get(0); + assertNull(r.getTotalEnergy()); + assertNull(r.getTotalDistance()); + // Unset, so the getter still answers with the wall duration. + assertEquals(30 * MINUTE, r.getActiveDurationMillis()); + } + + @Test + void aNutritionEntryKeepsItsSparseNutrients() throws Exception { + NutritionSample n = NutritionSample.create(T0); + n.setNutrient(Nutrient.PROTEIN, 12); + n.setNutrient(Nutrient.TOTAL_FAT, 3.5); + n.setMealType(NutritionSample.MEAL_LUNCH); + n.setFoodName("cheese sandwich"); + + List back = roundTrip(n, HealthDataType.NUTRITION); + assertEquals(1, back.size()); + NutritionSample r = (NutritionSample) back.get(0); + assertEquals(2, r.getNutrientCount()); + assertEquals(12, r.getNutrient(Nutrient.PROTEIN) + .getValue(Nutrient.PROTEIN.getUnit()), 0.001); + assertEquals(NutritionSample.MEAL_LUNCH, r.getMealType()); + // Carried by the in-memory snapshot path and originally missed + // here, which is the whole failure mode a codec has: a field + // nobody looks at until a restart. + assertEquals("cheese sandwich", r.getFoodName()); + } + + @Test + void aBloodPressureReadingKeepsBothValues() throws Exception { + BloodPressureSample bp = BloodPressureSample.create(118, 76, T0); + bp.setPulse(new HealthQuantity(58, HealthUnit.COUNT_PER_MINUTE)); + + List back = roundTrip(bp, + HealthDataType.BLOOD_PRESSURE); + assertEquals(1, back.size()); + BloodPressureSample r = (BloodPressureSample) back.get(0); + assertEquals(118, r.getSystolic().getValue( + HealthUnit.MILLIMETER_OF_MERCURY), 0.001); + assertEquals(76, r.getDiastolic().getValue( + HealthUnit.MILLIMETER_OF_MERCURY), 0.001); + assertEquals(58, r.getPulse().getValue(HealthUnit.COUNT_PER_MINUTE), + 0.001); + } + + /** + * A restored sample brings its identifier back, so the generator has + * to clear it. Handing out an id something already holds makes a + * delete-by-id remove the wrong record. + */ + @Test + void identifiersAreNotReusedAfterARestart() throws Exception { + StoredHealthStore first = new StoredHealthStore(); + first.write(one(QuantitySample.create(HealthDataType.STEPS, + new HealthQuantity(1, HealthUnit.COUNT), T0, + T0 + MINUTE))).get(); + List before = first.readSamples(new SampleQuery() + .addType(HealthDataType.STEPS) + .setTimeRange(HealthTimeRange.between(T0 - MINUTE, + T0 + 2 * MINUTE))).get(); + String firstId = before.get(0).getId(); + + StoredHealthStore second = new StoredHealthStore(); + second.write(one(QuantitySample.create(HealthDataType.STEPS, + new HealthQuantity(2, HealthUnit.COUNT), T0, + T0 + MINUTE))).get(); + List after = second.readSamples(new SampleQuery() + .addType(HealthDataType.STEPS) + .setTimeRange(HealthTimeRange.between(T0 - MINUTE, + T0 + MINUTE))).get(); + + assertEquals(2, after.size()); + assertNotEquals(after.get(0).getId(), after.get(1).getId(), + "a restored id must not be handed out again"); + assertTrue(after.get(0).getId().equals(firstId) + || after.get(1).getId().equals(firstId), + "the restored sample keeps the id it was written with"); + } + + /** A delete is persisted too, not just the write. */ + @Test + void aDeleteSurvivesARestart() throws Exception { + StoredHealthStore first = new StoredHealthStore(); + first.write(one(QuantitySample.create(HealthDataType.STEPS, + new HealthQuantity(7, HealthUnit.COUNT), T0, + T0 + MINUTE))).get(); + List stored = first.readSamples(new SampleQuery() + .addType(HealthDataType.STEPS) + .setTimeRange(HealthTimeRange.between(T0 - MINUTE, + T0 + MINUTE))).get(); + first.delete(HealthDeleteRequest.byId(HealthDataType.STEPS, + stored.get(0).getId())).get(); + + StoredHealthStore second = new StoredHealthStore(); + assertTrue(second.readSamples(new SampleQuery() + .addType(HealthDataType.STEPS) + .setTimeRange(HealthTimeRange.between(T0 - MINUTE, + T0 + MINUTE))).get().isEmpty()); + } + + /** + * A store that cannot be written must not report the write as stored. + * + *

{@code Storage.writeObject} answers false for a full or + * unwritable store rather than throwing, and ignoring that let the + * caller be told a durable write succeeded when the record only ever + * reached memory -- on the very ports whose whole claim is + * durability. The change is rolled back so memory and disk agree.

+ */ + @Test + void aFailedPersistFailsTheWriteAndRollsItBack() throws Exception { + UnwritableStore store = new UnwritableStore(); + AsyncResource write = store.write( + one(QuantitySample.create(HealthDataType.STEPS, + new HealthQuantity(3, HealthUnit.COUNT), T0, + T0 + MINUTE))); + + assertNotNull(errorOf(write), "the write must not look successful"); + assertEquals(HealthError.DATABASE_INACCESSIBLE, + ((HealthException) errorOf(write)).getError()); + assertTrue(store.readSamples(new SampleQuery() + .addType(HealthDataType.STEPS) + .setTimeRange(HealthTimeRange.between(T0 - MINUTE, + T0 + 2 * MINUTE))).get().isEmpty(), + "the rolled-back sample must not linger in memory"); + } + + /** A delete that cannot be persisted puts the records back. */ + @Test + void aFailedPersistRestoresADelete() throws Exception { + UnwritableStore store = new UnwritableStore(); + store.allowWrites = true; + store.write(one(QuantitySample.create(HealthDataType.STEPS, + new HealthQuantity(3, HealthUnit.COUNT), T0, + T0 + MINUTE))).get(); + List stored = store.readSamples(new SampleQuery() + .addType(HealthDataType.STEPS) + .setTimeRange(HealthTimeRange.between(T0 - MINUTE, + T0 + 2 * MINUTE))).get(); + assertEquals(1, stored.size()); + + store.allowWrites = false; + AsyncResource delete = store.delete( + HealthDeleteRequest.byId(HealthDataType.STEPS, + stored.get(0).getId())); + + assertNotNull(errorOf(delete)); + assertEquals(1, store.readSamples(new SampleQuery() + .addType(HealthDataType.STEPS) + .setTimeRange(HealthTimeRange.between(T0 - MINUTE, + T0 + 2 * MINUTE))).get().size(), + "the record must come back when the delete cannot be" + + " persisted"); + } + + private static Throwable errorOf(AsyncResource r) { + final Throwable[] err = new Throwable[1]; + r.except(new com.codename1.util.SuccessCallback() { + public void onSucess(Throwable t) { + err[0] = t; + } + }); + return err[0]; + } + + /** A store whose backing storage refuses to take anything. */ + private static final class UnwritableStore + extends com.codename1.impl.health.LocalHealthStore { + + private boolean allowWrites; + + @Override + protected boolean persist() { + return allowWrites; + } + } + + /** + * A failed save must not take the older records with it. + * + *

{@code Storage.writeObject} deletes the entry when it fails, so + * by the time it answers false the previous contents are already + * gone. Rolling the in-memory change back is not enough on its own -- + * without putting the old blob back, one full disk costs every record + * the app ever wrote rather than the single write that failed.

+ */ + @Test + void aFailedSaveKeepsTheRecordsThatWereAlreadyStored() throws Exception { + StoredHealthStore first = new StoredHealthStore(); + first.write(one(QuantitySample.create(HealthDataType.STEPS, + new HealthQuantity(11, HealthUnit.COUNT), T0, + T0 + MINUTE))).get(); + + // A second store over the same entry whose next save fails the + // way a full disk does -- Storage deletes the entry and then + // answers false. + FailingOnceStore second = new FailingOnceStore(); + AsyncResource doomed = second.write( + one(QuantitySample.create(HealthDataType.STEPS, + new HealthQuantity(22, HealthUnit.COUNT), T0, + T0 + MINUTE))); + assertNotNull(errorOf(doomed), "the write must be reported failed"); + + // Whatever the failure did, the earlier record must still be + // readable by a fresh store. + StoredHealthStore third = new StoredHealthStore(); + List back = third.readSamples(new SampleQuery() + .addType(HealthDataType.STEPS) + .setTimeRange(HealthTimeRange.between(T0 - MINUTE, + T0 + 2 * MINUTE))).get(); + assertEquals(1, back.size(), + "the previously stored record must survive a failed save"); + assertEquals(11, ((QuantitySample) back.get(0)) + .getValue(HealthUnit.COUNT), 0.001); + } + + /** + * Fails its first save exactly as a full disk does -- deleting the + * entry first, then answering false -- and writes normally after. + */ + private static final class FailingOnceStore extends StoredHealthStore { + + private boolean failed; + + @Override + protected boolean writeBlob(String blob) { + if (!failed) { + failed = true; + Storage.getInstance() + .deleteStorageFile("cn1$health$local"); + return false; + } + return super.writeBlob(blob); + } + } + + /** + * A mutation and the save it triggers are one transaction. + * + *

Holding only the sample-list lock released it between changing + * the list and encoding it, so two concurrent mutations could each + * snapshot and then write in the opposite order -- the older snapshot + * landing last. Both callers were told they had succeeded, and a + * record deleted before the restart was back after it.

+ * + *

Driven through a store that stalls inside the save, which is the + * window the race needs; without the transaction the stalled writer + * overwrites the delete that completed while it was parked.

+ */ + @Test + void aMutationAndItsSaveAreOneTransaction() throws Exception { + final StallingStore store = new StallingStore(); + store.write(one(QuantitySample.create(HealthDataType.STEPS, + new HealthQuantity(1, HealthUnit.COUNT), T0, + T0 + MINUTE))).get(); + List stored = store.readSamples(query()).get(); + assertEquals(1, stored.size()); + final String firstId = stored.get(0).getId(); + + // A second write that parks inside persist, and a delete of the + // first record racing it. + store.stallNextSave = true; + final Throwable[] failure = new Throwable[1]; + Thread writer = new Thread(new Runnable() { + public void run() { + try { + store.write(one(QuantitySample.create( + HealthDataType.STEPS, + new HealthQuantity(2, HealthUnit.COUNT), + T0 + MINUTE, T0 + 2 * MINUTE))).get(); + } catch (Throwable t) { + failure[0] = t; + } + } + }); + writer.start(); + store.awaitStall(); + final java.util.concurrent.CountDownLatch deleted = + new java.util.concurrent.CountDownLatch(1); + Thread deleter = new Thread(new Runnable() { + public void run() { + try { + store.delete(HealthDeleteRequest.byId( + HealthDataType.STEPS, firstId)).get(); + deleted.countDown(); + } catch (Throwable t) { + failure[0] = t; + } + } + }); + deleter.start(); + // Wait for the delete to reach a decisive state rather than for a + // wall-clock interval: under the transaction it blocks on the + // same lock the parked write holds, and without it it runs to + // completion and the parked write then lands its stale snapshot + // on top. Timing out here would make the test pass for the wrong + // reason, so the loop below insists on one of the two. + Thread.State reached = awaitBlockedOrDone(deleter); + assertNotNull(reached, "the delete neither blocked nor finished"); + store.releaseStall(); + writer.join(10000); + deleter.join(10000); + assertNull(failure[0]); + + // Whatever order they ran in, disk and memory must agree. + List inMemory = new ArrayList(); + for (HealthSample s : store.readSamples(query()).get()) { + inMemory.add(s.getId()); + } + List onDisk = new ArrayList(); + for (HealthSample s : new StoredHealthStore() + .readSamples(query()).get()) { + onDisk.add(s.getId()); + } + assertEquals(inMemory, onDisk, + "the saved store must match the one in memory"); + } + + /** + * Waits until {@code t} is either blocked on a monitor or finished, + * and reports which. Returns null if it does neither in time. + */ + private static Thread.State awaitBlockedOrDone(Thread t) + throws InterruptedException { + for (int iter = 0; iter < 500; iter++) { + Thread.State state = t.getState(); + if (state == Thread.State.BLOCKED + || state == Thread.State.TERMINATED) { + return state; + } + Thread.sleep(10); + } + return null; + } + + private static SampleQuery query() { + return new SampleQuery().addType(HealthDataType.STEPS) + .setTimeRange(HealthTimeRange.between(T0 - MINUTE, + T0 + 10 * MINUTE)); + } + + /** A store whose save can be parked, opening the race window. */ + private static final class StallingStore extends StoredHealthStore { + + private volatile boolean stallNextSave; + private final java.util.concurrent.CountDownLatch stalled = + new java.util.concurrent.CountDownLatch(1); + private final java.util.concurrent.CountDownLatch release = + new java.util.concurrent.CountDownLatch(1); + + void awaitStall() throws InterruptedException { + assertTrue(stalled.await(10, java.util.concurrent.TimeUnit + .SECONDS), "the writer never reached the save"); + } + + void releaseStall() { + release.countDown(); + } + + @Override + protected boolean writeBlob(String blob) { + if (stallNextSave) { + stallNextSave = false; + stalled.countDown(); + try { + release.await(10, + java.util.concurrent.TimeUnit.SECONDS); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + return super.writeBlob(blob); + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthStoreTest.java b/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthStoreTest.java new file mode 100644 index 00000000000..eef2c7a599f --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/health/LocalHealthStoreTest.java @@ -0,0 +1,543 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import com.codename1.impl.health.LocalHealthStore; +import com.codename1.junit.UITestBase; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.List; +import java.util.TimeZone; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * End-to-end behaviour of the local store, which is what the desktop, + * JavaScript and simulator ports return. These cases also pin the shared + * aggregation rules, since the local store is the only implementation that + * exercises them without a platform behind it. + */ +class LocalHealthStoreTest extends UITestBase { + + private static final TimeZone UTC = TimeZone.getTimeZone("UTC"); + private static final TimeZone LA = + TimeZone.getTimeZone("America/Los_Angeles"); + + private LocalHealthStore store; + + @BeforeEach + void createStore() { + store = new LocalHealthStore(); + } + + private static long utc(int year, int month, int day, int hour) { + Calendar c = Calendar.getInstance(UTC); + c.clear(); + c.set(year, month - 1, day, hour, 0, 0); + return c.getTimeInMillis(); + } + + private void writeSteps(long start, long end, double count) { + QuantitySample s = QuantitySample.create(HealthDataType.STEPS, + new HealthQuantity(count, HealthUnit.COUNT), start, end); + assertNull(errorOf(store.write(s)), "write should succeed"); + } + + @Test + void writeThenReadRoundTrips() { + writeSteps(utc(2026, 1, 1, 9), utc(2026, 1, 1, 10), 1200); + List read = store.readSamples(new SampleQuery() + .addType(HealthDataType.STEPS) + .setTimeRange(HealthTimeRange.between(utc(2026, 1, 1, 0), + utc(2026, 1, 2, 0)))).get(); + assertEquals(1, read.size()); + assertEquals(1200, + ((QuantitySample) read.get(0)).getValue(HealthUnit.COUNT), + 1e-9); + assertNotNull(read.get(0).getId(), "a written sample gets an id"); + } + + /** + * Values come back in the type's canonical unit no matter what unit + * they were written in, so cross-platform code never has to ask. + */ + @Test + void readsAreNormalizedToTheCanonicalUnit() { + QuantitySample lbs = QuantitySample.create(HealthDataType.BODY_MASS, + new HealthQuantity(154, HealthUnit.POUND), utc(2026, 1, 1, 8)); + store.write(lbs).get(); + + List read = store.readSamples(new SampleQuery() + .addType(HealthDataType.BODY_MASS) + .setTimeRange(HealthTimeRange.between(utc(2026, 1, 1, 0), + utc(2026, 1, 2, 0)))).get(); + assertEquals(1, read.size()); + QuantitySample q = (QuantitySample) read.get(0); + assertSame(HealthUnit.KILOGRAM, q.getQuantity().getUnit()); + assertEquals(69.85, q.getValue(HealthUnit.KILOGRAM), 1e-2); + } + + @Test + void requestedUnitOverridesTheCanonicalOne() { + QuantitySample kg = QuantitySample.create(HealthDataType.BODY_MASS, + new HealthQuantity(70, HealthUnit.KILOGRAM), + utc(2026, 1, 1, 8)); + store.write(kg).get(); + + List read = store.readSamples(new SampleQuery() + .addType(HealthDataType.BODY_MASS) + .setUnit(HealthUnit.POUND) + .setTimeRange(HealthTimeRange.between(utc(2026, 1, 1, 0), + utc(2026, 1, 2, 0)))).get(); + assertSame(HealthUnit.POUND, + ((QuantitySample) read.get(0)).getQuantity().getUnit()); + } + + /** + * Steps accumulate over time, so an instantaneous step sample is + * meaningless. It is rejected here rather than by the platform later. + */ + @Test + void instantaneousWriteOfACumulativeTypeIsRejected() { + assertThrows(IllegalArgumentException.class, + () -> QuantitySample.create(HealthDataType.STEPS, + new HealthQuantity(100, HealthUnit.COUNT), + utc(2026, 1, 1, 9))); + } + + @Test + void writeWithAWrongDimensionUnitFailsBeforeReachingTheStore() { + QuantitySample bad = QuantitySample.create(HealthDataType.BODY_MASS, + new HealthQuantity(70, HealthUnit.METER), + utc(2026, 1, 1, 8)); + Throwable err = errorOf(store.write(bad)); + assertNotNull(err); + assertEquals(HealthError.UNIT_MISMATCH, + ((HealthException) err).getError()); + } + + @Test + void deleteByIdRemovesOnlyThatSample() { + writeSteps(utc(2026, 1, 1, 9), utc(2026, 1, 1, 10), 100); + writeSteps(utc(2026, 1, 1, 11), utc(2026, 1, 1, 12), 200); + List all = store.readSamples(allStepsIn(2026, 1, 1)) + .get(); + assertEquals(2, all.size()); + + int removed = store.delete( + HealthDeleteRequest.byId(HealthDataType.STEPS, + all.get(0).getId())).get() + .intValue(); + assertEquals(1, removed); + assertEquals(1, store.readSamples(allStepsIn(2026, 1, 1)).get().size()); + } + + private static SampleQuery allStepsIn(int y, int m, int d) { + return new SampleQuery().addType(HealthDataType.STEPS) + .setTimeRange(HealthTimeRange.between(utc(y, m, d, 0), + utc(y, m, d + 1, 0))); + } + + @Test + void cumulativeAggregationSumsPerBucket() { + writeSteps(utc(2026, 1, 1, 9), utc(2026, 1, 1, 10), 1000); + writeSteps(utc(2026, 1, 1, 11), utc(2026, 1, 1, 12), 500); + writeSteps(utc(2026, 1, 2, 9), utc(2026, 1, 2, 10), 300); + + List buckets = store.aggregate(new AggregateQuery() + .addType(HealthDataType.STEPS) + .addMetric(AggregateMetric.TOTAL) + .setTimeRange(HealthTimeRange.between(utc(2026, 1, 1, 0), + utc(2026, 1, 3, 0))) + .setBucket(HealthInterval.calendarDays(1, UTC))).get(); + + assertEquals(2, buckets.size()); + assertEquals(1500, buckets.get(0) + .get(HealthDataType.STEPS, AggregateMetric.TOTAL) + .getValue(HealthUnit.COUNT), 1e-9); + assertEquals(300, buckets.get(1) + .get(HealthDataType.STEPS, AggregateMetric.TOTAL) + .getValue(HealthUnit.COUNT), 1e-9); + } + + /** + * The single most consequential aggregation rule: an empty bucket + * reports null, not zero. A day with no data and a day on which the + * user took no steps are different facts, and conflating them draws a + * flat line through every day the phone stayed home. + */ + @Test + void emptyBucketsReportNullRatherThanZero() { + writeSteps(utc(2026, 1, 1, 9), utc(2026, 1, 1, 10), 1000); + + List buckets = store.aggregate(new AggregateQuery() + .addType(HealthDataType.STEPS) + .addMetric(AggregateMetric.TOTAL) + .setTimeRange(HealthTimeRange.between(utc(2026, 1, 1, 0), + utc(2026, 1, 3, 0))) + .setBucket(HealthInterval.calendarDays(1, UTC))).get(); + + assertEquals(2, buckets.size()); + assertNotNull(buckets.get(0) + .get(HealthDataType.STEPS, AggregateMetric.TOTAL)); + assertNull(buckets.get(1) + .get(HealthDataType.STEPS, AggregateMetric.TOTAL), + "a day with no data must not report zero steps"); + assertTrue(buckets.get(1).isEmpty()); + assertEquals(0, buckets.get(1).getSampleCount(HealthDataType.STEPS)); + } + + /** + * A calendar day is 23 hours across the spring-forward transition. A + * fixed 86_400_000 bucket would drift a day's worth of data into the + * neighbouring bucket, silently, twice a year. + */ + @Test + void calendarDayBucketsSurviveTheSpringForwardTransition() { + // 2026-03-08 is the US spring-forward date; that local day is 23h. + Calendar c = Calendar.getInstance(LA); + c.clear(); + c.set(2026, Calendar.MARCH, 8, 0, 0, 0); + long dayStart = c.getTimeInMillis(); + c.add(Calendar.DAY_OF_MONTH, 1); + long nextDayStart = c.getTimeInMillis(); + + assertEquals(23 * 3600000L, nextDayStart - dayStart, + "2026-03-08 in Los Angeles is a 23-hour day"); + + HealthInterval day = HealthInterval.calendarDays(1, LA); + assertEquals(nextDayStart, day.nextBoundary(dayStart), + "the bucket boundary must follow the calendar, not a fixed" + + " 24 hours"); + + // A sample late on the short day must land in that day's bucket. + writeSteps(nextDayStart - 3600000L, nextDayStart - 1800000L, 700); + List buckets = store.aggregate(new AggregateQuery() + .addType(HealthDataType.STEPS) + .addMetric(AggregateMetric.TOTAL) + .setTimeRange(HealthTimeRange.between(dayStart, nextDayStart)) + .setBucket(day)).get(); + assertEquals(1, buckets.size()); + assertEquals(700, buckets.get(0) + .get(HealthDataType.STEPS, AggregateMetric.TOTAL) + .getValue(HealthUnit.COUNT), 1e-9); + } + + /** + * The average is weighted by duration, so a heart rate sustained for + * ten minutes outweighs a single spot reading. An unweighted mean over + * irregularly sampled data is how a chart ends up disagreeing with the + * platform's own summary. + */ + @Test + void discreteAverageIsDurationWeighted() { + long base = utc(2026, 1, 1, 9); + // 60 bpm held for 10 minutes, then 120 bpm for 1 minute. + store.write(QuantitySample.create(HealthDataType.HEART_RATE, + new HealthQuantity(60, HealthUnit.COUNT_PER_MINUTE), + base, base + 600000L)).get(); + store.write(QuantitySample.create(HealthDataType.HEART_RATE, + new HealthQuantity(120, HealthUnit.COUNT_PER_MINUTE), + base + 600000L, base + 660000L)).get(); + + List buckets = store.aggregate(new AggregateQuery() + .addType(HealthDataType.HEART_RATE) + .addMetric(AggregateMetric.AVERAGE) + .addMetric(AggregateMetric.MINIMUM) + .addMetric(AggregateMetric.MAXIMUM) + .setTimeRange(HealthTimeRange.between(utc(2026, 1, 1, 0), + utc(2026, 1, 2, 0)))).get(); + + assertEquals(1, buckets.size()); + AggregateResult b = buckets.get(0); + // Unweighted this would be 90; duration-weighted it is ~65.5. + assertEquals(65.45, + b.get(HealthDataType.HEART_RATE, AggregateMetric.AVERAGE) + .getValue(HealthUnit.COUNT_PER_MINUTE), 0.1); + assertEquals(60, + b.get(HealthDataType.HEART_RATE, AggregateMetric.MINIMUM) + .getValue(HealthUnit.COUNT_PER_MINUTE), 1e-9); + assertEquals(120, + b.get(HealthDataType.HEART_RATE, AggregateMetric.MAXIMUM) + .getValue(HealthUnit.COUNT_PER_MINUTE), 1e-9); + } + + @Test + void meaninglessMetricForATypeIsRejected() { + Throwable err = errorOf(store.aggregate(new AggregateQuery() + .addType(HealthDataType.BODY_MASS) + .addMetric(AggregateMetric.TOTAL) + .setTimeRange(HealthTimeRange.lastDays(1)))); + assertNotNull(err, "totalling every weight ever recorded is not a" + + " number anyone should be given"); + assertEquals(HealthError.INVALID_ARGUMENT, + ((HealthException) err).getError()); + } + + @Test + void limitTruncatesAndSaysSo() { + for (int i = 0; i < 5; i++) { + writeSteps(utc(2026, 1, 1, 9) + i * 60000L, + utc(2026, 1, 1, 9) + i * 60000L + 30000L, 10); + } + SamplePage page = store.readSamplePage(new SampleQuery() + .addType(HealthDataType.STEPS) + .setLimit(3) + .setTimeRange(HealthTimeRange.between(utc(2026, 1, 1, 0), + utc(2026, 1, 2, 0)))).get(); + assertEquals(3, page.size()); + assertTrue(page.isTruncated()); + } + + @Test + void descendingSortReturnsNewestFirst() { + writeSteps(utc(2026, 1, 1, 9), utc(2026, 1, 1, 10), 100); + writeSteps(utc(2026, 1, 1, 11), utc(2026, 1, 1, 12), 200); + List read = store.readSamples(new SampleQuery() + .addType(HealthDataType.STEPS) + .setSortDescending(true) + .setTimeRange(HealthTimeRange.between(utc(2026, 1, 1, 0), + utc(2026, 1, 2, 0)))).get(); + assertEquals(200, + ((QuantitySample) read.get(0)).getValue(HealthUnit.COUNT), + 1e-9); + } + + /** + * Series samples are flattened by default so both platforms hand back + * the same thing; turning it off preserves record identity. + */ + @Test + void seriesAreFlattenedByDefault() { + long base = utc(2026, 1, 1, 9); + SeriesSample series = SeriesSample.create(HealthDataType.HEART_RATE, + base, base + 2000, + new long[] { base, base + 1000, base + 2000 }, + new long[] { base, base + 1000, base + 2000 }, + new double[] { 60, 62, 64 }, HealthUnit.COUNT_PER_MINUTE); + store.write(series).get(); + + SampleQuery q = new SampleQuery().addType(HealthDataType.HEART_RATE) + .setTimeRange(HealthTimeRange.between(utc(2026, 1, 1, 0), + utc(2026, 1, 2, 0))); + List flattened = store.readSamples(q).get(); + assertEquals(3, flattened.size()); + for (HealthSample s : flattened) { + assertTrue(s instanceof QuantitySample); + } + + List grouped = + store.readSamples(q.setFlattenSeries(false)).get(); + assertEquals(1, grouped.size()); + assertTrue(grouped.get(0) instanceof SeriesSample); + assertEquals(3, ((SeriesSample) grouped.get(0)).size()); + } + + @Test + void sourceFilterExcludesOtherApps() { + QuantitySample mine = QuantitySample.create(HealthDataType.BODY_MASS, + new HealthQuantity(70, HealthUnit.KILOGRAM), + utc(2026, 1, 1, 8)); + mine.setSource(new HealthSource("com.example.mine", "Mine", null, + null, null)); + QuantitySample theirs = QuantitySample.create( + HealthDataType.BODY_MASS, + new HealthQuantity(71, HealthUnit.KILOGRAM), + utc(2026, 1, 1, 9)); + theirs.setSource(new HealthSource("com.example.theirs", "Theirs", + null, null, null)); + List both = new ArrayList(); + both.add(mine); + both.add(theirs); + store.write(both).get(); + + List filtered = store.readSamples(new SampleQuery() + .addType(HealthDataType.BODY_MASS) + .addSource("com.example.mine") + .setTimeRange(HealthTimeRange.between(utc(2026, 1, 1, 0), + utc(2026, 1, 2, 0)))).get(); + assertEquals(1, filtered.size()); + assertEquals("com.example.mine", + filtered.get(0).getSource().getBundleId()); + } + + /** + * An {@code except} callback on an already-settled resource fires + * synchronously, so the error can be read without waiting. + */ + private static Throwable errorOf(com.codename1.util.AsyncResource r) { + final Throwable[] err = new Throwable[1]; + r.except(new com.codename1.util.SuccessCallback() { + public void onSucess(Throwable t) { + err[0] = t; + } + }); + return err[0]; + } + + /** + * Every sample shape is copied on the way in and on the way out. + * + *

Only {@link QuantitySample} used to be, so writing a workout, a + * sleep session or a nutrition entry left the store holding the + * caller's object -- editing it afterwards silently rewrote what was + * stored, and clearing its identifier broke deletion by id. A read + * handed the same object straight back, so the same edit worked in + * reverse.

+ */ + @Test + void everySampleShapeIsSnapshotOnWriteAndOnRead() throws Exception { + LocalHealthStore store = new LocalHealthStore(); + + WorkoutSample workout = WorkoutSample.create( + WorkoutActivityType.RUNNING, 1000L, 5000L); + workout.setId("workout-1"); + workout.setActiveDurationMillis(4000L); + // The store assigns the identifier, as a platform would. + String assigned = store.write(workout).get().getSampleIds().get(0); + workout.setId("tampered"); + workout.setActiveDurationMillis(0L); + + List read = store.readSamples(new SampleQuery() + .addType(HealthDataType.WORKOUT) + .setTimeRange(HealthTimeRange.between(0L, 10_000L))).get(); + assertEquals(1, read.size()); + WorkoutSample stored = (WorkoutSample) read.get(0); + assertNotSame(workout, stored, "the store must not alias the caller"); + assertEquals(assigned, stored.getId(), + "an edit after the write must not reach the store"); + assertEquals(4000L, stored.getActiveDurationMillis(), + "shape-specific state survives the write"); + + // And the object handed back is a copy too. + stored.setId("tampered-again"); + stored.setActiveDurationMillis(0L); + WorkoutSample again = (WorkoutSample) store.readSamples( + new SampleQuery().addType(HealthDataType.WORKOUT) + .setTimeRange(HealthTimeRange.between(0L, 10_000L))) + .get().get(0); + assertEquals(assigned, again.getId(), + "editing a read result must not reach the store either"); + assertEquals(4000L, again.getActiveDurationMillis()); + } + + /** + * A write does not stamp an identifier onto the caller's sample. + * + *

This was the one store that mutated its input. {@code + * HealthSample.hashCode()} switches from identity to the id once one + * is set, so a sample already held in a {@code HashSet} or used as a + * map key became unreachable the moment it was written. The id reaches + * the caller through {@link HealthWriteResult}, as on every other + * platform.

+ */ + @Test + void writingDoesNotStampAnIdOntoTheCallersSample() throws Exception { + LocalHealthStore store = new LocalHealthStore(); + QuantitySample sample = QuantitySample.create(HealthDataType.STEPS, + new HealthQuantity(100, HealthUnit.COUNT), 1000L, 2000L); + + java.util.Set held = + new java.util.HashSet(); + held.add(sample); + + String id = store.write(sample).get().getSampleIds().get(0); + assertNull(sample.getId(), + "the caller's sample must come back untouched"); + assertTrue(held.contains(sample), + "a sample used as a key must stay reachable after a write"); + + // And the stored record does carry the identifier the result named. + List read = store.readSamples(new SampleQuery() + .addType(HealthDataType.STEPS) + .setTimeRange(HealthTimeRange.between(0L, 5000L))).get(); + assertEquals(id, read.get(0).getId()); + } + + /** + * An id-based delete honours the type it was given. + * + *

Health Connect deletes against a record class, so a stale or + * mispaired type and id removes nothing there. Matching on the + * identifier alone made the local store more destructive than the + * platform it stands in for.

+ */ + @Test + void deletingByIdRespectsTheRequestedType() throws Exception { + LocalHealthStore store = new LocalHealthStore(); + QuantitySample steps = QuantitySample.create(HealthDataType.STEPS, + new HealthQuantity(100, HealthUnit.COUNT), 1000L, 2000L); + String id = store.write(steps).get().getSampleIds().get(0); + + // The right id, the wrong type: nothing is removed. + assertEquals(0, store.delete(HealthDeleteRequest.byId( + HealthDataType.BODY_MASS, id)).get().intValue()); + assertEquals(1, store.readSamples(new SampleQuery() + .addType(HealthDataType.STEPS) + .setTimeRange(HealthTimeRange.between(0L, 5000L))) + .get().size()); + + // The pair the write actually produced does remove it. + assertEquals(1, store.delete(HealthDeleteRequest.byId( + HealthDataType.STEPS, id)).get().intValue()); + assertEquals(0, store.readSamples(new SampleQuery() + .addType(HealthDataType.STEPS) + .setTimeRange(HealthTimeRange.between(0L, 5000L))) + .get().size()); + } + + /** + * A unit conversion on the write path must not cost the sample its + * source. + * + *

It made the same measurement filterable or not depending on + * which unit the caller happened to use: a weight in kilograms kept + * its source, and one in pounds -- converted on the way in -- came + * back excluded from every {@code addSource()} query.

+ */ + @Test + void aConvertedWriteKeepsItsSource() throws Exception { + LocalHealthStore store = new LocalHealthStore(); + QuantitySample inPounds = QuantitySample.create( + HealthDataType.BODY_MASS, + new HealthQuantity(154, HealthUnit.POUND), 1767225600000L); + inPounds.setSource(new HealthSource("com.example.scales", "Scales", + null, null, null)); + List batch = new ArrayList(); + batch.add(inPounds); + store.write(batch).get(); + + List filtered = store.readSamples(new SampleQuery() + .addType(HealthDataType.BODY_MASS) + .addSource("com.example.scales") + .setTimeRange(HealthTimeRange.between(1767225000000L, + 1767226000000L))).get(); + assertEquals(1, filtered.size(), + "the converted sample must still match its own source"); + assertEquals("com.example.scales", + filtered.get(0).getSource().getBundleId()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/health/SleepAndIdentityGuardsTest.java b/maven/core-unittests/src/test/java/com/codename1/health/SleepAndIdentityGuardsTest.java new file mode 100644 index 00000000000..fbce753dab6 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/health/SleepAndIdentityGuardsTest.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Guards on the value objects an app builds by hand: sleep sessions and + * the identifier a subscription is persisted under. + */ +class SleepAndIdentityGuardsTest { + + private static final long START = 1767225600000L; + + private static SleepStageInterval stage(SleepStage s, long from, + long to) { + return new SleepStageInterval(s, START + from, START + to); + } + + /** + * A null in the supplied list was skipped by validation and then + * copied in anyway, so it sat in the session waiting for the first + * accessor that walked the stages to dereference it. + */ + @Test + void nullStagesAreDroppedRatherThanStored() { + List supplied = + new ArrayList(); + supplied.add(stage(SleepStage.LIGHT, 0, 1000)); + supplied.add(null); + supplied.add(stage(SleepStage.DEEP, 1000, 2000)); + + SleepSample sleep = SleepSample.create(START, START + 2000, + supplied); + + assertEquals(2, sleep.getStages().size()); + // These are the calls that used to throw. + assertTrue(sleep.hasStageDetail()); + assertEquals(1000, sleep.getDurationMillis(SleepStage.LIGHT)); + assertEquals(2000, sleep.getAsleepDurationMillis()); + } + + /** + * Platform sleep categories overlap, so summing them reported more + * time asleep than the session lasted. LIGHT [0,10] plus DEEP [5,15] + * covers 15, not 20. + */ + @Test + void overlappingStagesCountTheOverlapOnce() { + List supplied = + new ArrayList(); + supplied.add(stage(SleepStage.LIGHT, 0, 10000)); + supplied.add(stage(SleepStage.DEEP, 5000, 15000)); + + SleepSample sleep = SleepSample.create(START, START + 15000, + supplied); + + assertEquals(15000, sleep.getAsleepDurationMillis()); + assertTrue(sleep.getAsleepDurationMillis() + <= sleep.getDurationMillis()); + } + + /** Two spans of one stage that overlap are the same minutes twice. */ + @Test + void overlappingSpansOfOneStageCountOnce() { + List supplied = + new ArrayList(); + supplied.add(stage(SleepStage.REM, 0, 10000)); + supplied.add(stage(SleepStage.REM, 4000, 6000)); + + SleepSample sleep = SleepSample.create(START, START + 10000, + supplied); + + assertEquals(10000, sleep.getDurationMillis(SleepStage.REM)); + } + + /** Disjoint spans still add up, in any supplied order. */ + @Test + void disjointStagesStillSum() { + List supplied = + new ArrayList(); + supplied.add(stage(SleepStage.DEEP, 12000, 15000)); + supplied.add(stage(SleepStage.LIGHT, 0, 5000)); + + SleepSample sleep = SleepSample.create(START, START + 15000, + supplied); + + assertEquals(8000, sleep.getAsleepDurationMillis()); + } + + /** Awake time is not time asleep, overlapping or not. */ + @Test + void awakeSpansAreNotCounted() { + List supplied = + new ArrayList(); + supplied.add(stage(SleepStage.LIGHT, 0, 10000)); + supplied.add(stage(SleepStage.AWAKE, 3000, 6000)); + + SleepSample sleep = SleepSample.create(START, START + 10000, + supplied); + + assertEquals(10000, sleep.getAsleepDurationMillis()); + assertEquals(3000, sleep.getDurationMillis(SleepStage.AWAKE)); + } + + /** + * The subscription registry is newline-delimited by record and + * tab-delimited by field and is written unescaped, so an id carrying + * either came back as a different id -- or as none -- after a + * restart, and the subscription stopped delivering with nothing said. + */ + @Test + void subscriptionIdsRejectTheRegistryDelimiters() { + assertThrows(IllegalArgumentException.class, + () -> new SubscriptionRequest("steps\nv1")); + assertThrows(IllegalArgumentException.class, + () -> new SubscriptionRequest("steps\tv1")); + assertThrows(IllegalArgumentException.class, + () -> new SubscriptionRequest("steps\rv1")); + } + + /** Ordinary ids, including punctuation and non-ASCII, still work. */ + @Test + void ordinarySubscriptionIdsAreAccepted() { + assertEquals("steps-v1", + new SubscriptionRequest(" steps-v1 ").getId()); + assertEquals("pas quotidiens", + new SubscriptionRequest("pas quotidiens").getId()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/health/WorkoutAndNutritionTest.java b/maven/core-unittests/src/test/java/com/codename1/health/WorkoutAndNutritionTest.java new file mode 100644 index 00000000000..ff68e1af893 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/health/WorkoutAndNutritionTest.java @@ -0,0 +1,607 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health; + +import com.codename1.health.nutrition.Nutrient; +import com.codename1.health.nutrition.NutritionSample; +import com.codename1.health.workout.WorkoutConfiguration; +import com.codename1.health.workout.WorkoutEvent; +import com.codename1.health.workout.WorkoutManager; +import com.codename1.health.workout.WorkoutSession; +import com.codename1.health.workout.WorkoutSessionState; +import com.codename1.junit.UITestBase; +import com.codename1.util.AsyncResource; +import com.codename1.util.SuccessCallback; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** Workout recording and nutrition logging. */ +class WorkoutAndNutritionTest extends UITestBase { + + /** + * The stores write whatever they are handed, so a total in the wrong + * dimension round-tripped intact and only blew up in the caller that + * did the documented thing and asked for kilocalories. + */ + @Test + void workoutTotalsRejectTheWrongDimension() { + WorkoutSample w = WorkoutSample.create(WorkoutActivityType.RUNNING, + 1767225600000L, 1767225600000L + 60000L); + assertThrows(IllegalArgumentException.class, + () -> w.setTotalEnergy( + new HealthQuantity(70, HealthUnit.KILOGRAM))); + assertThrows(IllegalArgumentException.class, + () -> w.setTotalDistance( + new HealthQuantity(500, HealthUnit.KILOCALORIE))); + assertNull(w.getTotalEnergy()); + assertNull(w.getTotalDistance()); + } + + /** + * Active duration excludes pauses, so it is a subset of the span and + * cannot exceed it. Stored unchecked, every pace and average drawn + * from the workout was wrong in a way that looks like a units bug + * somewhere else. + */ + @Test + void activeDurationCannotExceedTheWorkoutItself() { + WorkoutSample w = WorkoutSample.create(WorkoutActivityType.RUNNING, + 1767225600000L, 1767225600000L + 60000L); + assertThrows(IllegalArgumentException.class, + () -> w.setActiveDurationMillis(60001L)); + // The whole span is legal -- a workout with no pauses. + w.setActiveDurationMillis(60000L); + assertEquals(60000L, w.getActiveDurationMillis()); + // And a negative still means "not reported separately", which + // reads back as the wall duration. + w.setActiveDurationMillis(-1L); + assertEquals(60000L, w.getActiveDurationMillis()); + } + + /** Any unit of the right dimension is accepted, and null still means + * the total was never measured. */ + @Test + void workoutTotalsAcceptAnyUnitOfTheRightDimension() { + WorkoutSample w = WorkoutSample.create(WorkoutActivityType.RUNNING, + 1767225600000L, 1767225600000L + 60000L); + w.setTotalEnergy(new HealthQuantity(2000, HealthUnit.KILOJOULE)); + w.setTotalDistance(new HealthQuantity(5, HealthUnit.KILOMETER)); + assertEquals(5000, + w.getTotalDistance().in(HealthUnit.METER).getValue( + HealthUnit.METER), 0.001); + w.setTotalEnergy(null); + assertNull(w.getTotalEnergy()); + } + + private static Throwable errorOf(AsyncResource r) { + final Throwable[] err = new Throwable[1]; + r.except(new SuccessCallback() { + public void onSucess(Throwable t) { + err[0] = t; + } + }); + return err[0]; + } + + private static WorkoutSession startedSession() { + WorkoutManager m = new WorkoutManager(); + WorkoutSession s = m.startSession(new WorkoutConfiguration() + .setActivityType(WorkoutActivityType.RUNNING)).get(); + s.start(); + return s; + } + + // ---- workouts ---- + + @Test + void aFreshManagerReportsNoLiveSupportAndRecordsInstead() { + WorkoutManager m = new WorkoutManager(); + assertFalse(m.isLiveSessionSupported()); + assertFalse(m.isSensorCollectionSupported()); + WorkoutSession s = m.startSession(new WorkoutConfiguration()).get(); + assertNotNull(s); + assertFalse(s.isLive(), + "a recorded session must say so, so UI does not promise a" + + " heart rate that will never arrive"); + } + + @Test + void stateMachineFollowsTheLifecycle() { + WorkoutManager m = new WorkoutManager(); + WorkoutSession s = m.startSession(new WorkoutConfiguration()).get(); + assertEquals(WorkoutSessionState.NOT_STARTED, s.getState()); + s.start(); + assertEquals(WorkoutSessionState.RUNNING, s.getState()); + s.pause(); + assertEquals(WorkoutSessionState.PAUSED, s.getState()); + s.resume(); + assertEquals(WorkoutSessionState.RUNNING, s.getState()); + } + + /** + * Illegal transitions fail with a typed error rather than throwing or + * silently doing nothing. + */ + @Test + void illegalTransitionsFailWithSessionState() { + WorkoutManager m = new WorkoutManager(); + WorkoutSession s = m.startSession(new WorkoutConfiguration()).get(); + + Throwable err = errorOf(s.pause()); + assertNotNull(err, "pausing a session that never started"); + assertEquals(HealthError.SESSION_STATE, + ((HealthException) err).getError()); + + err = errorOf(s.end()); + assertNotNull(err, "ending a session that never started"); + assertEquals(HealthError.SESSION_STATE, + ((HealthException) err).getError()); + } + + @Test + void onlyOneSessionMayRunAtATime() { + WorkoutManager m = new WorkoutManager(); + m.startSession(new WorkoutConfiguration()).get(); + Throwable err = errorOf(m.startSession(new WorkoutConfiguration())); + assertNotNull(err, "an abandoned workout is data the user believed" + + " was being recorded"); + assertEquals(HealthError.SESSION_STATE, + ((HealthException) err).getError()); + } + + @Test + void discardFreesTheManagerForANewSession() { + WorkoutManager m = new WorkoutManager(); + WorkoutSession s = m.startSession(new WorkoutConfiguration()).get(); + s.discard(); + assertNull(m.getActiveSession()); + assertNull(errorOf(m.startSession(new WorkoutConfiguration()))); + } + + /** + * A statistic nothing was fed for is null, not zero -- showing "0 bpm" + * would be a claim the app cannot support. + */ + @Test + void unfedStatisticsAreNullRatherThanZero() { + WorkoutSession s = startedSession(); + assertNull(s.getStatistic(HealthDataType.HEART_RATE, + AggregateMetric.AVERAGE)); + assertNull(s.getStatistic(HealthDataType.ACTIVE_ENERGY, + AggregateMetric.TOTAL)); + } + + @Test + void fedSamplesRollUpIntoStatistics() { + WorkoutSession s = startedSession(); + List samples = new ArrayList(); + samples.add(QuantitySample.create(HealthDataType.HEART_RATE, + new HealthQuantity(60, HealthUnit.COUNT_PER_MINUTE), 1000L)); + samples.add(QuantitySample.create(HealthDataType.HEART_RATE, + new HealthQuantity(80, HealthUnit.COUNT_PER_MINUTE), 2000L)); + s.addSamples(samples); + + assertEquals(70.0, s.getStatistic(HealthDataType.HEART_RATE, + AggregateMetric.AVERAGE) + .getValue(HealthUnit.COUNT_PER_MINUTE), 1e-9); + assertEquals(60.0, s.getStatistic(HealthDataType.HEART_RATE, + AggregateMetric.MINIMUM) + .getValue(HealthUnit.COUNT_PER_MINUTE), 1e-9); + assertEquals(80.0, s.getStatistic(HealthDataType.HEART_RATE, + AggregateMetric.MAXIMUM) + .getValue(HealthUnit.COUNT_PER_MINUTE), 1e-9); + assertEquals(80.0, s.getStatistic(HealthDataType.HEART_RATE, + AggregateMetric.LATEST) + .getValue(HealthUnit.COUNT_PER_MINUTE), 1e-9); + } + + @Test + void cumulativeSamplesTotalRatherThanAverage() { + WorkoutSession s = startedSession(); + List samples = new ArrayList(); + samples.add(QuantitySample.create(HealthDataType.ACTIVE_ENERGY, + new HealthQuantity(100, HealthUnit.KILOCALORIE), + 1000L, 2000L)); + samples.add(QuantitySample.create(HealthDataType.ACTIVE_ENERGY, + new HealthQuantity(50, HealthUnit.KILOCALORIE), + 2000L, 3000L)); + s.addSamples(samples); + + assertEquals(150.0, s.getStatistic(HealthDataType.ACTIVE_ENERGY, + AggregateMetric.TOTAL).getValue(HealthUnit.KILOCALORIE), + 1e-9); + } + + @Test + void eventsAreRecordedInOrder() { + WorkoutSession s = startedSession(); + s.addEvent(WorkoutEvent.lap(1000L)); + s.addEvent(WorkoutEvent.marker(2000L, "hill")); + List events = s.getEvents(); + // start() records nothing, but pause/resume do; only ours here. + assertEquals(2, events.size()); + assertEquals(WorkoutEvent.Kind.LAP, events.get(0).getKind()); + assertEquals("hill", events.get(1).getLabel()); + } + + @Test + void pausingRecordsAnEventAndStopsTheElapsedClock() throws Exception { + WorkoutSession s = startedSession(); + s.pause(); + long afterPause = s.getElapsedMillis(); + Thread.sleep(30); + assertEquals(afterPause, s.getElapsedMillis(), + "the elapsed clock must not advance while paused"); + boolean sawPause = false; + for (WorkoutEvent e : s.getEvents()) { + if (e.getKind() == WorkoutEvent.Kind.PAUSE) { + sawPause = true; + } + } + assertTrue(sawPause); + } + + @Test + void configurationCarriesTheActivityAndLocation() { + WorkoutConfiguration c = new WorkoutConfiguration() + .setActivityType(WorkoutActivityType.CYCLING) + .setLocationType( + com.codename1.health.workout.WorkoutLocationType + .OUTDOOR) + .setTitle("Evening ride"); + assertEquals(WorkoutActivityType.CYCLING, c.getActivityType()); + assertEquals("Evening ride", c.getTitle()); + // Keepalive is covered on its own below: it is refused in this + // release, and this case asserted that it was stored. + } + + @Test + void workoutSampleTotalsAreNullUntilSet() { + WorkoutSample w = WorkoutSample.create(WorkoutActivityType.RUNNING, + 1000L, 5000L); + assertNull(w.getTotalEnergy(), + "an unmeasured energy total must not read as 0 kcal"); + assertNull(w.getTotalDistance()); + assertEquals(4000L, w.getActiveDurationMillis(), + "active duration falls back to the wall duration"); + } + + // ---- nutrition ---- + + @Test + void nutritionSampleIsSparse() { + NutritionSample lunch = NutritionSample.create(1000L, 2000L) + .setFoodName("Chicken salad") + .setMealType(NutritionSample.MEAL_LUNCH) + .setNutrient(Nutrient.ENERGY, 420) + .setNutrient(Nutrient.PROTEIN, 35); + + assertEquals(2, lunch.getNutrientCount()); + assertTrue(lunch.hasNutrient(Nutrient.ENERGY)); + assertFalse(lunch.hasNutrient(Nutrient.SODIUM)); + assertEquals(NutritionSample.MEAL_LUNCH, lunch.getMealType()); + assertEquals("Chicken salad", lunch.getFoodName()); + } + + /** + * An unmeasured nutrient is null, not zero -- the distinction matters + * to someone managing their sodium. + */ + @Test + void unmeasuredNutrientsReadAsNull() { + NutritionSample s = NutritionSample.create(1000L, 2000L) + .setNutrient(Nutrient.ENERGY, 100); + assertNull(s.getNutrient(Nutrient.SODIUM)); + assertNotNull(s.getNutrient(Nutrient.ENERGY)); + } + + @Test + void nutrientAmountsUseTheNutrientsOwnUnit() { + NutritionSample s = NutritionSample.create(1000L, 2000L) + .setNutrient(Nutrient.SODIUM, 610); + assertSame(HealthUnit.MILLIGRAM, + s.getNutrient(Nutrient.SODIUM).getUnit()); + assertEquals(610, s.getNutrient(Nutrient.SODIUM) + .getValue(HealthUnit.MILLIGRAM), 1e-9); + } + + @Test + void nutrientAmountsConvertFromAnExplicitUnit() { + NutritionSample s = NutritionSample.create(1000L, 2000L) + .setNutrient(Nutrient.SODIUM, 1.0, HealthUnit.GRAM); + assertEquals(1000.0, s.getNutrient(Nutrient.SODIUM) + .getValue(HealthUnit.MILLIGRAM), 1e-6); + } + + @Test + void wrongDimensionNutrientUnitIsRejected() { + assertThrows(IllegalArgumentException.class, + () -> NutritionSample.create(1000L, 2000L) + .setNutrient(Nutrient.PROTEIN, 5, HealthUnit.LITER)); + } + + @Test + void nutrientLookupRoundTripsAndIsTotal() { + for (Nutrient n : Nutrient.values()) { + assertSame(n, Nutrient.forId(n.getId())); + assertNotNull(n.getUnit()); + } + assertNull(Nutrient.forId("unobtainium")); + assertNull(Nutrient.forId(null)); + } + + @Test + void instantaneousNutritionStillSpansAnInterval() { + NutritionSample s = NutritionSample.create(1000L); + assertFalse(s.isInstantaneous(), + "nutrition is an interval-only type"); + assertSame(HealthDataType.NUTRITION, s.getType()); + } + + /** + * A recorded workout says what it could not store. + * + *

Health Connect has no single-value write form for the + * series-shaped types -- power, speed and both cadences -- which is + * exactly what a bike or foot pod feeds into a workout. Those samples + * were dropped on the way to the store while {@code end()} resolved as + * though nothing had happened. They still cannot be stored, but the + * workout now names them so the app can keep them itself.

+ */ + @Test + void aRecordedWorkoutReportsSamplesItCouldNotStore() throws Exception { + final FakeHealthStore store = new FakeHealthStore(); + // The mobile shape: the workout record itself has no write form, + // and neither do the series-backed sensor types. + store.unwritable.add(HealthDataType.WORKOUT); + store.unwritable.add(HealthDataType.POWER); + implementation.setHealth(new Health() { + @Override + public boolean isSupported() { + return true; + } + + @Override + public HealthStore getStore() { + return store; + } + }); + try { + WorkoutManager workouts = Health.getInstance().getWorkouts(); + WorkoutSession session = workouts.startSession( + new WorkoutConfiguration().setActivityType( + WorkoutActivityType.CYCLING)).get(); + session.start(); + List fed = new ArrayList(); + fed.add(FakeHealthStore.sample(HealthDataType.POWER, + 1000L, 1000L, 210)); + fed.add(FakeHealthStore.sample(HealthDataType.ACTIVE_ENERGY, + 1000L, 2000L, 12)); + session.addSamples(fed).get(); + + WorkoutSample done = session.end().get(); + assertEquals("power", done.getMetadata().get( + WorkoutSample.SAMPLES_NOT_PERSISTED), + "the workout must name what it could not store"); + assertEquals("true", done.getMetadata().get( + WorkoutSample.WORKOUT_NOT_PERSISTED), + "and say that the session record itself was not stored"); + // Nothing writable at all -- the ordinary no-sensor workout on + // both mobile platforms. That path returns early, and used to + // come back with neither an id nor any explanation. + WorkoutSession bare = workouts.startSession( + new WorkoutConfiguration().setActivityType( + WorkoutActivityType.WALKING)).get(); + bare.start(); + WorkoutSample bareDone = bare.end().get(); + assertEquals("true", bareDone.getMetadata().get( + WorkoutSample.WORKOUT_NOT_PERSISTED), + "an empty recorded workout still says it was not" + + " persisted"); + } finally { + implementation.setHealth(null); + } + } + + /** + * Every distance category counts toward the workout total. + * + *

The rollup picked the first category that answered, and never + * looked at {@code DISTANCE_SWIMMING} at all -- so a recorded swim + * came back with no total distance despite the samples that produced + * it being right there, and a triathlon reported the run while + * silently discarding the ride and the swim.

+ */ + @Test + void everyDistanceCategoryReachesTheWorkoutTotal() throws Exception { + final FakeHealthStore store = new FakeHealthStore(); + // The mobile shape, and what keeps this deterministic: neither + // platform accepts a workout record through the sample write + // path, so end() returns the rolled-up sample without writing it + // -- and a session that started and ended inside the same + // millisecond is not rejected for marking a single instant. + store.unwritable.add(HealthDataType.WORKOUT); + implementation.setHealth(new Health() { + @Override + public boolean isSupported() { + return true; + } + + @Override + public HealthStore getStore() { + return store; + } + }); + try { + WorkoutManager workouts = Health.getInstance().getWorkouts(); + + WorkoutSession swim = workouts.startSession( + new WorkoutConfiguration().setActivityType( + WorkoutActivityType.SWIMMING)).get(); + swim.start(); + List laps = new ArrayList(); + laps.add(FakeHealthStore.sample( + HealthDataType.DISTANCE_SWIMMING, 1000L, 2000L, 750)); + swim.addSamples(laps).get(); + WorkoutSample swimDone = swim.end().get(); + assertNotNull(swimDone.getTotalDistance(), + "a swim fed distance samples must report a total"); + assertEquals(750, swimDone.getTotalDistance() + .getValue(HealthUnit.METER), 1e-9); + + WorkoutSession tri = workouts.startSession( + new WorkoutConfiguration().setActivityType( + WorkoutActivityType.OTHER)).get(); + tri.start(); + List legs = new ArrayList(); + legs.add(FakeHealthStore.sample( + HealthDataType.DISTANCE_SWIMMING, 1000L, 2000L, 1500)); + legs.add(FakeHealthStore.sample( + HealthDataType.DISTANCE_CYCLING, 2000L, 3000L, 40000)); + legs.add(FakeHealthStore.sample( + HealthDataType.DISTANCE_WALKING_RUNNING, + 3000L, 4000L, 10000)); + tri.addSamples(legs).get(); + WorkoutSample triDone = tri.end().get(); + assertEquals(51500, triDone.getTotalDistance() + .getValue(HealthUnit.METER), 1e-9, + "every leg counts, not the first one that answers"); + + // Still null with nothing fed in: no distance and zero + // distance are different facts. + WorkoutSession bare = workouts.startSession( + new WorkoutConfiguration().setActivityType( + WorkoutActivityType.WALKING)).get(); + bare.start(); + assertNull(bare.end().get().getTotalDistance()); + } finally { + implementation.setHealth(null); + } + } + + /** + * A series fed to a workout counts toward its statistics, and LATEST + * follows the timestamp rather than the arrival order. + * + *

The rollup skipped anything that was not a {@code QuantitySample}, + * so a heart-rate trace left AVERAGE, MINIMUM and MAXIMUM null for + * data the session was collecting and would go on to persist. And + * LATEST was replaced by whatever arrived last, so a delayed device + * reading or an unsorted history batch reported a stale value as the + * newest.

+ */ + @Test + void seriesSamplesCountAndLatestFollowsTheClock() throws Exception { + WorkoutSession s = startedSession(); + + long[] at = {5000L, 6000L, 7000L}; + double[] bpm = {60, 80, 70}; + List fed = new ArrayList(); + fed.add(SeriesSample.create(HealthDataType.HEART_RATE, at[0], at[2], + at, at, bpm, HealthUnit.COUNT_PER_MINUTE)); + s.addSamples(fed).get(); + + assertEquals(60.0, s.getStatistic(HealthDataType.HEART_RATE, + AggregateMetric.MINIMUM).getValue( + HealthUnit.COUNT_PER_MINUTE), 1e-9); + assertEquals(80.0, s.getStatistic(HealthDataType.HEART_RATE, + AggregateMetric.MAXIMUM).getValue( + HealthUnit.COUNT_PER_MINUTE), 1e-9); + assertEquals(70.0, s.getStatistic(HealthDataType.HEART_RATE, + AggregateMetric.LATEST).getValue( + HealthUnit.COUNT_PER_MINUTE), 1e-9, + "the last measurement in the series is the latest"); + + // An older reading arriving afterwards must not become "latest". + List late = new ArrayList(); + late.add(FakeHealthStore.sample(HealthDataType.HEART_RATE, + 3000L, 3000L, 55)); + s.addSamples(late).get(); + assertEquals(70.0, s.getStatistic(HealthDataType.HEART_RATE, + AggregateMetric.LATEST).getValue( + HealthUnit.COUNT_PER_MINUTE), 1e-9, + "a delayed older reading is not the newest one"); + assertEquals(55.0, s.getStatistic(HealthDataType.HEART_RATE, + AggregateMetric.MINIMUM).getValue( + HealthUnit.COUNT_PER_MINUTE), 1e-9, + "but it still counts everywhere else"); + } + + /** + * Background keepalive is not implemented anywhere in this release, + * so the setter refuses it rather than storing a request nothing + * honours. + * + *

An accepted-but-ignored flag is the worst of the options here: + * the app cannot tell it was ignored until a user loses a workout to + * a suspended process.

+ */ + @Test + void backgroundKeepAliveIsRefusedRatherThanIgnored() { + WorkoutConfiguration c = new WorkoutConfiguration() + .setActivityType(WorkoutActivityType.RUNNING); + assertThrows(IllegalArgumentException.class, + () -> c.setKeepAliveInBackground(true)); + // False is the default and stays accepted. + c.setKeepAliveInBackground(false); + assertFalse(c.isKeepAliveInBackground()); + } + + /** + * A null in the fed list must not reach the session's collection. + * + *

{@code rollUp} ignored it, but the list was stored whole and + * {@code doEnd} dereferences what it stored -- so one null threw out + * of {@code end()} after the session had already moved to STOPPED, + * leaving the caller with neither a result nor a session it could + * retry.

+ */ + @Test + void aNullSampleDoesNotBreakTheWorkoutAtTheEnd() { + WorkoutSession s = startedSession(); + List fed = new ArrayList(); + fed.add(FakeHealthStore.sample(HealthDataType.HEART_RATE, + 1767225600000L, 1767225600000L, 140)); + fed.add(null); + s.addSamples(fed); + + AsyncResource ended = s.end(); + assertNull(errorOf(ended), "ending must not throw over a null"); + assertNotNull(ended.get()); + } + + /** A list of nothing but nulls is accepted and adds nothing. */ + @Test + void aListOfNullsAddsNothing() { + WorkoutSession s = startedSession(); + List fed = new ArrayList(); + fed.add(null); + AsyncResource added = s.addSamples(fed); + assertNull(errorOf(added)); + assertEquals(Boolean.TRUE, added.get()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/health/sensors/BleSensorReconnectTest.java b/maven/core-unittests/src/test/java/com/codename1/health/sensors/BleSensorReconnectTest.java new file mode 100644 index 00000000000..97bd3bd1ccd --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/health/sensors/BleSensorReconnectTest.java @@ -0,0 +1,896 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +import com.codename1.bluetooth.BluetoothError; +import com.codename1.bluetooth.BluetoothException; +import com.codename1.bluetooth.BluetoothUuid; +import com.codename1.bluetooth.gatt.GattCharacteristic; +import com.codename1.bluetooth.gatt.GattDescriptor; +import com.codename1.bluetooth.gatt.GattService; +import com.codename1.bluetooth.le.BlePeripheral; +import com.codename1.bluetooth.le.ConnectionOptions; +import com.codename1.bluetooth.le.ConnectionPriority; +import com.codename1.bluetooth.le.ConnectionState; +import com.codename1.bluetooth.le.L2capChannel; +import com.codename1.junit.UITestBase; +import com.codename1.util.AsyncResource; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The auto-reconnect ladder, driven through a fake peripheral rather than + * a radio. + * + *

The behaviour under test is what happens when the link comes back but + * the work that follows it does not: a session that reconnects and then + * trips over discovery used to be retired outright, which is not something + * the caller can see coming or recover from.

+ */ +class BleSensorReconnectTest extends UITestBase { + + /** Heart Rate service and its measurement characteristic. */ + private static final BluetoothUuid HR_SERVICE = + BluetoothUuid.fromShort(0x180D); + private static final BluetoothUuid HR_MEASUREMENT = + BluetoothUuid.fromShort(0x2A37); + + /** Battery service and its level characteristic. */ + private static final BluetoothUuid BATTERY_SERVICE = + BluetoothUuid.fromShort(0x180F); + private static final BluetoothUuid BATTERY_LEVEL = + BluetoothUuid.fromShort(0x2A19); + + /** + * A transient discovery failure on the way back must not end the + * session: the reconnect listener never retries from FAILED, so one + * stumble used to stop the documented auto-reconnect for good. + */ + @Test + void aTransientDiscoveryFailureOnReconnectIsRetried() { + FakePeripheral p = new FakePeripheral(); + BleSensorSession session = start(p); + assertEquals(SensorSessionState.STREAMING, session.getState()); + + p.failDiscoveries = 1; + p.dropLink(); + + pumpUntil(session, SensorSessionState.STREAMING); + assertEquals(SensorSessionState.STREAMING, session.getState(), + "one failed discovery should be retried, not fatal"); + assertTrue(p.discoveries >= 3, + "expected a retry after the failed discovery, saw " + + p.discoveries + " discoveries"); + } + + /** + * It cannot retry forever either. A peripheral that has genuinely + * changed will never expose the characteristic again, and a session + * reconnecting at it all day is its own failure mode. + */ + @Test + void aPersistentDiscoveryFailureEventuallyEndsTheSession() { + FakePeripheral p = new FakePeripheral(); + BleSensorSession session = start(p); + + p.failDiscoveries = 99; + p.dropLink(); + + pumpUntil(session, SensorSessionState.FAILED); + assertEquals(SensorSessionState.FAILED, session.getState()); + assertTrue(p.discoveries <= 5, + "the ladder should be bounded, saw " + p.discoveries + + " discoveries"); + } + + /** + * A failure on the *initial* start still fails the caller's resource, + * because somebody is waiting on it. + */ + @Test + void anInitialDiscoveryFailureStillFailsTheCaller() { + FakePeripheral p = new FakePeripheral(); + p.failDiscoveries = 1; + BleSensorSession session = new BleSensorSession("fake", + HealthSensorProfile.HEART_RATE, new SensorSessionOptions(), + p); + started.add(session); + AsyncResource out = + new AsyncResource(); + session.start(out); + flushSerialCalls(); + + assertTrue(out.isDone()); + assertNotNull(errorOf(out)); + assertEquals(SensorSessionState.FAILED, session.getState()); + } + + private static Throwable errorOf(AsyncResource r) { + final Throwable[] err = new Throwable[1]; + r.except(new com.codename1.util.SuccessCallback() { + public void onSucess(Throwable t) { + err[0] = t; + } + }); + return err[0]; + } + + private BleSensorSession start(FakePeripheral p) { + BleSensorSession session = new BleSensorSession("fake", + HealthSensorProfile.HEART_RATE, new SensorSessionOptions(), + p); + started.add(session); + AsyncResource out = + new AsyncResource(); + session.start(out); + flushSerialCalls(); + return session; + } + + /** + * The ladder runs over {@code callSerially}, so each hop needs the + * queue pumped. Bounded so a session that never settles fails the + * test rather than hanging it. + */ + private void pumpUntil(BleSensorSession session, + SensorSessionState wanted) { + for (int iter = 0; iter < 50; iter++) { + flushSerialCalls(); + if (session.getState() == wanted) { + return; + } + } + fail("session stayed in " + session.getState() + ", wanted " + + wanted); + } + + /** A peripheral whose every operation succeeds unless scripted not to. */ + private static final class FakePeripheral extends BlePeripheral { + + private int failDiscoveries; + private int failConnects; + /// What the Battery Level characteristic answers, or null for no + /// battery service at all. + Byte batteryLevel; + private int discoveries; + private int connects; + + @Override + public String getAddress() { + return "FA:KE:00:00:00:01"; + } + + @Override + public String getName() { + return "Fake Strap"; + } + + /// Delivers a 0x2A37 notification with the given rate, as a + /// strap would. + void notifyHeartRate(int bpm) { + GattService hr = peripheralService(); + if (hr == null) { + return; + } + fireNotification(hr.getCharacteristic(HR_MEASUREMENT), + new byte[] { 0x00, (byte) bpm }); + } + + private GattService peripheralService() { + return getService(HR_SERVICE); + } + + void dropLink() { + fireConnectionStateChanged(ConnectionState.DISCONNECTED, + new BluetoothException(BluetoothError.NOT_CONNECTED, + "link dropped")); + } + + @Override + protected void doConnect(ConnectionOptions options, + AsyncResource out) { + connects++; + if (failConnects > 0) { + failConnects--; + out.error(new BluetoothException( + BluetoothError.CONNECTION_FAILED, "no such device")); + return; + } + out.complete(this); + } + + @Override + protected void doDisconnect() { + fireConnectionStateChanged(ConnectionState.DISCONNECTED, null); + } + + @Override + protected void doDiscoverServices( + AsyncResource> out) { + discoveries++; + if (failDiscoveries > 0) { + failDiscoveries--; + out.error(new BluetoothException( + BluetoothError.GATT_ERROR, + "discovery failed")); + return; + } + GattService hr = new GattService(this, HR_SERVICE, true, 0); + hr.addCharacteristic(new GattCharacteristic(hr, HR_MEASUREMENT, + GattCharacteristic.PROPERTY_NOTIFY, 0)); + List services = new ArrayList(); + services.add(hr); + if (batteryLevel != null) { + GattService battery = new GattService(this, + BATTERY_SERVICE, true, 0); + battery.addCharacteristic(new GattCharacteristic(battery, + BATTERY_LEVEL, GattCharacteristic.PROPERTY_READ, 0)); + services.add(battery); + } + out.complete(services); + } + + @Override + protected void doReadCharacteristic(GattCharacteristic c, + AsyncResource out) { + if (batteryLevel != null + && BATTERY_LEVEL.equals(c.getUuid())) { + out.complete(new byte[] { batteryLevel.byteValue() }); + return; + } + out.complete(new byte[] { 0 }); + } + + @Override + protected void doWriteCharacteristic(GattCharacteristic c, + byte[] value, boolean withResponse, + AsyncResource out) { + out.complete(Boolean.TRUE); + } + + @Override + protected void doReadDescriptor(GattDescriptor d, + AsyncResource out) { + out.complete(new byte[] { 0 }); + } + + @Override + protected void doWriteDescriptor(GattDescriptor d, byte[] value, + AsyncResource out) { + out.complete(Boolean.TRUE); + } + + @Override + protected void doSetNotifications(GattCharacteristic c, + boolean enable, boolean indication, + AsyncResource out) { + out.complete(Boolean.TRUE); + } + + @Override + protected void doReadRssi(AsyncResource out) { + out.complete(Integer.valueOf(-50)); + } + + @Override + protected void doRequestMtu(int mtu, AsyncResource out) { + out.complete(Integer.valueOf(mtu)); + } + + @Override + protected void doRequestConnectionPriority( + ConnectionPriority priority, AsyncResource out) { + out.complete(Boolean.TRUE); + } + + @Override + protected void doCreateBond(AsyncResource out) { + out.complete(Boolean.TRUE); + } + + @Override + protected void doOpenL2cap(int psm, boolean secure, + AsyncResource out) { + out.error(new BluetoothException(BluetoothError.NOT_SUPPORTED, + "no channels here")); + } + } + + /** + * A session that has ended must not keep retrying its store writes. + * + *

The re-arm guard tested only for STOPPED, but the reconnect + * ladder retires a session as FAILED -- so exactly the session nobody + * holds a handle to any more, already gone from the manager's + * registry, went on firing writes and errors on a timer that nothing + * could cancel.

+ */ + @Test + void aFailedSessionStopsRetryingItsWrites() throws Exception { + CountingStore store = new CountingStore(); + com.codename1.health.Health health = + new com.codename1.health.Health() { + @Override + public boolean isSupported() { + return true; + } + + @Override + public com.codename1.health.HealthStore getStore() { + return store; + } + }; + implementation.setHealth(health); + try { + FakePeripheral p = new FakePeripheral(); + SensorSessionOptions options = new SensorSessionOptions() + .setWriteToStore(true) + .setStoreBatchMillis(10); + BleSensorSession session = new BleSensorSession("fake", + HealthSensorProfile.HEART_RATE, options, p); + started.add(session); + AsyncResource out = + new AsyncResource(); + session.start(out); + flushSerialCalls(); + assertEquals(SensorSessionState.STREAMING, session.getState()); + + // One reading, so a batch is pending, then a permanent + // failure while it is still unwritten. + final int[] samplesSeen = new int[1]; + session.addListener(new SensorSampleListener() { + public void sensorSample(SensorSession s, + com.codename1.health.HealthSample sample) { + samplesSeen[0]++; + } + + public void sensorStateChanged(SensorSession s, + SensorSessionState state) { + } + + public void sensorError(SensorSession s, + com.codename1.health.HealthException error) { + } + }); + p.notifyHeartRate(72); + flushSerialCalls(); + assertTrue(samplesSeen[0] > 0, + "the notification should have decoded to a sample"); + // The batch has to have been attempted at least once, or + // the retry this guards against never had anything to retry + // and the test would pass for no reason. + int beforeFailure = pumpFor(200, store); + assertTrue(beforeFailure > 0, + "the session should have tried to write its batch"); + + p.failDiscoveries = 99; + p.dropLink(); + pumpUntil(session, SensorSessionState.FAILED); + assertTrue(session.isTerminal()); + + int afterFailure = pumpFor(300, store); + assertEquals(afterFailure, pumpFor(300, store), + "a session that has ended must issue no further" + + " writes; it issued more"); + } finally { + implementation.setHealth(null); + } + } + + /** Pumps the EDT for a while and reports the store's write count. */ + private int pumpFor(long millis, WriteCounter store) throws Exception { + long until = System.currentTimeMillis() + millis; + while (System.currentTimeMillis() < until) { + flushSerialCalls(); + Thread.sleep(10); + } + return store.writeCount(); + } + + /** Anything that counts the writes it was handed. */ + private interface WriteCounter { + int writeCount(); + } + + /** A store that refuses every write and counts the attempts. */ + private static final class CountingStore + extends com.codename1.health.HealthStore implements WriteCounter { + + /// Per instance, not static. A shared counter let a session + /// leaked by one test move another test's numbers with nothing + /// pointing at the leak -- which is what made + /// aFailedSessionStopsRetryingItsWrites fail in CI while passing + /// on its own. + int writes; + + public int writeCount() { + return writes; + } + + @Override + public boolean isSupported() { + return true; + } + + @Override + public boolean isTypeSupported( + com.codename1.health.HealthDataType type) { + return true; + } + + @Override + public boolean isWritable( + com.codename1.health.HealthDataType type) { + return true; + } + + @Override + protected void doWrite( + java.util.List samples, + AsyncResource out) { + writes++; + out.error(new com.codename1.health.HealthException( + com.codename1.health.HealthError.UNAUTHORIZED, + "scripted permanent refusal")); + } + } + + /** + * Samples of a type the store will never accept are dropped, not + * retried. + * + *

An Android cycling-power or cadence session produces types + * Health Connect can read but not write. Shared validation rejects + * the batch before it reaches the store, so the retry resent the + * identical batch for as long as the session streamed -- an error + * every {@code storeBatchMillis}, for ever, and a buffer that only + * grew. Counted through the error callback rather than through store + * writes, because a refused batch never reaches the store at all.

+ */ + @Test + void permanentlyUnwritableSamplesAreDroppedRatherThanRetried() + throws Exception { + RefusingStore store = new RefusingStore(); + com.codename1.health.Health health = + new com.codename1.health.Health() { + @Override + public boolean isSupported() { + return true; + } + + @Override + public com.codename1.health.HealthStore getStore() { + return store; + } + }; + implementation.setHealth(health); + try { + FakePeripheral p = new FakePeripheral(); + BleSensorSession session = new BleSensorSession("fake", + HealthSensorProfile.HEART_RATE, + new SensorSessionOptions().setWriteToStore(true) + .setStoreBatchMillis(10), p); + started.add(session); + final int[] errors = new int[1]; + AsyncResource out = + new AsyncResource(); + session.start(out); + flushSerialCalls(); + session.addListener(new SensorSampleListener() { + public void sensorSample(SensorSession s, + com.codename1.health.HealthSample sample) { + } + + public void sensorStateChanged(SensorSession s, + SensorSessionState state) { + } + + public void sensorError(SensorSession s, + com.codename1.health.HealthException error) { + errors[0]++; + } + }); + p.notifyHeartRate(72); + flushSerialCalls(); + + pump(300); + int afterFirstRound = errors[0]; + assertTrue(afterFirstRound > 0, + "the refused batch should have reported an error"); + pump(300); + assertEquals(afterFirstRound, errors[0], + "a type the store refuses outright must not be" + + " retried; it kept failing"); + } finally { + implementation.setHealth(null); + } + } + + /// Sessions any test started, stopped before the next test runs. + /// + /// A session left streaming keeps its flush timer, and the timer + /// resolves the store through Health.getInstance() at flush time -- + /// so a session leaked by one test writes into the *next* test's + /// store and moves its counts. That is what broke + /// aFailedSessionStopsRetryingItsWrites in CI while it passed alone: + /// the write it saw after the session ended was not its own session's. + private final List started = + new ArrayList(); + + @org.junit.jupiter.api.AfterEach + void stopStartedSessions() { + for (SensorSession s : started) { + try { + s.stop(); + } catch (Throwable ignored) { + // A test may already have torn it down. + } + } + started.clear(); + flushSerialCalls(); + } + + /** Pumps the EDT for a while so timers can fire. */ + private void pump(long millis) throws Exception { + long until = System.currentTimeMillis() + millis; + while (System.currentTimeMillis() < until) { + flushSerialCalls(); + Thread.sleep(10); + } + } + + /** A store that supports the type for reading but never for writing. */ + private static final class RefusingStore + extends com.codename1.health.HealthStore { + + @Override + public boolean isSupported() { + return true; + } + + @Override + public boolean isTypeSupported( + com.codename1.health.HealthDataType type) { + return true; + } + + @Override + public boolean isWritable( + com.codename1.health.HealthDataType type) { + return false; + } + + @Override + protected void doWrite( + java.util.List samples, + AsyncResource out) { + out.error(new com.codename1.health.HealthException( + com.codename1.health.HealthError.TYPE_NOT_SUPPORTED, + "not writable here")); + } + } + + /** + * Repeated connect failures are bounded, like discovery ones. + * + *

A failed {@code connect()} publishes DISCONNECTED, the reconnect + * listener fires on that transition and reconnects at once, so a + * sensor that has gone for good span the ladder at full speed -- + * forever, because only discovery and subscribe failures were + * counting toward the limit.

+ */ + @Test + void repeatedConnectFailuresRetireTheSession() { + FakePeripheral p = new FakePeripheral(); + BleSensorSession session = start(p); + assertEquals(SensorSessionState.STREAMING, session.getState()); + + p.failConnects = 99; + p.dropLink(); + + pumpUntil(session, SensorSessionState.FAILED); + assertTrue(session.isTerminal()); + assertTrue(p.connects <= 6, + "the ladder should be bounded, saw " + p.connects + + " connect attempts"); + } + + /** + * A store that commits its first chunk and then fails, so a caller + * that requeues the whole batch writes the committed part twice. + */ + private static final class HalfCommittingStore + extends com.codename1.health.HealthStore { + + /** Every value handed to doWrite, across every attempt. */ + final java.util.List written = new ArrayList(); + + @Override + public boolean isSupported() { + return true; + } + + @Override + public boolean isTypeSupported( + com.codename1.health.HealthDataType type) { + return true; + } + + @Override + public boolean isWritable( + com.codename1.health.HealthDataType type) { + return true; + } + + /** One record per chunk, so the batch splits sample by sample. */ + @Override + public int getMaxWriteBatchSize() { + return 1; + } + + @Override + protected void doWrite( + java.util.List samples, + AsyncResource out) { + com.codename1.health.QuantitySample q = + (com.codename1.health.QuantitySample) samples.get(0); + double bpm = q.getQuantity().getValue( + com.codename1.health.HealthUnit.COUNT_PER_MINUTE); + written.add(Double.valueOf(bpm)); + if (bpm >= 71) { + // The later sample always fails, so the earlier one is + // always a committed prefix of a failed write. + out.error(new com.codename1.health.HealthException( + com.codename1.health.HealthError.DATABASE_INACCESSIBLE, + "scripted failure on the second chunk")); + return; + } + com.codename1.health.HealthWriteResult r = + new com.codename1.health.HealthWriteResult(); + r.addSampleId("committed-" + written.size()); + out.complete(r); + } + + int timesWritten(double bpm) { + int n = 0; + for (Double d : written) { + if (d.doubleValue() == bpm) { + n++; + } + } + return n; + } + } + + /** + * A retry after a partly-successful write does not resend what was + * already committed. + * + *

A buffered batch larger than the platform's chunk can fail on a + * later chunk with the earlier ones already in the store. + * {@code getPartialResult()} names those, but the retry rebuilt its + * list from the whole batch -- so every retry wrote the committed + * prefix again, and a session that kept streaming kept duplicating + * it. Asserted as an invariant rather than a count: the committed + * sample is written exactly once no matter how often the tail is + * retried.

+ */ + @Test + void aRetryDoesNotResendTheCommittedPrefix() throws Exception { + final HalfCommittingStore store = new HalfCommittingStore(); + implementation.setHealth(new com.codename1.health.Health() { + @Override + public boolean isSupported() { + return true; + } + + @Override + public com.codename1.health.HealthStore getStore() { + return store; + } + }); + try { + FakePeripheral p = new FakePeripheral(); + BleSensorSession session = new BleSensorSession("fake", + HealthSensorProfile.HEART_RATE, + // Long enough that two notifications issued back to + // back are unambiguously one batch, rather than + // relying on them landing inside a few milliseconds. + new SensorSessionOptions().setWriteToStore(true) + .setStoreBatchMillis(400), p); + started.add(session); + AsyncResource out = + new AsyncResource(); + session.start(out); + flushSerialCalls(); + + p.notifyHeartRate(70); + p.notifyHeartRate(71); + flushSerialCalls(); + + pump(2000); + + assertTrue(store.timesWritten(71) > 1, + "the uncommitted sample must keep being retried, or" + + " this proves nothing"); + assertEquals(1, store.timesWritten(70), + "the committed sample must never be written again"); + } finally { + implementation.setHealth(null); + } + } + + /** + * A session that gives up flushes exactly once, and never again. + * + *

Two things have to hold at once here, and an earlier version of + * this test asserted only the second by demanding no write at all. + * The buffer must reach the store -- a session that exhausted its + * reconnects still holds whatever arrived since the last batch + * boundary, up to a full {@code storeBatchMillis} of it -- and the + * timer that was armed for that batch must not then fire a second + * write from a session nobody holds.

+ */ + @Test + void aSessionThatGivesUpFlushesOnceAndNeverAgain() throws Exception { + final CountingStore store = new CountingStore(); + implementation.setHealth(new com.codename1.health.Health() { + @Override + public boolean isSupported() { + return true; + } + + @Override + public com.codename1.health.HealthStore getStore() { + return store; + } + }); + try { + FakePeripheral p = new FakePeripheral(); + BleSensorSession session = new BleSensorSession("fake", + HealthSensorProfile.HEART_RATE, + // Long enough that nothing is written on the timer: + // any write this test sees is the teardown's. + new SensorSessionOptions().setWriteToStore(true) + .setStoreBatchMillis(600), p); + started.add(session); + AsyncResource out = + new AsyncResource(); + session.start(out); + flushSerialCalls(); + + p.notifyHeartRate(70); + flushSerialCalls(); + p.failDiscoveries = 99; + p.dropLink(); + pumpUntil(session, SensorSessionState.FAILED); + + assertEquals(1, pumpFor(1500, store), + "the buffered reading must reach the store when the" + + " session gives up"); + assertEquals(1, pumpFor(800, store), + "and the timer armed for that batch must not fire a" + + " second write afterwards"); + } finally { + implementation.setHealth(null); + } + } + + /** + * Tearing a session down twice does not write twice. + * + *

{@code endSession()} and {@code stop()} both flush, and a failed + * flush used to requeue what it could not write -- even from a + * session that had already ended, which could never flush it again. + * So the buffer refilled and the next teardown sent it. That is one + * more write from a dead session, and it survived both the terminal + * state check and making the buffer claim atomic, because neither of + * them is on this path.

+ * + *

Deterministic where the reconnect test was not: the second + * teardown is this test calling {@code stop()}, not a timer.

+ */ + @Test + void tearingDownTwiceDoesNotWriteTwice() throws Exception { + CountingStore store = new CountingStore(); + implementation.setHealth(new com.codename1.health.Health() { + @Override + public boolean isSupported() { + return true; + } + + @Override + public com.codename1.health.HealthStore getStore() { + return store; + } + }); + try { + FakePeripheral p = new FakePeripheral(); + BleSensorSession session = new BleSensorSession("fake", + HealthSensorProfile.HEART_RATE, + new SensorSessionOptions().setWriteToStore(true) + .setStoreBatchMillis(10), p); + started.add(session); + AsyncResource out = + new AsyncResource(); + session.start(out); + flushSerialCalls(); + p.notifyHeartRate(72); + flushSerialCalls(); + + p.failDiscoveries = 99; + p.dropLink(); + pumpUntil(session, SensorSessionState.FAILED); + int settled = pumpFor(400, store); + + // The second teardown, by hand. + session.stop(); + assertEquals(SensorSessionState.FAILED, session.getState(), + "stopping a failed session must not rewrite how it" + + " ended"); + assertEquals(settled, pumpFor(400, store), + "a second teardown must not issue another write"); + } finally { + implementation.setHealth(null); + } + } + + /** + * A reserved battery level is not reported as a percentage. + * + *

The Battery Level characteristic reserves everything above 100, + * and a peripheral answering {@code 0xFF} for "unknown" was handed to + * the app as a 255% battery.

+ */ + @Test + void aReservedBatteryLevelLeavesTheBatteryUnknown() throws Exception { + FakePeripheral p = new FakePeripheral(); + p.batteryLevel = (byte) 0xFF; + BleSensorSession session = new BleSensorSession("fake", + HealthSensorProfile.HEART_RATE, + new SensorSessionOptions(), p); + started.add(session); + AsyncResource out = + new AsyncResource(); + session.start(out); + flushSerialCalls(); + + assertNull(session.getBatteryPercent(), + "255 is reserved, not a percentage"); + + FakePeripheral ok = new FakePeripheral(); + ok.batteryLevel = (byte) 72; + BleSensorSession good = new BleSensorSession("fake2", + HealthSensorProfile.HEART_RATE, + new SensorSessionOptions(), ok); + started.add(good); + good.start(new AsyncResource()); + flushSerialCalls(); + assertEquals(Integer.valueOf(72), good.getBatteryPercent(), + "and an ordinary level is still reported"); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/health/sensors/HeartRateMeasurementTest.java b/maven/core-unittests/src/test/java/com/codename1/health/sensors/HeartRateMeasurementTest.java new file mode 100644 index 00000000000..433c762a2d7 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/health/sensors/HeartRateMeasurementTest.java @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The Heart Rate Measurement characteristic (0x2A37) is the single most + * widely implemented health profile, and its variable-length layout is the + * one most often decoded wrongly. These cases pin the four traps: unsigned + * reads, the contact-supported/detected pair, kilojoule energy units, and + * variable-count RR intervals. + */ +class HeartRateMeasurementTest { + + @Test + void parsesUint8HeartRate() { + HeartRateMeasurement m = + HeartRateMeasurement.parse(new byte[] { 0x00, 60 }); + assertNotNull(m); + assertEquals(60, m.getHeartRate()); + assertEquals(0, m.getRrIntervalCount()); + assertFalse(m.hasEnergyExpended()); + } + + /** + * A heart rate above 127 is negative when read as a signed Java byte. + * An unguarded parser reports -56 bpm for a sprinting athlete. + */ + @Test + void uint8HeartRateAboveSignedByteRangeIsReadUnsigned() { + HeartRateMeasurement m = HeartRateMeasurement.parse( + new byte[] { 0x00, (byte) 200 }); + assertNotNull(m); + assertEquals(200, m.getHeartRate()); + } + + @Test + void parsesUint16HeartRateLittleEndian() { + // flags bit0 set -> uint16; 0x012C = 300 + HeartRateMeasurement m = HeartRateMeasurement.parse( + new byte[] { 0x01, 0x2C, 0x01 }); + assertNotNull(m); + assertEquals(300, m.getHeartRate()); + } + + /** + * Both contact bits clear means the strap does not implement contact + * detection at all. Reporting that as "not touching skin" is a very + * common bug and produces a permanent false warning on such devices. + */ + @Test + void contactUnsupportedIsNotTheSameAsContactAbsent() { + HeartRateMeasurement m = + HeartRateMeasurement.parse(new byte[] { 0x00, 70 }); + assertNotNull(m); + assertFalse(m.isSensorContactSupported()); + assertFalse(m.isSensorContactDetected()); + } + + @Test + void contactSupportedAndDetectedAreReportedSeparately() { + // bit2 supported, bit1 detected + HeartRateMeasurement both = + HeartRateMeasurement.parse(new byte[] { 0x06, 70 }); + assertNotNull(both); + assertTrue(both.isSensorContactSupported()); + assertTrue(both.isSensorContactDetected()); + + // bit2 supported, bit1 clear -> genuinely not touching skin + HeartRateMeasurement supportedOnly = + HeartRateMeasurement.parse(new byte[] { 0x04, 70 }); + assertNotNull(supportedOnly); + assertTrue(supportedOnly.isSensorContactSupported()); + assertFalse(supportedOnly.isSensorContactDetected()); + } + + /** + * Detected-without-supported is a malformed combination; the parser must + * not report contact on the strength of a bit the profile says is + * meaningless. + */ + @Test + void contactDetectedWithoutSupportedIsIgnored() { + HeartRateMeasurement m = + HeartRateMeasurement.parse(new byte[] { 0x02, 70 }); + assertNotNull(m); + assertFalse(m.isSensorContactSupported()); + assertFalse(m.isSensorContactDetected()); + } + + /** Energy expended is transmitted in kilojoules, not kilocalories. */ + @Test + void energyExpendedIsKilojoulesAndConvertsToKilocalories() { + // flags bit3, uint8 rate, energy 0x0064 = 100 kJ + HeartRateMeasurement m = HeartRateMeasurement.parse( + new byte[] { 0x08, 70, 0x64, 0x00 }); + assertNotNull(m); + assertTrue(m.hasEnergyExpended()); + assertEquals(100, m.getEnergyExpendedKilojoules()); + assertEquals(23.9, m.getEnergyExpendedKilocalories(), 0.1); + } + + /** + * The RR field has no count: it runs to the end of the payload. A strap + * notifying once a second while the heart beats faster sends several per + * notification, and dropping any of them corrupts every HRV metric. + */ + @Test + void allRrIntervalsInAPayloadAreReported() { + // flags bit4, rate 60, three intervals: 1024, 512, 2048 + HeartRateMeasurement m = HeartRateMeasurement.parse(new byte[] { + 0x10, 60, + 0x00, 0x04, + 0x00, 0x02, + 0x00, 0x08 }); + assertNotNull(m); + assertEquals(3, m.getRrIntervalCount()); + assertEquals(1000.0, m.getRrIntervalMillis(0), 1e-6); + assertEquals(500.0, m.getRrIntervalMillis(1), 1e-6); + assertEquals(2000.0, m.getRrIntervalMillis(2), 1e-6); + } + + /** RR intervals are 1/1024-second units, not milliseconds. */ + @Test + void rrIntervalsConvertFrom1024thsOfASecond() { + HeartRateMeasurement m = HeartRateMeasurement.parse( + new byte[] { 0x10, 60, (byte) 0x00, 0x03 }); + assertNotNull(m); + assertEquals(1, m.getRrIntervalCount()); + assertEquals(768 * 1000.0 / 1024.0, m.getRrIntervalMillis(0), 1e-9); + } + + @Test + void parsesEnergyAndRrTogetherInTheRightOrder() { + // flags bits 3 and 4, uint16 rate 0x004B = 75, energy 50, one RR + HeartRateMeasurement m = HeartRateMeasurement.parse(new byte[] { + 0x19, 0x4B, 0x00, + 0x32, 0x00, + 0x00, 0x04 }); + assertNotNull(m); + assertEquals(75, m.getHeartRate()); + assertEquals(50, m.getEnergyExpendedKilojoules()); + assertEquals(1, m.getRrIntervalCount()); + assertEquals(1000.0, m.getRrIntervalMillis(0), 1e-6); + } + + /** Reserved flag bits are ignored rather than treated as an error. */ + @Test + void reservedFlagBitsAreIgnored() { + HeartRateMeasurement m = HeartRateMeasurement.parse( + new byte[] { (byte) 0xE0, 65 }); + assertNotNull(m); + assertEquals(65, m.getHeartRate()); + } + + /** + * A short packet must yield null, not an exception: this runs inside a + * notification callback, and one misbehaving strap must not crash the app. + */ + @Test + void truncatedPayloadsReturnNullAndNeverThrow() { + assertNull(HeartRateMeasurement.parse(null)); + assertNull(HeartRateMeasurement.parse(new byte[0])); + assertNull(HeartRateMeasurement.parse(new byte[] { 0x00 })); + // claims uint16 but only one value byte follows + assertNull(HeartRateMeasurement.parse(new byte[] { 0x01, 0x2C })); + // claims energy expended but the field is missing + assertNull(HeartRateMeasurement.parse(new byte[] { 0x08, 70 })); + // claims energy expended but only half of it is present + assertNull(HeartRateMeasurement.parse( + new byte[] { 0x08, 70, 0x64 })); + } + + /** + * An odd trailing byte in the RR region is a truncated notification, + * not a run of pairs with a spare byte. + * + *

This used to be accepted with the byte dropped, on the reasoning + * that not reading past the array was enough. It is not: the intervals + * this parser exists to expose feed an HRV calculation, and half a + * beat interval silently discarded is a wrong answer rather than a + * rounding error. Truncated payloads return null, which is what the + * rest of the parser already promises.

+ */ + @Test + void oddTrailingByteInRrRegionIsATruncatedPayload() { + assertNull(HeartRateMeasurement.parse( + new byte[] { 0x10, 60, 0x00, 0x04, 0x11 })); + } + + /** The same payload without the spare byte still decodes. */ + @Test + void completeRrPairsStillDecode() { + HeartRateMeasurement m = HeartRateMeasurement.parse( + new byte[] { 0x10, 60, 0x00, 0x04 }); + assertNotNull(m); + assertEquals(1, m.getRrIntervalCount()); + assertEquals(1000.0, m.getRrIntervalMillis(0), 1e-6); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/health/sensors/SensorParserTest.java b/maven/core-unittests/src/test/java/com/codename1/health/sensors/SensorParserTest.java new file mode 100644 index 00000000000..fbe4ae3fc13 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/health/sensors/SensorParserTest.java @@ -0,0 +1,680 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The remaining Bluetooth SIG health profiles. Each case targets a trap + * that produces plausible-looking wrong numbers rather than an obvious + * failure -- which is what makes them dangerous. + */ +class SensorParserTest { + + // ---- Cycling Power (0x2A63) ---- + + /** + * Instantaneous power is sint16. A power meter reports negative watts + * while back-pedalling or coasting downhill; reading it unsigned turns + * a brief -5 W into 65531 W and poisons the ride's average and maximum. + */ + @Test + void cyclingPowerIsSignedAndCanBeNegative() { + // flags 0x0000, power 0xFFFB = -5 + CyclingPowerMeasurement m = CyclingPowerMeasurement.parse( + new byte[] { 0x00, 0x00, (byte) 0xFB, (byte) 0xFF }); + assertNotNull(m); + assertEquals(-5, m.getInstantaneousPowerWatts()); + } + + @Test + void cyclingPowerParsesPositiveValues() { + // 0x00FA = 250 W + CyclingPowerMeasurement m = CyclingPowerMeasurement.parse( + new byte[] { 0x00, 0x00, (byte) 0xFA, 0x00 }); + assertNotNull(m); + assertEquals(250, m.getInstantaneousPowerWatts()); + assertFalse(m.hasCrankData()); + } + + /** + * The optional fields are positional, so a parser that skips the wrong + * number of bytes reads crank data out of the middle of another field. + */ + @Test + void cyclingPowerSkipsOptionalFieldsInOrder() { + // flags: pedal balance (0x01) + accumulated torque (0x04) + // + crank revolutions (0x20) = 0x0025 + byte[] payload = new byte[] { + 0x25, 0x00, // flags + (byte) 0xC8, 0x00, // power 200 + 50, // pedal balance (25%) + 0x00, 0x00, // accumulated torque (skipped) + 0x0A, 0x00, // crank revolutions 10 + 0x00, 0x04 // crank event time 1024 + }; + CyclingPowerMeasurement m = CyclingPowerMeasurement.parse(payload); + assertNotNull(m); + assertEquals(200, m.getInstantaneousPowerWatts()); + assertEquals(25.0, m.getPedalPowerBalancePercent(), 1e-9); + assertTrue(m.hasCrankData()); + assertEquals(10, m.getCrankRevolutions()); + assertEquals(1024, m.getLastCrankEventTime()); + } + + @Test + void cyclingPowerRejectsTruncatedPayloads() { + assertNull(CyclingPowerMeasurement.parse(null)); + assertNull(CyclingPowerMeasurement.parse(new byte[] { 0x00, 0x00 })); + // claims crank data but the fields are missing + assertNull(CyclingPowerMeasurement.parse( + new byte[] { 0x20, 0x00, 0x00, 0x00 })); + } + + // ---- Cycling Speed and Cadence (0x2A5B) ---- + + @Test + void cscParsesWheelAndCrankBlocksIndependently() { + // flags 0x03: both present + byte[] payload = new byte[] { + 0x03, + 0x10, 0x00, 0x00, 0x00, // wheel revs 16 (uint32) + 0x00, 0x04, // wheel event 1024 + 0x05, 0x00, // crank revs 5 (uint16) + 0x00, 0x08 // crank event 2048 + }; + CscMeasurement m = CscMeasurement.parse(payload); + assertNotNull(m); + assertTrue(m.hasWheelData()); + assertTrue(m.hasCrankData()); + assertEquals(16L, m.getWheelRevolutions()); + assertEquals(1024, m.getLastWheelEventTime()); + assertEquals(5, m.getCrankRevolutions()); + assertEquals(2048, m.getLastCrankEventTime()); + } + + @Test + void cscCrankOnlyPayloadReportsNoWheelData() { + CscMeasurement m = CscMeasurement.parse( + new byte[] { 0x02, 0x05, 0x00, 0x00, 0x04 }); + assertNotNull(m); + assertFalse(m.hasWheelData()); + assertTrue(m.hasCrankData()); + assertEquals(-1L, m.getWheelRevolutions()); + } + + /** + * A uint32 revolution count above 2^31 must stay positive; read as a + * signed int it goes negative and the derived cadence flips sign. + */ + @Test + void cscWheelRevolutionsStayPositiveAboveTwoToThe31() { + byte[] payload = new byte[] { + 0x01, + 0x00, 0x00, 0x00, (byte) 0x80, // 2147483648 + 0x00, 0x04 + }; + CscMeasurement m = CscMeasurement.parse(payload); + assertNotNull(m); + assertEquals(2147483648L, m.getWheelRevolutions()); + } + + // ---- Running Speed and Cadence (0x2A53) ---- + + @Test + void rscParsesSpeedAndCadence() { + // speed 0x0200 = 512 -> 2 m/s; cadence 180 steps/min + RscMeasurement m = RscMeasurement.parse( + new byte[] { 0x04, 0x00, 0x02, (byte) 180 }); + assertNotNull(m); + assertEquals(2.0, m.getSpeedMetersPerSecond(), 1e-9); + assertEquals(180, m.getCadenceStepsPerMinute()); + assertTrue(m.isRunning()); + } + + /** + * Cadence is transmitted in steps per minute, not strides. Halving it + * is a UI decision, made explicit rather than applied silently. + */ + @Test + void rscExposesBothStepAndStrideConventions() { + RscMeasurement m = RscMeasurement.parse( + new byte[] { 0x00, 0x00, 0x02, (byte) 180 }); + assertNotNull(m); + assertEquals(180, m.getCadenceStepsPerMinute()); + assertEquals(90.0, m.getStrideRatePerMinute(), 1e-9); + } + + @Test + void rscTotalDistanceIsTenthsOfAMetre() { + // flags 0x02: total distance present; 0x000003E8 = 1000 -> 100 m + byte[] payload = new byte[] { + 0x02, 0x00, 0x02, (byte) 180, + (byte) 0xE8, 0x03, 0x00, 0x00 + }; + RscMeasurement m = RscMeasurement.parse(payload); + assertNotNull(m); + assertEquals(100.0, m.getTotalDistanceMeters(), 1e-9); + } + + // ---- Health Thermometer (0x2A1C) ---- + + /** + * Temperature uses the 32-bit IEEE-11073 FLOAT, unlike blood pressure + * and glucose which use the 16-bit SFLOAT. Decoding one as the other + * yields plausible nonsense. + */ + @Test + void temperatureDecodesIeee11073Float32() { + // mantissa 365, exponent -1 -> 36.5 degC + byte[] payload = new byte[] { + 0x00, + 0x6D, 0x01, 0x00, // mantissa 365 (24-bit LE) + (byte) 0xFF // exponent -1 + }; + TemperatureMeasurement m = TemperatureMeasurement.parse(payload); + assertNotNull(m); + assertEquals(36.5, m.getCelsius(), 1e-9); + assertEquals(97.7, m.getFahrenheit(), 1e-6); + } + + @Test + void temperatureConvertsFromFahrenheitWhenFlagged() { + // flags 0x01 = Fahrenheit; mantissa 986, exponent -1 -> 98.6F + byte[] payload = new byte[] { + 0x01, + (byte) 0xDA, 0x03, 0x00, + (byte) 0xFF + }; + TemperatureMeasurement m = TemperatureMeasurement.parse(payload); + assertNotNull(m); + assertEquals(37.0, m.getCelsius(), 1e-6); + } + + /** + * A thermometer that cannot obtain a reading sends a reserved value. + * Letting it through would report a patient temperature of 8388607. + */ + @Test + void temperatureRejectsTheReservedNaNValue() { + byte[] payload = new byte[] { + 0x00, + (byte) 0xFF, (byte) 0xFF, 0x7F, // 0x007FFFFF = NaN + 0x00 + }; + assertNull(TemperatureMeasurement.parse(payload)); + } + + // ---- Blood Pressure (0x2A35) ---- + + @Test + void bloodPressureDecodesThreeSfloatValues() { + // 120, 80, 93 mmHg as SFLOAT with exponent 0 + byte[] payload = new byte[] { + 0x00, + 0x78, 0x00, // systolic 120 + 0x50, 0x00, // diastolic 80 + 0x5D, 0x00 // mean 93 + }; + BloodPressureMeasurement m = BloodPressureMeasurement.parse(payload); + assertNotNull(m); + assertEquals(120.0, m.getSystolicMmHg(), 1e-9); + assertEquals(80.0, m.getDiastolicMmHg(), 1e-9); + assertEquals(93.0, m.getMeanArterialMmHg(), 1e-9); + assertFalse(m.hasPulse()); + } + + /** + * A cuff that fails mid-inflation sends the reserved SFLOAT NaN. + * Without this check the reading surfaces as 2047 mmHg. + */ + @Test + void bloodPressureRejectsAFailedMeasurement() { + byte[] payload = new byte[] { + 0x00, + (byte) 0xFF, 0x07, // 0x07FF = NaN + 0x50, 0x00, + 0x5D, 0x00 + }; + assertNull(BloodPressureMeasurement.parse(payload)); + } + + @Test + void bloodPressureConvertsFromKilopascals() { + // flags 0x01 = kPa; 16 kPa is about 120 mmHg + byte[] payload = new byte[] { + 0x01, + 0x10, 0x00, + 0x0A, 0x00, + 0x0C, 0x00 + }; + BloodPressureMeasurement m = BloodPressureMeasurement.parse(payload); + assertNotNull(m); + assertEquals(120.0, m.getSystolicMmHg(), 0.1); + } + + @Test + void bloodPressureRejectsTruncatedPayloads() { + assertNull(BloodPressureMeasurement.parse(null)); + assertNull(BloodPressureMeasurement.parse(new byte[] { 0x00, 0x78 })); + } + + // ---- Weight Scale (0x2A9D) ---- + + /** + * The raw uint16 is scaled by 0.005 kg in SI mode but 0.01 lb in + * Imperial -- a different multiplier, not a unit conversion applied + * afterwards. Confusing them is wrong by roughly a factor of two, + * which is close enough to look plausible. + */ + @Test + void weightUsesADifferentResolutionPerUnitSystem() { + // SI: 14000 * 0.005 = 70 kg + WeightMeasurement si = WeightMeasurement.parse( + new byte[] { 0x00, (byte) 0xB0, 0x36 }); + assertNotNull(si); + assertFalse(si.isImperial()); + assertEquals(70.0, si.getWeightKg(), 1e-9); + + // Imperial: 15400 * 0.01 lb = 154 lb = 69.85 kg + WeightMeasurement imperial = WeightMeasurement.parse( + new byte[] { 0x01, 0x28, 0x3C }); + assertNotNull(imperial); + assertTrue(imperial.isImperial()); + assertEquals(69.85, imperial.getWeightKg(), 1e-2); + } + + @Test + void weightParsesOptionalBmiAndHeight() { + // flags 0x08: BMI and height present, SI units + byte[] payload = new byte[] { + 0x08, + (byte) 0xB0, 0x36, // 70 kg + (byte) 0xE6, 0x00, // BMI 23.0 + (byte) 0xEA, 0x06 // height 1770 * 0.001 = 1.77 m + }; + WeightMeasurement m = WeightMeasurement.parse(payload); + assertNotNull(m); + assertTrue(m.hasBmiAndHeight()); + assertEquals(23.0, m.getBmi(), 1e-9); + assertEquals(1.77, m.getHeightMeters(), 1e-9); + } + + /** + * A scale that reports the BMI/height field but could not measure one + * of them sends the profile's 0xFFFF. Scaled rather than checked, that + * surfaced as a BMI of 6553.5 and a height of about 6.5 kilometres -- + * next to a weight that is perfectly real, so nothing about the + * reading looks suspect. + */ + @Test + void weightTreatsUnavailableBmiAndHeightAsMissing() { + byte[] payload = new byte[] { + 0x08, // BMI/height present, SI units + 0x10, 0x27, // 10000 * 0.005 = 50 kg + (byte) 0xFF, (byte) 0xFF, // BMI unavailable + (byte) 0xFF, (byte) 0xFF // height unavailable + }; + WeightMeasurement m = WeightMeasurement.parse(payload); + assertNotNull(m); + assertEquals(50.0, m.getWeightKg(), 1e-6); + assertTrue(Double.isNaN(m.getBmi())); + assertTrue(Double.isNaN(m.getHeightMeters())); + } + + /** One unavailable field does not discard the other. */ + @Test + void weightKeepsTheHeightWhenOnlyBmiIsUnavailable() { + byte[] payload = new byte[] { + 0x08, + 0x10, 0x27, + (byte) 0xFF, (byte) 0xFF, // BMI unavailable + 0x76, 0x06 // 1654 * 0.001 = 1.654 m + }; + WeightMeasurement m = WeightMeasurement.parse(payload); + assertNotNull(m); + assertTrue(Double.isNaN(m.getBmi())); + assertEquals(1.654, m.getHeightMeters(), 1e-6); + } + + /** + * The predicate says "and", and the two fields are optional + * independently -- so a valid BMI beside an unavailable height must + * answer false, or a caller that gates on it goes on to use NaN. + */ + @Test + void weightPresenceCheckRequiresBothFields() { + byte[] bmiOnly = new byte[] { + 0x08, + 0x10, 0x27, + 0x2A, 0x00, // BMI 4.2 + (byte) 0xFF, (byte) 0xFF // height unavailable + }; + WeightMeasurement m = WeightMeasurement.parse(bmiOnly); + assertNotNull(m); + assertFalse(m.hasBmiAndHeight()); + assertTrue(Double.isNaN(m.getHeightMeters())); + + byte[] both = new byte[] { + 0x08, + 0x10, 0x27, + 0x2A, 0x00, + 0x76, 0x06 + }; + assertTrue(WeightMeasurement.parse(both).hasBmiAndHeight()); + } + + /** + * The Date Time characteristic defines years 1582-9999 and reserves + * everything else. A malformed 10000 used to decode as an ordinary + * future timestamp, so a reading was published under a date the + * device never claimed -- and one whose age is negative, which makes + * it look permanently fresh. + */ + @Test + void datesOutsideTheProfileRangeAreUnknown() { + // A weight reading whose timestamp field carries year 10000. + byte[] future = new byte[] { + 0x02, // timestamp present, SI units + 0x10, 0x27, // 50 kg + 0x10, 0x27, 1, 1, 12, 0, 0 // year 10000 + }; + WeightMeasurement m = WeightMeasurement.parse(future); + assertNotNull(m); + assertEquals(-1, m.getTimestampMillis(), + "a reserved year is not a date"); + + byte[] tooEarly = new byte[] { + 0x02, + 0x10, 0x27, + 0x2D, 0x06, 1, 1, 12, 0, 0 // year 1581 + }; + assertEquals(-1, + WeightMeasurement.parse(tooEarly).getTimestampMillis()); + + byte[] valid = new byte[] { + 0x02, + 0x10, 0x27, + (byte) 0xEA, 0x07, 1, 1, 12, 0, 0 // year 2026 + }; + assertTrue(WeightMeasurement.parse(valid).getTimestampMillis() > 0); + } + + @Test + void weightRejectsTruncatedPayloads() { + assertNull(WeightMeasurement.parse(null)); + assertNull(WeightMeasurement.parse(new byte[] { 0x00 })); + assertNull(WeightMeasurement.parse( + new byte[] { 0x08, (byte) 0xB0, 0x36 })); + } + + // ---- Glucose (0x2A18) ---- + + @Test + void glucoseDecodesConcentrationAndSequence() { + // flags 0x02: concentration present, kg/L units + // 0x00 base time year 2026-01-01 12:00:00 + byte[] payload = new byte[] { + 0x02, + 0x07, 0x00, // sequence 7 + (byte) 0xEA, 0x07, 1, 1, 12, 0, 0, // 2026-01-01 12:00:00 + 0x5A, (byte) 0xB0, // SFLOAT: 90 * 10^-5 kg/L + 0x11 // type 1, location 1 (finger) + }; + GlucoseMeasurement m = GlucoseMeasurement.parse(payload); + assertNotNull(m); + assertEquals(7, m.getSequenceNumber()); + assertTrue(m.hasConcentration()); + assertEquals(GlucoseMeasurement.SAMPLE_LOCATION_FINGER, + m.getSampleLocation()); + // 90 mg/dL is about 5 mmol/L + assertEquals(90.0, m.getMilligramsPerDeciliter(), 0.5); + assertEquals(4.995, m.getMillimolesPerLiter(), 0.05); + } + + /** + * A control-solution reading is a calibration check, not the user's + * blood glucose. Storing it would put a fictitious value into their + * medical history. + */ + @Test + void glucoseFlagsControlSolutionReadings() { + byte[] payload = new byte[] { + 0x02, + 0x08, 0x00, + (byte) 0xEA, 0x07, 1, 1, 12, 0, 0, + 0x5A, (byte) 0xB0, + 0x41 // location 4 = control solution + }; + GlucoseMeasurement m = GlucoseMeasurement.parse(payload); + assertNotNull(m); + assertTrue(m.isControlSolution()); + } + + @Test + void glucoseWithoutConcentrationIsAPlaceholderRecord() { + byte[] payload = new byte[] { + 0x00, + 0x09, 0x00, + (byte) 0xEA, 0x07, 1, 1, 12, 0, 0 + }; + GlucoseMeasurement m = GlucoseMeasurement.parse(payload); + assertNotNull(m); + assertFalse(m.hasConcentration()); + assertTrue(Double.isNaN(m.getMillimolesPerLiter())); + } + + /** + * 0x8000 is the profile's "offset unknown" value. Read as a number it + * is -32768 minutes, which moved the reading back about 22.8 days -- + * a real glucose value published, and on some paths stored, under a + * date the meter never reported. + */ + @Test + void glucoseIgnoresTheUnknownTimeOffsetSentinel() { + byte[] withSentinel = new byte[] { + 0x03, // time offset + concentration + 0x0A, 0x00, + (byte) 0xEA, 0x07, 1, 1, 12, 0, 0, // 2026-01-01 12:00:00 + 0x00, (byte) 0x80, // offset 0x8000 = unknown + 0x5A, (byte) 0xB0, + 0x11 + }; + byte[] noOffset = new byte[] { + 0x02, + 0x0A, 0x00, + (byte) 0xEA, 0x07, 1, 1, 12, 0, 0, + 0x5A, (byte) 0xB0, + 0x11 + }; + GlucoseMeasurement sentinel = GlucoseMeasurement.parse(withSentinel); + GlucoseMeasurement plain = GlucoseMeasurement.parse(noOffset); + assertNotNull(sentinel); + assertNotNull(plain); + assertEquals(plain.getTimestampMillis(), + sentinel.getTimestampMillis()); + } + + /** + * An offset that lands in the future is refused, and the base time + * stands instead. + * + *

The measurement already happened, so a time after now is not a + * time it can have been taken at. A meter stamping a recent base time + * and a junk offset -- 32767 minutes moves it three weeks ahead -- + * produced a reading that {@code getLatest()} reported as the + * freshest there was, because its age came out negative.

+ */ + @Test + void glucoseRefusesAnOffsetThatLandsInTheFuture() { + java.util.Calendar now = java.util.Calendar.getInstance( + java.util.TimeZone.getTimeZone("UTC")); + int year = now.get(java.util.Calendar.YEAR); + byte[] payload = new byte[] { + 0x03, + 0x0C, 0x00, + (byte) (year & 0xFF), (byte) ((year >> 8) & 0xFF), + (byte) (now.get(java.util.Calendar.MONTH) + 1), + (byte) now.get(java.util.Calendar.DAY_OF_MONTH), + (byte) now.get(java.util.Calendar.HOUR_OF_DAY), + (byte) now.get(java.util.Calendar.MINUTE), + (byte) now.get(java.util.Calendar.SECOND), + (byte) 0xFF, 0x7F, // +32767 minutes, three weeks on + 0x5A, (byte) 0xB0, + 0x11 + }; + GlucoseMeasurement m = GlucoseMeasurement.parse(payload); + assertNotNull(m); + assertTrue(m.getTimestampMillis() + <= System.currentTimeMillis() + 60000L, + "a reading must not be stamped in the future"); + } + + /** A large offset is still a real offset, as long as it does not + * arrive at a time the reading cannot have been taken at. The + * Glucose Service recommends a device limit what a user may enter to + * a day either way and says in the same breath that the field + * carries the full signed range, so magnitude alone decides + * nothing. */ + @Test + void glucoseAppliesAnOrdinaryTimeOffset() { + byte[] payload = new byte[] { + 0x03, + 0x0B, 0x00, + (byte) 0xEA, 0x07, 1, 1, 12, 0, 0, + 0x1E, 0x00, // +30 minutes + 0x5A, (byte) 0xB0, + 0x11 + }; + byte[] noOffset = new byte[] { + 0x02, + 0x0B, 0x00, + (byte) 0xEA, 0x07, 1, 1, 12, 0, 0, + 0x5A, (byte) 0xB0, + 0x11 + }; + GlucoseMeasurement offset = GlucoseMeasurement.parse(payload); + GlucoseMeasurement plain = GlucoseMeasurement.parse(noOffset); + assertNotNull(offset); + assertNotNull(plain); + assertEquals(plain.getTimestampMillis() + 30L * 60000L, + offset.getTimestampMillis()); + } + + @Test + void glucoseRejectsTruncatedPayloads() { + assertNull(GlucoseMeasurement.parse(null)); + assertNull(GlucoseMeasurement.parse(new byte[] { 0x02, 0x07 })); + } + + // ---- Profiles ---- + + @Test + void profilesDeclareTheirServiceAndStreamingNature() { + assertEquals(0x180D, + HealthSensorProfile.HEART_RATE.getServiceUuid() + .getShortValue()); + assertTrue(HealthSensorProfile.HEART_RATE.isStreaming()); + // Episodic devices report one reading per measurement, so UI should + // say "waiting" rather than showing a stale live value. + assertFalse(HealthSensorProfile.WEIGHT_SCALE.isStreaming()); + assertFalse(HealthSensorProfile.BLOOD_PRESSURE.isStreaming()); + assertFalse(HealthSensorProfile.GLUCOSE.isStreaming()); + } + + @Test + void everyProfileDeclaresAtLeastOneProducedType() { + for (HealthSensorProfile p : HealthSensorProfile.values()) { + assertFalse(p.getProducedTypes().isEmpty(), + p.getName() + " should produce at least one type"); + assertNotNull(p.getMeasurementUuid()); + } + } + + // ------------------------------------------------------------------ + // cumulative counters + // ------------------------------------------------------------------ + + /** + * A stopped wheel repeats its counter and timestamp forever. + * + *

Returning NaN for every repeat left a live cadence display frozen + * at the last moving value, so a rider waiting at a light still read + * 90 rpm. After enough repeats to rule out a duplicate packet the + * tracker reports zero, which is what is actually happening.

+ */ + @Test + void aStoppedSensorEventuallyReportsZero() { + CumulativeCounterTracker t = new CumulativeCounterTracker( + 0x10000L, 1024); + assertTrue(Double.isNaN(t.update(100, 0)), + "the first notification has no baseline"); + double moving = t.update(110, 1024); + assertEquals(600.0, moving, 0.1, "10 revolutions in one second"); + + // The sensor stops: same counter, same event time, repeatedly. + assertTrue(Double.isNaN(t.update(110, 1024)), + "one repeat could still be a duplicate packet"); + assertEquals(0.0, t.update(110, 1024), 0.001, + "a sustained repeat means stopped, not unknown"); + assertEquals(0.0, t.update(110, 1024), 0.001); + } + + /** Movement after a stop resumes reporting a real rate. */ + @Test + void movementAfterAStopReportsAgain() { + CumulativeCounterTracker t = new CumulativeCounterTracker( + 0x10000L, 1024); + t.update(100, 0); + t.update(110, 1024); + t.update(110, 1024); + assertEquals(0.0, t.update(110, 1024), 0.001); + assertEquals(300.0, t.update(115, 2048), 0.1, + "five revolutions in the next second"); + } + + /** + * A device clock set to the future is refused, so the caller falls + * back to receipt time. + * + *

A cuff or scale left at 2099 -- out of the box, or after a + * battery change -- passes every field check, and the session then + * prefers that stamp over the moment the notification arrived, stores + * the reading under it, and {@code getLatest()} reports it as the + * freshest there is because its age comes out negative. Same bound + * and same reasoning as the glucose time offset.

+ */ + @Test + void aDeviceClockInTheFutureIsRefused() { + // The date decoder is what this pins; read it directly so the + // assertion does not depend on which profile carries a timestamp. + assertEquals(-1L, GattDateTime.read(new GattReader(new byte[] { + 0x2B, 0x08, 12, 25, 9, 30, 0 + })), "a timestamp years ahead of now must be refused"); + assertTrue(GattDateTime.read(new GattReader(new byte[] { + (byte) 0xEA, 0x07, 1, 1, 12, 0, 0 + })) > 0, "and an ordinary past timestamp must still decode"); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/health/sensors/SensorScanFailureTest.java b/maven/core-unittests/src/test/java/com/codename1/health/sensors/SensorScanFailureTest.java new file mode 100644 index 00000000000..a0d279996cd --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/health/sensors/SensorScanFailureTest.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.health.sensors; + +import com.codename1.bluetooth.FakeBluetooth; +import com.codename1.health.Health; +import com.codename1.health.HealthException; +import com.codename1.junit.UITestBase; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * A sensor scan that cannot start has to say so. + * + *

The failure arrives on the {@code BleScan} the health layer keeps to + * itself -- callers are handed a {@code SensorScan}, which deliberately + * does not expose it -- so without forwarding, a scan that never started + * looked exactly like one that started and found nothing.

+ */ +class SensorScanFailureTest extends UITestBase { + + private FakeBluetooth fake; + + @BeforeEach + void installFake() { + fake = new FakeBluetooth(); + implementation.setBluetooth(fake); + } + + @AfterEach + void removeFake() { + implementation.setBluetooth(null); + } + + /** Collects whatever the discovery listener is told. */ + private static final class Recorder + implements SensorDiscoveryListener { + + private HealthException failure; + private int discovered; + + public void sensorDiscovered(HealthSensor sensor) { + discovered++; + } + + public void scanFailed(HealthException error) { + failure = error; + } + } + + @Test + void aScanThatCannotStartReportsItToTheListener() { + fake.getFakeLE().failNextStart( + new IllegalStateException("adapter busy")); + Recorder recorder = new Recorder(); + + SensorScan scan = Health.getInstance().getSensors() + .startScan(new SensorScanSettings(), recorder); + flushSerialCalls(); + + assertNotNull(scan); + assertNotNull(recorder.failure, + "a scan that never started must reach scanFailed"); + assertFalse(scan.isScanning(), + "and it must not report itself as running"); + } + + @Test + void aScanThatStartsReportsNoFailure() { + Recorder recorder = new Recorder(); + + SensorScan scan = Health.getInstance().getSensors() + .startScan(new SensorScanSettings(), recorder); + flushSerialCalls(); + + assertNull(recorder.failure); + assertTrue(scan.isScanning()); + scan.stop(); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java index d9d71e2e30d..2421458e70a 100644 --- a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java +++ b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java @@ -147,6 +147,7 @@ public class TestCodenameOneImplementation extends CodenameOneImplementation { private boolean inCall; private LocationManager locationManager; private com.codename1.bluetooth.Bluetooth bluetooth; + private com.codename1.health.Health health; private L10NManager localizationManager; private ImageIO imageIO; private VideoIO videoIO; @@ -1255,6 +1256,7 @@ public void reset() { mediaRecorderBuilderHandler = null; locationManager = null; bluetooth = null; + health = null; localizationManager = null; imageIO = null; inAppPurchase = null; @@ -1420,6 +1422,25 @@ public void setBluetooth(com.codename1.bluetooth.Bluetooth bluetooth) { this.bluetooth = bluetooth; } + /** + * Returns the scripted health entry point installed via + * {@link #setHealth(com.codename1.health.Health)}. Defaults to + * {@code null} so {@code Health.getInstance()} falls back to the no-op + * base instance, mirroring ports without health support. + */ + @Override + public com.codename1.health.Health getHealth() { + return health; + } + + /** + * Scripts the health stack returned by {@link #getHealth()}; cleared + * back to {@code null} by {@link #reset()}. + */ + public void setHealth(com.codename1.health.Health health) { + this.health = health; + } + public void setLocalizationManager(L10NManager localizationManager) { this.localizationManager = localizationManager; } diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/BluetoothSimulationWindowTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/BluetoothSimulationWindowTest.java index 3975a9daa71..013b6a4ce4b 100644 --- a/maven/javase/src/test/java/com/codename1/impl/javase/BluetoothSimulationWindowTest.java +++ b/maven/javase/src/test/java/com/codename1/impl/javase/BluetoothSimulationWindowTest.java @@ -23,7 +23,7 @@ package com.codename1.impl.javase; import com.codename1.bluetooth.BluetoothUuid; -import com.codename1.impl.javase.bluetooth.ManualScheduler; +import com.codename1.impl.javase.simulator.ManualScheduler; import com.codename1.impl.javase.bluetooth.SimulatedBluetoothStack; import com.codename1.impl.javase.bluetooth.VirtualCharacteristic; import com.codename1.impl.javase.bluetooth.VirtualPeripheral; diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/AbstractVirtualStackTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/AbstractVirtualStackTest.java index 48872231264..b33bee569b5 100644 --- a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/AbstractVirtualStackTest.java +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/AbstractVirtualStackTest.java @@ -22,6 +22,8 @@ */ package com.codename1.impl.javase.bluetooth; +import com.codename1.impl.javase.simulator.ManualScheduler; + import com.codename1.bluetooth.BluetoothError; import com.codename1.bluetooth.BluetoothUuid; import com.codename1.bluetooth.gatt.GattCharacteristic; diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/BluetoothSimulationHeadlessTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/BluetoothSimulationHeadlessTest.java index c4ce7b981e1..c29f4f0c0da 100644 --- a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/BluetoothSimulationHeadlessTest.java +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/BluetoothSimulationHeadlessTest.java @@ -43,11 +43,22 @@ public class BluetoothSimulationHeadlessTest { private static final String PACKAGE_DIR = "../../Ports/JavaSE/src/com/codename1/impl/javase/bluetooth"; - /** The pure-Java stack core that must not import Codename One UI. */ - private static final String[] LAYER_A_SOURCES = { + /** + * The schedulers are shared with the simulated health store and + * therefore live one package up, in {@code impl.javase.simulator}. + * They are still stack core and still bound by these guards. + */ + private static final String SIMULATOR_DIR = + "../../Ports/JavaSE/src/com/codename1/impl/javase/simulator"; + + private static final String[] SHARED_SCHEDULER_SOURCES = { "SimScheduler.java", "AutoScheduler.java", - "ManualScheduler.java", + "ManualScheduler.java" + }; + + /** The pure-Java stack core that must not import Codename One UI. */ + private static final String[] LAYER_A_SOURCES = { "SimulatedBluetoothStack.java", "VirtualPeripheral.java", "VirtualService.java", @@ -68,6 +79,14 @@ private static File packageDir() { return dir; } + private static File simulatorDir() { + File dir = new File(SIMULATOR_DIR); + Assertions.assertTrue(dir.isDirectory(), + "Could not find the simulator package sources at " + + dir.getAbsolutePath()); + return dir; + } + private static String read(File f) throws IOException { StringBuilder sb = new StringBuilder(); BufferedReader br = new BufferedReader(new FileReader(f)); @@ -104,7 +123,8 @@ public void deterministicCoreNeverTouchesWallClockTime() throws Exception { for (String name : new String[] {"SimulatedBluetoothStack.java", "ManualScheduler.java"}) { - File src = new File(packageDir(), name); + File src = new File("ManualScheduler.java".equals(name) + ? simulatorDir() : packageDir(), name); Assertions.assertTrue(src.exists(), "missing " + name); String content = read(src); Assertions.assertFalse(content.contains("Thread.sleep"), @@ -123,13 +143,33 @@ public void deterministicCoreNeverTouchesWallClockTime() @Test public void stackCoreStaysFreeOfCodenameOneUi() throws Exception { for (String name : LAYER_A_SOURCES) { - File src = new File(packageDir(), name); + assertNoCodenameOneUi(new File(packageDir(), name), name); + } + for (String name : SHARED_SCHEDULER_SOURCES) { + assertNoCodenameOneUi(new File(simulatorDir(), name), name); + } + } + + private static void assertNoCodenameOneUi(File src, String name) + throws IOException { + Assertions.assertTrue(src.exists(), "missing " + name); + String content = read(src); + Assertions.assertFalse( + content.contains("import com.codename1.ui"), + name + " is stack core and must not depend on" + + " com.codename1.ui"); + } + + @Test + public void sharedSchedulersAreHeadless() throws Exception { + for (String name : SHARED_SCHEDULER_SOURCES) { + File src = new File(simulatorDir(), name); Assertions.assertTrue(src.exists(), "missing " + name); String content = read(src); - Assertions.assertFalse( - content.contains("import com.codename1.ui"), - name + " is stack core and must not depend on" - + " com.codename1.ui"); + Assertions.assertFalse(content.contains("javax.swing"), + name + " must not use Swing"); + Assertions.assertFalse(content.contains("javafx."), + name + " must not use JavaFX"); } } } diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/ConcurrentOpsTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/ConcurrentOpsTest.java index 8079eec67d8..54bd055d2da 100644 --- a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/ConcurrentOpsTest.java +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/ConcurrentOpsTest.java @@ -22,6 +22,8 @@ */ package com.codename1.impl.javase.bluetooth; +import com.codename1.impl.javase.simulator.ManualScheduler; + import com.codename1.bluetooth.BluetoothError; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/RecordedTraceReplayTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/RecordedTraceReplayTest.java index a705388fd90..fa016dab5f9 100644 --- a/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/RecordedTraceReplayTest.java +++ b/maven/javase/src/test/java/com/codename1/impl/javase/bluetooth/RecordedTraceReplayTest.java @@ -22,6 +22,8 @@ */ package com.codename1.impl.javase.bluetooth; +import com.codename1.impl.javase.simulator.ManualScheduler; + import com.codename1.bluetooth.BluetoothException; import com.codename1.bluetooth.BluetoothUuid; import com.codename1.impl.bluetooth.BleBackend; diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/health/HealthReadAuthTrapTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/health/HealthReadAuthTrapTest.java new file mode 100644 index 00000000000..7653ac7691a --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/health/HealthReadAuthTrapTest.java @@ -0,0 +1,528 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.javase.health; + +import com.codename1.health.HealthAuthorizationStatus; +import com.codename1.health.HealthDataType; +import com.codename1.health.HealthError; +import com.codename1.health.HealthException; +import com.codename1.health.HealthQuantity; +import com.codename1.health.HealthSample; +import com.codename1.health.HealthTimeRange; +import com.codename1.health.HealthUnit; +import com.codename1.health.QuantitySample; +import com.codename1.health.SampleQuery; +import com.codename1.util.AsyncResource; +import com.codename1.util.SuccessCallback; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The single highest-value behaviour the health simulator reproduces. + * + *

HealthKit deliberately refuses to disclose read authorization: a denied + * read looks exactly like having no data, so that an app cannot infer what a + * user is choosing to hide. A developer testing only against a permissive + * store will not meet this until review or production. These cases pin that + * the simulator reproduces it faithfully, and that switching to Health + * Connect's behaviour makes the same script fail loudly instead.

+ */ +class HealthReadAuthTrapTest { + + private SimulatedHealthStore store; + + @BeforeEach + void createStore() { + store = new SimulatedHealthStore(); + QuantitySample s = QuantitySample.create(HealthDataType.HEART_RATE, + new HealthQuantity(62, HealthUnit.COUNT_PER_MINUTE), + 1_767_225_600_000L); + List seed = new ArrayList(); + seed.add(s); + store.seed(seed); + } + + private SampleQuery heartRateQuery() { + return new SampleQuery().addType(HealthDataType.HEART_RATE) + .setTimeRange(HealthTimeRange.between(1_767_000_000_000L, + 1_768_000_000_000L)); + } + + private static Throwable errorOf(AsyncResource r) { + final Throwable[] err = new Throwable[1]; + r.except(new SuccessCallback() { + public void onSucess(Throwable t) { + err[0] = t; + } + }); + return err[0]; + } + + /** The permissive baseline: data is there and comes back. */ + @Test + void grantedReadReturnsData() { + store.setReadPermission(HealthDataType.HEART_RATE, + SimulatedHealthStore.ReadAuthScript.GRANTED); + List read = store.readSamples(heartRateQuery()).get(); + assertEquals(1, read.size()); + } + + /** + * The trap, end to end: authorization "succeeds", the status is + * UNKNOWN, and the query returns empty with no error. An app + * that reports "you denied access" here would be guessing. + */ + @Test + void iosDeniedReadIsIndistinguishableFromHavingNoData() { + store.setReadAuthorizationPolicy( + SimulatedHealthStore.ReadAuthPolicy.IOS_OPAQUE); + store.setReadPermission(HealthDataType.HEART_RATE, + SimulatedHealthStore.ReadAuthScript.DENIED_SILENT); + + AsyncResource auth = store.requestAuthorization( + com.codename1.health.HealthAccess.read( + HealthDataType.HEART_RATE)); + assertEquals(Boolean.TRUE, auth.get(), + "the authorization flow completes even when nothing was" + + " granted -- true means 'the user was asked'"); + + assertEquals(HealthAuthorizationStatus.UNKNOWN, + store.getReadAuthorizationStatus(HealthDataType.HEART_RATE), + "iOS never discloses read authorization"); + + AsyncResource> read = + store.readSamples(heartRateQuery()); + assertNull(errorOf(read), "a denied read must not surface an error"); + assertTrue(read.get().isEmpty(), + "a denied read is silently empty"); + } + + /** + * The other half of the trap: a genuinely empty store produces exactly + * the same observations, which is why the two cannot be told apart and + * why the API offers no hasReadPermission. + */ + @Test + void grantedButNoDataIsObservationallyIdenticalToDenial() { + store.setReadAuthorizationPolicy( + SimulatedHealthStore.ReadAuthPolicy.IOS_OPAQUE); + store.setReadPermission(HealthDataType.HEART_RATE, + SimulatedHealthStore.ReadAuthScript.GRANTED_BUT_NO_DATA); + + assertEquals(HealthAuthorizationStatus.UNKNOWN, + store.getReadAuthorizationStatus(HealthDataType.HEART_RATE)); + AsyncResource> read = + store.readSamples(heartRateQuery()); + assertNull(errorOf(read)); + assertTrue(read.get().isEmpty()); + } + + /** + * A type scripted to yield nothing empties itself, not the query. + * + *

Adding one such type used to blank the whole page, so unrelated + * steps disappeared alongside the heart rate the script was hiding -- + * and a developer would reasonably read that as a bug in their own + * code rather than as the script working. Neither platform behaves + * that way: HealthKit hands back what it will show you and stays quiet + * about the rest.

+ */ + @Test + void oneEmptyTypeDoesNotEmptyTheOthers() { + List seed = new ArrayList(); + seed.add(QuantitySample.create(HealthDataType.STEPS, + new HealthQuantity(2500, HealthUnit.COUNT), + 1_767_225_600_000L, 1_767_225_660_000L)); + store.seed(seed); + store.setReadAuthorizationPolicy( + SimulatedHealthStore.ReadAuthPolicy.IOS_OPAQUE); + store.setReadPermission(HealthDataType.HEART_RATE, + SimulatedHealthStore.ReadAuthScript.GRANTED_BUT_NO_DATA); + store.setReadPermission(HealthDataType.STEPS, + SimulatedHealthStore.ReadAuthScript.GRANTED); + + AsyncResource> read = store.readSamples( + new SampleQuery().addType(HealthDataType.HEART_RATE) + .addType(HealthDataType.STEPS) + .setTimeRange(HealthTimeRange.between( + 1_767_000_000_000L, 1_768_000_000_000L))); + assertNull(errorOf(read)); + List back = read.get(); + assertEquals(1, back.size(), "the granted type still contributes"); + assertEquals(HealthDataType.STEPS, back.get(0).getType()); + } + + /** + * A hidden record must not spend the query's limit. + * + *

The first fix filtered the finished page, which is applied after + * the shared store has sorted and cut to the limit -- so a hidden + * record that sorted first ate the only slot and a limit-one query + * came back empty with a visible record sitting right behind it. The + * records nobody can see are withheld before the sort now.

+ */ + @Test + void aHiddenRecordDoesNotSpendTheLimit() { + List seed = new ArrayList(); + // The steps sample is older, so a descending read reaches the + // hidden heart-rate sample first. + seed.add(QuantitySample.create(HealthDataType.STEPS, + new HealthQuantity(2500, HealthUnit.COUNT), + 1_767_225_000_000L, 1_767_225_060_000L)); + seed.add(QuantitySample.create(HealthDataType.HEART_RATE, + new HealthQuantity(70, HealthUnit.COUNT_PER_MINUTE), + 1_767_225_600_000L)); + store.seed(seed); + store.setReadAuthorizationPolicy( + SimulatedHealthStore.ReadAuthPolicy.IOS_OPAQUE); + store.setReadPermission(HealthDataType.HEART_RATE, + SimulatedHealthStore.ReadAuthScript.GRANTED_BUT_NO_DATA); + store.setReadPermission(HealthDataType.STEPS, + SimulatedHealthStore.ReadAuthScript.GRANTED); + + AsyncResource> read = store.readSamples( + new SampleQuery().addType(HealthDataType.HEART_RATE) + .addType(HealthDataType.STEPS) + .setSortDescending(true) + .setLimit(1) + .setTimeRange(HealthTimeRange.between( + 1_767_000_000_000L, 1_768_000_000_000L))); + assertNull(errorOf(read)); + List back = read.get(); + assertEquals(1, back.size(), + "the visible record should fill the budget"); + assertEquals(HealthDataType.STEPS, back.get(0).getType()); + } + + /** The same rule for aggregates, which take a separate path. */ + @Test + void oneEmptyTypeDoesNotEmptyTheAggregate() { + // Both types are cumulative, because a metric applies to every + // type in the query and TOTAL is not meaningful for a discrete + // one. + List seed = new ArrayList(); + seed.add(QuantitySample.create(HealthDataType.STEPS, + new HealthQuantity(2500, HealthUnit.COUNT), + 1_767_225_600_000L, 1_767_225_660_000L)); + seed.add(QuantitySample.create( + HealthDataType.DISTANCE_WALKING_RUNNING, + new HealthQuantity(1800, HealthUnit.METER), + 1_767_225_600_000L, 1_767_225_660_000L)); + store.seed(seed); + store.setReadAuthorizationPolicy( + SimulatedHealthStore.ReadAuthPolicy.IOS_OPAQUE); + store.setReadPermission(HealthDataType.DISTANCE_WALKING_RUNNING, + SimulatedHealthStore.ReadAuthScript.GRANTED_BUT_NO_DATA); + store.setReadPermission(HealthDataType.STEPS, + SimulatedHealthStore.ReadAuthScript.GRANTED); + + AsyncResource> agg = + store.aggregate(new com.codename1.health.AggregateQuery() + .addType(HealthDataType.DISTANCE_WALKING_RUNNING) + .addType(HealthDataType.STEPS) + .addMetric(com.codename1.health.AggregateMetric.TOTAL) + .setTimeRange(HealthTimeRange.between( + 1_767_000_000_000L, 1_768_000_000_000L))); + assertNull(errorOf(agg)); + List buckets = agg.get(); + assertFalse(buckets.isEmpty()); + com.codename1.health.HealthQuantity steps = buckets.get(0).get( + HealthDataType.STEPS, + com.codename1.health.AggregateMetric.TOTAL); + assertNotNull(steps, "the granted type still aggregates"); + assertEquals(2500, steps.getValue(HealthUnit.COUNT), 0.001); + assertNull(buckets.get(0).get( + HealthDataType.DISTANCE_WALKING_RUNNING, + com.codename1.health.AggregateMetric.TOTAL), + "and the hidden type still contributes nothing"); + } + + /** Health Connect answers honestly and fails loudly. */ + @Test + void androidDeniedReadFailsWithUnauthorized() { + store.setReadAuthorizationPolicy( + SimulatedHealthStore.ReadAuthPolicy.ANDROID_EXPLICIT); + store.setReadPermission(HealthDataType.HEART_RATE, + SimulatedHealthStore.ReadAuthScript.DENIED_SILENT); + + assertEquals(HealthAuthorizationStatus.DENIED, + store.getReadAuthorizationStatus(HealthDataType.HEART_RATE), + "Health Connect read permission is an ordinary grant"); + + Throwable err = errorOf(store.readSamples(heartRateQuery())); + assertNotNull(err); + assertEquals(HealthError.UNAUTHORIZED, + ((HealthException) err).getError()); + } + + /** + * IOS_OPAQUE is the default precisely because it is the surprising + * behaviour -- a developer must meet it in the simulator, not in + * review. + */ + @Test + void iosOpaqueIsTheDefaultPolicy() { + assertEquals(SimulatedHealthStore.ReadAuthPolicy.IOS_OPAQUE, + new SimulatedHealthStore().getReadAuthorizationPolicy()); + } + + /** Write authorization is truthfully reportable on both platforms. */ + @Test + void writeAuthorizationIsAlwaysAnswerable() { + store.setWritePermission(HealthDataType.BODY_MASS, + HealthAuthorizationStatus.DENIED); + assertEquals(HealthAuthorizationStatus.DENIED, + store.getWriteAuthorizationStatus(HealthDataType.BODY_MASS)); + store.setWritePermission(HealthDataType.BODY_MASS, + HealthAuthorizationStatus.AUTHORIZED); + assertEquals(HealthAuthorizationStatus.AUTHORIZED, + store.getWriteAuthorizationStatus(HealthDataType.BODY_MASS)); + } + + /** A denied write fails, unlike a denied read. */ + @Test + void deniedWriteFails() { + store.setWritePermission(HealthDataType.BODY_MASS, + HealthAuthorizationStatus.DENIED); + QuantitySample w = QuantitySample.create(HealthDataType.BODY_MASS, + new HealthQuantity(70, HealthUnit.KILOGRAM), + 1_767_225_600_000L); + Throwable err = errorOf(store.write(w)); + assertNotNull(err, "a denied write is reportable and must fail"); + assertEquals(HealthError.UNAUTHORIZED, + ((HealthException) err).getError()); + } + + /** + * Every rejection is observable on the calling thread, whichever layer + * produced it. + * + *

A store call can be turned down in three places -- the shared + * unsupported check, query validation, and the backend itself -- and + * all three must behave the same way, because a caller cannot tell + * which one answered. Routing one of them through {@code callSerially} + * made the store's threading depend on why it failed, so the + * same rejection resolved inline or a cycle later depending on whether + * an event thread happened to be pumping. This asserts the three + * together rather than one at a time, which is how the split went + * unnoticed.

+ */ + @Test + void everyRejectionResolvesOnTheCallingThread() { + store.setAvailable(false); + assertTrue(store.readSamples(heartRateQuery()).isDone(), + "an unsupported store must answer before returning"); + + store.setAvailable(true); + store.setReadPermission(HealthDataType.HEART_RATE, + SimulatedHealthStore.ReadAuthScript.GRANTED); + assertTrue(store.readSamples(new SampleQuery()).isDone(), + "a rejected query must answer before returning"); + + store.failNext("query", HealthError.DATABASE_INACCESSIBLE, + "device locked"); + assertTrue(store.readSamples(heartRateQuery()).isDone(), + "a backend failure must answer before returning"); + } + + /** Fault injection is one-shot, so a test can assert recovery. */ + @Test + void primedFailureFiresOnceThenRecovers() { + store.setReadPermission(HealthDataType.HEART_RATE, + SimulatedHealthStore.ReadAuthScript.GRANTED); + store.failNext("query", HealthError.DATABASE_INACCESSIBLE, + "device locked"); + + Throwable err = errorOf(store.readSamples(heartRateQuery())); + assertNotNull(err); + assertEquals(HealthError.DATABASE_INACCESSIBLE, + ((HealthException) err).getError()); + + assertEquals(1, store.readSamples(heartRateQuery()).get().size(), + "the next call must succeed"); + } + + /** + * An unavailable provider fails every operation with NOT_SUPPORTED + * rather than pretending the store is simply empty. + */ + @Test + void unavailableStoreFailsRatherThanReturningEmpty() { + store.setAvailable(false); + assertFalse(store.isSupported()); + Throwable err = errorOf(store.readSamples(heartRateQuery())); + assertNotNull(err); + assertEquals(HealthError.NOT_SUPPORTED, + ((HealthException) err).getError()); + } + + @Test + void resetScriptsRestoresDefaultsWithoutDiscardingData() { + store.setAvailable(false); + store.setAllReadPermissions( + SimulatedHealthStore.ReadAuthScript.DENIED_ERROR); + store.resetScripts(); + + assertTrue(store.isSupported()); + assertEquals(SimulatedHealthStore.ReadAuthPolicy.IOS_OPAQUE, + store.getReadAuthorizationPolicy()); + assertEquals(1, store.readSamples(heartRateQuery()).get().size(), + "resetScripts must not discard seeded data"); + } + + /** + * A denied write is unauthorized, not unsupported. + * + *

Both mobile stores answer {@code isWritable} from what the + * platform can store, independently of what the user has allowed. The + * simulator folded the scripted grant into that answer, so a test + * scripting DENIED was refused by the shared layer as + * TYPE_NOT_SUPPORTED before the authorization path ran at all -- the + * developer chased "this platform cannot store that" instead of the + * permission problem they were simulating. NOT_DETERMINED went the + * other way and wrote successfully, letting an app ship having never + * exercised its own authorization flow.

+ */ + @Test + void writeAuthorizationIsSeparateFromWritability() { + QuantitySample w = QuantitySample.create(HealthDataType.BODY_MASS, + new HealthQuantity(70, HealthUnit.KILOGRAM), + 1_767_225_600_000L); + + store.setWritePermission(HealthDataType.BODY_MASS, + HealthAuthorizationStatus.DENIED); + assertTrue(store.isWritable(HealthDataType.BODY_MASS), + "the platform can still store a body mass"); + assertEquals(HealthError.UNAUTHORIZED, + ((HealthException) errorOf(store.write(w))).getError()); + + store.setWritePermission(HealthDataType.BODY_MASS, + HealthAuthorizationStatus.NOT_DETERMINED); + assertEquals(HealthError.UNAUTHORIZED, + ((HealthException) errorOf(store.write(w))).getError(), + "a write before the user was asked is refused too"); + + store.setWritePermission(HealthDataType.BODY_MASS, + HealthAuthorizationStatus.AUTHORIZED); + assertNull(errorOf(store.write(w)), "a granted write succeeds"); + } + + /** + * A denied read denies the aggregate too. + * + *

Aggregation reads the local records directly rather than going + * through the sample path, so a type scripted DENIED_SILENT returned a + * real total while the matching sample read came back empty. A + * developer would have drawn a chart from data the simulator was + * pretending they could not see -- the exact trap this store exists to + * spring.

+ */ + @Test + void aDeniedReadDeniesTheAggregateToo() { + store.setReadAuthorizationPolicy( + SimulatedHealthStore.ReadAuthPolicy.IOS_OPAQUE); + store.setReadPermission(HealthDataType.HEART_RATE, + SimulatedHealthStore.ReadAuthScript.DENIED_SILENT); + + AsyncResource> + r = store.aggregate(new com.codename1.health.AggregateQuery() + .addType(HealthDataType.HEART_RATE) + .addMetric(com.codename1.health.AggregateMetric.AVERAGE) + .setTimeRange(HealthTimeRange.between( + 1_767_000_000_000L, 1_768_000_000_000L))); + assertNull(errorOf(r), "iOS denial stays silent here as well"); + for (com.codename1.health.AggregateResult bucket : r.get()) { + assertNull(bucket.get(HealthDataType.HEART_RATE, + com.codename1.health.AggregateMetric.AVERAGE), + "a denied aggregate must not report a total"); + } + + store.setReadAuthorizationPolicy( + SimulatedHealthStore.ReadAuthPolicy.ANDROID_EXPLICIT); + Throwable err = errorOf(store.aggregate( + new com.codename1.health.AggregateQuery() + .addType(HealthDataType.HEART_RATE) + .addMetric(com.codename1.health.AggregateMetric.AVERAGE) + .setTimeRange(HealthTimeRange.between( + 1_767_000_000_000L, 1_768_000_000_000L)))); + assertNotNull(err, "Health Connect fails loudly here too"); + assertEquals(HealthError.UNAUTHORIZED, + ((HealthException) err).getError()); + } + + /** + * Deleting needs write authorization, as it does on Health Connect. + * + *

Writes honoured the scripted status while deletes went straight + * through, so a test could delete records it was supposedly not + * allowed to touch.

+ */ + @Test + void aDeniedWriteDeniesTheDelete() { + store.setWritePermission(HealthDataType.HEART_RATE, + HealthAuthorizationStatus.DENIED); + Throwable err = errorOf(store.delete( + com.codename1.health.HealthDeleteRequest.byRange( + HealthDataType.HEART_RATE, + HealthTimeRange.between(1_767_000_000_000L, + 1_768_000_000_000L)))); + assertNotNull(err, "an unauthorized delete must fail"); + assertEquals(HealthError.UNAUTHORIZED, + ((HealthException) err).getError()); + + store.setWritePermission(HealthDataType.HEART_RATE, + HealthAuthorizationStatus.AUTHORIZED); + assertNull(errorOf(store.delete( + com.codename1.health.HealthDeleteRequest.byRange( + HealthDataType.HEART_RATE, + HealthTimeRange.between(1_767_000_000_000L, + 1_768_000_000_000L)))), + "a granted delete still works"); + } + + /** + * The simulator's "make health unavailable" action must be visible + * where the guide tells an app to look -- the facade -- not only when + * an operation fails. + */ + @Test + void simulatedUnavailabilityReachesTheFacade() { + com.codename1.impl.health.LocalHealth health = + new com.codename1.impl.health.LocalHealth(store); + assertTrue(health.isSupported()); + assertEquals(com.codename1.health.HealthAvailability.LOCAL_ONLY, + health.getAvailability()); + + store.setAvailable(false); + + assertFalse(health.isSupported(), + "an app branching on the facade must see the simulation"); + assertEquals(com.codename1.health.HealthAvailability.NOT_SUPPORTED, + health.getAvailability()); + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/health/HealthSimulatorHooksTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/health/HealthSimulatorHooksTest.java new file mode 100644 index 00000000000..4828ef2f6d5 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/health/HealthSimulatorHooksTest.java @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.javase.health; + +import com.codename1.impl.javase.HealthSimulatorHooks; +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Guards on the shipped hooks file and the hook class. + * + *

The interesting one is that the real, shipped + * {@code simulator-hooks.properties} parses with two namespaces. A + * classpath entry can only carry one copy of that resource, so adding the + * health menu required extending the loader with a {@code groups=} form; + * parsing the actual file is what proves the extension works rather than + * only the synthetic fixtures.

+ */ +class HealthSimulatorHooksTest { + + private static final String HOOKS = + "../../Ports/JavaSE/src/META-INF/codenameone/" + + "simulator-hooks.properties"; + + private static Properties shipped() throws IOException { + File f = new File(HOOKS); + assertTrue(f.isFile(), "missing " + f.getAbsolutePath()); + Properties p = new Properties(); + BufferedReader r = new BufferedReader(new FileReader(f)); + try { + p.load(r); + } finally { + r.close(); + } + return p; + } + + @Test + void shippedFileDeclaresBothGroups() throws Exception { + Properties p = shipped(); + String groups = p.getProperty("groups"); + assertNotNull(groups, "the shipped file must use the groups= form"); + assertTrue(groups.contains("bluetooth")); + assertTrue(groups.contains("health")); + } + + /** + * The Bluetooth menu predates the groups= form; it must keep working + * unchanged now that it is expressed as a prefixed group. + */ + @Test + void bluetoothGroupStillDeclaresItsItems() throws Exception { + Properties p = shipped(); + assertEquals("Bluetooth", p.getProperty("bluetooth.name")); + assertEquals("bluetooth", p.getProperty("bluetooth.namespace")); + assertNotNull(p.getProperty("bluetooth.item1")); + assertNotNull(p.getProperty("bluetooth.label1")); + assertNotNull(p.getProperty("bluetooth.item8"), + "the API-only failure hook must survive the rewrite"); + } + + @Test + void healthGroupDeclaresItsItems() throws Exception { + Properties p = shipped(); + assertEquals("Health", p.getProperty("health.name")); + assertEquals("health", p.getProperty("health.namespace")); + assertNotNull(p.getProperty("health.item1")); + } + + /** + * Items are positional and the loader stops at the first gap, so a + * missing number silently truncates the menu. + */ + @Test + void healthItemsAreContiguous() throws Exception { + Properties p = shipped(); + int n = 1; + while (p.getProperty("health.item" + n) != null) { + n++; + } + assertTrue(n > 12, "expected at least 12 health items, found " + + (n - 1)); + } + + /** + * The trap hook is the one worth guaranteeing exists, since it is what + * a developer clicks to discover the behaviour before App Review does. + */ + @Test + void theReadAuthTrapHookIsPresentAndLabelled() throws Exception { + Properties p = shipped(); + assertEquals("com.codename1.impl.javase.HealthSimulatorHooks" + + "#grantWriteDenyReadSilently", + p.getProperty("health.item3")); + assertEquals("Grant Write, Deny Read Without Error", + p.getProperty("health.label3")); + } + + /** Every referenced hook must resolve to a public static void no-arg. */ + @Test + void everyHealthHookResolvesToTheRequiredSignature() throws Exception { + Properties p = shipped(); + int n = 1; + String action; + while ((action = p.getProperty("health.item" + n)) != null) { + int hash = action.indexOf('#'); + assertTrue(hash > 0, "malformed action: " + action); + String fqcn = action.substring(0, hash); + String methodName = action.substring(hash + 1); + Class cls = Class.forName(fqcn); + Method m = cls.getDeclaredMethod(methodName); + assertTrue(Modifier.isStatic(m.getModifiers()), + methodName + " must be static"); + assertTrue(Modifier.isPublic(m.getModifiers()), + methodName + " must be public"); + assertEquals(void.class, m.getReturnType(), + methodName + " must return void"); + n++; + } + assertTrue(n > 1, "no health hooks were checked"); + } + + /** + * The hooks class runs inside the simulator process but must not drag + * in a UI toolkit, matching the guard the Bluetooth simulation carries. + */ + @Test + void hooksClassIsHeadless() throws Exception { + File src = new File("../../Ports/JavaSE/src/com/codename1/impl/" + + "javase/HealthSimulatorHooks.java"); + assertTrue(src.isFile()); + StringBuilder sb = new StringBuilder(); + BufferedReader br = new BufferedReader(new FileReader(src)); + try { + String line; + while ((line = br.readLine()) != null) { + sb.append(line).append('\n'); + } + } finally { + br.close(); + } + String content = sb.toString(); + assertFalse(content.contains("javax.swing"), + "hooks must not use Swing"); + assertFalse(content.contains("javafx."), + "hooks must not use JavaFX"); + } + + /** Loading the demo dataset must be deterministic across runs. */ + @Test + void syntheticDataIsDeterministicForASeed() { + long end = 1_767_225_600_000L; + int a = new SyntheticHealthData(42).generateWeek(end).size(); + int b = new SyntheticHealthData(42).generateWeek(end).size(); + assertEquals(a, b); + assertTrue(a > 0, "a week of synthetic data should not be empty"); + } + + /** + * Nothing in the generator is a recording of anyone's data, so it must + * produce values inside plausible physiological bounds rather than + * replaying a trace. + */ + @Test + void syntheticHeartRatesStayInPhysiologicalBounds() { + java.util.List samples = + new SyntheticHealthData(7).generateWeek(1_767_225_600_000L); + int checked = 0; + for (com.codename1.health.HealthSample s : samples) { + if (s.getType() != com.codename1.health.HealthDataType.HEART_RATE) { + continue; + } + double bpm = ((com.codename1.health.QuantitySample) s) + .getValue(com.codename1.health.HealthUnit + .COUNT_PER_MINUTE); + assertTrue(bpm >= 35 && bpm <= 205, + "implausible synthetic heart rate: " + bpm); + checked++; + } + assertTrue(checked > 0, "no heart rate samples were generated"); + } + + @Test + void hooksClassExposesTheSimulatedStoreFactory() throws Exception { + Method m = HealthSimulatorHooks.class + .getDeclaredMethod("createSimulatedHealth"); + assertTrue(Modifier.isStatic(m.getModifiers())); + } +} diff --git a/scripts/hellocodenameone/common/codenameone_settings.properties b/scripts/hellocodenameone/common/codenameone_settings.properties index 4eb37e223f0..1fbf5370aeb 100644 --- a/scripts/hellocodenameone/common/codenameone_settings.properties +++ b/scripts/hellocodenameone/common/codenameone_settings.properties @@ -1,16 +1,21 @@ -codename1.android.keystore= -codename1.android.keystoreAlias= -codename1.android.keystorePassword= +#Updated keystore +#Sun Jul 26 13:28:43 IDT 2026 +codename1.android.keystore=/Users/shai/dev/cn1/scripts/hellocodenameone/android/../common/androidCerts/KeyChain.ks +codename1.android.keystoreAlias=androidKey +codename1.android.keystorePassword=password +codename1.arg.android.androidAuto.poi=true +codename1.arg.android.health.privacyPolicyUrl=https\://www.codenameone.com/privacy-policy.html +codename1.arg.android.health.read=steps,heart_rate +codename1.arg.android.health.write=steps codename1.arg.android.useAndroidX=true codename1.arg.ios.applicationQueriesSchemes=cydia -codename1.arg.ios.NSCameraUsageDescription=Used by the CI smoke test to verify the com.codename1.camera native bridge compiles. The app never opens a camera session. +codename1.arg.ios.carplay.audio=true +codename1.arg.ios.maps.provider=apple codename1.arg.ios.newStorageLocation=true +codename1.arg.ios.NSCameraUsageDescription=Used by the CI smoke test to verify the com.codename1.camera native bridge compiles. The app never opens a camera session. +codename1.arg.ios.NSHealthShareUsageDescription=Used by the CI smoke test to verify the com.codename1.health native bridge compiles. The app never reads real health data. +codename1.arg.ios.NSHealthUpdateUsageDescription=Used by the CI smoke test to verify the com.codename1.health write path compiles. The app never writes real health data. codename1.arg.ios.uiscene=true -# In-car (com.codename1.car) CI smoke wiring. The app references the API so the CarPlay natives -# (iOS) and the Android Auto CarAppService + androidx.car.app dependency get compiled in CI; the -# category hints pick which CarPlay entitlement / Android Auto intent-filter category is wired. -codename1.arg.ios.carplay.audio=true -codename1.arg.android.androidAuto.poi=true codename1.arg.java.version=17 codename1.cssTheme=true codename1.displayName=HelloCodenameOne @@ -29,14 +34,12 @@ codename1.j2me.nativeTheme=nbproject/nativej2me.res codename1.kotlin=false codename1.languageLevel=5 codename1.mainName=HelloCodenameOne -codename1.watchMain=com.codenameone.examples.hellocodenameone.HelloCodenameOneWatch -codename1.tvMain=com.codenameone.examples.hellocodenameone.HelloCodenameOne codename1.packageName=com.codenameone.examples.hellocodenameone codename1.rim.certificatePassword= codename1.rim.signtoolCsk= codename1.rim.signtoolDb= codename1.secondaryTitle=Hello World +codename1.tvMain=com.codenameone.examples.hellocodenameone.HelloCodenameOne codename1.vendor=CodenameOne codename1.version=1.0 - -codename1.arg.ios.maps.provider=apple +codename1.watchMain=com.codenameone.examples.hellocodenameone.HelloCodenameOneWatch diff --git a/scripts/hellocodenameone/common/src/test/java/com/codenameone/examples/hellocodenameone/HealthConformanceTest.java b/scripts/hellocodenameone/common/src/test/java/com/codenameone/examples/hellocodenameone/HealthConformanceTest.java new file mode 100644 index 00000000000..9024c8dc8a2 --- /dev/null +++ b/scripts/hellocodenameone/common/src/test/java/com/codenameone/examples/hellocodenameone/HealthConformanceTest.java @@ -0,0 +1,246 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone; + +import com.codename1.health.AggregateMetric; +import com.codename1.health.AggregateQuery; +import com.codename1.health.AggregateResult; +import com.codename1.health.Health; +import com.codename1.health.HealthAuthorizationStatus; +import com.codename1.health.HealthAvailability; +import com.codename1.health.HealthDataType; +import com.codename1.health.HealthInterval; +import com.codename1.health.HealthQuantity; +import com.codename1.health.HealthSample; +import com.codename1.health.HealthStore; +import com.codename1.health.HealthTimeRange; +import com.codename1.health.HealthUnit; +import com.codename1.health.QuantitySample; +import com.codename1.health.SampleQuery; +import com.codename1.health.sensors.HealthSensorProfile; +import com.codename1.health.sensors.HeartRateMeasurement; +import com.codename1.testing.AbstractTest; + +import java.util.List; +import java.util.TimeZone; + +/** + * On-device conformance for the health API, run on every port. + * + *

This deliberately asserts only what must hold everywhere, + * because the ports genuinely differ: a device may have no health store, no + * Bluetooth, or no granted permissions, and none of those are failures. What + * must never differ is the shape of the API -- the facade is non-null, the + * sub-facades are non-null, capability queries answer rather than throw, and + * operations on an unsupported port fail cleanly instead of hanging or + * crashing.

+ * + *

The pure functions -- unit conversion, the GATT parsers, aggregate + * boundaries -- are asserted exactly, since they must produce identical + * results on ParparVM, on Android's ART, in the browser and on the desktop + * JVM. Those are the parts most likely to break under a translator.

+ */ +public class HealthConformanceTest extends AbstractTest { + + @Override + public String toString() { + return "HealthConformanceTest"; + } + + @Override + public boolean runTest() throws Exception { + return facadeIsAlwaysUsable() + && capabilityQueriesAnswer() + && unitConversionIsExact() + && heartRateParserIsExact() + && aggregateBucketsTileTheRange() + && storeOperationsBehave(); + } + + /** The facade and every sub-facade must exist on every port. */ + private boolean facadeIsAlwaysUsable() { + Health h = Health.getInstance(); + assertBool(h != null, "Health.getInstance() must never be null"); + assertBool(h.getStore() != null, "getStore() must never be null"); + assertBool(h.getWorkouts() != null, + "getWorkouts() must never be null"); + assertBool(h.getSensors() != null, "getSensors() must never be null"); + assertBool(Health.getInstance() == h, + "getInstance() must be stable"); + return true; + } + + /** + * Capability queries must answer rather than throw, whatever the port + * supports. + */ + private boolean capabilityQueriesAnswer() { + Health h = Health.getInstance(); + HealthAvailability a = h.getAvailability(); + assertBool(a != null, "getAvailability() must return a value"); + HealthStore store = h.getStore(); + // Calling these must be safe even with no store behind them. + store.isSupported(); + store.isTypeSupported(HealthDataType.STEPS); + store.isPushDelivery(); + HealthAuthorizationStatus read = + store.getReadAuthorizationStatus(HealthDataType.STEPS); + assertBool(read != null, + "read authorization status must answer, even if UNKNOWN"); + assertBool(h.getConfigurationProblems() != null, + "configuration problems must never be null"); + return true; + } + + /** + * Unit conversion is pure arithmetic and must be identical on every + * runtime. The glucose factor is included because getting it wrong is a + * patient-safety issue, not a rounding difference. + */ + private boolean unitConversionIsExact() { + assertClose(0.45359237, + HealthUnit.convert(1, HealthUnit.POUND, HealthUnit.KILOGRAM), + 1e-9, "lb to kg"); + assertClose(212.0, + HealthUnit.convert(100, HealthUnit.DEGREE_CELSIUS, + HealthUnit.DEGREE_FAHRENHEIT), + 1e-9, "degC to degF (affine)"); + assertClose(5.5499, + HealthUnit.convert(100, HealthUnit.MILLIGRAM_PER_DECILITER, + HealthUnit.MILLIMOLE_PER_LITER), + 1e-3, "glucose mg/dL to mmol/L"); + assertClose(1609.344, + HealthUnit.convert(1, HealthUnit.MILE, HealthUnit.METER), + 1e-9, "mile to m"); + return true; + } + + /** + * The 0x2A37 flags-byte handling, which every translator must produce + * identically -- unsigned reads especially, since signedness is where + * ParparVM and the JS port are most likely to diverge. + */ + private boolean heartRateParserIsExact() { + HeartRateMeasurement m = HeartRateMeasurement.parse( + new byte[] { 0x00, (byte) 200 }); + assertBool(m != null, "a valid uint8 payload must parse"); + assertBool(m.getHeartRate() == 200, + "200 bpm must be read unsigned, got " + m.getHeartRate()); + + HeartRateMeasurement rr = HeartRateMeasurement.parse(new byte[] { + 0x10, 60, 0x00, 0x04, 0x00, 0x02 }); + assertBool(rr != null, "an RR payload must parse"); + assertBool(rr.getRrIntervalCount() == 2, + "both RR intervals must be reported, got " + + rr.getRrIntervalCount()); + assertClose(1000.0, rr.getRrIntervalMillis(0), 1e-6, + "RR interval in ms"); + + // A short payload must yield null rather than throwing, since this + // runs inside a notification callback on a real device. + assertBool(HeartRateMeasurement.parse(new byte[] { 0x01, 0x2C }) + == null, "a truncated payload must return null"); + + assertBool(HealthSensorProfile.HEART_RATE.isStreaming(), + "heart rate is a streaming profile"); + return true; + } + + /** + * Bucket boundaries are computed in shared code, so they must tile the + * range identically everywhere -- including through the CLDC Calendar + * implementations the translators provide. + */ + private boolean aggregateBucketsTileTheRange() { + TimeZone utc = TimeZone.getTimeZone("UTC"); + HealthInterval day = HealthInterval.calendarDays(1, utc); + long start = 1767225600000L; // 2026-01-01T00:00:00Z + long next = day.nextBoundary(start); + assertBool(next == start + 86400000L, + "a UTC calendar day is 24h, got " + (next - start)); + + HealthInterval hourly = HealthInterval.hours(1); + assertBool(hourly.nextBoundary(start) == start + 3600000L, + "an hour bucket must advance by an hour"); + assertBool(!hourly.isCalendarBased(), + "a fixed interval is not calendar based"); + assertBool(day.isCalendarBased(), + "a day interval is calendar based"); + return true; + } + + /** + * Store operations must behave on every port: either they work, or they + * fail cleanly. Neither hanging nor throwing out of the call is + * acceptable. + */ + private boolean storeOperationsBehave() throws Exception { + HealthStore store = Health.getInstance().getStore(); + SampleQuery q = new SampleQuery() + .addType(HealthDataType.STEPS) + .setTimeRange(HealthTimeRange.lastHours(1)) + .setLimit(10); + + // Must return a resource rather than throwing, whatever the port. + assertBool(store.readSamples(q) != null, + "readSamples must return a resource"); + + AggregateQuery aq = new AggregateQuery() + .addType(HealthDataType.STEPS) + .addMetric(AggregateMetric.TOTAL) + .setTimeRange(HealthTimeRange.lastDays(1)); + assertBool(store.aggregate(aq) != null, + "aggregate must return a resource"); + + QuantitySample w = QuantitySample.create(HealthDataType.BODY_MASS, + new HealthQuantity(70, HealthUnit.KILOGRAM), + System.currentTimeMillis()); + assertBool(store.write(w) != null, + "write must return a resource"); + + // Where a store does exist, a write followed by a read must round + // trip -- this is the only end-to-end assertion, and it is skipped + // rather than failed where there is no store to write to. + if (store.isSupported() && store.isWritable(HealthDataType.BODY_MASS) + && Health.getInstance().getAvailability() + != HealthAvailability.NOT_SUPPORTED) { + List read = store.readSamples(new SampleQuery() + .addType(HealthDataType.BODY_MASS) + .setTimeRange(HealthTimeRange.lastHours(1)) + .setLimit(50)).get(); + assertBool(read != null, "a supported store must return a list"); + } + return true; + } + + /** + * A tolerance-based comparison, which AbstractTest does not provide for + * doubles. Delegates the failure to the framework's own assertion so it + * is reported the same way as every other failure. + */ + private void assertClose(double expected, double actual, + double epsilon, String message) { + assertBool(Math.abs(expected - actual) <= epsilon, + message + ": expected " + expected + " but was " + actual); + } +} diff --git a/scripts/hellocodenameone/health-smoke/HealthSmokeDemo.java b/scripts/hellocodenameone/health-smoke/HealthSmokeDemo.java new file mode 100644 index 00000000000..ccc782790b1 --- /dev/null +++ b/scripts/hellocodenameone/health-smoke/HealthSmokeDemo.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone; + +import com.codename1.health.Health; +import com.codename1.health.HealthAccess; +import com.codename1.health.HealthChangeBatch; +import com.codename1.health.HealthBackgroundListener; +import com.codename1.health.HealthDataType; +import com.codename1.health.HealthStore; +import com.codename1.health.HealthTimeRange; +import com.codename1.health.SampleQuery; +import com.codename1.health.sensors.HealthSensorProfile; +import com.codename1.health.sensors.HealthSensors; +import com.codename1.io.Log; + +/** + * Exercises the health API so the build server's class scanner detects it + * and produces a real health-enabled build. + * + *

This file deliberately lives outside the Hello Codename One source + * root. {@code health-android.yml} copies it in before building, and + * nothing else does. Referencing {@code com.codename1.health} outside the + * sensors subpackage is what makes AndroidGradleBuilder inject the Health + * Connect dependency, the manifest fragments and the Kotlin bridge -- and + * it also raises {@code minSdkVersion} to 26, which is why it must not sit + * in the shared app. The conformance suite runs at the framework's normal + * floor, and at 26 the platform supplies {@code java.time} instead of the + * desugared library, which silently changes behaviour for every unrelated + * test in the suite.

+ * + *

Nothing here opens a real health session; it only touches the API + * surface so the code paths compile and link on device.

+ */ +public class HealthSmokeDemo { + + /** + * Registered by the build-generated factory. Present so the builder's + * {@code implementsInterface} detection has something to find and the + * generated bindings class is actually produced and compiled. + */ + public static class SmokeBackgroundListener + implements HealthBackgroundListener { + public void healthDataChanged(HealthChangeBatch batch) { + Log.p("health background batch: " + batch.getAdded().size()); + } + } + + private HealthSmokeDemo() { + } + + /** Touches the store surface without requiring any granted access. */ + public static void probeStore() { + Health health = Health.getInstance(); + Log.p("health availability: " + health.getAvailability()); + HealthStore store = health.getStore(); + Log.p("health supported: " + store.isSupported()); + Log.p("steps supported: " + + store.isTypeSupported(HealthDataType.STEPS)); + Log.p("push delivery: " + store.isPushDelivery()); + Log.p("read auth: " + + store.getReadAuthorizationStatus(HealthDataType.STEPS)); + for (String problem : health.getConfigurationProblems()) { + Log.p("health configuration problem: " + problem); + } + } + + /** Issues a bounded read, which must fail cleanly where unsupported. */ + public static void probeRead() { + SampleQuery q = new SampleQuery() + .addType(HealthDataType.STEPS) + .setTimeRange(HealthTimeRange.lastHours(1)) + .setLimit(10); + Health.getInstance().getStore().readSamples(q) + .onResult((samples, err) -> { + if (err != null) { + Log.p("health read failed as expected: " + err); + } else { + Log.p("health read returned " + samples.size()); + } + }); + } + + /** Touches the authorization path without presenting a sheet. */ + public static void probeAuthorizationSurface() { + HealthAccess[] access = + HealthAccess.readWrite(HealthDataType.STEPS); + Log.p("health access pair: " + access.length); + Health.getInstance().getStore() + .getAuthorizationRequestStatus(access) + .onResult((status, err) -> + Log.p("health request status: " + status)); + } + + /** Touches the BLE sensor layer, which needs no health store. */ + public static void probeSensors() { + HealthSensors sensors = Health.getInstance().getSensors(); + Log.p("health sensors supported: " + sensors.isSupported()); + Log.p("known sensor profiles: " + + HealthSensorProfile.values().size()); + } +}