Skip to content

Commit

Permalink
New external intent capture mode for multiple image captures (#275)
Browse files Browse the repository at this point in the history
* multiple image captures

* update

* update

* update

* update

* Update CameraControlsOverlay.kt

* Update MainActivity.kt

* address comments

* address comments

* Update MainActivity.kt

* Update PreviewViewModel.kt

* address comments

* Update CameraControlsOverlay.kt

* Update PreviewViewModel.kt

* Update PreviewViewModel.kt
  • Loading branch information
davidjiagoogle authored Nov 6, 2024
1 parent 21fdaf1 commit beb030b
Show file tree
Hide file tree
Showing 9 changed files with 242 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import android.net.Uri
import android.os.Environment
import android.provider.MediaStore
import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.isNotDisplayed
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onNodeWithTag
Expand All @@ -36,11 +37,13 @@ import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_SUCCESS_TAG
import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG
import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS
import com.google.jetpackcamera.utils.IMAGE_CAPTURE_TIMEOUT_MILLIS
import com.google.jetpackcamera.utils.MESSAGE_DISAPPEAR_TIMEOUT_MILLIS
import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS
import com.google.jetpackcamera.utils.VIDEO_CAPTURE_TIMEOUT_MILLIS
import com.google.jetpackcamera.utils.deleteFilesInDirAfterTimestamp
import com.google.jetpackcamera.utils.doesImageFileExist
import com.google.jetpackcamera.utils.getIntent
import com.google.jetpackcamera.utils.getMultipleImageCaptureIntent
import com.google.jetpackcamera.utils.getSingleImageCaptureIntent
import com.google.jetpackcamera.utils.getTestUri
import com.google.jetpackcamera.utils.runMediaStoreAutoDeleteScenarioTest
import com.google.jetpackcamera.utils.runScenarioTestForResult
Expand Down Expand Up @@ -86,7 +89,7 @@ internal class ImageCaptureDeviceTest {
val uri = getTestUri(DIR_PATH, timeStamp, "jpg")
val result =
runScenarioTestForResult<MainActivity>(
getIntent(uri, MediaStore.ACTION_IMAGE_CAPTURE)
getSingleImageCaptureIntent(uri, MediaStore.ACTION_IMAGE_CAPTURE)
) {
// Wait for the capture button to be displayed
composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
Expand All @@ -107,7 +110,7 @@ internal class ImageCaptureDeviceTest {
val uri = Uri.parse("asdfasdf")
val result =
runScenarioTestForResult<MainActivity>(
getIntent(uri, MediaStore.ACTION_IMAGE_CAPTURE)
getSingleImageCaptureIntent(uri, MediaStore.ACTION_IMAGE_CAPTURE)
) {
// Wait for the capture button to be displayed
composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
Expand All @@ -132,7 +135,7 @@ internal class ImageCaptureDeviceTest {
val uri = getTestUri(DIR_PATH, timeStamp, "mp4")
val result =
runScenarioTestForResult<MainActivity>(
getIntent(uri, MediaStore.ACTION_IMAGE_CAPTURE)
getSingleImageCaptureIntent(uri, MediaStore.ACTION_IMAGE_CAPTURE)
) {
// Wait for the capture button to be displayed
composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
Expand All @@ -152,6 +155,124 @@ internal class ImageCaptureDeviceTest {
Truth.assertThat(doesImageFileExist(uri, "video")).isFalse()
}

@Test
fun multipleImageCaptureExternal_returnsResultOk() {
val timeStamp = System.currentTimeMillis()
val uriStrings = arrayListOf<String>()
for (i in 1..3) {
val uri = getTestUri(DIR_PATH, timeStamp + i.toLong(), "jpg")
uriStrings.add(uri.toString())
}
val result =
runScenarioTestForResult<MainActivity>(
getMultipleImageCaptureIntent(
uriStrings,
MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA
)
) {
// Wait for the capture button to be displayed
composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
}
repeat(2) {
clickCaptureAndWaitUntilMessageDisappears(
IMAGE_CAPTURE_TIMEOUT_MILLIS,
IMAGE_CAPTURE_SUCCESS_TAG
)
}
clickCapture()
}
Truth.assertThat(result.resultCode).isEqualTo(Activity.RESULT_OK)
for (string in uriStrings) {
Truth.assertThat(doesImageFileExist(Uri.parse(string), "image")).isTrue()
}
deleteFilesInDirAfterTimestamp(DIR_PATH, instrumentation, timeStamp)
}

@Test
fun multipleImageCaptureExternal_withNullUriList_returnsResultOk() {
val timeStamp = System.currentTimeMillis()
val result =
runScenarioTestForResult<MainActivity>(
getMultipleImageCaptureIntent(null, MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA)
) {
// Wait for the capture button to be displayed
composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
}
repeat(2) {
clickCaptureAndWaitUntilMessageDisappears(
IMAGE_CAPTURE_TIMEOUT_MILLIS,
IMAGE_CAPTURE_SUCCESS_TAG
)
}
uiDevice.pressBack()
}
Truth.assertThat(result.resultCode).isEqualTo(Activity.RESULT_OK)
deleteFilesInDirAfterTimestamp(DIR_PATH, instrumentation, timeStamp)
}

@Test
fun multipleImageCaptureExternal_withNullUriList_returnsResultCancel() {
val result =
runScenarioTestForResult<MainActivity>(
getMultipleImageCaptureIntent(null, MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA)
) {
// Wait for the capture button to be displayed
composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
}
uiDevice.pressBack()
}
Truth.assertThat(result.resultCode).isEqualTo(Activity.RESULT_CANCELED)
}

@Test
fun multipleImageCaptureExternal_withIllegalUri_returnsResultOk() {
val timeStamp = System.currentTimeMillis()
val uriStrings = arrayListOf<String>()
uriStrings.add("illegal_uri")
uriStrings.add(getTestUri(DIR_PATH, timeStamp, "jpg").toString())
val result =
runScenarioTestForResult<MainActivity>(
getMultipleImageCaptureIntent(
uriStrings,
MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA
)
) {
// Wait for the capture button to be displayed
composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
}
clickCaptureAndWaitUntilMessageDisappears(
IMAGE_CAPTURE_TIMEOUT_MILLIS,
IMAGE_CAPTURE_FAILURE_TAG
)
clickCapture()
}
Truth.assertThat(result.resultCode).isEqualTo(Activity.RESULT_OK)
Truth.assertThat(doesImageFileExist(Uri.parse(uriStrings[1]), "image")).isTrue()
deleteFilesInDirAfterTimestamp(DIR_PATH, instrumentation, timeStamp)
}

private fun clickCaptureAndWaitUntilMessageDisappears(msgTimeOut: Long, msgTag: String) {
clickCapture()
composeTestRule.waitUntil(timeoutMillis = msgTimeOut) {
composeTestRule.onNodeWithTag(msgTag).isDisplayed()
}
composeTestRule.waitUntil(
timeoutMillis = MESSAGE_DISAPPEAR_TIMEOUT_MILLIS
) {
composeTestRule.onNodeWithTag(msgTag).isNotDisplayed()
}
}

private fun clickCapture() {
composeTestRule.onNodeWithTag(CAPTURE_BUTTON)
.assertExists()
.performClick()
}

companion object {
val DIR_PATH: String =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).path
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS
import com.google.jetpackcamera.utils.VIDEO_CAPTURE_TIMEOUT_MILLIS
import com.google.jetpackcamera.utils.deleteFilesInDirAfterTimestamp
import com.google.jetpackcamera.utils.doesImageFileExist
import com.google.jetpackcamera.utils.getIntent
import com.google.jetpackcamera.utils.getSingleImageCaptureIntent
import com.google.jetpackcamera.utils.getTestUri
import com.google.jetpackcamera.utils.longClickForVideoRecording
import com.google.jetpackcamera.utils.runMediaStoreAutoDeleteScenarioTest
Expand Down Expand Up @@ -80,7 +80,7 @@ internal class VideoRecordingDeviceTest {
val uri = getTestUri(DIR_PATH, timeStamp, "mp4")
val result =
runScenarioTestForResult<MainActivity>(
getIntent(uri, MediaStore.ACTION_VIDEO_CAPTURE)
getSingleImageCaptureIntent(uri, MediaStore.ACTION_VIDEO_CAPTURE)
) {
// Wait for the capture button to be displayed
composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
Expand All @@ -98,7 +98,7 @@ internal class VideoRecordingDeviceTest {
val uri = Uri.parse("asdfasdf")
val result =
runScenarioTestForResult<MainActivity>(
getIntent(uri, MediaStore.ACTION_VIDEO_CAPTURE)
getSingleImageCaptureIntent(uri, MediaStore.ACTION_VIDEO_CAPTURE)
) {
// Wait for the capture button to be displayed
composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
Expand All @@ -120,7 +120,7 @@ internal class VideoRecordingDeviceTest {
val uri = getTestUri(ImageCaptureDeviceTest.DIR_PATH, timeStamp, "mp4")
val result =
runScenarioTestForResult<MainActivity>(
getIntent(uri, MediaStore.ACTION_VIDEO_CAPTURE)
getSingleImageCaptureIntent(uri, MediaStore.ACTION_VIDEO_CAPTURE)
) {
// Wait for the capture button to be displayed
composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ const val APP_START_TIMEOUT_MILLIS = 10_000L
const val IMAGE_CAPTURE_TIMEOUT_MILLIS = 5_000L
const val VIDEO_CAPTURE_TIMEOUT_MILLIS = 5_000L
const val VIDEO_DURATION_MILLIS = 2_000L
const val MESSAGE_DISAPPEAR_TIMEOUT_MILLIS = 10_000L
const val COMPONENT_PACKAGE_NAME = "com.google.jetpackcamera"
const val COMPONENT_CLASS = "com.google.jetpackcamera.MainActivity"
inline fun <reified T : Activity> runMediaStoreAutoDeleteScenarioTest(
mediaUri: Uri,
filePrefix: String = "",
Expand Down Expand Up @@ -186,18 +189,30 @@ fun doesImageFileExist(uri: Uri, prefix: String): Boolean {
return false
}

fun getIntent(uri: Uri, action: String): Intent {
fun getSingleImageCaptureIntent(uri: Uri, action: String): Intent {
val intent = Intent(action)
intent.setComponent(
ComponentName(
"com.google.jetpackcamera",
"com.google.jetpackcamera.MainActivity"
COMPONENT_PACKAGE_NAME,
COMPONENT_CLASS
)
)
intent.putExtra(MediaStore.EXTRA_OUTPUT, uri)
return intent
}

fun getMultipleImageCaptureIntent(uriStrings: ArrayList<String>?, action: String): Intent {
val intent = Intent(action)
intent.setComponent(
ComponentName(
COMPONENT_PACKAGE_NAME,
COMPONENT_CLASS
)
)
intent.putStringArrayListExtra(MediaStore.EXTRA_OUTPUT, uriStrings)
return intent
}

fun stateDescriptionMatches(expected: String?) = SemanticsMatcher("stateDescription is $expected") {
SemanticsProperties.StateDescription in it.config &&
(it.config[SemanticsProperties.StateDescription] == expected)
Expand Down
29 changes: 29 additions & 0 deletions app/src/main/java/com/google/jetpackcamera/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,19 @@ class MainActivity : ComponentActivity() {
) ?: intent?.clipData?.getItemAt(0)?.uri
}

private fun getMultipleExternalCaptureUri(): List<Uri>? {
val stringUris = intent.getStringArrayListExtra(MediaStore.EXTRA_OUTPUT)
if (stringUris.isNullOrEmpty()) {
return null
} else {
val result = mutableListOf<Uri>()
for (string in stringUris) {
result.add(Uri.parse(string))
}
return result
}
}

private fun getPreviewMode(): PreviewMode {
return intent?.action?.let { action ->
when (action) {
Expand Down Expand Up @@ -210,6 +223,22 @@ class MainActivity : ComponentActivity() {
}
}

MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA -> {
val uriList: List<Uri>? = getMultipleExternalCaptureUri()
PreviewMode.ExternalMultipleImageCaptureMode(
uriList
) { event: PreviewViewModel.ImageCaptureEvent, uriIndex: Int ->
Log.d(TAG, "onMultipleImageCapture, event: $event")
if (uriList == null) {
setResult(RESULT_OK, Intent())
} else if (uriList != null && uriIndex == uriList.size - 1) {
setResult(RESULT_OK, Intent())
Log.d(TAG, "onMultipleImageCapture, finish()")
finish()
}
}
}

else -> {
Log.w(TAG, "Ignoring external intent with unknown action.")
getStandardMode()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ sealed interface PreviewMode {
) : PreviewMode

/**
* Under this mode, the app is launched by an external intent to capture an image.
* Under this mode, the app is launched by an external intent to capture one image.
*/
data class ExternalImageCaptureMode(
val imageCaptureUri: Uri?,
Expand All @@ -44,4 +44,12 @@ sealed interface PreviewMode {
val videoCaptureUri: Uri?,
val onVideoCapture: (PreviewViewModel.VideoCaptureEvent) -> Unit
) : PreviewMode

/**
* Under this mode, the app is launched by an external intent to capture multiple images.
*/
data class ExternalMultipleImageCaptureMode(
val imageCaptureUris: List<Uri>?,
val onImageCapture: (PreviewViewModel.ImageCaptureEvent, Int) -> Unit
) : PreviewMode
}
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ private fun ContentScreen(
ContentResolver,
Uri?,
Boolean,
(PreviewViewModel.ImageCaptureEvent) -> Unit
(PreviewViewModel.ImageCaptureEvent, Int) -> Unit
) -> Unit = { _, _, _, _ -> },
onStartVideoRecording: (
Uri?,
Expand Down
Loading

0 comments on commit beb030b

Please # to comment.