Open PDFs from URLs on Android

Nutrient usually works best with PDF documents on the local file system of your device. While you can also open documents from practically any remote source using a custom data provider, there are several reasons — such as performance, cache control, and battery impact — to use local file system documents. Our SDK offers several means for opening a remote document, with different levels of control for how to deal with the downloaded file. Let’s start with the easiest method and work our way up.

💡 Tip: You can find additional example code inside DocumentDownloadExample and RemoteUrlExample in the Catalog app.

1. Use the URL as you would to open a local file with a Uri

Our SDK checks the scheme of the provided Uri for http or https and will replace it internally with a UrlDataProvider. The file will be downloaded into your app’s cache folder and opened from there. The next time you open the same URL, it’ll be downloaded again, and the previously cached file will be overwritten. This mechanism works with any of the following methods that accept either a Uri or a DocumentSource (which can wrap a Uri).

To load a PDF, use:

For image documents, use:

Our SDK doesn’t delete any of the downloaded files, since the OS will purge the the cache folder automatically if it’s low on resources. If you want to clean up yourself, call:

  • UrlDataProvider.deleteCachedFileForUrl(url) to remove the download of a specific URL.
  • UrlDataProvider.deleteCachedFiles to clear all downloads.

2. Direct use of a UrlDataProvider

As mentioned in the previous section, passing a remote URL will indirectly make use of UrlDataProvider. But you can use it directly, too. This enables you to provide a File object for storing the downloaded file. Nutrient Android SDK reuses this file if it already exists and avoids downloading it when it’s already locally available. Ensure the app has read/write access to the file path.

Create an instance of UrlDataProvider like this:

// We use a `File` object for easy parsing of the filename from the URL.
val file = File(urlString)
val provider = UrlDataProvider(URL(urlString), File(context.filesDir, "myDownloads/${file.name}"))

PdfDocumentLoader and ImageDocumentLoader don’t support data providers directly, but you can wrap them into a DocumentSource.

PdfDocumentLoader.openDocument(context, DocumentSource(dataProvider))

To load a PDF, use:

For image documents, use:

Important: Don’t block your main thread!

This information applies to both the direct and indirect use of UrlDataProvider:

If you call any of the PdfDocumentLoader/ImageDocumentLoader open methods — regardless of if they’re async or not — with a remote URL on the main thread, you’ll run into a DownloadOnMainThreadException. This is because it would block the main thread until the document is downloaded and loaded. And the general consensus is this is a bad idea.

That said, if you’re absolutely sure that blocking the main thread with a download operation is a good idea, you can still make a blocking call that bypasses the exception with :\openDocumentAsync(...).subscribeOn(_<any scheduler but main>_).blockingGet()or...blockingSubscribe(...)`.

3. Download the file on your own

Creating a download request

Before initiating a download, create a DownloadRequest object that defines all information required to perform the download. Using the DownloadRequest.Builder, you can set the download source, the output file or folder, whether existing files should be overwritten, etc. A call to Builder#build will then output the immutable DownloadRequest object:

val request: DownloadRequest = DownloadRequest.Builder(context)
.uri("content://com.example.app/documents/example.pdf")
.build()

The DownloadRequest.Builder#uri method can handle URIs pointing to content providers or to a file in the app’s assets (i.e. URIs starting with file:///android_asset/).

Starting the download

Once you’ve created a DownloadRequest, start the download by passing it to DownloadJob#startDownload:

val job: DownloadJob = DownloadJob.startDownload(request)

The DownloadJob object has three responsibilities:

  • Holding a task that will handle downloading the PDF file from the source defined in the request to the file system.
  • Providing methods for observing and controlling the download progress.
  • By retaining the DownloadJob instance across configuration changes, you can keep the download job alive and prevent it from being interrupted.

Observing the download progress

The download job will continuously report the download progress using Progress instances. You can set a ProgressListener via DownloadJob#setProgressListener, which will observe progress, completion, and download errors. All methods of the listener are called on the main thread. This means you can update your UI directly from these methods. Just be aware that you shouldn’t invoke longer-running operations or access the network without switching to the background thread.

ℹ️ Note: While there can only be a single ProgressListener per job, you can register as many observers to the Observable<Progress>(opens in a new tab) as you’d like.

job.setProgressListener(object : DownloadJob.ProgressListenerAdapter() {
override fun onProgress(progress: Progress) {
progressBar.setProgress((100f * progress.bytesReceived / progress.totalBytes).toInt())
}
override fun onComplete(output: File) {
PdfActivity.showDocument(context, Uri.fromFile(output), configuration.build())
}
override fun onError(exception: Throwable) {
handleDownloadError(exception)
}
})

Using a progress observable

If you prefer more control over how to receive Progress events, you can retrieve an RxJava Observable<Progress>(opens in a new tab). Similar to the ProgressListener, the observable will emit Progress events, as well as completion and error events. Events are received on a background thread by default, so your code needs to handle switching to the main thread on its own:

job.progress
.observeOn(AndroidSchedulers.mainThread())
.subscribe(object : Subscriber<Progress>() {
override fun onCompleted() {
PdfActivity.showDocument(context, Uri.fromFile(output), configuration.build())
}
override fun onError(e: Throwable) {
handleDownloadError(e)
}
override fun onNext(progress: Progress) {
progressBar.setProgress((100f * progress.bytesReceived / progress.totalBytes).toInt())
}
})

ℹ️ Note: Since progress events are non-critical, the progress observable will automatically handle back pressure by dropping excessive events.

4. Implementing a custom download source

If you want to download a PDF from a custom source, e.g. the web, you can use your own DownloadSource implementation. The open()] method has to return an InputStream(opens in a new tab) instance that will return the complete content of the PDF file. The getLength() method has to return either the download size in bytes, or DownloadSource#UNKNOWN_DOWNLOAD_SIZE if the size isn’t known. The returned size is optional, since it’s only used for creating higher quality Progress events.

Here’s an example of a DownloadSource that can download a PDF file from the web:

class WebDownloadSource (private val documentURL: URL) : DownloadSource {
override fun open(): InputStream {
val connection = documentURL.openConnection() as HttpURLConnection
connection.connect()
return connection.inputStream
}
override fun getLength(): Long {
var length = DownloadSource.UNKNOWN_DOWNLOAD_SIZE
try {
val contentLength = documentURL.openConnection().contentLength
if (contentLength != -1) {
length = contentLength.toLong()
}
} catch (e: IOException) {
e.printStackTrace()
}
return length
}
}

Retaining the download

To keep downloads running uninterrupted when your activity is recreated during a configuration change, you need to retain the DownloadJob instance of your download. The simplest way to do this is to store the instance inside a retained Fragment(opens in a new tab) and retrieve it after the activity has been recreated:

class MyActivity : AppCompatActivity(), DownloadJob.ProgressListener {
/**
* A non-UI fragment for retaining the download job.
*/
class DownloadFragment : Fragment() {
var job: DownloadJob? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Retaining this fragment allows it to carry the attached download job across configuration changes.
retainInstance = true
}
}
/**
* Fragment for holding and retaining the current download job.
*/
private var downloadFragment: DownloadFragment? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// This will return an existing fragment if the activity was recreated while a download was running.
downloadFragment = supportFragmentManager.findFragmentByTag(DOWNLOAD_FRAGMENT) as DownloadFragment
downloadFragment?.job?.setProgressListener(this)
}
override fun onDestroy() {
super.onDestroy()
// Clear the listener to prevent activity leaks.
downloadFragment?.job?.setProgressListener(null)
}
/**
* This method will initiate the download.
*/
private fun startDownload() {
if (downloadFragment == null) {
downloadFragment = DownloadFragment()
// Once the fragment is added, the download is safely retained.
supportFragmentManager.beginTransaction().add(downloadFragment, DOWNLOAD_FRAGMENT).commit()
}
val request = DownloadRequest.Builder(this).uri("content://com.example.app/documents/example.pdf").build()
val job = DownloadJob.startDownload(request)
job.setProgressListener(this)
downloadFragment?.job = job
}
override fun onProgress(progress: Progress) {
// Update your UI.
}
override fun onComplete(output: File) {
supportFragmentManager.beginTransaction().remove(downloadFragment).commit()
// Handle the finished download.
}
override fun onError(exception: Throwable) {
// Report and handle download errors.
}
companion object {
/**
* Tag of the retained fragment.
*/
internal val DOWNLOAD_FRAGMENT = "download_fragment"
}
}