The constraint. Android exposes the Bluetooth HID Device profile through BluetoothHidDevice, and the platform accepts one registered app per phone. A second app calling registerApp() while another still holds the registration does not queue behind it — it simply fails. Any product family that ships a keyboard, a mouse and a presenter as separate installs has to solve this explicitly.
The naive alternatives are both bad. Merging everything into one app forces users to install features they do not want. Letting each app grab the profile whenever it starts produces a race in which the outcome depends on which app was opened last, and the failure surfaces as “it just does not connect” with nothing useful in the log.
What follows is the structure the Dotori HID apps use: a shared identity so the host sees one device, a signature-protected handover so only sibling apps can release the session, and a signing arrangement that makes that permission actually work once the apps are distributed through Google Play.
The host must see one device, not three
A Bluetooth host reads the SDP record at pairing time and caches it. It does not re-read it on every reconnect. That single fact drives the whole design: if each app publishes its own service name or its own HID report descriptor, the host treats them as different devices even though they come from the same phone. The host then keeps the one it cached and rejects the others.
Observed symptoms when the identity is not shared: repeated “device out of range” errors on Windows, mutual authentication failures, and eventually removal of the link key — that is, the pairing is destroyed and the user has to pair again.
So the three values below are shared constants, not per-app resources:
| Value | Rule |
|---|---|
| SDP name / description / provider | Identical string in every app. Not the app name, not the package name |
| HID report descriptor | Byte-identical. One combined keyboard-and-mouse descriptor with separate report IDs |
| Subclass | Same SUBCLASS1_COMBO declaration in every app |
Do not derive the SDP provider from packageName. It is tempting, and it works until the day an applicationId changes. Then the identity silently changes with it and every existing pairing breaks — a Bluetooth-protocol breaking change disguised as a refactor. A fixed literal costs nothing and removes the failure mode.
A useful way to think about it: the report descriptor answers what the device can send, and the descriptor is the same whether the user is currently typing, moving the pointer, or advancing slides. Which app produced the report is an implementation detail that the host neither sees nor cares about.
Handing the session over
Because only one app can hold the registration, a starting app must ask the current holder to release it. The mechanism is a broadcast that is explicitly addressed and permission-protected:
Intent(ACTION_RELEASE).setPackage(siblingPackage)
context.sendBroadcast(intent, permission)
Three properties matter here. The intent is explicit (setPackage), so it is delivered to a known app rather than broadcast to whoever is listening. It is sent with a permission, so a third-party app cannot receive it. And the receiver on the other side declares the same permission, so a third-party app cannot forge it either.
The receiving app stops its foreground service and gives up the registration. The starting app then waits before its first attempt, because shutdown is not instantaneous — a settle delay of roughly 250 ms, and one retry about 1.5 s later if the first registerApp() still collides. Values in that range came from watching real shutdowns take up to a second and a half.
The permission itself is declared signature:
<permission android:name="…hid.session.permission.RELEASE"
android:protectionLevel="signature" />
<uses-permission android:name="…hid.session.permission.RELEASE" />
Keep the permission name identical across the family and never version it per app. Do split it by build type, though — a DEBUG variant alongside the RELEASE one — so that a debug build on the phone talks only to other debug builds. A debug-to-release fallback looks convenient and hides exactly the failure you need to see before shipping.
The signing key decides whether any of this works
This is the part that is easy to get wrong, because everything above can be correct in source and still fail after publication.
A signature permission is granted only when the apps are signed by the same certificate as actually installed. In local development that is the debug key, so handover works on the first try and gives a false sense of completeness. Under Google Play, the installed APK is signed by the Play app signing key — not by the upload key. Two apps can share an upload keystore and still receive different app signing keys, in which case the permission is never granted and the handover broadcast is silently dropped.
| Key | Role | Must match across the family? |
|---|---|---|
| Upload key | Proves who is uploading the AAB | Convenient, but not what grants the permission |
| Play app signing key | Signs the APK that users install | Yes — this is the one that matters |
In Play Console the setting is App signing → Change key → Use the same key as another app in this developer account. The critical detail is timing:
The option closes once the app has been published to a public track. Before publication it is available and the cost is trivial — an app with no installs and no uploaded bundle loses nothing. Afterwards the choice is gone, and because Google Play permanently binds an applicationId to an app (deleting the app does not free the identifier), recovering means shipping under a new package name.
Practical order for a new sibling app: create the app, upload the first bundle to an internal track, change the signing key immediately, then verify. Do not put a public release between those steps. Verification means comparing the app signing certificate fingerprints of both apps in Console — every axis shown, including the quantum-resistant key when present — and separately confirming that the upload certificate matches too. Checking the upload certificate alone does not answer the question.
Package visibility is not optional
On Android 11 and above, an app cannot see or send an explicit broadcast to an arbitrary package unless it declares that package. Each app therefore lists its siblings, in both build-type flavours:
<queries>
<package android:name="com.example.keyboard" />
<package android:name="com.example.keyboard.debug" />
<package android:name="com.example.ppt" />
<package android:name="com.example.ppt.debug" />
</queries>
Omitting this does not throw. The sibling lookup simply returns “not installed”, the release request is never sent, and the new app fails to register for no visible reason. It is worth a contract test that parses the merged manifest and asserts the exact expected set, because the symptom is indistinguishable from a Bluetooth problem.
Surviving a package rename
Sibling apps usually exchange more than a release broadcast — a content provider for connection state, another for subscription data. Derive those authorities from the build variable rather than typing them out:
android:authorities="${applicationId}.hid-bridge"
and resolve the sibling side at runtime from the package name (content://$sibling.hid-bridge). With no authority literal anywhere, a package rename propagates automatically. The parts that must then change are few and easy to enumerate: the applicationId, the sibling list, each <queries> block, and the contract tests.
Keeping namespace separate from applicationId is worth knowing about here — the Kotlin package and the store identifier are independent, so a store-side rename does not have to touch the source tree at all. Whether to move both or only the store identifier is a judgement call about which kind of inconsistency you would rather maintain.
What to verify before shipping
- Physical, not log-based. Confirm input arrives at the host. Matching
down/uppairs in the app log can coexist with nothing reaching the PC, because the loss happens on the link. - Signer equality on the device. Install the Play builds of every sibling and compare the signer of the installed APKs, not the local ones.
- Handover in both directions. Keyboard → mouse and mouse → keyboard, using an existing pairing, with no re-pairing prompt.
- Remove stale builds first. An older sibling still on the phone can hold the registration while not knowing about the newer app — its own sibling list predates it, so it never receives a release request. The asymmetry is invisible until you look for it.
- Contract tests over the merged manifest. Permission names per build type, provider authorities per variant, the sibling mapping, and rejection of unknown packages.
Summary. The Bluetooth work — descriptor, SDP, transport — is the visible part but rarely the hard part. The failures that cost the most time are a shared identity that quietly diverges, a permission that is correct in source but ungranted after publication, and a package-visibility declaration that turns a missing entry into a silent no-op.