ci(release): fixed google callback authentication
This commit is contained in:
parent
a40589cc89
commit
0a77b13032
|
|
@ -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.
|
- [ ] 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.
|
- [ ] 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).
|
- [ ] 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.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import 'package:crypto/crypto.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.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:http/http.dart' as http;
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
|
@ -54,18 +55,30 @@ class GoogleHealthTokens {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Google Health cloud connector: OAuth2 (authorization-code + PKCE) through
|
/// Google Health cloud connector: OAuth2 through Google Identity Services on
|
||||||
/// the system browser with a loopback redirect
|
/// Android (native account chooser + consent, `serverAuthCode` exchange) and
|
||||||
/// (`http://localhost:<port>/callback`), secure-storage-backed tokens with
|
/// authorization-code + PKCE through the system browser with a loopback
|
||||||
/// transparent refresh, and normalized weight / active-energy reads from the
|
/// redirect (`http://localhost:<port>/callback`) on desktop/iOS, backed by
|
||||||
/// Google Health API — the official successor to the retired Fitbit Web API
|
/// secure-storage tokens with transparent refresh, and normalized weight /
|
||||||
/// (shutdown 2026-10-30); Fitbit, Pixel and Wear OS data flows into Google
|
/// active-energy reads from the Google Health API — the official successor to
|
||||||
/// Health.
|
/// 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
|
/// Requires `GOOGLE_HEALTH_CLIENT_ID` (a **Web application** OAuth client)
|
||||||
/// `.env` (optionally `GOOGLE_HEALTH_REDIRECT_PORT`, default 52789). Create
|
/// and `GOOGLE_HEALTH_CLIENT_SECRET` in `.env` (optionally
|
||||||
/// them in a Google Cloud project with the Google Health API enabled; the
|
/// `GOOGLE_HEALTH_REDIRECT_PORT`, default 52789). Create them in a Google
|
||||||
/// loopback redirect must be an Authorized redirect URI on the OAuth client.
|
/// 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 {
|
class GoogleHealthConnector implements ICloudHealthConnector {
|
||||||
GoogleHealthConnector({http.Client? client, FlutterSecureStorage? storage})
|
GoogleHealthConnector({http.Client? client, FlutterSecureStorage? storage})
|
||||||
: _client = client ?? http.Client(),
|
: _client = client ?? http.Client(),
|
||||||
|
|
@ -138,6 +151,14 @@ class GoogleHealthConnector implements ICloudHealthConnector {
|
||||||
return false;
|
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 verifier = _randomUrlSafe(64);
|
||||||
final authorizeUrl = Uri.parse(
|
final authorizeUrl = Uri.parse(
|
||||||
'$_authorizeEndpoint?response_type=code'
|
'$_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
|
@override
|
||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
await _storage.delete(key: tokenStorageKey);
|
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
|
@override
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,12 @@ class IntegrationsController extends Notifier<IntegrationsState> {
|
||||||
isRequestingPermissions: false,
|
isRequestingPermissions: false,
|
||||||
errorMessage: granted
|
errorMessage: granted
|
||||||
? null
|
? 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 '
|
: 'Health permissions were denied. Enable them in your device '
|
||||||
'settings to sync biometrics.',
|
'settings to sync biometrics.',
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import app_links
|
||||||
import device_info_plus
|
import device_info_plus
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
import flutter_secure_storage_macos
|
import flutter_secure_storage_macos
|
||||||
|
import google_sign_in_ios
|
||||||
import mobile_scanner
|
import mobile_scanner
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
|
|
@ -18,6 +19,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||||
|
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
||||||
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
|
|
|
||||||
48
pubspec.lock
48
pubspec.lock
|
|
@ -477,6 +477,54 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "14.8.1"
|
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:
|
gotrue:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ dependencies:
|
||||||
sqlite3: ^3.4.0
|
sqlite3: ^3.4.0
|
||||||
# Scheduling support for flutter_local_notifications (zonedSchedule).
|
# Scheduling support for flutter_local_notifications (zonedSchedule).
|
||||||
timezone: ^0.9.4
|
timezone: ^0.9.4
|
||||||
|
google_sign_in: 6.3.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue