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.
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:
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:
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.