Alaqan Mini SDK
for Android
Java SDK for the Alaqan Mini palm-vein scanner. Distributed as an AAR; integrates over USB host on Android 7.0+.
Overview
Three operations: startIdentify(), startEnroll(), startRescan(). Two listener registries: DeviceEventsListener for device state and FrameListener for processed frames. Both bind to your LifecycleOwner. The SDK runs the USB session, camera stream and on-device inference. You provide the UI and an API key.
Public surface at a glance
// Setup
sdk.setApiKey(String);
sdk.initialize(Context);
sdk.addDeviceEventsListener(LifecycleOwner, DeviceEventsListener);
sdk.addFrameListener(LifecycleOwner, FrameListener); // optional preview
// Operations (one at a time)
sdk.startIdentify(IdentifyCallback);
sdk.startEnroll(externalId, name, metadata, EnrollCallback);
sdk.startRescan(externalId, RescanCallback);
sdk.stopOperation();
// Device
sdk.getDevice().connect() / disconnect() / reboot();
// Shutdown (process exit)
sdk.destroy();
Requirements
| Item | Value |
|---|---|
| Android API | 24+ (target 34 recommended) |
| ABIs | arm64-v8a, armeabi-v7a |
| AAR size | ~21 MB |
| Steady-state RAM | ~30 MB native |
| Hardware | USB host (OTG); Alaqan Mini scanner |
| Network | Outbound HTTPS for activation and biometric calls |
| Permissions | INTERNET (declared by SDK); android.hardware.usb.host feature |
Installation
Drop the AAR into app/libs/
app/
├── libs/
│ └── alaqan-mini-sdk.aar
└── build.gradle.kts
Wire it up in build.gradle.kts
android {
defaultConfig {
minSdk = 24
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") }
}
}
dependencies {
implementation(files("libs/alaqan-mini-sdk.aar"))
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.lifecycle:lifecycle-common:2.6.2")
}
USB device filter (res/xml/device_filter.xml)
<?xml version="1.0" encoding="utf-8"?>
<resources>
<usb-device vendor-id="17992" product-id="65280" />
</resources>
AndroidManifest.xml: minimum activity setup
<uses-feature android:name="android.hardware.usb.host" android:required="true" />
<activity android:name=".MainActivity"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/device_filter" />
</activity>
singleTask?
The manifest USB-attach intent is routed to your existing instance via onNewIntent() instead of spawning a new one. The SDK keeps its USB session.
Quick Start
One AlaqanMiniSDK per process. Hold it in your Application. The launcher Activity initializes the SDK and opens the USB session; from there you gate the UI on connection state. Each operation lives in its own Activity (see Identify).
// App.java: single SDK instance shared across activities.
public final class App extends Application {
private final AlaqanMiniSDK sdk = new AlaqanMiniSDK();
public AlaqanMiniSDK getSdk() { return sdk; }
}
// MainActivity.java: bootstrap the SDK and react to its lifecycle events.
public class MainActivity extends AppCompatActivity
implements DeviceEventsListener {
private AlaqanMiniSDK sdk;
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
sdk = ((App) getApplication()).getSdk();
sdk.setApiKey(BuildConfig.ALAQAN_API_KEY);
sdk.addDeviceEventsListener(this, this); // removed automatically on ON_DESTROY
sdk.initialize(this);
}
@Override public void onInitialized() { sdk.getDevice().connect(); }
@Override public void onDeviceConnected(@NonNull Device d) { /* enable operation entry points in your UI */ }
@Override public void onDeviceDisconnected(@NonNull Device d) { /* disable operation entry points */ }
@Override public void onError(@NonNull SdkError e) { /* ambient only; operation errors come via the operation callback */ }
@Override
protected void onDestroy() {
if (isFinishing()) sdk.destroy();
super.onDestroy();
}
}
Inject via BuildConfig, fetch from your backend on first launch, or read from secure storage. The SDK never persists it.
onDeviceConnected.
The event fires on every reconnect (cable wiggle, soft reboot). Start operations in response to user intent, typically in onResume of a dedicated screen.
Listener Model
Two registries, both multi-subscriber, both lifecycle-aware. Add the same listener once per LifecycleOwner; the SDK removes it on ON_DESTROY.
| Registry | Delivers | Frequency | Lifecycle binding |
|---|---|---|---|
DeviceEventsListener | SDK init, device connect/disconnect, ambient errors | Sparse | Removed on ON_DESTROY |
FrameListener | Per-frame FrameResult | ~30 Hz | Active only between ON_START and ON_STOP |
Sticky replay
If onInitialized() or onDeviceConnected() already fired before you subscribe, the SDK delivers them again to the new listener. An Activity recreated after rotation re-binds and gets current state without polling isInitialized() / isConnected().
Auto-streaming
The frame stream starts when the first FrameListener is added and stops when the last is removed. There is no startStreaming() / stopStreaming(); registration is the switch.
Error scoping
| Source | Delivered to |
|---|---|
| During an active operation | The operation callback's onResult |
| Outside any operation (init, USB, watchdog) | DeviceEventsListener.onError |
Errors are never delivered twice. Your operation callback owns the operation outcome.
Threading
Every callback runs on the main thread. All public SDK methods are safe to call from any thread. The only constraint to remember: Device.getSensingRange() blocks on a USB round-trip, so call it from a worker.
stopOperation() is thread-safe. The terminal onResult(OP_CANCELLED) always lands on the main thread; if you call stopOperation() from the main thread it fires inline before your handler returns.
Lifecycle
One SDK instance per process; the lifecycle of each Activity binds its own listeners.
| Where | What to call |
|---|---|
Application.onCreate() | new AlaqanMiniSDK() (held in your Application) |
First Activity's onCreate() | setApiKey(...) · addDeviceEventsListener(this, this) · initialize(this) |
Each Activity's onCreate() | addDeviceEventsListener(this, this). Sticky replay re-syncs the UI. |
Preview screen's onCreate() | addFrameListener(this, this). Streams only while STARTED. |
Launcher Activity's onDestroy() when isFinishing() | destroy() |
After destroy(), calling initialize() on the same instance throws IllegalStateException. Construct a new AlaqanMiniSDK if you need to reinitialize. Never call destroy() from a non-launcher Activity's onDestroy; it tears down models and forces a cold start.
Operations
Three operations, single-active. Starting a second while one runs fails fast with OP_ALREADY_RUNNING on the new callback.
void startIdentify(IdentifyCallback cb);
void startEnroll(String externalId, String name,
@Nullable String metadata, EnrollCallback cb);
void startRescan(String externalId, RescanCallback cb);
void stopOperation();
void setOperationTimeout(long ms); // default 30 000; 0 disables
OperationState getOperationState(); // IDLE | DETECTING | CAPTURING | PROCESSING
OperationType getOperationType(); // NONE | IDENTIFY | ENROLL | RESCAN
State machine
| State | Meaning |
|---|---|
IDLE | No operation running |
DETECTING | Waiting for a hand that passes confidence + quality + steadiness gates |
CAPTURING | Enroll/rescan only; collecting up to three frames |
PROCESSING | Frames captured, server call in flight |
Identify (1:N)
Captures one steady frame and submits it. Returns the matched person or an error. Start in onResume, cancel in onPause; the activity owns the operation lifecycle.
// IdentifyActivity.java: one identify lifecycle, tied to onResume/onPause.
public class IdentifyActivity extends AppCompatActivity
implements IdentifyCallback {
private AlaqanMiniSDK sdk;
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
sdk = ((App) getApplication()).getSdk();
}
@Override protected void onResume() { super.onResume(); sdk.startIdentify(this); }
@Override protected void onPause() { sdk.stopOperation(); super.onPause(); }
@Override public void onPreviewFrame(@NonNull FrameResult r) {
// r.bitmap is a shared buffer. Draw it here. Never retain it past this call.
}
@Override public void onGuidance(@NonNull Guidance g) {
// map g to your localized prompt; SDK does not localize.
}
@Override public void onResult(@NonNull IdentifyResult r) {
if (isFinishing() || isDestroyed()) return;
ErrorCode code = r.error != null ? r.error.code : null;
if (code == ErrorCode.OP_CANCELLED) return; // user navigated away
if (code == ErrorCode.OP_DISCONNECTED
|| code == ErrorCode.OP_NOT_CONNECTED) { finish(); return; }
// r.isSuccess() → r.externalId, r.name, r.metadata
// otherwise → r.error
finish();
}
}
| IdentifyResult field | Type | Notes |
|---|---|---|
scanId | String? | Server scan id; success only |
externalId | String? | Matched person's external id |
name | String? | Null if not set on enroll |
metadata | String? | JSON object string set on enroll |
error | SdkError? | Null on success |
Enroll
Captures three steady frames and uploads the highest-quality one. onProgress fires after each capture so you can render an N/3 indicator.
sdk.startEnroll("emp-1024", "Aiman", null, new EnrollCallback() {
@Override public void onGuidance(@NonNull Guidance g) { /* update prompt */ }
@Override public void onProgress(int captured, int total) { /* render N/total */ }
@Override public void onResult(@NonNull EnrollResult r) {
// r.isSuccess() → r.scanId; otherwise r.error.
}
});
nameandmetadataare optional; passnullto omit.metadatamust be a valid JSON object string when provided.EnrollResultexposesscanIdanderror.
Rescan
Replaces an enrolled person's biometric template, keeping their name and metadata. Same flow and same result type as enroll; the marker interface RescanCallback extends EnrollCallback documents intent at the call site.
sdk.startRescan("emp-1024", new RescanCallback() {
@Override public void onGuidance(@NonNull Guidance g) { /* update prompt */ }
@Override public void onProgress(int captured, int total) { /* render N/total */ }
@Override public void onResult(@NonNull EnrollResult r) {
// r.isSuccess() on success; otherwise r.error.
}
});
Guidance
UI hints emitted as state changes. Map each value to your own localized strings; the SDK does not localize.
| Value | Meaning |
|---|---|
SHOW_PALM | No hand in frame |
MOVE_CLOSER | Hand bbox area below threshold |
MOVE_FURTHER | Hand bbox area above threshold |
CENTER_PALM | Hand bbox off-center |
HOLD_STEADY | Detected but quality or steadiness gate not met |
CAPTURING | Enroll/rescan only; fires once per captured frame |
PROCESSING | Server call in flight |
API Reference
AlaqanMiniSDK
void setApiKey(@Nullable String key);
void initialize(@NonNull Context ctx);
boolean isInitialized();
Device getDevice();
@Nullable String getNativeVersion();
void setLedGateEnabled(boolean on); // default true
void setConfig(@NonNull SdkConfig cfg);
SdkConfig getConfig();
void destroy();
| Method | Contract |
|---|---|
setApiKey | Required before initialize(). Setting null after init causes server calls to fail with API_KEY_MISSING until a new key is set. |
initialize | Async. On success → onInitialized(). On failure → onError(SdkError) with API_KEY_MISSING / SERVER_NETWORK_ERROR / MODEL_LOAD_FAILED. Idempotent until destroy(). |
setLedGateEnabled | When true, frames with the device LED off are skipped before inference. Set false for well-lit indoor rigs where the LED never lights. |
destroy | Releases native memory, USB session and threads. Single-shot per instance. |
Device
// Connection
void connect(); // no-op until SDK is initialized
void disconnect();
boolean isConnected();
// Identity
@Nullable String getSerialNumber(); // e.g. "GX4TYQWH3D"
@Nullable String getFirmwareVersion(); // e.g. "2026042319"
// Distance sensor
int getDistance(); // last reading in mm; -1 if unknown
@Nullable int[] getSensingRange(); // {min,max} mm. BLOCKING.
void setSensingRange(int min, int max); // async; default 50–200; clamped [0,255]
// Cameras
Cameras cameras();
// Power
void reboot(); // soft reboot: disconnect, then auto-reconnect
All getters return null / -1 until onDeviceConnected fires. setSensingRange is fire-and-forget; getSensingRange waits for a USB reply, so call it on a worker thread.
Cameras
The scanner has two cameras: CameraType.IR (infrared) and CameraType.VL (visible light). The active camera is the source of preview frames delivered to FrameListener.
Cameras cams = sdk.getDevice().cameras();
cams.setActive(CameraType.VL); // switch preview source
CameraType active = cams.getActiveType();
long ir = cams.getIrFrameCount(); // frames since connect
long vl = cams.getVlFrameCount();
The detection camera is selected by the SDK while an operation is running and is not partner-controllable.
SdkConfig
Currently exposes one knob: the frame-stall watchdog interval. Apply via setConfig(); takes effect on the next watchdog start.
sdk.setConfig(new SdkConfig.Builder()
.frameWatchdogMs(15_000L) // default 30 000; min 1 000
.build());
If no frame arrives for frameWatchdogMs ms while the device is connected, the SDK fires DEVICE_NOT_RESPONDING on onError once. The connection stays up; this is a signal, not a teardown trigger. Quality and steadiness thresholds are not partner-tunable.
Listeners & Callbacks
DeviceEventsListener
public interface DeviceEventsListener {
default void onInitialized() {}
default void onDeviceConnected(@NonNull Device d) {}
default void onDeviceDisconnected(@NonNull Device d) {}
default void onError(@NonNull SdkError error) {}
}
sdk.addDeviceEventsListener(listener); // manual lifetime
sdk.addDeviceEventsListener(lifecycleOwner, listener); // removed on ON_DESTROY
sdk.removeDeviceEventsListener(listener);
All methods are default; implement only what you need. Errors from active operations are not delivered here.
FrameListener
public interface FrameListener { void onFrame(@NonNull FrameResult r); }
sdk.addFrameListener(listener); // stream starts
sdk.addFrameListener(lifecycleOwner, listener); // streams only while STARTED
sdk.removeFrameListener(listener); // stream stops on last remove
FrameResult.bitmap is shared and reused. Render or Bitmap.copy() inside onFrame; never call recycle() and never keep the reference past the callback.
IdentifyCallback
public interface IdentifyCallback {
default void onPreviewFrame(@NonNull FrameResult r) {}
void onGuidance(@NonNull Guidance g);
void onResult(@NonNull IdentifyResult r);
}
onResult fires exactly once. onPreviewFrame is a no-op by default; override it if you render preview straight from the operation callback. Otherwise use a separate FrameListener.
EnrollCallback
public interface EnrollCallback {
default void onPreviewFrame(@NonNull FrameResult r) {}
void onGuidance(@NonNull Guidance g);
void onProgress(int captured, int total); // total currently 3
void onResult(@NonNull EnrollResult r);
}
RescanCallback
public interface RescanCallback extends EnrollCallback {}
Marker interface. Methods are identical to EnrollCallback. Use it at the call site so intent is documented; the SDK can later add rescan-specific events without breaking enroll callers.
Data Types
FrameResult
| Field | Type | Notes |
|---|---|---|
jpegSize | int | Source JPEG size in bytes |
width / height | int | Frame dimensions in pixels |
bitmap | Bitmap? | Decoded preview; shared, do not recycle, do not retain |
hands | HandDetection[]? | null if no hands |
moreHands | boolean | true if more hands than the array's length were found |
inferenceMs | float | Total inference time |
ledOn | boolean | Device LED state at capture |
steady | boolean | Bbox stable across consecutive detections |
HandDetection
| Field | Type | Notes |
|---|---|---|
confidence | float | Detection confidence, 0–1 |
x / y / w / h | float | Bbox top-left + size in pixels |
quality | float | Quality score, 0–100; -1 if not computed |
Results
Both result types follow the same shape: check isSuccess() first; on failure read error.
| Type | Fields | Returned by |
|---|---|---|
IdentifyResult | scanId, externalId, name, metadata, error | startIdentify() |
EnrollResult | scanId, error | startEnroll(), startRescan() |
SdkError
public final class SdkError {
@NonNull public final ErrorCode code;
@Nullable public final String detail;
}
Branch on code (an enum). detail carries a server message for SERVER_REJECTED, otherwise an exception text. Use it for logs, never for control flow.
ErrorCode exposes three things. code() returns a stable 4-digit string for telemetry. category() returns one of DEVICE, AUTH, STREAMING, NATIVE, NETWORK, OPERATION. fromCode(String) does the reverse lookup.
@Override
public void onError(@NonNull SdkError e) {
switch (e.code) {
case API_KEY_MISSING: promptForKey(); break;
case DEVICE_NOT_RESPONDING:
case USB_NO_FW_VERSION: // informational; leave UI alone break;
default:
if (e.code.category() == ErrorCode.Category.NETWORK) showOffline();
else showError(e.code.code());
}
}
Error Codes
The full ErrorCode enum. Branch on the enum value; code() returns a stable 4-digit string suitable for logging and back-end reporting.
Device & USB (1xxx)
| Constant | Meaning · what to do | |
|---|---|---|
| 1001 | USB_PERMISSION_DENIED | User rejected the USB permission dialog. Re-prompt or guide the user to grant access. |
| 1002 | USB_NO_INTERFACE | USB interface not exposed by the device. Check VID/PID filter and cable. |
| 1003 | USB_NO_ENDPOINTS | Required USB endpoints missing. Hardware-level issue; reseat or swap the device. |
| 1004 | USB_OPEN_FAILED | Could not open the USB device. Often follows a kernel/USB-stack hiccup; retry after replug. |
| 1005 | USB_CLAIM_FAILED | Another process holds the interface. Close other apps using the device. |
| 1006 | USB_NO_SERIAL | Serial number unreadable. Server biometric calls require it; treat as a connect failure. |
| 1007 | DEVICE_NOT_RESPONDING | No frames within frameWatchdogMs. Informational; the connection stays up. |
| 1008 | USB_NO_FW_VERSION | Firmware version probe failed. Informational; the connection stays up. |
Authentication (2xxx)
| Constant | Meaning · what to do | |
|---|---|---|
| 2001 | AUTH_CHALLENGE_FAILED | Device authentication failed at stage 1. Replug or reboot device. |
| 2002 | AUTH_INVALID_REPLY | Device returned an unexpected response during authentication. Replug or reboot. |
| 2003 | AUTH_COMPUTE_FAILED | Client-side authentication step failed. Re-call initialize(). |
| 2004 | AUTH_VERIFY_FAILED | Device authentication failed at stage 2. Replug or reboot device. |
| 2005 | AUTH_REJECTED | Device rejected the SDK's credentials. Re-call initialize(); if persistent, contact support. |
Streaming (3xxx)
| Constant | Meaning · what to do | |
|---|---|---|
| 3001 | STREAM_START_FAILED | Could not send stream-start command. Replug the device; if persistent, contact support. |
| 3002 | STREAM_REJECTED | Device declined the stream-start. Reboot device. |
SDK & Native (4xxx)
| Constant | Meaning · what to do | |
|---|---|---|
| 4001 | NATIVE_CREATE_FAILED | Native runtime failed to start. Confirm both ABIs are packaged. |
| 4002 | MODEL_LOAD_FAILED | Models could not be loaded. Confirm activation succeeded (no preceding SERVER_*). |
| 4004 | AUTH_PROVIDER_MISSING | Internal misconfiguration; should not occur in released builds. |
Network (5xxx)
| Constant | Meaning · what to do | |
|---|---|---|
| 5001 | API_KEY_MISSING | Set the key with setApiKey() before initialize(). |
| 5002 | SERVER_NETWORK_ERROR | Transport error. Surface offline UI; offer retry. |
| 5003 | SERVER_INVALID_RESPONSE | Malformed server reply. Capture logs; contact support if persistent. |
| 5004 | SERVER_REJECTED | Server returned an error message. Read error.detail; the catalog below covers the common values. |
| 5005 | SERVER_TIMEOUT | Request exceeded 15 s. Offer retry; check network quality. |
Operations (6xxx)
| Constant | Meaning · what to do | |
|---|---|---|
| 6001 | OP_ALREADY_RUNNING | Another operation is active. Call stopOperation() first or wait. |
| 6002 | OP_CANCELLED | You called stopOperation(). Treat as a no-op in UI. |
| 6003 | OP_TIMEOUT | Operation timeout elapsed. Show retry UI. |
| 6004 | OP_DISCONNECTED | Device disconnected mid-operation. Close the screen. |
| 6005 | OP_NOT_INITIALIZED | Started before onInitialized(). Wait for the event or check isInitialized(). |
| 6006 | OP_NOT_CONNECTED | Started before onDeviceConnected(). Wait or check device.isConnected(). |
| 6007 | OP_INVALID_PARAMS | Bad arguments. Typically an empty externalId on enroll/rescan. |
SERVER_REJECTED.detail catalog
When code == SERVER_REJECTED, error.detail holds the server's message verbatim. Map these to your own copy:
| Detail | When | Suggested handling |
|---|---|---|
"no hand detected" | any | Prompt user to retry |
"quality check failed" | any | Prompt user to retry, hand cleaner / closer |
"liveness check failed" | any | Prompt user to retry with a real palm |
"template not found" | identify | Show "not enrolled" |
"person not found" | rescan | externalId is not registered; switch to enroll |
"person already exists" | enroll | Ask whether to rescan instead |
"template already enrolled" | enroll/rescan | The biometric matches a different person. Review the case. |
"duplicate capture detected" | any | Same image submitted twice; likely a double-tap. |
"invalid inference" | any | Not retryable. Escalate to support. |
"inference unavailable" | any | Retry with backoff |
Best Practices
One SDK instance, in Application
Construct once, share across activities, destroy() only when the launcher Activity is finishing.
Always bind listeners with a LifecycleOwner
Removes the listener on ON_DESTROY automatically. FrameListener additionally pauses the stream when the screen leaves the foreground.
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
sdk.addDeviceEventsListener(this, this); // sticky-replay + auto-cleanup
sdk.addFrameListener(this, this); // stream while visible
}
Drive UI from onGuidance / onProgress / onResult
You don't need a separate FrameListener while an operation is running. Operation callbacks already deliver preview frames via onPreviewFrame. Pick one source.
Cancel cleanly across navigation
@Override
protected void onPause() {
sdk.stopOperation(); // emits OP_CANCELLED; handle as a no-op
super.onPause();
}
Branch on ErrorCode, not its string
Use the enum and its category(). Strings are for logs and back-end telemetry only.
Treat DEVICE_NOT_RESPONDING and USB_NO_FW_VERSION as informational
Both are signals; neither tears the connection down. Real disconnects arrive via onDeviceDisconnected.
Troubleshooting
onDeviceConnected never fires
- Confirm cable, port, OTG support on the host.
device_filter.xmlcontainsvendor-id="17992"andproduct-id="65280".- Activity declares the manifest USB filter and
android:launchMode="singleTask".
Init fails with SERVER_NETWORK_ERROR / API_KEY_MISSING / MODEL_LOAD_FAILED
- API key set before
initialize(). - Network reachable; activation is HTTPS with a 15 s timeout.
- Both ABIs (
arm64-v8a,armeabi-v7a) packaged in the APK.
Guidance stuck on HOLD_STEADY
- Hand outside the configured sensing range; check
device.getDistance(). - Direct sunlight on the scanner glass; protective film not removed.
- Indoor rig with the device LED disabled. Call
setLedGateEnabled(false).
Frames stop arriving (DEVICE_NOT_RESPONDING)
- Try
device.reboot(). - If persistent, replug the device or contact support.
Support
Technical
support@alaqan.com
Sales
sales@alaqan.com
The ErrorCode.code() string. Any error.detail. Steps to reproduce. Device serial number and firmware version. SDK version. Android logcat covering the failure.