Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ee00650
add common layout components to render
andreia-ferreira Jun 2, 2026
7b27c49
update QrCodeGenerator to provide bitmap+logo for PDF documents
andreia-ferreira Jun 2, 2026
87d51a4
add android implementations for PDF platform interfaces
andreia-ferreira Jun 2, 2026
dcbca6f
add unit tests for PdfCursor and PdfPageController
andreia-ferreira Jun 2, 2026
5a163df
extract common logic for the footer reserve and table building
andreia-ferreira Jun 3, 2026
90a1d81
fix code formatting
andreia-ferreira Jun 9, 2026
b4b1fa8
improve test coverage
andreia-ferreira Jun 9, 2026
3772ba2
add tests for PdfWriter pagination
andreia-ferreira Jun 9, 2026
0eb9444
add logging for AndroidPdfImageProvider
andreia-ferreira Jun 9, 2026
d7bd17c
add todo comment about iOS support for the implementations
andreia-ferreira Jun 9, 2026
4762a2a
simplify AndroidPdfImageProvider
andreia-ferreira Jun 9, 2026
5930d55
Merge branch 'master' into andreia/3739/pdf-report-layout
shobhitagarwal1612 Jun 10, 2026
86bab5b
add unit tests for PdfWriter; AndroidPdfOutputProvider; PdfImageSet a…
andreia-ferreira Jun 10, 2026
8d5137b
fix code style check
andreia-ferreira Jun 10, 2026
b7d18a6
improve AndroidPdfImageProviderTest; add tests for DocumentPdfCanvas …
andreia-ferreira Jun 10, 2026
ef99661
Merge branch 'master' into andreia/3739/pdf-report-layout
andreia-ferreira Jun 11, 2026
a7d119e
update AndroidPdfImageProvider#load to use async/awaitAll
andreia-ferreira Jun 11, 2026
14fc959
simplify calculateInSampleSize
andreia-ferreira Jun 11, 2026
2f7b3ba
apply suggestion to PdfWriter
andreia-ferreira Jun 11, 2026
d388ce7
add IO dispatcher to AndroidPdfRenderer file operation
andreia-ferreira Jun 11, 2026
8948a19
update top border drawing logic for the table
andreia-ferreira Jun 11, 2026
25ffb1f
add unit tests to assure PdfWriter only draws one internal border bet…
andreia-ferreira Jun 11, 2026
87813c4
Merge branch 'master' into andreia/3739/pdf-report-layout
andreia-ferreira Jun 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions feature/pdf/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ plugins {
apply(from = "../../config/jacoco/jacoco.gradle")

kotlin {
jvmToolchain(libs.versions.jvmToolchainVersion.get().toInt())
android {
namespace = "org.groundplatform.feature.pdf"
compileSdk {
Expand Down Expand Up @@ -60,5 +61,21 @@ kotlin {
implementation(libs.kotlinx.coroutines.test)
}
}

androidMain {
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.exifinterface)
implementation(libs.compose.ui)
implementation(libs.timber)
}
}

val androidHostTest by getting {
dependencies {
implementation(libs.junit)
implementation(libs.robolectric)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.groundplatform.feature.pdf

import android.graphics.Bitmap
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlin.test.assertTrue
import kotlinx.coroutines.test.runTest
import org.groundplatform.feature.pdf.render.image.PdfImageSet
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment

@RunWith(RobolectricTestRunner::class)
class AndroidPdfImageProviderTest {

@Test
fun `calculateInSampleSize does not subsample when image already fits within 2x`() {
assertEquals(1, calculateInSampleSize(width = 100, height = 100, maxWidth = 60, maxHeight = 60))
}

@Test
fun `calculateInSampleSize halves a square image down towards the box`() {
assertEquals(2, calculateInSampleSize(width = 100, height = 100, maxWidth = 50, maxHeight = 50))
}

@Test
fun `calculateInSampleSize subsamples a typical landscape photo`() {
assertEquals(
2,
calculateInSampleSize(width = 4000, height = 3000, maxWidth = 1346, maxHeight = 1108),
)
}

@Test
fun `calculateInSampleSize subsamples a tall image on its binding axis`() {
assertEquals(
4,
calculateInSampleSize(width = 1000, height = 5000, maxWidth = 1346, maxHeight = 1108),
)
}

@Test
fun `calculateInSampleSize never upsamples a tiny image`() {
assertEquals(1, calculateInSampleSize(width = 10, height = 10, maxWidth = 50, maxHeight = 50))
}

@Test
fun `calculateInSampleSize leaves less than a 2x downscale for any input`() {
val maxWidth = 1346
val maxHeight = 1108
val dimensions = listOf(5000 to 1000, 1000 to 5000, 4000 to 3000, 3000 to 4000, 8000 to 8000)
for ((width, height) in dimensions) {
val sampleSize = calculateInSampleSize(width, height, maxWidth, maxHeight)
val decodedWidth = width / sampleSize
val decodedHeight = height / sampleSize
val fitScale = minOf(maxWidth.toFloat() / decodedWidth, maxHeight.toFloat() / decodedHeight)
assertTrue(fitScale > 0.5f)
}
}

@Test
fun `scaledToFit returns the same bitmap when it already fits`() {
val bitmap = bitmap(10, 10)
assertSame(bitmap, bitmap.scaledToFit(maxWidth = 50, maxHeight = 50))
}

@Test
fun `scaledToFit returns the same bitmap when it exactly matches the box`() {
val bitmap = bitmap(50, 50)
assertSame(bitmap, bitmap.scaledToFit(maxWidth = 50, maxHeight = 50))
}

@Test
fun `scaledToFit downscales preserving aspect ratio`() {
val result = bitmap(100, 50).scaledToFit(maxWidth = 50, maxHeight = 50)
assertEquals(50, result.width)
assertEquals(25, result.height)
}

@Test
fun `scaledToFit fits to the binding height when the box is wide`() {
val result = bitmap(50, 100).scaledToFit(maxWidth = 50, maxHeight = 50)
assertEquals(25, result.width)
assertEquals(50, result.height)
}

@Test
fun `load generates a qr image when content is provided`() = runTest {
val images = newProvider().load(qrContent = "https://example.org", photoFilenames = emptySet())

assertNotNull(images[PdfImageSet.ImageRef.Qr])
}

@Test
fun `load returns no qr image when content is null`() = runTest {
val images = newProvider().load(qrContent = null, photoFilenames = emptySet())

assertNull(images[PdfImageSet.ImageRef.Qr])
}

@Test
fun `load skips empty photo filenames`() = runTest {
val images = newProvider().load(qrContent = null, photoFilenames = setOf(""))

assertNull(images[PdfImageSet.ImageRef.Photo("")])
}

@Test
fun `load skips photos whose file does not exist`() = runTest {
val images = newProvider().load(qrContent = null, photoFilenames = setOf("missing.jpg"))

assertNull(images[PdfImageSet.ImageRef.Photo("missing.jpg")])
}

@Test
fun `release recycles the bitmaps it loaded`() = runTest {
val images = newProvider().load(qrContent = "https://example.org", photoFilenames = emptySet())
val qrBitmap = images[PdfImageSet.ImageRef.Qr]!!.bitmap
assertFalse(qrBitmap.isRecycled)

images.release()

assertTrue(qrBitmap.isRecycled)
}

private fun newProvider(): AndroidPdfImageProvider =
AndroidPdfImageProvider(RuntimeEnvironment.getApplication(), logoDrawableRes = 0)

private fun bitmap(width: Int, height: Int): Bitmap =
Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.groundplatform.feature.pdf

import android.content.Context
import java.io.File
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment

@RunWith(RobolectricTestRunner::class)
class AndroidPdfOutputProviderTest {

private lateinit var context: Context
private lateinit var reportsDir: File
private lateinit var provider: AndroidPdfOutputProvider

@Before
fun setUp() {
context = RuntimeEnvironment.getApplication()
reportsDir = File(context.cacheDir, PDF_SUBDIR)
reportsDir.deleteRecursively()
provider = AndroidPdfOutputProvider(context)
}

@Test
fun `newFilePath creates the reports directory and returns a pdf path`() {
val path = provider.newFilePath(PDF_FILE_NAME)

assertTrue(reportsDir.isDirectory)
assertEquals(File(reportsDir, "report.pdf").absolutePath, path)
}

@Test
fun `exists reflects whether the report file is present`() {
assertFalse(provider.exists(PDF_FILE_NAME))

File(provider.newFilePath(PDF_FILE_NAME)).writeText(PDF_TEXT)

assertTrue(provider.exists(PDF_FILE_NAME))
}

@Test
fun `listFiles returns an empty list when there is no reports directory`() {
assertTrue(provider.listFiles().isEmpty())
}

@Test
fun `listFiles returns only pdf files`() {
File(provider.newFilePath("a")).writeText(PDF_TEXT)
File(provider.newFilePath("b")).writeText(PDF_TEXT)
File(reportsDir, "notes.txt").writeText("ignore me")

val names = provider.listFiles().map { File(it.path).name }.sorted()

assertContentEquals(listOf("a.pdf", "b.pdf"), names)
}

@Test
fun `listFiles returns the cached pdf files with the correct lastModified value`() {
val file = File(provider.newFilePath(PDF_SUBDIR)).apply { writeText(PDF_TEXT) }
file.setLastModified(987654321L)

val entry = provider.listFiles().single()

assertEquals(file.absolutePath, entry.path)
assertEquals(987654321L, entry.lastModifiedMillis)
}

@Test
fun `deleteReport removes the file at the given path`() {
val path = provider.newFilePath(PDF_SUBDIR)
File(path).writeText(PDF_TEXT)

provider.deleteReport(path)

assertFalse(File(path).exists())
}

@Test
fun `pruneOldFiles deletes only reports older than a week`() {
val now = System.currentTimeMillis()
val fresh = File(provider.newFilePath("fresh")).apply { writeText(PDF_TEXT) }
val stale = File(provider.newFilePath("stale")).apply { writeText(PDF_TEXT) }
stale.setLastModified(now - 8L * 24 * 60 * 60 * 1000)

provider.pruneOldFiles()

assertTrue(fresh.exists())
assertFalse(stale.exists())
}

private companion object {
const val PDF_TEXT = "This is a test PDF."
const val PDF_SUBDIR = "reports"
const val PDF_FILE_NAME = "report"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.groundplatform.feature.pdf.render

import android.graphics.Bitmap
import android.graphics.RectF
import android.graphics.pdf.PdfDocument
import android.text.StaticLayout
import android.text.TextPaint
import kotlin.test.assertFailsWith
import org.groundplatform.feature.pdf.render.image.PdfImage
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class DocumentPdfCanvasTest {

private val canvas = DocumentPdfCanvas(PdfDocument())

@Test
fun `drawLine before a page is started fails`() {
assertFailsWith<IllegalStateException> { canvas.drawLine(0f, 0f, 10f, 10f) }
}

@Test
fun `drawImage before a page is started fails`() {
val image = PdfImage(Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888))
assertFailsWith<IllegalStateException> {
canvas.drawImage(image, RectF(0f, 0f, 10f, 10f), smoothScaling = false)
}
}

@Test
fun `drawStaticLayout before a page is started fails`() {
val layout = StaticLayout.Builder.obtain("body", 0, 4, TextPaint(), 100).build()
assertFailsWith<IllegalStateException> { canvas.drawStaticLayout(layout, x = 0f, y = 0f) }
}

@Test
fun `finishPage with no page open does nothing`() {
canvas.finishPage()
}
}
Loading
Loading