Skip to content

Migrate ShareActionHandlerModule to a type-safe Nitro module - #104550

Open
nabi-ebrahimi wants to merge 2 commits into
Expensify:mainfrom
nabi-ebrahimi:feat/migrate-share-action-handler-to-nitro
Open

nabi-ebrahimi wants to merge 2 commits into
Expensify:mainfrom
nabi-ebrahimi:feat/migrate-share-action-handler-to-nitro

Conversation

@nabi-ebrahimi

@nabi-ebrahimi nabi-ebrahimi commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Generate one shared-file contract for Swift, Kotlin, and TypeScript. Keep native storage internal and preserve the existing Onyx timestamp format.

Refs: #104432

Explanation of Change

This PR migrates the legacy ShareActionHandler React Native bridge to a type-safe Nitro module within @expensify/nitro-utils.

It replaces callback-based NativeModules communication and platform-specific payload formats with a single generated SharedFile contract implemented in Kotlin for Android and Swift for iOS. processFiles() now returns Promise<SharedFile[]>, eliminating Android JSON parsing and untyped native dictionaries in the Share flow, while safely falling back to an empty array on web.

The native implementations retain existing share behavior for images, videos, PDFs, plain text, and .txt file attachments, while improving validation for missing, unreadable, empty, or malformed shared content. The Share screen now surfaces the existing attachment-error UI for empty results or native-processing rejections, preventing an endless loading skeleton.

Fixed Issues

$ #104530
PROPOSAL:

Tests

  1. Build and install a fresh native Android or iOS app containing this PR. Do not use an older binary or a JS-only update.
  2. Open Gallery/Photos and share a JPEG or PNG receipt to Expensify.
  3. Verify the Share screen opens and the receipt preview loads with the correct aspect ratio.
  4. Verify the Submit tab is selected for the scannable receipt.
  5. Complete the normal Submit or Share flow and verify the attachment is sent successfully.
  6. Return to Gallery/Photos and share a different receipt.
  7. Verify the new receipt replaces the previous attachment and the Share screen does not crash.
  8. Share plain text to Expensify and verify the text is available in the Share flow.
  9. On Android, share text beginning with / and verify it is handled as text, not as a missing file path.
  10. Share an unsupported file type and verify the existing wrong-file-type error is displayed.
  • Verify that no errors appear in the JS console

Offline tests

  1. Build and install a fresh native Android or iOS app containing this PR.
  2. Sign in to Expensify, then disable the device’s internet connection.
  3. Share a JPEG or PNG receipt from Gallery/Photos to Expensify.
  4. Verify the Share screen opens and the receipt preview loads while offline.
  5. Verify the Submit tab is selected for a scannable receipt.
  6. Share plain text to Expensify and verify the text is available in the Share flow while offline.
  7. Share an unsupported file type, such as a .zip, and verify the existing wrong-file-type error appears while offline.
  8. Restore the internet connection after testing.

QA Steps

same as test

// TODO: These must be filled out, or the issue title must include "[No QA]."

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I verified any copy / text that was added to the app is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If the PR adds or modifies the UI:
    • I asked an AI agent to review the changes for accessibility issues and addressed its findings.
    • I tested with a screen reader (VoiceOver on macOS) and verified all new/changed elements are reachable with a logical focus order.
    • I verified all new/changed elements have meaningful accessible names and roles.
    • I verified state changes are announced (e.g. checked/unchecked, expanded/collapsed, selected).
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
REC-20261022115641.mp4
Android: mWeb Chrome

N/A

iOS: Native
REC-20261022123326.mp4
iOS: mWeb Safari

N/A

MacOS: Chrome / Safari

N/A

Generate one shared-file contract for Swift, Kotlin, and TypeScript. Keep native storage internal and preserve the existing Onyx timestamp format.

Refs: Expensify#104432
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ This PR is possibly changing native code and/or updating libraries, it may cause problems with HybridApp. Please check if any patch updates are required in the HybridApp repo and run an AdHoc build to verify that HybridApp will not break. Ask Contributor Plus for help if you are not sure how to handle this. ⚠️

@codecov

codecov Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ Changes either increased or maintained existing code coverage, great job!

Files with missing lines Coverage Δ
src/libs/ShareActionHandlerModule/index.ts 0.00% <0.00%> (ø)
src/pages/Share/ShareRootPage.tsx 89.83% <85.71%> (+89.83%) ⬆️
... and 538 files with indirect coverage changes

@nabi-ebrahimi
nabi-ebrahimi marked this pull request as ready for review October 22, 2026 08:18
@nabi-ebrahimi
nabi-ebrahimi requested review from a team as code owners October 22, 2026 08:18
@melvin-bot
melvin-bot Bot requested review from JmillsExpensify and shubham1206agra and removed request for a team October 22, 2026 08:18
@melvin-bot

melvin-bot Bot commented Oct 22, 2026

Copy link
Copy Markdown

@shubham1206agra Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@melvin-bot
melvin-bot Bot removed the request for review from a team October 22, 2026 08:18
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review 🔄 Running since 2026-10-22T08:18:51.867707Z e0ee644 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

};

interface ShareActionHandler extends HybridObject<{ios: 'swift'; android: 'kotlin'}> {
/** Returns an empty array when no share is pending; rejects if pending data cannot be read. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

❌ CONSISTENCY-15 (docs)

This newly added JSDoc comment joins two independent clauses with a semicolon (...when no share is pending; rejects if pending data cannot be read.). Comments should read as plain, natural sentences; a semicolon used to splice two statements together should be split into two separate sentences.

Suggested fix:

/** Returns an empty array when no share is pending. Rejects if pending data cannot be read. */
processFiles(): Promise<SharedFile[]>;

Reviewed at: e0ee644 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

return []
}

return try files.map { try self.processFile($0) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(Shubham's Ai) This rejects the whole batch if any single file fails processFile (empty, unreadable, not a regular file, or non-UTF-8 text_to_read). The old ObjC module skipped bad files and returned the rest. Could we keep that behavior, e.g. compactMap with try? (logging the skipped file), and only reject if no usable file remains?

const tempFile = processedFiles.at(0);
if (!tempFile) {
setErrorTitle(translate('attachmentPicker.attachmentError'));
setErrorMessage(translate('attachmentPicker.errorWhileSelectingCorruptedAttachment'));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(Shubham's Ai) An empty result now shows errorWhileSelectingCorruptedAttachment, but nothing is corrupt in this case; there's just no pending share. This path also runs whenever the app returns to the foreground (the AppState listener calls handleProcessFiles again). Is there an existing message that fits "nothing to share" better?

return@parallel arrayOf(SharedFile("text", content, "txt", timestamp, null))
}

if (!file.isFile || !file.canRead() || file.length() == 0L) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(Shubham's Ai) Throwing on a zero-byte file (here and the size > 0 guard in the Swift implementation) means the user gets the generic attachment error instead of the existing "attachment too small" (sizeNotMet) message from ShareRootPage. Could we let empty files through and keep the JS size validation as the single source of that error?

}

const {fileExtension} = splitExtensionFromFileName(tempFile?.content);
if (tempFile) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(Shubham's Ai) Nit: after the if (!tempFile) return guard above, this if (tempFile) is always true and the tempFile?. optional chains in this block are redundant. This could be flattened.

const handleProcessFiles = () => {
ShareActionHandler.processFiles()
.then((processedFiles) => {
if (errorTitle) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(Shubham's Ai) errorTitle here is the value from the render that started the call, not the current one, so it is stale by the time the promise resolves. This existed before, but the async chain widens the gap. Consider a ref or at least a comment.


override fun processFiles(): Promise<Array<SharedFile>> = Promise.parallel {
val context = NitroModules.applicationContext ?: throw IOException("React Native context is unavailable")
val preferences = context.getSharedPreferences("shareActionHandler", Context.MODE_PRIVATE)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(Shubham's Ai) Nit: "shareActionHandler" / "shareObject" duplicate IntentHandlerConstants (in both App and Mobile-Expensify), which this module can't import. Could you add a comment pointing at the source so the two don't drift?

retriever.release()
}
}
else -> null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(Shubham's Ai) aspectRatio is now undefined for non-media files on both platforms (previously 0 on Android, 1 on iOS). AttachmentPreview treats it as optional, so this looks fine, but please confirm a PDF share still previews correctly.

NitroModules: {createHybridObject: jest.fn(() => ({processFiles: jest.fn()}))},
}));

describe('ShareActionHandler native adapter', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(Shubham's Ai) These tests stub processFiles and then assert that it returns the stubbed value, so they only exercise the mock. tests/ui/ShareRootPageTest.tsx covers the real behavior; I think this file can be dropped.

let content = isText ? try String(contentsOf: url, encoding: .utf8) : url.absoluteString
var aspectRatio: Double?

if mimeType.hasPrefix("image/"), let image = UIImage(contentsOfFile: url.path), image.size.height > 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(Shubham's Ai) Optional: UIImage(contentsOfFile:) decodes the whole image just to read its dimensions. CGImageSourceCopyPropertiesAtIndex reads only the header, which is cheaper for large photos. (The old ObjC code did the same, so not a regression.)

@shubham1206agra

Copy link
Copy Markdown
Contributor

@nabi-ebrahimi Link https://github.com/Expensify/Mobile-Expensify/pull/14201 for HybridApp changes so we can make a build that works.

This branch has not been deployed

No deployments
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.

2 participants