Building Video Features Using CameraX and XML UI

Building video features in Android can be complex, requiring careful management of the camera, recording sessions, and user interface. CameraX, a Jetpack support library, simplifies this process by providing a robust and consistent API for camera-related tasks. While Jetpack Compose offers a modern approach to UI development, many projects still use XML layouts, either partially or entirely. This post demonstrates how to combine CameraX for video recording with an XML-based UI in your Android application.

Introduction to CameraX

CameraX is a Jetpack library designed to simplify camera development in Android. It addresses the inconsistencies and complexities of the older Camera and Camera2 APIs by offering a unified, lifecycle-aware interface. With CameraX, developers can easily implement features such as preview, image capture, and video recording.

Why CameraX?

  • Lifecycle Awareness: Integrates seamlessly with Android’s lifecycle, preventing memory leaks and crashes.
  • Simplified API: Offers a straightforward API that abstracts away the complexities of camera hardware.
  • Consistent Implementation: Provides consistent camera behavior across a wide range of devices.
  • Extensibility: Supports extensions like HDR, night mode, and portrait mode.

Setting Up the Project

Before diving into the implementation, let’s set up the project with the necessary dependencies and permissions.

Step 1: Add Dependencies

In your build.gradle file, include the following CameraX dependencies:

dependencies {
    def camerax_version = "1.3.0"
    implementation "androidx.camera:camera-camera2:${camerax_version}"
    implementation "androidx.camera:camera-lifecycle:${camerax_version}"
    implementation "androidx.camera:camera-viewfinder:${camerax_version}"
}

Sync the Gradle file to download and add the dependencies to your project.

Step 2: Add Permissions

In your AndroidManifest.xml, add the necessary camera and audio permissions:


<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />

Also, request these permissions at runtime to comply with Android’s permission model. Here’s a basic example in your MainActivity:


import android.Manifest
import android.content.pm.PackageManager
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat

private val REQUEST_CODE_PERMISSIONS = 10

private val REQUIRED_PERMISSIONS = arrayOf(
    Manifest.permission.CAMERA,
    Manifest.permission.RECORD_AUDIO,
    Manifest.permission.WRITE_EXTERNAL_STORAGE,
    Manifest.permission.READ_EXTERNAL_STORAGE
)

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        if (allPermissionsGranted()) {
            startCamera() // Function to start camera
        } else {
            ActivityCompat.requestPermissions(
                this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS
            )
        }
    }

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

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode == REQUEST_CODE_PERMISSIONS) {
            if (allPermissionsGranted()) {
                startCamera()
            } else {
                // Handle the case where permissions are not granted
            }
        }
    }

    private fun startCamera() {
        // Implement CameraX setup here
    }
}

Building the XML UI

Create the layout file activity_main.xml with a PreviewView for displaying the camera feed and a button to start/stop recording:


<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.camera.view.PreviewView
        android:id="@+id/viewFinder"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toTopOf="@+id/captureButton"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

    <Button
        android:id="@+id/captureButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Start Recording"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_marginBottom="16dp" />

</androidx.constraintlayout.widget.ConstraintLayout>

Integrating CameraX for Video Recording

Now, let’s integrate CameraX into your MainActivity to handle video recording.

Step 1: Initialize CameraX

Initialize CameraX in the startCamera function:


import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.*
import androidx.core.content.ContextCompat
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import android.widget.Toast
import androidx.camera.view.PreviewView

private lateinit var previewView: PreviewView
private lateinit var captureButton: Button
private var videoCapture: VideoCapture<Recorder>? = null
private var recording: Recording? = null

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        previewView = findViewById(R.id.viewFinder)
        captureButton = findViewById(R.id.captureButton)

        if (allPermissionsGranted()) {
            startCamera()
        } else {
            ActivityCompat.requestPermissions(
                this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS
            )
        }

        captureButton.setOnClickListener {
            captureVideo()
        }
    }

    private fun startCamera() {
        val cameraProviderFuture = ProcessCameraProvider.getInstance(this)

        cameraProviderFuture.addListener({
            val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

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

            val recorder = Recorder.Builder()
                .setQualitySelector(QualitySelector.from(Quality.HIGHEST))
                .build()
            videoCapture = VideoCapture.withOutput(recorder)

            try {
                cameraProvider.unbindAll()
                cameraProvider.bindToLifecycle(
                    this,
                    cameraSelector,
                    preview,
                    videoCapture
                )
            } catch (exc: Exception) {
                // Handle camera binding failure
            }

        }, ContextCompat.getMainExecutor(this))
    }

    private fun captureVideo() {
    val videoCapture = this.videoCapture ?: return

    captureButton.isEnabled = false

    val curRecording = recording
    if (curRecording != null) {
        curRecording.stop()
        recording = null
        return
    }

    val name = SimpleDateFormat(FILENAME_FORMAT, Locale.US)
        .format(System.currentTimeMillis())
    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(contentResolver, MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
        .setContentValues(contentValues)
        .build()
    recording = videoCapture.output
        .prepareRecording(this, mediaStoreOutputOptions)
        .apply {
            if (ActivityCompat.checkSelfPermission(
                    this@MainActivity,
                    Manifest.permission.RECORD_AUDIO
                ) != PackageManager.PERMISSION_GRANTED
            ) {
                return
            }
        }
        .start(ContextCompat.getMainExecutor(this)) { recordEvent ->
            when (recordEvent) {
                is VideoRecordEvent.Start -> {
                    captureButton.apply {
                        text = "Stop Recording"
                        isEnabled = true
                    }
                }
                is VideoRecordEvent.Finalize -> {
                    if (!recordEvent.hasError()) {
                        val msg = "Video capture succeeded: " +
                                "${recordEvent.outputResults.outputUri}"
                        Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT)
                            .show()
                    } else {
                        // Handle recording error
                    }
                    captureButton.apply {
                        text = "Start Recording"
                        isEnabled = true
                    }
                }
            }
        }

}
private val cameraSelector: CameraSelector = CameraSelector.DEFAULT_BACK
private const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS"
}

Step 2: Handle Video Recording

Implement the captureVideo function to start and stop video recording:

In this setup, when the “Start Recording” button is pressed:

  • The prepareRecording method initializes the video recording with specified audio and video configurations.
  • The start method begins the recording process asynchronously, providing a callback (Executor) for status updates.
  • During the recording, UI updates like button state changes and informational toasts notify the user about the ongoing recording.
  • When the “Stop Recording” button is pressed, stop is called, which finalizes the recording and saves the video file to the designated location.

Step 3: Update the onRequestPermissionsResult method


   override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode == REQUEST_CODE_PERMISSIONS) {
            if (allPermissionsGranted()) {
                startCamera()
            } else {
                Toast.makeText(this,
                    "Permissions not granted by the user.",
                    Toast.LENGTH_SHORT).show()
                finish()
            }
        }
    }

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

    private fun startCamera() {
        val cameraProviderFuture = ProcessCameraProvider.getInstance(this)

        cameraProviderFuture.addListener({
            val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

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

            val recorder = Recorder.Builder()
                .setQualitySelector(QualitySelector.from(Quality.HIGHEST))
                .build()
            videoCapture = VideoCapture.withOutput(recorder)

            try {
                cameraProvider.unbindAll()
                cameraProvider.bindToLifecycle(
                    this,
                    cameraSelector,
                    preview,
                    videoCapture
                )
            } catch (exc: Exception) {
               Log.e(TAG, "Use case binding failed", exc)
            }

        }, ContextCompat.getMainExecutor(this))
    }
    private fun captureVideo() {
        val videoCapture = this.videoCapture ?: return

        captureButton.isEnabled = false

        val curRecording = recording
        if (curRecording != null) {
            curRecording.stop()
            recording = null
            return
        }

        val name = SimpleDateFormat(FILENAME_FORMAT, Locale.US)
            .format(System.currentTimeMillis())
        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(contentResolver, MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
            .setContentValues(contentValues)
            .build()
        recording = videoCapture.output
            .prepareRecording(this, mediaStoreOutputOptions)
            .apply {
                if (ActivityCompat.checkSelfPermission(
                        this@MainActivity,
                        Manifest.permission.RECORD_AUDIO
                    ) != PackageManager.PERMISSION_GRANTED
                ) {
                    ActivityCompat.requestPermissions(
                        this@MainActivity,
                        arrayOf(Manifest.permission.RECORD_AUDIO),
                        REQUEST_CODE_PERMISSIONS
                    )
                    return
                }
            }
            .start(ContextCompat.getMainExecutor(this)) { recordEvent ->
                when (recordEvent) {
                    is VideoRecordEvent.Start -> {
                        captureButton.apply {
                            text = "Stop Recording"
                            isEnabled = true
                        }
                    }
                    is VideoRecordEvent.Finalize -> {
                        if (!recordEvent.hasError()) {
                            val msg = "Video capture succeeded: " +
                                    "${recordEvent.outputResults.outputUri}"
                            Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT)
                                .show()
                        } else {
                            val error = recordEvent.error
                            Log.e(TAG, "Video capture ends with error: $error")
                            Toast.makeText(baseContext, "Video capture ends with error: $error", Toast.LENGTH_SHORT)
                                .show()
                        }
                        captureButton.apply {
                            text = "Start Recording"
                            isEnabled = true
                        }
                    }
                }
            }

    }

Conclusion

Integrating CameraX for video recording with an XML-based UI in Android simplifies the development process. By using CameraX, you can easily manage camera sessions, handle video recording, and ensure consistent behavior across various devices. While Jetpack Compose offers a modern UI approach, combining CameraX with XML layouts allows developers to leverage existing UI components while benefiting from CameraX’s robust camera functionalities.