diff --git a/collect_app/build.gradle b/collect_app/build.gradle index e047b25210a..8ddd541b723 100644 --- a/collect_app/build.gradle +++ b/collect_app/build.gradle @@ -366,6 +366,7 @@ dependencies { implementation(libs.androidXConstraintLayoutCompose) implementation(libs.androidXComposeMaterialIcons) implementation(libs.androidXComposePreview) + implementation(libs.coil) testImplementation project(':forms-test') diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/FieldListUpdateTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/FieldListUpdateTest.kt index f74646fda9a..4c246a1c1b7 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/FieldListUpdateTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/FieldListUpdateTest.kt @@ -19,6 +19,7 @@ import org.odk.collect.android.support.pages.ProjectSettingsPage import org.odk.collect.android.support.rules.FormEntryActivityTestRule import org.odk.collect.android.support.rules.TestRuleChain.chain import org.odk.collect.androidtest.RecordedIntentsRule +import org.odk.collect.testshared.AssertionFramework import java.io.File import java.io.FileOutputStream import java.util.Random @@ -234,9 +235,9 @@ class FieldListUpdateTest { .clickOnGroup("Push off screen binary") .clickOnQuestion("Source10") .assertNoQuestion("Target10-15") - .clickOnString(org.odk.collect.strings.R.string.capture_image) + .clickOnString(org.odk.collect.strings.R.string.capture_image, FormEntryPage("fieldlist-updates"), AssertionFramework.COMPOSE) .assertQuestion("Target10-15") - .assertText(org.odk.collect.strings.R.string.capture_image) + .assertText(org.odk.collect.strings.R.string.capture_image, AssertionFramework.COMPOSE) } @Test diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/IntentGroupTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/IntentGroupTest.kt index 23f00914c7b..0aebb738d2a 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/IntentGroupTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/IntentGroupTest.kt @@ -26,6 +26,7 @@ import android.os.Bundle import android.os.Environment import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithText import androidx.core.content.FileProvider import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.Espresso.onView @@ -35,18 +36,14 @@ import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.intent.Intents.intending import androidx.test.espresso.intent.matcher.IntentMatchers.isInternal -import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom import androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withClassName -import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withTagValue import androidx.test.espresso.matcher.ViewMatchers.withText import com.google.android.material.textfield.TextInputEditText import org.hamcrest.CoreMatchers -import org.hamcrest.Matchers import org.hamcrest.core.StringEndsWith import org.junit.Rule import org.junit.Test @@ -277,16 +274,18 @@ class IntentGroupTest { } private fun assertImageWidgetWithoutAnswer() { - onView(CoreMatchers.allOf( - withTagValue(Matchers.`is`("ImageView")), - withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) - .check(doesNotExist()) + val context = ApplicationProvider.getApplicationContext() + composeRule + .onNodeWithText(context.getString(R.string.capture_image)) + .assertDoesNotExist() - onView(withId(org.odk.collect.android.R.id.capture_button)) - .check(matches(CoreMatchers.not(isDisplayed()))) + composeRule + .onNodeWithText(context.getString(R.string.choose_image)) + .assertDoesNotExist() - onView(withId(org.odk.collect.android.R.id.choose_button)) - .check(matches(CoreMatchers.not(isDisplayed()))) + composeRule + .onNodeWithClickLabel(R.string.open_file) + .assertDoesNotExist() } private fun assertAudioWidgetWithoutAnswer() { @@ -296,7 +295,7 @@ class IntentGroupTest { private fun assertVideoWidgetWithoutAnswer() { composeRule - .onNodeWithClickLabel(ApplicationProvider.getApplicationContext().getString(R.string.play_video)) + .onNodeWithClickLabel(R.string.play_video) .assertDoesNotExist() } @@ -306,17 +305,18 @@ class IntentGroupTest { .assertDoesNotExist() } + @OptIn(ExperimentalTestApi::class) private fun assertImageWidgetWithAnswer() { - onView(CoreMatchers.allOf( - withTagValue(Matchers.`is`("ImageView")), - withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) - .check(matches(CoreMatchers.not(doesNotExist()))) + val context = ApplicationProvider.getApplicationContext() + composeRule + .onNodeWithText(context.getString(R.string.capture_image)) + .assertDoesNotExist() - onView(withId(org.odk.collect.android.R.id.capture_button)) - .check(matches(CoreMatchers.not(isDisplayed()))) + composeRule + .onNodeWithText(context.getString(R.string.choose_image)) + .assertDoesNotExist() - onView(withId(org.odk.collect.android.R.id.choose_button)) - .check(matches(CoreMatchers.not(isDisplayed()))) + composeRule.waitUntilAtLeastOneExists(hasClickLabel(R.string.open_file)) } private fun assertAudioWidgetWithAnswer() { @@ -330,10 +330,9 @@ class IntentGroupTest { composeRule.waitUntilExactlyOneExists(hasClickLabel(R.string.play_video)) } + @OptIn(ExperimentalTestApi::class) private fun assertFileWidgetWithAnswer() { - composeRule - .onNodeWithClickLabel(ApplicationProvider.getApplicationContext().getString(R.string.open_file)) - .assertExists() + composeRule.waitUntilAtLeastOneExists(hasClickLabel(R.string.open_file)) } @Throws(IOException::class) diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/smoke/AllWidgetsFormTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/smoke/AllWidgetsFormTest.kt index b3fe5363b82..607426973db 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/smoke/AllWidgetsFormTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/smoke/AllWidgetsFormTest.kt @@ -6,6 +6,7 @@ import org.odk.collect.android.support.pages.FormEntryPage import org.odk.collect.android.support.rules.BlankFormTestRule import org.odk.collect.android.support.rules.TestRuleChain.chain import org.odk.collect.strings.R.string +import org.odk.collect.testshared.AssertionFramework /** * Integration test that runs through a form with all question types. @@ -47,7 +48,7 @@ class AllWidgetsFormTest { .swipeToNextQuestion("Image widget") .swipeToNextQuestion("Image widget without Choose button") .swipeToNextQuestion("Selfie widget") - .clickOnText("Take Picture") + .clickOnText("Take Picture", AssertionFramework.COMPOSE) .pressBack(FormEntryPage("All widgets")) .swipeToNextQuestion("Draw widget") .swipeToNextQuestion("Annotate widget") diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt index 2f5709b60e0..ed177deec14 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt @@ -322,13 +322,22 @@ abstract class Page> { return FormHierarchyPage(formName) } - fun clickOnText(text: String): T { - EspressoInteractions.clickOn( - allOf( - withText(text), - withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE) - ) - ) + @JvmOverloads + fun clickOnText(text: String, assertionFramework: AssertionFramework = AssertionFramework.ESPRESSO): T { + when (assertionFramework) { + AssertionFramework.ESPRESSO -> { + EspressoInteractions.clickOn( + allOf( + withText(text), + withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE) + ) + ) + } + + AssertionFramework.COMPOSE -> { + ComposeInteractions.clickOn(composeRule!!, hasText(text)) + } + } return this as T } diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/ImageWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/ImageWidget.java deleted file mode 100644 index 542e6590dd6..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/ImageWidget.java +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright (C) 2009 University of Washington - * - * 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 - * - * http://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.odk.collect.android.widgets; - -import static org.odk.collect.android.utilities.ApplicationConstants.RequestCodes; - -import android.annotation.SuppressLint; -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.view.View; -import org.javarosa.form.api.FormEntryPrompt; -import org.odk.collect.android.databinding.ImageWidgetBinding; -import org.odk.collect.android.formentry.questions.QuestionDetails; -import org.odk.collect.android.storage.StoragePathProvider; -import org.odk.collect.android.storage.StorageSubdirectory; -import org.odk.collect.android.utilities.Appearances; -import org.odk.collect.android.utilities.QuestionMediaManager; -import org.odk.collect.android.widgets.utilities.ImageCaptureIntentCreator; -import org.odk.collect.android.widgets.utilities.WaitingForDataRegistry; -import org.odk.collect.androidshared.system.CameraUtils; -import org.odk.collect.selfiecamera.CaptureSelfieActivity; -import java.util.Locale; - -/** - * Widget that allows user to take pictures, sounds or video and add them to the form. - * - * @author Carl Hartung (carlhartung@gmail.com) - * @author Yaw Anokwa (yanokwa@gmail.com) - */ - -@SuppressLint("ViewConstructor") -public class ImageWidget extends BaseImageWidget { - ImageWidgetBinding binding; - - private boolean selfie; - - public ImageWidget(Context context, final QuestionDetails prompt, QuestionMediaManager questionMediaManager, WaitingForDataRegistry waitingForDataRegistry, String tmpImageFilePath, Dependencies dependencies) { - super(context, prompt, questionMediaManager, waitingForDataRegistry, tmpImageFilePath, dependencies); - imageClickHandler = new ViewImageClickHandler(); - imageCaptureHandler = new ImageCaptureHandler(); - - render(); - updateAnswer(); - } - - @Override - protected View onCreateWidgetView(Context context, FormEntryPrompt prompt, int answerFontSize) { - binding = ImageWidgetBinding.inflate(((Activity) context).getLayoutInflater()); - - String appearance = prompt.getAppearanceHint(); - selfie = Appearances.isFrontCameraAppearance(prompt); - if (selfie || ((appearance != null && appearance.toLowerCase(Locale.ENGLISH).contains(Appearances.NEW)))) { - binding.chooseButton.setVisibility(View.GONE); - } - - binding.captureButton.setOnClickListener(v -> getPermissionsProvider().requestCameraPermission((Activity) getContext(), this::captureImage)); - binding.chooseButton.setOnClickListener(v -> imageCaptureHandler.chooseImage(org.odk.collect.strings.R.string.choose_image)); - binding.image.setOnClickListener(v -> imageClickHandler.clickImage("viewImage")); - - if (questionDetails.isReadOnly()) { - binding.captureButton.setVisibility(View.GONE); - binding.chooseButton.setVisibility(View.GONE); - } - - errorTextView = binding.errorMessage; - imageView = binding.image; - - return binding.getRoot(); - } - - @Override - public Intent addExtrasToIntent(Intent intent) { - return intent; - } - - @Override - protected boolean doesSupportDefaultValues() { - return false; - } - - @Override - public void clearAnswer() { - super.clearAnswer(); - binding.captureButton.setText(getContext().getString(org.odk.collect.strings.R.string.capture_image)); - } - - @Override - public void setOnLongClickListener(OnLongClickListener l) { - binding.captureButton.setOnLongClickListener(l); - binding.chooseButton.setOnLongClickListener(l); - super.setOnLongClickListener(l); - } - - @Override - public void cancelLongPress() { - super.cancelLongPress(); - binding.captureButton.cancelLongPress(); - binding.chooseButton.cancelLongPress(); - } - - private void captureImage() { - if (selfie && new CameraUtils().isFrontCameraAvailable(getContext())) { - Intent intent = new Intent(getContext(), CaptureSelfieActivity.class); - intent.putExtra(CaptureSelfieActivity.EXTRA_TMP_PATH, new StoragePathProvider().getOdkDirPath(StorageSubdirectory.CACHE)); - imageCaptureHandler.captureImage(intent, RequestCodes.MEDIA_FILE_PATH, org.odk.collect.strings.R.string.capture_image); - } else { - Intent intent = ImageCaptureIntentCreator.imageCaptureIntent(getFormEntryPrompt(), getContext(), tmpImageFilePath); - imageCaptureHandler.captureImage(intent, RequestCodes.IMAGE_CAPTURE, org.odk.collect.strings.R.string.capture_image); - } - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/MediaWidgetAnswerViewModel.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/MediaWidgetAnswerViewModel.kt index be751179de0..065c2e0df07 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/MediaWidgetAnswerViewModel.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/MediaWidgetAnswerViewModel.kt @@ -10,6 +10,7 @@ import org.odk.collect.android.utilities.MediaUtils import org.odk.collect.android.utilities.QuestionMediaManager import org.odk.collect.androidshared.utils.getVideoThumbnail import org.odk.collect.async.Scheduler +import java.io.File class MediaWidgetAnswerViewModel( private val scheduler: Scheduler, @@ -30,6 +31,10 @@ class MediaWidgetAnswerViewModel( return bitmapState } + fun getImage(answer: String?): File? { + return questionMediaManager.getAnswerFile(answer) + } + fun openFile(context: Context, answer: String?, mimeType: String? = null) { val file = questionMediaManager.getAnswerFile(answer) if (file != null) { diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/WidgetAnswer.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/WidgetAnswer.kt index c027fec7545..ade1aa534a8 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/WidgetAnswer.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/WidgetAnswer.kt @@ -1,16 +1,23 @@ package org.odk.collect.android.widgets import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AttachFile import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp import org.javarosa.core.model.Constants import org.javarosa.form.api.FormEntryPrompt +import org.odk.collect.android.widgets.image.ImageWidgetAnswer import org.odk.collect.android.widgets.video.VideoWidgetAnswer import org.odk.collect.icons.R @@ -49,6 +56,19 @@ fun WidgetAnswer( ) } } + Constants.CONTROL_IMAGE_CHOOSE -> ImageWidgetAnswer( + if (summaryView) { + modifier + .height(200.dp) + .wrapContentWidth(Alignment.Start) + } else { + modifier.fillMaxWidth() + }, + answer, + if (summaryView) ContentScale.Fit else ContentScale.FillWidth, + mediaWidgetAnswerViewModel, + onLongClick + ) Constants.CONTROL_VIDEO_CAPTURE -> VideoWidgetAnswer(modifier, answer, mediaWidgetAnswerViewModel, onLongClick) Constants.CONTROL_FILE_CAPTURE -> { val context = LocalContext.current diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/WidgetFactory.java b/collect_app/src/main/java/org/odk/collect/android/widgets/WidgetFactory.java index d038aea9b61..b34ce96c6da 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/WidgetFactory.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/WidgetFactory.java @@ -40,6 +40,7 @@ import org.odk.collect.android.widgets.datetime.DateTimeWidget; import org.odk.collect.android.widgets.datetime.DateWidget; import org.odk.collect.android.widgets.datetime.TimeWidget; +import org.odk.collect.android.widgets.image.ImageWidget; import org.odk.collect.android.widgets.items.LabelWidget; import org.odk.collect.android.widgets.items.LikertWidget; import org.odk.collect.android.widgets.items.ListMultiWidget; diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/image/FileAnswerDelegate.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/image/FileAnswerDelegate.kt new file mode 100644 index 00000000000..8346aa3b03e --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/image/FileAnswerDelegate.kt @@ -0,0 +1,48 @@ +package org.odk.collect.android.widgets.image + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import org.javarosa.core.model.data.IAnswerData +import org.javarosa.core.model.data.StringData +import org.javarosa.form.api.FormEntryPrompt +import org.odk.collect.android.utilities.QuestionMediaManager +import timber.log.Timber +import java.io.File + +class FileAnswerDelegate( + private val questionMediaManager: QuestionMediaManager, + private val prompt: FormEntryPrompt +) { + var binaryName by mutableStateOf(prompt.answerText) + private set + + fun getAnswer(): IAnswerData? { + return binaryName?.let { StringData(it) } + } + + fun deleteFile() { + questionMediaManager.deleteAnswerFile(prompt.index.toString(), binaryName) + binaryName = null + } + + fun setData(objectData: Any): Boolean { + if (binaryName != null) { + deleteFile() + } + + if (objectData is File) { + if (objectData.exists()) { + questionMediaManager.replaceAnswerFile(prompt.index.toString(), objectData.absolutePath) + binaryName = objectData.name + return true + } else { + Timber.e(Error("File does not exist: ${objectData.absolutePath}")) + } + } else { + Timber.e(Error("FileAnswerDelegate.setData must receive a File object, but received ${objectData.javaClass.name}")) + } + + return false + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/image/ImageWidget.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/image/ImageWidget.kt new file mode 100644 index 00000000000..5feba05f23d --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/image/ImageWidget.kt @@ -0,0 +1,125 @@ +package org.odk.collect.android.widgets.image + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.view.View +import android.widget.Toast +import androidx.compose.ui.platform.ComposeView +import org.javarosa.core.model.data.IAnswerData +import org.javarosa.form.api.FormEntryPrompt +import org.odk.collect.android.formentry.questions.QuestionDetails +import org.odk.collect.android.storage.StoragePathProvider +import org.odk.collect.android.storage.StorageSubdirectory +import org.odk.collect.android.utilities.Appearances +import org.odk.collect.android.utilities.ApplicationConstants.RequestCodes +import org.odk.collect.android.utilities.QuestionMediaManager +import org.odk.collect.android.widgets.QuestionWidget +import org.odk.collect.android.widgets.interfaces.FileWidget +import org.odk.collect.android.widgets.interfaces.WidgetDataReceiver +import org.odk.collect.android.widgets.utilities.ImageCaptureIntentCreator +import org.odk.collect.android.widgets.utilities.QuestionFontSizeUtils +import org.odk.collect.android.widgets.utilities.WaitingForDataRegistry +import org.odk.collect.androidshared.system.CameraUtils +import org.odk.collect.androidshared.ui.ComposeThemeProvider.Companion.setContextThemedContent +import org.odk.collect.selfiecamera.CaptureSelfieActivity +import org.odk.collect.permissions.PermissionListener +import org.odk.collect.strings.R.string +import java.util.Locale + +@SuppressLint("ViewConstructor") +class ImageWidget @JvmOverloads constructor( + context: Context, + questionDetails: QuestionDetails, + private val questionMediaManager: QuestionMediaManager, + private val waitingForDataRegistry: WaitingForDataRegistry, + private val tmpImageFilePath: String, + private val dependencies: Dependencies, + private val fileAnswerDelegate: FileAnswerDelegate = FileAnswerDelegate(questionMediaManager, questionDetails.prompt) +) : QuestionWidget(context, dependencies, questionDetails), FileWidget, WidgetDataReceiver { + private val selfie: Boolean = Appearances.isFrontCameraAppearance(formEntryPrompt) + + init { render() } + + override fun onCreateWidgetView(context: Context, prompt: FormEntryPrompt, answerFontSize: Int): View { + val readOnly = questionDetails.isReadOnly + val newImagesOnly = selfie || prompt.appearanceHint?.lowercase(Locale.ENGLISH)?.contains(Appearances.NEW) == true + val buttonFontSize = QuestionFontSizeUtils.getFontSize(settings, QuestionFontSizeUtils.FontSize.BODY_LARGE) + + return ComposeView(context).apply { + setContextThemedContent { + ImageWidgetContent( + dependencies.mediaWidgetAnswerViewModel, + formEntryPrompt, + fileAnswerDelegate.binaryName, + readOnly, + newImagesOnly, + buttonFontSize, + onCaptureClick = { captureImage() }, + onChooseClick = { chooseImage() }, + onLongClick = { showContextMenu() } + ) + } + } + } + + override fun getAnswer(): IAnswerData? { + return fileAnswerDelegate.getAnswer() + } + + override fun clearAnswer() { + fileAnswerDelegate.deleteFile() + widgetValueChanged() + } + + override fun deleteFile() { + fileAnswerDelegate.deleteFile() + } + + override fun setData(answer: Any) { + if (fileAnswerDelegate.setData(answer)) { + widgetValueChanged() + } + } + + override fun setOnLongClickListener(listener: OnLongClickListener?) {} + + private fun captureImage() { + permissionsProvider.requestCameraPermission(context as Activity, object : PermissionListener { + override fun granted() { + if (selfie && CameraUtils().isFrontCameraAvailable(context)) { + val intent = Intent(context, CaptureSelfieActivity::class.java).apply { + putExtra(CaptureSelfieActivity.EXTRA_TMP_PATH, StoragePathProvider().getOdkDirPath(StorageSubdirectory.CACHE)) + } + launchActivityForResult(intent, RequestCodes.MEDIA_FILE_PATH, string.capture_image) + } else { + val intent = ImageCaptureIntentCreator.imageCaptureIntent(formEntryPrompt, context, tmpImageFilePath) + launchActivityForResult(intent, RequestCodes.IMAGE_CAPTURE, string.capture_image) + } + } + }) + } + + private fun chooseImage() { + val intent = Intent(Intent.ACTION_GET_CONTENT).apply { + type = "image/*" + } + launchActivityForResult(intent, RequestCodes.IMAGE_CHOOSER, string.choose_image) + } + + private fun launchActivityForResult(intent: Intent, requestCode: Int, errorStringResource: Int) { + try { + waitingForDataRegistry.waitForData(formEntryPrompt.index) + (context as Activity).startActivityForResult(intent, requestCode) + } catch (_: ActivityNotFoundException) { + Toast.makeText( + context, + context.getString(string.activity_not_found, context.getString(errorStringResource)), + Toast.LENGTH_SHORT + ).show() + waitingForDataRegistry.cancelWaitingForData() + } + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/image/ImageWidgetAnswer.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/image/ImageWidgetAnswer.kt new file mode 100644 index 00000000000..f323f867a16 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/image/ImageWidgetAnswer.kt @@ -0,0 +1,60 @@ +package org.odk.collect.android.widgets.image + +import androidx.compose.foundation.combinedClickable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import coil3.compose.AsyncImage +import org.odk.collect.android.widgets.MediaWidgetAnswerViewModel +import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickGuard + +@Composable +fun ImageWidgetAnswer( + modifier: Modifier, + answer: String, + contentScale: ContentScale, + mediaWidgetAnswerViewModel: MediaWidgetAnswerViewModel, + onLongClick: () -> Unit +) { + val context = LocalContext.current + val imageFile = remember(answer) { mediaWidgetAnswerViewModel.getImage(answer) } + var isError by remember(answer) { mutableStateOf(false) } + + imageFile?.let { + if (isError) { + Text( + modifier = modifier, + text = stringResource(org.odk.collect.strings.R.string.selected_invalid_image), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + } else { + AsyncImage( + model = it, + contentScale = contentScale, + contentDescription = null, + onError = { isError = true }, + modifier = modifier + .clip(MaterialTheme.shapes.large) + .combinedClickable( + onClick = { + if (MultiClickGuard.allowClick()) { + mediaWidgetAnswerViewModel.openFile(context, answer, "image/*") + } + }, + onLongClick = onLongClick, + onClickLabel = stringResource(org.odk.collect.strings.R.string.open_file) + ) + ) + } + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/image/ImageWidgetContent.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/image/ImageWidgetContent.kt new file mode 100644 index 00000000000..9a12b41f163 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/image/ImageWidgetContent.kt @@ -0,0 +1,61 @@ +package org.odk.collect.android.widgets.image + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PhotoCamera +import androidx.compose.material.icons.filled.PhotoLibrary +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import org.javarosa.form.api.FormEntryPrompt +import org.odk.collect.android.widgets.MediaWidgetAnswerViewModel +import org.odk.collect.android.widgets.WidgetAnswer +import org.odk.collect.android.widgets.WidgetIconButton +import org.odk.collect.androidshared.ui.compose.marginStandard +import org.odk.collect.strings.R.string + +@Composable +fun ImageWidgetContent( + mediaWidgetAnswerViewModel: MediaWidgetAnswerViewModel, + formEntryPrompt: FormEntryPrompt, + answer: String?, + readOnly: Boolean, + newImagesOnly: Boolean, + buttonFontSize: Int, + onCaptureClick: () -> Unit, + onChooseClick: () -> Unit, + onLongClick: () -> Unit +) { + Column { + if (!readOnly) { + WidgetIconButton( + Icons.Default.PhotoCamera, + stringResource(string.capture_image), + buttonFontSize, + onCaptureClick, + onLongClick + ) + } + + if (!readOnly && !newImagesOnly) { + WidgetIconButton( + Icons.Default.PhotoLibrary, + stringResource(string.choose_image), + buttonFontSize, + onChooseClick, + onLongClick, + Modifier + .padding(top = marginStandard()) + ) + } + + WidgetAnswer( + Modifier.padding(top = marginStandard()), + formEntryPrompt, + answer, + mediaWidgetAnswerViewModel = mediaWidgetAnswerViewModel, + onLongClick = onLongClick + ) + } +} diff --git a/collect_app/src/main/res/layout/hierarchy_question_item.xml b/collect_app/src/main/res/layout/hierarchy_question_item.xml index 763b504d8e8..d575bb570f1 100644 --- a/collect_app/src/main/res/layout/hierarchy_question_item.xml +++ b/collect_app/src/main/res/layout/hierarchy_question_item.xml @@ -4,13 +4,13 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" + android:layout_marginHorizontal="@dimen/margin_standard" android:paddingVertical="@dimen/margin_small"> \ No newline at end of file diff --git a/collect_app/src/main/res/layout/image_widget.xml b/collect_app/src/main/res/layout/image_widget.xml deleted file mode 100644 index fdd41d353bc..00000000000 --- a/collect_app/src/main/res/layout/image_widget.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/ImageWidgetTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/ImageWidgetTest.java deleted file mode 100644 index 1da6dc4fbef..00000000000 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/ImageWidgetTest.java +++ /dev/null @@ -1,217 +0,0 @@ -package org.odk.collect.android.widgets; - -import android.content.Intent; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.Drawable; -import android.provider.MediaStore; -import android.view.View; -import android.widget.ImageView; - -import androidx.annotation.NonNull; -import androidx.core.util.Pair; - -import net.bytebuddy.utility.RandomString; - -import org.javarosa.core.model.data.StringData; -import org.javarosa.core.reference.ReferenceManager; -import org.junit.Test; -import org.odk.collect.android.R; -import org.odk.collect.android.formentry.questions.QuestionDetails; -import org.odk.collect.android.injection.config.AppDependencyModule; -import org.odk.collect.android.support.CollectHelpers; -import org.odk.collect.android.support.MockFormEntryPromptBuilder; -import org.odk.collect.android.utilities.QuestionMediaManager; -import org.odk.collect.android.widgets.base.FileWidgetTest; -import org.odk.collect.android.widgets.support.FakeQuestionMediaManager; -import org.odk.collect.android.widgets.support.FakeWaitingForDataRegistry; -import org.odk.collect.android.widgets.support.SynchronousImageLoader; -import org.odk.collect.imageloader.ImageLoader; -import org.odk.collect.shared.TempFiles; - -import java.io.File; -import java.io.IOException; - -import static java.util.Collections.singletonList; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.notNullValue; -import static org.hamcrest.Matchers.nullValue; -import static org.junit.Assert.assertNull; -import static org.mockito.Mockito.when; -import static org.odk.collect.android.support.CollectHelpers.setupFakeReferenceManager; -import static org.robolectric.Shadows.shadowOf; - -/** - * @author James Knight - */ -public class ImageWidgetTest extends FileWidgetTest { - - private File currentFile; - - @NonNull - @Override - public ImageWidget createWidget() { - QuestionMediaManager fakeQuestionMediaManager = new FakeQuestionMediaManager() { - @Override - public File getAnswerFile(String fileName) { - File result; - if (currentFile == null) { - result = super.getAnswerFile(fileName); - } else { - result = fileName.equals(DrawWidgetTest.USER_SPECIFIED_IMAGE_ANSWER) ? currentFile : null; - } - return result; - } - }; - return new ImageWidget(activity, new QuestionDetails(formEntryPrompt, readOnlyOverride), - fakeQuestionMediaManager, new FakeWaitingForDataRegistry(), TempFiles.getPathInTempDir(), dependencies); - } - - @NonNull - @Override - public StringData getNextAnswer() { - return new StringData(RandomString.make()); - } - - @Test - public void buttonsShouldLaunchCorrectIntentsWhenThereIsNoCustomPackage() { - stubAllRuntimePermissionsGranted(true); - - Intent intent = getIntentLaunchedByClick(R.id.capture_button); - assertActionEquals(MediaStore.ACTION_IMAGE_CAPTURE, intent); - assertThat(intent.getPackage(), equalTo(null)); - - intent = getIntentLaunchedByClick(R.id.choose_button); - assertActionEquals(Intent.ACTION_GET_CONTENT, intent); - assertTypeEquals("image/*", intent); - } - - @Test - public void buttonsShouldLaunchCorrectIntentsWhenCustomPackageIsSet() { - formEntryPrompt = new MockFormEntryPromptBuilder() - .withAdditionalAttribute("intent", "com.customcameraapp") - .build(); - - stubAllRuntimePermissionsGranted(true); - - Intent intent = getIntentLaunchedByClick(R.id.capture_button); - assertActionEquals(MediaStore.ACTION_IMAGE_CAPTURE, intent); - assertThat(intent.getPackage(), equalTo("com.customcameraapp")); - - intent = getIntentLaunchedByClick(R.id.choose_button); - assertActionEquals(Intent.ACTION_GET_CONTENT, intent); - assertTypeEquals("image/*", intent); - } - - @Test - public void buttonsShouldNotLaunchIntentsWhenPermissionsDenied() { - stubAllRuntimePermissionsGranted(false); - - assertNull(getIntentLaunchedByClick(R.id.capture_button)); - } - - @Test - public void usingReadOnlyOptionShouldMakeAllClickableElementsDisabled() { - when(formEntryPrompt.isReadOnly()).thenReturn(true); - - assertThat(getSpyWidget().binding.captureButton.getVisibility(), is(View.GONE)); - assertThat(getSpyWidget().binding.chooseButton.getVisibility(), is(View.GONE)); - } - - @Test - public void whenReadOnlyOverrideOptionIsUsed_shouldAllClickableElementsBeDisabled() { - readOnlyOverride = true; - when(formEntryPrompt.isReadOnly()).thenReturn(false); - - assertThat(getSpyWidget().binding.captureButton.getVisibility(), is(View.GONE)); - assertThat(getSpyWidget().binding.chooseButton.getVisibility(), is(View.GONE)); - } - - @Test - public void whenThereIsNoAnswer_hideImageViewAndErrorMessage() { - ImageWidget widget = createWidget(); - - assertThat(widget.getImageView().getVisibility(), is(View.GONE)); - assertThat(widget.getImageView().getDrawable(), nullValue()); - - assertThat(widget.getErrorTextView().getVisibility(), is(View.GONE)); - } - - @Test - public void whenTheAnswerImageCanNotBeLoaded_hideImageViewAndShowErrorMessage() throws IOException { - CollectHelpers.overrideAppDependencyModule(new AppDependencyModule() { - @Override - public ImageLoader providesImageLoader() { - return new SynchronousImageLoader(true); - } - }); - - String imagePath = File.createTempFile("current", ".bmp").getAbsolutePath(); - currentFile = new File(imagePath); - - formEntryPrompt = new MockFormEntryPromptBuilder() - .withAnswerDisplayText(DrawWidgetTest.USER_SPECIFIED_IMAGE_ANSWER) - .build(); - - ImageWidget widget = createWidget(); - - assertThat(widget.getImageView().getVisibility(), is(View.GONE)); - assertThat(widget.getImageView().getDrawable(), nullValue()); - - assertThat(widget.getErrorTextView().getVisibility(), is(View.VISIBLE)); - } - - @Test - public void whenPromptHasDefaultAnswer_doesNotShow() throws Exception { - String imagePath = File.createTempFile("default", ".bmp").getAbsolutePath(); - ReferenceManager referenceManager = setupFakeReferenceManager(singletonList( - new Pair<>(DrawWidgetTest.DEFAULT_IMAGE_ANSWER, imagePath) - )); - CollectHelpers.overrideAppDependencyModule(new AppDependencyModule() { - @Override - public ReferenceManager providesReferenceManager() { - return referenceManager; - } - - @Override - public ImageLoader providesImageLoader() { - return new SynchronousImageLoader(); - } - }); - - formEntryPrompt = new MockFormEntryPromptBuilder() - .withAnswerDisplayText(DrawWidgetTest.DEFAULT_IMAGE_ANSWER) - .build(); - - ImageWidget widget = createWidget(); - ImageView imageView = widget.getImageView(); - assertThat(imageView.getVisibility(), is(View.GONE)); - } - - @Test - public void whenPromptHasCurrentAnswer_showsInImageView() throws Exception { - CollectHelpers.overrideAppDependencyModule(new AppDependencyModule() { - @Override - public ImageLoader providesImageLoader() { - return new SynchronousImageLoader(); - } - }); - - String imagePath = File.createTempFile("current", ".bmp").getAbsolutePath(); - currentFile = new File(imagePath); - - formEntryPrompt = new MockFormEntryPromptBuilder() - .withAnswerDisplayText(DrawWidgetTest.USER_SPECIFIED_IMAGE_ANSWER) - .build(); - - ImageWidget widget = createWidget(); - ImageView imageView = widget.getImageView(); - assertThat(imageView.getVisibility(), is(View.VISIBLE)); - Drawable drawable = imageView.getDrawable(); - assertThat(drawable, notNullValue()); - - String loadedPath = shadowOf(((BitmapDrawable) drawable).getBitmap()).getCreatedFromPath(); - assertThat(loadedPath, equalTo(imagePath)); - } -} diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/image/FileAnswerDelegateTest.kt b/collect_app/src/test/java/org/odk/collect/android/widgets/image/FileAnswerDelegateTest.kt new file mode 100644 index 00000000000..09285528e55 --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/image/FileAnswerDelegateTest.kt @@ -0,0 +1,87 @@ +package org.odk.collect.android.widgets.image + +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.nullValue +import org.junit.Test +import org.odk.collect.android.support.MockFormEntryPromptBuilder +import org.odk.collect.android.widgets.support.FakeQuestionMediaManager +import java.io.File + +class FileAnswerDelegateTest { + private val questionMediaManager = FakeQuestionMediaManager() + private val prompt = MockFormEntryPromptBuilder().build() + private val delegate = FileAnswerDelegate(questionMediaManager, prompt) + + @Test + fun `getAnswer() returns StringData with binary name when answer is present`() { + val promptWithAnswer = MockFormEntryPromptBuilder() + .withAnswerDisplayText("image.jpg") + .build() + val delegate = FileAnswerDelegate(questionMediaManager, promptWithAnswer) + + assertThat(delegate.getAnswer()?.value, equalTo("image.jpg" as Any)) + } + + @Test + fun `getAnswer() returns null when binary name is null`() { + assertThat(delegate.getAnswer(), nullValue()) + } + + @Test + fun `deleteFile() clears binary name and calls questionMediaManager`() { + val promptWithAnswer = MockFormEntryPromptBuilder() + .withAnswerDisplayText("image.jpg") + .withIndex("1") + .build() + val delegate = FileAnswerDelegate(questionMediaManager, promptWithAnswer) + + delegate.deleteFile() + + assertThat(delegate.binaryName, nullValue()) + assertThat(questionMediaManager.originalFiles["1"], equalTo("image.jpg")) + } + + @Test + fun `setData() with valid file updates binary name and returns true`() { + val file = File.createTempFile("new_image", ".jpg") + + val changed = delegate.setData(file) + + assertThat(changed, equalTo(true)) + assertThat(delegate.binaryName, equalTo(file.name)) + assertThat(questionMediaManager.recentFiles[prompt.index.toString()], equalTo(file.absolutePath)) + } + + @Test + fun `setData() deletes old file if it exists`() { + val promptWithAnswer = MockFormEntryPromptBuilder() + .withAnswerDisplayText("old_image.jpg") + .withIndex("1") + .build() + val delegate = FileAnswerDelegate(questionMediaManager, promptWithAnswer) + + val file = File.createTempFile("new_image", ".jpg") + delegate.setData(file) + + assertThat(questionMediaManager.originalFiles["1"], equalTo("old_image.jpg")) + } + + @Test + fun `setData() does nothing and returns false when file does not exist`() { + val file = File("non_existent_file") + + val changed = delegate.setData(file) + + assertThat(changed, equalTo(false)) + assertThat(delegate.binaryName, nullValue()) + } + + @Test + fun `setData() does nothing and returns false when data is not a file`() { + val changed = delegate.setData("not a file") + + assertThat(changed, equalTo(false)) + assertThat(delegate.binaryName, nullValue()) + } +} diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/image/ImageWidgetTest.kt b/collect_app/src/test/java/org/odk/collect/android/widgets/image/ImageWidgetTest.kt new file mode 100644 index 00000000000..c80f8da5ec6 --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/image/ImageWidgetTest.kt @@ -0,0 +1,194 @@ +package org.odk.collect.android.widgets.image + +import android.content.Intent +import android.provider.MediaStore +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import coil3.ImageLoader +import coil3.SingletonImageLoader +import coil3.imageDecoderEnabled +import net.bytebuddy.utility.RandomString +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.notNullValue +import org.javarosa.core.model.Constants +import org.javarosa.core.model.data.StringData +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.odk.collect.android.formentry.questions.QuestionDetails +import org.odk.collect.android.support.MockFormEntryPromptBuilder +import org.odk.collect.android.support.WidgetTestActivity +import org.odk.collect.android.widgets.MediaWidgetAnswerViewModel +import org.odk.collect.android.widgets.QuestionWidget +import org.odk.collect.android.widgets.base.FileWidgetTest +import org.odk.collect.android.widgets.support.FakeQuestionMediaManager +import org.odk.collect.android.widgets.support.FakeWaitingForDataRegistry +import org.odk.collect.androidtest.onNodeWithClickLabel +import org.odk.collect.shared.TempFiles +import org.odk.collect.strings.R.string +import org.robolectric.Shadows.shadowOf +import java.io.File + +class ImageWidgetTest : FileWidgetTest() { + @get:Rule + val composeRule = createAndroidComposeRule() + private lateinit var fileAnswerDelegate: FileAnswerDelegate + private val questionMediaManager = FakeQuestionMediaManager() + private val mediaWidgetAnswerViewModel = MediaWidgetAnswerViewModel(mock(), questionMediaManager, mock()) + private val dependencies = QuestionWidget.Dependencies( + null, + mediaWidgetAnswerViewModel + ) + + @Before + fun setup() { + SingletonImageLoader.setUnsafe { context -> + ImageLoader.Builder(context) + .imageDecoderEnabled(false) + .build() + } + } + + @After + fun teardown() { + SingletonImageLoader.reset() + } + + override fun createWidget(): ImageWidget { + fileAnswerDelegate = FileAnswerDelegate(questionMediaManager, formEntryPrompt) + whenever(formEntryPrompt.controlType).thenReturn(Constants.CONTROL_IMAGE_CHOOSE) + + return ImageWidget( + composeRule.activity, + QuestionDetails(formEntryPrompt, readOnlyOverride), + questionMediaManager, + FakeWaitingForDataRegistry(), + TempFiles.getPathInTempDir(), + dependencies, + fileAnswerDelegate + ).also { + widgetInComposeActivity(composeRule, it) + activity = composeRule.activity + } + } + + @Test + override fun settingANewAnswerShouldCallDeleteMediaToRemoveTheOldFile() { + super.settingANewAnswerShouldRemoveTheOldAnswer() + + val promptIndex = formEntryPrompt.index.toString() + assertThat(questionMediaManager.originalFiles[promptIndex], equalTo(formEntryPrompt.answerText)) + assertThat(questionMediaManager.recentFiles[promptIndex], notNullValue()) + } + + @Test + override fun callingClearAnswerShouldCallDeleteMediaAndRemoveTheExistingAnswer() { + super.callingClearShouldRemoveTheExistingAnswer() + + val promptIndex = formEntryPrompt.index.toString() + assertThat(questionMediaManager.originalFiles[promptIndex], equalTo(formEntryPrompt.answerText)) + assertThat(questionMediaManager.recentFiles[promptIndex], equalTo(null)) + } + + override fun getNextAnswer(): StringData { + return StringData(RandomString.make()) + } + + @Test + fun `buttons should launch correct intents when there is no custom package`() { + stubAllRuntimePermissionsGranted(true) + + composeRule.onNodeWithClickLabel(activity.getString(string.capture_image)).performClick() + var intent = shadowOf(activity).nextStartedActivity + assertActionEquals(MediaStore.ACTION_IMAGE_CAPTURE, intent) + assertThat(intent!!.getPackage(), equalTo(null)) + + composeRule.onNodeWithClickLabel(activity.getString(string.choose_image)).performClick() + intent = shadowOf(activity).nextStartedActivity + assertActionEquals(Intent.ACTION_GET_CONTENT, intent) + assertTypeEquals("image/*", intent) + } + + @Test + fun `buttons should launch correct intents when custom package is set`() { + formEntryPrompt = MockFormEntryPromptBuilder(formEntryPrompt) + .withAdditionalAttribute("intent", "com.customcameraapp") + .build() + + stubAllRuntimePermissionsGranted(true) + + composeRule.onNodeWithClickLabel(activity.getString(string.capture_image)).performClick() + var intent = shadowOf(activity).nextStartedActivity + assertActionEquals(MediaStore.ACTION_IMAGE_CAPTURE, intent) + assertThat(intent!!.getPackage(), equalTo("com.customcameraapp")) + + composeRule.onNodeWithClickLabel(activity.getString(string.choose_image)).performClick() + intent = shadowOf(activity).nextStartedActivity + assertActionEquals(Intent.ACTION_GET_CONTENT, intent) + assertTypeEquals("image/*", intent) + } + + @Test + fun `capture button should not launch any intent when permissions denied`() { + stubAllRuntimePermissionsGranted(false) + + composeRule.onNodeWithClickLabel(activity.getString(string.capture_image)).performClick() + assertThat(shadowOf(activity).nextStartedActivity, equalTo(null)) + } + + @Test + override fun usingReadOnlyOptionShouldMakeAllClickableElementsDisabled() { + whenever(formEntryPrompt.isReadOnly).thenReturn(true) + createWidget() + + composeRule.onNodeWithClickLabel(activity.getString(string.capture_image)).assertDoesNotExist() + composeRule.onNodeWithClickLabel(activity.getString(string.choose_image)).assertDoesNotExist() + } + + @Test + fun `when read-only override option is used should all clickable elements be disabled`() { + readOnlyOverride = true + whenever(formEntryPrompt.isReadOnly).thenReturn(false) + createWidget() + + composeRule.onNodeWithClickLabel(activity.getString(string.capture_image)).assertDoesNotExist() + composeRule.onNodeWithClickLabel(activity.getString(string.choose_image)).assertDoesNotExist() + } + + @Test + fun `when there is no answer hide image view`() { + createWidget() + composeRule.onNodeWithClickLabel(activity.getString(string.open_file)).assertDoesNotExist() + } + + @Test + fun `when prompt has current answer shows in image view`() { + val file = questionMediaManager.addAnswerFile(File.createTempFile("current", ".png")) + + formEntryPrompt = MockFormEntryPromptBuilder(formEntryPrompt) + .withAnswerDisplayText(file.name) + .build() + + createWidget() + composeRule.onNodeWithClickLabel(activity.getString(string.open_file)).assertExists() + composeRule.onNodeWithText(activity.getString(string.selected_invalid_image)).assertDoesNotExist() + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun `when the answer image cannot be loaded shows error message`() { + formEntryPrompt = MockFormEntryPromptBuilder(formEntryPrompt) + .withAnswerDisplayText("non_existent_file.bmp") + .build() + + createWidget() + composeRule.waitUntilAtLeastOneExists(hasText(activity.getString(string.selected_invalid_image))) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 89ec6878718..636225de047 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -101,6 +101,7 @@ camera-mlkit-vision = { group = "androidx.camera", name = "camera-mlkit-vision", jsoup = { group = "org.jsoup", name = "jsoup", version = "1.22.1" } mlkit-barcodescanning = { group = "com.google.android.gms", name = "play-services-mlkit-barcode-scanning", version = "18.3.1" } runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } +coil = { group = "io.coil-kt.coil3", name = "coil-compose", version = "3.4.0" } # Test dependencies junit = { group = "junit", name = "junit", version = "4.13.2" } diff --git a/test-shared/src/main/java/org/odk/collect/testshared/ComposeInteractions.kt b/test-shared/src/main/java/org/odk/collect/testshared/ComposeInteractions.kt index 9b96b6b5d94..a3e3be0a4d0 100644 --- a/test-shared/src/main/java/org/odk/collect/testshared/ComposeInteractions.kt +++ b/test-shared/src/main/java/org/odk/collect/testshared/ComposeInteractions.kt @@ -5,7 +5,7 @@ import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.performClick object ComposeInteractions { - fun clickOn(composeRule: ComposeTestRule, matcher: SemanticsMatcher, assertion: () -> Unit) { + fun clickOn(composeRule: ComposeTestRule, matcher: SemanticsMatcher, assertion: () -> Unit = {}) { composeRule.onNode(matcher).performClick() assertion() }