From f4890363ed8e808b36cf38a019438623e32cde91 Mon Sep 17 00:00:00 2001 From: IronTony Date: Sat, 22 Aug 2026 18:02:36 +0200 Subject: [PATCH 1/2] 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 | 253 +++++++++++++++--- .../expo/modules/docusign/DocuSignModule.kt | 6 +- src/DocuSign.types.ts | 19 ++ src/useDocuSignSigning.ts | 5 + 4 files changed, 250 insertions(+), 33 deletions(-) diff --git a/android/src/main/java/expo/modules/docusign/DocuSignManager.kt b/android/src/main/java/expo/modules/docusign/DocuSignManager.kt index fab14b3..e9bd1b2 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,54 +309,209 @@ 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" - ) - } + listener + ) + } catch (e: Exception) { + val pending = pendingCompletion.getAndSet(null) + pending?.invoke(Result.failure(SigningFailedException(e.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" - ) - } + /** + * 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 800e68d..afe15d7 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 32ec191..cc39aa9 100644 --- a/src/useDocuSignSigning.ts +++ b/src/useDocuSignSigning.ts @@ -140,6 +140,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 16cd6f3d5eb401ce1a1d44775d61e40010ba2fb8 Mon Sep 17 00:00:00 2001 From: IronTony Date: Sat, 22 Aug 2026 18:02:48 +0200 Subject: [PATCH 2/2] 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 0b25d52..7b09912 100644 --- a/README.md +++ b/README.md @@ -333,6 +333,7 @@ type CaptiveSigningParams = { recipientUserName: string; recipientEmail: string; recipientClientUserId: string; + launchStrategy?: 'fetch' | 'signingUrl'; // Android only, default 'fetch' }; type SigningResult = { @@ -348,11 +349,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` **iOS only.** Presents the DocuSign signing UI using a pre-minted recipient view URL (obtained server-side from `POST /envelopes/{id}/views/recipient`). Bypasses SDK authentication; no `initialize` / `loginWithAccessToken` required first. diff --git a/src/useDocuSignSigning.test.ts b/src/useDocuSignSigning.test.ts index cc39b4b..79b85f1 100644 --- a/src/useDocuSignSigning.test.ts +++ b/src/useDocuSignSigning.test.ts @@ -303,3 +303,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, + }); + }); +});