Skip to content

Android TV Remote support for Mi TVs without MiLink - #5

Merged
EndLess728 merged 13 commits into
mainfrom
feat/android-tv-remote
Oct 20, 2026
Merged

EndLess728 merged 13 commits into
mainfrom
feat/android-tv-remote

Conversation

@EndLess728

Copy link
Copy Markdown
Owner

Adds support for Mi TVs that don't run Xiaomi's MiLink service, through Google's Android TV Remote protocol (the one the Google TV app uses), in all three apps. Fixes #4.

What changes

  • Protocol choice per TV. The apps try MiLink (TCP 6091) first. If nothing listens there, they use Android TV Remote: TLS 1.2 on 6466, with a client certificate. They remember which protocol each TV uses.
  • One-time pairing. When the TV doesn't know the app's certificate, a pairing screen asks for the 6-character code the TV shows (polo protocol on 6467). A checksum catches mistyped codes before they're sent.
  • Client certificate. Each app generates a persistent RSA key (keychain on iOS and macOS, app storage on Android). The app builds the self-signed X.509 certificate itself, since Apple platforms have no API for it.
  • Typing goes into the focused TV field as IME edits, because the service ignores letter keys.
  • Input source opens Settings and Recent apps is greyed out on these TVs. The service ignores every key we tried for them (tested on the reporter's MiTV-MOOR2). MiLink TVs are unchanged.
  • CI: manual runs can publish releases. A suffixed version (1.2.0-alpha.1) becomes a pre-release. macos_only skips the mobile builds for quick Mac test builds.

Bugs found along the way (macOS/iOS)

  • Apple's TLS 1.3 handshake fails against the TV's TLS 1.2 server, so TLS is pinned to 1.2.
  • A rejected or stalled handshake looked like "nothing listening" and sent the app back to MiLink in a loop. The app now connects plain TCP before TLS.
  • macOS never sent the client certificate: looking it up by label in the login keychain came back empty. The certificate is now a file, joined with the keychain key using SecIdentityCreateWithCertificate. iOS got the same hardening.

Testing

  • Mobile (Jest): protobuf messages, pairing secret and certificate are checked against bytes from androidtvremote2, the library behind Home Assistant's integration. The client's probe, pair and connect flow is tested against a mocked native socket.
  • macOS (swift test, in CI): the same fixtures, a keychain identity test, and an end-to-end test against apps/macos/Tests/fake_tv.py. That's a stand-in TV: TLS 1.2 only, client certificates required, unpaired ones rejected, real pairing. The test covers MiLink fallback, pairing, keys, typing and reconnecting. The fake TV itself was checked against androidtvremote2.
  • Real TV: the reporter confirmed pairing, navigation, volume, media keys and typing on a MiTV-MOOR2 with the macOS pre-releases (1.2.0-alpha.4/5).

Generated by Claude Code

claude added 13 commits October 20, 2026 07:31
Some Mi TVs (e.g. MiTV-MOOR2 on Android TV 11, #4) don't run Xiaomi's
MiLink service on 6091 but do run Google's Android TV Remote service.
The mobile apps now speak its v2 protocol as a fallback:

- Try MiLink first; if nothing listens on 6091, connect to 6466 over
  TLS with a client certificate, and remember the protocol per TV.
- If the TV drops the TLS session before configuring, run the one-time
  pairing on 6467: the TV shows a 6-character code, the app checks its
  checksum, then sends the SHA-256 secret over both public keys.
- Native module: TLS sockets, a persistent RSA client key (Keychain on
  iOS, app storage on Android), signing, and the TV's certificate.
  The self-signed X.509 certificate is built in TypeScript since iOS
  has no API for it.
- Keys use RemoteKeyInject; text goes through RemoteImeBatchEdit when
  the TV reports a focused field, else per-key.
- New pairing screen.

Protocol encoders, pairing secret and certificate are unit tested
against bytes from androidtvremote2; the client's probe/pair/connect
flow is tested with a mocked native socket.

CI: a manual run can publish a release; a suffixed version such as
1.2.0-alpha.1 becomes a mobile-only pre-release for testing branches.

Refs #4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YS6HGULeQFiqj9SZfZ7Gvv
Brings the mobile apps' Android TV Remote fallback to the Mac app, so
Mi TVs without MiLink (#4) work there too:

- ATVProtocol.swift: Swift port of src/atv (protobuf framing, pairing
  and remote messages, pairing secret, self-signed certificate DER).
- ClientIdentity.swift: RSA client key and certificate in the login
  keychain, combined into the TLS identity.
- RemoteClient (was MiLinkClient): tries MiLink, falls back to TLS
  6466, pairs on 6467 when the TV doesn't know the certificate, and
  remembers the protocol per TV.
- Pairing sheet in the popover for the code shown on the TV.
- XCTest suite against the same androidtvremote2 fixtures as mobile;
  CI runs it before building.

Pre-releases now include the macOS build again.

Refs #4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YS6HGULeQFiqj9SZfZ7Gvv
Network.framework often reports a TV aborting the TLS handshake (our
certificate isn't paired yet) as a plain POSIX reset rather than a TLS
error. The macOS app and the iOS module only counted TLS errors as
"something listens on 6466", so they treated the TV as unreachable,
went back to probing MiLink, and never reached pairing (#4).

Any error other than refused / timed out / no route now counts as
reachable. The macOS status line also names the port that failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YS6HGULeQFiqj9SZfZ7Gvv
The Mac app still never reached pairing on a MOOR2 (#4). Two causes:

- Apple clients offered TLS 1.3. The TV's remote service (Netty) is a
  TLS 1.2 server, and native clients that work with Xiaomi TVs over
  Network.framework pin TLS 1.2 (e.g. M1KE-27/xiaomi-remote). Pin min
  and max to 1.2 on macOS and iOS (and Android, for consistency).
- A stalled or rejected TLS handshake can surface as a timeout or reset,
  which read as "nothing on 6466", so the app went back to MiLink and
  looped. Now a plain TCP connection is made first, like the Android
  module does: once TCP gets through, any TLS failure means the service
  is there and pairing starts.

Also: TLS connections get a 10 s timeout (the keychain may prompt
before the key can sign), a pairing failure keeps the sheet open with
the error and port instead of silently starting over, and the sheet
shows the raw error.

Adds an end-to-end test: CI starts Tests/fake_tv.py, a stand-in
Android TV (TLS 1.2 only, client certificates required, unpaired ones
rejected, real polo pairing with a code), and drives the Mac client
through MiLink fallback, pairing, connect, key presses and reconnect.
The fake TV is checked against androidtvremote2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YS6HGULeQFiqj9SZfZ7Gvv
The end-to-end test showed the Mac app connecting without a client
certificate ("peer did not return a certificate"), so the TV refused
both pairing and the remote session. Looking the certificate and
identity up by label in the login keychain came back empty.

Now the certificate lives in Application Support and the identity is
built with SecIdentityCreateWithCertificate, which pairs it with the
private key in the keychain. The key is tagged as well as labeled. A
missing identity is reported in the UI instead of silently connecting
without one. Adds a step-by-step keychain test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YS6HGULeQFiqj9SZfZ7Gvv
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YS6HGULeQFiqj9SZfZ7Gvv
While macOS Android TV Remote support is being tested (#4), alpha and
beta builds skip Android and iOS. Manual runs can also pass macos_only.
The release job runs as long as nothing failed, and the notes only
list the platforms that were built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YS6HGULeQFiqj9SZfZ7Gvv
"Type on TV" did nothing on the MOOR2 (#4). Text was only sent as an
IME batch edit after the TV had reported IME counters; otherwise it
fell back to letter key events, which the Android TV Remote service
ignores. Now text always goes as an IME edit, counters defaulting to 0
and following the TV's batch edits, IME show requests and focused-field
reports. The cursor sits after the text, as kud/androidtv-remote and
googletv-tui do.

The fake TV records IME edits, and the macOS end-to-end test types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YS6HGULeQFiqj9SZfZ7Gvv
Over Android TV Remote the MOOR2 ignores KEYCODE_TV_INPUT (178) and
KEYCODE_APP_SWITCH (187), like most key codes outside the service's
supported set, and nothing documents what Xiaomi TVs respond to
instead. On TVs using that protocol, Input source and Recent apps now
open a panel of candidates (TV, HDMI 1-4, Guide, Settings, F1-F12;
hold or double-press Home, All apps, Menu, hold OK) so a tester can
report which one works (#4). MiLink TVs keep the original keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YS6HGULeQFiqj9SZfZ7Gvv
Release prep for the Android TV Remote work (#4):

- The test panel is gone. Testing on a MiTV-MOOR2 showed the Android
  TV Remote service ignores every input-source and recent-apps key we
  tried. On those TVs Input source now opens Settings (where the inputs
  are) and Recent apps is greyed out; MiLink TVs are unchanged. Same in
  the macOS and mobile apps.
- Mobile parity with the macOS fixes: a failed pairing stays on the
  pairing screen with the error and port instead of silently starting
  over; iOS keeps the client certificate as a file too, re-adds it to
  the keychain if the identity can't be formed, and refuses to connect
  without a certificate rather than failing obscurely.
- CI: every release builds all three apps again; macos_only stays as an
  opt-in for quick Mac test builds.
- README: MOOR2 tested, Android TV Remote limitations, keychain note,
  TLS 1.2 / TCP probe / certificate notes, end-to-end test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YS6HGULeQFiqj9SZfZ7Gvv
@EndLess728
EndLess728 merged commit 1be0abc into main Oct 20, 2026
5 checks passed
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.

MiTV-MOOR2 support

2 participants