@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.
- package/LICENSE +21 -0
- package/README.md +106 -2
- package/android/build.gradle +152 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt +241 -0
- package/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt +148 -0
- package/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +1077 -0
- package/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt +156 -0
- package/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt +120 -0
- package/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt +315 -0
- package/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt +326 -0
- package/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt +163 -0
- package/app.plugin.d.ts +5 -0
- package/app.plugin.js +333 -0
- package/dist/AuthView.d.ts +35 -0
- package/dist/AuthView.d.ts.map +1 -0
- package/dist/AuthView.js +79 -0
- package/dist/AuthView.js.map +1 -0
- package/dist/AuthView.types.d.ts +62 -0
- package/dist/AuthView.types.d.ts.map +1 -0
- package/dist/AuthView.types.js +0 -0
- package/dist/EmbeddedNavigation.types.d.ts +24 -0
- package/dist/EmbeddedNavigation.types.d.ts.map +1 -0
- package/dist/EmbeddedNavigation.types.js +0 -0
- package/dist/UserButton.d.ts +38 -0
- package/dist/UserButton.d.ts.map +1 -0
- package/dist/UserButton.js +68 -0
- package/dist/UserButton.js.map +1 -0
- package/dist/UserProfileCustomPages.d.ts +81 -0
- package/dist/UserProfileCustomPages.d.ts.map +1 -0
- package/dist/UserProfileCustomPages.js +140 -0
- package/dist/UserProfileCustomPages.js.map +1 -0
- package/dist/UserProfileView.d.ts +76 -0
- package/dist/UserProfileView.d.ts.map +1 -0
- package/dist/UserProfileView.js +101 -0
- package/dist/UserProfileView.js.map +1 -0
- package/dist/_virtual/_rolldown/runtime.js +29 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -0
- package/dist/specs/NativeClerkAuthView.android.d.ts +15 -0
- package/dist/specs/NativeClerkAuthView.android.d.ts.map +1 -0
- package/dist/specs/NativeClerkAuthView.android.js +8 -0
- package/dist/specs/NativeClerkAuthView.android.js.map +1 -0
- package/dist/specs/NativeClerkAuthView.d.ts +15 -0
- package/dist/specs/NativeClerkAuthView.d.ts.map +1 -0
- package/dist/specs/NativeClerkAuthView.js +9 -0
- package/dist/specs/NativeClerkAuthView.js.map +1 -0
- package/dist/specs/NativeClerkModule.d.ts +4 -0
- package/dist/specs/NativeClerkModule.d.ts.map +1 -0
- package/dist/specs/NativeClerkModule.js +8 -0
- package/dist/specs/NativeClerkModule.js.map +1 -0
- package/dist/specs/NativeClerkModule.types.d.ts +11 -0
- package/dist/specs/NativeClerkModule.types.d.ts.map +1 -0
- package/dist/specs/NativeClerkModule.types.js +0 -0
- package/dist/specs/NativeClerkModule.web.d.ts +4 -0
- package/dist/specs/NativeClerkModule.web.d.ts.map +1 -0
- package/dist/specs/NativeClerkModule.web.js +7 -0
- package/dist/specs/NativeClerkModule.web.js.map +1 -0
- package/dist/specs/NativeClerkUserButtonView.android.d.ts +6 -0
- package/dist/specs/NativeClerkUserButtonView.android.d.ts.map +1 -0
- package/dist/specs/NativeClerkUserButtonView.android.js +8 -0
- package/dist/specs/NativeClerkUserButtonView.android.js.map +1 -0
- package/dist/specs/NativeClerkUserButtonView.d.ts +6 -0
- package/dist/specs/NativeClerkUserButtonView.d.ts.map +1 -0
- package/dist/specs/NativeClerkUserButtonView.js +9 -0
- package/dist/specs/NativeClerkUserButtonView.js.map +1 -0
- package/dist/specs/NativeClerkUserProfileView.android.d.ts +13 -0
- package/dist/specs/NativeClerkUserProfileView.android.d.ts.map +1 -0
- package/dist/specs/NativeClerkUserProfileView.android.js +8 -0
- package/dist/specs/NativeClerkUserProfileView.android.js.map +1 -0
- package/dist/specs/NativeClerkUserProfileView.d.ts +13 -0
- package/dist/specs/NativeClerkUserProfileView.d.ts.map +1 -0
- package/dist/specs/NativeClerkUserProfileView.js +9 -0
- package/dist/specs/NativeClerkUserProfileView.js.map +1 -0
- package/dist/useAuthViewState.d.ts +12 -0
- package/dist/useAuthViewState.d.ts.map +1 -0
- package/dist/useAuthViewState.js +76 -0
- package/dist/useAuthViewState.js.map +1 -0
- package/dist/utils/native-module.d.ts +3 -0
- package/dist/utils/native-module.d.ts.map +1 -0
- package/dist/utils/native-module.js +14 -0
- package/dist/utils/native-module.js.map +1 -0
- package/expo-module.config.json +15 -0
- package/ios/ClerkAppDelegateSubscriber.swift +16 -0
- package/ios/ClerkAuthNativeView.swift +157 -0
- package/ios/ClerkExpo.podspec +67 -0
- package/ios/ClerkExpoModule.swift +315 -0
- package/ios/ClerkNativeBridge.swift +1575 -0
- package/ios/ClerkNativeViewHost.swift +204 -0
- package/ios/ClerkUserButtonNativeView.swift +34 -0
- package/ios/ClerkUserProfileNativeView.swift +92 -0
- package/ios/Tests/ClerkAuthNativeViewPaperTests.swift +30 -0
- package/ios/Tests/ClerkNativeBridgeTests.swift +185 -0
- package/ios/Tests/ClerkNativeViewHostTests.swift +66 -0
- package/ios/Tests/ClerkUserProfileCustomPageStateTests.swift +311 -0
- package/package.json +79 -3
- package/react-native.config.js +8 -0
- package/src/specs/NativeClerkAuthView.android.ts +15 -0
- package/src/specs/NativeClerkAuthView.ts +19 -0
- package/src/specs/NativeClerkModule.ts +6 -0
- package/src/specs/NativeClerkModule.types.ts +9 -0
- package/src/specs/NativeClerkModule.web.ts +3 -0
- package/src/specs/NativeClerkUserButtonView.android.ts +9 -0
- package/src/specs/NativeClerkUserButtonView.ts +11 -0
- package/src/specs/NativeClerkUserProfileView.android.ts +13 -0
- package/src/specs/NativeClerkUserProfileView.ts +17 -0
- 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
|
+
}
|