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..77e9945 100644 --- a/android/src/main/java/expo/modules/docusign/DocuSignManager.kt +++ b/android/src/main/java/expo/modules/docusign/DocuSignManager.kt @@ -323,6 +323,88 @@ 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 + } + + val signingUri = android.net.Uri.parse(signingUrl) + 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 + } + 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/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..9d901da --- /dev/null +++ b/src/api.test.ts @@ -0,0 +1,23 @@ +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); +}); 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); }