Jetpack Compose and CameraX for Video

Integrating the camera into Android applications is a common requirement, and Jetpack Compose, with its declarative UI paradigm, can streamline this process. Paired with CameraX, Android’s Jetpack library for camera support, you can efficiently implement video recording functionalities in your Compose apps. This blog post will guide you through the process of using Jetpack Compose with CameraX to create a video recording application.

What is CameraX?

CameraX is a Jetpack support library that simplifies camera development. It provides a consistent and easy-to-use API, working across various Android device configurations. CameraX supports functionalities like preview, image analysis, image capture, and video capture.

Why Use CameraX with Jetpack Compose?

  • Simplified Camera Implementation: CameraX handles the complexities of camera implementation across different devices.
  • Declarative UI with Compose: Jetpack Compose allows you to create a modern, declarative UI for your camera application.
  • Lifecycle Integration: CameraX works seamlessly with Android’s lifecycle components, reducing boilerplate code.

How to Implement Video Recording with Jetpack Compose and CameraX

Let’s walk through the process of creating a simple video recording application using Jetpack Compose and CameraX.

Step 1: Add Dependencies

First, add the necessary dependencies to your build.gradle file:

dependencies {
    implementation("androidx.core:core-ktx:1.10.1")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
    implementation("androidx.activity:activity-compose:1.8.1")
    implementation(platform("androidx.compose:compose-bom:2023.03.00"))
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-graphics")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.compose.material3:material3")

    // CameraX dependencies
    implementation("androidx.camera:camera-core:1.3.0")
    implementation("androidx.camera:camera-camera2:1.3.0")
    implementation("androidx.camera:camera-lifecycle:1.3.0")
    implementation("androidx.camera:camera-video:1.3.0") // Required for video capture
    implementation("androidx.camera:camera-view:1.3.0")

    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
    androidTestImplementation(platform("androidx.compose:compose-bom:2023.03.00"))
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
    debugImplementation("androidx.compose.ui:ui-tooling")
    debugImplementation("androidx.compose.ui:ui-test-manifest")
}

Sync your Gradle files to apply the changes.

Step 2: Permissions

Add the necessary permissions to your AndroidManifest.xml:





Request the permissions in your Activity. Here’s a helper function:

private val REQUIRED_PERMISSIONS = mutableListOf (
    Manifest.permission.CAMERA,
    Manifest.permission.RECORD_AUDIO
).apply {
    if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
        add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
    }
}.toTypedArray()

fun Context.hasPermissions(permissions: Array): Boolean {
    return permissions.all {
        ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
    }
}

You can request the permission when the activity is created:


private lateinit var activityResultLauncher: ActivityResultLauncher>

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    activityResultLauncher =
        registerForActivityResult(
            ActivityResultContracts.RequestMultiplePermissions()
        ) { permissions ->
            // Handle Permission grant/revoke
            var permissionGranted = true
            permissions.entries.forEach {
                if (it.key in REQUIRED_PERMISSIONS && it.value == false)
                    permissionGranted = false
            }
            if (!permissionGranted) {
                Toast.makeText(this,
                "Permission request denied",
                Toast.LENGTH_SHORT).show()
            } else {
                startCamera(
                    context = this,
                    lifecycleOwner = this,
                    cameraSelector = CameraSelector.DEFAULT_BACK,
                    previewView = previewView
                )
            }
        }
    
    setContent {
        ComposeCameraxTheme {
            // A surface container using the 'background' color from the theme
            Surface(
                modifier = Modifier.fillMaxSize(),
                color = MaterialTheme.colorScheme.background
            ) {
                CameraPreview()
            }
        }
    }

    if (allPermissionsGranted()) {
        startCamera()
    } else {
        requestPermissions()
    }

}

private fun requestPermissions() {
    activityResultLauncher.launch(REQUIRED_PERMISSIONS)
}

private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
    ContextCompat.checkSelfPermission(
        baseContext, it) == PackageManager.PERMISSION_GRANTED
}

Step 3: Create the Camera Preview Composable

Define a Composable to display the camera preview:

import android.content.Context
import android.util.Log
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.runtime.Composable
import androidx.compose.ui.viewinterop.AndroidView
import androidx.camera.core.*
import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.fillMaxSize
import androidx.camera.lifecycle.LifecycleOwner
import androidx.compose.ui.platform.LocalLifecycleOwner

@Composable
fun CameraPreview(
    cameraSelector: CameraSelector = CameraSelector.DEFAULT_BACK
) {

    val lifecycleOwner = LocalLifecycleOwner.current
    AndroidView(
        factory = { context ->
            val previewView = PreviewView(context).also {
                it.scaleType = PreviewView.ScaleType.FILL_CENTER
            }
            startCamera(context = context,
                lifecycleOwner = lifecycleOwner,
                cameraSelector = cameraSelector,
                previewView = previewView)

            previewView
        },
        modifier = Modifier.fillMaxSize()
    )
}


fun startCamera(context: Context, lifecycleOwner: LifecycleOwner, cameraSelector: CameraSelector, previewView: PreviewView) {
    val cameraProviderFuture = ProcessCameraProvider.getInstance(context)

    cameraProviderFuture.addListener({
        // Used to bind the lifecycle of cameras to the lifecycle owner
        val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

        // Preview
        val preview = Preview.Builder()
            .build()
            .also {
                it.setSurfaceProvider(previewView.surfaceProvider)
            }

        try {
            // Unbind use cases before rebinding
            cameraProvider.unbindAll()

            // Bind use cases to camera
            cameraProvider.bindToLifecycle(
                lifecycleOwner,
                cameraSelector,
                preview)

        } catch(exc: Exception) {
            Log.e("CameraPreview", "Use case binding failed", exc)
        }
    }, ContextCompat.getMainExecutor(context))
}

Step 4: Implement Video Recording Functionality

Modify the startCamera function to incorporate video capture:

import android.Manifest
import android.content.ContentValues
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.provider.MediaStore
import android.util.Log
import android.widget.Toast
import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview
import androidx.camera.lifecycle.LifecycleOwner
import androidx.camera.video.MediaStoreOutputOptions
import androidx.camera.video.Recorder
import androidx.camera.video.Recording
import androidx.camera.video.VideoRecordEvent
import androidx.core.content.ContextCompat
import androidx.camera.video.FileOutputOptions
import java.io.File
import androidx.camera.video.VideoRecorder

var recording: Recording? = null

fun startCamera(context: Context, lifecycleOwner: LifecycleOwner, cameraSelector: CameraSelector, previewView: PreviewView) {
    val cameraProviderFuture = ProcessCameraProvider.getInstance(context)

    cameraProviderFuture.addListener({
        // Used to bind the lifecycle of cameras to the lifecycle owner
        val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

        // Preview
        val preview = Preview.Builder()
            .build()
            .also {
                it.setSurfaceProvider(previewView.surfaceProvider)
            }

        // Video
        val recorder = Recorder.Builder()
            .setQualitySelector(QualitySelector.from(Quality.HIGHEST))
            .build()

        val videoRecorder = VideoRecorder.withRecorder(recorder)

        // Select camera, back is default
        val cameraSelectorNew = CameraSelector.DEFAULT_BACK

        try {
            // Unbind use cases before rebinding
            cameraProvider.unbindAll()

            // Bind use cases to camera
            cameraProvider.bindToLifecycle(
                lifecycleOwner,
                cameraSelector,
                preview,
                videoRecorder
            )

        } catch(exc: Exception) {
            Log.e("CameraPreview", "Use case binding failed", exc)
        }
    }, ContextCompat.getMainExecutor(context))
}


fun captureVideo(context : Context, lifecycleOwner: LifecycleOwner) {
    val videoFile = File(context.getExternalFilesDir(null), "my_video.mp4")

    val outputOptions = FileOutputOptions.Builder(videoFile).build()

    if (recording != null) {
        // Stop the current recording session.
        recording?.stop()
        recording = null
        return
    }

    // create and start a new recording session
    recording = VideoRecorder.Builder()
        .build()
        .startRecording(outputOptions,
            ContextCompat.getMainExecutor(context))

    // Starts recording video with the MediaStore Output option
//    val name = "CameraX-video-" + SimpleDateFormat(FILENAME_FORMAT, Locale.US)
//        .format(System.currentTimeMillis()) + ".mp4"
//    val contentValues = ContentValues().apply {
//        put(MediaStore.MediaColumns.DISPLAY_NAME, name)
//        put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4")
//        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
//            put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/CameraX-Video")
//        }
//    }
//
//    val mediaStoreOutputOptions = MediaStoreOutputOptions
//        .Builder(context.contentResolver, MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
//        .setContentValues(contentValues)
//        .build()
//
//    recording = VideoCapture.output
//        .prepareRecording(mediaStoreOutputOptions)
//        .apply {
//            if (ContextCompat.checkSelfPermission(context,
//                    Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
//                withAudioEnabled()
//            }
//        }
//        .start(ContextCompat.getMainExecutor(context)) { recordEvent ->
//            when(recordEvent) {
//                is VideoRecordEvent.Start -> {
//                   // TODO
//                }
//                is VideoRecordEvent.Finalize -> {
//                    if (!recordEvent.hasError()) {
//                      // TODO
//                    } else {
//                        //Release recording after an error
//                        recording?.close()
//                        recording = null
//                        Log.e("Video Capture", "Video capture ends with error: ${recordEvent.error}")
//                    }
//                }
//            }
//        }
}

This extended function initializes the VideoCapture, binds it to the lifecycle, and adds the video capture functionality.

Step 5: Create UI Controls

Add UI elements like a button to toggle video recording:

@Composable
fun CameraPreview() {
    val lifecycleOwner = LocalLifecycleOwner.current
    var isRecording by remember { mutableStateOf(false) }
    val context = LocalContext.current

    Column {
        AndroidView(
            factory = { context ->
                val previewView = PreviewView(context).also {
                    it.scaleType = PreviewView.ScaleType.FILL_CENTER
                }
                startCamera(context = context,
                    lifecycleOwner = lifecycleOwner,
                    cameraSelector = CameraSelector.DEFAULT_BACK,
                    previewView = previewView)

                previewView
            },
            modifier = Modifier
                .fillMaxWidth()
                .weight(1f)
        )
        Button(onClick = {
                captureVideo(context, lifecycleOwner)
            },
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp)) {
            Text(text = if (isRecording) "Stop Recording" else "Start Recording")
        }
    }
}

Complete MainActivity.kt

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.example.composecamerax.ui.theme.ComposeCameraxTheme
import androidx.compose.foundation.layout.*
import androidx.camera.view.PreviewView
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.compose.ui.viewinterop.AndroidView
import androidx.camera.core.*
import androidx.compose.ui.Alignment
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalLifecycleOwner
import android.util.Log
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import android.content.ContentValues
import android.provider.MediaStore
import java.text.SimpleDateFormat
import java.util.Locale
import androidx.camera.video.*

class MainActivity : ComponentActivity() {
    private lateinit var cameraExecutor: ExecutorService

    private lateinit var previewView: PreviewView

    private val REQUIRED_PERMISSIONS = mutableListOf (
        Manifest.permission.CAMERA,
        Manifest.permission.RECORD_AUDIO
    ).apply {
        if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
            add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
        }
    }.toTypedArray()

    private lateinit var activityResultLauncher: ActivityResultLauncher>

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        activityResultLauncher =
            registerForActivityResult(
                ActivityResultContracts.RequestMultiplePermissions()
            ) { permissions ->
                // Handle Permission grant/revoke
                var permissionGranted = true
                permissions.entries.forEach {
                    if (it.key in REQUIRED_PERMISSIONS && it.value == false)
                        permissionGranted = false
                }
                if (!permissionGranted) {
                    Toast.makeText(this,
                        "Permission request denied",
                        Toast.LENGTH_SHORT).show()
                } else {
                   // startCamera(baseContext, CameraSelector.DEFAULT_BACK)
                }
            }

        setContent {
            ComposeCameraxTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    CameraPreview()
                }
            }
        }

        if (allPermissionsGranted()) {
          //  startCamera(baseContext,CameraSelector.DEFAULT_BACK)
        } else {
            requestPermissions()
        }

        cameraExecutor = Executors.newSingleThreadExecutor()
    }

    private fun requestPermissions() {
        activityResultLauncher.launch(REQUIRED_PERMISSIONS)
    }

    private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
        ContextCompat.checkSelfPermission(
            baseContext, it) == PackageManager.PERMISSION_GRANTED
    }

    override fun onDestroy() {
        super.onDestroy()
        cameraExecutor.shutdown()
    }
}

Conclusion

By integrating Jetpack Compose with CameraX, you can efficiently create a video recording application with a modern, declarative UI. CameraX handles the underlying camera complexities, while Jetpack Compose allows you to design the UI elements and interactions in a straightforward manner. The complete implementation of a video recording feature involves adding necessary dependencies, requesting permissions, implementing video capture functionality, and creating corresponding UI controls. This approach simplifies the camera implementation process and results in more maintainable and testable code.