Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
15 changes: 6 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -355,7 +353,7 @@ type SigningResult = {

### `presentCaptiveSigningWithUrl(params: CaptiveSigningUrlParams): Promise<SigningResult>`

**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 = {
Expand All @@ -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<void>`

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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).
Expand Down
82 changes: 82 additions & 0 deletions android/src/main/java/expo/modules/docusign/DocuSignManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<SigningOutcome>) -> Unit
) {
if (!isInitialized) {
completion(Result.failure(NotInitializedException()))
return
}
Comment on lines +338 to +341

@IronTony IronTony Aug 22, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No validation on signingUrl, and the SDK does none on this path.

From the 2.1.4 bytecode: the 4-arg session overload checks for an empty envelope ID and an empty client user ID, and reports failures through the listener. The 5-arg URL overload has no checks at all and calls startActivity unconditionally. CaptiveSigningUrlRecord.signingUrl defaults to "" when JS omits it, so a blank or malformed URL launches an empty signing activity, and the promise settles only if that activity happens to call the listener back.

Add a guard next to this isInitialized check: reject a blank signingUrl, and ideally a non-https scheme.

(iOS has the same gap, but that is pre-existing and not yours to fix here. Tracking it separately.)


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)
Expand Down
33 changes: 27 additions & 6 deletions android/src/main/java/expo/modules/docusign/DocuSignModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
5 changes: 3 additions & 2 deletions jest.setup.js
Original file line number Diff line number Diff line change
@@ -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(),
},
}));
22 changes: 22 additions & 0 deletions src/api.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
14 changes: 1 addition & 13 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { Platform } from 'react-native';

import {
CaptiveSigningParams,
CaptiveSigningUrlParams,
Expand Down Expand Up @@ -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<SigningResult> {
if (Platform.OS !== 'ios') {
return Promise.reject(
new Error(
'presentCaptiveSigningWithUrl is iOS-only. Use presentCaptiveSigning + loginWithAccessToken for Android parity.',
),
);
}
return DocuSignModule.presentCaptiveSigningWithUrl(params);
}
Comment on lines 44 to 48

@IronTony IronTony Aug 22, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A small src/api.test.ts asserting presentCaptiveSigningWithUrl delegates to the native module would cover the branch this PR removes. jest.setup.js already stubs the native module.

The file's wider 0% coverage is pre-existing debt and not yours to fix here.


Expand Down