Skip to content

Commit

Permalink
Update outdated dependencies (#287)
Browse files Browse the repository at this point in the history
* Add preview for popup composable

 Latest update of Compose added non-transparent color to
 popup content background. This adds a preview to help
 debug and change back to previous behavior.

* Update dependencies

* Remove old unused material dependency

* Add description and sub-text to pop-up preview

* Revert background color for list items to transparent in popups

* Fix build errors and ensure CameraXCameraUseCaseTest passes

* Ensure core:camera instrumented tests run in CI

* Ensure DataStoreModuleTest cleans up properly

* Update CameraX to version from build 12696077

* Update AGP to 8.7.3

* Fix navigation cycle

 Ensure navController isn't called during composition by putting lambda
 that uses it in a LuanchedEffect.

 Use explicit popUpTo()

 These fix an issue that was triggered due to aosp/3010755
  • Loading branch information
temcguir authored Dec 12, 2024
1 parent e457d92 commit 31c1e63
Show file tree
Hide file tree
Showing 15 changed files with 169 additions and 90 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ These tests can be run on a connected device via Android Studio, or can be teste
Emulator using built-in Gradle Managed Device tasks. Currently, we include Pixel 2 (API 28) and
Pixel 8 (API 34) emulators which can be used to run instrumentation tests with:

`$ ./gradlew pixel2Api28DebugAndroidTest` and
`$ ./gradlew pixel8Api34DebugAndroidTest`
`$ ./gradlew pixel2Api28StableDebugAndroidTest` and
`$ ./gradlew pixel8Api34StableDebugAndroidTest`


## Source Code Headers
Expand Down
14 changes: 9 additions & 5 deletions app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,12 @@ private fun JetpackCameraNavHost(
composable(PERMISSIONS_ROUTE) {
PermissionsScreen(
shouldRequestAudioPermission = previewMode is PreviewMode.StandardMode,
onNavigateToPreview = {
onAllPermissionsGranted = {
// Pop off the permissions screen
navController.navigate(PREVIEW_ROUTE) {
// cannot navigate back to permissions after leaving
popUpTo(0)
popUpTo(PERMISSIONS_ROUTE) {
inclusive = true
}
}
},
openAppSettings = onOpenAppSettings
Expand All @@ -95,9 +97,11 @@ private fun JetpackCameraNavHost(
// Automatically navigate to permissions screen when camera permission revoked
LaunchedEffect(key1 = permissionStates.permissions[0].status) {
if (!permissionStates.permissions[0].status.isGranted) {
// Pop off the preview screen
navController.navigate(PERMISSIONS_ROUTE) {
// cannot navigate back to preview
popUpTo(0)
popUpTo(PREVIEW_ROUTE) {
inclusive = true
}
}
}
}
Expand Down
17 changes: 17 additions & 0 deletions core/camera/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,23 @@ android {
}
}

@Suppress("UnstableApiUsage")
testOptions {
managedDevices {
localDevices {
create("pixel2Api28") {
device = "Pixel 2"
apiLevel = 28
}
create("pixel8Api34") {
device = "Pixel 8"
apiLevel = 34
systemImageSource = "aosp_atd"
}
}
}
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import com.google.jetpackcamera.core.camera.CameraUseCase.OnVideoRecordEvent.OnVideoRecordError
import com.google.jetpackcamera.core.camera.CameraUseCase.OnVideoRecordEvent.OnVideoRecordStatus
import com.google.jetpackcamera.core.camera.CameraUseCase.OnVideoRecordEvent.OnVideoRecorded
import com.google.jetpackcamera.core.camera.utils.APP_REQUIRED_PERMISSIONS
import com.google.jetpackcamera.settings.ConstraintsRepository
Expand All @@ -38,6 +37,8 @@ import com.google.jetpackcamera.settings.model.FlashMode
import com.google.jetpackcamera.settings.model.Illuminant
import com.google.jetpackcamera.settings.model.LensFacing
import java.io.File
import kotlin.time.DurationUnit
import kotlin.time.toDuration
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand All @@ -47,8 +48,10 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.produceIn
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.After
import org.junit.Assert.fail
Expand All @@ -63,9 +66,9 @@ import org.junit.runner.RunWith
class CameraXCameraUseCaseTest {

companion object {
private const val STATUS_VERIFY_COUNT = 5
private const val GENERAL_TIMEOUT_MS = 3_000L
private const val STATUS_VERIFY_TIMEOUT_MS = 10_000L
private const val RECORDING_TIMEOUT_MS = 10_000L
private const val RECORDING_START_DURATION_MS = 500L
}

@get:Rule
Expand Down Expand Up @@ -96,16 +99,20 @@ class CameraXCameraUseCaseTest {
cameraUseCase.runCameraOnMain()

// Act.
val recordEvent = cameraUseCase.startRecordingAndGetEvents()

// Assert.
recordEvent.onRecordStatus.await(STATUS_VERIFY_TIMEOUT_MS)
val recordingComplete = CompletableDeferred<Unit>()
cameraUseCase.startRecording {
when (it) {
is OnVideoRecorded -> {
recordingComplete.complete(Unit)
}
is OnVideoRecordError -> recordingComplete.completeExceptionally(it.error)
}
}

// Act.
cameraUseCase.stopVideoRecording()

// Assert.
recordEvent.onRecorded.await()
recordingComplete.await()
}

@Test
Expand All @@ -128,41 +135,43 @@ class CameraXCameraUseCaseTest {
torchEnabled.awaitValue(false)

// Act: Start recording with FlashMode.ON
val recordingComplete = CompletableDeferred<Unit>()
cameraUseCase.setFlashMode(FlashMode.ON)
val recordEvent = cameraUseCase.startRecordingAndGetEvents()
cameraUseCase.startRecording {
when (it) {
is OnVideoRecorded -> {
recordingComplete.complete(Unit)
}
is OnVideoRecordError -> recordingComplete.completeExceptionally(it.error)
}
}

// Assert: Torch enabled transitions to true.
torchEnabled.awaitValue(true)

// Act: Ensure enough data is received and stop recording.
recordEvent.onRecordStatus.await(STATUS_VERIFY_TIMEOUT_MS)
cameraUseCase.stopVideoRecording()

// Assert: Torch enabled transitions to false.
torchEnabled.awaitValue(false)

// Clean-up.
recordingComplete.await()
torchEnabled.cancel()
}

private suspend fun createAndInitCameraXUseCase(
appSettings: CameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS,
constraintsRepository: SettableConstraintsRepository = SettableConstraintsRepositoryImpl()
) = CameraXCameraUseCase(
application,
useCaseScope,
Dispatchers.Default,
constraintsRepository
application = application,
defaultDispatcher = Dispatchers.Default,
iODispatcher = Dispatchers.IO,
constraintsRepository = constraintsRepository
).apply {
initialize(appSettings, CameraUseCase.UseCaseMode.STANDARD) {}
providePreviewSurface()
}

private data class RecordEvents(
val onRecorded: CompletableDeferred<Unit>,
val onRecordStatus: CompletableDeferred<Unit>
)

private suspend fun CompletableDeferred<*>.await(timeoutMs: Long = GENERAL_TIMEOUT_MS) =
withTimeoutOrNull(timeoutMs) {
await()
Expand All @@ -178,31 +187,38 @@ class CameraXCameraUseCaseTest {
}
} ?: fail("Timeout while waiting for expected value: $expectedValue")

private suspend fun CameraXCameraUseCase.startRecordingAndGetEvents(
statusVerifyCount: Int = STATUS_VERIFY_COUNT
): RecordEvents {
val onRecorded = CompletableDeferred<Unit>()
val onRecordStatus = CompletableDeferred<Unit>()
var statusCount = 0
startVideoRecording(null, false) {
when (it) {
is OnVideoRecorded -> {
val videoUri = it.savedUri
if (videoUri != Uri.EMPTY) {
videosToDelete.add(videoUri)
}
onRecorded.complete(Unit)
private suspend fun CameraXCameraUseCase.startRecording(
onVideoRecord: (CameraUseCase.OnVideoRecordEvent) -> Unit
) {
// Start recording
startVideoRecording(
videoCaptureUri = null,
shouldUseUri = false
) { event ->
// Track files that need to be deleted
if (event is OnVideoRecorded) {
val videoUri = event.savedUri
if (videoUri != Uri.EMPTY) {
videosToDelete.add(videoUri)
}
is OnVideoRecordError -> onRecorded.complete(Unit)
is OnVideoRecordStatus -> {
statusCount++
if (statusCount == statusVerifyCount) {
onRecordStatus.complete(Unit)
}
}

// Forward event to provided callback
onVideoRecord(event)
}

// Wait for recording duration to reach start duration to consider it started
withTimeout(RECORDING_TIMEOUT_MS) {
getCurrentCameraState().transform { cameraState ->
(cameraState.videoRecordingState as? VideoRecordingState.Active)?.let {
emit(
it.elapsedTimeNanos.toDuration(DurationUnit.NANOSECONDS).inWholeMilliseconds
)
}
}.first { elapsedTimeMs ->
elapsedTimeMs >= RECORDING_START_DURATION_MS
}
}
return RecordEvents(onRecorded, onRecordStatus)
}

private fun CameraXCameraUseCase.providePreviewSurface() {
Expand All @@ -219,7 +235,7 @@ class CameraXCameraUseCaseTest {
}
}

private suspend fun CameraXCameraUseCase.runCameraOnMain() {
private fun CameraXCameraUseCase.runCameraOnMain() {
useCaseScope.launch(Dispatchers.Main) { runCamera() }
instrumentation.waitForIdleSync()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import androidx.camera.camera2.interop.ExperimentalCamera2Interop
import androidx.camera.core.CameraInfo
import androidx.camera.core.CameraSelector
import androidx.camera.core.DynamicRange as CXDynamicRange
import androidx.camera.core.ExperimentalImageCaptureOutputFormat
import androidx.camera.core.ImageCapture
import androidx.camera.core.Preview
import androidx.camera.core.UseCase
Expand Down Expand Up @@ -77,7 +76,6 @@ val CameraInfo.sensorLandscapeRatio: Float
}
} ?: Float.NaN

@OptIn(ExperimentalImageCaptureOutputFormat::class)
fun Int.toAppImageFormat(): ImageOutputFormat? {
return when (this) {
ImageCapture.OUTPUT_FORMAT_JPEG -> ImageOutputFormat.JPEG
Expand Down Expand Up @@ -120,7 +118,6 @@ fun CameraInfo.filterSupportedFixedFrameRates(desired: Set<Int>): Set<Int> {
}

val CameraInfo.supportedImageFormats: Set<ImageOutputFormat>
@OptIn(ExperimentalImageCaptureOutputFormat::class)
get() = ImageCapture.getImageCaptureCapabilities(this).supportedOutputFormats
.mapNotNull(Int::toAppImageFormat)
.toSet()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import androidx.camera.core.Camera
import androidx.camera.core.CameraControl
import androidx.camera.core.CameraEffect
import androidx.camera.core.CameraInfo
import androidx.camera.core.ExperimentalImageCaptureOutputFormat
import androidx.camera.core.FocusMeteringAction
import androidx.camera.core.ImageCapture
import androidx.camera.core.Preview
Expand Down Expand Up @@ -348,7 +347,6 @@ internal fun createUseCaseGroup(
}.build()
}

@OptIn(ExperimentalImageCaptureOutputFormat::class)
private fun createImageUseCase(
cameraInfo: CameraInfo,
aspectRatio: AspectRatio,
Expand Down Expand Up @@ -719,7 +717,10 @@ private suspend fun startVideoRecordingInternal(
else -> {
onVideoRecord(
CameraUseCase.OnVideoRecordEvent.OnVideoRecordError(
onVideoRecordEvent.cause
RuntimeException(
"Recording finished with error: ${onVideoRecordEvent.error}",
onVideoRecordEvent.cause
)
)
)
currentCameraState.update { old ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ interface CameraUseCase {
sealed interface OnVideoRecordEvent {
data class OnVideoRecorded(val savedUri: Uri) : OnVideoRecordEvent

data class OnVideoRecordError(val error: Throwable?) : OnVideoRecordEvent
data class OnVideoRecordError(val error: Throwable) : OnVideoRecordEvent
}

enum class UseCaseMode {
Expand Down
1 change: 0 additions & 1 deletion core/common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ dependencies {

implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.android.material)
implementation(libs.kotlinx.atomicfu)
implementation(libs.androidx.tracing)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class DataStoreModuleTest {
fun dataStoreModule_read_can_handle_corrupted_file() = runTest {
// should handle exception and replace file information
val dataStore: DataStore<JcaSettings> = FakeDataStoreModule.provideDataStore(
scope = this,
scope = this.backgroundScope,
serializer = FakeJcaSettingsSerializer(failReadWithCorruptionException = true),
file = testFile
)
Expand Down
1 change: 0 additions & 1 deletion feature/permissions/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ dependencies {

implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.android.material)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
Expand All @@ -36,7 +37,7 @@ private const val TAG = "PermissionsScreen"
@Composable
fun PermissionsScreen(
shouldRequestAudioPermission: Boolean,
onNavigateToPreview: () -> Unit,
onAllPermissionsGranted: () -> Unit,
openAppSettings: () -> Unit
) {
val permissionStates = rememberMultiplePermissionsState(
Expand All @@ -53,7 +54,7 @@ fun PermissionsScreen(
)
PermissionsScreen(
permissionStates = permissionStates,
onNavigateToPreview = onNavigateToPreview,
onAllPermissionsGranted = onAllPermissionsGranted,
openAppSettings = openAppSettings
)
}
Expand All @@ -67,7 +68,7 @@ fun PermissionsScreen(
@Composable
fun PermissionsScreen(
modifier: Modifier = Modifier,
onNavigateToPreview: () -> Unit,
onAllPermissionsGranted: () -> Unit,
openAppSettings: () -> Unit,
permissionStates: MultiplePermissionsState,
viewModel: PermissionsViewModel = hiltViewModel<
Expand All @@ -77,6 +78,12 @@ fun PermissionsScreen(
) {
Log.d(TAG, "PermissionsScreen")
val permissionsUiState: PermissionsUiState by viewModel.permissionsUiState.collectAsState()
LaunchedEffect(permissionsUiState) {
if (permissionsUiState is PermissionsUiState.AllPermissionsGranted) {
onAllPermissionsGranted()
}
}

if (permissionsUiState is PermissionsUiState.PermissionsNeeded) {
val permissionEnum =
(permissionsUiState as PermissionsUiState.PermissionsNeeded).currentPermission
Expand Down Expand Up @@ -111,7 +118,5 @@ fun PermissionsScreen(
onRequestPermission = { permissionLauncher.launch(permissionEnum.getPermission()) },
onOpenAppSettings = openAppSettings
)
} else {
onNavigateToPreview()
}
}
Loading

0 comments on commit 31c1e63

Please # to comment.