diff --git a/packages/binding/android/src/main/java/com/pingidentity/rnbinding/RNPingBindingCommon.kt b/packages/binding/android/src/main/java/com/pingidentity/rnbinding/RNPingBindingCommon.kt index 0873a1940..9acd1aeec 100644 --- a/packages/binding/android/src/main/java/com/pingidentity/rnbinding/RNPingBindingCommon.kt +++ b/packages/binding/android/src/main/java/com/pingidentity/rnbinding/RNPingBindingCommon.kt @@ -7,6 +7,7 @@ package com.pingidentity.rnbinding import androidx.annotation.VisibleForTesting +import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableMap @@ -19,11 +20,11 @@ import com.pingidentity.logger.NONE import com.pingidentity.logger.Logger import com.pingidentity.rncore.CoreRuntime import com.pingidentity.rncore.error.ErrorType +import com.pingidentity.rncore.utils.launchBridge import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch import java.lang.ref.WeakReference /** @@ -113,41 +114,32 @@ object RNPingBindingCommon { "No foreground activity is available for Journey device binding.") return } - scope.launch { - try { - val index = parseCallbackIndex(options) - val callback = resolveDeviceBindingCallback(journeyId, index) - if (callback == null) { - rejectWithError(promise, BindingErrorCodes.BINDING_CALLBACK_NOT_FOUND, - "No active DeviceBindingCallback found for journey $journeyId at index $index.", - ErrorType.STATE_ERROR) - return@launch - } - val jsDeviceName = parseStringOption(options, "deviceName") - val jsSigningAlgorithm = parseStringOption(options, "signingAlgorithm") - callback.bind { - logger = resolvedLogger ?: Logger.NONE - jsDeviceName?.let { deviceName = it } - jsSigningAlgorithm?.let { signingAlgorithm = it } - applyCommonBindingConfig(this, options, callConfig.userKeyStorageId) - if (callConfig.hasPinCollector) { - appPinConfig { pinCollector { prompt -> bridgePinCollector(reactContext, prompt) } } - } - }.fold( - onSuccess = { promise.resolve(createJourneyResultPayload("success")) }, - onFailure = { error -> - rejectWithError(promise, resolveBindingErrorCode(error, BindingErrorCodes.BINDING_BIND_ERROR), - error.localizedMessage ?: "Journey device binding callback execution failed.", throwable = error) - } - ) - } catch (error: IllegalArgumentException) { - rejectWithError(promise, BindingErrorCodes.BINDING_BIND_ERROR, - error.localizedMessage ?: "Invalid Journey device binding options payload.", - ErrorType.ARGUMENT_ERROR, error) - } catch (error: Throwable) { - rejectWithError(promise, BindingErrorCodes.BINDING_BIND_ERROR, - error.localizedMessage ?: "Journey device binding callback execution failed.", throwable = error) + scope.launchBridge(promise, BindingErrorCodes.BINDING_BIND_ERROR) { + val index = parseCallbackIndex(options) + val callback = resolveDeviceBindingCallback(journeyId, index) + if (callback == null) { + rejectWithError(promise, BindingErrorCodes.BINDING_CALLBACK_NOT_FOUND, + "No active DeviceBindingCallback found for journey $journeyId at index $index.", + ErrorType.STATE_ERROR) + return@launchBridge } + val jsDeviceName = parseStringOption(options, "deviceName") + val jsSigningAlgorithm = parseStringOption(options, "signingAlgorithm") + callback.bind { + logger = resolvedLogger ?: Logger.NONE + jsDeviceName?.let { deviceName = it } + jsSigningAlgorithm?.let { signingAlgorithm = it } + applyCommonBindingConfig(this, options, callConfig.userKeyStorageId) + if (callConfig.hasPinCollector) { + appPinConfig { pinCollector { prompt -> bridgePinCollector(reactContext, prompt) } } + } + }.fold( + onSuccess = { promise.resolve(createJourneyResultPayload("success")) }, + onFailure = { error -> + rejectWithError(promise, resolveBindingErrorCode(error, BindingErrorCodes.BINDING_BIND_ERROR), + error.localizedMessage ?: "Journey device binding callback execution failed.", throwable = error) + } + ) } } @@ -171,44 +163,35 @@ object RNPingBindingCommon { "No foreground activity is available for Journey device signing.") return } - scope.launch { - try { - val index = parseCallbackIndex(options) - val callback = resolveDeviceSigningVerifierCallback(journeyId, index) - if (callback == null) { - rejectWithError(promise, BindingErrorCodes.BINDING_CALLBACK_NOT_FOUND, - "No active DeviceSigningVerifierCallback found for journey $journeyId at index $index.", - ErrorType.STATE_ERROR) - return@launch - } - val jsSigningAlgorithm = parseStringOption(options, "signingAlgorithm") - val jsClaims = parseClaims(options) - callback.sign { - logger = resolvedLogger ?: Logger.NONE - jsSigningAlgorithm?.let { signingAlgorithm = it } - if (jsClaims.isNotEmpty()) { claims { putAll(jsClaims) } } - applyCommonBindingConfig(this, options, callConfig.userKeyStorageId) - if (callConfig.hasPinCollector) { - appPinConfig { pinCollector { prompt -> bridgePinCollector(reactContext, prompt) } } - } - if (callConfig.hasUserKeySelector) { - userKeySelector { keys -> bridgeUserKeySelector(reactContext, keys) } - } - }.fold( - onSuccess = { promise.resolve(createJourneyResultPayload("success")) }, - onFailure = { error -> - rejectWithError(promise, resolveBindingErrorCode(error, BindingErrorCodes.BINDING_SIGN_ERROR), - error.localizedMessage ?: "Journey device signing callback execution failed.", throwable = error) - } - ) - } catch (error: IllegalArgumentException) { - rejectWithError(promise, BindingErrorCodes.BINDING_SIGN_ERROR, - error.localizedMessage ?: "Invalid Journey device signing options payload.", - ErrorType.ARGUMENT_ERROR, error) - } catch (error: Throwable) { - rejectWithError(promise, BindingErrorCodes.BINDING_SIGN_ERROR, - error.localizedMessage ?: "Journey device signing callback execution failed.", throwable = error) + scope.launchBridge(promise, BindingErrorCodes.BINDING_SIGN_ERROR) { + val index = parseCallbackIndex(options) + val callback = resolveDeviceSigningVerifierCallback(journeyId, index) + if (callback == null) { + rejectWithError(promise, BindingErrorCodes.BINDING_CALLBACK_NOT_FOUND, + "No active DeviceSigningVerifierCallback found for journey $journeyId at index $index.", + ErrorType.STATE_ERROR) + return@launchBridge } + val jsSigningAlgorithm = parseStringOption(options, "signingAlgorithm") + val jsClaims = parseClaims(options) + callback.sign { + logger = resolvedLogger ?: Logger.NONE + jsSigningAlgorithm?.let { signingAlgorithm = it } + if (jsClaims.isNotEmpty()) { claims { putAll(jsClaims) } } + applyCommonBindingConfig(this, options, callConfig.userKeyStorageId) + if (callConfig.hasPinCollector) { + appPinConfig { pinCollector { prompt -> bridgePinCollector(reactContext, prompt) } } + } + if (callConfig.hasUserKeySelector) { + userKeySelector { keys -> bridgeUserKeySelector(reactContext, keys) } + } + }.fold( + onSuccess = { promise.resolve(createJourneyResultPayload("success")) }, + onFailure = { error -> + rejectWithError(promise, resolveBindingErrorCode(error, BindingErrorCodes.BINDING_SIGN_ERROR), + error.localizedMessage ?: "Journey device signing callback execution failed.", throwable = error) + } + ) } } @@ -217,71 +200,56 @@ object RNPingBindingCommon { /** Returns all registered device binding keys from [UserKeysStorage] as a JS array. */ @JvmStatic fun getAllKeys(promise: Promise) { - scope.launch { - try { - val storage = userKeysStorage - val result = com.facebook.react.bridge.Arguments.createArray().apply { - storage.findAll().forEach { key -> - pushMap(com.facebook.react.bridge.Arguments.createMap().apply { - putString("id", key.id) - putString("userId", key.userId) - putString("username", key.userName) - putString("authenticationType", key.authType.name) - }) - } + scope.launchBridge(promise, BindingErrorCodes.BINDING_ERROR) { + val storage = userKeysStorage + val result = Arguments.createArray().apply { + storage.findAll().forEach { key -> + pushMap(Arguments.createMap().apply { + putString("id", key.id) + putString("userId", key.userId) + putString("username", key.userName) + putString("authenticationType", key.authType.name) + }) } - promise.resolve(result) - } catch (error: Throwable) { - rejectWithError(promise, BindingErrorCodes.BINDING_ERROR, - error.localizedMessage ?: "Failed to retrieve binding keys.", throwable = error) } + promise.resolve(result) } } /** Deletes the key identified by [userId] and [keyId], including its KeyStore material. */ @JvmStatic fun deleteKey(userId: String, keyId: String, promise: Promise) { - scope.launch { - try { - val storage = userKeysStorage - val key = storage.findAll().firstOrNull { it.id == keyId && it.userId == userId } - if (key == null) { - rejectWithError(promise, BindingErrorCodes.BINDING_KEY_DELETE_ERROR, - "No binding key found.", ErrorType.STATE_ERROR) - return@launch - } - deleteKeyMaterial(key) - storage.delete(key) - promise.resolve(null) - } catch (error: Throwable) { + scope.launchBridge(promise, BindingErrorCodes.BINDING_KEY_DELETE_ERROR) { + val storage = userKeysStorage + val key = storage.findAll().firstOrNull { it.id == keyId && it.userId == userId } + if (key == null) { rejectWithError(promise, BindingErrorCodes.BINDING_KEY_DELETE_ERROR, - error.localizedMessage ?: "Failed to delete binding key.", throwable = error) + "No binding key found.", ErrorType.STATE_ERROR) + return@launchBridge } + deleteKeyMaterial(key) + storage.delete(key) + promise.resolve(null) } } /** Deletes all registered device binding keys, including their KeyStore material. */ @JvmStatic fun deleteAllKeys(promise: Promise) { - scope.launch { - try { - val storage = userKeysStorage - val errors = mutableListOf() - storage.findAll().forEach { key -> - runCatching { - deleteKeyMaterial(key) - storage.delete(key) - }.onFailure { errors.add(it.localizedMessage ?: "Failed to delete key.") } - } - if (errors.isEmpty()) { - promise.resolve(null) - } else { - rejectWithError(promise, BindingErrorCodes.BINDING_KEY_DELETE_ERROR, - errors.joinToString("; ")) - } - } catch (error: Throwable) { + scope.launchBridge(promise, BindingErrorCodes.BINDING_KEY_DELETE_ERROR) { + val storage = userKeysStorage + val errors = mutableListOf() + storage.findAll().forEach { key -> + runCatching { + deleteKeyMaterial(key) + storage.delete(key) + }.onFailure { errors.add(it.localizedMessage ?: "Failed to delete key.") } + } + if (errors.isEmpty()) { + promise.resolve(null) + } else { rejectWithError(promise, BindingErrorCodes.BINDING_KEY_DELETE_ERROR, - error.localizedMessage ?: "Failed to delete all binding keys.", throwable = error) + errors.joinToString("; ")) } } } diff --git a/packages/browser/android/src/main/java/com/pingidentity/rnbrowser/RNPingBrowserCommon.kt b/packages/browser/android/src/main/java/com/pingidentity/rnbrowser/RNPingBrowserCommon.kt index eb0d37887..87b978832 100644 --- a/packages/browser/android/src/main/java/com/pingidentity/rnbrowser/RNPingBrowserCommon.kt +++ b/packages/browser/android/src/main/java/com/pingidentity/rnbrowser/RNPingBrowserCommon.kt @@ -34,7 +34,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch +import com.pingidentity.rncore.utils.launchBridge /** * Common utilities for the Ping Browser module. @@ -188,7 +188,7 @@ object RNPingBrowserCommon { null } - scope.launch { + scope.launchBridge(promise, BrowserErrorCodes.BROWSER_OPEN_ERROR) { val launchUrl = try { parseLaunchUrl(url) } catch (e: MalformedURLException) { @@ -200,12 +200,14 @@ object RNPingBrowserCommon { ), e ) - return@launch + return@launchBridge } val result = try { val resolvedRedirectUri = redirectUri?.toUri() ?: browserLauncher.redirectUri browserLauncher.launch(launchUrl, resolvedRedirectUri) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Result.failure(e) } @@ -216,22 +218,22 @@ object RNPingBrowserCommon { val payload = mapFactory() payload.putString("type", "cancel") promise.resolve(payload) - return@launch + return@launchBridge } val payload = mapFactory() payload.putString("type", "success") payload.putString("url", uri.toString()) promise.resolve(payload) - return@launch + return@launchBridge } val error = result.exceptionOrNull() - if (error is BrowserCanceledException || error is CancellationException) { + if (error is BrowserCanceledException) { val payload = mapFactory() payload.putString("type", "cancel") promise.resolve(payload) - return@launch + return@launchBridge } // Map native errors to the shared JS contract from RNPingCore. diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index d1fd77c5e..50345194b 100644 --- a/packages/core/android/build.gradle +++ b/packages/core/android/build.gradle @@ -86,4 +86,5 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" testImplementation "junit:junit:4.13.2" + testImplementation "org.robolectric:robolectric:4.11.1" } diff --git a/packages/core/android/src/main/java/com/pingidentity/rncore/utils/CoroutineBridge.kt b/packages/core/android/src/main/java/com/pingidentity/rncore/utils/CoroutineBridge.kt new file mode 100644 index 000000000..ec94a7ebd --- /dev/null +++ b/packages/core/android/src/main/java/com/pingidentity/rncore/utils/CoroutineBridge.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package com.pingidentity.rncore.utils + +import com.facebook.react.bridge.Promise +import com.pingidentity.rncore.error.mapThrowableToGenericError +import com.pingidentity.rncore.error.reject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +/** + * Launches a coroutine that automatically handles promise rejection on failure. + * + * [CancellationException] is re-thrown so that structured-concurrency scope cancellation + * propagates correctly without settling the promise. All other [Throwable] instances are + * mapped to a [com.pingidentity.rncore.error.GenericError] via + * [mapThrowableToGenericError] and the promise is rejected with the resulting error. + * + * @param promise The React Native promise to reject on failure. Success settlement + * (`resolve`) is the caller's responsibility inside [block]. + * @param errorCode The module-specific error code passed to [mapThrowableToGenericError]. + * @param context Additional [CoroutineContext] elements merged into the launch context. + * Defaults to [EmptyCoroutineContext] so the receiver scope's dispatcher is preserved. + * @param block The suspend body to execute inside the coroutine. + * @return The [Job] for the launched coroutine. + */ +@JvmSynthetic +fun CoroutineScope.launchBridge( + promise: Promise, + errorCode: String, + context: CoroutineContext = EmptyCoroutineContext, + block: suspend CoroutineScope.() -> Unit +): Job = launch(context) { + try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + promise.reject(mapThrowableToGenericError(e, errorCode), e) + } +} diff --git a/packages/core/android/src/test/java/com/pingidentity/rncore/utils/CoroutineBridgeQaTest.kt b/packages/core/android/src/test/java/com/pingidentity/rncore/utils/CoroutineBridgeQaTest.kt new file mode 100644 index 000000000..ca144bdbf --- /dev/null +++ b/packages/core/android/src/test/java/com/pingidentity/rncore/utils/CoroutineBridgeQaTest.kt @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package com.pingidentity.rncore.utils + +import com.facebook.react.bridge.JavaOnlyArray +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.WritableArray +import com.facebook.react.bridge.WritableMap +import com.facebook.soloader.SoLoader +import com.facebook.soloader.nativeloader.NativeLoader +import com.facebook.soloader.nativeloader.SystemDelegate +import com.pingidentity.rncore.error.ErrorType +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.job +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements + +/** + * QA-targeted tests for [launchBridge] covering scenarios not already exercised by + * [CoroutineBridgeTest]: scope-level cancellation, subclass CancellationException, + * no-dispatcher passthrough, and promise not double-settled on launchBridge success. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29], manifest = Config.NONE, shadows = [ShadowQaArguments::class]) +class CoroutineBridgeQaTest { + + @Before + fun setUp() { + runCatching { SoLoader.init(RuntimeEnvironment.getApplication(), false) } + runCatching { NativeLoader.init(SystemDelegate()) } + } + + // ----------------------------------------------------------------------- + // Helper + // ----------------------------------------------------------------------- + + private class TestPromise : Promise { + private val latch = CountDownLatch(1) + + var rejectedCode: String? = null + private set + var rejectedMessage: String? = null + private set + var rejectedThrowable: Throwable? = null + private set + var rejectedUserInfo: WritableMap? = null + private set + var resolved: Boolean = false + private set + var settleCount: Int = 0 + private set + + fun await(timeoutMs: Long = 2_000): Boolean = + latch.await(timeoutMs, TimeUnit.MILLISECONDS) + + override fun resolve(value: Any?) { + settleCount++ + resolved = true + latch.countDown() + } + + override fun reject(code: String, message: String?) { + settleCount++ + rejectedCode = code + rejectedMessage = message + latch.countDown() + } + + override fun reject(code: String, throwable: Throwable?) { + settleCount++ + rejectedCode = code + rejectedThrowable = throwable + latch.countDown() + } + + override fun reject(code: String, message: String?, throwable: Throwable?) { + settleCount++ + rejectedCode = code + rejectedMessage = message + rejectedThrowable = throwable + latch.countDown() + } + + override fun reject(throwable: Throwable) { + settleCount++ + rejectedThrowable = throwable + latch.countDown() + } + + override fun reject(throwable: Throwable, userInfo: WritableMap) { + settleCount++ + rejectedThrowable = throwable + latch.countDown() + } + + override fun reject(code: String, userInfo: WritableMap) { + settleCount++ + rejectedCode = code + latch.countDown() + } + + override fun reject(code: String, throwable: Throwable?, userInfo: WritableMap) { + settleCount++ + rejectedCode = code + rejectedThrowable = throwable + latch.countDown() + } + + override fun reject(code: String, message: String?, userInfo: WritableMap) { + settleCount++ + rejectedCode = code + rejectedMessage = message + latch.countDown() + } + + override fun reject( + code: String?, + message: String?, + throwable: Throwable?, + userInfo: WritableMap? + ) { + settleCount++ + rejectedCode = code + rejectedMessage = message + rejectedThrowable = throwable + rejectedUserInfo = userInfo + latch.countDown() + } + + @Suppress("DEPRECATION") + override fun reject(message: String) { + settleCount++ + rejectedMessage = message + latch.countDown() + } + } + + // ----------------------------------------------------------------------- + // AC 6: Scope-level cancellation — promise not settled + // ----------------------------------------------------------------------- + + /** + * AC 6: When the scope is cancelled while the coroutine is in-flight, + * launchBridge must NOT reject the promise. The CancellationException + * generated by scope.cancel() must propagate through launchBridge's + * re-throw path without touching the promise. + */ + @Test + fun scopeCancellationDoesNotSettlePromise() { + val promise = TestPromise() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + val blockStarted = AtomicBoolean(false) + + // We need a real IO scope to actually interleave cancel with in-flight work. + val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val blockLatch = CountDownLatch(1) + val cancelLatch = CountDownLatch(1) + + val job = ioScope.launchBridge(promise, "TEST_CANCEL_ERROR") { + blockStarted.set(true) + blockLatch.countDown() // signal: block has started + cancelLatch.await() // wait: allow cancel to proceed + yield() // cooperative cancellation point + } + + // Wait for block to start, then cancel the scope + assertTrue("Block must start within 2s", blockLatch.await(2, TimeUnit.SECONDS)) + ioScope.cancel() // cancels the scope + cancelLatch.countDown() // unblock the coroutine body + + runBlocking { job.join() } // wait for coroutine to finish + + // Promise must not be settled after cancellation + assertFalse( + "Promise must NOT be settled when scope is cancelled", + promise.await(timeoutMs = 500) + ) + assertNull("rejectedCode must remain null on scope cancellation", promise.rejectedCode) + assertFalse("resolved must remain false on scope cancellation", promise.resolved) + } + + // ----------------------------------------------------------------------- + // AC 2: CancellationException subclass also re-thrown + // ----------------------------------------------------------------------- + + /** + * AC 2: A custom subclass of CancellationException must also be re-thrown + * (not caught as a generic Throwable). This tests the catch order: + * `catch (e: CancellationException)` must fire before `catch (e: Throwable)`. + */ + @Test + fun cancellationExceptionSubclassIsRethrownNotRejected() { + class CustomCancellation(msg: String) : CancellationException(msg) + + val promise = TestPromise() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + + val job = scope.launchBridge(promise, "TEST_ERROR") { + throw CustomCancellation("custom cancel") + } + + runBlocking { job.join() } + + assertFalse("Promise must not be settled for CancellationException subclass", + promise.await(timeoutMs = 100)) + assertNull(promise.rejectedCode) + assertFalse(promise.resolved) + } + + // ----------------------------------------------------------------------- + // AC 9: Receiver scope's dispatcher preserved (no context override) + // ----------------------------------------------------------------------- + + /** + * AC 9 / Constraint 5: When no context override is provided, the launchBridge + * call inherits the receiver scope's dispatcher. If the binding scope uses + * Dispatchers.Main and launchBridge is called without a context override, the + * coroutine must launch on Main. We verify the invariant indirectly: a scope + * with a specific CoroutineName propagates that name into the launched coroutine + * when no context override is given (EmptyCoroutineContext). + */ + @Test + fun noContextOverridePreservesReceiverScopeContext() { + val promise = TestPromise() + // Use a named scope so we can observe propagation + val namedScope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined + + kotlinx.coroutines.CoroutineName("receiver-scope")) + var capturedName: String? = null + + // No context override — should inherit receiver scope's CoroutineName + val job = namedScope.launchBridge(promise, "TEST_ERROR") { + capturedName = kotlinx.coroutines.currentCoroutineContext()[ + kotlinx.coroutines.CoroutineName]?.name + } + + runBlocking { job.join() } + + // The receiver scope's CoroutineName must be present (not overridden) + assertEquals("receiver-scope", capturedName) + } + + // ----------------------------------------------------------------------- + // AC 3: Error code is passed verbatim to the GenericError payload + // ----------------------------------------------------------------------- + + /** + * AC 3: The error code string passed to launchBridge must appear verbatim + * as the `error` field in the rejected GenericError userInfo map. + */ + @Test + fun errorCodeIsPreservedVerbatimInRejectedPayload() { + val specificCode = "BINDING_BIND_ERROR" + val promise = TestPromise() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + + val job = scope.launchBridge(promise, specificCode) { + throw RuntimeException("unexpected failure") + } + + runBlocking { job.join() } + + assertTrue(promise.await(timeoutMs = 100)) + assertEquals( + "Error code in rejected promise must match the code passed to launchBridge", + specificCode, + promise.rejectedCode + ) + assertEquals( + "Error code in userInfo map must match the code passed to launchBridge", + specificCode, + promise.rejectedUserInfo?.getString("error") + ) + } + + // ----------------------------------------------------------------------- + // AC 2: Original Throwable is the second argument to Promise.reject + // ----------------------------------------------------------------------- + + /** + * AC 2 / FR 9: The original throwable must be passed as the second argument + * to the four-arg Promise.reject(code, message, throwable, userInfo) so the + * native stack trace is preserved for React Native's error reporting. + */ + @Test + fun originalThrowableIdentityIsPreservedInRejection() { + val promise = TestPromise() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + val cause = IllegalArgumentException("exact-instance") + + val job = scope.launchBridge(promise, "TEST_ERROR") { throw cause } + runBlocking { job.join() } + + assertTrue(promise.await(timeoutMs = 100)) + assertTrue( + "The exact throwable instance must be passed to Promise.reject", + promise.rejectedThrowable === cause + ) + } +} + +@Implements(className = "com.facebook.react.bridge.Arguments") +object ShadowQaArguments { + @Implementation + @JvmStatic + fun createMap(): WritableMap = JavaOnlyMap() + + @Implementation + @JvmStatic + fun createArray(): WritableArray = JavaOnlyArray() +} diff --git a/packages/core/android/src/test/java/com/pingidentity/rncore/utils/CoroutineBridgeTest.kt b/packages/core/android/src/test/java/com/pingidentity/rncore/utils/CoroutineBridgeTest.kt new file mode 100644 index 000000000..94bb66c25 --- /dev/null +++ b/packages/core/android/src/test/java/com/pingidentity/rncore/utils/CoroutineBridgeTest.kt @@ -0,0 +1,352 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package com.pingidentity.rncore.utils + +import com.facebook.react.bridge.JavaOnlyArray +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.WritableArray +import com.facebook.react.bridge.WritableMap +import com.facebook.soloader.SoLoader +import com.facebook.soloader.nativeloader.NativeLoader +import com.facebook.soloader.nativeloader.SystemDelegate +import com.pingidentity.rncore.error.ErrorType +import java.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements + +/** + * Unit tests for the [launchBridge] coroutine extension. + * + * Tests use [Dispatchers.Unconfined] for the coroutine scope so that the launched + * coroutine runs eagerly on the calling thread — the same thread Robolectric uses + * to shadow React Native's [WritableNativeMap]. This avoids the JNI initialisation + * failure that occurs when [Arguments.createMap] is called from a background thread + * that is outside Robolectric's sandbox. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29], manifest = Config.NONE, shadows = [ShadowRnCoreArguments::class]) +class CoroutineBridgeTest { + + @Before + fun setUp() { + runCatching { SoLoader.init(RuntimeEnvironment.getApplication(), false) } + runCatching { NativeLoader.init(SystemDelegate()) } + } + + // --------------------------------------------------------------------------- + // Test helpers + // --------------------------------------------------------------------------- + + /** + * Minimal [Promise] implementation that captures rejection arguments and + * signals a latch so tests can synchronise against async bridge calls. + */ + private class TestPromise : Promise { + private val latch = CountDownLatch(1) + + var rejectedCode: String? = null + private set + var rejectedMessage: String? = null + private set + var rejectedThrowable: Throwable? = null + private set + var rejectedUserInfo: WritableMap? = null + private set + var resolved: Boolean = false + private set + + fun await(timeoutMs: Long = 2_000): Boolean = + latch.await(timeoutMs, TimeUnit.MILLISECONDS) + + override fun resolve(value: Any?) { + resolved = true + latch.countDown() + } + + override fun reject(code: String, message: String?) { + rejectedCode = code + rejectedMessage = message + latch.countDown() + } + + override fun reject(code: String, throwable: Throwable?) { + rejectedCode = code + rejectedThrowable = throwable + latch.countDown() + } + + override fun reject(code: String, message: String?, throwable: Throwable?) { + rejectedCode = code + rejectedMessage = message + rejectedThrowable = throwable + latch.countDown() + } + + override fun reject(throwable: Throwable) { + rejectedThrowable = throwable + latch.countDown() + } + + override fun reject(throwable: Throwable, userInfo: WritableMap) { + rejectedThrowable = throwable + latch.countDown() + } + + override fun reject(code: String, userInfo: WritableMap) { + rejectedCode = code + latch.countDown() + } + + override fun reject(code: String, throwable: Throwable?, userInfo: WritableMap) { + rejectedCode = code + rejectedThrowable = throwable + latch.countDown() + } + + override fun reject(code: String, message: String?, userInfo: WritableMap) { + rejectedCode = code + rejectedMessage = message + latch.countDown() + } + + override fun reject( + code: String?, + message: String?, + throwable: Throwable?, + userInfo: WritableMap? + ) { + rejectedCode = code + rejectedMessage = message + rejectedThrowable = throwable + rejectedUserInfo = userInfo + latch.countDown() + } + + @Suppress("DEPRECATION") + override fun reject(message: String) { + rejectedMessage = message + latch.countDown() + } + } + + // --------------------------------------------------------------------------- + // CancellationException behaviour + // --------------------------------------------------------------------------- + + /** + * When the block throws [CancellationException], [launchBridge] re-throws it + * so that structured-concurrency propagation is correct, and the promise is + * never settled. + */ + @Test + fun cancellationExceptionIsRethrownAndPromiseNotSettled() { + val promise = TestPromise() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + + val job = scope.launchBridge(promise, "TEST_ERROR") { + throw CancellationException("test cancellation") + } + + // The coroutine ran eagerly (Unconfined); join ensures it is done. + runBlocking { job.join() } + + assertFalse("Promise must not be settled when CancellationException is thrown", promise.await(timeoutMs = 100)) + assertNull("rejectedCode must be null", promise.rejectedCode) + assertFalse("resolved must be false", promise.resolved) + } + + // --------------------------------------------------------------------------- + // Throwable → promise rejection + // --------------------------------------------------------------------------- + + /** + * [IllegalArgumentException] must produce [ErrorType.ARGUMENT_ERROR] in the + * rejected [GenericError]. + */ + @Test + fun illegalArgumentExceptionRejectsWithArgumentError() { + val promise = TestPromise() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + val errorCode = "TEST_ERROR" + val cause = IllegalArgumentException("bad argument") + + val job = scope.launchBridge(promise, errorCode) { throw cause } + runBlocking { job.join() } + + assertTrue("Promise must be settled", promise.await(timeoutMs = 100)) + assertEquals(errorCode, promise.rejectedCode) + assertEquals(ErrorType.ARGUMENT_ERROR.rawValue, promise.rejectedUserInfo?.getString("type")) + assertEquals(cause, promise.rejectedThrowable) + } + + /** + * [IOException] must produce [ErrorType.NETWORK_ERROR] in the rejected + * [GenericError]. + */ + @Test + fun ioExceptionRejectsWithNetworkError() { + val promise = TestPromise() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + val errorCode = "TEST_ERROR" + val cause = IOException("network failure") + + val job = scope.launchBridge(promise, errorCode) { throw cause } + runBlocking { job.join() } + + assertTrue("Promise must be settled", promise.await(timeoutMs = 100)) + assertEquals(errorCode, promise.rejectedCode) + assertEquals(ErrorType.NETWORK_ERROR.rawValue, promise.rejectedUserInfo?.getString("type")) + assertEquals(cause, promise.rejectedThrowable) + } + + /** + * An arbitrary [RuntimeException] must produce [ErrorType.INTERNAL_ERROR] in + * the rejected [GenericError]. + */ + @Test + fun runtimeExceptionRejectsWithInternalError() { + val promise = TestPromise() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + val errorCode = "TEST_ERROR" + val cause = RuntimeException("unexpected failure") + + val job = scope.launchBridge(promise, errorCode) { throw cause } + runBlocking { job.join() } + + assertTrue("Promise must be settled", promise.await(timeoutMs = 100)) + assertEquals(errorCode, promise.rejectedCode) + assertEquals(ErrorType.INTERNAL_ERROR.rawValue, promise.rejectedUserInfo?.getString("type")) + assertEquals(cause, promise.rejectedThrowable) + } + + /** + * An arbitrary [Error] subclass (non-Exception Throwable) must also be caught + * and result in [ErrorType.INTERNAL_ERROR]. This validates that [launchBridge] + * catches [Throwable], not just [Exception]. + */ + @Test + fun errorSubclassRejectsWithInternalError() { + val promise = TestPromise() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + val errorCode = "TEST_ERROR" + val cause = Error("jvm error") + + val job = scope.launchBridge(promise, errorCode) { throw cause } + runBlocking { job.join() } + + assertTrue("Promise must be settled", promise.await(timeoutMs = 100)) + assertEquals(errorCode, promise.rejectedCode) + assertEquals(ErrorType.INTERNAL_ERROR.rawValue, promise.rejectedUserInfo?.getString("type")) + assertEquals(cause, promise.rejectedThrowable) + } + + /** + * When the block completes without throwing, the promise is NOT rejected by + * [launchBridge] — promise settlement is the responsibility of the block itself. + */ + @Test + fun successfulBlockDoesNotRejectPromise() { + val promise = TestPromise() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + + val job = scope.launchBridge(promise, "TEST_ERROR") { + // Block succeeds without resolving/rejecting the promise. + } + + runBlocking { job.join() } + + assertNull("rejectedCode must be null on success", promise.rejectedCode) + assertFalse("resolved must be false — block did not resolve", promise.resolved) + } + + /** + * The original [Throwable] is passed as the second argument to + * [Promise.reject] so the native stack trace is preserved. + */ + @Test + fun originalThrowableIsPassedToPromiseReject() { + val promise = TestPromise() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + val cause = RuntimeException("original") + + val job = scope.launchBridge(promise, "TEST_ERROR") { throw cause } + runBlocking { job.join() } + + assertTrue(promise.await(timeoutMs = 100)) + assertNotNull(promise.rejectedThrowable) + assertEquals(cause, promise.rejectedThrowable) + } + + // --------------------------------------------------------------------------- + // Context forwarding + // --------------------------------------------------------------------------- + + /** + * Verifies that [launchBridge] forwards the [context] parameter to the underlying + * [kotlinx.coroutines.launch] call. A [CoroutineName] element is passed as the + * context; the block reads [currentCoroutineContext] and captures the name. After + * the coroutine completes the captured name must equal the one that was passed in. + */ + @Test + fun contextParameterIsForwardedToLaunch() { + val expectedName = "test-coroutine-context" + var capturedName: String? = null + + val promise = TestPromise() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + + val job = scope.launchBridge(promise, "TEST_ERROR", CoroutineName(expectedName)) { + capturedName = currentCoroutineContext()[CoroutineName]?.name + } + + runBlocking { job.join() } + + assertEquals( + "context parameter must be forwarded to the underlying launch call", + expectedName, + capturedName + ) + } +} + +/** + * Shadows [com.facebook.react.bridge.Arguments] to avoid loading native JNI libraries + * during unit tests. Returns [JavaOnlyMap] and [JavaOnlyArray] instead of their + * native-backed counterparts. + */ +@Implements(className = "com.facebook.react.bridge.Arguments") +object ShadowRnCoreArguments { + @Implementation + @JvmStatic + fun createMap(): WritableMap = JavaOnlyMap() + + @Implementation + @JvmStatic + fun createArray(): WritableArray = JavaOnlyArray() +} diff --git a/packages/device-client/android/src/main/java/com/pingidentity/rndeviceclient/RNPingDeviceClientCommon.kt b/packages/device-client/android/src/main/java/com/pingidentity/rndeviceclient/RNPingDeviceClientCommon.kt index f5fc6f5a1..de64b3d5f 100644 --- a/packages/device-client/android/src/main/java/com/pingidentity/rndeviceclient/RNPingDeviceClientCommon.kt +++ b/packages/device-client/android/src/main/java/com/pingidentity/rndeviceclient/RNPingDeviceClientCommon.kt @@ -25,10 +25,11 @@ import com.pingidentity.rncore.error.ErrorType import com.pingidentity.rncore.error.GenericError import com.pingidentity.rncore.error.reject import com.pingidentity.rncore.logger.LoggerHandleContract +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch +import com.pingidentity.rncore.utils.launchBridge import java.net.MalformedURLException import java.net.URI import java.net.URISyntaxException @@ -181,32 +182,40 @@ object RNPingDeviceClientCommon { val client = registry[handleId] ?: run { DeviceErrorClassifier.rejectHandleNotFound(promise); return } - scope.launch { - try { - // TODO-PARITY: Android repo properties are suffixed with `Device` (`oathDevice`, `pushDevice`, ...) - // while iOS uses bare names (`oath`, `push`, ...). Pick one convention. - // TODO-PARITY: Android uses `.devices()` while iOS uses `.get()`. Pick one convention. - val result = when (deviceType) { + scope.launchBridge(promise, DeviceClientErrorCodes.DEVICE_CLIENT_ERROR) { + // TODO-PARITY: Android repo properties are suffixed with `Device` (`oathDevice`, `pushDevice`, ...) + // while iOS uses bare names (`oath`, `push`, ...). Pick one convention. + // TODO-PARITY: Android uses `.devices()` while iOS uses `.get()`. Pick one convention. + val result = try { + when (deviceType) { DeviceType.OATH -> client.oathDevice.devices() DeviceType.PUSH -> client.pushDevice.devices() DeviceType.BOUND -> client.boundDevice.devices() DeviceType.PROFILE -> client.profileDevice.devices() DeviceType.WEB_AUTHN -> client.webAuthnDevice.devices() else -> { - DeviceErrorClassifier.rejectInvalidType(promise, deviceType); return@launch + DeviceErrorClassifier.rejectInvalidType(promise, deviceType); return@launchBridge } } - result.fold( - onSuccess = { list -> - val payload = Arguments.createMap() - payload.putArray("result", DeviceJson.encodeDevices(list as List)) - promise.resolve(payload) - }, - onFailure = { err -> DeviceErrorClassifier.rejectThrowable(promise, err) }, - ) + // Must re-throw: without this, CancellationException falls through to the + // inner Throwable catch and gets passed to the package-local error mapper, + // settling the promise instead of propagating scope cancellation. + } catch (e: CancellationException) { + throw e } catch (t: Throwable) { DeviceErrorClassifier.rejectThrowable(promise, t) + return@launchBridge } + // fold is outside the try/catch so onFailure exceptions propagate to + // launchBridge directly rather than being caught and double-rejected. + result.fold( + onSuccess = { list -> + val payload = Arguments.createMap() + payload.putArray("result", DeviceJson.encodeDevices(list as List)) + promise.resolve(payload) + }, + onFailure = { err -> DeviceErrorClassifier.rejectThrowable(promise, err) }, + ) } } @@ -232,30 +241,33 @@ object RNPingDeviceClientCommon { val client = registry[handleId] ?: run { DeviceErrorClassifier.rejectHandleNotFound(promise); return } - scope.launch { - try { + scope.launchBridge(promise, DeviceClientErrorCodes.DEVICE_CLIENT_ERROR) { + val result = try { val decoded = DeviceJson.decodeDevice(deviceType, device) - val result = when (deviceType) { + when (deviceType) { DeviceType.OATH -> client.oathDevice.updateAs(decoded) DeviceType.PUSH -> client.pushDevice.updateAs(decoded) DeviceType.BOUND -> client.boundDevice.updateAs(decoded) DeviceType.PROFILE -> client.profileDevice.updateAs(decoded) DeviceType.WEB_AUTHN -> client.webAuthnDevice.updateAs(decoded) else -> { - DeviceErrorClassifier.rejectInvalidType(promise, deviceType); return@launch + DeviceErrorClassifier.rejectInvalidType(promise, deviceType); return@launchBridge } } - result.fold( - onSuccess = { d -> - val payload = Arguments.createMap() - payload.putMap("result", DeviceJson.encodeDevice(d as Device)) - promise.resolve(payload) - }, - onFailure = { err -> DeviceErrorClassifier.rejectThrowable(promise, err) }, - ) + } catch (e: CancellationException) { + throw e } catch (t: Throwable) { DeviceErrorClassifier.rejectThrowable(promise, t) + return@launchBridge } + result.fold( + onSuccess = { d -> + val payload = Arguments.createMap() + payload.putMap("result", DeviceJson.encodeDevice(d as Device)) + promise.resolve(payload) + }, + onFailure = { err -> DeviceErrorClassifier.rejectThrowable(promise, err) }, + ) } } @@ -281,30 +293,33 @@ object RNPingDeviceClientCommon { val client = registry[handleId] ?: run { DeviceErrorClassifier.rejectHandleNotFound(promise); return } - scope.launch { - try { + scope.launchBridge(promise, DeviceClientErrorCodes.DEVICE_CLIENT_ERROR) { + val result = try { val decoded = DeviceJson.decodeDevice(deviceType, device) - val result = when (deviceType) { + when (deviceType) { DeviceType.OATH -> client.oathDevice.deleteAs(decoded) DeviceType.PUSH -> client.pushDevice.deleteAs(decoded) DeviceType.BOUND -> client.boundDevice.deleteAs(decoded) DeviceType.PROFILE -> client.profileDevice.deleteAs(decoded) DeviceType.WEB_AUTHN -> client.webAuthnDevice.deleteAs(decoded) else -> { - DeviceErrorClassifier.rejectInvalidType(promise, deviceType); return@launch + DeviceErrorClassifier.rejectInvalidType(promise, deviceType); return@launchBridge } } - result.fold( - onSuccess = { d -> - val payload = Arguments.createMap() - payload.putMap("result", DeviceJson.encodeDevice(d as Device)) - promise.resolve(payload) - }, - onFailure = { err -> DeviceErrorClassifier.rejectThrowable(promise, err) }, - ) + } catch (e: CancellationException) { + throw e } catch (t: Throwable) { DeviceErrorClassifier.rejectThrowable(promise, t) + return@launchBridge } + result.fold( + onSuccess = { d -> + val payload = Arguments.createMap() + payload.putMap("result", DeviceJson.encodeDevice(d as Device)) + promise.resolve(payload) + }, + onFailure = { err -> DeviceErrorClassifier.rejectThrowable(promise, err) }, + ) } } diff --git a/packages/device-id/android/src/main/java/com/pingidentity/rndeviceid/RNPingDeviceIdCommon.kt b/packages/device-id/android/src/main/java/com/pingidentity/rndeviceid/RNPingDeviceIdCommon.kt index 6aeaa2bf0..ed651fa17 100644 --- a/packages/device-id/android/src/main/java/com/pingidentity/rndeviceid/RNPingDeviceIdCommon.kt +++ b/packages/device-id/android/src/main/java/com/pingidentity/rndeviceid/RNPingDeviceIdCommon.kt @@ -8,12 +8,10 @@ package com.pingidentity.rndeviceid import com.facebook.react.bridge.Promise import com.pingidentity.device.id.DeviceIdentifier -import com.pingidentity.rncore.error.mapThrowableToGenericError -import com.pingidentity.rncore.error.reject +import com.pingidentity.rncore.utils.launchBridge import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch /** * Shared implementation for Device ID operations on Android. @@ -52,14 +50,9 @@ object RNPingDeviceIdCommon { promise: Promise, resolver: suspend () -> String ) { - scope.launch { - try { - val id = resolver() - promise.resolve(id) - } catch (e: Exception) { - val error = mapThrowableToGenericError(e, DeviceIdErrorCodes.DEVICE_ID_ERROR) - promise.reject(error, e) - } + scope.launchBridge(promise, DeviceIdErrorCodes.DEVICE_ID_ERROR) { + val id = resolver() + promise.resolve(id) } } diff --git a/packages/device-profile/android/src/main/java/com/pingidentity/rndeviceprofile/RNPingDeviceProfileCommon.kt b/packages/device-profile/android/src/main/java/com/pingidentity/rndeviceprofile/RNPingDeviceProfileCommon.kt index 5d93cc2a6..95cc9c2f4 100644 --- a/packages/device-profile/android/src/main/java/com/pingidentity/rndeviceprofile/RNPingDeviceProfileCommon.kt +++ b/packages/device-profile/android/src/main/java/com/pingidentity/rndeviceprofile/RNPingDeviceProfileCommon.kt @@ -7,7 +7,6 @@ package com.pingidentity.rndeviceprofile -import android.util.Log import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReadableArray @@ -32,9 +31,10 @@ import com.pingidentity.rncore.error.mapThrowableToGenericError import com.pingidentity.rncore.error.reject import com.pingidentity.rncore.logger.LoggerHandleContract import com.pingidentity.rncore.utils.JsonBridgeMapper +import com.pingidentity.rncore.utils.launchBridge import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import kotlinx.coroutines.SupervisorJob import kotlinx.serialization.json.JsonObject /** @@ -43,7 +43,7 @@ import kotlinx.serialization.json.JsonObject object RNPingDeviceProfileCommon { private const val LOCATION_SERVICES_CLASS = "com.google.android.gms.location.LocationServices" - private val scope = CoroutineScope(Dispatchers.IO) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private const val LOCATION_ERROR_MESSAGE = "LocationCollector requires Google Play Services Location. Add " + "\"com.google.android.gms:play-services-location\" to your app dependencies." @@ -110,25 +110,18 @@ object RNPingDeviceProfileCommon { return } - scope.launch { - try { - val deviceCollectors = buildCollectors(collectorTypes, includeLocation = true) - val jsonElement = if (deviceCollectors.isEmpty()) { - JsonObject(emptyMap()) - } else { - deviceCollectors.collect() - } - val bridgePayload = when (jsonElement) { - is JsonObject -> JsonBridgeMapper.encodeJsonObject(jsonElement) - else -> JsonBridgeMapper.encodeJsonElement(jsonElement) - } - promise.resolve(bridgePayload) - } catch (e: Throwable) { - promise.reject( - mapThrowableToGenericError(e, DeviceProfileErrorCodes.DEVICE_PROFILE_COLLECT_ERROR), - e - ) + scope.launchBridge(promise, DeviceProfileErrorCodes.DEVICE_PROFILE_COLLECT_ERROR) { + val deviceCollectors = buildCollectors(collectorTypes, includeLocation = true) + val jsonElement = if (deviceCollectors.isEmpty()) { + JsonObject(emptyMap()) + } else { + deviceCollectors.collect() + } + val bridgePayload = when (jsonElement) { + is JsonObject -> JsonBridgeMapper.encodeJsonObject(jsonElement) + else -> JsonBridgeMapper.encodeJsonElement(jsonElement) } + promise.resolve(bridgePayload) } } @@ -158,52 +151,44 @@ object RNPingDeviceProfileCommon { return } - scope.launch { - try { - val metadataCollectors = buildCollectors(collectorTypes, includeLocation = false) - val callback = resolveDeviceProfileCallback(journeyId) - if (callback == null) { - val error = GenericError( - type = ErrorType.STATE_ERROR, - error = DeviceProfileErrorCodes.DEVICE_PROFILE_CALLBACK_NOT_FOUND, - message = "No active Device Profile callback found for journey $journeyId." - ) - promise.reject(error) - return@launch - } + scope.launchBridge(promise, DeviceProfileErrorCodes.DEVICE_PROFILE_COLLECT_ERROR) { + val metadataCollectors = buildCollectors(collectorTypes, includeLocation = false) + val callback = resolveDeviceProfileCallback(journeyId) + if (callback == null) { + val error = GenericError( + type = ErrorType.STATE_ERROR, + error = DeviceProfileErrorCodes.DEVICE_PROFILE_CALLBACK_NOT_FOUND, + message = "No active Device Profile callback found for journey $journeyId." + ) + promise.reject(error) + return@launchBridge + } - val result = callback.collect { - logger = resolvedLogger ?: Logger.NONE - collectors { - addAll(metadataCollectors) - } + val result = callback.collect { + logger = resolvedLogger ?: Logger.NONE + collectors { + addAll(metadataCollectors) } - - result.fold( - onSuccess = { - promise.resolve( - createJourneyResultPayload(type = "success") - ) - }, - onFailure = { error -> - promise.reject( - mapThrowableToGenericError( - error, - DeviceProfileErrorCodes.DEVICE_PROFILE_COLLECT_ERROR - ), - error - ) - } - ) - } catch (error: Throwable) { - promise.reject( - mapThrowableToGenericError( - error, - DeviceProfileErrorCodes.DEVICE_PROFILE_COLLECT_ERROR - ), - error - ) } + + result.fold( + onSuccess = { + promise.resolve( + createJourneyResultPayload(type = "success") + ) + }, + // The SDK wraps all exceptions in Result.Failure before launchBridge can + // intercept them, so we map errors explicitly here rather than rethrowing. + onFailure = { error -> + promise.reject( + mapThrowableToGenericError( + error, + DeviceProfileErrorCodes.DEVICE_PROFILE_COLLECT_ERROR + ), + error + ) + } + ) } } diff --git a/packages/external-idp/android/src/main/java/com/pingidentity/rnexternalidp/RNPingExternalIdpCommon.kt b/packages/external-idp/android/src/main/java/com/pingidentity/rnexternalidp/RNPingExternalIdpCommon.kt index 5f6ff1b46..f0e78ba19 100644 --- a/packages/external-idp/android/src/main/java/com/pingidentity/rnexternalidp/RNPingExternalIdpCommon.kt +++ b/packages/external-idp/android/src/main/java/com/pingidentity/rnexternalidp/RNPingExternalIdpCommon.kt @@ -27,12 +27,13 @@ import com.pingidentity.rncore.error.mapThrowableToGenericError import com.pingidentity.rncore.error.reject import com.pingidentity.rncore.logger.LoggerHandleContract import com.pingidentity.rncore.utils.JsonBridgeMapper +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import com.pingidentity.rncore.utils.launchBridge import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -144,7 +145,7 @@ object RNPingExternalIdpCommon { return } - scope.launch { + scope.launchBridge(promise, ExternalIdpErrorCodes.AUTHORIZE_ERROR) { try { val index = parseCallbackIndex(options) logger?.i("External IdP authorizeForJourney requested for callback index $index") @@ -157,7 +158,7 @@ object RNPingExternalIdpCommon { message = "No active IdP callback found for journey $journeyId at index $index.", type = ErrorType.STATE_ERROR ) - return@launch + return@launchBridge } val parsedRedirectUri = try { parseRedirectUri(callConfig.redirectUri, callback.provider) @@ -170,7 +171,7 @@ object RNPingExternalIdpCommon { type = ErrorType.ARGUMENT_ERROR, throwable = e ) - return@launch + return@launchBridge } // TODO: Add browser fallback here when the Android native SDK supports it for AIC Journey flows. @@ -203,6 +204,11 @@ object RNPingExternalIdpCommon { ) } ) + // Must re-throw: without this, CancellationException falls through to the + // inner Throwable/Exception catch and gets passed to the package-local error + // mapper, settling the promise instead of propagating scope cancellation. + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { when (e) { is ClassNotFoundException, is NoClassDefFoundError -> { @@ -274,7 +280,7 @@ object RNPingExternalIdpCommon { return } - scope.launch { + scope.launchBridge(promise, ExternalIdpErrorCodes.CONFIG_ERROR) { try { val index = parseCallbackIndex(options) logger?.i("External IdP select provider requested for callback index $index") @@ -287,12 +293,14 @@ object RNPingExternalIdpCommon { message = "No active SelectIdp callback found for journey $journeyId at index $index.", type = ErrorType.STATE_ERROR ) - return@launch + return@launchBridge } callback.value = selectedProvider logger?.d("External IdP select provider succeeded") promise.resolve(null) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { logger?.e("External IdP select provider failed", e) rejectWithError( diff --git a/packages/fido/android/src/main/java/com/pingidentity/rnfido/RNPingFidoCommon.kt b/packages/fido/android/src/main/java/com/pingidentity/rnfido/RNPingFidoCommon.kt index 2418e05eb..4239925fb 100644 --- a/packages/fido/android/src/main/java/com/pingidentity/rnfido/RNPingFidoCommon.kt +++ b/packages/fido/android/src/main/java/com/pingidentity/rnfido/RNPingFidoCommon.kt @@ -24,10 +24,10 @@ import com.pingidentity.rncore.error.mapThrowableToGenericError import com.pingidentity.rncore.error.reject import com.pingidentity.rncore.logger.LoggerHandleContract import com.pingidentity.rncore.utils.JsonBridgeMapper +import com.pingidentity.rncore.utils.launchBridge import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject @@ -147,40 +147,33 @@ object RNPingFidoCommon { defaultFailureMessage: String, operation: suspend (client: FidoClient, input: JsonObject) -> Result ) { - scope.launch { - try { - val input = JsonBridgeMapper.decodeReadableMap(options) - val client = createFidoClient(parseCallConfig(config)) - val result = operation(client, input) - result.fold( - onSuccess = { payload -> - promise.resolve(JsonBridgeMapper.encodeJsonObject(payload)) - }, - onFailure = { error -> - rejectWithError( - promise = promise, - code = errorCode, - message = error.localizedMessage ?: defaultFailureMessage, - throwable = error - ) - } - ) - } catch (error: IllegalArgumentException) { + scope.launchBridge(promise, errorCode) { + val input = try { + JsonBridgeMapper.decodeReadableMap(options) + } catch (e: IllegalArgumentException) { rejectWithError( promise = promise, code = errorCode, - message = error.localizedMessage ?: invalidOptionsMessage, - type = ErrorType.ARGUMENT_ERROR, - throwable = error - ) - } catch (error: Throwable) { - rejectWithError( - promise = promise, - code = errorCode, - message = error.localizedMessage ?: defaultFailureMessage, - throwable = error + message = e.localizedMessage ?: invalidOptionsMessage, + throwable = e ) + return@launchBridge } + val client = createFidoClient(parseCallConfig(config)) + val result = operation(client, input) + result.fold( + onSuccess = { payload -> + promise.resolve(JsonBridgeMapper.encodeJsonObject(payload)) + }, + onFailure = { error -> + rejectWithError( + promise = promise, + code = errorCode, + message = error.localizedMessage ?: defaultFailureMessage, + throwable = error + ) + } + ) } } @@ -217,57 +210,40 @@ object RNPingFidoCommon { return } - scope.launch { - try { - val index = parseCallbackIndex(options) - val callback = resolveRegistrationCallback(journeyId, index) - if (callback == null) { - rejectWithError( - promise = promise, - code = FidoErrorCodes.FIDO_CALLBACK_NOT_FOUND, - message = "No active FIDO registration callback found for journey $journeyId at index $index.", - type = ErrorType.STATE_ERROR - ) - return@launch - } - - val deviceName = parseDeviceName(options) - val result = if (deviceName == null) { - callback.register() - } else { - callback.register(deviceName) - } - - result.fold( - onSuccess = { - promise.resolve(createJourneyResultPayload(type = "success")) - }, - onFailure = { error -> - rejectWithError( - promise = promise, - code = FidoErrorCodes.FIDO_REGISTER_ERROR, - message = error.localizedMessage - ?: "Journey FIDO registration callback execution failed.", - throwable = error - ) - } - ) - } catch (error: IllegalArgumentException) { + scope.launchBridge(promise, FidoErrorCodes.FIDO_REGISTER_ERROR) { + val index = parseCallbackIndex(options) + val callback = resolveRegistrationCallback(journeyId, index) + if (callback == null) { rejectWithError( promise = promise, - code = FidoErrorCodes.FIDO_REGISTER_ERROR, - message = error.localizedMessage ?: "Invalid Journey FIDO registration options payload.", - type = ErrorType.ARGUMENT_ERROR, - throwable = error - ) - } catch (error: Throwable) { - rejectWithError( - promise = promise, - code = FidoErrorCodes.FIDO_REGISTER_ERROR, - message = error.localizedMessage ?: "Journey FIDO registration callback execution failed.", - throwable = error + code = FidoErrorCodes.FIDO_CALLBACK_NOT_FOUND, + message = "No active FIDO registration callback found for journey $journeyId at index $index.", + type = ErrorType.STATE_ERROR ) + return@launchBridge + } + + val deviceName = parseDeviceName(options) + val result = if (deviceName == null) { + callback.register() + } else { + callback.register(deviceName) } + + result.fold( + onSuccess = { + promise.resolve(createJourneyResultPayload(type = "success")) + }, + onFailure = { error -> + rejectWithError( + promise = promise, + code = FidoErrorCodes.FIDO_REGISTER_ERROR, + message = error.localizedMessage + ?: "Journey FIDO registration callback execution failed.", + throwable = error + ) + } + ) } } @@ -304,51 +280,42 @@ object RNPingFidoCommon { return } - scope.launch { - try { - val index = parseCallbackIndex(options) - val callback = resolveAuthenticationCallback(journeyId, index) - if (callback == null) { - rejectWithError( - promise = promise, - code = FidoErrorCodes.FIDO_CALLBACK_NOT_FOUND, - message = "No active FIDO authentication callback found for journey $journeyId at index $index.", - type = ErrorType.STATE_ERROR - ) - return@launch - } + scope.launchBridge(promise, FidoErrorCodes.FIDO_AUTHENTICATE_ERROR) { + val index = parseCallbackIndex(options) + val callback = resolveAuthenticationCallback(journeyId, index) + if (callback == null) { + rejectWithError( + promise = promise, + code = FidoErrorCodes.FIDO_CALLBACK_NOT_FOUND, + message = "No active FIDO authentication callback found for journey $journeyId at index $index.", + type = ErrorType.STATE_ERROR + ) + return@launchBridge + } - callback.authenticate().fold( - onSuccess = { - promise.resolve(createJourneyResultPayload(type = "success")) - }, - onFailure = { error -> - if (isRecoverableFidoAuthenticationFailure(error)) { - rejectWithError( - promise = promise, - code = FidoErrorCodes.FIDO_AUTHENTICATE_CANCELLED, - message = "FIDO authentication cancelled: ${error.localizedMessage ?: error}", - throwable = error - ) - return@fold - } + callback.authenticate().fold( + onSuccess = { + promise.resolve(createJourneyResultPayload(type = "success")) + }, + onFailure = { error -> + if (isRecoverableFidoAuthenticationFailure(error)) { rejectWithError( promise = promise, - code = FidoErrorCodes.FIDO_AUTHENTICATE_ERROR, - message = error.localizedMessage - ?: "Journey FIDO authentication callback execution failed.", + code = FidoErrorCodes.FIDO_AUTHENTICATE_CANCELLED, + message = "FIDO authentication cancelled: ${error.localizedMessage ?: error}", throwable = error ) + return@fold } - ) - } catch (error: Throwable) { - rejectWithError( - promise = promise, - code = FidoErrorCodes.FIDO_AUTHENTICATE_ERROR, - message = error.localizedMessage ?: "Journey FIDO authentication callback execution failed.", - throwable = error - ) - } + rejectWithError( + promise = promise, + code = FidoErrorCodes.FIDO_AUTHENTICATE_ERROR, + message = error.localizedMessage + ?: "Journey FIDO authentication callback execution failed.", + throwable = error + ) + } + ) } } diff --git a/packages/fido/android/src/test/java/com/pingidentity/rnfido/RNPingFidoTest.kt b/packages/fido/android/src/test/java/com/pingidentity/rnfido/RNPingFidoTest.kt index 4b3b4d2c9..94fd60fb3 100644 --- a/packages/fido/android/src/test/java/com/pingidentity/rnfido/RNPingFidoTest.kt +++ b/packages/fido/android/src/test/java/com/pingidentity/rnfido/RNPingFidoTest.kt @@ -6,6 +6,8 @@ */ package com.pingidentity.rnfido +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.JavaOnlyArray import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.bridge.Promise import com.facebook.react.bridge.WritableMap @@ -16,7 +18,9 @@ import com.pingidentity.rncore.utils.JsonBridgeMapper import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject -import io.mockk.unmockkAll +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import org.junit.Assert.assertEquals @@ -40,13 +44,17 @@ class RNPingFidoTest { fun setUp() { runCatching { SoLoader.init(RuntimeEnvironment.getApplication(), false) } runCatching { NativeLoader.init(SystemDelegate()) } + mockkStatic(Arguments::class) + every { Arguments.createMap() } answers { JavaOnlyMap() } + every { Arguments.createArray() } answers { JavaOnlyArray() } RNPingFidoCommon.foregroundActivityProvider = { true } } @After fun tearDown() { RNPingFidoCommon.foregroundActivityProvider = { true } - unmockkAll() + unmockkStatic(Arguments::class) + unmockkObject(JsonBridgeMapper) } // MARK: - Error code contracts @@ -164,8 +172,8 @@ class RNPingFidoTest { } /** - * Ensures registration uses the stable invalid-options fallback message when decode errors - * without a detail message. + * Ensures registration rejects with the stable error code and descriptive fallback message + * when decode errors without a message — preserving the original JS-visible contract. */ @Test fun registerRejectsWithInvalidOptionsMessageWhenDecodeFailsWithoutMessage() { @@ -182,8 +190,8 @@ class RNPingFidoTest { } /** - * Ensures authentication uses the stable invalid-options fallback message when decode errors - * without a detail message. + * Ensures authentication rejects with the stable error code and descriptive fallback message + * when decode errors without a message — preserving the original JS-visible contract. */ @Test fun authenticateRejectsWithInvalidOptionsMessageWhenDecodeFailsWithoutMessage() { diff --git a/packages/journey/android/src/main/java/com/pingidentity/rnjourney/RNPingJourneyCommon.kt b/packages/journey/android/src/main/java/com/pingidentity/rnjourney/RNPingJourneyCommon.kt index 10d084952..f0238d63c 100644 --- a/packages/journey/android/src/main/java/com/pingidentity/rnjourney/RNPingJourneyCommon.kt +++ b/packages/journey/android/src/main/java/com/pingidentity/rnjourney/RNPingJourneyCommon.kt @@ -29,11 +29,11 @@ import com.pingidentity.rncore.error.reject import com.pingidentity.rncore.logger.LoggerHandleContract import com.pingidentity.rncore.registry.NativeHandle import com.pingidentity.rncore.utils.JsonBridgeMapper +import com.pingidentity.rncore.utils.launchBridge import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonObject import java.util.concurrent.ConcurrentHashMap @@ -253,17 +253,13 @@ internal object RNPingJourneyCommon { val forceAuth = getBooleanOption(options, "forceAuth") val noSession = getBooleanOption(options, "noSession") - scope.launch { - try { - val node = workflow.start(journeyName) { - this.forceAuth = forceAuth - this.noSession = noSession - } - setNodeState(journeyId, node) - promise.resolve(JourneyNodeMapper.mapNode(node, resolveJourneyLogger(journeyId))) - } catch (error: Exception) { - promise.reject(JourneyErrorMapper.map(error, JourneyErrorCodes.START), error) + scope.launchBridge(promise, JourneyErrorCodes.START) { + val node = workflow.start(journeyName) { + this.forceAuth = forceAuth + this.noSession = noSession } + setNodeState(journeyId, node) + promise.resolve(JourneyNodeMapper.mapNode(node, resolveJourneyLogger(journeyId))) } } @@ -321,7 +317,7 @@ internal object RNPingJourneyCommon { return } - scope.launch { + scope.launchBridge(promise, JourneyErrorCodes.NEXT) { try { if (mutations.isNotEmpty()) { JourneyCallbackValueApplier.apply(currentNode, mutations) @@ -356,8 +352,6 @@ internal object RNPingJourneyCommon { ), error ) - } catch (error: Exception) { - promise.reject(JourneyErrorMapper.map(error, JourneyErrorCodes.NEXT), error) } } } @@ -391,14 +385,10 @@ internal object RNPingJourneyCommon { return } - scope.launch { - try { - val resumedNode = workflow.resume(Uri.parse(uri)) - setNodeState(journeyId, resumedNode) - promise.resolve(JourneyNodeMapper.mapNode(resumedNode, resolveJourneyLogger(journeyId))) - } catch (error: Exception) { - promise.reject(JourneyErrorMapper.map(error, JourneyErrorCodes.RESUME), error) - } + scope.launchBridge(promise, JourneyErrorCodes.RESUME) { + val resumedNode = workflow.resume(Uri.parse(uri)) + setNodeState(journeyId, resumedNode) + promise.resolve(JourneyNodeMapper.mapNode(resumedNode, resolveJourneyLogger(journeyId))) } } @@ -450,32 +440,28 @@ internal object RNPingJourneyCommon { return } - scope.launch { - try { - val user = workflow.user() - if (user == null) { - promise.resolve(null) - return@launch - } + scope.launchBridge(promise, JourneyErrorCodes.USER) { + val user = workflow.user() + if (user == null) { + promise.resolve(null) + return@launchBridge + } - when (val tokenResult = user.token()) { - is Result.Success<*> -> { - val token = tokenResult.value as? Token - ?: throw IllegalStateException("Invalid token payload type") - promise.resolve(mapSessionPayload(user, token)) - } - is Result.Failure<*> -> { - promise.reject( - GenericError( - type = ErrorType.AUTH_ERROR, - error = JourneyErrorCodes.USER, - message = tokenResult.value.toString() - ) + when (val tokenResult = user.token()) { + is Result.Success<*> -> { + val token = tokenResult.value as? Token + ?: throw IllegalStateException("Invalid token payload type") + promise.resolve(mapSessionPayload(user, token)) + } + is Result.Failure<*> -> { + promise.reject( + GenericError( + type = ErrorType.AUTH_ERROR, + error = JourneyErrorCodes.USER, + message = tokenResult.value.toString() ) - } + ) } - } catch (error: Exception) { - promise.reject(JourneyErrorMapper.map(error, JourneyErrorCodes.USER), error) } } } @@ -498,32 +484,28 @@ internal object RNPingJourneyCommon { return } - scope.launch { - try { - val user = workflow.user() - if (user == null) { - promise.resolve(null) - return@launch - } + scope.launchBridge(promise, JourneyErrorCodes.USER) { + val user = workflow.user() + if (user == null) { + promise.resolve(null) + return@launchBridge + } - when (val tokenResult = user.refresh()) { - is Result.Success<*> -> { - val token = tokenResult.value as? Token - ?: throw IllegalStateException("Invalid token payload type") - promise.resolve(mapSessionPayload(user, token)) - } - is Result.Failure<*> -> { - promise.reject( - GenericError( - type = ErrorType.AUTH_ERROR, - error = JourneyErrorCodes.USER, - message = tokenResult.value.toString() - ) + when (val tokenResult = user.refresh()) { + is Result.Success<*> -> { + val token = tokenResult.value as? Token + ?: throw IllegalStateException("Invalid token payload type") + promise.resolve(mapSessionPayload(user, token)) + } + is Result.Failure<*> -> { + promise.reject( + GenericError( + type = ErrorType.AUTH_ERROR, + error = JourneyErrorCodes.USER, + message = tokenResult.value.toString() ) - } + ) } - } catch (error: Exception) { - promise.reject(JourneyErrorMapper.map(error, JourneyErrorCodes.USER), error) } } } @@ -546,14 +528,10 @@ internal object RNPingJourneyCommon { return } - scope.launch { - try { - val user = workflow.user() - user?.revoke() - promise.resolve(true) - } catch (error: Exception) { - promise.reject(JourneyErrorMapper.map(error, JourneyErrorCodes.USER), error) - } + scope.launchBridge(promise, JourneyErrorCodes.USER) { + val user = workflow.user() + user?.revoke() + promise.resolve(true) } } @@ -575,32 +553,28 @@ internal object RNPingJourneyCommon { return } - scope.launch { - try { - val user = workflow.user() - if (user == null) { - promise.resolve(null) - return@launch - } + scope.launchBridge(promise, JourneyErrorCodes.USER) { + val user = workflow.user() + if (user == null) { + promise.resolve(null) + return@launchBridge + } - when (val result = user.userinfo(false)) { - is Result.Success<*> -> { - val userInfo = result.value as? JsonObject - ?: throw IllegalStateException("Invalid userinfo payload type") - promise.resolve(JsonBridgeMapper.encodeJsonObject(userInfo)) - } - is Result.Failure<*> -> { - promise.reject( - GenericError( - type = ErrorType.AUTH_ERROR, - error = JourneyErrorCodes.USER, - message = result.value.toString() - ) + when (val result = user.userinfo(false)) { + is Result.Success<*> -> { + val userInfo = result.value as? JsonObject + ?: throw IllegalStateException("Invalid userinfo payload type") + promise.resolve(JsonBridgeMapper.encodeJsonObject(userInfo)) + } + is Result.Failure<*> -> { + promise.reject( + GenericError( + type = ErrorType.AUTH_ERROR, + error = JourneyErrorCodes.USER, + message = result.value.toString() ) - } + ) } - } catch (error: Exception) { - promise.reject(JourneyErrorMapper.map(error, JourneyErrorCodes.USER), error) } } } @@ -623,23 +597,19 @@ internal object RNPingJourneyCommon { return } - scope.launch { - try { - val user = workflow.user() - if (user == null) { - promise.resolve(null) - return@launch - } - - val ssoToken = user.session() - val resultMap = Arguments.createMap() - resultMap.putString("value", ssoToken.value) - resultMap.putString("successUrl", ssoToken.successUrl) - resultMap.putString("realm", ssoToken.realm) - promise.resolve(resultMap) - } catch (error: Exception) { - promise.reject(JourneyErrorMapper.map(error, JourneyErrorCodes.USER), error) + scope.launchBridge(promise, JourneyErrorCodes.USER) { + val user = workflow.user() + if (user == null) { + promise.resolve(null) + return@launchBridge } + + val ssoToken = user.session() + val resultMap = Arguments.createMap() + resultMap.putString("value", ssoToken.value) + resultMap.putString("successUrl", ssoToken.successUrl) + resultMap.putString("realm", ssoToken.realm) + promise.resolve(resultMap) } } @@ -661,15 +631,11 @@ internal object RNPingJourneyCommon { return } - scope.launch { - try { - val user = workflow.user() - user?.logout() - clearNodeState(journeyId) - promise.resolve(true) - } catch (error: Exception) { - promise.reject(JourneyErrorMapper.map(error, JourneyErrorCodes.LOGOUT), error) - } + scope.launchBridge(promise, JourneyErrorCodes.LOGOUT) { + val user = workflow.user() + user?.logout() + clearNodeState(journeyId) + promise.resolve(true) } } diff --git a/packages/oath/android/src/main/java/com/pingidentity/rnoath/RNPingOathCommon.kt b/packages/oath/android/src/main/java/com/pingidentity/rnoath/RNPingOathCommon.kt index 4636c297a..0152a7825 100644 --- a/packages/oath/android/src/main/java/com/pingidentity/rnoath/RNPingOathCommon.kt +++ b/packages/oath/android/src/main/java/com/pingidentity/rnoath/RNPingOathCommon.kt @@ -32,12 +32,13 @@ import com.pingidentity.rncore.policy.OathPolicyDescriptor import com.pingidentity.rncore.policy.OathPolicyEvaluatorConfigHandleContract import com.pingidentity.rncore.storage.OathStorageConfigHandleContract import com.pingidentity.storage.sqlite.SQLiteStorageConfig +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import com.pingidentity.rncore.utils.launchBridge import java.util.Date import java.util.UUID import java.util.concurrent.ConcurrentHashMap @@ -125,7 +126,7 @@ object RNPingOathCommon { * @param promise Bridge promise resolved with a UUID handle string or rejected with [GenericError]. */ fun create(config: ReadableMap, promise: Promise) { - scope.launch { + scope.launchBridge(promise, OathErrorCodes.OATH_INITIALIZATION_FAILED, Dispatchers.Default) { try { val storageId = if (config.hasKey("storageId") && !config.isNull("storageId")) { config.getString("storageId") @@ -141,7 +142,7 @@ object RNPingOathCommon { message = "Unresolvable storageId: $storageId" ) ) - return@launch + return@launchBridge } SQLOathStorage(SQLiteStorageConfig().apply { handle.databaseName?.let { databaseName = it } }) } else null @@ -170,7 +171,7 @@ object RNPingOathCommon { message = "Unresolvable policyEvaluatorId: $policyEvaluatorId" ) ) - return@launch + return@launchBridge } // When the descriptor carries a loggerId, use that logger for the evaluator. // Otherwise fall back to the logger resolved from OathClientConfig.loggerId. @@ -221,6 +222,11 @@ object RNPingOathCommon { registry.remove(handle)?.client?.close() throw t } + // Must re-throw: without this, CancellationException falls through to the + // inner Throwable/Exception catch and gets passed to the package-local error + // mapper, settling the promise instead of propagating scope cancellation. + } catch (e: CancellationException) { + throw e } catch (e: Exception) { promise.reject(OathErrorMapper.mapThrowable(e, OathErrorCodes.OATH_INITIALIZATION_FAILED), e) } @@ -243,12 +249,14 @@ object RNPingOathCommon { message = "No OATH client found for handle $handle" ) ) - scope.launch { + scope.launchBridge(promise, OathErrorCodes.OATH_INVALID_URI, Dispatchers.Default) { try { val credential = entry.mutex.withLock { entry.client.addCredentialFromUri(uri).getOrThrow() } promise.resolve(encodeCredential(credential)) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { promise.reject(OathErrorMapper.mapThrowable(e, OathErrorCodes.OATH_INVALID_URI), e) } @@ -271,7 +279,7 @@ object RNPingOathCommon { message = "No OATH client found for handle $handle" ) ) - scope.launch { + scope.launchBridge(promise, OathErrorCodes.OATH_CREDENTIAL_NOT_FOUND, Dispatchers.Default) { try { val credential = entry.mutex.withLock { entry.client.getCredential(credentialId).getOrThrow() @@ -281,6 +289,8 @@ object RNPingOathCommon { } else { promise.resolve(null) } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { promise.reject(OathErrorMapper.mapThrowable(e, OathErrorCodes.OATH_CREDENTIAL_NOT_FOUND), e) } @@ -302,7 +312,7 @@ object RNPingOathCommon { message = "No OATH client found for handle $handle" ) ) - scope.launch { + scope.launchBridge(promise, OathErrorCodes.OATH_UNKNOWN_ERROR, Dispatchers.Default) { try { val credentials = entry.mutex.withLock { entry.client.getCredentials().getOrThrow() @@ -312,6 +322,8 @@ object RNPingOathCommon { array.pushMap(encodeCredential(c)) } promise.resolve(array) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { promise.reject(OathErrorMapper.mapThrowable(e, OathErrorCodes.OATH_UNKNOWN_ERROR), e) } @@ -348,7 +360,7 @@ object RNPingOathCommon { message = "No OATH client found for handle $handle" ) ) - scope.launch { + scope.launchBridge(promise, OathErrorCodes.OATH_CREDENTIAL_NOT_FOUND, Dispatchers.Default) { try { val decoded = decodeCredential(credential) val saved = entry.mutex.withLock { @@ -371,6 +383,8 @@ object RNPingOathCommon { } } saved?.let { promise.resolve(encodeCredential(it)) } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { promise.reject(OathErrorMapper.mapThrowable(e, OathErrorCodes.OATH_CREDENTIAL_NOT_FOUND), e) } @@ -392,12 +406,14 @@ object RNPingOathCommon { message = "No OATH client found for handle $handle" ) ) - scope.launch { + scope.launchBridge(promise, OathErrorCodes.OATH_CREDENTIAL_NOT_FOUND, Dispatchers.Default) { try { val deleted = entry.mutex.withLock { entry.client.deleteCredential(credentialId).getOrThrow() } promise.resolve(deleted) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { promise.reject(OathErrorMapper.mapThrowable(e, OathErrorCodes.OATH_CREDENTIAL_NOT_FOUND), e) } @@ -419,12 +435,14 @@ object RNPingOathCommon { message = "No OATH client found for handle $handle" ) ) - scope.launch { + scope.launchBridge(promise, OathErrorCodes.OATH_CODE_GENERATION_FAILED, Dispatchers.Default) { try { val code = entry.mutex.withLock { entry.client.generateCode(credentialId).getOrThrow() } promise.resolve(code) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { promise.reject(OathErrorMapper.mapThrowable(e, OathErrorCodes.OATH_CODE_GENERATION_FAILED), e) } @@ -447,7 +465,7 @@ object RNPingOathCommon { message = "No OATH client found for handle $handle" ) ) - scope.launch { + scope.launchBridge(promise, OathErrorCodes.OATH_CODE_GENERATION_FAILED, Dispatchers.Default) { try { val codeInfo = entry.mutex.withLock { entry.client.generateCodeWithValidity(credentialId).getOrThrow() @@ -459,6 +477,8 @@ object RNPingOathCommon { map.putDouble("progress", codeInfo.progress) map.putInt("totalPeriod", codeInfo.totalPeriod) promise.resolve(map) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { promise.reject(OathErrorMapper.mapThrowable(e, OathErrorCodes.OATH_CODE_GENERATION_FAILED), e) } @@ -477,10 +497,12 @@ object RNPingOathCommon { */ fun close(handle: String, promise: Promise) { val entry = registry.remove(handle) ?: return promise.resolve(null) - scope.launch { + scope.launchBridge(promise, OathErrorCodes.OATH_STATE_ERROR, Dispatchers.Default) { try { entry.mutex.withLock { entry.client.close() } promise.resolve(null) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { promise.reject(OathErrorMapper.mapThrowable(e, OathErrorCodes.OATH_STATE_ERROR), e) } diff --git a/packages/oidc/android/src/main/java/com/pingidentity/rnoidc/RNPingOidcCommon.kt b/packages/oidc/android/src/main/java/com/pingidentity/rnoidc/RNPingOidcCommon.kt index c38472623..8f724a9de 100644 --- a/packages/oidc/android/src/main/java/com/pingidentity/rnoidc/RNPingOidcCommon.kt +++ b/packages/oidc/android/src/main/java/com/pingidentity/rnoidc/RNPingOidcCommon.kt @@ -26,15 +26,14 @@ import com.pingidentity.rncore.oidc.OidcOpenIdConfig import com.pingidentity.rncore.CoreRuntime import com.pingidentity.rncore.error.ErrorType import com.pingidentity.rncore.error.GenericError -import com.pingidentity.rncore.error.mapThrowableToGenericError import com.pingidentity.rncore.error.reject import com.pingidentity.rncore.registry.NativeHandle +import com.pingidentity.rncore.utils.launchBridge import com.pingidentity.utils.Result import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonObject @@ -234,18 +233,14 @@ object RNPingOidcCommon { return } - scope.launch(Dispatchers.IO) { - try { - when (val result = handle.user.token()) { - is Result.Success -> - promise.resolve(OidcResponseMapper.encodeTokens(result.value)) - is Result.Failure -> { - val error = OidcErrorMapper.mapOidcError(result.value, OidcErrorCodes.OIDC_TOKEN_ERROR) - promise.reject(error) - } + scope.launchBridge(promise, OidcErrorCodes.OIDC_TOKEN_ERROR, Dispatchers.IO) { + when (val result = handle.user.token()) { + is Result.Success -> + promise.resolve(OidcResponseMapper.encodeTokens(result.value)) + is Result.Failure -> { + val error = OidcErrorMapper.mapOidcError(result.value, OidcErrorCodes.OIDC_TOKEN_ERROR) + promise.reject(error) } - } catch (e: Exception) { - promise.reject(mapThrowableToGenericError(e, OidcErrorCodes.OIDC_TOKEN_ERROR), e) } } } @@ -269,18 +264,14 @@ object RNPingOidcCommon { return } - scope.launch(Dispatchers.IO) { - try { - when (val result = handle.user.refresh()) { - is Result.Success -> - promise.resolve(OidcResponseMapper.encodeTokens(result.value)) - is Result.Failure -> { - val error = OidcErrorMapper.mapOidcError(result.value, OidcErrorCodes.OIDC_REFRESH_ERROR) - promise.reject(error) - } + scope.launchBridge(promise, OidcErrorCodes.OIDC_REFRESH_ERROR, Dispatchers.IO) { + when (val result = handle.user.refresh()) { + is Result.Success -> + promise.resolve(OidcResponseMapper.encodeTokens(result.value)) + is Result.Failure -> { + val error = OidcErrorMapper.mapOidcError(result.value, OidcErrorCodes.OIDC_REFRESH_ERROR) + promise.reject(error) } - } catch (e: Exception) { - promise.reject(mapThrowableToGenericError(e, OidcErrorCodes.OIDC_REFRESH_ERROR), e) } } } @@ -305,18 +296,14 @@ object RNPingOidcCommon { return } - scope.launch(Dispatchers.IO) { - try { - when (val result = handle.user.userinfo(cache)) { - is Result.Success -> - promise.resolve(OidcResponseMapper.encodeUserinfo(result.value)) - is Result.Failure -> { - val error = OidcErrorMapper.mapOidcError(result.value, OidcErrorCodes.OIDC_USERINFO_ERROR) - promise.reject(error) - } + scope.launchBridge(promise, OidcErrorCodes.OIDC_USERINFO_ERROR, Dispatchers.IO) { + when (val result = handle.user.userinfo(cache)) { + is Result.Success -> + promise.resolve(OidcResponseMapper.encodeUserinfo(result.value)) + is Result.Failure -> { + val error = OidcErrorMapper.mapOidcError(result.value, OidcErrorCodes.OIDC_USERINFO_ERROR) + promise.reject(error) } - } catch (e: Exception) { - promise.reject(mapThrowableToGenericError(e, OidcErrorCodes.OIDC_USERINFO_ERROR), e) } } } @@ -340,13 +327,9 @@ object RNPingOidcCommon { return } - scope.launch(Dispatchers.IO) { - try { - handle.user.revoke() - promise.resolve(null) - } catch (e: Exception) { - promise.reject(mapThrowableToGenericError(e, OidcErrorCodes.OIDC_REVOKE_ERROR), e) - } + scope.launchBridge(promise, OidcErrorCodes.OIDC_REVOKE_ERROR, Dispatchers.IO) { + handle.user.revoke() + promise.resolve(null) } } @@ -369,13 +352,9 @@ object RNPingOidcCommon { return } - scope.launch(Dispatchers.IO) { - try { - val result = handle.client.endSession() - promise.resolve(result) - } catch (e: Exception) { - promise.reject(mapThrowableToGenericError(e, OidcErrorCodes.OIDC_LOGOUT_ERROR), e) - } + scope.launchBridge(promise, OidcErrorCodes.OIDC_LOGOUT_ERROR, Dispatchers.IO) { + val result = handle.client.endSession() + promise.resolve(result) } } @@ -400,37 +379,39 @@ object RNPingOidcCommon { } val authorizeParams = OidcConfigParser.buildAuthorizeParams(options) - scope.launch(Dispatchers.IO) { + scope.launchBridge(promise, OidcErrorCodes.OIDC_AUTHORIZE_ERROR, Dispatchers.IO) { val result = try { withContext(Dispatchers.Main) { handle.web.authorize { authorizeParams.forEach { (key, value) -> this[key] = value } } } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - if (e is BrowserCanceledException || e is CancellationException) { + if (e is BrowserCanceledException) { val canceled = Arguments.createMap() canceled.putString("type", "cancel") promise.resolve(canceled) - return@launch + return@launchBridge } promise.reject(OidcErrorMapper.mapAuthorizeThrowable(e), e) - return@launch + return@launchBridge } if (result.isSuccess) { val payload = Arguments.createMap() payload.putString("type", "success") promise.resolve(payload) - return@launch + return@launchBridge } val error = result.exceptionOrNull() - if (error is BrowserCanceledException || error is CancellationException) { + if (error is BrowserCanceledException) { val canceled = Arguments.createMap() canceled.putString("type", "cancel") promise.resolve(canceled) - return@launch + return@launchBridge } val mapped = OidcErrorMapper.mapAuthorizeThrowable(error) @@ -457,13 +438,9 @@ object RNPingOidcCommon { return } - scope.launch(Dispatchers.IO) { - try { - val user = handle.web.user() - promise.resolve(user != null) - } catch (e: Exception) { - promise.reject(mapThrowableToGenericError(e, OidcErrorCodes.OIDC_HAS_USER_ERROR), e) - } + scope.launchBridge(promise, OidcErrorCodes.OIDC_HAS_USER_ERROR, Dispatchers.IO) { + val user = handle.web.user() + promise.resolve(user != null) } } @@ -486,29 +463,25 @@ object RNPingOidcCommon { return } - scope.launch(Dispatchers.IO) { - try { - val user = handle.web.user() - if (user == null) { - val error = GenericError( - type = ErrorType.STATE_ERROR, - error = OidcErrorCodes.OIDC_TOKEN_ERROR, - message = "No authenticated user is available for this OIDC web client" - ) - promise.reject(error) - return@launch - } + scope.launchBridge(promise, OidcErrorCodes.OIDC_TOKEN_ERROR, Dispatchers.IO) { + val user = handle.web.user() + if (user == null) { + val error = GenericError( + type = ErrorType.STATE_ERROR, + error = OidcErrorCodes.OIDC_TOKEN_ERROR, + message = "No authenticated user is available for this OIDC web client" + ) + promise.reject(error) + return@launchBridge + } - when (val result = user.token()) { - is Result.Success -> - promise.resolve(OidcResponseMapper.encodeTokens(result.value)) - is Result.Failure -> { - val error = OidcErrorMapper.mapOidcError(result.value, OidcErrorCodes.OIDC_TOKEN_ERROR) - promise.reject(error) - } + when (val result = user.token()) { + is Result.Success -> + promise.resolve(OidcResponseMapper.encodeTokens(result.value)) + is Result.Failure -> { + val error = OidcErrorMapper.mapOidcError(result.value, OidcErrorCodes.OIDC_TOKEN_ERROR) + promise.reject(error) } - } catch (e: Exception) { - promise.reject(mapThrowableToGenericError(e, OidcErrorCodes.OIDC_TOKEN_ERROR), e) } } } @@ -532,29 +505,25 @@ object RNPingOidcCommon { return } - scope.launch(Dispatchers.IO) { - try { - val user = handle.web.user() - if (user == null) { - val error = GenericError( - type = ErrorType.STATE_ERROR, - error = OidcErrorCodes.OIDC_REFRESH_ERROR, - message = "No authenticated user is available for this OIDC web client" - ) - promise.reject(error) - return@launch - } + scope.launchBridge(promise, OidcErrorCodes.OIDC_REFRESH_ERROR, Dispatchers.IO) { + val user = handle.web.user() + if (user == null) { + val error = GenericError( + type = ErrorType.STATE_ERROR, + error = OidcErrorCodes.OIDC_REFRESH_ERROR, + message = "No authenticated user is available for this OIDC web client" + ) + promise.reject(error) + return@launchBridge + } - when (val result = user.refresh()) { - is Result.Success -> - promise.resolve(OidcResponseMapper.encodeTokens(result.value)) - is Result.Failure -> { - val error = OidcErrorMapper.mapOidcError(result.value, OidcErrorCodes.OIDC_REFRESH_ERROR) - promise.reject(error) - } + when (val result = user.refresh()) { + is Result.Success -> + promise.resolve(OidcResponseMapper.encodeTokens(result.value)) + is Result.Failure -> { + val error = OidcErrorMapper.mapOidcError(result.value, OidcErrorCodes.OIDC_REFRESH_ERROR) + promise.reject(error) } - } catch (e: Exception) { - promise.reject(mapThrowableToGenericError(e, OidcErrorCodes.OIDC_REFRESH_ERROR), e) } } } @@ -579,29 +548,25 @@ object RNPingOidcCommon { return } - scope.launch(Dispatchers.IO) { - try { - val user = handle.web.user() - if (user == null) { - val error = GenericError( - type = ErrorType.STATE_ERROR, - error = OidcErrorCodes.OIDC_USERINFO_ERROR, - message = "No authenticated user is available for this OIDC web client" - ) - promise.reject(error) - return@launch - } + scope.launchBridge(promise, OidcErrorCodes.OIDC_USERINFO_ERROR, Dispatchers.IO) { + val user = handle.web.user() + if (user == null) { + val error = GenericError( + type = ErrorType.STATE_ERROR, + error = OidcErrorCodes.OIDC_USERINFO_ERROR, + message = "No authenticated user is available for this OIDC web client" + ) + promise.reject(error) + return@launchBridge + } - when (val result = user.userinfo(cache)) { - is Result.Success -> - promise.resolve(OidcResponseMapper.encodeUserinfo(result.value)) - is Result.Failure -> { - val error = OidcErrorMapper.mapOidcError(result.value, OidcErrorCodes.OIDC_USERINFO_ERROR) - promise.reject(error) - } + when (val result = user.userinfo(cache)) { + is Result.Success -> + promise.resolve(OidcResponseMapper.encodeUserinfo(result.value)) + is Result.Failure -> { + val error = OidcErrorMapper.mapOidcError(result.value, OidcErrorCodes.OIDC_USERINFO_ERROR) + promise.reject(error) } - } catch (e: Exception) { - promise.reject(mapThrowableToGenericError(e, OidcErrorCodes.OIDC_USERINFO_ERROR), e) } } } @@ -625,23 +590,19 @@ object RNPingOidcCommon { return } - scope.launch(Dispatchers.IO) { - try { - val user = handle.web.user() - if (user == null) { - val error = GenericError( - type = ErrorType.STATE_ERROR, - error = OidcErrorCodes.OIDC_REVOKE_ERROR, - message = "No authenticated user is available for this OIDC web client" - ) - promise.reject(error) - return@launch - } - user.revoke() - promise.resolve(null) - } catch (e: Exception) { - promise.reject(mapThrowableToGenericError(e, OidcErrorCodes.OIDC_REVOKE_ERROR), e) + scope.launchBridge(promise, OidcErrorCodes.OIDC_REVOKE_ERROR, Dispatchers.IO) { + val user = handle.web.user() + if (user == null) { + val error = GenericError( + type = ErrorType.STATE_ERROR, + error = OidcErrorCodes.OIDC_REVOKE_ERROR, + message = "No authenticated user is available for this OIDC web client" + ) + promise.reject(error) + return@launchBridge } + user.revoke() + promise.resolve(null) } } @@ -664,23 +625,19 @@ object RNPingOidcCommon { return } - scope.launch(Dispatchers.IO) { - try { - val user = handle.web.user() - if (user == null) { - val error = GenericError( - type = ErrorType.STATE_ERROR, - error = OidcErrorCodes.OIDC_LOGOUT_ERROR, - message = "No authenticated user is available for this OIDC web client" - ) - promise.reject(error) - return@launch - } - user.logout() - promise.resolve(null) - } catch (e: Exception) { - promise.reject(mapThrowableToGenericError(e, OidcErrorCodes.OIDC_LOGOUT_ERROR), e) + scope.launchBridge(promise, OidcErrorCodes.OIDC_LOGOUT_ERROR, Dispatchers.IO) { + val user = handle.web.user() + if (user == null) { + val error = GenericError( + type = ErrorType.STATE_ERROR, + error = OidcErrorCodes.OIDC_LOGOUT_ERROR, + message = "No authenticated user is available for this OIDC web client" + ) + promise.reject(error) + return@launchBridge } + user.logout() + promise.resolve(null) } } diff --git a/packages/push/android/src/main/java/com/pingidentity/rnpush/RNPingPushCommon.kt b/packages/push/android/src/main/java/com/pingidentity/rnpush/RNPingPushCommon.kt index 6246ab20b..4bd5d597a 100644 --- a/packages/push/android/src/main/java/com/pingidentity/rnpush/RNPingPushCommon.kt +++ b/packages/push/android/src/main/java/com/pingidentity/rnpush/RNPingPushCommon.kt @@ -13,7 +13,7 @@ import com.facebook.react.bridge.ReadableMap import com.facebook.react.modules.core.DeviceEventManagerModule import com.google.firebase.messaging.FirebaseMessaging import com.pingidentity.android.ContextProvider -import org.json.JSONObject +import com.pingidentity.logger.Logger import com.pingidentity.mfa.push.NotificationCleanupConfig import com.pingidentity.mfa.push.NotificationCleanupConfig.CleanupMode import com.pingidentity.mfa.push.PushClient @@ -24,18 +24,20 @@ import com.pingidentity.rncore.error.ErrorType import com.pingidentity.rncore.error.GenericError import com.pingidentity.rncore.error.reject import com.pingidentity.rncore.storage.StorageConfigHandleContract +import com.pingidentity.rncore.utils.launchBridge import com.pingidentity.storage.sqlite.passphrase.KeyStorePassphraseProvider -import com.pingidentity.logger.Logger +import java.lang.ref.WeakReference +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await -import java.lang.ref.WeakReference -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import kotlin.time.Duration.Companion.milliseconds +import org.json.JSONObject /** * Singleton that owns all push MFA business logic for the Android RN bridge. @@ -55,7 +57,7 @@ object RNPingPushCommon { const val EVENT_FCM_TOKEN_RECEIVED = RNPingPushEvents.FCM_TOKEN_RECEIVED const val EVENT_PUSH_MESSAGE_RECEIVED = RNPingPushEvents.PUSH_MESSAGE_RECEIVED - private var scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + @Volatile private var scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val registry = ConcurrentHashMap() @@ -224,9 +226,18 @@ object RNPingPushCommon { */ @JvmStatic fun cleanup() { + val clients = registry.values.toList() + registry.clear() + // Close all clients on a dedicated scope so close() — a suspend function — + // can complete cleanly. The main scope is cancelled afterward; a fresh scope + // is created so the singleton can be re-used after re-initialisation. + val cleanupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + cleanupScope.launch { + clients.forEach { runCatching { it.close() } } + cleanupScope.cancel() + } scope.cancel() scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - registry.clear() reactContextRef = null synchronized(pendingEvents) { pendingEvents.clear() } synchronized(pendingMessages) { pendingMessages.clear() } @@ -244,7 +255,7 @@ object RNPingPushCommon { */ @JvmStatic fun initialize(config: ReadableMap, promise: Promise) { - scope.launch { + scope.launchBridge(promise, PushErrorCode.NETWORK_FAILURE) { try { val loggerId = if (config.hasKey("loggerId") && !config.isNull("loggerId")) { config.getString("loggerId")?.trim()?.takeIf { it.isNotEmpty() } @@ -254,6 +265,11 @@ object RNPingPushCommon { val clientId = UUID.randomUUID().toString() registry[clientId] = client promise.resolve(clientId) + // Must re-throw: without this, CancellationException falls through to the + // inner Throwable catch and gets passed to PushErrorMapper, settling the promise + // instead of propagating scope cancellation. + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { reject(e, promise) } @@ -273,10 +289,12 @@ object RNPingPushCommon { promise.reject(GenericError(type = ErrorType.STATE_ERROR, error = PushErrorCode.NOT_INITIALIZED, message = "Push client not found for id: $clientId")) return } - scope.launch { + scope.launchBridge(promise, PushErrorCode.NETWORK_FAILURE) { try { val credential = client.addCredentialFromUri(uri).getOrThrow() promise.resolve(serializeCredential(credential)) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { reject(e, promise) } @@ -296,11 +314,13 @@ object RNPingPushCommon { promise.reject(GenericError(type = ErrorType.STATE_ERROR, error = PushErrorCode.NOT_INITIALIZED, message = "Push client not found for id: $clientId")) return } - scope.launch { + scope.launchBridge(promise, PushErrorCode.NETWORK_FAILURE) { try { val credential = deserializeCredential(credentialMap) val saved = client.saveCredential(credential).getOrThrow() promise.resolve(serializeCredential(saved)) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { reject(e, promise) } @@ -319,7 +339,7 @@ object RNPingPushCommon { promise.reject(GenericError(type = ErrorType.STATE_ERROR, error = PushErrorCode.NOT_INITIALIZED, message = "Push client not found for id: $clientId")) return } - scope.launch { + scope.launchBridge(promise, PushErrorCode.NETWORK_FAILURE) { try { val credentials = client.getCredentials().getOrThrow() val array = Arguments.createArray() @@ -327,6 +347,8 @@ object RNPingPushCommon { val result = Arguments.createMap() result.putArray("credentials", array) promise.resolve(result) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { reject(e, promise) } @@ -346,7 +368,7 @@ object RNPingPushCommon { promise.reject(GenericError(type = ErrorType.STATE_ERROR, error = PushErrorCode.NOT_INITIALIZED, message = "Push client not found for id: $clientId")) return } - scope.launch { + scope.launchBridge(promise, PushErrorCode.NETWORK_FAILURE) { try { val credential = client.getCredential(credentialId).getOrThrow() val result = Arguments.createMap() @@ -356,6 +378,8 @@ object RNPingPushCommon { result.putNull("credential") } promise.resolve(result) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { reject(e, promise) } @@ -376,10 +400,12 @@ object RNPingPushCommon { promise.reject(GenericError(type = ErrorType.STATE_ERROR, error = PushErrorCode.NOT_INITIALIZED, message = "Push client not found for id: $clientId")) return } - scope.launch { + scope.launchBridge(promise, PushErrorCode.NETWORK_FAILURE) { try { val result = client.deleteCredential(credentialId).getOrThrow() promise.resolve(result) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { reject(e, promise) } @@ -400,10 +426,12 @@ object RNPingPushCommon { promise.reject(GenericError(type = ErrorType.STATE_ERROR, error = PushErrorCode.NOT_INITIALIZED, message = "Push client not found for id: $clientId")) return } - scope.launch { + scope.launchBridge(promise, PushErrorCode.NETWORK_FAILURE) { try { val result = client.setDeviceToken(token, credentialId).getOrThrow() promise.resolve(result) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { reject(e, promise) } @@ -422,7 +450,7 @@ object RNPingPushCommon { promise.reject(GenericError(type = ErrorType.STATE_ERROR, error = PushErrorCode.NOT_INITIALIZED, message = "Push client not found for id: $clientId")) return } - scope.launch { + scope.launchBridge(promise, PushErrorCode.NETWORK_FAILURE) { try { val token = client.getDeviceToken().getOrThrow() val result = Arguments.createMap() @@ -432,6 +460,8 @@ object RNPingPushCommon { result.putNull("token") } promise.resolve(result) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { reject(e, promise) } @@ -451,7 +481,7 @@ object RNPingPushCommon { promise.reject(GenericError(type = ErrorType.STATE_ERROR, error = PushErrorCode.NOT_INITIALIZED, message = "Push client not found for id: $clientId")) return } - scope.launch { + scope.launchBridge(promise, PushErrorCode.NETWORK_FAILURE) { try { val dataMap: Map = messageData.toHashMap() .mapValues { it.value?.toString() ?: "" } @@ -463,6 +493,8 @@ object RNPingPushCommon { result.putNull("notification") } promise.resolve(result) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { reject(e, promise) } @@ -482,7 +514,7 @@ object RNPingPushCommon { promise.reject(GenericError(type = ErrorType.STATE_ERROR, error = PushErrorCode.NOT_INITIALIZED, message = "Push client not found for id: $clientId")) return } - scope.launch { + scope.launchBridge(promise, PushErrorCode.NETWORK_FAILURE) { try { val notification = client.processNotification(message).getOrThrow() val result = Arguments.createMap() @@ -492,6 +524,8 @@ object RNPingPushCommon { result.putNull("notification") } promise.resolve(result) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { reject(e, promise) } @@ -511,10 +545,12 @@ object RNPingPushCommon { promise.reject(GenericError(type = ErrorType.STATE_ERROR, error = PushErrorCode.NOT_INITIALIZED, message = "Push client not found for id: $clientId")) return } - scope.launch { + scope.launchBridge(promise, PushErrorCode.NETWORK_FAILURE) { try { val result = client.approveNotification(notificationId).getOrThrow() promise.resolve(result) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { reject(e, promise) } @@ -535,10 +571,12 @@ object RNPingPushCommon { promise.reject(GenericError(type = ErrorType.STATE_ERROR, error = PushErrorCode.NOT_INITIALIZED, message = "Push client not found for id: $clientId")) return } - scope.launch { + scope.launchBridge(promise, PushErrorCode.NETWORK_FAILURE) { try { val result = client.approveChallengeNotification(notificationId, challengeResponse).getOrThrow() promise.resolve(result) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { reject(e, promise) } @@ -559,10 +597,12 @@ object RNPingPushCommon { promise.reject(GenericError(type = ErrorType.STATE_ERROR, error = PushErrorCode.NOT_INITIALIZED, message = "Push client not found for id: $clientId")) return } - scope.launch { + scope.launchBridge(promise, PushErrorCode.NETWORK_FAILURE) { try { val result = client.approveBiometricNotification(notificationId, authenticationMethod).getOrThrow() promise.resolve(result) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { reject(e, promise) } @@ -582,10 +622,12 @@ object RNPingPushCommon { promise.reject(GenericError(type = ErrorType.STATE_ERROR, error = PushErrorCode.NOT_INITIALIZED, message = "Push client not found for id: $clientId")) return } - scope.launch { + scope.launchBridge(promise, PushErrorCode.NETWORK_FAILURE) { try { val result = client.denyNotification(notificationId).getOrThrow() promise.resolve(result) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { reject(e, promise) } @@ -604,10 +646,12 @@ object RNPingPushCommon { promise.reject(GenericError(type = ErrorType.STATE_ERROR, error = PushErrorCode.NOT_INITIALIZED, message = "Push client not found for id: $clientId")) return } - scope.launch { + scope.launchBridge(promise, PushErrorCode.NETWORK_FAILURE) { try { val notifications = client.getPendingNotifications().getOrThrow() promise.resolve(buildNotificationsResult(notifications)) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { reject(e, promise) } @@ -626,10 +670,12 @@ object RNPingPushCommon { promise.reject(GenericError(type = ErrorType.STATE_ERROR, error = PushErrorCode.NOT_INITIALIZED, message = "Push client not found for id: $clientId")) return } - scope.launch { + scope.launchBridge(promise, PushErrorCode.NETWORK_FAILURE) { try { val notifications = client.getAllNotifications().getOrThrow() promise.resolve(buildNotificationsResult(notifications)) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { reject(e, promise) } @@ -649,7 +695,7 @@ object RNPingPushCommon { promise.reject(GenericError(type = ErrorType.STATE_ERROR, error = PushErrorCode.NOT_INITIALIZED, message = "Push client not found for id: $clientId")) return } - scope.launch { + scope.launchBridge(promise, PushErrorCode.NETWORK_FAILURE) { try { val notification = client.getNotification(notificationId).getOrThrow() val result = Arguments.createMap() @@ -659,6 +705,8 @@ object RNPingPushCommon { result.putNull("notification") } promise.resolve(result) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { reject(e, promise) } @@ -678,10 +726,12 @@ object RNPingPushCommon { promise.reject(GenericError(type = ErrorType.STATE_ERROR, error = PushErrorCode.NOT_INITIALIZED, message = "Push client not found for id: $clientId")) return } - scope.launch { + scope.launchBridge(promise, PushErrorCode.NETWORK_FAILURE) { try { val count = client.cleanupNotifications(credentialId).getOrThrow() promise.resolve(count) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { reject(e, promise) } @@ -704,7 +754,7 @@ object RNPingPushCommon { promise.reject(GenericError(type = ErrorType.STATE_ERROR, error = PushErrorCode.NOT_INITIALIZED, message = "Push client not found for id: $clientId")) return } - scope.launch { + scope.launchBridge(promise, PushErrorCode.NETWORK_FAILURE) { try { val token = FirebaseMessaging.getInstance().token.await() client.setDeviceToken(token, null).getOrThrow() @@ -712,6 +762,8 @@ object RNPingPushCommon { val result = Arguments.createMap() result.putString("token", token) promise.resolve(result) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { reject(e, promise) } @@ -732,10 +784,12 @@ object RNPingPushCommon { promise.resolve(null) return } - scope.launch { + scope.launchBridge(promise, PushErrorCode.NETWORK_FAILURE) { try { client.close() promise.resolve(null) + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { reject(e, promise) }