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.