As an indie developer building an offline-first task and calendar app (ROCIs Tasks) using Flutter with native Android Kotlin widgets, nothing gives you sudden micro-heart attacks quite like user bug reports about authentication loops.
Recently, I hit a nasty regression: users were getting hammered with multiple Google sign-in prompts. Tap the Google Tasks sync toggle? Prompt. Tap 'Create Task'? Another prompt. It felt less like a productivity app and more like an aggressive security guard.
Here is the breakdown of why this happened and how I fixed it.
The Mystery: Death by a Thousand Prompts
Users trying to sync their tasks were seeing back-to-back sign-in overlays. Even worse, generic email/password users were occasionally getting swept into the credential flow. That is a textbook way to tank your app's retention on Google Play.
Root Cause Analysis
Digging into the codebase revealed three distinct culprits conspiring against my users:
-
Decoupled Auth in Google Sign-In v7: Authentication (ID token) and Authorization (Access token/scopes) are now separate steps. My code was calling
attemptLightweightAuthentication()insidegetGoogleAccessToken()whenever the cache missed. On Android, this doesn't always fail silently—it can trigger a Credential Manager overlay chooser. -
The Amnesiac In-Memory Session: The authenticated
GoogleSignInAccountobject was never stored in memory. Every single token request started completely from scratch. -
Redundant Scopes: My mobile build was requesting the Google Calendar scope (
https://www.googleapis.com/auth/calendar). But on mobile, ROCIs Tasks usesdevice_calendarfor native OS calendar integration. Asking for the web calendar scope via Google Sign-In triggered redundant, scary permission consent prompts.
The Solution & Architecture
To clean this up, I refactored the AuthService with a strict caching and platform-aware scope strategy.
First, I introduced an in-memory session cache and hooked into the authentication event stream on startup:
class AuthService {
GoogleSignInAccount? _googleUser;
Future<void> initialize() async {
_googleSignIn.authenticationEvents.listen((event) {
if (event is GoogleSignInAccountEvent) {
_googleUser = event.user;
}
});
await _restoreGoogleSignInSession();
}
// Smart startup restoration - only for users who actually linked Google Tasks
Future<void> _restoreGoogleSignInSession() async {
if (_userHasLinkedGoogleTasks) {
_googleUser = await _googleSignIn.attemptLightweightAuthentication();
}
}
}
Next, I split the requested scopes by platform. Web needs the Calendar API; mobile handles it natively via the OS device calendar:
List<String> getScopesForPlatform() {
if (kIsWeb) {
return [
'email',
'profile',
'https://www.googleapis.com/auth/tasks',
'https://www.googleapis.com/auth/calendar',
];
} else {
// Mobile uses device_calendar for native OS integration
return [
'email',
'profile',
'https://www.googleapis.com/auth/tasks',
];
}
}
Finally, I unified SharedPreferences token caching across platforms so that access tokens survive app restarts without forcing unnecessary network calls.
Key Lessons for Other Devs
- Never assume silent auth is silent: Modern Android credential managers can surface UI when you least expect it. Cache your user state locally.
- Audit your OAuth scopes: Don't request web-only API scopes on mobile if your app handles things natively via platform channels.
- Respect your users' intent: Generic email/password accounts should never touch Google OAuth logic on startup.
Wrapping Up
Building ROCIs Tasks as a solo indie dev means obsessing over details like smooth auth flows so the app feels as native and polished as possible. If you want to check out an offline-first task manager with native Kotlin widgets built in Flutter, take a look at ROCIs Tasks on Google Play or visit tasks.rocisapps.com. Happy coding!
Top comments (0)