Camera, Location, Sensors, Media and Connectivity

This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

TrailMate is a hiking-trail companion, so most of what makes it useful comes from the device’s own sensors and radios rather than from its UI: a camera to log a trail photo, GPS to record a hike’s path, an accelerometer and step counter to show progress, and a media player for a guided-audio trail description. This page covers the Jetpack APIs for those device features — CameraX, the fused location provider and geofencing, SensorManager, Media3 — plus brief coverage of audio recording, Bluetooth LE, NFC, USB, WebView, and the other Android form factors (Wear OS, TV, Auto, XR). The baseline used throughout is compileSdk = 37, targetSdk = 36, minSdk = 26, CameraX 1.6.2 and Media3 1.11.1. Requesting the runtime permissions these APIs need (CAMERA, ACCESS_FINE_LOCATION, ACCESS_BACKGROUND_LOCATION, RECORD_AUDIO, BLUETOOTH_SCAN) is covered on Permissions, Privacy and Security and is assumed granted in the examples below. Running the fused location provider and camera capture continuously in the background — TrailMate’s RecordHikeService foreground service — is covered on Background Work: WorkManager, Services and Alarms; this page covers only how to read a location, a sensor value or a camera frame once a caller already has permission and, where relevant, is already running in the right component. Kotlin coroutines, Flow and callbackFlow, used throughout to turn callback-based APIs into cold streams, are covered on Coroutines Basics and Flows and are not re-explained here. ViewModel scoping for the screens below is covered on ViewModel, Lifecycle-Aware Components, Coroutines and Flow.

CameraX

CameraX is a Jetpack library that wraps the lower-level Camera2 API behind a lifecycle-aware, use-case-based API that behaves consistently across device manufacturers. TrailMate uses it for PhotoCaptureScreen, the screen a hiker uses to attach a photo to a TrailPhoto record.

Camera2 vs. CameraX

Camera2, introduced in API 21, is the platform’s low-level camera API: it exposes capture sessions, capture requests and image readers directly, giving full manual control (exposure, focus, per-frame metadata) at the cost of a large amount of boilerplate and device-specific quirks that an app must handle itself. CameraX is built on top of Camera2 and is the API Android’s own documentation now recommends for almost all camera use, including in TrailMate:

Recommendation

Use CameraX

Default choice. Lifecycle-aware binding (no manual open/close/release bookkeeping), consistent behavior across devices via device-specific quirk workarounds CameraX ships with, and purpose-built use cases (Preview, ImageCapture, ImageAnalysis, VideoCapture) that compose instead of one monolithic capture pipeline. This is what TrailMate’s PhotoCaptureScreen is built on below.

Use Camera2 directly

Only when a feature genuinely has no CameraX equivalent: per-frame manual sensor/lens control for computational photography, RAW capture pipelines, or reusing an existing Camera2-based rendering pipeline. CameraX exposes a Camera2Interop escape hatch for tuning individual capture parameters without dropping to raw Camera2.

CameraX Architecture and Use Cases

A CameraX app binds one or more use cases to a LifecycleOwner through a ProcessCameraProvider; CameraX then starts and stops the camera automatically as that lifecycle owner moves through STARTED/STOPPED, the same way LiveData and coroutine scopes tie work to a lifecycle elsewhere in the app. Four use cases cover TrailMate’s needs:

Use case Purpose

Preview

Streams camera frames to a surface for the viewfinder. Does not itself save anything.

ImageCapture

Takes a single high-resolution still photo and writes it to a file or a MediaStore entry. This is what PhotoCaptureScreen uses to save a TrailPhoto.

ImageAnalysis

Delivers a stream of lower-resolution frames to an app-supplied analyzer, for tasks like barcode scanning or ML Kit trail-marker detection. Not used by TrailMate today, but composes with Preview and ImageCapture if added later.

VideoCapture

Records video (with audio, if RECORD_AUDIO is granted) via a paired Recorder. Not used by TrailMate.

Multiple use cases can be bound together in the same bindToLifecycle call (for example Preview
ImageCapture), and CameraX resolves a single compatible resolution/aspect-ratio configuration across all of them.

PhotoCaptureScreen: Preview + ImageCapture in Compose

A Preview use case needs a SurfaceProvider to draw into, and it must be bound to a lifecycle through ProcessCameraProvider before it produces frames. Two options exist for displaying it:

  • PreviewView (a View) wrapped in AndroidView — the long-standing, still fully supported approach, usable from minSdk 26 upward.

  • CameraXViewfinder, a Compose-native composable shipped as part of CameraX’s Compose integration (androidx.camera:camera-compose) that renders the Preview surface directly without an AndroidView interop boundary, giving it normal Compose semantics for sizing, clipping and recomposition.

TrailMate’s PhotoCaptureScreen uses CameraXViewfinder. The ViewModel owns the Preview and ImageCapture use cases and binds both to the screen’s lifecycle with ProcessCameraProvider.awaitInstance(…​) (the coroutine-friendly accessor from camera-lifecycle) and bindToLifecycle(…​); binding is what makes the Preview deliver a SurfaceRequest to the viewfinder and gives ImageCapture a running camera:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/photo/PhotoCaptureScreen.kt
@Composable
fun PhotoCaptureScreen(
    onPhotoSaved: (Uri) -> Unit,
    viewModel: PhotoCaptureViewModel = hiltViewModel()
) {
    val lifecycleOwner = LocalLifecycleOwner.current
    val surfaceRequest by viewModel.surfaceRequest.collectAsStateWithLifecycle()

    LaunchedEffect(lifecycleOwner) {
        // Suspends while bound; leaving the composition cancels it and unbinds the camera.
        viewModel.bindToCamera(lifecycleOwner)
    }

    Box(modifier = Modifier.fillMaxSize()) {
        surfaceRequest?.let { request ->
            CameraXViewfinder(
                surfaceRequest = request,
                modifier = Modifier.fillMaxSize()
            )
        }
        FloatingActionButton(
            onClick = {
                viewModel.capturePhoto { uri -> onPhotoSaved(uri) }
            },
            modifier = Modifier.align(Alignment.BottomCenter).padding(24.dp)
        ) {
            Icon(Icons.Default.PhotoCamera, contentDescription = "Take photo")
        }
    }
}
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/photo/PhotoCaptureViewModel.kt
@HiltViewModel
class PhotoCaptureViewModel @Inject constructor(
    @ApplicationContext private val context: Context
) : ViewModel() {

    private val _surfaceRequest = MutableStateFlow<SurfaceRequest?>(null)
    val surfaceRequest: StateFlow<SurfaceRequest?> = _surfaceRequest.asStateFlow()

    private val preview = Preview.Builder().build().apply {
        setSurfaceProvider { request -> _surfaceRequest.value = request }
    }
    private val imageCapture = ImageCapture.Builder()
        .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
        .build()

    suspend fun bindToCamera(lifecycleOwner: LifecycleOwner) {
        val cameraProvider = ProcessCameraProvider.awaitInstance(context)
        cameraProvider.bindToLifecycle(
            lifecycleOwner,
            CameraSelector.DEFAULT_BACK_CAMERA,
            preview,
            imageCapture
        )
        try {
            awaitCancellation()
        } finally {
            cameraProvider.unbind(preview, imageCapture)
            _surfaceRequest.value = null
        }
    }

    fun capturePhoto(onSaved: (Uri) -> Unit) {
        val name = "TrailMate_${System.currentTimeMillis()}.jpg"
        val contentValues = ContentValues().apply {
            put(MediaStore.MediaColumns.DISPLAY_NAME, name)
            put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
            put(MediaStore.MediaColumns.RELATIVE_PATH, "Pictures/TrailMate")
        }
        val outputOptions = ImageCapture.OutputFileOptions.Builder(
            context.contentResolver,
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            contentValues
        ).build()

        imageCapture.takePicture(
            outputOptions,
            ContextCompat.getMainExecutor(context),
            object : ImageCapture.OnImageSavedCallback {
                override fun onImageSaved(output: ImageCapture.OutputFileResults) {
                    output.savedUri?.let(onSaved)
                }
                override fun onError(exception: ImageCaptureException) {
                    // surface exception through UiState, as elsewhere in TrailMate
                }
            }
        )
    }
}

Binding Preview and ImageCapture in a single bindToLifecycle call lets CameraX pick one compatible configuration for both. A simpler alternative is LifecycleCameraController (from camera-view), which creates and binds its own Preview/ImageCapture internally; it is paired with a PreviewView through previewView.controller = cameraController, and captures with cameraController.takePicture(…​) rather than with a separately built ImageCapture. Mixing the two — binding a controller while displaying or capturing with use cases the app built itself — leaves the app’s own use cases unbound, so the viewfinder stays black and takePicture fails. Where Compose interop is not wanted — for example inside a legacy TrailDetailFragment — the same Preview use case can instead draw into a PreviewView inflated in XML via preview.setSurfaceProvider(previewView.surfaceProvider), with the binding and capture logic unchanged. MediaStore and scoped storage, used above to save the photo, are covered on Local Storage: DataStore, Files, Scoped Storage and MediaStore.

Location

TrailMate’s core value proposition — recording a hike’s path — depends on the fused location provider (FusedLocationProviderClient, from Google Play services), which blends GPS, Wi-Fi and cell signals into a single location source that is more power-efficient than reading GPS hardware directly.

One-shot location: getCurrentLocation

For a single up-to-date fix (for example, tagging a TrailPhoto with the coordinates it was taken at), getCurrentLocation requests one fresh location without registering an ongoing callback:

// core/data/src/main/kotlin/com/example/trailmate/core/data/location/LocationSource.kt
class LocationSource @Inject constructor(
    @ApplicationContext private val context: Context
) {
    private val client = LocationServices.getFusedLocationProviderClient(context)

    suspend fun currentLocation(): Location? {
        val cancellationTokenSource = CancellationTokenSource()
        return client.getCurrentLocation(
            Priority.PRIORITY_BALANCED_POWER_ACCURACY,
            cancellationTokenSource.token
        ).await()
    }
}

Priority.PRIORITY_HIGH_ACCURACY trades battery for GPS-level precision and requires ACCESS_FINE_LOCATION; PRIORITY_BALANCED_POWER_ACCURACY is coarser but cheaper and is satisfied by ACCESS_COARSE_LOCATION alone.

Continuous updates as a Flow

Recording a hike needs a stream of locations, not one fix. requestLocationUpdates is callback-based, so LocationSource wraps it in callbackFlow to expose it as a cold Flow that TrailMate’s RecordHikeService collects:

// core/data/src/main/kotlin/com/example/trailmate/core/data/location/LocationSource.kt
fun locationUpdates(intervalMillis: Long = 5_000L): Flow<Location> = callbackFlow {
    val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, intervalMillis).build()
    val callback = object : LocationCallback() {
        override fun onLocationResult(result: LocationResult) {
            result.lastLocation?.let { trySend(it) }
        }
    }
    client.requestLocationUpdates(request, callback, Looper.getMainLooper())
    awaitClose { client.removeLocationUpdates(callback) }
}

The same pattern — a callback-based platform API wrapped in callbackFlow with cleanup in awaitClose — recurs throughout this page for sensors and Bluetooth below. See Flows for callbackFlow itself.

Continuous updates after the user leaves the app need a foreground service of type location (with the FOREGROUND_SERVICE_LOCATION manifest permission and a granted ACCESS_FINE_LOCATION or ACCESS_COARSE_LOCATION) that was started while the app was visible — exactly the shape of RecordHikeService. Such a service does not need ACCESS_BACKGROUND_LOCATION; that permission is only required to start a location foreground service while the app is already in the background, or to receive location without any foreground service (as geofencing does below). Both are covered on Background Work: WorkManager, Services and Alarms and Permissions, Privacy and Security.

Maps

Displaying a recorded hike’s path on a map is out of scope for this page’s depth, but TrailMate’s TrailDetailScreen would use Maps Compose (com.google.maps.android:maps-compose), a Compose wrapper around the Maps SDK for Android that renders a GoogleMap composable and accepts Marker/Polyline content for the recorded path.

Geofencing

Geofencing lets TrailMate fire a notification when a hiker enters or exits a trailhead’s radius without keeping a location stream running continuously, which is far more battery-friendly for a boundary check than polling locationUpdates. A GeofencingClient registers one or more Geofence circles together with a PendingIntent that the system delivers a GeofencingEvent to on transition:

// core/data/src/main/kotlin/com/example/trailmate/core/data/location/TrailheadGeofences.kt
fun registerTrailheadGeofence(
    context: Context,
    client: GeofencingClient,
    trail: Trail,
    latitude: Double,
    longitude: Double
) {
    val geofence = Geofence.Builder()
        .setRequestId(trail.id)
        .setCircularRegion(latitude, longitude, /* radiusMeters = */ 200f)
        .setExpirationDuration(Geofence.NEVER_EXPIRE)
        .setTransitionTypes(Geofence.GEOFENCE_TRANSITION_ENTER)
        .build()
    val request = GeofencingRequest.Builder()
        .addGeofence(geofence)
        .setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER)
        .build()
    val pendingIntent = PendingIntent.getBroadcast(
        context, 0, Intent(context, GeofenceBroadcastReceiver::class.java),
        // Play services fills in the transition data, so the intent must be mutable on API 31+.
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
    )
    client.addGeofences(request, pendingIntent)
}

GeofenceBroadcastReceiver reads the GeofencingEvent from the delivered intent and posts a notification the same way as the sync notifications covered on Notifications, Firebase Cloud Messaging and App Widgets (Glance). The PendingIntent is created with FLAG_MUTABLE because Google Play services writes the transition extras into it; an immutable intent reaches the receiver without them, so GeofencingEvent.fromIntent() reports no transition. Geofencing requires ACCESS_FINE_LOCATION and, for apps targeting Android 10 (API 29) or higher, ACCESS_BACKGROUND_LOCATION, since geofence transitions are delivered while the app is in the background.

Sensors

SensorManager gives access to the device’s motion, position and environment sensors. TrailMate uses it to show a step count and orientation while a hike is being recorded. Reading a sensor is callback-based (SensorEventListener), so, following the same pattern as location updates, SensorSource wraps it in a Flow:

// core/data/src/main/kotlin/com/example/trailmate/core/data/sensors/SensorSource.kt
class SensorSource @Inject constructor(
    @ApplicationContext context: Context
) {
    private val sensorManager = context.getSystemService(SensorManager::class.java)

    fun sensorReadings(type: Int): Flow<SensorEvent> = callbackFlow {
        val sensor = sensorManager.getDefaultSensor(type)
            ?: return@callbackFlow close(IllegalStateException("Sensor $type not available"))
        val listener = object : SensorEventListener {
            override fun onSensorChanged(event: SensorEvent) { trySend(event) }
            override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
        }
        sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_UI)
        awaitClose { sensorManager.unregisterListener(listener) }
    }

    fun stepCount(): Flow<Int> = sensorReadings(Sensor.TYPE_STEP_COUNTER)
        .map { it.values[0].toInt() }

    fun rotationVector(): Flow<FloatArray> = sensorReadings(Sensor.TYPE_ROTATION_VECTOR)
        .map { it.values.copyOf() }
}

Sensor.TYPE_ACCELEROMETER gives raw acceleration (including gravity) along three axes and is the basis for step-detection algorithms an app implements itself; Sensor.TYPE_STEP_COUNTER is a hardware- or sensor-fusion-backed count maintained by the platform since the last reboot, cheaper to use directly than re-deriving steps from raw accelerometer data; Sensor.TYPE_ROTATION_VECTOR is a fused orientation sensor (combining accelerometer, gyroscope and magnetometer) used to show a compass heading. SENSOR_DELAY_UI throttles delivery to a UI-appropriate rate; SENSOR_DELAY_GAME or SENSOR_DELAY_FASTEST would be used for lower-latency needs at a higher battery cost.

Media3

Media3 is Android’s unified media library (superseding the older ExoPlayer and android.media combination) for playing audio and video. TrailMate could use it to play a downloaded audio guide for a trail.

ExoPlayer in Compose

Media3’s player, ExoPlayer, has no Compose-native surface either; playback is driven by an ExoPlayer instance held in a ViewModel and rendered through a PlayerView wrapped in AndroidView:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/audio/TrailAudioGuideScreen.kt
@Composable
fun TrailAudioGuideScreen(audioUrl: String, viewModel: TrailAudioGuideViewModel = hiltViewModel()) {
    val exoPlayer = viewModel.player
    LaunchedEffect(audioUrl) {
        exoPlayer.setMediaItem(MediaItem.fromUri(audioUrl))
        exoPlayer.prepare()
        exoPlayer.play()
    }
    AndroidView(
        modifier = Modifier.fillMaxWidth(),
        factory = { context -> PlayerView(context).apply { player = exoPlayer } }
    )
}
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/audio/TrailAudioGuideViewModel.kt
@HiltViewModel
class TrailAudioGuideViewModel @Inject constructor(
    @ApplicationContext context: Context
) : ViewModel() {
    val player: ExoPlayer = ExoPlayer.Builder(context).build()

    override fun onCleared() {
        player.release()
    }
}

Background Playback with MediaSessionService

Playing audio only while TrailAudioGuideScreen is visible is fine for a short clip, but a full trail-length audio guide needs to keep playing when the screen turns off or the user switches apps, with lock-screen and Bluetooth-headset controls. That requires moving playback into a MediaSessionService, a foreground service that owns the ExoPlayer and publishes a MediaSession describing the current playback state; a MediaController elsewhere in the app (or in a system surface such as Android Auto) then sends commands to it instead of holding the player directly:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/audio/TrailAudioPlaybackService.kt
class TrailAudioPlaybackService : MediaSessionService() {
    private lateinit var player: ExoPlayer
    private lateinit var mediaSession: MediaSession

    override fun onCreate() {
        super.onCreate()
        player = ExoPlayer.Builder(this).build()
        mediaSession = MediaSession.Builder(this, player).build()
    }

    override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession = mediaSession

    override fun onDestroy() {
        mediaSession.run { player.release(); release() }
        super.onDestroy()
    }
}

A MediaSessionService follows the same foreground-service rules — manifest declaration, notification, and startForeground — covered in general on Background Work: WorkManager, Services and Alarms; Media3 handles the required "media playback" notification for it automatically.

Audio Recording

Recording audio — for example, a short voice note attached to a TrailPhoto — uses MediaRecorder, configured with an audio source, output format and encoder before recording starts:

// MediaRecorder(Context) was added in API 31; TrailMate's minSdk is 26.
val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
    MediaRecorder(context)
} else {
    @Suppress("DEPRECATION")
    MediaRecorder()
}.apply {
    setAudioSource(MediaRecorder.AudioSource.MIC)
    setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
    setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
    setOutputFile(outputFile.absolutePath)
    prepare()
    start()
}
// later: recorder.stop(); recorder.release()

This requires RECORD_AUDIO and, like ImageCapture above, writes through MediaStore/scoped storage rather than a raw file path on modern targets. Media3 also ships a separate low-level recording API (androidx.media3:media3-exoplayer transformer/muxer classes) for apps assembling audio or video programmatically, which is out of scope here. For apps that stream microphone audio to a live conversational agent, see ElevenLabs Agents for the vendor SDK route and Voice in the Browser for the web-client counterpart.

Bluetooth LE, NFC and USB

TrailMate does not currently use any of these, but they follow the same shape as the APIs above:

  • Bluetooth Low Energy (BluetoothLeScanner, BluetoothGatt) would let TrailMate pair with a Bluetooth-connected heart-rate strap or GPS watch, reading characteristic updates as a callbackFlow the same way LocationSource and SensorSource do above. Scanning requires BLUETOOTH_SCAN, and connecting requires BLUETOOTH_CONNECT (both runtime permissions since API 31).

  • NFC (NfcAdapter) would let TrailMate read an NFC tag mounted at a trailhead kiosk to jump straight to that trail’s detail screen, using an intent filter for NDEF_DISCOVERED rather than continuous scanning.

  • USB (UsbManager) is relevant mainly for accessory/peripheral integrations (external GPS dongles, custom hardware) and is a poor fit for a consumer hiking app; it is mentioned here only for completeness.

WebView

WebView embeds a web page inside a native screen — TrailMate could use it to show a trail’s Wikipedia entry or a third-party park-authority page without leaving the app. WebView is a View, so in Compose it is used through AndroidView:

@Composable
fun TrailInfoWebView(url: String) {
    AndroidView(
        modifier = Modifier.fillMaxSize(),
        factory = { context ->
            WebView(context).apply {
                settings.javaScriptEnabled = true
                webViewClient = WebViewClient()
                loadUrl(url)
            }
        }
    )
}

Enabling JavaScript widens the attack surface of whatever page is loaded, so it should only be turned on for trusted, fixed URLs; addJavascriptInterface (bridging Kotlin objects into page JavaScript) is covered, together with its security implications, on Permissions, Privacy and Security. For most "show one external page" needs, Chrome Custom Tabs (outside this page’s scope) is a lighter-weight and more secure alternative to embedding a full WebView.

Other Form Factors

CameraX, location, sensors and Media3 all extend beyond phones and tablets to Android’s other form factors. TrailMate itself targets phones and tablets only, so this section is pointers rather than coverage:

Form factor Relevance

Wear OS

A companion TrailMate watch app could show live pace and heart rate during a hike using the same SensorManager and fused-location APIs covered above, built with Compose for Wear OS and the Health Services API for fitness-specific sensors.

Android TV

Not relevant to a hiking app; TV apps use a leanback-oriented navigation model and d-pad input instead of touch.

Android Auto

A MediaSessionService-based audio guide (as above) can surface its playback controls in an Android Auto head unit automatically, since Auto is a MediaController client of the same session.

Android XR

Not relevant to TrailMate’s current scope; XR apps adapt Compose layouts to a spatial environment.