Compare commits

..

No commits in common. "main" and "v0.1.1" have entirely different histories.
main ... v0.1.1

6 changed files with 14 additions and 71 deletions

View File

@ -226,21 +226,10 @@ 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)
- [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`.
- [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.
- [ ] 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.
- [ ] 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.

View File

@ -4,12 +4,11 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<!-- Health Connect (Android 14+ / Play services): read permissions matching
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. -->
the HealthGateway request set (weight, steps, total calories burned,
active energy burned, blood glucose). Health Connect federates
Samsung Health and Pixel sensor feeds through this runtime. -->
<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_ACTIVE_CALORIES_BURNED"/>
<uses-permission android:name="android.permission.health.READ_BLOOD_GLUCOSE"/>
@ -47,12 +46,6 @@
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="com.companionglp.glpcompanion"/>
</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>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@ -1,11 +1,5 @@
package com.companionglp.glpcompanion
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.android.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()
class MainActivity : FlutterActivity()

View File

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

View File

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

View File

@ -86,7 +86,6 @@ class _FakeHealthSyncRepository implements IHealthSyncRepository {
class _FakeHealthGateway extends IHealthGateway {
bool nativeSupported = true;
bool granted = true;
bool cloudReady = false;
List<HealthMetric> weights = [];
List<HealthMetric> energies = [];
int permissionRequests = 0;
@ -105,9 +104,6 @@ class _FakeHealthGateway extends IHealthGateway {
return granted;
}
@override
Future<bool> isCloudSyncReady() async => cloudReady;
@override
Future<List<HealthMetric>> fetchWeightHistory({
required DateTime start,
@ -290,32 +286,6 @@ 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 {