Skip to content

feat(android): add settings bluetooth on|off - #3721

Open
mikolajadamowicz wants to merge 6 commits into
callstack:mainfrom
mikolajadamowicz:feat/android-bluetooth-setting
Open

mikolajadamowicz wants to merge 6 commits into
callstack:mainfrom
mikolajadamowicz:feat/android-bluetooth-setting

Conversation

@mikolajadamowicz

@mikolajadamowicz mikolajadamowicz commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds settings bluetooth on|off for Android over adb, local and Limrun, next to wifi, airplane and mobile-data. Turning the adapter off lets a test drop a Bluetooth headset mid-playback.

agent-device settings bluetooth off   # { "bluetooth": "disabled" }
  • Runs svc bluetooth enable|disable from Android 8.1, API 27. On Android 13+ svc forwards to cmd bluetooth_manager, so one command covers every level.
  • svc bluetooth exits 0 even when the adapter refuses, so the change is confirmed by polling bluetooth_on for up to 2 s. If it never settles it fails with COMMAND_FAILED and reason bluetooth-not-applied or bluetooth-unreadable, like mobile-data.
  • Nothing is written, and the error says dispatched: no, without android.hardware.bluetooth, below 8.1, on an unreadable API level, or after cancellation. Other targets refuse it before anything runs.
  • pm list features gets one parser, shared with TV discovery. The API-level read moves to device-sdk-int.ts, and three underFakeTimers helpers become one.

22 files.

Validation

At 8e302d9e, on 77137b01, format, lint, layering, fallow, build, command-docs and related unit tests pass. Typecheck fails only in test/integration/command-coverage/declarations.ts, which is unchanged from main and broken there too. Coverage, integration and smoke run in CI.

Unit tests cover each refusal with the adb calls it made, the read-back, dispatch disclosure and cancellation. Reverting each fix fails at least one.

On an API 37 emulator at 07e3bb1f, settings bluetooth off and on report disabled and enabled, and bluetooth_on reads 0 then 1. On API 27 and API 26 images without android.hardware.bluetooth, it refuses with android_bluetooth_adapter_missing and dispatched: no and leaves bluetooth_on unchanged. Raw svc bluetooth exits 0 there without doing anything, which is why the adapter and API checks run first. Not covered: Limrun.

View guided diff

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 22 files

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/daemon/src/execution/handlers/__tests__/snapshot-settings-handler.test.ts">

<violation number="1" location="packages/daemon/src/execution/handlers/__tests__/snapshot-settings-handler.test.ts:248">
P2: This test claims the Bluetooth request is refused before dispatch, but it does not assert the typed dispatch disclosure. Add `details.dispatched === 'no'` so the test distinguishes a true admission refusal from an ambiguous dispatch status; keep the empty mutation assertion as the device-side evidence.</violation>
</file>

Comment thread packages/contracts/src/settings-catalog.ts
Comment thread packages/platform-android/src/device-sdk-int.ts Outdated
if (response && !response.ok) {
expect(response.error.code).toBe('UNSUPPORTED_OPERATION');
expect(response.error.message).toBe('settings bluetooth is not supported on this device');
expect(response.error.details?.reason).toBe('unsupported-platform-leaf');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This test claims the Bluetooth request is refused before dispatch, but it does not assert the typed dispatch disclosure. Add details.dispatched === 'no' so the test distinguishes a true admission refusal from an ambiguous dispatch status; keep the empty mutation assertion as the device-side evidence.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/daemon/src/execution/handlers/__tests__/snapshot-settings-handler.test.ts, line 248:

<comment>This test claims the Bluetooth request is refused before dispatch, but it does not assert the typed dispatch disclosure. Add `details.dispatched === 'no'` so the test distinguishes a true admission refusal from an ambiguous dispatch status; keep the empty mutation assertion as the device-side evidence.</comment>

<file context>
@@ -227,6 +227,29 @@ test('settings text-size reads the category the owner holds without mutating any
+  if (response && !response.ok) {
+    expect(response.error.code).toBe('UNSUPPORTED_OPERATION');
+    expect(response.error.message).toBe('settings bluetooth is not supported on this device');
+    expect(response.error.details?.reason).toBe('unsupported-platform-leaf');
+  }
+  expect(fixtureSettingsMutations).toHaveLength(0);
</file context>
Suggested change
expect(response.error.details?.reason).toBe('unsupported-platform-leaf');
expect(response.error.details?.reason).toBe('unsupported-platform-leaf');
expect(response.error.details?.dispatched).toBe('no');

@mikolajadamowicz mikolajadamowicz Oct 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not fixing this one here. The refusal comes from the shared unsupportedOperationResponse in runtime-admission.ts, and it never sets dispatched. So the router marks every mutating command refused at admission as unknown, settings or not. That needs a fix in admission for all routes, which is a separate PR.

Comment thread website/docs/docs/commands.md Outdated
Comment thread CHANGELOG.md Outdated
@thymikee

Copy link
Copy Markdown
Member

The Android bluetooth change is not ready to merge at bfd0cfb. I found three problems to fix first.

The public Node client type cannot express bluetooth. The catalog entry uses app: 'input', so StateOnlySetting maps it to never (client-settings.ts#L27-L29). SettingsUpdateOptions has a hand-written arm for mobile-data but none for bluetooth (line 47). So client.settings.update({ setting: 'bluetooth', state: 'off' }) is a type error for Node and AI-SDK users, while the CLI and docs say it works. Only the CLI reaches it, through a cast at settings.ts#L79. Every SETTINGS entry should have exactly one arm in SettingsUpdateOptions. Please derive the device-level arm from the catalog for each entry with app: 'input' and a device-level mobile scope, so one rule covers mobile-data and bluetooth. A type test should assert that Extract<SettingsUpdateOptions, { setting: N }> is not never for every SettingName. I did not run tsc. This comes from reading the conditional types at this commit.

The read-back code in settings-bluetooth.ts#L113 is a near-copy of the mobile-data code in settings.ts (lines 330-380). awaitAndroidBluetoothState, parseAndroidBluetoothState and androidBluetoothSettleError repeat the same settings get global <row> poll, message template, reason split and details shape. Only the row, value map, interval and hint differ. Two copies will drift. Bluetooth already handles exit codes and allowFailure, which mobile-data lacks. Could you add one parameterized global-toggle helper and route both settings through it? Then settings-bluetooth.ts would keep only the adapter and API-level preflight, about 50 lines.

The new device write path is not tested on a device: svc bluetooth, the bluetooth_on read-back, and the pm list features gate. Two assumptions are unverified. One is that svc bluetooth exits 0 without effect below API 27. The other is that bluetooth_on settles within 2 s. If either is wrong, users would see a misleading bluetooth-not-applied error. Please run settings bluetooth off then on on an Android 8.1+ emulator whose pm list features includes android.hardware.bluetooth. Each call should return { bluetooth: 'disabled' | 'enabled' }, and adb shell settings get global bluetooth_on should read 0 then 1. Please also run once on an image without that feature. It should show UNSUPPORTED_OPERATION with reason android_bluetooth_adapter_missing and dispatched no. A Limrun Android off/on run would cover the second admitted backend.

Not blocking: the iOS refusal test in snapshot-settings-handler.test.ts#L248 could also assert details?.dispatched is 'no'. Take it or leave it.

Open review threads: the dispatched assertion thread still applies and is the non-blocking note above. These four no longer apply, so please resolve them. The --app refusal is fixed because the catalog now declares app: 'input' with a device-level mobile scope. The parseInt thread is fixed because device-sdk-int.ts validates the whole token. The commands.md wording now matches the preflight order. The CHANGELOG wording no longer calls value 2 unreadable.

The one failing check is the iOS simulator E2E smoke scenario. It timed out waiting for the deep-link destination. This diff touches no iOS, deep-link or readiness code, so the failure is likely unrelated. I did not read the queued second Smoke Tests run. There are no conflicts.

Before merge, add the catalog-derived SettingsUpdateOptions arm for bluetooth and move the read-back into the shared helper with mobile-data. Then post the emulator run above.

@mikolajadamowicz

Copy link
Copy Markdown
Contributor Author

Fixed the client type and the shared read-back in 07e3bb1. bluetooth and mobile-data now share settings-global-toggle.ts, and SettingsUpdateOptions derives the device-wide arm from the catalog, with a type test that every SettingName has an arm.

Emulator run at 07e3bb1 on emulator-5554, API 37, which declares android.hardware.bluetooth:

$ agent-device settings bluetooth off --platform android --serial emulator-5554 --json
{"success":true,"data":{"setting":"bluetooth","state":"off","bluetooth":"disabled","message":"Updated setting: bluetooth"}}
$ adb shell settings get global bluetooth_on
0
$ agent-device settings bluetooth on --platform android --serial emulator-5554 --json
{"success":true,"data":{"setting":"bluetooth","state":"on","bluetooth":"enabled","message":"Updated setting: bluetooth"}}
$ adb shell settings get global bluetooth_on
1

Not covered yet. Every image I have locally declares android.hardware.bluetooth, the API 36 Android TV one included, so none of them reaches the android_bluetooth_adapter_missing refusal. The Limrun run and the pre-API 27 behaviour of svc bluetooth are still open too.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 5 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread packages/platform-android/src/settings-global-toggle.ts Outdated
@mikolajadamowicz

Copy link
Copy Markdown
Contributor Author

More emulator runs at 07e3bb1, on AOSP default arm64 images for API 27 and API 26. Neither declares android.hardware.bluetooth.

# API 27
$ agent-device settings bluetooth off --platform android --serial emulator-5558 --json
UNSUPPORTED_OPERATION  reason=android_bluetooth_adapter_missing  dispatched=no
$ adb shell settings get global bluetooth_on   # unchanged
1
$ adb shell svc bluetooth disable; echo $?
Got a null BluetoothAdapter, is the system running?
0

# API 26
$ agent-device settings bluetooth on --platform android --serial emulator-5560 --json
UNSUPPORTED_OPERATION  reason=android_bluetooth_adapter_missing  dispatched=no
$ adb shell svc bluetooth disable; echo $?
[svc help, with no bluetooth entry]
0

So both assumptions hold on real builds. Below API 27, svc has no bluetooth command and exits 0 after printing its help. On API 27 without an adapter, svc bluetooth exits 0 having done nothing. The 2 s settle held on the API 37 run above.

Limrun isn't run. I don't have Limrun credentials on this machine.

@thymikee

Copy link
Copy Markdown
Member

The Android side of this PR now reads well at 8005f9c. The earlier evidence request is met, and the one open P1 thread (the probe timeout) is fixed. I did not run tsc or vitest, and the live emulator transcripts are the author's, not reproduced by me. I have no Limrun Android run. A Limrun image without android.hardware.bluetooth would refuse the toggle instead of switching it.

The branch has merge conflicts with main. Smoke Tests is still queued, with no failure shown, and this diff only touches the Android settings write path and contract types, so nothing links it to that route yet.

Not blocking, and you can take or leave these: stateKey in settings-global-toggle.ts is a plain string with a Record<string, AndroidToggleState> return type, so setAndroidBluetooth loses its typed {bluetooth} and {mobileData} result shape. Making the helper generic over Key extends string and returning Record<Key, AndroidToggleState> would restore it. The mobile-data path in settings.ts now runs on the shared helper, which adds allowFailure with an exit-code check, dispatched=no on an abort before dispatch, and a fixed 1 s read-back timeout. If mobile-data has shipped, one CHANGELOG clause would cover that.

On the open threads, one P2 still applies: the iOS bluetooth refusal test asserts no details.dispatched === 'no', and it is not a merge gate. These five are fixed at head, so please resolve them: probe timeout now uses the fixed 1 s constant with a test, catalog scope declares bluetooth as device-level with the refusal covered, SDK token check validates the whole token, commands.md wording matches the preflight order, and CHANGELOG wording no longer calls bluetooth_on=2 unreadable.

Please rebase onto main and let Smoke Tests finish. Both are needed before merge.

Toggle the Android Bluetooth adapter from `settings`, next to wifi and
airplane. QA automation for media playback uses it to stand in for a
Bluetooth audio device disconnecting mid-session.

- Runs `svc bluetooth enable|disable` from Android 8.1 (API 27). From
  Android 13 AOSP's `svc` forwards that to `cmd bluetooth_manager`, so one
  command serves every supported level.
- The adapter is proven with `pm list features` before the write, so a
  device or emulator image without android.hardware.bluetooth is refused
  with UNSUPPORTED_OPERATION and reason android_bluetooth_adapter_missing,
  unchanged. Builds older than Android 8.1 are refused with
  android_bluetooth_command_unavailable, and an unreadable API level
  fails with the getprop output before any write.
- `svc bluetooth` exits 0 when the adapter refuses the change, so the
  write is confirmed by polling `settings get global bluetooth_on` for up
  to 2 s (host-kit pollUntilSettled). A state that never arrives fails
  with COMMAND_FAILED and reason bluetooth-not-applied, or
  bluetooth-unreadable with the stored value; success reports the
  resulting `bluetooth` state.
- Refusals before the write disclose dispatched: no, failures after it
  dispatched: unknown. A cancelled request stops before the write.
- Apple targets refuse the setting at admission through the existing
  per-owner facts.
- `pm list features` now has one parser, androidFeatureListDeclares, which
  TV discovery and the Bluetooth check share. The API-level read moves out
  of the permission module into device-sdk-int.ts and keeps its result.
  The three copies of the underFakeTimers test helper become one in
  test-utils.
…ctly

- bluetooth is device-wide, so the catalog declares it the way
  mobile-data does: the app rides the request input and a named app is
  refused with setting_app_not_consumed before dispatch.
- readAndroidSdkInt accepts only a whole positive integer. parseInt
  read `27garbage` as 27, which would let the Bluetooth gate dispatch on
  an unreadable API level.
- The docs and changelog say the old-build refusal applies only once
  the adapter check passes, and describe bluetooth-unreadable as an
  unrecognized bluetooth_on value.
settings.ts imported settings-bluetooth.ts statically, so importing
mechanics.ts also evaluated settings-bluetooth.ts and the
inventory-parsers.ts it pulls in. The eager-closure budget caught the
growth (176 to 179 modules). The bluetooth case now imports the module
when it runs, the way device-boot and lifecycle load theirs.
…ce-wide client arms

- mobile-data and bluetooth now go through one setAndroidGlobalToggle in
  settings-global-toggle.ts: the svc write, the abort check, the
  settings-global read-back poll, the typed settle error, and dispatch
  disclosure. Each setting only declares its service, row, value map,
  reasons, interval and hint. mobile-data gains the exit-code handling
  bluetooth already had, and settings-bluetooth.ts keeps only the
  adapter and API-level preflight.
- SettingsUpdateOptions derives one arm for every catalog entry with
  app: 'input' and a device-level mobile scope, replacing the
  hand-written mobile-data arm. client.settings.update({ setting:
  'bluetooth', state: 'off' }) now type-checks. A type test asserts every
  SettingName has an arm.
pollUntilSettled sleeps up to the deadline and then samples once more.
Each `settings get global` read took the deadline's remaining time as
its adb timeout, so that last read ran with a 1 ms budget and failed as
an adb timeout instead of reaching the typed not-applied reason. Reads
now get a fixed 1 s budget; the 2 s settle window still bounds polling.
AndroidGlobalToggle is generic over its stateKey, so setAndroidBluetooth
returns { bluetooth } and the mobile-data toggle { mobileData } again
instead of a string-keyed record.
@mikolajadamowicz
mikolajadamowicz force-pushed the feat/android-bluetooth-setting branch from 8005f9c to 8e302d9 Compare October 16, 2026 17:48
@mikolajadamowicz

Copy link
Copy Markdown
Contributor Author

Rebased onto main at 77137b0. The new head is 8e302d9 and has no conflicts.

I took the typed-result suggestion. AndroidGlobalToggle is now generic over its stateKey, so bluetooth returns { bluetooth } and mobile-data { mobileData }.

I skipped the CHANGELOG clause. git tag --contains 87fe3ea8 is empty, so mobile-data hasn't shipped and its existing Unreleased entry still describes it.

Heads-up: main doesn't typecheck right now. test/integration/command-coverage/declarations.ts:558 calls linux.gap(...), which #3720 added after #3717 removed gap. That file is the same on this branch as on main, so Lint, Typecheck & Package will fail here until main is fixed. I checked the rest with that line patched locally and not committed. Format, lint, typecheck, layering, fallow and build pass, along with command-docs and 3,433 related tests.

@thymikee

Copy link
Copy Markdown
Member

The code is in good shape. The earlier findings from 8005f9c are now fixed at 8e302d9, and I found no new problems in the code.

The red checks look unrelated to this PR. Lint/Typecheck, Repo Guards, Integration and the Android catalog smoke fail on linux.gap is not a function (TS2339) at test/integration/command-coverage/declarations.ts:558. This PR does not touch that file, and main fails the same way. #3740 fixes it. The smoke job 117114007177 fails in prepare ios-runner (prepare_deadline_expired), and this patch touches no Apple runner or iOS path.

On the open review threads: the P2 thread on the dispatched assertion still applies (#3721 (comment)). The empty fixtureSettingsMutations already proves nothing was dispatched, so it does not block merge. These threads are fixed at 8e302d9 and the author can resolve them: the probe timeout (#3721 (comment)), the catalog scope (#3721 (comment)), the SDK token check (#3721 (comment)), the docs order (#3721 (comment)) and the CHANGELOG wording (#3721 (comment)).

I did not run tsc or vitest on 8e302d9. The delta is type-only, and the author reports a local typecheck with declarations.ts:558 patched. I did not reproduce the emulator transcripts from the earlier round. There is no Limrun Android run, so an image without android.hardware.bluetooth would refuse the toggle. I did not re-check the mobile-data shipping claim with git tag --contains 87fe3ea8.

Nothing in this PR holds up the merge. Once #3740 fixes the typecheck on main, please rerun CI, including the iOS runner preflight smoke.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants