Files
Bailey DixonandClaude Opus 4.6 35808e7905 feat(C1-C4): sideload-only tier - location, contacts, call, send_sms
C1 android_location: last-known GPS via LocationManager across
providers, staleness warning, no fresh-fix requests from background.

C2 android_search_contacts: ContactsContract filter + phone number
resolution, privacy-respecting logging.

C3 android_call: auto-dial via ACTION_CALL on sideload, fallback to
ACTION_DIAL on googlePlay / permission-denied. Destructive-verb
confirmation modal gates every call.

C4 android_send_sms: direct SmsManager.sendTextMessage +
sendMultipartTextMessage with PendingIntent result callback (actual
wait for send completion, not fire-and-forget). API-version-aware
SmsManager retrieval. Voice-to-bridge SendSms intent handler now
emits a real /send_sms bridge.command envelope instead of a
malformed tool.call payload; contact->number resolution marked
TODO(C4) with a sketch since fire-and-forget dispatch lacks
response correlation. Destructive-verb confirmation modal gates
every send.

All four permissions added to app/src/sideload/AndroidManifest.xml
only - NOT the main manifest. Flavor gate via
FeatureFlags.BuildFlavor.isSideload in BridgeCommandHandler. Tools
return 'sideload-only' errors on googlePlay devices. Every call/send
logs the full payload to the safety-rails activity log via the
confirmation modal's method + text fields.

Tests: 39 stdlib-unittest cases across
plugin/tests/test_android_{location,search_contacts,call,send_sms}.py
- happy / denied / timeout / schema coverage for each tool. Existing
test_android_tool.py tool-count assertion bumped 14 -> 18.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:45:43 -04:00

128 lines
5.1 KiB
Python

"""
Unit tests for ``plugin.tools.android_tool.android_call`` (Tier C3).
Stdlib ``unittest`` + ``unittest.mock`` only. Run via::
python -m unittest plugin.tests.test_android_call
Coverage:
* happy path auto-dial (sideload — mode="auto_dial").
* happy path dialer fallback (googlePlay or no-permission — mode="dialer_opened").
* user denied destructive confirmation — bridge returns 403.
* invalid number format.
* no dialer installed.
* schema registration sanity.
Note: the phone flavor decision (auto_dial vs dialer_opened) happens on the
phone, not in the Python tool. From the tool's perspective it just POSTs
``/call`` with the number and echoes whatever the bridge returns.
"""
from __future__ import annotations
import json
import sys
import unittest
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from plugin.tools import android_tool # noqa: E402
class TestAndroidCallHappyPath(unittest.TestCase):
def test_auto_dial_mode(self) -> None:
fake = {"number": "+15551234567", "mode": "auto_dial"}
with mock.patch.object(android_tool, "_post", return_value=fake) as m:
result = json.loads(android_tool.android_call("+15551234567"))
self.assertEqual(result["mode"], "auto_dial")
self.assertEqual(result["number"], "+15551234567")
args = m.call_args.args
kwargs = m.call_args.kwargs
sent_path = args[0] if args else kwargs["path"]
sent_body = args[1] if len(args) > 1 else kwargs.get("payload", {})
self.assertEqual(sent_path, "/call")
self.assertEqual(sent_body["number"], "+15551234567")
def test_dialer_fallback_mode(self) -> None:
"""googlePlay flavor (or permission-denied) opens the dialer."""
fake = {
"number": "555-1234",
"mode": "dialer_opened",
"note": "opened dialer — user must tap Call manually",
}
with mock.patch.object(android_tool, "_post", return_value=fake):
result = json.loads(android_tool.android_call("555-1234"))
self.assertEqual(result["mode"], "dialer_opened")
self.assertIn("note", result)
class TestAndroidCallDenied(unittest.TestCase):
def test_user_denied_confirmation(self) -> None:
"""Phone safety-rails destructive-verb modal was denied."""
fake = {
"error": "user denied destructive action",
"reason": "confirmation_denied_or_timeout",
}
with mock.patch.object(android_tool, "_post", return_value=fake):
result = json.loads(android_tool.android_call("+15551234567"))
self.assertIn("error", result)
self.assertEqual(result["reason"], "confirmation_denied_or_timeout")
def test_invalid_number_passthrough(self) -> None:
fake = {"error": "call: number contains invalid characters"}
with mock.patch.object(android_tool, "_post", return_value=fake):
result = json.loads(android_tool.android_call("abcdef"))
self.assertIn("error", result)
self.assertIn("invalid", result["error"])
class TestAndroidCallInfrastructure(unittest.TestCase):
def test_no_dialer_installed(self) -> None:
fake = {"error": "no dialer installed"}
with mock.patch.object(android_tool, "_post", return_value=fake):
result = json.loads(android_tool.android_call("+15551234567"))
self.assertIn("no dialer", result["error"])
def test_network_error(self) -> None:
with mock.patch.object(
android_tool, "_post", side_effect=ConnectionError("relay down")
):
result = json.loads(android_tool.android_call("+15551234567"))
self.assertIn("error", result)
self.assertIn("relay down", result["error"])
def test_sideload_only_on_googleplay(self) -> None:
"""googlePlay build where auto-dial is forbidden — bridge rejects at handler."""
fake = {"error": "android_call auto-dial is sideload-only"}
with mock.patch.object(android_tool, "_post", return_value=fake):
result = json.loads(android_tool.android_call("+15551234567"))
self.assertIn("sideload-only", result["error"])
class TestAndroidCallSchema(unittest.TestCase):
def test_registered(self) -> None:
self.assertIn("android_call", android_tool._SCHEMAS)
self.assertIn("android_call", android_tool._HANDLERS)
def test_schema_shape(self) -> None:
schema = android_tool._SCHEMAS["android_call"]
self.assertEqual(schema["name"], "android_call")
self.assertIn("confirmation", schema["description"].lower())
self.assertIn("number", schema["parameters"]["properties"])
self.assertIn("number", schema["parameters"]["required"])
def test_handler_dispatch(self) -> None:
fake = {"number": "x", "mode": "auto_dial"}
with mock.patch.object(android_tool, "_post", return_value=fake):
out = android_tool._HANDLERS["android_call"]({"number": "x"})
result = json.loads(out)
self.assertEqual(result["mode"], "auto_dial")
if __name__ == "__main__":
unittest.main()