From 02178b5383a03aeab0d82cc1584f3d7b6a4e9aad Mon Sep 17 00:00:00 2001 From: nik Date: Fri, 21 Aug 2026 15:18:33 +0200 Subject: [PATCH 1/4] feat: add Android support for captive signing with URL --- CHANGELOG.md | 6 ++ README.md | 15 ++-- .../expo/modules/docusign/DocuSignManager.kt | 73 +++++++++++++++++++ .../expo/modules/docusign/DocuSignModule.kt | 33 +++++++-- src/api.ts | 14 +--- 5 files changed, 113 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 569562d..0060c89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Next + +### New features + +- **Android**: Add `presentCaptiveSigningWithUrl` support. The URL flow now has iOS/Android parity and does not require `loginWithAccessToken`. + ## 1.0.5 ### New features diff --git a/README.md b/README.md index 0b25d52..9a9777b 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,7 @@ Built on the Expo Modules API. Works with Expo SDK 55+ (managed or bare with `ex | Flow | iOS | Android | |-------------------------------------------------------------------------|:---:|:-------:| | Session flow: `initialize` → `loginWithAccessToken` → `presentCaptiveSigning` | ✅ | ✅ | -| URL flow: `presentCaptiveSigningWithUrl` | ✅ | ❌ | - -The DocuSign Android SDK (2.1.4) does not expose a public URL-based signing entry point; calling `presentCaptiveSigningWithUrl` on Android rejects with `not_implemented`. **Default to the session flow for cross-platform apps.** +| URL flow: `initialize` → `presentCaptiveSigningWithUrl` | ✅ | ✅ | ## One backend response, both platforms @@ -355,7 +353,7 @@ type SigningResult = { ### `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. +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. ```ts type CaptiveSigningUrlParams = { @@ -373,12 +371,12 @@ type CaptiveSigningUrlParams = { **Throws:** -- `not_implemented` on Android (see [Limitations](#limitations)) +- rejects if `initialize` has not been called - `signing_failed` if the URL is expired, malformed, or rejected by DocuSign **Returns:** same `SigningResult` shape as `presentCaptiveSigning`. -**Why this flow:** keeps the DocuSign access token out of the mobile app entirely; your backend mints the signing URL and hands that single-use credential to the client. Recommended for production iOS flows. +**Why this flow:** keeps the DocuSign access token out of the mobile app entirely; your backend mints the signing URL and hands that single-use credential to the client. Recommended for production iOS and Android flows. ### `logout(): Promise` @@ -497,12 +495,12 @@ function SigningScreen() { - Calls `initialize()` automatically on mount (toggle with `autoInitialize: false` if you want to defer) - Accepts both signing flows via a discriminated union on `startSigning(session)`: - `{ type: 'session', ... }`: runs `loginWithAccessToken` + `presentCaptiveSigning` (iOS + Android) - - `{ type: 'url', ... }`: runs `presentCaptiveSigningWithUrl` (iOS only; throws on Android) + - `{ type: 'url', ... }`: runs `presentCaptiveSigningWithUrl` (iOS + Android, no SDK login) - Tracks SDK state in a finite state machine - Subscribes to error events and surfaces them in the `error` field - Cleans up event listeners on unmount -### URL-flow example (iOS) +### URL-flow example (iOS + Android) ```tsx const result = await startSigning({ @@ -884,7 +882,6 @@ The module uses `appContext.activityProvider.currentActivity` to get the current ## Limitations -- **URL-based captive signing is iOS-only.** `presentCaptiveSigningWithUrl` is supported on iOS via DocuSign's iOS SDK 4.1.1. The DocuSign Android SDK 2.1.4 does not expose a public API that accepts a pre-minted recipient view URL, so the method throws `not_implemented` on Android. On Android, use `loginWithAccessToken` + `presentCaptiveSigning` (the session path). Your backend can branch response shape per platform. - **Offline signing not exposed.** DocuSign's Android SDK supports offline signing via `com.docusign:sdk-offline-signing`. This package does not currently expose offline APIs. If you need offline signing, open an issue or send a pull request. - **Template management not exposed.** The SDK's template caching and download APIs are not wrapped. Template creation should happen server-side via the DocuSign REST API. - **Custom UI not supported.** The signing UI is provided by the DocuSign SDK and cannot be customized from this package. DocuSign owns the look and feel of the signing ceremony (this is intentional for legal compliance consistency). diff --git a/android/src/main/java/expo/modules/docusign/DocuSignManager.kt b/android/src/main/java/expo/modules/docusign/DocuSignManager.kt index fab14b3..10e106e 100644 --- a/android/src/main/java/expo/modules/docusign/DocuSignManager.kt +++ b/android/src/main/java/expo/modules/docusign/DocuSignManager.kt @@ -323,6 +323,79 @@ internal object DocuSignManager { } } + /** + * Presents captive signing from a pre-minted DocuSign recipient-view URL. + * The URL is the signing credential, so SDK initialization is required but + * a prior `loginWithAccessToken` call is not. + */ + fun presentCaptiveSigningWithUrl( + activity: Activity, + signingUrl: String, + envelopeId: String, + recipientId: String?, + completion: (Result) -> Unit + ) { + if (!isInitialized) { + completion(Result.failure(NotInitializedException())) + return + } + + if (!pendingCompletion.compareAndSet(null, completion)) { + completion(Result.failure(SigningFailedException("A signing session is already in progress"))) + return + } + 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"))) + } + } + 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..f4f35b2 100644 --- a/android/src/main/java/expo/modules/docusign/DocuSignModule.kt +++ b/android/src/main/java/expo/modules/docusign/DocuSignModule.kt @@ -146,12 +146,33 @@ class DocuSignModule : Module() { } } - AsyncFunction("presentCaptiveSigningWithUrl") { _: CaptiveSigningUrlRecord, promise: Promise -> - promise.reject( - "not_implemented", - "presentCaptiveSigningWithUrl is iOS-only. On Android, use presentCaptiveSigning after login.", - UnsupportedOperationException("presentCaptiveSigningWithUrl is iOS-only") - ) + AsyncFunction("presentCaptiveSigningWithUrl") { params: CaptiveSigningUrlRecord, promise: Promise -> + val activity: Activity = appContext.activityProvider?.currentActivity + ?: throw Exceptions.MissingActivity() + + DocuSignManager.presentCaptiveSigningWithUrl( + activity = activity, + signingUrl = params.signingUrl, + envelopeId = params.envelopeId, + recipientId = params.recipientId.takeIf { it.isNotEmpty() } + ) { result -> + result.fold( + onSuccess = { outcome -> + promise.resolve( + mapOf( + "status" to outcome.status, + "envelopeId" to outcome.envelopeId, + "errorCode" to outcome.errorCode, + "errorMessage" to outcome.errorMessage + ) + ) + }, + onFailure = { error -> + emitSigningError(params.envelopeId, "signing_failed", error.message ?: "Unknown error") + promise.reject("signing_failed", error.message ?: "Unknown error", error as? Exception) + } + ) + } } AsyncFunction("logout") { promise: Promise -> diff --git a/src/api.ts b/src/api.ts index 37ecaea..24389f0 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,5 +1,3 @@ -import { Platform } from 'react-native'; - import { CaptiveSigningParams, CaptiveSigningUrlParams, @@ -41,21 +39,11 @@ export function presentCaptiveSigning( * encodes recipient identity via a short-lived token. {@link initialize} is * still required. * - * @platform iOS only. The Android DocuSign SDK (2.1.4) does not expose a - * public URL-based signing entry point; calling this on Android rejects with - * a clear JS error. For cross-platform parity, prefer {@link presentCaptiveSigning} - * with the session flow (accessToken + envelopeId + recipient). + * Supported on iOS and Android. */ export function presentCaptiveSigningWithUrl( params: CaptiveSigningUrlParams, ): Promise { - if (Platform.OS !== 'ios') { - return Promise.reject( - new Error( - 'presentCaptiveSigningWithUrl is iOS-only. Use presentCaptiveSigning + loginWithAccessToken for Android parity.', - ), - ); - } return DocuSignModule.presentCaptiveSigningWithUrl(params); } From acea31f31c8921ef3bf34e0ca9c0aae272e512b6 Mon Sep 17 00:00:00 2001 From: nik Date: Mon, 24 Aug 2026 08:17:13 +0200 Subject: [PATCH 2/4] fix: validate signing url and add tests --- .../expo/modules/docusign/DocuSignManager.kt | 10 +++++++++ jest.setup.js | 5 +++-- src/api.test.ts | 22 +++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 src/api.test.ts diff --git a/android/src/main/java/expo/modules/docusign/DocuSignManager.kt b/android/src/main/java/expo/modules/docusign/DocuSignManager.kt index 10e106e..164bad0 100644 --- a/android/src/main/java/expo/modules/docusign/DocuSignManager.kt +++ b/android/src/main/java/expo/modules/docusign/DocuSignManager.kt @@ -15,6 +15,7 @@ import java.net.HttpURLConnection import java.net.URL import java.util.concurrent.atomic.AtomicReference import kotlin.concurrent.thread +import androidx.core.net.toUri internal enum class DocuSignEnvironment(val value: String) { DEMO("demo"), @@ -340,6 +341,15 @@ internal object DocuSignManager { return } + val signingUri = signingUrl.toUri() + if ( + !signingUri.scheme.equals("https", ignoreCase = true) || + signingUri.host.isNullOrBlank() + ) { + completion(Result.failure(SigningFailedException("Signing URL must be a valid HTTPS URL"))) + return + } + if (!pendingCompletion.compareAndSet(null, completion)) { completion(Result.failure(SigningFailedException("A signing session is already in progress"))) return diff --git a/jest.setup.js b/jest.setup.js index 0118b50..396ec6b 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,6 +1,7 @@ // Stub the native module so tests never reach requireNativeModule. -// Individual test files override './api' directly via jest.mock. jest.mock('./src/DocuSignModule', () => ({ __esModule: true, - default: {}, + default: { + presentCaptiveSigningWithUrl: jest.fn(), + }, })); diff --git a/src/api.test.ts b/src/api.test.ts new file mode 100644 index 0000000..b7f119f --- /dev/null +++ b/src/api.test.ts @@ -0,0 +1,22 @@ +import { expect, it, jest } from '@jest/globals'; +import DocuSignModule from './DocuSignModule'; +import { presentCaptiveSigningWithUrl } from './api'; + +it('delegates presentCaptiveSigningWithUrl to the native module', () => { + const mockPresentCaptiveSigningWithUrl = jest.mocked( + DocuSignModule.presentCaptiveSigningWithUrl, + ); + const params = { + signingUrl: 'https://demo.docusign.net/signing/example', + envelopeId: 'envelope-id', + recipientId: 'recipient-id', + }; + const nativeResult = Promise.resolve({ + status: 'completed' as const, + envelopeId: params.envelopeId, + }); + mockPresentCaptiveSigningWithUrl.mockReturnValue(nativeResult); + + expect(presentCaptiveSigningWithUrl(params)).toBe(nativeResult); + expect(mockPresentCaptiveSigningWithUrl).toHaveBeenCalledWith(params); +}); From 943c2721a3d9aa7201b62f25f560911cdcfa39fd Mon Sep 17 00:00:00 2001 From: nik Date: Mon, 24 Aug 2026 08:24:38 +0200 Subject: [PATCH 3/4] fix: revert suggested android studio syntax --- android/src/main/java/expo/modules/docusign/DocuSignManager.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/android/src/main/java/expo/modules/docusign/DocuSignManager.kt b/android/src/main/java/expo/modules/docusign/DocuSignManager.kt index 164bad0..77e9945 100644 --- a/android/src/main/java/expo/modules/docusign/DocuSignManager.kt +++ b/android/src/main/java/expo/modules/docusign/DocuSignManager.kt @@ -15,7 +15,6 @@ import java.net.HttpURLConnection import java.net.URL import java.util.concurrent.atomic.AtomicReference import kotlin.concurrent.thread -import androidx.core.net.toUri internal enum class DocuSignEnvironment(val value: String) { DEMO("demo"), @@ -341,7 +340,7 @@ internal object DocuSignManager { return } - val signingUri = signingUrl.toUri() + val signingUri = android.net.Uri.parse(signingUrl) if ( !signingUri.scheme.equals("https", ignoreCase = true) || signingUri.host.isNullOrBlank() From 24b68a2f40e39345ab9205e65726902c4b535c26 Mon Sep 17 00:00:00 2001 From: nik Date: Wed, 26 Aug 2026 23:36:36 +0200 Subject: [PATCH 4/4] fix: eslint --- src/api.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api.test.ts b/src/api.test.ts index b7f119f..9d901da 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -1,4 +1,5 @@ import { expect, it, jest } from '@jest/globals'; + import DocuSignModule from './DocuSignModule'; import { presentCaptiveSigningWithUrl } from './api';