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
| Element | Downloads |
|---|---|
| Worker documents | A document from the worker's document list |
| Employer documents | A document from the employer's document list |
| Worker payments list | A paystub (Print earnings statement), a check (Print check), or all paystubs at once |
| Run payroll | Checks, after the payroll is submitted |
| Payroll runs list | Checks 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
| Android | iOS | |
|---|---|---|
| What the user sees | The 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 do | Add 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.
- The element asks Salsa for a download link to the file.
- Android: the element opens the link. The WebView calls your download handler, which saves
W-2.pdfto the Downloads folder and shows a notification. - 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.
- The element asks Salsa for a download link to the paystub PDF.
- 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.
- 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 domainDo not load your Salsa.js page as inline HTML (for example, with
loadHTMLStringorloadDataWithBaseURL), and do not load an element URL directly in the WebView. Inline HTML has anullorigin. Browser storage and security features do not work there, and elements fail with errors such asSalsa 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_DOWNLOADSwithDownloadManagerworks with scoped storage on Android 10 and later. It does not needWRITE_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,
onOpenWindowreceives 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-popupsandallow-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 uselocalStorage.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
onShowFileChooserin yourWebChromeClientand 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
| Symptom | Likely cause | Fix |
|---|---|---|
| Android: download buttons do nothing | No DownloadListener on the WebView | Add the download handler. |
| Android: the download completes but no notification shows | No POST_NOTIFICATIONS permission on Android 13 or later | Request the permission at runtime. |
| iOS: download buttons do nothing | The share sheet cannot open, and your mobile app ignores window.open() | Handle new windows in your WKUIDelegate or with onOpenWindow. |
| The file replaces your page | Your mobile app loads the file URL in the main WebView | Open 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 HTML | Load your Salsa.js page from your domain over HTTPS. |
| Downloads fail inside your own iframe | The iframe sandbox does not allow downloads or popups | Add the iframe permissions. |
| Android: the upload button does nothing | No onShowFileChooser in your WebChromeClient | Add 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.
Updated about 8 hours ago
