Modern Android camera. Jetpack Compose + CameraX. Live Dual-Camera composite · Live Draggable Primary/PiP Swap Mid-Recording (GLES + MediaCodec) · app-private gallery.
📸 Photo ZERO_SHUTTER_LAG |
🎥 Video HIGHEST / HD dual |
🔀 Live dual-camera one MP4 | ♻️ Mid-record PiP swap no split |
| 🎨 5 composite layouts | 🖐️ Draggable PiP off recompose path | 🖼️ Private gallery prefix-filtered | 🔐 Queue-based perms auto-advance |
Single camera — flash, zoom presets, lens flip via cameraControl
|
Draggable PiP — pinch 20–55% canvas width, snap-to-corner on release |
Tap the preview to cycle DualLayout. Each variant is a branch in the GL shader.
![]() PIP_RECT
|
![]() PIP_CIRCLE
|
![]() SPLIT_VERTICAL
|
![]() SPLIT_HORIZONTAL
|
![]() REACTION_STRIP
|
Layout cycling blocked mid-recording — encoder canvas is dim-locked at record-start.
Single-Activity Compose app. MainActivity → PoppTheme → permission gate → MainContent (Navigation3).
com.debanshu777.popp
├── data/{camera,media} — repositories + MediaStoreGateway
├── di/ — Hilt modules + dispatcher qualifiers
├── navigation/ — Navigation3 destinations
└── ui/features/{camera,gallery,permissions}
Per-action use cases. ViewModels orchestrate UI only. Hardware state lives in CameraSessionRepository (StateFlow<CameraSessionState>). @ApplicationContext injected in exactly two places: DefaultCameraProviderRepository, MediaStoreGateway.
📷 Live dual-camera swap — GLES + MediaCodec composite pipeline
ConcurrentCamera + CompositionSettings is bind-time only — any layered solution that re-binds to swap PiP kills the encoder and splits the MP4. Popp composites itself.
back cam ──→ SurfaceTexture(back) ─┐
├─→ GL composite shader ─→ shared EGL ┬─→ SurfaceView (preview)
front cam ─→ SurfaceTexture(front) ┘ (reads dualBackPrimary) └─→ MediaCodec ─→ MediaMuxer ─→ MP4
- Two
Previewuse cases, oneSurfaceTextureeach, bound viaConcurrentCamera.bindToLifecycle(listOf(...)). HandlerThread-backed renderer owns EGL14 context with twoeglSurfaces (preview + encoder) sharing the same context.- Primary/PiP = one
@Volatile var backPrimary: Boolean. Flip → next frame swaps texture roles. No rebind. - Encoder PTS via
eglPresentationTimeANDROID(...)from the back camera'sSurfaceTexture.getTimestamp(), rebased to start at 0. - Encoder dims computed at record-start from viewport pixel aspect, capped 1280 long edge, rounded DOWN to 16-multiple.
orientationHint = 0.
Rotation gotcha — CameraX's
SurfaceTexture.getTransformMatrix(...)already bakes display rotation. StackingMatrix.rotateM(...)on top → 90° tilted preview. Tex matrix order:ST · mirror · crop. No rotation.
Deep dive → docs/06-live-dual-swap.md
🏗️ Use-case architecture — per-action use cases, repositories, sealed Result types
- Use cases own threading. Blocking work wrapped in
withContext(@IoDispatcher). CameraX binds onDispatchers.Main.immediate. CameraSessionRepositoryis a singletonStateFlow<CameraSessionState>holding liveCamera,ImageCapture,VideoCapture,SurfaceRequest,Recording,DualCameraSession,DualVideoEncoder.Start/Stop/ToggleVideoRecording/ToggleDualRecordinguse cases return sealedResultunions. VMwhen-switches once, pushes snackbar + UiState.- Use cases never inject raw
Context. Writes go throughMediaStoreGateway, reads throughMediaRepository.
Deep dive → docs/07-usecase-architecture.md
🖼️ Camera preview + capture — SurfaceRequest, MediaStore, prefix contract
CameraXViewfinder (from androidx.camera.compose) consumes a SurfaceRequest directly — no AndroidView, no SurfaceHolder callbacks.
LifecycleStartEffect(useFront, useDualCamera, dualBackPrimary) {
viewModel.startCamera(lifecycleOwner)
onStopOrDispose { viewModel.stopCamera() }
}Photo↔Video is not a rebind key — both use cases are pre-bound in single mode.
All writes go through MediaStore → DCIM/Popp/ → prefixed filenames. Same prefix is the read-side LIKE selection. App-private gallery, scoped-storage compliant.
Deep dives → 01-camera-preview, 02-photo-and-video-capture
🔐 Permissions — sealed enum + queue, auto-advancing UI
Three layers:
- Activity gate — main UI composes only when
MultiplePermissionsState.allPermissionsGranted. - Sealed
PermissionEnum— each branch owns manifest string, icon, strings, optionality flag. - One-screen queue —
PermissionsViewModelwalks queue in order. Optional perms (RECORD_AUDIO) drop on first decline; required perms stay until granted or settings deep-link.
SDK split (TIRAMISU+) for READ_MEDIA_IMAGES / READ_MEDIA_VIDEO vs READ_EXTERNAL_STORAGE lives in two places — MainActivity.requiredPermissions and PermissionEnum.getRequiredPermissions() — keep them in sync.
Deep dive → docs/05-permissions.md
./gradlew assembleDebug # Build debug APK
./gradlew installDebug # Install on device/emulator
./gradlew lint # Android lint
./gradlew test # JVM unit tests
./gradlew connectedDebugAndroidTest # Instrumented testsDevice requirements
| Feature | Requirement |
|---|---|
| Single-camera + gallery | API 24+ |
| Dual-camera recording | API 26+ (MediaMuxer(FileDescriptor), IS_PENDING are 26+/29+) |
| Dual-camera button | PackageManager.FEATURE_CAMERA_CONCURRENT + back+front pair in availableConcurrentCameraInfos |
| # | Doc | Topic |
|---|---|---|
| 01 | Camera preview pipeline | SurfaceRequest ↔ CameraXViewfinder, lifecycle keys |
| 02 | Photo & video capture | MediaStore writes, prefix contract |
| 03 | Concurrent (dual) camera | Legacy ConcurrentCamera (historical) |
| 04 | Recording the dual feed | Legacy CompositionSettings recording (historical) |
| 05 | Permissions | Accompanist + sealed-enum queue |
| 06 | Live dual-camera swap | GLES + MediaCodec composite pipeline |
| 07 | Use-case architecture | Per-action use cases, repositories, threading |









