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}"))
// We use a `File` object for easy parsing of the filename from the URL.
final var file = new File(urlString);
final var provider = new UrlDataProvider(new URL(urlString), new File(context.getFilesDir(), "myDownloads/" + file.getName()));

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

PdfDocumentLoader.openDocument(context, DocumentSource(dataProvider))
PdfDocumentloader.openDocument(context, new 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(__).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()
final DownloadRequest request = new 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)
final DownloadJob job = 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> 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)
    }
})
job.setProgressListener(new DownloadJob.ProgressListenerAdapter() {
    @Override public void onProgress(@NonNull Progress progress) {
        progressBar.setProgress((int) (100 * progress.bytesReceived / (float) progress.totalBytes));
    }

    @Override public void onComplete(@NonNull File output) {
        PdfActivity.showDocument(context, Uri.fromFile(output), configuration.build());
    }

    @Override public void onError(@NonNull Throwable exception) {
        handleDownloadError(exception);
    }
});

Using a progress observable

If you prefer more control over how to receive Progress events, you can retrieve an RxJava Observable<Progress>. 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())
        }
    })
job.getProgress()
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(new Subscriber<Progress>() {
        @Override public void onCompleted() {
            PdfActivity.showDocument(context, Uri.fromFile(output), configuration.build());
        }

        @Override public void onError(Throwable e) {
            handleDownloadError(exception);
        }

        @Override public void onNext(Progress progress) {
            progressBar.setProgress((int) (100 * progress.bytesReceived / (float) progress.totalBytes));
        }
    });

ℹ️ 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 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
    }
}
private static class WebDownloadSource implements DownloadSource {
    @NonNull private final URL documentURL;

    private WebDownloadSource(@NonNull URL documentURL) {
        this.documentURL = documentURL;
    }

    @Override public InputStream open() throws IOException {
        final HttpURLConnection connection = (HttpURLConnection) documentURL.openConnection();
        connection.connect();
        return connection.getInputStream();
    }

    @Override public long getLength() {
        long length = DownloadSource.UNKNOWN_DOWNLOAD_SIZE;

        try {
            final int contentLength = documentURL.openConnection().getContentLength();
            if (contentLength != -1) {
                length = contentLength;
            }
        } catch (IOException e) {
            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 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"
    }
}
public class MyActivity extends AppCompatActivity implements DownloadJob.ProgressListener {

    /**
     * A non-UI fragment for retaining the download job.
     */
    public final static class DownloadFragment extends Fragment {
        public DownloadJob job;

        @Override public void onCreate(@Nullable Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);

            // Retaining this fragment allows it to carry the attached download job across configuration changes.
            setRetainInstance(true);
        }
    }

    /**
     * Tag of the retained fragment.
     */
    final static String DOWNLOAD_FRAGMENT = "download_fragment";

    /**
     * Fragment for holding and retaining the current download job.
     */
    private DownloadFragment downloadFragment;

    @Override protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // This will return an existing fragment if the activity was recreated while a download was running.
        downloadFragment = (DownloadFragment) getSupportFragmentManager().findFragmentByTag(DOWNLOAD_FRAGMENT);
        if (downloadFragment != null) {
            downloadFragment.job.setProgressListener(this);
        }
    }

    @Override protected void onDestroy() {
        super.onDestroy();

        // Clear the listener to prevent activity leaks.
        if (downloadFragment != null) {
            downloadFragment.job.setProgressListener(null);
        }
    }

    /**
     * This method will initiate the download.
     */
    private void startDownload() {
        if (downloadFragment == null) {
            downloadFragment = new DownloadFragment();

            // Once the fragment is added, the download is safely retained.
            getSupportFragmentManager().beginTransaction().add(downloadFragment, DOWNLOAD_FRAGMENT).commit();
        }

        final DownloadRequest request = new DownloadRequest.Builder(this)
            .uri("content://com.example.app/documents/example.pdf")
            .build();

        downloadFragment.job = DownloadJob.startDownload(request);
        downloadFragment.job.setProgressListener(this);
    }

    @Override public void onProgress(@NonNull Progress progress) {
        // Update your UI.
    }

    @Override public void onComplete(@NonNull File output) {
        getSupportFragmentManager().beginTransaction().remove(downloadFragment).commit();

        // Handle the finished download.
    }

    @Override public void onError(@NonNull Throwable exception) {
        // Report and handle download errors.
    }
}