Handle downloads in mobile apps

Learn how to configure your mobile app's WebView so users can download documents and paystubs from embedded UI elements.

Many of Salsa's UI elements let users download files, such as documents, paystubs and checks. In a browser, downloads work with no setup. In a native mobile app, the elements run inside a WebView, and the WebView does not handle downloads the way a browser does. Your mobile app must do some of the work.

This guide shows what users see when they download a file on mobile, and how to configure your WebView on each platform. Each platform section is complete, so you can go directly to the one you use.

Where users download files

ElementDownloads
Worker documentsA document from the worker's document list
Employer documentsA document from the employer's document list
Worker payments listA paystub (Print earnings statement), a check (Print check), or all paystubs at once
Run payrollChecks, after the payroll is submitted
Payroll runs listChecks and all paystubs of a payroll run

All downloads use the same flow, and the setup in this guide applies to all of them. It also applies to all file types that Salsa accepts for documents: PDF, PNG, JPG, CSV, TSV, TXT, XML, XLS and XLSX.

On mobile, the document lists do not show the View action. Users download the document instead.

What users see

AndroidiOS
What the user seesThe file goes to the Downloads folder, and a system notification shows when it is done.The iOS share sheet opens. The user picks where the file goes, for example Save to Files or Print.
What your mobile app must doAdd a download handler. Without it, download buttons do nothing.Handle new windows, for the case when the share sheet cannot open.

The two platforms work differently. On Android, Salsa marks the file as a download, so the WebView passes it to your download handler and does not try to show it inside the element. On iOS, the element gets the file itself and gives it to the share sheet, so the WebView only has to let the share sheet open. If the share sheet cannot open, the download button opens the file in a new window instead.

Example: download a document

A worker opens the Worker documents element and taps Download on a W-2.

  1. The element asks Salsa for a download link to the file.
  2. Android: the element opens the link. The WebView calls your download handler, which saves W-2.pdf to the Downloads folder and shows a notification.
  3. iOS: the element gets the file and opens the share sheet. The worker taps Save to Files.

Example: download a paystub

A worker opens the Worker payments list element, selects a payment and taps Print earnings statement.

  1. The element asks Salsa for a download link to the paystub PDF.
  2. Android: the WebView calls your download handler, which saves the PDF to the Downloads folder. The worker opens it from the notification and prints it from the PDF viewer.
  3. iOS: the share sheet opens. The worker taps Print, or saves the PDF with Save to Files.

Before you start

Load your Salsa.js page from your own domain over HTTPS, as described in Embed a component. Salsa.js shows each element in an iframe from components.salsa.dev (components.sandbox.salsa.dev in sandbox), and the downloads start inside that iframe.

graph TD
  APP["Your mobile app"] --> WV["WebView<br/>WKWebView / Android WebView / react-native-webview"]
  WV --> PAGE["Your page, on your domain over HTTPS<br/>loads Salsa.js"]
  PAGE --> EL["UI element<br/>iframe from components.salsa.dev"]
  EL -- "file download" --> APP
🚧

Use a page on your domain

Do not load your Salsa.js page as inline HTML (for example, with loadHTMLString or loadDataWithBaseURL), and do not load an element URL directly in the WebView. Inline HTML has a null origin. Browser storage and security features do not work there, and elements fail with errors such as Salsa Error: Security Error: The operation is insecure.

Native Android (WebView)

The Android WebView does not download files by itself. Your mobile app must handle downloads.

WebView settings

webView.settings.apply {
    javaScriptEnabled = true
    domStorageEnabled = true
    allowFileAccess = false
    allowContentAccess = false
    setSupportMultipleWindows(false)
}

When setSupportMultipleWindows is false (the default), the download link opens in the same WebView. The WebView then sends the file to your download handler and keeps your page on the screen.

Download handler

Set a DownloadListener. The WebView calls it for each file the user downloads, and you give the file to the system DownloadManager:

webView.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ ->
    val fileName = URLUtil.guessFileName(url, contentDisposition, mimeType)

    val request = DownloadManager.Request(Uri.parse(url)).apply {
        setMimeType(mimeType)
        addRequestHeader("User-Agent", userAgent)
        setTitle(fileName)
        setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
        setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
    }

    val downloadManager = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
    downloadManager.enqueue(request)
}

Download links expire after a short time, so start the download when the listener is called. Do not save the URL for later.

Permissions

  • POST_NOTIFICATIONS (Android 13, API level 33 and later): request it at runtime to show the download notification. Without it, the download still completes, but the user gets no notification.
  • Storage: saving to Environment.DIRECTORY_DOWNLOADS with DownloadManager works with scoped storage on Android 10 and later. It does not need WRITE_EXTERNAL_STORAGE.

Native iOS (WKWebView)

On iOS, downloads usually need no code in your mobile app. The element gets the file and opens the iOS share sheet.

WebView settings

WKWebView allows JavaScript and localStorage by default. Keep the default website data store (WKWebsiteDataStore.default()), so data stays between sessions.

let config = WKWebViewConfiguration()
let webView = WKWebView(frame: .zero, configuration: config)
webView.uiDelegate = self
webView.load(URLRequest(url: URL(string: "https://your-domain.com/payroll")!))

When the share sheet cannot open

If the share sheet is not available, the download button calls window.open() with the file URL. WKWebView ignores window.open() unless your WKUIDelegate handles it. Show the file in a separate view, so your page stays loaded:

import SafariServices

func webView(
    _ webView: WKWebView,
    createWebViewWith configuration: WKWebViewConfiguration,
    for navigationAction: WKNavigationAction,
    windowFeatures: WKWindowFeatures
) -> WKWebView? {
    if let url = navigationAction.request.url {
        present(SFSafariViewController(url: url), animated: true)
    }
    return nil
}

Do not load the file URL in your main WebView. That replaces your page, and the user loses their place in the element.

React Native (react-native-webview)

Use react-native-webview 13.16.0 or later. It includes the download handler that native Android needs, and it uses WKWebView on iOS.

<WebView
  source={{ uri: 'https://your-domain.com/payroll' }}
  javaScriptEnabled={true}
  domStorageEnabled={true}
  onOpenWindow={(event) => {
    // The share sheet could not open (iOS). Show the file outside the WebView.
    Linking.openURL(event.nativeEvent.targetUrl);
  }}
/>
  • Android: the library's built-in download handler saves the file.
  • iOS: the share sheet opens, the same as native iOS. If it cannot open, onOpenWindow receives the file URL.

To log downloads on iOS, use the onFileDownload callback.

Ionic and Capacitor

Capacitor shows your mobile app in the platform WebView, so the native Android and iOS sections apply. Capacitor does not add a download handler for Android. To get native download handling without writing it yourself, open your Salsa.js page with the Capacitor InAppBrowser plugin.

If your page runs inside an iframe

Some apps show the Salsa.js page inside an iframe of their own. In that case, your iframe limits what the element can do with downloads. Give it these permissions:

<iframe
  src="https://your-domain.com/payroll"
  sandbox="allow-scripts allow-same-origin allow-forms allow-popups
           allow-popups-to-escape-sandbox allow-downloads allow-modals"
  allow="web-share">
</iframe>
  • allow-downloads: lets files download.
  • allow-popups and allow-popups-to-escape-sandbox: let the file open in a new window when the share sheet is not available.
  • allow-same-origin: lets the page use localStorage.
  • allow="web-share": lets the iOS share sheet open from inside the iframe.

File uploads

The Employer documents element also lets users upload files. Uploads need less setup than downloads:

  • iOS and React Native: uploads work with no extra code. The WebView opens the system file picker.
  • Android: the WebView does not open a file picker by itself. Implement onShowFileChooser in your WebChromeClient and return the result from your activity:
private var fileUploadCallback: ValueCallback<Array<Uri>>? = null

// In your WebChromeClient:
override fun onShowFileChooser(
    webView: WebView?,
    filePathCallback: ValueCallback<Array<Uri>>?,
    fileChooserParams: FileChooserParams?,
): Boolean {
    fileUploadCallback?.onReceiveValue(null)
    fileUploadCallback = filePathCallback
    val intent = fileChooserParams?.createIntent() ?: return false
    startActivityForResult(intent, FILE_CHOOSER_REQUEST_CODE)
    return true
}

// In your Activity:
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (requestCode == FILE_CHOOSER_REQUEST_CODE) {
        val results = WebChromeClient.FileChooserParams.parseResult(resultCode, data)
        fileUploadCallback?.onReceiveValue(results ?: arrayOf())
        fileUploadCallback = null
    }
}

Troubleshooting

SymptomLikely causeFix
Android: download buttons do nothingNo DownloadListener on the WebViewAdd the download handler.
Android: the download completes but no notification showsNo POST_NOTIFICATIONS permission on Android 13 or laterRequest the permission at runtime.
iOS: download buttons do nothingThe share sheet cannot open, and your mobile app ignores window.open()Handle new windows in your WKUIDelegate or with onOpenWindow.
The file replaces your pageYour mobile app loads the file URL in the main WebViewOpen it in a separate view, as shown for iOS.
Salsa Error: Security Error: The operation is insecure.The page has a null origin, for example because it was loaded as inline HTMLLoad your Salsa.js page from your domain over HTTPS.
Downloads fail inside your own iframeThe iframe sandbox does not allow downloads or popupsAdd the iframe permissions.
Android: the upload button does nothingNo onShowFileChooser in your WebChromeClientAdd the file chooser.

Known limitations

  • Report downloads. Salsa does not yet use the mobile download flow for the Cash Requirements and Payroll Journal reports in the payroll runs list. On iOS, a report can open inside the element instead of the share sheet.
  • Downloading all paystubs at once on iOS. Salsa does not yet use the share sheet for this bulk download, and it can fail on iOS. Users can download each paystub one at a time.

Did this page help you?