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:
- Capture user annotations (such as signatures).
- Flatten them into the PDF (making them permanent).
- 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
- Flatten annotations — Merge signatures/annotations into the PDF content.
- Process asynchronously — Avoid user interface (UI) freezes during PDF processing.
- 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:
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:
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:
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:
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 —
ProcessorProgressprovides real-time updates on processing status.
Best practices
We recommend the following best practices when implementing this solution.
Error handling
Process PdfProcessorException separately:
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:
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:
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:
try { uploadToServer(outputFile)} finally { // Ensure file is deleted even if upload fails. outputFile.delete()}Encrypt PDFs during upload for sensitive documents:
// 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:
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:
val progressDialog = ProgressDialog(context).apply { setMessage("Processing PDF...") setCancelable(true) setOnCancelListener { // Dispose of RxJava subscription. processingDisposable?.dispose() } show()}Monitor processing time for large documents:
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"inAndroidManifest.xml.
Upload fails with timeout
- Increase OkHttp timeout settings.
- Compress PDFs before upload using
DocumentSaveOptions.setRewriteAndOptimizeFileSize(true). - Consider chunked upload for large files.
Annotations not visible after upload
- Verify flattening completed successfully.
- Check that
AnnotationProcessingMode.FLATTEN(notKEEP) is used. - Test the uploaded PDF in multiple PDF readers.
Related resources
- Flatten annotations guide — Detailed annotation flattening options
- Save to remote server — Upload PDFs without flattening
PdfProcessorAPI reference — Complete processor documentation- Android file upload best practices(opens in a new tab) — Google’s file upload guidelines