androidtvremote2 0.3.2__tar.gz → 0.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {androidtvremote2-0.3.2/src/androidtvremote2.egg-info → androidtvremote2-0.3.3}/PKG-INFO +2 -2
  2. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/README.md +1 -1
  3. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/pyproject.toml +1 -1
  4. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/src/androidtvremote2/androidtv_remote.py +13 -3
  5. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/src/androidtvremote2/certificate_generator.py +1 -1
  6. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/src/androidtvremote2/model.py +21 -21
  7. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/src/androidtvremote2/pairing.py +9 -4
  8. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/src/androidtvremote2/remote.py +21 -8
  9. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3/src/androidtvremote2.egg-info}/PKG-INFO +2 -2
  10. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/tests/test_androidtv_remote.py +54 -0
  11. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/tests/test_pairing.py +25 -4
  12. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/tests/test_remote.py +56 -13
  13. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/LICENSE +0 -0
  14. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/setup.cfg +0 -0
  15. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/src/androidtvremote2/__init__.py +0 -0
  16. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/src/androidtvremote2/base.py +0 -0
  17. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/src/androidtvremote2/const.py +0 -0
  18. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/src/androidtvremote2/exceptions.py +0 -0
  19. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/src/androidtvremote2/polo_pb2.py +0 -0
  20. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/src/androidtvremote2/polo_pb2.pyi +0 -0
  21. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/src/androidtvremote2/py.typed +0 -0
  22. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/src/androidtvremote2/remotemessage_pb2.py +0 -0
  23. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/src/androidtvremote2/remotemessage_pb2.pyi +0 -0
  24. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/src/androidtvremote2/voice_stream.py +0 -0
  25. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/src/androidtvremote2.egg-info/SOURCES.txt +0 -0
  26. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/src/androidtvremote2.egg-info/dependency_links.txt +0 -0
  27. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/src/androidtvremote2.egg-info/requires.txt +0 -0
  28. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/src/androidtvremote2.egg-info/top_level.txt +0 -0
  29. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/tests/test_base.py +0 -0
  30. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/tests/test_certificate_generator.py +0 -0
  31. {androidtvremote2-0.3.2 → androidtvremote2-0.3.3}/tests/test_voice_stream.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: androidtvremote2
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: A Python library for interacting with Android TV using the Android TV Remote protocol v2
5
5
  Author-email: tronikos <tronikos@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -26,7 +26,7 @@ Dynamic: license-file
26
26
  A Python library for interacting with Android TV using the Android TV Remote protocol v2. This is the same protocol the Google TV mobile app is using. It doesn't require ADB or enabling developer tools on the Android TV device. It only requires the [Android TV Remote Service](https://play.google.com/store/apps/details?id=com.google.android.tv.remote.service) that comes pre-installed on most Android TV devices.
27
27
 
28
28
  For a list of the most common commands you can send to the Android TV see: [TvKeys](https://github.com/tronikos/androidtvremote2/blob/main/TvKeys.txt).
29
- For a full list see [remotemessage.proto](https://github.com/tronikos/androidtvremote2/blob/b4c49ac03043b1b9c40c2f2960e466d5a3b8bd67/src/androidtvremote2/remotemessage.proto#L90).
29
+ For a full list see [remotemessage.proto](https://github.com/tronikos/androidtvremote2/blob/main/src/androidtvremote2/remotemessage.proto).
30
30
  In addition to commands you can send URLs to open apps registered to handle them. See [this guide](https://community.home-assistant.io/t/android-tv-remote-app-links-deep-linking-guide/567921) for how to find deep links for apps.
31
31
 
32
32
  Voice commands can also be sent as PCM 16-bit mono 8 kHz audio data.
@@ -3,7 +3,7 @@
3
3
  A Python library for interacting with Android TV using the Android TV Remote protocol v2. This is the same protocol the Google TV mobile app is using. It doesn't require ADB or enabling developer tools on the Android TV device. It only requires the [Android TV Remote Service](https://play.google.com/store/apps/details?id=com.google.android.tv.remote.service) that comes pre-installed on most Android TV devices.
4
4
 
5
5
  For a list of the most common commands you can send to the Android TV see: [TvKeys](https://github.com/tronikos/androidtvremote2/blob/main/TvKeys.txt).
6
- For a full list see [remotemessage.proto](https://github.com/tronikos/androidtvremote2/blob/b4c49ac03043b1b9c40c2f2960e466d5a3b8bd67/src/androidtvremote2/remotemessage.proto#L90).
6
+ For a full list see [remotemessage.proto](https://github.com/tronikos/androidtvremote2/blob/main/src/androidtvremote2/remotemessage.proto).
7
7
  In addition to commands you can send URLs to open apps registered to handle them. See [this guide](https://community.home-assistant.io/t/android-tv-remote-app-links-deep-linking-guide/567921) for how to find deep links for apps.
8
8
 
9
9
  Voice commands can also be sent as PCM 16-bit mono 8 kHz audio data.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "androidtvremote2"
3
- version = "0.3.2"
3
+ version = "0.3.3"
4
4
  license = "Apache-2.0"
5
5
  authors = [
6
6
  { name="tronikos", email="tronikos@gmail.com" },
@@ -77,7 +77,7 @@ class AndroidTVRemote:
77
77
 
78
78
  :param client_name: client name. Will be shown on the Android TV during pairing.
79
79
  :param certfile: filename that contains the client certificate in PEM format.
80
- :param keyfile: filename that contains the public key in PEM format.
80
+ :param keyfile: filename that contains the private key in PEM format.
81
81
  :param host: IP address of the Android TV.
82
82
  :param api_port: port for connecting and sending commands.
83
83
  :param pair_port: port for pairing.
@@ -210,7 +210,7 @@ class AndroidTVRemote:
210
210
  self._is_available_updated_callbacks.remove(callback)
211
211
 
212
212
  async def async_generate_cert_if_missing(self) -> bool:
213
- """Generate client certificate and public key if missing.
213
+ """Generate client certificate and private key if missing.
214
214
 
215
215
  :returns: True if a new certificate was generated.
216
216
  """
@@ -244,6 +244,8 @@ class AndroidTVRemote:
244
244
  :raises InvalidAuth: if pairing is needed first.
245
245
  """
246
246
  ssl_context = await self._create_ssl_context()
247
+ if self._remote_message_protocol:
248
+ self._remote_message_protocol.close()
247
249
  on_con_lost = self._loop.create_future()
248
250
  on_remote_started = self._loop.create_future()
249
251
  try:
@@ -271,8 +273,13 @@ class AndroidTVRemote:
271
273
  raise InvalidAuth("Need to pair") from exc
272
274
  raise CannotConnect(f"Couldn't connect to {self.host}:{self._api_port}") from exc
273
275
 
274
- await asyncio.wait((on_con_lost, on_remote_started), return_when=asyncio.FIRST_COMPLETED)
276
+ try:
277
+ await asyncio.wait((on_con_lost, on_remote_started), return_when=asyncio.FIRST_COMPLETED)
278
+ except BaseException:
279
+ self._remote_message_protocol.close()
280
+ raise
275
281
  if on_con_lost.done():
282
+ self._remote_message_protocol.close()
276
283
  con_lost_exc = on_con_lost.result()
277
284
  LOGGER.debug(
278
285
  "Couldn't connect to %s:%s. Error: %s",
@@ -317,12 +324,15 @@ class AndroidTVRemote:
317
324
 
318
325
  def keep_reconnecting(self, invalid_auth_callback: Callable[[], None] | None = None) -> None:
319
326
  """Create a task to keep reconnecting whenever connection is lost."""
327
+ if self._reconnect_task:
328
+ self._reconnect_task.cancel()
320
329
  self._reconnect_task = self._loop.create_task(self._async_reconnect(invalid_auth_callback))
321
330
 
322
331
  def disconnect(self) -> None:
323
332
  """Disconnect any open connections."""
324
333
  if self._reconnect_task:
325
334
  self._reconnect_task.cancel()
335
+ self._reconnect_task = None
326
336
  if self._remote_message_protocol:
327
337
  self._remote_message_protocol.close()
328
338
  self._remote_message_protocol = None
@@ -18,7 +18,7 @@ from cryptography.x509.oid import NameOID
18
18
  def generate_selfsigned_cert(hostname: str) -> tuple[bytes, bytes]:
19
19
  """Generate self signed certificate for a hostname.
20
20
 
21
- :return: self signed certificate and public key in PEM format
21
+ :return: self signed certificate and private key in PEM format
22
22
  """
23
23
  key = rsa.generate_private_key(
24
24
  public_exponent=65537,
@@ -1,21 +1,21 @@
1
- """Data models for AndroidTVRemote."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import TypedDict
6
-
7
-
8
- class DeviceInfo(TypedDict):
9
- """A TypedDict for device information."""
10
-
11
- manufacturer: str
12
- model: str
13
- sw_version: str
14
-
15
-
16
- class VolumeInfo(TypedDict):
17
- """A TypedDict for volume information."""
18
-
19
- level: int
20
- max: int
21
- muted: bool
1
+ """Data models for AndroidTVRemote."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TypedDict
6
+
7
+
8
+ class DeviceInfo(TypedDict):
9
+ """A TypedDict for device information."""
10
+
11
+ manufacturer: str
12
+ model: str
13
+ sw_version: str
14
+
15
+
16
+ class VolumeInfo(TypedDict):
17
+ """A TypedDict for volume information."""
18
+
19
+ level: int
20
+ max: int
21
+ muted: bool
@@ -34,6 +34,11 @@ def _create_message() -> OuterMessage:
34
34
  return msg
35
35
 
36
36
 
37
+ def _int_to_bytes(val: int) -> bytes:
38
+ """Convert a positive integer to big-endian bytes."""
39
+ return val.to_bytes((val.bit_length() + 7) // 8, "big")
40
+
41
+
37
42
  def _get_modulus_and_exponent(cert: x509.Certificate) -> tuple[int, int]:
38
43
  """Extract modulus and exponent from a certificate."""
39
44
  public_numbers = cert.public_key().public_numbers() # type: ignore[union-attr]
@@ -113,10 +118,10 @@ class PairingProtocol(ProtobufProtocol):
113
118
  server_modulus, server_exponent = _get_modulus_and_exponent(server_cert)
114
119
 
115
120
  h = hashlib.sha256()
116
- h.update(bytes.fromhex(f"{client_modulus:X}"))
117
- h.update(bytes.fromhex(f"0{client_exponent:X}"))
118
- h.update(bytes.fromhex(f"{server_modulus:X}"))
119
- h.update(bytes.fromhex(f"0{server_exponent:X}"))
121
+ h.update(_int_to_bytes(client_modulus))
122
+ h.update(_int_to_bytes(client_exponent))
123
+ h.update(_int_to_bytes(server_modulus))
124
+ h.update(_int_to_bytes(server_exponent))
120
125
  h.update(bytes.fromhex(pairing_code[2:]))
121
126
  hash_result = h.digest()
122
127
 
@@ -112,6 +112,8 @@ class RemoteProtocol(ProtobufProtocol):
112
112
  self.volume_info: VolumeInfo | None = None
113
113
  self.ime_counter: int = 0
114
114
  self.ime_field_counter: int = 0
115
+ self._has_ime_counter = False
116
+ self._has_ime_field_counter = False
115
117
  self._loop = loop
116
118
  self._idle_disconnect_task: asyncio.Task[None] | None = None
117
119
  self._reset_idle_disconnect_task()
@@ -170,9 +172,10 @@ class RemoteProtocol(ProtobufProtocol):
170
172
  def send_text(self, text: str) -> None:
171
173
  """Send a text string to Android TV via the input method.
172
174
 
173
- The text length minus one is used for both `start` and `end` in the RemoteImeObject.
174
- The `ime_counter` and `ime_field_counter` values are taken from self (batch_edit_info response),
175
- which is populated when a message with a remote_ime_batch_edit field is received.
175
+ The text is inserted at the cursor, which is left after it: `start` and `end` are the text's length.
176
+ `ime_counter` and `ime_field_counter` come from the focused field's status (`remote_ime_key_inject`
177
+ and `remote_ime_show_request`), falling back to `remote_ime_batch_edit` when the key inject carries
178
+ no counter.
176
179
 
177
180
  :param text: The text string to be sent.
178
181
  """
@@ -181,8 +184,7 @@ class RemoteProtocol(ProtobufProtocol):
181
184
 
182
185
  self._reset_idle_disconnect_task()
183
186
  msg = RemoteMessage()
184
- param_value = len(text) - 1
185
- ime_object = RemoteImeObject(start=param_value, end=param_value, value=text)
187
+ ime_object = RemoteImeObject(start=len(text), end=len(text), value=text)
186
188
  edit_info = RemoteEditInfo(insert=1, text_field_status=ime_object)
187
189
  batch_edit = RemoteImeBatchEdit(
188
190
  ime_counter=self.ime_counter,
@@ -311,11 +313,22 @@ class RemoteProtocol(ProtobufProtocol):
311
313
  elif msg.HasField("remote_set_active"):
312
314
  new_msg.remote_set_active.active = self._active_features
313
315
  elif msg.HasField("remote_ime_key_inject"):
314
- self.current_app = msg.remote_ime_key_inject.app_info.app_package
316
+ inject = msg.remote_ime_key_inject
317
+ self.current_app = inject.app_info.app_package
315
318
  self._on_current_app_updated(self.current_app)
319
+ self.ime_counter = inject.app_info.counter
320
+ self.ime_field_counter = inject.text_field_status.counter_field
321
+ self._has_ime_counter = bool(self.ime_counter)
322
+ self._has_ime_field_counter = bool(self.ime_field_counter)
323
+ elif msg.HasField("remote_ime_show_request"):
324
+ if msg.remote_ime_show_request.HasField("remote_text_field_status"):
325
+ self.ime_field_counter = msg.remote_ime_show_request.remote_text_field_status.counter_field
326
+ self._has_ime_field_counter = True
316
327
  elif msg.HasField("remote_ime_batch_edit"):
317
- self.ime_counter = msg.remote_ime_batch_edit.ime_counter
318
- self.ime_field_counter = msg.remote_ime_batch_edit.field_counter
328
+ if not self._has_ime_counter:
329
+ self.ime_counter = msg.remote_ime_batch_edit.ime_counter
330
+ if not self._has_ime_field_counter:
331
+ self.ime_field_counter = msg.remote_ime_batch_edit.field_counter
319
332
  elif msg.HasField("remote_set_volume_level"):
320
333
  self.volume_info = {
321
334
  "level": msg.remote_set_volume_level.volume_level,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: androidtvremote2
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: A Python library for interacting with Android TV using the Android TV Remote protocol v2
5
5
  Author-email: tronikos <tronikos@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -26,7 +26,7 @@ Dynamic: license-file
26
26
  A Python library for interacting with Android TV using the Android TV Remote protocol v2. This is the same protocol the Google TV mobile app is using. It doesn't require ADB or enabling developer tools on the Android TV device. It only requires the [Android TV Remote Service](https://play.google.com/store/apps/details?id=com.google.android.tv.remote.service) that comes pre-installed on most Android TV devices.
27
27
 
28
28
  For a list of the most common commands you can send to the Android TV see: [TvKeys](https://github.com/tronikos/androidtvremote2/blob/main/TvKeys.txt).
29
- For a full list see [remotemessage.proto](https://github.com/tronikos/androidtvremote2/blob/b4c49ac03043b1b9c40c2f2960e466d5a3b8bd67/src/androidtvremote2/remotemessage.proto#L90).
29
+ For a full list see [remotemessage.proto](https://github.com/tronikos/androidtvremote2/blob/main/src/androidtvremote2/remotemessage.proto).
30
30
  In addition to commands you can send URLs to open apps registered to handle them. See [this guide](https://community.home-assistant.io/t/android-tv-remote-app-links-deep-linking-guide/567921) for how to find deep links for apps.
31
31
 
32
32
  Voice commands can also be sent as PCM 16-bit mono 8 kHz audio data.
@@ -17,6 +17,8 @@ from androidtvremote2.exceptions import ConnectionClosed, InvalidAuth
17
17
  from androidtvremote2.remote import VOICE_CHUNK_MIN_SIZE
18
18
  from androidtvremote2.remotemessage_pb2 import RemoteDirection, RemoteKeyCode, RemoteMessage
19
19
 
20
+ from .conftest import FakeTransport, parse_written
21
+
20
22
  if TYPE_CHECKING:
21
23
  from collections.abc import Callable
22
24
  from pathlib import Path
@@ -232,6 +234,58 @@ async def test_disconnect_cancels_the_reconnect_task(remote: AndroidTVRemote, co
232
234
  await asyncio.sleep(0)
233
235
 
234
236
  assert reconnect_task.cancelled() or reconnect_task.done()
237
+ assert remote._reconnect_task is None
238
+
239
+
240
+ async def test_keep_reconnecting_cancels_previous_reconnect_task(remote: AndroidTVRemote, connected: RemoteHarness) -> None:
241
+ """Calling keep_reconnecting() twice cancels the previous reconnect task."""
242
+ remote.keep_reconnecting()
243
+ first_task = remote._reconnect_task
244
+ assert first_task is not None
245
+
246
+ remote.keep_reconnecting()
247
+ second_task = remote._reconnect_task
248
+ await asyncio.sleep(0)
249
+
250
+ assert first_task.cancelled()
251
+ assert second_task is not None
252
+ assert not second_task.done()
253
+ remote.disconnect()
254
+
255
+
256
+ async def test_cancelling_async_connect_closes_protocol_and_idle_task(
257
+ remote: AndroidTVRemote, monkeypatch: pytest.MonkeyPatch
258
+ ) -> None:
259
+ """Cancelling async_connect while waiting for remote_start closes the transport and idle timer."""
260
+ await remote.async_generate_cert_if_missing()
261
+ fake_transport = FakeTransport()
262
+ connected_event = asyncio.Event()
263
+
264
+ async def fake_create_connection(
265
+ protocol_factory: Callable[[], object], *_args: object, **_kwargs: object
266
+ ) -> tuple[object, object]:
267
+ proto = protocol_factory()
268
+ proto.connection_made(fake_transport) # type: ignore[attr-defined]
269
+ connected_event.set()
270
+ return fake_transport, proto
271
+
272
+ monkeypatch.setattr(remote._loop, "create_connection", fake_create_connection)
273
+
274
+ connect_task = asyncio.create_task(remote.async_connect())
275
+ await connected_event.wait()
276
+ await asyncio.sleep(0)
277
+
278
+ assert remote._remote_message_protocol is not None
279
+ idle_task = remote._remote_message_protocol._idle_disconnect_task
280
+ assert idle_task is not None
281
+
282
+ connect_task.cancel()
283
+ with pytest.raises(asyncio.CancelledError):
284
+ await connect_task
285
+
286
+ assert fake_transport.is_closing()
287
+ assert idle_task.cancelled()
288
+ assert parse_written(fake_transport.written, RemoteMessage) == []
235
289
 
236
290
 
237
291
  async def test_disconnect_is_idempotent(remote: AndroidTVRemote, connected: RemoteHarness) -> None:
@@ -74,10 +74,8 @@ def expected_pin(client_cert_pem: bytes, server_cert_pem: bytes, nonce: str) ->
74
74
  client_n, client_e = modulus_and_exponent(client_cert_pem)
75
75
  server_n, server_e = modulus_and_exponent(server_cert_pem)
76
76
  h = hashlib.sha256()
77
- h.update(bytes.fromhex(f"{client_n:X}"))
78
- h.update(bytes.fromhex(f"0{client_e:X}"))
79
- h.update(bytes.fromhex(f"{server_n:X}"))
80
- h.update(bytes.fromhex(f"0{server_e:X}"))
77
+ for val in (client_n, client_e, server_n, server_e):
78
+ h.update(val.to_bytes((val.bit_length() + 7) // 8, "big"))
81
79
  h.update(bytes.fromhex(nonce))
82
80
  return f"{h.digest()[0]:02X}{nonce}"
83
81
 
@@ -147,6 +145,29 @@ async def test_finish_pairing_sends_the_secret(
147
145
  assert secret.secret.secret[0] == int(pin[0:2], 16)
148
146
 
149
147
 
148
+ async def test_finish_pairing_handles_odd_hex_length_modulus_and_even_hex_exponent(
149
+ pairing: PairingHarness, monkeypatch: pytest.MonkeyPatch
150
+ ) -> None:
151
+ """Moduli with an odd number of hex digits or 2-nibble exponents don't fail bytes conversion."""
152
+ await run_start_pairing(pairing)
153
+ pairing.clear()
154
+ # 0xabc has 3 hex digits (odd); 0x11 (17) has 2 hex digits (would be 3 with a leading '0').
155
+ monkeypatch.setattr("androidtvremote2.pairing._get_modulus_and_exponent", lambda _cert: (0xABC, 0x11))
156
+ h = hashlib.sha256()
157
+ for val in (0xABC, 0x11, 0xABC, 0x11):
158
+ h.update(val.to_bytes((val.bit_length() + 7) // 8, "big"))
159
+ h.update(bytes.fromhex("ABCD"))
160
+ pin = f"{h.digest()[0]:02X}ABCD"
161
+
162
+ task = asyncio.ensure_future(pairing.protocol.async_finish_pairing(pin))
163
+ await wait_until_sent(pairing, 1)
164
+ pairing.receive(device_msg("secret_ack", secret=b"\x00" * 32))
165
+ await task
166
+
167
+ (secret,) = pairing.sent()
168
+ assert secret.secret.secret == h.digest()
169
+
170
+
150
171
  async def test_duplicate_acks_do_not_raise(pairing: PairingHarness) -> None:
151
172
  """A repeated acknowledgement doesn't blow up inside data_received.
152
173
 
@@ -167,22 +167,65 @@ async def test_volume_level_updates_volume_info(remote_factory: Callable[..., Re
167
167
  assert harness.volume_info_updates == [{"level": 12, "max": 100, "muted": True}]
168
168
 
169
169
 
170
- async def test_ime_batch_edit_updates_the_counters_used_by_send_text(
170
+ async def test_send_text_uses_the_focused_fields_counters(
171
171
  remote_factory: Callable[..., RemoteHarness],
172
172
  ) -> None:
173
- """The counters echoed back in send_text come from the device."""
173
+ """ime_counter and ime_field_counter come from remote_ime_key_inject, not overwritten by batch_edit."""
174
174
  harness = remote_factory()
175
- msg = RemoteMessage()
176
- msg.remote_ime_batch_edit.ime_counter = 9
177
- msg.remote_ime_batch_edit.field_counter = 4
178
- harness.receive(msg)
175
+ inject = RemoteMessage()
176
+ inject.remote_ime_key_inject.app_info.app_package = "org.jellyfin.androidtv"
177
+ inject.remote_ime_key_inject.app_info.counter = 5
178
+ inject.remote_ime_key_inject.text_field_status.counter_field = 141
179
+ harness.receive(inject)
180
+ batch = RemoteMessage()
181
+ batch.remote_ime_batch_edit.ime_counter = 1
182
+ batch.remote_ime_batch_edit.field_counter = 1
183
+ harness.receive(batch)
179
184
 
180
- assert (harness.protocol.ime_counter, harness.protocol.ime_field_counter) == (9, 4)
185
+ assert (harness.protocol.ime_counter, harness.protocol.ime_field_counter) == (5, 141)
181
186
 
182
187
  harness.protocol.send_text("hi")
183
188
  (sent,) = harness.sent()
184
- assert sent.remote_ime_batch_edit.ime_counter == 9
185
- assert sent.remote_ime_batch_edit.field_counter == 4
189
+ assert sent.remote_ime_batch_edit.ime_counter == 5
190
+ assert sent.remote_ime_batch_edit.field_counter == 141
191
+
192
+
193
+ async def test_ime_show_request_updates_the_field_counter(
194
+ remote_factory: Callable[..., RemoteHarness],
195
+ ) -> None:
196
+ """A focused field announces updated counter_field in remote_ime_show_request."""
197
+ harness = remote_factory()
198
+ msg = RemoteMessage()
199
+ msg.remote_ime_show_request.remote_text_field_status.counter_field = 7
200
+ harness.receive(msg)
201
+
202
+ assert harness.protocol.ime_field_counter == 7
203
+
204
+
205
+ async def test_ime_batch_edit_is_used_when_key_inject_has_no_counters(
206
+ remote_factory: Callable[..., RemoteHarness],
207
+ ) -> None:
208
+ """When key_inject carries zero counters, batch_edit counters are used and stale counters are cleared."""
209
+ harness = remote_factory()
210
+ inject = RemoteMessage()
211
+ inject.remote_ime_key_inject.app_info.app_package = "com.android.vending"
212
+ inject.remote_ime_key_inject.app_info.counter = 3
213
+ inject.remote_ime_key_inject.text_field_status.counter_field = 38
214
+ harness.receive(inject)
215
+ assert (harness.protocol.ime_counter, harness.protocol.ime_field_counter) == (3, 38)
216
+
217
+ # Switching to an app that reports 0 counters clears the previous app's counters.
218
+ youtube = RemoteMessage()
219
+ youtube.remote_ime_key_inject.app_info.app_package = "com.google.android.youtube.tv"
220
+ harness.receive(youtube)
221
+ assert (harness.protocol.ime_counter, harness.protocol.ime_field_counter) == (0, 0)
222
+
223
+ # A subsequent batch_edit populates the counters as fallback.
224
+ batch = RemoteMessage()
225
+ batch.remote_ime_batch_edit.ime_counter = 9
226
+ batch.remote_ime_batch_edit.field_counter = 4
227
+ harness.receive(batch)
228
+ assert (harness.protocol.ime_counter, harness.protocol.ime_field_counter) == (9, 4)
186
229
 
187
230
 
188
231
  async def test_remote_error_is_logged_as_an_error(
@@ -206,7 +249,7 @@ async def test_unhandled_message_is_logged_and_ignored(
206
249
  """A message this library has no branch for doesn't produce a reply."""
207
250
  harness = remote_factory()
208
251
  msg = RemoteMessage()
209
- msg.remote_ime_show_request.SetInParent()
252
+ msg.remote_app_link_launch_request.app_link = "https://example.com"
210
253
  with caplog.at_level(logging.DEBUG, logger="androidtvremote2"):
211
254
  harness.receive(msg)
212
255
 
@@ -283,7 +326,7 @@ async def test_send_key_command_dispatches_the_text_prefix(remote_factory: Calla
283
326
 
284
327
 
285
328
  async def test_send_text_builds_the_batch_edit(remote_factory: Callable[..., RemoteHarness]) -> None:
286
- """send_text uses len(text) - 1 for start and end, as the device expects."""
329
+ """send_text puts the cursor after the text: start and end are its length."""
287
330
  harness = remote_factory()
288
331
  harness.protocol.send_text("abcd")
289
332
 
@@ -291,8 +334,8 @@ async def test_send_text_builds_the_batch_edit(remote_factory: Callable[..., Rem
291
334
  edit = sent.remote_ime_batch_edit.edit_info[0]
292
335
  assert edit.insert == 1
293
336
  assert edit.text_field_status.value == "abcd"
294
- assert edit.text_field_status.start == 3
295
- assert edit.text_field_status.end == 3
337
+ assert edit.text_field_status.start == 4
338
+ assert edit.text_field_status.end == 4
296
339
 
297
340
 
298
341
  async def test_send_text_rejects_empty_text(remote_factory: Callable[..., RemoteHarness]) -> None: