Skip to content

feat: add Android support for captive signing with URL - #3

Merged
IronTony merged 4 commits into
IronTony:mainfrom
nikonhub:main
Aug 27, 2026
Merged

IronTony merged 4 commits into
IronTony:mainfrom
nikonhub:main

Conversation

@nikonhub

Copy link
Copy Markdown
Contributor

Add Android support for presentCaptiveSigningWithUrl using the URL based launchCaptiveSigning, which is supported by the SDK (doc). README updated 👍 .

@IronTony IronTony added documentation Improvements or additions to documentation enhancement New feature or request labels Aug 21, 2026

@IronTony IronTony left a comment

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.

I verified the premise against the shipped androidsdk:2.1.4 AAR rather than the docs. DSSigningDelegate.launchCaptiveSigning(Context, String signingURL, String, String, DSCaptiveSigningListener) is there. Its body calls CaptiveSigningActivity.getIntent(context, envelopeId, null, recipientId, signingURL) and sets setAuthNeeded(false) before startActivity. So the argument order here is right, params 3 and 4 really are nullable (only context, signingURL and listener get checkNotNullParameter), and the "no loginWithAccessToken required" claim holds. The README line this PR deletes was wrong.

Two items I want fixed before merge: the URL validation and the duplicate error event. The rest can land as follow-ups.

For the description: onCancel(envelopeId, recipientId) maps to handleSigningCancelled(envelopeId, null), so reason is always null on Android cancels, while iOS forwards DSMSigningExitReasonKey. That matches the existing session flow, so it is not a blocker, but the parity claim is a little stronger than what ships. Version bumps in package.json and android/build.gradle (both still on 1.0.5) I'll handle at release time.

Comment thread android/src/main/java/expo/modules/docusign/DocuSignManager.kt
Comment thread src/api.ts

@IronTony IronTony left a comment

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.

Switching this to changes requested so the state is explicit. The approach is right and I verified it against the shipped SDK, so this is about two specific items before merge, both already threaded inline:

  1. DocuSignManager.kt:338-341: reject a blank (and ideally non-https) signingUrl. The SDK's URL overload validates nothing and will launch an empty signing activity, leaving the promise unsettled.
  2. DocuSignModule.kt:171: drop the second onSigningError emit. It fires a duplicate event and flattens recipient_signing_failed into signing_failed.

The rest of the review can land as follow-ups. Ping me once those two are in and I'll re-review.

@IronTony
IronTony dismissed their stale review August 22, 2026 16:04

Re-triaged. Most of what I flagged was maintainer work on pre-existing code and has moved to #4. Replacing this with a smaller review.

@IronTony IronTony left a comment

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.

Trimmed this down. Five of my seven comments were really maintainer work on code that predates your PR, so I have deleted those threads and moved the items to #4. Sorry for the noise.

For the record, what moved: the listener and launch-helper extraction, the currentEnvelopeId catch in presentCaptiveSigning, the duplicate onSigningError emit, the CodedException code forwarding, and the README Throws bullet. They are tracked as a checklist on #4 and will land there after this merges.

Two things left, both in code this PR adds:

  1. DocuSignManager.kt:341: reject a blank and ideally non-https signingUrl. The 5-arg overload validates nothing and calls startActivity unconditionally, so a blank URL launches an empty signing activity and the promise never settles.
  2. src/api.ts: a small delegation test for presentCaptiveSigningWithUrl.

The approach is right and I verified it against the shipped 2.1.4 AAR. Ping me once those two are in.

Heads up on #4: it adds an opt-in launchStrategy to the session flow that mints a recipient view on device and launches it through the same overload you exposed here. Different entry point, same SDK path, so it will consume your entrypoint rather than duplicate it. It is a draft and waits for this one to merge first, so there is no rush on your side and no rebase coming your way.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The Android implementation introduces androidx.core.net.toUri() usage without declaring core-ktx, which can break compilation depending on transitive dependencies.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds Android support for the URL-based captive signing flow (presentCaptiveSigningWithUrl) so consumers can launch captive signing from a pre-minted recipient-view URL on both iOS and Android, aligning the JS API surface and documentation across platforms.

Changes:

  • Remove the JS-side iOS-only guard for presentCaptiveSigningWithUrl and delegate directly to the native module on both platforms.
  • Implement Android presentCaptiveSigningWithUrl via DocuSign.getInstance().getSigningDelegate().launchCaptiveSigning(...), including basic HTTPS URL validation and in-flight session guarding.
  • Update README/CHANGELOG and add a Jest unit test plus native-module mocking for presentCaptiveSigningWithUrl.
File summaries
File Description
src/api.ts Removes iOS-only restriction and exposes URL flow on Android via native module.
src/api.test.ts Adds a unit test asserting JS delegation to the native module.
README.md Updates flow matrix and URL-flow docs to reflect iOS+Android support and initialize requirement.
jest.setup.js Extends the native-module Jest stub to include presentCaptiveSigningWithUrl.
CHANGELOG.md Documents upcoming Android URL-flow support under “Next”.
android/src/main/java/expo/modules/docusign/DocuSignModule.kt Adds Android async function bridging for presentCaptiveSigningWithUrl.
android/src/main/java/expo/modules/docusign/DocuSignManager.kt Implements URL-based captive signing on Android using the DocuSign SDK delegate.
Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread android/src/main/java/expo/modules/docusign/DocuSignManager.kt Outdated
Copilot AI review requested due to automatic review settings August 24, 2026 06:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The Android URL validation can crash on malformed URLs, and not_initialized failures are currently surfaced as signing_failed instead of a distinct error code.

Review details

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

android/src/main/java/expo/modules/docusign/DocuSignManager.kt:350

  • The HTTPS validation can crash for malformed/relative URLs because Uri.parse(signingUrl).scheme may be null at runtime; calling .equals(...) on a null scheme will throw instead of returning a clean SigningFailedException.
    val signingUri = android.net.Uri.parse(signingUrl)
    if (
      !signingUri.scheme.equals("https", ignoreCase = true) ||
      signingUri.host.isNullOrBlank()
    ) {

android/src/main/java/expo/modules/docusign/DocuSignModule.kt:173

  • presentCaptiveSigningWithUrl can fail with NotInitializedException, but the module currently rejects it with code signing_failed. This makes it hard for callers to distinguish a missing initialize() call (and it contradicts the documented not_initialized code in the README error table).
          onFailure = { error ->
            emitSigningError(params.envelopeId, "signing_failed", error.message ?: "Unknown error")
            promise.reject("signing_failed", error.message ?: "Unknown error", error as? Exception)
          }
  • Files reviewed: 7/7 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@nikonhub

nikonhub commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

👋 Hi @IronTony , I've addressed both comments. Ready for another look 👍

Edit : Calling equals on a nullable string doesn't crash (test here)

@IronTony
IronTony self-requested a review August 26, 2026 19:00
IronTony
IronTony previously approved these changes Aug 26, 2026

@IronTony IronTony left a comment

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.

LGTM 👍

IronTony added a commit that referenced this pull request Aug 26, 2026
The protect-main ruleset requires status checks named build, lint and test, and
also CodeQL results. The repo had no workflows at all, so none of those were
ever reported and every PR sat permanently blocked on checks that had no
producer. #3 is currently stuck exactly this way.

Job ids are build / lint / test deliberately: with no name override, the job id
becomes the status check context, which is what the ruleset matches on.
Renaming a job here silently makes its required check unsatisfiable again.

Three separate jobs rather than one with three steps, so each context reports
independently and a lint failure does not mask a test failure.

permissions is contents: read, there are no secrets, and no untrusted context
values reach any run step. Actions are tag-pinned rather than SHA-pinned, which
is worth revisiting if the repo standardises on stricter supply-chain rules.
Copilot AI review requested due to automatic review settings August 26, 2026 19:33

This comment was marked as resolved.

@IronTony

Copy link
Copy Markdown
Owner

CI is wired up now and running on this PR. build and test are green. lint fails on one warning, and that one is on me, not you: I turned lint on in #6 after you had already written this file, so you were coding against a config that did not exist yet. Sorry for the moving target.

src/api.test.ts needs a blank line between the external and relative import groups:

 import { expect, it, jest } from '@jest/globals';
+
 import DocuSignModule from './DocuSignModule';
 import { presentCaptiveSigningWithUrl } from './api';

npx eslint . --fix produces exactly that if you would rather not hand-edit it. Nothing else in the repo flags.

One heads up on the CI itself: this PR is from a fork, so GitHub holds the workflow runs until I approve them. I approved this round and will keep doing so on each push, but that is why the checks sat on "Waiting for status to be reported" for a while rather than starting.

Once lint is green I will re-approve and merge. The two things I asked for are both in and both look right: the https/host guard sits ahead of the compareAndSet so a rejected URL never claims the pending slot, and the delegation test covers the branch the PR removes. Nice catch reverting toUri() back to android.net.Uri.parse too, that would have been a compile failure waiting to happen.

@IronTony IronTony closed this Aug 27, 2026
@IronTony IronTony reopened this Aug 27, 2026
@IronTony
IronTony merged commit 4ff873f into IronTony:main Aug 27, 2026
3 checks passed
@nikonhub

Copy link
Copy Markdown
Contributor Author

Thanks for the review and for merging 🙌

IronTony added a commit that referenced this pull request Aug 27, 2026
Everything here touches code that predates #3. It sat on that PR's review as
requested changes, which was wrong of me: asking a contributor to refactor the
maintainer's own code as a condition of landing their feature is scope creep.
Pulled off #3 and landed here instead.

Both entrypoints carried their own copy of a 33-line DSCaptiveSigningListener,
so any callback fix had to be made twice and the copies could drift.
captiveSigningListener() is now the single source, and
presentCaptiveSigningWithUrl folds onto the same launchWithSigningUrl helper the
signing-URL strategy uses.

#3's https/host guard becomes isHttpsUrl and now covers every URL this object
hands to the SDK or sends credentials to. It stays ahead of the compareAndSet in
presentCaptiveSigningWithUrl: a rejected URL must not claim the pending slot, or
the next valid call is refused as already in progress.

That guard also now covers the recipient-view request itself. `host` arrives
from JS unvalidated and that request carries the session bearer token, so an
http:// host would have sent it in cleartext. The signing-URL strategy is the
first code path to treat `host` as an endpoint rather than passing it to the
SDK, so the exposure comes in with this feature. Throwing routes to the fetch
fallback, meaning a misconfigured host degrades instead of failing outright.

launchViaEnvelopeFetch's catch now clears currentEnvelopeId. Only the URL path
did, and #3 had the correct shape.

restApiRoot is lifted out of the request builder. Its ordering is load-bearing,
since an already-versioned host would otherwise fall through and get a second
/restapi/v2.1 appended, and that reads better as a named function than buried in
a connection setup. Both branches are now case-insensitive; an uppercase
RESTAPI segment previously fell through and built a doubled path. Not unit
tested: android/build.gradle declares implementation project(':expo-modules-core'),
which only resolves inside a host app, so there is no standalone gradle build
and CI runs jest only.

Error codes are the behaviour change. CodedException infers a code from the
class name when none is given, so forwarding it verbatim would have surfaced
NotInitializedException as ERR_NOT_INITIALIZED rather than the not_initialized
the README error table has always documented. The exceptions now carry explicit
codes and the module forwards them, so callers can tell a missing initialize()
from a missing login from a real signing failure instead of receiving
signing_failed for all three.

The module also stops emitting onSigningError itself. handleSigningError already
emits, so every failure delivered two events, and the module's copy flattened
recipient_signing_failed into signing_failed. The emit in loginWithAccessToken
stays: that path never reaches handleSigningError, so it is the only event.
IronTony added a commit that referenced this pull request Aug 27, 2026
Everything here touches code that predates #3. It sat on that PR's review as
requested changes, which was wrong of me: asking a contributor to refactor the
maintainer's own code as a condition of landing their feature is scope creep.
Pulled off #3 and landed here instead.

Both entrypoints carried their own copy of a 33-line DSCaptiveSigningListener,
so any callback fix had to be made twice and the copies could drift.
captiveSigningListener() is now the single source, and
presentCaptiveSigningWithUrl folds onto the same launchWithSigningUrl helper the
signing-URL strategy uses.

#3's https/host guard becomes isHttpsUrl and now covers every URL this object
hands to the SDK or sends credentials to. It stays ahead of the compareAndSet in
presentCaptiveSigningWithUrl: a rejected URL must not claim the pending slot, or
the next valid call is refused as already in progress.

That guard also now covers the recipient-view request itself. host arrives from
JS unvalidated and that request carries the session bearer token, so an http://
host would have sent it in cleartext. The signing-URL strategy is the first code
path to treat host as an endpoint rather than passing it to the SDK, so the
exposure comes in with this feature. The check runs before the connection is
opened and before the Authorization header is set, and throwing routes to the
fetch fallback, so a misconfigured host degrades instead of failing outright.

launchViaEnvelopeFetch's catch now clears currentEnvelopeId. Only the URL path
did, and #3 had the correct shape.

restApiRoot is lifted out of the request builder. Its ordering is load-bearing,
since an already-versioned host would otherwise fall through and get a second
/restapi/v2.1 appended, and that reads better as a named function than buried in
a connection setup. Both branches are now case-insensitive; an uppercase
RESTAPI segment previously fell through and built a doubled path. Not unit
tested: android/build.gradle declares implementation project(':expo-modules-core'),
which only resolves inside a host app, so there is no standalone gradle build
and CI runs jest only.

Error codes are the behaviour change. CodedException infers a code from the
class name when none is given, so forwarding it verbatim would have surfaced
NotInitializedException as ERR_NOT_INITIALIZED rather than the not_initialized
the README error table has always documented. The exceptions now carry explicit
codes and the module forwards them, so callers can tell a missing initialize()
from a missing login from a real signing failure instead of receiving
signing_failed for all three.

The module also stops emitting onSigningError itself. handleSigningError already
emits, so every failure delivered two events, and the module's copy flattened
recipient_signing_failed into signing_failed. The emit in loginWithAccessToken
stays: that path never reaches handleSigningError, so it is the only event.
IronTony added a commit that referenced this pull request Aug 29, 2026
Everything here touches code that predates #3. It sat on that PR's review as
requested changes, which was wrong of me: asking a contributor to refactor the
maintainer's own code as a condition of landing their feature is scope creep.
Pulled off #3 and landed here instead.

Both entrypoints carried their own copy of a 33-line DSCaptiveSigningListener,
so any callback fix had to be made twice and the copies could drift.
captiveSigningListener() is now the single source, and
presentCaptiveSigningWithUrl folds onto the same launchWithSigningUrl helper the
signing-URL strategy uses.

#3's https/host guard becomes isHttpsUrl and now covers every URL this object
hands to the SDK or sends credentials to. It stays ahead of the compareAndSet in
presentCaptiveSigningWithUrl: a rejected URL must not claim the pending slot, or
the next valid call is refused as already in progress.

That guard also now covers the recipient-view request itself. host arrives from
JS unvalidated and that request carries the session bearer token, so an http://
host would have sent it in cleartext. The signing-URL strategy is the first code
path to treat host as an endpoint rather than passing it to the SDK, so the
exposure comes in with this feature. The check runs before the connection is
opened and before the Authorization header is set, and throwing routes to the
fetch fallback, so a misconfigured host degrades instead of failing outright.

launchViaEnvelopeFetch's catch now clears currentEnvelopeId. Only the URL path
did, and #3 had the correct shape.

restApiRoot is lifted out of the request builder. Its ordering is load-bearing,
since an already-versioned host would otherwise fall through and get a second
/restapi/v2.1 appended, and that reads better as a named function than buried in
a connection setup. Both branches are now case-insensitive; an uppercase
RESTAPI segment previously fell through and built a doubled path. Not unit
tested: android/build.gradle declares implementation project(':expo-modules-core'),
which only resolves inside a host app, so there is no standalone gradle build
and CI runs jest only.

Error codes are the behaviour change. CodedException infers a code from the
class name when none is given, so forwarding it verbatim would have surfaced
NotInitializedException as ERR_NOT_INITIALIZED rather than the not_initialized
the README error table has always documented. The exceptions now carry explicit
codes and the module forwards them, so callers can tell a missing initialize()
from a missing login from a real signing failure instead of receiving
signing_failed for all three.

The module also stops emitting onSigningError itself. handleSigningError already
emits, so every failure delivered two events, and the module's copy flattened
recipient_signing_failed into signing_failed. The emit in loginWithAccessToken
stays: that path never reaches handleSigningError, so it is the only event.
@IronTony IronTony mentioned this pull request Aug 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants