Jetpack Compose Integration
This guide covers integrating the Paper Receipt camera UI into a Jetpack Compose–based Android application. It describes two primary integration strategies: using RecognizerView to build a custom scanning UI, and using the out-of-the-box camera experience for a complete, pre-built camera flow.
Custom Scan using RecognizerView
To integrate RecognizerView with Jetpack Compose, use the AndroidView composable to embed the traditional Android View inside your Compose UI. Manage the view's lifecycle by creating it in the factory lambda and releasing it in onRelease.
The following example sets up RecognizerView inside a composable:
@Composable
fun RecognizerViewComposable(
modifier: Modifier,
// ...
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
// Use remember to prevent the RecognizerView from being re-created on every recomposition.
val recognizerView = remember {
RecognizerView(context).apply {
// Observe Recognizer Callback
recognizerCallback(object: CameraRecognizerCallback {
// ...
})
// Initialize with ScanOptions
initialize(
ScanOptions
.newBuilder()
// ...
.build()
)
// Attach Lifecycle Owner
lifecycle(lifecycleOwner)
}
}
AndroidView(
factory = { recognizerView },
modifier = modifier,
onRelease = { view ->
view.terminate()
}
)
}
Avoiding RecognizerNotInitializedException on re-entry
RecognizerView is a per-screen view, but the underlying Recognizer is a process-wide
singleton — calling terminate() on any instance tears down the shared session for all of them.
Compose disposes departing nodes after the entering composition is applied, so when the user
re-enters the scan screen the old RecognizerView's onRelease { terminate() } can run after a
new instance has already initialized. That poisons the new session: an in-flight capture callback
that re-enters the view (confirmPicture(), preliminaryResults(), finishedScanning()) then
throws RecognizerNotInitializedException, and frames may be silently dropped.
Follow both rules below to keep exactly one live session and to guard the async callback.
Ordering rules — one live RecognizerView at a time:
- Create and initialize the view once via
remember {}(as shown above), keyed so that re-entering the screen does not leave two instances alive at once. - Call
lifecycle(lifecycleOwner)exactly once, with the composable's ownLifecycleOwner— not a longer-lived owner such as the host activity. - Keep
initialize()andonRelease { terminate() }on the sameAndroidViewnode so init and teardown are paired. - Never hold a
RecognizerViewin a ViewModel or singleton and terminate it later — a lateterminate()will kill an unrelated active session.
Guard the capture callback. onCaptured is delivered on the CameraCaptureListener you pass to
takePicture(...). Verify the session is still initialized before touching the view, and wrap the
view calls defensively:
recognizerView.takePicture(object : CameraCaptureListener {
override fun onCaptured(results: BitmapResult) {
// Skip if the shared session was terminated (e.g. by a stale instance on re-entry).
if (!recognizerView.initialized()) return
try {
recognizerView.confirmPicture(results)
} catch (e: RecognizerException) {
// Session was terminated between the guard and this call — nothing left to do.
} catch (e: IllegalStateException) {
}
}
})
The same guard applies to any call that re-enters the view from an async callback, including
recognizerView.preliminaryResults() and recognizerView.finishedScanning(). Where a
LifecycleOwner is in scope, you can also require the screen to be at least STARTED before
touching the view.
recognizerView.initialized() is only available from Paper Receipt SDK 2.2.2 onwards. Confirm
your SDK version before relying on it.
Earlier SDK versions (before 2.2.2). recognizerView.initialized() does not exist yet. Track
the session state yourself with a remember { mutableStateOf(false) } flag scoped to the same
composable that owns the view, and wrap the RecognizerView setup so initialize() and
terminate() always update it together:
@Composable
fun RecognizerViewComposable(
modifier: Modifier,
// ...
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
// Tracks whether the session behind recognizerView is currently live.
val recognizerSessionActive = remember { mutableStateOf(false) }
val recognizerView = remember {
RecognizerView(context).apply {
recognizerCallback(object: CameraRecognizerCallback {
// ...
})
initialize(
ScanOptions
.newBuilder()
// ...
.build()
)
recognizerSessionActive.value = true
lifecycle(lifecycleOwner)
}
}
AndroidView(
factory = { recognizerView },
modifier = modifier,
onRelease = { view ->
recognizerSessionActive.value = false
view.terminate()
}
)
}
Then swap the guard in onCaptured to check the flag instead of recognizerView.initialized():
recognizerView.takePicture(object : CameraCaptureListener {
override fun onCaptured(results: BitmapResult) {
// Skip if the shared session was terminated (e.g. by a stale instance on re-entry).
if (!recognizerSessionActive.value) return
try {
recognizerView.confirmPicture(results)
} catch (e: RecognizerException) {
// Session was terminated between the guard and this call — nothing left to do.
} catch (e: IllegalStateException) {
}
}
})
This flag is a fallback, not a substitute for the ordering rules above — it only protects the
callback; overlapping RecognizerView lifetimes must still be avoided.
Out-of-the-box Camera Experience
There are two ways to integrate the pre-built camera experience: embedding CameraRecognizerFragment directly in a composable for a tightly integrated UI, or launching it as a separate activity through a CameraRecognizerContract for a more decoupled flow.
1. Embed CameraRecognizerFragment using AndroidFragment
This approach embeds CameraRecognizerFragment within a composable using the AndroidFragment composable from the androidx.fragment:fragment-compose library. It's a good fit if your app already uses a Fragment-based architecture, or if you want to place the camera view as a component within a larger composable screen. The camera's lifecycle and results are managed directly within the composable's context.
// build.gradle
implementation "androidx.fragment:fragment-compose:1.8.9"
import androidx.fragment.app.FragmentActivity
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
CameraRecognizerContent(
modifier = Modifier,
onScanResults = { results: CameraRecognizerResults ->
// Process CameraRecognizerResults here...
}
)
}
}
}
@Composable
internal fun CameraRecognizerContent(
modifier: Modifier,
onScanResults: ((CameraRecognizerResults) -> Unit),
) {
val lifecycleOwner = LocalLifecycleOwner.current
AndroidFragment<CameraRecognizerFragment>(
modifier = modifier.fillMaxSize(),
arguments = bundleOf(
CameraRecognizerFragment.OPTIONS to ScanOptions
.newBuilder()
// Define your own ScanOptions configuration here...
.build(),
CameraRecognizerFragment.CAMERA_CHARACTERISTICS to CameraCharacteristics.Builder()
.scanCharacteristics(
ScanCharacteristics.Builder()
// Define your own ScanCharacteristics configuration here...
.build()
)
.tooltipCharacteristics(
TooltipCharacteristics.Builder()
// e.g. .displayDataMissingTooltip(true)
// See "Configuring tooltips" below.
.build()
)
// Define other CameraCharacteristics configuration here...
.build(),
),
) { fragment ->
fragment.parentFragmentManager
.setFragmentResultListener(
CameraRecognizerFragment.SCAN_SESSION_RESULTS_KEY,
lifecycleOwner,
) { _, bundle ->
bundle.parcelable<CameraRecognizerResults>(CameraRecognizerFragment.SCAN_RESULTS_KEY)
?.let { results ->
// Retrieve CameraRecognizerResults here...
onScanResults(results)
}
}
}
}
2. Launch as an Activity using CameraRecognizerContract
This approach leverages the modern Android Activity Result APIs. By pairing rememberLauncherForActivityResult with CameraRecognizerContract, you launch the camera recognizer as a separate activity and receive the results in a callback. It's ideal for a more decoupled architecture where scanning is a distinct step in a user flow, and it simplifies state management because the camera UI is entirely separate from your calling composable.
@Composable
internal fun CameraRecognizerContent(
modifier: Modifier,
onScanResults: ((CameraRecognizerResults) -> Unit),
) {
val launcher = rememberLauncherForActivityResult(
CameraRecognizerContract()
) { results: CameraRecognizerResults ->
onScanResults(results)
}
// Launch the camera recognizer when this composable enters the composition
LaunchedEffect(Unit) {
launcher.launch(
CameraRecognizerOptions.Builder()
.options(
ScanOptions
.newBuilder()
// Define your own ScanOptions configuration here...
.build()
)
.characteristics(
CameraCharacteristics.Builder()
// Define other CameraCharacteristics configuration here...
.scanCharacteristics(
ScanCharacteristics.Builder()
// Define your own ScanCharacteristics configuration here...
.build()
)
.tooltipCharacteristics(
TooltipCharacteristics.Builder()
// e.g. .displayDataMissingTooltip(true)
// See "Configuring tooltips" below.
.build()
)
.build()
)
.build()
)
}
}
Configuring tooltips
TooltipCharacteristics controls which in-camera tooltips the SDK may show and how long each one stays on screen. Frame counts are measured in captured frames, not seconds.
TooltipCharacteristics.Builder()
.displayTooltips(true)
.displayDataMissingTooltip(true)
.build()
| Method | Default | Effect |
|---|---|---|
displayTooltips(Boolean) | true | Master switch. When false, no tooltips are shown. |
displayDataMissingTooltip(Boolean) | false | When true, shows the "No receipt detected" tooltip (recognizer_missing_data_tooltip) once minFramesMissingDataTooltip is reached. New in Paper Receipt SDK 2.3.1 — before this, the tooltip could not be enabled. |
maxFramesShowAlignHint(Int) | 1 | Number of captured frames for which the "Start next photo here" hint (recognizer_align_image_hint) remains visible. |
maxFramesMultiplePhotosHint(Int) | 0 | Number of captured frames for which the multiple-photos hint remains visible. 0 disables it. |
maxFramesLongReceiptAddPhotosHint(Int) | 3 | Number of captured frames for which the "Long receipt? Take multiple photos." hint (recognizer_camera_long_receipt_add_photos_hint) remains visible. |
minFramesMissingDataTooltip(Int) | 7 | Minimum captured frames before the missing-data tooltip becomes eligible. Has no effect unless displayDataMissingTooltip(true) is also set. |
Tooltip appearance is themed separately through blinkTooltipStyle — see Camera Theme Customization. Tooltip copy comes from string resources — see Camera UI Customization.