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`). - 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)
- [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`. - [ ] 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**. - [ ] 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.
- [ ] 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,12 +4,11 @@
<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, total calories burned, active the HealthGateway request set (weight, steps, total calories burned,
energy burned, blood glucose). STEPS was removed from the set: it is active energy burned, blood glucose). Health Connect federates
never fetched, and the package:health README ties step access to the Samsung Health and Pixel sensor feeds through this runtime. -->
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"/>
@ -47,12 +46,6 @@
<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,11 +1,5 @@
package com.companionglp.glpcompanion package com.companionglp.glpcompanion
import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterActivity
// FlutterFragmentActivity (not the default FlutterActivity) is required by class MainActivity : FlutterActivity()
// 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,6 +81,7 @@ 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 native permission is denied /// no cloud connector provisioned), when permission is denied, or when
/// *and* no cloud partner holds a valid grant, or when the sync throws; /// the sync throws; the reason is reflected in
/// the reason is reflected in [BiometricState.statusMessage]. /// [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,14 +245,10 @@ 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. A native denial is // manage their own OAuth grant at connect time.
// 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 && !await gateway.isCloudSyncReady()) { if (!granted) {
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,7 +86,6 @@ 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;
@ -105,9 +104,6 @@ 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,
@ -290,32 +286,6 @@ 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 {