diff --git a/app/build.gradle b/app/build.gradle index 8baeaef88b..d59fd2ec5e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -190,6 +190,7 @@ dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') implementation project(':core:domain') implementation project(':core:ui') + implementation project(':feature:pdf') implementation libs.androidx.multidex implementation libs.androidx.preference.ktx diff --git a/app/src/main/java/org/groundplatform/android/di/GroundApplicationModule.kt b/app/src/main/java/org/groundplatform/android/di/GroundApplicationModule.kt index 220304232a..6ecfed8340 100644 --- a/app/src/main/java/org/groundplatform/android/di/GroundApplicationModule.kt +++ b/app/src/main/java/org/groundplatform/android/di/GroundApplicationModule.kt @@ -27,6 +27,10 @@ import java.util.Locale import javax.inject.Singleton import org.groundplatform.android.R import org.groundplatform.android.util.SurveyDeepLinkParser +import org.groundplatform.ui.util.AndroidDateFormatter +import org.groundplatform.ui.util.ComposeStringResolver +import org.groundplatform.ui.util.DateFormatter +import org.groundplatform.ui.util.StringResolver @InstallIn(SingletonComponent::class) @Module(includes = [ViewModelModule::class]) @@ -47,4 +51,11 @@ object GroundApplicationModule { deepLinkHost = resources.getString(R.string.deeplink_host), deepLinkPath = resources.getString(R.string.survey_deeplink_path), ) + + @Provides + @Singleton + fun provideDateFormatter(@ApplicationContext context: Context): DateFormatter = + AndroidDateFormatter(context) + + @Provides @Singleton fun provideStringResolver(): StringResolver = ComposeStringResolver } diff --git a/app/src/main/java/org/groundplatform/android/di/PdfModule.kt b/app/src/main/java/org/groundplatform/android/di/PdfModule.kt new file mode 100644 index 0000000000..e3badfb4b3 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/di/PdfModule.kt @@ -0,0 +1,112 @@ +/* + * 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.android.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import org.groundplatform.android.BuildConfig +import org.groundplatform.android.R +import org.groundplatform.android.di.coroutines.DefaultDispatcher +import org.groundplatform.android.di.coroutines.IoDispatcher +import org.groundplatform.feature.pdf.AndroidPdfImageProvider +import org.groundplatform.feature.pdf.AndroidPdfOutputProvider +import org.groundplatform.feature.pdf.AndroidPdfRenderer +import org.groundplatform.feature.pdf.AndroidPdfReportLauncher +import org.groundplatform.feature.pdf.LoiReportExporter +import org.groundplatform.feature.pdf.PdfExportService +import org.groundplatform.feature.pdf.PdfImageProvider +import org.groundplatform.feature.pdf.PdfOutputProvider +import org.groundplatform.feature.pdf.PdfRenderer +import org.groundplatform.feature.pdf.PdfReportLauncher +import org.groundplatform.feature.pdf.mapper.LoiReportMapper +import org.groundplatform.feature.pdf.mapper.TaskValueMapper +import org.groundplatform.ui.util.DateFormatter +import org.groundplatform.ui.util.StringResolver + +@Module +@InstallIn(SingletonComponent::class) +object PdfModule { + + @Provides + @Singleton + fun providePdfImageProvider(@ApplicationContext context: Context): PdfImageProvider = + AndroidPdfImageProvider(context = context, logoDrawableRes = R.drawable.ground_logo) + + @Provides + @Singleton + fun providePdfOutputFactory(@ApplicationContext context: Context): PdfOutputProvider = + AndroidPdfOutputProvider(context) + + @Provides + @Singleton + fun providePdfRenderer(@IoDispatcher ioDispatcher: CoroutineDispatcher): PdfRenderer = + AndroidPdfRenderer(ioDispatcher) + + @Provides + @Singleton + fun provideTaskValueMapper( + strings: StringResolver, + dateFormatter: DateFormatter, + ): TaskValueMapper = TaskValueMapper(strings = strings, dateFormatter = dateFormatter) + + @Provides + @Singleton + fun provideLoiReportMapper( + taskValueMapper: TaskValueMapper, + strings: StringResolver, + dateFormatter: DateFormatter, + ): LoiReportMapper = + LoiReportMapper( + taskValueMapper = taskValueMapper, + strings = strings, + dateFormatter = dateFormatter, + ) + + @Provides + @Singleton + fun providePdfReportLauncher(@ApplicationContext context: Context): PdfReportLauncher = + AndroidPdfReportLauncher(context = context, fileProviderAuthority = BuildConfig.APPLICATION_ID) + + @Provides + @Singleton + fun providePdfReportService( + imageProvider: PdfImageProvider, + renderer: PdfRenderer, + outputProvider: PdfOutputProvider, + launcher: PdfReportLauncher, + @DefaultDispatcher coroutineDispatcher: CoroutineDispatcher, + ): PdfExportService = + PdfExportService( + imageProvider = imageProvider, + renderer = renderer, + outputProvider = outputProvider, + launcher = launcher, + coroutineDispatcher = coroutineDispatcher, + ) + + @Provides + @Singleton + fun provideLoiReportExporter( + mapper: LoiReportMapper, + exportService: PdfExportService, + ): LoiReportExporter = LoiReportExporter(mapper = mapper, exportService = exportService) +} diff --git a/app/src/main/java/org/groundplatform/android/di/UseCaseModule.kt b/app/src/main/java/org/groundplatform/android/di/UseCaseModule.kt index 34c20a224d..ea84061442 100644 --- a/app/src/main/java/org/groundplatform/android/di/UseCaseModule.kt +++ b/app/src/main/java/org/groundplatform/android/di/UseCaseModule.kt @@ -38,7 +38,14 @@ object UseCaseModule { locationOfInterestRepository: LocationOfInterestRepositoryInterface, userRepository: UserRepositoryInterface, surveyRepository: SurveyRepositoryInterface, - ) = GetLoiReportUseCase(locationOfInterestRepository, userRepository, surveyRepository) + submissionRepository: SubmissionRepositoryInterface, + ) = + GetLoiReportUseCase( + locationOfInterestRepository = locationOfInterestRepository, + userRepositoryInterface = userRepository, + surveyRepositoryInterface = surveyRepository, + submissionRepositoryInterface = submissionRepository, + ) @Provides fun providesUpdateUserSettingsUseCase(userRepository: UserRepositoryInterface) = diff --git a/app/src/main/java/org/groundplatform/android/di/coroutines/CoroutineDispatchersModule.kt b/app/src/main/java/org/groundplatform/android/di/coroutines/CoroutineDispatchersModule.kt index 12b9c9dfc9..8295ea7f01 100644 --- a/app/src/main/java/org/groundplatform/android/di/coroutines/CoroutineDispatchersModule.kt +++ b/app/src/main/java/org/groundplatform/android/di/coroutines/CoroutineDispatchersModule.kt @@ -30,8 +30,14 @@ object CoroutineDispatchersModule { @IoDispatcher @Provides fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO @MainDispatcher @Provides fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main + + @DefaultDispatcher + @Provides + fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default } +@Retention(AnnotationRetention.RUNTIME) @Qualifier annotation class DefaultDispatcher + @Retention(AnnotationRetention.RUNTIME) @Qualifier annotation class IoDispatcher @Retention(AnnotationRetention.RUNTIME) @Qualifier annotation class MainDispatcher diff --git a/app/src/main/java/org/groundplatform/android/repository/SubmissionRepository.kt b/app/src/main/java/org/groundplatform/android/repository/SubmissionRepository.kt index e03fab65d1..352cbfdf34 100644 --- a/app/src/main/java/org/groundplatform/android/repository/SubmissionRepository.kt +++ b/app/src/main/java/org/groundplatform/android/repository/SubmissionRepository.kt @@ -112,4 +112,7 @@ constructor( private suspend fun getPendingDeleteCount(loiId: String) = localSubmissionStore.getPendingDeleteCount(loiId) + + override suspend fun getSubmissions(loi: LocationOfInterest) = + localSubmissionStore.getSubmissions(loi, loi.job.id) } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionFragment.kt index adfc69de01..182480ad14 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionFragment.kt @@ -22,6 +22,7 @@ import android.view.ViewGroup import androidx.hilt.navigation.fragment.hiltNavGraphViewModels import androidx.navigation.fragment.findNavController import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import org.groundplatform.android.R import org.groundplatform.android.ui.common.AbstractFragment import org.groundplatform.android.ui.common.BackPressListener @@ -29,7 +30,6 @@ import org.groundplatform.android.ui.common.EphemeralPopups import org.groundplatform.android.ui.home.HomeScreenViewModel import org.groundplatform.android.util.createComposeView import org.groundplatform.android.util.openAppSettings -import javax.inject.Inject /** Fragment allowing the user to collect data to complete a task. */ @AndroidEntryPoint @@ -55,6 +55,7 @@ class DataCollectionFragment : AbstractFragment(), BackPressListener { onExitConfirmed = { navigateBack() }, onOpenSettings = { requireActivity().openAppSettings() }, onAwaitingPhotoCapture = { homeScreenViewModel.awaitingPhotoCapture = it }, + onReportExportError = { popups.ErrorPopup().unknownError() }, ) } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreen.kt index 766035e189..7edc08f9bf 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionScreen.kt @@ -43,6 +43,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.groundplatform.android.R import org.groundplatform.android.ui.components.ConfirmationDialog import org.groundplatform.android.ui.datacollection.tasks.TaskScreenContainer +import org.groundplatform.ui.components.loireport.LoiReportAction /** * The main screen for data collection, coordinating the task sequence and host UI. @@ -57,6 +58,7 @@ import org.groundplatform.android.ui.datacollection.tasks.TaskScreenContainer fun DataCollectionScreen( viewModel: DataCollectionViewModel, onValidationError: (resId: Int) -> Unit, + onReportExportError: () -> Unit, onExitConfirmed: () -> Unit, onOpenSettings: () -> Unit, onAwaitingPhotoCapture: (Boolean) -> Unit, @@ -71,11 +73,16 @@ fun DataCollectionScreen( is DataCollectionUiEffect.OpenSettings -> onOpenSettings() is DataCollectionUiEffect.SetAwaitingPhotoCapture -> onAwaitingPhotoCapture(effect.awaiting) is DataCollectionUiEffect.ShowValidationError -> onValidationError(effect.errorResId) + is DataCollectionUiEffect.ShowReportExportError -> onReportExportError() } } } - DataCollectionContent(uiState = uiState, onCloseClicked = { viewModel.onCloseClicked() }) { + DataCollectionContent( + uiState = uiState, + onCloseClicked = { viewModel.onCloseClicked() }, + onLoiReportAction = { viewModel.onLoiReportAction(it) }, + ) { readyState -> val tasks = readyState.tasks if (tasks.isNotEmpty()) { @@ -134,6 +141,7 @@ object DataCollectionScreenTestTags { fun DataCollectionContent( uiState: DataCollectionUiState, onCloseClicked: () -> Unit, + onLoiReportAction: (LoiReportAction) -> Unit, pagerContent: @Composable (DataCollectionUiState.Ready) -> Unit, ) { Scaffold(topBar = { DataCollectionToolbar(uiState, onCloseClicked) }) { innerPadding -> @@ -153,7 +161,11 @@ fun DataCollectionContent( ReadyContent { pagerContent(uiState) } } is DataCollectionUiState.TaskSubmitted -> { - DataSubmissionConfirmationScreen(loiReport = uiState.loiReport) { onCloseClicked() } + DataSubmissionConfirmationScreen( + loiReport = uiState.loiReport, + onLoiReportAction = onLoiReportAction, + onDismissed = onCloseClicked, + ) } } } 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 bd1c6946a9..10b2b1c88a 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 @@ -29,6 +29,7 @@ import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport import org.groundplatform.domain.model.job.Job import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.ui.theme.AppTheme +import kotlin.time.Clock private const val PAGER_CONTENT_TEXT = "Pager Content Area" @@ -37,7 +38,11 @@ private const val PAGER_CONTENT_TEXT = "Pager Content Area" @ExcludeFromJacocoGeneratedReport private fun DataCollectionContentLoadingPreview() { AppTheme { - DataCollectionContent(uiState = DataCollectionUiState.Loading, onCloseClicked = {}) { + DataCollectionContent( + uiState = DataCollectionUiState.Loading, + onCloseClicked = {}, + onLoiReportAction = {}, + ) { Box(modifier = Modifier.fillMaxSize().background(Color.LightGray)) { Text(text = PAGER_CONTENT_TEXT, modifier = Modifier.align(Alignment.Center)) } @@ -57,6 +62,7 @@ private fun DataCollectionContentErrorPreview() { cause = Error("Some error"), ), onCloseClicked = {}, + onLoiReportAction = {}, ) { Box(modifier = Modifier.fillMaxSize().background(Color.LightGray)) { Text(text = PAGER_CONTENT_TEXT, modifier = Modifier.align(Alignment.Center)) @@ -82,6 +88,7 @@ private fun DataCollectionContentPreview() { position = TaskPosition(0, 1, 3), ), onCloseClicked = {}, + onLoiReportAction = {}, ) { Box(modifier = Modifier.fillMaxSize().background(Color.LightGray)) { Text(text = PAGER_CONTENT_TEXT, modifier = Modifier.align(Alignment.Center)) @@ -99,9 +106,21 @@ private fun DataCollectionContentCompletePreview() { uiState = DataCollectionUiState.TaskSubmitted( loiReport = - LoiReport(loiName = "Point A", geoJson = JsonObject(mapOf()), submissionDetails = null) + LoiReport( + loiName = "Point A", + geoJson = JsonObject(mapOf()), + submissionDetails = + LoiReport.SubmissionDetails( + surveyName = "Test Survey", + userName = "John Doe", + userEmail = "john.doe@example.com", + dateMillis = Clock.System.now().toEpochMilliseconds(), + submissions = emptyList(), + ), + ) ), onCloseClicked = {}, + onLoiReportAction = {}, ) { Box(modifier = Modifier.fillMaxSize().background(Color.LightGray)) { Text(text = PAGER_CONTENT_TEXT, modifier = Modifier.align(Alignment.Center)) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt index f4221e6901..416d2e9b17 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/DataCollectionViewModel.kt @@ -59,6 +59,8 @@ import org.groundplatform.domain.model.task.Task import org.groundplatform.domain.repository.SubmissionRepositoryInterface import org.groundplatform.domain.usecases.GetLoiReportUseCase import org.groundplatform.domain.usecases.submission.SubmitDataUseCase +import org.groundplatform.feature.pdf.LoiReportExporter +import org.groundplatform.ui.components.loireport.LoiReportAction import timber.log.Timber sealed interface DataCollectionUiEffect { @@ -69,6 +71,8 @@ sealed interface DataCollectionUiEffect { data class SetAwaitingPhotoCapture(val awaiting: Boolean) : DataCollectionUiEffect data class ShowValidationError(val errorResId: Int) : DataCollectionUiEffect + + data object ShowReportExportError : DataCollectionUiEffect } /** View model for the Data Collection fragment. */ @@ -86,6 +90,7 @@ internal constructor( private val viewModelFactory: ViewModelFactory, private val dataCollectionInitializer: DataCollectionInitializer, private val getLoiReportUseCase: GetLoiReportUseCase, + private val loiReportExporter: LoiReportExporter, ) : AbstractViewModel() { private val _uiEffects = Channel(Channel.BUFFERED) @@ -200,6 +205,15 @@ internal constructor( } } + fun onLoiReportAction(action: LoiReportAction) { + val loiReport = (uiState.value as? DataCollectionUiState.TaskSubmitted)?.loiReport + viewModelScope.launch { + if (loiReport == null || loiReportExporter.export(loiReport, action).isFailure) { + _uiEffects.send(DataCollectionUiEffect.ShowReportExportError) + } + } + } + fun handleLoiNameAction(action: LoiNameAction, taskId: String) { when (action) { is LoiNameAction.Confirmed -> { 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 0b18ea8b3a..039c4c9b11 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 @@ -58,9 +58,11 @@ import kotlinx.serialization.json.JsonPrimitive import org.groundplatform.android.R import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport import org.groundplatform.domain.model.locationofinterest.LoiReport +import org.groundplatform.ui.components.loireport.LoiReportAction import org.groundplatform.ui.components.loireport.SubmissionPdfItem import org.groundplatform.ui.components.qrcode.GroundQrCode import org.groundplatform.ui.theme.AppTheme +import kotlin.time.Clock import org.jetbrains.compose.resources.stringResource as multiplatformStringResource @Composable @@ -68,6 +70,7 @@ fun DataSubmissionConfirmationScreen( modifier: Modifier = Modifier, loiReport: LoiReport? = null, onDismissed: () -> Unit, + onLoiReportAction: (LoiReportAction) -> Unit, ) { val baseModifier = modifier @@ -88,12 +91,16 @@ fun DataSubmissionConfirmationScreen( } } Spacer(modifier = Modifier.width(16.dp)) - ShareableContent(modifier = Modifier.weight(1f), loiReport = loiReport) + ShareableContent( + modifier = Modifier.weight(1f), + loiReport = loiReport, + onLoiReportAction = onLoiReportAction, + ) } } else { Column(modifier = baseModifier, horizontalAlignment = Alignment.CenterHorizontally) { HeaderContent(modifier = Modifier.padding(vertical = 16.dp)) - ShareableContent(loiReport = loiReport) + ShareableContent(loiReport = loiReport, onLoiReportAction = onLoiReportAction) OutlinedButton(modifier = Modifier.padding(vertical = 24.dp), onClick = { onDismissed() }) { Text( modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), @@ -136,7 +143,11 @@ private fun HeaderContent(modifier: Modifier = Modifier) { } @Composable -private fun ShareableContent(modifier: Modifier = Modifier, loiReport: LoiReport?) { +private fun ShareableContent( + modifier: Modifier = Modifier, + loiReport: LoiReport?, + onLoiReportAction: (LoiReportAction) -> Unit, +) { val context = LocalContext.current loiReport?.let { @@ -164,21 +175,15 @@ private fun ShareableContent(modifier: Modifier = Modifier, loiReport: LoiReport } 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 */ - }, - ) - } + 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 = { onLoiReportAction(LoiReportAction.OnPdfItemClicked) }, + onShareClick = { onLoiReportAction(LoiReportAction.OnShareClicked) }, + ) } } } @@ -201,19 +206,38 @@ private val testLoiReport = ), ) ), - submissionDetails = null, + submissionDetails = + LoiReport.SubmissionDetails( + surveyName = "Test Survey", + userName = "John Doe", + userEmail = "john.doe@example.com", + dateMillis = Clock.System.now().toEpochMilliseconds(), + submissions = emptyList(), + ), ) @Composable @Preview(showSystemUi = true) @ExcludeFromJacocoGeneratedReport private fun DataSubmissionConfirmationScreenPortraitPreview() { - AppTheme { DataSubmissionConfirmationScreen(loiReport = testLoiReport) {} } + AppTheme { + DataSubmissionConfirmationScreen( + loiReport = testLoiReport, + onLoiReportAction = {}, + onDismissed = {}, + ) + } } @Composable @Preview(heightDp = 320, widthDp = 800) @ExcludeFromJacocoGeneratedReport private fun DataSubmissionConfirmationScreenLandscapePreview() { - AppTheme { DataSubmissionConfirmationScreen(loiReport = testLoiReport) {} } + AppTheme { + DataSubmissionConfirmationScreen( + loiReport = testLoiReport, + onLoiReportAction = {}, + onDismissed = {}, + ) + } } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt index 432ccdf284..f249c00f20 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerFragment.kt @@ -25,7 +25,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.fragment.findNavController import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import org.groundplatform.android.R import org.groundplatform.android.databinding.BasemapLayoutBinding import org.groundplatform.android.ui.common.AbstractMapContainerFragment import org.groundplatform.android.ui.common.BaseMapViewModel @@ -39,12 +38,10 @@ import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentActio import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState import org.groundplatform.android.ui.home.mapcontainer.jobs.SelectedLoiSheetData import org.groundplatform.android.ui.map.MapFragment -import org.groundplatform.android.usecases.datasharingterms.GetDataSharingTermsUseCase import org.groundplatform.android.util.renderComposableDialog import org.groundplatform.android.util.setComposableContent import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY -import timber.log.Timber /** Main app view, displaying the map and related controls (center cross-hairs, add button, etc). */ @AndroidEntryPoint @@ -64,13 +61,6 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { map.featureClicks.launchWhenStartedAndCollect { mapContainerViewModel.onFeatureClicked(it) } } - private fun hasValidTasks(cardUiData: DataCollectionEntryPointData) = - when (cardUiData) { - // LOI tasks are filtered out of the tasks list for pre-defined tasks. - is SelectedLoiSheetData -> cardUiData.loi.job.tasks.values.count { !it.isAddLoiTask } > 0 - is AdHocDataCollectionButtonData -> cardUiData.job.tasks.values.isNotEmpty() - } - private fun showDataSharingTermsDialog( cardUiData: DataCollectionEntryPointData, dataSharingTerms: Survey.DataSharingTerms, @@ -83,45 +73,6 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { } } - /** Invoked when user clicks on the map cards to collect data. */ - private fun onCollectData(cardUiData: DataCollectionEntryPointData) { - if (!cardUiData.canCollectData) { - // Skip data collection screen if the user can't submit any data - // TODO: Revisit UX for displaying view only mode - // Issue URL: https://github.com/google/ground-android/issues/1667 - ephemeralPopups.ErrorPopup().show(getString(R.string.collect_data_viewer_error)) - return - } - if (!hasValidTasks(cardUiData)) { - // NOTE(#2539): The DataCollectionFragment will crash if there are no tasks. - ephemeralPopups.ErrorPopup().show(getString(R.string.no_tasks_error)) - return - } - - mapContainerViewModel - .getDataSharingTerms() - .onSuccess { terms -> - if (terms == null) { - // Data sharing terms already accepted or missing. - navigateToDataCollectionFragment(cardUiData) - } else { - showDataSharingTermsDialog(cardUiData, terms) - } - } - .onFailure { - Timber.e(it, "Failed to get data sharing terms") - ephemeralPopups - .ErrorPopup() - .show( - if (it is GetDataSharingTermsUseCase.InvalidCustomSharingTermsException) { - R.string.invalid_data_sharing_terms - } else { - R.string.something_went_wrong - } - ) - } - } - /** Invoked when user clicks delete on a site. */ private fun onDeleteSite(loiData: SelectedLoiSheetData) { mapContainerViewModel.deleteLoi(loiData.loi) @@ -160,12 +111,14 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { onJobComponentAction = { handleJobMapComponentAction(jobMapComponentState = jobMapComponentState, action = it) }, + onLoiReportAction = { mapContainerViewModel.onLoiReportAction(it) }, ) } } binding.bottomContainer.bringToFront() - showDataCollectionHint() + mapContainerViewModel.uiEffects.launchWhenStartedAndCollect { handleUiEffect(it) } + mapContainerViewModel.showDataCollectionHint() // LOIs associated with the survey have been synced to the local db by this point. We can // enable location lock if no LOIs exist or a previous camera position doesn't exist. @@ -186,7 +139,7 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { ) { when (action) { is JobMapComponentAction.OnAddDataClicked -> { - onCollectData(action.selectedLoi) + mapContainerViewModel.onCollectData(action.selectedLoi) } is JobMapComponentAction.OnDeleteSiteClicked -> { onDeleteSite(action.selectedLoi) @@ -199,10 +152,12 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { val jobs = (jobMapComponentState as? JobMapComponentState.AddLoiButton)?.jobs ?: (jobMapComponentState as? JobMapComponentState.JobSelectionModal)?.jobs - jobs?.firstOrNull { it.job == action.job }?.let { onCollectData(it) } + jobs?.firstOrNull { it.job == action.job }?.let { mapContainerViewModel.onCollectData(it) } } is JobMapComponentAction.OnAddLoiButtonClicked -> { - mapContainerViewModel.resolveAddLoiAction(jobMapComponentState)?.let { onCollectData(it) } + mapContainerViewModel.resolveAddLoiAction(jobMapComponentState)?.let { + mapContainerViewModel.onCollectData(it) + } } JobMapComponentAction.OnJobSelectionModalDismissed -> { mapContainerViewModel.setJobSelectionModalVisibility(false) @@ -210,33 +165,18 @@ class HomeScreenMapContainerFragment : AbstractMapContainerFragment() { } } - /** - * Displays a popup hint informing users how to begin collecting data. - * - * This method should only be called after view creation and should only trigger once per view - * create. - */ - private fun showDataCollectionHint() { - if (!this::mapContainerViewModel.isInitialized) { - return Timber.w("showDataCollectionHint() called before mapContainerViewModel initialized") - } - if (!this::binding.isInitialized) { - return Timber.w("showDataCollectionHint() called before binding initialized") - } - - // Decides which survey-related popup to show based on the current survey. - mapContainerViewModel.surveyUpdateFlow.launchWhenStartedAndCollectFirst { surveyProperties -> - surveyProperties.getInfoPopupMessageId()?.let { showInfoPopup(it) } + private fun handleUiEffect(event: HomeScreenMapContainerUiEffect) { + when (event) { + is HomeScreenMapContainerUiEffect.ShowError -> + ephemeralPopups.ErrorPopup().show(event.messageId) + is HomeScreenMapContainerUiEffect.ShowInfo -> showInfoPopup(event.messageId) + is HomeScreenMapContainerUiEffect.NavigateToDataCollection -> + navigateToDataCollectionFragment(event.data) + is HomeScreenMapContainerUiEffect.ShowDataSharingTerms -> + showDataSharingTermsDialog(event.data, event.terms) } } - private fun HomeScreenMapContainerViewModel.SurveyProperties.getInfoPopupMessageId(): Int? = - if (noLois && !addLoiPermitted) { - R.string.read_only_data_collection_hint - } else { - null - } - private fun showInfoPopup(messageId: Int) { ephemeralPopups .InfoPopup(binding.bottomContainer, messageId, EphemeralPopups.PopupDuration.LONG) diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt index 1e83325ea0..e56cc9bb7b 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreen.kt @@ -42,6 +42,7 @@ import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentActio import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState import org.groundplatform.domain.model.job.Job import org.groundplatform.domain.model.job.Style +import org.groundplatform.ui.components.loireport.LoiReportAction import org.groundplatform.ui.theme.AppTheme @Composable @@ -53,6 +54,7 @@ fun HomeScreenMapContainerScreen( jobComponentState: JobMapComponentState, onBaseMapAction: (BaseMapAction) -> Unit, onJobComponentAction: (JobMapComponentAction) -> Unit, + onLoiReportAction: (LoiReportAction) -> Unit, ) { Box(modifier = modifier.fillMaxSize()) { if (shouldShowMapActions) { @@ -85,7 +87,11 @@ fun HomeScreenMapContainerScreen( ) } - JobMapComponent(state = jobComponentState, onAction = onJobComponentAction) + JobMapComponent( + state = jobComponentState, + onJobComponentAction = onJobComponentAction, + onLoiReportAction = onLoiReportAction, + ) } } } @@ -154,6 +160,7 @@ private fun HomeScreenMapContainerScreenPreview() { shouldShowRecenter = true, onBaseMapAction = {}, onJobComponentAction = {}, + onLoiReportAction = {}, ) } } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerUiEffect.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerUiEffect.kt new file mode 100644 index 0000000000..a8fc15720f --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerUiEffect.kt @@ -0,0 +1,38 @@ +/* + * 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.android.ui.home.mapcontainer + +import androidx.annotation.StringRes +import org.groundplatform.android.ui.home.mapcontainer.jobs.DataCollectionEntryPointData +import org.groundplatform.domain.model.Survey + +/** + * One-off events emitted by [HomeScreenMapContainerViewModel] for the host fragment to render + * (popups, navigation, dialogs). + */ +sealed interface HomeScreenMapContainerUiEffect { + data class ShowError(@StringRes val messageId: Int) : HomeScreenMapContainerUiEffect + + data class ShowInfo(@StringRes val messageId: Int) : HomeScreenMapContainerUiEffect + + data class NavigateToDataCollection(val data: DataCollectionEntryPointData) : + HomeScreenMapContainerUiEffect + + data class ShowDataSharingTerms( + val data: DataCollectionEntryPointData, + val terms: Survey.DataSharingTerms, + ) : HomeScreenMapContainerUiEffect +} diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt index 32250a83df..c78ce13b7d 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModel.kt @@ -22,6 +22,7 @@ import kotlin.time.Duration.Companion.milliseconds import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -35,8 +36,10 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import org.groundplatform.android.R import org.groundplatform.android.common.Constants.CLUSTERING_ZOOM_THRESHOLD import org.groundplatform.android.data.local.LocalValueStore import org.groundplatform.android.system.LocationManager @@ -46,6 +49,7 @@ import org.groundplatform.android.ui.common.BaseMapViewModel import org.groundplatform.android.ui.common.LocationOfInterestHelper import org.groundplatform.android.ui.common.SharedViewModel import org.groundplatform.android.ui.home.mapcontainer.jobs.AdHocDataCollectionButtonData +import org.groundplatform.android.ui.home.mapcontainer.jobs.DataCollectionEntryPointData import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState import org.groundplatform.android.ui.home.mapcontainer.jobs.SelectedLoiSheetData import org.groundplatform.android.ui.map.Feature @@ -62,6 +66,9 @@ import org.groundplatform.domain.repository.SubmissionRepositoryInterface import org.groundplatform.domain.repository.SurveyRepositoryInterface import org.groundplatform.domain.repository.UserRepositoryInterface import org.groundplatform.domain.usecases.GetLoiReportUseCase +import org.groundplatform.feature.pdf.LoiReportExporter +import org.groundplatform.ui.components.loireport.LoiReportAction +import timber.log.Timber @OptIn(ExperimentalCoroutinesApi::class) @SharedViewModel @@ -81,6 +88,7 @@ internal constructor( private val localValueStore: LocalValueStore, private val locationOfInterestHelper: LocationOfInterestHelper, private val getLoiReportUseCase: GetLoiReportUseCase, + private val loiReportExporter: LoiReportExporter, ) : BaseMapViewModel( locationManager, @@ -139,6 +147,9 @@ internal constructor( */ val jobMapComponentState: StateFlow + private val _uiEffects = Channel(Channel.BUFFERED) + val uiEffects: Flow = _uiEffects.receiveAsFlow() + init { // THIS SHOULD NOT BE CALLED ON CONFIG CHANGE @@ -328,4 +339,78 @@ internal constructor( featureClicked.value = null } } + + fun onLoiReportAction(action: LoiReportAction) { + val loiReport = + (jobMapComponentState.value as? JobMapComponentState.LoiSelected)?.loi?.loiReport + viewModelScope.launch { + if (loiReport == null || loiReportExporter.export(loiReport, action).isFailure) { + _uiEffects.send(HomeScreenMapContainerUiEffect.ShowError(R.string.unexpected_error)) + } + } + } + + /** Invoked when user clicks on the map cards to collect data. */ + fun onCollectData(cardUiData: DataCollectionEntryPointData) { + viewModelScope.launch { + when { + !cardUiData.canCollectData -> + // Skip data collection screen if the user can't submit any data. + // TODO: Revisit UX for displaying view only mode + // Issue URL: https://github.com/google/ground-android/issues/1667 + _uiEffects.send( + HomeScreenMapContainerUiEffect.ShowError(R.string.collect_data_viewer_error) + ) + !hasValidTasks(cardUiData) -> + // NOTE(#2539): The DataCollectionFragment will crash if there are no tasks. + _uiEffects.send(HomeScreenMapContainerUiEffect.ShowError(R.string.no_tasks_error)) + else -> + getDataSharingTerms() + .onSuccess { terms -> + if (terms == null) { + // Data sharing terms already accepted or missing. + _uiEffects.send(HomeScreenMapContainerUiEffect.NavigateToDataCollection(cardUiData)) + } else { + _uiEffects.send( + HomeScreenMapContainerUiEffect.ShowDataSharingTerms(cardUiData, terms) + ) + } + } + .onFailure { + Timber.e(it, "Failed to get data sharing terms") + val messageId = + if (it is GetDataSharingTermsUseCase.InvalidCustomSharingTermsException) { + R.string.invalid_data_sharing_terms + } else { + R.string.something_went_wrong + } + _uiEffects.send(HomeScreenMapContainerUiEffect.ShowError(messageId)) + } + } + } + } + + private fun hasValidTasks(cardUiData: DataCollectionEntryPointData) = + when (cardUiData) { + // LOI tasks are filtered out of the tasks list for pre-defined tasks. + is SelectedLoiSheetData -> cardUiData.loi.job.tasks.values.count { !it.isAddLoiTask } > 0 + is AdHocDataCollectionButtonData -> cardUiData.job.tasks.values.isNotEmpty() + } + + /** + * Displays a popup hint informing users how to begin collecting data. + * + * This method should only be called after view creation and should only trigger once per view + * create. + */ + fun showDataCollectionHint() { + viewModelScope.launch { + val properties = surveyUpdateFlow.first() + if (properties.noLois && !properties.addLoiPermitted) { + _uiEffects.send( + HomeScreenMapContainerUiEffect.ShowInfo(R.string.read_only_data_collection_hint) + ) + } + } + } } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt index 5a73e513f6..e7ae41816b 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponent.kt @@ -42,34 +42,43 @@ import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentActio import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentAction.OnJobSelected import org.groundplatform.domain.model.job.Job import org.groundplatform.domain.model.job.Style +import org.groundplatform.ui.components.loireport.LoiReportAction import org.groundplatform.ui.theme.AppTheme @Composable -fun JobMapComponent(state: JobMapComponentState, onAction: (JobMapComponentAction) -> Unit) { +fun JobMapComponent( + state: JobMapComponentState, + onJobComponentAction: (JobMapComponentAction) -> Unit, + onLoiReportAction: (LoiReportAction) -> Unit, +) { when (state) { is JobMapComponentState.LoiSelected -> { var showShareLoiModal by rememberSaveable { mutableStateOf(false) } LoiJobSheet( state = state.loi, - onCollectClicked = { onAction(OnAddDataClicked(state.loi)) }, - onDeleteClicked = { onAction(OnDeleteSiteClicked(state.loi)) }, - onDismiss = { onAction(JobMapComponentAction.OnJobCardDismissed) }, + onCollectClicked = { onJobComponentAction(OnAddDataClicked(state.loi)) }, + onDeleteClicked = { onJobComponentAction(OnDeleteSiteClicked(state.loi)) }, + onDismiss = { onJobComponentAction(JobMapComponentAction.OnJobCardDismissed) }, onShareClicked = { showShareLoiModal = true }, ) if (showShareLoiModal && state.loi.loiReport != null) { - ShareLocationModal(state.loi.loiReport) { showShareLoiModal = false } + ShareLocationModal( + loiReport = state.loi.loiReport, + onLoiReportAction = onLoiReportAction, + onDismiss = { showShareLoiModal = false }, + ) } } is JobMapComponentState.AddLoiButton -> { - AddLoiButton(onClick = { onAction(JobMapComponentAction.OnAddLoiButtonClicked) }) + AddLoiButton(onClick = { onJobComponentAction(JobMapComponentAction.OnAddLoiButtonClicked) }) } is JobMapComponentState.JobSelectionModal -> { JobSelectionModal( jobs = state.jobs.map { it.job }, - onJobClicked = { job -> onAction(OnJobSelected(job)) }, - onDismiss = { onAction(JobMapComponentAction.OnJobSelectionModalDismissed) }, + onJobClicked = { job -> onJobComponentAction(OnJobSelected(job)) }, + onDismiss = { onJobComponentAction(JobMapComponentAction.OnJobSelectionModalDismissed) }, ) } is JobMapComponentState.Hidden -> {} @@ -136,7 +145,9 @@ private fun JobMapComponentPreview() { ), ) ) - ) - ) {} + ), + onJobComponentAction = {}, + onLoiReportAction = {}, + ) } } 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 346a83f0ef..54d17ab5c4 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 @@ -46,11 +46,13 @@ 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 import org.groundplatform.android.R import org.groundplatform.domain.model.locationofinterest.LoiReport +import org.groundplatform.ui.components.loireport.LoiReportAction import org.groundplatform.ui.components.loireport.SubmissionPdfItem import org.groundplatform.ui.components.qrcode.GroundQrCode import org.groundplatform.ui.theme.AppTheme @@ -58,7 +60,11 @@ import org.jetbrains.compose.resources.stringResource as multiplatformStringReso @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ShareLocationModal(loiReport: LoiReport, onDismiss: () -> Unit) { +fun ShareLocationModal( + loiReport: LoiReport, + onDismiss: () -> Unit, + onLoiReportAction: (LoiReportAction) -> Unit, +) { val context = LocalContext.current Dialog( @@ -95,25 +101,19 @@ fun ShareLocationModal(loiReport: LoiReport, onDismiss: () -> Unit) { } 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 */ - }, - ) - } + SubmissionPdfItem( + modifier = Modifier.fillMaxWidth(), + title = it.surveyName, + loiName = loiReport.loiName, + userName = it.userName, + date = DateFormat.getDateFormat(context).format(Date(it.dateMillis)), + onItemClick = { onLoiReportAction(LoiReportAction.OnPdfItemClicked) }, + onShareClick = { onLoiReportAction(LoiReportAction.OnShareClicked) }, + ) } TextButton( - modifier = Modifier.align(Alignment.End).padding(top = 16.dp), + modifier = Modifier.align(Alignment.End).padding(16.dp), onClick = onDismiss, ) { Text(text = stringResource(R.string.close)) @@ -143,12 +143,19 @@ private fun ShareLocationModalPreview() { ), ) ), - submissionDetails = null, + submissionDetails = + LoiReport.SubmissionDetails( + surveyName = "Test Survey", + userName = "John Doe", + userEmail = "john.doe@example.com", + dateMillis = Clock.System.now().toEpochMilliseconds(), + submissions = emptyList(), + ), ) AppTheme { Surface(modifier = Modifier.fillMaxSize()) { - ShareLocationModal(loiReport = testLoiReport, onDismiss = {}) + ShareLocationModal(loiReport = testLoiReport, onDismiss = {}, onLoiReportAction = {}) } } } diff --git a/app/src/test/java/org/groundplatform/android/FakeData.kt b/app/src/test/java/org/groundplatform/android/FakeData.kt index 986cdd2be7..e858c36b16 100644 --- a/app/src/test/java/org/groundplatform/android/FakeData.kt +++ b/app/src/test/java/org/groundplatform/android/FakeData.kt @@ -129,14 +129,7 @@ object FakeData { ), ) ), - submissionDetails = - LoiReport.SubmissionDetails( - surveyName = SURVEY.title, - userName = USER.displayName, - userEmail = USER.email, - dateMillis = LOCATION_OF_INTEREST.lastModified.clientTimestamp, - submissions = null, - ), + submissionDetails = null, ) val LOCATION_OF_INTEREST_FEATURE = Feature( diff --git a/app/src/test/java/org/groundplatform/android/TestCoroutineDispatchersModule.kt b/app/src/test/java/org/groundplatform/android/TestCoroutineDispatchersModule.kt index 7ae38281f4..acaa42caa9 100644 --- a/app/src/test/java/org/groundplatform/android/TestCoroutineDispatchersModule.kt +++ b/app/src/test/java/org/groundplatform/android/TestCoroutineDispatchersModule.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestDispatcher import org.groundplatform.android.di.coroutines.CoroutineDispatchersModule +import org.groundplatform.android.di.coroutines.DefaultDispatcher import org.groundplatform.android.di.coroutines.IoDispatcher import org.groundplatform.android.di.coroutines.MainDispatcher @@ -43,4 +44,8 @@ object TestCoroutineDispatchersModule { @MainDispatcher @Provides fun provideMainDispatcher(testDispatcher: TestDispatcher): CoroutineDispatcher = testDispatcher + + @DefaultDispatcher + @Provides + fun provideDefaultDispatcher(testDispatcher: TestDispatcher): CoroutineDispatcher = testDispatcher } diff --git a/app/src/test/java/org/groundplatform/android/repository/SubmissionRepositoryTest.kt b/app/src/test/java/org/groundplatform/android/repository/SubmissionRepositoryTest.kt index cc26d06a15..65377363fe 100644 --- a/app/src/test/java/org/groundplatform/android/repository/SubmissionRepositoryTest.kt +++ b/app/src/test/java/org/groundplatform/android/repository/SubmissionRepositoryTest.kt @@ -27,6 +27,7 @@ import org.groundplatform.domain.model.locationofinterest.LocationOfInterest import org.groundplatform.domain.model.mutation.Mutation import org.groundplatform.domain.model.mutation.SubmissionMutation import org.groundplatform.domain.model.submission.DraftSubmission +import org.groundplatform.domain.model.submission.Submission import org.groundplatform.domain.model.submission.TextTaskData import org.groundplatform.domain.model.submission.ValueDelta import org.groundplatform.domain.model.task.Task @@ -216,6 +217,21 @@ class SubmissionRepositoryTest { assertThat(repository.getPendingCreateCount(loi.id)).isEqualTo(7) } + @Test + fun `getSubmissions returns submissions for the LOI's job from the local store`() = runTest { + val expected = listOf(TEST_SUBMISSION) + whenever(localSubmissionStore.getSubmissions(TEST_LOI, TEST_JOB.id)).thenReturn(expected) + + assertThat(repository.getSubmissions(TEST_LOI)).isEqualTo(expected) + } + + @Test + fun `getSubmissions returns empty list when local store has no submissions`() = runTest { + whenever(localSubmissionStore.getSubmissions(TEST_LOI, TEST_JOB.id)).thenReturn(emptyList()) + + assertThat(repository.getSubmissions(TEST_LOI)).isEmpty() + } + private suspend fun setupMocks( uuid: String = TEST_UUID, loi: LocationOfInterest? = TEST_LOI, @@ -263,5 +279,13 @@ class SubmissionRepositoryTest { deltas = TEST_DELTAS, currentTaskId = TEST_CURRENT_TASK_ID, ) + val TEST_SUBMISSION: Submission = + FakeDataGenerator.newSubmission( + surveyId = TEST_SURVEY.id, + locationOfInterest = TEST_LOI, + job = TEST_JOB, + created = AuditInfo(TEST_USER), + lastModified = AuditInfo(TEST_USER), + ) } } diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionFragmentTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionFragmentTest.kt index abc8908c78..9c93dc97f1 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionFragmentTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionFragmentTest.kt @@ -26,6 +26,9 @@ import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import javax.inject.Inject +import kotlin.time.Clock import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle @@ -38,6 +41,7 @@ import org.groundplatform.android.R import org.groundplatform.android.data.local.room.converter.SubmissionDeltasConverter import org.groundplatform.android.data.remote.FakeRemoteDataStore import org.groundplatform.android.data.sync.MutationSyncWorkManager +import org.groundplatform.android.di.PdfModule import org.groundplatform.android.getString import org.groundplatform.android.testrules.FragmentScenarioRule import org.groundplatform.android.ui.datacollection.tasks.point.DropPinTaskViewModel @@ -63,18 +67,21 @@ import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterfac import org.groundplatform.domain.repository.MutationRepositoryInterface import org.groundplatform.domain.repository.SubmissionRepositoryInterface import org.groundplatform.domain.repository.UserRepositoryInterface +import org.groundplatform.feature.pdf.LoiReportExporter +import org.groundplatform.ui.components.loireport.LoiReportAction import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.shadows.ShadowToast -import javax.inject.Inject -import kotlin.time.Clock @OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest +@UninstallModules(PdfModule::class) @RunWith(RobolectricTestRunner::class) class DataCollectionFragmentTest : BaseHiltTest() { @get:Rule(order = 4) val composeTestRule = createComposeRule() @@ -88,6 +95,7 @@ class DataCollectionFragmentTest : BaseHiltTest() { @Inject lateinit var userRepository: UserRepositoryInterface @BindValue @Mock lateinit var mutationSyncWorkManager: MutationSyncWorkManager + @BindValue @Mock lateinit var loiReportExporter: LoiReportExporter lateinit var fragment: DataCollectionFragment @@ -708,6 +716,48 @@ class DataCollectionFragmentTest : BaseHiltTest() { assertTrue(state is DataCollectionUiState.TaskSubmitted) } + @Test + fun `onLoiReportAction shows an error when exporting the report fails`() = runWithTestDispatcher { + whenever(loiReportExporter.export(any(), any())).thenReturn(Result.failure(RuntimeException())) + setupFragment() + runner() + .inputText(TASK_1_RESPONSE) + .clickNextButton() + .selectOption(TASK_2_OPTION_LABEL) + .clickDoneButton() + advanceUntilIdle() + val state = fragment.viewModel.uiState.value as DataCollectionUiState.TaskSubmitted + assertThat(state.loiReport).isNotNull() + + fragment.viewModel.onLoiReportAction(LoiReportAction.OnShareClicked) + advanceUntilIdle() + composeTestRule.waitForIdle() + + assertThat(ShadowToast.shownToastCount()).isEqualTo(1) + assertThat(ShadowToast.getTextOfLatestToast()).isEqualTo(getString(R.string.unexpected_error)) + } + + @Test + fun `onLoiReportAction does not show an error when exporting the report succeeds`() = + runWithTestDispatcher { + whenever(loiReportExporter.export(any(), any())).thenReturn(Result.success(Unit)) + setupFragment() + runner() + .inputText(TASK_1_RESPONSE) + .clickNextButton() + .selectOption(TASK_2_OPTION_LABEL) + .clickDoneButton() + advanceUntilIdle() + val state = fragment.viewModel.uiState.value as DataCollectionUiState.TaskSubmitted + assertThat(state.loiReport).isNotNull() + + fragment.viewModel.onLoiReportAction(LoiReportAction.OnShareClicked) + advanceUntilIdle() + composeTestRule.waitForIdle() + + assertThat(ShadowToast.shownToastCount()).isEqualTo(0) + } + @Test fun `Clicking done after triggering conditional task saves task data`() = runWithTestDispatcher { setupFragment() diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenTest.kt index 2e93bc5aa4..b0715119ed 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/DataCollectionScreenTest.kt @@ -75,6 +75,7 @@ class DataCollectionScreenTest { onExitConfirmed = onExitConfirmed, onOpenSettings = {}, onAwaitingPhotoCapture = {}, + onReportExportError = {}, ) } } 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 3b558eea13..d50912fc2a 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 @@ -26,6 +26,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import ground_android.core.ui.generated.resources.Res import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson +import ground_android.core.ui.generated.resources.share import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -33,8 +34,10 @@ 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.LoiReportAction 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.assertEquals import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -48,7 +51,11 @@ class DataSubmissionConfirmationScreenTest { @Test fun `Shows the correct content on portrait`() { composeTestRule.setContent { - DataSubmissionConfirmationScreen(loiReport = LOI_REPORT, onDismissed = {}) + DataSubmissionConfirmationScreen( + loiReport = LOI_REPORT, + onDismissed = {}, + onLoiReportAction = {}, + ) } composeTestRule @@ -71,7 +78,11 @@ class DataSubmissionConfirmationScreenTest { LocalConfiguration provides Configuration().apply { orientation = Configuration.ORIENTATION_LANDSCAPE } ) { - DataSubmissionConfirmationScreen(loiReport = LOI_REPORT, onDismissed = {}) + DataSubmissionConfirmationScreen( + loiReport = LOI_REPORT, + onDismissed = {}, + onLoiReportAction = {}, + ) } } @@ -90,7 +101,7 @@ class DataSubmissionConfirmationScreenTest { @Test fun `Does not show QR section if the LoiReport is null`() { composeTestRule.setContent { - DataSubmissionConfirmationScreen(loiReport = null, onDismissed = {}) + DataSubmissionConfirmationScreen(loiReport = null, onDismissed = {}, onLoiReportAction = {}) } composeTestRule @@ -107,6 +118,7 @@ class DataSubmissionConfirmationScreenTest { DataSubmissionConfirmationScreen( loiReport = LOI_REPORT.copy(submissionDetails = FakeDataGenerator.newSubmissionDetails()), onDismissed = {}, + onLoiReportAction = {}, ) } @@ -119,18 +131,59 @@ class DataSubmissionConfirmationScreenTest { DataSubmissionConfirmationScreen( loiReport = LOI_REPORT.copy(submissionDetails = null), onDismissed = {}, + onLoiReportAction = {}, ) } composeTestRule.onNodeWithTag(TEST_TAG_PDF_ITEM).assertDoesNotExist() } + @Test + fun `Clicking the PDF item triggers OnPdfItemClicked`() { + var action: LoiReportAction? = null + val details = FakeDataGenerator.newSubmissionDetails() + + composeTestRule.setContent { + DataSubmissionConfirmationScreen( + loiReport = LOI_REPORT.copy(submissionDetails = details), + onDismissed = {}, + onLoiReportAction = { action = it }, + ) + } + + composeTestRule.onNodeWithTag(TEST_TAG_PDF_ITEM).performScrollTo() + composeTestRule.onNodeWithText(details.surveyName).performClick() + + composeTestRule.runOnIdle { assertEquals(LoiReportAction.OnPdfItemClicked, action) } + } + + @Test + fun `Clicking the share button triggers OnShareClicked`() { + var action: LoiReportAction? = null + + composeTestRule.setContent { + DataSubmissionConfirmationScreen( + loiReport = LOI_REPORT.copy(submissionDetails = FakeDataGenerator.newSubmissionDetails()), + onDismissed = {}, + onLoiReportAction = { action = it }, + ) + } + + composeTestRule.onNodeWithText(getString(Res.string.share)).performScrollTo().performClick() + + composeTestRule.runOnIdle { assertEquals(LoiReportAction.OnShareClicked, action) } + } + @Test fun `onDismiss is triggered when the close button is clicked`() { var dismissed = false composeTestRule.setContent { - DataSubmissionConfirmationScreen(loiReport = LOI_REPORT, onDismissed = { dismissed = true }) + DataSubmissionConfirmationScreen( + loiReport = LOI_REPORT, + onDismissed = { dismissed = true }, + onLoiReportAction = {}, + ) } composeTestRule.onNodeWithText(getString(R.string.close)).performScrollTo().performClick() diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreenTest.kt index 1c26ce5cc1..a14c36e579 100644 --- a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerScreenTest.kt @@ -22,7 +22,6 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import org.junit.Test import kotlin.test.assertTrue import org.groundplatform.android.FakeData.ADHOC_JOB import org.groundplatform.android.R @@ -34,6 +33,7 @@ import org.groundplatform.android.ui.home.mapcontainer.jobs.AdHocDataCollectionB import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentAction import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState import org.junit.Rule +import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -176,6 +176,7 @@ class HomeScreenMapContainerScreenTest { jobComponentState = jobComponentState, onBaseMapAction = onBaseMapAction, onJobComponentAction = onJobComponentAction, + onLoiReportAction = {}, ) } } diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt index 6691549013..8fe2ac7262 100644 --- a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt @@ -27,11 +27,14 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.FakeData.ADHOC_JOB +import org.groundplatform.android.FakeData.DATA_SHARING_TERMS +import org.groundplatform.android.FakeData.JOB import org.groundplatform.android.FakeData.LOCATION_OF_INTEREST import org.groundplatform.android.FakeData.LOCATION_OF_INTEREST_FEATURE import org.groundplatform.android.FakeData.LOCATION_OF_INTEREST_LOI_REPORT import org.groundplatform.android.FakeData.SURVEY import org.groundplatform.android.FakeData.USER +import org.groundplatform.android.R import org.groundplatform.android.data.remote.FakeRemoteDataStore import org.groundplatform.android.di.LocationOfInterestRepositoryModule import org.groundplatform.android.system.auth.FakeAuthenticationManager @@ -39,11 +42,13 @@ import org.groundplatform.android.ui.home.mapcontainer.jobs.AdHocDataCollectionB import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState import org.groundplatform.android.ui.home.mapcontainer.jobs.SelectedLoiSheetData import org.groundplatform.android.usecases.survey.ActivateSurveyUseCase +import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.geometry.Coordinates import org.groundplatform.domain.model.map.Bounds import org.groundplatform.domain.model.map.CameraPosition import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface import org.groundplatform.domain.repository.UserRepositoryInterface +import org.groundplatform.ui.components.loireport.LoiReportAction import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -99,7 +104,7 @@ class HomeScreenMapContainerViewModelTest : BaseHiltTest() { loi = LOCATION_OF_INTEREST, submissionCount = 0, showDeleteLoiButton = true, - loiReport = LOCATION_OF_INTEREST_LOI_REPORT, + loiReport = LOCATION_OF_INTEREST_LOI_REPORT.copy(submissionDetails = null), ) ) ) @@ -198,6 +203,108 @@ class HomeScreenMapContainerViewModelTest : BaseHiltTest() { assertThat(result).isNull() } + @Test + fun `onCollectData emits ShowError when user cannot collect data`() = runWithTestDispatcher { + val cardData = AdHocDataCollectionButtonData(canCollectData = false, job = ADHOC_JOB) + + viewModel.onCollectData(cardData) + advanceUntilIdle() + + assertThat(viewModel.uiEffects.first()) + .isEqualTo(HomeScreenMapContainerUiEffect.ShowError(R.string.collect_data_viewer_error)) + } + + @Test + fun `onCollectData emits ShowError when card has no valid tasks`() = runWithTestDispatcher { + val cardData = + SelectedLoiSheetData( + canCollectData = true, + loi = LOCATION_OF_INTEREST, + submissionCount = 0, + showDeleteLoiButton = false, + loiReport = null, + ) + + viewModel.onCollectData(cardData) + advanceUntilIdle() + + assertThat(viewModel.uiEffects.first()) + .isEqualTo(HomeScreenMapContainerUiEffect.ShowError(R.string.no_tasks_error)) + } + + @Test + fun `onCollectData emits ShowDataSharingTerms when terms not yet accepted`() = + runWithTestDispatcher { + val cardData = AdHocDataCollectionButtonData(canCollectData = true, job = ADHOC_JOB) + + viewModel.onCollectData(cardData) + advanceUntilIdle() + + assertThat(viewModel.uiEffects.first()) + .isEqualTo( + HomeScreenMapContainerUiEffect.ShowDataSharingTerms(cardData, DATA_SHARING_TERMS) + ) + } + + @Test + fun `onCollectData emits NavigateToDataCollection when terms already accepted`() = + runWithTestDispatcher { + viewModel.grantDataSharingConsent() + val cardData = AdHocDataCollectionButtonData(canCollectData = true, job = ADHOC_JOB) + + viewModel.onCollectData(cardData) + advanceUntilIdle() + + assertThat(viewModel.uiEffects.first()) + .isEqualTo(HomeScreenMapContainerUiEffect.NavigateToDataCollection(cardData)) + } + + @Test + fun `onCollectData emits ShowError when data sharing terms are invalid`() = + runWithTestDispatcher { + val survey = + SURVEY.copy( + id = "INVALID_TERMS_SURVEY", + dataSharingTerms = Survey.DataSharingTerms.Custom(""), + ) + remoteDataStore.surveys = listOf(survey) + activateSurvey(survey.id) + advanceUntilIdle() + val cardData = AdHocDataCollectionButtonData(canCollectData = true, job = ADHOC_JOB) + + viewModel.onCollectData(cardData) + advanceUntilIdle() + + assertThat(viewModel.uiEffects.first()) + .isEqualTo(HomeScreenMapContainerUiEffect.ShowError(R.string.invalid_data_sharing_terms)) + } + + @Test + fun `showDataCollectionHint emits ShowInfo for read-only survey with no LOIs`() = + runWithTestDispatcher { + val readOnlySurvey = SURVEY.copy(id = "READ_ONLY_SURVEY", jobMap = mapOf(JOB.id to JOB)) + whenever(loiRepository.getValidLois(readOnlySurvey)).thenReturn(flowOf(setOf())) + remoteDataStore.surveys = listOf(readOnlySurvey) + activateSurvey(readOnlySurvey.id) + advanceUntilIdle() + + viewModel.showDataCollectionHint() + advanceUntilIdle() + + assertThat(viewModel.uiEffects.first()) + .isEqualTo(HomeScreenMapContainerUiEffect.ShowInfo(R.string.read_only_data_collection_hint)) + } + + @Test + fun `onLoiReportAction emits ShowError when no LOI is selected`() = runWithTestDispatcher { + // No LOI is selected, so there is no report to export. + viewModel.onLoiReportAction(LoiReportAction.OnShareClicked) + advanceUntilIdle() + + assertThat(viewModel.uiEffects.first()) + .isEqualTo(HomeScreenMapContainerUiEffect.ShowError(R.string.unexpected_error)) + } + companion object { private val BOUNDS = Bounds(Coordinates(-20.0, -20.0), Coordinates(-10.0, -10.0)) val CAMERA_POSITION = diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponentTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponentTest.kt index 39c014c2c4..0806fd6037 100644 --- a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponentTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/jobs/JobMapComponentTest.kt @@ -238,7 +238,11 @@ class JobMapComponentTest { LOCATION_OF_INTEREST_LOI_REPORT, ) composeTestRule.setContent { - JobMapComponent(state = JobMapComponentState.LoiSelected(loiSheetData), onAction = {}) + JobMapComponent( + state = JobMapComponentState.LoiSelected(loiSheetData), + onJobComponentAction = {}, + onLoiReportAction = {}, + ) } composeTestRule.onNodeWithText(getString(Res.string.share)).performClick() @@ -258,7 +262,11 @@ class JobMapComponentTest { LOCATION_OF_INTEREST_LOI_REPORT, ) composeTestRule.setContent { - JobMapComponent(state = JobMapComponentState.LoiSelected(loiSheetData), onAction = {}) + JobMapComponent( + state = JobMapComponentState.LoiSelected(loiSheetData), + onJobComponentAction = {}, + onLoiReportAction = {}, + ) } composeTestRule.onNodeWithText(getString(Res.string.share)).performClick() @@ -273,6 +281,12 @@ class JobMapComponentTest { state: JobMapComponentState, onAction: (JobMapComponentAction) -> Unit = {}, ) { - composeTestRule.setContent { JobMapComponent(state = state, onAction = { onAction(it) }) } + composeTestRule.setContent { + JobMapComponent( + state = state, + onJobComponentAction = { onAction(it) }, + onLoiReportAction = {}, + ) + } } } 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 8ffad04946..c5987d3283 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 @@ -23,6 +23,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import ground_android.core.ui.generated.resources.Res import ground_android.core.ui.generated.resources.scan_this_qr_to_download_geojson +import ground_android.core.ui.generated.resources.share import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -30,9 +31,11 @@ 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.LoiReportAction 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 +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -46,7 +49,9 @@ class ShareLocationModalTest { @Test fun `Modal is displayed correctly and shows the QR code with the LOI geometry`() { composeTestRule.setContent { - AppTheme { ShareLocationModal(loiReport = LOI_REPORT, onDismiss = {}) } + AppTheme { + ShareLocationModal(loiReport = LOI_REPORT, onDismiss = {}, onLoiReportAction = {}) + } } composeTestRule.onNodeWithText(getString(R.string.share_location)).assertIsDisplayed() composeTestRule.onNodeWithText(LOI_NAME).assertIsDisplayed() @@ -65,6 +70,7 @@ class ShareLocationModalTest { ShareLocationModal( loiReport = LOI_REPORT.copy(submissionDetails = FakeDataGenerator.newSubmissionDetails()), onDismiss = {}, + onLoiReportAction = {}, ) } } @@ -76,19 +82,67 @@ class ShareLocationModalTest { fun `Does not show the PDF item when submissions is null`() { composeTestRule.setContent { AppTheme { - ShareLocationModal(loiReport = LOI_REPORT.copy(submissionDetails = null), onDismiss = {}) + ShareLocationModal( + loiReport = LOI_REPORT.copy(submissionDetails = null), + onDismiss = {}, + onLoiReportAction = {}, + ) } } composeTestRule.onNodeWithTag(TEST_TAG_PDF_ITEM).assertDoesNotExist() } + @Test + fun `Clicking the PDF item triggers OnPdfItemClicked`() { + var action: LoiReportAction? = null + val details = FakeDataGenerator.newSubmissionDetails() + + composeTestRule.setContent { + AppTheme { + ShareLocationModal( + loiReport = LOI_REPORT.copy(submissionDetails = details), + onDismiss = {}, + onLoiReportAction = { action = it }, + ) + } + } + + composeTestRule.onNodeWithTag(TEST_TAG_PDF_ITEM).performScrollTo() + composeTestRule.onNodeWithText(details.surveyName).performClick() + + composeTestRule.runOnIdle { assertEquals(LoiReportAction.OnPdfItemClicked, action) } + } + + @Test + fun `Clicking the share button triggers OnShareClicked`() { + var action: LoiReportAction? = null + + composeTestRule.setContent { + AppTheme { + ShareLocationModal( + loiReport = LOI_REPORT.copy(submissionDetails = FakeDataGenerator.newSubmissionDetails()), + onDismiss = {}, + onLoiReportAction = { action = it }, + ) + } + } + + composeTestRule.onNodeWithText(getString(Res.string.share)).performScrollTo().performClick() + + composeTestRule.runOnIdle { assertEquals(LoiReportAction.OnShareClicked, action) } + } + @Test fun `onDismiss callback is triggered when close button is clicked`() { var dismissed = false composeTestRule.setContent { - ShareLocationModal(loiReport = LOI_REPORT, onDismiss = { dismissed = true }) + ShareLocationModal( + loiReport = LOI_REPORT, + onDismiss = { dismissed = true }, + onLoiReportAction = {}, + ) } composeTestRule.onNodeWithText(getString(R.string.close)).performScrollTo().performClick() 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 f558df72f5..1769ed44c2 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 @@ -29,6 +29,6 @@ data class LoiReport( val userName: String, val userEmail: String, val dateMillis: Long, - val submissions: List?, + val submissions: List, ) } diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/submission/SubmissionData.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/submission/SubmissionData.kt index 222a581415..a76c82812d 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/submission/SubmissionData.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/submission/SubmissionData.kt @@ -36,8 +36,9 @@ data class SubmissionData(private val data: Map = mapOf()) { fun copyWithDeltas(deltas: List): SubmissionData { val newData = data.toMutableMap() deltas.forEach { - if (it.newTaskData.isNotNullOrEmpty()) { - newData[it.taskId] = it.newTaskData + val newTaskData = it.newTaskData + if (newTaskData is SkippedTaskData || newTaskData.isNotNullOrEmpty()) { + newData[it.taskId] = newTaskData } else { newData.remove(it.taskId) } diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/SubmissionRepositoryInterface.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/SubmissionRepositoryInterface.kt index 0b426d9749..7b198accb6 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/SubmissionRepositoryInterface.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/SubmissionRepositoryInterface.kt @@ -55,4 +55,10 @@ interface SubmissionRepositoryInterface { suspend fun getTotalSubmissionCount(loi: LocationOfInterest): Int suspend fun getPendingCreateCount(loiId: String): Int + + /** + * Returns all submissions recorded for the given LOI. Includes synced submissions and locally + * pending CREATE mutations that have not yet been uploaded. + */ + suspend fun getSubmissions(loi: LocationOfInterest): 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 0568c02072..493f420520 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 @@ -31,6 +31,7 @@ import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY import org.groundplatform.domain.model.locationofinterest.LoiProperties import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface +import org.groundplatform.domain.repository.SubmissionRepositoryInterface import org.groundplatform.domain.repository.SurveyRepositoryInterface import org.groundplatform.domain.repository.UserRepositoryInterface import org.groundplatform.domain.util.toFixedDecimals @@ -44,37 +45,40 @@ class GetLoiReportUseCase( private val locationOfInterestRepository: LocationOfInterestRepositoryInterface, private val userRepositoryInterface: UserRepositoryInterface, private val surveyRepositoryInterface: SurveyRepositoryInterface, + private val submissionRepositoryInterface: SubmissionRepositoryInterface, ) { /** * Returns a [LoiReport] for the given LOI, or `null` if it does not exist. * - * @param loiName the identifier of the location of interest. + * @param loiName the name of the location of interest + * @param loiId the identifier of the location of interest. * @param surveyId the identifier of the survey the LOI belongs to. * @throws IllegalStateException if the LOI geometry is a bare [LinearRing]. */ 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, - geoJson = - it.geometry.toGeoJson( - it.properties.filter { property -> property.key == LOI_NAME_PROPERTY } - ), - submissionDetails = - LoiReport.SubmissionDetails( - surveyName = surveyName, - userName = user.displayName, - userEmail = user.email, - dateMillis = loi.lastModified.clientTimestamp, - submissions = submissions, - ), - ) - } + val loi = locationOfInterestRepository.getOfflineLoi(surveyId, loiId) ?: return null + val submissions = + submissionRepositoryInterface.getSubmissions(loi).sortedBy { it.lastModified.clientTimestamp } + val submissionDetails = + if (submissions.isNotEmpty()) { + val user = userRepositoryInterface.getAuthenticatedUser() + val surveyName = surveyRepositoryInterface.getOfflineSurvey(surveyId)?.title.orEmpty() + LoiReport.SubmissionDetails( + surveyName = surveyName, + userName = user.displayName, + userEmail = user.email, + dateMillis = loi.lastModified.clientTimestamp, + submissions = submissions, + ) + } else null + return LoiReport( + loiName = loiName, + geoJson = + loi.geometry.toGeoJson( + loi.properties.filter { property -> property.key == LOI_NAME_PROPERTY } + ), + submissionDetails = submissionDetails, + ) } /** 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 95a316eed7..1a0def1f1b 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 @@ -18,6 +18,7 @@ package org.groundplatform.domain.usecases import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNull import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json import org.groundplatform.domain.model.geometry.Coordinates @@ -33,6 +34,7 @@ import org.groundplatform.domain.model.locationofinterest.LoiReport import org.groundplatform.domain.model.locationofinterest.generateProperties import org.groundplatform.testing.FakeDataGenerator import org.groundplatform.testing.FakeLocationOfInterestRepository +import org.groundplatform.testing.FakeSubmissionRepository import org.groundplatform.testing.FakeSurveyRepository import org.groundplatform.testing.FakeUserRepository @@ -41,8 +43,9 @@ class GetLoiReportUseCaseTest { private val loiRepository = FakeLocationOfInterestRepository() private val userRepository = FakeUserRepository() private val surveyRepository = FakeSurveyRepository() + private val submissionRepository = FakeSubmissionRepository() private val getLoiReportUseCase = - GetLoiReportUseCase(loiRepository, userRepository, surveyRepository) + GetLoiReportUseCase(loiRepository, userRepository, surveyRepository, submissionRepository) @Test fun `Should get a report with the correct geoJson for a Point`() = runTest { @@ -310,6 +313,7 @@ class GetLoiReportUseCaseTest { @Test fun `Should populate loiName, userName and dateMillis from the inputs`() = runTest { userRepository.currentUser = FakeDataGenerator.newUser(displayName = "John Doe") + submissionRepository.submissions = listOf(FakeDataGenerator.newSubmission()) loiRepository.offlineLoi = loiRepository.offlineLoi.copy( lastModified = AuditInfo(user = userRepository.currentUser, clientTimestamp = 987654321L) @@ -327,6 +331,7 @@ class GetLoiReportUseCaseTest { fun `Should populate surveyName from the offline survey`() = runTest { surveyRepository.offlineSurveys = listOf(FakeDataGenerator.newSurvey(id = "surveyId", title = "Restoration areas")) + submissionRepository.submissions = listOf(FakeDataGenerator.newSubmission()) val loiReport = getLoiReportUseCase.invoke(loiName = "loiName", loiId = "loiId", surveyId = "surveyId")!! @@ -334,6 +339,44 @@ class GetLoiReportUseCaseTest { assertEquals("Restoration areas", loiReport.submissionDetails!!.surveyName) } + @Test + fun `Should return submissions ordered by lastModified clientTimestamp`() = runTest { + val older = + FakeDataGenerator.newSubmission( + id = "older", + lastModified = AuditInfo(FakeDataGenerator.newUser(), clientTimestamp = 100L), + ) + val middle = + FakeDataGenerator.newSubmission( + id = "middle", + lastModified = AuditInfo(FakeDataGenerator.newUser(), clientTimestamp = 200L), + ) + val newer = + FakeDataGenerator.newSubmission( + id = "newer", + lastModified = AuditInfo(FakeDataGenerator.newUser(), clientTimestamp = 300L), + ) + submissionRepository.submissions = listOf(newer, older, middle) + + val loiReport = + getLoiReportUseCase.invoke(loiName = "loiName", loiId = "loiId", surveyId = "surveyId")!! + + assertEquals( + listOf("older", "middle", "newer"), + loiReport.submissionDetails!!.submissions.map { it.id }, + ) + } + + @Test + fun `Should return null submission details when no submissions exist`() = runTest { + submissionRepository.submissions = emptyList() + + val loiReport = + getLoiReportUseCase.invoke(loiName = "loiName", loiId = "loiId", surveyId = "surveyId")!! + + assertNull(loiReport.submissionDetails) + } + private suspend fun invokeUseCase(geometry: Geometry, properties: LoiProperties): LoiReport { loiRepository.offlineLoi = loiRepository.offlineLoi.copy(geometry = geometry, properties = properties) diff --git a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeSubmissionRepository.kt b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeSubmissionRepository.kt index 8cfdcc4cac..399ae61171 100644 --- a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeSubmissionRepository.kt +++ b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeSubmissionRepository.kt @@ -18,6 +18,7 @@ package org.groundplatform.testing import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.locationofinterest.LocationOfInterest import org.groundplatform.domain.model.submission.DraftSubmission +import org.groundplatform.domain.model.submission.Submission import org.groundplatform.domain.model.submission.ValueDelta import org.groundplatform.domain.repository.SubmissionRepositoryInterface @@ -26,6 +27,7 @@ class FakeSubmissionRepository : SubmissionRepositoryInterface { var latestDraftSubmissionId: String = "" var pendingCreateCount: Int = 0 var pendingDeleteCount: Int = 0 + var submissions: List = emptyList() var onSaveSubmissionCall = FakeCall {} override suspend fun saveSubmission( @@ -75,6 +77,8 @@ class FakeSubmissionRepository : SubmissionRepositoryInterface { override suspend fun getPendingCreateCount(loiId: String): Int = pendingCreateCount + override suspend fun getSubmissions(loi: LocationOfInterest): List = submissions + data class SaveSubmissionParams( val surveyId: String, val loiId: String, diff --git a/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/LoiReportAction.kt b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/LoiReportAction.kt new file mode 100644 index 0000000000..208ca81341 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/groundplatform/ui/components/loireport/LoiReportAction.kt @@ -0,0 +1,22 @@ +/* + * 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.components.loireport + +sealed interface LoiReportAction { + data object OnShareClicked : LoiReportAction + + data object OnPdfItemClicked : LoiReportAction +} diff --git a/feature/pdf/build.gradle.kts b/feature/pdf/build.gradle.kts index 24ac27ee4b..21681b7903 100644 --- a/feature/pdf/build.gradle.kts +++ b/feature/pdf/build.gradle.kts @@ -22,6 +22,7 @@ plugins { apply(from = "../../config/jacoco/jacoco.gradle") kotlin { + jvmToolchain(libs.versions.jvmToolchainVersion.get().toInt()) android { namespace = "org.groundplatform.feature.pdf" compileSdk { @@ -60,5 +61,21 @@ kotlin { implementation(libs.kotlinx.coroutines.test) } } + + androidMain { + dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.exifinterface) + implementation(libs.compose.ui) + implementation(libs.timber) + } + } + + val androidHostTest by getting { + dependencies { + implementation(libs.junit) + implementation(libs.robolectric) + } + } } } diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt new file mode 100644 index 0000000000..fb7189f119 --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProviderTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf + +import android.graphics.Bitmap +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import org.groundplatform.feature.pdf.render.image.PdfImageSet +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class AndroidPdfImageProviderTest { + + @Test + fun `calculateInSampleSize does not subsample when image already fits within 2x`() { + assertEquals(1, calculateInSampleSize(width = 100, height = 100, maxWidth = 60, maxHeight = 60)) + } + + @Test + fun `calculateInSampleSize halves a square image down towards the box`() { + assertEquals(2, calculateInSampleSize(width = 100, height = 100, maxWidth = 50, maxHeight = 50)) + } + + @Test + fun `calculateInSampleSize subsamples a typical landscape photo`() { + assertEquals( + 2, + calculateInSampleSize(width = 4000, height = 3000, maxWidth = 1346, maxHeight = 1108), + ) + } + + @Test + fun `calculateInSampleSize subsamples a tall image on its binding axis`() { + assertEquals( + 4, + calculateInSampleSize(width = 1000, height = 5000, maxWidth = 1346, maxHeight = 1108), + ) + } + + @Test + fun `calculateInSampleSize never upsamples a tiny image`() { + assertEquals(1, calculateInSampleSize(width = 10, height = 10, maxWidth = 50, maxHeight = 50)) + } + + @Test + fun `calculateInSampleSize leaves less than a 2x downscale for any input`() { + val maxWidth = 1346 + val maxHeight = 1108 + val dimensions = listOf(5000 to 1000, 1000 to 5000, 4000 to 3000, 3000 to 4000, 8000 to 8000) + for ((width, height) in dimensions) { + val sampleSize = calculateInSampleSize(width, height, maxWidth, maxHeight) + val decodedWidth = width / sampleSize + val decodedHeight = height / sampleSize + val fitScale = minOf(maxWidth.toFloat() / decodedWidth, maxHeight.toFloat() / decodedHeight) + assertTrue(fitScale > 0.5f) + } + } + + @Test + fun `scaledToFit returns the same bitmap when it already fits`() { + val bitmap = bitmap(10, 10) + assertSame(bitmap, bitmap.scaledToFit(maxWidth = 50, maxHeight = 50)) + } + + @Test + fun `scaledToFit returns the same bitmap when it exactly matches the box`() { + val bitmap = bitmap(50, 50) + assertSame(bitmap, bitmap.scaledToFit(maxWidth = 50, maxHeight = 50)) + } + + @Test + fun `scaledToFit downscales preserving aspect ratio`() { + val result = bitmap(100, 50).scaledToFit(maxWidth = 50, maxHeight = 50) + assertEquals(50, result.width) + assertEquals(25, result.height) + } + + @Test + fun `scaledToFit fits to the binding height when the box is wide`() { + val result = bitmap(50, 100).scaledToFit(maxWidth = 50, maxHeight = 50) + assertEquals(25, result.width) + assertEquals(50, result.height) + } + + @Test + fun `load generates a qr image when content is provided`() = runTest { + val images = newProvider().load(qrContent = "https://example.org", photoFilenames = emptySet()) + + assertNotNull(images[PdfImageSet.ImageRef.Qr]) + } + + @Test + fun `load returns no qr image when content is null`() = runTest { + val images = newProvider().load(qrContent = null, photoFilenames = emptySet()) + + assertNull(images[PdfImageSet.ImageRef.Qr]) + } + + @Test + fun `load skips empty photo filenames`() = runTest { + val images = newProvider().load(qrContent = null, photoFilenames = setOf("")) + + assertNull(images[PdfImageSet.ImageRef.Photo("")]) + } + + @Test + fun `load skips photos whose file does not exist`() = runTest { + val images = newProvider().load(qrContent = null, photoFilenames = setOf("missing.jpg")) + + assertNull(images[PdfImageSet.ImageRef.Photo("missing.jpg")]) + } + + @Test + fun `release recycles the bitmaps it loaded`() = runTest { + val images = newProvider().load(qrContent = "https://example.org", photoFilenames = emptySet()) + val qrBitmap = images[PdfImageSet.ImageRef.Qr]!!.bitmap + assertFalse(qrBitmap.isRecycled) + + images.release() + + assertTrue(qrBitmap.isRecycled) + } + + private fun newProvider(): AndroidPdfImageProvider = + AndroidPdfImageProvider(RuntimeEnvironment.getApplication(), logoDrawableRes = 0) + + private fun bitmap(width: Int, height: Int): Bitmap = + Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) +} diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt new file mode 100644 index 0000000000..7023daae7a --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProviderTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf + +import android.content.Context +import java.io.File +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class AndroidPdfOutputProviderTest { + + private lateinit var context: Context + private lateinit var reportsDir: File + private lateinit var provider: AndroidPdfOutputProvider + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + reportsDir = File(context.cacheDir, PDF_SUBDIR) + reportsDir.deleteRecursively() + provider = AndroidPdfOutputProvider(context) + } + + @Test + fun `newFilePath creates the reports directory and returns a pdf path`() { + val path = provider.newFilePath(PDF_FILE_NAME) + + assertTrue(reportsDir.isDirectory) + assertEquals(File(reportsDir, "report.pdf").absolutePath, path) + } + + @Test + fun `exists reflects whether the report file is present`() { + assertFalse(provider.exists(PDF_FILE_NAME)) + + File(provider.newFilePath(PDF_FILE_NAME)).writeText(PDF_TEXT) + + assertTrue(provider.exists(PDF_FILE_NAME)) + } + + @Test + fun `listFiles returns an empty list when there is no reports directory`() { + assertTrue(provider.listFiles().isEmpty()) + } + + @Test + fun `listFiles returns only pdf files`() { + File(provider.newFilePath("a")).writeText(PDF_TEXT) + File(provider.newFilePath("b")).writeText(PDF_TEXT) + File(reportsDir, "notes.txt").writeText("ignore me") + + val names = provider.listFiles().map { File(it.path).name }.sorted() + + assertContentEquals(listOf("a.pdf", "b.pdf"), names) + } + + @Test + fun `listFiles returns the cached pdf files with the correct lastModified value`() { + val file = File(provider.newFilePath(PDF_SUBDIR)).apply { writeText(PDF_TEXT) } + file.setLastModified(987654321L) + + val entry = provider.listFiles().single() + + assertEquals(file.absolutePath, entry.path) + assertEquals(987654321L, entry.lastModifiedMillis) + } + + @Test + fun `deleteReport removes the file at the given path`() { + val path = provider.newFilePath(PDF_SUBDIR) + File(path).writeText(PDF_TEXT) + + provider.deleteReport(path) + + assertFalse(File(path).exists()) + } + + @Test + fun `pruneOldFiles deletes only reports older than a week`() { + val now = System.currentTimeMillis() + val fresh = File(provider.newFilePath("fresh")).apply { writeText(PDF_TEXT) } + val stale = File(provider.newFilePath("stale")).apply { writeText(PDF_TEXT) } + stale.setLastModified(now - 8L * 24 * 60 * 60 * 1000) + + provider.pruneOldFiles() + + assertTrue(fresh.exists()) + assertFalse(stale.exists()) + } + + private companion object { + const val PDF_TEXT = "This is a test PDF." + const val PDF_SUBDIR = "reports" + const val PDF_FILE_NAME = "report" + } +} diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvasTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvasTest.kt new file mode 100644 index 0000000000..a25fc8d176 --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvasTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import android.graphics.Bitmap +import android.graphics.RectF +import android.graphics.pdf.PdfDocument +import android.text.StaticLayout +import android.text.TextPaint +import kotlin.test.assertFailsWith +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DocumentPdfCanvasTest { + + private val canvas = DocumentPdfCanvas(PdfDocument()) + + @Test + fun `drawLine before a page is started fails`() { + assertFailsWith { canvas.drawLine(0f, 0f, 10f, 10f) } + } + + @Test + fun `drawImage before a page is started fails`() { + val image = PdfImage(Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888)) + assertFailsWith { + canvas.drawImage(image, RectF(0f, 0f, 10f, 10f), smoothScaling = false) + } + } + + @Test + fun `drawStaticLayout before a page is started fails`() { + val layout = StaticLayout.Builder.obtain("body", 0, 4, TextPaint(), 100).build() + assertFailsWith { canvas.drawStaticLayout(layout, x = 0f, y = 0f) } + } + + @Test + fun `finishPage with no page open does nothing`() { + canvas.finishPage() + } +} diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt new file mode 100644 index 0000000000..34060fb339 --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/FakePdfCanvas.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import android.graphics.RectF +import android.text.StaticLayout +import org.groundplatform.feature.pdf.render.image.PdfImage + +internal class FakePdfCanvas : PdfCanvas { + val startedPages = mutableListOf() + var finishedPages = 0 + val drawnText = mutableListOf() + val drawnImages = mutableListOf() + val drawnLines = mutableListOf() + + override fun startPage(pageNumber: Int) { + startedPages += pageNumber + } + + override fun finishPage() { + finishedPages++ + } + + override fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) { + drawnText += layout.text.toString() + } + + override fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) { + drawnImages += image + } + + override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) { + drawnLines += PdfLine(x1, y1, x2, y2) + } +} diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfCanvasTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfCanvasTest.kt new file mode 100644 index 0000000000..2f0adcecc7 --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfCanvasTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import android.graphics.Bitmap +import android.graphics.RectF +import android.text.StaticLayout +import android.text.TextPaint +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PdfCanvasTest { + @Test + fun `MeasurementPdfCanvas ignores every call`() { + val layout = StaticLayout.Builder.obtain("body", 0, "body".length, TextPaint(), 100).build() + val image = PdfImage(Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888)) + with(MeasurementPdfCanvas) { + startPage(pageNumber = 1) + drawStaticLayout(layout, x = 0f, y = 0f) + drawImage(image, RectF(0f, 0f, 10f, 10f), smoothScaling = true) + drawLine(0f, 0f, 10f, 10f) + finishPage() + } + } +} diff --git a/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt new file mode 100644 index 0000000000..172fb6858d --- /dev/null +++ b/feature/pdf/src/androidHostTest/kotlin/org/groundplatform/feature/pdf/render/PdfWriterTest.kt @@ -0,0 +1,310 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import android.graphics.Bitmap +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.groundplatform.feature.pdf.render.image.PdfImageSet +import org.groundplatform.feature.pdf.render.image.PdfImageSet.ImageRef +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PdfWriterTest { + + @Test + fun `measurement and draw passes emit the same page count`() { + val measuredPages = renderPageCount(totalPages = null) + + val drawnPages = renderPageCount(totalPages = measuredPages) + + assertTrue(measuredPages > 1) + assertEquals(measuredPages, drawnPages) + } + + @Test + fun `does not open a page for a document with no qr and no rows`() { + val canvas = FakePdfCanvas() + + newPdfWriter(EMPTY_DOCUMENT, PdfImageSet(emptyMap()), canvas).drawDocument(EMPTY_DOCUMENT) + + assertEquals(0, canvas.startedPages.size) + assertEquals(0, canvas.finishedPages) + } + + @Test + fun `opens and closes exactly one page for a single-page document`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + assertEquals(listOf(1), canvas.startedPages) + assertEquals(1, canvas.finishedPages) + } + + @Test + fun `draws the header values on the page`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + assertTrue(canvas.drawnText.contains(HEADER.surveyName)) + assertTrue(canvas.drawnText.contains(HEADER.jobName)) + assertTrue(canvas.drawnText.contains(HEADER.timestamp)) + } + + @Test + fun `draws the footer text on the page`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + assertTrue( + canvas.drawnText.contains( + "${FOOTER.dataCollectorLabel}: ${FOOTER.dataCollectorName}, ${FOOTER.userEmail}" + ) + ) + } + + @Test + fun `draws the header and footer on every page`() { + val canvas = FakePdfCanvas() + val pdfWriter = + newPdfWriter(TEST_PDF_DOCUMENT, PdfImageSet(emptyMap()), canvas, totalPages = null) + + pdfWriter.drawDocument(TEST_PDF_DOCUMENT) + + assertTrue(pdfWriter.pageCount > 1) + assertEquals(pdfWriter.pageCount, canvas.drawnText.count { it == HEADER.surveyName }) + assertEquals( + pdfWriter.pageCount, + canvas.drawnText.count { + it == "${FOOTER.dataCollectorLabel}: ${FOOTER.dataCollectorName}, ${FOOTER.userEmail}" + }, + ) + } + + @Test + fun `draws the qr image and caption when a qr image is provided`() { + val qr = pdfImage() + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, pdfImageSet(qr = qr)) + + assertTrue(canvas.drawnImages.any { it.bitmap === qr.bitmap }) + assertTrue(canvas.drawnText.contains(QR_BLOCK.scanCaption)) + } + + @Test + fun `skips the qr block when no qr image is provided`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, pdfImageSet(qr = null)) + + assertFalse(canvas.drawnText.contains(QR_BLOCK.scanCaption)) + } + + @Test + fun `draws text answers as text layouts`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + assertTrue(canvas.drawnText.contains(SINGLE_PAGE_DOCUMENT.table.rows[0].question)) + assertTrue( + canvas.drawnText.contains( + (SINGLE_PAGE_DOCUMENT.table.rows[0].answer as SubmissionPdfDocument.Answer.Text) + .lines + .first() + ) + ) + assertTrue(canvas.drawnText.contains(SINGLE_PAGE_DOCUMENT.table.rows[1].question)) + } + + @Test + fun `draws photo answers as images`() { + val photo = pdfImage() + val canvas = + renderDocument(SINGLE_PAGE_DOCUMENT, pdfImageSet(photos = mapOf("photo.jpg" to photo))) + + assertTrue(canvas.drawnImages.any { it.bitmap === photo.bitmap }) + } + + @Test + fun `does not draw a photo answer when its image is missing`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, pdfImageSet(photos = emptyMap())) + + assertTrue(canvas.drawnImages.isEmpty()) + } + + @Test + fun `includes the page number in the footer when totalPages is set`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, totalPages = 1) + + assertTrue(canvas.drawnText.contains("1/1")) + } + + @Test + fun `omits the page number from the footer when totalPages is null`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT, totalPages = null) + + assertFalse(canvas.drawnText.any { it.contains("/") }) + } + + @Test + fun `draws a top border on only the first table row of a page`() { + val canvas = renderDocument(SINGLE_PAGE_DOCUMENT) + + // SINGLE_PAGE_DOCUMENT has 2 rows on one page: the first gets a top border, the second doesn't. + assertEquals(2, canvas.drawnLines.count { it.startX == it.endX }) + assertEquals(1, canvas.topBorderCount()) + } + + @Test + fun `draws a fresh top border on the first row of every page`() { + val canvas = FakePdfCanvas() + val pdfWriter = + newPdfWriter(TEST_PDF_DOCUMENT, PdfImageSet(emptyMap()), canvas, totalPages = null) + + pdfWriter.drawDocument(TEST_PDF_DOCUMENT) + + // Every page resets the flag, so each page's first row draws exactly 1 top border. + assertTrue(pdfWriter.pageCount > 1) + assertEquals(pdfWriter.pageCount, canvas.topBorderCount()) + } + + @Test + fun `skips the table when there are no rows`() { + val tableless = + SINGLE_PAGE_DOCUMENT.copy(table = SINGLE_PAGE_DOCUMENT.table.copy(rows = emptyList())) + + val canvas = renderDocument(tableless, pdfImageSet(qr = pdfImage())) + + assertEquals(listOf(1), canvas.startedPages) + assertFalse(canvas.drawnText.contains(TABLE.submissionLabel)) + } + + private fun renderDocument( + document: SubmissionPdfDocument, + images: PdfImageSet = pdfImageSet(qr = pdfImage()), + totalPages: Int? = 1, + ): FakePdfCanvas = + FakePdfCanvas().also { newPdfWriter(document, images, it, totalPages).drawDocument(document) } + + private fun renderPageCount(totalPages: Int?): Int = + newPdfWriter(TEST_PDF_DOCUMENT, PdfImageSet(emptyMap()), MeasurementPdfCanvas, totalPages) + .apply { drawDocument(TEST_PDF_DOCUMENT) } + .pageCount + + private fun newPdfWriter( + document: SubmissionPdfDocument, + images: PdfImageSet, + canvas: PdfCanvas, + totalPages: Int? = null, + ): PdfWriter = + PdfWriter( + pdfCanvas = canvas, + images = images, + totalPages = totalPages, + header = document.header, + footer = document.footer, + ) + + private fun FakePdfCanvas.topBorderCount(): Int { + // This counts the rows as each row draws exactly 1 vertical divider + val rowCount = drawnLines.count { it.startX == it.endX } + val horizontalLines = drawnLines.count { it.startY == it.endY } + return horizontalLines - rowCount + } + + private fun pdfImage(): PdfImage = PdfImage(Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888)) + + private fun pdfImageSet( + qr: PdfImage? = null, + photos: Map = emptyMap(), + ): PdfImageSet = + PdfImageSet( + buildMap { + qr?.let { put(ImageRef.Qr, it) } + photos.forEach { (name, image) -> put(ImageRef.Photo(name), image) } + } + ) + + private companion object { + val HEADER = + SubmissionPdfDocument.Header( + surveyLabel = "Survey", + surveyName = "Survey name", + jobLabel = "Job", + jobName = "Job name", + timestamp = "timestamp", + ) + val FOOTER = + SubmissionPdfDocument.Footer( + dataCollectorLabel = "Collector", + dataCollectorName = "John Doe", + userEmail = "user@gmail.com", + ) + + val QR_BLOCK = SubmissionPdfDocument.QrBlock(scanCaption = "Scan") + + val TABLE = + SubmissionPdfDocument.Table( + submissionLabel = "Submission", + loiName = "Plot 42", + rows = emptyList(), + ) + + val EMPTY_DOCUMENT = + SubmissionPdfDocument( + header = HEADER, + qrBlock = QR_BLOCK, + footer = FOOTER, + table = TABLE, + ) + + val SINGLE_PAGE_DOCUMENT = + SubmissionPdfDocument( + header = HEADER, + qrBlock = QR_BLOCK, + footer = FOOTER, + table = + TABLE.copy( + rows = + listOf( + SubmissionPdfDocument.Row( + question = "What is your name?", + answer = SubmissionPdfDocument.Answer.Text(listOf("John")), + ), + SubmissionPdfDocument.Row( + question = "Take a picture of a tree", + answer = SubmissionPdfDocument.Answer.Photo(remoteFilename = "photo.jpg"), + ), + ) + ), + ) + + val TEST_PDF_DOCUMENT = + SubmissionPdfDocument( + header = HEADER, + qrBlock = QR_BLOCK, + footer = FOOTER, + table = + TABLE.copy( + rows = + List(200) { index -> + SubmissionPdfDocument.Row( + question = "Question $index", + answer = SubmissionPdfDocument.Answer.Text(listOf("Answer $index")), + ) + } + ), + ) + } +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt new file mode 100644 index 0000000000..5e41d143f8 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfImageProvider.kt @@ -0,0 +1,188 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.os.Environment +import androidx.annotation.DrawableRes +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.core.graphics.scale +import androidx.exifinterface.media.ExifInterface +import java.io.File +import kotlin.math.roundToInt +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import org.groundplatform.feature.pdf.render.fitInside +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.groundplatform.feature.pdf.render.image.PdfImageSet +import org.groundplatform.feature.pdf.render.layout.QrBlockLayout +import org.groundplatform.feature.pdf.render.layout.TableLayout +import org.groundplatform.feature.pdf.render.pointsToRenderPixels +import org.groundplatform.ui.components.qrcode.PDF_LOGO_SIZE_FRACTION +import org.groundplatform.ui.components.qrcode.generateQrBitmap +import timber.log.Timber + +/** + * Android implementation of [PdfImageProvider]. + * + * Bitmaps are decoded and scaled to their final on-page pixel size here so the renderer can draw + * them as-is without any further bitmap work. + * + * @param context application context used for resource access and file lookups. + * @param logoDrawableRes resource id of the centre logo bitmap. Caller supplies the app's branding. + */ +// TODO: Add equivalent iOS implementations for PDF feature +// Issue URL: https://github.com/google/ground-android/issues/3775 +class AndroidPdfImageProvider( + private val context: Context, + @DrawableRes private val logoDrawableRes: Int, +) : PdfImageProvider { + + private val qrMaxPx = pointsToRenderPixels(QrBlockLayout.QR_SIZE) + private val photoMaxWidthPx = pointsToRenderPixels(TableLayout.ANSWER_TEXT_WIDTH.toFloat()) + private val photoMaxHeightPx = pointsToRenderPixels(TableLayout.PHOTO_MAX_HEIGHT.toFloat()) + + override suspend fun load(qrContent: String?, photoFilenames: Set): PdfImageSet = + coroutineScope { + val deferredQr = qrContent?.let { content -> + async { + generateQrCodeBitmap(content)?.let { bitmap -> + PdfImageSet.ImageRef.Qr to bitmap + } + } + } + + val deferredPhotos = + photoFilenames + .filter { it.isNotEmpty() } + .map { filename -> + async { + loadPhotoBitmap(filename)?.let { bitmap -> + PdfImageSet.ImageRef.Photo(filename) to bitmap + } + } + } + + val results = (listOfNotNull(deferredQr) + deferredPhotos).awaitAll().filterNotNull() + + val images = mutableMapOf() + val bitmapsToRelease = mutableListOf() + results.forEach { (ref, bitmap) -> + bitmapsToRelease += bitmap + images[ref] = PdfImage(bitmap) + } + + PdfImageSet(images = images, onRelease = { bitmapsToRelease.forEach(Bitmap::recycle) }) + } + + private fun generateQrCodeBitmap(content: String): Bitmap? = + runCatching { + generateQrBitmap( + content = content, + logo = + BitmapFactory.decodeResource(context.resources, logoDrawableRes)?.asImageBitmap(), + logoSizeFraction = PDF_LOGO_SIZE_FRACTION, + ) + .asAndroidBitmap() + .scaledToFit(qrMaxPx, qrMaxPx) + } + .onFailure { Timber.e(it, "Failed to generate QR code bitmap for PDF report") } + .getOrNull() + + private fun loadPhotoBitmap(remoteFilename: String): Bitmap? { + val rootDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: return null + val file = File(rootDir, remoteFilename.substringAfterLast('/')) + if (!file.exists()) return null + return runCatching { decodeScaledAndOriented(file) } + .onFailure { Timber.e(it, "Failed to decode photo for PDF report") } + .getOrNull() + } + + /** + * Decodes the photo subsampled to roughly the largest size it can occupy in the PDF, scales it to + * fit the photo box, then applies the EXIF orientation. + */ + private fun decodeScaledAndOriented(file: File): Bitmap? { + val path = file.absolutePath + val degrees = runCatching { ExifInterface(path).rotationDegrees }.getOrDefault(0) + // A 90°/270° EXIF rotation swaps width and height, so size the decode box accordingly. + val swapAxes = degrees == 90 || degrees == 270 + val boxWidth = if (swapAxes) photoMaxHeightPx else photoMaxWidthPx + val boxHeight = if (swapAxes) photoMaxWidthPx else photoMaxHeightPx + + val decoded = decodeSubsampled(path, boxWidth, boxHeight) ?: return null + return decoded.scaledToFit(boxWidth, boxHeight).rotated(degrees) + } + + /** Decodes the photo subsampled to roughly the [maxWidth] × [maxHeight] box it will occupy. */ + private fun decodeSubsampled(path: String, maxWidth: Int, maxHeight: Int): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, bounds) + val options = + BitmapFactory.Options().apply { + inSampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight, maxWidth, maxHeight) + } + return BitmapFactory.decodeFile(path, options) + } +} + +/** + * Largest power-of-two sample size that keeps the decoded bitmap at or above the [maxWidth] × + * [maxHeight]. + */ +internal fun calculateInSampleSize(width: Int, height: Int, maxWidth: Int, maxHeight: Int): Int { + // True if at least one dimension is still larger than the target when downsampled. + fun canDownsample(sampleSize: Int): Boolean { + val meetsTargetWidth = width / sampleSize >= maxWidth + val meetsTargetHeight = height / sampleSize >= maxHeight + return meetsTargetWidth || meetsTargetHeight + } + + var sampleSize = 1 + while (canDownsample(sampleSize * 2)) { + sampleSize *= 2 + } + return sampleSize +} + +/** + * Returns the receiver scaled down to fit [maxWidth] × [maxHeight], preserving aspect ratio and + * never upscaling. + */ +internal fun Bitmap.scaledToFit(maxWidth: Int, maxHeight: Int): Bitmap { + val fitted = fitInside(width, height, maxWidth, maxHeight) + val targetWidth = fitted.width.roundToInt().coerceAtLeast(1) + val targetHeight = fitted.height.roundToInt().coerceAtLeast(1) + if (targetWidth == width && targetHeight == height) return this + return scale(targetWidth, targetHeight).also { if (it !== this) recycle() } +} + +/** Returns the receiver rotated [degrees] clockwise, or unchanged when no rotation is needed. */ +private fun Bitmap.rotated(degrees: Int): Bitmap { + if (degrees == 0) return this + val matrix = Matrix().apply { postRotate(degrees.toFloat()) } + return runCatching { Bitmap.createBitmap(this, 0, 0, width, height, matrix, true) } + .getOrElse { + Timber.w(it, "Failed to rotate photo by $degrees°, returning unrotated bitmap") + null + } + ?.also { if (it !== this) recycle() } ?: this +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt new file mode 100644 index 0000000000..0076867b7d --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfOutputProvider.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf + +import android.content.Context +import java.io.File + +private const val REPORTS_SUBDIR = "reports" + +// TODO: Add equivalent iOS implementations for PDF feature +// Issue URL: https://github.com/google/ground-android/issues/3775 +class AndroidPdfOutputProvider(private val context: Context) : PdfOutputProvider { + + private val reportsDir + get() = File(context.cacheDir, REPORTS_SUBDIR) + + override fun newFilePath(name: String): String { + val outputDir = reportsDir.apply { mkdirs() } + return File(outputDir, "$name.pdf").absolutePath + } + + override fun exists(name: String): Boolean = File(reportsDir, "$name.pdf").exists() + + override fun listFiles(): List = + reportsDir + .listFiles { f -> f.isFile && f.extension == "pdf" } + ?.map { PdfOutputProvider.CachedPdf(it.absolutePath, it.lastModified()) } ?: emptyList() + + override fun deleteReport(path: String) { + File(path).delete() + } +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt new file mode 100644 index 0000000000..19d0949e86 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfRenderer.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf + +import android.graphics.pdf.PdfDocument +import java.io.File +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.render.DocumentPdfCanvas +import org.groundplatform.feature.pdf.render.MeasurementPdfCanvas +import org.groundplatform.feature.pdf.render.PdfCanvas +import org.groundplatform.feature.pdf.render.PdfWriter +import org.groundplatform.feature.pdf.render.image.PdfImageSet + +/** + * Android [PdfRenderer] for a [SubmissionPdfDocument]. The drawing of each section lives in + * [PdfWriter]; the [PdfCanvas] decides whether each pass writes to a real [PdfDocument] or just + * counts pages. + */ +// TODO: Add equivalent iOS implementations for PDF feature +// Issue URL: https://github.com/google/ground-android/issues/3775 +class AndroidPdfRenderer(private val ioDispatcher: CoroutineDispatcher) : PdfRenderer { + + override suspend fun render( + document: SubmissionPdfDocument, + images: PdfImageSet, + outputPath: String, + ) { + // Measurement first so the footer can show "page/total" + val totalPages = measurePageCount(document, images) + val pdf = PdfDocument() + try { + writer(document, images, DocumentPdfCanvas(pdf), totalPages = totalPages) + .drawDocument(document) + withContext(ioDispatcher) { File(outputPath).outputStream().use { pdf.writeTo(it) } } + } finally { + pdf.close() + } + } + + private fun measurePageCount(document: SubmissionPdfDocument, images: PdfImageSet): Int = + writer(document, images, MeasurementPdfCanvas, totalPages = null) + .apply { drawDocument(document) } + .pageCount + + private fun writer( + document: SubmissionPdfDocument, + images: PdfImageSet, + pdfCanvas: PdfCanvas, + totalPages: Int?, + ): PdfWriter = + PdfWriter( + pdfCanvas = pdfCanvas, + images = images, + header = document.header, + footer = document.footer, + totalPages = totalPages, + ) +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt new file mode 100644 index 0000000000..d03dfba043 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/AndroidPdfReportLauncher.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf + +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import java.io.File + +/** Launches the system share sheet or external viewer for a report file via [FileProvider]. */ +// TODO: Add equivalent iOS implementations for PDF feature +// Issue URL: https://github.com/google/ground-android/issues/3775 +class AndroidPdfReportLauncher( + private val context: Context, + private val fileProviderAuthority: String, +) : PdfReportLauncher { + + override fun share(path: String) { + val uri = FileProvider.getUriForFile(context, fileProviderAuthority, File(path)) + val sendIntent = + Intent(Intent.ACTION_SEND).apply { + type = PDF_MIME_TYPE + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + launchChooser(sendIntent) + } + + override fun open(path: String) { + val uri = FileProvider.getUriForFile(context, fileProviderAuthority, File(path)) + val viewIntent = + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, PDF_MIME_TYPE) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + launchChooser(viewIntent) + } + + private fun launchChooser(target: Intent) { + val chooser = + Intent.createChooser(target, null).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + context.startActivity(chooser) + } + + companion object { + private const val PDF_MIME_TYPE = "application/pdf" + } +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt new file mode 100644 index 0000000000..35681b16a3 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/DocumentPdfCanvas.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.pdf.PdfDocument +import android.text.StaticLayout +import androidx.core.graphics.withTranslation +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.groundplatform.feature.pdf.render.layout.TableLayout + +/** + * [PdfCanvas] that draws onto a real [PdfDocument], one page at a time. Image bitmaps are expected + * to arrive at their on-page pixel size; the canvas does no further scaling. + */ +internal class DocumentPdfCanvas(private val pdf: PdfDocument) : PdfCanvas { + private var currentPage: PdfDocument.Page? = null + + private val strokePaint = + Paint().apply { + style = Paint.Style.STROKE + strokeWidth = TableLayout.BORDER_WIDTH + isAntiAlias = true + } + + private val smoothImagePaint = + Paint().apply { + isFilterBitmap = true + isAntiAlias = true + isDither = true + } + + override fun startPage(pageNumber: Int) { + val info = + PdfDocument.PageInfo.Builder(PdfConfig.PAGE_WIDTH, PdfConfig.PAGE_HEIGHT, pageNumber).create() + currentPage = pdf.startPage(info) + } + + override fun finishPage() { + currentPage?.also { pdf.finishPage(it) } + currentPage = null + } + + override fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) { + canvas().withTranslation(x, y) { layout.draw(this) } + } + + override fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) { + canvas().drawBitmap(image.bitmap, null, frame, if (smoothScaling) smoothImagePaint else null) + } + + override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) { + canvas().drawLine(x1, y1, x2, y2, strokePaint) + } + + private fun canvas(): Canvas = currentPage?.canvas ?: error("draw called with no page open") +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt new file mode 100644 index 0000000000..8f532c7a94 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfCanvas.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import android.graphics.RectF +import android.text.StaticLayout +import org.groundplatform.feature.pdf.render.image.PdfImage + +/** Abstraction for drawing onto a PDF page. */ +internal interface PdfCanvas { + fun startPage(pageNumber: Int) + + fun finishPage() + + fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) + + fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) + + fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) +} + +/** Used during the page-counting phase. Drops every drawing call. */ +internal object MeasurementPdfCanvas : PdfCanvas { + override fun startPage(pageNumber: Int) = Unit + + override fun finishPage() = Unit + + override fun drawStaticLayout(layout: StaticLayout, x: Float, y: Float) = Unit + + override fun drawImage(image: PdfImage, frame: RectF, smoothScaling: Boolean) = Unit + + override fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float) = Unit +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt new file mode 100644 index 0000000000..25b8c3913e --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfTextPaints.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import android.graphics.Color +import android.graphics.Typeface +import android.text.TextPaint +import org.groundplatform.feature.pdf.render.PdfConfig.BODY_SIZE +import org.groundplatform.feature.pdf.render.PdfConfig.CAPTION_SIZE +import org.groundplatform.feature.pdf.render.PdfConfig.TITLE_SIZE + +internal class PdfTextPaints { + val title: TextPaint = textPaint(TITLE_SIZE, bold = false) + val body: TextPaint = textPaint(BODY_SIZE, bold = false) + val metaLabel: TextPaint = textPaint(CAPTION_SIZE, bold = true, textColor = Color.GRAY) + val meta: TextPaint = textPaint(CAPTION_SIZE, bold = false, textColor = Color.GRAY) + val caption: TextPaint = textPaint(CAPTION_SIZE, bold = false) + + private fun textPaint(size: Float, bold: Boolean, textColor: Int = Color.BLACK): TextPaint = + TextPaint().apply { + textSize = size + color = textColor + isAntiAlias = true + if (bold) typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + } +} diff --git a/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt new file mode 100644 index 0000000000..d23bfe5a12 --- /dev/null +++ b/feature/pdf/src/androidMain/kotlin/org/groundplatform/feature/pdf/render/PdfWriter.kt @@ -0,0 +1,276 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import android.graphics.RectF +import android.graphics.Typeface +import android.graphics.pdf.PdfDocument +import android.text.Layout +import android.text.SpannableString +import android.text.Spanned +import android.text.StaticLayout +import android.text.TextPaint +import android.text.TextUtils +import android.text.style.StyleSpan +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Answer +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Footer +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.Header +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument.QrBlock +import org.groundplatform.feature.pdf.render.PdfConfig.LINE_SPACING +import org.groundplatform.feature.pdf.render.PdfConfig.USABLE_WIDTH +import org.groundplatform.feature.pdf.render.image.PdfImage +import org.groundplatform.feature.pdf.render.image.PdfImageSet +import org.groundplatform.feature.pdf.render.layout.PageFooterLayout +import org.groundplatform.feature.pdf.render.layout.PageHeaderLayout +import org.groundplatform.feature.pdf.render.layout.QrBlockLayout +import org.groundplatform.feature.pdf.render.layout.TableLayout + +/** + * Draws a [SubmissionPdfDocument] onto a [PdfDocument], one section at a time, paginating top-down. + * Holds the mutable drawing state (current page, [PdfCursor], shared paints) shared by all + * sections. + */ +internal class PdfWriter( + private val pdfCanvas: PdfCanvas, + private val images: PdfImageSet, + private val totalPages: Int? = null, + private val header: Header, + footer: Footer, +) : PdfPageController.PageLifecycle { + private val paints = PdfTextPaints() + + private val footerLayout: StaticLayout = buildFooterLayout(footer) + private val cursor = + PdfCursor(footerReserve = PageFooterLayout.reserve(footerLayout.height.toFloat())) + private val pageController = PdfPageController(cursor, this) + + val pageCount: Int + get() = pageController.pageCount + + override fun onPageStarted(pageNumber: Int) { + pdfCanvas.startPage(pageNumber) + drawPageHeader() + } + + override fun onPageEnding(pageNumber: Int) { + drawPageFooter() + pdfCanvas.finishPage() + } + + fun drawDocument(document: SubmissionPdfDocument) { + drawQrBlock(document.qrBlock) + drawTable(document.table) + finalizePage() + } + + private fun drawQrBlock(block: QrBlock) { + val qr = images[PdfImageSet.ImageRef.Qr] ?: return + pageController.ensurePage() + val captionLayout = + staticLayout( + block.scanCaption, + paints.caption, + QrBlockLayout.QR_SIZE.toInt(), + Layout.Alignment.ALIGN_CENTER, + ) + val layout = + QrBlockLayout.compute(top = cursor.y, captionHeight = captionLayout.height.toFloat()) + drawImage(qr, layout.qrFrame, smoothScaling = false) + drawStaticLayoutAt(captionLayout, layout.captionOffset) + cursor.moveTo(layout.nextCursorY) + } + + private fun drawTable(table: SubmissionPdfDocument.Table) { + val rows = table.rows.takeIf { it.isNotEmpty() } ?: return + pageController.ensurePage() + val label = + SpannableString("${table.submissionLabel}: ${table.loiName}").apply { + setSpan( + StyleSpan(Typeface.BOLD), + 0, + table.submissionLabel.length, + Spanned.SPAN_INCLUSIVE_EXCLUSIVE, + ) + } + val labelLayout = staticLayout(label, paints.title, USABLE_WIDTH) + val tableLabel = + TableLayout.getLabel(top = cursor.y, labelHeight = labelLayout.height.toFloat()) + drawStaticLayoutAt(labelLayout, tableLabel.labelOffset) + cursor.moveTo(tableLabel.nextCursorY) + rows.forEach { row -> + when (val answer = row.answer) { + is Answer.Text -> + drawTableRow( + questionText = row.question, + answerText = answer.lines.joinToString("\n"), + photo = null, + ) + is Answer.Photo -> + drawTableRow( + questionText = row.question, + answerText = "", + photo = images[PdfImageSet.ImageRef.Photo(answer.remoteFilename)], + ) + } + } + } + + private fun finalizePage() { + pageController.finalizePage() + } + + private fun drawPageHeader() { + val columnWidth = PageHeaderLayout.COLUMN_WIDTH + val surveyLabel = staticLayout(header.surveyLabel, paints.metaLabel, columnWidth) + val surveyValue = + staticLayout( + text = header.surveyName, + paint = paints.meta, + maxWidth = columnWidth, + maxLines = PageHeaderLayout.MAX_LINES, + ) + val jobLabel = + staticLayout(header.jobLabel, paints.metaLabel, columnWidth, Layout.Alignment.ALIGN_CENTER) + val jobValue = + staticLayout( + text = header.jobName, + paint = paints.meta, + maxWidth = columnWidth, + alignment = Layout.Alignment.ALIGN_CENTER, + maxLines = PageHeaderLayout.MAX_LINES, + ) + val timestamp = + staticLayout( + text = header.timestamp, + paint = paints.meta, + maxWidth = columnWidth, + alignment = Layout.Alignment.ALIGN_OPPOSITE, + maxLines = PageHeaderLayout.MAX_LINES, + ) + + val layout = + PageHeaderLayout.compute( + top = cursor.y, + labelHeight = surveyLabel.height.toFloat(), + valueHeight = surveyValue.height.toFloat(), + ) + + drawStaticLayoutAt(surveyLabel, layout.leftColumn.labelOffset) + drawStaticLayoutAt(surveyValue, layout.leftColumn.valueOffset) + drawStaticLayoutAt(jobLabel, layout.centerColumn.labelOffset) + drawStaticLayoutAt(jobValue, layout.centerColumn.valueOffset) + drawStaticLayoutAt(timestamp, layout.rightTextOffset) + cursor.moveTo(layout.nextCursorY) + } + + private fun drawPageFooter() { + val layout = PageFooterLayout.compute(footerHeight = footerLayout.height.toFloat()) + drawStaticLayoutAt(footerLayout, layout.footerTextOffset) + totalPages?.let { total -> + val pageNumber = + staticLayout( + "${pageController.pageCount}/$total", + paints.meta, + layout.pageNumberMaxWidth, + alignment = Layout.Alignment.ALIGN_OPPOSITE, + maxLines = 1, + ) + drawStaticLayoutAt(pageNumber, layout.pageNumberOffset) + } + } + + private fun drawTableRow(questionText: String, answerText: String, photo: PdfImage?) { + val questionLayout = staticLayout(questionText, paints.body, TableLayout.TASK_TEXT_WIDTH) + val answerLayout = + answerText + .takeIf { it.isNotEmpty() } + ?.let { staticLayout(it, paints.body, TableLayout.ANSWER_TEXT_WIDTH) } + val photoSize = photo?.let { + fitInside(it.width, it.height, TableLayout.ANSWER_TEXT_WIDTH, TableLayout.PHOTO_MAX_HEIGHT) + } + + val questionHeight = questionLayout.height.toFloat() + val answerHeight = answerLayout?.height?.toFloat() ?: 0f + pageController.newPageIfShort(TableLayout.getRowHeight(questionHeight, answerHeight, photoSize)) + val rowLayout = + TableLayout.getRow( + rowTop = cursor.y, + leftTextHeight = questionHeight, + rightTextHeight = answerHeight, + rightImageSize = photoSize, + includeTopBorder = pageController.isFirstTableRowOnPage, + ) + if (pageController.isFirstTableRowOnPage) { + pageController.consumeFirstTableRowOnPage() + } + + rowLayout.borders.drawableLines.forEach { drawLine(it) } + drawStaticLayoutAt(questionLayout, rowLayout.content.leftTextOffset) + if (answerLayout != null && rowLayout.content.rightTextOffset != null) { + drawStaticLayoutAt(answerLayout, rowLayout.content.rightTextOffset) + } + if (photo != null && rowLayout.content.rightImageFrame != null) { + drawImage(photo, rowLayout.content.rightImageFrame, smoothScaling = true) + } + cursor.advance(rowLayout.totalHeight) + } + + private fun drawStaticLayoutAt(layout: StaticLayout, offset: PdfOffset) = + pdfCanvas.drawStaticLayout(layout, offset.x, offset.y) + + private fun drawImage(image: PdfImage, frame: PdfRect, smoothScaling: Boolean) = + pdfCanvas.drawImage(image, RectF(frame.x, frame.y, frame.right, frame.bottom), smoothScaling) + + private fun drawLine(line: PdfLine) = + pdfCanvas.drawLine(line.startX, line.startY, line.endX, line.endY) + + private fun buildFooterLayout(footer: Footer): StaticLayout { + val footerLabel = footer.dataCollectorLabel + val footerText = + SpannableString("$footerLabel: ${footer.dataCollectorName}, ${footer.userEmail}").apply { + setSpan(StyleSpan(Typeface.BOLD), 0, footerLabel.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + } + return staticLayout( + footerText, + paints.meta, + PageFooterLayout.TEXT_MAX_WIDTH, + maxLines = PageFooterLayout.MAX_LINES, + ) + } + + /** + * Lays out [text] wrapped to [maxWidth]. When [maxLines] is set, overflow is ellipsized so a + * single long value can't grow the layout unboundedly. + */ + private fun staticLayout( + text: CharSequence, + paint: TextPaint, + maxWidth: Int, + alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL, + maxLines: Int = Int.MAX_VALUE, + ): StaticLayout = + StaticLayout.Builder.obtain(text, 0, text.length, paint, maxWidth) + .setAlignment(alignment) + .setLineSpacing(LINE_SPACING, 1f) + .apply { + if (maxLines != Int.MAX_VALUE) { + setMaxLines(maxLines) + setEllipsize(TextUtils.TruncateAt.END) + } + } + .build() +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/LoiReportExporter.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/LoiReportExporter.kt new file mode 100644 index 0000000000..005ed0327c --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/LoiReportExporter.kt @@ -0,0 +1,38 @@ +/* + * 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.domain.model.locationofinterest.LoiReport +import org.groundplatform.feature.pdf.mapper.LoiReportMapper +import org.groundplatform.ui.components.loireport.LoiReportAction + +class LoiReportExporter( + private val mapper: LoiReportMapper, + private val exportService: PdfExportService, +) { + suspend fun export(loiReport: LoiReport, action: LoiReportAction): Result = runCatching { + val pdfAction = + when (action) { + LoiReportAction.OnShareClicked -> PdfExportService.Action.Share + LoiReportAction.OnPdfItemClicked -> PdfExportService.Action.Open + } + + val submission = + loiReport.submissionDetails?.submissions?.firstOrNull() ?: error("No submission to export") + val request = mapper.map(loiReport, submission) ?: error("Failed to map LoiReport") + exportService.export(request, pdfAction) + } +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt new file mode 100644 index 0000000000..4559f1e115 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfCursor.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +/** Tracks the current vertical draw position on a page and the space reserved for the footer. */ +internal class PdfCursor( + /** Space kept clear above the bottom margin for the footer. */ + private val footerReserve: Float, + private val pageHeight: Int = PdfConfig.PAGE_HEIGHT, + private val margin: Int = PdfConfig.MARGIN, +) { + var y: Float = margin.toFloat() + private set + + val isAtPageTop: Boolean + get() = y == margin.toFloat() + + fun reset() { + y = margin.toFloat() + } + + fun moveTo(absoluteY: Float) { + y = absoluteY + } + + fun advance(delta: Float) { + y += delta + } + + /** Whether a block of the given [height] still fits above the footer reserve on this page. */ + fun fits(height: Float): Boolean = y + height <= pageHeight - margin - footerReserve +} diff --git a/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt new file mode 100644 index 0000000000..3224310098 --- /dev/null +++ b/feature/pdf/src/commonMain/kotlin/org/groundplatform/feature/pdf/render/PdfPageController.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +/** + * Platform-agnostic page state machine for PDF rendering. Delegates the actual page allocation and + * drawing to a platform-specific [PageLifecycle] implementation. + */ +internal class PdfPageController( + private val cursor: PdfCursor, + private val lifecycle: PageLifecycle, +) { + interface PageLifecycle { + /** Called after a new page has been allocated. The header should be drawn here. */ + fun onPageStarted(pageNumber: Int) + + /** Called before the page is closed. The footer and per-page flush should happen here. */ + fun onPageEnding(pageNumber: Int) + } + + private var pageIndex = 0 + private var pageOpen = false + + var isFirstTableRowOnPage = true + private set + + /** Number of pages emitted so far. Equals the current page number while a page is open. */ + val pageCount: Int + get() = pageIndex + + fun ensurePage() { + if (!pageOpen) beginPage() + } + + /** Records that the first table row on the current page has been drawn. */ + fun consumeFirstTableRowOnPage() { + isFirstTableRowOnPage = false + } + + fun newPageIfShort(spaceNeeded: Float) { + ensurePage() + if (cursor.fits(spaceNeeded) || cursor.isAtPageTop) return + finalizePage() + beginPage() + } + + fun finalizePage() { + if (!pageOpen) return + lifecycle.onPageEnding(pageIndex) + pageOpen = false + } + + private fun beginPage() { + pageIndex++ + pageOpen = true + isFirstTableRowOnPage = true + cursor.reset() + lifecycle.onPageStarted(pageIndex) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/LoiReportExporterTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/LoiReportExporterTest.kt new file mode 100644 index 0000000000..cce188c3c0 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/LoiReportExporterTest.kt @@ -0,0 +1,89 @@ +/* + * 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.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import org.groundplatform.feature.pdf.helpers.FakeDateFormatter +import org.groundplatform.feature.pdf.helpers.FakePdfExportService +import org.groundplatform.feature.pdf.helpers.FakeStringResolver +import org.groundplatform.feature.pdf.mapper.LoiReportMapper +import org.groundplatform.feature.pdf.mapper.TaskValueMapper +import org.groundplatform.testing.FakeDataGenerator +import org.groundplatform.ui.components.loireport.LoiReportAction + +class LoiReportExporterTest { + + private val mapper = + LoiReportMapper( + taskValueMapper = + TaskValueMapper(strings = FakeStringResolver, dateFormatter = FakeDateFormatter), + strings = FakeStringResolver, + dateFormatter = FakeDateFormatter, + ) + + @Test + fun `opens the report when the action is OnPdfItemClicked`() = runTest { + val service = FakePdfExportService() + val exporter = LoiReportExporter(mapper, service.service) + + val result = exporter.export(FakeDataGenerator.newLoiReport(), LoiReportAction.OnPdfItemClicked) + + assertTrue(result.isSuccess) + assertEquals(service.outputPath, service.openedPath) + assertNull(service.sharedPath) + } + + @Test + fun `shares the report when the action is OnShareClicked`() = runTest { + val service = FakePdfExportService() + val exporter = LoiReportExporter(mapper, service.service) + + val result = exporter.export(FakeDataGenerator.newLoiReport(), LoiReportAction.OnShareClicked) + + assertTrue(result.isSuccess) + assertEquals(service.outputPath, service.sharedPath) + assertNull(service.openedPath) + } + + @Test + fun `returns failure without exporting when report has no submission`() = runTest { + val service = FakePdfExportService() + val exporter = LoiReportExporter(mapper, service.service) + val loiReport = + FakeDataGenerator.newLoiReport( + submissionDetails = FakeDataGenerator.newSubmissionDetails(submissions = emptyList()) + ) + + val result = exporter.export(loiReport, LoiReportAction.OnPdfItemClicked) + + assertTrue(result.isFailure) + assertNull(service.openedPath) + } + + @Test + fun `returns failure when export throws`() = runTest { + val service = FakePdfExportService().apply { renderError = RuntimeException("boom") } + val exporter = LoiReportExporter(mapper, service.service) + + val result = exporter.export(FakeDataGenerator.newLoiReport(), LoiReportAction.OnPdfItemClicked) + + assertTrue(result.isFailure) + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakePdfExportService.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakePdfExportService.kt new file mode 100644 index 0000000000..e8e7614116 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/helpers/FakePdfExportService.kt @@ -0,0 +1,83 @@ +/* + * 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 kotlinx.coroutines.Dispatchers +import org.groundplatform.feature.pdf.PdfExportService +import org.groundplatform.feature.pdf.PdfImageProvider +import org.groundplatform.feature.pdf.PdfOutputProvider +import org.groundplatform.feature.pdf.PdfRenderer +import org.groundplatform.feature.pdf.PdfReportLauncher +import org.groundplatform.feature.pdf.model.SubmissionPdfDocument +import org.groundplatform.feature.pdf.render.image.PdfImageSet + +@Suppress("UseDataClass") +class FakePdfExportService(val outputPath: String = "/tmp/report.pdf") { + var renderError: Throwable? = null + + var openedPath: String? = null + private set + + var sharedPath: String? = null + private set + + var imagesReleased: Boolean = false + private set + + val deletedPaths: MutableList = mutableListOf() + + val service: PdfExportService = + 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) = outputPath + + 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, + ) +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt new file mode 100644 index 0000000000..263343b1bd --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfCursorTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PdfCursorTest { + + @Test + fun `fresh cursor starts at the top margin`() { + val cursor = newCursor() + + assertEquals(PdfConfig.MARGIN.toFloat(), cursor.y) + assertTrue(cursor.isAtPageTop) + } + + @Test + fun `advance moves the cursor down by the given delta`() { + val cursor = newCursor() + val start = cursor.y + + cursor.advance(75f) + + assertEquals(start + 75f, cursor.y) + assertFalse(cursor.isAtPageTop) + } + + @Test + fun `moveTo sets the cursor to an absolute Y`() { + val cursor = newCursor() + + cursor.moveTo(400f) + + assertEquals(400f, cursor.y) + } + + @Test + fun `reset returns the cursor to the top margin`() { + val cursor = newCursor() + cursor.advance(300f) + + cursor.reset() + + assertEquals(PdfConfig.MARGIN.toFloat(), cursor.y) + assertTrue(cursor.isAtPageTop) + } + + @Test + fun `isAtPageTop reflects whether Y matches the top margin`() { + val cursor = newCursor() + assertTrue(cursor.isAtPageTop) + + cursor.advance(1f) + assertFalse(cursor.isAtPageTop) + + cursor.moveTo(PdfConfig.MARGIN.toFloat()) + assertTrue(cursor.isAtPageTop) + } + + @Test + fun `fits returns true when there is room above the bottom margin`() { + val cursor = newCursor() + + val available = PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN + assertTrue(cursor.fits(available.toFloat())) + assertTrue(cursor.fits(10f)) + } + + @Test + fun `fits returns false when the requested height overflows the bottom margin`() { + val cursor = newCursor() + + val available = PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN + assertFalse(cursor.fits(available + 1f)) + } + + @Test + fun `fits subtracts the footer reserve from the available space`() { + val cursor = newCursor(footerReserve = 50f) + val available = (PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN).toFloat() + + assertTrue(cursor.fits(available - 50f)) + assertFalse(cursor.fits(available - 49f)) + } + + @Test + fun `fits depends on the current Y position`() { + val cursor = newCursor() + val available = (PdfConfig.PAGE_HEIGHT - 2 * PdfConfig.MARGIN).toFloat() + + cursor.advance(100f) + + assertTrue(cursor.fits(available - 100f)) + assertFalse(cursor.fits(available - 99f)) + } + + @Test + fun `custom page height and margin are respected`() { + val cursor = newCursor(pageHeight = 200, margin = 10) + + assertEquals(10f, cursor.y) + // Usable height = 200 - 2*10 = 180. + assertTrue(cursor.fits(180f)) + assertFalse(cursor.fits(181f)) + } + + private fun newCursor( + footerReserve: Float = 0f, + pageHeight: Int = PdfConfig.PAGE_HEIGHT, + margin: Int = PdfConfig.MARGIN, + ) = PdfCursor(footerReserve = footerReserve, pageHeight = pageHeight, margin = margin) +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt new file mode 100644 index 0000000000..bc2ebdc2a9 --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/PdfPageControllerTest.kt @@ -0,0 +1,198 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PdfPageControllerTest { + + private val lifecycle = + object : PdfPageController.PageLifecycle { + val events: MutableList = mutableListOf() + + override fun onPageStarted(pageNumber: Int) { + events += PageEvent.Started(pageNumber) + } + + override fun onPageEnding(pageNumber: Int) { + events += PageEvent.Ending(pageNumber) + } + } + private val cursor = PdfCursor(footerReserve = 0f) + private val controller = PdfPageController(cursor, lifecycle) + + @Test + fun `Should have zero pages at the start`() { + assertEquals(0, controller.pageCount) + assertTrue(lifecycle.events.isEmpty()) + } + + @Test + fun `ensurePage starts the a page`() { + controller.ensurePage() + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1)), lifecycle.events) + } + + @Test + fun `ensurePage is idempotent while the page is open`() { + controller.ensurePage() + controller.ensurePage() + controller.ensurePage() + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1)), lifecycle.events) + } + + @Test + fun `finalizePage does nothing if there is no page open`() { + controller.finalizePage() + + assertEquals(0, controller.pageCount) + assertTrue(lifecycle.events.isEmpty()) + } + + @Test + fun `finalizePage does nothing when the page is already closed`() { + controller.ensurePage() + controller.finalizePage() + controller.finalizePage() + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1), PageEvent.Ending(1)), lifecycle.events) + } + + @Test + fun `ensurePage followed by finalize emits start and end events`() { + controller.ensurePage() + controller.finalizePage() + + assertEquals(listOf(PageEvent.Started(1), PageEvent.Ending(1)), lifecycle.events) + } + + @Test + fun `newPageIfShort starts a page if there is none open`() { + controller.newPageIfShort(spaceNeeded = 10f) + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1)), lifecycle.events) + } + + @Test + fun `newPageIfShort does not emit more pages if impossible to fit content in a new page`() { + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + + assertEquals(1, controller.pageCount) + assertEquals(listOf(PageEvent.Started(1)), lifecycle.events) + } + + @Test + fun `newPageIfShort does nothing if the content fits in the current page`() { + controller.ensurePage() + lifecycle.events.clear() + + controller.newPageIfShort(spaceNeeded = 10f) + + assertEquals(1, controller.pageCount) + assertTrue(lifecycle.events.isEmpty()) + } + + @Test + fun `newPageIfShort starts a new page if the content overflows`() { + controller.ensurePage() + cursor.advance(100f) + lifecycle.events.clear() + + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + + assertEquals(2, controller.pageCount) + assertEquals(listOf(PageEvent.Ending(1), PageEvent.Started(2)), lifecycle.events) + } + + @Test + fun `newPageIfShort should set the cursor at the start of the new page`() { + controller.ensurePage() + cursor.advance(500f) + + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + + assertEquals(PdfConfig.MARGIN.toFloat(), cursor.y) + } + + @Test + fun `Adding multiple pages emits the correct start and end events`() { + controller.ensurePage() + cursor.advance(100f) + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + cursor.advance(100f) + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + controller.finalizePage() + + assertEquals(3, controller.pageCount) + assertEquals( + listOf( + PageEvent.Started(1), + PageEvent.Ending(1), + PageEvent.Started(2), + PageEvent.Ending(2), + PageEvent.Started(3), + PageEvent.Ending(3), + ), + lifecycle.events, + ) + } + + @Test + fun `pageCount reflects the current page number while the page is open`() { + controller.ensurePage() + assertEquals(1, controller.pageCount) + + cursor.advance(100f) + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + assertEquals(2, controller.pageCount) + } + + @Test + fun `isFirstTableRowOnPage is true until consumed`() { + controller.ensurePage() + + assertTrue(controller.isFirstTableRowOnPage) + controller.consumeFirstTableRowOnPage() + assertFalse(controller.isFirstTableRowOnPage) + } + + @Test + fun `isFirstTableRowOnPage resets to true on a new page`() { + controller.ensurePage() + controller.consumeFirstTableRowOnPage() + assertFalse(controller.isFirstTableRowOnPage) + + cursor.advance(100f) + controller.newPageIfShort(spaceNeeded = Float.MAX_VALUE) + + assertTrue(controller.isFirstTableRowOnPage) + } + + private sealed interface PageEvent { + data class Started(val pageNumber: Int) : PageEvent + + data class Ending(val pageNumber: Int) : PageEvent + } +} diff --git a/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSetTest.kt b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSetTest.kt new file mode 100644 index 0000000000..78415a5adc --- /dev/null +++ b/feature/pdf/src/commonTest/kotlin/org/groundplatform/feature/pdf/render/image/PdfImageSetTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.feature.pdf.render.image + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.groundplatform.feature.pdf.render.image.PdfImageSet.ImageRef + +class PdfImageSetTest { + + @Test + fun `get returns null for a ref that is not in the set`() { + val set = PdfImageSet(emptyMap()) + + assertNull(set[ImageRef.Qr]) + assertNull(set[ImageRef.Photo("missing.jpg")]) + } + + @Test + fun `release invokes the onRelease callback`() { + var released = 0 + val set = PdfImageSet(emptyMap(), onRelease = { released++ }) + + set.release() + + assertEquals(1, released) + } + + @Test + fun `Photo refs are equal when their filenames match`() { + assertEquals(ImageRef.Photo("a.jpg"), ImageRef.Photo("a.jpg")) + assertTrue(ImageRef.Photo("a.jpg") != ImageRef.Photo("b.jpg")) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6c921835e6..c1d2848f05 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,7 @@ coreTestingVersion = "1.1.1" coreVersion = "1.7.0" coroutinesVersion = "1.11.0" detektVersion = "1.23.8" +exifInterfaceVersion = "1.4.2" espressoContribVersion = "3.7.0" firebaseBomVersion = "34.14.1" firebaseCrashlyticsGradleVersion = "3.0.7" @@ -91,6 +92,7 @@ androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4 androidx-core = { module = "androidx.test:core", version.ref = "coreVersion" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtxVersion" } androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } +androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifInterfaceVersion" } androidx-espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "espressoContribVersion" } androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoContribVersion" } androidx-espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "espressoContribVersion" }