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
This commit is contained in:
Mike Kell 2026-10-18 11:31:50 -04:00
parent 0a77b13032
commit 86a7cd9852
6 changed files with 71 additions and 14 deletions

View File

@ -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`). - 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). - 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`). - 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 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 <keyAlias>`); 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 <apk>`): 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] 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 <keyAlias>`); 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 <apk>`): 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] 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 `<intent-filter>` for `androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE` on `.MainActivity` (the `<queries>` 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). - [ ] 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). - [ ] 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. - [ ] 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.

View File

@ -4,11 +4,12 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<!-- Health Connect (Android 14+ / Play services): read permissions matching <!-- Health Connect (Android 14+ / Play services): read permissions matching
the HealthGateway request set (weight, steps, total calories burned, the HealthGateway request set (weight, total calories burned, active
active energy burned, blood glucose). Health Connect federates energy burned, blood glucose). STEPS was removed from the set: it is
Samsung Health and Pixel sensor feeds through this runtime. --> 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. -->
<uses-permission android:name="android.permission.health.READ_WEIGHT"/> <uses-permission android:name="android.permission.health.READ_WEIGHT"/>
<uses-permission android:name="android.permission.health.READ_STEPS"/>
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED"/> <uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED"/>
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED"/> <uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED"/>
<uses-permission android:name="android.permission.health.READ_BLOOD_GLUCOSE"/> <uses-permission android:name="android.permission.health.READ_BLOOD_GLUCOSE"/>
@ -46,6 +47,12 @@
<category android:name="android.intent.category.BROWSABLE"/> <category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="com.companionglp.glpcompanion"/> <data android:scheme="com.companionglp.glpcompanion"/>
</intent-filter> </intent-filter>
<!-- Health Connect (package:health README): lets the Health
Connect permissions screen resolve this app for the
rationale step of the permission flow. -->
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE"/>
</intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@ -1,5 +1,11 @@
package com.companionglp.glpcompanion 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()

View File

@ -81,7 +81,6 @@ class MobileHealthGateway extends IHealthGateway {
/// Pixel sensor feeds into this single runtime. /// Pixel sensor feeds into this single runtime.
static const List<HealthDataType> _androidReadTypes = [ static const List<HealthDataType> _androidReadTypes = [
HealthDataType.WEIGHT, HealthDataType.WEIGHT,
HealthDataType.STEPS,
HealthDataType.TOTAL_CALORIES_BURNED, HealthDataType.TOTAL_CALORIES_BURNED,
HealthDataType.ACTIVE_ENERGY_BURNED, HealthDataType.ACTIVE_ENERGY_BURNED,
HealthDataType.BLOOD_GLUCOSE, HealthDataType.BLOOD_GLUCOSE,

View File

@ -219,9 +219,9 @@ class BiometricController extends AsyncNotifier<BiometricState> {
/// same source/window is replaced, not duplicated). /// same source/window is replaced, not duplicated).
/// ///
/// Returns `false` when no sync source exists on this platform (Web with /// Returns `false` when no sync source exists on this platform (Web with
/// no cloud connector provisioned), when permission is denied, or when /// no cloud connector provisioned), when native permission is denied
/// the sync throws; the reason is reflected in /// *and* no cloud partner holds a valid grant, or when the sync throws;
/// [BiometricState.statusMessage]. /// the reason is reflected in [BiometricState.statusMessage].
Future<bool> syncFromHealth({Duration window = syncWindow}) async { Future<bool> syncFromHealth({Duration window = syncWindow}) async {
final gateway = ref.read(healthGatewayProvider); final gateway = ref.read(healthGatewayProvider);
if (!gateway.supportsNativeHealth && !gateway.supportsCloudSync) { if (!gateway.supportsNativeHealth && !gateway.supportsCloudSync) {
@ -245,10 +245,14 @@ class BiometricController extends AsyncNotifier<BiometricState> {
try { try {
// Native stores need an OS permission grant first; cloud connectors // 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) { if (gateway.supportsNativeHealth) {
final granted = await gateway.requestPermissions(); final granted = await gateway.requestPermissions();
if (!granted) { if (!granted && !await gateway.isCloudSyncReady()) {
await _refresh( await _refresh(
statusMessage: 'Health permissions were denied.', statusMessage: 'Health permissions were denied.',
error: StateError('Health permissions denied'), error: StateError('Health permissions denied'),

View File

@ -86,6 +86,7 @@ class _FakeHealthSyncRepository implements IHealthSyncRepository {
class _FakeHealthGateway extends IHealthGateway { class _FakeHealthGateway extends IHealthGateway {
bool nativeSupported = true; bool nativeSupported = true;
bool granted = true; bool granted = true;
bool cloudReady = false;
List<HealthMetric> weights = []; List<HealthMetric> weights = [];
List<HealthMetric> energies = []; List<HealthMetric> energies = [];
int permissionRequests = 0; int permissionRequests = 0;
@ -104,6 +105,9 @@ class _FakeHealthGateway extends IHealthGateway {
return granted; return granted;
} }
@override
Future<bool> isCloudSyncReady() async => cloudReady;
@override @override
Future<List<HealthMetric>> fetchWeightHistory({ Future<List<HealthMetric>> fetchWeightHistory({
required DateTime start, required DateTime start,
@ -286,6 +290,32 @@ void main() {
expect(repository.entries, isEmpty); 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( test(
'imports weights and per-day active energy, then re-syncs without ' 'imports weights and per-day active energy, then re-syncs without '
'duplicates', () async { 'duplicates', () async {