WS101 Programmer's Guide

EMDK For Android 14.0

Overview

Zebra's WS101 Bluetooth Communication Badge is a rugged Bluetooth comm device that can add hands-free voice capabilities to Zebra mobile computers and tablets. Small and light enough to be worn or pocketed, WS101-series devices provide workers with a means to easily communicate with fellow employees through Zebra's voice-enabled apps or a company's own. Staff can make calls, look up inventory items, hear responses and receive instructions, all without touching a mobile device or tablet.

        
The WS101 in healthcare, pharma and retail.

Main Features

  • Programmable Button Events: P1, P2, P3 (press/release/long-press)
  • Programmable Voice Triggers: Wake Word, Duress Word
  • Connection Events: Access Control List (ACL) dis/connect
  • SCO Audio: Start/stop mic capture
  • SDK version query (getVersion())

App-dev Requirements

  • Android Studio with Java 11 or later
  • Android API level 30 or greater
  • Zebra device(s) with Bluetooth running Android 11 or later
  • WS101 connected via Bluetooth for "Phone calls" and "Media audio"
  • If targeting Android 12 or later: Bluetooth runtime permissions (see Permissions section)

What is SCO Audio?

Synchronous Connection-oriented (SCO) Audio is a Bluetooth protocol designed for usage scenarios that can benefit from low-latency communications, such as those for voice. SCO Audio delivers its payload with less delay than other protocols by using reserved time slots. The trade-off is lower bandwidth, making SCO Audio unsuitable for music streaming and other high-fidelity audio content.


WS101 Series Controls

Front View


Control Description
1 Microphone Captures voice and ambient sounds.
2 Badge clip tower bar Enables the attachment of a lanyard and/or badge clip.
3 Power Performs multiple functions:
Power: Press and hold to turn device on or off. Device enters pairing mode each time it's turned on.
Pairing: Hold to re-enter pairing mode after device has been powered up.
Incoming call: Press once to answer; press twice to reject. On a call: Press twice to hang up.
4 P1 programmable button Sends an event to a registered app (unprogrammed by default). Zebra recommends programming this button for push-to-talk function.
5 LED ring Multi-colored visual indicator for multiple functions, including pairing, battery charging and level, incoming call, call on hold.

Side Views


Control Description
1 Volume up/down Adjusts volume of the device speaker or call volume for headset.
2 P2 programmable button Sends an event to a registered app. By default, mutes/unmutes the mic during calls.
3 Barcode Enables the "scan-to-pair" Bluetooth function.
4 NFC "N-Mark" Location for the "tap-to-pair" Bluetooth function.

Top View


Control Description
1 3.5mm audio jack* Input for headphones or headset; supports push-to-talk functions.
2 Top LED Visual status indicator for incoming calls, call on hold.
3 Speaker Projects audio upward, toward device user.
4 P3 programmable button (red) Sends an event to a registered app (unprogrammed by default).

* Feature not present on "-H" (blue) models.


Installation

Android Archive (AAR, local)

1. Copy libs/zebra-audio-connect-sdk-release.aar to the app/libs/ directory.

2. Modify settings.gradle to include:

        
        dependencyResolutionManagement {
            repositories {
            flatDir { dirs 'libs' }
            // ... other repos
            }
        }

3. Modify app/build.gradle to include:

    
    implementation files('libs/zebra-audio-connect-sdk-release.aar')

If the app is to enable R8 (formerly ProGuard) minification (minifyEnabled = true), the consumer R8 Rules (below) also must be included.


Permissions

The SDK does not request permissions automatically. The app must declare and request them as below.

Manifest entries

    // Bluetooth Classic and SCO 
    <uses-permission android:name="android.permission.BLUETOOTH" />
    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />

    // Android 12+ Bluetooth control 
    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
    <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />

    // Microphone for SCO audio capture
    <uses-permission android:name="android.permission.RECORD_AUDIO" />

Runtime requests (example)

    // Permissions array
    private val requiredPermissions = arrayOf(
        Manifest.permission.RECORD_AUDIO,
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            Manifest.permission.BLUETOOTH_CONNECT,
         Manifest.permission.BLUETOOTH_SCAN
     } else {
         Manifest.permission.BLUETOOTH,
         Manifest.permission.BLUETOOTH_ADMIN
     }
    )

    // Launcher
    private val permLauncher = registerForActivityResult(
        ActivityResultContracts.RequestMultiplePermissions()
    ) { results ->
        if (results.values.all { it }) initSdk() else onPermsDenied()
    }

    // In Activity.onCreate():
    if (hasAllPermissions()) initSdk() else
    permLauncher.launch(requiredPermissions)

Data Structures

Button Event Listener:

    
    interface ZWAButtonEventListener {
        fun onButtonEvent(button: ZWAButton, state: ButtonState)
    }
    enum class ZWAButton {
        P1,     //Programmable button 1
        P2,     //Programmable button 2
        P3      //Programmable button 3
    }
    enum class ButtonState {
        PRESS,      // Button pressed down
        RELEASE,    // Button released
        LONG_PRESS  // Button held down for longer than one (1) second
    }

image WS101 and WS101-H programmable button identifiers.
Click image to enlarge; ESC to exit.

Connection Event Listener Interface:

    
    interface ZWAConnectionListener {
        fun onDeviceConnected(device: ZWABluetoothDevice)
        fun onDeviceDisconnected(device: ZWABluetoothDevice)
    }
    data class ZWABluetoothDevice(
        val name: String,           // Device name (e.g., "Zebra WS101")
        val address: String,        // Bluetooth MAC address("00:11:22:33:AA:BB")
        val deviceType: DeviceType, // CLASSIC or BLE
        val isConnected: Boolean    // Current connection status
    )
    enum class DeviceType {
        CLASSIC,
        BLE
    }

Voice Trigger Listener Interface:

    
    interface ZWAVoiceTriggerListener {
        fun onVoiceTriggerDetected(triggerType: VoiceTriggerType)
    }
    enum class VoiceTriggerType {
        WAKE_WORD,
        DURESS_WORD
    }
  • WAKE_WORD: Triggers app-defined actions, such as launching a voice assistant app.
  • DURESS_WORD: Triggers app-defined actions, such calling for emergency services.

SCO Connection Listener Interface:

    
    interface ScoConnectionListener {
        fun onScoStarted()
        fun onScoFailed(error: ScoError)
    }
    enum class ScoError {
        BLUETOOTH_DISABLED,
        DISCONNECTED,
        UNKNOWN_ERROR
    }

Initialization

Application-level:

Recommended for apps that can declare manifest permissions ahead of runtime.

    
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        ZebraAudioConnectSDK.initialize(
            this,
            setOf(
                ZebraAudioConnectSDK.Module.BUTTONS,
                ZebraAudioConnectSDK.Module.VOICE,
                ZebraAudioConnectSDK.Module.CONNECTION,
                ZebraAudioConnectSDK.Module.SCO
     )
   )
}
    override fun onTerminate() {
        ZebraAudioConnectSDK.shutdown()
        super.onTerminate()
    }
}

Activity-level (after permissions):

    
    fun initSdk() {
        ZebraAudioConnectSDK.initialize(
            applicationContext,
            setOf(
                ZebraAudioConnectSDK.Module.BUTTONS,
                ZebraAudioConnectSDK.Module.VOICE,
                ZebraAudioConnectSDK.Module.CONNECTION,
                ZebraAudioConnectSDK.Module.SCO
        )
    )
    versionTextView.text = "SDK v${ZebraAudioConnectSDK.getVersion()}"
}

R8 Rules:

Required only if the consuming app enables minifyEnabled = true.

Add these lines to the R8 (formerly ProGuard) configuration (e.g. in consumer-rules.pro):

    
    # Keep all SDK classes and members
    -keep class com.zebra.audio.connect.sdk.** { *; }

    # Keep enum values for correct dispatch in listeners
    -keepclassmembers enum com.zebra.audio.connect.sdk.** { *; }

    # Ensure the module’s build.gradle already references this file:
    android {
    defaultConfig {

    // ...
    consumerProguardFiles("consumer-rules.pro")
    }
    buildTypes {
    release {
        isMinifyEnabled = true
        proguardFiles(
            getDefaultProguardFile("proguard-android-optimize.txt"),
            "proguard-rules.pro"
    )
    consumerProguardFiles("consumer-rules.pro")
            }
       }
    }    

Public API

    
    // Version
    val sdkVersion = ZebraAudioConnectSDK.getVersion()        

    // Listeners
    ZebraAudioConnectSDK.setButtonEventListener { button, state -> /* ... */ }
    ZebraAudioConnectSDK.setVoiceTriggerListener { triggerType -> /* ... */ }
    ZebraAudioConnectSDK.setConnectionListener(object : ZWAConnectionListener {
        override fun onDeviceConnected(device: ZWABluetoothDevice) { /* ... */ }
        override fun onDeviceDisconnected(device: ZWABluetoothDevice, reason:
    DisconnectReason) { /* ... */ }
    })
    ZebraAudioConnectSDK.setScoListener(object : ScoConnectionListener {
        override fun onScoStarted() = /* ... */
        override fun onScoFailed(error: ScoError) = /* ... */
    })

    // SCO control
    ZebraAudioConnectSDK.startScoConnection()
    ZebraAudioConnectSDK.stopScoConnection()

Usage Example (MainActivity)

    
    class MainActivity : AppCompatActivity() {

    // UI elements
    private lateinit var btnRecord: Button
    private lateinit var btnPlay: Button
    private lateinit var txtLog: TextView
    private var isRecording = false
    private var isPlaying = false

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

    // init views
    btnRecord = findViewById(R.id.btn_start_recording)
    btnPlay = findViewById(R.id.btn_playback)
    txtLog = findViewById(R.id.textLog)

    // permissions & SDK init
    if (hasAllPermissions()) initSdkAndListeners()
    else permLauncher.launch(requiredPermissions)
    updateUi()
    btnRecord.setOnClickListener {
    if (!isRecording) startScoAndRecord() else stopRecording()
    }
    btnPlay.setOnClickListener { if (!isPlaying) startPlayback() else
    stopPlayback() }
    }
    private fun initSdkAndListeners() {
    ZebraAudioConnectSDK.initialize(...)

    // Delay before recording to allow SCO setup
    val scoDelayMs = 50L // Adjust per device; can be 0-200 ms. See Best Practices and Error Handling (below)

    // Button events
    ZebraAudioConnectSDK.setButtonEventListener { btn, st -> log("Btn $btn →
    $st") }

    // Voice triggers
     ZebraAudioConnectSDK.setVoiceTriggerListener { tr -> log("Voice $tr") }

    // Connection events
    ZebraAudioConnectSDK.setConnectionListener(...)

    // SCO events
    ZebraAudioConnectSDK.setScoListener(...)
    }

    // ... record, playback, log, updateUi implementations ...
    }

Best Practices and Error Handling

  • SCO timing: Establishing an SCO link is asynchronous and varies by hardware. After invoking startScoConnection(), a brief delay (typically 50–200 ms) before starting MediaRecorder ensures that the audio pipeline is ready, and avoids clipping. Test with target device to determine the optimal delay. On Android 12 (and later), it's OK to proceed after receiving the "SCO Ready" event.
  • Permission denial: Disable relevant UI (btnRecord.isEnabled = false) and show guidance to the user.
  • ScoError cases: Handle ScoError.BLUETOOTH_DISABLED, prompt user to enable Bluetooth. Retry or abort gracefully.
  • Multi-app: Initiate only one SCO session at a time. Use Android Audio Focus or custom locking if multiple apps integrate the SDK.

Multi-app Considerations

  • Button/Voice events: Broadcast to all registered listeners across apps.
  • SCO sessions: Only one active session is allowed at a time. Subsequent startScoConnection() calls fail; handle using onScoFailed().

Frequently Asked Questions

Why isn't the WS101 mic working?

The WS101 requires exclusive use of the microphone via Synchronous Connection-oriented (SCO) audio connection for voice capture. Some of the most common causes of mic failure are listed below.

Causes for mic failure:

  • Wrong connection type: SCO requires the Bluetooth "Hands-free" or "Headset" profile to be active. The WS101 must be connected for "Phone calls" and "Media audio" in the Android System settings panel.
  • Another app using the microphone: Only one app can use Bluetooth SCO audio at a time. Quit other app(s) that use the mic (e.g. phone dialer, voice recorder) before running yours.
  • Missing permissions: If the device is running Android 12 or later, the problem could be that your app lacks special Bluetooth permissions required at runtime.
  • Also see next question.

Why doesn't my app work on Android 12?

In addition to a declaration in the app manifest (as indicated under Permissions above), devices running Android 12 (or later) enforce additional Bluetooth permissions that must be requested at runtime. Heightened security introduced with Android 12 requires that apps be granted permission for "Nearby devices" or "Bluetooth" when they start up. To address the issue, try the steps below:

  1. When the app first runs, grant the "Nearby devices" permission when prompted.
  2. If previously denied, go to Settings > Apps > [Your App Name] > Permissions and enable "Nearby devices."
  3. Restart the app to activate the permission changes.

Lack of background permission also might be hindering the app. Try granting the app background permission if the issue persists.


Why isn't my app receiving WS101 button presses or wake-word events?

Button presses and voice commands use the Bluetooth "Hands-free Profile" control channel, which is enabled only when Bluetooth is configured for audio and for calls. If the device is configured only for audio, the channel that carries button and voice signals is not available for your app to use.

Check WS101 settings:

  1. Go to Settings > Connections (or) Connected Devices* and look for a WS101 device.
  2. If there's none, tap "Pair new device" and/or follow the normal process for Bluetooth pairing.
    The message "Connected for Phone calls and Media audio" should appear.*
  3. Otherwise, tap the gear icon." The "Phone calls" and "Media audio" toggle controls should both be active.

* Exact processes and wording vary by Android version.

Other things to check:

  • Pairing vs. Active Connection - Check that the device is not only paired but also connected.
    Previously paired (and remembered) devices don't always reconnect when a host device comes into range.
  • Wrong button or voice trigger mappings - Zebra recommends using the Zebra Bluetooth Comms Utility (coming soon) to create and verify button mappings to configure wake- and duress-word settings.

Can I check whether a WS101 is connected before initializing the SDK?

Yes. The sample Java code below shows how to detect existing WS101 connections using Android Bluetooth APIs:

    
    /**
     * Checks if any supported Zebra WS101 devices are currently connected
     * Supports device name matching ("ws101"), MAC prefix ("94:FB:29"),
     * or Zebra manufacturer ID filtering
     */
    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    private fun checkConnectedDevices(context: Context): Boolean {
        return try {
        val bluetoothAdapter = (context.getSystemService(BLUETOOTH_SERVICE) as? BluetoothManager)?.
    adapter
        if (bluetoothAdapter?.isEnabled != true) return false
        bluetoothAdapter.bondedDevices?.any { device ->
        val supported = isDeviceSupported(device.name, device.address)
        val connected = isDeviceConnected(device)
        supported && connected
    } ?: false
    } catch (e: Exception) {
        Log.e(TAG, "Error checking connected devices", e)
        false
        }
    }
    private fun isDeviceSupported(deviceName: String?, macAddress: String?): Boolean {
        val nameMatches = deviceName?.lowercase()?.contains("ws101") == true
        val macMatches = macAddress?.uppercase()?.startsWith("94:FB:29") == true //MAC address prefix 94:
    FB:29 is registered to Zebra Technologies Inc.
        return nameMatches || macMatches
    }

    /**
     * Checks if a Bluetooth device is currently connected to the Headset/Hands-free profile
     */
    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    private fun isDeviceConnected(device: BluetoothDevice): Boolean {
     return try {
     val bluetoothAdapter = (getSystemService(BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
     ?: return false
     // Check Headset (HFP) connection state - required for WS101 button/voice events
     val headsetState = bluetoothAdapter.getProfileConnectionState(android.bluetooth.
    BluetoothProfile.HEADSET)
     headsetState == android.bluetooth.BluetoothProfile.STATE_CONNECTED
     } catch (e: Exception) {
     Log.w(TAG, "Unable to check headset profile connection for ${device.name}: ${e.message}")
     false
     }
    }

The program code above is provided for demonstration purposes only, and is NOT intended for use in production environments.


Also See