Skip to main content

Android - Camera Module Interface

The CameraModule interface lets your module replace the SDK's built-in camera capture flow with a custom native experience. It is most commonly used to integrate third-party check-capture SDKs or a fully custom capture UI for Mobile Remote Deposit Capture (MRDC), but the same interface can power any flow that needs to return images to the Q2 application.

The module is built on top of Android's ActivityResultContract API. The Q2 application launches your Activity through the contract you provide, and your parseResult implementation converts the activity result into a typed CameraModuleResult that the application understands.

How it fits into the application

Your CameraModule is registered exactly like any other module — through an entry in settings.json. At runtime the SDK discovers the implementation, instantiates it, and uses it whenever a check capture is requested.

The flow looks like this:

  1. The user starts a deposit-check flow inside the Q2 web view.
  2. The Q2 application looks for a registered CameraModule. If one exists, it builds a CameraModuleConfiguration and asks your contract.createIntent() to launch your Activity.
  3. Your Activity captures the image(s) and finishes with a result Intent that carries the image paths (or an error).
  4. Your contract.parseResult() turns that Intent into a CameraModuleResult.
  5. The Q2 application either uses the captured images directly, or — if you want to take ownership of the network submission — calls submitCapturedImages() on your module.

If your module returns CameraModuleResult.Failed with the LICENSE_ERROR error code, the Q2 application automatically falls back to the legacy mrdcCamera mob module. Any other failure is reported as a capture error.

Registering your module

Your module is registered as an sdk_module in the DevApp's devapp/src/main/assets/conf/settings.json (production builds use the equivalent production config). The class referenced by classPath must implement CameraModule.

{
"name": "myCustomCamera",
"identifier": "com.acme.camera",
"classPath": "com.acme.camera.MyCustomCameraModule",
"moduleType": "mrdcCamera",
"data": {
"licenseKey": "<your-vendor-license-key>"
},
"enabled": true
}

Notes:

  • Only one CameraModule is active at runtime. If multiple modules in settings.json implement CameraModule, the first one registered wins and the rest are ignored — a warning is logged with the skipped module name.
  • moduleType should be set to "mrdcCamera" so the module is correctly grouped with other check-capture modules. The SDK still locates your implementation by its CameraModule interface, but the type keeps settings.json consistent with the legacy mob-module entries.
  • Anything you put under data is passed to your module through SdkModuleConfig and is the standard place to store license keys or other build-time configuration. See Configuring settings.json.

The CameraModule interface

interface CameraModule {
val contract: ActivityResultContract<CameraModuleConfiguration, CameraModuleResult?>

fun registerForCameraResult(
owner: ActivityResultRegistryOwner,
onResult: (CameraModuleResult?) -> Unit
): ActivityResultLauncher<CameraModuleConfiguration>

fun startCameraFlow(
context: Context,
configuration: CameraModuleConfiguration,
launcher: ActivityResultLauncher<CameraModuleConfiguration>
)

fun submitCapturedImages(
captureResult: CameraModuleResult.Success,
callback: CameraModuleSubmissionCallback
): Boolean
}

registerForCameraResult and startCameraFlow are provided as default helpers — you generally only need to provide contract and submitCapturedImages.

contract

The ActivityResultContract that drives the whole flow. Implementations must override:

  • createIntent(context, input) — return the Intent that launches your camera Activity. The input parameter is a CameraModuleConfiguration (see below).
  • parseResult(resultCode, intent) — convert the activity result back into a CameraModuleResult. Return null only if the result represents an unrecoverable cancellation that should be treated the same as RESULT_CANCELED; in most flows you should return a typed CameraModuleResult.Success or CameraModuleResult.Failed.

submitCapturedImages(...)

Called when the user confirms a capture and the deposit is ready to be submitted.

  • Return false to let the Q2 application submit the captured images using its built-in deposit-submission flow. This is the right choice for the vast majority of integrations — your module just provides the capture UX.
  • Return true if your module needs to take full ownership of submission (for example, you need to pre-process or sign the images before they are sent). When you return true you must eventually invoke either callback.onSubmissionSuccess() or callback.onSubmissionFailed(error) from a background or main-thread context — failing to do so leaves the deposit flow hanging.
interface CameraModuleSubmissionCallback {
fun onSubmissionSuccess()
fun onSubmissionFailed(error: String)
}

Configuration & result types

CameraModuleConfiguration

data class CameraModuleConfiguration(
val cameraModuleCaptureType: CameraModuleCaptureType = CameraModuleCaptureType.Front,
val additionalConfig: Map<String, Any> = emptyMap()
)
  • cameraModuleCaptureType tells your camera which check side(s) to capture.
  • additionalConfig is a free-form map populated by the Q2 application from the current RdcConfigurationEntity. It is the recommended place to read per-deposit values such as the camera license key (additionalConfig["CameraLicense"]), per-item amount limits, multi-step flags, etc.

CameraModuleCaptureType

sealed class CameraModuleCaptureType(val key: String) {
object Front : CameraModuleCaptureType("com.q2.camera.capture.front")
object Back : CameraModuleCaptureType("com.q2.camera.capture.back")
}

The Q2 application currently issues either Front or Back. Your Activity should be able to handle both. Front-and-back combined captures are issued as two sequential calls.

CameraModuleResult

sealed interface CameraModuleResult : Parcelable {
data class Success(val frontImage: File?, val backImage: File?) : CameraModuleResult
data class Failed(val errorCode: String, val message: String) : CameraModuleResult
}
  • For a single-side capture, populate the matching field and leave the other null.
  • The File objects must reference real, readable files on disk — the SDK reads them when forwarding the deposit.

CameraModuleConstants

Constant keys for building and parsing your result Intent. Use these directly so you remain compatible with future SDK versions:

object CameraModuleConstants {
const val ERROR_CODE_KEY = "com.q2.camera.error.code"
const val ERROR_MESSAGE_KEY = "com.q2.camera.error.message"
const val LICENSE_KEY = "licenseKey"
const val FRONT_IMAGE_RESULT_KEY = "com.q2.camera.result.front_image"
const val BACK_IMAGE_RESULT_KEY = "com.q2.camera.result.back_image"

object ErrorCodes {
const val USER_CANCELLED = "USER_CANCELLED"
const val ACTIVITY_FAILED = "ACTIVITY_FAILED"
const val NO_DATA = "NO_DATA"
const val NO_IMAGES = "NO_IMAGES"
const val LICENSE_ERROR = "LICENSE_ERROR"
}
}
Error codeWhen to return itSDK behaviour
USER_CANCELLEDThe user backed out without capturing.Treated as a normal cancellation; no error UI shown.
ACTIVITY_FAILEDYour Activity finished with a non-OK result code.Generic capture-failure error shown.
NO_DATAYour Activity finished with RESULT_OK but returned a null Intent.Generic capture-failure error shown.
NO_IMAGESThe Intent had no front or back image path.Generic capture-failure error shown.
LICENSE_ERRORThe vendor SDK rejected the license at runtime.The SDK falls back to the legacy mrdcCamera mob module automatically.

You can also return a custom errorCode string. Anything other than the values above is surfaced to the deposit flow as a generic error with your message text.

End-to-end example

The example below stitches the pieces together: an SdkModuleConfig-aware CameraModule, a custom Activity (skeleton only), and full result/error handling.

class MyCustomCameraModule(private val sdkUtils: SdkUtils) : CameraModule {

private val licenseKey: String =
sdkUtils.getSdkModuleConfig().data
?.optString(CameraModuleConstants.LICENSE_KEY)
.orEmpty()

override val contract =
object : ActivityResultContract<CameraModuleConfiguration, CameraModuleResult?>() {

override fun createIntent(
context: Context,
input: CameraModuleConfiguration
): Intent {
return MyCameraActivity.newIntent(
context = context,
captureType = input.cameraModuleCaptureType,
licenseKey = licenseKey,
extras = input.additionalConfig
)
}

override fun parseResult(
resultCode: Int,
intent: Intent?
): CameraModuleResult? = parseCameraResult(resultCode, intent)
}

/**
* Return false to let the Q2 application submit the deposit using its
* built-in flow. Return true only if you need to take ownership of the
* upload yourself.
*/
override fun submitCapturedImages(
captureResult: CameraModuleResult.Success,
callback: CameraModuleSubmissionCallback
): Boolean = false

private fun parseCameraResult(resultCode: Int, intent: Intent?): CameraModuleResult? {
if (resultCode == Activity.RESULT_CANCELED) {
return CameraModuleResult.Failed(
CameraModuleConstants.ErrorCodes.USER_CANCELLED,
"User cancelled camera capture"
)
}
if (resultCode != Activity.RESULT_OK) {
return CameraModuleResult.Failed(
CameraModuleConstants.ErrorCodes.ACTIVITY_FAILED,
"Camera failed with code: $resultCode"
)
}
if (intent == null) {
return CameraModuleResult.Failed(
CameraModuleConstants.ErrorCodes.NO_DATA,
"No data returned from camera"
)
}

// Vendor reported a license problem — let the SDK fall back to legacy.
intent.getStringExtra(CameraModuleConstants.ERROR_CODE_KEY)?.let {
val message = intent.getStringExtra(CameraModuleConstants.ERROR_MESSAGE_KEY)
?: "Unknown camera error"
return CameraModuleResult.Failed(
CameraModuleConstants.ErrorCodes.LICENSE_ERROR,
message
)
}

val front = intent.getStringExtra(CameraModuleConstants.FRONT_IMAGE_RESULT_KEY)
?.let(::File)
val back = intent.getStringExtra(CameraModuleConstants.BACK_IMAGE_RESULT_KEY)
?.let(::File)

return if (front == null && back == null) {
CameraModuleResult.Failed(
CameraModuleConstants.ErrorCodes.NO_IMAGES,
"No images were captured"
)
} else {
CameraModuleResult.Success(front, back)
}
}
}

A minimal Activity for the example above only needs to:

  1. Read CameraModuleCaptureType from the launching Intent and capture that side.
  2. On success, write the captured image to a file in your app's cache directory and put its absolute path under FRONT_IMAGE_RESULT_KEY or BACK_IMAGE_RESULT_KEY.
  3. On error, put the error code in ERROR_CODE_KEY and a human-readable description in ERROR_MESSAGE_KEY.
  4. Call setResult(RESULT_OK, intent) and finish(). Use RESULT_CANCELED for user-initiated cancellation.

Setting up DevApp for development

DevApp is the recommended environment for developing a camera module against the Q2 application without needing the full FI build.

  1. Add the module to devapp/src/main/assets/conf/settings.json under sdk_modules (see the snippet near the top of this page). Disable any other mrdcCamera modules you don't want to test against — only one CameraModule is active at a time.
  2. Confirm permissions. The CAMERA permission must be declared in your module's AndroidManifest.xml. The Q2 application does not request it on your behalf — your Activity is responsible for requesting it at runtime (ActivityCompat.requestPermissions or the Activity Result API).
  3. Run DevApp. Sign in, navigate to a Deposit Check flow, pick an account, and start a capture. The SDK will route the request to your CameraModule.

To verify your module is the one being used, run:

adb logcat -s SdkModuleStore:D

You should see Using <your module name> sdk module. If you instead see Skipping <your module name> sdk module, another CameraModule was registered first — disable it in settings.json.

Testing checklist

  • Front-only capture returns Success with a non-null frontImage.
  • Back-only capture returns Success with a non-null backImage.
  • User cancellation (back button, system gesture, decline permission) returns Failed(USER_CANCELLED, ...) — confirm the deposit screen does not show an error toast.
  • License rejection returns Failed(LICENSE_ERROR, ...) and the deposit flow falls back to the legacy mrdcCamera module without crashing.
  • Process death: send the app to the background while your camera Activity is on screen and force-stop it. The deposit flow should recover cleanly when the app is reopened.
  • additionalConfig is honored — log input.additionalConfig["CameraLicense"] in createIntent and confirm it matches the value DevApp sends.
  • submitCapturedImages — if you override it to return true, exercise both onSubmissionSuccess() and onSubmissionFailed("...") paths.

Common pitfalls

  • Returning null from parseResult is treated by the deposit flow as a user cancellation. Prefer an explicit Failed so you can attach an error code and message.
  • Holding Activity references across the contract boundary will leak. The contract receives a Context parameter — use that to build the Intent rather than capturing an outer activity.
  • Returning true from submitCapturedImages and forgetting to invoke the callback will hang the deposit flow indefinitely. Always invoke exactly one of onSubmissionSuccess() / onSubmissionFailed(error).
  • Two camera modules enabled at once — only the first one wins, the rest are silently skipped. Disable the others in settings.json rather than relying on registration order.

Update Your settings.json

Ensure your settings.json file in the root of the DevApp is updated to reflect your module changes. Learn more in Configuring settings.json.