33package expo.modules.clerk
44
55import android.content.Context
6+ import android.os.Handler
7+ import android.os.Looper
68import android.util.Log
79import androidx.compose.ui.graphics.Color
810import androidx.compose.ui.unit.dp
@@ -37,12 +39,14 @@ import kotlinx.coroutines.flow.combine
3739import kotlinx.coroutines.flow.distinctUntilChanged
3840import kotlinx.coroutines.flow.first
3941import kotlinx.coroutines.launch
42+ import kotlinx.coroutines.withContext
4043import kotlinx.coroutines.withTimeout
4144import org.json.JSONObject
4245
4346private const val TAG = " ClerkExpoModule"
4447private const val NATIVE_AUTH_FLOW_CHANGED_EVENT = " clerkNativeAuthFlowChanged"
4548private const val NATIVE_CLIENT_CHANGED_EVENT = " clerkNativeClientChanged"
49+ private const val NATIVE_CLIENT_INVALIDATED_EVENT = " clerkNativeClientInvalidated"
4650private const val HOST_SDK_HEADER = " x-clerk-host-sdk"
4751private const val HOST_SDK_VERSION_HEADER = " x-clerk-host-sdk-version"
4852private const val HOST_SDK = " expo"
@@ -204,6 +208,12 @@ class ClerkExpoModule : Module() {
204208 private var lastObservedClientState: ClientStateSnapshot ? = null
205209 private var jsOriginatedClientSyncDepth = 0
206210 private var configuredPublishableKey: String? = null
211+ private val mainHandler = Handler (Looper .getMainLooper())
212+ private var clientInvalidationObserverJob: Job ? = null
213+ private val clientInvalidationTracker = ClerkClientInvalidationTracker (
214+ schedule = { flush -> mainHandler.post(flush) },
215+ emit = { sendEvent(NATIVE_CLIENT_INVALIDATED_EVENT , emptyMap<String , Any ?>()) }
216+ )
207217
208218 private data class AuthFlowStateSnapshot (
209219 val isLoaded : Boolean ,
@@ -238,7 +248,7 @@ class ClerkExpoModule : Module() {
238248 override fun definition () = ModuleDefinition {
239249 Name (" ClerkExpo" )
240250
241- Events (NATIVE_AUTH_FLOW_CHANGED_EVENT , NATIVE_CLIENT_CHANGED_EVENT )
251+ Events (NATIVE_AUTH_FLOW_CHANGED_EVENT , NATIVE_CLIENT_CHANGED_EVENT , NATIVE_CLIENT_INVALIDATED_EVENT )
242252
243253 OnCreate {
244254 sharedInstance = this @ClerkExpoModule
@@ -253,6 +263,9 @@ class ClerkExpoModule : Module() {
253263 authFlowStateObserverJob = null
254264 clientStateObserverJob?.cancel()
255265 clientStateObserverJob = null
266+ clientInvalidationObserverJob?.cancel()
267+ clientInvalidationObserverJob = null
268+ mainHandler.removeCallbacksAndMessages(null )
256269 }
257270
258271 AsyncFunction (" configure" ) { pubKey: String , bearerToken: String? , promise: Promise ->
@@ -263,6 +276,22 @@ class ClerkExpoModule : Module() {
263276 getClientToken(promise)
264277 }
265278
279+ AsyncFunction (" configureNative" ) { pubKey: String , seedDeviceToken: String? , promise: Promise ->
280+ configureNative(pubKey, seedDeviceToken, promise)
281+ }
282+
283+ AsyncFunction (" getDeviceToken" ) { promise: Promise ->
284+ getDeviceToken(promise)
285+ }
286+
287+ AsyncFunction (" setDeviceToken" ) { token: String? , expected: String? , promise: Promise ->
288+ setDeviceToken(token, expected, promise)
289+ }
290+
291+ AsyncFunction (" refreshClient" ) { promise: Promise ->
292+ refreshClient(promise)
293+ }
294+
266295 AsyncFunction (" getAuthFlowState" ) { promise: Promise ->
267296 promise.resolve(authFlowStatePayload())
268297 }
@@ -615,6 +644,140 @@ class ClerkExpoModule : Module() {
615644 }
616645 }
617646
647+ // MARK: - single-token client sync
648+
649+ private fun configureNative (pubKey : String , seedDeviceToken : String? , promise : Promise ) {
650+ val context = reactContext ? : run {
651+ promise.reject(" E_CONFIGURE_FAILED" , " React context is not available" , null )
652+ return
653+ }
654+
655+ coroutineScope.launch {
656+ try {
657+ val activePublishableKey = configuredPublishableKey ? : Clerk .publishableKey
658+ val didConfigure = when {
659+ activePublishableKey == null -> {
660+ Clerk .initialize(context, pubKey, clerkConfigurationOptions())
661+ true
662+ }
663+ activePublishableKey != pubKey -> {
664+ Clerk .switchConfiguration(context, pubKey, clerkConfigurationOptions())
665+ true
666+ }
667+ else -> false
668+ }
669+ if (didConfigure) {
670+ configuredPublishableKey = pubKey
671+ appContext.currentActivity?.let { Clerk .attachActivity(it) }
672+ // Must follow initialize(), which resets customTheme.
673+ loadThemeFromAssets(context)
674+ }
675+ startClientStateObserver()
676+
677+ val didAdoptSeed = adoptSeedDeviceTokenIfNeeded(seedDeviceToken)
678+ startClientInvalidationObserver()
679+ lastObservedClientState = clientStateSnapshot()
680+ if (didAdoptSeed) {
681+ // An initialization refresh started before the seed was stored is fenced off by the token change.
682+ launch {
683+ val result = Clerk .refreshClient()
684+ if (result is ClerkResult .Failure ) {
685+ debugLog(TAG , " configureNative - refresh after seed adoption failed: ${result.error} " )
686+ }
687+ clientInvalidationTracker.observe(clientFingerprint())
688+ }
689+ }
690+ promise.resolve(null )
691+ } catch (e: Exception ) {
692+ promise.reject(" E_CONFIGURE_FAILED" , " Failed to configure Clerk SDK: ${e.message} " , e)
693+ }
694+ }
695+ }
696+
697+ private suspend fun adoptSeedDeviceTokenIfNeeded (seedDeviceToken : String? ): Boolean {
698+ val seed = seedDeviceToken?.trim()?.takeIf { it.isNotEmpty() } ? : return false
699+ return withContext(Dispatchers .IO ) {
700+ Clerk .getDeviceToken() == null && Clerk .setDeviceToken(seed, null )
701+ }
702+ }
703+
704+ private fun startClientInvalidationObserver () {
705+ clientInvalidationTracker.reset(clientFingerprint())
706+ if (clientInvalidationObserverJob != null ) {
707+ return
708+ }
709+
710+ clientInvalidationObserverJob = coroutineScope.launch {
711+ Clerk .clientFlow.collect { client ->
712+ clientInvalidationTracker.observe(clientFingerprint(client))
713+ }
714+ }
715+ }
716+
717+ private fun clientFingerprint (client : Client ? = Clerk .clientFlow.value): ClerkClientFingerprint {
718+ return ClerkClientFingerprint .from(client, clientStateSnapshot(client).deviceToken)
719+ }
720+
721+ private fun isClerkConfigured (): Boolean = Clerk .publishableKey != null
722+
723+ private fun getDeviceToken (promise : Promise ) {
724+ if (! isClerkConfigured()) {
725+ promise.resolve(null )
726+ return
727+ }
728+ try {
729+ promise.resolve(Clerk .getDeviceToken())
730+ } catch (e: Exception ) {
731+ promise.reject(" E_GET_DEVICE_TOKEN_FAILED" , e.message ? : " Unable to read the device token" , e)
732+ }
733+ }
734+
735+ private fun setDeviceToken (token : String? , expected : String? , promise : Promise ) {
736+ coroutineScope.launch {
737+ try {
738+ val didSet = withContext(Dispatchers .IO ) { Clerk .setDeviceToken(token, expected) }
739+ val fingerprint = clientFingerprint()
740+ if (didSet) {
741+ clientInvalidationTracker.acknowledgeDeviceToken(fingerprint.deviceToken, fingerprint)
742+ } else {
743+ clientInvalidationTracker.observe(fingerprint)
744+ }
745+ promise.resolve(didSet)
746+ } catch (e: Exception ) {
747+ val error = clerkSetDeviceTokenBridgeError(e)
748+ promise.reject(error.code, error.message, e)
749+ }
750+ }
751+ }
752+
753+ private fun refreshClient (promise : Promise ) {
754+ if (! isClerkConfigured()) {
755+ promise.reject(
756+ " E_NOT_CONFIGURED" ,
757+ " Clerk must be configured with configureNative before syncing client state." ,
758+ null
759+ )
760+ return
761+ }
762+
763+ coroutineScope.launch {
764+ try {
765+ val result = Clerk .refreshClient()
766+ clientInvalidationTracker.observe(clientFingerprint())
767+ when (result) {
768+ is ClerkResult .Success -> promise.resolve(null )
769+ is ClerkResult .Failure -> promise.reject(
770+ " E_REFRESH_CLIENT_FAILED" ,
771+ result.error?.firstMessage() ? : result.throwable?.message ? : " Client refresh failed" ,
772+ result.throwable
773+ )
774+ }
775+ } catch (e: Exception ) {
776+ promise.reject(" E_REFRESH_CLIENT_FAILED" , e.message ? : " Client refresh failed" , e)
777+ }
778+ }
779+ }
780+
618781 // MARK: - getClientToken
619782
620783 private fun getClientToken (promise : Promise ) {
0 commit comments