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..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,14 +99,7 @@ private fun DataCollectionContentCompletePreview() { uiState = DataCollectionUiState.TaskSubmitted( loiReport = - LoiReport( - surveyName = "Test Survey", - userName = "John Doe", - dateMillis = Clock.System.now().toEpochMilliseconds(), - loiName = "Point A", - geoJson = JsonObject(mapOf()), - submissions = emptyList() - ) + LoiReport(loiName = "Point A", geoJson = JsonObject(mapOf()), 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..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 @@ -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,20 +163,22 @@ private fun ShareableContent(modifier: Modifier = Modifier, loiReport: LoiReport ) } - loiReport.submissions?.let { - SubmissionPdfItem( - modifier = Modifier.fillMaxWidth().padding(top = 16.dp), - title = loiReport.surveyName, - loiName = loiReport.loiName, - userName = loiReport.userName, - date = DateFormat.getDateFormat(context).format(Date(loiReport.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 */ - }, - ) + loiReport.submissionDetails?.let { + 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 */ + }, + ) + } } } } @@ -185,9 +186,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 +201,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..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 @@ -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,20 +94,22 @@ fun ShareLocationModal(loiReport: LoiReport, onDismiss: () -> Unit) { ) } - loiReport.submissions?.let { - SubmissionPdfItem( - modifier = Modifier.fillMaxWidth(), - title = loiReport.surveyName, - loiName = loiReport.loiName, - userName = loiReport.userName, - date = DateFormat.getDateFormat(context).format(Date(loiReport.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 */ - }, - ) + loiReport.submissionDetails?.let { + 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( @@ -128,9 +129,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 +143,7 @@ private fun ShareLocationModalPreview() { ), ) ), - submissions = emptyList(), + submissionDetails = null, ) AppTheme { 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/app/src/test/java/org/groundplatform/android/FakeData.kt b/app/src/test/java/org/groundplatform/android/FakeData.kt index 5e7892b7c8..986cdd2be7 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,14 @@ object FakeData { ), ) ), - submissions = 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( 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..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 @@ -60,15 +60,19 @@ class GetLoiReportUseCase( // 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 = + 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 { diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index fa9c565cbd..8d9a120036 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() @@ -29,6 +39,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..5d82702f54 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,26 @@ 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/build.gradle.kts b/core/ui/build.gradle.kts index 5d71bdb988..5a997471c8 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 { @@ -53,8 +51,7 @@ 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) } } 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/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 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/jvmMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.jvm.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.android.kt similarity index 59% rename from core/ui/src/jvmMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.jvm.kt rename to feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.android.kt index edc40ef030..03b460c2fe 100644 --- a/core/ui/src/jvmMain/kotlin/org/groundplatform/ui/components/qrcode/QrCodeGenerator.jvm.kt +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.android.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.feature.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 [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/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfExportService.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfExportService.kt new file mode 100644 index 0000000000..c5ad3215a8 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfExportService.kt @@ -0,0 +1,76 @@ +/* + * 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 kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.groundplatform.feature.pdf.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) + } 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() + } + } + 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/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfImageProvider.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfImageProvider.kt new file mode 100644 index 0000000000..49ef9047ab --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/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.feature.pdf + +import org.groundplatform.feature.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/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfOutputProvider.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfOutputProvider.kt new file mode 100644 index 0000000000..f5b1717394 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/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.feature.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/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfRenderer.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfRenderer.kt new file mode 100644 index 0000000000..7f04629636 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/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.feature.pdf + +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 + * 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/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfReportLauncher.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/PdfReportLauncher.kt new file mode 100644 index 0000000000..79cad8b79e --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/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.feature.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/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 new file mode 100644 index 0000000000..418ff725aa --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapper.kt @@ -0,0 +1,121 @@ +/* + * 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.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.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 + +/** 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 = + 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(100) + + 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)) + } + } + + /** + * 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.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/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 83% 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 2bc0b96325..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 @@ -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() } diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.kt new file mode 100644 index 0000000000..b8e1015bd9 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/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.feature.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/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSet.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSet.kt new file mode 100644 index 0000000000..74e001cd68 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/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.feature.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/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") + } +} diff --git a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeDateFormatter.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeDateFormatter.kt similarity index 90% rename from core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeDateFormatter.kt rename to feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeDateFormatter.kt index a22a47e26d..638e21aaae 100644 --- a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeDateFormatter.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/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.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 { diff --git a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeStringResolver.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt similarity index 80% rename from core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeStringResolver.kt rename to feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt index 251041f9db..429efe244a 100644 --- a/core/ui/src/commonTest/kotlin/org/groundplatform/ui/util/FakeStringResolver.kt +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakeStringResolver.kt @@ -13,12 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.ui.util +package org.groundplatform.feature.pdf.helpers +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. + * [org.groundplatform.ui.util.StringResolver] for tests so display logic can be asserted without a + * Compose resource runtime. */ object FakeStringResolver : StringResolver { 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 new file mode 100644 index 0000000000..3f3939c6c8 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/mapper/LoiReportMapperTest.kt @@ -0,0 +1,146 @@ +/* + * 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.mapper + +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 + +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 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é/São/José", + userName = "Test?", + ), + ), + submission = FakeDataGenerator.newSubmission(), + ) + + assertEquals("CaféSãoJosé_ß_Test_$timestampSegment", request!!.fileName) + } + + @Test + fun `file name is capped at 100 characters`() = runTest { + val request = + mapper.map( + loiReport = + FakeDataGenerator.newLoiReport( + loiName = "x", + submissionDetails = + FakeDataGenerator.newSubmissionDetails(surveyName = "a".repeat(300), userName = "y"), + ), + submission = FakeDataGenerator.newSubmission(), + ) + + assertEquals(100, 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())) + } +} 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 81% 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..e146ad0f4f 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.helpers.FakeDateFormatter +import org.groundplatform.feature.pdf.helpers.FakeStringResolver +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 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/feature/pdf/src/iosMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.ios.kt b/feature/pdf/src/iosMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.ios.kt new file mode 100644 index 0000000000..adadacec43 --- /dev/null +++ b/feature/pdf/src/iosMain/kotlin/org/groundplatform/feature/pdf/render/image/PdfImage.ios.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.feature.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() +} 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'