Skip to content

Commit 2b8b98d

Browse files
authored
feat(flow): native cancellation signals and typed bridge errors (#329)
1 parent 3dc0d50 commit 2b8b98d

9 files changed

Lines changed: 72 additions & 24 deletions

File tree

descopesdk/src/main/java/com/descope/android/DescopeFlowBridge.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import com.descope.internal.others.debug
1919
import com.descope.internal.others.error
2020
import com.descope.internal.others.info
2121
import com.descope.internal.others.isUnsafeEnabled
22+
import com.descope.internal.others.parseServerError
2223
import com.descope.internal.others.stringOrEmptyAsNull
2324
import com.descope.internal.others.with
2425
import com.descope.internal.routes.isWebAuthnSupported
@@ -103,8 +104,15 @@ internal class FlowBridge(val webView: WebView) {
103104
}
104105

105106
private fun bridgeOnError(error: String) {
107+
val parsed = parseServerError(error)
108+
val exception = when {
109+
parsed == null -> DescopeException.flowFailed.with(message = error)
110+
// Convert server flow cancellation to local flow cancellation for cohesive error handling
111+
parsed.code == "E102122" -> DescopeException.flowCancelled.with(message = parsed.message)
112+
else -> parsed
113+
}
106114
handler.post {
107-
listener?.onError(DescopeException.flowFailed.with(message = error))
115+
listener?.onError(exception)
108116
}
109117
}
110118

descopesdk/src/main/java/com/descope/android/DescopeFlowCoordinator.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ class DescopeFlowCoordinator(val webView: WebView) {
299299
} catch (e: DescopeException) {
300300
if (e == DescopeException.oauthNativeCancelled) {
301301
logger.info("OAuth native canceled")
302+
sendResponse(FlowBridgeResponse.Failure("OAuthNativeCancelled"))
302303
return
303304
}
304305
logger.error("OAuth native failed", e)
@@ -310,9 +311,14 @@ class DescopeFlowCoordinator(val webView: WebView) {
310311
pendingDeepLinkType = request.variant
311312
logger.info("Launching custom tab for ${request.variant}")
312313
try {
313-
launchCustomTab(webView.context, request.startUrl, flow?.presentation?.createCustomTabsIntent(webView.context))
314+
launchCustomTab(webView.context, request.startUrl, flow?.presentation?.createCustomTabsIntent(webView.context)) {
315+
// Custom tab dismissed by the user before completing the auth flow.
316+
pendingDeepLinkType = null
317+
sendResponse(FlowBridgeResponse.Failure("WebAuthCancelled"))
318+
}
314319
} catch (e: DescopeException) {
315320
logger.error("Failed to launch custom tab", e)
321+
pendingDeepLinkType = null
316322
sendResponse(FlowBridgeResponse.Failure("CustomTabFailure"))
317323
}
318324
}
@@ -325,6 +331,7 @@ class DescopeFlowCoordinator(val webView: WebView) {
325331
} catch (e: DescopeException) {
326332
if (e == DescopeException.passkeyCancelled) {
327333
logger.info("Passkeys canceled")
334+
sendResponse(FlowBridgeResponse.Failure("PasskeyCancelled"))
328335
return
329336
}
330337
val failure = when (e) {
@@ -353,6 +360,7 @@ class DescopeFlowCoordinator(val webView: WebView) {
353360
} catch (e: DescopeException) {
354361
if (e == DescopeException.passkeyCancelled) {
355362
logger.info("Passkeys canceled")
363+
sendResponse(FlowBridgeResponse.Failure("PasskeyCancelled"))
356364
return
357365
}
358366
val failure = when (e) {

descopesdk/src/main/java/com/descope/android/DescopeHelperActivity.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class DescopeHelperActivity : Activity() {
3030
// interfere with user input, etc.
3131
if (listenForClose) {
3232
listenForClose = false
33+
activityHelper.onCustomTabCanceled()
3334
finish()
3435
} else {
3536
listenForClose = true

descopesdk/src/main/java/com/descope/android/Utils.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ fun sendViewIntent(context: Context, uri: Uri) {
2727

2828
// Custom Tab
2929

30-
fun launchCustomTab(context: Context, url: String, customTabsIntent: CustomTabsIntent? = null) {
31-
launchCustomTab(context, url.toUri(), customTabsIntent)
30+
fun launchCustomTab(context: Context, url: String, customTabsIntent: CustomTabsIntent? = null, onCancel: (() -> Unit)? = null) {
31+
launchCustomTab(context, url.toUri(), customTabsIntent, onCancel)
3232
}
3333

34-
fun launchCustomTab(context: Context, uri: Uri, customTabsIntent: CustomTabsIntent? = null) {
35-
activityHelper.openCustomTab(context, customTabsIntent ?: defaultCustomTabIntent(), uri)
34+
fun launchCustomTab(context: Context, uri: Uri, customTabsIntent: CustomTabsIntent? = null, onCancel: (() -> Unit)? = null) {
35+
activityHelper.openCustomTab(context, customTabsIntent ?: defaultCustomTabIntent(), uri, onCancel)
3636
}
3737

3838
internal fun defaultCustomTabIntent(): CustomTabsIntent {

descopesdk/src/main/java/com/descope/internal/http/ClientErrors.kt

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,7 @@
11
package com.descope.internal.http
22

3-
import com.descope.internal.others.toMap
43
import com.descope.internal.others.with
54
import com.descope.types.DescopeException
6-
import org.json.JSONObject
7-
8-
internal fun parseServerError(response: String): DescopeException? {
9-
try {
10-
val map = JSONObject(response).toMap()
11-
val code = map["errorCode"] as? String ?: return null
12-
val desc = map["errorDescription"] as? String ?: "Descope server error"
13-
val message = map["errorMessage"] as? String
14-
return DescopeException(code = code, desc = desc, message = message)
15-
} catch (_: Exception) {
16-
return null
17-
}
18-
}
195

206
internal fun exceptionFromResponseCode(code: Int): DescopeException? {
217
val desc = failureFromResponseCode(code) ?: return null

descopesdk/src/main/java/com/descope/internal/http/DescopeClient.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.descope.internal.http
22

33
import com.descope.android.SystemInfo
4+
import com.descope.internal.others.parseServerError
45
import com.descope.sdk.DescopeConfig
56
import com.descope.sdk.DescopeSdk
67
import com.descope.types.DeliveryMethod

descopesdk/src/main/java/com/descope/internal/others/ActivityHelper.kt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,18 @@ import com.descope.types.DescopeException
1010

1111
internal interface ActivityHelper {
1212
val customTabsIntent: CustomTabsIntent?
13-
fun openCustomTab(context: Context, customTabsIntent: CustomTabsIntent, url: Uri)
13+
// onCancel is registered JIT (lifecycle-bound to this custom-tab launch) so
14+
// it doesn't outlive the tab and can't be hijacked by a different caller.
15+
fun openCustomTab(context: Context, customTabsIntent: CustomTabsIntent, url: Uri, onCancel: (() -> Unit)? = null)
1416
fun closeCustomTab(context: Context)
17+
fun onCustomTabCanceled()
1518
}
1619

1720
internal val activityHelper = object : ActivityHelper {
21+
private var cancelCallback: (() -> Unit)? = null
1822
override var customTabsIntent: CustomTabsIntent? = null
1923

20-
override fun openCustomTab(context: Context, customTabsIntent: CustomTabsIntent, url: Uri) {
24+
override fun openCustomTab(context: Context, customTabsIntent: CustomTabsIntent, url: Uri, onCancel: (() -> Unit)?) {
2125
this.customTabsIntent = customTabsIntent
2226
if (!isBrowserSupported(context, url)) {
2327
throw DescopeException.customTabFailed.with(message = "No browser application was found")
@@ -30,11 +34,13 @@ internal val activityHelper = object : ActivityHelper {
3034
} catch (e: Exception) {
3135
throw DescopeException.customTabFailed.with(message = "Failed to open custom tab from context", cause = e)
3236
}
37+
this.cancelCallback = onCancel
3338
}
3439

3540
override fun closeCustomTab(context: Context) {
3641
if (this.customTabsIntent == null) return
3742
this.customTabsIntent = null
43+
this.cancelCallback = null
3844
val intent = Intent(context, DescopeHelperActivity::class.java)
3945
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
4046
try {
@@ -44,6 +50,11 @@ internal val activityHelper = object : ActivityHelper {
4450
}
4551
}
4652

53+
override fun onCustomTabCanceled() {
54+
cancelCallback?.invoke()
55+
cancelCallback = null
56+
}
57+
4758
private fun isBrowserSupported(context: Context, uri: Uri): Boolean {
4859
val intent = Intent(Intent.ACTION_VIEW, uri)
4960
val component = intent.resolveActivity(context.packageManager)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
11
package com.descope.internal.others
22

33
import com.descope.types.DescopeException
4+
import org.json.JSONObject
45

56
internal fun DescopeException.with(desc: String? = null, message: String? = null, cause: Throwable? = null) = DescopeException(
67
code = code,
78
desc = desc ?: this.desc,
89
message = message ?: this.message,
910
cause = cause ?: this.cause,
1011
)
12+
13+
internal fun parseServerError(response: String): DescopeException? {
14+
try {
15+
val map = JSONObject(response).toMap()
16+
val code = map["errorCode"] as? String ?: return null
17+
val desc = map["errorDescription"] as? String ?: "Descope server error"
18+
val message = map["errorMessage"] as? String
19+
return DescopeException(code = code, desc = desc, message = message)
20+
} catch (_: Exception) {
21+
return null
22+
}
23+
}

descopesdk/src/test/java/com/descope/types/DescopeExceptionTest.kt

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
package com.descope.types
22

3-
import com.descope.internal.http.parseServerError
3+
import com.descope.internal.others.parseServerError
44
import org.json.JSONObject
55
import org.junit.Assert.assertEquals
6+
import org.junit.Assert.assertNull
67
import org.junit.Assert.fail
78
import org.junit.Test
89

@@ -22,5 +23,24 @@ class DescopeExceptionTest {
2223
else -> fail("wrong when clause")
2324
}
2425
}
25-
26+
27+
@Test
28+
fun error_message_preserved() {
29+
val payload = JSONObject().apply {
30+
put("errorCode", "E102122")
31+
put("errorDescription", "Flow aborted")
32+
put("errorMessage", "User canceled")
33+
}.toString()
34+
val exception = parseServerError(payload)
35+
assertEquals("E102122", exception?.code)
36+
assertEquals("Flow aborted", exception?.desc)
37+
assertEquals("User canceled", exception?.message)
38+
}
39+
40+
@Test
41+
fun invalid_error_payload_parsing() {
42+
assertNull(parseServerError("not a json error"))
43+
assertNull(parseServerError(JSONObject().apply { put("errorMessage", "no code here") }.toString()))
44+
}
45+
2646
}

0 commit comments

Comments
 (0)