Current section
Files
Jump to
Current section
Files
mob_bluetooth
CHANGELOG.md
CHANGELOG.md
# Changelog
All notable changes to **mob_bluetooth** are documented here.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org/spec/v2.0.0.html).
---
## [0.4.2] - 2026-10-06
### Fixed
- **Android: the bridge passes `./gradlew :app:lintRelease`** (MOB-400). Eight
`MissingPermission` errors in `MobBluetoothBridge.kt` failed a host app's
release lint. The discovery receiver read `device.bondState` with no
handler; it needs `BLUETOOTH_CONNECT` on API 31+, which discovery doesn't,
so an app holding `BLUETOOTH_SCAN` without it crashed on the main thread
at the first device found. `{:bt, :discovered, …}` now carries
`bonded: false` when the bond state can't be read. The PIN auto-answer,
GATT `sendResponse`, advertising/GATT teardown and adapter-name restore
calls handle `SecurityException` explicitly (they already swallowed it
through a broader `catch`, which lint doesn't count). No change for
granted permissions.
## [0.4.1] - 2026-10-01
### Fixed
- **Android: `start_advertising(local_name: …)` advertises the requested
name, not the previous one** (MOB-360). `BluetoothAdapter.setName` is
asynchronous and the scan response packs whatever name the stack has when
`startAdvertising` runs, so on some devices (seen on API 30) a start
advertised the adapter's PREVIOUS name — and a name too long for the scan
response was accepted, then failed the NEXT start with `:data_too_large`.
The bridge now tracks every `setName` it issues until
`ACTION_LOCAL_NAME_CHANGED` reports it, and holds `startAdvertising` until
none is in flight, bounded at 1.5 s (then it starts anyway and logs a
warning). `getName()` is never trusted while a rename or restore is in
flight, and a late broadcast of an earlier rename can't release a newer
start. The same holds in reverse: a start without `:local_name` right after
a named one (or a stop) waits for the adapter's own name to be restored,
instead of advertising the old `local_name`. Starts without `:local_name` on
an unrenamed adapter, or whose name the adapter already carries, start at
once as before. The adapter's own name stays saved until the adapter is
back on it with nothing in flight, so quick start / stop sequences restore
the phone's own name rather than one of the advert names. A newer
`start_advertising/2` or a `stop_advertising/1` cancels a start still
waiting for its name — it never begins advertising — and a start superseded
or stopped before its outcome now delivers no `:advertising_started` /
`:advertising_failed` (previously a late callback could still report it);
every other start delivers exactly one. A start rejected before setup
(`:bad_service_uuid`, `:adapter_disabled`, …) now also stops the previous
advert and restores the adapter name before reporting its failure, so the
last event always matches what is advertising.
## [0.4.0] - 2026-10-01
### Fixed
- **Android: classic discovery works through the documented permission
flow** (MOB-319). `Mob.Permissions.request(socket, :bluetooth_connect)`
never asked for location, but `startDiscovery()` needs
`ACCESS_FINE_LOCATION` on API ≤ 30 and on API 31+ unless `BLUETOOTH_SCAN`
is declared `neverForLocation`. Without it the platform returned `false`
(`Permission denial: Need ACCESS_FINE_LOCATION permission to get scan
results`) and callers got `{:bt, :error, %{reason: :start_failed}}`. The
capability is now per-SDK: `ACCESS_FINE_LOCATION` on API ≤ 30 (where the
Nearby-devices permissions don't exist and so could never be granted);
`BLUETOOTH_SCAN` / `CONNECT` / `ADVERTISE` on API 31+, plus
`ACCESS_FINE_LOCATION` + `ACCESS_COARSE_LOCATION` unless the installed
app's `BLUETOOTH_SCAN` carries `neverForLocation` (read from the package's
requested-permission flags). The manifest now also declares
`ACCESS_COARSE_LOCATION` (Android 12+ ignores a FINE-only request). A
refused `startDiscovery()` reports `:location_permission_required` or
`:location_disabled` when that's the cause, `:start_failed` otherwise.
mob_dev's manifest schema can't emit `neverForLocation` / `maxSdkVersion`,
so hosts that want location-free discovery on API 31+ declare those tags
themselves (README "Permissions").
- **Android: a second `pair/3` for a device that is already bonding no
longer drops the first caller** (MOB-320). The second call's
`createBond()` returned `false` and its failure path removed the first
caller's pid, so the first caller never got `:paired`. A `pair/3` while
that device's bond is in flight (from an earlier `pair/3`, or already
`BOND_BONDING`) now joins it: every caller gets the bond's terminal
`:paired` / `:pair_failed`, and the later caller's `:pin` is ignored. If
the host Activity is replaced mid-bond, each waiting caller gets
`:pair_failed` with the new reason `:activity_replaced` (the bond
receiver goes with the old Activity).
- **Android: `MobBluetooth.Le.start_advertising(local_name: ...)` no longer
renames the Bluetooth adapter permanently** (MOB-321). The adapter's own
name is saved before the rename and restored on `stop_advertising/1`, on
every advertising failure, and when a restart drops `:local_name`; a
restart with a new `:local_name` keeps the original. The original is only
forgotten once the adapter is seen carrying it again
(`ACTION_LOCAL_NAME_CHANGED` or a later read), because `setName` is
asynchronous and a quick stop/restart could otherwise save one of the
plugin's own pending names as the original. While advertising, the
adapter's name is `local_name` (documented in `MobBluetooth.Le`).
- **Android: each `start_discovery/1` gets exactly one
`:discovery_finished`, and the receiver doesn't outlive the run**
(MOB-322). Before, the receiver and its pid stayed registered after a
failure (`:permission_denied`, `:start_failed`, …) and after a normal
finish. A later discovery stop, such as the one `createBond()` triggers,
then sent a stray `{:bt, :discovery_finished}` to the old caller. Every
failure path now unregisters the receiver. A successful run unregisters
it after its own `DISCOVERY_FINISHED`, and ignores the FINISHED broadcast
from cancelling a discovery that was already running when it started.
- **Android: session retirement after HFP disconnects is serialised with
SPP** (MOB-352). `bt_disconnect`'s final retirement and the HFP
connection receiver's retirement now run under `btSppLock` and skip a
session that holds a live SPP socket, so a concurrent SPP (re)connect to
the same device can no longer lose its session id.
### Added
- Host tests for the Android bridge's pure policy (per-SDK permissions,
discovery failure reasons, pair waiters, adapter-name restore), compiled
with `kotlinc` and run on a desktop JVM; tagged `:kotlin` and skipped
where no Android SDK is installed.
## [0.3.2] - 2026-10-01
### Fixed
- **Android: `MobBluetooth.pair(socket, device, pin: "0000")` actually
answers the pairing prompt now** (MOB-61). The Elixir side has
documented programmatic PIN pairing for years
(`lib/mob_bluetooth.ex:98-107,221-224`), but the Kotlin `bt_pair`
read only `optString("address")` from the JSON — the `pin` field
was dropped and the user got the system pairing dialog anyway. A
new `ACTION_PAIRING_REQUEST` receiver, armed once when a caller
supplies a `:pin`, calls `device.setPin(pin.toByteArray(UTF_8))`
for `PAIRING_VARIANT_PIN` requests and `abortBroadcast()`s the
system UI receiver. Passkey confirmation and OOB variants still
fall through to the system dialog (they need a human).
`setActivity` swap resets all lifecycle-owned receivers so a fresh
Activity re-arms cleanly.
- **Android: HFP vendor-specific AT events from headsets without a
plugin session no longer allocate phantom sessions** (MOB-195). The
vendor-AT receiver used to fall back to `btSessionFor(dev)` for any
headset that sent a vendor AT command, including headsets paired
through system Settings. That leaked one `btSessionMap` entry per
device for the app's lifetime. Events from devices with no existing
session are now dropped; they had no subscriber pid to route to anyway.
- **Android: HFP now emits `:bt_hfp, :connected` for already-connected
headsets (and, on Android ≤ 9, newly-initiated connects) and
`:bt_hfp, :disconnected` for local + remote hang-ups**
(MOB-63 + MOB-64). The pre-fix `bt_hfp_connect` only ever emitted
`:connecting` for a fresh connection — the terminal `:connected`
was documented but never fired, so consumers had no way to observe
"your HFP is now live." `bt_disconnect` always emitted the
SPP-shaped `:bt_spp, :disconnected` regardless of profile and never
actually called the HFP `proxy.disconnect(device)` reflection method.
A single lifetime-scoped `BroadcastReceiver` on
`BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED` now drives both
events. `bt_hfp_connect` registers it once + records the caller pid
in `btHfpSessionPids`; `bt_disconnect` marks the session in
`btHfpLocalDisconnects` before initiating the reflection call, so
the receiver picks `reason = "local"` vs the "peer" default. SPP
and HFP disconnects are now emitted independently (a session with
both profiles active gets both events). The receiver filters to
known sessions so system-initiated pairs (user pairs a headset via
Settings) don't allocate phantom `btSessionMap` entries.
Also folded from the pre-commit review: every `connectedDevices()`
call (in `bt_disconnect` and `bt_hfp_connect`) is now
`SecurityException`-guarded (`BLUETOOTH_CONNECT` on API 31+). A
missing grant no longer crashes the app from the main-thread proxy
callback; `Hfp.connect` instead emits exactly one
`{:bt_hfp, :connect_failed, %{reason: :permission_denied}}` and drops
the session's pid mapping (a `SecurityException` from the reflective
`connect()` maps to the same reason). A synchronous-`false` result from
the reflective HFP `connect()` clears the session's pid mapping and
emits `:connect_failed`, so callers get exactly one terminal event.
**Platform limit:** since Android 10, `BluetoothHeadset.connect()` is
restricted to privileged/system apps (`BLUETOOTH_PRIVILEGED`). For a
normal app the call returns `false`, so `Hfp.connect/2` on a headset the
system hasn't connected yields exactly one
`{:bt_hfp, :connect_failed, %{reason: :hfp_connect_failed}}`. When the
headset is already connected (through Settings or its own reconnect),
`Hfp.connect/2` attaches a session and delivers `:connected` right
away. Verified on Android 15 with a Monster Open Ear AC317:
`:connected`, then a local `disconnect` giving exactly one `:local`,
and link loss giving exactly one `:peer`.
- **Android: `pair` / `pair(pin:)` without `BLUETOOTH_CONNECT` now
reply `{:bt, :pair_failed, %{reason: :permission_denied}}`** instead
of no message (the unguarded `bondState` read threw before the
guarded `createBond`). `unpair` reports `:permission_denied` instead
of `:remove_bond_unavailable` for the same cause.
- **Android: SPP sessions emit exactly one `:bt_spp, :disconnected`.**
A local `MobBluetooth.disconnect/2` used to emit `:remote` (from the
read thread, whose read throws once the socket closes) and then
`:local`. Whichever side removes the socket first now sends the only
`:disconnected` event. If the peer's close wins that race, the
`disconnect/2` caller gets `{:bt, :error, %{reason: :no_session}}`
after the `:remote` event, instead of nothing. A remote close also
retires the session id, unless HFP or a newer connection to the same
device still uses it, so a later `disconnect(sid)` returns
`{:bt, :error, %{reason: :no_session}}` instead of a synthetic second
`:disconnected`.
- **Android: JNI exception no longer leaks onto the BEAM scheduler thread**
(MOB-62). The zig NIF had zero `ExceptionCheck`/`ExceptionClear` calls,
which meant a `SecurityException` raised by any Kotlin bridge method
(BLUETOOTH_CONNECT/SCAN/ADVERTISE permission gap on Android 12+) or a
`NoSuchMethodError` from an older/stripped bridge would linger on the
JNIEnv. The next JNI call on the same BEAM scheduler thread was then
undefined behaviour per the JNI spec.
Fix mirrors mob core's `mob_ui_cache_class` pattern (see
`mob/android/jni/mob_nif.zig`):
- `nativeRegister` now caches every method through a new `cacheMethod`
helper that clears any pending exception on a null return.
- Every `CallStaticVoidMethod` site (12 direct + 1 in `callBridgePidStr`,
which fans out to 5 more via `bt_pair` / `bt_unpair` / `bt_hfp_connect`
/ `bt_spp_connect` / `ble_start_advertising`) calls
`jni.exceptionClear(jenv)` immediately after the call — the JNI spec
guarantees the clear is a no-op if no exception is pending.
Existing runtime NIFs already guarded on a missing method-id cache via
`if (g_bt.<method> == null) return btUnsupported(env);`, so the `:unsupported`
path continues to work. Return-type contracts are unchanged; no public
API change. iOS path is untouched.
---
## [0.3.1] - 2026-09-30
### Changed
- **Re-signed with plugin envelope v2** (MOB-287). mob_dev 0.7.2+ verifies
this signature before evaluating the manifest. mob_dev 0.7.0 / 0.7.1 can't
read v2 signatures and report this release as `invalid signature` —
upgrade the host app to `{:mob_dev, "~> 0.7.2", only: :dev, runtime: false}`.
No plugin code changes.
- Cut from the 0.3.0 tag: the Android fixes merged on master since 0.3.0 (MOB-61/62/63/64/195) await device verification and will ship in 0.3.2.
## [0.3.0] - 2026-06-26
### Added
- **BLE GATT peripheral role (`MobBluetooth.Le`).** Building on the 0.2.0 BLE
central/advertise surface, this adds the *peripheral* role: run a GATT server,
advertise a **service** with characteristics, push notifications to subscribed
centrals, and receive writes — i.e. present the phone as a BLE accessory
(sensor, BLE-MIDI peripheral) that a computer or another phone connects to and
exchanges data with. API: `MobBluetooth.Le.start_advertising/2`,
`stop_advertising/1`, `notify/3`; events tagged `:bt_le`
(`:advertising_started`/`:advertising_failed`, `:central_connected`/
`:central_disconnected`, `:subscribed`/`:unsubscribed`, `:write`).
- **Cross-platform** (unlike the iOS-only `ble_scan`/`ble_advertise` surface):
`BluetoothGattServer` + `BluetoothLeAdvertiser` on Android, `CBPeripheralManager`
GATT server on iOS — added to the existing `mob_bluetooth_nif`. Adds the legacy
`BLUETOOTH` / `BLUETOOTH_ADMIN` permissions for API <= 30. Device-verified
end-to-end on Android (Moto G power) and iOS (iPhone SE) driving a BLE-MIDI
peripheral (mob_midi) that a Mac receives as live MIDI, both directions.
---
## [0.2.2] - 2026-06-24
### Changed
- **Background BLE is now opt-in per app** (was: both modes always declared in
0.2.1). By default the plugin declares no `UIBackgroundModes`, so apps that
don't need background BLE don't ship an unused background-mode declaration
(which Apple rejects at review). An app enables exactly the mode(s) it uses:
config :mob_bluetooth, ble_background_modes: [:central] # background scanning/connecting
config :mob_bluetooth, ble_background_modes: [:peripheral] # background advertising
config :mob_bluetooth, ble_background_modes: [:central, :peripheral] # both
The manifest reads this at build time via `MobDev.Plugin.host_config` and
contributes the matching `UIBackgroundModes` entries, array-merged into the
host Info.plist by mob_dev >= 0.6.16. **Verified on a real iOS build**: with
`[:central, :peripheral]` configured, the built app's Info.plist
`UIBackgroundModes` = `[audio, bluetooth-central, bluetooth-peripheral]`
(composing with a pre-existing entry). Requires **mob_dev >= 0.6.16**.
---
## [0.2.1] - 2026-06-24
### Added
- **Background BLE support.** The iOS BLE surface now actually works while the
app is backgrounded, not just in the foreground:
- The manifest declares `UIBackgroundModes` `bluetooth-central` +
`bluetooth-peripheral`, merged into the host Info.plist by mob_dev >= 0.6.16
(composes with any existing entry such as `audio`).
- `ble_scan/2` takes a `:service_uuids` filter
(`ble_scan(socket, service_uuids: ["180D"])`). iOS silently drops an
unfiltered scan once backgrounded, so a filter is required for background
scanning; omitting it keeps the previous foreground-only "scan for
everything" behaviour. (`ble_scan(socket)` is unchanged.)
See the "Background BLE" moduledoc section for the iOS throttling caveats
(coalesced scans, no local name in background advertising). Requires
**mob_dev >= 0.6.16** for the `UIBackgroundModes` array merge.
---
## [0.2.0] - 2026-06-24
### Added
- **iOS BLE surface (CoreBluetooth).** iOS has no public Bluetooth Classic API,
so the existing `bt_*` surface (discovery, pairing, HFP, SPP) stays
Android-only. This adds a separate, parallel **BLE** capability via
CoreBluetooth, available on iOS:
- `ble_scan/1` / `ble_stop_scan/1` — scan for nearby BLE peripherals
(`CBCentralManager`), emitting `{:bt, :ble_scan_started}` and
`{:bt, :ble_device, %{id, name, rssi}}` per advertisement.
- `ble_advertise/2` / `ble_stop_advertise/1` — advertise this device as a BLE
peripheral (`CBPeripheralManager`), emitting `{:bt, :ble_advertising}`.
iOS-only (returns `{:error, :unsupported}` on Android, which has no BLE surface
in this plugin yet), and needs a real radio, so it does nothing on the iOS
Simulator. Ships an ObjC NIF (`priv/native/ios/mob_bluetooth_nif.m`) +
the `CoreBluetooth` framework; events reuse the existing `:bt` device-event
family. **Device-verified on a physical iPhone SE** (3rd gen): `ble_scan`
returned real nearby peripherals with RSSI and `ble_advertise` started
advertising.
---
## [0.1.3] - 2026-06-24
### Added
- **`:bluetooth_connect` runtime permission capability.** The Android bridge now
implements `MobPermissionProvider`, mapping `:bluetooth_connect` to the whole
Android 12+ "Nearby devices" group (`BLUETOOTH_CONNECT` / `BLUETOOTH_SCAN` /
`BLUETOOTH_ADVERTISE`), and the manifest registers the capability. So
`Mob.Permissions.request(socket, :bluetooth_connect)` now shows the runtime
dialog and a single grant unlocks discovery, pairing, and `make_discoverable`
together — previously the plugin declared the permissions but had no way to
request the grant in-app (it returned `:denied` without a manual `adb grant`).
Android-only (BT Classic is unsupported on iOS). Device-verified on a Moto G
power 5G. (#2)
---
## [0.1.2] - 2026-06-24
### Added
- **`MobBluetooth.make_discoverable/2`** — request that the device become
discoverable to nearby Bluetooth devices for `:duration` seconds (default 120)
via `ACTION_REQUEST_DISCOVERABLE`, showing the system "make discoverable?"
dialog. First call to exercise `BLUETOOTH_ADVERTISE` (now declared in the
manifest). Fire-and-forget: the system dialog is the user-facing result; the
accept/deny outcome is not captured (a follow-up needs `onActivityResult`
plumbing). Failures (adapter off / permission not granted) arrive as
`{:bt, :error, reason}`. An invalid `:duration` falls back to the default
(pure, tested `discoverable_duration/1`). Device-verified on a Moto G power 5G
(Android 15). (#1)
### Security
- Bumped `plug` 1.19.2 → 1.20.1 (dev/test-only transitive via `mob_dev`),
clearing EEF-CVE-2026-54892 (quadratic-time nested-param decoding DoS). Does
not ship in the package; lockfile-only.
---
## [0.1.1] - 2026-06-16
### Changed
- Signed release: the published package now carries a verified Ed25519
signature (shared mob first-party key, regenerated in CI on every
release). Generated apps trust it via `config :mob, :trusted_plugins`,
so it clears the plugin signature gate without `acknowledge_unsafe_plugins`.
## [0.1.0] - 2026-06-12
Initial release. Bluetooth (discovery, SPP / HFP / HID) for Mob apps.
- Device discovery and connection management surfaced through `MobBluetooth`.
- Extracted from mob core in the 0.7.0 plugin-extraction wave.
- Requires `mob ~> 0.7`.