CameraX

So far TrailLog has treated a trail photo as something that already exists on disk. This lesson covers taking that photo inside TrailLog itself, using CameraX — Jetpack's modern camera library.

Why CameraX Instead of Camera2 Directly

The older Camera2 API gives an app direct, low-level control over device camera hardware — and that's exactly its problem for most apps: it's notoriously inconsistent across manufacturers, requiring device-specific workarounds for things as basic as image rotation and aspect ratio that should just work the same everywhere. CameraX is built on top of Camera2 but deliberately designed to smooth all of that over, giving you one consistent API that behaves the same across the huge range of Android devices and camera hardware in the real world. For the overwhelming majority of apps — TrailLog included — CameraX is the right choice.

CameraX's Core Use Cases

Use caseWhat it's for
PreviewShows a live camera feed on screen, so the user can frame their shot before capturing.
ImageCaptureTakes a single still photo and saves it to a file — this is the one TrailLog actually needs.
ImageAnalysisStreams live camera frames to your own code for real-time processing (e.g. detecting a barcode). TrailLog doesn't need this, but it's worth knowing CameraX supports it for apps that do.

Step 1: A PreviewView in TrailLog's "Add Photo" Screen

The layout for the screen where a user photographs a new trail needs a PreviewView — CameraX's dedicated view for rendering the live camera feed:

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

<Button
    android:id="@+id/captureButton"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="Capture" />

Step 2: Binding Use Cases to the Lifecycle

With the CAMERA runtime permission already granted (following the same pattern as Runtime Permissions), CameraX is wired up by fetching a ProcessCameraProvider and binding the use cases you need to an Activity or Fragment's lifecycle:

private lateinit var imageCapture: ImageCapture

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

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

        val preview = Preview.Builder().build().also {
            it.setSurfaceProvider(binding.cameraPreview.surfaceProvider)
        }
        imageCapture = ImageCapture.Builder().build()

        val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA

        cameraProvider.unbindAll()
        cameraProvider.bindToLifecycle(
            this,
            cameraSelector,
            preview,
            imageCapture
        )
    }, ContextCompat.getMainExecutor(this))
}

bindToLifecycle(this, ...) is the part that matters most here: it hands CameraX the Activity itself as a LifecycleOwner, so CameraX automatically starts the camera when the screen becomes visible and stops it when the screen is paused or destroyed. Without lifecycle binding, it's easy to accidentally leave the camera hardware open after the user has already navigated away — a real resource leak, and one CameraX's design specifically avoids by tying camera usage directly to the screen's own lifecycle.

Step 3: Capturing a Photo and Saving It via FileProvider

ImageCapture.takePicture() writes the photo directly to a destination file you provide, using the same app-specific external "Pictures" folder and FileProvider setup from FileProvider & Content Providers:

private fun captureTrailPhoto(trailId: Int) {
    val photoFile = File(getExternalFilesDir("Pictures"), "trail_${trailId}.jpg")
    val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()

    imageCapture.takePicture(
        outputOptions,
        ContextCompat.getMainExecutor(this),
        object : ImageCapture.OnImageSavedCallback {
            override fun onImageSaved(output: ImageCapture.OutputFileResults) {
                val photoUri = FileProvider.getUriForFile(
                    this@TrailPhotoActivity,
                    "com.codingwithsonu.traillog.fileprovider",
                    photoFile
                )
                // save photoUri onto this trail's record
            }

            override fun onError(exception: ImageCaptureException) {
                // show an error — capture failed
            }
        }
    )
}

Note the same FileProvider.getUriForFile() call from the earlier lesson reappears here unchanged — capturing a photo with CameraX and sharing an existing photo through a share sheet both end up needing the exact same safe, scoped content:// URI.

The CAMERA Runtime Permission

Like fine location or notifications, camera access requires the CAMERA runtime permission, requested with the same ActivityResultContracts.RequestPermission() pattern covered in Runtime Permissions — request it right before calling startCamera(), never assume it's already granted.

Note: This XML layout and Kotlin code are correct, real CameraX usage, but this website's own project doesn't run a live Android Studio build, so nothing here executes on the page itself.

One Less Headache: Rotation Handled for You

One genuinely painful problem with hand-rolled camera code used to be getting photo rotation right across different devices and orientations — photos coming out sideways depending on how the phone was held. CameraX handles sensor rotation and lens orientation correctly out of the box, so TrailLog's captured photos display right-side-up without any manual rotation math.

Up next: A trail photo captures a moment, but a trail also has a real place. Fused Location Provider covers getting TrailLog's GPS coordinates.