Skip to content

Move concurrent (dual) camera into an optional capture sub-mode module - #608

Open
temcguir wants to merge 83 commits into
temcguir/feature-policy-video-settingsfrom
temcguir/concurrent-camera-submode
Open

temcguir wants to merge 83 commits into
temcguir/feature-policy-video-settingsfrom
temcguir/concurrent-camera-submode

Conversation

@temcguir

@temcguir temcguir commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Part of the capture sub-mode stack: #598 ← #599 ← #607 ← this PR ← #609.

Architecture

image

Amber hexagon: changed in this PR. Blue box: unchanged. Purple: the CaptureSubModeProvider interface. Dashed arrow: implements. A module labeled with a PR number is added in that PR.

Summary

Concurrent camera becomes a capture sub-mode provided by a new optional module, :core:camera:concurrent (with DI in concurrent-di). Apps that don't include the module don't depend on concurrent camera code.

Changes

  • New modules.
    • ConcurrentCameraSubModeProvider supplies the "Dual" sub-mode under video capture.
    • It is supported only on devices that report concurrent cameras.
    • It runs its own session through CameraSessionBinding.Custom.
  • Dual policy. Locks the settings the dual session does not support:
    • SDR, stabilization off, and frame rate auto.
    • Flash limited to off/on/auto (no low-light boost).
    • Camera effect set to none.
    • Video quality set to unspecified, which is the quality the concurrent session records with.
  • Effects. An effect selected before entering Dual is turned off while Dual is active and restored when it ends. Selecting an effect no longer blocks Dual.
  • Sessions. CameraXCameraSystem dispatches CameraSessionBinding.Custom generically. The concurrent session rebinds when the primary lens changes. A missing camera pair, a failed bind, or a stream configuration error reports a sub-mode error, so the camera switches back to a single camera and the preview shows an error snackbar. A removed camera is still reported as a camera error.
  • Internal API. Session helpers used by sub-mode modules are marked @InternalCameraApi and require opt-in. Public helpers are CameraSessionContext extensions (applyDeviceRotation, createVideoUseCase, attachPreviewSurfaceProvider, mapCameraStateError), so no public extensions are declared on CameraX types. The CameraX state error mapping stays internal.
  • Settings screen. The settings that Dual locks are disabled with the sub-mode rationale. The dedicated concurrent-camera UI state, rationale, tags, and strings are removed.

Breaking changes (library API)

  • Removed:
    • ConcurrentCameraMode, its mappers, and its proto enum. The proto field number and name are reserved.
    • CameraAppSettings.concurrentCameraMode
    • CameraSystem.setConcurrentCameraMode
    • SettingsRepository.updateConcurrentCameraMode and SettingsDataSource.updateConcurrentCameraMode
    • CameraSystemConstraints.concurrentCamerasSupported
  • Migration: apps that used these should include :core:camera:concurrent and concurrent-di, then select the Dual sub-mode.

Testing

  • Unit: ConcurrentCameraSubModeProviderTest (including the video quality lock), settings and UI state adapter tests, CameraErrorMappingTest, ModelMappersTest.
  • On device:
    • ConcurrentCameraSessionTest, including the sub-mode error reported when no camera pair is available or binding fails.
    • ConcurrentCameraTest, which skips on devices without concurrent cameras. It covers lens flip keeping Dual selected, the Settings rationale shown during Dual and cleared after, and recording in Dual with the single-stream effect selected: the effect is turned off during Dual and restored after.
    • CaptureModeSettingsTest.

…mode

- Widen 11 session helper declarations in :core:camera with @InternalCameraApi so custom session runners can execute outside :core:camera.
- Replace PerpetualSessionSettings.ConcurrentCamera with generic CustomSession dispatched through CameraSessionBinding.Custom.
- Implement ConcurrentCameraSubModeProvider as a VIDEO_ONLY capture sub-mode with a declarative CameraFeaturePolicy contributed via @IntoSet in ConcurrentCameraSubModeModule.
- Remove hardcoded ConcurrentCameraMode branches from CameraXCameraSystem, :feature:settings, and :ui:uistateadapter:capture.
- Remove the concurrent camera setting completely: delete the ConcurrentCameraMode model and proto, reserve its settings proto field, and drop it from the settings data sources, repositories, settings screen, and strings. Concurrent camera is now selected only through the capture mode carousel.
- Selecting a capture sub-mode from STANDARD adopts the sub-mode's parent capture mode, so that, e.g., dual camera can be selected without first switching to video.
- Update instrumented tests to select concurrent camera through the carousel, and determine the current capture mode from the image/video toggle when a sub-mode replaces the quick settings title.
…rent-camera-submode

# Conflicts:
#	core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt
…ode-selector

# Conflicts:
#	core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt
#	core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSystem.kt
#	core/camera/src/main/java/com/google/jetpackcamera/core/camera/ConcurrentCameraSession.kt
#	core/camera/testing/src/main/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystem.kt
#	ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt
…rent-camera-submode

# Conflicts:
#	core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt
#	core/camera/src/main/java/com/google/jetpackcamera/core/camera/ConcurrentCameraSession.kt
…rent-camera-submode

# Conflicts:
#	core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt
#	core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSystem.kt
#	core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt
#	core/camera/src/main/java/com/google/jetpackcamera/core/camera/ConcurrentCameraSession.kt
#	core/camera/testing/src/main/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystem.kt
…rent-camera-submode

# Conflicts:
#	app/src/androidTest/java/com/google/jetpackcamera/CaptureModeSettingsTest.kt
…rent-camera-submode

# Conflicts:
#	core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt
#	core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionSettings.kt
#	core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the concurrent camera mode from a top-level setting into a modular capture sub-mode (concurrent_dual), introducing the :core:camera:concurrent module and updating CameraXCameraSystem to support custom sessions. The feedback suggests simplifying the camera info lookup in ConcurrentCameraSession using Kotlin standard library functions, extracting literal scales and offsets into named constants to avoid magic numbers, and using require with type checks in ConcurrentCameraSubModeProvider to leverage Kotlin's smart casting.

…oncurrent-camera-submode

# Conflicts:
#	core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt
A missing concurrent camera pair, a failed bind, or a stream configuration
error while Dual is active now reports a capture sub-mode error instead of
a camera error, so the camera system switches back to a single camera and
the preview shows the sub-mode error snackbar. A removed camera is still
reported as a camera error.
- The Dual policy locks the video quality to the unspecified quality
  that the concurrent session records with, and restores it on exit.
- Add an app test that selects the single-stream effect, records in Dual
  with the effect turned off, and checks the effect is restored after
  leaving Dual.
- Generalize the sub-mode selection test helper so other sub-modes can
  use it.
…ttings' into temcguir/concurrent-camera-submode
@temcguir
temcguir requested a review from Kimblebee October 19, 2026 18:45
…ttings' into temcguir/concurrent-camera-submode
…oncurrent-camera-submode

# Conflicts:
#	feature/settings/src/main/res/values/strings.xml
#	ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/DisabledReason.kt
#	ui/uistateadapter/capture/src/main/res/values/strings.xml
…oncurrent-camera-submode

# Conflicts:
#	core/settings/datastore-prefs/src/main/java/com/google/jetpackcamera/core/settings/datastoreprefs/PreferenceKeys.kt
#	core/settings/datastore-prefs/src/main/java/com/google/jetpackcamera/core/settings/datastoreprefs/PrefsDataStoreSettingsDataSource.kt
#	feature/settings/src/test/java/com/google/jetpackcamera/settings/CameraAppSettingsViewModelTest.kt
Comment on lines 895 to 897
old?.copy(
cameraLensFacing = lensFacing,
activeCaptureSubModeId = CaptureSubModeId.DEFAULT

@Kimblebee Kimblebee Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resetting activeCaptureSubModeId = CaptureSubModeId.DEFAULT in setLensFacing makes isContinuingSameProvider (activeCaptureSubModeId == requestedSubModeId) always false on lens flips. When flipping BACK -> FRONT in Dual mode, tryApplyCaptureSubModeConstraints() calls restoreCaptureSubModeOverrides() against FRONT constraints and recreates PolicyOverrides, permanently wiping out rear-only pre-sub-mode settings (UHD, HLG10, 60 fps, OPTICAL) and requested = true flags.

Since restoreCaptureSubModeOverrides() already resets activeCaptureSubModeId = DEFAULT whenever a sub-mode isn't supported on the new lens, we can keep activeCaptureSubModeId intact here:

                old?.copy(
                    cameraLensFacing = lensFacing
                )

Comment on lines +209 to +234
.collectLatest { (primaryState, secondaryState) ->
val mappedError = (primaryState.error ?: secondaryState.error)
?.let { mapCameraStateError(it) }
if (mappedError == CameraError.StreamConfigError) {
reportSubModeFailure(
sessionSettings,
IllegalStateException(
"Concurrent stream configuration failed"
)
)
return@collectLatest
}
val bothOpen = primaryState.type == CXCameraState.Type.OPEN &&
secondaryState.type == CXCameraState.Type.OPEN
currentCameraState.update { old ->
old.copy(
isCameraRunning =
primaryState.type == CXCameraState.Type.OPEN,
cameraError = when {
mappedError != null -> mappedError
bothOpen -> null
else -> old.cameraError
}
)
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues in this state collector:

  1. On ERROR_STREAM_CONFIG, combine(primaryState, secondaryState) emits across OPENING -> CLOSING -> CLOSED. Because return@collectLatest only exits the current emission and each new IllegalStateException uses reference equality, this may emit multiple distinct CaptureSubModeState.Error states and queues duplicate snackbars. Guarding with if (currentCameraState.value.captureSubModeState !is CaptureSubModeState.Error) (like in CameraSession.kt line 374) prevents duplicates.
  2. When bothOpen is true, this should also reset captureSubModeState = CaptureSubModeState.Idle (matching CameraSession.kt line 389).

…oncurrent-camera-submode

# Conflicts:
#	core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt
#	core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionSettings.kt
#	core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt
#	core/camera/src/main/java/com/google/jetpackcamera/core/camera/ConcurrentCameraSession.kt
#	core/model-proto/src/test/java/com/google/jetpackcamera/model/proto/ModelMappersTest.kt
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants