t66: app names on a device that exposes none

The reported Shield names no app: `pm dump com.netflix.ninja` is ~2241 lines of
dumpsys and grep for `application-label` over the whole capture finds nothing.
The probe shipped `| head -300`, which cut the dump BEFORE the package section,
so "no label" was a truncation artefact; the sweep then named every app after
its own package id, and the apps editor pre-filled the `pkg=pkg` row the user
read as a bug.

  * the probe greps ON THE DEVICE for the three shapes Android prints
    (`application-label`, `nonLocalizedLabel`, `labelRes`); a quoted value is a
    name, `null` and a resource id are not. The dump is never truncated.
  * after APP_LABEL_PROBE_LIMIT consecutive "ran, no label" answers the device
    is taken at its word: the sweep stops spending a shell per package
    (APP_LABEL_TTL expiry re-checks, and a package whose probe FAILED still owes
    its retry — t57 UNIT 0 survives, driven by the app on screen).
  * ONE precedence chain in `ha_adb_common/app_label.resolve_package_name`, used
    by the media player, the Current App sensor, the discovery table, the source
    list and the apps editor: user map > device label > curated map > package id
    (the id shown ONCE, `name` empty when nothing names the app).
  * the curated table covers the user's 21 apps (community-maintained, not
    device truth) and the Configure row says when the device named nothing.
  * the real device shape is a test fixture (trimmed, and marked as such in the
    file: the raw capture is not in the repo) pinning that no label is
    extracted, that the probe count is bounded, and that `Netflix` surfaces.

Also drops the dead `ATTR_MEDIA_DAG_NOW_PLAYING` re-exports left by the t65
flattening and updates the RESEARCH_SHIELD now-playing note to the flat names.

No real Shield was available: the fetch/burst behaviour is exercised against the
documented Android output and the trimmed reproduction of the reported capture.
This commit is contained in:
James Mackay committed 2026-10-12 10:54:38 +01:00
1 parent f29f62ddae
commit 7b32de789a
16 files changed
+973 -176

No files matched your search

+44 -34
View File
@@ -875,7 +875,9 @@ class TestPrivacyOrdering:
player._last_channel = "101"
np = player._get_app_now_playing()
assert np["title"] == TIVIMATE_PACKAGE # app identity, not the channel
# t66: the identity tier now resolves through the shared chain, so the app
# reads by its name — still the APP, never the channel or the media title.
assert np["title"] == "TiviMate"
assert np["title"] != "101"
async def test_channel_number_never_leaks_in_privacy_mode_with_last_known(self, modules):
@@ -893,7 +895,8 @@ class TestPrivacyOrdering:
np = player._get_app_now_playing()
assert np["state"] == "playing" # playback truth survives privacy
assert np["title"] == TIVIMATE_PACKAGE # but identity stays the app
assert np["title"] == "TiviMate" # but identity stays the app
assert "Sky News" not in np.values() # the media title never leaks
class TestVolumeAndPowerLadder:
@@ -1725,23 +1728,26 @@ class TestCurrentAppNameEndToEndT55:
return player, manager, sensor, poller
async def test_known_app_resolves_by_fetching_its_label(self, modules):
# t66: a package NOT in the curated table, so the fall-through under test
# is really the raw id and not a curated name.
player, manager, sensor, poller = self._wire(
modules, "com.netflix.ninja", {"com.netflix.ninja": "application-label: 'Netflix'"}
modules,
"com.example.labelled",
{"com.example.labelled": "application-label: 'Labelled App'"},
)
# first render: cache miss → the sensor falls through to the package
assert sensor.native_value == "com.netflix.ninja"
assert manager.app_label_requests == {"com.netflix.ninja"}
assert sensor.native_value == "com.example.labelled"
assert manager.app_label_requests == {"com.example.labelled"}
# the watchdog drains the fetch for the CURRENT package
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert poller.dumps and "com.netflix.ninja" in poller.dumps[0]
assert poller.dumps and "com.example.labelled" in poller.dumps[0]
# next render: the resolved device name, in the sensor AND the player
assert sensor.native_value == "Netflix"
assert sensor.extra_state_attributes["app_name"] == "Netflix"
assert player.app_name == "Netflix"
assert sensor.native_value == "Labelled App"
assert player.app_name == "Labelled App"
async def test_mapped_app_never_needs_the_device_label(self, modules):
player, manager, sensor, poller = self._wire(
@@ -1818,7 +1824,9 @@ class TestCurrentAppNameEndToEndT55:
here — the pre-fix tree fails this at the second cycle.
"""
player, manager, sensor, poller = self._wire(
modules, "ar.tvplayer.tv", {"ar.tvplayer.tv": "application-label: 'TiviMate'"}
modules,
"com.example.labelled",
{"com.example.labelled": "application-label: 'Labelled App'"},
)
async def _blank(_cmd):
@@ -1827,16 +1835,16 @@ class TestCurrentAppNameEndToEndT55:
# cycle 1: the transport delivered nothing → a FAILED fetch
poller.shell = _blank
assert sensor.native_value == "ar.tvplayer.tv"
assert sensor.native_value == "com.example.labelled"
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert "ar.tvplayer.tv" not in manager._app_labels # nothing stamped
assert sensor.native_value == "ar.tvplayer.tv"
assert "com.example.labelled" not in manager._app_labels # nothing stamped
assert sensor.native_value == "com.example.labelled"
# the next render re-enqueues, and the retry is what recovers the label
assert manager.app_label("ar.tvplayer.tv") is None
assert manager.app_label_requests == {"ar.tvplayer.tv"}
assert manager.app_label("com.example.labelled") is None
assert manager.app_label_requests == {"com.example.labelled"}
dump_count = len([d for d in poller.dumps if "pm dump" in d])
real_shell = _LabelPoller.shell.__get__(poller, _LabelPoller)
@@ -1846,9 +1854,9 @@ class TestCurrentAppNameEndToEndT55:
# a later GOOD dump recovers it — the pre-fix cache made this impossible
assert len([d for d in poller.dumps if "pm dump" in d]) == dump_count + 1
assert sensor.native_value == "TiviMate"
assert sensor.extra_state_attributes["app_name"] == "TiviMate"
assert player.app_name == "TiviMate"
assert sensor.native_value == "Labelled App"
assert sensor.extra_state_attributes["app_name"] == "Labelled App"
assert player.app_name == "Labelled App"
@pytest.mark.parametrize("blank", [None, ""], ids=["none", "empty"])
async def test_the_player_recovers_the_label_after_a_failed_fetch(
@@ -1861,7 +1869,9 @@ class TestCurrentAppNameEndToEndT55:
stuck on the package id — so both are pinned.
"""
player, manager, sensor, poller = self._wire(
modules, "com.netflix.ninja", {"com.netflix.ninja": "application-label: 'Netflix'"}
modules,
"com.example.labelled",
{"com.example.labelled": "application-label: 'Labelled App'"},
)
async def _blank(_cmd):
@@ -1869,19 +1879,19 @@ class TestCurrentAppNameEndToEndT55:
return blank
poller.shell = _blank
assert player.app_name == "com.netflix.ninja"
assert player.app_name == "com.example.labelled"
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert "com.netflix.ninja" not in manager._app_labels
assert player.app_name == "com.netflix.ninja"
assert "com.example.labelled" not in manager._app_labels
assert player.app_name == "com.example.labelled"
assert manager.app_label("com.netflix.ninja") is None # re-enqueued
assert manager.app_label("com.example.labelled") is None # re-enqueued
poller.shell = _LabelPoller.shell.__get__(poller, _LabelPoller)
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert player.app_name == "Netflix"
assert sensor.native_value == "Netflix"
assert player.app_name == "Labelled App"
assert sensor.native_value == "Labelled App"
async def test_a_genuinely_label_less_package_is_still_negatively_cached(
self, modules
@@ -1910,16 +1920,16 @@ class TestCurrentAppNameEndToEndT55:
async def test_the_fetch_targets_the_app_on_screen_not_the_previous_one(self, modules):
player, manager, sensor, poller = self._wire(
modules,
"ar.tvplayer.tv",
"com.example.one",
{
"ar.tvplayer.tv": "application-label: 'TiviMate'",
"com.google.android.youtube.tv": "application-label: 'YouTube'",
"com.example.one": "application-label: 'One TV'",
"com.example.two": "application-label: 'Two TV'",
},
)
assert sensor.native_value == "ar.tvplayer.tv" # miss for the 1st app
manager._on_current_app("com.google.android.youtube.tv")
assert sensor.native_value == "com.google.android.youtube.tv"
assert sensor.native_value == "com.example.one" # miss for the 1st app
manager._on_current_app("com.example.two")
assert sensor.native_value == "com.example.two"
# both packages are queued; the watchdog drains ONE per cycle
await manager._watchdog_cycle(5.0)
@@ -1929,9 +1939,9 @@ class TestCurrentAppNameEndToEndT55:
# every drained fetch names a package, and the app that is CURRENT is
# the one the sensor now shows the label of
assert "ar.tvplayer.tv" in poller.dumps[0]
assert "com.google.android.youtube.tv" in poller.dumps[1]
assert sensor.native_value == "YouTube"
assert "com.example.one" in poller.dumps[0]
assert "com.example.two" in poller.dumps[1]
assert sensor.native_value == "Two TV"
class TestAvailabilityLadder: