Compare commits
1 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
86a7cd9852 |
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue