The logic for serving and storing associations between users and apps
README.md

loggedin.fyi Android SDK #

Android library that lists, heartbeats, and forgets hinted AT Proto DIDs for the current device.

How the session works #

On the web the session key is the HttpOnly loggedin_session cookie on the loggedin.fyi origin. A native app cannot hold that cookie — but a Custom Tab shares the default browser's cookie jar. So the app makes one visible trip to a first-party loggedin.fyi page, and in exchange the worker mints a signed bearer token carrying the same session id. Every call after that is plain HTTPS with no browser.

The token is an HMAC the worker alone can produce, so unlike a self-asserted device id it cannot be guessed, and it pins the app's origin server-side. It lasts at most 7 days.

A WebView will not do: it has its own cookie jar and would see an empty device.

Install #

Open sdks/android in Android Studio, or include the module:

include(":loggedin")
project(":loggedin").projectDir = file("sdks/android/loggedin")
implementation(project(":loggedin"))

The library declares INTERNET and nothing else. No phone-state or identifier permission is involved.

Register the callback #

The worker only hands a token to a URL the app already published in redirect_uris in its /oauth-client-metadata.json, matched exactly. A custom scheme and an https App Link both work:

{
  "client_id": "https://your-app.example/oauth-client-metadata.json",
  "redirect_uris": ["example.your-app://loggedin"]
}

Declare the matching Activity:

<activity
    android:name=".LoggedinRedirectActivity"
    android:exported="true"
    android:launchMode="singleTask">
    <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="example.your-app" android:host="loggedin" />
    </intent-filter>
</activity>

Bootstrap #

val bootstrap = LoggedinBootstrap(
    context,
    origin = "https://your-app.example",
    callback = "example.your-app://loggedin",
)

// Account chooser, before login.
bootstrap.start(activity)

// Or, right after the app signed an account in: "remember this account?"
bootstrap.start(activity, did = sessionDid)

Then in the redirect Activity — construct the same LoggedinBootstrap there, it is only config plus the shared store:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    val uri = intent?.data ?: return finish()
    try {
        when (val result = bootstrap.onBootstrapRedirect(uri)) {
            is BootstrapResult.Approved -> startLoginWith(result.did) // null: user wants another account
            BootstrapResult.Declined -> Unit
        }
    } catch (err: LoggedinException) {
        // InvalidState: the redirect did not carry the state we generated. Drop it.
    }
    finish()
}

onBootstrapRedirect checks that state matches the value start generated, in constant time, and consumes it — so a replayed or forged redirect stores nothing.

Call the API #

val client = LoggedinClient(context, origin = "https://your-app.example")

if (!client.hasToken()) {
    bootstrap.start(activity)
    return
}

val accounts = client.listAccounts()   // List<Account>: did + the origins that hinted it
client.heartbeat(sessionDid)           // after a successful login
client.logout(sessionDid)              // on sign-out

listAccounts, heartbeat, logout, and originLabels block. Call them off the main thread.

origin must be the HTTPS origin that serves /oauth-client-metadata.json, and must be the same one passed to the bootstrap. It is never sent on the wire — the token pins it — but it is validated so a misconfigured app fails immediately.

Re-bootstrapping #

A 401 means the token was forged, expired, or the worker rotated its secret. The client clears the stored token and raises LoggedinException.NeedsBootstrap; the only fix is another trip through bootstrap.start.

try {
    client.heartbeat(sessionDid)
} catch (err: LoggedinException.NeedsBootstrap) {
    bootstrap.start(activity, did = sessionDid)
}

Other worker errors arrive as LoggedinException.HttpStatus, whose message is the worker's own.

Storage #

By default the token and the pending state live in app-private SharedPreferences (fyi.loggedin.sdk), which on an unrooted device no other app can read. The iOS SDK puts its token in the Keychain; Android has no equally frictionless equivalent now that androidx.security:security-crypto is deprecated, and the token is a 7-day credential scoped to one origin's hint list, so the app sandbox is the default here.

Supply your own LoggedinTokenStore to put it somewhere else — the Keystore, your own encrypted store, or InMemoryTokenStore to keep the token off disk entirely and re-bootstrap each launch:

val store = InMemoryTokenStore()
val bootstrap = LoggedinBootstrap("https://your-app.example", "example.your-app://loggedin", store)
val client = LoggedinClient("https://your-app.example", store)

Both objects must share one store: the bootstrap writes the token, the client reads it.

Test #

# from sdks/android, with a JDK and Android SDK installed
./gradlew :loggedin:test

The unit tests are plain JVM tests — no device or emulator.