This HTML page is not optimized for LLM or AI agent consumption. Fetch the Markdown version instead: /guides/android/best-practices/calling-async-apis-from-java.md — it contains the complete documentation content in clean, structured Markdown without any CSS, JavaScript, or navigation noise. Calling asynchronous APIs from Java on Android | Nutrient

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 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:

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:

APIKind
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 APISynchronous 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:

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() 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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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():

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.