zero_tap_easy 0.1.1
zero_tap_easy: ^0.1.1 copied to clipboard
Zero-tap sign-in restoration for Android. Silently recreate a signed-in session on a user's new device using Credential Manager Restore Credentials.
zero_tap_easy #
Zero-tap sign-in restoration for Android. Silently recreate a signed-in session on a user's new device using Credential Manager's Restore Credentials.
Your user buys a new phone, restores their apps, opens yours — and is already signed in. No password, no OAuth round trip, no tap.
Google Play requires this from April 2027. Apps with sign-in that don't support Zero-Tap Sign-In restoration will lose full publishing capability and Play Store visibility. Play technical quality requirements →
Features #
- 4 methods, 1 import —
isSupported,createRestoreKey,getRestoreKey,clearRestoreKey. - Automatic E2EE fallback — retries with local-only key when the device has no backup or screen lock.
- Typed exceptions with stable error codes — no string-matching on platform errors.
- Platform-safe —
isSupported()returnsfalseon iOS, web, and desktop; never throws. - Testable — swap the platform interface for a fake with zero device dependency.
- No manifest changes — no
allowBackupedits, no Gradle changes, noMainActivitysubclassing.
Getting started #
Prerequisites #
| Requirement | Minimum |
|---|---|
| Android | 9 (API 28) |
| Google Play services | 24220000 |
| Flutter | 3.3.0 |
| Dart SDK | 3.5.0 |
The plugin's minSdk is 21. Restore keys are gated at runtime, so a low
minSdk still compiles and runs — isSupported() simply returns false on
older devices.
You need a server #
Restore keys are WebAuthn credentials. This package handles the device half. Your backend must issue and verify WebAuthn options, exactly as a passkey relying party does. If you already run a passkey server, reuse it.
Firebase Auth alone is not enough. Firebase has no WebAuthn relying party.
You need your own endpoints; for Firebase, mint a custom token server-side and
call signInWithCustomToken.
Installation #
Add zero_tap_easy to your pubspec.yaml:
dependencies:
zero_tap_easy: ^0.1.0
Or install via the command line:
$ flutter pub add zero_tap_easy
That is the whole setup. No manifest edits, no Gradle changes.
Usage #
Quick start #
import 'package:zero_tap_easy/zero_tap_easy.dart';
// ── After the user signs in (once per account per device) ────────────
if (await ZeroTapEasy.isSupported()) {
final options = await api.getRegistrationOptions();
final created = await ZeroTapEasy.createRestoreKey(options);
await api.verifyRegistration(created.responseJson);
}
// ── On app launch, when nobody is signed in ──────────────────────────
if (await ZeroTapEasy.isSupported()) {
final options = await api.getAuthenticationOptions();
final assertion = await ZeroTapEasy.getRestoreKey(options);
if (assertion != null) {
await api.verifyAssertion(assertion); // returns a session
}
}
// ── On sign-out ──────────────────────────────────────────────────────
await ZeroTapEasy.clearRestoreKey();
Full integration example #
Below is a production-style sign-in flow showing where each method fits.
import 'package:zero_tap_easy/zero_tap_easy.dart';
class AuthService {
final ApiClient api;
final LocalStorage storage;
AuthService(this.api, this.storage);
/// Call on app launch before showing the sign-in screen.
Future<String?> tryRestoreSession() async {
if (!await ZeroTapEasy.isSupported()) return null;
if (storage.hasSession) return null; // already signed in
try {
final options = await api.getAuthenticationOptions();
final assertion = await ZeroTapEasy.getRestoreKey(options);
if (assertion == null) return null; // no key on this device
// Server verifies the assertion and returns a session token.
final session = await api.verifyRestoreAssertion(assertion);
await storage.saveSession(session);
return session;
} on ZeroTapException catch (e) {
// A restore key is a convenience — never block launch.
print('Restore failed (${e.code}): ${e.message}');
return null;
}
}
/// Call after a successful sign-in or registration.
Future<void> registerRestoreKey() async {
if (!await ZeroTapEasy.isSupported()) return;
if (storage.hasRestoreKey) return; // already registered on this device
try {
final options = await api.getRegistrationOptions();
final created = await ZeroTapEasy.createRestoreKey(options);
await api.verifyRestoreRegistration(created.responseJson);
if (!created.usedCloudBackup) {
// Key is local-only. It survives device-to-device transfer
// but NOT a cloud restore (how most people migrate).
print('Warning: restore key is local-only');
}
storage.hasRestoreKey = true;
} on ZeroTapException catch (e) {
print('Restore key creation failed (${e.code}): ${e.message}');
}
}
/// Call on sign-out, account deletion, or server-side session revocation.
Future<void> signOut() async {
await ZeroTapEasy.clearRestoreKey();
await api.revokeSession();
storage.clearAll();
}
}
Handling the cloud-backup fallback #
When the device cannot support cloud backup (no Google backup or no screen
lock), Android raises E2eeUnavailableException. By default,
createRestoreKey retries once with cloud backup off. You can opt out:
try {
final created = await ZeroTapEasy.createRestoreKey(
options,
retryWithoutCloudBackup: false, // don't auto-retry
);
} on ZeroTapE2eeUnavailableException {
// Device can't do cloud backup — decide what to do yourself.
showDialog('Enable a screen lock and Google backup for best results.');
}
API reference #
ZeroTapEasy.isSupported() #
static Future<bool> isSupported()
Returns true only on Android 9+ with Play services ≥ 24220000. Returns
false — never throws — on iOS, web, desktop, older Android, and devices
without Play services. Guard the other three methods with this.
ZeroTapEasy.createRestoreKey() #
static Future<RestoreKeyCreation> createRestoreKey(
String requestJson, {
bool isCloudBackupEnabled = true,
bool retryWithoutCloudBackup = true,
})
Creates a restore key. requestJson is a WebAuthn
PublicKeyCredentialCreationOptionsJSON from your server.
Returns a RestoreKeyCreation with:
responseJson— the WebAuthn registration response to POST to your server.usedCloudBackup— whether the key was backed up to the cloud.
Call once per account per device, not on every login. Track with a flag in your own storage.
ZeroTapEasy.getRestoreKey() #
static Future<String?> getRestoreKey(String requestJson)
Returns the WebAuthn assertion JSON, or null when this device holds no restore
key. requestJson is a PublicKeyCredentialRequestOptionsJSON from your server
with an empty allowCredentials (discoverable).
Call on first launch before showing your sign-in screen. Give it a timeout and treat every failure as "show the sign-in screen."
ZeroTapEasy.clearRestoreKey() #
static Future<void> clearRestoreKey()
Deletes the restore key. Call on sign-out, account deletion, and server-side session revocation (HTTP 401 after a password reset or remote logout).
Credential Manager will not do this for you. Without this call, a signed-out user is silently signed back in on their next device. Safe to call when no key exists.
Error handling #
Every exception thrown is a ZeroTapException with a stable code:
| Exception | Code | When |
|---|---|---|
ZeroTapUnsupportedException |
UNSUPPORTED |
Not Android, below API 28, or Play services missing/old |
ZeroTapE2eeUnavailableException |
E2EE_UNAVAILABLE |
No backup or screen lock; only surfaces if retry is disabled |
ZeroTapRequestJsonException |
INVALID_REQUEST_JSON |
Server payload is malformed — a bug, not a device condition |
ZeroTapCancelledException |
CANCELLED |
User or system cancelled the operation |
ZeroTapException |
FAILED |
Anything else from Credential Manager |
Recommended pattern — one catch that logs and falls through:
try {
final assertion = await ZeroTapEasy.getRestoreKey(options);
if (assertion != null) {
await api.verifyAssertion(assertion);
}
} on ZeroTapException catch (e) {
analytics.log('restore_failed', {'code': e.code, 'message': e.message});
// Fall through to the normal sign-in screen.
}
Server-side contract #
Your backend needs four WebAuthn endpoints:
| Endpoint | Authenticated? | Returns |
|---|---|---|
| Registration options | Yes — user just signed in | PublicKeyCredentialCreationOptionsJSON with a valid user.id |
| Registration verify | Yes | 200; stores the public key against the user |
| Authentication options | No | PublicKeyCredentialRequestOptionsJSON, discoverable (empty allowCredentials) |
| Authentication verify | No | A session for the identified user |
The two unauthenticated endpoints are the part people get wrong. At restore time your app has no session — that is the entire point.
Verify origin and androidPackageName #
Restore keys bind to your package name and signing certificate, not to a
domain. The clientDataJSON looks like:
{
"type": "webauthn.create",
"challenge": "…",
"origin": "android:apk-key-hash:<base64url SHA-256 of your signing cert>",
"androidPackageName": "com.example.yourapp"
}
Your server must check both fields. Pin origin to the apk-key-hash of
your release signing certificate (plus debug in non-production), and
androidPackageName to your applicationId.
Server best practices #
Google's guidance for the server side:
- Distinguish restore credentials from passkeys — never list them in passkey management UI.
- Expect orphaned keys — uninstall deletes the key locally with no callback. Delete old keys when a new one registers.
- Long TTL — a user may sign out mid-migration; the server key must survive.
- Multiple devices per user — one active key per device, not per user.
- Clear on server-side invalidation — password reset, remote logout.
Testing #
Unit testing (no device needed) #
Swap the platform implementation to test your sign-in logic without a device:
import 'package:flutter_test/flutter_test.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:zero_tap_easy/zero_tap_easy.dart';
class FakeZeroTap extends ZeroTapEasyPlatform
with MockPlatformInterfaceMixin {
String? assertionToReturn;
@override
Future<bool> isSupported() async => true;
@override
Future<String> createRestoreKey(
String requestJson, {
required bool isCloudBackupEnabled,
}) async => '{"id":"abc","type":"public-key"}';
@override
Future<String?> getRestoreKey(String requestJson) async =>
assertionToReturn;
@override
Future<void> clearRestoreKey() async {}
}
void main() {
late FakeZeroTap fake;
setUp(() {
fake = FakeZeroTap();
ZeroTapEasyPlatform.instance = fake;
});
test('restore sign-in when a key is present', () async {
fake.assertionToReturn = '{"sig":"x"}';
final assertion = await ZeroTapEasy.getRestoreKey('{}');
expect(assertion, isNotNull);
});
test('falls through when no key is present', () async {
fake.assertionToReturn = null;
final assertion = await ZeroTapEasy.getRestoreKey('{}');
expect(assertion, isNull);
});
}
Integration testing (on device or emulator) #
The example/ app includes on-device integration tests:
$ cd example
$ flutter test integration_test -d emulator-5554
This verifies the channel round trip, the API/GMS gate, and the null-not-throw contract on a real device.
End-to-end: the full migration round trip #
This is the only test that proves zero-tap actually works.
- Launch the emulator from Android Studio (2025.2.1+).
- Run the example app and create a restore key.
- Running Devices toolbar → Backup App Data.
- Uninstall and reinstall the app.
- Toolbar → Restore App Data → select the backup.
- Reopen the app —
getRestoreKeyshould return a non-null assertion.
See TESTING.md for detailed instructions and known gotchas.
Comparison #
| Package | Restore Credentials |
|---|---|
credential_manager |
No — one-tap, passwords, passkeys, federated only |
passkeys |
Yes, since 2.23.0, as part of a passkey-first API |
zero_tap_easy |
Purpose-built for this one job, with no passkey surface |
If you already use passkeys for real passkeys, use its restore support. If
you just need to satisfy the Play requirement with minimal surface area, use
this.
Limitations #
- Android only. Every method no-ops or throws elsewhere, by design.
- Foreground restoration only (tier 2). Background restoration via
BackupAgent.onRestoreFinished()is planned, not shipped. - One account per app. Restore Credentials does not support multiple simultaneous accounts.
- Tied to your package name. A different
applicationIdis a different restore key. - Mobile and tablet only. Does not cross form factors.
- First-setup profile only on multi-profile devices.
Additional information #
- About Restore Credentials
- Implement Restore Credentials
- Test Restore Credentials
- Play Console technical quality requirements
- Passkeys server guides
Contributing #
Contributions are welcome! Please file issues and pull requests on the GitHub repository.
License #
MIT — see LICENSE for details.