Skip to content

Commit 54eb8c4

Browse files
committed
Add GoogleSignIn bootstrap singleton
Introduce GoogleSignInBootstrap to centralize and guard the one-time GoogleSignIn.instance.initialize() call across the app. AuthService now delegates initialization to the bootstrap and exposes ensureGoogleInitialized() (no-op on desktop) so callers can warm up the GIS SDK. WebSession uses a Future guard (_googleInitInFlight) and the bootstrap to avoid double subscriptions and to allow retries on transient failures. The website generator screens warm the SDK when the user picks the Sheets contact channel to avoid popup timing issues on web. Added unit tests for the bootstrap and a dev dependency on google_sign_in_platform_interface to support testing.
1 parent 97bccc2 commit 54eb8c4

8 files changed

Lines changed: 354 additions & 16 deletions

File tree

lib/core/services/auth_service.dart

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import 'package:fula_files/core/services/secure_storage_service.dart';
1414
import 'package:fula_files/core/services/share_link_builder.dart';
1515
import 'package:fula_files/core/services/shelf_service.dart';
1616
import 'package:fula_files/core/services/fula_api_service.dart';
17+
import 'package:fula_files/core/services/google_signin_bootstrap.dart';
1718
import 'package:fula_files/core/services/sync_service.dart';
1819
import 'package:fula_files/core/services/bucket_cache_service.dart';
1920
import 'package:fula_files/core/services/cloud_sync_mapping_service.dart';
@@ -85,7 +86,6 @@ class AuthService {
8586
static final AuthService instance = AuthService._();
8687

8788
final _googleSignIn = GoogleSignIn.instance;
88-
bool _googleInitialized = false;
8989

9090
AuthUser? _currentUser;
9191
Uint8List? _encryptionKey;
@@ -148,9 +148,23 @@ class AuthService {
148148
}
149149
}
150150

151-
Future<void> _ensureGoogleInitialized() async {
152-
if (_googleInitialized) return;
151+
/// Warm up Google Sign-In before a flow that will need it.
152+
///
153+
/// Worth calling from a user gesture that *precedes* the one needing Google
154+
/// (e.g. choosing the Google Forms contact channel, several taps before
155+
/// Publish): on web the scope request opens a popup, and browsers only allow
156+
/// that within a few seconds of a real click — a first-time load of the GIS
157+
/// SDK inside the Publish handler can eat that budget. No-op on desktop,
158+
/// where Google Sign-In is unavailable.
159+
Future<void> ensureGoogleInitialized() async {
160+
if (PlatformCapabilities.isDesktop) return;
161+
await _ensureGoogleInitialized();
162+
}
153163

164+
/// Delegates to [GoogleSignInBootstrap], which owns the process's single
165+
/// `initialize()` call — the web shell's sign-in screen initializes the very
166+
/// same singleton, and a second `initialize()` throws there.
167+
Future<void> _ensureGoogleInitialized() async {
154168
String? clientId;
155169
String? serverClientId;
156170

@@ -173,11 +187,10 @@ class AuthService {
173187
_googleServerClientId.isNotEmpty ? _googleServerClientId : null;
174188
}
175189

176-
await _googleSignIn.initialize(
190+
await GoogleSignInBootstrap.ensureInitialized(
177191
clientId: clientId,
178192
serverClientId: serverClientId,
179193
);
180-
_googleInitialized = true;
181194
}
182195

183196
/// Check if Google Sign-In is properly configured
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import 'dart:async';
2+
3+
import 'package:flutter/foundation.dart';
4+
import 'package:google_sign_in/google_sign_in.dart';
5+
6+
/// Owns the one and only `GoogleSignIn.instance.initialize()` call in the
7+
/// process.
8+
///
9+
/// `GoogleSignIn.instance` is a process-wide singleton, and google_sign_in v7
10+
/// documents that `initialize()` must be called *exactly once* — but the Dart
11+
/// wrapper does not enforce it. The enforcement lives in the web
12+
/// implementation, whose `init()` throws
13+
/// `StateError('init() has already been called ...')` on the second call. The
14+
/// Android/iOS implementations swallow a repeat init silently, so a second
15+
/// `initialize()` is a latent bug on every platform and a hard failure only on
16+
/// web.
17+
///
18+
/// The app has two natural doors into Google auth — the web shell's sign-in
19+
/// screen ([WebSession.initGoogleWeb]) and the lazy scope request in
20+
/// [AuthService] — and each used to keep its own private `bool` guard over the
21+
/// shared singleton. A new web user opened both doors in one page session:
22+
/// signing in with Google initialized it once, then publishing a website whose
23+
/// contact form is backed by a Google Form asked for the Drive scope, which
24+
/// initialized it again and threw. Returning users never hit it, because a
25+
/// session restored from storage skips the sign-in screen entirely and leaves
26+
/// the scope request as the *first* initialize.
27+
///
28+
/// Routing both doors through this class turns "initialize exactly once" from a
29+
/// convention that two files have to agree on into an invariant that holds by
30+
/// construction.
31+
class GoogleSignInBootstrap {
32+
GoogleSignInBootstrap._();
33+
34+
static bool _done = false;
35+
static Future<void>? _inFlight;
36+
static String? _usedClientId;
37+
static String? _usedServerClientId;
38+
39+
/// Whether `GoogleSignIn.instance` has been initialized.
40+
static bool get isInitialized => _done;
41+
42+
/// Initializes `GoogleSignIn.instance` unless that has already happened.
43+
///
44+
/// Safe to call from anywhere, any number of times, including concurrently:
45+
/// the first caller does the work and every other caller awaits that same
46+
/// future. First call wins for [clientId]/[serverClientId] — the plugin has
47+
/// no reconfigure API, so a later, different value could not be applied even
48+
/// if we wanted to.
49+
static Future<void> ensureInitialized({
50+
String? clientId,
51+
String? serverClientId,
52+
}) {
53+
if (_done) {
54+
assert(() {
55+
if (_usedClientId != clientId ||
56+
_usedServerClientId != serverClientId) {
57+
debugPrint(
58+
'GoogleSignInBootstrap: ignoring a repeat initialize() with '
59+
'different parameters (clientId=$clientId, '
60+
'serverClientId=$serverClientId); the singleton is already '
61+
'configured with clientId=$_usedClientId, '
62+
'serverClientId=$_usedServerClientId. Callers on one platform '
63+
'are expected to agree on these.',
64+
);
65+
}
66+
return true;
67+
}());
68+
return Future<void>.value();
69+
}
70+
return _inFlight ??= _initialize(clientId, serverClientId);
71+
}
72+
73+
static Future<void> _initialize(
74+
String? clientId,
75+
String? serverClientId,
76+
) async {
77+
try {
78+
await GoogleSignIn.instance.initialize(
79+
clientId: clientId,
80+
serverClientId: serverClientId,
81+
);
82+
} on StateError catch (e) {
83+
// The double-init guard is the only StateError `initialize()` raises, so
84+
// arriving here means the singleton is already configured — by a code
85+
// path this class does not know about, or by the previous run of a hot
86+
// restart, which resets Dart state but not the GIS SDK already loaded
87+
// into the page. Either way it is initialized, which is all the caller
88+
// asked for. Note this is deliberately matched by TYPE, not by message:
89+
// the wording of that error changes between plugin versions.
90+
debugPrint('GoogleSignInBootstrap: initialize() reports an existing '
91+
'initialization; continuing. ($e)');
92+
} catch (e) {
93+
// Something transient — e.g. the GIS script failed to load. Drop the
94+
// cached future so the next caller retries instead of inheriting the
95+
// failure forever.
96+
_inFlight = null;
97+
rethrow;
98+
}
99+
_done = true;
100+
_usedClientId = clientId;
101+
_usedServerClientId = serverClientId;
102+
}
103+
104+
/// Test-only: forget that initialization happened.
105+
@visibleForTesting
106+
static void resetForTest() {
107+
_done = false;
108+
_inFlight = null;
109+
_usedClientId = null;
110+
_usedServerClientId = null;
111+
}
112+
}

lib/features/websites/screens/generate_website_screen.dart

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:async';
2+
13
import 'package:flutter/material.dart';
24
import 'package:flutter/services.dart';
35
import 'package:lucide_icons/lucide_icons.dart';
@@ -358,6 +360,16 @@ class _GenerateWebsiteScreenState extends State<GenerateWebsiteScreen> {
358360
setState(() => _pricing = pricing);
359361
}
360362

363+
/// Load the Google Sign-In SDK as soon as the user picks the channel that
364+
/// will need it, rather than inside the Publish handler. Failures are
365+
/// ignored: this is a head start, and Publish still initializes on demand.
366+
void _warmGoogleIfSheets() {
367+
if (_channel != ContactFormChannel.sheets) return;
368+
unawaited(AuthService.instance.ensureGoogleInitialized().catchError(
369+
(Object e) => debugPrint('Google Sign-In warm-up failed: $e'),
370+
));
371+
}
372+
361373
Future<void> _submit() async {
362374
final name = _nameController.text.trim();
363375
if (name.isEmpty) return;
@@ -908,7 +920,10 @@ class _GenerateWebsiteScreenState extends State<GenerateWebsiteScreen> {
908920
),
909921
],
910922
selected: {_channel},
911-
onSelectionChanged: (s) => setState(() => _channel = s.first),
923+
onSelectionChanged: (s) {
924+
setState(() => _channel = s.first);
925+
_warmGoogleIfSheets();
926+
},
912927
),
913928
if (_channel != ContactFormChannel.sheets) ...[
914929
const SizedBox(height: 12),

lib/web/screens/web_generate_website_screen.dart

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:async';
2+
13
import 'package:flutter/material.dart';
24
import 'package:flutter/services.dart';
35
import 'package:lucide_icons/lucide_icons.dart';
@@ -286,6 +288,18 @@ class _WebGenerateWebsiteScreenState extends State<WebGenerateWebsiteScreen> {
286288
return null;
287289
}
288290

291+
/// Load the Google Sign-In SDK as soon as the user picks the channel that
292+
/// will need it. On web this matters beyond speed: `authorizeScopes` opens a
293+
/// popup, and a browser only permits that while the click that led to it is
294+
/// still "recent" (a few seconds) — long enough for a cached SDK, not always
295+
/// for a cold one. Failures are ignored; Publish still initializes on demand.
296+
void _warmGoogleIfSheets() {
297+
if (_channel != ContactFormChannel.sheets) return;
298+
unawaited(AuthService.instance.ensureGoogleInitialized().catchError(
299+
(Object e) => debugPrint('Google Sign-In warm-up failed: $e'),
300+
));
301+
}
302+
289303
Future<void> _submit() async {
290304
final name = _nameController.text.trim();
291305
if (name.isEmpty) return;
@@ -801,7 +815,10 @@ class _WebGenerateWebsiteScreenState extends State<WebGenerateWebsiteScreen> {
801815
],
802816
selected: {_channel},
803817
showSelectedIcon: false,
804-
onSelectionChanged: (s) => setState(() => _channel = s.first),
818+
onSelectionChanged: (s) {
819+
setState(() => _channel = s.first);
820+
_warmGoogleIfSheets();
821+
},
805822
),
806823
if (_channel != ContactFormChannel.sheets) ...[
807824
const SizedBox(height: 10),

lib/web/services/web_session.dart

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import 'package:sign_in_with_apple/sign_in_with_apple.dart';
88
import 'package:fula_files/core/services/auth_core.dart';
99
import 'package:fula_files/core/services/bucket_cache_service.dart';
1010
import 'package:fula_files/core/services/fula_api_service.dart';
11+
import 'package:fula_files/core/services/google_signin_bootstrap.dart';
1112
import 'package:fula_files/core/services/master_health_service.dart';
1213
import 'package:fula_files/core/services/secure_storage_service.dart';
1314
import 'package:fula_files/core/utils/bip39_local.dart';
@@ -169,7 +170,7 @@ class WebSession extends ChangeNotifier {
169170
// provider to Mode B (OAuth + seed); empty means legacy Mode A.
170171
// ==========================================================================
171172

172-
bool _googleInited = false;
173+
Future<void>? _googleInitInFlight;
173174

174175
/// Passphrase the user typed before clicking the Google button (the
175176
/// GIS button is the only sign-in trigger on web, so the choice has
@@ -178,19 +179,37 @@ class WebSession extends ChangeNotifier {
178179

179180
/// Initialize google_sign_in for web and start consuming credential
180181
/// events. Idempotent; call from the sign-in screen.
181-
Future<void> initGoogleWeb() async {
182-
if (_googleInited) return;
183-
await GoogleSignIn.instance.initialize(
184-
clientId: AuthCore.googleWebClientId,
185-
);
182+
///
183+
/// The guard is a Future rather than a bool because the sign-in screen
184+
/// calls this from `initState` without awaiting it: two screens mounted
185+
/// in quick succession would both pass a bool guard that is only set
186+
/// after the await, and subscribe the credential handler twice.
187+
Future<void> initGoogleWeb() => _googleInitInFlight ??= _initGoogleWeb();
188+
189+
Future<void> _initGoogleWeb() async {
190+
try {
191+
// Shared with AuthService — `GoogleSignIn.instance` is a process-wide
192+
// singleton whose initialize() may run only once, and AuthService
193+
// initializes it too when it asks for the Drive scope.
194+
await GoogleSignInBootstrap.ensureInitialized(
195+
clientId: AuthCore.googleWebClientId,
196+
);
197+
} catch (e) {
198+
// Leave the door open for a retry — e.g. the GIS script failed to load
199+
// on a flaky connection and the user reopens the sign-in sheet. Callers
200+
// are fire-and-forget, so this must not escape as an unhandled error.
201+
_googleInitInFlight = null;
202+
debugPrint('WebSession: Google Sign-In init failed: $e');
203+
return;
204+
}
186205
// Lifetime subscription — WebSession is a singleton that lives as
187-
// long as the page, so the stream is never cancelled.
206+
// long as the page, so the stream is never cancelled. It sits inside
207+
// the guarded future above so it is registered exactly once.
188208
GoogleSignIn.instance.authenticationEvents.listen((event) {
189209
if (event is GoogleSignInAuthenticationEventSignIn) {
190210
unawaited(_onGoogleCredential(event.user));
191211
}
192212
});
193-
_googleInited = true;
194213
}
195214

196215
Future<void> _onGoogleCredential(GoogleSignInAccount account) async {

pubspec.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -779,7 +779,7 @@ packages:
779779
source: hosted
780780
version: "6.3.0"
781781
google_sign_in_platform_interface:
782-
dependency: transitive
782+
dependency: "direct dev"
783783
description:
784784
name: google_sign_in_platform_interface
785785
sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5"

pubspec.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,9 @@ dev_dependencies:
166166
integration_test:
167167
sdk: flutter
168168
mocktail: ^1.0.4
169+
# Lets google_signin_bootstrap_test swap in a fake GoogleSignInPlatform to
170+
# reproduce the web plugin's "init() called twice" StateError.
171+
google_sign_in_platform_interface: ^3.1.0
169172
flutter_lints: ^6.0.0
170173
flutter_launcher_icons: ^0.14.2
171174
flutter_native_splash: ^2.4.4

0 commit comments

Comments
 (0)