Building Snook-A-Look in Kotlin Multiplatform meant sharing as much UI and business logic as possible. But when it came to authentication, we hit a classic platform disparity: Apple Sign-In. On iOS, it's a breeze with native APIs. On Android, it's a completely different beast involving OAuth web flows.
We didn't want to pollute our shared Compose UI with platform-specific auth logic. We needed a clean expect/actual bridge that could seamlessly suspend a KMP coroutine, launch the web flow on Android, and resume the KMP coroutine with the auth payload.
Here is exactly how we engineered a resilient Apple Sign-In flow on Android using Chrome Custom Tabs and Kotlin Coroutines.
When a user taps "Sign in with Apple" on Android, we must:
- Open a Chrome Custom Tab pointing to Apple's OAuth URL.
- Wait for the user to complete the flow.
- Catch the redirect intent when Apple pings our web callback.
- Unblock the KMP coroutine waiting for the result.
The danger? The Android OS might kill our app while the user is in Chrome, or the user might just manually close the tab without logging in. Our coroutine would hang forever if we didn't handle lifecycle events and state persistence properly.
We built an AppleAuthRegistry to hold our CancellableContinuation by a unique state ID.
// AppleAuthRegistry.kt
object AppleAuthRegistry {
private val pending = ConcurrentHashMap<String, CancellableContinuation<AppleAuthArtifact>>()
fun newState(): String = UUID.randomUUID().toString()
suspend fun await(state: String): AppleAuthArtifact = suspendCancellableCoroutine { cont ->
pending[state] = cont
cont.invokeOnCancellation { pending.remove(state) }
}
fun resume(state: String, artifact: AppleAuthArtifact) {
pending.remove(state)?.resume(artifact)
}
fun cancel(state: String) {
pending.remove(state)?.resumeWithException(
OAuthFlowException(OAuthErrorCode.USER_CANCELLED)
)
}
fun isPending(state: String): Boolean = pending.containsKey(state)
}In our actual class AppleSignInBridge for Android, we persist the pending state to SharedPreferences before launching the Custom Tab. This guarantees kill-resume resilience. We also inject a DefaultLifecycleObserver to monitor when the user cancels the flow by closing the tab.
// AppleSignInBridge.android.kt
actual class AppleSignInBridge(private val activityProvider: () -> Activity?) {
actual suspend fun getIdToken(rawNonce: String): AppleAuthArtifact {
val activity = activityProvider() ?: throw OAuthFlowException(OAuthErrorCode.UNKNOWN, "No foreground activity")
val state = AppleAuthRegistry.newState()
// Persist state for kill-resume resilience
activity.applicationContext
.getSharedPreferences("snook_prefs", Context.MODE_PRIVATE)
.edit().putString("apple_oauth_pending_state", state).apply()
val nonce256 = sha256Hex(rawNonce)
val url = buildAppleAuthUrl(state, nonce256)
var tabLaunched = false
val cancelObserver = object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
if (!tabLaunched) return
// Remove on first resume after tab launch to catch user cancellation
owner.lifecycle.removeObserver(this)
if (AppleAuthRegistry.isPending(state)) {
AppleAuthRegistry.cancel(state)
}
}
}
(activity as? ComponentActivity)?.lifecycle?.addObserver(cancelObserver)
return try {
CustomTabsIntent.Builder().build().launchUrl(activity, Uri.parse(url))
tabLaunched = true
// Suspend the KMP coroutine here!
AppleAuthRegistry.await(state)
} finally {
(activity as? ComponentActivity)?.lifecycle?.removeObserver(cancelObserver)
AppleAuthRegistry.cancel(state) // Cleanup on exit
}
}
// ... URL building logic omitted for brevity ...
}Finally, when Apple redirects to our backend and our backend deep-links back to the app, our transparent AppleWebCallbackActivity intercepts the intent, extracts the state, and resumes the coroutine via the registry. It then ensures the main task is brought back to the foreground.
// AppleWebCallbackActivity.kt
class AppleWebCallbackActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleIntent()
finish()
}
private fun handleIntent() {
val uri = intent?.data ?: return
val state = uri.getQueryParameter("state")
if (state != null) {
AppleAuthRegistry.resume(
state = state,
artifact = AppleAuthArtifact(
identityToken = null,
stateToken = state,
firstName = null,
lastName = null,
email = null,
)
)
// Explicitly bring MainActivity task to front so Chrome CCT doesn't stay foreground
packageManager.getLaunchIntentForPackage(packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}?.let { startActivity(it) }
}
}
}By isolating the Chrome Custom Tab mechanics and lifecycle observers within the Android actual implementation, we kept our shared KMP UI completely oblivious to the platform differences. The shared code simply calls AppleSignInBridge.getIdToken() and suspends until the native platform delivers the payload. Clean, robust, and crash-resilient!