Skip to content

About

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

Resources

Stars

0 stars

Watchers

0 watching

Forks

 
 

Repository files navigation

Remote for Apple TV — Android

中文说明

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 new in 2.0

  • 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 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.

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

To log every protocol frame while debugging:

./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:

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.

./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, a reference implementation known to interoperate with real devices. Writing these layers against a specification alone is how subtle interop bugs get shipped.

./gradlew :protocol:test

See 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/. 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.

Protocol knowledge and test vectors are derived from pyatv (MIT). See 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.

About

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

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages