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 198c81eba7..3aad0b8162 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 @@ -220,6 +220,7 @@ internal constructor( override fun isLastWithValue(taskData: TaskData?): Boolean = isLastPositionWithValue(task, taskData) }, + surveyId = state.surveyId, ) updateDataAndInvalidateTasks(task, taskData) taskViewModels.value[task.id] = created diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskFragment.kt index f132d0ec5c..dbc8211bbd 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskFragment.kt @@ -33,7 +33,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow -import androidx.core.view.doOnAttach import androidx.hilt.navigation.fragment.hiltNavGraphViewModels import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlin.properties.Delegates @@ -99,11 +98,6 @@ abstract class AbstractTaskFragment : AbstractFragmen instructionData?.let { InstructionsDialog(it) } } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - view.doOnAttach { onTaskViewAttached() } - } - override fun onResume() { super.onResume() onTaskResume() @@ -119,9 +113,6 @@ abstract class AbstractTaskFragment : AbstractFragmen /** Invoked when the instruction dialog is dismissed. */ open fun onInstructionDialogDismissed() {} - /** Invoked after the task view gets attached to the fragment. */ - open fun onTaskViewAttached() {} - /** Invoked when the task fragment is visible to the user. */ open fun onTaskResume() {} diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModel.kt index ad02f0180f..b2a518654a 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModel.kt @@ -52,6 +52,7 @@ abstract class AbstractTaskViewModel internal constructor() : AbstractViewModel( .stateIn(viewModelScope, WhileSubscribed(5_000), emptyList()) } + lateinit var surveyId: String lateinit var task: Task private lateinit var taskPositionInterface: TaskPositionInterface @@ -60,7 +61,9 @@ abstract class AbstractTaskViewModel internal constructor() : AbstractViewModel( task: Task, taskData: TaskData?, taskPositionInterface: TaskPositionInterface, + surveyId: String, ) { + this.surveyId = surveyId this.task = task this.taskPositionInterface = taskPositionInterface setValue(taskData) diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskViewModel.kt index f810e8f59e..395d2b234c 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskViewModel.kt @@ -47,8 +47,9 @@ class MultipleChoiceTaskViewModel @Inject constructor() : AbstractTaskViewModel( task: Task, taskData: TaskData?, taskPositionInterface: TaskPositionInterface, + surveyId: String, ) { - super.initialize(job, task, taskData, taskPositionInterface) + super.initialize(job, task, taskData, taskPositionInterface, surveyId) loadPendingSelections() updateMultipleChoiceItems() } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskContent.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskContent.kt deleted file mode 100644 index 16030e0f9c..0000000000 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskContent.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.groundplatform.android.ui.datacollection.tasks.photo - -import android.net.Uri -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import org.groundplatform.android.R -import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport -import org.groundplatform.android.ui.datacollection.components.UriImage -import org.groundplatform.ui.theme.AppTheme - -@Composable -fun PhotoTaskContent(uri: Uri, onTakePhoto: () -> Unit, modifier: Modifier = Modifier) { - Box(modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp)) { - if (uri == Uri.EMPTY) { - CaptureButton(onTakePhoto) - } else { - UriImage(uri = uri, modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) - } - } -} - -@Composable -private fun CaptureButton(onTakePhoto: () -> Unit) { - FilledTonalButton( - onClick = onTakePhoto, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - ) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.outline_photo_camera), - contentDescription = stringResource(id = R.string.camera), - modifier = Modifier.size(ButtonDefaults.IconSize), - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(text = stringResource(id = R.string.camera)) - } -} - -@Preview(showBackground = true) -@Composable -@ExcludeFromJacocoGeneratedReport -private fun PhotoTaskContentPreviewEmpty() { - AppTheme { PhotoTaskContent(uri = Uri.EMPTY, onTakePhoto = {}) } -} - -@Preview(showBackground = true) -@Composable -@ExcludeFromJacocoGeneratedReport -private fun PhotoTaskScreenPreviewWithPhoto() { - AppTheme { - PhotoTaskContent(uri = "content://media/external/images/media/1".toUri(), onTakePhoto = {}) - } -} diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskEvent.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskEvent.kt new file mode 100644 index 0000000000..02bfe79c30 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskEvent.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.ui.datacollection.tasks.photo + +import android.net.Uri + +sealed interface PhotoTaskEvent { + data class LaunchCamera(val uri: Uri) : PhotoTaskEvent + + data class ShowError(val errorType: PhotoTaskError) : PhotoTaskEvent +} + +enum class PhotoTaskError { + PERMISSION_DENIED, + CAMERA_LAUNCH_FAILED, + PHOTO_SAVE_FAILED, +} diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskFragment.kt index 949e566e60..ee3989a0b3 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskFragment.kt @@ -15,145 +15,32 @@ */ package org.groundplatform.android.ui.datacollection.tasks.photo -import android.Manifest -import android.net.Uri -import android.os.Build import android.os.Bundle -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.lifecycleScope +import android.view.LayoutInflater +import android.view.ViewGroup import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import org.groundplatform.android.R -import org.groundplatform.android.di.coroutines.ApplicationScope -import org.groundplatform.android.di.coroutines.IoDispatcher -import org.groundplatform.android.di.coroutines.MainScope -import org.groundplatform.android.repository.UserMediaRepository -import org.groundplatform.android.system.PermissionDeniedException -import org.groundplatform.android.system.PermissionsManager -import org.groundplatform.android.ui.common.EphemeralPopups -import org.groundplatform.android.ui.components.ConfirmationDialog import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskFragment import org.groundplatform.android.ui.home.HomeScreenViewModel -import org.groundplatform.ui.theme.sizes -import timber.log.Timber +import org.groundplatform.android.util.createComposeView /** Fragment allowing the user to capture a photo to complete a task. */ @AndroidEntryPoint class PhotoTaskFragment : AbstractTaskFragment() { - @Inject lateinit var userMediaRepository: UserMediaRepository - @Inject @ApplicationScope lateinit var externalScope: CoroutineScope - @Inject @MainScope lateinit var mainScope: CoroutineScope - @Inject lateinit var permissionsManager: PermissionsManager - @Inject lateinit var popups: EphemeralPopups - @Inject @IoDispatcher lateinit var ioDispatcher: CoroutineDispatcher private val homeScreenViewModel: HomeScreenViewModel by lazy { getViewModel(HomeScreenViewModel::class.java) } - // Registers a callback to execute after a user captures a photo from the on-device camera. - private lateinit var capturePhotoLauncher: ActivityResultLauncher - - private var hasRequestedPermissionsOnResume = false - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - capturePhotoLauncher = - registerForActivityResult(ActivityResultContracts.TakePicture()) { result: Boolean -> - externalScope.launch(ioDispatcher) { viewModel.onCaptureResult(result) } - } - } - - @Composable - override fun TaskBody() { - var showPermissionDeniedDialog by viewModel.showPermissionDeniedDialog - val uri by viewModel.uri.collectAsStateWithLifecycle(Uri.EMPTY) - - PhotoTaskContent( - modifier = Modifier.padding(horizontal = MaterialTheme.sizes.taskViewPadding), - uri = uri, - onTakePhoto = { onTakePhoto() }, + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = createComposeView { + PhotoTaskScreen( + viewModel = viewModel, + onFooterPositionUpdated = { saveFooterPosition(it) }, + onAction = { handleTaskScreenAction(it) }, + onAwaitingPhotoCapture = { homeScreenViewModel.awaitingPhotoCapture = it }, ) - - if (showPermissionDeniedDialog) { - ConfirmationDialog( - title = R.string.permission_denied, - description = R.string.camera_permissions_needed, - confirmButtonText = R.string.ok, - onConfirmClicked = { showPermissionDeniedDialog = false }, - ) - } - } - - override fun onTaskViewAttached() { - viewModel.surveyId = dataCollectionViewModel.requireSurveyId() - } - - override fun onResume() { - super.onResume() - - if (!hasRequestedPermissionsOnResume) { - obtainCapturePhotoPermissions() - hasRequestedPermissionsOnResume = true - } - } - - // Requests camera/photo access permissions from the device, executing an optional callback - // when permission is granted. - private fun obtainCapturePhotoPermissions(onPermissionsGranted: () -> Unit = {}) { - lifecycleScope.launch { - try { - - // From Android 11 onwards (api level 30), requesting WRITE_EXTERNAL_STORAGE permission - // always returns denied. By default, the app has read/write access to shared data. - // - // For more details please refer to: - // https://developer.android.com/about/versions/11/privacy/storage#permissions-target-11 - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - permissionsManager.obtainPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) - } - - permissionsManager.obtainPermission(Manifest.permission.CAMERA) - - onPermissionsGranted() - } catch (_: PermissionDeniedException) { - viewModel.showPermissionDeniedDialog.value = true - } - } - } - - fun onTakePhoto() { - if (viewModel.hasLaunchedCamera) return - - // Keep track of the fact that we are restoring the application after a photo capture. - homeScreenViewModel.awaitingPhotoCapture = true - obtainCapturePhotoPermissions { lifecycleScope.launch { launchPhotoCapture() } } - } - - private suspend fun launchPhotoCapture() { - try { - viewModel.waitForPhotoCapture(viewModel.task.id) - val uri = viewModel.createImageFileUri() - viewModel.capturedUri = uri - viewModel.hasLaunchedCamera = true - capturePhotoLauncher.launch(uri) - Timber.d("Capture photo intent sent") - } catch (e: IllegalArgumentException) { - homeScreenViewModel.awaitingPhotoCapture = false - popups.ErrorPopup().unknownError() - Timber.e(e) - } } } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskScreen.kt new file mode 100644 index 0000000000..aa0ca0af41 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskScreen.kt @@ -0,0 +1,185 @@ +/* + * 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.datacollection.tasks.photo + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.flowWithLifecycle +import org.groundplatform.android.R +import org.groundplatform.android.ui.components.ConfirmationDialog +import org.groundplatform.android.ui.datacollection.components.TaskHeader +import org.groundplatform.android.ui.datacollection.components.UriImage +import org.groundplatform.android.ui.datacollection.tasks.TaskScreen +import org.groundplatform.android.ui.datacollection.tasks.TaskScreenAction +import org.groundplatform.ui.theme.sizes + +@Composable +fun PhotoTaskScreen( + viewModel: PhotoTaskViewModel, + onFooterPositionUpdated: (Float) -> Unit, + onAction: (TaskScreenAction) -> Unit, + onAwaitingPhotoCapture: (Boolean) -> Unit, +) { + val taskActionButtonsStates by viewModel.taskActionButtonStates.collectAsStateWithLifecycle() + val uri by viewModel.uri.collectAsStateWithLifecycle(Uri.EMPTY) + val isAwaiting by viewModel.isAwaitingPhotoCapture.collectAsStateWithLifecycle() + + var activeError by rememberSaveable { mutableStateOf(null) } + + val currentOnAwaiting by rememberUpdatedState(onAwaitingPhotoCapture) + LaunchedEffect(isAwaiting) { currentOnAwaiting(isAwaiting) } + + val capturePhotoLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { result -> + viewModel.onCaptureResult(result) + } + + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(viewModel.events, lifecycleOwner) { + viewModel.events.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED).collect { + currentEvent -> + when (currentEvent) { + is PhotoTaskEvent.LaunchCamera -> capturePhotoLauncher.launch(currentEvent.uri) + is PhotoTaskEvent.ShowError -> activeError = currentEvent.errorType + } + } + } + + TaskScreen( + taskHeader = + TaskHeader(label = viewModel.task.label, iconResId = R.drawable.ic_question_answer), + taskActionButtonsStates = taskActionButtonsStates, + onFooterPositionUpdated = onFooterPositionUpdated, + onAction = onAction, + taskBody = { + PhotoTaskContent( + uri = uri, + onTakePhoto = { viewModel.onTakePhoto() }, + activeError = activeError, + onDismissError = { activeError = null }, + modifier = Modifier.padding(horizontal = MaterialTheme.sizes.taskViewPadding), + ) + }, + ) +} + +@Composable +internal fun PhotoTaskContent( + uri: Uri, + onTakePhoto: () -> Unit, + activeError: PhotoTaskError?, + onDismissError: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp)) { + if (uri == Uri.EMPTY) { + CaptureButton(onTakePhoto) + } else { + UriImage(uri = uri, modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) + } + } + + activeError?.let { errorType -> + val (titleResId, messageResId) = + when (errorType) { + PhotoTaskError.PERMISSION_DENIED -> + Pair(R.string.permission_denied, R.string.camera_permissions_needed) + PhotoTaskError.CAMERA_LAUNCH_FAILED -> + Pair(R.string.camera_launch_failed_title, R.string.camera_launch_failed_desc) + PhotoTaskError.PHOTO_SAVE_FAILED -> + Pair(R.string.photo_save_failed_title, R.string.photo_save_failed_desc) + } + ConfirmationDialog( + title = titleResId, + description = messageResId, + confirmButtonText = R.string.ok, + onConfirmClicked = onDismissError, + dismissButtonText = null, + ) + } +} + +@Composable +private fun CaptureButton(onTakePhoto: () -> Unit) { + FilledTonalButton( + onClick = onTakePhoto, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.outline_photo_camera), + contentDescription = stringResource(id = R.string.camera), + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(text = stringResource(id = R.string.camera)) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewPhotoTaskContentEmpty() { + PhotoTaskContent(uri = Uri.EMPTY, onTakePhoto = {}, activeError = null, onDismissError = {}) +} + +@Preview(showBackground = true) +@Composable +private fun PreviewPhotoTaskContentWithPhoto() { + PhotoTaskContent( + uri = "content://mock/uri".toUri(), + onTakePhoto = {}, + activeError = null, + onDismissError = {}, + ) +} + +@Preview(showBackground = true) +@Composable +private fun PreviewPhotoTaskContentWithError() { + PhotoTaskContent( + uri = Uri.EMPTY, + onTakePhoto = {}, + activeError = PhotoTaskError.CAMERA_LAUNCH_FAILED, + onDismissError = {}, + ) +} diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskViewModel.kt index d3b599e845..652fe481b3 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskViewModel.kt @@ -15,18 +15,28 @@ */ package org.groundplatform.android.ui.datacollection.tasks.photo +import android.Manifest.permission.CAMERA +import android.Manifest.permission.WRITE_EXTERNAL_STORAGE import android.net.Uri -import androidx.compose.runtime.mutableStateOf +import android.os.Build +import android.os.Build.VERSION_CODES import androidx.lifecycle.viewModelScope -import java.io.IOException +import java.io.File import javax.inject.Inject -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.groundplatform.android.data.remote.firebase.FirebaseStorageManager import org.groundplatform.android.repository.UserMediaRepository +import org.groundplatform.android.system.PermissionDeniedException +import org.groundplatform.android.system.PermissionsManager import org.groundplatform.android.ui.datacollection.components.ButtonActionState import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskViewModel import org.groundplatform.domain.model.submission.TaskData @@ -34,27 +44,62 @@ import org.groundplatform.domain.model.submission.isNotNullOrEmpty import org.groundplatform.domain.model.task.PhotoTaskData import timber.log.Timber -class PhotoTaskViewModel @Inject constructor(private val userMediaRepository: UserMediaRepository) : - AbstractTaskViewModel() { +class PhotoTaskViewModel +@Inject +constructor( + private val userMediaRepository: UserMediaRepository, + private val permissionsManager: PermissionsManager, +) : AbstractTaskViewModel() { - /** - * Task id waiting for a photo result. As only one photo result is returned at a time, we can - * directly map it 1:1 with the task waiting for a photo result. - */ - var taskWaitingForPhoto: String? = null + private var tempPhotoFilePath: String? = null - lateinit var surveyId: String + private val _isAwaitingPhotoCapture = MutableStateFlow(false) + val isAwaitingPhotoCapture: StateFlow = _isAwaitingPhotoCapture.asStateFlow() - var hasLaunchedCamera: Boolean = false - var capturedUri: Uri? = null + private val _events = Channel() + val events: Flow = _events.receiveAsFlow() - val showPermissionDeniedDialog = mutableStateOf(false) + val uri: StateFlow = + taskTaskData + .map { taskData -> + if (taskData is PhotoTaskData && taskData.isNotNullOrEmpty()) { + userMediaRepository.getDownloadUrl(taskData.remoteFilename) + } else { + Uri.EMPTY + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), Uri.EMPTY) - val uri: Flow = taskTaskData.map { taskData -> - if (taskData is PhotoTaskData && taskData.isNotNullOrEmpty()) { - userMediaRepository.getDownloadUrl(taskData.remoteFilename) - } else { - Uri.EMPTY + fun onTakePhoto() { + if (_isAwaitingPhotoCapture.value) return + _isAwaitingPhotoCapture.value = true + viewModelScope.launch { obtainCapturePhotoPermissions { launchPhotoCapture() } } + } + + private suspend fun obtainCapturePhotoPermissions(onPermissionsGranted: suspend () -> Unit = {}) { + try { + if (Build.VERSION.SDK_INT < VERSION_CODES.R) { + permissionsManager.obtainPermission(WRITE_EXTERNAL_STORAGE) + } + permissionsManager.obtainPermission(CAMERA) + onPermissionsGranted() + } catch (_: PermissionDeniedException) { + _isAwaitingPhotoCapture.value = false + _events.send(PhotoTaskEvent.ShowError(PhotoTaskError.PERMISSION_DENIED)) + } + } + + private suspend fun launchPhotoCapture() { + try { + val file = userMediaRepository.createImageFile(task.id) + tempPhotoFilePath = file.absolutePath + val uri = userMediaRepository.getUriForFile(file) + _events.send(PhotoTaskEvent.LaunchCamera(uri)) + Timber.d("Capture photo intent sent") + } catch (e: IllegalArgumentException) { + _isAwaitingPhotoCapture.value = false + _events.send(PhotoTaskEvent.ShowError(PhotoTaskError.CAMERA_LAUNCH_FAILED)) + Timber.e(e, "Error launching photo capture") } } @@ -66,38 +111,26 @@ class PhotoTaskViewModel @Inject constructor(private val userMediaRepository: Us getNextButton(taskData), ) - suspend fun createImageFileUri(): Uri { - val file = userMediaRepository.createImageFile(task.id) - return userMediaRepository.getUriForFile(file) - } - - fun waitForPhotoCapture(taskId: String) { - taskWaitingForPhoto = taskId - } - fun onCaptureResult(result: Boolean) { - if (result && capturedUri != null) { - viewModelScope.launch { savePhotoTaskData(capturedUri!!) } + val filePath = tempPhotoFilePath + tempPhotoFilePath = null // Clear to avoid reusing stale path + viewModelScope.launch { + if (result && filePath != null) { + finalizePhotoCapture(File(filePath)) + } + _isAwaitingPhotoCapture.value = false } - hasLaunchedCamera = false } - /** - * Saves photo data stored on an on-device URI in Ground-associated storage and prepares it for - * inclusion in a data collection submission. - */ - private suspend fun savePhotoTaskData(uri: Uri) { - val currentTask = taskWaitingForPhoto - requireNotNull(currentTask) { "Photo captured but no task waiting for the result" } - + /** Finalizes the photo capture by adding it to the gallery and updating the task data. */ + private suspend fun finalizePhotoCapture(file: File) { try { - val file = userMediaRepository.savePhotoFromUri(uri, currentTask) userMediaRepository.addImageToGallery(file.absolutePath, file.name) val remoteFilename = FirebaseStorageManager.getRemoteMediaPath(surveyId, file.name) - - withContext(Dispatchers.Main) { setValue(PhotoTaskData(remoteFilename)) } - } catch (e: IOException) { - Timber.e(e, "Error saving photo to storage") + setValue(PhotoTaskData(remoteFilename)) + } catch (e: Exception) { + _events.send(PhotoTaskEvent.ShowError(PhotoTaskError.PHOTO_SAVE_FAILED)) + Timber.e(e, "Error finalizing photo capture") } } } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModel.kt index 4578c7f862..0cc059bf29 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModel.kt @@ -52,8 +52,9 @@ constructor( task: Task, taskData: TaskData?, taskPositionInterface: TaskPositionInterface, + surveyId: String, ) { - super.initialize(job, task, taskData, taskPositionInterface) + super.initialize(job, task, taskData, taskPositionInterface, surveyId) pinColor = job.getDefaultColor() // Drop a marker for current value diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt index d4218f94e0..6d0d5a35f3 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModel.kt @@ -170,8 +170,9 @@ internal constructor( task: Task, taskData: TaskData?, taskPositionInterface: TaskPositionInterface, + surveyId: String, ) { - super.initialize(job, task, taskData, taskPositionInterface) + super.initialize(job, task, taskData, taskPositionInterface, surveyId) viewModelScope.launch { measurementUnits = getUserSettingsUseCase.invoke().measurementUnits } featureStyle = Feature.Style(job.getDefaultColor(), Feature.VertexStyle.CIRCLE) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 8039f6d9a3..43f2772bcd 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -240,4 +240,8 @@ Recentrar La carga de la encuesta ha excedido el tiempo de espera. Verifica tu conexión e inténtalo de nuevo. + Error de cámara + No se pudo abrir la cámara. Por favor, inténtalo de nuevo. + Error al guardar + No se pudo guardar la foto capturada. Por favor, inténtalo de nuevo. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 07b69a30ea..182ffb5621 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -219,4 +219,8 @@ Recentrer Le chargement du sondage a expiré. Veuillez vérifier votre connexion et réessayer. + Erreur de caméra + Impossible d’ouvrir la caméra. Veuillez réessayer. + Erreur d’enregistrement + Impossible d’enregistrer la photo capturée. Veuillez réessayer. diff --git a/app/src/main/res/values-lo/strings.xml b/app/src/main/res/values-lo/strings.xml index 21072161b3..cceb70074d 100644 --- a/app/src/main/res/values-lo/strings.xml +++ b/app/src/main/res/values-lo/strings.xml @@ -209,4 +209,8 @@ ຕຳແໜ່ງຂອງທ່ານບໍ່ແມ່ນຍຳ ລອງຢືນນິ່ງໆ ຫຼື ລໍຖ້າໃຫ້ໄດ້ຕຳແໜ່ງທີ່ແມ່ນຍຳກວ່າ ຈັດຕຳແໜ່ງກາງໃໝ່ + ຂໍ້ຜິດພາດຂອງກ້ອງ + ບໍ່ສາມາດເປີດກ້ອງໄດ້. ກະລຸນາລອງໃໝ່ອີກຄັ້ງ. + ຂໍ້ຜິດພາດໃນການບັນທຶກ + ບໍ່ສາມາດບັນທຶກຮູບພາບທີ່ຖ່າຍໄດ້. ກະລຸນາລອງໃໝ່ອີກຄັ້ງ. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index f71f162528..dd4ec7fa05 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -242,4 +242,8 @@ Recentrar O carregamento da pesquisa expirou. Verifique sua conexão e tente novamente. + Erro de câmera + Falha ao abrir a câmera. Por favor, tente novamente. + Erro ao salvar + Falha ao salvar a foto capturada. Por favor, tente novamente. diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 2ff810b821..1899831122 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -211,4 +211,8 @@ ตำแหน่งของคุณยังไม่แม่นยำ ลองยืนนิ่ง ๆ หรือรอให้ระบบระบุตำแหน่งได้แม่นยำยิ่งขึ้น จัดกึ่งกลางใหม่ + ข้อผิดพลาดของกล้อง + ไม่สามารถเปิดกล้องได้ โปรดลองอีกครั้ง + ข้อผิดพลาดในการบันทึก + ไม่สามารถบันทึกรูปภาพที่ถ่ายได้ โปรดลองอีกครั้ง diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index a7b042b342..277a2c008a 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -213,4 +213,8 @@ Recenter Đã hết thời gian tải khảo sát. Vui lòng kiểm tra kết nối và thử lại. + Lỗi máy ảnh + Không thể mở máy ảnh. Vui lòng thử lại. + Lỗi lưu + Không thể lưu ảnh đã chụp. Vui lòng thử lại. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ae50c9985b..d54288672e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -125,6 +125,10 @@ Previous Connect to the internet to sign in You need to grant this application camera permissions to submit photos. + Camera Error + Failed to open the camera. Please try again. + Save Error + Failed to save the captured photo. Please try again. Incomplete area Initializing… Move the map and tap “Add point” to add points around the desired area. diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModelTest.kt index 9671066997..ef443c7eae 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/AbstractTaskViewModelTest.kt @@ -189,6 +189,7 @@ class AbstractTaskViewModelTest : BaseHiltTest() { override fun isLastWithValue(taskData: TaskData?): Boolean = isLastTaskWithValue }, + surveyId = "survey_id", ) } } diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/BaseTaskFragmentTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/BaseTaskFragmentTest.kt index 78a19d79db..a64a5abbea 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/BaseTaskFragmentTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/BaseTaskFragmentTest.kt @@ -84,6 +84,7 @@ abstract class BaseTaskFragmentTest, VM : AbstractT override fun isLastWithValue(taskData: TaskData?) = isLastPosition }, + surveyId = "survey_id", ) whenever(dataCollectionViewModel.getTaskViewModel(task.id)).thenReturn(viewModel) whenever(dataCollectionViewModel.isCurrentActiveTaskFlow(task.id)) diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/date/DateTaskScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/date/DateTaskScreenTest.kt index 8e9e071377..48611e362b 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/date/DateTaskScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/date/DateTaskScreenTest.kt @@ -62,6 +62,7 @@ class DateTaskScreenTest { override fun isLastWithValue(taskData: TaskData?) = false }, + surveyId = "survey_id", ) composeTestRule.setContent { diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/instruction/InstructionTaskScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/instruction/InstructionTaskScreenTest.kt index 13e1be713f..7d25d61d7a 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/instruction/InstructionTaskScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/instruction/InstructionTaskScreenTest.kt @@ -62,6 +62,7 @@ class InstructionTaskScreenTest { override fun isLastWithValue(taskData: TaskData?) = isLastWithValue }, + surveyId = "survey_id", ) } diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/location/CaptureLocationTaskViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/location/CaptureLocationTaskViewModelTest.kt index b166e895a1..591085a6d3 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/location/CaptureLocationTaskViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/location/CaptureLocationTaskViewModelTest.kt @@ -161,6 +161,7 @@ class CaptureLocationTaskViewModelTest : BaseHiltTest() { override fun isLastWithValue(taskData: TaskData?): Boolean = isLastTaskWithValue }, + surveyId = "survey_id", ) } diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskScreenTest.kt index 3107ae022d..b9a770724b 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskScreenTest.kt @@ -69,6 +69,7 @@ class MultipleChoiceTaskScreenTest { override fun isLastWithValue(taskData: TaskData?) = isLastWithValue }, + surveyId = "survey_id", ) composeTestRule.setContent { diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/number/NumberTaskScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/number/NumberTaskScreenTest.kt index 2e6ac644a0..b9d100178d 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/number/NumberTaskScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/number/NumberTaskScreenTest.kt @@ -59,6 +59,7 @@ class NumberTaskScreenTest { override fun isLastWithValue(taskData: TaskData?) = false }, + surveyId = "survey_id", ) buttonActionStateChecker = ButtonActionStateChecker(composeTestRule) diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskFragmentTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskFragmentTest.kt deleted file mode 100644 index 2e773201b6..0000000000 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskFragmentTest.kt +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright 2023 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.datacollection.tasks.photo - -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.fragment.app.Fragment -import com.google.common.truth.Truth.assertThat -import dagger.hilt.android.testing.BindValue -import dagger.hilt.android.testing.HiltAndroidTest -import org.groundplatform.android.repository.UserMediaRepository -import org.groundplatform.android.system.PermissionsManager -import org.groundplatform.android.ui.common.EphemeralPopups -import org.groundplatform.android.ui.common.ViewModelFactory -import org.groundplatform.android.ui.datacollection.DataCollectionViewModel -import org.groundplatform.android.ui.datacollection.components.ButtonAction -import org.groundplatform.android.ui.datacollection.components.ButtonActionState -import org.groundplatform.android.ui.datacollection.tasks.BaseTaskFragmentTest -import org.groundplatform.android.ui.home.HomeScreenViewModel -import org.groundplatform.domain.model.job.Job -import org.groundplatform.domain.model.job.Style -import org.groundplatform.domain.model.task.Task -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mock -import org.mockito.Mockito.doReturn -import org.mockito.Mockito.verify -import org.mockito.kotlin.any -import org.mockito.kotlin.eq -import org.mockito.kotlin.whenever -import org.robolectric.RobolectricTestRunner - -@HiltAndroidTest -@RunWith(RobolectricTestRunner::class) -class PhotoTaskFragmentTest : BaseTaskFragmentTest() { - - @BindValue @Mock override lateinit var dataCollectionViewModel: DataCollectionViewModel - @BindValue @Mock lateinit var userMediaRepository: UserMediaRepository - @BindValue @Mock override lateinit var viewModelFactory: ViewModelFactory - @BindValue @Mock lateinit var permissionsManager: PermissionsManager - @BindValue @Mock lateinit var popups: EphemeralPopups - - private val task = - Task( - id = "task_1", - index = 0, - type = Task.Type.PHOTO, - label = "Task for capturing a photo", - isRequired = false, - ) - private val job = Job("job", Style("#112233")) - - @Mock lateinit var homeScreenViewModel: HomeScreenViewModel - lateinit var photoTaskViewModel: PhotoTaskViewModel - - override fun setUp() { - super.setUp() - homeScreenViewModel = org.mockito.Mockito.mock(HomeScreenViewModel::class.java) - photoTaskViewModel = PhotoTaskViewModel(userMediaRepository) - - doReturn(homeScreenViewModel).`when`(viewModelFactory).create(HomeScreenViewModel::class.java) - doReturn(photoTaskViewModel).`when`(viewModelFactory).create(PhotoTaskViewModel::class.java) - doReturn(homeScreenViewModel) - .`when`(viewModelFactory) - .get(any(), eq(HomeScreenViewModel::class.java)) - whenever(dataCollectionViewModel.requireSurveyId()).thenReturn("test survey id") - kotlinx.coroutines.runBlocking { - val file = - java.io.File( - org.robolectric.RuntimeEnvironment.getApplication() - .getExternalFilesDir(android.os.Environment.DIRECTORY_PICTURES), - "image.jpg", - ) - file.createNewFile() - whenever(userMediaRepository.createImageFile(any())).thenReturn(file) - whenever(userMediaRepository.getUriForFile(any())).thenReturn(android.net.Uri.EMPTY) - } - } - - @Test - fun `displays task header correctly`() { - setupTaskFragment(job, task) - - hasTaskViewWithHeader(task) - } - - @Test - fun `Initial action buttons state`() { - setupTaskFragment(job, task) - - assertFragmentHasButtons( - ButtonActionState(ButtonAction.PREVIOUS, isEnabled = true, isVisible = true), - ButtonActionState(ButtonAction.UNDO, isEnabled = false, isVisible = false), - ButtonActionState(ButtonAction.SKIP, isEnabled = true, isVisible = true), - ButtonActionState(ButtonAction.NEXT, isEnabled = false, isVisible = true), - ) - } - - @Test - fun `Initial action buttons state when task is required`() { - setupTaskFragment(job, task.copy(isRequired = true)) - - assertFragmentHasButtons( - ButtonActionState(ButtonAction.PREVIOUS, isEnabled = true, isVisible = true), - ButtonActionState(ButtonAction.UNDO, isEnabled = false, isVisible = false), - ButtonActionState(ButtonAction.SKIP, isEnabled = false, isVisible = false), - ButtonActionState(ButtonAction.NEXT, isEnabled = false, isVisible = true), - ) - } - - @Test - fun `action buttons when task is required`() { - setupTaskFragment(job, task.copy(isRequired = true)) - - runner() - .assertButtonIsDisabled("Next") - .assertButtonIsHidden("Skip") - .assertButtonIsHidden("Undo", true) - } - - @Test - fun `taking photo sends intent`() { - setupTaskFragment(job, task) - - composeTestRule.onNodeWithText("Camera").performClick() - - org.robolectric.shadows.ShadowLooper.idleMainLooper() - - kotlinx.coroutines.runBlocking { - verify(userMediaRepository).createImageFile(any()) - verify(userMediaRepository).getUriForFile(any()) - } - - assertThat(photoTaskViewModel.hasLaunchedCamera).isTrue() - } -} diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskScreenTest.kt index d0af37967b..09b9c22063 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskScreenTest.kt @@ -15,6 +15,7 @@ */ package org.groundplatform.android.ui.datacollection.tasks.photo +import android.Manifest.permission.CAMERA import android.net.Uri import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed @@ -24,18 +25,85 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.core.net.toUri import com.google.common.truth.Truth.assertThat +import java.io.File +import kotlinx.coroutines.runBlocking +import org.groundplatform.android.repository.UserMediaRepository +import org.groundplatform.android.system.PermissionDeniedException +import org.groundplatform.android.system.PermissionsManager +import org.groundplatform.android.ui.datacollection.components.ButtonAction +import org.groundplatform.android.ui.datacollection.components.ButtonActionState +import org.groundplatform.android.ui.datacollection.tasks.ButtonActionStateChecker +import org.groundplatform.android.ui.datacollection.tasks.TaskPositionInterface +import org.groundplatform.android.ui.datacollection.tasks.TaskScreenAction +import org.groundplatform.domain.model.job.Job +import org.groundplatform.domain.model.job.Style +import org.groundplatform.domain.model.submission.TaskData +import org.groundplatform.domain.model.task.PhotoTaskData +import org.groundplatform.domain.model.task.Task +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class PhotoTaskScreenTest { + @get:Rule val composeTestRule = createComposeRule() + @Mock private lateinit var userMediaRepository: UserMediaRepository + @Mock private lateinit var permissionsManager: PermissionsManager + private lateinit var viewModel: PhotoTaskViewModel + private var lastButtonAction: ButtonAction? = null + private val buttonActionStateChecker = ButtonActionStateChecker(composeTestRule) + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + } + + private fun setupTaskScreen( + task: Task, + taskData: TaskData? = null, + isFirst: Boolean = false, + isLastWithValue: Boolean = false, + ) { + lastButtonAction = null + viewModel = PhotoTaskViewModel(userMediaRepository, permissionsManager) + viewModel.initialize( + job = JOB, + task = task, + taskData = taskData, + taskPositionInterface = + object : TaskPositionInterface { + override fun isFirst() = isFirst + + override fun isLastWithValue(taskData: TaskData?) = isLastWithValue + }, + surveyId = "survey_1", + ) + + composeTestRule.setContent { + PhotoTaskScreen( + viewModel = viewModel, + onFooterPositionUpdated = {}, + onAction = { + if (it is TaskScreenAction.OnButtonClicked) { + lastButtonAction = it.action + } + }, + onAwaitingPhotoCapture = {}, + ) + } + } + @Test fun `shows capture button when photo is not present`() { - composeTestRule.setContent { PhotoTaskContent(uri = Uri.EMPTY, onTakePhoto = {}) } + setupTaskScreen(TASK) composeTestRule.onNodeWithText("Camera").assertIsDisplayed() composeTestRule.onNodeWithContentDescription("Preview").assertIsNotDisplayed() @@ -43,24 +111,170 @@ class PhotoTaskScreenTest { @Test fun `shows photo preview when photo is present`() { - composeTestRule.setContent { - PhotoTaskContent(uri = "content://media/external/images/media/1".toUri(), onTakePhoto = {}) + runBlocking { + whenever(userMediaRepository.getDownloadUrl("test_file.jpg")) + .thenReturn("content://media/external/images/media/1".toUri()) } + setupTaskScreen(TASK, PhotoTaskData("test_file.jpg")) + composeTestRule.onNodeWithText("Camera").assertIsNotDisplayed() composeTestRule.onNodeWithContentDescription("Preview").assertIsDisplayed() } @Test fun `invokes onTakePhoto callback when capture button is clicked`() { - var onTakePhotoCalled = false + setupTaskScreen(TASK) - composeTestRule.setContent { - PhotoTaskContent(uri = Uri.EMPTY, onTakePhoto = { onTakePhotoCalled = true }) - } + val file = File("test.jpg") + val dummyUri = Uri.parse("content://test") + runBlocking { whenever(userMediaRepository.createImageFile(TASK.id)).thenReturn(file) } + whenever(userMediaRepository.getUriForFile(file)).thenReturn(dummyUri) composeTestRule.onNodeWithText("Camera").performClick() - assertThat(onTakePhotoCalled).isTrue() + composeTestRule.waitForIdle() + + runBlocking { verify(permissionsManager).obtainPermission(CAMERA) } + } + + @Test + fun `displays task header correctly`() { + setupTaskScreen(TASK) + + composeTestRule.onNodeWithText(TASK.label).assertIsDisplayed() + } + + @Test + fun `sets initial action buttons state when task is optional`() { + setupTaskScreen(TASK) + + buttonActionStateChecker.assertButtonStates( + ButtonActionState(ButtonAction.PREVIOUS, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.SKIP, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.NEXT, isEnabled = false, isVisible = true), + ) + } + + @Test + fun `sets initial action buttons state when task is required`() { + setupTaskScreen(TASK.copy(isRequired = true)) + + buttonActionStateChecker.assertButtonStates( + ButtonActionState(ButtonAction.PREVIOUS, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.SKIP, isEnabled = false, isVisible = false), + ButtonActionState(ButtonAction.NEXT, isEnabled = false, isVisible = true), + ) + } + + @Test + fun `sets action buttons state when data is present`() { + runBlocking { + whenever(userMediaRepository.getDownloadUrl("test_file.jpg")) + .thenReturn("content://media/external/images/media/1".toUri()) + } + setupTaskScreen(TASK.copy(isRequired = true), PhotoTaskData("test_file.jpg")) + + buttonActionStateChecker.assertButtonStates( + ButtonActionState(ButtonAction.PREVIOUS, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.SKIP, isEnabled = false, isVisible = false), + ButtonActionState(ButtonAction.NEXT, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.UNDO, isEnabled = true, isVisible = true), + ) + } + + @Test + fun `sets action buttons state when it is the first task`() { + setupTaskScreen(TASK, isFirst = true) + + buttonActionStateChecker.assertButtonStates( + ButtonActionState(ButtonAction.PREVIOUS, isEnabled = false, isVisible = true), + ButtonActionState(ButtonAction.SKIP, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.NEXT, isEnabled = false, isVisible = true), + ) + } + + @Test + fun `sets action buttons state when it is the last task`() { + setupTaskScreen(TASK, isLastWithValue = true) + + buttonActionStateChecker.assertButtonStates( + ButtonActionState(ButtonAction.PREVIOUS, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.SKIP, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.DONE, isEnabled = false, isVisible = true), + ) + } + + @Test + fun `invokes onButtonClicked when action button is clicked`() { + setupTaskScreen(TASK) + + buttonActionStateChecker.getNode(ButtonAction.PREVIOUS).performClick() + + assertThat(lastButtonAction).isEqualTo(ButtonAction.PREVIOUS) + } + + @Test + fun `dismisses permission denied dialog when OK is clicked`() { + setupTaskScreen(TASK) + + runBlocking { + whenever(permissionsManager.obtainPermission(CAMERA)).then { + throw PermissionDeniedException("Permission denied") + } + } + + viewModel.onTakePhoto() + + composeTestRule.onNodeWithText("Permission denied").assertIsDisplayed() + composeTestRule.onNodeWithText("OK").performClick() + composeTestRule.onNodeWithText("Permission denied").assertIsNotDisplayed() + } + + @Test + fun `shows photo save failed dialog when finalize fails`() { + setupTaskScreen(TASK) + + val file = File("test.jpg") + val dummyUri = Uri.parse("content://test") + runBlocking { + whenever(userMediaRepository.createImageFile(TASK.id)).thenReturn(file) + whenever(userMediaRepository.addImageToGallery(file.absolutePath, file.name)).then { + error("Save failed") + } + } + whenever(userMediaRepository.getUriForFile(file)).thenReturn(dummyUri) + + viewModel.onTakePhoto() + viewModel.onCaptureResult(true) + + composeTestRule.onNodeWithText("Save Error").assertIsDisplayed() + } + + @Test + fun `shows camera launch failed dialog when camera launch fails`() { + setupTaskScreen(TASK) + + runBlocking { + whenever(userMediaRepository.createImageFile(TASK.id)).then { + throw IllegalArgumentException("Launch failed") + } + } + + viewModel.onTakePhoto() + + composeTestRule.onNodeWithText("Camera Error").assertIsDisplayed() + } + + companion object { + private val JOB = Job("job", Style("#112233")) + private val TASK = + Task( + id = "task_1", + index = 0, + type = Task.Type.PHOTO, + label = "Task for capturing a photo", + isRequired = false, + ) } } diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskViewModelTest.kt index ce4d037bc5..d78bbc4043 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/photo/PhotoTaskViewModelTest.kt @@ -18,25 +18,28 @@ package org.groundplatform.android.ui.datacollection.tasks.photo import android.net.Uri +import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidTest import java.io.File import javax.inject.Inject -import kotlin.test.assertFalse -import kotlin.test.assertTrue +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.repository.UserMediaRepository -import org.groundplatform.android.ui.datacollection.components.ButtonAction +import org.groundplatform.android.system.PermissionsManager import org.groundplatform.android.ui.datacollection.tasks.TaskPositionInterface import org.groundplatform.domain.model.job.Job import org.groundplatform.domain.model.job.Style import org.groundplatform.domain.model.submission.TaskData import org.groundplatform.domain.model.task.PhotoTaskData import org.groundplatform.domain.model.task.Task +import org.junit.After import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock @@ -51,119 +54,85 @@ import org.robolectric.RobolectricTestRunner class PhotoTaskViewModelTest : BaseHiltTest() { @BindValue @Mock lateinit var userMediaRepository: UserMediaRepository + @BindValue @Mock lateinit var permissionsManager: PermissionsManager @Inject lateinit var viewModel: PhotoTaskViewModel - @Mock private lateinit var mockFile: File - override fun setUp() { super.setUp() setupViewModel() + Dispatchers.setMain(testDispatcher) } - @Test - fun `createImageFileUri creates file and returns uri`() = runWithTestDispatcher { - val mockUri = mock() - whenever(userMediaRepository.createImageFile(any())).thenReturn(mockFile) - whenever(userMediaRepository.getUriForFile(mockFile)).thenReturn(mockUri) - - val uri = viewModel.createImageFileUri() - - assertThat(uri).isEqualTo(mockUri) - verify(userMediaRepository).createImageFile(TASK.id) - verify(userMediaRepository).getUriForFile(mockFile) + @After + fun tearDown() { + Dispatchers.resetMain() } @Test - fun `waitForPhotoCapture sets taskWaitingForPhoto`() { - viewModel.waitForPhotoCapture(TASK.id) + fun `onCaptureResult saves photo when result is true`() = runWithTestDispatcher { + whenever(permissionsManager.obtainPermission(any())).thenReturn(Unit) + val mockFile = mock() + whenever(userMediaRepository.createImageFile(any())).thenReturn(mockFile) + whenever(userMediaRepository.getUriForFile(mockFile)).thenReturn(mock()) + whenever(mockFile.absolutePath).thenReturn("/path/to/file.jpg") + whenever(mockFile.name).thenReturn("file.jpg") - assertThat(viewModel.taskWaitingForPhoto).isEqualTo(TASK.id) - } + viewModel.onTakePhoto() + advanceUntilIdle() - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `onCaptureResult saves photo when result is true and uri is present`() = - runWithTestDispatcher { - val uri = mock() - viewModel.capturedUri = uri - viewModel.taskWaitingForPhoto = TASK.id - whenever(userMediaRepository.savePhotoFromUri(any(), any())).thenReturn(mockFile) - whenever(mockFile.absolutePath).thenReturn("/path/to/file") - whenever(mockFile.name).thenReturn("file.jpg") + viewModel.taskTaskData.test { + assertThat(awaitItem()).isNull() viewModel.onCaptureResult(true) advanceUntilIdle() - verify(userMediaRepository).savePhotoFromUri(uri, TASK.id) - verify(userMediaRepository).addImageToGallery("/path/to/file", "file.jpg") - assertThat(viewModel.hasLaunchedCamera).isFalse() + val item = awaitItem() + assertThat(item).isInstanceOf(PhotoTaskData::class.java) + assertThat((item as PhotoTaskData).remoteFilename) + .isEqualTo("user-media/surveys/survey_1/submissions/file.jpg") } - @Test - fun `onCaptureResult does nothing when result is false`() = runWithTestDispatcher { - viewModel.onCaptureResult(false) - - verify(userMediaRepository, org.mockito.kotlin.never()).savePhotoFromUri(any(), any()) - assertThat(viewModel.hasLaunchedCamera).isFalse() - } - - @Test - fun `onCaptureResult does nothing when capturedUri is null`() = runWithTestDispatcher { - viewModel.capturedUri = null - - viewModel.onCaptureResult(true) - - verify(userMediaRepository, org.mockito.kotlin.never()).savePhotoFromUri(any(), any()) - assertThat(viewModel.hasLaunchedCamera).isFalse() - } - - @Test - fun `Should have the correct action buttons in the proper order`() = runWithTestDispatcher { - advanceUntilIdle() - - val states = viewModel.taskActionButtonStates.first() - - assertThat(states.map { it.action }) - .containsExactly( - ButtonAction.PREVIOUS, - ButtonAction.UNDO, - ButtonAction.SKIP, - ButtonAction.NEXT, - ) - .inOrder() + verify(userMediaRepository).addImageToGallery("/path/to/file.jpg", "file.jpg") + assertThat(viewModel.isAwaitingPhotoCapture.value).isFalse() } @Test - fun `UNDO is not visible and NEXT is disabled when the photo is not taken yet`() = + fun `onCaptureResult emits error when finalizePhotoCapture throws Exception`() = runWithTestDispatcher { - advanceUntilIdle() + whenever(permissionsManager.obtainPermission(any())).thenReturn(Unit) + val mockFile = mock() + whenever(userMediaRepository.createImageFile(any())).thenReturn(mockFile) + whenever(userMediaRepository.getUriForFile(mockFile)).thenReturn(mock()) + whenever(mockFile.absolutePath).thenReturn("/path/to/file.jpg") + whenever(mockFile.name).thenReturn("file.jpg") - val states = viewModel.taskActionButtonStates.first() + viewModel.events.test { + viewModel.onTakePhoto() + assertThat(awaitItem()).isInstanceOf(PhotoTaskEvent.LaunchCamera::class.java) - with(requireNotNull(states.find { it.action == ButtonAction.UNDO })) { - assertFalse(isVisible) - assertFalse(isEnabled) - } - with(requireNotNull(states.find { it.action == ButtonAction.NEXT })) { - assertTrue(isVisible) - assertFalse(isEnabled) + whenever(userMediaRepository.addImageToGallery(any(), any())).thenThrow(RuntimeException()) + + viewModel.onCaptureResult(true) + val event = awaitItem() + assertThat(event).isInstanceOf(PhotoTaskEvent.ShowError::class.java) + assertThat((event as PhotoTaskEvent.ShowError).errorType) + .isEqualTo(PhotoTaskError.PHOTO_SAVE_FAILED) } } @Test - fun `UNDO and NEXT are visible and enabled when the photo is present`() = runWithTestDispatcher { - viewModel.setValue(PhotoTaskData("path/photo.jpg")) - advanceUntilIdle() - - val states = viewModel.taskActionButtonStates.first() + fun `onTakePhoto emits LaunchCamera event`() = runWithTestDispatcher { + whenever(permissionsManager.obtainPermission(any())).thenReturn(Unit) + val mockFile = mock() + whenever(userMediaRepository.createImageFile(any())).thenReturn(mockFile) + val mockUri = mock() + whenever(userMediaRepository.getUriForFile(mockFile)).thenReturn(mockUri) - with(requireNotNull(states.find { it.action == ButtonAction.UNDO })) { - assertTrue(isVisible) - assertTrue(isEnabled) - } - with(requireNotNull(states.find { it.action == ButtonAction.NEXT })) { - assertTrue(isVisible) - assertTrue(isEnabled) + viewModel.events.test { + viewModel.onTakePhoto() + val event = awaitItem() + assertThat(event).isInstanceOf(PhotoTaskEvent.LaunchCamera::class.java) + assertThat((event as PhotoTaskEvent.LaunchCamera).uri).isEqualTo(mockUri) } } @@ -196,8 +165,8 @@ class PhotoTaskViewModelTest : BaseHiltTest() { override fun isLastWithValue(taskData: TaskData?) = isLastTaskWithValue }, + "survey_1", ) - viewModel.surveyId = "survey_1" } companion object { diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModelTest.kt index aeb8581388..d80c7a269b 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/point/DropPinTaskViewModelTest.kt @@ -141,6 +141,7 @@ class DropPinTaskViewModelTest : BaseHiltTest() { override fun isLastWithValue(taskData: TaskData?): Boolean = isLastTaskWithValue }, + surveyId = "survey_id", ) } } diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt index 64f7b7fc2c..e7c68e35c9 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/polygon/DrawAreaTaskViewModelTest.kt @@ -671,6 +671,7 @@ class DrawAreaTaskViewModelTest : BaseHiltTest() { override fun isLastWithValue(taskData: TaskData?) = false }, + surveyId = "survey_id", ) } diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/text/TextTaskScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/text/TextTaskScreenTest.kt index 94870a902d..de4c94e11a 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/text/TextTaskScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/text/TextTaskScreenTest.kt @@ -62,6 +62,7 @@ class TextTaskScreenTest { override fun isLastWithValue(taskData: TaskData?) = false }, + surveyId = "survey_id", ) composeTestRule.setContent { diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/time/TimeTaskScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/time/TimeTaskScreenTest.kt index fda680a07f..0933ccbacb 100644 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/time/TimeTaskScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/time/TimeTaskScreenTest.kt @@ -62,6 +62,7 @@ class TimeTaskScreenTest { override fun isLastWithValue(taskData: TaskData?) = false }, + surveyId = "survey_id", ) composeTestRule.setContent {