brainstate

msthind04/AppleTV-Remote · source

Snapshot 0 · 2026-11-04T12:47:28Z · Kotlin · Apache-2.0

Content fingerprintbd5567d820072cfe2bde1d9853d9e7446429a8c526295da4e1e361894b4d50fa

GitHub description at capture

Open-source Apple TV remote for Android. Native Kotlin implementation of Apple's Companion Link protocol - no server or bridge required.

README at capture

# Remote for Apple TV — Android

[中文说明](README.zh-CN.md)

An open-source **Apple TV remote for Android**. It speaks Apple's Companion
Link protocol natively in Kotlin, so there is no companion server, no Python
bridge, and nothing to run on a Raspberry Pi — just an APK on your phone.

Works with modern Apple TVs, where older DMAP-based remote apps no longer do.

> **Not affiliated with Apple Inc.** "Apple", "Apple TV", "AirPlay" and "Siri"
> are trademarks of Apple Inc., registered in the U.S. and other countries.
> They are used here solely to describe compatibility.

## Features

- **Discovery** — finds Apple TVs on your network automatically
- **Siri-Remote-style pad** — directional buttons around the rim, a touch
  surface in the middle. Swiping streams real touch samples with a
  pointer-ballistics curve, so momentum scrolling behaves like the hardware
  remote; tap selects, holding the surface sends a long press, which is how
  tvOS opens contextual menus
- **Menu, Home, Play/Pause** — the play button reflects real playback state
- **Power** — tap opens Control Centre, as the remote's own power button does;
  hold to wake a sleeping Apple TV
- **App drawer** — a grid of tvOS app icons, fetched from the App Store and
  cached; tap one to launch it. Apple's own apps are in no catalogue, so they
  are drawn from bundled artwork generated by `tools/generate_app_icons.py`
- **Text entry** — type into search boxes from your phone's keyboard, which
  appears automatically when the TV focuses a text field. A notification offers
  the same thing from the lock screen, with a reply field you can type straight
  into
- **Now playing** — title, artist, artwork and playback state in a card with a
  scrubber you can drag to seek, 10-second skips and volume
- **Media notification** — the same controls in the notification shade and on
  the lock screen, so the phone need not be unlocked to use them

## What's changed

- The D-pad and trackpad are merged into one Siri-Remote-style pad: directional
  buttons on the rim, a touch surface in the middle, and the whole surface is
  live — swipes no longer depend on where the finger lands
- The layout is adaptive: the pad takes whatever height is left, the app drawer
  owns landscape outright, and the now-playing card stays put instead of
  jolting the column when playback state arrives
- Faster startup: the last device appears instantly and, if it is the only
  pairing, the app connects straight into the remote
- Wake & retry: when a connect fails because the Apple TV is asleep, one button
  nudges it awake and reconnects
- Simplified now-playing card (±10 s skips; play/pause lives in the transport
  row), hold-to-repeat volume, haptic ticks, and the keyboard panel folds when
  OK is pressed on the TV
- zh-CN localization and a light theme alongside the dark one
- Fixed an intermittent startup crash in the touch-event pipeline

## Install

Download the latest APK from [Releases](../../releases) and install it. You
will need to allow installation from unknown sources.

Requires **Android 8.0 (API 26)** or newer, on the same Wi-Fi network as the
Apple TV.

## Pairing

1. Open the app and tap your Apple TV.
2. Enter the 4-digit code shown on the TV. **The code expires quickly** — if
   pairing stalls, cancel and try again for a fresh code.
3. Optionally tap **Show what's playing** to enable now-playing, which needs a
   second, independent pairing with its own code.

Credentials are encrypted with a key held in the Android Keystore. They grant
full control of the Apple TV, so treat them like a password.

## Compatibility

Verified end-to-end against an **Apple TV 4K (AppleTV14,1) running tvOS 26.6**.

The Companion Link protocol has been stable since tvOS 13, so other models and
versions from that era onward are expected to work — but they are untested. If
you try one, please open an issue either way: reports of what works are as
useful as bug reports.

Apple TV 3rd generation and earlier are **not** supported. They use the older
DMAP protocol, which modern devices have dropped entirely.

### Volume

Volume controls appear only when the Apple TV reports it can route volume, via
the `Volume` bit of the `_mcF` field in an `_iMC` event.

If your Siri Remote changes volume over **infrared**, the remote emits IR
itself and the Apple TV is never in the path — so no network client can change
the volume, and the device correctly never advertises the capability. Volume is
expected to work on HDMI-CEC setups. An idle Apple TV often sends no `_iMC`
event at all, so the controls may stay hidden until something is playing.

### Connection stability

An Apple TV rotates the ephemeral port it advertises for Companion Link, even
while it is awake and playing. When that happens the existing connection dies,
and reconnecting to the cached port fails outright. Three things guard against
this:

- Credentials are keyed on the device's stable `rpMRtID`, never on host:port,
  so a rotated port never orphans a pairing.
- Reconnection rediscovers the device over mDNS first rather than trusting the
  port it last saw.
- An idle connection is kept warm with periodic `NoOp` frames, and a failed
  write is treated as a lost connection — the reader blocks in `readFully` and
  will not notice a peer that vanished without a clean shutdown.

Commands transparently reconnect and retry once, so a dropped connection
surfaces as a brief pause rather than an error.

## Why this is non-trivial

Apple publishes no API for controlling an Apple TV. Since tvOS 15 the legacy
DMAP/DAAP protocol is gone entirely, and the only route in is Companion Link:

1. **Discovery** — mDNS on `_companion-link._tcp`.
2. **Pair-setup** — HomeKit-style SRP-6a (3072-bit, SHA-512) keyed by the
   4-digit PIN the TV displays, ending in an exchange of Ed25519 long-term keys.
3. **Pair-verify** — Curve25519 ECDH plus Ed25519 signatures on every connect,
   deriving per-session ChaCha20-Poly1305 keys.
4. **Commands** — OPACK-serialized frames over the encrypted channel.

Now-playing needs more still: a second pairing on port 7000 and an
MRP-over-AirPlay tunnel, described below.

## Layout

| Module | What it is |
|---|---|
| `protocol/` | Pure Kotlin/JVM. No Android dependencies. All crypto and wire format. |
| `cli/` | Desktop harness for pairing and testing against a real device. |
| `app/` | Android app (Jetpack Compose, Material 3). |

The protocol lives in a plain JVM module on purpose: it makes the risky part —
crypto and byte framing — testable from a terminal against real hardware,
instead of only through an emulator.

```
opack/Opack.kt          Apple's OPACK binary serialization
hap/Tlv8.kt             TLV8 records used by the pairing handshakes
hap/HapPairing.kt       Transport-agnostic pair-setup and pair-verify
crypto/Srp.kt           SRP-6a client (3072-bit group, SHA-512)
crypto/Primitives.kt    HKDF, Ed25519, X25519, ChaCha20-Poly1305
plist/BinaryPlist.kt    bplist00 reader/writer with UID support
companion/              Framing, session, commands, RTI text input
airplay/                HAP channels, AP2 session, MRP data stream
mrp/                    Protobuf wire codec and now-playing model
```

The Android module has grown its own moving parts:

```
RemoteScreen.kt         D-pad, trackpad, transport, app drawer, now-playing card
RemoteViewModel.kt      Connection ownership, reconnection, UI state
NowPlayingService.kt    Foreground service holding the MediaSession
NotificationBridge.kt   Handoff between the view model and the notifications
KeyboardNotification.kt Direct-reply prompt when the TV asks for text
AppIcons.kt             App Store artwork lookup and disk cache
SystemAppIcons.kt       Bundled artwork for Apple's own apps
```

## Building

Requires **JDK 21** (Android Gradle Plugin does not support JDK 25) and the
Android SDK with platform 35.

```bash
./gradlew :app:assembleDebug      # installable debug APK
./gradlew :app:assembleRelease    # minified, ~3 MB
./gradlew :protocol:test          # conformance tests
```

To log every protocol frame while debugging:

```bash
./gradlew :app:assembleDebug -PwireLogging=true
```

This is off by default because it writes frame contents, including pairing
traffic, to logcat.

The drawer's bundled icons are generated rather than hand-drawn:

```bash
python3 tools/generate_app_icons.py app/src/main/res/drawable
python3 tools/generate_app_icons.py app/src/main/res/drawable /tmp/preview
```

One geometry definition emits both the shipped vector drawables and, given a
second argument, SVG previews of the same paths — so what you review is what
ships. Previews go outside `res/`, where aapt would reject them. Edit the spec
at the foot of that script to change a colour or a glyph.

## CLI

Useful for testing the protocol without a phone.

```bash
./gradlew :cli:installDist

./cli/build/install/cli/bin/cli scan
./cli/build/install/cli/bin/cli pair          <host> 49153
./cli/build/install/cli/bin/cli pair-airplay  <host>
./cli/build/install/cli/bin/cli apps          <host> 49153
./cli/build/install/cli/bin/cli key           <host> 49153 home
./cli/build/install/cli/bin/cli text          <host> 49153 "search term"
./cli/build/install/cli/bin/cli nowplaying    <host>
```

Set `ATV_DEBUG=1` to log every frame in both directions. Credentials are
written to `~/.config/appletv-remote/`.

## Correctness

The crypto and serialization are validated byte-for-byte against vectors
generated from [pyatv](https://github.com/postlund/pyatv), a reference
implementation known to interoperate with real devices. Writing these layers
against a specification alone is how subtle interop bugs get shipped.

```bash
./gradlew :protocol:test
```

See [`tools/`](tools/) for regenerating the vectors.

This approach caught several bugs that would have been painful to diagnose
against live hardware:

- **Nonce truncation.** Kotlin masks `Long` shifts to 6 bits, so building a
  12-byte counter nonce with `counter shr (8 * i)` wrapped at `i == 8` and
  leaked counter bytes into the high nonce bytes. Only the very first message
  of a session was unaffected.
- **SRP salt encoding.** The salt must be hashed as the raw bytes the device
  sent. Round-tripping it through an integer drops a leading zero byte and
  breaks the proof — for roughly 1 in 256 pairings.
- **Non-canonical plist output.** CoreFoundation deduplicates equal scalars, so
  repeated strings such as `"NSObject"` collapse to one object. Without that the
  archive is structurally valid but byte-different, and tvOS rejects it silently.
- **Unsigned OPACK integers.** Encoding `-10` wraps it to `246`, which would
  seek four minutes the wrong way. `Opack.pack` now refuses negative integers.

## Protocol notes

### Text entry

The RTI channel does not use OPACK. Its payloads are NSKeyedArchiver archives —
binary property lists with UID cross-references — so `plist/BinaryPlist.kt`
implements the `bplist00` format and `companion/RtiPayloads.kt` builds the two
archives tvOS expects.

The keyboard presents itself automatically when the Apple TV focuses a text
field, via `_tiStarted` / `_tiStopped` events. A field already focused when the
app connects produces no event, so the initial state is seeded from the
`_tiStart` response instead. Sending text itself performs a `_tiStop`/`_tiStart`
round trip, and those echoes are suppressed so the keyboard does not dismiss
itself on every send.

### Now playing

From tvOS 15 onward, now-playing metadata is only available by tunnelling the
Media Remote Protocol through AirPlay:

1. Pair-verify on the AirPlay control connection (port 7000), after which the
   connection is wrapped in HAP block encryption.
2. `SETUP` with `isRemoteControlOnly` to get an event port, then connect the
   event channel. Nothing useful arrives on it, but the receiver will not
   proceed without one that answers.
3. `RECORD`.
4. `SETUP` again for a data port, with a random 64-bit seed folded into the
   key-derivation salt.
5. MRP handshake: `DEVICE_INFO` first — the device stays silent until it
   arrives — then `SET_CONNECTION_STATE` and `CLIENT_UPDATES_CONFIG`.

The data channel nests three framings: a 32-byte big-endian header, a binary
plist, and `params.data` holding varint-length-prefixed protobufs.

`mrp/Protobuf.kt` is a generic wire-format codec rather than generated sources.
MRP defines dozens of message types and now-playing touches about six fields
across four of them, so reading the wire format directly avoids putting protoc
into an Android build and tolerates unknown fields from newer tvOS revisions.

Two things that are easy to get wrong:

- **Track metadata is not in `nowPlayingInfo`.** That field is essentially
  always absent. Real titles and artists arrive in
  `SetStateMessage.playbackQueue.contentItems`, indexed by the queue's
  `location`.
- **`PlayerPath.client` is field 2, not 1** — field 1 is `origin`. Reading
  field 1 yields the device's own name for every app, silently collapsing all
  players into one entry so one app's metadata leaks onto another.
- **The playhead rides on the content item too.** Elapsed time is
  `ContentItemMetadata.elapsedTime`, field 35, alongside `duration` at 14 —
  not the `nowPlayingInfo` pair, which is usually absent. Field 12 is
  `releaseDate` and is also a double, so guessing at it decodes cleanly and
  yields a playhead of roughly the year 2015 in seconds. Field numbers here
  are worth checking against pyatv's `.proto` definitions rather than
  inferring, precisely because a wrong one need not fail loudly.

Position updates arrive sporadically, so the UI extrapolates between them from
an anchor — the last reported playhead and the moment it landed — rather than
polling. The media notification hands the same anchor to Android, which runs
its own scrubber forward at the reported rate.

## Notifications

The now-playing notification is a platform `MediaSession` rather than a
hand-drawn layout, so the system's own media controls drive it: artwork, a
scrubber that animates between updates, and transport buttons. Volume is
published through `setPlaybackToRemote`, which turns it into a slider instead
of two buttons — but only when the Apple TV reports it can route volume.

It runs inside a foreground service, and the service is the point rather than
the notification. It is what keeps the process, and therefore the Companion and
MRP connections, alive once the app leaves the screen. That is also why it runs
for the whole connection instead of only during playback: Android 12 forbids
starting a foreground service from the background, so waiting for playback
would mean the notification could never appear unless the app happened to be
open.

**Its lifetime is the app's.** Swiping the app off the recents list clears the
view model, which closes the connections and takes the notification with it.
Surviving that would mean moving connection ownership into the service.

The keyboard prompt is a separate notification carrying an Android direct-reply
field, so text can be typed and sent from the shade without unlocking. It is
raised by the TV's own focus event — deliberately not by the in-app keyboard
toggle, which the user drives — and reposted as the field's contents change,
which is also what clears the reply spinner after a send.

## App icons

The Apple TV sends no artwork: Companion Link reports a name and a bundle id
and nothing else. The drawer fills that gap from Apple's public iTunes
endpoints, trying four queries in turn and stopping at the first that answers:

1. the tvOS listing in the phone's own store region — 512×307 artwork, exactly
   the tile's 5:3;
2. the same in the US store, which is the widest catalogue;
3. the app's iOS listing, whose icon is square and so sits inside the tile
   rather than filling it;
4. a search by name, which is the only step that can be wrong.

That last step is gated: the store's title must *equal* the app's name, give or
take a tagline after a separator. Mere containment is not enough — it put
"Fusion Smart Education" on an app called Fusion. Note that the search endpoint
ignores `entity=tvSoftware` and only answers for `software`, so a name match
never yields tvOS-shaped artwork.

Results are cached on disk, hits and misses alike, since an app that is not in
the store today will not be tomorrow. Network failures are deliberately *not*
cached, or a dropout would leave an app wearing initials until the cache
cleared. The cache directory carries a version suffix: a miss recorded by an
older, narrower set of queries says nothing about what the current ones would
find.

Apple's own apps are in no catalogue under any region or entity, so they are
never looked up. They ship with the app instead, as original artwork in one
visual language — see the generator under [`tools/`](tools/). Original rather
than Apple's own, which this project has no licence to redistribute.

## Security

- Pairing credentials are encrypted with an AES-GCM key held in the Android
  Keystore, which on most devices is non-extractable. Backups are disabled,
  since Keystore-wrapped ciphertext cannot be restored onto other hardware.
- Wire logging is off unless explicitly built with `-PwireLogging=true`.
- The app requests `INTERNET`, network and Wi-Fi state, `NEARBY_WIFI_DEVICES`
  (required for local service discovery on Android 13+), `POST_NOTIFICATIONS`
  and the foreground-service permissions the media notification needs. There is
  no analytics and no account.
- **One request leaves your network.** The Apple TV never sends app artwork, so
  the drawer looks each bundle id up against Apple's public iTunes endpoint —
  the same one App Store search uses. Apple therefore learns which apps are
  installed on your TV. Apple's own apps are never looked up, since they appear
  in no store. Results are cached on disk, hits and misses alike, so each app is
  asked about once. Nothing else about your device or usage is
  sent, and the remote itself works entirely on the LAN.

Found a security issue? Please open an issue.

## Contributing

Contributions are welcome — especially **compatibility reports** for models and
tvOS versions other than the one tested.

Please run `./gradlew :protocol:test` before opening a pull request. If you
change protocol behaviour, add a conformance vector rather than only a
round-trip test: self-consistency proves nothing about what a real device
accepts.

## Licence

Licensed under the [Apache License 2.0](LICENSE).

Protocol knowledge and test vectors are derived from
[pyatv](https://github.com/postlund/pyatv) (MIT). See
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).

Companion Link is an undocumented Apple protocol; this is a clean-room-style
implementation built from public reverse-engineering work. It controls hardware
you own, on your own network.