mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
feat(import): read Health Connect exports
Upload the ZIP the Android Health Connect app writes and import its `health_connect_export.db` in the background, the same way an Apple Health export is imported: streamed to disk with a SHA-256, capped at 1 GiB, one import per account, a job row of kind `health_connect` that the shared reconcile cron and staging sweep now look up on its own queue. The worker extracts the database (4 GiB ceiling, zip-bomb checks) and opens it read-only with `immutable=1`, `trusted_schema=OFF` and `query_only`. A view in place of a record table refuses the file, columns are found by name, and schema versions below 9 are refused. Records are read in batches of 500 and aggregated in SQLite: steps, energy and distance as one total per day from the app the person's Health Connect priority list ranks first, heart rate per minute for 90 days and as hourly means before that, sleep stages with the unknown stage counted only in sessions without real stages, workouts, cycle days, water and micronutrients. Apps whose integration the account has connected are left out, and a reading already stored under another source is skipped. Rows carry source HEALTH_CONNECT, `hc:<uuid>` or the `stats:` ids, so a second import of the same export writes nothing. Both staged files are removed on every way out; the job result holds counts per type and per app only. Settings gain a Health Connect card with the export steps, progress and the outcome, in all seven languages, and the import is documented in docs/integrations/health-connect.md.
This commit is contained in:
1 parent
2622f63571
commit
996d2519d1
45 files changed
+6289
-100
No files matched your search
+16
-8
@@ -14136,28 +14136,29 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"401": *a1
|
||||
"409":
|
||||
description: An import for this account is already queued or running with different bytes. The upload was discarded.
|
||||
description: An import for this account (Health Connect or Apple Health) is already queued or running. The upload was
|
||||
discarded. `errorCode` is `import.apple_health.busy` for either kind, `jobId` names the running job.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"413":
|
||||
description: The declared `Content-Length` exceeds the upload cap.
|
||||
description: The declared `Content-Length` exceeds the 1 GiB upload cap.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422":
|
||||
description: "The upload is not a Health Connect export the importer can read: no `file` part, no export database in the
|
||||
ZIP, or a database version older than the importer supports."
|
||||
description: "The multipart body could not be read: no `file` part, a malformed body, or more bytes than the cap.
|
||||
Whether the ZIP is a readable Health Connect export is decided by the worker and reported on the job
|
||||
(`status: failed`, `failureReason`)."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"429": *a5
|
||||
"501": *a9
|
||||
"503":
|
||||
description: No background worker is bound, so nothing would run the import.
|
||||
description: No background worker is bound, or the queue did not take the job, so nothing would run the import.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -14178,7 +14179,6 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
"501": *a9
|
||||
/api/import/csv:
|
||||
post:
|
||||
tags:
|
||||
@@ -66181,11 +66181,19 @@ components:
|
||||
type: string
|
||||
additionalProperties: {}
|
||||
- type: "null"
|
||||
description: The worker's outcome summary, null until it finishes.
|
||||
description: "The worker's outcome summary, null until it finishes. Counts only: `perType` (read / inserted / updated /
|
||||
unchanged / skipped per measurement type), `perApp` (records per Android package, `leftOut` for an app whose
|
||||
integration the account has connected), `workouts`, `sleep`, `cycle`, `nutrients`, `skipped` (reason →
|
||||
count), `warnings` (codes for tables or columns the importer could not read), `totals`. Never a value, a
|
||||
timestamp or text from the file."
|
||||
failureReason:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: Why the job failed. Starts with a code the client can key on when the file was refused —
|
||||
`unsupported_version` (database older than version 9), `not_health_connect` (no `health_connect_export.db`,
|
||||
or not a Health Connect database), `unsafe_schema` (a record table is a view), `too_large`,
|
||||
`staging_missing` — else free text.
|
||||
required:
|
||||
- jobId
|
||||
- status
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# Health Connect import
|
||||
|
||||
HealthLog reads the export file of Android's Health Connect and folds
|
||||
it into the same timeline as every other source. There is no Android
|
||||
app and no live sync: you export from the phone, upload the file, and
|
||||
a background job imports it. Rows from this path carry the source
|
||||
`HEALTH_CONNECT`.
|
||||
|
||||
## What you need
|
||||
|
||||
- An Android phone with Health Connect and its export feature. The
|
||||
export arrived with Android 15 and reaches Android 14 through a
|
||||
system update; if you do not see it, update Health Connect first.
|
||||
- A cloud storage app on the phone (Google Drive, Dropbox, Nextcloud
|
||||
or any other app that offers itself as a storage location). Health
|
||||
Connect refuses to save its export to the phone's own storage or the
|
||||
Downloads folder, so the file always goes through a cloud first.
|
||||
- A HealthLog account in a browser. The upload uses your normal
|
||||
session; there is no token to create.
|
||||
|
||||
## 1. Export on the phone
|
||||
|
||||
1. Open Health Connect (in the system settings, or the Health Connect
|
||||
app).
|
||||
2. Go to **Manage data**, then **Export and import**, then
|
||||
**Scheduled export** or **Export now**, depending on the version.
|
||||
3. Pick the cloud storage app as the destination. You can choose how
|
||||
often Health Connect exports again; HealthLog only ever reads the
|
||||
file you upload.
|
||||
|
||||
The result is a ZIP file named `Health Connect.zip` with a single
|
||||
entry, `health_connect_export.db`. That entry is a copy of the phone's
|
||||
Health Connect database (a SQLite file). Health Connect does not
|
||||
encrypt it, so treat the file like any other export of your health
|
||||
data.
|
||||
|
||||
## 2. Upload to HealthLog
|
||||
|
||||
Download the ZIP from the cloud to the computer or phone you use
|
||||
HealthLog on, open **Settings → Export & Import** and drop the file
|
||||
onto the **Health Connect export** card.
|
||||
|
||||
The endpoint is `POST /api/import/health-connect-export`
|
||||
(`multipart/form-data`, field `file`). It:
|
||||
|
||||
1. Allows three uploads per minute per account.
|
||||
2. Refuses anything above **1 GiB**, once against the declared
|
||||
`Content-Length` and again while the body streams to disk.
|
||||
3. Streams the body to a temp file and hashes it with SHA-256 on the
|
||||
way. Uploading the same bytes again returns the earlier job
|
||||
(`idempotent: true`) instead of importing twice; a failed job never
|
||||
blocks a retry.
|
||||
4. Runs one import per account at a time, Apple Health and Health
|
||||
Connect together. A second upload while one runs answers `409` and
|
||||
the file is discarded.
|
||||
5. Records the upload's size and hash in the audit log, and nothing
|
||||
from inside the file.
|
||||
|
||||
`GET /api/import/health-connect-export/status` answers the account's
|
||||
latest Health Connect job, or `null`. The card polls it while a job
|
||||
runs.
|
||||
|
||||
## 3. What the import does
|
||||
|
||||
The worker extracts `health_connect_export.db` (at most 4 GiB, with
|
||||
the same zip-bomb checks as the Apple Health importer) and opens it
|
||||
read-only. The file is treated as untrusted input:
|
||||
|
||||
- It is opened with SQLite's `immutable` flag, so a database last
|
||||
written in WAL mode opens without its `-wal` and `-shm` files, and
|
||||
nothing is ever written next to it.
|
||||
- Views, triggers and generated columns in the file cannot call
|
||||
functions with side effects (`trusted_schema=OFF`), and a view in
|
||||
place of one of the record tables refuses the whole file.
|
||||
- Columns are looked up by name. Health Connect adds columns as its
|
||||
schema version moves, so their order differs between phones.
|
||||
Database versions below 9 are refused; versions above 27 (the newest
|
||||
known when this was written) are read, with a warning on the job.
|
||||
A table that lacks a column the importer needs is skipped and named
|
||||
in the job's `warnings`.
|
||||
|
||||
### What becomes what
|
||||
|
||||
| Health Connect | HealthLog |
|
||||
| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Weight, lean body mass (grams) | `WEIGHT`, `LEAN_BODY_MASS` in kg |
|
||||
| Body fat | `BODY_FAT` in % |
|
||||
| Blood pressure | `BLOOD_PRESSURE_SYS` and `BLOOD_PRESSURE_DIA`, one pair per reading |
|
||||
| Resting heart rate, heart-rate variability (RMSSD) | `RESTING_HEART_RATE`, `HRV_RMSSD` |
|
||||
| Oxygen saturation, respiratory rate, body temperature, VO2 max | the matching types |
|
||||
| Blood glucose (mmol/L) | `BLOOD_GLUCOSE` in mg/dL, fasting and after-meal context kept |
|
||||
| Heart rate | per minute for the last 90 days; older history as hourly means with the hour's minimum and maximum |
|
||||
| Steps, active calories, distance | one total per day |
|
||||
| Sleep sessions and stages | one row per stage; light sleep becomes core sleep, both awake stages awake time; a session without stages counts as asleep |
|
||||
| Exercise sessions | workouts, with the Health Connect exercise type kept on the workout |
|
||||
| Menstruation flow and periods | cycle day logs (needs cycle tracking switched on) |
|
||||
| Hydration and vitamins and minerals | daily nutrient totals (needs the Nutrients module) |
|
||||
|
||||
Left out on purpose: food energy and macronutrients, exercise routes,
|
||||
planned workouts, Health Connect's access logs, the "out of bed" sleep
|
||||
stage, and an "unknown" sleep stage inside a session that also has real
|
||||
stages.
|
||||
|
||||
### When several apps write the same thing
|
||||
|
||||
The export holds every app's data side by side. Summing them would
|
||||
count a step the phone and the watch both saw twice, so:
|
||||
|
||||
- A day total (steps, calories, distance, water, nutrients) comes from
|
||||
one app per day: the first one your Health Connect app priority
|
||||
list names for that category, otherwise the app with the largest
|
||||
total.
|
||||
- Heart rate comes from one app per hour, by the same rule.
|
||||
- Of two overlapping sleep sessions or workouts from different apps,
|
||||
the one from the higher-ranked app is kept.
|
||||
- Two apps writing the same type at the same instant keep the
|
||||
higher-ranked app's value.
|
||||
|
||||
### Apps HealthLog already connects to
|
||||
|
||||
If your account has the Withings, Fitbit, Google Health, Oura, Polar
|
||||
or WHOOP integration connected, that app's records in the export are
|
||||
left out: HealthLog already receives them directly, and importing them
|
||||
again would put every reading in twice. As a further safety net, a
|
||||
reading that matches one already stored under another source (same
|
||||
type and value within two seconds) is left out too. The job result
|
||||
lists how many records each app contributed and which apps were left
|
||||
out.
|
||||
|
||||
### Importing again
|
||||
|
||||
Every row is keyed by the record's Health Connect ID (`hc:<uuid>`),
|
||||
day totals and hourly means by their `stats:` ID. Importing the same
|
||||
export again writes nothing; a newer export that overlaps an older one
|
||||
adds only what is new. A value that changed on the phone is updated;
|
||||
a value you deleted in HealthLog stays deleted.
|
||||
|
||||
## Privacy
|
||||
|
||||
- The uploaded ZIP and the extracted database are deleted as soon as
|
||||
the job ends, whether it succeeded or failed. A periodic sweep
|
||||
removes anything a crash left behind.
|
||||
- The job result holds counts only: per measurement type and per app.
|
||||
No value, no timestamp and no text from the file (notes, titles)
|
||||
is stored on the job.
|
||||
|
||||
## Operator notes
|
||||
|
||||
- The worker uses Node's built-in `node:sqlite` module. It needs no
|
||||
flag on Node 22 and prints one `ExperimentalWarning` per process.
|
||||
- Memory stays bounded: the database is read in batches of 500 rows
|
||||
and aggregated inside SQLite, so what the import keeps alive does not
|
||||
grow with the length of the history. Measured under a 792 MB heap,
|
||||
one and three years of minute-by-minute heart rate peaked at about
|
||||
160 MB of heap in total.
|
||||
- Split web and worker containers need a shared staging directory,
|
||||
exactly as for the Apple Health import.
|
||||
- The status of a running job shows up under **Settings → Export &
|
||||
Import**; a job interrupted by a restart is marked failed by the
|
||||
same reconcile pass that watches Apple Health imports.
|
||||
Reference in new issue
Block a user