1. Kotlin
  2. Camera Controls

Camera Controls

The Camera Controls API gives you direct, observable control over the camera session that powers VisionCameraView. Instead of treating the camera as an opaque box that either works or doesn't, you get a full state snapshot on every meaningful change (status, active lens, zoom range, torch, focus mode, preview liveness), a capability query you can run before the camera is even started, and setters for zoom, torch, focus, and physical lens selection.

Use it when you need to build your own camera UI on top of the SDK: a zoom slider that knows the real min/max for the current device, a torch button that only renders when the device actually has a flash, a lens picker for ultra-wide/wide/telephoto, or a preview that stays hidden behind a placeholder until real pixels are flowing.

INFO

The io.packagex.visionsdk.camera.core package, setLensSelection(), setFocusPoint(), setFocusMode(), and the camera-state listener APIs are available since v2.5.0. setZoomRatio(), setLinearZoom(), setFlashTurnedOn(), and getCamera() existed before v2.5.0 but are now routed through the same camera core, which is what makes the state snapshots truthful.

From v2.7.0, rampZoomRatio() adds a duration-based zoom ramp (see Zoom), and VisionCameraView correctly releases and re-arms the camera across a detach/reattach cycle (see View Attach and Detach).


Observing Camera State

Register a CameraStateListener on VisionCameraView to receive a full CameraState snapshot on every change. Callbacks are always dispatched on the main thread.

        private val cameraStateListener =
    CameraStateListener { state ->
        Log.d("Camera", "status=${state.status} lens=${state.activeLens?.id} zoom=${state.zoomRatio}")
    }

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    visionCameraView.addCameraStateListener(cameraStateListener)
}

override fun onDestroy() {
    visionCameraView.removeCameraStateListener(cameraStateListener)
    super.onDestroy()
}

      

Because a listener only hears about future changes, read the current snapshot synchronously when you attach one (for example, to paint the initial state of a control bar). Gate anything that consumes the zoom range on status == RUNNING - see the warning below:

        val state = visionCameraView.currentCameraState()

// Only a RUNNING snapshot carries the real hardware zoom range.
if (state.status == CameraStatus.RUNNING) {
    zoomSlider.valueFrom = state.minZoomRatio
    zoomSlider.valueTo = state.maxZoomRatio
    zoomSlider.value = state.zoomRatio
}

// isTorchEnabled / focusMode are safe to read at any status.
torchButton.isChecked = state.isTorchEnabled

      
WARNING

Only a RUNNING snapshot carries real zoom values. Every non-RUNNING snapshot - IDLE, STARTING, INTERRUPTED, ERROR - hardcodes nominal placeholders: zoomRatio, minZoomRatio, and maxZoomRatio are all 1.0f, isTorchEnabled is false, focusMode is CONTINUOUS, and activeLens is null. These are the same nominal values iOS reports, and they are not hardware readings - never treat them as the device's real range.

The failure this causes is not subtle. The normal time to attach a listener and paint a control bar is onCreate(), before the camera is RUNNING, so minZoomRatio and maxZoomRatio are both 1.0. Feeding those to a Material Slider sets valueFrom == valueTo, which the slider rejects at layout time with IllegalStateException: valueFrom(1.0) must be smaller than valueTo(1.0) - a crash on first paint, not a cosmetic glitch. Set slider bounds only from a RUNNING snapshot, and use CameraCapabilities (which needs no session) if you want real hardware numbers before the camera starts.

CameraState

Field Type Meaning
status CameraStatus Session lifecycle stage. See table below.
error CameraError? Non-null only when status == ERROR. Fatal: the session is not running.
warning CameraError? Non-fatal advisory that accompanies a RUNNING session. Currently only LensUnavailable, emitted when a requested lens pin could not be honored and the session fell back.
facing LensFacing BACK or FRONT. Reflects the requested facing, not a physical camera id.
activeLens Lens? The lens the session actually bound. null for every non-RUNNING status.
zoomRatio Float Current zoom, wide-normalized (1.0 = wide at 1x). Already clamped to the live range. Nominal 1.0 for every non-RUNNING status.
minZoomRatio Float Smallest zoom the bound camera accepts. Below 1.0 on devices with an ultra-wide lens. Nominal 1.0 for every non-RUNNING status.
maxZoomRatio Float Largest zoom the bound camera accepts. Nominal 1.0 for every non-RUNNING status.
isTorchEnabled Boolean Observed torch state, read back from the camera rather than echoed from your request. Nominal false for every non-RUNNING status.
focusMode FocusMode CONTINUOUS, SINGLE, or LOCKED. Nominal CONTINUOUS for every non-RUNNING status.
isPreviewActive Boolean true once the currently-bound camera has delivered at least one frame. See Preview Liveness.

CameraStatus

Value Meaning
IDLE Nothing bound. The state before the first start, and after stopCamera().
STARTING A bind is in flight.
RUNNING A camera is bound. Note this does not guarantee frames are flowing yet.
INTERRUPTED The session lost the camera. On Android this has exactly two causes: the app was backgrounded, or the SDK's use cases were unbound externally. Recovery is always an explicit rebind - never an automatic regain. The backgrounded case recovers on its own because the SDK observes the process lifecycle and rebinds on foreground; the external-unbind case has no such event coming, so it stays INTERRUPTED until you call startCamera() (or CameraSession.start()).
ERROR The bind failed. error explains why.

CameraError

CameraError is a sealed class, so you can exhaustively handle it in a when:

Subclass Meaning
CameraError.PermissionDenied The CAMERA permission was not granted.
CameraError.LensUnavailable(requested: LensSelection) The requested lens could not be bound. Surfaces as a warning (session still runs on a fallback lens) or as an error if no camera at all could be resolved for the facing.
CameraError.ConfigurationFailed(cause: Throwable?) CameraX rejected the use-case configuration.
        when (val error = state.error) {
    is CameraError.PermissionDenied -> requestCameraPermission()
    is CameraError.LensUnavailable -> Log.w("Camera", "lens unavailable: ${error.requested}")
    is CameraError.ConfigurationFailed -> Log.e("Camera", "config failed", error.cause)
    null -> Unit
}

      

There is no time-based throttle in the Android SDK. One state change is one callback. Two mechanisms do reduce redundant callbacks, though, and neither is time-based:

  • Snapshots are de-duplicated before dispatch, so a value-identical re-emission never reaches your listener. Every field is compared by value except error and warning, which are compared by concrete CameraError subclass only - so two consecutive LensUnavailable warnings for different requested lenses are treated as the same condition and collapse into one callback. If you need the exact requested lens of every fallback, do not rely on getting a callback per distinct request.
  • Bursts of control-setter calls (a dragged zoom slider firing 60-120 times per second) are conflated into at most one scheduled apply-and-emit, so you get the latest value rather than every intermediate one.

You can still receive one callback per genuinely distinct value. If this callback drives an expensive view tree, throttle on your side.

INFO

The React Native wrapper adds its own 10 Hz throttle on top of this before forwarding state to JS. That throttle is a property of the RN bridge, not of the Kotlin SDK - a native Kotlin consumer does not get it.


Querying Capabilities

CameraCapabilities.snapshot(context) reads the device's camera hardware directly. It does not require a running camera session, so you can call it before startCamera() to decide which controls to render at all.

        val capabilities = CameraCapabilities.snapshot(this)

val backLenses = capabilities.lenses(LensFacing.BACK)
val hasTorch = capabilities.hasTorch(LensFacing.BACK)
val canTapToFocus = capabilities.supportsFocusPoint(LensFacing.BACK)
val stops = capabilities.zoomStops(LensFacing.BACK)   // e.g. [0.67, 1.0]

      
Method Returns Notes
snapshot(context) CameraCapabilities Static factory. Enumerates both top-level cameras and the physical sub-lenses of logical multi-cameras.
lenses(facing) List<Lens> Every optically-distinct lens for that facing. Empty if the facing does not exist.
zoomStops(facing) List<Float> Suggested slider detents, wide-normalized and sorted ascending.
hasTorch(facing) Boolean True if any lens on that facing reports a flash unit.
supportsFocusPoint(facing) Boolean True if that facing has at least one lens.

Lens

Field Type Meaning
id String Camera2 camera id. Stable for the device, not portable across devices.
kind LensKind ULTRA_WIDE, WIDE, TELEPHOTO, or UNKNOWN, derived from focal length and sensor size.
facing LensFacing The facing this lens belongs to. A lens only ever appears under its own facing.
minZoomRatio / maxZoomRatio Float This lens's own zoom range.
zoomSwitchPoints List<Float> Always empty on Android - CameraX/Camera2 exposes no lens switch-over API.
hasFlash Boolean Whether this lens has a flash unit.
isLogical Boolean True for a logical multi-camera that groups physical sub-lenses.
isPinnable Boolean Whether this lens can be pinned. Gate every pin on this.

Because zoomSwitchPoints is always empty, zoomStops(facing) returns at most two values on Android: the facing's minimum zoom (only when it is below 1.0) and 1.0. Treat it as "here are the optical anchor points we can honestly name", not as a complete detent list.


Zoom

Zoom on Android is wide-normalized: 1.0 means the wide lens at 1x. A device with an ultra-wide reports a minZoomRatio below 1.0 (for example 0.67 on a Pixel 7), and zooming out past 1.0 is how you reach the ultra-wide field of view.

        // Absolute ratio
visionCameraView.setZoomRatio(2.0F)

// Linear 0..1 across the device's range (what a slider usually wants)
visionCameraView.setLinearZoom(0.5F)

      

setZoomRatio() and setLinearZoom() are two views of the same desired state - whichever you called most recently wins.

Requests are stored unclamped and clamped against the live camera's real range only at apply time. That means calling setZoomRatio(5.0F) before startCamera() is honored: the value is retained and applied once the true range is known, instead of being truncated against a guessed range.

For reading the current values, prefer CameraState (zoomRatio, minZoomRatio, maxZoomRatio) - it is always populated while RUNNING. The direct getters exist as well, but return null before a camera is bound:

        val current: Float? = visionCameraView.getCurrentZoomRatio()
val min: Float? = visionCameraView.getMinZoomRatioAvailable()
val max: Float? = visionCameraView.getMaxZoomRatioAvailable()
val linear: Float? = visionCameraView.currentLinearZoom()

      

Ramping to a Zoom Ratio

Available since v2.7.0. setZoomRatio() jumps immediately; rampZoomRatio() transitions smoothly over a duration instead:

        visionCameraView.rampZoomRatio(4.0F, 400)

      

CameraX has no native ramp primitive (unlike AVFoundation's rate-based ramp on iOS), so this is driven by an internal ticker running at roughly 60 steps per second that walks the zoom ratio from its current value to the target over durationMs. A new rampZoomRatio() call issued while one is already in flight starts from the actual current zoom rather than the previous target, and a setZoomRatio() call mid-ramp supersedes it.


Torch

        visionCameraView.setFlashTurnedOn(true)

      

Check CameraCapabilities.hasTorch(facing) before rendering a torch control, and read the resulting state back from CameraState.isTorchEnabled rather than tracking your own boolean - isTorchEnabled reflects what the camera actually reports, so it stays truthful if the request could not be honored.

Like zoom, a torch request made before the camera is bound is retained and applied on bind.


Focus

        // Tap-to-focus: x/y are VIEW-normalized (0..1) in DISPLAY orientation.
// (0,0) is the top-left of what the user sees on screen; (1,1) is bottom-right.
visionCameraView.setFocusPoint(0.5F, 0.5F)

visionCameraView.setFocusMode(FocusMode.CONTINUOUS)

      

setFocusPoint() takes view coordinates, not sensor or surface-plane coordinates. The SDK compensates internally for sensor mounting rotation and front-camera mirroring, so you can pass a touch position divided by the view's width and height directly. Values outside 0..1 are clamped.

FocusMode values:

Value Behavior
CONTINUOUS Continuous autofocus. The default.
SINGLE One-shot autofocus.
LOCKED Focus held at its current position.

The SDK also ships a built-in gesture layer if you don't want to wire your own: enableTapToFocus() / disableTapToFocus() and enablePinchPanToZoom() / disablePinchPanToZoom(). Both are off by default.

The SDK installs its own OnTouchListener to drive those gestures - not on VisionCameraView itself, but on the internal PreviewView it adds as a MATCH_PARENT child. That child listener returns true for every event, so it consumes the whole touch stream. Do not call setOnTouchListener() on VisionCameraView: it does not replace anything and the built-in gestures keep working, but a parent only receives events no child consumed, so your listener silently never fires. If you want to drive setFocusPoint() from your own touch handling, place a transparent overlay View above the camera view and attach your listener there.

Note also that the built-in tap-to-focus gesture talks to CameraX directly rather than going through setFocusPoint(), so a focus applied by the gesture is invisible to the SDK's tracked state.

setFocusPoint() is a one-shot request, unlike the other controls. Calling it before the camera is bound retains the point and applies it on the first bind, after which it is cleared - it is not re-applied on later rebinds. setFocusMode(), by contrast, is persistent desired state and is re-applied on every bind.


Lens Selection

By default the session lets the platform pick a lens for the requested facing (LensSelection.Auto). Pinning restricts the session to one specific physical lens - useful when you want a guaranteed ultra-wide for scanning a wide pallet label, or a guaranteed wide when the platform keeps drifting to a crop sensor.

        val capabilities = CameraCapabilities.snapshot(this)

val ultraWide = capabilities
    .lenses(LensFacing.BACK)
    .firstOrNull { it.kind == LensKind.ULTRA_WIDE && it.isPinnable }

if (ultraWide != null) {
    visionCameraView.setLensSelection(LensSelection.Pin(ultraWide))
} else {
    visionCameraView.setLensSelection(LensSelection.Auto)
}

      

LensSelection is a sealed interface with two cases:

Case Meaning
LensSelection.Auto Let the platform choose. The default, and the value to use to unpin.
LensSelection.Pin(lens: Lens) Bind this exact lens.

LensSelection.Pin requires lens.isPinnable == true and throws IllegalArgumentException at construction time otherwise. Always filter on isPinnable before constructing a pin. A lens is pinnable when it is a physical sub-lens of a logical multi-camera, or when its facing has more than one separately-enumerable top-level camera - pinning the only camera on a facing would be indistinguishable from Auto, so it is reported as not pinnable.

A pin that passes isPinnable but cannot actually be bound at runtime is not an error. The session falls back to the plain facing selector, keeps status == RUNNING, and surfaces CameraError.LensUnavailable(requested) in CameraState.warning:

        CameraStateListener { state ->
    val warning = state.warning
    if (warning is CameraError.LensUnavailable) {
        // Non-fatal: the camera is running, just not on the lens you asked for.
        showToast("Requested lens unavailable, using default")
    }
}

      

Facing

Facing is set through CameraSettings, not through LensSelection:

        visionCameraView.setCameraSettings(
    CameraSettings(cameraLensFace = CameraLensFace.Front)
)

      

A pin is facing-specific, so switching facing resets the lens selection back to Auto. If you want a pinned lens on the new facing, re-resolve it against CameraCapabilities and call setLensSelection() again. Changing facing while the camera is bound also performs a full stop/start internally; changing any other CameraSettings field (such as nthFrameToProcess) does not.


Preview Liveness

isPreviewActive is a first-frame signal, not a session-started signal. It becomes true only once the currently-bound camera has delivered at least one frame to the analysis pipeline. It is false initially and while IDLE / STARTING, and is reset to false on every rebind, stopCamera(), interruption, and error.

This is the signal to use to reveal a preview. status == RUNNING only means a camera was bound - the surface may still be black for a few frames, which is exactly the window where users see a flash of black behind your UI.

        CameraStateListener { state ->
    val ready = state.status == CameraStatus.RUNNING && state.isPreviewActive
    placeholderView.isVisible = !ready
    visionCameraView.alpha = if (ready) 1F else 0F
}

      

isPreviewActive mirrors iOS's VSDKCameraState.isPreviewActive field-for-field, so cross-platform code can share the same reveal logic.


Lifecycle and Control Persistence

Both startCamera() and stopCamera() behave as before; what v2.5.0 adds is a well-defined rule for what happens to your control values across a rebind.

The session distinguishes an input-device change (the facing changed, or the pinned lens id changed) from an ordinary rebind. The persistent controls are zoom, torch, and focus mode; a focus point is one-shot and is never re-applied.

Transition Zoom / torch / focus mode
rescan(), or stopCamera() + startCamera() with the same facing and lens Preserved and re-applied to the new binding.
Facing change Reset to defaults (zoom 1.0, torch off, focus mode CONTINUOUS).
Pinned lens change, including pinning or unpinning Reset to defaults.
First-ever startCamera() Preserved - values you set before the first bind are honored.

So a scan loop that stops and restarts the camera between captures keeps the user's zoom and torch. But if your UI owns those values and you change facing or lens, you must re-assert them yourself after the camera comes back:

        CameraStateListener { state ->
    if (state.status == CameraStatus.RUNNING && lastStatus != CameraStatus.RUNNING) {
        // Re-assert UI-owned control values after a (re)bind.
        visionCameraView.setFlashTurnedOn(uiState.torchEnabled)
        visionCameraView.setZoomRatio(uiState.zoomRatio)
        visionCameraView.setFocusMode(uiState.focusMode)
    }
    lastStatus = state.status
}

      

View Attach and Detach

Available since v2.7.0. VisionCameraView now releases the camera when it detaches from the window, and re-arms it automatically when it reattaches. This closes a leak where a detached view - for example, one left behind after a fragment transaction or a ViewPager swipe - kept the camera bound indefinitely.

This interacts predictably with the rest of the lifecycle:

  • If you called stopCamera() explicitly before the view detached, it stays stopped on reattach - detach/reattach no longer overrides an explicit stop with auto-resume.
  • An in-flight rampZoomRatio() ramp is cancelled before the camera unbinds on detach, instead of continuing to tick against a camera that is no longer there.
  • A redundant startCamera() call on an already-bound camera no longer releases the active analyzer out from under a live frame stream.

Gotchas

  • isPreviewActive is first-frame, not session-started. RUNNING alone is not enough to reveal a preview without risking a black flash. Gate on status == RUNNING && isPreviewActive.

  • Zoom is wide-normalized. 1.0 is wide at 1x, not "minimum zoom". A device's minZoomRatio can be below 1.0; do not assume a 1.0..max slider range.

  • Changing lens selection performs a full unbind/rebind, which resets isPreviewActive to false. Expected, and it happens on iOS too - a fresh RUNNING state always means the binding just changed, so no frame has been delivered for this bind yet. Do not treat the true -> false transition as an error or tear down your UI - just re-run your reveal logic when it flips back.

  • A lens change also resets zoom, torch, and focus mode (it is an input-device change). A plain rescan() does not. Re-assert UI-owned values on the RUNNING transition.

  • setOnTouchListener() on VisionCameraView silently never fires. The SDK's own listener for tap-to-focus and pinch-to-zoom lives on the internal MATCH_PARENT PreviewView child and returns true for every event, so the child consumes the whole touch stream before the parent sees it. Your listener is not overwritten and the built-in gestures are not broken - your callback simply never runs. Use a transparent overlay View above the camera view for your own touch handling.

  • Pinning does not narrow the reported zoom range on Android. This differs from iOS. CameraX reports the same logical-camera zoom range regardless of a physical pin - on a Pixel 7, a verified ultra-wide pin still reports 0.67..8.0. Lens.minZoomRatio / Lens.maxZoomRatio from CameraCapabilities are per-lens hardware values and can therefore differ from the live CameraState range while pinned. Zoom requests while pinned are accepted without error; what they do optically to a single pinned sensor is device-dependent.

  • LensSelection.Pin throws on a non-pinnable lens. IllegalArgumentException at construction. Always filter on isPinnable. A runtime bind failure, by contrast, is non-fatal: fallback plus a LensUnavailable warning.

  • Switching facing silently drops the pin. Expected, since a lens id only exists under one facing. Re-resolve and re-pin if you need it.

  • zoomSwitchPoints is always empty on Android, so zoomStops() returns at most [min, 1.0]. There is no Camera2 API to report lens switch-over points honestly, and the SDK does not invent them.

  • Values set directly on the raw CameraX object are not tracked. getCamera() remains available as an escape hatch, but anything you set via camera.cameraControl is invisible to the SDK's own desired-state store and will be overwritten by the SDK's values on the next rebind. Prefer the VisionCameraView setters.

  • Listener callbacks are de-duplicated and conflated, but never throttled. During a zoom drag you can still receive one callback per genuinely distinct value. The 10 Hz throttle you may have seen referenced belongs to the React Native bridge, not to this SDK. Throttle on your side if the callback drives expensive rendering.

  • Cross-platform parity note. The reset-on-input-device-change rule, the wide-normalized zoom scale, isPreviewActive's first-frame semantics (including the true -> false -> true dip on a lens change), and the nominal values in non-RUNNING snapshots all match iOS exactly. The one known Android divergence is that pinning does not narrow the reported zoom range - it does on iOS. Cross-platform reveal logic needs no Android special case.


Advanced: CameraSession

VisionCameraView delegates to a public CameraSession, which you can use directly if you need a camera pipeline without the scanning view. It exposes the same state as a StateFlow, which is often more convenient in Compose or a coroutine-based ViewModel:

        val session = CameraSession(context)
session.bindTo(this)   // stops the session on ON_DESTROY

lifecycleScope.launch {
    session.state.collect { state ->
        // Same CameraState snapshots as the listener API.
    }
}

session.start(
    CameraConfiguration.Builder()
        .facing(LensFacing.BACK)
        .lens(LensSelection.Auto)
        .rotationLock(RotationLock.FOLLOW_DISPLAY)
        .build()
)

session.controller.setZoom(2.0F)
session.controller.setTorchEnabled(true)
session.controller.setFocusPoint(0.5F, 0.5F)
session.controller.setFocusMode(FocusMode.SINGLE)

      
Member Description
state: StateFlow<CameraState> Conflated stream of snapshots. Safe to read from any thread.
controller: CameraController setZoom, setLinearZoom, setTorchEnabled, setFocusPoint, setFocusMode, currentLinearZoom.
addStateListener / removeStateListener Java-friendly callback alternative to the flow. Dispatched on the main thread.
start() / start(configuration) Bind the camera.
update(configuration) Change facing, lens, or rotation lock. Rebinds only while RUNNING. While IDLE or INTERRUPTED it merges into the desired configuration and re-emits the same status against the new facing without binding; while STARTING it is held and applied when the in-flight bind lands; from ERROR it replaces the configuration and retries the bind.
stop() Unbind and emit the IDLE snapshot.
bindTo(owner) / unbind() Tie the session's teardown to a LifecycleOwner.
previewView: CameraPreviewView The preview surface for this session.

RotationLock controls the orientation pin: FOLLOW_DISPLAY (default), LOCKED_PORTRAIT, or LOCKED_LANDSCAPE. When using VisionCameraView, set this through CameraSettings(orientationMode = ...) instead.


Sample Code

A complete control bar wired to camera state:

        class ScannerActivity : AppCompatActivity() {

    private lateinit var visionCameraView: VisionCameraView
    private var lastStatus: CameraStatus? = null

    // UI-owned desired state, re-asserted after every (re)bind.
    private var desiredZoom = 1.0F
    private var desiredTorch = false

    private val cameraStateListener =
        CameraStateListener { state ->
            // 1. Reveal the preview only once real frames are flowing.
            val ready = state.status == CameraStatus.RUNNING && state.isPreviewActive
            findViewById<View>(R.id.placeholder).isVisible = !ready

            // 2. Keep the zoom slider bounded by the device's real range.
            if (state.status == CameraStatus.RUNNING) {
                val slider = findViewById<Slider>(R.id.zoomSlider)
                slider.valueFrom = state.minZoomRatio
                slider.valueTo = state.maxZoomRatio
                slider.value = state.zoomRatio
            }

            // 3. Torch state comes from the camera, not from our own boolean.
            findViewById<MaterialButton>(R.id.torchButton).isChecked = state.isTorchEnabled

            // 4. Non-fatal lens fallback.
            if (state.warning is CameraError.LensUnavailable) {
                Toast.makeText(this, "Requested lens unavailable", Toast.LENGTH_SHORT).show()
            }

            // 5. Fatal errors.
            when (val error = state.error) {
                is CameraError.PermissionDenied -> requestCameraPermission()
                is CameraError.ConfigurationFailed -> Log.e("Camera", "config failed", error.cause)
                is CameraError.LensUnavailable -> Log.e("Camera", "no camera for ${error.requested}")
                null -> Unit
            }

            // 6. Re-assert UI-owned values on every RUNNING transition, since a
            //    facing or lens change resets them to defaults.
            if (state.status == CameraStatus.RUNNING && lastStatus != CameraStatus.RUNNING) {
                visionCameraView.setZoomRatio(desiredZoom)
                visionCameraView.setFlashTurnedOn(desiredTorch)
            }
            lastStatus = state.status
        }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_scanner)
        visionCameraView = findViewById(R.id.visionCameraView)

        // Render controls based on real hardware, before starting the camera.
        val capabilities = CameraCapabilities.snapshot(this)
        findViewById<View>(R.id.torchButton).isVisible =
            capabilities.hasTorch(LensFacing.BACK)

        val lensPicker = findViewById<View>(R.id.lensPicker)
        val pinnableLenses = capabilities.lenses(LensFacing.BACK).filter { it.isPinnable }
        lensPicker.isVisible = pinnableLenses.isNotEmpty()

        findViewById<Slider>(R.id.zoomSlider).addOnChangeListener { _, value, fromUser ->
            if (fromUser) {
                desiredZoom = value
                visionCameraView.setZoomRatio(value)
            }
        }

        findViewById<MaterialButton>(R.id.torchButton).addOnCheckedChangeListener { _, checked ->
            desiredTorch = checked
            visionCameraView.setFlashTurnedOn(checked)
        }

        // Built-in gestures. Do NOT call setOnTouchListener() on VisionCameraView -
        // the SDK's listener sits on the internal PreviewView child and consumes every
        // event, so yours would never fire. To drive setFocusPoint() from your own touch
        // handling, put a transparent overlay View on top instead.
        visionCameraView.enableTapToFocus()
        visionCameraView.enablePinchPanToZoom()

        // Optional: focus the center of the frame explicitly.
        findViewById<View>(R.id.focusCenterButton).setOnClickListener {
            visionCameraView.setFocusPoint(0.5F, 0.5F)
        }

        visionCameraView.addCameraStateListener(cameraStateListener)

        visionCameraView.configure(
            detectionMode = DetectionMode.BarcodeOrQRCode,
            scanningMode = ScanningMode.Auto,
            isMultipleScanEnabled = false
        )
        visionCameraView.startCamera()
    }

    private fun pinUltraWide() {
        val lens = CameraCapabilities
            .snapshot(this)
            .lenses(LensFacing.BACK)
            .firstOrNull { it.kind == LensKind.ULTRA_WIDE && it.isPinnable }
            ?: return

        // This rebinds the session: isPreviewActive drops to false, and zoom/torch
        // reset to defaults before the listener above re-asserts them.
        visionCameraView.setLensSelection(LensSelection.Pin(lens))
    }

    private fun requestCameraPermission() {
        // Your own permission flow (for example an ActivityResultContracts.RequestPermission
        // launcher), then call visionCameraView.startCamera() again once granted.
    }

    override fun onDestroy() {
        visionCameraView.removeCameraStateListener(cameraStateListener)
        super.onDestroy()
    }
}

      

Imports

Imports used by the sample above and by the shorter snippets on this page:

        import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.isVisible
import com.google.android.material.button.MaterialButton
import com.google.android.material.slider.Slider
import io.packagex.visionsdk.camera.core.CameraCapabilities
import io.packagex.visionsdk.camera.core.CameraError
import io.packagex.visionsdk.camera.core.CameraState
import io.packagex.visionsdk.camera.core.CameraStateListener
import io.packagex.visionsdk.camera.core.CameraStatus
import io.packagex.visionsdk.camera.core.FocusMode
import io.packagex.visionsdk.camera.core.Lens
import io.packagex.visionsdk.camera.core.LensFacing
import io.packagex.visionsdk.camera.core.LensKind
import io.packagex.visionsdk.camera.core.LensSelection
import io.packagex.visionsdk.core.DetectionMode
import io.packagex.visionsdk.core.ScanningMode
import io.packagex.visionsdk.ui.views.VisionCameraView

      

The CameraSession section additionally needs io.packagex.visionsdk.camera.core.CameraSession, CameraConfiguration, and RotationLock, plus androidx.lifecycle.lifecycleScope; the facing snippet needs io.packagex.visionsdk.config.CameraSettings and io.packagex.visionsdk.core.CameraLensFace.