Petnow LogoPetnow

Fully Custom UI

How to build a 100% custom camera UI with CameraController and your own SurfaceView.


Before starting this guide, read the UI Module Overview and Basic Usage.

Overview

The CameraView from Basic Usage draws both the preview and the default tracking UI for you. By contrast, if you want to draw everything yourself, including the tracking overlay, you can use only CameraController without CameraView and provide the preview Surface yourself.

This approach is well suited to the following cases:

  • A fully custom screen based on Jetpack Compose
  • Bridges such as React Native / Flutter (the Petnow React Native package actually uses this approach)
  • Cases where you need to draw your own overlay instead of using the SDK's default tracking UI

CameraView Approach vs. Fully Custom Approach

FeatureCameraView (default)Fully custom
Preview SurfaceManaged internally by CameraViewCreate your own SurfaceView and call attachPreviewSurface()
Default tracking UIBuilt-inNone — implement it yourself
Camera permissionHandled by the hostHandled by the host
Receiving statusV2 listener / getState()V2 listener / getState()
UI flexibilityAdd overlays100% custom

In the fully custom approach, the SDK does not draw any UI. You must render progress, status, and tracking boxes yourself from getState() or the V2 listener values.


Architecture

CameraController manages the entire lifecycle of the camera/detection but is not involved in UI rendering. You draw the preview with your own SurfaceView and receive status through the StateFlow (getState()) and the PetnowCameraDetectionListenerV2 callbacks.


Basic Integration

Step 1: Create the Controller + Register a Listener

import io.petnow.ui.CameraController
import io.petnow.ui.CameraResult
import io.petnow.ui.config.LicenseInfo
import io.petnow.ui.status.DetectionStatus
import io.petnow.callback.PetnowCameraDetectionListenerV2

val controller = CameraController(
    context.applicationContext,
    LicenseInfo(apiKey = "YOUR_API_KEY", isDebugMode = false),
    scope,
).apply {
    setDetectionListenerV2(object : PetnowCameraDetectionListenerV2 {
        override fun onDetectionStatus(primaryDetectionStatus: DetectionStatus) { /* render it yourself */ }
        override fun onDetectionProgress(progress: Int) { /* 0~100 */ }
        override fun onDetectionFinished(result: CameraResult) { /* handle the result */ }
    })
}

Step 2: Provide the SurfaceView + Start the Session

Pass the Surface of your own SurfaceView to attachPreviewSurface(). The camera opens once the Surface is ready. Since initializeCamera() waits until the camera is open, you can call startDetection() afterward.

import android.view.SurfaceHolder
import android.view.SurfaceView
import io.petnow.ui.config.DetectionConfiguration
import io.petnow.ui.config.DetectionPurpose
import io.petnow.ui.config.PetSpecies
import java.util.UUID

val config = DetectionConfiguration(
    species = PetSpecies.DOG,
    purpose = DetectionPurpose.PET_PROFILE_REGISTRATION,
    enableFakeDetection = false,
)
val captureSessionId: UUID = /* captureSessionId received from the server */

val surfaceView = SurfaceView(context).apply {
    holder.addCallback(object : SurfaceHolder.Callback {
        override fun surfaceCreated(holder: SurfaceHolder) {
            controller.attachPreviewSurface(holder.surface) 
            scope.launch {
                controller.initializeCamera(config, captureSessionId) 
                controller.startDetection() 
            }
        }
        override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, ht: Int) {}
        override fun surfaceDestroyed(holder: SurfaceHolder) {
            controller.detachPreviewSurface() // camera closes
        }
    })
}

The host handles permissions

CameraController does not request the camera permission. Declare the CAMERA permission in AndroidManifest.xml and request the runtime permission yourself before starting the session.

Step 3: Observe Status (StateFlow)

scope.launch {
    controller.state.collect { state ->
        // state.progressPercent: progress (0~100)
        // state.detectionStatusList: list of detection statuses for the current frame (PetnowDetectionStatus)
        // state.currentDetectionResult: nose/face BoundingBox (DetectionResult?) — for rendering the tracking box
        // state.isDetectionFinished: whether detection is complete
    }
}

Step 4: Release Resources

controller.release()  // fully releases native detector/sound pool (terminates)

finalizeCamera() stops only the camera session and lets the controller be reused. When you leave the screen for good, clean up the native resources too with release().


Jetpack Compose Example

@Composable
fun PetnowCameraScreen(captureSessionId: UUID) {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()

    val controller = remember {
        CameraController(
            context.applicationContext,
            LicenseInfo(apiKey = "YOUR_API_KEY", isDebugMode = false),
            scope,
        )
    }
    val state by controller.state.collectAsState()

    val config = remember {
        DetectionConfiguration(
            species = PetSpecies.DOG,
            purpose = DetectionPurpose.PET_PROFILE_REGISTRATION,
            enableFakeDetection = false,
        )
    }

    LaunchedEffect(Unit) {
        controller.setDetectionListenerV2(object : PetnowCameraDetectionListenerV2 {
            override fun onDetectionStatus(primaryDetectionStatus: DetectionStatus) { /* ... */ }
            override fun onDetectionProgress(progress: Int) { /* ... */ }
            override fun onDetectionFinished(result: CameraResult) { /* ... */ }
        })
    }

    // The screen owns the controller, so dispose = full release().
    DisposableEffect(Unit) {
        onDispose { controller.release() }
    }

    Column(modifier = Modifier.fillMaxSize()) {
        // Preview: wrap your own SurfaceView in an AndroidView.
        AndroidView(
            factory = { ctx ->
                SurfaceView(ctx).apply {
                    holder.addCallback(object : SurfaceHolder.Callback {
                        override fun surfaceCreated(holder: SurfaceHolder) {
                            controller.attachPreviewSurface(holder.surface)
                            scope.launch {
                                controller.initializeCamera(config, captureSessionId)
                                controller.startDetection()
                            }
                        }
                        override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, ht: Int) {}
                        override fun surfaceDestroyed(holder: SurfaceHolder) {
                            controller.detachPreviewSurface()
                        }
                    })
                }
            },
            modifier = Modifier.weight(1f),
        )

        // Progress bar (rendered directly from state)
        LinearProgressIndicator(
            progress = { state.progressPercent / 100f },
            modifier = Modifier.fillMaxWidth(),
        )

        // Detection status text
        Text(
            text = state.detectionStatusList.firstOrNull()?.name ?: "",
            modifier = Modifier.padding(16.dp),
            textAlign = TextAlign.Center,
        )
    }
}

Camera Switch / Pause / Resume

Uses the same controller API as the CameraView approach.

controller.switchCamera()    // returns a Result (front ↔ back)
controller.pauseDetection()  // pause (returns nothing)
controller.resumeDetection() // resume (returns a Result)
controller.startDetection()  // re-capture: reset progress and start a new session (returns a Result)

Sound Playback

import io.petnow.ui.sound.SoundType

val streamId = controller.playSound(SoundType.RANDOM)
controller.stopSound(streamId)

For details on the available sound types, see the Sound Guide.


API Summary

MethodDescription
CameraController(context, license, scope)Create the controller
setDetectionListenerV2(listener)Register callbacks for detection status/progress/result
attachPreviewSurface(surface)Connect your own Surface (camera opens)
detachPreviewSurface()Detach the Surface (camera closes)
initializeCamera(config, captureSessionId)Prepare the session (suspend)
startDetection()Start/restart detection (Result)
pauseDetection() / resumeDetection()Pause / resume detection
switchCamera()Switch front/back (Result)
getState()Observe StateFlow<DetectionState>
setBracketingMode(enabled)Configure bracketing mode
playSound(type) / stopSound(id)Play/stop sound
finalizeCamera()Stop the camera session (reusable)
release()Fully release native resources (terminate)

Next Steps

On this page