From 86a7cd98523c4c730a837f6f27f67e7b97100dac Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sun, 18 Oct 2026 11:31:50 -0400 Subject: [PATCH] fix(android,health): unblock Health Connect grant flow and cloud-backed sync Field-reported on the alpha APK: the Samsung/Pixel row failed with 'Health permissions were denied' without ever showing a dialog, and the Google Health connect reported the same error despite a successful OAuth. - MainActivity now extends FlutterFragmentActivity: package:health v13 casts the host activity to androidx ComponentActivity to call registerForActivityResult, so with plain FlutterActivity the permission launcher stayed null and requestAuthorization() silently returned false (HealthPlugin.kt:317 'Permission launcher not found') - add the androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE intent-filter required by the health package README on the main activity - drop STEPS / READ_STEPS from the Android permission set: steps are never fetched anywhere and the README ties step access to ACTIVITY_RECOGNITION, which this app does not declare (weight, total + active calories, blood glucose remain, all declared in the manifest) - syncFromHealth treats a native permission denial as fatal only when no cloud partner holds a grant, so a Google Health-connected sync proceeds without the OS permission (native reads degrade to empty) - tests: biometric controller covers native-denial + cloud-ready sync (cloudReady fake override); suite 185/185, analyze clean, debug APK builds (also clears the standing flutter build apk --debug gate) - BUILD_STATE: record both fixes, check off the Android OAuth client and connect gates, add the Health Connect on-device verification gate --- BUILD_STATE.md | 15 ++++++++-- android/app/src/main/AndroidManifest.xml | 15 +++++++--- .../companionglp/glpcompanion/MainActivity.kt | 10 +++++-- lib/core/platform/health_gateway_mobile.dart | 1 - .../controllers/biometric_controller.dart | 14 +++++---- .../biometric_controller_test.dart | 30 +++++++++++++++++++ 6 files changed, 71 insertions(+), 14 deletions(-) diff --git a/BUILD_STATE.md b/BUILD_STATE.md index 096cadb..7009f8e 100644 --- a/BUILD_STATE.md +++ b/BUILD_STATE.md @@ -226,10 +226,21 @@ Release engineering for alpha (pre-release) builds across Android and Windows, d - UX: the Google Health row's failure copy is now OAuth-accurate — "Google Health sign-in was cancelled or did not complete. Tap Connect to try again." — instead of pointing the user at device settings (integrations controller branches on `HealthProviderType.googleHealth`). - Dependency: `google_sign_in: 6.3.0` added (Android implementation 6.2.1). - Verified: `flutter analyze` clean; `flutter test` 184/184 green (including `integrations_controller_test.dart` and `settings_screen_test.dart`). +- [x] Android post-connect sync fix (field-reported right after the GIS fix: Google Health **connected**, but the app then showed "Health permissions were denied."): `BiometricController.syncFromHealth()` unconditionally demanded the **native** Health Connect grant first and bailed on denial — never reaching the cloud connector, which needs no OS permission. The bail is now `!granted && !await gateway.isCloudSyncReady()` — a native denial is fatal only when no cloud partner holds a grant; with Google Health connected, cloud reads proceed (native degrades to empty while ungranted) and the status reports `Synced N weight sample(s)…`. Tests: `biometric_controller_test.dart` gains a `cloudReady` fake override plus "native denial with a connected cloud partner still syncs partner data" (11 tests in file); full suite 185/185, analyze clean. Still prompts for Health Connect once — a denial there no longer breaks the cloud sync. ## Google Health on Android — Verification Gates (2026-10-18, manual) -- [ ] Google Cloud → the same project as `GOOGLE_HEALTH_CLIENT_ID` → APIs & Services → Credentials → **Create OAuth client ID → Application type Android**: package name `com.companionglp.glpcompanion` with the SHA-1 of the release `upload-keystore.jks` (`keytool -list -v -keystore upload-keystore.jks -alias `); add a second Android client with the debug keystore SHA-1 (`%USERPROFILE%\.android\debug.keystore`, alias `androiddebugkey`, storepass `android`) for debug builds. The GIS SDK resolves the caller by package/SHA-1 — without a match it returns no `serverAuthCode` and Connect fails (the connector logs the checklist in debug). Fingerprints computed 2026-10-18 (re-verify against the installed APK with `keytool -printcert -jarfile `): release `upload-keystore.jks` alias `upload` = `79:8B:73:52:70:77:C3:46:C7:12:06:88:42:0E:97:67:CC:B8:E2:85`; debug `~/.android/debug.keystore` alias `androiddebugkey` = `67:BA:FE:4A:15:5B:52:73:E1:8F:0D:F7:44:81:7B:5C:72:C3:A1:36`. -- [ ] On-device alpha round trip: install the rebuilt arm64 APK → Settings → Google Health → Connect → native account chooser → Google consent (activity & fitness + health metrics read scopes) → returns straight into the app → row flips **Connected** → weight/active energy appear on the Nutrition dashboard as `cloudSync` entries. +- [x] Google Cloud Android OAuth client(s) — DONE 2026-10-18 (the on-device connect succeeded, proving a matching package/SHA-1 client exists): Google Cloud → the same project as `GOOGLE_HEALTH_CLIENT_ID` → APIs & Services → Credentials → **Create OAuth client ID → Application type Android**: package name `com.companionglp.glpcompanion` with the SHA-1 of the release `upload-keystore.jks` (`keytool -list -v -keystore upload-keystore.jks -alias `); add a second Android client with the debug keystore SHA-1 (`%USERPROFILE%\.android\debug.keystore`, alias `androiddebugkey`, storepass `android`) for debug builds. The GIS SDK resolves the caller by package/SHA-1 — without a match it returns no `serverAuthCode` and Connect fails (the connector logs the checklist in debug). Fingerprints computed 2026-10-18 (re-verify against the installed APK with `keytool -printcert -jarfile `): release `upload-keystore.jks` alias `upload` = `79:8B:73:52:70:77:C3:46:C7:12:06:88:42:0E:97:67:CC:B8:E2:85`; debug `~/.android/debug.keystore` alias `androiddebugkey` = `67:BA:FE:4A:15:5B:52:73:E1:8F:0D:F7:44:81:7B:5C:72:C3:A1:36`. +- [x] On-device connect round trip (confirmed 2026-10-18): install the rebuilt arm64 APK → Settings → Google Health → Connect → native account chooser → Google consent (activity & fitness + health metrics read scopes) → returns straight into the app → row flips **Connected**. +- [ ] Data round trip (pending an APK rebuild containing the post-connect sync fix above): after Connect, status reads `Synced N weight sample(s)…` instead of "Health permissions were denied.", and weight/active energy appear on the Nutrition dashboard (Android imports persist under the `healthConnect` platform source). + +## Health Connect (Samsung & Pixel) Row — Fix (2026-10-18) + +- [x] Native Health Connect permission request failed silently (field-reported: tapping the "Health Connect (Samsung & Pixel)" row showed "Health permissions were denied. Enable them in your device settings" with **no permission dialog ever appearing**). Root cause per the `package:health` v13.3.1 README: `requestAuthorization()` registers its permission launcher via `(activity as ComponentActivity).registerForActivityResult(...)` in `onAttachedToActivity` — the host activity must therefore extend **`FlutterFragmentActivity`**, but `MainActivity` extended plain `FlutterActivity` (`android.app.Activity`, not androidx `ComponentActivity`). The cast fails → the launcher stays `null` → the plugin answers `success(false)` immediately (HealthPlugin.kt:317) → our gateway maps it to "denied". Three fixes: + - `MainActivity.kt` now extends `FlutterFragmentActivity` (with a comment explaining the cast requirement). Drop-in for all other plugins (app_links, supabase, url_launcher, notifications, google_sign_in all bind to any Activity). + - `AndroidManifest.xml`: added the README-required `` for `androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE` on `.MainActivity` (the `` block and `ViewPermissionUsageActivity` alias were already present; `health_connect_config.xml` is optional restore tooling and was not added). + - Read-set hygiene: `STEPS` removed from `_androidReadTypes` and `READ_STEPS` from the manifest — steps are **never fetched** anywhere in the app, and the README ties step access to the `ACTIVITY_RECOGNITION` permission this app does not declare; an ungrantable entry risks poisoning the whole permission request. Remaining set: weight, total calories burned, active energy burned, blood glucose (all four declared in the manifest). + - Verified: `flutter analyze` clean; `flutter test` 185/185 green; **`flutter build apk --debug` → `Built build\app\outputs\flutter-apk\app-debug.apk`** (also clears the standing build-gate item for the debug APK; Gradle 135.1 s; pre-existing KGP warning for device_info_plus/health/mobile_scanner is non-fatal today). +- [ ] On-device Health Connect round trip: tap the Samsung/Pixel row → the Health Connect permission screen **appears** (weight, calories, glucose toggles) → grant → row flips **Connected** → status shows a `Synced …` result. If a stale denial exists from earlier attempts, clear it once under Android Settings → Apps → glpcompanion → Permissions (or the Health Connect app's App permissions screen), then retry in-app. - [ ] Disconnect then Connect again re-prompts for an account (SDK sign-out path), and the row recovers cleanly if the sign-in sheet is dismissed (cancel → the OAuth-specific error banner, button un-busy). - [ ] Follow-up (not part of this fix): iOS still uses the loopback redirect and carries the same background fragility — migrate it to the SDK path when iOS alpha starts (needs an iOS-type OAuth client + reversed-client-ID URL scheme). - [ ] Web-client housekeeping (field-checked 2026-10-18: the console screenshot showed the WRONG client — the project has **two** Web clients): `GOOGLE_HEALTH_CLIENT_ID` = `305941286539-2bqe3cte3gg6b2m64k0uosdp3tvrcfhq…` is the **Google Health** client — confirm it holds `http://localhost:52789/callback` under Authorized redirect URIs (desktop/iOS loopback only; the Android GIS flow uses **no** redirect URI) and that its secret matches `GOOGLE_HEALTH_CLIENT_SECRET`. The other client, `305941286539-8p9itkud4m0r9luiu53rg2s9liqon7lm…` ("GLP-1 Companion Web", redirect `https://pyrgjdvqbjzakuwvbpxw.supabase.co/auth/v1/callback`), is the **Supabase sign-in** client — leave it untouched. Secrets are no longer viewable in the console — rotate via **Add secret** + `.env` update if mismatched. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9c7fef2..aa7c57f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,11 +4,12 @@ + the HealthGateway request set (weight, total calories burned, active + energy burned, blood glucose). STEPS was removed from the set: it is + never fetched, and the package:health README ties step access to the + ACTIVITY_RECOGNITION permission this app does not declare. Health + Connect federates Samsung Health and Pixel sensor feeds. --> - @@ -46,6 +47,12 @@ + + + + diff --git a/android/app/src/main/kotlin/com/companionglp/glpcompanion/MainActivity.kt b/android/app/src/main/kotlin/com/companionglp/glpcompanion/MainActivity.kt index 0fc4af5..ecb15a8 100644 --- a/android/app/src/main/kotlin/com/companionglp/glpcompanion/MainActivity.kt +++ b/android/app/src/main/kotlin/com/companionglp/glpcompanion/MainActivity.kt @@ -1,5 +1,11 @@ package com.companionglp.glpcompanion -import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.FlutterFragmentActivity -class MainActivity : FlutterActivity() +// FlutterFragmentActivity (not the default FlutterActivity) is required by +// package:health v13: it casts the host activity to +// androidx.activity.ComponentActivity to call registerForActivityResult for +// the Health Connect permission flow. With plain FlutterActivity that cast +// fails, the permission launcher stays null, and requestAuthorization() +// silently returns false ("Health permissions were denied" with no dialog). +class MainActivity : FlutterFragmentActivity() diff --git a/lib/core/platform/health_gateway_mobile.dart b/lib/core/platform/health_gateway_mobile.dart index 97589ed..9ef512b 100644 --- a/lib/core/platform/health_gateway_mobile.dart +++ b/lib/core/platform/health_gateway_mobile.dart @@ -81,7 +81,6 @@ class MobileHealthGateway extends IHealthGateway { /// Pixel sensor feeds into this single runtime. static const List _androidReadTypes = [ HealthDataType.WEIGHT, - HealthDataType.STEPS, HealthDataType.TOTAL_CALORIES_BURNED, HealthDataType.ACTIVE_ENERGY_BURNED, HealthDataType.BLOOD_GLUCOSE, diff --git a/lib/features/health_sync/presentation/controllers/biometric_controller.dart b/lib/features/health_sync/presentation/controllers/biometric_controller.dart index 1f5b616..a9fc8c3 100644 --- a/lib/features/health_sync/presentation/controllers/biometric_controller.dart +++ b/lib/features/health_sync/presentation/controllers/biometric_controller.dart @@ -219,9 +219,9 @@ class BiometricController extends AsyncNotifier { /// same source/window is replaced, not duplicated). /// /// Returns `false` when no sync source exists on this platform (Web with - /// no cloud connector provisioned), when permission is denied, or when - /// the sync throws; the reason is reflected in - /// [BiometricState.statusMessage]. + /// no cloud connector provisioned), when native permission is denied + /// *and* no cloud partner holds a valid grant, or when the sync throws; + /// the reason is reflected in [BiometricState.statusMessage]. Future syncFromHealth({Duration window = syncWindow}) async { final gateway = ref.read(healthGatewayProvider); if (!gateway.supportsNativeHealth && !gateway.supportsCloudSync) { @@ -245,10 +245,14 @@ class BiometricController extends AsyncNotifier { try { // Native stores need an OS permission grant first; cloud connectors - // manage their own OAuth grant at connect time. + // manage their own OAuth grant at connect time. A native denial is + // therefore fatal only when no cloud partner can supply the data — + // with Google Health connected, `isCloudSyncReady()` is true and the + // cloud reads proceed without any OS permission (the gateway merges + // both sources; native degrades to empty while ungranted). if (gateway.supportsNativeHealth) { final granted = await gateway.requestPermissions(); - if (!granted) { + if (!granted && !await gateway.isCloudSyncReady()) { await _refresh( statusMessage: 'Health permissions were denied.', error: StateError('Health permissions denied'), diff --git a/test/features/health_sync/presentation/biometric_controller_test.dart b/test/features/health_sync/presentation/biometric_controller_test.dart index 38f36cd..7e18c14 100644 --- a/test/features/health_sync/presentation/biometric_controller_test.dart +++ b/test/features/health_sync/presentation/biometric_controller_test.dart @@ -86,6 +86,7 @@ class _FakeHealthSyncRepository implements IHealthSyncRepository { class _FakeHealthGateway extends IHealthGateway { bool nativeSupported = true; bool granted = true; + bool cloudReady = false; List weights = []; List energies = []; int permissionRequests = 0; @@ -104,6 +105,9 @@ class _FakeHealthGateway extends IHealthGateway { return granted; } + @override + Future isCloudSyncReady() async => cloudReady; + @override Future> fetchWeightHistory({ required DateTime start, @@ -286,6 +290,32 @@ void main() { expect(repository.entries, isEmpty); }); + test( + 'native denial with a connected cloud partner still syncs partner ' + 'data', () async { + gateway.granted = false; + gateway.cloudReady = true; + gateway.weights = [ + HealthMetric( + timestamp: DateTime.now().subtract(const Duration(hours: 1)), + value: 182, + unit: 'lb', + ), + ]; + await load(); + + final ok = await notifier().syncFromHealth(); + + expect(ok, isTrue); + // The native grant is still requested exactly once… + expect(gateway.permissionRequests, 1); + final state = container.read(biometricControllerProvider).valueOrNull!; + // …but the denial no longer blocks the cloud-backed sync. + expect(state.statusMessage, contains('Synced 1 weight sample(s)')); + expect(state.statusMessage, isNot(contains('denied'))); + expect(repository.entries, hasLength(1)); + }); + test( 'imports weights and per-day active energy, then re-syncs without ' 'duplicates', () async {