Iceblock SDK

The Flutter package

Built, in r87 (#658): the package is generated from the Rust facade with flutter_rust_bridge, as the Iceblock app’s own bridge is, so the two are built together and say the same things. It’s packages/iceblock_flutter in the Iceblock repository, with a small notes app in example/.

What’s in it

Two packages: the platform parts alone, iceblock_platform Built, with no Rust (Android’s conditions, the background job, waking and being woken, and finding Iceblock: iceblock_installed()), for an app that builds its own bridge on iceblock-sdk, as the first app does; and the full package Built, in r87, which adds the compiled library and its Dart API on top of them.

  • The Dart API: the facade’s functions and types, with Futures for what waits and Streams for what’s watched.
  • The compiled library for Android (64-bit ARM, and x86_64 for the emulator) and Linux (x86_64). Other platforms later, as the core supports them.
  • On Android, the Kotlin the core needs from the platform: network conditions (metered, VPN, the gateway), power, and keys in the app’s private storage.

Using it

dependencies:
  iceblock_flutter:
    hosted: https://git.tricauda.com/api/packages/iceblock/pub
    version: ^0.14.9
await Iceblock.init();
final app = await IceblockApp.start(config: IceblockConfig(
  dataDir: '${(await getApplicationSupportDirectory()).path}/iceblock',
  appId: 'com.example.notes',
  appName: 'Notes',
  asks: const ['collections'],
  requires: const [],
));

final notes = await app.ownCollection();
await app.write(collection: notes.id, path: '2026/10/plan.md', bytes: utf8.encode(text));
final bytes = await app.read(collection: notes.id, path: '2026/10/plan.md');
final entries = await app.list(collection: notes.id, folder: '2026/10');
app.watch(collection: notes.id).listen((changes) => refresh(changes));

app.events().listen((e) {
  switch (e.kind) {
    case 'join-asked': app.answerJoin(id: e.id, allow: askTheUser(e.name!));
    case 'granted' || 'taken-back' || 'denied': refreshPermissions(e.permission!);
    case 'removed': showRemoved();
  }
});

final group = await app.createGroup(name: 'Family');
final link = await app.groupInvite(group: group, role: 'contributor');

What’s there: starting and status; Collections and their files (writing, from a file too, reading, to a file too, listing, details, renaming, deleting, folders, watching); inviting the app’s other devices and joining, the user’s Iceblock included; permissions, asking and opening Iceblock at the asking; events; devices; presence and use; and groups (making, inviting, joining, members, Collections, syncing, presence). Each is the facade’s call of the same name, its ids and permissions as strings. The rest of the facade comes as the first app needs it.

The rest of the facade Built, in r96 (library 0.16.0)

Asked by the first app on 23 October, as it moves its logic to Dart: everything iceblock-sdk has, in Dart too, apart from start_with_keystore, which is for tests. The reference says, call by call, how each is called in Dart. Breaking: acceptGroupInvitation returns a JoiningInfo, the group’s id and whether it’s joined or waiting, where it returned the id or null.

  • Starting with a whole manifest: asks and requires take kinds of group permission (group, group-presence, group-devices, group-contact, each covering every group) and a least role in a group’s Collections (group-role:contributor), as the Rust Manifest does.
  • Using the app in a group: whether the group has the app and this network uses it there, using it (agreeing to group-contact with it), adding it, what it requires, whether presence is shared, and proposing it out; and, when accepting an invitation waits for an Owner or Caretaker, which group it’s for.
  • A group’s successions and following them to the network now; who’s out of touch, and when this member last heard from the home.
  • Keeping a Collection out of Files; whether the app may make groups; a file’s earlier versions, reading and restoring them, and restoring a deleted file; reading part of a file; syncing one Collection now.
  • The rest: a group’s role changes, removing a member, leaving, giving it a Collection; asking for a Collection; mobile data; the owner’s name.
  • A fault, fixed: cancelling a subscription to events() never completed, as a stream from Rust waits for its next event to finish cancelling. events() is now the package’s own, over the bridge’s eventStream: cancelling it ends the stream at once.

What the package does for the app

  • Conditions. It passes Android’s network and power conditions to the core itself, as the Iceblock app does, so an app doesn’t. Built in iceblock_platform (IceblockPlatform.conditions, conditionChanges), which an app passes to the library: with its own bridge, or the full package’s setConditions.
  • Running in the background (decided: the user never has to open each app to see what’s new). Built in iceblock_platform (Background), and the Iceblock app runs on it.
    • The package brings the Iceblock app’s background job, on by default: Android’s WorkManager, about every 15 minutes on Wi-Fi and hourly on mobile data, the app not open. Android runs jobs of several apps in the same maintenance windows, so apps built on Iceblock wake together.
    • Each run calls the app’s own handler with what changed, as the Iceblock app’s backgroundWork entry point does: the app posts a notification for what’s worth one.
    • In deep Doze, Android stretches those runs to its maintenance windows, an hour or more apart. Without push, nothing reaches a sleeping phone sooner: updates arrive late, never missed.
    • Release builds keep what WorkManager needs. R8, which shrinks an app’s release build, removed the constructor WorkManager creates its database through, so an app on iceblock_platform crashed as it started (NoSuchMethodException: androidx.work.impl.WorkDatabase_Impl.<init>). Built, in r105 (library 0.16.9): the package ships the rule that keeps it (consumer ProGuard rules), so an app needs nothing of its own. With an earlier release, an app keeps it in its own proguard-rules.pro: -keep class * extends androidx.room.RoomDatabase { <init>(); }.
  • Waking each other, the wake permission. Built in iceblock_platform (wakeIceblock, wake), any app waking any other; Iceblock waking the apps granted wake, and only those, is Built in r76.
    • Iceblock wakes the app. Once Iceblock is installed, it’s syncing in the background anyway. When something arrives for an app holding wake, it wakes the app (an explicit broadcast to its package), which syncs from it over the phone’s loopback and notifies. Apps with nothing new stay asleep: one background syncer per phone, not one per app.
    • The app wakes Iceblock. An app that’s just written something, and may be closed before it’s sent, wakes Iceblock on the same phone, which takes the change over loopback and sends it on when it can. Iceblock’s receiver only asks for a sync soon, rate-limited, so any app may call it: it needs no permission.
    • Neither helps in Doze, when Android holds wake-ups back too; while the user is using either app, the phone isn’t dozing, and it’s at once.
  • Saying the app’s in use, for presence (#762). Built, in r88, in iceblock_platform: UseReporter(() => app.used()) says so as the app comes to the front, and every minute while it stays there, never in the background; stop() ends it. Android and Linux (where the window having focus counts).
  • Pairing screens. Decided: data only. The packages give links, QR payloads and events; the app draws its own screens, in its own voice.
  • Links. An app’s group invitations can be its own URLs, which open the app rather than Iceblock, so people without Iceblock can join from them: the core makes them from a link address the app gives it at start (link_base, an origin only: everything else goes after the #, which browsers never send to a server, so nothing is recorded), the app shows them or draws their QR codes and handles the link when it’s opened, and accepting takes the URL or the bare invitation. Built in the facade (Config::link_base).

Two apps on one phone

The Iceblock app and an app built on the package are two devices, each with its own keys and store, even on one phone: their sandboxes keep them apart. Once the app joins the user’s network, they’re two devices of it, and sync with each other as any two devices do, on the phone itself, over loopback, while both are running.

Joining Iceblock on the same phone Built, on Android in r84, on Linux in r85

No QR code, no fingerprints to compare: the two apps talk directly.

  1. The app finds Iceblock installed (it lists Iceblock’s package in its manifest’s <queries>), and offers to join it.
  2. It opens Iceblock’s “add an app” screen directly, with an intent that carries its name, its device’s fingerprint and where it listens on the phone, the permissions it may ask for (its manifest), and whether it has files to bring.
  3. Android tells Iceblock which app opened it, and Iceblock reads that app’s signing certificate. So the app’s identity is checked by the phone, not taken from what it says: Iceblock pins the app’s app_id (its package name) to that certificate the first time, a later install signed the same way is recognised as the same app, and one claiming the id with another certificate is refused as that app.
  4. Iceblock shows it in context: the app’s name and icon, what it asks for, that its files come too. The user approves, after unlocking, as for any approval.
  5. Iceblock invites it there and then, approved in advance for that fingerprint at Scoped, over the phone’s loopback: the app joins (or is adopted, with its files) while both are open, and Iceblock returns to it, saying it’s done.

In the platform package it’s one call: IceblockPlatform.joinIceblock(name: …, fingerprint: app.status().device, asks: […], reportsActivity: app.reportsActivity()) opens Iceblock’s screen (which, for an app that checks when the person’s at their computer, offers to keep that, #790) and answers with an invitation for this device alone (or null, if the user said no), which the app joins with the SDK’s app.join(invitation); an app with files is adopted with them. The app hands Iceblock a PendingIntent of its own for the answer: Android says which app made it, which is how Iceblock knows who asks. The invitation reaches Iceblock over loopback first, and lets in only that device, saying it’s that app, without asking again; anyone else using it is asked about as ever.

Later requests go the same way: an app that’s asked for a Collection or a permission opens Iceblock at what it asked (IceblockPlatform.openIceblockRequests(name: …), after syncing), rather than waiting for the user to find a notice. Iceblock shows that app’s requests, each to approve, deny or always deny, as the user, then returns to it. On Linux, the same through Iceblock’s control socket, which only the same user can reach, from the SDK (app.join_on_this_device(), app.open_requests()): Iceblock’s window comes up at the question. Linux can’t say which app asks, so there the app’s id isn’t checked. Built, in r85 (#759).

Building it

Both packages are built in the Iceblock repository and published to the iceblock organisation’s pub registry on git.tricauda.com with each release whose library changed, at the library’s version: iceblock_platform: {hosted: https://git.tricauda.com/api/packages/iceblock/pub, version: ^0.13.0}. The full package carries the library already compiled for each platform, so an app depending on it needs no Rust. The platform package carries no Rust at all, for an app that builds its own bridge on iceblock-sdk, as the first app does: two Rust libraries each holding a core in one app would be two devices. Built: the platform package, published since stage 1, and the full package, since r87 (#658). An app on the full package needs no Rust toolchain, crates or bridge of its own.