Skip to content

feat: support pubky contact deep links - #1320

Merged
ovitrif merged 12 commits into
masterfrom
codex/pubky-contact-deeplink
Sep 25, 2026
Merged

ovitrif merged 12 commits into
masterfrom
codex/pubky-contact-deeplink

Conversation

@ben-kaufman

@ben-kaufman ben-kaufman commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

This PR adds bitkit://contact?pubky=<public-key> links that open the existing Pubky scanner contact flow.

iOS counterpart: synonymdev/bitkit-ios#768

Description

  • Opens Add Contact with an unknown key prefilled, Contact Detail for a saved contact, or Profile for the wallet's own key, using existing scanner routing and receiver refresh.
  • Uses existing deferred-scan handling for wallet unlock and waits for Pubky initialization and contacts before classifying a cold-start link.
  • Validates the URL and key before routing, rejecting missing, duplicate, overlong and non-key payloads.
  • Documents the link format and adds the matching cross-platform contact-link journey.

Example:

bitkit://contact?pubky=pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg

The value may be a raw 52-character key or include the pubky prefix. URL-encode the value. Existing Paykit feature gating is unchanged.

Out of Scope

  • Contact management: no automatic save and no new screens.
  • Payments and authorization: opening the link neither pays nor approves an auth request; no SDK changes.
  • Generic screen/sheet deep links and app links.

Design

N/A — no UI changes. Reuses existing Add Contact, Contact Detail and Profile screens.

Preview

N/A — existing scanner screens are unchanged.

QA Notes

Manual Tests

  • 1. Paykit-enabled wallet → open the link with an unsaved key: Add Contact opens with the key prefilled; nothing is saved automatically.
  • 2. Open with a saved contact's key → Contact Detail: existing contact is shown rather than Add Contact or Send.
  • 3. Open with the wallet's own key → Profile: no self-contact is created.
  • 4. Force-stop the app with PIN enabled → open a saved-contact link → unlock: Contact Detail opens once after identity and contacts load.
  • 5. Open missing, duplicate or invalid pubky parameters: no contact, payment or auth flow opens.
  • 6. regression: scan a Pubky key or payment QR: existing scanner behavior is unchanged.

Automated Checks

  • PubkyContactLinkTest.kt: accepted key forms, malformed URL authorities, missing/duplicate parameters and rejection of non-key payloads.
  • AppViewModelSendFlowTest.kt: unknown/own-key routing, restored saved-contact routing and receiver refresh, PIN deferral, wallet/feature gates and rejection without payment decode or auth.
  • Local compile and full unit suite passed: 2,747 tests, zero failures. Checks use the installed NDK 28.2.13676358 and -Dmaven.repo.local=/private/tmp/contact-deeplink-empty-maven --no-configuration-cache to bypass stale locally published artifacts.
  • Repository detekt, whitespace and journey XML checks passed. Existing scanner-complexity and unrelated detekt warnings remain; this PR does not refactor the scanner. The manual journey is documented but was not driven on-device.

@ben-kaufman
ben-kaufman requested review from jvsena42 and ovitrif and removed request for jvsena42 September 22, 2026 07:50
@greptile-apps

greptile-apps Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

The PR appears safe to merge, with a non-blocking responsiveness issue for malformed contact links during cold-start initialization.

Findings

  1. P2 Malformed links delay scans ▶

Summary

This PR adds validated bitkit://contact?pubky=… handling and routes contact links through the existing Pubky scanner flow.

  • Normalizes raw and prefixed Pubky keys while rejecting malformed contact URLs.
  • Defers locked-wallet links and waits for restored identity/contact state before choosing Profile, Contact Detail, or Add Contact.
  • Adds unit coverage, user-facing documentation, a changelog entry, and a cross-platform manual journey.
  • One non-blocking issue remains: malformed contact links can unnecessarily wait for Pubky initialization before being rejected.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Open bitkit contact URI] --> B{Wallet unlocked?}
    B -- No --> C[Queue deferred scan]
    C --> B
    B -- Yes --> D[Wait for Pubky initialization]
    D --> E{Active Pubky identity?}
    E -- Yes --> F[Wait for contacts load]
    E -- No --> G[Validate and normalize key]
    F --> G
    G -->|Invalid| H[Show decode error]
    G -->|Own key| I[Open Profile]
    G -->|Saved contact| J[Open Contact Detail]
    G -->|Unknown key| K[Open Add Contact]
Loading

Reviews (1) · Last reviewed commit: "feat: support pubky contact deep links"

Comment thread app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt Outdated
@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Regtest APK

Built from ff3e8c1 (run).

Download bitkit-dev-debug universal APK (expires in 30 days).

@ovi-reviewer ovi-reviewer Bot 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.

Verdict: ✅ Approve


Review: diff 8 files.
Counterpart synonymdev/bitkit-ios#768: diverges, see the parity finding.

Findings:
1 inline (non-blocking)

Audit:
Audited - no findings.

Coverage:
QA: journeys and manual tests await all reviewers to approve, author can run it now via comment: @ovi-reviewer test


Reviewed by claude-opus-5-xhigh via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest · audit (author or owner)

Comment thread app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No HIGH or MEDIUM findings. Two LOWs inline, no verifier pass; read them as observations.

Checked and clean:

  • No state change from the link. A valid link only navigates: to Add Contact (fetchProfile is read-only), Contact Detail, or Profile for the user's own key. Saving and paying stay tap-driven.
  • Scheme precedence. The order is SamRock → screen → recovery → pubky-auth → pubkyauth:// → contact. A pubky= value carrying bitcoin:, lightning: or pubkyauth:// fails normalization and is toasted before decode or handlePubkyAuth runs.
  • Parser. It rejects userinfo, port, path, fragment and extra or duplicate query params. The ≤57 length check runs before truncation, and the tests exercise the real PaykitPublicKeys.normalize.
  • Locked wallet and pre-onboarding. On a locked wallet the link goes through enqueueDeferredScan and is flushed once after PIN. Before onboarding it is dropped by the walletExists() guard. With Paykit off it is dropped silently.
  • Journey. The file name, journey name and all actions are identical to synonymdev/bitkit-ios#768; only the platform launch command differs. The suite table is alphabetical and the count of 3 matches the folder.

Comment thread app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt Outdated
Comment thread journeys/README.md Outdated

@ovi-reviewer ovi-reviewer Bot 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.

Verdict: ✅ Approve


Reaudit: diff 2 files.
Counterpart synonymdev/bitkit-ios#768: equivalent.

Findings:
N/A

Audit:
Already done in comment.

Coverage:
QA: journeys and manual tests await all reviewers to approve, author can run it now via comment: @ovi-reviewer test


Reviewed by claude-opus-5-xhigh via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest · audit (author or owner)

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Delta since db45d0519 (94405b015): no findings. processDeeplink now checks PubkyContactLink.matches before isRecoveryModeDeeplink(). That closes a real hole: at the previous head, bitkit://contact/recovery-mode?pubky=… turned on recovery mode before the contact parser saw it. The regression test covers it with never().setRecoveryMode(true) and asserts one decoding toast per malformed link. My two LOWs from the last pass are still open.

@jvsena42 jvsena42 left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Delta since 94405b015 (54d174cbb): no findings. Both of my LOWs are fixed.

  • The deeplink now waits on contactsLoadCompletionVersion, which is bumped on success and also on a failure for the current key. A failed startup fetch therefore releases the link at once, without marking contacts as loaded.
  • A stale load for a previous key bumps neither signal. reset zeroes both.
  • The deeplinks row has its no README marker back.

ovi-reviewer[bot]

This comment was marked as resolved.

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Delta since 54d174cbb (cc87440db): no HIGH or MEDIUM findings. One LOW inline.

This addresses ovi-reviewer's point. After a failed startup load, the link retries once and only routes on a real success, so a saved contact is no longer sent to Add Contact from an empty list. Completion is now marked in finally, after the mutex is released, and only for the current key.

Comment thread app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
ovi-reviewer[bot]

This comment was marked as resolved.

@ovi-reviewer ovi-reviewer Bot 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.

Advice: ✅ Approve


Reaudit: diff 2 files.
Counterpart synonymdev/bitkit-ios#768: diverges, see the parity finding.

Findings:
N/A

Audit:
Already done in comment.

Coverage:
QA: journeys and manual tests await all reviewers to approve, author can run it now via comment: @ovi-reviewer test


Reviewed by claude-opus-5-xhigh via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest · audit (author or owner)

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Delta since cc87440db (456680c58): no findings. My LOW is fixed. The retry snapshots contactsLoadCompletionVersion and waits for a newer completion, so a concurrent load that succeeds routes the saved contact. If no newer completion ever arrives (sign-out mid-wait, or a stale-key load), the outer withTimeoutOrNull(AUTHORIZATION_TIMEOUT) bounds the wait and ends in the existing toast. The success-path tests now assert that loadContacts() is not called when contacts are already loaded, which answers ovi-reviewer's coverage point.

ovi-reviewer[bot]
ovi-reviewer Bot previously requested changes Sep 22, 2026

@ovi-reviewer ovi-reviewer Bot 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.

Verdict: ⛔️ Request Changes


Tests for the review: 5 of 6 manual tests passed.

QA:
Tested on Android 16 emulator (Pixel 2)

Test 1 ✅ passed

evidence
1.mp4

Test 2 ✅ passed

evidence
2.mp4

Test 3 ✅ passed

evidence
3.mp4

Test 4 ✅ passed

evidence
4.mp4

Test 5 ⛔️ failed: Duplicate pubky parameters opened Contact Detail instead of ignoring the malformed link.

evidence
5.mp4

Test 6 ✅ passed

evidence
6.mp4

Tip

Worth a journey

Test 1
  • Open a contact link for an unsaved Pubky key
  • Verify Add Contact opens with the supplied key
  • Verify Save remains available without automatic saving
Test 2
  • Return to wallet home
  • Open a contact link for a saved Pubky key
  • Verify Contact Detail opens for the saved contact
Test 3
  • Return to wallet home
  • Open a contact link for your own Pubky key
  • Verify Profile opens without contact controls
Test 4
  • Enable a wallet PIN
  • Force-stop the app
  • Open a saved contact link
  • Verify the PIN screen appears before contact UI
  • Enter the PIN
  • Verify Contact Detail opens once
  • Press Back once and verify wallet home appears
Test 6
  • From wallet home, open the QR scanner
  • Scan a valid Pubky key
  • Verify Add Contact opens for that key
  • Return to wallet home
  • Scan a valid wallet payment QR
  • Verify the existing zero-savings payment behavior returns home

Reviewed by gpt-5.6-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest · audit (author or owner)

@ben-kaufman

Copy link
Copy Markdown
Contributor Author

The app rejects the full duplicate URI, and the deeplink regression passes with that exact input. Opening Contact Detail means Android received only the first pubky value. adb shell parses & unless the URI is quoted inside the remote command. Could you rerun with the whole device-side URI quoted so the & reaches the app?

@ovitrif

ovitrif commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator

@ovi-reviewer retest 5

@ovi-reviewer ovi-reviewer Bot 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.

Verdict: ✅ Approve


Tests for the review: no test needed the device again; each one passed on an earlier run.

QA:
Tests 1-4, 6 already done in review.
Test 5 already done in review.


Reviewed by claude-opus-5-5-xhigh via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest · audit (author or owner)

@ovi-reviewer

ovi-reviewer Bot commented Sep 23, 2026

Copy link
Copy Markdown

@ben-kaufman conflicts

@ovitrif
ovitrif enabled auto-merge September 23, 2026 20:38

@ovi-reviewer ovi-reviewer Bot 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.

Verdict: ✅ Approve


Reaudit: diff 0 files.
No new findings; the rest is in the review.
Counterpart bitkit-ios#768 diverges; see the parity finding.
Tests 1-4, 6 already done in review.
Test 5 already done in review.


Reviewed by gpt-6-sol-xhigh via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest · audit (author or owner)

@ovitrif
ovitrif dismissed ovi-reviewer[bot]’s stale review September 24, 2026 05:53

addressed - tests pass

@piotr-iohk

piotr-iohk commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Found this while testing the example link from the PR. Saving that contact and opening the same link again stays on Add Contact. A real saved contact opens Contact Detail.

The example key is pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg. It is the unit-test key, and it is not published. Its last character is non-canonical z-base32: those 32 bytes encode as pubky3rsd…k8yw5xy. PaykitPublicKeys.normalize keeps the original string, and contact lookup compares those strings, so …yw5xg and …yw5xy do not match.

After Save, the stored key comes back as 3RSD…W5XY, while the contact name stays pubky…yw5xg. The link still carries …yw5xg, so it misses the saved contact and opens Add Contact. Saving again briefly adds a second row, 3RSD…W5XG, and a reload leaves only W5XY.

This shows up with the app already unlocked. Force-stop and the PIN screen only made it look like a cold-start problem, because that reload is what brings the canonical key back. pubkyc3x4fe94g3epxpodxbn73i6o5wf5pk9yh9t51b17oqrdw9yjpeho is already canonical, and the same link opens Contact Detail for it.

The key is a valid public key with no pkarr record, so Add Contact saves a placeholder. That is existing behavior, and it is what surfaces the mismatch. The link itself does not save anything.

The same lookup is string equality on iOS (PubkyPublicKeyFormat.matches / resolvePubkyRoute, save via Paykit.saveContact), and the same test key is in the iOS tests. Not checked on an iOS simulator yet.

Steps

  1. Paykit-enabled wallet with an existing profile.
  2. Open bitkit://contact?pubky=pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg.
  3. Save.
  4. Open the same link again.

Expected: Contact Detail for the saved contact. Actual: Add Contact for 3RSD…W5XG, with 3RSD…W5XY already in the list under the name pubky…yw5xg.

Screen recording:

qa-contact-deeplink.mov

logs.zip

@ben-kaufman

Copy link
Copy Markdown
Contributor Author

Canonicalized the key before routing. The example link now resolves to the same stored key, so reopening it after Save opens Contact Detail.

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Delta since 712fdf125 (285db622f): no findings. The link now canonicalizes the final z-base-32 symbol, masking off the four padding bits, so two encodings of the same key resolve to one contact. I checked the mask against the alphabet: g (6) becomes y (0), which is what the test asserts.

One question, not a finding: is the canonical form what the paste and scan paths store? If a contact was saved from a non-canonical encoding, a deeplink for the same key would now route to Add Contact rather than Contact Detail.

@ben-kaufman

Copy link
Copy Markdown
Contributor Author

They did not. Plain paste and scanner input kept the non-canonical spelling. I changed contact routing and new-contact persistence to canonicalize first, so those paths now save and look up the same key as the deeplink.

@piotr-iohk

Copy link
Copy Markdown
Collaborator

The …yw5xg contact link is fixed on 285db622. On a regtest build, Add Contact shows the canonical key 3RSD...W5XY and does not save until Save is tapped. Opening that same link again opens Contact Detail, including after force-stop and PIN unlock. The contacts list has one row, …yw5xy.

Own key opens Profile. Invalid, duplicate, and contact/recovery-mode links stay on the wallet. PubkyContactLink.publicKey(from:) on iOS origin/master 3ac017b6 still returns the original spelling, so that remains a separate follow-up.

piotr-iohk
piotr-iohk previously approved these changes Sep 24, 2026

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. (Modulo conflict on journeys/README.md)

Please port the contact-key canonicalization to iOS. PubkyContactLink.publicKey(from:) on origin/master 3ac017b6 still keeps the original last character.

@ovitrif

ovitrif commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator

@ben-kaufman conflicts

ovitrif
ovitrif previously approved these changes Sep 24, 2026

@ovitrif ovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

utACk

@ovitrif ovitrif added this to the 2.6.0 milestone Sep 24, 2026
@ben-kaufman
ben-kaufman dismissed stale reviews from ovitrif and piotr-iohk via ff3e8c1 September 25, 2026 11:48

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

0c774ea answers the canonical-form question: canonicalized() runs after normalized, and the SDK's alphabet check means the padding mask can only clear padding bits, so two keys cannot collide. The paste, scanner, address-continue and deeplink routes, plus fetchContactProfile/addContact, now agree with what contactRecords() returns. refreshContactReceiverPaths intentionally keeps the loaded key, and a test pins that. No regressions in the new commit.

One LOW observation inline (gated behind Paykit UI). Not independently verified.

Checked and clean:

  • No state change without a tap: a valid link only navigates to AddContact / ContactDetail / Profile; addContact and payContact need a tap; the bare canonical key misses Bip21, SamRock and pubkyauth, and the route branch returns before decode.
  • Parser: authority must equal contact (no userinfo/port), empty path, no fragment, exactly one pubky param, raw & rejected, length bounded before take(57); %26 inside the value fails the alphabet check.
  • processDeeplink precedence: SamRock, screen, pubky-auth callbacks can't be reached from a contact host; bitkit://contact/recovery-mode no longer flips recovery mode.
  • Gating: malformed links short-circuit before the init wait; a flag-off link is dropped after the wait and handleScan re-checks the flag; pre-onboarding is dropped; locked wallet replays with routePubkyKeys preserved.
  • Own key routes to Profile; CannotAddSelf still guards.
  • The query is dropped from deeplink logs, so the key never reaches them.

Comment thread app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-approving ff3e8c18. The merge of master only added documentation rows in journeys/README.md. The contact-link code is unchanged from 0c774ea.

The contact-key canonicalization still needs an iOS port. PubkyContactLink.publicKey(from:) on origin/master 3ac017b6 keeps the original last character.

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

tAck

@ovitrif
ovitrif merged commit 417932b into master Sep 25, 2026
19 checks passed
@ovitrif
ovitrif deleted the codex/pubky-contact-deeplink branch September 25, 2026 14:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants