The Atsign FoundationThe Atsign Foundation

pub package pub points build status gitHub license

at_client_flutter

The Flutter layer on top of at_client. Adds pre-built onboarding / authentication dialogs, device-keychain storage for atKeys, and Flutter-specific extensions — so a new Flutter app can go from "user has an atSign" to "AtClient in hand" with a few widget calls. One import covers an app: package:at_client_flutter/at_client_flutter.dart re-exports at_client, and nothing here asks an app to import at_auth.

Supports mobile, desktop, and IoT targets via Flutter. Flutter web is not supported — atSign onboarding and key handling rely on platform plugins (key-chain, file storage) that don't have web implementations today.

What's in the box

Capability API
Select atSign + root domain AtSignSelectionDialog.show(context) → AtsignSelection
Onboard a new atSign (CRAM) RegistrarCramDialog.show(...) then CramDialog.show(...) → AtClient
Authenticate via .atKeys file AtKeysFileDialog.show(...) then PkamDialog.show(...) → AtClient
Authenticate via device keychain PkamDialog.show(..., keys: KeychainAtKeysIo()) → AtClient
Enroll a new device via APKAM ApkamActivationDialog.show(...) → AtClient (request side) / EnrollmentRequestList (approve side)
Manage enrollments client.enrollments — list, approve, deny, revoke, passcodes (from at_client)
Keychain read / write / delete KeychainStorage (lib/src/keychain/keychain_storage.dart)
Flutter helpers on core types import 'package:at_client_flutter/extensions.dart';

Examples

The authoritative, end-to-end walkthroughs live in this package's example app. Read these rather than copying snippets from here:

  • example/lib/walkthrough.dart — all four authentication / onboarding flows (CRAM onboarding, atKeys-file login, keychain login, APKAM enrollment), each ending with the AtClient in hand. If you only read one file, read this one.
  • example/lib/apkam_example.dart — the approve/deny side of APKAM (e.g. a "manager" device approving a new phone's enrollment request), with a simulated requester built on client.enrollments.otp() and Atsign.enroll.
  • example/lib/main.dart — minimal host app wiring the two flows above into navigation.

Smaller copy/paste snippets live under example/lib/snippets:

  • example/lib/snippets/at_invitation.dart — replacement for the deprecated at_invitation_flutter package. It keeps the SMS/email invite flow as app-owned code instead of a separate Flutter package.

For a full Flutter app using at_client_flutter in anger, see the two flagship examples — deliberately positioned side-by-side to make a fundamental SDK trade-off visible:

todos — the idiomatic AtCollection<T> Flutter app

example/todos/ is the first place to look when building a real Flutter application on the Atsign Protocol that needs a typed shared dataset. It drives every common collection-shaped pattern through the mobile / desktop widget stack: typed AtCollection<T> with fromJson / typeTag, sub-collections (notes per todo), the Query<T> builder with reactive watch() / watchWithSub / watchSingle, sharedWith updates, built-in read receipts, scheduled visibility via availableAt. Wire-compatible with the CLI sibling so the same data flows live between TUI and Flutter instances.

Full design, source tour, and multi-device demo in example/todos/README.md.

dockerstats — live container telemetry

example/dockerstats/ is the canonical worked example of an SDK pattern the API doesn't impose: deliver via short-lived notifications, store in a relational database. The publisher (a Dart CLI) emits one docker stats sample per container per cycle as a single notificationService.send(...) — no AtCollection, no keystore writes, no sync queue. The Flutter dashboard subscribes, persists every sample as-received to a per-atSign SQLite database (no roll-up, no compaction at rest), and renders charts off that local store with a user-selectable window (5 m → all). Each window change runs one SQL GROUP BY query sized to the chart's pixel budget, so even an "all" view over years of raw data stays responsive; live notifications fold into the visible buckets incrementally.

It exists to demonstrate the trade-off explicitly: mis-applying AtCollection<T> to a high-frequency observation stream — where query / aggregation / windowing is the dominant design concern — would be wrong. AtCollection<T> is for typed shared datasets (the todos example above); notifications + local DB is for streams of observations.

Full design, query-time aggregation semantics, and the seed-DB workflow for cross-window chart development are in example/dockerstats/README.md.

Onboarding, provisioning & timeouts

Registering a brand-new atSign and having its atServer provisioned are two separate steps — provisioning can lag registration by anything from seconds to a few minutes. CramDialog (at_client's Atsign.activate underneath) handles that wait for you: it polls for the atServer to come up for 5 minutes by default, every 2 seconds. Opening an atSign that already has keys (PkamDialog, Atsign.open underneath) instead makes one bounded connect attempt, seconds long, and hands back the client with its connection state — online, offline or refused — because an existing atSign is already provisioned and a dead network there should surface quickly.

What a Flutter app should do:

  1. Let CramDialog wait. The 5-minute provisioning poll is built in — don't wrap your own retry loop around it (that just re-stacks the retries this design removed).

  2. Show progress, not a blind spinner. A multi-minute wait behind an indeterminate spinner reads as "hung." CramDialog shows each step of the activation as it happens; pass progressBuilder when the default rendering does not fit your design, and it takes over entirely.

  3. When the dialog reports a failure, offer Retry rather than a longer wait. In the rare case provisioning runs past 5 minutes, a "Still setting up your atSign — tap to keep waiting" button that shows CramDialog again (a fresh 5-minute poll) beats baking in a 15-minute single timeout that makes every genuine failure feel broken.

  4. Returning users go through PkamDialog, which comes back in seconds either way. An offline client still serves what it holds locally and reports on client.connection.changes when the atServer is reached; a refused one (revoked, an unapproved enrollment) is the state an app asks the user about.

Note: the activation has no cancellation token, so the 5-minute poll runs to completion or timeout even if the user navigates away — a "Cancel" button can only change the UI, not abort the in-flight poll. True cancellation is tracked in #2075.

The client the dialogs hand back

Every dialog hands back the AtClient it opened, and the app owns it: it is the app's to use and, when it is done, to stop(). An app whose screens read AtClientManager.getInstance().atClient makes it current with AtClientManager.getInstance().use(client); an app that passes the client around needs no AtClientManager at all, and EnrollmentRequestList takes an atClient for that case. The details (the chosen storage directory, the namespace) are all in example/lib/walkthrough.dart in the _storage(...) and _adopt(...) functions.

The dialogs take the AtClientPreference and, optionally, the AtClientStorage the client opens on; with no storage a Hive store opens under preference.hiveStoragePath. They also take lookUps:, at_client's AtLookUpFactory, for an app that chooses its transport or reaches its atServers through a proxy: every connection the client they hand back opens comes from it. With none, TLS on TCP with the defaults. Together with keys: these are the three platform-supplied things a client is built from; at_client's README shows the factory.

Keychain storage

KeychainStorage wraps the device keychain (iOS / Android / macOS / Windows via biometric_storage) and stores atKeys. CramDialog and ApkamActivationDialog file the keys they mint in the keychain unless given another keys store, and PkamDialog takes backupKeys, stores the keys are copied into once the client is open — so a login from a .atKeys file populates the keychain for next time.

KeychainAtKeysIo is a full WrittenAtKeysIo: as well as read and write it implements flush, and inherits update. That matters because the post-quantum paths add key material to the store as they run — a namespace key's private half, the atSign's signing-root private — and on Flutter this is the default store. Use update for any addition, never a hand-rolled read → mutate → flush; see at_auth's note on why.

write is create-only, like every other WrittenAtKeysIo: it throws AtKeysFileOverwriteException if the atSign already has an entry. To persist a change to keys that are already stored, use flush or update. read of an atSign the keychain does not hold throws AtKeysSourceAbsentException, as the file store does, which is how Atsign.enroll tells "no keys yet" from "keys this process cannot read".

An enrollment awaiting approval lives in the keys store too, as pending key material: ApkamActivationDialog reads it back and resumes the wait rather than submitting a second request.

Windows apps additionally need:

dependencies:
  biometric_storage: ^5.0.1

Direct usage is rare, but when you need it:

final keychainStorage = KeychainStorage();

AtKeys? alice = await keychainStorage.getAtsign('@alice');
List<String> stored = await keychainStorage.getAllAtsigns();
await keychainStorage.appendAtKeysToKeychain(keys: atKeys);
await keychainStorage.removeAtsignFromKeychain('@alice');

Exporting atKeys

End users must back up their master atKeys (see at_auth's lifecycle writeup). The keychain → file export:

final atSign = AtClientManager.getInstance().atClient.getCurrentAtSign()!;
final atKeys = await KeychainStorage().getAtsign(atSign);
if (atKeys == null) throw Exception('No keys found for $atSign');

final atKeysIo = FileAtKeysIo(
  filePath: (_) => '/path/to/${atSign}_key.atKeys',
);
atKeysIo.write(atSign, atKeys);

Migrating from 1.x

2.0 removes the two services that orchestrated at_auth for an app, and an app no longer imports at_auth. The dialogs take the atSign, the keys store and the AtClientPreference, and hand back the AtClient they opened; an app with its own UI calls the Atsign verbs directly. Nothing an app reads after login changes.

1.x 2.0
AuthService().onboard(AtOnboardingRequest(atSign)..rootDomain = ..., cramKey) CramDialog.show(context, atSign: ..., cramKey: ..., preference: ...) → AtClient, or Atsign(atSign).activate(cramSecret: ..., keys: KeychainAtKeysIo(), preference: ...)
AuthService().authenticate(AtAuthRequest(atSign, atKeysIo: ...), backupKeys: ...) PkamDialog.show(context, atSign: ..., keys: ..., preference: ..., backupKeys: ...) → AtClient, or Atsign(atSign).open(keys: ..., preference: ...)
AtClientManager.getInstance().setCurrentAtSign(..., atChops: response.atChops, ...) afterwards AtClientManager.getInstance().use(client); setCurrentAtSign is deprecated
response.isSuccessful, response.atAuthKeys, response.atLookUp a null client means the dialog failed or was cancelled; the keys are in the store the client opened on; the connection is the client's (client.connection.current)
FlutterEnrollmentService().enroll(AtEnrollmentRequest(...)) then awaitApproval, then a PkamDialog ApkamActivationDialog.show(context, atSign: ..., rootDomain: ..., appName: ..., deviceName: ..., namespaces: ..., preference: ...) → AtClient, or Atsign(atSign).enroll(...) then pending.client(preference)
FlutterEnrollmentService().approve(...) / .deny(...) / .revoke(...) client.enrollments.approve(id) / .deny(id) / .revoke(id); EnrollmentRequestList(atClient: client) renders the roster and decides
.getEnrollments(...) / .list(statuses, atLookUp) client.enrollments.requests (a stream of new requests) / .list(statuses: ...) / .pending()
.generateOtp() / .setSpp(spp: ...), answering an Otp client.enrollments.otp() / .spp(value, expiry: ...), answering a Passcode; KeychainStorage().saveSpp(atSign, passcode) keeps it
.getActiveSpp() / .getAllSpps() KeychainStorage().getActiveSpp(atSign) / .getAllSpps(atSign)
.isManagerKey() no direct equivalent: read client.enrollments.list() and look for an approved enrollment holding __manage, which is what it did
AtSignSelectionDialog.show(context) → AuthRequest → AtsignSelection (atSign, rootDomain)
RegistrarCramDialog.show(context, request, registrar: ...) RegistrarCramDialog.show(context, atSign, registrar: ...)
ApkamActivationDialog.show(...) → AtEnrollmentResponse, atKeysIo: → AtClient; keys: is where the enrollment's keys are filed, the keychain by default, and a request already pending there is resumed rather than repeated
EnrollmentRequestList() on the manager's current client unchanged; EnrollmentRequestList(atClient: ...) for an app that passes its client around
KeychainStorage.readEnrollmentData / writeEnrollmentData / deleteEnrollmentData / validateEnrollment, EnrollmentData, Otp gone: an enrollment awaiting approval lives in the keys store as pending key material
import 'package:at_client_flutter/at_client_flutter.dart' for AtAuthRequest, AtEnrollmentRequest, AtEnrollmentResponse, ... gone with the services; AtKeys, AtKeysIo, FileAtKeysIo, InMemoryAtKeysIo, NamespacePermission and EnrollmentKeyExchangeMode still come through, from at_client, and RegistrarService from at_auth

A returning user's login can now come back offline: PkamDialog hands back the client whatever the network did, and client.connection.current says whether it is online, offline or refused. A 1.x app treated every failure to reach the atServer as a failed login; a 2.0 app decides.

Before and after

The things a 1.x app commonly did, each as it was and as it is now. The 2.0 side shares two helpers:

// Where this app keeps the atSign's local store. closedByClient: the client
// closes it when it stops, so there is nothing to tear down.
Future<HiveAtClientStorage> _storage(String atSign) async {
  final dir = await getApplicationSupportDirectory();
  return HiveAtClientStorage(atSign: atSign, storagePath: dir.path, closedByClient: true);
}

final preference = AtClientPreference()..namespace = 'my_app';

Log in from the keychain, then set up the client

// 1.x
final response = await AuthService().authenticate(
  AtAuthRequest(atSign, atKeysIo: KeychainAtKeysIo(), rootDomain: rootDomain),
  backupKeys: [KeychainAtKeysIo()],
);
if (!response.isSuccessful) return;
final acp = AtClientPreference()
  ..rootDomain = rootDomain.rootDomain
  ..rootPort = rootDomain.rootPort
  ..namespace = 'my_app'
  ..hiveStoragePath = dir.path
  ..commitLogPath = dir.path;
await AtClientManager.getInstance().setCurrentAtSign(
  response.atSign, 'my_app', acp,
  enrollmentId: response.enrollmentId,
  atChops: response.atChops,
  atLookUp: response.atLookUp,
);

// 2.0
final client = await PkamDialog.show(context,
  atSign: atSign,
  rootDomain: rootDomain,
  keys: KeychainAtKeysIo(),
  preference: preference,
  storage: await _storage(atSign),
);
if (client == null) return;                       // cancelled, or the dialog failed
AtClientManager.getInstance().use(client);        // if screens read the manager

The same for a .atKeys file: keys: atKeysIo from AtKeysFileDialog.show, plus backupKeys: [KeychainAtKeysIo()] to copy the keys into the keychain.

Onboard a new atSign

// 1.x
final request = await AtSignSelectionDialog.show(context);           // AuthRequest
final cramKey = await RegistrarCramDialog.show(context,
    request as AtOnboardingRequest, registrar: registrar);
final response = await CramDialog.show(context, request: request, cramKey: cramKey!);
if (response == null || !response.isSuccessful) return;
await AtClientManager.getInstance().setCurrentAtSign(/* as above */);

// 2.0
final selection = await AtSignSelectionDialog.show(context);         // AtsignSelection
final cramKey = await RegistrarCramDialog.show(context, selection!.atSign, registrar: registrar);
final client = await CramDialog.show(context,
  atSign: selection.atSign,
  rootDomain: selection.rootDomain,
  cramKey: cramKey!,
  preference: preference,
  storage: await _storage(selection.atSign),
);
if (client == null) return;
AtClientManager.getInstance().use(client);

Enroll this device with an atSign another device holds

// 1.x: three steps, and the enrollment checkpoint lived in the keychain
final enrollment = await ApkamActivationDialog.show(context,
  atSign: atSign, rootDomain: rootDomain, appName: 'my_app',
  deviceName: 'default', namespaces: {'my_app': 'rw'});
if (enrollment?.atAuthKeys == null) return;
final response = await PkamDialog.show(context,
  request: AtAuthRequest(atSign, atAuthKeys: enrollment!.atAuthKeys!, rootDomain: rootDomain),
  backupKeys: [KeychainAtKeysIo()]);
if (response == null || !response.isSuccessful) return;
await AtClientManager.getInstance().setCurrentAtSign(/* as above */);

// 2.0: one step; the dialog waits for the approval and hands back the client.
// A request already pending in `keys` is resumed, not repeated.
final client = await ApkamActivationDialog.show(context,
  atSign: atSign, rootDomain: rootDomain, appName: 'my_app',
  deviceName: 'default', namespaces: {'my_app': 'rw'},
  preference: preference,
  keys: KeychainAtKeysIo(),
  storage: await _storage(atSign),
);
if (client == null) return;
AtClientManager.getInstance().use(client);

Approve, deny and revoke on the manager device; passcodes

// 1.x
final service = FlutterEnrollmentService();
service.getEnrollments(statusFilters: [EnrollmentStatus.pending]).listen((r) => ...);
await AtEnrollment.create().approve(
  EnrollmentRequestDecision.approved(
      enrollmentId: r.enrollmentId,
      apkamSymmetricKey: AtBytes.fromString(r.encryptedAPKAMSymmetricKey!),
      atSign: atSign),
  AtClientManager.getInstance().atClient.getRemoteSecondary()!.atLookUp,
);
final Otp otp = await service.generateOtp();
final Otp spp = await service.setSpp(spp: value, sppExpiry: expiry);
final active = await service.getActiveSpp();

// 2.0: the client's own handle; passcodes the keychain keeps
final enrollments = client.enrollments;
enrollments.requests.listen((Enrollment r) => ...);      // new requests as they arrive
final pending = await enrollments.pending();
await enrollments.approve(r.enrollmentId!);              // or deny(id), revoke(id)
final Passcode otp = await enrollments.otp();
final Passcode spp = await enrollments.spp(value, expiry: expiry);
await KeychainStorage().saveSpp(atSign, spp);
final active = await KeychainStorage().getActiveSpp(atSign);

Log out, and switch atSigns

// 1.x: the manager owned the client
await AtClientManager.getInstance().setCurrentAtSign(nextAtSign, 'my_app', acp, ...);

// 2.0: the app owns it. Opening an atSign whose client is live is refused,
// so every sign-in stops the previous client first.
await client.stop();
final next = await PkamDialog.show(context, atSign: nextAtSign, keys: KeychainAtKeysIo(),
    preference: preference, storage: await _storage(nextAtSign));
if (next != null) AtClientManager.getInstance().use(next);

What a failed login looks like

// 1.x: any failure to reach the atServer was a failed login
if (!response.isSuccessful) showError(response.atClientException);

// 2.0: the client comes back, and says what happened
final state = client.connection.current;   // online | offline | refused, with a cause
if (state.isRefused) askTheUser(state.cause);          // revoked, unauthenticated, ...
client.connection.changes.listen((s) => setState(() => _state = s));

Where to go next

  • at_client — the SDK whose AtClient the dialogs hand back, and whose Atsign verbs they run
  • at_auth — the .atKeys keyfile format and the registrar client
  • at_commons — AtKey, Metadata, and friends

Open source usage and contributions

BSD3-licensed. See CONTRIBUTING.md for guidance on setting up tools, running tests, and raising a PR.