Skip to content

Tutorial

This walkthrough goes from an empty Flutter app to two devices sharing an encrypted shopping list. All code on this page lives in examples/quickstart/ and is compiled in CI, so it cannot drift from the real API.

1. Install

Add the Flutter package to your pubspec.yaml, plus the code generator as a dev dependency, and select the SQLCipher build of SQLite for at-rest encryption:

dependencies:
  passnote_flutter: ^1.1.2

dev_dependencies:
  build_runner: ^2.15.0
  passnote_generator: ^1.1.2

hooks:
  user_defines:
    sqlite3:
      source: sqlcipher

passnote_flutter brings the whole library: the engine, the crypto, the transports, and the Flutter glue (keychain key store, change notifiers). The hooks stanza makes package:sqlite3 bundle SQLCipher; without it, opening an encrypted database throws SqlCipherUnavailableException instead of writing plaintext to disk.

flutter pub get

2. Define a model

Annotate a plain class. The generator produces its codec: the mapping to and from stored form, with @DocumentId() marking the stable id.

part 'main.g.dart';

@Document()
class Item {
  const Item({required this.id, required this.name, this.done = false});

  @DocumentId()
  final String id;

  final String name;
  final bool done;

  Item copyWith({String? name, bool? done}) =>
      Item(id: id, name: name ?? this.name, done: done ?? this.done);
}

// `dart run build_runner build` generates _$ItemCodec from the annotations:
// toMap/fromMap over the fields, with `id` as the document id.
final itemCodec = _$ItemCodec;

Run the generator after changing the model:

dart run build_runner build

3. Open Passnote

passnoteBuilder returns a builder with the production pieces installed: SQLite storage, the OS keychain, LAN discovery, XChaCha20-Poly1305 encryption. Call it once and share the handle across the app.

final passnote = await passnoteBuilder(
  appId: 'shopping', // scopes discovery + storage
  salt: 'shopping:v1', // stable, app-specific key-derivation salt
)
    // Override any default before open(). This one is optional.
    .compactionHorizon(const Duration(days: 60))
    .open();

appId scopes discovery and storage: only apps with the same id see each other. salt pins your keys to your app; keep it constant.

4. Open a database

A database is one shared, encrypted dataset. Its passphrase protects the device roster and seeds discovery.

// On the first device, generate a passphrase to show the user:
final words = passnote.generatePassphrase().join(' ');

// On that same device, open the database with it:
final db = await passnote.openDatabase('home', passphrase: words);

final items = db.collection<Item>('items', itemCodec);

The first device to open a database founds its device group and holds its group key. Other devices join by pairing (step 7), not by retyping the passphrase: a second device that calls openDatabase reaches DeviceState.needsPairing, where opening a collection throws.

On every later launch, reopen without asking for the passphrase again; the derived key was stored in the OS keychain by openDatabase:

final db = await passnote.reopenDatabase('home');

5. Read and write

Future<void> seed(Collection<Item> items) async {
  await items.put(Item(id: newId(), name: 'Milk'));
  await items.put(Item(id: newId(), name: 'Bread'));

  // Soft-delete by id:
  final someId = itemCodec.idOf((await items.all()).first);
  await items.delete(someId);

  // Query by scanning the decoded documents:
  final remaining = await items.where((i) => !i.done);
  debugPrint('${remaining.length} item(s) still to buy');
}

Writes work immediately, online or offline. For filtered, sorted, or paginated reads there is also an indexed query builder; see Query and paginate.

6. React to changes

watch() emits the current list once loaded, then again on every change, local or from a peer:

void logChanges(Collection<Item> items) {
  items.watch().listen((all) {
    debugPrint('list now has ${all.length} items');
  });
}

In widgets, use CollectionNotifier (or CollectionNotifier.query for a bounded slice) instead of hand-managing subscriptions.

7. Pair a second device

Real devices need OS permissions first

Local discovery is gated by every platform: an Info.plist entry and an entitlement on iOS, manifest permissions on Android, sandbox entitlements on macOS. A missing one fails silently. Do Set up platforms once per platform; simulators are more permissive than devices.

Run the app on a second device on the same network. On the device that already has the data, create a one-time nine-digit code and show it:

Future<void> sponsorPairing(Passnote passnote) async {
  // On a device that already has the data: create a one-time code.
  final code = await passnote.startPairing('home', joinerLabel: 'new phone');
  debugPrint('On the new device, enter: ${code.display}');
}

On the new device, enter that code:

Future<Database> joinWithCode(Passnote passnote, String typedCode) {
  // On the new device: enter the code. The exchange authenticates both
  // sides, hands over the keys, and returns a ready database.
  return passnote.pairWith('home', PairingCode.parse(typedCode));
}

Add an item on either device. It appears on the other within a moment.

8. What just happened

  • Each device keeps its own complete copy of the list in an encrypted SQLite file. Reads and writes never wait for the network.
  • The devices found each other over the local network and exchanged only encrypted data. Nothing left the room in plaintext, and nothing touched a server.
  • The nine-digit code ran a short cryptographic exchange (PAKE) that proved both devices held the same code, then sealed the data keys to the new device's own keypair. Guessing costs an attacker one visible failed attempt per guess, and the sponsor withdraws the code after twenty.
  • Edits made on both devices at once merge automatically and every device arrives at the same result. How that works is in Clocks and merge.

Next: pick per-field merge behaviour in Choose field types, write your first convergence test with Test your app, or read the data model. If the two devices never found each other, Troubleshoot sync walks the usual causes.