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 |
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
|
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 |
|---|---|
|
Streams camera frames to a surface for the viewfinder. Does not itself save anything. |
|
Takes a single high-resolution still photo and writes it to a file or a |
|
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 |
|
Records video (with audio, if |
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(aView) wrapped inAndroidView— 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 thePreviewsurface directly without anAndroidViewinterop 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 acallbackFlowthe same wayLocationSourceandSensorSourcedo above. Scanning requiresBLUETOOTH_SCAN, and connecting requiresBLUETOOTH_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 forNDEF_DISCOVEREDrather 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
|
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 |
Android XR |
Not relevant to TrailMate’s current scope; XR apps adapt Compose layouts to a spatial environment. |