DEV Community

Cover image for Offline-First Challenge: Making CSV & PDF Reports Right on Android
Joseph Sanjaya
Joseph Sanjaya

Posted on • Originally published at Medium

Offline-First Challenge: Making CSV & PDF Reports Right on Android

Usually, when someone asks for a “report feature,” we Android devs breathe easy that’s backend territory. We send the data, wait for a neat PDF or CSV, and move on with our day.

Not this time.

The requirement came with one deceptively simple line: “It has to work fully offline.”

captionless image

No APIs. No internet. No backend wizardry. Everything had to happen locally, right inside the Android app.

“Sometimes the hardest part of an offline app isn’t the lack of network it’s realizing the phone is your backend now.”

What started as a “simple export button” turned into a deep dive through file permissions, document contracts, and Compose rendering quirks. Somewhere between debugging URI access and watching Compose draw on an invisible canvas, I realized this wasn’t just about exporting data it was about rethinking what ‘frontend’ really means.

The Problem: When Servers Step Aside

Most Android apps live in a comfortable ecosystem data comes from APIs, heavy work happens on the server, and we just display the results beautifully.

But take away the server, and everything changes. Suddenly, you’re not just designing UI you’re handling the entire workflow: data collection, structuring, rendering, and writing files to storage.

“The moment your app goes offline, you stop being a client developer you become a system architect.”

Offline-first development forces you to think differently. It’s not just about making things work without the internet it’s about designing an experience that feels complete, even in isolation.

CSV Generation: The Warmup

Before diving into the PDF rabbit hole, there was the warm-up round generating CSV files. Compared to Compose and pagination math, this felt like a vacation.

A CSV is basically structured text with commas. You don’t need fancy rendering or layout logic, just clean data and careful escaping.

“CSV generation is that rare Android task that behaves exactly how you expect it to.”

In Compose, you can kick off file creation with the Storage Access Framework.
This ensures users choose where the CSV goes, while your app writes safely through a provided URI:

val csvLauncher = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.CreateDocument("text/csv"),
    onResult = { uri ->
        if (uri != null) {
            csvWriter.writeCsv(uri, transactions)
        }
    }
)
Enter fullscreen mode Exit fullscreen mode

When triggered (say, from a button click), this opens Android’s file picker so the user can pick the save location.

Next, the writer itself lightweight, dependency-free, and easy to plug into any project:

class CsvWriter(
    private val transactionCsvMapper: TransactionCsvMapper,
    private val context: Context
) {
    fun writeCsv(uri: Uri, transactions: List<ExportedTransaction>) {
        context.contentResolver.openOutputStream(uri)?.use { output ->
            val headers = TransactionCsvMapper.HEADERS
            val rows = transactions.map { transactionCsvMapper.toCsvRow(it) }            BufferedWriter(OutputStreamWriter(output)).use { writer ->
                writer.appendLine(headers.joinToString(","))
                for (row in rows) {
                    writer.appendLine(row.joinToString(",") { escapeCsv(it) })
                }
            }
        }
    }    
private fun escapeCsv(value: String): String {
        val needsQuotes = value.contains(",") || value.contains("\"") || value.contains("\n")
        return if (needsQuotes) "\"${value.replace("\"", "\"\"")}\"" else value
    }
}
Enter fullscreen mode Exit fullscreen mode

That’s all you need. No external libraries, no fragile permissions hacks just clean Kotlin I/O.
When you trigger the launcher, the system handles file creation and your writer takes care of the content.

There’s a certain joy in that simplicity. No layouts, no rendering, no UI thread drama. Just data in, file out.

The Built-in PDF Way

Before we jumped into custom layouts, life was simple.
Android already gives you a handy class called PdfDocumentyou just draw directly onto a Canvas.

Here’s what a minimal version looks like:

val pdfDocument = PdfDocument()
val pageInfo = PdfDocument.PageInfo.Builder(595, 842, 1).create()
val page = pdfDocument.startPage(pageInfo)
val canvas = page.canvas
val paint = Paint().apply { textSize = 16f }
canvas.drawText("Hello, PDF World!", 50f, 50f, paint)
pdfDocument.finishPage(page)
val file = File(context.cacheDir, "report.pdf")
pdfDocument.writeTo(FileOutputStream(file))
pdfDocument.close()
Enter fullscreen mode Exit fullscreen mode

Pros:

  • Runs fully offline.
  • Works fine for static text or simple receipts.
  • Lightweight and dependency-free.

Cons:

  • No layout engine you calculate everything manually.
  • Forget about Compose, dynamic grids, or pretty formatting.

Custom-Designed PDFs with Compose

Okay, now we’re getting into the fun (and slightly painful) part.

When the requirement changed from “just export a PDF” to “make it look beautiful, like a real report”, the built-in PdfDocument wasn’t enough anymore. You can draw text and rectangles but not full layouts, grids, or dynamic tables like Compose gives us.

The dream:

“What if we could use the same Jetpack Compose UI that powers our app screen, and print that as a PDF?”

That’s exactly what we did.
But to make it work, we first needed to define a precise page specification because print layouts aren’t like pixels on a phone screen.

Step 1: Defining the Page Specs

Printing uses typographic points instead of pixels, and your layout must scale correctly for real-world paper sizes like A4 or Letter.
So we start by defining a PageSpec and a PdfPageConfig to handle this conversion neatly.

enum class PageSpec(val widthPoints: Double, val heightPoints: Double) {
    A4(595.2755905511812, 841.8897637795277),
    LETTER(612.0, 792.0);
    fun toPx(dpi: Int): Pair<Int, Int> {
        val scale = dpi.toDouble() / 72.0
        val widthPx = round(widthPoints * scale).toInt()
        val heightPx = round(heightPoints * scale).toInt()
        return Pair(widthPx, heightPx)
    }
    companion object {
        fun fromLocale(locale: Locale): PageSpec {
            return when (locale.country.uppercase(Locale.ROOT)) {
                "US", "CA", "MX" -> LETTER
                else -> A4
            }
        }
    }
}
data class PdfPageConfig(
    val pageSpec: PageSpec = PageSpec.fromLocale(Locale.getDefault()),
    val dpi: Int = 300
) {
    fun getSizePx() = pageSpec.toPx(dpi)
}
Enter fullscreen mode Exit fullscreen mode

This lets us dynamically adapt to paper type, locale, and print DPI without hardcoding anything.
It also ensures that when Compose renders off-screen, our elements will line up perfectly when printed.

Step 2: Building the Composable Page

The key trick here is when rendering to PDF, your Composable must match exactly the size of a printed page no scrolling, no “infinite height”.

That’s where Modifier.requiredSize() comes in.
Here’s how our export composable looks:

@Composable
fun ExportTxPdf(
    fileName: String,
    transactions: PersistentList<ExportedTransaction>,
    pageIndex: Int,
    totalPages: Int,
    pdfPageConfig: PdfPageConfig
    modifier: Modifier = Modifier
) {
    val tableSpec = remember { ExportTxTableSpec.default() }
    val (pageWidthPx, pageHeightPx) = pdfPageConfig.getSizePx()
    val density = LocalDensity.current
    val pageWidthDp = with(density) { pageWidthPx.toDp() }
    val pageHeightDp = with(density) { pageHeightPx.toDp() }    
    Column(
        modifier = modifier
            .requiredSize(pageWidthDp, pageHeightDp)
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        ExportTxHeader(tableSpec)
        transactions.forEachIndexed { index, transaction ->
            val color = if (index % 2 == 0) Color(0xFFE9E6F7) else Color.White
            ExportTxTableItem(tableSpec, transaction, color = color)
        }
        Spacer(modifier = Modifier.weight(1f))
        ExportTxFooter(fileName, pageIndex, totalPages)
    }
}
Enter fullscreen mode Exit fullscreen mode

Each page is rendered like a real paper same width, same height, all inside a Compose layout.

No guesswork, no scaling errors.

💡 Pro tip (quote block idea):_
“When you treat your PDF page as a Compose layout, you stop thinking in pixels you start designing paper.”_

Step 3: Printing the Composables to PDF

Once each Composable page is ready, we still need to print it into a real PDF document.
That means rendering the Compose view to a Bitmap, then drawing it onto the PdfDocument page canvas.

Here’s the helper extension we use for that:

private fun List<View>.saveAsPdf(outputStream: OutputStream) {
    if (isEmpty()) return
    val dpi = 300
    val pageSpec = PageSpec.A4
    val (pageWidthPx, pageHeightPx) = pageSpec.toPx(dpi)
    val document = PdfDocument()
    forEachIndexed { index, view ->
        val widthSpec = View.MeasureSpec.makeMeasureSpec(pageWidthPx, View.MeasureSpec.EXACTLY)
        val heightSpec = View.MeasureSpec.makeMeasureSpec(pageHeightPx, View.MeasureSpec.EXACTLY)
        view.measure(widthSpec, heightSpec)
        view.layout(0, 0, pageWidthPx, pageHeightPx)
        val bitmap = createBitmap(pageWidthPx, pageHeightPx)
        val canvas = Canvas(bitmap)
        view.draw(canvas)
        val pageInfo = PdfDocument.PageInfo.Builder(pageWidthPx, pageHeightPx, index + 1).create()
        val page = document.startPage(pageInfo)
        page.canvas.drawBitmap(bitmap, 0f, 0f, null)
        document.finishPage(page)
        bitmap.recycle()
    }
    document.writeTo(outputStream)
    document.close()
}
Enter fullscreen mode Exit fullscreen mode

This method converts a list of offscreen-rendered Compose views into a print-ready, paginated PDF all offline, all on device.

Now here’s the catch: Compose rendering must happen on the main thread. You can’t just call it inside a background coroutine like your CSV export it’ll crash or hang.
So, you need to offload heavy work like bitmap compression or file writing to Dispatchers.IO, but keep the Compose snapshot creation on the UI thread.

🏁 Wrapping It All Up

All this work landed as a new feature in open-source app (Brain Wallet) that I actively maintained, now you can export transactions directly to CSV or beautifully styled PDFs.

Check it out here:
👉 GitHub —gruntsoftware/android: The open source code of Brainwallet Android

Download Here:
👉Brainwallet®: Buy Litecoin — Apps on Google Play

🌱 Takeaways

To wrap it up neatly:

  • ✅ Compose can render beautiful PDFs but it must run on the UI thread.
  • 📐 Accurate page specs (points → pixels → Dp) are key for consistent layouts.
  • 🧮 Handling pagination and page size manually is unavoidable but rewarding.
  • 💾 CSV export is the easy win but custom PDFs are where the real learning begins.
  • 🚀 And yes, you can now build full offline report systems entirely on Android.

📚 Originally published on Medium.

💬 I also share free mentoring sessions on ADPList.

🚀 Let’s connect and talk about Android, Kotlin, and building great systems together!

Top comments (0)