
Prequisite
Many modern Android applications include a file reader feature to display important documents inside the app. This helps users access information without downloading files separately. For example, banking apps often show monthly e-statements, while other platforms display terms and conditions or agreements such as a memorandum of understanding (MoU). By integrating a reader directly into the application, developers can provide a smoother and more secure user experience.
Files used in Android apps can appear in several formats depending on the purpose of the document. Common formats include .doc, .xlsx, .txt, and especially .pdf. Among these formats, PDF is widely preferred because it preserves layout, fonts, and formatting across different devices. This consistency makes it suitable for official documents, reports, and agreements that must appear the same for every user.
PDF files themselves can be distributed in two different forms. Some PDFs are open and can be viewed directly without restrictions. Others are protected with a password, which requires users to enter the correct credentials before accessing the content. Password-protected PDFs are commonly used when the document contains sensitive information or private records.
However, in certain industries such as finance, education, or cryptocurrency platforms, even a standard PDF file may not be secure enough to deliver directly. For confidentiality and security reasons, the document might be encrypted first. Instead of sending the file in a normal .pdf format, the system converts the PDF into a Base64-encoded string. The application then decodes the Base64 data back into a readable PDF when the user opens it, helping protect sensitive information during transmission.
In this session I’m going to show you step-by-step how to build Base64 Native PDF reader in Jetpack Compose.
This library was inspired Pdf Box with some modifications.
Getting Started
- Download Pdf Box to your local repository and unzip it.
- Open Android Studio and create new empty activity (compose) project.
- Since we’re going to build this module as a library, start by creating a new library module. In Android Studio, go to File > New > New Module, then choose Android Library.
- We are going to separate into 3 module (so you should add 3 new modules): NativePdfLoader, PdfHelper, and PdfDecryptor. The structure should look like below:
NativePdfLoader: This module is intended for loading the Composable UI used to display PDF content.
PdfHelper: This module provides general extension helper functions for working with PDFs, such as loading files and retrieving decrypted documents.
PdfDecryptor: This module acts as the core component responsible for handling the PDF decryption process.
parent_project/
├── .gradle
├── app/
│ ├── src
│ └── build.gradle.kts
├── build
├── gradle
├── nativePdfLoader/
│ ├── src
│ └── build.gradle.kts
├── pdfDecryptor/
│ ├── src
│ └── build.gradle.kts
├── pdfHelper/
│ ├── src
│ └── build.gradle.kts
├── .gitattributes
├── .gitignore
└── build.gradle.kts
5. Open Downloaded Pdf Box project and copy entire library folder to your PdfDecryptor module.
Part 1: PdfDecryptor Module
- open your build.gradle.kts. Find bouncycastle gradle and replace from gemalto.jp2 to OpenCV:
dependencies {
// implement bouncycastle
api("org.bouncycastle:bcprov-jdk15to18:1.73")
api("org.bouncycastle:bcpkix-jdk15to18:1.73")
api("org.bouncycastle:bcutil-jdk15to18:1.73")
// replace gemalto.jp2 to opencv
// implementation("com.gemalto.jp2:jp2-android:1.0.3")
implementation("org.opencv:opencv:4.9.0")
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
.
.
}
We are using Bouncy Castle, An open-source Java cryptography library that extends Java’s built-in security capabilities widely used in PDF processing, SSL, encryption, and digital signatures. This is used in PDF for Encrypted content, Digital signatures, Password protection, and Certificate validation.
bcprov-jdk15to18 — Core Provider
bcpkix-jdk15to18 — PKI & Certificate
bcutil-jdk15to18 — Utilities
We replaced com.gemalto.jp2:jp2-android:1.0.3 with org.opencv:opencv:4.9.0, as the former was last updated on April 29, 2021 and is now considered deprecated. Additionally, it is distributed under a proprietary license, which limits flexibility for long-term maintenance and integration.
We are using OpenCV (Open Source Computer Vision Library), open-source library for image processing. We need this because PDF contents sometime can contains image files with JPEG2000 and TIFF format. This format cannot be decoded natively in Android like JPEG, PNG, BMP, or WebP.
2. Open JPXFilter.java file and subtitute these codes:
private Bitmap readJPX(InputStream input, DecodeOptions options, DecodeResult result) throws IOException
{
.
.
JP2Decoder decoder = new JP2Decoder(input);
.
.
}
@Override
protected void encode(InputStream input, OutputStream encoded, COSDictionary parameters)
throws IOException
{
Bitmap bitmap = BitmapFactory.decodeStream(input);
byte[] jpeBytes = new JP2Encoder(bitmap).encode();
IOUtils.copy(new ByteArrayInputStream(jpeBytes), encoded);
encoded.flush();
}
to these codes (Open CV version):
private Bitmap readJPX(InputStream input, DecodeOptions options, DecodeResult result) throws IOException
{
.
.
Bitmap image = decodeJP2(input);
.
.
}
public Bitmap decodeJP2(InputStream inputStream) {
try {
// 1. convert InputStream to ByteArray
byte[] byteArray = new byte[inputStream.available()];
inputStream.read(byteArray);
// 2. convert ByteArray to MatOfByte
MatOfByte matOfByte = new MatOfByte(byteArray);
// 3. decode JP2 from MatOfByte
Mat decodedMat = Imgcodecs.imdecode(matOfByte, Imgcodecs.IMREAD_COLOR);
// 4. check if decoding was successful
if (decodedMat.empty()) return null;
// 5. convert Mat to Bitmap
Bitmap bitmap = Bitmap.createBitmap(
decodedMat.cols(),
decodedMat.rows(),
Bitmap.Config.ARGB_8888
);
Utils.matToBitmap(decodedMat, bitmap);
// 6. release memory
matOfByte.release();
decodedMat.release();
inputStream.close();
return bitmap;
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
@Override
protected void encode(InputStream input, OutputStream encoded, COSDictionary parameters)
throws IOException
{
// 1. convert InputStream to ByteArray
byte[] byteArray = new byte[input.available()];
input.read(byteArray);
// 2. convert ByteArray to MatOfByte
MatOfByte matOfByte = new MatOfByte(byteArray);
// 3. decode image from MatOfByte
Mat decodedMat = Imgcodecs.imdecode(matOfByte, Imgcodecs.IMREAD_COLOR);
// 4. encode Mat to JP2 format
MatOfByte encodedMatOfByte = new MatOfByte();
MatOfInt params = new MatOfInt(); // no extra params needed
Imgcodecs.imencode(".jp2", decodedMat, encodedMatOfByte, params);
// 5. convert encoded MatOfByte to ByteArray
byte[] jp2Bytes = encodedMatOfByte.toArray();
// 6. write to OutputStream
IOUtils.copy(new ByteArrayInputStream(jp2Bytes), encoded);
encoded.flush();
// 7. release memory
matOfByte.release();
decodedMat.release();
encodedMatOfByte.release();
params.release();
}
Part 2: PdfHelper Module
- Open build.gradle.kts and implement module pdfDecryptor
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
// implement :pdfDecryptor
implementation(project(":pdfDecryptor"))
}
2. Create PdfViewHelper.kt file and add these codes:
class PdfViewHelper(val context: Context) {
private var pdfUriEncrypted: Uri? = null
private var pdfFile: File? = null
private var pdfFilename: String = ""
var onShowPasswordDialog: (pdfByteArray: ByteArray) -> Unit = {}
var onSuccess: (listBitmap: List<Bitmap>) -> Unit = {}
var onError: (e: Exception) -> Unit = {}
fun getPDFUri(): Uri? {
return pdfUriEncrypted
}
fun clearCache() {
try {
pdfFile?.let { if (it.exists()) it.delete() }
pdfFile = null
pdfUriEncrypted = null
} catch (e: Exception) {
// println("Failed to clear pdf cache: ${e.message}")
}
}
fun loadPdfFromBase64(
base64String: String,
filename: String,
applicationId: String
) {
if (base64String.isEmpty() || filename.isEmpty()) return
pdfFilename = filename
CoroutineScope(Dispatchers.IO).launch {
val pdfByteArray = Base64.decode(base64String, Base64.DEFAULT)
pdfFile = File(context.cacheDir, pdfFilename)
pdfFile?.let {
FileOutputStream(it).use { it.write(pdfByteArray) }
pdfUriEncrypted = FileProvider.getUriForFile(
context,
applicationId,
it
)
if (isPdfEncrypted(pdfByteArray)) {
withContext(Dispatchers.Main) {
onShowPasswordDialog(pdfByteArray)
}
} else {
val listBitmap = convertPdfFileToBitmaps(it)
withContext(Dispatchers.Main) {
onSuccess(listBitmap)
}
}
}
}
}
fun decryptPdfFile(
pdfBytes: ByteArray,
password: String
) {
CoroutineScope(Dispatchers.Main).launch {
try {
val doc: PDDocument = PDDocument.load(pdfBytes, password)
if (doc.isEncrypted) {
doc.isAllSecurityToBeRemoved = true
}
val decryptedFile = File(context.cacheDir, pdfFilename)
doc.save(decryptedFile)
doc.close()
// println("PDF Decrypt result: PDF decrypted successfully")
val listBitmap = convertPdfFileToBitmaps(decryptedFile)
onSuccess(listBitmap)
} catch (e: IOException) {
e.printStackTrace()
// println("PDF IOException result: Error => ${e.message}")
onError(e)
} catch (e: Exception) {
e.printStackTrace()
// println("PDF General result: Error => ${e.message}")
onError(e)
}
}
}
private fun isPdfEncrypted(pdfBytes: ByteArray): Boolean {
return try {
val document: PDDocument = PDDocument.load(pdfBytes, "")
val isFileEncrypted = document.isEncrypted
document.close()
isFileEncrypted
} catch (e: Exception) {
true
}
}
private fun convertPdfFileToBitmaps(pdfFile: File): List<Bitmap> {
val bitmaps = mutableListOf<Bitmap>()
var fd: ParcelFileDescriptor? = null
var renderer: PdfRenderer? = null
try {
fd = ParcelFileDescriptor.open(pdfFile, ParcelFileDescriptor.MODE_READ_ONLY)
renderer = PdfRenderer(fd)
for (i in 0 until renderer.pageCount) {
renderer.openPage(i).use { page ->
val bitmap = createBitmap(page.width, page.height)
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
bitmaps.add(bitmap)
}
}
} catch (e: Exception) {
e.printStackTrace()
onError(e)
} finally {
try {
renderer?.close()
fd?.close()
} catch (e: Exception) {
onError(e)
}
}
return bitmaps
}
}
Process Flow will be:
Retrieve Base64 string ➠ Decode Base64 into ByteArray ➠ Check whether the PDF requires a password ➠ If password-protected, display a password input dialog ➠ Decrypt the ByteArray into a PDF file (password optional) ➠ Save the file as a temporary file ➠ Convert the PDF file into a list of Bitmap objects for rendering.
Checking PDF is passworded or not through this function
private fun isPdfEncrypted(pdfBytes: ByteArray): Boolean {
return try {
val document: PDDocument = PDDocument.load(pdfBytes, "")
val isFileEncrypted = document.isEncrypted
document.close()
isFileEncrypted
} catch (e: Exception) {
true
}
}
Part 3: NativePdfLoader Module
- Open build.gradle.kts and implement module PdfHelper
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
.
.
// implement :pdfDecryptor
implementation(project(":pdfHelper"))
}
2. First, create a new package folder named pdfLoader. Inside this package, create four Kotlin files: PdfLoader.kt, Ext.kt, ZoomableLayout.kt, and ZoomableImage.kt.
The project structure should look like this:
nativePdfLoader/
├── manifests
├── kotlin+java/
│ ├── com
│ | └── example
│ | └── project
│ | └── pdfLoader
│ | └── Ext.kt
│ | └── PdfLoader.kt
│ | └── ZoomableLayout.kt
│ | └── ZoomableImage.kt
│ └── com(androidTest)
│ └── com(test)
└── res
3. In Ext.kt add this code:
fun String.toComposeColor(): Color {
return Color(this.toColorInt())
}
4. In ZoomableImage.kt add this code:
@Composable
fun ZoomableImage(bitmap: Bitmap) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.clipToBounds(),
contentScale = ContentScale.FillWidth
)
}
5. In ZoomableLayout.kt add this code:
@Composable
fun ZoomableLayout(listBitmap: List<Bitmap>) {
val coroutineScope = rememberCoroutineScope()
val scale = remember { Animatable(1f) }
val offsetX = remember { Animatable(0f) }
val offsetY = remember { Animatable(0f) }
val transformState = rememberTransformableState { zoomChange, offsetChange, _ ->
val newScale = (scale.value * zoomChange).coerceIn(1f, 5f)
coroutineScope.launch {
scale.snapTo(newScale)
if (newScale == 1f) {
launch { offsetX.animateTo(0f, animationSpec = spring()) }
launch { offsetY.animateTo(0f, animationSpec = spring()) }
} else {
launch { offsetX.snapTo(offsetX.value + offsetChange.x) }
launch { offsetY.snapTo(offsetY.value + offsetChange.y) }
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.clipToBounds()
.graphicsLayer(
scaleX = scale.value,
scaleY = scale.value,
translationX = offsetX.value,
translationY = offsetY.value,
clip = true
)
.transformable(
state = transformState,
lockRotationOnZoomPan = true,
canPan = { scale.value > 1f }
)
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
coroutineScope.launch {
launch {
scale.animateTo(
1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
}
launch {
offsetX.animateTo(
0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
}
launch {
offsetY.animateTo(
0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
}
}
}
)
}
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(count = listBitmap.size) { i ->
ZoomableImage(bitmap = listBitmap[i])
}
}
}
}
Basically, we want to display the content using a LazyColumn while enabling pinch-to-zoom behavior from its parent container. The zoom interaction will control the overall scale of the content, allowing users to zoom in and out smoothly.
To achieve this, we animate the scale value for zooming in and out. At the same time, we manage offsetX and offsetY to handle dragging when the zoom is active. This allows users to move around the zoomed content naturally while maintaining a smooth and responsive interaction experience.
val scale = remember { Animatable(1f) }
val offsetX = remember { Animatable(0f) }
val offsetY = remember { Animatable(0f) }
to handle pinch-to-zoom and pan gestures on an parent container, we will use this code:
val transformState = rememberTransformableState { zoomChange, offsetChange, _ ->
val newScale = (scale.value * zoomChange).coerceIn(1f, 5f)
coroutineScope.launch {
scale.snapTo(newScale)
if (newScale == 1f) {
//Reset Position (normal/scale = 1), using animateTo to feel smoothly animated
launch { offsetX.animateTo(0f, animationSpec = spring()) }
launch { offsetY.animateTo(0f, animationSpec = spring()) }
} else {
//Zoom in/out Position, using snapTo to feel instant responsive
launch { offsetX.snapTo(offsetX.value + offsetChange.x) }
launch { offsetY.snapTo(offsetY.value + offsetChange.y) }
}
}
}
Add transformable to detects gestures (user input) and add graphicsLayer to applies visual changes (output display)
6. In PdfLoader.kt add this code:
@Composable
fun NativePdfCompose(
pdfBase64: String = "",
pdfFilename: String = "",
dialogPasswordTitle: String = "",
dialogPasswordInputText: String = "",
dialogPasswordPositiveButton: String = "",
dialogPasswordPositiveTextColor: String = "",
dialogPasswordPositiveBgColor: String = "",
dialogPasswordNegativeButton: String = "",
dialogPasswordNegativeTextColor: String = "",
dialogPasswordNegativeBgColor: String = "",
dialogErrorText: String = "",
onError: (Exception) -> Unit = {},
onDismissDialog: () -> Unit = {}
) {
var pdfBytes by remember { mutableStateOf<ByteArray?>(null) }
var listOfBitmap by remember { mutableStateOf<List<Bitmap>>(listOf()) }
var showPasswordDialog by remember { mutableStateOf(true) }
var isWrongPassword by remember { mutableStateOf(false) }
val pdfFilename = pdfFilename.ifEmpty { "${System.currentTimeMillis()}.pdf" }
val context = LocalContext.current
val pdfViewHelper = remember { PdfViewHelper(context) }
pdfViewHelper.onShowPasswordDialog = { pdfByteArray ->
pdfBytes = pdfByteArray
}
pdfViewHelper.onSuccess = { listBitmap ->
listOfBitmap = listBitmap
isWrongPassword = false
showPasswordDialog = false
}
pdfViewHelper.onError = { e ->
onError(e)
isWrongPassword = true
}
pdfViewHelper.loadPdfFromBase64(
pdfBase64,
pdfFilename,
LocalContext.current.packageName
)
Box(modifier = Modifier.fillMaxSize().background(color = Color.White)) {
RenderAsImageBitmap(listOfBitmap)
pdfBytes?.let { bytes ->
if (showPasswordDialog) {
ShowInputPasswordDialog(
isWrongPassword,
dialogPasswordTitle,
dialogPasswordInputText,
dialogPasswordPositiveButton,
dialogPasswordPositiveTextColor,
dialogPasswordPositiveBgColor,
dialogPasswordNegativeButton,
dialogPasswordNegativeTextColor,
dialogPasswordNegativeBgColor,
dialogErrorText,
onDismiss = {
showPasswordDialog = false
onDismissDialog()
},
onSubmit = { password ->
pdfViewHelper.decryptPdfFile(bytes, password)
},
onValueChange = {
isWrongPassword = false
}
)
}
}
}
}
@Composable
private fun RenderAsImageBitmap(listBitmap: List<Bitmap>) {
ZoomableLayout(listBitmap)
}
@Composable
private fun ShowInputPasswordDialog(
isWrongPassword: Boolean,
dialogPasswordTitle: String = "",
dialogPasswordInputText: String = "",
dialogPasswordPositiveButton: String = "",
dialogPasswordPositiveTextColor: String = "",
dialogPasswordPositiveBgColor: String = "",
dialogPasswordNegativeButton: String = "",
dialogPasswordNegativeTextColor: String = "",
dialogPasswordNegativeBgColor: String = "",
dialogErrorText: String = "",
onValueChange: () -> Unit = {},
onDismiss: () -> Unit,
onSubmit: (String) -> Unit
) {
var password by remember { mutableStateOf("") }
val title = dialogPasswordTitle.ifEmpty { "Enter PDF Password" }
val inputText = dialogPasswordInputText.ifEmpty { "Input password here" }
val positiveButtonText = dialogPasswordPositiveButton.ifEmpty { "Submit" }
val negativeButtonText = dialogPasswordNegativeButton.ifEmpty { "Close" }
val errorText = dialogErrorText.ifEmpty { "Incorrect password" }
val positiveTextColor = if (dialogPasswordPositiveTextColor.isEmpty()) Color.Black else dialogPasswordPositiveTextColor.toComposeColor()
val negativeTextColor = if (dialogPasswordNegativeTextColor.isEmpty()) Color.Black else dialogPasswordNegativeTextColor.toComposeColor()
val positiveBgColor = if (dialogPasswordPositiveBgColor.isEmpty()) Color.Gray else dialogPasswordPositiveBgColor.toComposeColor()
val negativeBgColor = if (dialogPasswordNegativeBgColor.isEmpty()) Color.White else dialogPasswordNegativeBgColor.toComposeColor()
AlertDialog(
containerColor = Color.White,
onDismissRequest = { },
title = { Text(title, fontSize = 18.sp) },
text = {
Column {
OutlinedTextField(
value = password,
onValueChange = {
password = it
onValueChange()
},
label = { Text(inputText) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = if (isWrongPassword) Color.Red else Color.Black,
focusedLabelColor = if (isWrongPassword) Color.Red else Color.Black,
focusedTextColor = Color.Black,
focusedLeadingIconColor = Color.Black,
focusedTrailingIconColor = Color.Black,
unfocusedBorderColor = if (isWrongPassword) Color.Red else Color.Gray,
unfocusedLabelColor = Color.Gray,
unfocusedTextColor = Color.Black,
cursorColor = Color.Black
)
)
if (isWrongPassword) Text(errorText, color = Color.Red,
modifier = Modifier.padding(0.dp, 10.dp))
}
},
confirmButton = {
Button(
onClick = { onSubmit(password) },
colors = ButtonDefaults.buttonColors(
containerColor = positiveBgColor,
contentColor = positiveTextColor
),
) {
Text(positiveButtonText)
}
},
dismissButton = {
Button(
onClick = { onDismiss() },
colors = ButtonDefaults.buttonColors(
containerColor = negativeBgColor,
contentColor = negativeTextColor
),
) {
Text(negativeButtonText)
}
}
)
}
7. Open AndroidManifest.xml and add this code:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
</application>
</manifest>
This code provides support for accessing files through a cache file provider.
Final Part: App Module (Main Project)
- Open build.gradle.kts and add this code:
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
.
.
// implement :nativePdfLoader
implementation(project(":nativePdfLoader"))
}
2. Open MainActivity.kt and you can directly use this PDF Loader
@Composable
fun Sample(modifier: Modifier) {
val pdfStr = "YOUR_BASE64_STRING_HERE"
Box(modifier) {
NativePdfCompose(
pdfBase64 = pdfStr,
pdfFilename = "YOUR_PDF_NAME" //optional
)
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
NativePdfTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { paddingValues ->
Sample(modifier = Modifier.padding(paddingValues))
}
}
}
}
}
And that is the end. Result will look like below (demo video provided)
https://youtube.com/shorts/TRkxKmAJQ9M?feature=share
Happy Coding! :)
🔥 For full source code you can find on my Github
Building Base64 Native Passworded PDF Reader in Jetpack Compose was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.




This Post Has 0 Comments