Add Capacitor Health Connect sync
All checks were successful
Android CI / build (push) Successful in 2m6s
Web CI / test (push) Successful in 1m14s
Android Release / release-apk (push) Successful in 1m52s

This commit is contained in:
Daniel 2026-05-25 16:31:51 +02:00
parent 38d9f50c31
commit dd70da8c71
10 changed files with 331 additions and 49 deletions

View file

@ -12,8 +12,8 @@ android {
applicationId 'com.danvics.calorieai'
minSdk 26
targetSdk 35
versionCode 10
versionName '1.10'
versionCode 11
versionName '1.11'
}
buildFeatures {

View file

@ -1,4 +1,5 @@
apply plugin: 'com.android.application'
apply plugin: 'org.jetbrains.kotlin.android'
android {
namespace = "com.danvics.calorieai"
@ -7,8 +8,8 @@ android {
applicationId "com.danvics.calorieai"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10
versionName "1.10"
versionCode 11
versionName "1.11"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@ -22,6 +23,13 @@ android {
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = '21'
}
}
repositories {
@ -35,6 +43,8 @@ dependencies {
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation 'androidx.health.connect:connect-client:1.1.0-alpha12'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"

View file

@ -24,6 +24,17 @@
</activity>
<activity-alias
android:name=".ViewPermissionUsageActivity"
android:exported="true"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@ -41,4 +52,7 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="androidx.health.permission.READ_STEPS" />
<uses-permission android:name="androidx.health.permission.READ_ACTIVE_CALORIES_BURNED" />
<uses-permission android:name="androidx.health.permission.READ_EXERCISE" />
</manifest>

View file

@ -0,0 +1,171 @@
package com.danvics.calorieai
import androidx.activity.result.ActivityResultLauncher
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
import androidx.health.connect.client.records.ExerciseSessionRecord
import androidx.health.connect.client.records.StepsRecord
import androidx.health.connect.client.records.metadata.DataOrigin
import androidx.health.connect.client.request.AggregateRequest
import androidx.health.connect.client.request.ReadRecordsRequest
import androidx.health.connect.client.time.TimeRangeFilter
import androidx.health.connect.client.PermissionController
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
@CapacitorPlugin(name = "HealthConnect")
class HealthConnectPlugin : Plugin() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val permissions = setOf(
HealthPermission.getReadPermission(StepsRecord::class),
HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class),
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
)
private var permissionLauncher: ActivityResultLauncher<Set<String>>? = null
private var pendingSyncCall: PluginCall? = null
override fun load() {
permissionLauncher = activity.registerForActivityResult(
PermissionController.createRequestPermissionResultContract()
) { granted ->
val call = pendingSyncCall ?: return@registerForActivityResult
pendingSyncCall = null
if (granted.containsAll(permissions)) readAndResolve(call) else call.reject("Health Connect permission was not granted.")
}
}
@PluginMethod
fun sync(call: PluginCall) {
when (HealthConnectClient.getSdkStatus(context)) {
HealthConnectClient.SDK_AVAILABLE -> ensurePermissionsThenSync(call)
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> call.reject("Install or update Health Connect, then try again.")
else -> call.reject("Health Connect is not available on this device.")
}
}
private fun ensurePermissionsThenSync(call: PluginCall) {
scope.launch {
runCatching {
val granted = HealthConnectClient.getOrCreate(context).permissionController.getGrantedPermissions()
if (granted.containsAll(permissions)) {
readActivities(call)
} else {
pendingSyncCall = call
permissionLauncher?.launch(permissions) ?: call.reject("Health Connect permissions are not ready.")
}
}.onFailure { error -> call.reject(error.message ?: "Health Connect sync failed.") }
}
}
private fun readAndResolve(call: PluginCall) {
scope.launch {
runCatching { readActivities(call) }
.onFailure { error -> call.reject(error.message ?: "Health Connect sync failed.") }
}
}
private suspend fun readActivities(call: PluginCall) {
val days = (call.getInt("days") ?: 14).coerceIn(1, 90).toLong()
val client = HealthConnectClient.getOrCreate(context)
val zone = ZoneId.systemDefault()
val today = LocalDate.now(zone)
val syncedAt = Instant.now().toString()
val activities = JSArray()
for (offset in 0 until days) {
val date = today.minusDays(offset)
val start = date.atStartOfDay(zone).toInstant()
val end = date.plusDays(1).atStartOfDay(zone).toInstant()
val aggregate = client.aggregate(
AggregateRequest(
metrics = setOf(StepsRecord.COUNT_TOTAL, ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL),
timeRangeFilter = TimeRangeFilter.between(start, end),
)
)
val steps = aggregate[StepsRecord.COUNT_TOTAL] ?: 0L
val calories = aggregate[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories ?: 0.0
if (steps > 0L || calories > 0.0) {
activities.put(
activityJson(
id = "health-connect-day-$date",
sourceName = "Health Connect",
type = "Daily activity",
title = "Daily activity",
startAt = start.toString(),
endAt = end.toString(),
calories = calories,
steps = steps,
syncedAt = syncedAt,
)
)
}
}
val rangeStart = today.minusDays(days - 1).atStartOfDay(zone).toInstant()
val rangeEnd = today.plusDays(1).atStartOfDay(zone).toInstant()
client.readRecords(
ReadRecordsRequest(
recordType = ExerciseSessionRecord::class,
timeRangeFilter = TimeRangeFilter.between(rangeStart, rangeEnd),
pageSize = 200,
)
).records.forEach { session ->
activities.put(
activityJson(
id = "health-connect-session-${session.metadata.id}",
sourceName = sourceName(session.metadata.dataOrigin),
type = "Exercise ${session.exerciseType}",
title = session.title ?: "Exercise session",
startAt = session.startTime.toString(),
endAt = session.endTime.toString(),
syncedAt = syncedAt,
)
)
}
call.resolve(JSObject().put("activities", activities))
}
private fun activityJson(
id: String,
sourceName: String,
type: String,
title: String,
startAt: String,
endAt: String,
calories: Double = 0.0,
steps: Long = 0L,
syncedAt: String,
): JSObject = JSObject()
.put("id", id)
.put("source", "health_connect")
.put("sourceName", sourceName)
.put("type", type)
.put("title", title)
.put("startAt", startAt)
.put("endAt", endAt)
.put("calories", calories)
.put("steps", steps)
.put("distanceMeters", 0)
.put("syncedAt", syncedAt)
private fun sourceName(origin: DataOrigin): String = origin.packageName.ifBlank { "Health Connect" }
override fun handleOnDestroy() {
super.handleOnDestroy()
scope.cancel()
}
}

View file

@ -1,5 +1,12 @@
package com.danvics.calorieai;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(HealthConnectPlugin.class);
super.onCreate(savedInstanceState);
}
}

View file

@ -9,6 +9,7 @@ buildscript {
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files

View file

@ -1,5 +1,5 @@
ext {
minSdkVersion = 24
minSdkVersion = 26
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
@ -13,4 +13,4 @@ ext {
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
}
}

View file

@ -1,5 +1,6 @@
<script>
import { onMount } from 'svelte';
import { registerPlugin } from '@capacitor/core';
import AppShell from './AppShell.svelte';
import DashboardPage from './DashboardPage.svelte';
import AnalyticsPage from './AnalyticsPage.svelte';
@ -10,6 +11,8 @@
import { defaultSettings, fallbackModels, mealTypes, pages } from './appConfig.js';
import { calculateCaloriePlan, planSummary } from './calorie.js';
const HealthConnect = registerPlugin('HealthConnect');
let loginMode = location.pathname === '/login' || location.pathname === '/reset' || location.pathname === '/verify';
let username = 'admin';
let password = '';
@ -36,6 +39,7 @@
let modelStatus = '';
let modelError = false;
let modelSearching = false;
let modelActionId = '';
let settings = { ...defaultSettings };
let currentPassword = '';
let newPassword = '';
@ -44,6 +48,9 @@
let accountVerified = false;
let accountStatus = '';
let accountError = false;
let healthConnectStatus = 'Health Connect not synced.';
let healthConnectError = false;
let healthConnectSyncing = false;
let passwordStatus = '';
let passwordError = false;
let resetPasswordValue = '';
@ -278,6 +285,30 @@
} catch {}
}
async function syncHealthConnect() {
healthConnectStatus = 'Syncing Health Connect...';
healthConnectError = false;
healthConnectSyncing = true;
try {
const nativeResult = await HealthConnect.sync({ days: 14 });
const synced = Array.isArray(nativeResult.activities) ? nativeResult.activities : [];
const response = await fetch('/api/activities/sync', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ source: 'health_connect', activities: synced }),
});
const data = await response.json().catch(() => ({ error: 'Health Connect server sync failed.' }));
if (!response.ok) throw new Error(data.error || 'Health Connect server sync failed.');
activities = data.activities || [];
healthConnectStatus = `Synced ${synced.length} Health Connect record${synced.length === 1 ? '' : 's'}.`;
} catch (error) {
healthConnectStatus = error?.message || 'Health Connect sync failed.';
healthConnectError = true;
} finally {
healthConnectSyncing = false;
}
}
function applyPlanConfig(config) {
plans = config.plans || [];
selectedPlanId = config.selectedPlanId || plans[0]?.id || '';
@ -375,43 +406,63 @@
}
async function addModel(model) {
const response = await fetch('/api/models/add', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(model),
});
if (!response.ok) {
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to add model.';
modelError = true;
return;
}
const data = await response.json();
models = data.models?.length ? data.models : models;
settings.visionModel = models.some(item => item.id === settings.visionModel) ? settings.visionModel : models[0].id;
settings.taskModel = model.id;
await saveSettings();
modelStatus = `Added ${model.name || model.id}.`;
modelActionId = model.id;
modelStatus = `Adding ${model.name || model.id}...`;
modelError = false;
try {
const response = await fetch('/api/models/add', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(model),
});
if (!response.ok) {
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to add model.';
modelError = true;
return;
}
const data = await response.json();
models = data.models?.length ? data.models : models;
settings.visionModel = models.some(item => item.id === settings.visionModel) ? settings.visionModel : models[0].id;
settings.taskModel = model.id;
await saveSettings();
modelStatus = `Added ${model.name || model.id}.`;
modelError = false;
} catch (error) {
modelStatus = error?.message || 'Failed to add model.';
modelError = true;
} finally {
modelActionId = '';
}
}
async function removeModel(modelId) {
const response = await fetch('/api/models/remove', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: modelId }),
});
if (!response.ok) {
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to remove model.';
modelError = true;
return;
}
const data = await response.json();
models = data.models?.length ? data.models : fallbackModels;
if (!models.some(model => model.id === settings.visionModel)) settings.visionModel = data.defaultModel || models[0].id;
if (!models.some(model => model.id === settings.taskModel)) settings.taskModel = data.defaultModel || models[0].id;
await saveSettings();
modelStatus = 'Model removed.';
modelActionId = modelId;
modelStatus = 'Removing model...';
modelError = false;
try {
const response = await fetch('/api/models/remove', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: modelId }),
});
if (!response.ok) {
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to remove model.';
modelError = true;
return;
}
const data = await response.json();
models = data.models?.length ? data.models : fallbackModels;
if (!models.some(model => model.id === settings.visionModel)) settings.visionModel = data.defaultModel || models[0].id;
if (!models.some(model => model.id === settings.taskModel)) settings.taskModel = data.defaultModel || models[0].id;
await saveSettings();
modelStatus = 'Model removed.';
modelError = false;
} catch (error) {
modelStatus = error?.message || 'Failed to remove model.';
modelError = true;
} finally {
modelActionId = '';
}
}
async function changePassword() {
@ -1054,10 +1105,14 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
{modelStatus}
{modelError}
{modelSearching}
{modelActionId}
bind:accountEmail
{accountVerified}
{accountStatus}
{accountError}
{healthConnectStatus}
{healthConnectError}
{healthConnectSyncing}
{passwordStatus}
{passwordError}
{resetPasswordValue}
@ -1071,6 +1126,7 @@ Include: daily calorie target, maintenance calories, protein target, meal loggin
{removeModel}
{saveAccountEmail}
{sendVerificationEmail}
{syncHealthConnect}
{changePassword}
{resetPassword}
/>

View file

@ -8,10 +8,14 @@
export let modelStatus;
export let modelError;
export let modelSearching;
export let modelActionId;
export let accountEmail;
export let accountVerified;
export let accountStatus;
export let accountError;
export let healthConnectStatus;
export let healthConnectError;
export let healthConnectSyncing;
export let passwordStatus;
export let passwordError;
export let resetPasswordValue;
@ -25,6 +29,7 @@
export let removeModel;
export let saveAccountEmail;
export let sendVerificationEmail;
export let syncHealthConnect;
export let changePassword;
export let resetPassword;
</script>
@ -65,7 +70,7 @@
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<div class="mb-2 text-xs font-bold uppercase tracking-wide text-slate-500">Saved models</div>
<div class="max-h-40 overflow-y-auto pr-1">
{#each models as model}<div class="mb-2 flex items-center gap-2 rounded-lg bg-white px-3 py-2 text-sm shadow-sm"><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span><button class="rounded-md bg-red-50 px-2 py-1 text-xs font-bold text-red-600 hover:bg-red-100" type="button" on:click={() => removeModel(model.id)}>Remove</button></div>{/each}
{#each models as model}<div class="mb-2 grid gap-2 rounded-lg bg-white px-3 py-2 text-sm shadow-sm sm:flex sm:items-center"><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span><button class="rounded-lg bg-red-50 px-3 py-2 text-xs font-bold text-red-600 hover:bg-red-100 disabled:opacity-60" type="button" on:click={() => removeModel(model.id)} disabled={modelActionId === model.id}>{modelActionId === model.id ? 'Removing...' : 'Remove'}</button></div>{/each}
</div>
</div>
</div>
@ -73,7 +78,7 @@
<label class="mb-2 block text-sm font-bold text-slate-700" for="modelSearch">Search provider models</label>
<div class="flex flex-col gap-2 sm:flex-row"><input id="modelSearch" class="input" bind:value={modelSearch} placeholder="Search current models, e.g. claude, gemini, gpt" on:keydown={(event) => event.key === 'Enter' && searchModels()}><button class="btn-primary sm:w-36" type="button" on:click={searchModels} disabled={modelSearching}>{modelSearching ? 'Searching...' : 'Search'}</button></div>
<div class="mt-3 max-h-72 overflow-y-auto rounded-xl border border-slate-200 bg-white p-2">
{#each discoveredModels as model}<div class="mb-2 flex items-center gap-2 rounded-lg border border-slate-100 px-3 py-2 text-sm"><button class="rounded-md bg-blue-600 px-2.5 py-1 text-xs font-black text-white hover:bg-blue-700" type="button" on:click={() => addModel(model)}>+</button><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span></div>{/each}
{#each discoveredModels as model}<div class="mb-2 grid gap-2 rounded-lg border border-slate-100 px-3 py-2 text-sm sm:flex sm:items-center"><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span><button class="rounded-lg bg-blue-600 px-3 py-2 text-xs font-black text-white hover:bg-blue-700 disabled:opacity-60 sm:order-first" type="button" on:click={() => addModel(model)} disabled={modelActionId === model.id}>{modelActionId === model.id ? 'Adding...' : 'Add'}</button></div>{/each}
</div>
</div>
</div>
@ -90,6 +95,10 @@
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"><strong class="block text-slate-900">2. Health Connect</strong>In Garmin Connect, enable sharing to Android Health Connect. Garmin sends steps, calories, and workouts after each device sync.</div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"><strong class="block text-slate-900">3. Calorie AI Android</strong>Open Calorie AI on Android, go to Settings, and tap Sync Health Connect. The web dashboard updates after the server receives the sync.</div>
</div>
<div class="flex flex-col gap-3 border-t border-slate-100 px-4 py-4 sm:flex-row sm:items-center">
<button class="btn-primary sm:w-52" type="button" on:click={syncHealthConnect} disabled={healthConnectSyncing}>{healthConnectSyncing ? 'Syncing...' : 'Sync Health Connect'}</button>
<p id="healthConnectStatus" class:text-red-600={healthConnectError} class="text-sm font-semibold text-green-700">{healthConnectStatus}</p>
</div>
<div class="border-t border-slate-100 bg-slate-50 px-4 py-3 text-xs leading-5 text-slate-500">Direct Garmin web login is not available because Garmin Health API requires partner approval and API credentials.</div>
</section>
</div>

View file

@ -6,15 +6,17 @@ const png = Buffer.from(
);
async function mockModels(page) {
let models = [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
];
await page.route('**/api/models', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
defaultModel: 'claude-haiku-4.5',
models: [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
],
models,
}),
});
});
@ -35,19 +37,28 @@ async function mockModels(page) {
await page.route('**/api/models/add', async (route) => {
const body = route.request().postDataJSON();
models = [...models.filter(model => model.id !== body.id), { id: body.id, name: body.name || body.id }];
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
ok: true,
defaultModel: 'claude-haiku-4.5',
models: [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
{ id: body.id, name: body.name || body.id },
],
models,
}),
});
});
await page.route('**/api/models/remove', async (route) => {
const body = route.request().postDataJSON();
models = models.filter(model => model.id !== body.id);
if (!models.length) models = [{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' }];
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true, defaultModel: models[0].id, models }),
});
});
}
async function mockPlans(page) {
@ -254,11 +265,14 @@ test.describe('authenticated app', () => {
await page.locator('#modelSearch').fill('gemini');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByText('Gemini 2.5 Flash')).toBeVisible();
await page.getByRole('button', { name: '+' }).last().click();
await page.getByRole('button', { name: 'Add' }).last().click();
await expect(page.locator('#modelStatus')).toContainText('Added Gemini 2.5 Flash.');
await page.locator('#visionModel').selectOption('gemini-2.5-flash');
await page.locator('#taskModel').selectOption('gemini-2.5-flash');
await page.getByRole('button', { name: 'Save selected models' }).click();
await expect(page.locator('#modelStatus')).toContainText('Models saved.');
await page.getByRole('button', { name: 'Remove' }).last().click();
await expect(page.locator('#modelStatus')).toContainText('Model removed.');
await page.getByRole('button', { name: 'Log meal' }).click();
await page.locator('#mealDate').fill('2026-05-18');