Skip to content

Invitations in Compose

com.calimero.mero.compose.invitations is mero-react’s invitations module: one way for an invitation link to reach the app, be confirmed, be joined, and wait for the namespace’s data.

The shareable link is buildInviteLink(slug, token) — https://links.calimero.network/<slug>/join?invitation=… (pass host = "calimero://" for the device-local calimero://<slug>/join?invitation=…). On the device, declare an intent filter for your slug (the app’s package id) and hand every intent to the capture:

<activity android:name=".MainActivity" android:launchMode="singleTop" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="calimero" android:host="com.example.app" android:pathPrefix="/join" />
</intent-filter>
<!-- Optionally an https App Link on a domain you own (android:autoVerify="true"). -->
</activity>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
primeInvitationCapture(this, appKey = "com.example.app", slugs = listOf("com.example.app"))
InvitationCapture.shared.capture(intent) // a cold open
setContent { App() }
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
InvitationCapture.shared.capture(intent) // a link while the app runs
}

The capture dedups links by a content nonce, persists them in SharedPreferences (an invitation survives process death and the sign-in), replays them to a listener that subscribes late, and drops one only when the app acks it. A join link for another app’s slug is acked and dropped. Each invitation auto-joins at most MAX_AUTO_JOIN_ATTEMPTS (3) launches; after that it is still handed over, so a prompt or field can offer it again. onInvitation, peekInvitation, subscribeInvitation, invitationFromRaw and urlWithoutInvitation are the rest of mero-react’s surface.

@Composable
fun AppRoot(client: MeroClient) {
val join = rememberJoinInvitation()
val redemption =
rememberInviteRedemption(
parse = { token, _ -> myCodec(token) }, // → BasicParsedInvitation(namespaceId, invitation, teamName)
redeem = { p -> redeemInvitation(p.namespaceId, p.invitation, join.invitationRedeemer(JoinInvitationInput(p.namespaceId, p.invitation)), p.teamName) },
enabled = client.state.collectAsStateWithLifecycle().value.isAuthenticated,
)
InvitationGate(callbackUrl = "myapp://calimero-enrol", parse = { t, p -> myCodec(t) }, noun = "space") {
Home()
}
InvitationDialog(redemption, noun = "space", appName = "My App")
}

InviteRedemption goes Idle → AwaitingConfirmation (joining is a state change, so it asks by default) → Joining → Joined / AlreadyMember / Failed. accept, decline (acks it, so “Not now” does not return every launch), retry and dismiss drive it; the invitation is acked on a settled outcome, never on the render. describeInviteFailure(reason, noun) says what happened in plain words.

InvitationDialog adds SYNCING: a join returns before the namespace’s data arrives, so it waits while the namespace is flagged just-joined (JustJoinedNamespaces), offers “Continue in background” after 30 s, lets go at 120 s, and shows “You’re in” for 1.2 s. InvitationGate is the signed-out landing: a spinner while the session restores, “You’re invited to join …” with Not now / Log in to join, or “This invitation can’t be read”. Their logic is plain Kotlin — InvitationDialogModel, invitationDialogCopy, decideInvitationGate, invitationGateCopy — and unit-tested.

On the screen that shows the namespace:

val sync = rememberJoinSync(namespaceId, settled = documents.loaded) // an empty answer is settled
val syncing by sync.isSyncing.collectAsStateWithLifecycle()
JoinSyncBanner(show = syncing, what = "documents", onDismiss = sync::dismiss)

The gate lifts when the data settles, after JOIN_SYNC_WATCHDOG_MS (30 s) with nothing in flight, or at JOIN_SYNC_MAX_MS (120 s) whatever is reported.

JoinInvitation.joinInvitation(input) joins as the session can: on a node, the namespace and then the named context; as an account, DelegatedBootstrap resolves the node the invitation names, has it admit the account, and connects through it. The result is step-typed (JoinInvitationStep, with a label per step) and says whether the invitation itself is dead (final, isFinalInvitationError). AccountEnrolment and isReturningFromWallet(url) wrap the Cloud sign-in for apps that drive it themselves.