TODO (IA — follow-up)
This document's information architecture overlaps with Android Integration. After an editorial decision, we'll consolidate one of the two as canonical. For now, use this document as an Account Merge / canonical_sub addendum. For the OAuth/storage base patterns, follow Android Integration.
Android (Kotlin) Integration
This document covers the standard pattern for integrating logi as an RP in an Android native app (regardless of Jetpack Compose / View XML). It's the Android mirror of the iOS Swift guide (Swift) and offers the same promises for the OAuth flow and credential storage.
For overall architecture recommendations, the Android Integration page has more detail; this document focuses on canonical_sub / linked_subs handling and the pattern for storing credentials separately.
Storing credentials
logi issues two client-side secrets:
| Secret | Purpose | Expiry |
|---|---|---|
| refresh_token | Refresh the access_token | 30 days (rotation) |
| device_secret | The primary PoP for an anonymous user, the device credential | Indefinite (device lifecycle) |
For the storage pattern, follow the DataStore + Tink pattern in Android Integration — Storing the refresh token, but split the two secrets into separate DataStore keys (aliases) — so that revoking/wiping one doesn't affect the other.
No EncryptedSharedPreferences
EncryptedSharedPreferences in androidx.security:security-crypto is deprecated as of 1.1.0. In new code, use only the DataStore + Tink pattern. Never store device_secret in plain SharedPreferences either — it would be exposed in plaintext in backups.
Claims model
@Serializable
data class LogiClaims(
val sub: String,
@SerialName("canonical_sub") val canonicalSub: String? = null,
@SerialName("is_canonical") val isCanonical: Boolean? = null,
@SerialName("linked_subs") val linkedSubs: List<String>? = null,
@SerialName("previously_anonymous") val previouslyAnonymous: Boolean? = null,
val email: String? = null,
@SerialName("email_verified") val emailVerified: Boolean? = null,
val anonymous: Boolean? = null
) {
/** Always use this value for foreign-key / local-cache lookups. */
val effectiveSub: String
get() = canonicalSub ?: sub
}Mapping the user after token verification
class LogiSessionHandler(private val localStore: LocalStore) {
fun handle(claims: LogiClaims) {
val canonical = claims.effectiveSub
val currentLocal = localStore.userId
if (currentLocal != null && currentLocal != canonical) {
// A merge happened — update the local cache to canonical.
localStore.migrateUser(from = currentLocal, to = canonical)
}
localStore.userId = canonical
if (claims.previouslyAnonymous == true) {
localStore.previouslyAnonymous = true
}
}
}Handling linked_subs
If the canonical account (isCanonical == true) absorbed old subs, pull the local-cache data into the canonical sub view:
if (claims.isCanonical == true) {
claims.linkedSubs?.forEach { absorbed ->
localStore.reassignData(from = absorbed, to = claims.sub)
}
}Write reassignData to be idempotent — skip subs that have already been moved.
Detecting the Anonymous → Identified transition
if (claims.anonymous == false && localStore.wasAnonymous) {
// promotion happened: sync the device-local data from the anonymous era
// to the promoted user's cloud account.
syncEngine.runPromotionUpload()
localStore.wasAnonymous = false
}OAuth flow
The Authorization Code + PKCE flow uses the standard pattern on the Android page as-is. What this document covers is the canonical-aware layer that sits on top — for the OAuth redirect/state/code_verifier handling itself, see the existing guide.
Verifying the merge behavior
Before enabling this in production, confirm the following in staging:
- [ ] When two SSOs happen on the same device (T1), the next token's
canonical_subis populated appropriately. - [ ] When signing up for the first time with an SSO of the same email (T2),
linked_subsarrives immediately. - [ ] After running T3 (
/me/mergevia WebView), canonical changes on the next token rotation. - [ ] After an anonymous-first signup + SSO promotion,
previously_anonymous == true. - [ ] The absorbed side's refresh token is invalid immediately (a
refresh_token_revokedresponse).