diff --git a/app/src/androidTest/java/com/google/jetpackcamera/DebugOverlayTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/DebugOverlayTest.kt index 56ec67e2d..fde453ca2 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/DebugOverlayTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/DebugOverlayTest.kt @@ -15,6 +15,7 @@ */ package com.google.jetpackcamera +import android.content.pm.PackageManager import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick @@ -65,10 +66,18 @@ class DebugOverlayTest { @Test fun hideComponentsButton_togglesUiVisibility() { + val pm = InstrumentationRegistry.getInstrumentation().targetContext.packageManager + val hasMultipleCameras = pm.hasSystemFeature(PackageManager.FEATURE_CAMERA_FRONT) && + pm.hasSystemFeature(PackageManager.FEATURE_CAMERA) + runMainActivityScenarioTest(debugExtra) { composeTestRule.waitForCaptureButton() composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertExists() - composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assertExists() + if (hasMultipleCameras) { + composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assertExists() + } else { + composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assertDoesNotExist() + } composeTestRule.onNodeWithTag(DEBUG_OVERLAY_BUTTON).assertExists() composeTestRule.onNodeWithTag(LOGICAL_CAMERA_ID_TAG).assertExists() composeTestRule.onNodeWithTag(PHYSICAL_CAMERA_ID_TAG).assertExists() @@ -88,7 +97,11 @@ class DebugOverlayTest { composeTestRule.onNodeWithTag(BTN_DEBUG_HIDE_COMPONENTS_TAG).performClick() composeTestRule.waitForNodeWithTag(CAPTURE_BUTTON) - composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assertExists() + if (hasMultipleCameras) { + composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assertExists() + } else { + composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assertDoesNotExist() + } composeTestRule.onNodeWithTag(DEBUG_OVERLAY_BUTTON).assertExists() composeTestRule.onNodeWithTag(LOGICAL_CAMERA_ID_TAG).assertExists() composeTestRule.onNodeWithTag(PHYSICAL_CAMERA_ID_TAG).assertExists() diff --git a/app/src/androidTest/java/com/google/jetpackcamera/ExternalAutomationCompatibilityTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/ExternalAutomationCompatibilityTest.kt index 87ad1de13..1b34ab4af 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/ExternalAutomationCompatibilityTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/ExternalAutomationCompatibilityTest.kt @@ -15,6 +15,7 @@ */ package com.google.jetpackcamera +import android.content.pm.PackageManager import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule @@ -72,11 +73,17 @@ class ExternalAutomationCompatibilityTest { .that(captureButton) .isNotNull() - // Verify flip camera button is visible via Resource ID - val flipButton = device.findObject(By.res(FLIP_CAMERA_BUTTON)) - assertWithMessage("Flip camera button not found by UI Automator via Resource ID") - .that(flipButton) - .isNotNull() + val pm = InstrumentationRegistry.getInstrumentation().targetContext.packageManager + val hasMultipleCameras = pm.hasSystemFeature(PackageManager.FEATURE_CAMERA_FRONT) && + pm.hasSystemFeature(PackageManager.FEATURE_CAMERA) + + if (hasMultipleCameras) { + // Verify flip camera button is visible via Resource ID + val flipButton = device.findObject(By.res(FLIP_CAMERA_BUTTON)) + assertWithMessage("Flip camera button not found by UI Automator via Resource ID") + .that(flipButton) + .isNotNull() + } } } diff --git a/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt index a1af65e30..e4a812f76 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt @@ -17,7 +17,6 @@ package com.google.jetpackcamera import android.os.Build import android.provider.MediaStore -import androidx.compose.ui.test.isEnabled import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick @@ -37,7 +36,7 @@ import com.google.jetpackcamera.utils.IMAGE_CAPTURE_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.SCREEN_FLASH_OVERLAY_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS import com.google.jetpackcamera.utils.VIDEO_CAPTURE_TIMEOUT_MILLIS -import com.google.jetpackcamera.utils.assume +import com.google.jetpackcamera.utils.assumeFlipCameraAvailable import com.google.jetpackcamera.utils.getCurrentLensFacing import com.google.jetpackcamera.utils.longClickForVideoRecordingCheckingElapsedTime import com.google.jetpackcamera.utils.runMainActivityMediaStoreAutoDeleteScenarioTest @@ -120,9 +119,10 @@ internal class FlashDeviceTest { // Ensure camera has a back camera and flip to it val lensFacing = composeTestRule.getCurrentLensFacing() if (lensFacing != LensFacing.BACK) { - composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assume(isEnabled()) { + composeTestRule.assumeFlipCameraAvailable( "Device does not have a back camera to flip to." - }.performClick() + ) + composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).performClick() } composeTestRule.setFlashMode(FlashMode.ON) @@ -149,9 +149,10 @@ internal class FlashDeviceTest { // Ensure camera has a front camera and flip to it val lensFacing = composeTestRule.getCurrentLensFacing() if (lensFacing != LensFacing.FRONT) { - composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assume(isEnabled()) { + composeTestRule.assumeFlipCameraAvailable( "Device does not have a front camera to flip to." - }.performClick() + ) + composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).performClick() } composeTestRule.setFlashMode(FlashMode.ON) @@ -190,9 +191,10 @@ internal class FlashDeviceTest { // Ensure camera has the target lens facing camera and flip to it val lensFacing = composeTestRule.getCurrentLensFacing() if (lensFacing != targetLensFacing) { - composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assume(isEnabled()) { + composeTestRule.assumeFlipCameraAvailable( "Device does not have a $targetLensFacing camera to flip to." - }.performClick() + ) + composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).performClick() } composeTestRule.setFlashMode(FlashMode.ON) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt index 6a17a821e..3364f18ca 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt @@ -18,7 +18,6 @@ package com.google.jetpackcamera import androidx.compose.ui.geometry.Offset import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.click -import androidx.compose.ui.test.isEnabled import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick @@ -38,7 +37,7 @@ import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_DROP_DOWN import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_SCRIM import com.google.jetpackcamera.ui.components.capture.SETTINGS_BUTTON import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS -import com.google.jetpackcamera.utils.assume +import com.google.jetpackcamera.utils.assumeFlipCameraAvailable import com.google.jetpackcamera.utils.onNodeWithText import com.google.jetpackcamera.utils.runMainActivityScenarioTest import com.google.jetpackcamera.utils.searchForQuickSetting @@ -102,10 +101,8 @@ class NavigationTest { composeTestRule.waitForCaptureButton() // If flipping the camera is available, flip it. Otherwise skip test. - composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON) - .assume(isEnabled()) { - "Device does not have multiple cameras to flip between." - }.performClick() + composeTestRule.assumeFlipCameraAvailable() + composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).performClick() // open quick settings composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN).assertExists().performClick() @@ -146,7 +143,7 @@ class NavigationTest { // Press the device's back button uiDevice.pressBack() - // Assert we're on PreviewScreen and bottom sheet is closed + // Assert we're on PreviewScreen by verifying bottom sheet is closed and finding the capture button composeTestRule.waitForNodeWithTagToDisappear(QUICK_SETTINGS_BOTTOM_SHEET) composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertIsDisplayed() } diff --git a/app/src/androidTest/java/com/google/jetpackcamera/SingleLensModeTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/SingleLensModeTest.kt index 004dec830..439e1cf98 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/SingleLensModeTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/SingleLensModeTest.kt @@ -17,7 +17,6 @@ package com.google.jetpackcamera import android.content.pm.PackageManager import android.os.Bundle -import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.test.platform.app.InstrumentationRegistry @@ -83,8 +82,8 @@ class SingleLensModeTest(private val lensFacing: String) { // Wait for the capture button to be visible composeTestRule.waitForCaptureButton() - // Assert that the flip camera button is disabled - composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assertIsNotEnabled() + // Assert that the flip camera button does not exist + composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assertDoesNotExist() } } } diff --git a/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt index 11ea5778d..1f42142f2 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt @@ -16,7 +16,6 @@ package com.google.jetpackcamera import androidx.compose.ui.test.doubleClick -import androidx.compose.ui.test.isEnabled import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -31,7 +30,7 @@ import com.google.jetpackcamera.ui.components.capture.FLIP_CAMERA_BUTTON import com.google.jetpackcamera.ui.components.capture.PREVIEW_DISPLAY import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS -import com.google.jetpackcamera.utils.assume +import com.google.jetpackcamera.utils.assumeFlipCameraAvailable import com.google.jetpackcamera.utils.getCurrentLensFacing import com.google.jetpackcamera.utils.runMainActivityScenarioTest import com.google.jetpackcamera.utils.waitForNodeWithTag @@ -111,9 +110,7 @@ inline fun runFlipCameraTest( composeTestRule.waitForNodeWithTag(PREVIEW_DISPLAY, APP_START_TIMEOUT_MILLIS) // If flipping the camera is available, flip it. Otherwise skip test. - composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assume(isEnabled()) { - "Device does not have multiple cameras to flip between." - } + composeTestRule.assumeFlipCameraAvailable() block() } diff --git a/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt b/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt index c8dbdc3b0..88d580bb3 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.printToString import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat +import com.google.common.truth.TruthJUnit.assume import com.google.errorprone.annotations.CanIgnoreReturnValue import com.google.jetpackcamera.model.CaptureMode import com.google.jetpackcamera.model.ConcurrentCameraMode @@ -821,3 +822,10 @@ internal fun buildGeneralErrorMessage( return sb.toString() } + +fun ComposeTestRule.assumeFlipCameraAvailable( + message: String = "Device does not have multiple cameras to flip between." +) { + val isFlipAvailable = onAllNodesWithTag(FLIP_CAMERA_BUTTON).fetchSemanticsNodes().isNotEmpty() + assume().withMessage(message).that(isFlipAvailable).isTrue() +} diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index 0f29ed43b..1b34e119f 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -513,10 +513,7 @@ private fun ContentScreen( modifier = modifier.testTag(FLIP_CAMERA_BUTTON), onClick = onFlipCamera, flipLensUiState = flipLensState.value, - enabledCondition = when (val uiState = flipLensState.value) { - is FlipLensUiState.Available -> uiState.availableLensFacings.size > 1 - FlipLensUiState.Unavailable -> false - } + enabledCondition = flipLensState.value is FlipLensUiState.Available ) } } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraControlBackgroundStyle.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraControlBackgroundStyle.kt index cdc922725..0cf7f4fbb 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraControlBackgroundStyle.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraControlBackgroundStyle.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color /** * Defines the background style variants for camera controls to maintain visual @@ -29,6 +30,17 @@ internal enum class CameraControlBackgroundStyle { WHITE_20 } +internal val CameraControlBackgroundStyle.containerColor: Color + get() = when (this) { + CameraControlBackgroundStyle.BLACK_60 -> Color.Black.copy(alpha = 0.6f) + CameraControlBackgroundStyle.WHITE_20 -> Color.White.copy(alpha = 0.2f) + } + +internal val CameraControlBackgroundStyle.disabledContainerColor: Color + get() = when (this) { + CameraControlBackgroundStyle.BLACK_60 -> Color.Black.copy(alpha = 0.2f) + CameraControlBackgroundStyle.WHITE_20 -> Color.White.copy(alpha = 0.05f) + } private val DefaultOverlapTargetBounds = mutableStateOf(Rect.Zero) /** diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt index e0d41551f..1626d49b7 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt @@ -240,7 +240,11 @@ private fun VerticalMaterialControls( .height(120.dp), contentAlignment = Alignment.Center ) { - flipCameraButton(Modifier) + OverlapAwareStyleProvider( + overlapThreshold = 0.5f + ) { + flipCameraButton(Modifier) + } } } } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt index 3a6248832..a26016403 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt @@ -66,9 +66,9 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledIconToggleButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme @@ -115,6 +115,7 @@ import androidx.compose.ui.unit.round import androidx.compose.ui.unit.sp import com.google.jetpackcamera.core.camera.VideoRecordingState import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.LensFacing import com.google.jetpackcamera.model.StabilizationMode import com.google.jetpackcamera.model.VideoQuality import com.google.jetpackcamera.ui.controller.SnackBarController @@ -909,10 +910,18 @@ fun FlipCameraButton( initialLaunch = true } } - IconButton( - modifier = modifier, + val backgroundStyle = LocalCameraControlBackgroundStyle.current + FilledIconButton( + modifier = modifier.size(56.dp), onClick = onClick, - enabled = enabledCondition + enabled = enabledCondition, + shape = RoundedCornerShape(12.dp), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = backgroundStyle.containerColor, + contentColor = Color.White, + disabledContainerColor = backgroundStyle.disabledContainerColor, + disabledContentColor = Color.White.copy(alpha = 0.38f) + ) ) { val contentDescription = when (flipLensUiState.selectedLensFacing) { com.google.jetpackcamera.model.LensFacing.FRONT -> stringResource( @@ -926,7 +935,7 @@ fun FlipCameraButton( painter = painterResource(R.drawable.ic_flip_camera_android), contentDescription = contentDescription, modifier = Modifier - .size(IconButtonDefaults.extraLargeIconSize) + .size(26.dp) .rotate(animatedRotation.value) ) } @@ -1175,3 +1184,80 @@ private fun ElapsedTimeTextPreview() { } } } + +@Preview(name = "Flip Camera Button Previews") +@Composable +private fun PreviewFlipCameraButton() { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Dark Background + Box( + modifier = Modifier + .background(Color.Black) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + FlipCameraButton( + enabledCondition = true, + flipLensUiState = FlipLensUiState.Available( + selectedLensFacing = LensFacing.BACK, + availableLensFacings = listOf( + SingleSelectableUiState.SelectableUi(LensFacing.BACK), + SingleSelectableUiState.SelectableUi(LensFacing.FRONT) + ) + ), + onClick = {} + ) + } + + // Light Gray Background + Box( + modifier = Modifier + .background(Color.DarkGray) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CompositionLocalProvider( + LocalCameraControlBackgroundStyle provides CameraControlBackgroundStyle.BLACK_60 + ) { + FlipCameraButton( + enabledCondition = true, + flipLensUiState = FlipLensUiState.Available( + selectedLensFacing = LensFacing.BACK, + availableLensFacings = listOf( + SingleSelectableUiState.SelectableUi(LensFacing.BACK), + SingleSelectableUiState.SelectableUi(LensFacing.FRONT) + ) + ), + onClick = {} + ) + } + } + + // White Background + Box( + modifier = Modifier + .background(Color.LightGray) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CompositionLocalProvider( + LocalCameraControlBackgroundStyle provides CameraControlBackgroundStyle.BLACK_60 + ) { + FlipCameraButton( + enabledCondition = true, + flipLensUiState = FlipLensUiState.Available( + selectedLensFacing = LensFacing.BACK, + availableLensFacings = listOf( + SingleSelectableUiState.SelectableUi(LensFacing.BACK), + SingleSelectableUiState.SelectableUi(LensFacing.FRONT) + ) + ), + onClick = {} + ) + } + } + } +} diff --git a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/FlipLensUiStateAdapter.kt b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/FlipLensUiStateAdapter.kt index a9a232d8d..21a189de2 100644 --- a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/FlipLensUiStateAdapter.kt +++ b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/FlipLensUiStateAdapter.kt @@ -52,6 +52,10 @@ fun FlipLensUiState.Companion.from( ORDERED_UI_SUPPORTED_LENS_FACINGS ) + if (availableLensFacings.size <= 1) { + return FlipLensUiState.Unavailable + } + return FlipLensUiState.Available( selectedLensFacing = cameraAppSettings.cameraLensFacing, availableLensFacings = availableLensFacings