Save signed PDFs directly to a remote server on Android

Many apps enable users to add annotations to PDFs (for example, signatures on contracts, stamps on delivery confirmations) and save the modified version directly to a remote server without storing files locally.

This guide explains how to:

  1. Capture user annotations (such as signatures).
  2. Flatten them into the PDF (making them permanent).
  3. Securely upload the modified PDF to your server.

Key challenge

By default, annotations exist as separate layers. Without flattening, uploaded PDFs may appear unsigned or missing annotations when viewed in other PDF readers.

Solution overview

  1. Flatten annotations — Merge signatures/annotations into the PDF content.
  2. Process asynchronously — Avoid user interface (UI) freezes during PDF processing.
  3. Upload from cache — Temporarily save the modified PDF to the app’s cache (not device storage).

Implementation

Follow the steps mentioned below to implement the solution.

Step 1: Flatten annotations

Use PdfProcessorTask to flatten annotations into the PDF:

Kotlin
val task = PdfProcessorTask
.fromDocument(document)
.changeAllAnnotations(PdfProcessorTask.AnnotationProcessingMode.FLATTEN)

Step 2: Process and save to cache

Generate the flattened PDF in the app’s cache directory with progress tracking:

Kotlin
val outputFile = File(context.cacheDir, "signed.pdf")
PdfProcessor.processDocumentAsync(task, outputFile)
.onBackpressureDrop() // Handle Flowable backpressure.
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ progress ->
// Track progress during processing.
val percentComplete = (progress.pagesProcessed * 100) / progress.totalPages
updateProgressIndicator(percentComplete)
},
{ error ->
// Handle processing errors.
Log.e(TAG, "PDF processing failed", error)
when (error) {
is PdfProcessorException -> {
showErrorMessage("Failed to process PDF: ${error.message}")
}
else -> {
showErrorMessage("Unexpected error: ${error.message}")
}
}
},
{
// Upload to server after processing completes.
lifecycleScope.launch(Dispatchers.IO) {
try {
uploadToServer(outputFile)
withContext(Dispatchers.Main) {
showSuccessMessage()
}
} catch (e: IOException) {
withContext(Dispatchers.Main) {
showErrorMessage("Upload failed: ${e.message}")
}
} finally {
outputFile.delete() // Clean up cache file
}
}
}
)

Step 3: Upload to server

Implement your server upload logic. Below is an example using Retrofit:

Kotlin
private suspend fun uploadToServer(file: File) {
val requestBody = file.asRequestBody("application/pdf".toMediaType())
val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
val response = yourApiService.uploadSignedPdf(part)
if (!response.isSuccessful) {
throw IOException("Upload failed with code: ${response.code()}")
}
}

Alternative: Synchronous processing with Kotlin coroutines

Alternatively, you can use synchronous processing within a coroutine:

Kotlin
lifecycleScope.launch(Dispatchers.IO) {
val outputFile = File(context.cacheDir, "signed.pdf")
try {
// Process synchronously on IO thread.
PdfProcessor.processDocument(task, outputFile)
// Upload to server.
uploadToServer(outputFile)
withContext(Dispatchers.Main) {
showSuccessMessage()
}
} catch (e: PdfProcessorException) {
withContext(Dispatchers.Main) {
showErrorMessage("Processing failed: ${e.message}")
}
} catch (e: IOException) {
withContext(Dispatchers.Main) {
showErrorMessage("Upload failed: ${e.message}")
}
} finally {
outputFile.delete()
}
}

Why the solution works

  • Flattening — Converts annotations (for example, signatures, stamps) into permanent PDF content that’s visible in all PDF readers.
  • Cache directory — Avoids permanent local storage; files are temporary and auto-cleared by the system.
  • Async workflow — Uses RxJava Flowable with backpressure handling to prevent UI blocking during processing/upload.
  • Progress tracking — ProcessorProgress provides real-time updates on processing status.

Best practices

We recommend the following best practices when implementing this solution.

Error handling

Process PdfProcessorException separately:

Kotlin
when (error) {
is PdfProcessorException -> {
// PDF processing specific error.
Log.e(TAG, "Processing error: ${error.detailedMessage}")
}
is IOException -> {
// Network or file I/O error.
Log.e(TAG, "I/O error: ${error.message}")
}
}

Implement retry logic with exponential backoff:

Kotlin
suspend fun uploadWithRetry(file: File, maxRetries: Int = 3) {
var delay = 1000L
repeat(maxRetries) { attempt ->
try {
uploadToServer(file)
return // Success.
} catch (e: IOException) {
if (attempt == maxRetries - 1) throw e
delay(delay)
delay *= 2 // Exponential backoff.
}
}
}

Validate server responses:

Kotlin
val response = yourApiService.uploadSignedPdf(part)
when {
response.isSuccessful -> {
Log.d(TAG, "Upload successful")
}
response.code() == 413 -> {
throw IOException("File too large for server")
}
response.code() >= 500 -> {
throw IOException("Server error: ${response.code()}")
}
else -> {
throw IOException("Upload failed: ${response.code()}")
}
}

Security

Always delete cached files after upload:

Kotlin
try {
uploadToServer(outputFile)
} finally {
// Ensure file is deleted even if upload fails.
outputFile.delete()
}

Encrypt PDFs during upload for sensitive documents:

Kotlin
// Use HTTPS endpoints (enforced by default in Android 9+).
interface ApiService {
@Multipart
@POST("https://secure.example.com/upload")
suspend fun uploadSignedPdf(@Part file: MultipartBody.Part): Response<ResponseBody>
}

Verify SSL certificates:

Kotlin
val client = OkHttpClient.Builder()
.certificatePinner(CertificatePinner.Builder()
.add("example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.build())
.build()

Performance

Use backpressure handling. The .onBackpressureDrop() operator prevents overwhelming the UI thread with progress updates for large PDFs.

Show cancellable progress dialog:

Kotlin
val progressDialog = ProgressDialog(context).apply {
setMessage("Processing PDF...")
setCancelable(true)
setOnCancelListener {
// Dispose of RxJava subscription.
processingDisposable?.dispose()
}
show()
}

Monitor processing time for large documents:

Kotlin
val startTime = System.currentTimeMillis()
PdfProcessor.processDocumentAsync(task, outputFile)
.subscribe(
{ /* progress */ },
{ /* error */ },
{
val duration = System.currentTimeMillis() - startTime
Log.d(TAG, "Processing completed in ${duration}ms")
}
)

Troubleshooting

OutOfMemoryError during processing

  • Process fewer pages at a time using PdfProcessorTask.newPage.
  • Reduce image quality before flattening.
  • Enable android:largeHeap="true" in AndroidManifest.xml.

Upload fails with timeout

Annotations not visible after upload

  • Verify flattening completed successfully.
  • Check that AnnotationProcessingMode.FLATTEN (not KEEP) is used.
  • Test the uploaded PDF in multiple PDF readers.