diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md index dd76e4e19..3a5b03c4a 100644 --- a/.gemini/styleguide.md +++ b/.gemini/styleguide.md @@ -30,6 +30,7 @@ When reviewing a pull request, focus on the following key areas: * Scan for inefficient operations, especially within Composable functions (e.g., expensive calculations, improper state management leading to excessive recompositions). * Analyze camera configurations and use cases for potential performance bottlenecks. * Ensure coroutines and asynchronous operations are used efficiently. + * **State Conflation in Adapters:** High-frequency stream data (e.g., nanosecond timestamps) should be rounded or conflated at the `UiStateAdapter` level before reaching the UI state, to avoid unnecessary recompositions. [Introduced in PR #514] 4. **Jetpack Compose & CameraX Usage** * Verify that Compose and CameraX APIs are used correctly and effectively. diff --git a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/compound/CaptureUiStateAdapter.kt b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/compound/CaptureUiStateAdapter.kt index 25a964024..116600839 100644 --- a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/compound/CaptureUiStateAdapter.kt +++ b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/compound/CaptureUiStateAdapter.kt @@ -16,6 +16,7 @@ package com.google.jetpackcamera.ui.uistateadapter.capture.compound import com.google.jetpackcamera.core.camera.CameraSystem +import com.google.jetpackcamera.core.camera.VideoRecordingState import com.google.jetpackcamera.model.ExternalCaptureMode import com.google.jetpackcamera.settings.ConstraintsRepository import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState @@ -39,6 +40,7 @@ import com.google.jetpackcamera.ui.uistate.capture.compound.PreviewDisplayUiStat import com.google.jetpackcamera.ui.uistate.capture.compound.QuickSettingsUiState import com.google.jetpackcamera.ui.uistateadapter.capture.from import com.google.jetpackcamera.ui.uistateadapter.capture.updateFrom +import java.util.concurrent.TimeUnit import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine @@ -56,6 +58,7 @@ import kotlinx.coroutines.flow.filterNotNull * needs to be tracked across recompositions (e.g., whether quick settings is open). * @param externalCaptureMode The [ExternalCaptureMode] influencing UI behavior based on how the * camera is launched (e.g., from an external intent). + * @param timePrecision The precision to use for rounding the elapsed time of video recording. * * @return A [Flow] that emits a new [CaptureUiState] whenever any of its underlying * data sources change. @@ -64,7 +67,8 @@ fun captureUiState( cameraSystem: CameraSystem, constraintsRepository: ConstraintsRepository, trackedCaptureUiState: MutableStateFlow, - externalCaptureMode: ExternalCaptureMode + externalCaptureMode: ExternalCaptureMode, + timePrecision: TimeUnit = TimeUnit.SECONDS ): Flow { var flashModeUiState: FlashModeUiState? = null var focusMeteringUiState: FocusMeteringUiState? = null @@ -75,6 +79,11 @@ fun captureUiState( cameraSystem.getCurrentCameraState(), trackedCaptureUiState ) { cameraAppSettings, systemConstraints, cameraState, trackedUiState -> + val videoRecordingState = cameraState.videoRecordingState + val roundedVideoRecordingState = + roundVideoRecordingState(videoRecordingState, timePrecision) + val roundedCameraState = cameraState.copy(videoRecordingState = roundedVideoRecordingState) + val captureModeUiState = CaptureModeUiState.from( systemConstraints, cameraAppSettings, @@ -95,20 +104,19 @@ fun captureUiState( it?.updateFrom( cameraAppSettings = cameraAppSettings, systemConstraints = systemConstraints, - cameraState = cameraState + cameraState = roundedCameraState ) ?: FlashModeUiState.from(cameraAppSettings, systemConstraints) } focusMeteringUiState = focusMeteringUiState.let { it?.updateFrom( - cameraState = cameraState + cameraState = roundedCameraState ) - ?: FocusMeteringUiState.from(cameraState) + ?: FocusMeteringUiState.from(roundedCameraState) } - CaptureUiState.Ready( externalCaptureMode = externalCaptureMode, - videoRecordingState = cameraState.videoRecordingState, + videoRecordingState = roundedVideoRecordingState, flipLensUiState = flipLensUiState, aspectRatioUiState = aspectRatioUiState, previewDisplayUiState = PreviewDisplayUiState( @@ -129,47 +137,69 @@ fun captureUiState( trackedUiState.focusedQuickSetting, externalCaptureMode ), - sessionFirstFrameTimestamp = cameraState.sessionFirstFrameTimestamp, + sessionFirstFrameTimestamp = roundedCameraState.sessionFirstFrameTimestamp, stabilizationUiState = StabilizationUiState.from( cameraAppSettings, - cameraState + roundedCameraState ), flashModeUiState = flashModeUiState, - videoQuality = cameraState.videoQualityInfo.quality, + videoQuality = roundedCameraState.videoQualityInfo.quality, audioUiState = AudioUiState.from( cameraAppSettings, - cameraState + roundedCameraState ), - elapsedTimeUiState = ElapsedTimeUiState.from(cameraState), + elapsedTimeUiState = ElapsedTimeUiState.from(roundedCameraState), captureButtonUiState = CaptureButtonUiState.from( cameraAppSettings, - cameraState, + roundedCameraState, trackedUiState.isRecordingLocked ), zoomUiState = ZoomUiState.from( systemConstraints, cameraAppSettings.cameraLensFacing, - cameraState + roundedCameraState ), zoomControlUiState = ZoomControlUiState.from( trackedUiState.zoomAnimationTarget, systemConstraints, cameraAppSettings, - cameraState + roundedCameraState ), captureModeToggleUiState = CaptureModeToggleUiState.from( systemConstraints, cameraAppSettings, - cameraState, + roundedCameraState, externalCaptureMode ), hdrUiState = hdrUiState, focusMeteringUiState = focusMeteringUiState, imageWellUiState = ImageWellUiState.from( trackedUiState.recentCapturedMedia, - cameraState.videoRecordingState + roundedVideoRecordingState ), screenFlashUiState = ScreenFlashUiState.from(trackedUiState) ) } } + +/** + * Rounds the elapsed time of a [VideoRecordingState] to the given [timePrecision] to reduce UI recomposition frequency. + */ +internal fun roundVideoRecordingState( + videoRecordingState: VideoRecordingState, + timePrecision: TimeUnit +): VideoRecordingState { + if (videoRecordingState !is VideoRecordingState.Active) return videoRecordingState + + val stepNanos = timePrecision.toNanos(1) + val roundedNanos = (videoRecordingState.elapsedTimeNanos / stepNanos) * stepNanos + + return when (videoRecordingState) { + is VideoRecordingState.Active.Recording -> videoRecordingState.copy( + elapsedTimeNanos = roundedNanos + ) + is VideoRecordingState.Active.Paused -> videoRecordingState.copy( + elapsedTimeNanos = roundedNanos + ) + } +} diff --git a/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureUiStateAdapterTest.kt b/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureUiStateAdapterTest.kt new file mode 100644 index 000000000..04a2bcf59 --- /dev/null +++ b/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureUiStateAdapterTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * 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 com.google.jetpackcamera.ui.uistateadapter.capture + +import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.core.camera.VideoRecordingState +import com.google.jetpackcamera.ui.uistateadapter.capture.compound.roundVideoRecordingState +import java.util.concurrent.TimeUnit +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class CaptureUiStateAdapterTest { + + @Test + fun roundVideoRecordingState_nanoseconds_noRounding() { + val state = VideoRecordingState.Active.Recording(0L, 0.0, 1234567890L) + val rounded = roundVideoRecordingState(state, TimeUnit.NANOSECONDS) + assertThat((rounded as VideoRecordingState.Active).elapsedTimeNanos).isEqualTo(1234567890L) + } + + @Test + fun roundVideoRecordingState_milliseconds_roundsToMillis() { + val state = VideoRecordingState.Active.Recording(0L, 0.0, 1234567890L) + val rounded = roundVideoRecordingState(state, TimeUnit.MILLISECONDS) + assertThat((rounded as VideoRecordingState.Active).elapsedTimeNanos).isEqualTo(1234000000L) + } + + @Test + fun roundVideoRecordingState_seconds_roundsToSeconds() { + val state = VideoRecordingState.Active.Recording(0L, 0.0, 1234567890L) + val rounded = roundVideoRecordingState(state, TimeUnit.SECONDS) + assertThat((rounded as VideoRecordingState.Active).elapsedTimeNanos).isEqualTo(1000000000L) + } + + @Test + fun roundVideoRecordingState_pausedState_roundsToSeconds() { + val state = VideoRecordingState.Active.Paused(0L, 0.0, 1234567890L) + val rounded = roundVideoRecordingState(state, TimeUnit.SECONDS) + assertThat((rounded as VideoRecordingState.Active).elapsedTimeNanos).isEqualTo(1000000000L) + } +}