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>
128 lines
5.1 KiB
Python
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()
|