ci(release): fixed google callback authentication

This commit is contained in:
Mike Kell 2026-10-18 10:29:06 -04:00
parent a40589cc89
commit 0a77b13032
6 changed files with 170 additions and 13 deletions

View File

@ -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 <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

@ -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:<port>/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:<port>/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<bool> _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<void> 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

View File

@ -203,6 +203,12 @@ class IntegrationsController extends Notifier<IntegrationsState> {
isRequestingPermissions: false,
errorMessage: granted
? null
: 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.',
);

View File

@ -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"))

View File

@ -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:

View File

@ -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: