@clerk/expo-native-components 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -2
  3. package/android/build.gradle +152 -0
  4. package/android/src/main/AndroidManifest.xml +4 -0
  5. package/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt +241 -0
  6. package/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt +148 -0
  7. package/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +1077 -0
  8. package/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt +156 -0
  9. package/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt +120 -0
  10. package/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt +315 -0
  11. package/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt +326 -0
  12. package/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt +163 -0
  13. package/app.plugin.d.ts +5 -0
  14. package/app.plugin.js +333 -0
  15. package/dist/AuthView.d.ts +35 -0
  16. package/dist/AuthView.d.ts.map +1 -0
  17. package/dist/AuthView.js +79 -0
  18. package/dist/AuthView.js.map +1 -0
  19. package/dist/AuthView.types.d.ts +62 -0
  20. package/dist/AuthView.types.d.ts.map +1 -0
  21. package/dist/AuthView.types.js +0 -0
  22. package/dist/EmbeddedNavigation.types.d.ts +24 -0
  23. package/dist/EmbeddedNavigation.types.d.ts.map +1 -0
  24. package/dist/EmbeddedNavigation.types.js +0 -0
  25. package/dist/UserButton.d.ts +38 -0
  26. package/dist/UserButton.d.ts.map +1 -0
  27. package/dist/UserButton.js +68 -0
  28. package/dist/UserButton.js.map +1 -0
  29. package/dist/UserProfileCustomPages.d.ts +81 -0
  30. package/dist/UserProfileCustomPages.d.ts.map +1 -0
  31. package/dist/UserProfileCustomPages.js +140 -0
  32. package/dist/UserProfileCustomPages.js.map +1 -0
  33. package/dist/UserProfileView.d.ts +76 -0
  34. package/dist/UserProfileView.d.ts.map +1 -0
  35. package/dist/UserProfileView.js +101 -0
  36. package/dist/UserProfileView.js.map +1 -0
  37. package/dist/_virtual/_rolldown/runtime.js +29 -0
  38. package/dist/index.d.ts +41 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +37 -0
  41. package/dist/specs/NativeClerkAuthView.android.d.ts +15 -0
  42. package/dist/specs/NativeClerkAuthView.android.d.ts.map +1 -0
  43. package/dist/specs/NativeClerkAuthView.android.js +8 -0
  44. package/dist/specs/NativeClerkAuthView.android.js.map +1 -0
  45. package/dist/specs/NativeClerkAuthView.d.ts +15 -0
  46. package/dist/specs/NativeClerkAuthView.d.ts.map +1 -0
  47. package/dist/specs/NativeClerkAuthView.js +9 -0
  48. package/dist/specs/NativeClerkAuthView.js.map +1 -0
  49. package/dist/specs/NativeClerkModule.d.ts +4 -0
  50. package/dist/specs/NativeClerkModule.d.ts.map +1 -0
  51. package/dist/specs/NativeClerkModule.js +8 -0
  52. package/dist/specs/NativeClerkModule.js.map +1 -0
  53. package/dist/specs/NativeClerkModule.types.d.ts +11 -0
  54. package/dist/specs/NativeClerkModule.types.d.ts.map +1 -0
  55. package/dist/specs/NativeClerkModule.types.js +0 -0
  56. package/dist/specs/NativeClerkModule.web.d.ts +4 -0
  57. package/dist/specs/NativeClerkModule.web.d.ts.map +1 -0
  58. package/dist/specs/NativeClerkModule.web.js +7 -0
  59. package/dist/specs/NativeClerkModule.web.js.map +1 -0
  60. package/dist/specs/NativeClerkUserButtonView.android.d.ts +6 -0
  61. package/dist/specs/NativeClerkUserButtonView.android.d.ts.map +1 -0
  62. package/dist/specs/NativeClerkUserButtonView.android.js +8 -0
  63. package/dist/specs/NativeClerkUserButtonView.android.js.map +1 -0
  64. package/dist/specs/NativeClerkUserButtonView.d.ts +6 -0
  65. package/dist/specs/NativeClerkUserButtonView.d.ts.map +1 -0
  66. package/dist/specs/NativeClerkUserButtonView.js +9 -0
  67. package/dist/specs/NativeClerkUserButtonView.js.map +1 -0
  68. package/dist/specs/NativeClerkUserProfileView.android.d.ts +13 -0
  69. package/dist/specs/NativeClerkUserProfileView.android.d.ts.map +1 -0
  70. package/dist/specs/NativeClerkUserProfileView.android.js +8 -0
  71. package/dist/specs/NativeClerkUserProfileView.android.js.map +1 -0
  72. package/dist/specs/NativeClerkUserProfileView.d.ts +13 -0
  73. package/dist/specs/NativeClerkUserProfileView.d.ts.map +1 -0
  74. package/dist/specs/NativeClerkUserProfileView.js +9 -0
  75. package/dist/specs/NativeClerkUserProfileView.js.map +1 -0
  76. package/dist/useAuthViewState.d.ts +12 -0
  77. package/dist/useAuthViewState.d.ts.map +1 -0
  78. package/dist/useAuthViewState.js +76 -0
  79. package/dist/useAuthViewState.js.map +1 -0
  80. package/dist/utils/native-module.d.ts +3 -0
  81. package/dist/utils/native-module.d.ts.map +1 -0
  82. package/dist/utils/native-module.js +14 -0
  83. package/dist/utils/native-module.js.map +1 -0
  84. package/expo-module.config.json +15 -0
  85. package/ios/ClerkAppDelegateSubscriber.swift +16 -0
  86. package/ios/ClerkAuthNativeView.swift +157 -0
  87. package/ios/ClerkExpo.podspec +67 -0
  88. package/ios/ClerkExpoModule.swift +315 -0
  89. package/ios/ClerkNativeBridge.swift +1575 -0
  90. package/ios/ClerkNativeViewHost.swift +204 -0
  91. package/ios/ClerkUserButtonNativeView.swift +34 -0
  92. package/ios/ClerkUserProfileNativeView.swift +92 -0
  93. package/ios/Tests/ClerkAuthNativeViewPaperTests.swift +30 -0
  94. package/ios/Tests/ClerkNativeBridgeTests.swift +185 -0
  95. package/ios/Tests/ClerkNativeViewHostTests.swift +66 -0
  96. package/ios/Tests/ClerkUserProfileCustomPageStateTests.swift +311 -0
  97. package/package.json +79 -3
  98. package/react-native.config.js +8 -0
  99. package/src/specs/NativeClerkAuthView.android.ts +15 -0
  100. package/src/specs/NativeClerkAuthView.ts +19 -0
  101. package/src/specs/NativeClerkModule.ts +6 -0
  102. package/src/specs/NativeClerkModule.types.ts +9 -0
  103. package/src/specs/NativeClerkModule.web.ts +3 -0
  104. package/src/specs/NativeClerkUserButtonView.android.ts +9 -0
  105. package/src/specs/NativeClerkUserButtonView.ts +11 -0
  106. package/src/specs/NativeClerkUserProfileView.android.ts +13 -0
  107. package/src/specs/NativeClerkUserProfileView.ts +17 -0
  108. package/src/specs/__tests__/native-view.web.test.ts +43 -0
@@ -0,0 +1,1077 @@
1
+ @file:OptIn(FrameworkIntegrationApi::class)
2
+
3
+ package expo.modules.clerk
4
+
5
+ import android.content.Context
6
+ import android.util.Log
7
+ import androidx.compose.ui.graphics.Color
8
+ import androidx.compose.ui.unit.dp
9
+ import com.clerk.api.Clerk
10
+ import com.clerk.api.ClerkConfigurationOptions
11
+ import com.clerk.api.FrameworkIntegrationApi
12
+ import com.clerk.api.network.model.client.Client
13
+ import com.clerk.api.network.model.error.ClerkErrorResponse
14
+ import com.clerk.api.network.model.error.firstMessage
15
+ import com.clerk.api.network.serialization.ClerkResult
16
+ import com.clerk.api.signin.SignIn
17
+ import com.clerk.api.biometriccredential.BiometricCredential
18
+ import com.clerk.api.biometriccredential.BiometricCredentialAvailability
19
+ import com.clerk.api.biometriccredential.BiometricCredentialKeyManagerException
20
+ import com.clerk.api.biometriccredential.BiometricCredentialPolicy
21
+ import com.clerk.api.session.SessionVerification
22
+ import com.clerk.api.session.startVerification
23
+ import com.clerk.api.session.verifyWithBiometrics
24
+ import com.clerk.api.ui.ClerkColors
25
+ import com.clerk.api.ui.ClerkDesign
26
+ import com.clerk.api.ui.ClerkTheme
27
+ import expo.modules.kotlin.Promise
28
+ import expo.modules.kotlin.modules.Module
29
+ import expo.modules.kotlin.modules.ModuleDefinition
30
+ import kotlinx.coroutines.CoroutineScope
31
+ import kotlinx.coroutines.Dispatchers
32
+ import kotlinx.coroutines.Job
33
+ import kotlinx.coroutines.TimeoutCancellationException
34
+ import kotlinx.coroutines.currentCoroutineContext
35
+ import kotlinx.coroutines.ensureActive
36
+ import kotlinx.coroutines.flow.combine
37
+ import kotlinx.coroutines.flow.distinctUntilChanged
38
+ import kotlinx.coroutines.flow.first
39
+ import kotlinx.coroutines.launch
40
+ import kotlinx.coroutines.withTimeout
41
+ import org.json.JSONObject
42
+
43
+ private const val TAG = "ClerkExpoModule"
44
+ private const val NATIVE_AUTH_FLOW_CHANGED_EVENT = "clerkNativeAuthFlowChanged"
45
+ private const val NATIVE_CLIENT_CHANGED_EVENT = "clerkNativeClientChanged"
46
+ private const val HOST_SDK_HEADER = "x-clerk-host-sdk"
47
+ private const val HOST_SDK_VERSION_HEADER = "x-clerk-host-sdk-version"
48
+ private const val HOST_SDK = "expo"
49
+
50
+ private fun debugLog(tag: String, message: String) {
51
+ if (BuildConfig.DEBUG) {
52
+ Log.d(tag, message)
53
+ }
54
+ }
55
+
56
+ internal fun biometricCredentialAvailabilityPayload(
57
+ availability: BiometricCredentialAvailability
58
+ ): Map<String, Any?> {
59
+ return mapOf(
60
+ "isAvailable" to availability.isAvailable,
61
+ "unavailableReason" to availability.unavailableReason?.name?.lowercase()
62
+ )
63
+ }
64
+
65
+ internal fun biometricCredentialEnvironmentAvailabilityPayload(
66
+ isInitialized: Boolean
67
+ ): Map<String, Any?>? {
68
+ if (isInitialized) {
69
+ return null
70
+ }
71
+
72
+ return mapOf(
73
+ "isAvailable" to false,
74
+ "unavailableReason" to "environment_unavailable"
75
+ )
76
+ }
77
+
78
+ internal fun biometricCredentialPayload(biometricCredential: BiometricCredential): Map<String, Any?> {
79
+ return mapOf(
80
+ "id" to biometricCredential.id,
81
+ "object" to "trusted_device",
82
+ "platform" to biometricCredential.platform.name.lowercase(),
83
+ "appIdentifier" to biometricCredential.appIdentifier,
84
+ "name" to biometricCredential.name,
85
+ "algorithm" to biometricCredential.algorithm,
86
+ "status" to biometricCredential.status.name.lowercase(),
87
+ "createdAt" to biometricCredential.createdAt,
88
+ "updatedAt" to biometricCredential.updatedAt,
89
+ "lastUsedAt" to biometricCredential.lastUsedAt,
90
+ "revokedAt" to biometricCredential.revokedAt
91
+ )
92
+ }
93
+
94
+ internal fun biometricSignInPayload(signIn: SignIn): Map<String, Any?> {
95
+ return mapOf(
96
+ "id" to signIn.id,
97
+ "status" to signIn.status.name.lowercase(),
98
+ "createdSessionId" to signIn.createdSessionId
99
+ )
100
+ }
101
+
102
+ internal fun biometricCredentialPolicy(policy: String): BiometricCredentialPolicy? {
103
+ return when (policy) {
104
+ "biometry_current_set" -> BiometricCredentialPolicy.BIOMETRY_CURRENT_SET
105
+ "biometry_any" -> BiometricCredentialPolicy.BIOMETRY_ANY
106
+ "biometry_or_device_passcode" -> BiometricCredentialPolicy.BIOMETRY_OR_DEVICE_PASSCODE
107
+ else -> null
108
+ }
109
+ }
110
+
111
+ internal fun biometricReverificationLevel(level: String): SessionVerification.Level? = when (level) {
112
+ "first_factor" -> SessionVerification.Level.FIRST_FACTOR
113
+ "second_factor" -> SessionVerification.Level.SECOND_FACTOR
114
+ "multi_factor" -> SessionVerification.Level.MULTI_FACTOR
115
+ else -> null
116
+ }
117
+
118
+ internal fun biometricReverificationPayload(
119
+ verification: SessionVerification,
120
+ sessionId: String
121
+ ): Map<String, Any?> = mapOf(
122
+ "id" to verification.id,
123
+ "status" to verification.status.name.lowercase(),
124
+ "level" to verification.level.value,
125
+ "sessionId" to (verification.session?.id ?: sessionId)
126
+ )
127
+
128
+ internal suspend fun verifyBiometricReverification(
129
+ started: SessionVerification,
130
+ verify: suspend (SessionVerification.Level) -> ClerkResult<SessionVerification, ClerkErrorResponse>
131
+ ): ClerkResult<SessionVerification, ClerkErrorResponse> = when (started.status) {
132
+ SessionVerification.Status.NEEDS_FIRST_FACTOR -> {
133
+ val result = verify(SessionVerification.Level.FIRST_FACTOR)
134
+ if (result is ClerkResult.Success && result.value.status == SessionVerification.Status.NEEDS_SECOND_FACTOR) {
135
+ currentCoroutineContext().ensureActive()
136
+ verify(SessionVerification.Level.SECOND_FACTOR)
137
+ } else {
138
+ result
139
+ }
140
+ }
141
+ SessionVerification.Status.NEEDS_SECOND_FACTOR -> verify(SessionVerification.Level.SECOND_FACTOR)
142
+ SessionVerification.Status.COMPLETE -> ClerkResult.success(started)
143
+ SessionVerification.Status.UNKNOWN -> ClerkResult.unknownFailure(
144
+ IllegalStateException("The server returned an unsupported reverification status.")
145
+ )
146
+ }
147
+
148
+ internal data class BiometricCredentialBridgeError(
149
+ val code: String,
150
+ val message: String
151
+ )
152
+
153
+ internal fun biometricCredentialEnvironmentError(isInitialized: Boolean): BiometricCredentialBridgeError? {
154
+ if (isInitialized) {
155
+ return null
156
+ }
157
+
158
+ return BiometricCredentialBridgeError(
159
+ code = "environment_unavailable",
160
+ message = "Biometric credential operations are unavailable until Clerk finishes configuring."
161
+ )
162
+ }
163
+
164
+ internal fun biometricCredentialKeyManagerErrorCode(
165
+ code: BiometricCredentialKeyManagerException.Code
166
+ ): String = code.name.lowercase()
167
+
168
+ internal fun biometricCredentialBridgeError(
169
+ throwable: Throwable,
170
+ fallbackCode: String,
171
+ fallbackMessage: String
172
+ ): BiometricCredentialBridgeError {
173
+ val keyManagerError = throwable as? BiometricCredentialKeyManagerException
174
+ return BiometricCredentialBridgeError(
175
+ code = keyManagerError?.code?.let(::biometricCredentialKeyManagerErrorCode) ?: fallbackCode,
176
+ message = throwable.message ?: fallbackMessage
177
+ )
178
+ }
179
+
180
+ internal fun biometricCredentialBridgeError(
181
+ failure: ClerkResult.Failure<ClerkErrorResponse>,
182
+ fallbackCode: String,
183
+ fallbackMessage: String
184
+ ): BiometricCredentialBridgeError {
185
+ val apiError = failure.error?.errors?.firstOrNull()
186
+ val throwable = failure.throwable
187
+ val keyManagerError = throwable as? BiometricCredentialKeyManagerException
188
+
189
+ return BiometricCredentialBridgeError(
190
+ code = apiError?.code
191
+ ?: keyManagerError?.code?.let(::biometricCredentialKeyManagerErrorCode)
192
+ ?: fallbackCode,
193
+ message = apiError?.longMessage
194
+ ?: apiError?.message
195
+ ?: throwable?.message
196
+ ?: fallbackMessage
197
+ )
198
+ }
199
+
200
+ class ClerkExpoModule : Module() {
201
+ private val coroutineScope = CoroutineScope(Dispatchers.Main)
202
+ private var authFlowStateObserverJob: Job? = null
203
+ private var clientStateObserverJob: Job? = null
204
+ private var lastObservedClientState: ClientStateSnapshot? = null
205
+ private var jsOriginatedClientSyncDepth = 0
206
+ private var configuredPublishableKey: String? = null
207
+
208
+ private data class AuthFlowStateSnapshot(
209
+ val isLoaded: Boolean,
210
+ val isAuthFlowComplete: Boolean
211
+ )
212
+
213
+ private data class ClientStateSnapshot(
214
+ val client: Client?,
215
+ val deviceToken: String?
216
+ )
217
+
218
+ private data class ClientStateChanges(
219
+ val client: Boolean,
220
+ val deviceToken: Boolean
221
+ )
222
+
223
+ companion object {
224
+ private var sharedInstance: ClerkExpoModule? = null
225
+
226
+ fun emitClientChanged(sourceId: String? = null) {
227
+ val instance = sharedInstance ?: return
228
+ instance.sendEvent(
229
+ NATIVE_CLIENT_CHANGED_EVENT,
230
+ instance.clientChangedPayload(
231
+ sourceId = sourceId,
232
+ changes = ClientStateChanges(client = true, deviceToken = true)
233
+ )
234
+ )
235
+ }
236
+ }
237
+
238
+ override fun definition() = ModuleDefinition {
239
+ Name("ClerkExpo")
240
+
241
+ Events(NATIVE_AUTH_FLOW_CHANGED_EVENT, NATIVE_CLIENT_CHANGED_EVENT)
242
+
243
+ OnCreate {
244
+ sharedInstance = this@ClerkExpoModule
245
+ startAuthFlowStateObserver()
246
+ }
247
+
248
+ OnDestroy {
249
+ if (sharedInstance === this@ClerkExpoModule) {
250
+ sharedInstance = null
251
+ }
252
+ authFlowStateObserverJob?.cancel()
253
+ authFlowStateObserverJob = null
254
+ clientStateObserverJob?.cancel()
255
+ clientStateObserverJob = null
256
+ }
257
+
258
+ AsyncFunction("configure") { pubKey: String, bearerToken: String?, promise: Promise ->
259
+ configure(pubKey, bearerToken, promise)
260
+ }
261
+
262
+ AsyncFunction("getClientToken") { promise: Promise ->
263
+ getClientToken(promise)
264
+ }
265
+
266
+ AsyncFunction("getAuthFlowState") { promise: Promise ->
267
+ promise.resolve(authFlowStatePayload())
268
+ }
269
+
270
+ AsyncFunction("syncClientStateFromJs") {
271
+ deviceToken: String?,
272
+ sourceId: String?,
273
+ didChangeClient: Boolean,
274
+ didChangeDeviceToken: Boolean,
275
+ promise: Promise ->
276
+ syncClientStateFromJs(
277
+ deviceToken,
278
+ sourceId,
279
+ didChangeClient,
280
+ didChangeDeviceToken,
281
+ promise
282
+ )
283
+ }
284
+
285
+ AsyncFunction("getTrustedDeviceAvailability") {
286
+ id: String?,
287
+ identifierHint: String?,
288
+ promise: Promise ->
289
+ getBiometricCredentialAvailability(id, identifierHint, promise)
290
+ }
291
+
292
+ AsyncFunction("listTrustedDevices") { promise: Promise ->
293
+ listBiometricCredentials(promise)
294
+ }
295
+
296
+ AsyncFunction("enrollTrustedDevice") {
297
+ deviceName: String?,
298
+ identifierHint: String?,
299
+ reason: String?,
300
+ policy: String,
301
+ promise: Promise ->
302
+ enrollBiometricCredential(deviceName, identifierHint, reason, policy, promise)
303
+ }
304
+
305
+ AsyncFunction("revokeTrustedDevice") { id: String, promise: Promise ->
306
+ revokeBiometricCredential(id, promise)
307
+ }
308
+
309
+ AsyncFunction("signInWithTrustedDevice") {
310
+ id: String?,
311
+ identifierHint: String?,
312
+ reason: String?,
313
+ promise: Promise ->
314
+ signInWithBiometrics(id, identifierHint, reason, promise)
315
+ }
316
+
317
+ AsyncFunction("reverifyWithBiometrics") {
318
+ sessionId: String,
319
+ level: String,
320
+ reason: String?,
321
+ promise: Promise ->
322
+ reverifyWithBiometrics(sessionId, level, reason, promise)
323
+ }
324
+ }
325
+
326
+ private val reactContext: Context?
327
+ get() = appContext.reactContext
328
+
329
+ private fun clerkConfigurationOptions(): ClerkConfigurationOptions {
330
+ val hostSdkVersion = BuildConfig.CLERK_EXPO_VERSION.trim()
331
+ val customHeaders = buildMap {
332
+ put(HOST_SDK_HEADER, HOST_SDK)
333
+ if (hostSdkVersion.isNotEmpty()) {
334
+ put(HOST_SDK_VERSION_HEADER, hostSdkVersion)
335
+ }
336
+ }
337
+
338
+ // JS owns client state. The native foreground refresh races SSO completion and mints duplicate clients (#9217).
339
+ return ClerkConfigurationOptions()
340
+ .withForegroundRefreshDisabled()
341
+ .withCustomHeaders(customHeaders)
342
+ }
343
+
344
+ private fun startAuthFlowStateObserver() {
345
+ if (authFlowStateObserverJob != null) {
346
+ return
347
+ }
348
+
349
+ authFlowStateObserverJob = coroutineScope.launch {
350
+ combine(Clerk.isInitialized, Clerk.isAuthFlowCompleteFlow) { isLoaded, isAuthFlowComplete ->
351
+ AuthFlowStateSnapshot(
352
+ isLoaded = isLoaded,
353
+ isAuthFlowComplete = isLoaded && isAuthFlowComplete
354
+ )
355
+ }
356
+ .distinctUntilChanged()
357
+ .collect { state ->
358
+ sendEvent(NATIVE_AUTH_FLOW_CHANGED_EVENT, authFlowStatePayload(state))
359
+ }
360
+ }
361
+ }
362
+
363
+ private fun authFlowStatePayload(
364
+ state: AuthFlowStateSnapshot = AuthFlowStateSnapshot(
365
+ isLoaded = Clerk.isInitialized.value,
366
+ isAuthFlowComplete = Clerk.isInitialized.value && Clerk.isAuthFlowComplete
367
+ )
368
+ ): Map<String, Boolean> {
369
+ return mapOf(
370
+ "isLoaded" to state.isLoaded,
371
+ "isAuthFlowComplete" to state.isAuthFlowComplete
372
+ )
373
+ }
374
+
375
+ private fun startClientStateObserver() {
376
+ if (clientStateObserverJob != null) {
377
+ return
378
+ }
379
+
380
+ lastObservedClientState = clientStateSnapshot()
381
+
382
+ clientStateObserverJob = coroutineScope.launch {
383
+ Clerk.clientFlow.collect { client ->
384
+ val previousClientState = lastObservedClientState
385
+ val newClientState = clientStateSnapshot(client)
386
+
387
+ if (newClientState == previousClientState) {
388
+ return@collect
389
+ }
390
+
391
+ lastObservedClientState = newClientState
392
+ if (jsOriginatedClientSyncDepth > 0) {
393
+ return@collect
394
+ }
395
+
396
+ sendEvent(
397
+ NATIVE_CLIENT_CHANGED_EVENT,
398
+ clientChangedPayload(
399
+ deviceToken = newClientState.deviceToken,
400
+ changes = ClientStateChanges(
401
+ client = newClientState.client != previousClientState?.client,
402
+ deviceToken = newClientState.deviceToken != previousClientState?.deviceToken
403
+ )
404
+ )
405
+ )
406
+ }
407
+ }
408
+ }
409
+
410
+ private fun clientStateSnapshot(client: Client? = Clerk.clientFlow.value): ClientStateSnapshot {
411
+ return ClientStateSnapshot(
412
+ client = client,
413
+ deviceToken = try {
414
+ Clerk.getDeviceToken()
415
+ } catch (e: Exception) {
416
+ debugLog(TAG, "clientStateSnapshot - getDeviceToken failed: ${e.message}")
417
+ null
418
+ }
419
+ )
420
+ }
421
+
422
+ private fun clientChangedPayload(
423
+ sourceId: String? = null,
424
+ changes: ClientStateChanges,
425
+ deviceToken: String? = clientStateSnapshot().deviceToken
426
+ ): Map<String, Any?> {
427
+ val result = mutableMapOf<String, Any?>(
428
+ "changed" to mapOf(
429
+ "client" to changes.client,
430
+ "deviceToken" to changes.deviceToken
431
+ ),
432
+ "deviceToken" to deviceToken
433
+ )
434
+ if (!sourceId.isNullOrEmpty()) {
435
+ result["sourceId"] = sourceId
436
+ }
437
+ return result
438
+ }
439
+
440
+ private fun emitSyncedClientChanged(
441
+ sourceId: String?,
442
+ changes: ClientStateChanges,
443
+ snapshot: ClientStateSnapshot = clientStateSnapshot()
444
+ ) {
445
+ lastObservedClientState = snapshot
446
+ sendEvent(
447
+ NATIVE_CLIENT_CHANGED_EVENT,
448
+ clientChangedPayload(
449
+ sourceId = sourceId,
450
+ changes = changes,
451
+ deviceToken = snapshot.deviceToken
452
+ )
453
+ )
454
+ }
455
+
456
+ // MARK: - configure
457
+
458
+ private fun configure(pubKey: String, bearerToken: String?, promise: Promise) {
459
+ val context = reactContext ?: run {
460
+ promise.reject("E_INIT_FAILED", "React context is not available", null)
461
+ return
462
+ }
463
+
464
+ coroutineScope.launch {
465
+ try {
466
+ val normalizedBearerToken = bearerToken?.trim()?.takeIf { it.isNotEmpty() }
467
+
468
+ if (!Clerk.isInitialized.value) {
469
+ // First-time initialization — write the bearer token to SharedPreferences
470
+ // before initializing so the SDK boots with the correct client.
471
+ if (normalizedBearerToken != null) {
472
+ context.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
473
+ .edit()
474
+ .putString("DEVICE_TOKEN", normalizedBearerToken)
475
+ .apply()
476
+ }
477
+
478
+ Clerk.initialize(context, pubKey, clerkConfigurationOptions())
479
+ startClientStateObserver()
480
+ // clerk-android registers ActivityLifecycleCallbacks during
481
+ // initialize(), but in React Native MainActivity has already passed
482
+ // onResume() by the time <ClerkProvider> mounts and we reach this
483
+ // line, so the callbacks miss the initial activity. Without seeding,
484
+ // the first Credential Manager call (Google sign-in / passkeys)
485
+ // fails with MissingActivity until the user backgrounds and
486
+ // foregrounds the app. currentActivity can be null here on
487
+ // cold start before React's host-resume sync — AuthView and
488
+ // UserProfile also call attachActivity() on mount as a backstop.
489
+ appContext.currentActivity?.let { Clerk.attachActivity(it) }
490
+ // Must be set AFTER Clerk.initialize() because initialize()
491
+ // resets customTheme to its `theme` parameter (default null).
492
+ loadThemeFromAssets(context)
493
+
494
+ // Wait for initialization to complete with timeout
495
+ try {
496
+ withTimeout(10_000L) {
497
+ Clerk.isInitialized.first { it }
498
+ }
499
+ // If a bearer token was provided, wait for native client state to hydrate
500
+ // before resolving the configure call.
501
+ if (normalizedBearerToken != null) {
502
+ withTimeout(5_000L) {
503
+ Clerk.clientFlow.first { it != null }
504
+ }
505
+ }
506
+ } catch (e: TimeoutCancellationException) {
507
+ val initError = Clerk.initializationError.value
508
+ val message = if (initError != null) {
509
+ "Clerk initialization timed out: ${initError.message}"
510
+ } else {
511
+ "Clerk initialization timed out after 10 seconds"
512
+ }
513
+ promise.reject("E_TIMEOUT", message, null)
514
+ return@launch
515
+ }
516
+
517
+ // Check for initialization errors
518
+ val error = Clerk.initializationError.value
519
+ if (error != null) {
520
+ promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}", null)
521
+ } else {
522
+ configuredPublishableKey = pubKey
523
+ lastObservedClientState = clientStateSnapshot()
524
+ promise.resolve(null)
525
+ }
526
+ return@launch
527
+ }
528
+
529
+ val activePublishableKey = configuredPublishableKey ?: Clerk.publishableKey
530
+ if (activePublishableKey != null && activePublishableKey != pubKey) {
531
+ Clerk.switchConfiguration(context, pubKey, clerkConfigurationOptions())
532
+ startClientStateObserver()
533
+ appContext.currentActivity?.let { Clerk.attachActivity(it) }
534
+ loadThemeFromAssets(context)
535
+
536
+ try {
537
+ withTimeout(10_000L) {
538
+ Clerk.isInitialized.first { it }
539
+ }
540
+ } catch (e: TimeoutCancellationException) {
541
+ val initError = Clerk.initializationError.value
542
+ val message = if (initError != null) {
543
+ "Clerk reconfiguration timed out: ${initError.message}"
544
+ } else {
545
+ "Clerk reconfiguration timed out after 10 seconds"
546
+ }
547
+ promise.reject("E_TIMEOUT", message, null)
548
+ return@launch
549
+ }
550
+
551
+ val error = Clerk.initializationError.value
552
+ if (error != null) {
553
+ promise.reject("E_RECONFIGURE_FAILED", "Failed to reconfigure Clerk SDK: ${error.message}", null)
554
+ return@launch
555
+ }
556
+
557
+ if (normalizedBearerToken != null) {
558
+ val clientState = clientStateSnapshot()
559
+ if (clientState.deviceToken != normalizedBearerToken || clientState.client == null) {
560
+ val result = Clerk.updateDeviceToken(normalizedBearerToken)
561
+ if (result is ClerkResult.Failure) {
562
+ debugLog(TAG, "configure - updateDeviceToken after reconfigure failed: ${result.error}")
563
+ }
564
+ }
565
+
566
+ try {
567
+ withTimeout(5_000L) {
568
+ Clerk.clientFlow.first { it != null }
569
+ }
570
+ } catch (_: TimeoutCancellationException) {
571
+ debugLog(TAG, "configure - client did not appear after reconfigure token update")
572
+ }
573
+ }
574
+
575
+ configuredPublishableKey = pubKey
576
+ lastObservedClientState = clientStateSnapshot()
577
+ promise.resolve(null)
578
+ return@launch
579
+ }
580
+
581
+ // Already initialized — use the public SDK API to update
582
+ // the device token and trigger a client/environment refresh.
583
+ startClientStateObserver()
584
+ if (normalizedBearerToken != null) {
585
+ val clientState = clientStateSnapshot()
586
+ val result = if (
587
+ clientState.deviceToken != normalizedBearerToken ||
588
+ clientState.client == null
589
+ ) {
590
+ Clerk.updateDeviceToken(normalizedBearerToken)
591
+ } else {
592
+ // A remounted JS runtime can have the same token while native
593
+ // client state is stale, so preserve one refresh in that case.
594
+ Clerk.refreshClient()
595
+ }
596
+ if (result is ClerkResult.Failure) {
597
+ debugLog(TAG, "configure - client refresh failed: ${result.error}")
598
+ }
599
+
600
+ // Wait for client state to hydrate with the new token (up to 5s).
601
+ try {
602
+ withTimeout(5_000L) {
603
+ Clerk.clientFlow.first { it != null }
604
+ }
605
+ } catch (_: TimeoutCancellationException) {
606
+ debugLog(TAG, "configure - client did not appear after token update")
607
+ }
608
+ }
609
+
610
+ lastObservedClientState = clientStateSnapshot()
611
+ promise.resolve(null)
612
+ } catch (e: Exception) {
613
+ promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${e.message}", e)
614
+ }
615
+ }
616
+ }
617
+
618
+ // MARK: - getClientToken
619
+
620
+ private fun getClientToken(promise: Promise) {
621
+ try {
622
+ // Use the SDK's public API which handles encrypted storage transparently.
623
+ // Direct SharedPreferences reads break on clerk-android >= 1.0.11 where
624
+ // DEVICE_TOKEN is encrypted via StorageCipher.
625
+ val deviceToken = Clerk.getDeviceToken()
626
+ promise.resolve(deviceToken)
627
+ } catch (e: Exception) {
628
+ debugLog(TAG, "getClientToken failed: ${e.message}")
629
+ promise.resolve(null)
630
+ }
631
+ }
632
+
633
+ // MARK: - biometric credentials
634
+
635
+ private fun getBiometricCredentialAvailability(
636
+ id: String?,
637
+ identifierHint: String?,
638
+ promise: Promise
639
+ ) {
640
+ val environmentAvailability = biometricCredentialEnvironmentAvailabilityPayload(Clerk.isInitialized.value)
641
+ if (environmentAvailability != null) {
642
+ promise.resolve(environmentAvailability)
643
+ return
644
+ }
645
+
646
+ coroutineScope.launch {
647
+ try {
648
+ val availability = Clerk.biometricCredentials.availability(id, identifierHint)
649
+ promise.resolve(biometricCredentialAvailabilityPayload(availability))
650
+ } catch (e: Exception) {
651
+ rejectBiometricCredentialException(
652
+ promise = promise,
653
+ fallbackCode = "E_TRUSTED_DEVICE_AVAILABILITY_FAILED",
654
+ fallbackMessage = "Unable to determine biometric-credential availability",
655
+ exception = e
656
+ )
657
+ }
658
+ }
659
+ }
660
+
661
+ private fun listBiometricCredentials(promise: Promise) {
662
+ if (!requireBiometricCredentialEnvironment(promise)) {
663
+ return
664
+ }
665
+
666
+ coroutineScope.launch {
667
+ try {
668
+ when (val result = Clerk.biometricCredentials.list()) {
669
+ is ClerkResult.Success -> promise.resolve(result.value.map(::biometricCredentialPayload))
670
+ is ClerkResult.Failure -> rejectBiometricCredentialFailure(
671
+ promise,
672
+ "E_TRUSTED_DEVICE_LIST_FAILED",
673
+ "Unable to list biometric credentials",
674
+ result
675
+ )
676
+ }
677
+ } catch (e: Exception) {
678
+ rejectBiometricCredentialException(
679
+ promise = promise,
680
+ fallbackCode = "E_TRUSTED_DEVICE_LIST_FAILED",
681
+ fallbackMessage = "Unable to list biometric credentials",
682
+ exception = e
683
+ )
684
+ }
685
+ }
686
+ }
687
+
688
+ private fun enrollBiometricCredential(
689
+ deviceName: String?,
690
+ identifierHint: String?,
691
+ reason: String?,
692
+ policy: String,
693
+ promise: Promise
694
+ ) {
695
+ if (!requireBiometricCredentialEnvironment(promise)) {
696
+ return
697
+ }
698
+
699
+ val biometricCredentialPolicy = biometricCredentialPolicy(policy)
700
+ if (biometricCredentialPolicy == null) {
701
+ promise.reject(
702
+ "invalid_trusted_device_policy",
703
+ "Invalid biometric-credential policy: $policy",
704
+ null
705
+ )
706
+ return
707
+ }
708
+
709
+ coroutineScope.launch {
710
+ try {
711
+ if (!attachCurrentActivityForBiometricCredential(promise)) {
712
+ return@launch
713
+ }
714
+ when (
715
+ val result = Clerk.biometricCredentials.enroll(
716
+ name = deviceName,
717
+ identifierHint = identifierHint,
718
+ policy = biometricCredentialPolicy,
719
+ promptSubtitle = reason
720
+ )
721
+ ) {
722
+ is ClerkResult.Success -> promise.resolve(biometricCredentialPayload(result.value))
723
+ is ClerkResult.Failure -> rejectBiometricCredentialFailure(
724
+ promise,
725
+ "E_TRUSTED_DEVICE_ENROLLMENT_FAILED",
726
+ "Unable to enroll biometric credential",
727
+ result
728
+ )
729
+ }
730
+ } catch (e: Exception) {
731
+ rejectBiometricCredentialException(
732
+ promise = promise,
733
+ fallbackCode = "E_TRUSTED_DEVICE_ENROLLMENT_FAILED",
734
+ fallbackMessage = "Unable to enroll biometric credential",
735
+ exception = e
736
+ )
737
+ }
738
+ }
739
+ }
740
+
741
+ private fun revokeBiometricCredential(id: String, promise: Promise) {
742
+ if (!requireBiometricCredentialEnvironment(promise)) {
743
+ return
744
+ }
745
+
746
+ coroutineScope.launch {
747
+ try {
748
+ when (val result = Clerk.biometricCredentials.revoke(id)) {
749
+ is ClerkResult.Success -> promise.resolve(biometricCredentialPayload(result.value))
750
+ is ClerkResult.Failure -> rejectBiometricCredentialFailure(
751
+ promise,
752
+ "E_TRUSTED_DEVICE_REVOCATION_FAILED",
753
+ "Unable to revoke biometric credential",
754
+ result
755
+ )
756
+ }
757
+ } catch (e: Exception) {
758
+ rejectBiometricCredentialException(
759
+ promise = promise,
760
+ fallbackCode = "E_TRUSTED_DEVICE_REVOCATION_FAILED",
761
+ fallbackMessage = "Unable to revoke biometric credential",
762
+ exception = e
763
+ )
764
+ }
765
+ }
766
+ }
767
+
768
+ private fun signInWithBiometrics(
769
+ id: String?,
770
+ identifierHint: String?,
771
+ reason: String?,
772
+ promise: Promise
773
+ ) {
774
+ if (!requireBiometricCredentialEnvironment(promise)) {
775
+ return
776
+ }
777
+
778
+ coroutineScope.launch {
779
+ try {
780
+ if (!attachCurrentActivityForBiometricCredential(promise)) {
781
+ return@launch
782
+ }
783
+ when (
784
+ val result = Clerk.biometricCredentials.signIn(
785
+ id = id,
786
+ identifierHint = identifierHint,
787
+ promptSubtitle = reason
788
+ )
789
+ ) {
790
+ is ClerkResult.Success -> promise.resolve(biometricSignInPayload(result.value))
791
+ is ClerkResult.Failure -> rejectBiometricCredentialFailure(
792
+ promise,
793
+ "E_TRUSTED_DEVICE_SIGN_IN_FAILED",
794
+ "Unable to sign in with biometric credential",
795
+ result
796
+ )
797
+ }
798
+ } catch (e: Exception) {
799
+ rejectBiometricCredentialException(
800
+ promise = promise,
801
+ fallbackCode = "E_TRUSTED_DEVICE_SIGN_IN_FAILED",
802
+ fallbackMessage = "Unable to sign in with biometric credential",
803
+ exception = e
804
+ )
805
+ }
806
+ }
807
+ }
808
+
809
+ private fun reverifyWithBiometrics(
810
+ sessionId: String,
811
+ level: String,
812
+ reason: String?,
813
+ promise: Promise
814
+ ) {
815
+ if (!requireBiometricCredentialEnvironment(promise)) return
816
+ val requestedLevel = biometricReverificationLevel(level)
817
+ if (requestedLevel == null) {
818
+ promise.reject("invalid_reverification_level", "Invalid biometric reverification level: $level", null)
819
+ return
820
+ }
821
+ coroutineScope.launch {
822
+ try {
823
+ val session = Clerk.clientFlow.value?.sessions?.firstOrNull { it.id == sessionId }
824
+ if (session == null) {
825
+ promise.reject(
826
+ "biometric_reverification_session_unavailable",
827
+ "The session to reverify is unavailable in the native Clerk client.",
828
+ null
829
+ )
830
+ return@launch
831
+ }
832
+ if (!attachCurrentActivityForBiometricCredential(promise)) return@launch
833
+ val started = when (val result = session.startVerification(requestedLevel)) {
834
+ is ClerkResult.Success -> result.value
835
+ is ClerkResult.Failure -> {
836
+ rejectBiometricCredentialFailure(promise, "E_BIOMETRIC_REVERIFICATION_FAILED",
837
+ "Unable to start biometric reverification", result)
838
+ return@launch
839
+ }
840
+ }
841
+ val result = verifyBiometricReverification(started) { factor ->
842
+ session.verifyWithBiometrics(promptSubtitle = reason, level = factor)
843
+ }
844
+ when (result) {
845
+ is ClerkResult.Success -> promise.resolve(biometricReverificationPayload(result.value, sessionId))
846
+ is ClerkResult.Failure -> rejectBiometricCredentialFailure(
847
+ promise, "E_BIOMETRIC_REVERIFICATION_FAILED", "Unable to reverify with biometrics", result
848
+ )
849
+ }
850
+ } catch (e: Exception) {
851
+ rejectBiometricCredentialException(promise, "E_BIOMETRIC_REVERIFICATION_FAILED",
852
+ "Unable to reverify with biometrics", e)
853
+ }
854
+ }
855
+ }
856
+
857
+ private fun requireBiometricCredentialEnvironment(promise: Promise): Boolean {
858
+ val error = biometricCredentialEnvironmentError(Clerk.isInitialized.value) ?: return true
859
+ promise.reject(error.code, error.message, null)
860
+ return false
861
+ }
862
+
863
+ private fun attachCurrentActivityForBiometricCredential(promise: Promise): Boolean {
864
+ val activity = appContext.currentActivity
865
+ if (activity == null) {
866
+ promise.reject(
867
+ "environment_unavailable",
868
+ "Biometric authentication requires an active Android activity",
869
+ null
870
+ )
871
+ return false
872
+ }
873
+
874
+ Clerk.attachActivity(activity)
875
+ return true
876
+ }
877
+
878
+ private fun rejectBiometricCredentialFailure(
879
+ promise: Promise,
880
+ code: String,
881
+ fallbackMessage: String,
882
+ failure: ClerkResult.Failure<ClerkErrorResponse>
883
+ ) {
884
+ val error = biometricCredentialBridgeError(failure, code, fallbackMessage)
885
+ promise.reject(
886
+ error.code,
887
+ error.message,
888
+ failure.throwable
889
+ )
890
+ }
891
+
892
+ private fun rejectBiometricCredentialException(
893
+ promise: Promise,
894
+ fallbackCode: String,
895
+ fallbackMessage: String,
896
+ exception: Exception
897
+ ) {
898
+ val error = biometricCredentialBridgeError(exception, fallbackCode, fallbackMessage)
899
+ promise.reject(error.code, error.message, exception)
900
+ }
901
+
902
+ // MARK: - syncClientStateFromJs
903
+
904
+ private fun syncClientStateFromJs(
905
+ deviceToken: String?,
906
+ sourceId: String?,
907
+ didChangeClient: Boolean,
908
+ didChangeDeviceToken: Boolean,
909
+ promise: Promise
910
+ ) {
911
+ if (!Clerk.isInitialized.value) {
912
+ promise.resolve(null)
913
+ return
914
+ }
915
+
916
+ coroutineScope.launch {
917
+ try {
918
+ jsOriginatedClientSyncDepth += 1
919
+ val previousClientState = clientStateSnapshot()
920
+ var refreshedClientWhileUpdatingToken = false
921
+
922
+ if (didChangeDeviceToken && !deviceToken.isNullOrBlank()) {
923
+ val currentDeviceToken = try {
924
+ Clerk.getDeviceToken()
925
+ } catch (_: Exception) {
926
+ null
927
+ }
928
+
929
+ if (currentDeviceToken != deviceToken) {
930
+ when (val result = Clerk.updateDeviceToken(deviceToken)) {
931
+ is ClerkResult.Failure -> {
932
+ promise.reject(
933
+ "E_SYNC_FROM_JS_FAILED",
934
+ result.error?.firstMessage() ?: result.throwable?.message ?: "Device token sync failed",
935
+ null
936
+ )
937
+ return@launch
938
+ }
939
+ is ClerkResult.Success -> {
940
+ refreshedClientWhileUpdatingToken = true
941
+ try {
942
+ withTimeout(5_000L) {
943
+ Clerk.clientFlow.first { it != null }
944
+ }
945
+ } catch (_: TimeoutCancellationException) {
946
+ debugLog(TAG, "syncClientStateFromJs - client did not appear after token update")
947
+ }
948
+ }
949
+ }
950
+ }
951
+ }
952
+
953
+ if (!refreshedClientWhileUpdatingToken && (didChangeClient || didChangeDeviceToken)) {
954
+ when (val result = Clerk.refreshClient()) {
955
+ is ClerkResult.Failure -> {
956
+ promise.reject(
957
+ "E_SYNC_FROM_JS_FAILED",
958
+ result.error?.firstMessage() ?: result.throwable?.message ?: "Client refresh failed",
959
+ null
960
+ )
961
+ }
962
+ is ClerkResult.Success -> {
963
+ val newClientState = clientStateSnapshot()
964
+ emitSyncedClientChanged(
965
+ sourceId,
966
+ ClientStateChanges(
967
+ client = newClientState.client != previousClientState.client,
968
+ deviceToken = newClientState.deviceToken != previousClientState.deviceToken
969
+ ),
970
+ newClientState
971
+ )
972
+ promise.resolve(null)
973
+ }
974
+ }
975
+ return@launch
976
+ }
977
+
978
+ val newClientState = clientStateSnapshot()
979
+ emitSyncedClientChanged(
980
+ sourceId,
981
+ ClientStateChanges(
982
+ client = newClientState.client != previousClientState.client,
983
+ deviceToken = newClientState.deviceToken != previousClientState.deviceToken
984
+ ),
985
+ newClientState
986
+ )
987
+ promise.resolve(null)
988
+ } catch (e: Exception) {
989
+ promise.reject("E_SYNC_FROM_JS_FAILED", e.message ?: "Client state sync failed", e)
990
+ } finally {
991
+ jsOriginatedClientSyncDepth = maxOf(0, jsOriginatedClientSyncDepth - 1)
992
+ }
993
+ }
994
+ }
995
+
996
+ // MARK: - Theme Loading
997
+
998
+ private fun loadThemeFromAssets(context: Context) {
999
+ try {
1000
+ val jsonString = context.assets
1001
+ .open("clerk_theme.json")
1002
+ .bufferedReader()
1003
+ .use { it.readText() }
1004
+ val json = JSONObject(jsonString)
1005
+ Clerk.customTheme = parseClerkTheme(json)
1006
+ } catch (e: java.io.FileNotFoundException) {
1007
+ // No theme file provided — use defaults
1008
+ } catch (e: Exception) {
1009
+ debugLog(TAG, "Failed to load clerk_theme.json: ${e.message}")
1010
+ }
1011
+ }
1012
+
1013
+ private fun parseClerkTheme(json: JSONObject): ClerkTheme {
1014
+ val colors = json.optJSONObject("colors")?.let { parseColors(it) }
1015
+ val darkColors = json.optJSONObject("darkColors")?.let { parseColors(it) }
1016
+ val design = json.optJSONObject("design")?.let { parseDesign(it) }
1017
+ return ClerkTheme(
1018
+ colors = colors,
1019
+ darkColors = darkColors,
1020
+ design = design
1021
+ )
1022
+ }
1023
+
1024
+ private fun parseColors(json: JSONObject): ClerkColors {
1025
+ return ClerkColors(
1026
+ primary = json.optStringColor("primary"),
1027
+ background = json.optStringColor("background"),
1028
+ input = json.optStringColor("input"),
1029
+ danger = json.optStringColor("danger"),
1030
+ success = json.optStringColor("success"),
1031
+ warning = json.optStringColor("warning"),
1032
+ foreground = json.optStringColor("foreground"),
1033
+ mutedForeground = json.optStringColor("mutedForeground"),
1034
+ primaryForeground = json.optStringColor("primaryForeground"),
1035
+ inputForeground = json.optStringColor("inputForeground"),
1036
+ neutral = json.optStringColor("neutral"),
1037
+ border = json.optStringColor("border"),
1038
+ ring = json.optStringColor("ring"),
1039
+ muted = json.optStringColor("muted"),
1040
+ shadow = json.optStringColor("shadow"),
1041
+ secondaryButtonBackground = json.optStringColor("secondaryButtonBackground"),
1042
+ secondaryButtonForeground = json.optStringColor("secondaryButtonForeground")
1043
+ )
1044
+ }
1045
+
1046
+ private fun parseDesign(json: JSONObject): ClerkDesign {
1047
+ return if (json.has("borderRadius")) {
1048
+ ClerkDesign(borderRadius = json.getDouble("borderRadius").toFloat().dp)
1049
+ } else {
1050
+ ClerkDesign()
1051
+ }
1052
+ }
1053
+
1054
+ private fun parseHexColor(hex: String): Color? {
1055
+ val cleaned = hex.removePrefix("#")
1056
+ return try {
1057
+ when (cleaned.length) {
1058
+ 6 -> Color(android.graphics.Color.parseColor("#FF$cleaned"))
1059
+ // Theme JSON uses RRGGBBAA; Android parseColor expects AARRGGBB
1060
+ 8 -> {
1061
+ val rrggbb = cleaned.substring(0, 6)
1062
+ val aa = cleaned.substring(6, 8)
1063
+ Color(android.graphics.Color.parseColor("#$aa$rrggbb"))
1064
+ }
1065
+ else -> null
1066
+ }
1067
+ } catch (e: Exception) {
1068
+ null
1069
+ }
1070
+ }
1071
+
1072
+ private fun JSONObject.optStringColor(key: String): Color? {
1073
+ if (!has(key) || isNull(key)) return null
1074
+ val value = optString(key)
1075
+ return parseHexColor(value)
1076
+ }
1077
+ }