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:
- The user starts a deposit-check flow inside the Q2 web view.
- The Q2 application looks for a registered
CameraModule. If one exists, it builds aCameraModuleConfigurationand asks yourcontract.createIntent()to launch yourActivity. - Your
Activitycaptures the image(s) and finishes with a resultIntentthat carries the image paths (or an error). - Your
contract.parseResult()turns thatIntentinto aCameraModuleResult. - 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
CameraModuleis active at runtime. If multiple modules insettings.jsonimplementCameraModule, the first one registered wins and the rest are ignored — a warning is logged with the skipped module name. moduleTypeshould be set to"mrdcCamera"so the module is correctly grouped with other check-capture modules. The SDK still locates your implementation by itsCameraModuleinterface, but the type keepssettings.jsonconsistent with the legacy mob-module entries.- Anything you put under
datais passed to your module throughSdkModuleConfigand 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 theIntentthat launches your cameraActivity. Theinputparameter is aCameraModuleConfiguration(see below).parseResult(resultCode, intent)— convert the activity result back into aCameraModuleResult. Returnnullonly if the result represents an unrecoverable cancellation that should be treated the same asRESULT_CANCELED; in most flows you should return a typedCameraModuleResult.SuccessorCameraModuleResult.Failed.
submitCapturedImages(...)
Called when the user confirms a capture and the deposit is ready to be submitted.
- Return
falseto 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
trueif 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 returntrueyou must eventually invoke eithercallback.onSubmissionSuccess()orcallback.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()
)
cameraModuleCaptureTypetells your camera which check side(s) to capture.additionalConfigis a free-form map populated by the Q2 application from the currentRdcConfigurationEntity. 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
Fileobjects 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 code | When to return it | SDK behaviour |
|---|---|---|
USER_CANCELLED | The user backed out without capturing. | Treated as a normal cancellation; no error UI shown. |
ACTIVITY_FAILED | Your Activity finished with a non-OK result code. | Generic capture-failure error shown. |
NO_DATA | Your Activity finished with RESULT_OK but returned a null Intent. | Generic capture-failure error shown. |
NO_IMAGES | The Intent had no front or back image path. | Generic capture-failure error shown. |
LICENSE_ERROR | The 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:
- Read
CameraModuleCaptureTypefrom the launching Intent and capture that side. - On success, write the captured image to a file in your app's cache directory
and put its absolute path under
FRONT_IMAGE_RESULT_KEYorBACK_IMAGE_RESULT_KEY. - On error, put the error code in
ERROR_CODE_KEYand a human-readable description inERROR_MESSAGE_KEY. - Call
setResult(RESULT_OK, intent)andfinish(). UseRESULT_CANCELEDfor 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.
- Add the module to
devapp/src/main/assets/conf/settings.jsonundersdk_modules(see the snippet near the top of this page). Disable any othermrdcCameramodules you don't want to test against — only oneCameraModuleis active at a time. - Confirm permissions. The
CAMERApermission must be declared in your module'sAndroidManifest.xml. The Q2 application does not request it on your behalf — yourActivityis responsible for requesting it at runtime (ActivityCompat.requestPermissionsor the Activity Result API). - 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
Successwith a non-nullfrontImage. - Back-only capture returns
Successwith a non-nullbackImage. - 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 legacymrdcCameramodule without crashing. - Process death: send the app to the background while your camera
Activityis on screen and force-stop it. The deposit flow should recover cleanly when the app is reopened. -
additionalConfigis honored — loginput.additionalConfig["CameraLicense"]increateIntentand confirm it matches the value DevApp sends. -
submitCapturedImages— if you override it to returntrue, exercise bothonSubmissionSuccess()andonSubmissionFailed("...")paths.
Common pitfalls
- Returning
nullfromparseResultis treated by the deposit flow as a user cancellation. Prefer an explicitFailedso you can attach an error code and message. - Holding
Activityreferences across the contract boundary will leak. The contract receives aContextparameter — use that to build theIntentrather than capturing an outer activity. - Returning
truefromsubmitCapturedImagesand forgetting to invoke the callback will hang the deposit flow indefinitely. Always invoke exactly one ofonSubmissionSuccess()/onSubmissionFailed(error). - Two camera modules enabled at once — only the first one wins, the rest are
silently skipped. Disable the others in
settings.jsonrather 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.