diff --git a/tests/tools/test_termux_api_detection.py b/tests/tools/test_termux_api_detection.py new file mode 100644 index 000000000000..d9d1ff7ce43b --- /dev/null +++ b/tests/tools/test_termux_api_detection.py @@ -0,0 +1,307 @@ +"""Regression tests for issue #31015 — Termux:API app detection. + +`/voice on` was reporting "Termux:API Android app is not installed" +even on devices where the app *is* installed and the +`termux-microphone-record` binary works fine. The cause was a single +brittle probe (`pm list packages com.termux.api`) that returns a +false negative on some Android versions / Termux configurations. + +These tests pin the new probe ladder: + + 1. `pm list packages` confirms the app → True (back-compat). + 2. `pm` missing or non-zero → fall back to + `cmd package list packages` (modern Android API 28+). + 3. Both probes inconclusive but `termux-microphone-record` on PATH + → trust the binary, return True. + 4. At least one probe ran cleanly and definitively did not list + the package → return False (the genuine "CLI without app" + case the existing warning was written for). +""" + +from __future__ import annotations + +import subprocess +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + + +# ── Helpers ──────────────────────────────────────────────────────────────── + + +def _make_run_dispatcher(behaviors): + """Build a fake `subprocess.run` that returns / raises per-command. + + `behaviors` maps the first arg of each invocation to either a + SimpleNamespace (returncode, stdout, stderr) or an Exception class + instance to raise. + """ + def _fake_run(cmd, **kwargs): + key = cmd[0] if isinstance(cmd, (list, tuple)) and cmd else cmd + action = behaviors.get(key) + if action is None: + raise AssertionError(f"Unexpected probe command: {cmd!r}") + if isinstance(action, BaseException): + raise action + return action + return _fake_run + + +def _force_termux(monkeypatch): + monkeypatch.setattr("tools.voice_mode._is_termux_environment", lambda: True) + + +# ── _termux_api_app_installed: probe ladder ─────────────────────────────── + + +class TestTermuxApiAppInstalledProbeLadder: + """The probe ladder is the heart of the #31015 fix — keep its truth + table pinned so future "simplifications" can't silently regress it.""" + + def test_returns_false_outside_termux(self, monkeypatch): + monkeypatch.setattr("tools.voice_mode._is_termux_environment", lambda: False) + from tools.voice_mode import _termux_api_app_installed + assert _termux_api_app_installed() is False + + def test_pm_confirms_package_returns_true(self, monkeypatch): + _force_termux(monkeypatch) + run = _make_run_dispatcher({ + "pm": SimpleNamespace( + returncode=0, + stdout="package:com.termux.api\n", + stderr="", + ), + }) + monkeypatch.setattr("tools.voice_mode.subprocess.run", run) + + from tools.voice_mode import _termux_api_app_installed + assert _termux_api_app_installed() is True + + def test_pm_clean_miss_then_cmd_confirms(self, monkeypatch): + """`pm` ran cleanly with no match → fall through to `cmd package`, + which finds the app. Some devices return empty `pm` output for + the calling user even when the package is installed.""" + _force_termux(monkeypatch) + run = _make_run_dispatcher({ + "pm": SimpleNamespace(returncode=0, stdout="", stderr=""), + "cmd": SimpleNamespace( + returncode=0, + stdout="package:com.termux.api\n", + stderr="", + ), + }) + monkeypatch.setattr("tools.voice_mode.subprocess.run", run) + + from tools.voice_mode import _termux_api_app_installed + assert _termux_api_app_installed() is True + + def test_pm_missing_then_cmd_confirms(self, monkeypatch): + """`pm` not on PATH → FileNotFoundError → fall through to `cmd`.""" + _force_termux(monkeypatch) + run = _make_run_dispatcher({ + "pm": FileNotFoundError("pm: command not found"), + "cmd": SimpleNamespace( + returncode=0, + stdout="package:com.termux.api\n", + stderr="", + ), + }) + monkeypatch.setattr("tools.voice_mode.subprocess.run", run) + + from tools.voice_mode import _termux_api_app_installed + assert _termux_api_app_installed() is True + + def test_pm_timeout_then_cmd_confirms(self, monkeypatch): + """A hung `pm` (5s timeout) must not block detection.""" + _force_termux(monkeypatch) + run = _make_run_dispatcher({ + "pm": subprocess.TimeoutExpired(cmd="pm", timeout=5), + "cmd": SimpleNamespace( + returncode=0, + stdout="package:com.termux.api\n", + stderr="", + ), + }) + monkeypatch.setattr("tools.voice_mode.subprocess.run", run) + + from tools.voice_mode import _termux_api_app_installed + assert _termux_api_app_installed() is True + + def test_pm_nonzero_exit_then_cmd_confirms(self, monkeypatch): + """Non-zero exit (e.g. permission denied for the calling user) + is treated as inconclusive, not as a definitive "no".""" + _force_termux(monkeypatch) + run = _make_run_dispatcher({ + "pm": SimpleNamespace(returncode=1, stdout="", stderr="permission denied"), + "cmd": SimpleNamespace( + returncode=0, + stdout="package:com.termux.api\n", + stderr="", + ), + }) + monkeypatch.setattr("tools.voice_mode.subprocess.run", run) + + from tools.voice_mode import _termux_api_app_installed + assert _termux_api_app_installed() is True + + def test_both_probes_inconclusive_but_binary_present_returns_true(self, monkeypatch): + """Core #31015 case: both probes fail or are unavailable, but the + `termux-microphone-record` binary is on PATH. Trust the binary — + a false positive only surfaces a precise runtime error, while a + false negative blocks /voice on entirely (the user-reported + symptom).""" + _force_termux(monkeypatch) + run = _make_run_dispatcher({ + "pm": FileNotFoundError("pm: command not found"), + "cmd": FileNotFoundError("cmd: command not found"), + }) + monkeypatch.setattr("tools.voice_mode.subprocess.run", run) + monkeypatch.setattr( + "tools.voice_mode.shutil.which", + lambda name: "/data/data/com.termux/files/usr/bin/termux-microphone-record" + if name == "termux-microphone-record" else None, + ) + + from tools.voice_mode import _termux_api_app_installed + assert _termux_api_app_installed() is True + + def test_both_probes_inconclusive_and_no_binary_returns_false(self, monkeypatch): + """Without the binary on PATH there's nothing to trust — fall + through to False so the user gets the install hint.""" + _force_termux(monkeypatch) + run = _make_run_dispatcher({ + "pm": FileNotFoundError("pm: command not found"), + "cmd": FileNotFoundError("cmd: command not found"), + }) + monkeypatch.setattr("tools.voice_mode.subprocess.run", run) + monkeypatch.setattr("tools.voice_mode.shutil.which", lambda name: None) + + from tools.voice_mode import _termux_api_app_installed + assert _termux_api_app_installed() is False + + def test_both_probes_clean_no_match_returns_false(self, monkeypatch): + """Clean (returncode=0) probes that don't list the package are + authoritative — the app is genuinely missing. Don't promote + this to True via the binary fallback.""" + _force_termux(monkeypatch) + run = _make_run_dispatcher({ + "pm": SimpleNamespace(returncode=0, stdout="", stderr=""), + "cmd": SimpleNamespace(returncode=0, stdout="", stderr=""), + }) + monkeypatch.setattr("tools.voice_mode.subprocess.run", run) + monkeypatch.setattr( + "tools.voice_mode.shutil.which", + lambda name: "/data/data/com.termux/files/usr/bin/termux-microphone-record" + if name == "termux-microphone-record" else None, + ) + + from tools.voice_mode import _termux_api_app_installed + assert _termux_api_app_installed() is False + + def test_match_is_case_insensitive(self, monkeypatch): + """Defensive against ROMs that capitalise the prefix differently.""" + _force_termux(monkeypatch) + run = _make_run_dispatcher({ + "pm": SimpleNamespace( + returncode=0, + stdout="Package:com.termux.api\n", + stderr="", + ), + }) + monkeypatch.setattr("tools.voice_mode.subprocess.run", run) + + from tools.voice_mode import _termux_api_app_installed + assert _termux_api_app_installed() is True + + +# ── End-to-end through detect_audio_environment ─────────────────────────── + + +class TestDetectAudioEnvironmentTermuxFallback: + """The point of the fix is that #31015 users with the binary on PATH + no longer see the misleading 'Termux:API Android app is not installed' + warning when the package-manager probe is inconclusive.""" + + def test_inconclusive_probes_with_binary_does_not_emit_app_warning( + self, monkeypatch + ): + monkeypatch.setenv("TERMUX_VERSION", "0.118.3") + monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.delenv("SSH_CONNECTION", raising=False) + + # No sounddevice — we go down the Termux:API branch. + monkeypatch.setattr( + "tools.voice_mode._import_audio", + lambda: (_ for _ in ()).throw(ImportError("no audio libs")), + ) + monkeypatch.setattr( + "tools.voice_mode._termux_microphone_command", + lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record", + ) + # Both probes fail (the #31015 reproduction). + run = _make_run_dispatcher({ + "pm": FileNotFoundError("pm: command not found"), + "cmd": FileNotFoundError("cmd: command not found"), + }) + monkeypatch.setattr("tools.voice_mode.subprocess.run", run) + monkeypatch.setattr( + "tools.voice_mode.shutil.which", + lambda name: "/data/data/com.termux/files/usr/bin/termux-microphone-record" + if name == "termux-microphone-record" else None, + ) + + from tools.voice_mode import detect_audio_environment + result = detect_audio_environment() + + assert result["available"] is True, ( + f"Voice mode should be available when the binary is on PATH " + f"and probes are inconclusive (issue #31015). Got: {result}" + ) + assert not any( + "Termux:API Android app is not installed" in w + for w in result["warnings"] + ), ( + "The misleading 'app is not installed' warning must not fire " + "when probes are inconclusive but the binary works (issue " + f"#31015). warnings={result['warnings']!r}" + ) + assert any( + "Termux:API microphone recording available" in n + for n in result.get("notices", []) + ) + + def test_clean_probes_no_match_still_blocks(self, monkeypatch): + """The genuine "CLI installed without the app" case still blocks + with the existing warning — important so users don't lose the + install hint when the package manager *can* tell us the truth.""" + monkeypatch.setenv("TERMUX_VERSION", "0.118.3") + monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.delenv("SSH_CONNECTION", raising=False) + + monkeypatch.setattr( + "tools.voice_mode._import_audio", + lambda: (_ for _ in ()).throw(ImportError("no audio libs")), + ) + monkeypatch.setattr( + "tools.voice_mode._termux_microphone_command", + lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record", + ) + run = _make_run_dispatcher({ + "pm": SimpleNamespace(returncode=0, stdout="", stderr=""), + "cmd": SimpleNamespace(returncode=0, stdout="", stderr=""), + }) + monkeypatch.setattr("tools.voice_mode.subprocess.run", run) + + from tools.voice_mode import detect_audio_environment + result = detect_audio_environment() + + assert result["available"] is False + assert any( + "Termux:API Android app is not installed" in w + for w in result["warnings"] + )