diff --git a/BUILD_STATE.md b/BUILD_STATE.md index 851504a..096cadb 100644 --- a/BUILD_STATE.md +++ b/BUILD_STATE.md @@ -218,3 +218,18 @@ Release engineering for alpha (pre-release) builds across Android and Windows, d - [ ] Configure Forgejo Actions secrets on `mtkell/glp-companion`: `ANDROID_KEYSTORE_BASE64` (base64 of `upload-keystore.jks`) and `ANDROID_KEY_PROPERTIES` (keyAlias / keyPassword / storeFile=`upload-keystore.jks` / storePassword). Without them the Android job's release build fails at signing. - [ ] Push a `v*` tag (e.g. `git tag v0.1.0 && git push origin v0.1.0`) and confirm all three workflow jobs go green and the prerelease at `https://git.kellsupport.com/mtkell/glp-companion/releases` carries the APK, MSIX, and portable zip assets. - [ ] Install-gate: sideload the arm64 APK on an Android device and install the MSIX on Windows; launch both and confirm the encrypted drift DB opens (the minify/shrink build is exactly why the SQLCipher keep-rules exist). + +## Post-Alpha Fixes (2026-10-18) + +- [x] Android Google Health connect fix (field-reported alpha: authorization completed in the browser, but on return the app "only shows the localhost call" — Connect stayed highlighted and the row never flipped to Connected). Root cause: `GoogleHealthConnector.authorize()` ran the RFC 8252 **loopback redirect** on Android too — it bound `HttpServer` on the device loopback and needed the *backgrounded* app to serve Chrome's `http://localhost:52789/callback` after consent, which Android's cached-app freezer prevents; the callback never arrived, so `authorize()` hung (button `busy`) until the 5-minute timeout. Google deprecates loopback for mobile client types, and custom-scheme redirects are rejected on the existing Web-type client ("Custom scheme URIs are not allowed for 'WEB' client type"), so the fix follows Google's recommended Android path (Google Identity Services): + - Connector (`google_health_connector_io.dart`): on `Platform.isAndroid` only, `authorize()` now runs **`package:google_sign_in`** (native account chooser + consent sheet — the app stays foreground, no HTTP callback) with `serverClientId = GOOGLE_HEALTH_CLIENT_ID` and `forceCodeForRefreshToken: true` (the `prompt=consent` equivalent), then exchanges the returned one-time `serverAuthCode` on the shared token endpoint (GIS codes carry no `redirect_uri`) — secure-storage tokens, transparent refresh, REST reads, and the parser are untouched. A 5-minute timeout mirrors the loopback path; a missing code logs the Android-client/SHA-1 checklist. `disconnect()` additionally signs the SDK out (best-effort) so Connect re-prompts for an account. Desktop/iOS keep the proven loopback flow. + - 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`). + +## 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. +- [ ] 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/lib/core/platform/cloud_health/google_health_connector_io.dart b/lib/core/platform/cloud_health/google_health_connector_io.dart index 43bdc79..f35e27a 100644 --- a/lib/core/platform/cloud_health/google_health_connector_io.dart +++ b/lib/core/platform/cloud_health/google_health_connector_io.dart @@ -7,6 +7,7 @@ import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:google_sign_in/google_sign_in.dart'; import 'package:http/http.dart' as http; import 'package:url_launcher/url_launcher.dart'; @@ -54,18 +55,30 @@ class GoogleHealthTokens { } } -/// Google Health cloud connector: OAuth2 (authorization-code + PKCE) through -/// the system browser with a loopback redirect -/// (`http://localhost:/callback`), secure-storage-backed tokens with -/// transparent refresh, and normalized weight / active-energy reads from the -/// Google Health API — the official successor to the retired Fitbit Web API -/// (shutdown 2026-10-30); Fitbit, Pixel and Wear OS data flows into Google -/// Health. +/// Google Health cloud connector: OAuth2 through Google Identity Services on +/// Android (native account chooser + consent, `serverAuthCode` exchange) and +/// authorization-code + PKCE through the system browser with a loopback +/// redirect (`http://localhost:/callback`) on desktop/iOS, backed by +/// secure-storage tokens with transparent refresh, and normalized weight / +/// active-energy reads from the Google Health API — the official successor to +/// the retired Fitbit Web API (shutdown 2026-10-30); Fitbit, Pixel and Wear +/// OS data flows into Google Health. /// -/// Requires `GOOGLE_HEALTH_CLIENT_ID` and `GOOGLE_HEALTH_CLIENT_SECRET` in -/// `.env` (optionally `GOOGLE_HEALTH_REDIRECT_PORT`, default 52789). Create -/// them in a Google Cloud project with the Google Health API enabled; the -/// loopback redirect must be an Authorized redirect URI on the OAuth client. +/// Requires `GOOGLE_HEALTH_CLIENT_ID` (a **Web application** OAuth client) +/// and `GOOGLE_HEALTH_CLIENT_SECRET` in `.env` (optionally +/// `GOOGLE_HEALTH_REDIRECT_PORT`, default 52789). Create them in a Google +/// Cloud project with the Google Health API enabled; the loopback redirect +/// must be an Authorized redirect URI on the OAuth client. Android also +/// needs an **Android** OAuth client (package `com.companionglp.glpcompanion` +/// + the signing certificate's SHA-1) in the same project, because the SDK +/// resolves the caller by package/SHA-1 before issuing a server auth code. +/// +/// Android deliberately avoids the loopback redirect: the callback requires +/// the *backgrounded* app to serve HTTP to the browser, and Android's +/// cached-app freezer prevents that — the browser only ever showed the +/// `localhost` URL and the Connect button hung until the 5-minute timeout. +/// Google likewise deprecates loopback for mobile client types and +/// recommends this SDK path instead. class GoogleHealthConnector implements ICloudHealthConnector { GoogleHealthConnector({http.Client? client, FlutterSecureStorage? storage}) : _client = client ?? http.Client(), @@ -138,6 +151,14 @@ class GoogleHealthConnector implements ICloudHealthConnector { return false; } + // Android runs the Google Identity Services SDK path instead of the + // loopback redirect: that flow needs the browser to reach an HTTP server + // held by this (backgrounded) app, which Android's process freezer + // prevents — the callback never arrived and authorize() hung. + if (Platform.isAndroid) { + return _authorizeViaGoogleSignIn(); + } + final verifier = _randomUrlSafe(64); final authorizeUrl = Uri.parse( '$_authorizeEndpoint?response_type=code' @@ -179,9 +200,73 @@ class GoogleHealthConnector implements ICloudHealthConnector { } } + /// Android authorization through the Google Identity Services SDK + /// (`package:google_sign_in`): the consent sheet renders natively inside a + /// foreground activity, so the round trip no longer depends on serving a + /// loopback HTTP callback from the backgrounded app. + /// + /// The SDK hands back a one-time [GoogleSignInAccount.serverAuthCode] + /// issued against the same Web client (`serverClientId`), which is + /// exchanged on the shared token endpoint — yielding the identical + /// access/refresh token pair the desktop loopback flow produces, so token + /// storage, transparent refresh, and the REST layer stay unchanged. + /// [forceCodeForRefreshToken] mirrors `prompt=consent` by guaranteeing a + /// refresh token on every re-consent. + Future _authorizeViaGoogleSignIn() async { + try { + final signIn = GoogleSignIn( + scopes: _scopes.split(' '), + serverClientId: _clientId, + forceCodeForRefreshToken: true, + ); + final account = await signIn.signIn().timeout(_callbackTimeout); + if (account == null) { + debugPrint('GoogleHealthConnector: Google sign-in was cancelled.'); + return false; + } + final code = account.serverAuthCode; + if (code == null || code.isEmpty) { + debugPrint( + 'GoogleHealthConnector: Google sign-in returned no authorization ' + 'code - verify the Android OAuth client (package ' + 'com.companionglp.glpcompanion + signing-cert SHA-1) exists in the ' + 'same Google Cloud project as GOOGLE_HEALTH_CLIENT_ID.', + ); + return false; + } + // Google-issued SDK codes are exchanged without a redirect_uri (per + // the GIS server-auth-code flow); everything else matches the shared + // [_requestTokens] path used by desktop. + final tokens = await _requestTokens({ + 'grant_type': 'authorization_code', + 'code': code, + }); + await _writeTokens(tokens); + return true; + } catch (error, stackTrace) { + debugPrintStack( + stackTrace: stackTrace, + label: 'GoogleHealthConnector: Android sign-in failed ($error)', + ); + return false; + } + } + @override Future disconnect() async { await _storage.delete(key: tokenStorageKey); + if (Platform.isAndroid) { + // Best-effort: drop the SDK session too, so the next Connect re-prompts + // for an account instead of silently reusing the previous grant. + try { + await GoogleSignIn( + scopes: _scopes.split(' '), + serverClientId: _clientId, + ).signOut(); + } catch (error) { + debugPrint('GoogleHealthConnector: SDK sign-out failed ($error)'); + } + } } @override diff --git a/lib/features/settings/presentation/controllers/integrations_controller.dart b/lib/features/settings/presentation/controllers/integrations_controller.dart index 8789ff0..f4da935 100644 --- a/lib/features/settings/presentation/controllers/integrations_controller.dart +++ b/lib/features/settings/presentation/controllers/integrations_controller.dart @@ -203,8 +203,14 @@ class IntegrationsController extends Notifier { isRequestingPermissions: false, errorMessage: granted ? null - : 'Health permissions were denied. Enable them in your device ' - 'settings to sync biometrics.', + : provider == HealthProviderType.googleHealth + // OAuth round trip (SDK on Android, browser loopback on + // desktop): nothing to enable in device settings — the flow + // was cancelled or the redirect never completed. + ? 'Google Health sign-in was cancelled or did not complete. ' + 'Tap Connect to try again.' + : 'Health permissions were denied. Enable them in your device ' + 'settings to sync biometrics.', ); } catch (error, stackTrace) { debugPrintStack( diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 1a891dd..0d58fbb 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import app_links import device_info_plus import flutter_local_notifications import flutter_secure_storage_macos +import google_sign_in_ios import mobile_scanner import shared_preferences_foundation import url_launcher_macos @@ -18,6 +19,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 9e12a95..4fabf99 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -477,6 +477,54 @@ packages: url: "https://pub.dev" source: hosted version: "14.8.1" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: d0a2c3bcb06e607bb11e4daca48bd4b6120f0bbc4015ccebbe757d24ea60ed2a + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: d5e23c56a4b84b6427552f1cf3f98f716db3b1d1a647f16b96dbb5b93afa2805 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: "102005f498ce18442e7158f6791033bbc15ad2dcc0afa4cf4752e2722a516c96" + url: "https://pub.dev" + source: hosted + version: "5.9.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "5f6f79cf139c197261adb6ac024577518ae48fdff8e53205c5373b5f6430a8aa" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: "460547beb4962b7623ac0fb8122d6b8268c951cf0b646dd150d60498430e4ded" + url: "https://pub.dev" + source: hosted + version: "0.12.4+4" gotrue: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index bd12245..cc364c6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,7 @@ dependencies: sqlite3: ^3.4.0 # Scheduling support for flutter_local_notifications (zonedSchedule). timezone: ^0.9.4 + google_sign_in: 6.3.0 dev_dependencies: flutter_test: