From 96d0828963bdf18a30e9a639d33d6717f5d7fa5a Mon Sep 17 00:00:00 2001 From: IronTony Date: Sat, 22 Aug 2026 18:02:36 +0200 Subject: [PATCH 1/4] feat(android): add a signing-URL launch strategy to presentCaptiveSigning The SDK's fetch-based launchCaptiveSigning downloads the envelope with include=documents before opening the ceremony. That download runs on a read timeout derived from the envelope size which floors at 15s when nothing is cached, so a large envelope on a slow connection exhausts it and the ceremony never opens. The SDK also exposes a signingURL overload that skips the download. Minting a recipient view needs only the recipient details already passed to presentCaptiveSigning plus the session credentials, so opting into launchStrategy 'signingUrl' takes the timing-out call out of the code path. Opt-in, defaulting to 'fetch', because the mint spends the consumer's bearer token on an endpoint that token is not guaranteed to be scoped for. A mint failure falls back to the fetch path, so the strategy can only add a way to succeed. Verified on device across 12 consecutive ceremonies. Two hazards the async hop introduces, both guarded: Minting puts a network round trip between capturing the Activity and using it, which the fetch path never did. canLaunchOn refuses to launch against a finishing Activity, and checks the pending completion by identity rather than nullness. reset() and endSigningSession() clear that slot and a fresh call can claim it while a mint is still in flight; a nullness check would pass in that window and launch this envelope wired to the new session's promise. Session credentials move into a single DocuSignSession reference so a login racing an in-flight mint cannot tear the triple and build a request with one session's token and another's account id. --- .../expo/modules/docusign/DocuSignManager.kt | 257 +++++++++++++++--- .../expo/modules/docusign/DocuSignModule.kt | 6 +- src/DocuSign.types.ts | 19 ++ src/useDocuSignSigning.ts | 5 + 4 files changed, 252 insertions(+), 35 deletions(-) diff --git a/android/src/main/java/expo/modules/docusign/DocuSignManager.kt b/android/src/main/java/expo/modules/docusign/DocuSignManager.kt index 77e9945..7cd9a65 100644 --- a/android/src/main/java/expo/modules/docusign/DocuSignManager.kt +++ b/android/src/main/java/expo/modules/docusign/DocuSignManager.kt @@ -11,10 +11,12 @@ import com.docusign.androidsdk.listeners.DSAuthenticationListener import com.docusign.androidsdk.listeners.DSCaptiveSigningListener import com.docusign.androidsdk.listeners.DSLogoutListener import com.docusign.androidsdk.util.DSMode +import java.io.IOException import java.net.HttpURLConnection import java.net.URL import java.util.concurrent.atomic.AtomicReference import kotlin.concurrent.thread +import org.json.JSONObject internal enum class DocuSignEnvironment(val value: String) { DEMO("demo"), @@ -26,6 +28,28 @@ internal enum class DocuSignEnvironment(val value: String) { } } +internal enum class CaptiveSigningLaunchStrategy(val value: String) { + FETCH("fetch"), + SIGNING_URL("signingUrl"); + + companion object { + fun fromString(value: String): CaptiveSigningLaunchStrategy = + values().firstOrNull { it.value == value } + ?: FETCH.also { + if (value.isNotEmpty()) { + android.util.Log.w("DocuSign", "unknown launchStrategy '$value', falling back to fetch") + } + } + } +} + +/** Credentials the signing-URL strategy needs to mint a recipient view. */ +internal data class DocuSignSession( + val accessToken: String, + val accountId: String, + val host: String +) + internal data class SigningOutcome( val status: String, val envelopeId: String, @@ -48,6 +72,9 @@ internal object DocuSignManager { @Volatile private var integratorKey: String = "" @Volatile private var environment: DocuSignEnvironment = DocuSignEnvironment.DEMO @Volatile private var currentEnvelopeId: String? = null + // One reference, not three fields: a login racing an in-flight mint would otherwise tear the + // triple and build a request with one session's token and another's account id. + @Volatile private var session: DocuSignSession? = null private val pendingCompletion = AtomicReference<((Result) -> Unit)?>(null) private enum class UserInfoProbe { OK, UNAUTHORIZED, NETWORK } @@ -96,6 +123,8 @@ internal object DocuSignManager { return } + session = DocuSignSession(accessToken = accessToken, accountId = accountId, host = host) + try { DocuSign.getInstance().getAuthenticationDelegate().login( accessToken, @@ -183,6 +212,10 @@ internal object DocuSignManager { val ctx = appContext if (!isInitialized || ctx == null) return hasLoggedIn = false + // The signing-URL strategy holds these between login and present, which is longer than this + // object retained a token for before. Drop them on the way out so a signed-out process is not + // sitting on a bearer token; the next login repopulates them. + session = null try { DocuSign.getInstance().getAuthenticationDelegate().logout( ctx, @@ -257,6 +290,7 @@ internal object DocuSignManager { recipientUserName: String, recipientEmail: String, recipientClientUserId: String, + launchStrategy: CaptiveSigningLaunchStrategy, completion: (Result) -> Unit ) { if (!isInitialized) { @@ -275,47 +309,66 @@ internal object DocuSignManager { } currentEnvelopeId = envelopeId + val listener = object : DSCaptiveSigningListener { + override fun onStart(envelopeId: String) {} + + override fun onSuccess(envelopeId: String) { + handleSigningCompleted(envelopeId) + } + + override fun onCancel(envelopeId: String, recipientId: String) { + handleSigningCancelled(envelopeId, null) + } + + override fun onError(envelopeId: String?, exception: DSSigningException) { + handleSigningError(envelopeId, "signing_failed", exception.message ?: "Unknown error") + } + + override fun onRecipientSigningSuccess(envelopeId: String, recipientId: String) {} + + override fun onRecipientSigningError( + envelopeId: String, + recipientId: String, + exception: DSSigningException + ) { + handleSigningError( + envelopeId, + "recipient_signing_failed", + exception.message ?: "Unknown error" + ) + } + } + + when (launchStrategy) { + CaptiveSigningLaunchStrategy.FETCH -> + launchViaEnvelopeFetch(activity, envelopeId, recipientClientUserId, listener) + CaptiveSigningLaunchStrategy.SIGNING_URL -> + launchViaSigningUrl( + activity, + envelopeId, + recipientUserName, + recipientEmail, + recipientClientUserId, + listener, + completion + ) + } + } + + private fun launchViaEnvelopeFetch( + activity: Activity, + envelopeId: String, + recipientClientUserId: String, + listener: DSCaptiveSigningListener + ) { try { DocuSign.getInstance().getCustomSettingsDelegate() .disableNativeComponentsInOnlineSigning(activity, true) - DocuSign.getInstance().getSigningDelegate().launchCaptiveSigning( activity, envelopeId, recipientClientUserId, - object : DSCaptiveSigningListener { - override fun onStart(envelopeId: String) {} - - override fun onSuccess(envelopeId: String) { - handleSigningCompleted(envelopeId) - } - - override fun onCancel(envelopeId: String, recipientId: String) { - handleSigningCancelled(envelopeId, null) - } - - override fun onError(envelopeId: String?, exception: DSSigningException) { - handleSigningError( - envelopeId, - "signing_failed", - exception.message ?: "Unknown error" - ) - } - - override fun onRecipientSigningSuccess(envelopeId: String, recipientId: String) {} - - override fun onRecipientSigningError( - envelopeId: String, - recipientId: String, - exception: DSSigningException - ) { - handleSigningError( - envelopeId, - "recipient_signing_failed", - exception.message ?: "Unknown error" - ) - } - } + listener ) } catch (e: Exception) { val pending = pendingCompletion.getAndSet(null) @@ -405,6 +458,142 @@ internal object DocuSignManager { } } + /** + * Mints a recipient view and launches the SDK's signing-URL overload. + * + * The fetch-based overload downloads the envelope with `include=documents` on a size-derived + * read timeout that floors at 15s when nothing is cached, and that download is what times out + * on large envelopes. The signing-URL overload skips it and points the WebView straight at a + * recipient-view URL, which needs nothing beyond the recipient details passed here and the + * session credentials already held, so the timing-out call never runs. + */ + private fun launchViaSigningUrl( + activity: Activity, + envelopeId: String, + recipientUserName: String, + recipientEmail: String, + recipientClientUserId: String, + listener: DSCaptiveSigningListener, + completion: (Result) -> Unit + ) { + thread(start = true, isDaemon = true) { + val url = try { + mintRecipientViewUrl(envelopeId, recipientUserName, recipientEmail, recipientClientUserId) + } catch (e: Exception) { + // A mint failure must not be worse than not offering the strategy at all. Falling back to + // the fetch path restores the default behaviour exactly, so this can only add a way to + // succeed. + activity.runOnUiThread { + if (!canLaunchOn(activity, envelopeId, completion)) return@runOnUiThread + launchViaEnvelopeFetch(activity, envelopeId, recipientClientUserId, listener) + } + return@thread + } + activity.runOnUiThread { + if (!canLaunchOn(activity, envelopeId, completion)) return@runOnUiThread + launchWithSigningUrl(activity, url, envelopeId, recipientClientUserId, listener) + } + } + } + + private fun launchWithSigningUrl( + activity: Activity, + url: String, + envelopeId: String, + recipientId: String?, + listener: DSCaptiveSigningListener + ) { + try { + DocuSign.getInstance().getCustomSettingsDelegate() + .disableNativeComponentsInOnlineSigning(activity, true) + DocuSign.getInstance().getSigningDelegate().launchCaptiveSigning( + activity, + url, + envelopeId, + recipientId, + listener + ) + } catch (e: Exception) { + currentEnvelopeId = null + val pending = pendingCompletion.getAndSet(null) + pending?.invoke(Result.failure(SigningFailedException(e.message ?: "Unknown error"))) + } + } + + /** + * Minting puts a network round trip between capturing the Activity and using it, which the fetch + * path never did because it launched on the same stack frame. `runOnUiThread` posts to the main + * looper regardless of Activity state, so by the time this runs the screen may be gone or the + * session already resolved by `reset` or `endSigningSession`. Launching the SDK against either is + * how a dead-window crash or an orphaned signing screen happens. + * + * The check is on identity, not nullness. `reset` and `endSigningSession` clear the slot, and a + * fresh `presentCaptiveSigning` can claim it before a stale mint lands. A nullness check would + * pass in that window and launch this envelope wired to the new session's promise, resolving it + * with the wrong outcome. On a mismatch, do nothing at all: the slot is not this call's to + * resolve, and its own completion was already settled by whoever cleared it. + */ + private fun canLaunchOn( + activity: Activity, + envelopeId: String, + completion: (Result) -> Unit + ): Boolean { + if (pendingCompletion.get() !== completion) return false + if (activity.isFinishing || activity.isDestroyed) { + handleSigningCancelled(envelopeId, "activity_unavailable") + return false + } + return true + } + + private fun mintRecipientViewUrl( + envelopeId: String, + recipientUserName: String, + recipientEmail: String, + recipientClientUserId: String + ): String { + val active = session ?: throw IllegalStateException("no active DocuSign session") + val base = active.host.trimEnd('/') + val root = when { + Regex("/restapi/v[0-9.]+$").containsMatchIn(base) -> base + base.endsWith("/restapi") -> "$base/v2.1" + else -> "$base/restapi/v2.1" + } + val endpoint = "$root/accounts/${active.accountId}/envelopes/$envelopeId/views/recipient" + val body = JSONObject() + .put("clientUserId", recipientClientUserId) + .put("userName", recipientUserName) + .put("email", recipientEmail) + .put("authenticationMethod", "none") + .put("returnUrl", "https://docusign/") + .toString() + var connection: HttpURLConnection? = null + return try { + connection = (URL(endpoint).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + // Deliberately tighter than the envelope download this replaces. A recipient view is a + // small JSON POST, and the thread holds the Activity for the whole round trip, so a long + // ceiling would just delay the fallback and pin the view tree while it waited. + connectTimeout = 15_000 + readTimeout = 15_000 + doOutput = true + setRequestProperty("Authorization", "Bearer ${active.accessToken}") + setRequestProperty("Content-Type", "application/json") + setRequestProperty("Accept", "application/json") + } + connection.outputStream.use { it.write(body.toByteArray()) } + val code = connection.responseCode + val stream = if (code in 200..299) connection.inputStream else connection.errorStream + val text = stream?.bufferedReader()?.use { it.readText() } ?: "" + if (code !in 200..299) { + throw IOException("recipient view request failed with HTTP $code") + } + JSONObject(text).getString("url") + } finally { + connection?.disconnect() + } + } + fun handleSigningCompleted(envelopeId: String) { val outcome = SigningOutcome(status = "completed", envelopeId = envelopeId) module?.emitSigningComplete(envelopeId) diff --git a/android/src/main/java/expo/modules/docusign/DocuSignModule.kt b/android/src/main/java/expo/modules/docusign/DocuSignModule.kt index f4f35b2..ad623b9 100644 --- a/android/src/main/java/expo/modules/docusign/DocuSignModule.kt +++ b/android/src/main/java/expo/modules/docusign/DocuSignModule.kt @@ -52,6 +52,9 @@ internal class CaptiveSigningRecord : Record { @Field var recipientClientUserId: String = "" + + @Field + var launchStrategy: String = "fetch" } internal class CaptiveSigningUrlRecord : Record { @@ -125,7 +128,8 @@ class DocuSignModule : Module() { envelopeId = params.envelopeId, recipientUserName = params.recipientUserName, recipientEmail = params.recipientEmail, - recipientClientUserId = params.recipientClientUserId + recipientClientUserId = params.recipientClientUserId, + launchStrategy = CaptiveSigningLaunchStrategy.fromString(params.launchStrategy) ) { result -> result.fold( onSuccess = { outcome -> diff --git a/src/DocuSign.types.ts b/src/DocuSign.types.ts index 68abb8f..51e5557 100644 --- a/src/DocuSign.types.ts +++ b/src/DocuSign.types.ts @@ -34,11 +34,30 @@ export type DocuSignAccountInfo = { email: string; }; +/** + * How the Android SDK opens the signing ceremony. + * + * `fetch` downloads the envelope first. That download runs on a size-derived + * read timeout which floors at 15s when nothing is cached, so it can time out + * on large envelopes. + * + * `signingUrl` mints a recipient view with the session access token and points + * the SDK straight at it, skipping the download. Requires the token to be + * scoped to create recipient views on the envelope; if the mint fails it falls + * back to `fetch`. + */ +export type CaptiveSigningLaunchStrategy = 'fetch' | 'signingUrl'; + export type CaptiveSigningParams = { envelopeId: string; recipientUserName: string; recipientEmail: string; recipientClientUserId: string; + /** + * Android only, ignored on iOS. Defaults to `fetch`, which is the behaviour + * of every release before this option existed. + */ + launchStrategy?: CaptiveSigningLaunchStrategy; }; export type CaptiveSigningUrlParams = { diff --git a/src/useDocuSignSigning.ts b/src/useDocuSignSigning.ts index dd8da86..15390b9 100644 --- a/src/useDocuSignSigning.ts +++ b/src/useDocuSignSigning.ts @@ -149,6 +149,11 @@ export function useDocuSignSigning( recipientUserName: session.recipientUserName, recipientEmail: session.recipientEmail, recipientClientUserId: session.recipientClientUserId, + // Spread rather than always sending the key, so a caller who never + // opts in produces the exact payload previous releases sent. + ...(session.launchStrategy + ? { launchStrategy: session.launchStrategy } + : {}), }); } From 60894bd3c8f61b4f29909e9c45625b76c37dbc67 Mon Sep 17 00:00:00 2001 From: IronTony Date: Sat, 22 Aug 2026 18:02:48 +0200 Subject: [PATCH 2/4] test(android): cover launchStrategy passthrough, document the strategies Four cases on the hook: signingUrl and an explicit fetch both reach the native call unchanged, the url flow never carries the field, and omitting it produces a payload with no launchStrategy key at all rather than one set to undefined. That last one is the backward-compatibility guarantee under test. The hook spreads the field in conditionally so an existing consumer who never opts in sends the exact payload previous releases sent, and the test asserts the key is absent rather than merely undefined. README gains an Android launch strategies section covering the timeout this addresses, the token scope the mint needs, and a pointer to presentCaptiveSigningWithUrl as the better shape when the backend can mint. --- README.md | 24 ++++++++++++ src/useDocuSignSigning.test.ts | 72 ++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/README.md b/README.md index 9a9777b..b16be5a 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,7 @@ type CaptiveSigningParams = { recipientUserName: string; recipientEmail: string; recipientClientUserId: string; + launchStrategy?: 'fetch' | 'signingUrl'; // Android only, default 'fetch' }; type SigningResult = { @@ -346,11 +347,34 @@ type SigningResult = { - `envelopeId`: the DocuSign envelope ID created by your backend - `recipientUserName`, `recipientEmail`: must match the recipient registered on the envelope - `recipientClientUserId`: the `clientUserId` of the embedded recipient, used by DocuSign to identify captive signers +- `launchStrategy`: how the Android SDK opens the ceremony, see [Android launch strategies](#android-launch-strategies). Ignored on iOS. **Throws:** rejects with `signing_failed` if the SDK fails to present the signing UI (e.g. not initialized, not logged in, invalid envelope). **Returns:** resolves with a `SigningResult` once the user completes or cancels. `status === 'completed'` means the user finished the signing ceremony. `status === 'cancelled'` means the user explicitly cancelled or closed the signing UI. +#### Android launch strategies + +The Android SDK can open a captive signing ceremony two ways, and `launchStrategy` picks between them. It has no effect on iOS. + +`'fetch'` is the default and matches every release before this option existed. The SDK downloads the envelope with `include=documents` and then opens the ceremony. That download runs on a read timeout derived from the envelope size, which floors at 15 seconds when nothing is cached, so a large envelope on a slow connection can exhaust it and the ceremony never opens. + +`'signingUrl'` skips the download. The module mints a recipient view with the session access token (`POST /accounts/{accountId}/envelopes/{envelopeId}/views/recipient`) and points the SDK straight at the returned URL, so the call that times out never runs. + +```ts +await presentCaptiveSigning({ + envelopeId, + recipientUserName, + recipientEmail, + recipientClientUserId, + launchStrategy: 'signingUrl', +}); +``` + +Before opting in, check that the access token you pass to `loginWithAccessToken` is scoped to create recipient views on the envelope. If the mint fails the module falls back to `'fetch'`, so the worst case is a wasted round trip per ceremony rather than a failure, but there is no point paying for it if the token cannot mint. + +If your backend already mints recipient view URLs, prefer `presentCaptiveSigningWithUrl` instead. It keeps the DocuSign access token off the device entirely, which is the better shape. `'signingUrl'` exists for teams who cannot change their backend. + ### `presentCaptiveSigningWithUrl(params: CaptiveSigningUrlParams): Promise` Presents the DocuSign signing UI using a pre-minted recipient view URL (obtained server-side from `POST /envelopes/{id}/views/recipient`). Bypasses SDK authentication; `initialize` is required, but `loginWithAccessToken` is not. diff --git a/src/useDocuSignSigning.test.ts b/src/useDocuSignSigning.test.ts index 5f4da54..d4dde05 100644 --- a/src/useDocuSignSigning.test.ts +++ b/src/useDocuSignSigning.test.ts @@ -295,3 +295,75 @@ describe('useDocuSignSigning', () => { expect(result.current.state).toBe(SIGNING_STATE.COMPLETED); }); }); + +describe('useDocuSignSigning launchStrategy', () => { + const sessionWithout = { + type: 'session', + accessToken: 'token', + envelopeId: 'env-1', + recipientUserName: 'r', + recipientEmail: 'r@example.com', + recipientClientUserId: 'client-1', + } as const; + + const startWith = async ( + session: Parameters< + ReturnType['startSigning'] + >[0], + ) => { + const { result } = renderHook(() => useDocuSignSigning({ config })); + + await waitFor(() => { + expect(result.current.state).toBe(SIGNING_STATE.READY); + }); + + await act(async () => { + await result.current.startSigning(session); + }); + }; + + it('omits launchStrategy from the native call when the caller does not set it', async () => { + await startWith(sessionWithout); + + const params = mockedApi.presentCaptiveSigning.mock.calls[0][0]; + + expect(params).toEqual({ + envelopeId: 'env-1', + recipientUserName: 'r', + recipientEmail: 'r@example.com', + recipientClientUserId: 'client-1', + }); + expect('launchStrategy' in params).toBe(false); + }); + + it('forwards launchStrategy signingUrl to the native call', async () => { + await startWith({ ...sessionWithout, launchStrategy: 'signingUrl' }); + + expect(mockedApi.presentCaptiveSigning).toHaveBeenCalledWith( + expect.objectContaining({ launchStrategy: 'signingUrl' }), + ); + }); + + it('forwards an explicit launchStrategy fetch unchanged', async () => { + await startWith({ ...sessionWithout, launchStrategy: 'fetch' }); + + expect(mockedApi.presentCaptiveSigning).toHaveBeenCalledWith( + expect.objectContaining({ launchStrategy: 'fetch' }), + ); + }); + + it('does not forward launchStrategy on the url flow', async () => { + await startWith({ + type: 'url', + signingUrl: 'https://example.com/sign', + envelopeId: 'env-2', + }); + + expect(mockedApi.presentCaptiveSigning).not.toHaveBeenCalled(); + expect(mockedApi.presentCaptiveSigningWithUrl).toHaveBeenCalledWith({ + signingUrl: 'https://example.com/sign', + envelopeId: 'env-2', + recipientId: undefined, + }); + }); +}); From c6f5843e26b0ed644100a08a93e6ae7159000491 Mon Sep 17 00:00:00 2001 From: IronTony Date: Thu, 27 Aug 2026 18:46:32 +0200 Subject: [PATCH 3/4] refactor(android): unify the captive signing launch path Everything here touches code that predates #3. It sat on that PR's review as requested changes, which was wrong of me: asking a contributor to refactor the maintainer's own code as a condition of landing their feature is scope creep. Pulled off #3 and landed here instead. Both entrypoints carried their own copy of a 33-line DSCaptiveSigningListener, so any callback fix had to be made twice and the copies could drift. captiveSigningListener() is now the single source, and presentCaptiveSigningWithUrl folds onto the same launchWithSigningUrl helper the signing-URL strategy uses. #3's https/host guard becomes isHttpsUrl and now covers every URL this object hands to the SDK or sends credentials to. It stays ahead of the compareAndSet in presentCaptiveSigningWithUrl: a rejected URL must not claim the pending slot, or the next valid call is refused as already in progress. That guard also now covers the recipient-view request itself. host arrives from JS unvalidated and that request carries the session bearer token, so an http:// host would have sent it in cleartext. The signing-URL strategy is the first code path to treat host as an endpoint rather than passing it to the SDK, so the exposure comes in with this feature. The check runs before the connection is opened and before the Authorization header is set, and throwing routes to the fetch fallback, so a misconfigured host degrades instead of failing outright. launchViaEnvelopeFetch's catch now clears currentEnvelopeId. Only the URL path did, and #3 had the correct shape. restApiRoot is lifted out of the request builder. Its ordering is load-bearing, since an already-versioned host would otherwise fall through and get a second /restapi/v2.1 appended, and that reads better as a named function than buried in a connection setup. Both branches are now case-insensitive; an uppercase RESTAPI segment previously fell through and built a doubled path. Not unit tested: android/build.gradle declares implementation project(':expo-modules-core'), which only resolves inside a host app, so there is no standalone gradle build and CI runs jest only. Error codes are the behaviour change. CodedException infers a code from the class name when none is given, so forwarding it verbatim would have surfaced NotInitializedException as ERR_NOT_INITIALIZED rather than the not_initialized the README error table has always documented. The exceptions now carry explicit codes and the module forwards them, so callers can tell a missing initialize() from a missing login from a real signing failure instead of receiving signing_failed for all three. The module also stops emitting onSigningError itself. handleSigningError already emits, so every failure delivered two events, and the module's copy flattened recipient_signing_failed into signing_failed. The emit in loginWithAccessToken stays: that path never reaches handleSigningError, so it is the only event. --- .../expo/modules/docusign/DocuSignError.kt | 39 +++- .../expo/modules/docusign/DocuSignManager.kt | 176 +++++++++--------- .../expo/modules/docusign/DocuSignModule.kt | 19 +- 3 files changed, 132 insertions(+), 102 deletions(-) diff --git a/android/src/main/java/expo/modules/docusign/DocuSignError.kt b/android/src/main/java/expo/modules/docusign/DocuSignError.kt index 71de0de..ecf059e 100644 --- a/android/src/main/java/expo/modules/docusign/DocuSignError.kt +++ b/android/src/main/java/expo/modules/docusign/DocuSignError.kt @@ -2,17 +2,36 @@ package expo.modules.docusign import expo.modules.kotlin.exception.CodedException -class NotInitializedException : - CodedException("DocuSign SDK has not been initialized. Call initialize() first.") +// Codes are given explicitly rather than inferred. CodedException derives a code from the class +// name when none is provided, which would surface NotInitializedException to JS as +// ERR_NOT_INITIALIZED, not the not_initialized documented in the README error table. -class NotLoggedInException : - CodedException("DocuSign SDK is not logged in. Call loginWithAccessToken() first.") +class NotInitializedException : CodedException( + "not_initialized", + "DocuSign SDK has not been initialized. Call initialize() first.", + null +) -class LoginFailedException(message: String) : - CodedException("DocuSign login failed: $message") +class NotLoggedInException : CodedException( + "not_logged_in", + "DocuSign SDK is not logged in. Call loginWithAccessToken() first.", + null +) -class SigningFailedException(message: String) : - CodedException("DocuSign signing failed: $message") +class LoginFailedException(message: String) : CodedException( + "login_failed", + "DocuSign login failed: $message", + null +) -class PresentationException(message: String) : - CodedException("Failed to present DocuSign signing UI: $message") +class SigningFailedException(message: String) : CodedException( + "signing_failed", + "DocuSign signing failed: $message", + null +) + +class PresentationException(message: String) : CodedException( + "presentation_failed", + "Failed to present DocuSign signing UI: $message", + null +) diff --git a/android/src/main/java/expo/modules/docusign/DocuSignManager.kt b/android/src/main/java/expo/modules/docusign/DocuSignManager.kt index 7cd9a65..320e8e8 100644 --- a/android/src/main/java/expo/modules/docusign/DocuSignManager.kt +++ b/android/src/main/java/expo/modules/docusign/DocuSignManager.kt @@ -50,6 +50,29 @@ internal data class DocuSignSession( val host: String ) +/** + * Normalises a DocuSign `host` into a REST API root. + * + * Consumers pass whatever their backend hands them, and the three shapes seen in the wild all have + * to reach the same place: + * + * https://demo.docusign.net -> https://demo.docusign.net/restapi/v2.1 + * https://demo.docusign.net/restapi -> https://demo.docusign.net/restapi/v2.1 + * https://demo.docusign.net/restapi/v2.1 -> unchanged, an explicit version wins + * + * Pulled out of the request builder so the branching is readable and can be exercised on its own. + * Ordering is load-bearing: the versioned check has to come first, or an already-versioned host + * would fall through and get a second `/restapi/v2.1` appended. + */ +internal fun restApiRoot(host: String): String { + val base = host.trimEnd('/') + return when { + Regex("/restapi/v[0-9.]+$", RegexOption.IGNORE_CASE).containsMatchIn(base) -> base + base.endsWith("/restapi", ignoreCase = true) -> "$base/v2.1" + else -> "$base/restapi/v2.1" + } +} + internal data class SigningOutcome( val status: String, val envelopeId: String, @@ -309,35 +332,7 @@ internal object DocuSignManager { } currentEnvelopeId = envelopeId - val listener = object : DSCaptiveSigningListener { - override fun onStart(envelopeId: String) {} - - override fun onSuccess(envelopeId: String) { - handleSigningCompleted(envelopeId) - } - - override fun onCancel(envelopeId: String, recipientId: String) { - handleSigningCancelled(envelopeId, null) - } - - override fun onError(envelopeId: String?, exception: DSSigningException) { - handleSigningError(envelopeId, "signing_failed", exception.message ?: "Unknown error") - } - - override fun onRecipientSigningSuccess(envelopeId: String, recipientId: String) {} - - override fun onRecipientSigningError( - envelopeId: String, - recipientId: String, - exception: DSSigningException - ) { - handleSigningError( - envelopeId, - "recipient_signing_failed", - exception.message ?: "Unknown error" - ) - } - } + val listener = captiveSigningListener() when (launchStrategy) { CaptiveSigningLaunchStrategy.FETCH -> @@ -355,6 +350,53 @@ internal object DocuSignManager { } } + /** + * One listener for every launch path. Both entrypoints previously built their own copy, so a fix + * to any callback had to be made twice and the copies could drift. + */ + private fun captiveSigningListener() = object : DSCaptiveSigningListener { + override fun onStart(envelopeId: String) {} + + override fun onSuccess(envelopeId: String) { + handleSigningCompleted(envelopeId) + } + + override fun onCancel(envelopeId: String, recipientId: String) { + handleSigningCancelled(envelopeId, null) + } + + override fun onError(envelopeId: String?, exception: DSSigningException) { + handleSigningError(envelopeId, "signing_failed", exception.message ?: "Unknown error") + } + + override fun onRecipientSigningSuccess(envelopeId: String, recipientId: String) {} + + override fun onRecipientSigningError( + envelopeId: String, + recipientId: String, + exception: DSSigningException + ) { + handleSigningError( + envelopeId, + "recipient_signing_failed", + exception.message ?: "Unknown error" + ) + } + } + + /** + * Guards every URL this object hands to the SDK or sends credentials to. + * + * The SDK's URL overload validates nothing and calls `startActivity` unconditionally, so a blank + * or non-https signing URL would open an empty signing activity that never calls the listener + * back, leaving the promise unsettled. The recipient-view request needs the same check for a + * different reason: it carries the session bearer token, and `host` arrives unvalidated from JS. + */ + private fun isHttpsUrl(url: String): Boolean { + val uri = android.net.Uri.parse(url) + return uri.scheme.equals("https", ignoreCase = true) && !uri.host.isNullOrBlank() + } + private fun launchViaEnvelopeFetch( activity: Activity, envelopeId: String, @@ -371,6 +413,7 @@ internal object DocuSignManager { listener ) } catch (e: Exception) { + currentEnvelopeId = null val pending = pendingCompletion.getAndSet(null) pending?.invoke(Result.failure(SigningFailedException(e.message ?: "Unknown error"))) } @@ -393,11 +436,9 @@ internal object DocuSignManager { return } - val signingUri = android.net.Uri.parse(signingUrl) - if ( - !signingUri.scheme.equals("https", ignoreCase = true) || - signingUri.host.isNullOrBlank() - ) { + // Ahead of the compareAndSet on purpose: a rejected URL must not claim the pending slot, or a + // later valid call would be refused as "already in progress". + if (!isHttpsUrl(signingUrl)) { completion(Result.failure(SigningFailedException("Signing URL must be a valid HTTPS URL"))) return } @@ -408,54 +449,7 @@ internal object DocuSignManager { } currentEnvelopeId = envelopeId - try { - DocuSign.getInstance().getCustomSettingsDelegate() - .disableNativeComponentsInOnlineSigning(activity, true) - - DocuSign.getInstance().getSigningDelegate().launchCaptiveSigning( - activity, - signingUrl, - envelopeId, - recipientId, - object : DSCaptiveSigningListener { - override fun onStart(envelopeId: String) {} - - override fun onSuccess(envelopeId: String) { - handleSigningCompleted(envelopeId) - } - - override fun onCancel(envelopeId: String, recipientId: String) { - handleSigningCancelled(envelopeId, null) - } - - override fun onError(envelopeId: String?, exception: DSSigningException) { - handleSigningError( - envelopeId, - "signing_failed", - exception.message ?: "Unknown error" - ) - } - - override fun onRecipientSigningSuccess(envelopeId: String, recipientId: String) {} - - override fun onRecipientSigningError( - envelopeId: String, - recipientId: String, - exception: DSSigningException - ) { - handleSigningError( - envelopeId, - "recipient_signing_failed", - exception.message ?: "Unknown error" - ) - } - } - ) - } catch (e: Exception) { - currentEnvelopeId = null - val pending = pendingCompletion.getAndSet(null) - pending?.invoke(Result.failure(SigningFailedException(e.message ?: "Unknown error"))) - } + launchWithSigningUrl(activity, signingUrl, envelopeId, recipientId, captiveSigningListener()) } /** @@ -479,6 +473,11 @@ internal object DocuSignManager { thread(start = true, isDaemon = true) { val url = try { mintRecipientViewUrl(envelopeId, recipientUserName, recipientEmail, recipientClientUserId) + .also { + // Same guard the public URL entrypoint applies. A malformed mint response would + // otherwise open an empty signing activity that never calls the listener back. + if (!isHttpsUrl(it)) throw IOException("recipient view returned an invalid URL") + } } catch (e: Exception) { // A mint failure must not be worse than not offering the strategy at all. Falling back to // the fetch path restores the default behaviour exactly, so this can only add a way to @@ -553,13 +552,14 @@ internal object DocuSignManager { recipientClientUserId: String ): String { val active = session ?: throw IllegalStateException("no active DocuSign session") - val base = active.host.trimEnd('/') - val root = when { - Regex("/restapi/v[0-9.]+$").containsMatchIn(base) -> base - base.endsWith("/restapi") -> "$base/v2.1" - else -> "$base/restapi/v2.1" + val endpoint = + "${restApiRoot(active.host)}/accounts/${active.accountId}/envelopes/$envelopeId/views/recipient" + // `host` arrives from JS unvalidated, and this request carries the session bearer token. Refuse + // to send it anywhere that is not https rather than leaking it in cleartext. Throwing here + // routes to the fetch fallback, so a misconfigured host degrades instead of failing outright. + if (!isHttpsUrl(endpoint)) { + throw IOException("DocuSign host must be an https URL to mint a recipient view") } - val endpoint = "$root/accounts/${active.accountId}/envelopes/$envelopeId/views/recipient" val body = JSONObject() .put("clientUserId", recipientClientUserId) .put("userName", recipientUserName) diff --git a/android/src/main/java/expo/modules/docusign/DocuSignModule.kt b/android/src/main/java/expo/modules/docusign/DocuSignModule.kt index ad623b9..7b1b88c 100644 --- a/android/src/main/java/expo/modules/docusign/DocuSignModule.kt +++ b/android/src/main/java/expo/modules/docusign/DocuSignModule.kt @@ -3,6 +3,7 @@ package expo.modules.docusign import android.app.Activity import android.content.Context import expo.modules.kotlin.Promise +import expo.modules.kotlin.exception.CodedException import expo.modules.kotlin.exception.Exceptions import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition @@ -143,8 +144,11 @@ class DocuSignModule : Module() { ) }, onFailure = { error -> - emitSigningError(params.envelopeId, "signing_failed", error.message ?: "Unknown error") - promise.reject("signing_failed", error.message ?: "Unknown error", error as? Exception) + // No emitSigningError here. handleSigningError already emits, so emitting again + // delivered two events per failure and flattened recipient_signing_failed into + // signing_failed. Failures that never reach the manager (not initialized, not logged + // in) are programming errors and reject without an event, matching iOS. + promise.reject(codeOf(error), error.message ?: "Unknown error", error as? Exception) } ) } @@ -172,8 +176,7 @@ class DocuSignModule : Module() { ) }, onFailure = { error -> - emitSigningError(params.envelopeId, "signing_failed", error.message ?: "Unknown error") - promise.reject("signing_failed", error.message ?: "Unknown error", error as? Exception) + promise.reject(codeOf(error), error.message ?: "Unknown error", error as? Exception) } ) } @@ -199,6 +202,14 @@ class DocuSignModule : Module() { } } + /** + * The rejection code for a manager failure. Every exception this module raises is a + * CodedException carrying an explicit code, so callers can tell not_initialized from + * not_logged_in from signing_failed instead of receiving signing_failed for all three. + */ + private fun codeOf(error: Throwable): String = + (error as? CodedException)?.code ?: "signing_failed" + internal fun emitSigningComplete(envelopeId: String) { sendEvent("onSigningComplete", mapOf("envelopeId" to envelopeId)) } From 7e6e9e496e561d30c47d8dc82f9c49905bb30d02 Mon Sep 17 00:00:00 2001 From: IronTony Date: Thu, 27 Aug 2026 18:46:42 +0200 Subject: [PATCH 4/4] docs: document the Android error codes and launch strategy The Throws entries for both present methods claimed signing_failed covered a missing initialize() and a missing login. That was true when the module hard-coded that code; it is not now that the real code is forwarded, so both entries name the specific codes instead. CHANGELOG gains the launch strategy, the URL validation and the currentEnvelopeId fix, plus a Breaking changes section. The error codes and the single onSigningError event are both observable behaviour changes: anyone matching on signing_failed to detect an uninitialised SDK, or counting error events, will see different results after upgrading. That belongs above the fold rather than inferred from a feature bullet. --- CHANGELOG.md | 11 +++++++++++ README.md | 10 +++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0060c89..617bb0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,20 @@ ## Next +### Breaking changes + +- **Android**: rejection codes now reflect the failure. `presentCaptiveSigning` and `presentCaptiveSigningWithUrl` previously rejected every error as `signing_failed`; they now surface `not_initialized`, `not_logged_in`, `login_failed` or `signing_failed`, matching the codes the error table has always documented. Callers matching on `error.code === 'signing_failed'` to detect a missing `initialize()` or `loginWithAccessToken()` need to match the specific code instead. +- **Android**: one `onSigningError` event per failure instead of two. The module emitted an event alongside the manager's own, which also flattened `recipient_signing_failed` into `signing_failed`. Listeners that deduplicated by hand can drop that workaround; listeners that counted events will see the count halve. + ### New features - **Android**: Add `presentCaptiveSigningWithUrl` support. The URL flow now has iOS/Android parity and does not require `loginWithAccessToken`. +- **Android**: Add an opt-in `launchStrategy` on `presentCaptiveSigning`. `signingUrl` mints a recipient view and launches the SDK's URL overload, skipping the envelope download that runs on a size-derived read timeout floored at 15s and can leave the ceremony unopened on large envelopes. Falls back to `fetch` if the mint fails. Defaults to `fetch`, so upgrading changes nothing unless you opt in. + +### Fixes + +- **Android**: reject a blank or non-`https` `signingUrl` before launching. The SDK's URL overload validates nothing and calls `startActivity` unconditionally, so a malformed URL opened an empty signing activity and left the promise unsettled. +- **Android**: `presentCaptiveSigning` now clears `currentEnvelopeId` when the launch itself throws, matching the URL path. ## 1.0.5 diff --git a/README.md b/README.md index b16be5a..04accd3 100644 --- a/README.md +++ b/README.md @@ -349,7 +349,11 @@ type SigningResult = { - `recipientClientUserId`: the `clientUserId` of the embedded recipient, used by DocuSign to identify captive signers - `launchStrategy`: how the Android SDK opens the ceremony, see [Android launch strategies](#android-launch-strategies). Ignored on iOS. -**Throws:** rejects with `signing_failed` if the SDK fails to present the signing UI (e.g. not initialized, not logged in, invalid envelope). +**Throws:** + +- `not_initialized` if `initialize` has not been called +- `not_logged_in` if `loginWithAccessToken` has not been called +- `signing_failed` if the SDK fails to present the signing UI (e.g. invalid envelope, or a signing session already in progress) **Returns:** resolves with a `SigningResult` once the user completes or cancels. `status === 'completed'` means the user finished the signing ceremony. `status === 'cancelled'` means the user explicitly cancelled or closed the signing UI. @@ -395,8 +399,8 @@ type CaptiveSigningUrlParams = { **Throws:** -- rejects if `initialize` has not been called -- `signing_failed` if the URL is expired, malformed, or rejected by DocuSign +- `not_initialized` if `initialize` has not been called +- `signing_failed` if the URL is blank or not `https`, or if it is expired or rejected by DocuSign **Returns:** same `SigningResult` shape as `presentCaptiveSigning`.