As Flutter developers, we usually think about persistence in terms of app restarts. Save a value, close the app, open it again, and read it back.
But what happens when the app is completely uninstalled?
Imagine this flow: a user signs in, removes the app, and installs it again on the same phone. Can the app recover a previously saved credential?
Android's Block Store API gives us a way to explore that behavior. In this guide, we'll connect it to Flutter through a Kotlin MethodChannel, restore a simulated session at startup, and handle the part that's easy to overlook: logout.
The example uses GetIt for dependency injection, Riverpod for state management, and GoRouter for routing, so the native integration fits into an existing Flutter feature rather than taking over the app.
Why Block Store Matters
The Problem with App-Local Session State
An in-memory isLoggedIn flag disappears when the process ends. App-local files and preferences also aren't a reliable way to demonstrate persistence through a complete uninstall. Android backup can introduce its own restore behavior, which makes a casual reinstall test easy to misinterpret.
For this experiment, we want a clear storage boundary: Flutter asks Android to save a small opaque value, and Android asks Google Play services to manage it.
The Block Store Approach
Block Store stores credential bytes through Google Play services. The official guide documents same-device uninstall/reinstall recovery when Backup services are enabled. Access is checked against the app's package name and signature. Official Block Store guide
Our demo uses one versioned key and a 32-byte payload. The signed-in state exists only in memory; it is reconstructed from the retrieved bytes after launch. We never persist an isLoggedIn flag as a substitute for recovery.
Here is the behavior we're building:
App launch
|
v
Loading screen -> Retrieve demo credential
|
+---------+----------+
| |
Valid payload Missing / invalid / error
| |
v v
Demo signed-in screen Demo sign-in screen
^ |
| Simulate login
| |
+---- Save 32 random bytes
Demo logout -> Clear in-memory session -> Delete saved demo key
|
Failure? Show retry
“Valid payload” means the expected demo format. Later, we'll look at where real server validation would belong.
Before You Start: Device Requirements
Use an Android device with working, up-to-date Google Play services. Enable Settings → Google → Backup before testing; the menu name may vary by manufacturer. A successful save does not confirm that this setting is enabled.
Keep the same package name and signing certificate across installations. Reuse the same signed binary where possible. Switching between differently signed local and Play-distributed builds can invalidate the comparison. Same-device testing and package/signature requirements
This implementation sets cloud backup to false. We are testing same-phone recovery, not cloud restore or migration to another device. The builder exposes cloud backup as a separate option. StoreBytesData.Builder reference
The official guide also suggests considering passkeys with Credential Manager for newer sign-in experiences. Choose an authentication approach based on your product's needs; this article focuses on the requested persistence experiment. Block Store guidance
How the Feature Fits into the Project
The existing authentication screens remain separate. Their username/password fields and HTTP client never touch this demo.
lib/
feature/block_store_demo/
domain/demo_credential_store.dart
data/block_store_service.dart
presentation/demo_session_provider.dart
presentation/demo_screen.dart
config/
di/dependency_injection.dart
appRoutes/app_route.dart
android/app/src/main/kotlin/com/example/blockstoredemo/
BlockStoreBridge.kt
MainActivity.kt
The Kotlin package and Dart application name below are neutral examples: com.example.blockstoredemo and demo_app. Adapt those names to your project. Keep the channel name identical on both sides. The feature structure and behavior follow the working implementation.
We'll build it in four layers:
| Layer | Responsibility |
|---|---|
| Kotlin bridge | Call Google Play services and return bytes or sanitized errors |
| Dart service | Expose save, retrieve, and delete through a typed interface |
| Riverpod Notifier | Own restoration, simulated login, logout, and operation ordering |
| Demo screen | Render loading, signed-in, signed-out, and error states |
Step 1: Add the Android Dependency
In android/app/build.gradle.kts, add:
dependencies {
implementation("com.google.android.gms:play-services-auth-blockstore:16.4.0")
}
Ensure Google's Maven repository is available in the project's Gradle repository configuration:
repositories {
google()
mavenCentral()
}
Version 16.4.0 is the dependency used in this implementation and listed by the guide when it was prepared. A native dependency change requires a full app rebuild; hot reload won't register the bridge. Official setup instructions
The project already includes flutter_riverpod, get_it, and go_router. This integration does not add another Dart package for Block Store.
Step 2: Build the Kotlin MethodChannel Bridge
File: android/app/src/main/kotlin/com/example/blockstoredemo/BlockStoreBridge.kt
The bridge handles three method names: save, retrieve, and delete.
There are a few deliberate choices in this class:
- Save only a 32-byte demo payload under one dedicated key.
- Retrieve only that key rather than every saved entry.
- Delete only that key, so future features can use Block Store independently.
- Return generic errors rather than exception messages or credential contents.
- Record pending logout before contacting Play services.
Here is the complete bridge:
package com.example.blockstoredemo
import android.content.Context
import com.google.android.gms.auth.blockstore.Blockstore
import com.google.android.gms.auth.blockstore.DeleteBytesRequest
import com.google.android.gms.auth.blockstore.RetrieveBytesRequest
import com.google.android.gms.auth.blockstore.StoreBytesData
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
/** Only the isolated demo key is accessed. No credential is ever logged. */
class BlockStoreBridge(private val context: Context, messenger: BinaryMessenger) {
private val channel = MethodChannel(messenger, "demo_app/block_store_demo")
private val client by lazy { Blockstore.getClient(context) }
private val key = "${context.packageName}.block_store_demo.v1"
// Survives process death, excluded from Auto Backup; never stores a token.
private val pendingLogout = File(context.noBackupFilesDir, "block_store_demo_logout")
init {
channel.setMethodCallHandler(::handle)
}
fun dispose() = channel.setMethodCallHandler(null)
private fun handle(call: MethodCall, result: MethodChannel.Result) {
if (call.method !in listOf("save", "retrieve", "delete")) {
result.notImplemented()
return
}
try {
// Record logout intent even if Play services is currently unavailable.
if (call.method == "delete") pendingLogout.writeText("pending")
if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context)
!= ConnectionResult.SUCCESS) {
result.error("unavailable", "Google Play services unavailable", null)
return
}
when (call.method) {
"save" -> {
val bytes = call.arguments as? ByteArray
if (bytes == null || bytes.size != 32) {
result.error("invalid_argument", "Invalid demo payload", null)
return
}
if (pendingLogout.exists()) {
result.error("deletion_pending", "Remove previous demo data first", null)
return
}
val request = StoreBytesData.Builder()
.setKey(key)
.setBytes(bytes)
.setShouldBackupToCloud(false)
.build()
client.storeBytes(request)
.addOnSuccessListener { count ->
if (count == bytes.size) result.success(null)
else result.error("storage_error", "Incomplete demo save", null)
}
.addOnFailureListener { fail(result) }
}
"retrieve" -> {
if (pendingLogout.exists()) {
delete(result)
} else {
val request = RetrieveBytesRequest.Builder().setKeys(listOf(key)).build()
client.retrieveBytes(request)
.addOnSuccessListener { response ->
result.success(response.blockstoreDataMap[key]?.bytes)
}
.addOnFailureListener { fail(result) }
}
}
"delete" -> delete(result)
}
} catch (_: Exception) {
fail(result)
}
}
private fun delete(result: MethodChannel.Result) {
val request = DeleteBytesRequest.Builder().setKeys(listOf(key)).build()
client.deleteBytes(request)
.addOnSuccessListener {
// false also means success: there was no entry to delete.
if (pendingLogout.exists() && !pendingLogout.delete()) {
result.error("storage_error", "Could not finish demo logout", null)
} else {
result.success(null)
}
}
.addOnFailureListener { fail(result) }
}
private fun fail(result: MethodChannel.Result) {
result.error("storage_error", "Block Store operation failed", null)
}
}
Saving and Retrieving the Payload
StoreBytesData.Builder.setKey() associates the bytes with our demo key. Notice the singular setKey when saving. The retrieval and deletion requests take a list through setKeys. Builder API reference
retrieveBytes(request) returns a map. A missing key becomes null in Dart. We use the request-based API rather than the deprecated no-argument overload. BlockstoreClient reference
Why Track Pending Logout?
Consider this sequence:
- The demo has saved a credential.
- The user taps logout.
- Play services becomes unavailable, so deletion fails.
- The app process ends and starts again.
Clearing Riverpod state alone would forget that logout was requested. The native marker lets the next retrieval attempt retry deletion instead of returning the old credential.
The marker contains only the word pending, never the credential. It lives in noBackupFilesDir, which Android excludes from automatic backup. Android Context reference
This marker handles process restarts, not uninstall persistence. If deletion fails and the app is uninstalled, the marker is removed with the app. The UI must tell the user to retry deletion before uninstalling.
Also notice that deleteBytes() returning false is not an error: it means there was nothing to delete. That is a valid logged-out state. Deletion semantics
Register the Bridge
File: android/app/src/main/kotlin/com/example/blockstoredemo/MainActivity.kt
package com.example.blockstoredemo
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
private var blockStoreBridge: BlockStoreBridge? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
blockStoreBridge = BlockStoreBridge(applicationContext, flutterEngine.dartExecutor.binaryMessenger)
}
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
blockStoreBridge?.dispose()
blockStoreBridge = null
super.cleanUpFlutterEngine(flutterEngine)
}
}
Using the application context avoids making the storage client depend on an activity's lifetime. Cleanup removes the channel handler when the Flutter engine detaches.
Step 3: Expose a Small Dart Storage Interface
File: lib/feature/block_store_demo/domain/demo_credential_store.dart
The presentation layer should not need to know about MethodChannel or Android task listeners. It only needs three storage operations and a validation boundary.
import 'dart:typed_data';
abstract interface class DemoCredentialStore {
Future<void> save(Uint8List credential);
Future<Uint8List?> retrieve();
Future<void> delete();
}
class DemoStorageException implements Exception {
const DemoStorageException(this.code);
final String code;
}
/// Future server validation belongs behind this boundary. The demo only checks
/// its payload format; this does not establish an authenticated identity.
abstract interface class DemoCredentialValidator {
Future<bool> validate(Uint8List credential);
}
class LocalDemoCredentialValidator implements DemoCredentialValidator {
@override
Future<bool> validate(Uint8List credential) async => credential.length == 32;
}
The validator intentionally does very little: it checks that the recovered value has the demo's expected length. Anyone who can produce 32 bytes can satisfy this check. It provides no proof of identity, ownership, expiration, or server access.
Keeping that limitation behind an explicit interface gives a future backend integration somewhere to validate recovered credentials before granting access.
Connect the Interface to Android
File: lib/feature/block_store_demo/data/block_store_service.dart
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../domain/demo_credential_store.dart';
class BlockStoreService implements DemoCredentialStore {
static const channel = MethodChannel('demo_app/block_store_demo');
Future<T?> _invoke<T>(String method, [Object? arguments]) async {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
throw const DemoStorageException('unsupported');
}
try {
return await channel.invokeMethod<T>(method, arguments);
} on MissingPluginException {
throw const DemoStorageException('unavailable');
} on PlatformException catch (error) {
// Never propagate native messages or payloads to UI/logs.
throw DemoStorageException(error.code);
}
}
@override
Future<void> save(Uint8List credential) async {
await _invoke<void>('save', credential);
}
@override
Future<Uint8List?> retrieve() => _invoke<Uint8List>('retrieve');
@override
Future<void> delete() async {
await _invoke<void>('delete');
}
}
The Dart Uint8List corresponds to the Kotlin ByteArray passed through the channel. The service rejects unsupported platforms before making a native call and maps missing-plugin errors to a controlled unavailable state.
Only error codes cross the domain boundary. We do not display or log arbitrary native messages.
Register the Dependencies with GetIt
File: lib/config/di/dependency_injection.dart
Add these imports and registrations to the existing dependency setup. This is an excerpt, not a replacement for the project's other registrations:
import 'package:demo_app/feature/block_store_demo/data/block_store_service.dart';
import 'package:demo_app/feature/block_store_demo/domain/demo_credential_store.dart';
// Inside the existing setup function; sl is the project's GetIt instance.
sl.registerLazySingleton<DemoCredentialStore>(() => BlockStoreService());
sl.registerLazySingleton<DemoCredentialValidator>(
() => LocalDemoCredentialValidator(),
);
Run that setup before runApp, and keep the app inside its existing ProviderScope. There is no need to initialize an HTTP client specifically for this feature.
Step 4: Manage the Demo Session with Riverpod
File: lib/feature/block_store_demo/presentation/demo_session_provider.dart
Saving and retrieving bytes is the easy part. Coordinating asynchronous state changes takes more care.
Our Notifier handles three problems:
Problem 1: The Sign-In Screen Flashes Before Recovery
If startup begins as “signed out,” the user can see a login screen while retrieval is running. Instead, the initial state is initializing: true. The UI shows a loader until the first retrieval finishes.
Problem 2: Rebuilds Trigger Repeated Retrieval
The _restoration future is cached for the lifetime of this provider instance. Multiple calls to restore() share the same attempt. An app relaunch creates a new provider instance and a new attempt.
Problem 3: Logout Races with Restore or Save
Suppose retrieval starts, logout runs, and then the original retrieval finishes. Without a guard, that old result could sign the demo in again.
The _generation counter invalidates older restore/login results when logout begins. A serialized queue also ensures that deletion runs after any save already in flight. Ignoring an old save's UI result is not enough; the saved bytes still need to be deleted.
Here is the complete state and Notifier:
import 'dart:async';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:demo_app/config/di/dependency_injection.dart';
import '../domain/demo_credential_store.dart';
final demoSessionProvider = NotifierProvider<DemoSession, DemoSessionState>(
DemoSession.new,
);
class DemoSessionState {
const DemoSessionState({
this.initializing = false,
this.busy = false,
this.signedIn = false,
this.restored = false,
this.message,
this.deletionFailed = false,
});
final bool initializing, busy, signedIn, restored, deletionFailed;
final String? message;
}
class DemoSession extends Notifier<DemoSessionState> {
late DemoCredentialStore _store;
late DemoCredentialValidator _validator;
Future<void>? _restoration;
Future<void> _queue = Future.value();
int _generation = 0;
@override
DemoSessionState build() {
_store = sl<DemoCredentialStore>();
_validator = sl<DemoCredentialValidator>();
// The initial frame is always a loading screen, never the login screen.
scheduleMicrotask(restore);
return const DemoSessionState(initializing: true);
}
Future<void> _serialize(Future<void> Function() action) {
final next = _queue.then((_) => action());
_queue = next.catchError((Object _) {});
return next;
}
Future<void> restore() => _restoration ??= _restore();
Future<void> _restore() {
final generation = _generation;
return _serialize(() async {
try {
final credential = await _store.retrieve();
final valid =
credential != null && await _validator.validate(credential);
if (!ref.mounted || generation != _generation) return;
state = DemoSessionState(
signedIn: valid,
restored: valid,
message: credential != null && !valid
? 'The saved demo data is invalid. Start a new demo session.'
: null,
);
} catch (error) {
if (!ref.mounted || generation != _generation) return;
state = DemoSessionState(message: _message(error));
}
});
}
Future<void> login() async {
if (state.initializing ||
state.busy ||
state.signedIn ||
state.deletionFailed) {
return;
}
final generation = ++_generation;
state = const DemoSessionState(busy: true);
await _serialize(() async {
try {
final random = Random.secure();
final credential = Uint8List.fromList(
List.generate(32, (_) => random.nextInt(256)),
);
await _store.save(credential);
if (!ref.mounted || generation != _generation) return;
state = const DemoSessionState(signedIn: true);
} catch (error) {
if (!ref.mounted || generation != _generation) return;
state = DemoSessionState(message: _message(error));
}
});
}
Future<void> logout() async {
// Invalidate pending restore/login before queuing deletion after any save.
++_generation;
state = const DemoSessionState(busy: true);
await _serialize(() async {
try {
await _store.delete();
if (!ref.mounted) return;
state = const DemoSessionState(
message: 'Demo logged out. Saved data removed.',
);
} catch (_) {
if (!ref.mounted) return;
state = const DemoSessionState(
deletionFailed: true,
message:
'Demo session cleared, but saved data could not be removed. '
'Retry removal before uninstalling; reinstall may restore it.',
);
}
});
}
String _message(Object error) {
if (error is DemoStorageException &&
{'unsupported', 'unavailable'}.contains(error.code)) {
return 'Block Store is unavailable. Use a supported Android device with up-to-date Google Play services.';
}
return 'Could not access saved demo data. Check Google Play services and try again.';
}
}
Simulated Login: Generate, Save, Then Update State
Random.secure() generates 32 random bytes after the user taps “Simulate login.” The UI becomes signed in only after save() completes successfully. There are no username or password inputs in this flow.
If storage fails, the demo stays signed out and displays a retryable error. A busy-state guard prevents duplicate login requests.
Startup Recovery: Retrieve, Check, Then Render
A recovered payload passes through DemoCredentialValidator before setting signedIn. Missing data is a normal signed-out result. Invalid data produces an explanatory message; it is never accepted as a session.
The implementation performs one retrieval attempt per provider instance. After a startup failure, relaunch to retry retrieval. “Simulate login” retries saving a new payload; it does not re-run the initial retrieval.
Logout: Clear State Even If Deletion Fails
Logout clears the simulated signed-in state immediately. If deletion fails, deletionFailed blocks a new simulated login and the screen offers “Retry saved data removal.”
That distinction matters: local sign-out and confirmed persistent deletion are separate outcomes. The interface should never report that saved data was removed when the native call failed.
Step 5: Choose the Startup Screen Without Flicker
File: lib/config/appRoutes/app_route.dart
The relevant routing configuration is:
// Inside the existing AppRoute class.
static const String auth = '/auth';
static const String demo = '/block-store-demo';
static final GoRouter router = GoRouter(
// Keep the existing navigator key and other router options.
initialLocation: !kIsWeb && defaultTargetPlatform == TargetPlatform.android
? demo
: auth,
routes: [
GoRoute(path: demo, builder: (context, state) => const DemoScreen()),
GoRoute(path: auth, builder: (context, state) => const AuthScreen()),
],
);
This excerpt uses flutter/foundation.dart, go_router, and imports for the two screens. Keep other existing routes when merging it into another app.
The router chooses the Android demo route first. Within that route, DemoScreen acts as the startup gate: it renders loading, signed-out, or signed-in content from the provider. We do not navigate to the existing auth form and then immediately redirect away from it.
Other platforms retain the existing auth startup. On Android, the demo also provides a link to the original sign-in screen, and that screen has a link back to the demo. The two session states remain independent.
Build the Demo Screen
File: lib/feature/block_store_demo/presentation/demo_screen.dart
This screen keeps the explanation visible while avoiding native API details in every interaction:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'demo_session_provider.dart';
class DemoScreen extends ConsumerWidget {
const DemoScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final session = ref.watch(demoSessionProvider);
final actions = ref.read(demoSessionProvider.notifier);
return Scaffold(
appBar: AppBar(title: const Text('Block Store demo')),
body: SafeArea(
child: session.initializing
? const Center(child: CircularProgressIndicator())
: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
session.signedIn
? 'Demo signed in'
: 'Try demo sign-in',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 16),
const Text(
'Frontend-only persistence demonstration. '
'No account is authenticated and no password is stored.',
),
const SizedBox(height: 16),
if (session.signedIn)
Text(
session.restored
? 'Saved demo credential recovered.'
: 'Demo credential saved. You can now uninstall and reinstall to test recovery.',
)
else
const Text(
'Enable Backup services in Settings > Google > Backup '
'before testing on this phone. Recovery depends on device settings.',
),
if (session.message != null) ...[
const SizedBox(height: 16),
Semantics(
liveRegion: true,
child: Text(session.message!),
),
],
const SizedBox(height: 24),
if (session.busy)
const Center(child: CircularProgressIndicator())
else if (session.signedIn || session.deletionFailed)
FilledButton(
onPressed: actions.logout,
child: Text(
session.deletionFailed
? 'Retry saved data removal'
: 'Log out of demo',
),
)
else ...[
FilledButton(
onPressed: actions.login,
child: const Text('Simulate login'),
),
TextButton(
onPressed: actions.logout,
child: const Text('Remove saved demo data'),
),
],
TextButton(
onPressed: session.busy
? null
: () => context.go('/auth'),
child: const Text('Open existing sign-in'),
),
],
),
),
),
),
),
);
}
}
The recovered screen says “Saved demo credential recovered.” It does not display the credential itself or imply that a server has authenticated anyone.
Error Handling: What the User Should See
| Situation | Demo behavior |
|---|---|
| No credential on first launch | Show simulated sign-in after loading |
| Expected demo payload recovered | Show the demo signed-in screen |
| Invalid payload length | Stay signed out and explain that saved demo data is invalid |
| Missing or unavailable Play services | Stay signed out and show an unavailable message |
| Native API or save error | Stay signed out and show a storage error |
| Logout deletion error | Clear local session, warn about saved data, and offer removal retry |
| Non-Android platform | Keep existing auth startup; service calls reject unsupported use |
Availability checks do not guarantee that every subsequent operation succeeds. The task failure listeners and Dart error mapping still need to handle API and storage failures.
One remaining limitation is that this demo does not add an application-level timeout around native tasks. Its error states depend on the native task completing with a result or failure; do not claim a bounded startup time if services stop responding. Adding a timeout later also requires handling late save completion, so a timed-out write cannot silently outlive logout.
Testing the Actual Uninstall/Reinstall Flow
A hot restart is not an uninstall test. Updating an existing install is not an uninstall test either.
Use the same phone and the same signed binary for the following scenarios. Confirm the Backup setting before starting.
Test 1: First Installation with No Saved Credential
Install on a device with no previous demo entry and launch the app.
Expected result: loading, then “Try demo sign-in.”
For a repeatable clean baseline, tap “Remove saved demo data,” wait for the removal confirmation, then uninstall and reinstall. Simply uninstalling an earlier demo install is not a reliable way to create an empty Block Store baseline.
Test 2: Simulated Login → Uninstall → Reinstall
- Tap “Simulate login.”
- Wait for “Demo signed in” and the saved-credential message.
- Completely uninstall the app through Android Settings or ADB.
- Reinstall the same signed binary on the same phone.
- Launch the app.
Expected result: loading, then “Demo signed in” and “Saved demo credential recovered.”
For example, using the neutral application ID from this article:
adb uninstall com.example.blockstoredemo
adb install /path/to/the-same-signed-app.apk
Use your real application ID and an existing APK path. Do not use adb uninstall -k, which retains app data. Avoid switching debug keystores or signing identities between the two installs.
Test 3: Logout → Uninstall → Reinstall
- Sign in to the demo.
- Tap “Log out of demo.”
- Wait for “Demo logged out. Saved data removed.”
- Completely uninstall and reinstall.
- Launch again.
Expected result: “Try demo sign-in,” with no recovered session.
If removal fails, restore service availability and retry before uninstalling. A warning about failed deletion is not a successful logout-persistence test.
Test 4: Unavailable or Failing Block Store
Use a disposable test environment without Google Play services, or disable the service only where the test device permits it.
Check that startup shows a controlled unavailable state, a failed save does not sign in, and the app does not crash.
For logout failure testing, save with services available first, then make them unavailable and log out. Expect local sign-out and a removal warning. Relaunch to exercise the pending-logout path, then restore services and retry deletion.
Real storage failures can be difficult to reproduce on a phone. Automated fakes cover the state transitions, but they do not prove how a particular device handles Block Store.
Common Pitfalls and Solutions
Pitfall 1: Assuming Saved Bytes Mean an Authenticated User
Problem: Treating any recovered value as proof that a real account is authenticated.
Solution: Keep the demo visibly labeled. In a real application, validate the recovered server-issued credential before granting access. Our length check only identifies the expected demo payload shape.
Pitfall 2: Using a Different Signing Certificate
Problem: A reinstall unexpectedly finds no credential after changing build distribution or keystores.
Solution: Keep package identity and signing stable across the test. Record which binary was installed rather than comparing unrelated builds.
Pitfall 3: Clearing Only Riverpod State on Logout
Problem: The next launch retrieves the old credential and reconstructs the demo session.
Solution: Delete the dedicated Block Store key and distinguish deletion success from local sign-out. Keep pending logout across process restarts and expose retry when deletion fails.
Pitfall 4: Ignoring a Late Save
Problem: Logout clears the screen, but an earlier save completes afterward and leaves a credential behind.
Solution: Invalidate stale results and order operations so deletion follows pending saves. Both protections are needed in this implementation.
Pitfall 5: Confusing Backup Settings with the Cloud Backup Flag
Problem: Assuming setShouldBackupToCloud(false) means device Backup services are irrelevant to the same-device test.
Solution: Follow the same-device prerequisites. The storage request's cloud flag and the phone's Backup setting are different controls. This demo deliberately leaves cloud backup disabled.
Pitfall 6: Expecting Recovery Under Every Configuration
Problem: Treating one successful phone test as a universal guarantee.
Solution: Treat missing credentials as a supported outcome and keep a normal sign-in path available. Device policies, settings, service availability, and data clearing can affect recovery; the client reference explicitly notes that certain user actions can clear stored data. BlockstoreClient retrieval behavior
Block Store is Android-specific. This bridge does not add equivalent iOS or web persistence. Cloud and device-to-device restoration require their own setup and verification. Enabling cloud backup also calls for evaluating encryption availability; the client exposes isEndToEndEncryptionAvailable() for that purpose. Encryption API reference
What Would Production Authentication Need?
The frontend integration supplies a place to store and recover bytes. A production authentication system still needs an authority that decides what those bytes mean.
A future implementation would need to:
- Issue credentials after real authentication. A server must establish the account/session relationship; locally generating random bytes cannot do that.
- Validate recovery before granting access. Replace the demo validator with a real validation or token-exchange flow. Do not set authenticated state solely because storage returned data.
- Enforce expiration. The server must reject expired credentials and provide an appropriate reauthentication path.
- Support revocation and rotation. Lost devices, compromised tokens, and revoked sessions must stop working even if a stored value remains recoverable.
- Coordinate logout. Delete the local recoverable credential and invalidate the relevant server session according to the product's logout policy. A device-only deletion is not server revocation.
- Handle validation failures and outages. Invalid credentials must not grant access; network failures need an explicit retry or sign-in policy.
DemoCredentialValidator is the integration boundary for that future work, not a simulated backend. The current article adds no endpoints, fake HTTP requests, or claims of secure authentication.
Key Takeaways
The useful part of this experiment is seeing the entire lifecycle work together: save a demo credential, recover it before choosing the screen, and remove it when the user logs out.
The Kotlin bridge stays small, Flutter owns presentation and state, and the existing authentication flow remains independent. The queue and pending-logout marker address the cases where a simple “save on login, read on startup” example can go wrong.
Try the complete uninstall/reinstall sequence on your own supported test device, then test logout with the same care. Record your settings and results so the behavior you describe is the behavior you actually observed.
Top comments (0)