Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/expo-native-single-token-sync-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/expo-native-components': minor
---

Add native module support for the upcoming single-token client sync between `@clerk/expo` and the Clerk iOS and Android SDKs. The native SDK's stored device token becomes the one token both runtimes use, and each side is notified to reload its own client when the other changes it. Existing sync behavior is unchanged until `@clerk/expo` adopts it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package expo.modules.clerk

import com.clerk.api.network.model.client.Client

/** The native client state that JS must refetch its own client for when it changes. */
internal data class ClerkClientFingerprint(
val clientId: String?,
val lastActiveSessionId: String?,
val sessions: List<SessionState>,
val activeUserId: String?,
val activeUserUpdatedAt: Long?,
val deviceToken: String?
) {
data class SessionState(val id: String, val status: String)

companion object {
fun from(client: Client?, deviceToken: String?): ClerkClientFingerprint {
val activeUser = client?.sessions?.firstOrNull { it.id == client.lastActiveSessionId }?.user
return ClerkClientFingerprint(
clientId = client?.id,
lastActiveSessionId = client?.lastActiveSessionId,
sessions = client?.sessions?.map { SessionState(it.id, it.status.name) }.orEmpty(),
activeUserId = activeUser?.id,
activeUserUpdatedAt = activeUser?.updatedAt,
deviceToken = deviceToken
)
}
}
}

/**
* Emits one payload-free invalidation per main-loop turn in which the fingerprint moved away from
* the last state JS was told about. Not thread-safe: call it from the main thread only.
*/
internal class ClerkClientInvalidationTracker(
private val schedule: (() -> Unit) -> Unit,
private val emit: () -> Unit
) {
private var baseline: ClerkClientFingerprint? = null
private var latest: ClerkClientFingerprint? = null
private var isFlushScheduled = false

/** Sets the state JS already knows about without emitting. */
fun reset(fingerprint: ClerkClientFingerprint?) {
baseline = fingerprint
latest = fingerprint
}

fun observe(fingerprint: ClerkClientFingerprint) {
if (baseline == null) return
latest = fingerprint
if (fingerprint == baseline || isFlushScheduled) return
isFlushScheduled = true
schedule(::flush)
}

/** A token JS wrote itself is not news to JS, so it must not echo back as an invalidation. */
fun acknowledgeDeviceToken(token: String?, current: ClerkClientFingerprint) {
baseline = baseline?.copy(deviceToken = token)
observe(current)
}

private fun flush() {
isFlushScheduled = false
val current = latest ?: return
if (current == baseline) return
baseline = current
emit()
}
}

internal data class ClerkDeviceTokenBridgeError(val code: String, val message: String)

internal fun clerkSetDeviceTokenBridgeError(throwable: Throwable): ClerkDeviceTokenBridgeError = when (throwable) {
is IllegalArgumentException -> ClerkDeviceTokenBridgeError(
"E_INVALID_DEVICE_TOKEN",
throwable.message ?: "Device token must not be blank"
)
is IllegalStateException -> ClerkDeviceTokenBridgeError(
"E_NOT_CONFIGURED",
throwable.message ?: "Clerk must be configured with configureNative before syncing client state."
)
else -> ClerkDeviceTokenBridgeError(
"E_SET_DEVICE_TOKEN_FAILED",
throwable.message ?: "Unable to set the device token"
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
package expo.modules.clerk

import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
Expand Down Expand Up @@ -37,12 +39,14 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.json.JSONObject

private const val TAG = "ClerkExpoModule"
private const val NATIVE_AUTH_FLOW_CHANGED_EVENT = "clerkNativeAuthFlowChanged"
private const val NATIVE_CLIENT_CHANGED_EVENT = "clerkNativeClientChanged"
private const val NATIVE_CLIENT_INVALIDATED_EVENT = "clerkNativeClientInvalidated"
private const val HOST_SDK_HEADER = "x-clerk-host-sdk"
private const val HOST_SDK_VERSION_HEADER = "x-clerk-host-sdk-version"
private const val HOST_SDK = "expo"
Expand Down Expand Up @@ -204,6 +208,12 @@ class ClerkExpoModule : Module() {
private var lastObservedClientState: ClientStateSnapshot? = null
private var jsOriginatedClientSyncDepth = 0
private var configuredPublishableKey: String? = null
private val mainHandler = Handler(Looper.getMainLooper())
private var clientInvalidationObserverJob: Job? = null
private val clientInvalidationTracker = ClerkClientInvalidationTracker(
schedule = { flush -> mainHandler.post(flush) },
emit = { sendEvent(NATIVE_CLIENT_INVALIDATED_EVENT, emptyMap<String, Any?>()) }
)

private data class AuthFlowStateSnapshot(
val isLoaded: Boolean,
Expand Down Expand Up @@ -238,7 +248,7 @@ class ClerkExpoModule : Module() {
override fun definition() = ModuleDefinition {
Name("ClerkExpo")

Events(NATIVE_AUTH_FLOW_CHANGED_EVENT, NATIVE_CLIENT_CHANGED_EVENT)
Events(NATIVE_AUTH_FLOW_CHANGED_EVENT, NATIVE_CLIENT_CHANGED_EVENT, NATIVE_CLIENT_INVALIDATED_EVENT)

OnCreate {
sharedInstance = this@ClerkExpoModule
Expand All @@ -253,6 +263,9 @@ class ClerkExpoModule : Module() {
authFlowStateObserverJob = null
clientStateObserverJob?.cancel()
clientStateObserverJob = null
clientInvalidationObserverJob?.cancel()
clientInvalidationObserverJob = null
mainHandler.removeCallbacksAndMessages(null)
}

AsyncFunction("configure") { pubKey: String, bearerToken: String?, promise: Promise ->
Expand All @@ -263,6 +276,22 @@ class ClerkExpoModule : Module() {
getClientToken(promise)
}

AsyncFunction("configureNative") { pubKey: String, seedDeviceToken: String?, promise: Promise ->
configureNative(pubKey, seedDeviceToken, promise)
}

AsyncFunction("getDeviceToken") { promise: Promise ->
getDeviceToken(promise)
}

AsyncFunction("setDeviceToken") { token: String?, expected: String?, promise: Promise ->
setDeviceToken(token, expected, promise)
}

AsyncFunction("refreshClient") { promise: Promise ->
refreshClient(promise)
}

AsyncFunction("getAuthFlowState") { promise: Promise ->
promise.resolve(authFlowStatePayload())
}
Expand Down Expand Up @@ -615,6 +644,140 @@ class ClerkExpoModule : Module() {
}
}

// MARK: - single-token client sync

private fun configureNative(pubKey: String, seedDeviceToken: String?, promise: Promise) {
val context = reactContext ?: run {
promise.reject("E_CONFIGURE_FAILED", "React context is not available", null)
return
}

coroutineScope.launch {
try {
val activePublishableKey = configuredPublishableKey ?: Clerk.publishableKey
val didConfigure = when {
activePublishableKey == null -> {
Clerk.initialize(context, pubKey, clerkConfigurationOptions())
true
}
activePublishableKey != pubKey -> {
Clerk.switchConfiguration(context, pubKey, clerkConfigurationOptions())
true
}
else -> false
}
if (didConfigure) {
configuredPublishableKey = pubKey
appContext.currentActivity?.let { Clerk.attachActivity(it) }
// Must follow initialize(), which resets customTheme.
loadThemeFromAssets(context)
}
startClientStateObserver()

val didAdoptSeed = adoptSeedDeviceTokenIfNeeded(seedDeviceToken)
startClientInvalidationObserver()
lastObservedClientState = clientStateSnapshot()
if (didAdoptSeed) {
// An initialization refresh started before the seed was stored is fenced off by the token change.
launch {
val result = Clerk.refreshClient()
if (result is ClerkResult.Failure) {
debugLog(TAG, "configureNative - refresh after seed adoption failed: ${result.error}")
}
clientInvalidationTracker.observe(clientFingerprint())
}
}
promise.resolve(null)
} catch (e: Exception) {
promise.reject("E_CONFIGURE_FAILED", "Failed to configure Clerk SDK: ${e.message}", e)
}
}
}

private suspend fun adoptSeedDeviceTokenIfNeeded(seedDeviceToken: String?): Boolean {
val seed = seedDeviceToken?.trim()?.takeIf { it.isNotEmpty() } ?: return false
return withContext(Dispatchers.IO) {
Clerk.getDeviceToken() == null && Clerk.setDeviceToken(seed, null)
}
}

private fun startClientInvalidationObserver() {
clientInvalidationTracker.reset(clientFingerprint())
if (clientInvalidationObserverJob != null) {
return
}

clientInvalidationObserverJob = coroutineScope.launch {
Clerk.clientFlow.collect { client ->
clientInvalidationTracker.observe(clientFingerprint(client))
}
}
}

private fun clientFingerprint(client: Client? = Clerk.clientFlow.value): ClerkClientFingerprint {
return ClerkClientFingerprint.from(client, clientStateSnapshot(client).deviceToken)
}

private fun isClerkConfigured(): Boolean = Clerk.publishableKey != null

private fun getDeviceToken(promise: Promise) {
if (!isClerkConfigured()) {
promise.resolve(null)
return
}
try {
promise.resolve(Clerk.getDeviceToken())
} catch (e: Exception) {
promise.reject("E_GET_DEVICE_TOKEN_FAILED", e.message ?: "Unable to read the device token", e)
}
}

private fun setDeviceToken(token: String?, expected: String?, promise: Promise) {
coroutineScope.launch {
try {
val didSet = withContext(Dispatchers.IO) { Clerk.setDeviceToken(token, expected) }
val fingerprint = clientFingerprint()
if (didSet) {
clientInvalidationTracker.acknowledgeDeviceToken(fingerprint.deviceToken, fingerprint)
} else {
clientInvalidationTracker.observe(fingerprint)
}
promise.resolve(didSet)
} catch (e: Exception) {
val error = clerkSetDeviceTokenBridgeError(e)
promise.reject(error.code, error.message, e)
}
}
}

private fun refreshClient(promise: Promise) {
if (!isClerkConfigured()) {
promise.reject(
"E_NOT_CONFIGURED",
"Clerk must be configured with configureNative before syncing client state.",
null
)
return
}

coroutineScope.launch {
try {
val result = Clerk.refreshClient()
clientInvalidationTracker.observe(clientFingerprint())
when (result) {
is ClerkResult.Success -> promise.resolve(null)
is ClerkResult.Failure -> promise.reject(
"E_REFRESH_CLIENT_FAILED",
result.error?.firstMessage() ?: result.throwable?.message ?: "Client refresh failed",
result.throwable
)
}
} catch (e: Exception) {
promise.reject("E_REFRESH_CLIENT_FAILED", e.message ?: "Client refresh failed", e)
}
}
}

// MARK: - getClientToken

private fun getClientToken(promise: Promise) {
Expand Down
Loading
Loading