---
name: debug-receivers
description: Drive the S2 debug build's playback and queue over ADB broadcasts — play the whole library, play/pause, skip, seek, remove a queue item, toggle shuffle/repeat, dump playback state as JSON, reimport the library, download or remove a song offline and dump the downloads. Use when checking playback on an emulator or device without tapping through the UI, or when you need the exact `am broadcast` syntax.
---

# Debug Broadcast Receivers

Debug-only receivers in `android/app/src/debug/` (never in release). All are exported but guarded
by `android.permission.DUMP`, which `adb shell` holds and third-party apps don't.

**Use the wrapper**, `support/scripts/s2-debug.sh <ACTION> [am broadcast extras]`. It sends the
broadcast, waits for the receiver's reply on logcat tag `S2Debug`, prints it, and exits non-zero on
an error or when no reply arrives within `S2_DEBUG_TIMEOUT` seconds (10). It honours
`ANDROID_SERIAL` / `ANDROID_ADB_SERVER_PORT`, so on a WSL lane `eval "$(support/scripts/remote-emu.sh env)"`
first. Broadcasts carry `FLAG_INCLUDE_STOPPED_PACKAGES`, so they also reach (and start) the app
after `am force-stop`. If the WSL lane's tunnel drops mid-run ("device offline"/"device not
found"), the wrapper reconnects it once via `remote-emu.sh reconnect` and retries once before
failing.

| Action | Extras | Does |
|---|---|---|
| `PLAY_ALL` | `[--ei index N] [--es album NAME]` | Queue every library song (`SongRepository`, `SongQuery.All()`), or only album NAME's, and play from index N, via the `PlaySongs` use case the song list uses |
| `PLAY` / `PAUSE` | | `PlaybackOperations.play()` / `pause()` |
| `NEXT` / `PREV` | | `skipToNext()` / `skipToPrev()` (`PREV` restarts the track past 2 s, as the UI does) |
| `SEEK` | `--el ms 20000` | `seekTo(ms)` |
| `REMOVE_QUEUE_ITEM` | `--ei position N` | `PlaybackOperations.removeQueueItem`, the queue screen's "Remove from Queue" path. N indexes the queue in its displayed (shuffle-aware) order |
| `REMOVE_PLAYLIST_SONG` | `--es playlist NAME --es song TITLE` | `PlaylistRepository.removeFromPlaylist`, the playlist detail screen's per-row "Remove" path (the `RemoveFromPlaylist` use case) |
| `REORDER_QUEUE` | `--ei from N --ei to N` | `QueueOperations.move(from, to)`, the queue screen's drag-to-reorder path. Both indices are in the displayed (shuffle-aware) order |
| `TAP_START` | `[--es name NAME]` | Start recording what `CrossfadeMixer` outputs (before the equalizer) to `<externalFilesDir>/crossfade-tap/NAME.wav` (default `crossfade-tap`); replies with the path. See below |
| `TAP_STOP` | | Finish the WAV; replies with path, format and duration |
| `SHUFFLE` | `[--ez enabled true\|false]` | Toggle, or set, the shuffle mode |
| `REPEAT` | `[--es mode off\|all\|one]` | Toggle (Off → All → One), or set, the repeat mode |
| `SPEED` | `--ef multiplier 1.5` | `PlaybackOperations.setPlaybackSpeed(multiplier)` |
| `SLEEP_TIMER` | `--el seconds 3 [--ez play_to_end true\|false]` | `SleepTimer.startTimer`, the same timer the Sleep Timer dialog starts |
| `DUMP_STATE` | | Print the state as one JSON line (below) |
| `DOWNLOAD_SONG` | `[--el song_id N]` | `DebugDownloadReceiver`: download a song for offline use (default: the first remote song) from its current stream URI, keyed by `song.path` |
| `REMOVE_DOWNLOAD` | `[--el song_id N]` | Remove that song's download |
| `DOWNLOAD_WIFI_ONLY` | `--ez enabled true\|false` | Set the Wi-Fi-only download preference (default true); pushed to the DownloadManager's requirements |
| `DUMP_DOWNLOADS` | | One JSON line: `wifiOnly`, `cacheFiles`/`cacheBytes` under `filesDir/downloads`, and `downloads` (`path`, `state`, `progress`, `bytesDownloaded`, `contentLength`) |
| `IMPORT` | | Wrapper-only alias for `DebugMediaImportReceiver`: reimport the library from MediaStore. Fire-and-forget; give it a few seconds |

Replies: `<ACTION> ok[: detail]` (e.g. `PLAY_ALL ok: 5 songs from index 0`,
`REMOVE_QUEUE_ITEM ok: <removed title>`) or `<ACTION> error: <reason>`. An action not in the
manifest's intent filter is never delivered, so the wrapper times out on typos.

`DUMP_STATE` fields: `state` (`PlaybackOperations.playbackState()`), `reportedState` (the
`playbackStateFlow` value), `positionMs` (`getProgress()`), `progressMs` (`progressFlow`),
`durationMs`, `savedPositionMs` (the persisted resume position), `queuePosition`, `queueSize`,
`title` (current song), `inLibrary` (false for a file opened from another app that isn't in the library,
playing as a transient song), `queueTitles` (every song name in the queue's displayed, shuffle-aware
order -- `queueTitles[queuePosition + 1]` is the item that will auto-advance to next), `shuffle`,
`repeat`, `speed` (`getPlaybackSpeed()`), `pendingLoad` (a track load in flight: the state is `Loading`).

```json
{"state":"Playing","reportedState":"Playing","positionMs":3225,"progressMs":3153,"durationMs":60029,"savedPositionMs":3050,"queuePosition":0,"queueSize":5,"title":"Playback One","queueTitles":["Playback One","Playback Two","Playback Three","Playback Four","Playback Five"],"shuffle":"Off","repeat":"Off","speed":1.0,"pendingLoad":false}
```

## Capturing the crossfade output

`TAP_START` / `TAP_STOP` record the mixer's PCM (16/24-bit, the stream's rate) as a WAV, across song joins, so a fade or
a gap can be checked from the file instead of by ear. Debug builds only: `WavTapAudioProcessor` is bound by
`CrossfadeTapDebugModule` in `src/debug`; release binds none. Start before the join, stop after; pausing writes
nothing. A stream in a different format from the first is left out and reported.

```bash
support/scripts/s2-debug.sh TAP_START --es name join1      # prints the device path
support/scripts/s2-debug.sh PLAY_ALL
# ... let it cross a join ...
support/scripts/s2-debug.sh TAP_STOP
adb pull /sdcard/Android/data/com.simplecityapps.shuttle.dev/files/crossfade-tap/join1.wav build/
```

To have the capture judged for you: `support/scripts/seed-test-media.sh crossfade` seeds tone songs (a distinct frequency
each, 16- and 24-bit albums), `support/scripts/crossfade-analyse.py tap.wav --freqs 330,550,770 --crossfade-ms 3000`
reports per-transition overlap, fade envelopes, gaps and clipping (exit 1 on a problem; its tests are
`python3 -I support/scripts/crossfade_analyse_test.py`), and `support/scripts/crossfade-check.sh [ms]` does the whole
seed, set, play, tap, pull, analyse run on the current lane.

## Typical check

```bash
support/scripts/remote-emu.sh start && eval "$(support/scripts/remote-emu.sh env)"
support/scripts/remote-emu.sh install
support/scripts/seed-test-media.sh playback --skip-onboarding   # 5 x 60 s tracks, library imported
support/scripts/s2-debug.sh PLAY_ALL
support/scripts/s2-debug.sh REMOVE_QUEUE_ITEM --ei position 0   # remove the playing item
support/scripts/s2-debug.sh DUMP_STATE                          # expect Playing, title "Playback Two"
support/scripts/remote-emu.sh stop
```

Parse the JSON with `python3 -c 'import json,sys; ...'` or `jq`. Ready-made checks built on this
live in `support/maestro/` (see its README).

## Raw broadcast

```bash
adb shell am broadcast -f 32 -p com.simplecityapps.shuttle.dev \
  -a com.simplecityapps.shuttle.debug.SEEK --el ms 20000
adb logcat -d -s S2Debug:I | tail -1
```

## Adding an action

Add a branch to `DebugPlaybackReceiver.handle()` (return an optional detail string, throw with the
reason on failure), add the `<action>` to the receiver's intent filter in
`android/app/src/debug/AndroidManifest.xml`, and add a row above. Keep release code untouched: reach
state through injected classes, not new public hooks.

**Never `pm clear` a device you don't own**; on a WSL lane, `remote-emu.sh reset` is the way to
wipe the debug app.
