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 9a9777b..04accd3 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,38 @@ 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). +**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. +#### 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. @@ -371,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`. 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 77e9945..320e8e8 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,51 @@ 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 +) + +/** + * 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, @@ -48,6 +95,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 +146,8 @@ internal object DocuSignManager { return } + session = DocuSignSession(accessToken = accessToken, accountId = accountId, host = host) + try { DocuSign.getInstance().getAuthenticationDelegate().login( accessToken, @@ -183,6 +235,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 +313,7 @@ internal object DocuSignManager { recipientUserName: String, recipientEmail: String, recipientClientUserId: String, + launchStrategy: CaptiveSigningLaunchStrategy, completion: (Result) -> Unit ) { if (!isInitialized) { @@ -275,49 +332,88 @@ internal object DocuSignManager { } currentEnvelopeId = envelopeId + val listener = captiveSigningListener() + + when (launchStrategy) { + CaptiveSigningLaunchStrategy.FETCH -> + launchViaEnvelopeFetch(activity, envelopeId, recipientClientUserId, listener) + CaptiveSigningLaunchStrategy.SIGNING_URL -> + launchViaSigningUrl( + activity, + envelopeId, + recipientUserName, + recipientEmail, + recipientClientUserId, + listener, + completion + ) + } + } + + /** + * 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, + 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) { + currentEnvelopeId = null val pending = pendingCompletion.getAndSet(null) pending?.invoke(Result.failure(SigningFailedException(e.message ?: "Unknown error"))) } @@ -340,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 } @@ -355,48 +449,68 @@ internal object DocuSignManager { } currentEnvelopeId = envelopeId + launchWithSigningUrl(activity, signingUrl, envelopeId, recipientId, captiveSigningListener()) + } + + /** + * 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) + .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 + // 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, - signingUrl, + url, 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" - ) - } - } + listener ) } catch (e: Exception) { currentEnvelopeId = null @@ -405,6 +519,81 @@ internal object DocuSignManager { } } + /** + * 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 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 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..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 @@ -52,6 +53,9 @@ internal class CaptiveSigningRecord : Record { @Field var recipientClientUserId: String = "" + + @Field + var launchStrategy: String = "fetch" } internal class CaptiveSigningUrlRecord : Record { @@ -125,7 +129,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 -> @@ -139,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) } ) } @@ -168,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) } ) } @@ -195,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)) } 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.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, + }); + }); +}); 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 } + : {}), }); }