From dd70da8c71da8457515d7dfdcd038c7d87b38ba4 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 25 May 2026 16:31:51 +0200 Subject: [PATCH] Add Capacitor Health Connect sync --- app/build.gradle | 4 +- web/android/app/build.gradle | 14 +- web/android/app/src/main/AndroidManifest.xml | 14 ++ .../danvics/calorieai/HealthConnectPlugin.kt | 171 ++++++++++++++++++ .../com/danvics/calorieai/MainActivity.java | 9 +- web/android/build.gradle | 1 + web/android/variables.gradle | 4 +- web/src/App.svelte | 120 ++++++++---- web/src/SettingsPage.svelte | 13 +- web/tests/app.spec.js | 30 ++- 10 files changed, 331 insertions(+), 49 deletions(-) create mode 100644 web/android/app/src/main/java/com/danvics/calorieai/HealthConnectPlugin.kt diff --git a/app/build.gradle b/app/build.gradle index 5912f85..bb79054 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId 'com.danvics.calorieai' minSdk 26 targetSdk 35 - versionCode 10 - versionName '1.10' + versionCode 11 + versionName '1.11' } buildFeatures { diff --git a/web/android/app/build.gradle b/web/android/app/build.gradle index e46eb00..c1dad43 100644 --- a/web/android/app/build.gradle +++ b/web/android/app/build.gradle @@ -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" diff --git a/web/android/app/src/main/AndroidManifest.xml b/web/android/app/src/main/AndroidManifest.xml index b29b5fb..bd9f8d4 100644 --- a/web/android/app/src/main/AndroidManifest.xml +++ b/web/android/app/src/main/AndroidManifest.xml @@ -24,6 +24,17 @@ + + + + + + + + + + diff --git a/web/android/app/src/main/java/com/danvics/calorieai/HealthConnectPlugin.kt b/web/android/app/src/main/java/com/danvics/calorieai/HealthConnectPlugin.kt new file mode 100644 index 0000000..15ee504 --- /dev/null +++ b/web/android/app/src/main/java/com/danvics/calorieai/HealthConnectPlugin.kt @@ -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>? = 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() + } +} diff --git a/web/android/app/src/main/java/com/danvics/calorieai/MainActivity.java b/web/android/app/src/main/java/com/danvics/calorieai/MainActivity.java index 7042e89..d7c5e10 100644 --- a/web/android/app/src/main/java/com/danvics/calorieai/MainActivity.java +++ b/web/android/app/src/main/java/com/danvics/calorieai/MainActivity.java @@ -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); + } +} diff --git a/web/android/build.gradle b/web/android/build.gradle index f8f0e43..29dba9e 100644 --- a/web/android/build.gradle +++ b/web/android/build.gradle @@ -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 diff --git a/web/android/variables.gradle b/web/android/variables.gradle index ee4ba41..a0aa104 100644 --- a/web/android/variables.gradle +++ b/web/android/variables.gradle @@ -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' -} \ No newline at end of file +} diff --git a/web/src/App.svelte b/web/src/App.svelte index ff2e430..741e35a 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,5 +1,6 @@ @@ -65,7 +70,7 @@
Saved models
- {#each models as model}
{model.name} {model.id}
{/each} + {#each models as model}
{model.name} {model.id}
{/each}
@@ -73,7 +78,7 @@
event.key === 'Enter' && searchModels()}>
- {#each discoveredModels as model}
{model.name} {model.id}
{/each} + {#each discoveredModels as model}
{model.name} {model.id}
{/each}
@@ -90,6 +95,10 @@
2. Health ConnectIn Garmin Connect, enable sharing to Android Health Connect. Garmin sends steps, calories, and workouts after each device sync.
3. Calorie AI AndroidOpen Calorie AI on Android, go to Settings, and tap Sync Health Connect. The web dashboard updates after the server receives the sync.
+
+ +

{healthConnectStatus}

+
Direct Garmin web login is not available because Garmin Health API requires partner approval and API credentials.
diff --git a/web/tests/app.spec.js b/web/tests/app.spec.js index 6f2306b..c8f09d5 100644 --- a/web/tests/app.spec.js +++ b/web/tests/app.spec.js @@ -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');