The Chainway C72 is a widely resold Android handheld UHF terminal built around an Impinj E710/E900-class module, and it ships with a demo APK and a source bundle that most integrators copy from directly. That bundle is the fastest way to end up with a reader that silently stops responding after a warm restart.
What the vendor docs don't tell you
Chainway's own SDK documentation and demo repositories still reference RFIDWithUHF as the entry point for UHF operations. Open the SDK's own Javadoc-equivalent comments, though, and the class carries a deprecation notice pointing to RFIDWithUHFUART as the replacement.
What the docs don't tell you
Two things bit us on the C72 that the SDK never mentions.
The reader isn't ready when init() returns. Calling startInventoryTag()
or entering locate mode immediately after initialising gives you a reader that
accepts the call and then does nothing. The SDK needs to finish bringing the
radio up first, and it does not tell you when that is. Gate the first command
on initialisation actually completing rather than on init() having returned.
The EPC does not start where you expect it in locate mode. We read the tag identifier at a bit offset of 2 — which is what a naive reading of the buffer layout suggests — and got identifiers that looked plausible and matched nothing. The correct offset was 32. The failure mode is the dangerous kind: no error, no exception, just an EPC that is quietly wrong, so locate walks you toward a tag that isn't the one you asked for.
Both cost real hours, and neither appears in any Chainway document we have seen.
Setup
Add the Chainway SDK .aar to app/libs/ and declare it in build.gradle:
dependencies {
implementation files('libs/UHFAndAPI-release.aar')
implementation files('libs/DeviceAPI-release.aar')
}Request the runtime permissions the reader stack needs before touching the device:
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.hardware.usb.host" />Initializing the reader
Initialization happens once, against the singleton instance, and must be checked for a boolean success result before any inventory call is made.
class RfidManager {
private var uhf: RFIDWithUHFUART? = null
fun init(): Boolean {
uhf = RFIDWithUHFUART.getInstance()
val ok = uhf?.init() ?: false
if (ok) {
uhf?.setPower(22) // dBm, 5–30 valid range
}
return ok
}
fun release() {
uhf?.free()
uhf = null
}
}Call release() from onPause(), not just onDestroy(). Android does not guarantee onDestroy() runs before the next process starts, and a UART port left open across an app swap comes back as an init() failure with no further diagnostic detail.
Reading tags with a coroutine loop
Inventory in the C72 SDK is buffer-based: startInventoryTag() starts the antenna sweeping, and readTagFromBuffer() drains whatever the firmware queued since the last drain. A tight polling loop on a background coroutine keeps the buffer from overflowing under a fast tag population.
class InventoryReader(private val uhf: RFIDWithUHFUART) {
private var job: Job? = null
fun start(scope: CoroutineScope, onTag: (UHFTagInfo) -> Unit) {
uhf.startInventoryTag()
job = scope.launch(Dispatchers.IO) {
while (isActive) {
val tag = uhf.readTagFromBuffer()
if (tag != null) {
withContext(Dispatchers.Main) { onTag(tag) }
} else {
delay(15)
}
}
}
}
fun stop() {
job?.cancel()
uhf.stopInventory()
}
}Do not call readTagFromBuffer() on the main thread in a while(true) loop directly — it will not throw, but it will visibly freeze the UI thread on any inventory session longer than a few hundred milliseconds.
Wiring the hardware trigger
The C72's side trigger does not surface as a UI event. It arrives as a KeyEvent on the hosting Activity, and the specific keycode varies across firmware batches — most C72 units use 139 or 280.
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == 139 || keyCode == 280) {
inventoryReader.start(lifecycleScope) { tag -> viewModel.onTagRead(tag) }
return true
}
return super.onKeyDown(keyCode, event)
}
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == 139 || keyCode == 280) {
inventoryReader.stop()
return true
}
return super.onKeyUp(keyCode, event)
}Debounce the key-down handler if the firmware repeats the event while the trigger is held — most batches do, at roughly the standard Android key-repeat interval, which will otherwise restart the inventory session mid-scan.
Production considerations
Production deployments need three things the demo app skips entirely: lifecycle-safe teardown, power tuning per deployment environment, and defensive handling of init() failure.
- Always pair
init()with a correspondingfree()inonPause, and guard every SDK call with a null-check on the reader instance — a backgrounded app can be killed by the OS betweenonPauseandonStop. - Tune antenna power to the read zone, not the maximum. 22 dBm is a reasonable default for handheld point-and-scan use; dense shelf environments should start lower and step up rather than start at 30 dBm and fight cross-reads.
- Treat
init()returningfalseas an expected runtime state, not an exceptional one — it happens whenever the UART port is still held by a previous process, and the correct recovery is a bounded retry with backoff, not a crash.
If you are also exporting to Excel
Two things that cost us time on the same build, adjacent to the reader itself:
- Apache POI on Android needs explicit R8 keep rules for the BEA XML stream
classes (
aavax.xml.stream), or XLSX parsing fails only in release builds — never in debug, which is where you will be testing. - Writing an export to shared storage behaves differently across API 27 through 35. Test the export path on the oldest device in the fleet, not just the newest, and write UTF-8 with a BOM if the file will be opened in Excel.
Common errors
| Error | Cause | Fix |
|---|---|---|
init() returns false on second launch | UART port still held by previous process | Call uhf.free() in onPause(), not only onDestroy(); add a retry with backoff before surfacing an error to the user |
| Locate mode walks toward the wrong tag | EPC read at the wrong bit offset in the buffer — 2 instead of 32 | Read the identifier at offset 32; there is no error raised, so verify a known tag's EPC by hand before trusting locate |
First command after init() silently does nothing | The radio has not finished coming up; init() returning is not the same as ready | Wait for initialisation to complete before issuing the first inventory or locate command |
readTagFromBuffer() returns null indefinitely | Inventory session not started, or antenna power set to 0 | Confirm startInventoryTag() was called and setPower() was set above 5 dBm before polling |
| Trigger key does nothing | Wrong keycode assumed for this firmware batch | Log event.keyCode on any unhandled key event during a test session and confirm against 139/280 for this specific unit |
| App freezes during scan | Buffer drain loop running on the main thread | Move the polling loop to Dispatchers.IO and post results back via withContext(Dispatchers.Main) |
| Duplicate EPCs flood the UI | No dedup applied to buffer output | Maintain a rolling Set<String> of seen EPCs with a TTL, and filter before dispatching to the UI layer |