diff --git a/feature/pdf/build.gradle.kts b/feature/pdf/build.gradle.kts index 24ac27ee4b..21681b7903 100644 --- a/feature/pdf/build.gradle.kts +++ b/feature/pdf/build.gradle.kts @@ -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 { @@ -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) + } + } } } diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt new file mode 100644 index 0000000000..fb7189f119 --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt @@ -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) +} diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt new file mode 100644 index 0000000000..7023daae7a --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt @@ -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" + } +} diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvasTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvasTest.kt new file mode 100644 index 0000000000..a25fc8d176 --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvasTest.kt @@ -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 { 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 { + 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 { canvas.drawStaticLayout(layout, x = 0f, y = 0f) } + } + + @Test + fun `finishPage with no page open does nothing`() { + canvas.finishPage() + } +} diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt new file mode 100644 index 0000000000..34060fb339 --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt @@ -0,0 +1,48 @@ +/* + * 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.RectF +import android.text.StaticLayout +import org.groundplatform.feature.pdf.render.image.PdfImage + +internal class FakePdfCanvas : PdfCanvas { + val startedPages = mutableListOf() + var finishedPages = 0 + val drawnText = mutableListOf() + val drawnImages = mutableListOf() + val drawnLines = mutableListOf() + + override fun startPage(pageNumber: Int) { + startedPages += pageNumber + } + + override fun finishPage() { + finishedPages++ + } + + override fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) { + drawnText += layout.text.toString() + } + + override fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) { + drawnImages += image + } + + override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) { + drawnLines += PdfLine(x1, y1, x2, y2) + } +} diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfCanvasTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfCanvasTest.kt new file mode 100644 index 0000000000..2f0adcecc7 --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfCanvasTest.kt @@ -0,0 +1,41 @@ +/* + * 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.text.StaticLayout +import android.text.TextPaint +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 PdfCanvasTest { + @Test + fun `MeasurementPdfCanvas ignores every call`() { + val layout = StaticLayout.Builder.obtain("body", 0, "body".length, TextPaint(), 100).build() + val image = PdfImage(Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888)) + with(MeasurementPdfCanvas) { + startPage(pageNumber = 1) + drawStaticLayout(layout, x = 0f, y = 0f) + drawImage(image, RectF(0f, 0f, 10f, 10f), smoothScaling = true) + drawLine(0f, 0f, 10f, 10f) + finishPage() + } + } +} diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt new file mode 100644 index 0000000000..172fb6858d --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt @@ -0,0 +1,310 @@ +/* + * 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 kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.groundplatform.feature.pdf.render.image.PdfImageSet +import org.groundplatform.feature.pdf.render.image.PdfImageSet.ImageRef +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PdfWriterTest { + + @Test + fun `measurement and draw passes emit the same page count`() { + val measuredPages = renderPageCount(totalPages = null) + + val drawnPages = renderPageCount(totalPages = measuredPages) + + assertTrue(measuredPages > 1) + assertEquals(measuredPages, drawnPages) + } + + @Test + fun `does not open a page for a document with no qr and no rows`() { + val canvas = FakePdfCanvas() + + newPdfWriter(EMPTY_DOCUMENT, PdfImageSet(emptyMap()), canvas).drawDocument(EMPTY_DOCUMENT) + + assertEquals(0, canvas.startedPages.size) + assertEquals(0, canvas.finishedPages) + } + + @Test + fun `opens and closes exactly one page for a single-page document`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + assertEquals(listOf(1), canvas.startedPages) + assertEquals(1, canvas.finishedPages) + } + + @Test + fun `draws the header values on the page`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + assertTrue(canvas.drawnText.contains(HEADER.surveyName)) + assertTrue(canvas.drawnText.contains(HEADER.jobName)) + assertTrue(canvas.drawnText.contains(HEADER.timestamp)) + } + + @Test + fun `draws the footer text on the page`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + assertTrue( + canvas.drawnText.contains( + "${FOOTER.dataCollectorLabel}: ${FOOTER.dataCollectorName}, ${FOOTER.userEmail}" + ) + ) + } + + @Test + fun `draws the header and footer on every page`() { + val canvas = FakePdfCanvas() + val pdfWriter = + newPdfWriter(TEST_PDF_DOCUMENT, PdfImageSet(emptyMap()), canvas, totalPages = null) + + pdfWriter.drawDocument(TEST_PDF_DOCUMENT) + + assertTrue(pdfWriter.pageCount > 1) + assertEquals(pdfWriter.pageCount, canvas.drawnText.count { it == HEADER.surveyName }) + assertEquals( + pdfWriter.pageCount, + canvas.drawnText.count { + it == "${FOOTER.dataCollectorLabel}: ${FOOTER.dataCollectorName}, ${FOOTER.userEmail}" + }, + ) + } + + @Test + fun `draws the qr image and caption when a qr image is provided`() { + val qr = pdfImage() + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, pdfImageSet(qr = qr)) + + assertTrue(canvas.drawnImages.any { it.bitmap === qr.bitmap }) + assertTrue(canvas.drawnText.contains(QR_BLOCK.scanCaption)) + } + + @Test + fun `skips the qr block when no qr image is provided`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, pdfImageSet(qr = null)) + + assertFalse(canvas.drawnText.contains(QR_BLOCK.scanCaption)) + } + + @Test + fun `draws text answers as text layouts`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + assertTrue(canvas.drawnText.contains(SINGLE_PAGE_DOCUMENT.table.rows[0].question)) + assertTrue( + canvas.drawnText.contains( + (SINGLE_PAGE_DOCUMENT.table.rows[0].answer as SubmissionPdfDocument.Answer.Text) + .lines + .first() + ) + ) + assertTrue(canvas.drawnText.contains(SINGLE_PAGE_DOCUMENT.table.rows[1].question)) + } + + @Test + fun `draws photo answers as images`() { + val photo = pdfImage() + val canvas = + renderDocument(SINGLE_PAGE_DOCUMENT, pdfImageSet(photos = mapOf("photo.jpg" to photo))) + + assertTrue(canvas.drawnImages.any { it.bitmap === photo.bitmap }) + } + + @Test + fun `does not draw a photo answer when its image is missing`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, pdfImageSet(photos = emptyMap())) + + assertTrue(canvas.drawnImages.isEmpty()) + } + + @Test + fun `includes the page number in the footer when totalPages is set`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, totalPages = 1) + + assertTrue(canvas.drawnText.contains("1/1")) + } + + @Test + fun `omits the page number from the footer when totalPages is null`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, totalPages = null) + + assertFalse(canvas.drawnText.any { it.contains("/") }) + } + + @Test + fun `draws a top border on only the first table row of a page`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + // SINGLE_PAGE_DOCUMENT has 2 rows on one page: the first gets a top border, the second doesn't. + assertEquals(2, canvas.drawnLines.count { it.startX == it.endX }) + assertEquals(1, canvas.topBorderCount()) + } + + @Test + fun `draws a fresh top border on the first row of every page`() { + val canvas = FakePdfCanvas() + val pdfWriter = + newPdfWriter(TEST_PDF_DOCUMENT, PdfImageSet(emptyMap()), canvas, totalPages = null) + + pdfWriter.drawDocument(TEST_PDF_DOCUMENT) + + // Every page resets the flag, so each page's first row draws exactly 1 top border. + assertTrue(pdfWriter.pageCount > 1) + assertEquals(pdfWriter.pageCount, canvas.topBorderCount()) + } + + @Test + fun `skips the table when there are no rows`() { + val tableless = + SINGLE_PAGE_DOCUMENT.copy(table = SINGLE_PAGE_DOCUMENT.table.copy(rows = emptyList())) + + val canvas = renderDocument(tableless, pdfImageSet(qr = pdfImage())) + + assertEquals(listOf(1), canvas.startedPages) + assertFalse(canvas.drawnText.contains(TABLE.submissionLabel)) + } + + private fun renderDocument( + document: SubmissionPdfDocument, + images: PdfImageSet = pdfImageSet(qr = pdfImage()), + totalPages: Int? = 1, + ): FakePdfCanvas = + FakePdfCanvas().also { newPdfWriter(document, images, it, totalPages).drawDocument(document) } + + private fun renderPageCount(totalPages: Int?): Int = + newPdfWriter(TEST_PDF_DOCUMENT, PdfImageSet(emptyMap()), MeasurementPdfCanvas, totalPages) + .apply { drawDocument(TEST_PDF_DOCUMENT) } + .pageCount + + private fun newPdfWriter( + document: SubmissionPdfDocument, + images: PdfImageSet, + canvas: PdfCanvas, + totalPages: Int? = null, + ): PdfWriter = + PdfWriter( + pdfCanvas = canvas, + images = images, + totalPages = totalPages, + header = document.header, + footer = document.footer, + ) + + private fun FakePdfCanvas.topBorderCount(): Int { + // This counts the rows as each row draws exactly 1 vertical divider + val rowCount = drawnLines.count { it.startX == it.endX } + val horizontalLines = drawnLines.count { it.startY == it.endY } + return horizontalLines - rowCount + } + + private fun pdfImage(): PdfImage = PdfImage(Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888)) + + private fun pdfImageSet( + qr: PdfImage? = null, + photos: Map = emptyMap(), + ): PdfImageSet = + PdfImageSet( + buildMap { + qr?.let { put(ImageRef.Qr, it) } + photos.forEach { (name, image) -> put(ImageRef.Photo(name), image) } + } + ) + + private companion object { + val HEADER = + SubmissionPdfDocument.Header( + surveyLabel = "Survey", + surveyName = "Survey name", + jobLabel = "Job", + jobName = "Job name", + timestamp = "timestamp", + ) + val FOOTER = + SubmissionPdfDocument.Footer( + dataCollectorLabel = "Collector", + dataCollectorName = "John Doe", + userEmail = "user@gmail.com", + ) + + val QR_BLOCK = SubmissionPdfDocument.QrBlock(scanCaption = "Scan") + + val TABLE = + SubmissionPdfDocument.Table( + submissionLabel = "Submission", + loiName = "Plot 42", + rows = emptyList(), + ) + + val EMPTY_DOCUMENT = + SubmissionPdfDocument( + header = HEADER, + qrBlock = QR_BLOCK, + footer = FOOTER, + table = TABLE, + ) + + val SINGLE_PAGE_DOCUMENT = + SubmissionPdfDocument( + header = HEADER, + qrBlock = QR_BLOCK, + footer = FOOTER, + table = + TABLE.copy( + rows = + listOf( + SubmissionPdfDocument.Row( + question = "What is your name?", + answer = SubmissionPdfDocument.Answer.Text(listOf("John")), + ), + SubmissionPdfDocument.Row( + question = "Take a picture of a tree", + answer = SubmissionPdfDocument.Answer.Photo(remoteFilename = "photo.jpg"), + ), + ) + ), + ) + + val TEST_PDF_DOCUMENT = + SubmissionPdfDocument( + header = HEADER, + qrBlock = QR_BLOCK, + footer = FOOTER, + table = + TABLE.copy( + rows = + List(200) { index -> + SubmissionPdfDocument.Row( + question = "Question $index", + answer = SubmissionPdfDocument.Answer.Text(listOf("Answer $index")), + ) + } + ), + ) + } +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt new file mode 100644 index 0000000000..5e41d143f8 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt @@ -0,0 +1,188 @@ +/* + * 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 android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.os.Environment +import androidx.annotation.DrawableRes +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.core.graphics.scale +import androidx.exifinterface.media.ExifInterface +import java.io.File +import kotlin.math.roundToInt +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import org.groundplatform.feature.pdf.render.fitInside +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.groundplatform.feature.pdf.render.image.PdfImageSet +import org.groundplatform.feature.pdf.render.layout.QrBlockLayout +import org.groundplatform.feature.pdf.render.layout.TableLayout +import org.groundplatform.feature.pdf.render.pointsToRenderPixels +import org.groundplatform.ui.components.qrcode.PDF_LOGO_SIZE_FRACTION +import org.groundplatform.ui.components.qrcode.generateQrBitmap +import timber.log.Timber + +/** + * Android implementation of [PdfImageProvider]. + * + * Bitmaps are decoded and scaled to their final on-page pixel size here so the renderer can draw + * them as-is without any further bitmap work. + * + * @param context application context used for resource access and file lookups. + * @param logoDrawableRes resource id of the centre logo bitmap. Caller supplies the app's branding. + */ +// TODO: Add equivalent iOS implementations for PDF feature +// Issue URL: https://github.com/google/ground-android/issues/3775 +class AndroidPdfImageProvider( + private val context: Context, + @DrawableRes private val logoDrawableRes: Int, +) : PdfImageProvider { + + private val qrMaxPx = pointsToRenderPixels(QrBlockLayout.QR_SIZE) + private val photoMaxWidthPx = pointsToRenderPixels(TableLayout.ANSWER_TEXT_WIDTH.toFloat()) + private val photoMaxHeightPx = pointsToRenderPixels(TableLayout.PHOTO_MAX_HEIGHT.toFloat()) + + override suspend fun load(qrContent: String?, photoFilenames: Set): PdfImageSet = + coroutineScope { + val deferredQr = qrContent?.let { content -> + async { + generateQrCodeBitmap(content)?.let { bitmap -> + PdfImageSet.ImageRef.Qr to bitmap + } + } + } + + val deferredPhotos = + photoFilenames + .filter { it.isNotEmpty() } + .map { filename -> + async { + loadPhotoBitmap(filename)?.let { bitmap -> + PdfImageSet.ImageRef.Photo(filename) to bitmap + } + } + } + + val results = (listOfNotNull(deferredQr) + deferredPhotos).awaitAll().filterNotNull() + + val images = mutableMapOf() + val bitmapsToRelease = mutableListOf() + results.forEach { (ref, bitmap) -> + bitmapsToRelease += bitmap + images[ref] = PdfImage(bitmap) + } + + PdfImageSet(images = images, onRelease = { bitmapsToRelease.forEach(Bitmap::recycle) }) + } + + private fun generateQrCodeBitmap(content: String): Bitmap? = + runCatching { + generateQrBitmap( + content = content, + logo = + BitmapFactory.decodeResource(context.resources, logoDrawableRes)?.asImageBitmap(), + logoSizeFraction = PDF_LOGO_SIZE_FRACTION, + ) + .asAndroidBitmap() + .scaledToFit(qrMaxPx, qrMaxPx) + } + .onFailure { Timber.e(it, "Failed to generate QR code bitmap for PDF report") } + .getOrNull() + + private fun loadPhotoBitmap(remoteFilename: String): Bitmap? { + val rootDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: return null + val file = File(rootDir, remoteFilename.substringAfterLast('/')) + if (!file.exists()) return null + return runCatching { decodeScaledAndOriented(file) } + .onFailure { Timber.e(it, "Failed to decode photo for PDF report") } + .getOrNull() + } + + /** + * Decodes the photo subsampled to roughly the largest size it can occupy in the PDF, scales it to + * fit the photo box, then applies the EXIF orientation. + */ + private fun decodeScaledAndOriented(file: File): Bitmap? { + val path = file.absolutePath + val degrees = runCatching { ExifInterface(path).rotationDegrees }.getOrDefault(0) + // A 90°/270° EXIF rotation swaps width and height, so size the decode box accordingly. + val swapAxes = degrees == 90 || degrees == 270 + val boxWidth = if (swapAxes) photoMaxHeightPx else photoMaxWidthPx + val boxHeight = if (swapAxes) photoMaxWidthPx else photoMaxHeightPx + + val decoded = decodeSubsampled(path, boxWidth, boxHeight) ?: return null + return decoded.scaledToFit(boxWidth, boxHeight).rotated(degrees) + } + + /** Decodes the photo subsampled to roughly the [maxWidth] × [maxHeight] box it will occupy. */ + private fun decodeSubsampled(path: String, maxWidth: Int, maxHeight: Int): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, bounds) + val options = + BitmapFactory.Options().apply { + inSampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight, maxWidth, maxHeight) + } + return BitmapFactory.decodeFile(path, options) + } +} + +/** + * Largest power-of-two sample size that keeps the decoded bitmap at or above the [maxWidth] × + * [maxHeight]. + */ +internal fun calculateInSampleSize(width: Int, height: Int, maxWidth: Int, maxHeight: Int): Int { + // True if at least one dimension is still larger than the target when downsampled. + fun canDownsample(sampleSize: Int): Boolean { + val meetsTargetWidth = width / sampleSize >= maxWidth + val meetsTargetHeight = height / sampleSize >= maxHeight + return meetsTargetWidth || meetsTargetHeight + } + + var sampleSize = 1 + while (canDownsample(sampleSize * 2)) { + sampleSize *= 2 + } + return sampleSize +} + +/** + * Returns the receiver scaled down to fit [maxWidth] × [maxHeight], preserving aspect ratio and + * never upscaling. + */ +internal fun Bitmap.scaledToFit(maxWidth: Int, maxHeight: Int): Bitmap { + val fitted = fitInside(width, height, maxWidth, maxHeight) + val targetWidth = fitted.width.roundToInt().coerceAtLeast(1) + val targetHeight = fitted.height.roundToInt().coerceAtLeast(1) + if (targetWidth == width && targetHeight == height) return this + return scale(targetWidth, targetHeight).also { if (it !== this) recycle() } +} + +/** Returns the receiver rotated [degrees] clockwise, or unchanged when no rotation is needed. */ +private fun Bitmap.rotated(degrees: Int): Bitmap { + if (degrees == 0) return this + val matrix = Matrix().apply { postRotate(degrees.toFloat()) } + return runCatching { Bitmap.createBitmap(this, 0, 0, width, height, matrix, true) } + .getOrElse { + Timber.w(it, "Failed to rotate photo by $degrees°, returning unrotated bitmap") + null + } + ?.also { if (it !== this) recycle() } ?: this +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt new file mode 100644 index 0000000000..0076867b7d --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt @@ -0,0 +1,45 @@ +/* + * 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 + +private const val REPORTS_SUBDIR = "reports" + +// TODO: Add equivalent iOS implementations for PDF feature +// Issue URL: https://github.com/google/ground-android/issues/3775 +class AndroidPdfOutputProvider(private val context: Context) : PdfOutputProvider { + + private val reportsDir + get() = File(context.cacheDir, REPORTS_SUBDIR) + + override fun newFilePath(name: String): String { + val outputDir = reportsDir.apply { mkdirs() } + return File(outputDir, "$name.pdf").absolutePath + } + + override fun exists(name: String): Boolean = File(reportsDir, "$name.pdf").exists() + + override fun listFiles(): List = + reportsDir + .listFiles { f -> f.isFile && f.extension == "pdf" } + ?.map { PdfOutputProvider.CachedPdf(it.absolutePath, it.lastModified()) } ?: emptyList() + + override fun deleteReport(path: String) { + File(path).delete() + } +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt new file mode 100644 index 0000000000..19d0949e86 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt @@ -0,0 +1,73 @@ +/* + * 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.pdf.PdfDocument +import java.io.File +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.render.DocumentPdfCanvas +import org.groundplatform.feature.pdf.render.MeasurementPdfCanvas +import org.groundplatform.feature.pdf.render.PdfCanvas +import org.groundplatform.feature.pdf.render.PdfWriter +import org.groundplatform.feature.pdf.render.image.PdfImageSet + +/** + * Android [PdfRenderer] for a [SubmissionPdfDocument]. The drawing of each section lives in + * [PdfWriter]; the [PdfCanvas] decides whether each pass writes to a real [PdfDocument] or just + * counts pages. + */ +// TODO: Add equivalent iOS implementations for PDF feature +// Issue URL: https://github.com/google/ground-android/issues/3775 +class AndroidPdfRenderer(private val ioDispatcher: CoroutineDispatcher) : PdfRenderer { + + override suspend fun render( + document: SubmissionPdfDocument, + images: PdfImageSet, + outputPath: String, + ) { + // Measurement first so the footer can show "page/total" + val totalPages = measurePageCount(document, images) + val pdf = PdfDocument() + try { + writer(document, images, DocumentPdfCanvas(pdf), totalPages = totalPages) + .drawDocument(document) + withContext(ioDispatcher) { File(outputPath).outputStream().use { pdf.writeTo(it) } } + } finally { + pdf.close() + } + } + + private fun measurePageCount(document: SubmissionPdfDocument, images: PdfImageSet): Int = + writer(document, images, MeasurementPdfCanvas, totalPages = null) + .apply { drawDocument(document) } + .pageCount + + private fun writer( + document: SubmissionPdfDocument, + images: PdfImageSet, + pdfCanvas: PdfCanvas, + totalPages: Int?, + ): PdfWriter = + PdfWriter( + pdfCanvas = pdfCanvas, + images = images, + header = document.header, + footer = document.footer, + totalPages = totalPages, + ) +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt new file mode 100644 index 0000000000..d03dfba043 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt @@ -0,0 +1,61 @@ +/* + * 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 android.content.Intent +import androidx.core.content.FileProvider +import java.io.File + +/** Launches the system share sheet or external viewer for a report file via [FileProvider]. */ +// TODO: Add equivalent iOS implementations for PDF feature +// Issue URL: https://github.com/google/ground-android/issues/3775 +class AndroidPdfReportLauncher( + private val context: Context, + private val fileProviderAuthority: String, +) : PdfReportLauncher { + + override fun share(path: String) { + val uri = FileProvider.getUriForFile(context, fileProviderAuthority, File(path)) + val sendIntent = + Intent(Intent.ACTION_SEND).apply { + type = PDF_MIME_TYPE + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + launchChooser(sendIntent) + } + + override fun open(path: String) { + val uri = FileProvider.getUriForFile(context, fileProviderAuthority, File(path)) + val viewIntent = + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, PDF_MIME_TYPE) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + launchChooser(viewIntent) + } + + private fun launchChooser(target: Intent) { + val chooser = + Intent.createChooser(target, null).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + context.startActivity(chooser) + } + + companion object { + private const val PDF_MIME_TYPE = "application/pdf" + } +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt new file mode 100644 index 0000000000..35681b16a3 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt @@ -0,0 +1,72 @@ +/* + * 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.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.pdf.PdfDocument +import android.text.StaticLayout +import androidx.core.graphics.withTranslation +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.groundplatform.feature.pdf.render.layout.TableLayout + +/** + * [PdfCanvas] that draws onto a real [PdfDocument], one page at a time. Image bitmaps are expected + * to arrive at their on-page pixel size; the canvas does no further scaling. + */ +internal class DocumentPdfCanvas(private val pdf: PdfDocument) : PdfCanvas { + private var currentPage: PdfDocument.Page? = null + + private val strokePaint = + Paint().apply { + style = Paint.Style.STROKE + strokeWidth = TableLayout.BORDER_WIDTH + isAntiAlias = true + } + + private val smoothImagePaint = + Paint().apply { + isFilterBitmap = true + isAntiAlias = true + isDither = true + } + + override fun startPage(pageNumber: Int) { + val info = + PdfDocument.PageInfo.Builder(PdfConfig.PAGE_WIDTH, PdfConfig.PAGE_HEIGHT, pageNumber).create() + currentPage = pdf.startPage(info) + } + + override fun finishPage() { + currentPage?.also { pdf.finishPage(it) } + currentPage = null + } + + override fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) { + canvas().withTranslation(x, y) { layout.draw(this) } + } + + override fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) { + canvas().drawBitmap(image.bitmap, null, frame, if (smoothScaling) smoothImagePaint else null) + } + + override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) { + canvas().drawLine(x1, y1, x2, y2, strokePaint) + } + + private fun canvas(): Canvas = currentPage?.canvas ?: error("draw called with no page open") +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt new file mode 100644 index 0000000000..8f532c7a94 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt @@ -0,0 +1,46 @@ +/* + * 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.RectF +import android.text.StaticLayout +import org.groundplatform.feature.pdf.render.image.PdfImage + +/** Abstraction for drawing onto a PDF page. */ +internal interface PdfCanvas { + fun startPage(pageNumber: Int) + + fun finishPage() + + fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) + + fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) + + fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) +} + +/** Used during the page-counting phase. Drops every drawing call. */ +internal object MeasurementPdfCanvas : PdfCanvas { + override fun startPage(pageNumber: Int) = Unit + + override fun finishPage() = Unit + + override fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) = Unit + + override fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) = Unit + + override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) = Unit +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt new file mode 100644 index 0000000000..25b8c3913e --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt @@ -0,0 +1,39 @@ +/* + * 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.Color +import android.graphics.Typeface +import android.text.TextPaint +import org.groundplatform.feature.pdf.render.PdfConfig.BODY_SIZE +import org.groundplatform.feature.pdf.render.PdfConfig.CAPTION_SIZE +import org.groundplatform.feature.pdf.render.PdfConfig.TITLE_SIZE + +internal class PdfTextPaints { + val title: TextPaint = textPaint(TITLE_SIZE, bold = false) + val body: TextPaint = textPaint(BODY_SIZE, bold = false) + val metaLabel: TextPaint = textPaint(CAPTION_SIZE, bold = true, textColor = Color.GRAY) + val meta: TextPaint = textPaint(CAPTION_SIZE, bold = false, textColor = Color.GRAY) + val caption: TextPaint = textPaint(CAPTION_SIZE, bold = false) + + private fun textPaint(size: Float, bold: Boolean, textColor: Int = Color.BLACK): TextPaint = + TextPaint().apply { + textSize = size + color = textColor + isAntiAlias = true + if (bold) typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + } +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt new file mode 100644 index 0000000000..d23bfe5a12 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt @@ -0,0 +1,276 @@ +/* + * 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.RectF +import android.graphics.Typeface +import android.graphics.pdf.PdfDocument +import android.text.Layout +import android.text.SpannableString +import android.text.Spanned +import android.text.StaticLayout +import android.text.TextPaint +import android.text.TextUtils +import android.text.style.StyleSpan +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Answer +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Footer +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Header +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.QrBlock +import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING +import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.groundplatform.feature.pdf.render.image.PdfImageSet +import org.groundplatform.feature.pdf.render.layout.PageFooterLayout +import org.groundplatform.feature.pdf.render.layout.PageHeaderLayout +import org.groundplatform.feature.pdf.render.layout.QrBlockLayout +import org.groundplatform.feature.pdf.render.layout.TableLayout + +/** + * Draws a [SubmissionPdfDocument] onto a [PdfDocument], one section at a time, paginating top-down. + * Holds the mutable drawing state (current page, [PdfCursor], shared paints) shared by all + * sections. + */ +internal class PdfWriter( + private val pdfCanvas: PdfCanvas, + private val images: PdfImageSet, + private val totalPages: Int? = null, + private val header: Header, + footer: Footer, +) : PdfPageController.PageLifecycle { + private val paints = PdfTextPaints() + + private val footerLayout: StaticLayout = buildFooterLayout(footer) + private val cursor = + PdfCursor(footerReserve = PageFooterLayout.reserve(footerLayout.height.toFloat())) + private val pageController = PdfPageController(cursor, this) + + val pageCount: Int + get() = pageController.pageCount + + override fun onPageStarted(pageNumber: Int) { + pdfCanvas.startPage(pageNumber) + drawPageHeader() + } + + override fun onPageEnding(pageNumber: Int) { + drawPageFooter() + pdfCanvas.finishPage() + } + + fun drawDocument(document: SubmissionPdfDocument) { + drawQrBlock(document.qrBlock) + drawTable(document.table) + finalizePage() + } + + private fun drawQrBlock(block: QrBlock) { + val qr = images[PdfImageSet.ImageRef.Qr] ?: return + pageController.ensurePage() + val captionLayout = + staticLayout( + block.scanCaption, + paints.caption, + QrBlockLayout.QR_SIZE.toInt(), + Layout.Alignment.ALIGN_CENTER, + ) + val layout = + QrBlockLayout.compute(top = cursor.y, captionHeight = captionLayout.height.toFloat()) + drawImage(qr, layout.qrFrame, smoothScaling = false) + drawStaticLayoutAt(captionLayout, layout.captionOffset) + cursor.moveTo(layout.nextCursorY) + } + + private fun drawTable(table: SubmissionPdfDocument.Table) { + val rows = table.rows.takeIf { it.isNotEmpty() } ?: return + pageController.ensurePage() + val label = + SpannableString("${table.submissionLabel}: ${table.loiName}").apply { + setSpan( + StyleSpan(Typeface.BOLD), + 0, + table.submissionLabel.length, + Spanned.SPAN_INCLUSIVE_EXCLUSIVE, + ) + } + val labelLayout = staticLayout(label, paints.title, USABLE_WIDTH) + val tableLabel = + TableLayout.getLabel(top = cursor.y, labelHeight = labelLayout.height.toFloat()) + drawStaticLayoutAt(labelLayout, tableLabel.labelOffset) + cursor.moveTo(tableLabel.nextCursorY) + rows.forEach { row -> + when (val answer = row.answer) { + is Answer.Text -> + drawTableRow( + questionText = row.question, + answerText = answer.lines.joinToString("\n"), + photo = null, + ) + is Answer.Photo -> + drawTableRow( + questionText = row.question, + answerText = "", + photo = images[PdfImageSet.ImageRef.Photo(answer.remoteFilename)], + ) + } + } + } + + private fun finalizePage() { + pageController.finalizePage() + } + + private fun drawPageHeader() { + val columnWidth = PageHeaderLayout.COLUMN_WIDTH + val surveyLabel = staticLayout(header.surveyLabel, paints.metaLabel, columnWidth) + val surveyValue = + staticLayout( + text = header.surveyName, + paint = paints.meta, + maxWidth = columnWidth, + maxLines = PageHeaderLayout.MAX_LINES, + ) + val jobLabel = + staticLayout(header.jobLabel, paints.metaLabel, columnWidth, Layout.Alignment.ALIGN_CENTER) + val jobValue = + staticLayout( + text = header.jobName, + paint = paints.meta, + maxWidth = columnWidth, + alignment = Layout.Alignment.ALIGN_CENTER, + maxLines = PageHeaderLayout.MAX_LINES, + ) + val timestamp = + staticLayout( + text = header.timestamp, + paint = paints.meta, + maxWidth = columnWidth, + alignment = Layout.Alignment.ALIGN_OPPOSITE, + maxLines = PageHeaderLayout.MAX_LINES, + ) + + val layout = + PageHeaderLayout.compute( + top = cursor.y, + labelHeight = surveyLabel.height.toFloat(), + valueHeight = surveyValue.height.toFloat(), + ) + + drawStaticLayoutAt(surveyLabel, layout.leftColumn.labelOffset) + drawStaticLayoutAt(surveyValue, layout.leftColumn.valueOffset) + drawStaticLayoutAt(jobLabel, layout.centerColumn.labelOffset) + drawStaticLayoutAt(jobValue, layout.centerColumn.valueOffset) + drawStaticLayoutAt(timestamp, layout.rightTextOffset) + cursor.moveTo(layout.nextCursorY) + } + + private fun drawPageFooter() { + val layout = PageFooterLayout.compute(footerHeight = footerLayout.height.toFloat()) + drawStaticLayoutAt(footerLayout, layout.footerTextOffset) + totalPages?.let { total -> + val pageNumber = + staticLayout( + "${pageController.pageCount}/$total", + paints.meta, + layout.pageNumberMaxWidth, + alignment = Layout.Alignment.ALIGN_OPPOSITE, + maxLines = 1, + ) + drawStaticLayoutAt(pageNumber, layout.pageNumberOffset) + } + } + + private fun drawTableRow(questionText: String, answerText: String, photo: PdfImage?) { + val questionLayout = staticLayout(questionText, paints.body, TableLayout.TASK_TEXT_WIDTH) + val answerLayout = + answerText + .takeIf { it.isNotEmpty() } + ?.let { staticLayout(it, paints.body, TableLayout.ANSWER_TEXT_WIDTH) } + val photoSize = photo?.let { + fitInside(it.width, it.height, TableLayout.ANSWER_TEXT_WIDTH, TableLayout.PHOTO_MAX_HEIGHT) + } + + val questionHeight = questionLayout.height.toFloat() + val answerHeight = answerLayout?.height?.toFloat() ?: 0f + pageController.newPageIfShort(TableLayout.getRowHeight(questionHeight, answerHeight, photoSize)) + val rowLayout = + TableLayout.getRow( + rowTop = cursor.y, + leftTextHeight = questionHeight, + rightTextHeight = answerHeight, + rightImageSize = photoSize, + includeTopBorder = pageController.isFirstTableRowOnPage, + ) + if (pageController.isFirstTableRowOnPage) { + pageController.consumeFirstTableRowOnPage() + } + + rowLayout.borders.drawableLines.forEach { drawLine(it) } + drawStaticLayoutAt(questionLayout, rowLayout.content.leftTextOffset) + if (answerLayout != null && rowLayout.content.rightTextOffset != null) { + drawStaticLayoutAt(answerLayout, rowLayout.content.rightTextOffset) + } + if (photo != null && rowLayout.content.rightImageFrame != null) { + drawImage(photo, rowLayout.content.rightImageFrame, smoothScaling = true) + } + cursor.advance(rowLayout.totalHeight) + } + + private fun drawStaticLayoutAt(layout: StaticLayout, offset: PdfOffset) = + pdfCanvas.drawStaticLayout(layout, offset.x, offset.y) + + private fun drawImage(image: PdfImage, frame: PdfRect, smoothScaling: Boolean) = + pdfCanvas.drawImage(image, RectF(frame.x, frame.y, frame.right, frame.bottom), smoothScaling) + + private fun drawLine(line: PdfLine) = + pdfCanvas.drawLine(line.startX, line.startY, line.endX, line.endY) + + private fun buildFooterLayout(footer: Footer): StaticLayout { + val footerLabel = footer.dataCollectorLabel + val footerText = + SpannableString("$footerLabel: ${footer.dataCollectorName}, ${footer.userEmail}").apply { + setSpan(StyleSpan(Typeface.BOLD), 0, footerLabel.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + } + return staticLayout( + footerText, + paints.meta, + PageFooterLayout.TEXT_MAX_WIDTH, + maxLines = PageFooterLayout.MAX_LINES, + ) + } + + /** + * Lays out [text] wrapped to [maxWidth]. When [maxLines] is set, overflow is ellipsized so a + * single long value can't grow the layout unboundedly. + */ + private fun staticLayout( + text: CharSequence, + paint: TextPaint, + maxWidth: Int, + alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL, + maxLines: Int = Int.MAX_VALUE, + ): StaticLayout = + StaticLayout.Builder.obtain(text, 0, text.length, paint, maxWidth) + .setAlignment(alignment) + .setLineSpacing(LINE_SPACING, 1f) + .apply { + if (maxLines != Int.MAX_VALUE) { + setMaxLines(maxLines) + setEllipsize(TextUtils.TruncateAt.END) + } + } + .build() +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt new file mode 100644 index 0000000000..4559f1e115 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt @@ -0,0 +1,45 @@ +/* + * 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 + +/** Tracks the current vertical draw position on a page and the space reserved for the footer. */ +internal class PdfCursor( + /** Space kept clear above the bottom margin for the footer. */ + private val footerReserve: Float, + private val pageHeight: Int = PdfConfig.PAGE_HEIGHT, + private val margin: Int = PdfConfig.MARGIN, +) { + var y: Float = margin.toFloat() + private set + + val isAtPageTop: Boolean + get() = y == margin.toFloat() + + fun reset() { + y = margin.toFloat() + } + + fun moveTo(absoluteY: Float) { + y = absoluteY + } + + fun advance(delta: Float) { + y += delta + } + + /** Whether a block of the given [height] still fits above the footer reserve on this page. */ + fun fits(height: Float): Boolean = y + height <= pageHeight - margin - footerReserve +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt new file mode 100644 index 0000000000..3224310098 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt @@ -0,0 +1,73 @@ +/* + * 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 + +/** + * Platform-agnostic page state machine for PDF rendering. Delegates the actual page allocation and + * drawing to a platform-specific [PageLifecycle] implementation. + */ +internal class PdfPageController( + private val cursor: PdfCursor, + private val lifecycle: PageLifecycle, +) { + interface PageLifecycle { + /** Called after a new page has been allocated. The header should be drawn here. */ + fun onPageStarted(pageNumber: Int) + + /** Called before the page is closed. The footer and per-page flush should happen here. */ + fun onPageEnding(pageNumber: Int) + } + + private var pageIndex = 0 + private var pageOpen = false + + var isFirstTableRowOnPage = true + private set + + /** Number of pages emitted so far. Equals the current page number while a page is open. */ + val pageCount: Int + get() = pageIndex + + fun ensurePage() { + if (!pageOpen) beginPage() + } + + /** Records that the first table row on the current page has been drawn. */ + fun consumeFirstTableRowOnPage() { + isFirstTableRowOnPage = false + } + + fun newPageIfShort(spaceNeeded: Float) { + ensurePage() + if (cursor.fits(spaceNeeded) || cursor.isAtPageTop) return + finalizePage() + beginPage() + } + + fun finalizePage() { + if (!pageOpen) return + lifecycle.onPageEnding(pageIndex) + pageOpen = false + } + + private fun beginPage() { + pageIndex++ + pageOpen = true + isFirstTableRowOnPage = true + cursor.reset() + lifecycle.onPageStarted(pageIndex) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt new file mode 100644 index 0000000000..263343b1bd --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt @@ -0,0 +1,128 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PdfCursorTest { + + @Test + fun `fresh cursor starts at the top margin`() { + val cursor = newCursor() + + assertEquals(PdfConfig.MARGIN.toFloat(), cursor.y) + assertTrue(cursor.isAtPageTop) + } + + @Test + fun `advance moves the cursor down by the given delta`() { + val cursor = newCursor() + val start = cursor.y + + cursor.advance(75f) + + assertEquals(start + 75f, cursor.y) + assertFalse(cursor.isAtPageTop) + } + + @Test + fun `moveTo sets the cursor to an absolute Y`() { + val cursor = newCursor() + + cursor.moveTo(400f) + + assertEquals(400f, cursor.y) + } + + @Test + fun `reset returns the cursor to the top margin`() { + val cursor = newCursor() + cursor.advance(300f) + + cursor.reset() + + assertEquals(PdfConfig.MARGIN.toFloat(), cursor.y) + assertTrue(cursor.isAtPageTop) + } + + @Test + fun `isAtPageTop reflects whether Y matches the top margin`() { + val cursor = newCursor() + assertTrue(cursor.isAtPageTop) + + cursor.advance(1f) + assertFalse(cursor.isAtPageTop) + + cursor.moveTo(PdfConfig.MARGIN.toFloat()) + assertTrue(cursor.isAtPageTop) + } + + @Test + fun `fits returns true when there is room above the bottom margin`() { + val cursor = newCursor() + + val available = PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN + assertTrue(cursor.fits(available.toFloat())) + assertTrue(cursor.fits(10f)) + } + + @Test + fun `fits returns false when the requested height overflows the bottom margin`() { + val cursor = newCursor() + + val available = PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN + assertFalse(cursor.fits(available + 1f)) + } + + @Test + fun `fits subtracts the footer reserve from the available space`() { + val cursor = newCursor(footerReserve = 50f) + val available = (PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN).toFloat() + + assertTrue(cursor.fits(available - 50f)) + assertFalse(cursor.fits(available - 49f)) + } + + @Test + fun `fits depends on the current Y position`() { + val cursor = newCursor() + val available = (PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN).toFloat() + + cursor.advance(100f) + + assertTrue(cursor.fits(available - 100f)) + assertFalse(cursor.fits(available - 99f)) + } + + @Test + fun `custom page height and margin are respected`() { + val cursor = newCursor(pageHeight = 200, margin = 10) + + assertEquals(10f, cursor.y) + // Usable height = 200 - 2*10 = 180. + assertTrue(cursor.fits(180f)) + assertFalse(cursor.fits(181f)) + } + + private fun newCursor( + footerReserve: Float = 0f, + pageHeight: Int = PdfConfig.PAGE_HEIGHT, + margin: Int = PdfConfig.MARGIN, + ) = PdfCursor(footerReserve = footerReserve, pageHeight = pageHeight, margin = margin) +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt new file mode 100644 index 0000000000..bc2ebdc2a9 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt @@ -0,0 +1,198 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PdfPageControllerTest { + + private val lifecycle = + object : PdfPageController.PageLifecycle { + val events: MutableList = mutableListOf() + + override fun onPageStarted(pageNumber: Int) { + events += PageEvent.Started(pageNumber) + } + + override fun onPageEnding(pageNumber: Int) { + events += PageEvent.Ending(pageNumber) + } + } + private val cursor = PdfCursor(footerReserve = 0f) + private val controller = PdfPageController(cursor, lifecycle) + + @Test + fun `Should have zero pages at the start`() { + assertEquals(0, controller.pageCount) + assertTrue(lifecycle.events.isEmpty()) + } + + @Test + fun `ensurePage starts the a page`() { + controller.ensurePage() + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1)), lifecycle.events) + } + + @Test + fun `ensurePage is idempotent while the page is open`() { + controller.ensurePage() + controller.ensurePage() + controller.ensurePage() + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1)), lifecycle.events) + } + + @Test + fun `finalizePage does nothing if there is no page open`() { + controller.finalizePage() + + assertEquals(0, controller.pageCount) + assertTrue(lifecycle.events.isEmpty()) + } + + @Test + fun `finalizePage does nothing when the page is already closed`() { + controller.ensurePage() + controller.finalizePage() + controller.finalizePage() + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1), PageEvent.Ending(1)), lifecycle.events) + } + + @Test + fun `ensurePage followed by finalize emits start and end events`() { + controller.ensurePage() + controller.finalizePage() + + assertEquals(listOf(PageEvent.Started(1), PageEvent.Ending(1)), lifecycle.events) + } + + @Test + fun `newPageIfShort starts a page if there is none open`() { + controller.newPageIfShort(spaceNeeded = 10f) + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1)), lifecycle.events) + } + + @Test + fun `newPageIfShort does not emit more pages if impossible to fit content in a new page`() { + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1)), lifecycle.events) + } + + @Test + fun `newPageIfShort does nothing if the content fits in the current page`() { + controller.ensurePage() + lifecycle.events.clear() + + controller.newPageIfShort(spaceNeeded = 10f) + + assertEquals(1, controller.pageCount) + assertTrue(lifecycle.events.isEmpty()) + } + + @Test + fun `newPageIfShort starts a new page if the content overflows`() { + controller.ensurePage() + cursor.advance(100f) + lifecycle.events.clear() + + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + + assertEquals(2, controller.pageCount) + assertEquals(listOf(PageEvent.Ending(1), PageEvent.Started(2)), lifecycle.events) + } + + @Test + fun `newPageIfShort should set the cursor at the start of the new page`() { + controller.ensurePage() + cursor.advance(500f) + + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + + assertEquals(PdfConfig.MARGIN.toFloat(), cursor.y) + } + + @Test + fun `Adding multiple pages emits the correct start and end events`() { + controller.ensurePage() + cursor.advance(100f) + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + cursor.advance(100f) + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + controller.finalizePage() + + assertEquals(3, controller.pageCount) + assertEquals( + listOf( + PageEvent.Started(1), + PageEvent.Ending(1), + PageEvent.Started(2), + PageEvent.Ending(2), + PageEvent.Started(3), + PageEvent.Ending(3), + ), + lifecycle.events, + ) + } + + @Test + fun `pageCount reflects the current page number while the page is open`() { + controller.ensurePage() + assertEquals(1, controller.pageCount) + + cursor.advance(100f) + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + assertEquals(2, controller.pageCount) + } + + @Test + fun `isFirstTableRowOnPage is true until consumed`() { + controller.ensurePage() + + assertTrue(controller.isFirstTableRowOnPage) + controller.consumeFirstTableRowOnPage() + assertFalse(controller.isFirstTableRowOnPage) + } + + @Test + fun `isFirstTableRowOnPage resets to true on a new page`() { + controller.ensurePage() + controller.consumeFirstTableRowOnPage() + assertFalse(controller.isFirstTableRowOnPage) + + cursor.advance(100f) + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + + assertTrue(controller.isFirstTableRowOnPage) + } + + private sealed interface PageEvent { + data class Started(val pageNumber: Int) : PageEvent + + data class Ending(val pageNumber: Int) : PageEvent + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSetTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSetTest.kt new file mode 100644 index 0000000000..78415a5adc --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSetTest.kt @@ -0,0 +1,49 @@ +/* + * 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.image + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.groundplatform.feature.pdf.render.image.PdfImageSet.ImageRef + +class PdfImageSetTest { + + @Test + fun `get returns null for a ref that is not in the set`() { + val set = PdfImageSet(emptyMap()) + + assertNull(set[ImageRef.Qr]) + assertNull(set[ImageRef.Photo("missing.jpg")]) + } + + @Test + fun `release invokes the onRelease callback`() { + var released = 0 + val set = PdfImageSet(emptyMap(), onRelease = { released++ }) + + set.release() + + assertEquals(1, released) + } + + @Test + fun `Photo refs are equal when their filenames match`() { + assertEquals(ImageRef.Photo("a.jpg"), ImageRef.Photo("a.jpg")) + assertTrue(ImageRef.Photo("a.jpg") != ImageRef.Photo("b.jpg")) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c0272ac034..dde3c5849a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,7 @@ coreTestingVersion = "1.1.1" coreVersion = "1.7.0" coroutinesVersion = "1.11.0" detektVersion = "1.23.8" +exifInterfaceVersion = "1.4.2" espressoContribVersion = "3.7.0" firebaseBomVersion = "34.14.1" firebaseCrashlyticsGradleVersion = "3.0.7" @@ -91,6 +92,7 @@ androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4 androidx-core = { module = "androidx.test:core", version.ref = "coreVersion" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtxVersion" } androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } +androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifInterfaceVersion" } androidx-espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "espressoContribVersion" } androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoContribVersion" } androidx-espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "espressoContribVersion" }