Understanding Android open accessory


I have created an electron app to provide the sync service that my business logic will be hosted from, and it would control an expo app (barebone) to accept info from the host.

The problem is I keep getting these errors:

> [syncService] Listening for AOA devices... > > [syncService] Device attached ΓÇö VID: 0x18D1 PID: 0x4EE7 > > [syncService] Target device found ΓÇö switching to AOA mode... > > [syncService] AOA switch failed: LIBUSB_ERROR_ACCESS

I have tried everything possible and I keep getting this error. I am looking for either assistance in how my code is set up or any leads to have the host open the device to communicate.

// electron/src/usb/syncService.js

import { usb } from 'usb'
const AOA_VENDOR_ID = 0x18D1
const AOA_ACCESSORY_PID = 0x2D00
const AOA_ACCESSORY_ADB_PID = 0x2D01

// ─── Your device's original IDs (before AOA switch) ──────────────────────────
const TARGET_VENDOR_ID = 0x18D1
const TARGET_PRODUCT_ID = 0x4EE7

const AOA_GET_PROTOCOL = 51
const AOA_SEND_STRING  = 52
const AOA_START        = 53

const NEXT_API_BASE = process.env.NEXT_API_BASE || 'https://yourserver.com'

// ─── Serial number reader ─────────────────────────────────────────────────────

function getSerialNumber(device) {
  return new Promise((resolve, reject) => {
    const idx = device.deviceDescriptor.iSerialNumber
    if (!idx) return resolve(null)

    device.getStringDescriptor(idx, (err, serial) => {
      if (err) return reject(err)
      resolve(serial)
    })
  })
}

// ─── Fetch approved data from your Entity Portal API ─────────────────────────

async function fetchApprovedData(serialNumber) {
  const res = await fetch(
    `${NEXT_API_BASE}/api/devices/${encodeURIComponent(serialNumber)}/approved`,
    {
      headers: { 'Content-Type': 'application/json' }
    }
  )

  if (!res.ok) {
    throw new Error(`API rejected serial ${serialNumber} — status ${res.status}`)
  }

  return res.json() // { tracks: [...], playlists: [...], restrictions: {...} }
}

// ─── Send payload to device over USB ─────────────────────────────────────────

function sendPayload(device, data) {
  return new Promise((resolve, reject) => {
    const iface = device.interfaces[0]

    try {
      iface.claim()
    } catch (err) {
      return reject(new Error(`Could not claim USB interface: ${err.message}`))
    }

    const outEndpoint = iface.endpoints.find(e => e.direction === 'out')
    if (!outEndpoint) {
      return reject(new Error('No OUT endpoint found on device interface'))
    }

    const payload = Buffer.from(JSON.stringify(data), 'utf8')

    // Send 4-byte length prefix first so Android knows total bytes incoming
    const lengthPrefix = Buffer.alloc(4)
    lengthPrefix.writeUInt32BE(payload.length, 0)

    console.log(`[syncService] Sending ${payload.length} bytes to device...`)

    outEndpoint.transfer(lengthPrefix, (err) => {
      if (err) return reject(new Error(`Length prefix transfer failed: ${err.message}`))

      // Send payload in chunks to avoid USB buffer overflow
      sendInChunks(outEndpoint, payload, 0, (err) => {
        if (err) return reject(err)

        console.log(`[syncService] Sync complete.`)
        iface.release(true, () => resolve())
      })
    })
  })
}

// Chunk size of 16KB is safe for most Android AOA implementations
const CHUNK_SIZE = 16 * 1024

function sendInChunks(endpoint, buffer, offset, callback) {
  if (offset >= buffer.length) return callback(null)

  const chunk = buffer.slice(offset, offset + CHUNK_SIZE)

  endpoint.transfer(chunk, (err) => {
    if (err) return callback(new Error(`Chunk transfer failed at offset ${offset}: ${err.message}`))
    sendInChunks(endpoint, buffer, offset + CHUNK_SIZE, callback)
  })
}

// ─── Main sync entry point ────────────────────────────────────────────────────



// ─── Control transfer helper ───────────────────────────────────────────────────

function controlTransfer(device, bmRequestType, bRequest, wValue, wIndex, data) {
  return new Promise((resolve, reject) => {
    device.controlTransfer(bmRequestType, bRequest, wValue, wIndex, data, (err, res) => {
      if (err) return reject(err)
      resolve(res)
    })
  })
}

// ─── Switch to AOA mode ──────────────────────────────────────────────────────

async function switchToAOA(device) {
  device.open()

  // 1. Check AOA protocol version
  const buf = await controlTransfer(device, 0xC0, AOA_GET_PROTOCOL, 0, 0, 2)
  const version = buf.readUInt16LE(0)
  console.log(`[syncService] AOA protocol version: ${version}`)
  if (version === 0) throw new Error('Device does not support AOA')

  // 2. Send accessory strings
  const strings = [
    [0, 'YourCompany'],
    [1, 'IpodSystem'],
    [2, 'iPod Sync System'],
    [3, '1.0'],
    [4, 'https://yoursite.com'],
    [5, 'IpodSystemSerial'],
  ]

  for (const [index, str] of strings) {
    await controlTransfer(device, 0x40, AOA_SEND_STRING, 0, index, Buffer.from(str + '\0'))
  }

  // 3. Tell device to switch to AOA mode — pass empty Buffer instead of 0
  await controlTransfer(device, 0x40, AOA_START, 0, 0, Buffer.alloc(0))
  console.log('[syncService] AOA switch sent — device will reconnect...')

  device.close()
}

// ─── USB attach listener — call this once from main.js ───────────────────────

function startListening() {
  usb.on('attach', (device) => {
    const { idVendor, idProduct } = device.deviceDescriptor

    console.log(`[syncService] Device attached — VID: 0x${idVendor.toString(16).toUpperCase()} PID: 0x${idProduct.toString(16).toUpperCase()}`)

    // If it's your device in normal mode — switch it to AOA
    if (idVendor === TARGET_VENDOR_ID && idProduct === TARGET_PRODUCT_ID) {
      console.log('[syncService] Target device found — switching to AOA mode...')
      switchToAOA(device).catch(err => console.error('[syncService] AOA switch failed:', err.message))
      return
    }

    // If it's already in AOA mode — sync it
    const isAccessory =
      idVendor === AOA_VENDOR_ID &&
      (idProduct === AOA_ACCESSORY_PID || idProduct === AOA_ACCESSORY_ADB_PID)

    if (!isAccessory) {
      console.log('[syncService] Not a target device — skipping.')
      return
    }

    console.log('[syncService] AOA device attached — starting sync...')
    syncDevice(device)
  })

  console.log('[syncService] Listening for AOA devices...')
}
const usb = require('usb')

const AOA_VENDOR_ID = 0x18D1
const AOA_ACCESSORY_PID = 0x2D00 // accessory only
const AOA_ACCESSORY_ADB_PID = 0x2D01 // accessory + adb

// AOA identification strings (shown to user on device prompt)
const AOA_STRINGS = {
  manufacturer: 'ipodSystem',
  modelName: 'MusicDevice',
  description: 'Music Sync',
  version: '1.0',
  uri: 'https://yourapp.com',
  serialNumber: '00000001'
}

async function findAndConnectDevice() {
  const devices = usb.getDeviceList()

  for (const device of devices) {
    try {
      device.open()
      const serial = await getSerial(device)

      // Step 1 — send AOA handshake to switch device into accessory mode
      await switchToAccessoryMode(device, serial)

      // device will disconnect and reconnect with AOA PID
      // listen for reconnection
    } catch (e) {
      device.close()
    }
  }
}

async function switchToAccessoryMode(device, serial) {
  // Check AOA protocol version
  const versionBuffer = Buffer.alloc(2)
  device.controlTransfer(
    0xC0, 51, 0, 0, 2,
    (err, data) => {
      if (err) throw err
      const version = data.readUInt16LE(0)
      console.log('AOA version:', version) // should be 1 or 2

      // Send identification strings
      sendAOAString(device, 0, AOA_STRINGS.manufacturer)
      sendAOAString(device, 1, AOA_STRINGS.modelName)
      sendAOAString(device, 2, AOA_STRINGS.description)
      sendAOAString(device, 3, AOA_STRINGS.version)
      sendAOAString(device, 4, AOA_STRINGS.uri)
      sendAOAString(device, 5, AOA_STRINGS.serialNumber)

      // Start accessory mode
      device.controlTransfer(0x40, 53, 0, 0, Buffer.alloc(0), (err) => {
        if (err) throw err
        // device will re-enumerate — wait for reconnect
      })
    }
  )
}

function sendAOAString(device, index, value) {
  const buf = Buffer.from(value + '\0', 'utf8')
  device.controlTransfer(0x40, 52, 0, index, buf, (err) => {
    if (err) console.error(`String ${index} failed:`, err)
  })
}

async function getSerial(device) {
  return new Promise((resolve, reject) => {
    device.controlTransfer(
      0x80, 6, 0x0303, 0x0409, 255,
      (err, data) => {
        if (err) return reject(err)
        resolve(data.slice(2).toString('utf16le').replace(/\0/g, ''))
      }
    )
  })
}

These two are the host (electron app) ^

Below are the module inside an expo app to register the intent and what not

package expo.modules.mymodule

import android.content.Intent
import android.hardware.usb.UsbAccessory
import android.hardware.usb.UsbManager
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.io.FileInputStream

class UsbAccessoryModule : Module() {
    companion object {
    val accessoryObservers = mutableListOf<(UsbAccessory) -> Unit>()
  }
  override fun definition() = ModuleDefinition {
    Name("UsbAccessory")

    AsyncFunction("startListening") {
      val context = appContext.reactContext
        ?: throw Exception("React context is not available")

      val usbManager = context.getSystemService(android.content.Context.USB_SERVICE) as UsbManager

      val activity = appContext.currentActivity
        ?: throw Exception("No current activity")

      val accessory: UsbAccessory? = activity.intent?.getParcelableExtra(UsbManager.EXTRA_ACCESSORY)
        ?: throw Exception("No USB accessory found")

      val pfd = usbManager.openAccessory(accessory)
      val inputStream = FileInputStream(pfd.fileDescriptor)

      // Read length prefix
      val lenBytes = ByteArray(4)
      inputStream.read(lenBytes)
      val length = ((lenBytes[0].toInt() and 0xFF) shl 24) or
                   ((lenBytes[1].toInt() and 0xFF) shl 16) or
                   ((lenBytes[2].toInt() and 0xFF) shl 8) or
                   (lenBytes[3].toInt() and 0xFF)

      // Read full payload
      val data = ByteArray(length)
      var offset = 0
      while (offset < length) {
        val read = inputStream.read(data, offset, length - offset)
        if (read < 0) break
        offset += read
      }

      String(data, Charsets.UTF_8) // returned as resolved value to JS
    }
  }
}
package expo.modules.mymodule

import android.app.Activity
import android.content.Intent
import android.hardware.usb.UsbAccessory
import android.hardware.usb.UsbManager
import android.os.Bundle
import expo.modules.core.interfaces.ReactActivityLifecycleListener

class MyReactActivityLifecycleListener : ReactActivityLifecycleListener {
  override fun onCreate(activity: Activity?, savedInstanceState: Bundle?) {
    handleIntent(activity?.intent)
  }

  override fun onNewIntent(intent: Intent?): Boolean {
    handleIntent(intent)
    return true
  }

  private fun handleIntent(intent: Intent?) {
    val accessory: UsbAccessory? = intent?.getParcelableExtra(UsbManager.EXTRA_ACCESSORY)
    if (accessory != null) {
      // Notify all observers registered by the module
      UsbAccessoryModule.accessoryObservers.forEach { observer ->
        observer(accessory)
      }
    }
  }
}

this will be injected into the main manifest:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-feature android:name="android.hardware.usb.host" />
    <uses-permission android:name="android.permission.USB_PERMISSION" />
</manifest>
<?xml version="1.0" encoding="utf-8"?>
<resources>
  <usb-accessory manufacturer="ipodSystem" model="MusicDevice" version="1.0" />
</resources>`;

can anyone tell me what is wrong or if they need more info ?

0
Apr 16 at 11:09 PM
User Avataruser32484004
#android#expo#electron#usb

No answer found for this question yet.