From 660f653017ccad73b22cb34c4ceddc9ab0f09cd4 Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 28 May 2026 18:33:35 +0200 Subject: [PATCH 01/12] add new strings for pdf labels --- core/ui/src/commonMain/composeResources/values-es/strings.xml | 4 ++++ core/ui/src/commonMain/composeResources/values-fr/strings.xml | 4 ++++ core/ui/src/commonMain/composeResources/values-lo/strings.xml | 4 ++++ core/ui/src/commonMain/composeResources/values-pt/strings.xml | 4 ++++ core/ui/src/commonMain/composeResources/values-th/strings.xml | 4 ++++ core/ui/src/commonMain/composeResources/values-vi/strings.xml | 4 ++++ core/ui/src/commonMain/composeResources/values/strings.xml | 4 ++++ 7 files changed, 28 insertions(+) diff --git a/core/ui/src/commonMain/composeResources/values-es/strings.xml b/core/ui/src/commonMain/composeResources/values-es/strings.xml index 1441bfe99a..20c08f2b4c 100644 --- a/core/ui/src/commonMain/composeResources/values-es/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-es/strings.xml @@ -29,4 +29,8 @@ S E O + Presentación + Encuesta + Trabajo + Recolector de datos diff --git a/core/ui/src/commonMain/composeResources/values-fr/strings.xml b/core/ui/src/commonMain/composeResources/values-fr/strings.xml index 04072375f2..faa708a94a 100644 --- a/core/ui/src/commonMain/composeResources/values-fr/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-fr/strings.xml @@ -28,4 +28,8 @@ S E O + Soumission + Enquête + Mission + Collecteur de données diff --git a/core/ui/src/commonMain/composeResources/values-lo/strings.xml b/core/ui/src/commonMain/composeResources/values-lo/strings.xml index c95e27b274..8d231efd3a 100644 --- a/core/ui/src/commonMain/composeResources/values-lo/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-lo/strings.xml @@ -28,4 +28,8 @@ ຕ.ອ ຕ.ຕ + ການສົ່ງຂໍ້ມູນ + ແບບສຳຫຼວດ + ພາລະກິດ + ຜູ້ເກັບຂໍ້ມູນ diff --git a/core/ui/src/commonMain/composeResources/values-pt/strings.xml b/core/ui/src/commonMain/composeResources/values-pt/strings.xml index bc6c073d85..fb03d932f4 100644 --- a/core/ui/src/commonMain/composeResources/values-pt/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-pt/strings.xml @@ -29,4 +29,8 @@ S E O + Submissão + Inquérito + Tarefa + Coletor de dados diff --git a/core/ui/src/commonMain/composeResources/values-th/strings.xml b/core/ui/src/commonMain/composeResources/values-th/strings.xml index e9c343f710..64e8abec9a 100644 --- a/core/ui/src/commonMain/composeResources/values-th/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-th/strings.xml @@ -28,4 +28,8 @@ ท.ใต้ ต.ออก ต.ตก + การส่งข้อมูล + แบบสำรวจ + งาน + ผู้เก็บข้อมูล diff --git a/core/ui/src/commonMain/composeResources/values-vi/strings.xml b/core/ui/src/commonMain/composeResources/values-vi/strings.xml index 2a1ef5fe2f..a6e9eb9712 100644 --- a/core/ui/src/commonMain/composeResources/values-vi/strings.xml +++ b/core/ui/src/commonMain/composeResources/values-vi/strings.xml @@ -28,4 +28,8 @@ Nam Đông Tây + bài gửi + Khảo sát + Công việc + Người thu thập dữ liệu diff --git a/core/ui/src/commonMain/composeResources/values/strings.xml b/core/ui/src/commonMain/composeResources/values/strings.xml index d7a73b1469..5b19452a85 100644 --- a/core/ui/src/commonMain/composeResources/values/strings.xml +++ b/core/ui/src/commonMain/composeResources/values/strings.xml @@ -29,4 +29,8 @@ S E W + Submission + Survey + Job + Data collector From 3b8cb9ebc0b232d484d91701939ebf76f7630572 Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 28 May 2026 18:38:45 +0200 Subject: [PATCH 02/12] add entry point for pdf export PdfExportService and the interfaces that will handle the different steps --- app/src/main/res/xml/file_paths.xml | 3 + .../ui/system/pdf/render/image/PdfImage.kt} | 19 +++-- .../ui/system/pdf/PdfExportService.kt | 71 +++++++++++++++++++ .../ui/system/pdf/PdfImageProvider.kt | 26 +++++++ .../ui/system/pdf/PdfOutputProvider.kt | 42 +++++++++++ .../ui/system/pdf/PdfRenderer.kt | 28 ++++++++ .../ui/system/pdf/PdfReportLauncher.kt | 26 +++++++ .../ui/system/pdf/render/image/PdfImage.kt | 25 +++++++ .../ui/system/pdf/render/image/PdfImageSet.kt | 37 ++++++++++ .../ui/system/pdf/render/image/PdfImage.kt | 30 ++++++++ 10 files changed, 301 insertions(+), 6 deletions(-) rename core/ui/src/{jvmMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.jvm.kt => androidMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt} (59%) create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfExportService.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfImageProvider.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfOutputProvider.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfRenderer.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfReportLauncher.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImageSet.kt create mode 100644 core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index e58780065b..6500c99a78 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -19,4 +19,7 @@ + \ No newline at end of file diff --git a/core/ui/src/jvmMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.jvm.kt b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt similarity index 59% rename from core/ui/src/jvmMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.jvm.kt rename to core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt index edc40ef030..df3fd43328 100644 --- a/core/ui/src/jvmMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.jvm.kt +++ b/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt @@ -13,11 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.components.qrcode +package org.groundplatform.ui.system.pdf.render.image -import androidx.compose.ui.graphics.ImageBitmap +import android.graphics.Bitmap -actual fun generateQrBitmap(content: String, useHighEcc: Boolean): ImageBitmap = - // The JVM target exists only to unit-test platform-independent logic, so QR generation is - // intentionally unimplemented here. - throw UnsupportedOperationException("QR code generation is not supported on the JVM target") +/** + * Android wraps a [android.graphics.Bitmap]. The renderer reads [bitmap] directly to draw on a + * Canvas. + */ +actual data class PdfImage(val bitmap: Bitmap) { + actual val width: Int + get() = bitmap.width + + actual val height: Int + get() = bitmap.height +} diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfExportService.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfExportService.kt new file mode 100644 index 0000000000..e1ce864805 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfExportService.kt @@ -0,0 +1,71 @@ +/* + * 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.ui.system.pdf + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.groundplatform.ui.model.SubmissionPdfDocument + +/** + * Shared entry point for the PDF export flow. + * + * Loads images, renders the document to disk, then opens or shares the file using + * [PdfReportLauncher]. + */ +class PdfExportService( + private val imageProvider: PdfImageProvider, + private val renderer: PdfRenderer, + private val outputProvider: PdfOutputProvider, + private val launcher: PdfReportLauncher, + private val coroutineDispatcher: CoroutineDispatcher, +) { + private val mutex = Mutex() + + suspend fun export(request: Request, action: Action) { + val outputPath = mutex.withLock { + withContext(coroutineDispatcher) { + outputProvider.pruneOldFiles() + val path = outputProvider.newFilePath(request.fileName) + if (!outputProvider.exists(request.fileName)) { + val images = imageProvider.load(request.qrContent, request.document.photoFilenames()) + try { + renderer.render(request.document, images, path) + } finally { + images.release() + } + } + path + } + } + when (action) { + Action.Open -> launcher.open(outputPath) + Action.Share -> launcher.share(outputPath) + } + } + + enum class Action { + Open, + Share, + } + + data class Request( + val document: SubmissionPdfDocument, + val qrContent: String?, + val fileName: String, + ) +} diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfImageProvider.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfImageProvider.kt new file mode 100644 index 0000000000..d8d0d7fde3 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfImageProvider.kt @@ -0,0 +1,26 @@ +/* + * 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.ui.system.pdf + +import org.groundplatform.ui.system.pdf.render.image.PdfImageSet + +/** + * Platform abstraction for images (QR code and photos) needed for PDF rendering. Implementations + * should handle bitmap decoding and resource lifecycle management. + */ +interface PdfImageProvider { + suspend fun load(qrContent: String?, photoFilenames: Set): PdfImageSet +} diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfOutputProvider.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfOutputProvider.kt new file mode 100644 index 0000000000..4f75a937fe --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfOutputProvider.kt @@ -0,0 +1,42 @@ +/* + * 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.ui.system.pdf + +import kotlin.time.Clock + +private const val MAX_AGE_MILLIS = 7L * 24 * 60 * 60 * 1000 // 1 week + +/** Manages file paths and cached PDF reports. */ +interface PdfOutputProvider { + fun newFilePath(name: String): String + + fun exists(name: String): Boolean + + fun listFiles(): List + + fun deleteReport(path: String) + + /** Removes cached reports older than [MAX_AGE_MILLIS] (1 week). */ + fun pruneOldFiles() { + val now = Clock.System.now().toEpochMilliseconds() + listFiles() + .filter { now - it.lastModifiedMillis > MAX_AGE_MILLIS } + .forEach { deleteReport(it.path) } + } + + /** A cached PDF file entry with its path and last-modified timestamp. */ + data class CachedPdf(val path: String, val lastModifiedMillis: Long) +} diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfRenderer.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfRenderer.kt new file mode 100644 index 0000000000..6fe97b60ae --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfRenderer.kt @@ -0,0 +1,28 @@ +/* + * 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.ui.system.pdf + +import org.groundplatform.ui.model.SubmissionPdfDocument +import org.groundplatform.ui.system.pdf.render.image.PdfImageSet + +/** + * Rasterises a [SubmissionPdfDocument] to a PDF file. Each platform should use its native text + * layout and PDF APIs to handle wrapping, pagination, and drawing. Writes the result to the + * provided output path. + */ +interface PdfRenderer { + suspend fun render(document: SubmissionPdfDocument, images: PdfImageSet, outputPath: String) +} diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfReportLauncher.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfReportLauncher.kt new file mode 100644 index 0000000000..c6d764e288 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfReportLauncher.kt @@ -0,0 +1,26 @@ +/* + * 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.ui.system.pdf + +/** + * Presents the two terminal actions on a generated report: share via system share sheet, open in an + * external viewer. + */ +interface PdfReportLauncher { + fun share(path: String) + + fun open(path: String) +} diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt new file mode 100644 index 0000000000..c4d3cceaa1 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt @@ -0,0 +1,25 @@ +/* + * 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.ui.system.pdf.render.image + +/** + * Opaque platform image handle (Android `Bitmap`, iOS `CGImageRef`). The renderer reads the + * platform value via the `actual` declaration. + */ +expect class PdfImage { + val width: Int + val height: Int +} diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImageSet.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImageSet.kt new file mode 100644 index 0000000000..ee5d874606 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImageSet.kt @@ -0,0 +1,37 @@ +/* + * 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.ui.system.pdf.render.image + +/** + * The set of images needed to render a single report. Owns the lifecycle of the platform handles, + * call [release] in a `finally` block to free native resources. + */ +class PdfImageSet( + private val images: Map, + private val onRelease: () -> Unit = {}, +) { + operator fun get(ref: ImageRef): PdfImage? = images[ref] + + fun release() { + onRelease() + } + + sealed interface ImageRef { + data object Qr : ImageRef + + data class Photo(val filename: String) : ImageRef + } +} diff --git a/core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt new file mode 100644 index 0000000000..aa620bbec6 --- /dev/null +++ b/core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt @@ -0,0 +1,30 @@ +/* + * 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.ui.system.pdf.render.image + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.CoreGraphics.CGImageGetHeight +import platform.CoreGraphics.CGImageGetWidth +import platform.CoreGraphics.CGImageRef + +@OptIn(ExperimentalForeignApi::class) +actual data class PdfImage(val cgImage: CGImageRef) { + actual val width: Int + get() = CGImageGetWidth(cgImage).toInt() + + actual val height: Int + get() = CGImageGetHeight(cgImage).toInt() +} From b061d06b43134d5a9d2e97bba48cd2cede5de4f5 Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 28 May 2026 18:46:17 +0200 Subject: [PATCH 03/12] update LoiReport and add mapper to map the data to the PdfExportService.Request --- .../DataCollectionScreenPreviews.kt | 5 +- .../DataSubmissionConfirmationScreen.kt | 20 ++- .../mapcontainer/jobs/ShareLocationModal.kt | 20 ++- .../org/groundplatform/android/FakeData.kt | 5 +- .../DataSubmissionConfirmationScreenTest.kt | 10 +- .../home/mapcontainer/jobs/LoiJobSheetTest.kt | 5 +- .../jobs/ShareLocationModalTest.kt | 13 +- .../model/locationofinterest/LoiReport.kt | 15 +- .../domain/usecases/GetLoiReportUseCase.kt | 8 +- core/testing/build.gradle.kts | 1 + .../testing/FakeDataGenerator.kt | 49 +++++++ .../ui/mapper/LoiReportMapper.kt | 107 +++++++++++++++ .../ui/mapper/LoiReportMapperTest.kt | 128 ++++++++++++++++++ 13 files changed, 327 insertions(+), 59 deletions(-) create mode 100644 core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/LoiReportMapper.kt create mode 100644 core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/LoiReportMapperTest.kt diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenPreviews.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenPreviews.kt index e338cd6bc2..d3777b4adc 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenPreviews.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenPreviews.kt @@ -101,12 +101,9 @@ private fun DataCollectionContentCompletePreview() { DataCollectionUiState.TaskSubmitted( loiReport = LoiReport( - surveyName = "Test Survey", - userName = "John Doe", - dateMillis = Clock.System.now().toEpochMilliseconds(), loiName = "Point A", geoJson = JsonObject(mapOf()), - submissions = emptyList() + submissionDetails = null ) ), onCloseClicked = {}, diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt index 6b1b24b742..4ae3e98a9e 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt @@ -45,15 +45,13 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import ground_android.core.ui.generated.resources.Res -import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson -import org.jetbrains.compose.resources.stringResource as multiplatformStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import ground_android.core.ui.generated.resources.Res +import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson import java.util.Date -import kotlin.time.Clock import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -63,6 +61,7 @@ import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.ui.components.loireport.SubmissionPdfItem import org.groundplatform.ui.components.qrcode.GroundQrCode import org.groundplatform.ui.theme.AppTheme +import org.jetbrains.compose.resources.stringResource as multiplatformStringResource @Composable fun DataSubmissionConfirmationScreen( @@ -164,13 +163,13 @@ private fun ShareableContent(modifier: Modifier = Modifier, loiReport: LoiReport ) } - loiReport.submissions?.let { + loiReport.submissionDetails?.let { SubmissionPdfItem( modifier = Modifier.fillMaxWidth().padding(top = 16.dp), - title = loiReport.surveyName, + title = it.surveyName, loiName = loiReport.loiName, - userName = loiReport.userName, - date = DateFormat.getDateFormat(context).format(Date(loiReport.dateMillis)), + userName = it.userName, + date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), onItemClick = { /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ }, @@ -185,9 +184,6 @@ private fun ShareableContent(modifier: Modifier = Modifier, loiReport: LoiReport private val testLoiReport = LoiReport( - surveyName = "Test Survey", - userName = "John Doe", - dateMillis = Clock.System.now().toEpochMilliseconds(), loiName = "Test LOI", geoJson = JsonObject( @@ -203,7 +199,7 @@ private val testLoiReport = ), ) ), - submissions = emptyList(), + submissionDetails = null, ) @Composable diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt index 54e9c1b782..80f5a3f12f 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt @@ -38,16 +38,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import ground_android.core.ui.generated.resources.Res -import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson -import org.jetbrains.compose.resources.stringResource as multiplatformStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import ground_android.core.ui.generated.resources.Res +import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson import java.util.Date -import kotlin.time.Clock import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -56,6 +54,7 @@ import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.ui.components.loireport.SubmissionPdfItem import org.groundplatform.ui.components.qrcode.GroundQrCode import org.groundplatform.ui.theme.AppTheme +import org.jetbrains.compose.resources.stringResource as multiplatformStringResource @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -95,13 +94,13 @@ fun ShareLocationModal(loiReport: LoiReport, onDismiss: () -> Unit) { ) } - loiReport.submissions?.let { + loiReport.submissionDetails?.let { SubmissionPdfItem( modifier = Modifier.fillMaxWidth(), - title = loiReport.surveyName, + title = it.surveyName, loiName = loiReport.loiName, - userName = loiReport.userName, - date = DateFormat.getDateFormat(context).format(Date(loiReport.dateMillis)), + userName = it.userName, + date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), onItemClick = { /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ }, @@ -128,9 +127,6 @@ private fun ShareLocationModalPreview() { val testLoiReport = LoiReport( loiName = "Test LOI", - surveyName = "Test Survey", - userName = "John Doe", - dateMillis = Clock.System.now().toEpochMilliseconds(), geoJson = JsonObject( mapOf( @@ -145,7 +141,7 @@ private fun ShareLocationModalPreview() { ), ) ), - submissions = emptyList(), + submissionDetails = null, ) AppTheme { diff --git a/app/src/test/java/org/groundplatform/android/FakeData.kt b/app/src/test/java/org/groundplatform/android/FakeData.kt index 5e7892b7c8..e858c36b16 100644 --- a/app/src/test/java/org/groundplatform/android/FakeData.kt +++ b/app/src/test/java/org/groundplatform/android/FakeData.kt @@ -111,10 +111,7 @@ object FakeData { val LOCATION_OF_INTEREST_LOI_REPORT = LoiReport( - surveyName = SURVEY.title, loiName = "Unnamed point", - userName = USER.displayName, - dateMillis = LOCATION_OF_INTEREST.lastModified.clientTimestamp, geoJson = JsonObject( mapOf( @@ -132,7 +129,7 @@ object FakeData { ), ) ), - submissions = null, + submissionDetails = null, ) val LOCATION_OF_INTEREST_FEATURE = Feature( diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreenTest.kt index d5c0226660..3b558eea13 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreenTest.kt @@ -32,6 +32,7 @@ import kotlinx.serialization.json.JsonPrimitive import org.groundplatform.android.R import org.groundplatform.android.getString import org.groundplatform.domain.model.locationofinterest.LoiReport +import org.groundplatform.testing.FakeDataGenerator import org.groundplatform.ui.components.loireport.TEST_TAG_PDF_ITEM import org.groundplatform.ui.components.qrcode.TEST_TAG_GROUND_QR_CODE import org.junit.Assert.assertTrue @@ -104,7 +105,7 @@ class DataSubmissionConfirmationScreenTest { fun `Shows the PDF item when submissions is not null`() { composeTestRule.setContent { DataSubmissionConfirmationScreen( - loiReport = LOI_REPORT.copy(submissions = emptyList()), + loiReport = LOI_REPORT.copy(submissionDetails = FakeDataGenerator.newSubmissionDetails()), onDismissed = {}, ) } @@ -116,7 +117,7 @@ class DataSubmissionConfirmationScreenTest { fun `Does not show the PDF item when submissions is null`() { composeTestRule.setContent { DataSubmissionConfirmationScreen( - loiReport = LOI_REPORT.copy(submissions = null), + loiReport = LOI_REPORT.copy(submissionDetails = null), onDismissed = {}, ) } @@ -140,10 +141,7 @@ class DataSubmissionConfirmationScreenTest { private companion object { private val LOI_REPORT = LoiReport( - surveyName = "Test Survey", loiName = "Test LOI", - userName = "John Doe", - dateMillis = 987654321L, geoJson = JsonObject( mapOf( @@ -158,7 +156,7 @@ class DataSubmissionConfirmationScreenTest { ), ) ), - submissions = emptyList(), + submissionDetails = null, ) } } diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/LoiJobSheetTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/LoiJobSheetTest.kt index 57e2fa1254..bea437a77a 100644 --- a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/LoiJobSheetTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/LoiJobSheetTest.kt @@ -125,10 +125,7 @@ class LoiJobSheetTest { private fun getLoiReport(name: String): LoiReport = LoiReport( - surveyName = "Test Survey", loiName = name, - userName = "John Doe", - dateMillis = 987654321L, geoJson = JsonObject( mapOf( @@ -143,7 +140,7 @@ class LoiJobSheetTest { ), ) ), - submissions = null, + submissionDetails = null, ) companion object { diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModalTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModalTest.kt index eee9bda893..8ffad04946 100644 --- a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModalTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModalTest.kt @@ -29,6 +29,7 @@ import kotlinx.serialization.json.JsonPrimitive import org.groundplatform.android.R import org.groundplatform.android.getString import org.groundplatform.domain.model.locationofinterest.LoiReport +import org.groundplatform.testing.FakeDataGenerator import org.groundplatform.ui.components.loireport.TEST_TAG_PDF_ITEM import org.groundplatform.ui.components.qrcode.TEST_TAG_GROUND_QR_CODE import org.groundplatform.ui.theme.AppTheme @@ -61,7 +62,10 @@ class ShareLocationModalTest { fun `Shows the PDF item when submissions is not null`() { composeTestRule.setContent { AppTheme { - ShareLocationModal(loiReport = LOI_REPORT.copy(submissions = emptyList()), onDismiss = {}) + ShareLocationModal( + loiReport = LOI_REPORT.copy(submissionDetails = FakeDataGenerator.newSubmissionDetails()), + onDismiss = {}, + ) } } @@ -72,7 +76,7 @@ class ShareLocationModalTest { fun `Does not show the PDF item when submissions is null`() { composeTestRule.setContent { AppTheme { - ShareLocationModal(loiReport = LOI_REPORT.copy(submissions = null), onDismiss = {}) + ShareLocationModal(loiReport = LOI_REPORT.copy(submissionDetails = null), onDismiss = {}) } } @@ -96,10 +100,7 @@ class ShareLocationModalTest { const val LOI_NAME = "Test Loi" val LOI_REPORT = LoiReport( - surveyName = "Test Survey", loiName = LOI_NAME, - userName = "John Doe", - dateMillis = 987654321L, geoJson = JsonObject( mapOf( @@ -114,7 +115,7 @@ class ShareLocationModalTest { ), ) ), - submissions = null, + submissionDetails = null, ) } } diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/locationofinterest/LoiReport.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/locationofinterest/LoiReport.kt index 5c43e198e4..f558df72f5 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/locationofinterest/LoiReport.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/locationofinterest/LoiReport.kt @@ -20,10 +20,15 @@ import org.groundplatform.domain.model.submission.Submission /** Represents the data collected for a specific LOI which can be downloaded and shared. */ data class LoiReport( - val surveyName: String, val loiName: String, - val userName: String, - val dateMillis: Long, val geoJson: JsonObject, - val submissions: List?, -) + val submissionDetails: SubmissionDetails?, +) { + data class SubmissionDetails( + val surveyName: String, + val userName: String, + val userEmail: String, + val dateMillis: Long, + val submissions: List?, + ) +} diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt index 1078b0d2f1..e6ef3e833e 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt @@ -56,19 +56,15 @@ class GetLoiReportUseCase( val loi = locationOfInterestRepository.getOfflineLoi(surveyId, loiId) val user = userRepositoryInterface.getAuthenticatedUser() val surveyName = surveyRepositoryInterface.getOfflineSurvey(surveyId)?.title.orEmpty() - val submissions = null // To be implemented in a follow-up on - // https://github.com/google/ground-android/issues/3715 return loi?.let { LoiReport( - surveyName = surveyName, loiName = loiName, - userName = user.displayName, - dateMillis = it.lastModified.clientTimestamp, geoJson = it.geometry.toGeoJson( it.properties.filter { property -> property.key == LOI_NAME_PROPERTY } ), - submissions = submissions, + submissionDetails = null, // To be implemented in a follow-up on + // https://github.com/google/ground-android/issues/3715 ) } } diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index fa9c565cbd..89b455a0ad 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -29,6 +29,7 @@ kotlin { implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) } } } diff --git a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDataGenerator.kt b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDataGenerator.kt index 44af15f04e..a4ad9d8f99 100644 --- a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDataGenerator.kt +++ b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDataGenerator.kt @@ -16,6 +16,7 @@ package org.groundplatform.testing import kotlin.time.Clock +import kotlinx.serialization.json.JsonObject import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.SurveyListItem import org.groundplatform.domain.model.User @@ -27,6 +28,7 @@ import org.groundplatform.domain.model.job.Style import org.groundplatform.domain.model.locationofinterest.AuditInfo import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY import org.groundplatform.domain.model.locationofinterest.LocationOfInterest +import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.domain.model.mutation.LocationOfInterestMutation import org.groundplatform.domain.model.mutation.Mutation import org.groundplatform.domain.model.mutation.Mutation.SyncStatus.PENDING @@ -34,6 +36,8 @@ import org.groundplatform.domain.model.mutation.SubmissionMutation import org.groundplatform.domain.model.settings.MeasurementUnits import org.groundplatform.domain.model.settings.UserSettings import org.groundplatform.domain.model.submission.DraftSubmission +import org.groundplatform.domain.model.submission.Submission +import org.groundplatform.domain.model.submission.SubmissionData import org.groundplatform.domain.model.submission.ValueDelta import org.groundplatform.domain.model.task.Condition import org.groundplatform.domain.model.task.MultipleChoice @@ -132,6 +136,25 @@ object FakeDataGenerator { currentTaskId = currentTaskId, ) + fun newSubmission( + id: String = "submission id", + surveyId: String = "survey id", + locationOfInterest: LocationOfInterest = newLocationOfInterest(), + job: Job = newJob(), + created: AuditInfo = AuditInfo(newUser()), + lastModified: AuditInfo = AuditInfo(newUser()), + data: SubmissionData = SubmissionData(), + ): Submission = + Submission( + id = id, + surveyId = surveyId, + locationOfInterest = locationOfInterest, + job = job, + created = created, + lastModified = lastModified, + data = data, + ) + fun newTask( id: String = "taskId", type: Task.Type = Task.Type.TEXT, @@ -220,4 +243,30 @@ object FakeDataGenerator { availableOffline: Boolean = false, generalAccess: Survey.GeneralAccess = Survey.GeneralAccess.PUBLIC, ) = SurveyListItem(id, title, description, availableOffline, generalAccess) + + fun newSubmissionDetails( + surveyName: String = "survey", + userName: String = "user", + userEmail: String = "user@email.com", + dateMillis: Long = 0L, + submissions: List = listOf(newSubmission()), + ): LoiReport.SubmissionDetails = + LoiReport.SubmissionDetails( + surveyName = surveyName, + userName = userName, + userEmail = userEmail, + dateMillis = dateMillis, + submissions = submissions, + ) + + fun newLoiReport( + loiName: String = "loi", + geoJson: JsonObject = JsonObject(emptyMap()), + submissionDetails: LoiReport.SubmissionDetails? = newSubmissionDetails(), + ): LoiReport = + LoiReport( + loiName = loiName, + geoJson = geoJson, + submissionDetails = submissionDetails, + ) } diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/LoiReportMapper.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/LoiReportMapper.kt new file mode 100644 index 0000000000..f706fc5011 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/LoiReportMapper.kt @@ -0,0 +1,107 @@ +/* + * 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.ui.mapper + +import ground_android.core.ui.generated.resources.Res +import ground_android.core.ui.generated.resources.job +import ground_android.core.ui.generated.resources.pdf_data_collector +import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson +import ground_android.core.ui.generated.resources.submission +import ground_android.core.ui.generated.resources.survey +import org.groundplatform.domain.model.locationofinterest.LoiReport +import org.groundplatform.domain.model.submission.Submission +import org.groundplatform.ui.model.SubmissionPdfDocument +import org.groundplatform.ui.model.SubmissionPdfDocument.Footer +import org.groundplatform.ui.model.SubmissionPdfDocument.Header +import org.groundplatform.ui.model.SubmissionPdfDocument.QrBlock +import org.groundplatform.ui.model.SubmissionPdfDocument.Row +import org.groundplatform.ui.system.pdf.PdfExportService +import org.groundplatform.ui.util.DateFormatter +import org.groundplatform.ui.util.StringResolver + +/** Maps an [LoiReport] and its [Submission] to a [PdfExportService.Request]. */ +class LoiReportMapper( + private val taskValueMapper: TaskValueMapper, + private val strings: StringResolver, + private val dateFormatter: DateFormatter, +) { + + suspend fun map(loiReport: LoiReport, submission: Submission): PdfExportService.Request? { + val details = loiReport.submissionDetails ?: return null + val rows = buildRows(submission) + val document = + SubmissionPdfDocument( + header = buildHeader(details, submission), + qrBlock = buildQrBlock(), + footer = buildFooter(details), + table = + SubmissionPdfDocument.Table( + submissionLabel = strings.resolve(Res.string.submission), + loiName = loiReport.loiName, + rows = rows, + ), + ) + val timestamp = + "${dateFormatter.formatDate(details.dateMillis)}_${dateFormatter.formatTime(details.dateMillis)}" + val fileName = + listOf(details.surveyName, loiReport.loiName, details.userName, timestamp) + .map { it.filter(::isSafeFileChar) } + .filter { it.isNotBlank() } + .joinToString("_") + .take(200) + + return PdfExportService.Request( + document = document, + qrContent = loiReport.geoJson.toString(), + fileName = fileName, + ) + } + + private suspend fun buildHeader( + details: LoiReport.SubmissionDetails, + submission: Submission, + ): Header = + Header( + surveyLabel = strings.resolve(Res.string.survey), + surveyName = details.surveyName, + jobLabel = strings.resolve(Res.string.job), + jobName = submission.job.name ?: submission.job.id, + timestamp = + "${dateFormatter.formatDate(details.dateMillis)} ${dateFormatter.formatTime(details.dateMillis)}", + ) + + private suspend fun buildQrBlock(): QrBlock = + QrBlock(scanCaption = strings.resolve(Res.string.scan_this_qr_to_download_geojson)) + + private suspend fun buildFooter(details: LoiReport.SubmissionDetails): Footer = + Footer( + dataCollectorLabel = strings.resolve(Res.string.pdf_data_collector), + dataCollectorName = details.userName, + userEmail = details.userEmail, + ) + + private suspend fun buildRows(submission: Submission): List = + submission.job.tasksSorted + .filter { !it.isOmittedFromDocExport() } + .mapNotNull { task -> + submission.data.getValue(task.id)?.let { value -> + Row(question = task.label, answer = taskValueMapper.map(task, value)) + } + } + + private fun isSafeFileChar(c: Char): Boolean = + c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c in "_-" +} diff --git a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/LoiReportMapperTest.kt b/core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/LoiReportMapperTest.kt new file mode 100644 index 0000000000..a8921a0e7f --- /dev/null +++ b/core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/LoiReportMapperTest.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.ui.mapper + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlinx.coroutines.test.runTest +import org.groundplatform.testing.FakeDataGenerator +import org.groundplatform.ui.util.FakeDateFormatter +import org.groundplatform.ui.util.FakeStringResolver + +class LoiReportMapperTest { + + private val mapper = + LoiReportMapper( + taskValueMapper = + TaskValueMapper(strings = FakeStringResolver, dateFormatter = FakeDateFormatter), + strings = FakeStringResolver, + dateFormatter = FakeDateFormatter, + ) + + private val timestampSegment = "DATE0_TIME0" + + @Test + fun `file name joins survey, loi, user, and timestamp with underscores`() = runTest { + val request = + mapper.map( + loiReport = + FakeDataGenerator.newLoiReport( + loiName = "Loi", + submissionDetails = + FakeDataGenerator.newSubmissionDetails(surveyName = "Survey", userName = "User"), + ), + submission = FakeDataGenerator.newSubmission(), + ) + + assertEquals("Survey_Loi_User_$timestampSegment", request!!.fileName) + } + + @Test + fun `file name strips characters outside the ASCII safe set`() = runTest { + val request = + mapper.map( + loiReport = + FakeDataGenerator.newLoiReport( + loiName = "loi/name?", + submissionDetails = + FakeDataGenerator.newSubmissionDetails( + surveyName = "My Survey!", + userName = "user@email.com", + ), + ), + submission = FakeDataGenerator.newSubmission(), + ) + + assertEquals("MySurvey_loiname_useremailcom_$timestampSegment", request!!.fileName) + } + + @Test + fun `file name drops blank segments without leaving double underscores`() = runTest { + val request = + mapper.map( + loiReport = + FakeDataGenerator.newLoiReport( + loiName = "loi", + submissionDetails = + FakeDataGenerator.newSubmissionDetails(surveyName = "", userName = "@@@"), + ), + submission = FakeDataGenerator.newSubmission(), + ) + + assertEquals("loi_$timestampSegment", request!!.fileName) + } + + @Test + fun `file name strips non-ASCII characters`() = runTest { + val request = + mapper.map( + loiReport = + FakeDataGenerator.newLoiReport( + loiName = "ß", + submissionDetails = + FakeDataGenerator.newSubmissionDetails(surveyName = "café", userName = "José"), + ), + submission = FakeDataGenerator.newSubmission(), + ) + + // "café" -> "caf", "ß" -> "", "José" -> "Jos". + assertEquals("caf_Jos_$timestampSegment", request!!.fileName) + } + + @Test + fun `file name is capped at 200 characters`() = runTest { + val request = + mapper.map( + loiReport = + FakeDataGenerator.newLoiReport( + loiName = "x", + submissionDetails = + FakeDataGenerator.newSubmissionDetails(surveyName = "a".repeat(300), userName = "y"), + ), + submission = FakeDataGenerator.newSubmission(), + ) + + assertEquals(200, request!!.fileName.length) + } + + @Test + fun `map returns null when submissionDetails are missing`() = runTest { + val report = FakeDataGenerator.newLoiReport(submissionDetails = null) + + assertNull(mapper.map(report, FakeDataGenerator.newSubmission())) + } +} From 0aa877edcc6040a0df57c19847261d80ebbdf8a5 Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 28 May 2026 18:48:31 +0200 Subject: [PATCH 04/12] remove unneeded code from gradle and move ComposeStringResourcesTest to androidHostTest --- core/ui/build.gradle.kts | 4 ++-- .../ui/resources/ComposeStringResourcesTest.kt | 0 .../org/groundplatform/ui/model/SubmissionPdfDocument.kt | 7 +++++++ 3 files changed, 9 insertions(+), 2 deletions(-) rename core/ui/src/{jvmTest => androidHostTest}/kotlin/org/groundplatform/ui/resources/ComposeStringResourcesTest.kt (100%) diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 5d71bdb988..c6c8bb7246 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -34,8 +34,6 @@ kotlin { withHostTest { isIncludeAndroidResources = true } } - jvm() - val xcfName = "GroundUiKit" listOf(iosArm64(), iosSimulatorArm64()).forEach { it.binaries.framework { @@ -56,6 +54,8 @@ kotlin { implementation(libs.compose.components.resources) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) } } diff --git a/core/ui/src/jvmTest/kotlin/org/groundplatform/ui/resources/ComposeStringResourcesTest.kt b/core/ui/src/androidHostTest/kotlin/org/groundplatform/ui/resources/ComposeStringResourcesTest.kt similarity index 100% rename from core/ui/src/jvmTest/kotlin/org/groundplatform/ui/resources/ComposeStringResourcesTest.kt rename to core/ui/src/androidHostTest/kotlin/org/groundplatform/ui/resources/ComposeStringResourcesTest.kt diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/model/SubmissionPdfDocument.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/model/SubmissionPdfDocument.kt index 2bc0b96325..80eb1d4e76 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/model/SubmissionPdfDocument.kt +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/model/SubmissionPdfDocument.kt @@ -51,4 +51,11 @@ data class SubmissionPdfDocument( val dataCollectorName: String, val userEmail: String, ) + + /** The distinct, non-empty photo filenames referenced by the document's table rows. */ + fun photoFilenames(): Set = + table.rows + .mapNotNull { (it.answer as? Answer.Photo)?.remoteFilename } + .filter { it.isNotEmpty() } + .toSet() } From 414a2d3d6ecb92805b12f1006d156cefa02a16b5 Mon Sep 17 00:00:00 2001 From: andreia Date: Thu, 28 May 2026 18:50:35 +0200 Subject: [PATCH 05/12] fix codecheck issues --- .../ui/datacollection/DataCollectionScreenPreviews.kt | 7 +------ .../groundplatform/domain/usecases/GetLoiReportUseCase.kt | 1 + .../kotlin/org/groundplatform/testing/FakeDataGenerator.kt | 6 +----- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenPreviews.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenPreviews.kt index d3777b4adc..bd1c6946a9 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenPreviews.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenPreviews.kt @@ -24,7 +24,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview -import kotlin.time.Clock import kotlinx.serialization.json.JsonObject import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport import org.groundplatform.domain.model.job.Job @@ -100,11 +99,7 @@ private fun DataCollectionContentCompletePreview() { uiState = DataCollectionUiState.TaskSubmitted( loiReport = - LoiReport( - loiName = "Point A", - geoJson = JsonObject(mapOf()), - submissionDetails = null - ) + LoiReport(loiName = "Point A", geoJson = JsonObject(mapOf()), submissionDetails = null) ), onCloseClicked = {}, ) { diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt index e6ef3e833e..e4a3985363 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt @@ -52,6 +52,7 @@ class GetLoiReportUseCase( * @param surveyId the identifier of the survey the LOI belongs to. * @throws IllegalStateException if the LOI geometry is a bare [LinearRing]. */ + @Suppress("UnusedPrivateProperty") suspend operator fun invoke(loiName: String, loiId: String, surveyId: String): LoiReport? { val loi = locationOfInterestRepository.getOfflineLoi(surveyId, loiId) val user = userRepositoryInterface.getAuthenticatedUser() diff --git a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDataGenerator.kt b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDataGenerator.kt index a4ad9d8f99..5d82702f54 100644 --- a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDataGenerator.kt +++ b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDataGenerator.kt @@ -264,9 +264,5 @@ object FakeDataGenerator { geoJson: JsonObject = JsonObject(emptyMap()), submissionDetails: LoiReport.SubmissionDetails? = newSubmissionDetails(), ): LoiReport = - LoiReport( - loiName = loiName, - geoJson = geoJson, - submissionDetails = submissionDetails, - ) + LoiReport(loiName = loiName, geoJson = geoJson, submissionDetails = submissionDetails) } From 858a7c3e83f9a038e86a18e4771aaedd0f17b535 Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 29 May 2026 12:36:07 +0200 Subject: [PATCH 06/12] extract pdf code to sepparate feature module --- core/testing/build.gradle.kts | 16 +++++- .../testing}/FakeDateFormatter.kt | 4 +- .../testing}/FakeStringResolver.kt | 3 +- core/ui/build.gradle.kts | 5 +- feature/pdf/.gitignore | 1 + feature/pdf/build.gradle.kts | 57 +++++++++++++++++++ .../pdf/render/image/PdfImage.android.kt | 4 +- .../feature}/pdf/PdfExportService.kt | 4 +- .../feature}/pdf/PdfImageProvider.kt | 4 +- .../feature}/pdf/PdfOutputProvider.kt | 2 +- .../feature}/pdf/PdfRenderer.kt | 6 +- .../feature}/pdf/PdfReportLauncher.kt | 2 +- .../feature/pdf}/mapper/LoiReportMapper.kt | 17 +++--- .../feature/pdf}/mapper/TaskValueMapper.kt | 11 ++-- .../pdf}/model/SubmissionPdfDocument.kt | 2 +- .../feature}/pdf/render/image/PdfImage.kt | 2 +- .../feature}/pdf/render/image/PdfImageSet.kt | 2 +- .../pdf}/mapper/LoiReportMapperTest.kt | 8 +-- .../pdf}/mapper/TaskValueMapperTest.kt | 37 ++++++------ .../feature/pdf/render/image/PdfImage.ios.kt | 2 +- settings.gradle | 1 + 21 files changed, 133 insertions(+), 57 deletions(-) rename core/{ui/src/commonTest/kotlin/org/groundplatform/ui/util => testing/src/commonMain/kotlin/org/groundplatform/testing}/FakeDateFormatter.kt (91%) rename core/{ui/src/commonTest/kotlin/org/groundplatform/ui/util => testing/src/commonMain/kotlin/org/groundplatform/testing}/FakeStringResolver.kt (92%) create mode 100644 feature/pdf/.gitignore create mode 100644 feature/pdf/build.gradle.kts rename core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt => feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.android.kt (84%) rename {core/ui/src/commonMain/kotlin/org/groundplatform/ui/system => feature/pdf/src/commonMain/kotlin/org/groundplatform/feature}/pdf/PdfExportService.kt (95%) rename {core/ui/src/commonMain/kotlin/org/groundplatform/ui/system => feature/pdf/src/commonMain/kotlin/org/groundplatform/feature}/pdf/PdfImageProvider.kt (89%) rename {core/ui/src/commonMain/kotlin/org/groundplatform/ui/system => feature/pdf/src/commonMain/kotlin/org/groundplatform/feature}/pdf/PdfOutputProvider.kt (97%) rename {core/ui/src/commonMain/kotlin/org/groundplatform/ui/system => feature/pdf/src/commonMain/kotlin/org/groundplatform/feature}/pdf/PdfRenderer.kt (84%) rename {core/ui/src/commonMain/kotlin/org/groundplatform/ui/system => feature/pdf/src/commonMain/kotlin/org/groundplatform/feature}/pdf/PdfReportLauncher.kt (95%) rename {core/ui/src/commonMain/kotlin/org/groundplatform/ui => feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf}/mapper/LoiReportMapper.kt (87%) rename {core/ui/src/commonMain/kotlin/org/groundplatform/ui => feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf}/mapper/TaskValueMapper.kt (93%) rename {core/ui/src/commonMain/kotlin/org/groundplatform/ui => feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf}/model/SubmissionPdfDocument.kt (97%) rename {core/ui/src/commonMain/kotlin/org/groundplatform/ui/system => feature/pdf/src/commonMain/kotlin/org/groundplatform/feature}/pdf/render/image/PdfImage.kt (93%) rename {core/ui/src/commonMain/kotlin/org/groundplatform/ui/system => feature/pdf/src/commonMain/kotlin/org/groundplatform/feature}/pdf/render/image/PdfImageSet.kt (95%) rename {core/ui/src/commonTest/kotlin/org/groundplatform/ui => feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf}/mapper/LoiReportMapperTest.kt (94%) rename {core/ui/src/commonTest/kotlin/org/groundplatform/ui => feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf}/mapper/TaskValueMapperTest.kt (82%) rename core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt => feature/pdf/src/iosMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.ios.kt (94%) diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index 89b455a0ad..ee1a78ca3b 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -13,11 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -plugins { alias(libs.plugins.kotlin.multiplatform) } +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) + alias(libs.plugins.android.lint) +} kotlin { - jvm() jvmToolchain(libs.versions.jvmToolchainVersion.get().toInt()) + androidLibrary { + namespace = "org.groundplatform.core.testing" + compileSdk = libs.versions.androidCompileSdk.get().toInt() + minSdk = libs.versions.androidMinSdk.get().toInt() + } + + jvm() iosArm64() iosSimulatorArm64() @@ -26,10 +36,12 @@ kotlin { commonMain { dependencies { implementation(project(":core:domain")) + implementation(project(":core:ui")) implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) + implementation(libs.compose.components.resources) } } } diff --git a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeDateFormatter.kt b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDateFormatter.kt similarity index 91% rename from core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeDateFormatter.kt rename to core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDateFormatter.kt index a22a47e26d..7cb4172eb8 100644 --- a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeDateFormatter.kt +++ b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDateFormatter.kt @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.util +package org.groundplatform.testing + +import org.groundplatform.ui.util.DateFormatter /** [DateFormatter] for tests, so assertions don't depend on the host locale or time zone. */ object FakeDateFormatter : DateFormatter { diff --git a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeStringResolver.kt b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeStringResolver.kt similarity index 92% rename from core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeStringResolver.kt rename to core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeStringResolver.kt index 251041f9db..dab6891631 100644 --- a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeStringResolver.kt +++ b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeStringResolver.kt @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.util +package org.groundplatform.testing +import org.groundplatform.ui.util.StringResolver import org.jetbrains.compose.resources.StringResource /** diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index c6c8bb7246..5a997471c8 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -51,11 +51,8 @@ kotlin { implementation(libs.compose.material3) implementation(libs.compose.ui) implementation(libs.compose.ui.tooling.preview) - implementation(libs.compose.components.resources) - implementation(libs.androidx.lifecycle.runtime.compose) + api(libs.compose.components.resources) implementation(libs.kotlinx.collections.immutable) - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.serialization.json) } } diff --git a/feature/pdf/.gitignore b/feature/pdf/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/feature/pdf/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/pdf/build.gradle.kts b/feature/pdf/build.gradle.kts new file mode 100644 index 0000000000..953e7adfce --- /dev/null +++ b/feature/pdf/build.gradle.kts @@ -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. + */ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) + alias(libs.plugins.android.lint) +} + +kotlin { + androidLibrary { + namespace = "org.groundplatform.feature.pdf" + compileSdk { version = release(libs.versions.androidCompileSdk.get().toInt()) } + minSdk = libs.versions.androidMinSdk.get().toInt() + withHostTest {} + } + + val xcfName = "GroundFeaturePdfKit" + listOf(iosArm64(), iosSimulatorArm64()).forEach { + it.binaries.framework { + baseName = xcfName + isStatic = true + } + } + sourceSets { + commonMain { + dependencies { + implementation(project(":core:domain")) + implementation(project(":core:ui")) + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.serialization.json) + } + } + + commonTest { + dependencies { + implementation(project(":core:testing")) + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } + } +} diff --git a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.android.kt similarity index 84% rename from core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt rename to feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.android.kt index df3fd43328..03b460c2fe 100644 --- a/core/ui/src/androidMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.android.kt @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.system.pdf.render.image +package org.groundplatform.feature.pdf.render.image import android.graphics.Bitmap /** - * Android wraps a [android.graphics.Bitmap]. The renderer reads [bitmap] directly to draw on a + * Android wraps a [Bitmap]. The renderer reads [bitmap] directly to draw on a * Canvas. */ actual data class PdfImage(val bitmap: Bitmap) { diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfExportService.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfExportService.kt similarity index 95% rename from core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfExportService.kt rename to feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfExportService.kt index e1ce864805..ef3068630d 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfExportService.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfExportService.kt @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.system.pdf +package org.groundplatform.feature.pdf import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import org.groundplatform.ui.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument /** * Shared entry point for the PDF export flow. diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfImageProvider.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfImageProvider.kt similarity index 89% rename from core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfImageProvider.kt rename to feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfImageProvider.kt index d8d0d7fde3..49ef9047ab 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfImageProvider.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfImageProvider.kt @@ -13,9 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.system.pdf +package org.groundplatform.feature.pdf -import org.groundplatform.ui.system.pdf.render.image.PdfImageSet +import org.groundplatform.feature.pdf.render.image.PdfImageSet /** * Platform abstraction for images (QR code and photos) needed for PDF rendering. Implementations diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfOutputProvider.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfOutputProvider.kt similarity index 97% rename from core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfOutputProvider.kt rename to feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfOutputProvider.kt index 4f75a937fe..f5b1717394 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfOutputProvider.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfOutputProvider.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.system.pdf +package org.groundplatform.feature.pdf import kotlin.time.Clock diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfRenderer.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfRenderer.kt similarity index 84% rename from core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfRenderer.kt rename to feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfRenderer.kt index 6fe97b60ae..7f04629636 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfRenderer.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfRenderer.kt @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.system.pdf +package org.groundplatform.feature.pdf -import org.groundplatform.ui.model.SubmissionPdfDocument -import org.groundplatform.ui.system.pdf.render.image.PdfImageSet +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.render.image.PdfImageSet /** * Rasterises a [SubmissionPdfDocument] to a PDF file. Each platform should use its native text diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfReportLauncher.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfReportLauncher.kt similarity index 95% rename from core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfReportLauncher.kt rename to feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfReportLauncher.kt index c6d764e288..79cad8b79e 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/PdfReportLauncher.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfReportLauncher.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.system.pdf +package org.groundplatform.feature.pdf /** * Presents the two terminal actions on a generated report: share via system share sheet, open in an diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/LoiReportMapper.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapper.kt similarity index 87% rename from core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/LoiReportMapper.kt rename to feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapper.kt index f706fc5011..c904d739af 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/LoiReportMapper.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapper.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.mapper +package org.groundplatform.feature.pdf.mapper import ground_android.core.ui.generated.resources.Res import ground_android.core.ui.generated.resources.job @@ -23,12 +23,13 @@ import ground_android.core.ui.generated.resources.submission import ground_android.core.ui.generated.resources.survey import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.domain.model.submission.Submission -import org.groundplatform.ui.model.SubmissionPdfDocument -import org.groundplatform.ui.model.SubmissionPdfDocument.Footer -import org.groundplatform.ui.model.SubmissionPdfDocument.Header -import org.groundplatform.ui.model.SubmissionPdfDocument.QrBlock -import org.groundplatform.ui.model.SubmissionPdfDocument.Row -import org.groundplatform.ui.system.pdf.PdfExportService +import org.groundplatform.feature.pdf.PdfExportService +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +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.model.SubmissionPdfDocument.Row +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Table import org.groundplatform.ui.util.DateFormatter import org.groundplatform.ui.util.StringResolver @@ -48,7 +49,7 @@ class LoiReportMapper( qrBlock = buildQrBlock(), footer = buildFooter(details), table = - SubmissionPdfDocument.Table( + Table( submissionLabel = strings.resolve(Res.string.submission), loiName = loiReport.loiName, rows = rows, diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/TaskValueMapper.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/TaskValueMapper.kt similarity index 93% rename from core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/TaskValueMapper.kt rename to feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/TaskValueMapper.kt index 121886bb8f..2a5b6d5ea7 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/mapper/TaskValueMapper.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/TaskValueMapper.kt @@ -13,9 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.mapper +package org.groundplatform.feature.pdf.mapper -import androidx.annotation.VisibleForTesting import ground_android.core.ui.generated.resources.Res import ground_android.core.ui.generated.resources.east import ground_android.core.ui.generated.resources.north @@ -25,6 +24,7 @@ import ground_android.core.ui.generated.resources.pdf_altitude import ground_android.core.ui.generated.resources.skipped import ground_android.core.ui.generated.resources.south import ground_android.core.ui.generated.resources.west +import kotlin.collections.orEmpty import kotlin.math.absoluteValue import kotlin.math.round import org.groundplatform.domain.model.geometry.Point @@ -38,7 +38,7 @@ import org.groundplatform.domain.model.submission.TextTaskData import org.groundplatform.domain.model.task.PhotoTaskData import org.groundplatform.domain.model.task.Task import org.groundplatform.domain.util.toFixedDecimals -import org.groundplatform.ui.model.SubmissionPdfDocument.Answer +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Answer import org.groundplatform.ui.util.DateFormatter import org.groundplatform.ui.util.StringResolver @@ -102,11 +102,10 @@ class TaskValueMapper( return "${formatDegrees(lat)} $latDir, ${formatDegrees(lng)} $lngDir" } - @VisibleForTesting - fun formatDegrees(value: Double): String = + internal fun formatDegrees(value: Double): String = "${value.absoluteValue.toFixedDecimals(DEGREES_DECIMALS)}°" - @VisibleForTesting fun formatMeters(value: Double): String = round(value).toLong().toString() + internal fun formatMeters(value: Double): String = round(value).toLong().toString() companion object { private const val DEGREES_DECIMALS = 6 diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/model/SubmissionPdfDocument.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/model/SubmissionPdfDocument.kt similarity index 97% rename from core/ui/src/commonMain/kotlin/org/groundplatform/ui/model/SubmissionPdfDocument.kt rename to feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/model/SubmissionPdfDocument.kt index 80eb1d4e76..8aa8bc1bbb 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/model/SubmissionPdfDocument.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/model/SubmissionPdfDocument.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.model +package org.groundplatform.feature.pdf.model /** * UI model for a submission PDF. Each property corresponds to a distinct visual section so that diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.kt similarity index 93% rename from core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt rename to feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.kt index c4d3cceaa1..b8e1015bd9 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.system.pdf.render.image +package org.groundplatform.feature.pdf.render.image /** * Opaque platform image handle (Android `Bitmap`, iOS `CGImageRef`). The renderer reads the diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImageSet.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSet.kt similarity index 95% rename from core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImageSet.kt rename to feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSet.kt index ee5d874606..74e001cd68 100644 --- a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImageSet.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSet.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.system.pdf.render.image +package org.groundplatform.feature.pdf.render.image /** * The set of images needed to render a single report. Owns the lifecycle of the platform handles, diff --git a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/LoiReportMapperTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt similarity index 94% rename from core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/LoiReportMapperTest.kt rename to feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt index a8921a0e7f..01ca24524a 100644 --- a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/LoiReportMapperTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt @@ -13,15 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.mapper +package org.groundplatform.feature.pdf.mapper import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import kotlinx.coroutines.test.runTest import org.groundplatform.testing.FakeDataGenerator -import org.groundplatform.ui.util.FakeDateFormatter -import org.groundplatform.ui.util.FakeStringResolver +import org.groundplatform.testing.FakeDateFormatter +import org.groundplatform.testing.FakeStringResolver class LoiReportMapperTest { @@ -36,7 +36,7 @@ class LoiReportMapperTest { private val timestampSegment = "DATE0_TIME0" @Test - fun `file name joins survey, loi, user, and timestamp with underscores`() = runTest { + fun `file name joins survey loi user and timestamp with underscores`() = runTest { val request = mapper.map( loiReport = diff --git a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/TaskValueMapperTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/TaskValueMapperTest.kt similarity index 82% rename from core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/TaskValueMapperTest.kt rename to feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/TaskValueMapperTest.kt index 04b24f772d..bfbddfd976 100644 --- a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/mapper/TaskValueMapperTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/TaskValueMapperTest.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.mapper +package org.groundplatform.feature.pdf.mapper import kotlin.test.Test import kotlin.test.assertEquals @@ -32,10 +32,10 @@ import org.groundplatform.domain.model.task.MultipleChoice import org.groundplatform.domain.model.task.Option import org.groundplatform.domain.model.task.PhotoTaskData import org.groundplatform.domain.model.task.Task +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument import org.groundplatform.testing.FakeDataGenerator -import org.groundplatform.ui.model.SubmissionPdfDocument.Answer -import org.groundplatform.ui.util.FakeDateFormatter -import org.groundplatform.ui.util.FakeStringResolver +import org.groundplatform.testing.FakeDateFormatter +import org.groundplatform.testing.FakeStringResolver class TaskValueMapperTest { @@ -46,7 +46,7 @@ class TaskValueMapperTest { @Test fun `TEXT task maps to the same value`() = runTest { assertEquals( - Answer.Text(listOf("free text")), + SubmissionPdfDocument.Answer.Text(listOf("free text")), mapper.map(task(Task.Type.TEXT), TextTaskData("free text")), ) } @@ -54,7 +54,7 @@ class TaskValueMapperTest { @Test fun `NUMBER task maps to the same value`() = runTest { assertEquals( - Answer.Text(listOf("42")), + SubmissionPdfDocument.Answer.Text(listOf("42")), mapper.map(task(Task.Type.NUMBER), NumberTaskData("42")), ) } @@ -62,7 +62,7 @@ class TaskValueMapperTest { @Test fun `Skipped value renders the skipped label`() = runTest { assertEquals( - Answer.Text(listOf("skipped")), + SubmissionPdfDocument.Answer.Text(listOf("skipped")), mapper.map(task(Task.Type.TEXT), SkippedTaskData()), ) } @@ -70,7 +70,7 @@ class TaskValueMapperTest { @Test fun `PHOTO task maps to a photo answer`() = runTest { assertEquals( - Answer.Photo("path/to/photo.jpg"), + SubmissionPdfDocument.Answer.Photo("path/to/photo.jpg"), mapper.map(task(Task.Type.PHOTO), PhotoTaskData("path/to/photo.jpg")), ) } @@ -78,14 +78,17 @@ class TaskValueMapperTest { @Test fun `unsupported value maps to an empty answer`() = runTest { val value = DropPinTaskData(Point(Coordinates(1.0, 2.0))) - assertEquals(Answer.Text(emptyList()), mapper.map(task(Task.Type.DROP_PIN), value)) + assertEquals( + SubmissionPdfDocument.Answer.Text(emptyList()), + mapper.map(task(Task.Type.DROP_PIN), value), + ) } @Test fun `DATE task renders date only`() = runTest { val millis = 987654321L assertEquals( - Answer.Text(listOf(dateFormatter.formatDate(millis))), + SubmissionPdfDocument.Answer.Text(listOf(dateFormatter.formatDate(millis))), mapper.map(task(Task.Type.DATE), DateTimeTaskData(millis)), ) } @@ -94,7 +97,7 @@ class TaskValueMapperTest { fun `TIME task renders time only`() = runTest { val millis = 987654321L assertEquals( - Answer.Text(listOf(dateFormatter.formatTime(millis))), + SubmissionPdfDocument.Answer.Text(listOf(dateFormatter.formatTime(millis))), mapper.map(task(Task.Type.TIME), DateTimeTaskData(millis)), ) } @@ -103,7 +106,7 @@ class TaskValueMapperTest { fun `non date or time task renders empty answer`() = runTest { val millis = 987654321L assertEquals( - Answer.Text(emptyList()), + SubmissionPdfDocument.Answer.Text(emptyList()), mapper.map(task(Task.Type.NUMBER), DateTimeTaskData(millis)), ) } @@ -112,7 +115,7 @@ class TaskValueMapperTest { fun `MULTIPLE_CHOICE renders each selected option label on its own line`() = runTest { val value = MultipleChoiceTaskData(multipleChoice(), selectedOptionIds = listOf("a", "b")) assertEquals( - Answer.Text(listOf("Apple", "Banana")), + SubmissionPdfDocument.Answer.Text(listOf("Apple", "Banana")), mapper.map(task(Task.Type.MULTIPLE_CHOICE, multipleChoice()), value), ) } @@ -122,7 +125,7 @@ class TaskValueMapperTest { val other = "${MultipleChoiceTaskData.OTHER_PREFIX}custom${MultipleChoiceTaskData.OTHER_SUFFIX}" val value = MultipleChoiceTaskData(multipleChoice(), selectedOptionIds = listOf("a", other)) assertEquals( - Answer.Text(listOf("Apple", "other: custom")), + SubmissionPdfDocument.Answer.Text(listOf("Apple", "other: custom")), mapper.map(task(Task.Type.MULTIPLE_CHOICE, multipleChoice()), value), ) } @@ -135,7 +138,7 @@ class TaskValueMapperTest { val result = mapper.map(task(Task.Type.CAPTURE_LOCATION), value) val expected = - Answer.Text( + SubmissionPdfDocument.Answer.Text( listOf( "${mapper.formatDegrees(1.5)} north, ${mapper.formatDegrees(2.25)} west", "pdf_altitude(${mapper.formatMeters(10.0)})", @@ -153,7 +156,9 @@ class TaskValueMapperTest { val result = mapper.map(task(Task.Type.CAPTURE_LOCATION), value) assertEquals( - Answer.Text(listOf("${mapper.formatDegrees(1.0)} south, ${mapper.formatDegrees(2.0)} east")), + SubmissionPdfDocument.Answer.Text( + listOf("${mapper.formatDegrees(1.0)} south, ${mapper.formatDegrees(2.0)} east") + ), result, ) } diff --git a/core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt b/feature/pdf/src/iosMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.ios.kt similarity index 94% rename from core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt rename to feature/pdf/src/iosMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.ios.kt index aa620bbec6..adadacec43 100644 --- a/core/ui/src/iosMain/kotlin/org/groundplatform/ui/system/pdf/render/image/PdfImage.kt +++ b/feature/pdf/src/iosMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.ios.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.system.pdf.render.image +package org.groundplatform.feature.pdf.render.image import kotlinx.cinterop.ExperimentalForeignApi import platform.CoreGraphics.CGImageGetHeight diff --git a/settings.gradle b/settings.gradle index 86bf7ba7ee..8b0871680a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -30,3 +30,4 @@ include ':app', ':e2eTest' include ':core:ui' include ':core:domain' include ':core:testing' +include ':feature:pdf' From 30ccb6457308a5434ae055621e74af12b94fa482 Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 29 May 2026 15:19:07 +0200 Subject: [PATCH 07/12] move testing helpers to feature:pdf --- core/testing/build.gradle.kts | 2 -- .../testing/FakeDateFormatter.kt | 26 ---------------- .../testing/FakeStringResolver.kt | 30 ------------------- .../feature/pdf/helpers/FakeDateFormatter.kt | 11 +++++++ .../feature/pdf/helpers/FakeStringResolver.kt | 16 ++++++++++ .../feature/pdf/mapper/LoiReportMapperTest.kt | 4 +-- .../feature/pdf/mapper/TaskValueMapperTest.kt | 4 +-- 7 files changed, 31 insertions(+), 62 deletions(-) delete mode 100644 core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDateFormatter.kt delete mode 100644 core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeStringResolver.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeDateFormatter.kt create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index ee1a78ca3b..8d9a120036 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -36,12 +36,10 @@ kotlin { commonMain { dependencies { implementation(project(":core:domain")) - implementation(project(":core:ui")) implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) - implementation(libs.compose.components.resources) } } } diff --git a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDateFormatter.kt b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDateFormatter.kt deleted file mode 100644 index 7cb4172eb8..0000000000 --- a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeDateFormatter.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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.testing - -import org.groundplatform.ui.util.DateFormatter - -/** [DateFormatter] for tests, so assertions don't depend on the host locale or time zone. */ -object FakeDateFormatter : DateFormatter { - - override fun formatDate(millis: Long): String = "DATE($millis)" - - override fun formatTime(millis: Long): String = "TIME($millis)" -} diff --git a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeStringResolver.kt b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeStringResolver.kt deleted file mode 100644 index dab6891631..0000000000 --- a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeStringResolver.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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.testing - -import org.groundplatform.ui.util.StringResolver -import org.jetbrains.compose.resources.StringResource - -/** - * [StringResolver] for tests so display logic can be asserted without a Compose resource runtime. - */ -object FakeStringResolver : StringResolver { - - override suspend fun resolve(resource: StringResource): String = resource.key - - override suspend fun resolve(resource: StringResource, vararg formatArgs: Any): String = - "${resource.key}(${formatArgs.joinToString()})" -} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeDateFormatter.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeDateFormatter.kt new file mode 100644 index 0000000000..8d24c51e1b --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeDateFormatter.kt @@ -0,0 +1,11 @@ +package org.groundplatform.feature.pdf.helpers + +import org.groundplatform.ui.util.DateFormatter + +/** [DateFormatter] for tests, so assertions don't depend on the host locale or time zone. */ +object FakeDateFormatter : DateFormatter { + + override fun formatDate(millis: Long): String = "DATE($millis)" + + override fun formatTime(millis: Long): String = "TIME($millis)" +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt new file mode 100644 index 0000000000..b68e91541b --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt @@ -0,0 +1,16 @@ +package org.groundplatform.feature.pdf.helpers + +import org.groundplatform.ui.util.StringResolver +import org.jetbrains.compose.resources.StringResource + +/** + * [org.groundplatform.ui.util.StringResolver] for tests so display logic can be asserted without a + * Compose resource runtime. + */ +object FakeStringResolver : StringResolver { + + override suspend fun resolve(resource: StringResource): String = resource.key + + override suspend fun resolve(resource: StringResource, vararg formatArgs: Any): String = + "${resource.key}(${formatArgs.joinToString()})" +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt index 01ca24524a..379d2068d6 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt @@ -19,9 +19,9 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import kotlinx.coroutines.test.runTest +import org.groundplatform.feature.pdf.helpers.FakeDateFormatter +import org.groundplatform.feature.pdf.helpers.FakeStringResolver import org.groundplatform.testing.FakeDataGenerator -import org.groundplatform.testing.FakeDateFormatter -import org.groundplatform.testing.FakeStringResolver class LoiReportMapperTest { diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/TaskValueMapperTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/TaskValueMapperTest.kt index bfbddfd976..e146ad0f4f 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/TaskValueMapperTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/TaskValueMapperTest.kt @@ -32,10 +32,10 @@ import org.groundplatform.domain.model.task.MultipleChoice import org.groundplatform.domain.model.task.Option import org.groundplatform.domain.model.task.PhotoTaskData import org.groundplatform.domain.model.task.Task +import org.groundplatform.feature.pdf.helpers.FakeDateFormatter +import org.groundplatform.feature.pdf.helpers.FakeStringResolver import org.groundplatform.feature.pdf.model.SubmissionPdfDocument import org.groundplatform.testing.FakeDataGenerator -import org.groundplatform.testing.FakeDateFormatter -import org.groundplatform.testing.FakeStringResolver class TaskValueMapperTest { From f890701ff1e0b9045a7cb3cb5ee3159a7be4068a Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 29 May 2026 15:31:38 +0200 Subject: [PATCH 08/12] fix failing tests --- .../DataSubmissionConfirmationScreen.kt | 28 ++++++++++--------- .../mapcontainer/jobs/ShareLocationModal.kt | 28 ++++++++++--------- .../domain/usecases/GetLoiReportUseCase.kt | 13 +++++++-- .../usecases/GetLoiReportUseCaseTest.kt | 6 ++-- 4 files changed, 43 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt index 4ae3e98a9e..0b18ea8b3a 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataSubmissionConfirmationScreen.kt @@ -164,19 +164,21 @@ private fun ShareableContent(modifier: Modifier = Modifier, loiReport: LoiReport } loiReport.submissionDetails?.let { - SubmissionPdfItem( - modifier = Modifier.fillMaxWidth().padding(top = 16.dp), - title = it.surveyName, - loiName = loiReport.loiName, - userName = it.userName, - date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), - onItemClick = { - /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ - }, - onShareClick = { - /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ - }, - ) + if (!it.submissions.isNullOrEmpty()) { + SubmissionPdfItem( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + title = it.surveyName, + loiName = loiReport.loiName, + userName = it.userName, + date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), + onItemClick = { + /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ + }, + onShareClick = { + /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ + }, + ) + } } } } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt index 80f5a3f12f..346a83f0ef 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/ShareLocationModal.kt @@ -95,19 +95,21 @@ fun ShareLocationModal(loiReport: LoiReport, onDismiss: () -> Unit) { } loiReport.submissionDetails?.let { - SubmissionPdfItem( - modifier = Modifier.fillMaxWidth(), - title = it.surveyName, - loiName = loiReport.loiName, - userName = it.userName, - date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), - onItemClick = { - /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ - }, - onShareClick = { - /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ - }, - ) + if (!it.submissions.isNullOrEmpty()) { + SubmissionPdfItem( + modifier = Modifier.fillMaxWidth(), + title = it.surveyName, + loiName = loiReport.loiName, + userName = it.userName, + date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), + onItemClick = { + /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ + }, + onShareClick = { + /* To be implemented in a follow-up on https://github.com/google/ground-android/issues/3715 */ + }, + ) + } } TextButton( diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt index e4a3985363..0568c02072 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCase.kt @@ -52,11 +52,12 @@ class GetLoiReportUseCase( * @param surveyId the identifier of the survey the LOI belongs to. * @throws IllegalStateException if the LOI geometry is a bare [LinearRing]. */ - @Suppress("UnusedPrivateProperty") suspend operator fun invoke(loiName: String, loiId: String, surveyId: String): LoiReport? { val loi = locationOfInterestRepository.getOfflineLoi(surveyId, loiId) val user = userRepositoryInterface.getAuthenticatedUser() val surveyName = surveyRepositoryInterface.getOfflineSurvey(surveyId)?.title.orEmpty() + val submissions = null // To be implemented in a follow-up on + // https://github.com/google/ground-android/issues/3715 return loi?.let { LoiReport( loiName = loiName, @@ -64,8 +65,14 @@ class GetLoiReportUseCase( it.geometry.toGeoJson( it.properties.filter { property -> property.key == LOI_NAME_PROPERTY } ), - submissionDetails = null, // To be implemented in a follow-up on - // https://github.com/google/ground-android/issues/3715 + submissionDetails = + LoiReport.SubmissionDetails( + surveyName = surveyName, + userName = user.displayName, + userEmail = user.email, + dateMillis = loi.lastModified.clientTimestamp, + submissions = submissions, + ), ) } } diff --git a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt index d802241110..95a316eed7 100644 --- a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/GetLoiReportUseCaseTest.kt @@ -319,8 +319,8 @@ class GetLoiReportUseCaseTest { getLoiReportUseCase.invoke(loiName = "Test LOI", loiId = "loiId", surveyId = "surveyId")!! assertEquals("Test LOI", loiReport.loiName) - assertEquals("John Doe", loiReport.userName) - assertEquals(987654321L, loiReport.dateMillis) + assertEquals("John Doe", loiReport.submissionDetails!!.userName) + assertEquals(987654321L, loiReport.submissionDetails.dateMillis) } @Test @@ -331,7 +331,7 @@ class GetLoiReportUseCaseTest { val loiReport = getLoiReportUseCase.invoke(loiName = "loiName", loiId = "loiId", surveyId = "surveyId")!! - assertEquals("Restoration areas", loiReport.surveyName) + assertEquals("Restoration areas", loiReport.submissionDetails!!.surveyName) } private suspend fun invokeUseCase(geometry: Geometry, properties: LoiProperties): LoiReport { From 36a8524fe62dbb9cc3ceb40ada14599287675948 Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 29 May 2026 16:29:38 +0200 Subject: [PATCH 09/12] add missing file license --- .../feature/pdf/helpers/FakeDateFormatter.kt | 15 +++++++++++++++ .../feature/pdf/helpers/FakeStringResolver.kt | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeDateFormatter.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeDateFormatter.kt index 8d24c51e1b..638e21aaae 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeDateFormatter.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeDateFormatter.kt @@ -1,3 +1,18 @@ +/* + * 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.helpers import org.groundplatform.ui.util.DateFormatter diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt index b68e91541b..429efe244a 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt @@ -1,3 +1,18 @@ +/* + * 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.helpers import org.groundplatform.ui.util.StringResolver From 84c60de47cc79548489ca9ec7a24ad694ea20a83 Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 29 May 2026 17:01:11 +0200 Subject: [PATCH 10/12] fix test --- app/src/test/java/org/groundplatform/android/FakeData.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/org/groundplatform/android/FakeData.kt b/app/src/test/java/org/groundplatform/android/FakeData.kt index e858c36b16..986cdd2be7 100644 --- a/app/src/test/java/org/groundplatform/android/FakeData.kt +++ b/app/src/test/java/org/groundplatform/android/FakeData.kt @@ -129,7 +129,14 @@ object FakeData { ), ) ), - submissionDetails = null, + submissionDetails = + LoiReport.SubmissionDetails( + surveyName = SURVEY.title, + userName = USER.displayName, + userEmail = USER.email, + dateMillis = LOCATION_OF_INTEREST.lastModified.clientTimestamp, + submissions = null, + ), ) val LOCATION_OF_INTEREST_FEATURE = Feature( From 4fac1a0f495d00457aa6760b9ae2008f64232670 Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 1 Jun 2026 10:38:32 +0200 Subject: [PATCH 11/12] handle cases where rendering might fail and create a corrupt file --- .../feature/pdf/PdfExportService.kt | 5 + .../feature/pdf/PdfExportServiceTest.kt | 129 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/PdfExportServiceTest.kt diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfExportService.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfExportService.kt index ef3068630d..c5ad3215a8 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfExportService.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfExportService.kt @@ -45,6 +45,11 @@ class PdfExportService( val images = imageProvider.load(request.qrContent, request.document.photoFilenames()) try { renderer.render(request.document, images, path) + } catch (e: Throwable) { + // Delete the partially written or corrupted file so we never open/share a broken PDF, + // then re-throw so the failure can be handled upstream. + outputProvider.deleteReport(path) + throw e } finally { images.release() } diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/PdfExportServiceTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/PdfExportServiceTest.kt new file mode 100644 index 0000000000..a2334dbb76 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/PdfExportServiceTest.kt @@ -0,0 +1,129 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.render.image.PdfImageSet + +class PdfExportServiceTest { + + private val path = "/tmp/report.pdf" + private val deletedPaths = mutableListOf() + private var imagesReleased = false + private var sharedPath: String? = null + private var openedPath: String? = null + + @Test + fun `deletes corrupted file and rethrows when rendering fails`() = runTest { + val failure = RuntimeException("out of memory") + + val thrown = + assertFailsWith { + service(renderError = failure).export(request, PdfExportService.Action.Open) + } + + assertEquals(failure, thrown) + assertEquals(listOf(path), deletedPaths) + assertTrue(imagesReleased) + } + + @Test + fun `does not delete file and launches when rendering succeeds`() = runTest { + service().export(request, PdfExportService.Action.Share) + + assertTrue(deletedPaths.isEmpty()) + assertEquals(path, sharedPath) + assertTrue(imagesReleased) + } + + private fun service(renderError: Throwable? = null) = + PdfExportService( + imageProvider = + object : PdfImageProvider { + override suspend fun load(qrContent: String?, photoFilenames: Set) = + PdfImageSet(images = emptyMap(), onRelease = { imagesReleased = true }) + }, + renderer = + object : PdfRenderer { + override suspend fun render( + document: SubmissionPdfDocument, + images: PdfImageSet, + outputPath: String, + ) { + renderError?.let { throw it } + } + }, + outputProvider = + object : PdfOutputProvider { + override fun newFilePath(name: String) = path + + override fun exists(name: String) = false + + override fun listFiles() = emptyList() + + override fun deleteReport(path: String) { + deletedPaths.add(path) + } + }, + launcher = + object : PdfReportLauncher { + override fun share(path: String) { + sharedPath = path + } + + override fun open(path: String) { + openedPath = path + } + }, + coroutineDispatcher = Dispatchers.Unconfined, + ) + + private companion object { + val document = + SubmissionPdfDocument( + header = + SubmissionPdfDocument.Header( + surveyLabel = "Survey", + surveyName = "Survey name", + jobLabel = "Job", + jobName = "Job name", + timestamp = "timestamp", + ), + qrBlock = SubmissionPdfDocument.QrBlock(scanCaption = "Scan"), + footer = + SubmissionPdfDocument.Footer( + dataCollectorLabel = "Collector", + dataCollectorName = "Name", + userEmail = "user@example.com", + ), + table = + SubmissionPdfDocument.Table( + submissionLabel = "Submission", + loiName = "Loi", + rows = emptyList(), + ), + ) + + val request = + PdfExportService.Request(document = document, qrContent = null, fileName = "report.pdf") + } +} From 89c647b4aa55005ac2fd840f05cf647ba9f4c7e8 Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 1 Jun 2026 10:39:00 +0200 Subject: [PATCH 12/12] fix file names not handling all types of characters --- .../feature/pdf/mapper/LoiReportMapper.kt | 17 +++++++++-- .../feature/pdf/mapper/LoiReportMapperTest.kt | 30 +++++++++++++++---- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapper.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapper.kt index c904d739af..418ff725aa 100644 --- a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapper.kt +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapper.kt @@ -62,7 +62,7 @@ class LoiReportMapper( .map { it.filter(::isSafeFileChar) } .filter { it.isNotBlank() } .joinToString("_") - .take(200) + .take(100) return PdfExportService.Request( document = document, @@ -103,6 +103,19 @@ class LoiReportMapper( } } + /** + * Allows Unicode letters, digits, combining marks, hyphens, and underscores while rejecting + * characters reserved or unsafe in file paths. + */ private fun isSafeFileChar(c: Char): Boolean = - c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c in "_-" + c.isLetterOrDigit() || c.category in COMBINING_MARK_CATEGORIES || c in "_-" + + private companion object { + val COMBINING_MARK_CATEGORIES = + setOf( + CharCategory.NON_SPACING_MARK, + CharCategory.COMBINING_SPACING_MARK, + CharCategory.ENCLOSING_MARK, + ) + } } diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt index 379d2068d6..3f3939c6c8 100644 --- a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt @@ -87,24 +87,42 @@ class LoiReportMapperTest { } @Test - fun `file name strips non-ASCII characters`() = runTest { + fun `file name preserves non-Latin characters`() = runTest { + val request = + mapper.map( + loiReport = + FakeDataGenerator.newLoiReport( + loiName = "ເພີ່ມຈຸດສຳຫຼວດ", + submissionDetails = + FakeDataGenerator.newSubmissionDetails(surveyName = "แบบสำรวจ", userName = "テスト"), + ), + submission = FakeDataGenerator.newSubmission(), + ) + + assertEquals("แบบสำรวจ_ເພີ່ມຈຸດສຳຫຼວດ_テスト_$timestampSegment", request!!.fileName) + } + + @Test + fun `file name sanitizes reserved characters and preserves accented Latin letters`() = runTest { val request = mapper.map( loiReport = FakeDataGenerator.newLoiReport( loiName = "ß", submissionDetails = - FakeDataGenerator.newSubmissionDetails(surveyName = "café", userName = "José"), + FakeDataGenerator.newSubmissionDetails( + surveyName = "Café/São/José", + userName = "Test?", + ), ), submission = FakeDataGenerator.newSubmission(), ) - // "café" -> "caf", "ß" -> "", "José" -> "Jos". - assertEquals("caf_Jos_$timestampSegment", request!!.fileName) + assertEquals("CaféSãoJosé_ß_Test_$timestampSegment", request!!.fileName) } @Test - fun `file name is capped at 200 characters`() = runTest { + fun `file name is capped at 100 characters`() = runTest { val request = mapper.map( loiReport = @@ -116,7 +134,7 @@ class LoiReportMapperTest { submission = FakeDataGenerator.newSubmission(), ) - assertEquals(200, request!!.fileName.length) + assertEquals(100, request!!.fileName.length) } @Test