Android USB Reader/Writer: USB Host with Kotlin (CDC-ACM & Bulk)
Android USB Reader/Writer: USB Host with Kotlin (CDC-ACM & Bulk)
This post shows how to talk to a USB device from Android using the USB Host API. We cover two common cases: CDC‑ACM serial devices and custom bulk endpoints. You will learn permission flow, opening interfaces, and reading/writing safely on background threads.
Requirements
- Android 6.0+ phone/tablet with USB‑OTG.
android.hardware.usb.hostfeature anduses-featuredeclared.- For CDC‑ACM, many devices will also work with libraries like
usb-serial-for-android, but here we use the platform API to show the principles.
Manifest (USB Host + permission)
<manifest ...>
<uses-feature android:name="android.hardware.usb.host" />
<uses-permission android:name="android.permission.USB_PERMISSION" tools:ignore="NewApi" />
<application ...>
<receiver android:name=".usb.UsbPermissionReceiver">
<intent-filter>
<action android:name="com.example.USB_PERMISSION" />
</intent-filter>
</receiver>
</application>
</manifest>
Requesting permission and opening the device
class UsbHelper(private val context: Context) {
private val usb by lazy { context.getSystemService(Context.USB_SERVICE) as UsbManager }
private val permissionAction = "com.example.USB_PERMISSION"
fun findDevice(vid: Int, pid: Int): UsbDevice? =
usb.deviceList.values.firstOrNull { it.vendorId == vid && it.productId == pid }
fun requestPermission(device: UsbDevice, onResult: (Boolean) -> Unit) {
val pi = PendingIntent.getBroadcast(
context, 0, Intent(permissionAction), PendingIntent.FLAG_IMMUTABLE
)
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, i: Intent?) {
context.unregisterReceiver(this)
val granted = i?.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) == true
onResult(granted)
}
}
context.registerReceiver(receiver, IntentFilter(permissionAction))
usb.requestPermission(device, pi)
}
}
Open interfaces/endpoints and start IO (bulk example)
data class UsbSession(
val conn: UsbDeviceConnection,
val intf: UsbInterface,
val inEp: UsbEndpoint,
val outEp: UsbEndpoint
)
fun openBulk(device: UsbDevice, manager: UsbManager): UsbSession? {
val intf = (0 until device.interfaceCount)
.map { device.getInterface(it) }
.firstOrNull { it.interfaceClass == UsbConstants.USB_CLASS_VENDOR_SPEC || it.interfaceClass == UsbConstants.USB_CLASS_COMM }
?: return null
val eps = (0 until intf.endpointCount).map { intf.getEndpoint(it) }
val inEp = eps.firstOrNull { it.direction == UsbConstants.USB_DIR_IN && it.type == UsbConstants.USB_ENDPOINT_XFER_BULK } ?: return null
val outEp = eps.firstOrNull { it.direction == UsbConstants.USB_DIR_OUT && it.type == UsbConstants.USB_ENDPOINT_XFER_BULK } ?: return null
val conn = manager.openDevice(device) ?: return null
if (!conn.claimInterface(intf, true)) { conn.close(); return null }
return UsbSession(conn, intf, inEp, outEp)
}
class UsbIo(private val session: UsbSession) {
@Volatile private var running = true
fun startReader(onData: (ByteArray) -> Unit) = thread(name = "usb-reader") {
val buf = ByteArray(512)
while (running) {
val n = session.conn.bulkTransfer(session.inEp, buf, buf.size, 1000)
if (n != null && n > 0) onData(buf.copyOf(n))
}
}
fun write(data: ByteArray, timeoutMs: Int = 1000): Int =
session.conn.bulkTransfer(session.outEp, data, data.size, timeoutMs) ?: -1
fun close() {
running = false
session.conn.releaseInterface(session.intf)
session.conn.close()
}
}
CDC‑ACM (serial‑like) setup
CDC‑ACM exposes a control interface and a data interface with bulk IN/OUT endpoints. You must set line coding and control signals via control transfers.
fun setupCdcAcm(conn: UsbDeviceConnection, controlIntf: UsbInterface) {
val SET_LINE_CODING = 0x20
val SET_CONTROL_LINE_STATE = 0x22
// 115200 8N1 line coding: dwDTERate(115200), bCharFormat(0=1stop), bParityType(0), bDataBits(8)
val line = byteArrayOf(0x00, 0xc2.toByte(), 0x01, 0x00, 0x00, 0x00, 0x08)
conn.controlTransfer(0x21, SET_LINE_CODING, 0, controlIntf.id, line, line.size, 1000)
// DTR(1) | RTS(2)
conn.controlTransfer(0x21, SET_CONTROL_LINE_STATE, 0x0003, controlIntf.id, null, 0, 1000)
}
fun openCdc(device: UsbDevice, manager: UsbManager): UsbSession? {
// Typically intf0: control (class COMM), intf1: data (class CDC_DATA)
val ctrl = (0 until device.interfaceCount).map { device.getInterface(it) }
.firstOrNull { it.interfaceClass == UsbConstants.USB_CLASS_COMM } ?: return null
val data = (0 until device.interfaceCount).map { device.getInterface(it) }
.firstOrNull { it.interfaceClass == UsbConstants.USB_CLASS_CDC_DATA } ?: return null
val inEp = (0 until data.endpointCount).map { data.getEndpoint(it) }
.first { it.direction == UsbConstants.USB_DIR_IN && it.type == UsbConstants.USB_ENDPOINT_XFER_BULK }
val outEp = (0 until data.endpointCount).map { data.getEndpoint(it) }
.first { it.direction == UsbConstants.USB_DIR_OUT && it.type == UsbConstants.USB_ENDPOINT_XFER_BULK }
val conn = manager.openDevice(device) ?: return null
if (!conn.claimInterface(ctrl, true) || !conn.claimInterface(data, true)) { conn.close(); return null }
setupCdcAcm(conn, ctrl)
return UsbSession(conn, data, inEp, outEp)
}
Usage flow
1) Find device by VID/PID via UsbManager.deviceList.
2) Request permission (broadcast receiver).
3) Open and claim interface(s), locate endpoints.
4) Start a background reader thread, write with bulkTransfer.
5) On lifecycle end, release interface and close connection.
Tips
- Use a dedicated
HandlerThread/CoroutineScope(Dispatchers.IO)instead of raw threads in apps. - For high throughput, prefer larger buffers and continuous reads; consider
UsbRequestqueue API for zero‑copy. - On Android 12+, exported receivers require
android:exportedin the manifest. - If a kernel driver claims CDC‑ACM, Android’s USB host still exposes it to apps; permission is always required.
Troubleshooting
- If
bulkTransferreturns -1 quickly, check endpoint direction/type and timeouts. - If permission dialog doesn’t appear, verify action string matches in broadcast/manifest.
- Some devices need an OTG adapter or external power; check logs with
adb logcat.