---
title: "Calling asynchronous APIs from Java on Android | Nutrient"
canonical_url: "https://www.nutrient.io/guides/android/best-practices/calling-async-apis-from-java/"
md_url: "https://www.nutrient.io/guides/android/best-practices/calling-async-apis-from-java.md"
last_updated: "2026-11-02T00:00:00.000Z"
description: "Call Nutrient Android SDK suspend functions and Flows from Java with synchronous overloads, runBlocking, RxJava, or a small Kotlin adapter."
---

# Calling asynchronous APIs from Java

Since Nutrient Android SDK 12.1, the SDK’s asynchronous APIs are Kotlin suspend functions or return a Kotlin `Flow`. Kotlin code calls them from a coroutine. Java can’t call a suspend function directly, so this guide shows the options that work from Java, starting with the simplest.

If you’re upgrading from an earlier version, where these APIs returned RxJava types, read the [12.1 migration guide](https://www.nutrient.io/guides/android/releases/12-1.md#migration-guide) first.

## Recognizing asynchronous APIs from Java

A Kotlin suspend function compiles to a Java method with an extra last parameter of type `kotlin.coroutines.Continuation`. The method returns `Object`. For example, `AnnotationProvider.getAnnotations(int pageIndex)` looks like this from Java:

```java

Object getAnnotations(int pageIndex, Continuation<? super List<? extends Annotation>> continuation);

```

The `Continuation` type argument is the result type that the Kotlin function declares, which is `List<? extends Annotation>` in this example. Don’t implement `Continuation` yourself. Use one of the approaches below, which supply it for you.

A method that returns `kotlinx.coroutines.flow.Flow` is an ordinary method. You can call it from any thread, but nothing runs until you collect the flow, and collecting is itself a suspend function.

Some common asynchronous APIs are:

| API                                                                                  | Kind           |
| ------------------------------------------------------------------------------------ | -------------- |
| `AnnotationProvider` methods, such as `getAnnotations()` and `addAnnotationToPage()` | Suspend        |
| `FormProvider` methods, such as `getFormFields()`                                    | Suspend        |
| `PdfDocumentLoader.openDocumentAsync()`                                              | Suspend        |
| `PdfDocument.saveAsync()`, `saveIfModifiedAsync()`, `renderPageToBitmapAsync()`      | Suspend        |
| `XfdfFormatter.parseXfdfAsync()`, `DocumentJsonFormatter.exportDocumentJson()`       | Suspend        |
| `TextSearch.performSearchAsync()`                                                    | Returns `Flow` |
| `PdfProcessor.processDocumentAsync()`                                                | Returns `Flow` |
| `DownloadJob.getProgress()`                                                          | Returns `Flow` |
| `InstantPdfDocument.syncAnnotationsAsync()`                                          | Returns `Flow` |

## Prefer synchronous overloads

Many asynchronous APIs have a synchronous counterpart that blocks until the work is done and returns the result directly. From Java, this is the simplest option. Call it on a worker thread, and post the result back to the main thread:

| Asynchronous API                             | Synchronous counterpart                              |
| -------------------------------------------- | ---------------------------------------------------- |
| `PdfDocumentLoader.openDocumentAsync()`      | `PdfDocumentLoader.openDocument()`                   |
| `TextSearch.performSearchAsync()`            | `TextSearch.performSearch()`                         |
| `PdfProcessor.processDocumentAsync()`        | `PdfProcessor.processDocument()`                     |
| `PdfDocument.saveAsync()`                    | `PdfDocument.save()`                                 |
| `PdfDocument.saveIfModifiedAsync()`          | `PdfDocument.saveIfModified()`                       |
| `PdfDocument.renderPageToBitmapAsync()`      | `PdfDocument.renderPageToBitmap()`                   |
| `PdfDocument.initPageCacheAsync()`           | `PdfDocument.initPageCache()`                        |
| `XfdfFormatter.parseXfdfAsync()`             | `XfdfFormatter.parseXfdf()`                          |
| `XfdfFormatter.writeXfdfAsync()`             | `XfdfFormatter.writeXfdf()`                          |
| `DocumentJsonFormatter.exportDocumentJson()` | `DocumentJsonFormatter.exportDocumentJsonBlocking()` |
| `DocumentJsonFormatter.importDocumentJson()` | `DocumentJsonFormatter.importDocumentJsonBlocking()` |

The following example opens a document, searches it, and shows the results. It uses an `ExecutorService` for the worker thread. `TextSearch` takes a `PdfConfiguration`. Pass the one your viewer uses, or build a default one:

```java

private final ExecutorService executor = Executors.newSingleThreadExecutor();

private void searchDocument(Uri documentUri, String query) {
    // Use the application context, because the worker can outlive the activity.
    Context context = getApplicationContext();
    PdfConfiguration configuration = new PdfConfiguration.Builder().build();

    executor.execute(() -> {
        try {
            // Both calls block, so they run on the executor's worker thread.
            PdfDocument document = PdfDocumentLoader.openDocument(context, documentUri);
            TextSearch textSearch = new TextSearch(document, configuration);
            List<SearchResult> results = textSearch.performSearch(query);

            runOnUiThreadIfAlive(() -> showResults(results));
        } catch (Exception e) {
            // Also catches unchecked SDK exceptions, which would otherwise end the worker thread.
            runOnUiThreadIfAlive(() -> showError(e));
        }
    });
}

// Views can only be updated on the main thread, and only while the activity is still alive.
private void runOnUiThreadIfAlive(Runnable action) {
    runOnUiThread(() -> {
        if (!isFinishing() &&!isDestroyed()) {
            action.run();
        }
    });
}

@Override
protected void onDestroy() {
    super.onDestroy();
    executor.shutdownNow();
}

```

`shutdownNow()` stops queued tasks, but it can’t stop a call to `openDocument()` or `performSearch()` that’s already running. That’s why the code uses the result only if the activity is still alive.

Outside an activity, post to the main thread with a `Handler` created with `new Handler(Looper.getMainLooper())`.

Some APIs also offer a listener instead of a `Flow`. For example, [`DownloadJob.setProgressListener()`](https://www.nutrient.io/api/android/nutrient/com.pspdfkit.document.download/-download-job/set-progress-listener.html) reports download progress through a plain Java interface.

## Call a suspend function with runBlocking

When an API has no synchronous counterpart, call it with `BuildersKt.runBlocking()` from `kotlinx-coroutines-core`. `runBlocking()` starts a coroutine, waits for it to finish, and returns its result. Pass `EmptyCoroutineContext.INSTANCE` as the context, because Kotlin default arguments aren’t available from Java. The lambda receives the `Continuation` and passes it to the SDK method:

```java

import kotlin.coroutines.EmptyCoroutineContext;
import kotlinx.coroutines.BuildersKt;

// `addAnnotationToPage()` is a suspend function. `runBlocking()` waits for it to finish,
// so only call this on a worker thread.
try {
    BuildersKt.runBlocking(
        EmptyCoroutineContext.INSTANCE,
        (scope, continuation) -> document.getAnnotationProvider().addAnnotationToPage(annotation, continuation));
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
} catch (Exception e) {
    runOnUiThreadIfAlive(() -> showError(e));
}

```

`runBlocking()` declares `InterruptedException`. It throws it when the waiting thread is interrupted, after canceling the coroutine. Restore the thread’s interrupted flag with `Thread.currentThread().interrupt()` so the code that owns the thread can see it. `runBlocking()` rethrows exceptions that the SDK method throws. Java doesn’t see them as checked exceptions, so catch `Exception` after `InterruptedException` to handle a failure such as an `IOException`. `runOnUiThreadIfAlive()` is the helper from the `ExecutorService` example above.

To get a result back, assign the return value of `runBlocking()`. Declare list results as `List<? extends T>`, because that’s how Kotlin’s read-only `List` appears to Java. Declaring `List<Annotation>` doesn’t compile:

```java

AnnotationProvider provider = document.getAnnotationProvider();

executor.execute(() -> {
    try {
        // `getAnnotations()` is a suspend function. `runBlocking()` waits for it to finish,
        // so only call this on a worker thread.
        List<? extends Annotation> annotations = BuildersKt.runBlocking(
            EmptyCoroutineContext.INSTANCE,
            (scope, continuation) -> provider.getAnnotations(0, continuation));

        runOnUiThreadIfAlive(() -> showAnnotations(annotations));
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    } catch (Exception e) {
        runOnUiThreadIfAlive(() -> showError(e));
    }
});

```

Never call `runBlocking()` on the main thread. It blocks the thread until the SDK finishes, which freezes your UI and can cause an Application Not Responding (ANR) error.

## Collect a flow from Java

You can collect a flow from Java by blocking a worker thread or by converting the flow to an RxJava `Flowable`.

### Blocking collection

On a worker thread, collect the whole flow into a list with `FlowKt.toList()` inside `runBlocking()`. Pass a new `ArrayList` as the destination, because the default argument isn’t available from Java:

```java

import kotlinx.coroutines.flow.FlowKt;

// Collecting a flow suspends, so only call this on a worker thread.
try {
    List<? extends SearchResult> results = BuildersKt.runBlocking(
        EmptyCoroutineContext.INSTANCE,
        (scope, continuation) -> FlowKt.toList(
            textSearch.performSearchAsync(query), new ArrayList<>(), continuation));
    runOnUiThreadIfAlive(() -> showResults(results));
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
} catch (Exception e) {
    runOnUiThreadIfAlive(() -> showError(e));
}

```

To handle each value as it arrives, call `collect()` on the flow. The collector lambda receives each value and its own continuation, and it must return `Unit.INSTANCE`. The following example reports progress while processing a document:

```java

import kotlin.Unit;

// Collecting a flow suspends, so only call this on a worker thread.
try {
    BuildersKt.runBlocking(
        EmptyCoroutineContext.INSTANCE,
        (scope, continuation) -> PdfProcessor.processDocumentAsync(task, outputFile).collect((progress, emitContinuation) -> {
                int pagesProcessed = progress.getPagesProcessed();
                int totalPages = progress.getTotalPages();
                runOnUiThreadIfAlive(() -> showProgress(pagesProcessed, totalPages));
                return Unit.INSTANCE;
            }, continuation));
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
} catch (Exception e) {
    runOnUiThreadIfAlive(() -> showError(e));
}

```

Only collect flows that complete this way. A flow that keeps emitting, such as a synchronization stream that listens for server changes, blocks the worker thread until you interrupt it.

### RxJava interop

If your Java code already uses RxJava, convert a `Flow` to a `Flowable` with `RxConvertKt.asFlowable()` from `kotlinx-coroutines-rx3`. It’s a plain static method, so Java can call it directly:

```java

import kotlin.coroutines.EmptyCoroutineContext;
import kotlinx.coroutines.rx3.RxConvertKt;

disposables.add(
    RxConvertKt.asFlowable(textSearch.performSearchAsync(query), EmptyCoroutineContext.INSTANCE).toList().observeOn(AndroidSchedulers.mainThread()).subscribe(this::showResults, this::showError));

```

Suspend functions need a Kotlin call site, because the `rxSingle` and `rxCompletable` builders take a suspending lambda. Put them in a small Kotlin object, and mark each method with `@JvmStatic` so Java can call it like a static method:

```kotlin

object NutrientRx {
    @JvmStatic
    fun getAnnotations(provider: AnnotationProvider, pageIndex: Int): Single<List<Annotation>> =
        rxSingle { provider.getAnnotations(pageIndex) }

    @JvmStatic
    fun addAnnotationToPage(provider: AnnotationProvider, annotation: Annotation): Completable =
        rxCompletable { provider.addAnnotationToPage(annotation) }
}

```

Java then keeps its usual subscription pattern, and disposing the subscription cancels the SDK call:

```java

disposables.add(
    NutrientRx.getAnnotations(document.getAnnotationProvider(), 0).observeOn(AndroidSchedulers.mainThread()).subscribe(this::showAnnotations, this::showError));

```

The SDK no longer brings RxJava into your app. Declare the dependencies yourself:

```kotlin

dependencies {
    implementation("io.reactivex.rxjava3:rxjava:<version>")
    implementation("io.reactivex.rxjava3:rxandroid:<version>")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-rx3:<version>")
}

```

Use a `kotlinx-coroutines-rx3` version that matches the `kotlinx-coroutines-core` version your app resolves. The Catalog’s **RxJava interop** and **Custom Search UI (Views)** examples run these adapters against real SDK APIs.

## Use a Kotlin adapter in larger Java codebases

`runBlocking()` ties up a thread for every call, and RxJava is a large dependency to add only for interop. If many Java classes call the SDK, we recommend adding one small Kotlin file that exposes each SDK call you use as a callback-style method. The coroutine stays inside Kotlin, and Java sees ordinary methods.

Your module needs the lifecycle runtime for `lifecycleScope`, and it must apply the Kotlin Android plugin if it doesn’t already:

```kotlin

dependencies {
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:<version>")
}

```

The adapter launches each call in the `lifecycleScope` of the caller’s activity or fragment. That scope runs on the main thread and is canceled when the lifecycle is destroyed. The SDK moves its own work off the main thread, so the callback runs on the main thread and you can update views directly:

```kotlin

object NutrientAsync {
    /** Receives the result of an SDK call on the main thread. */
    interface Callback<T> {
        fun onSuccess(result: T)

        fun onError(error: Throwable)
    }

    @JvmStatic
    fun getAnnotations(
        owner: LifecycleOwner,
        provider: AnnotationProvider,
        pageIndex: Int,
        callback: Callback<List<Annotation>>,
    ): Job = owner.launchWithCallback(callback) { provider.getAnnotations(pageIndex) }

    @JvmStatic
    fun search(
        owner: LifecycleOwner,
        textSearch: TextSearch,
        query: String,
        callback: Callback<List<SearchResult>>,
    ): Job = owner.launchWithCallback(callback) { textSearch.performSearchAsync(query).toList() }

    private fun <T> LifecycleOwner.launchWithCallback(callback: Callback<T>, block: suspend () -> T): Job =
        lifecycleScope.launch {
            val result = try {
                block()
            } catch (e: CancellationException) {
                // This scope was canceled, for example because the lifecycle ended: end without a callback.
                if (!isActive) throw e
                // The SDK canceled the call itself, for example because the document was closed.
                callback.onError(e)
                return@launch
            } catch (e: Exception) {
                callback.onError(e)
                return@launch
            }
            callback.onSuccess(result)
        }
}

```

The adapter invokes the callback outside the `try` block, so `onError()` doesn’t receive an exception that your callback code throws.

From Java, pass the activity or fragment as the `LifecycleOwner`. In a fragment that shows views, pass `getViewLifecycleOwner()`:

```java

private Job loadJob;

private void loadAnnotations() {
    loadJob = NutrientAsync.getAnnotations(this, document.getAnnotationProvider(), 0,
        new NutrientAsync.Callback<List<Annotation>>() {
            @Override
            public void onSuccess(List<Annotation> annotations) {
                showAnnotations(annotations);
            }

            @Override
            public void onError(Throwable error) {
                showError(error);
            }
        });
}

```

You don’t need to cancel the work when the screen is destroyed, because `lifecycleScope` does it for you, and no callback runs afterward. To cancel earlier — for example, when the user starts a new search — call `loadJob.cancel(null)`. The `null` is the optional cancellation cause, which Java has to pass explicitly.

Most SDK suspend functions move their work off the main thread. A few run on the caller’s thread and say so in their API documentation — for example, `XfdfFormatter.parseXfdfAsync()` and `DocumentJsonFormatter.exportDocumentJson()`. Wrap those in `withContext(Dispatchers.IO) { … }` inside the adapter.

If your Java code works with `ListenableFuture`, `kotlinx-coroutines-guava` provides a `future { … }` builder. Use it in the adapter instead of a callback interface. The library adds Guava to your app, so only use it if you already depend on Guava.

## Threading and cancellation pitfalls

- **Blocking the main thread.** Synchronous overloads, `runBlocking()`, and blocking flow collection all block the calling thread. Call them only on a worker thread. A blocked main thread freezes the UI and causes ANRs.

- **Updating views from a worker thread.** Post worker-thread results to the main thread with `runOnUiThread()` or a main-thread `Handler` before touching views.

- **Results arriving after the screen is gone.** A worker thread keeps running after the user leaves the screen. Shut down your executor in `onDestroy()`, and check `isFinishing()` and `isDestroyed()` before you use the result. The Kotlin adapter avoids this problem, because `lifecycleScope` cancels the work.

- **Swallowing interrupts.** Interrupting a thread that waits in `runBlocking()` — for example, with `executor.shutdownNow()` or `future.cancel(true)` — cancels the SDK call and throws `InterruptedException`. Call `Thread.currentThread().interrupt()` in the `catch` block instead of ignoring the exception.

- **Catching cancellation as an error.** In a Kotlin adapter, rethrow `CancellationException` before catching other exceptions. Otherwise, the adapter reports a canceled call as a failure.

- **Collecting flows that don’t complete.** Blocking collection only returns when the flow completes. Use the RxJava or Kotlin adapter approach for streams that keep emitting.
---

## Related pages

- [Manual Library Integration](/guides/android/advanced-integration/manual-library-integration.md)
- [Nightly Builds](/guides/android/advanced-integration/nightly-builds.md)
- [Permissions](/guides/android/advanced-integration/permissions.md)
- [Subscription Validation](/guides/android/announcements/subscription-validation.md)
- [Unsupported Internal Symbols](/guides/android/announcements/unsupported-internal-symbols.md)
- [Managing data storage and privacy in Android](/guides/android/best-practices/data-storage-and-privacy.md)
- [Customizing The Page Number](/guides/android/customizing-pdf-pages/customizing-the-page-number.md)
- [Check For Compatibility](/guides/android/faq/check-for-compatibility.md)
- [Framework Size](/guides/android/faq/framework-size.md)
- [Android PDF SDK security](/guides/android/faq/sdk-security.md)
- [Remove all Android logging calls that should be ignored in release builds to prevent logs in Logcat.](/guides/android/features/logging.md)
- [About Memory Usage](/guides/android/memory-and-storage/about-memory-usage.md)
- [Opening Local Files](/guides/android/miscellaneous/opening-local-files.md)
- [Optimize Pdf Documents For Mobile Rendering](/guides/android/miscellaneous/optimize-pdf-documents-for-mobile-rendering.md)
- [Powered By Nutrient](/guides/android/miscellaneous/powered-by-nutrient.md)
- [Third Party Compatibility](/guides/android/miscellaneous/third-party-compatibility.md)

