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/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.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 } + : {}), }); }