From 6c209b65574d5342eea8b94af16194e4d7be1463 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 5 Jul 2026 20:53:12 -0400 Subject: [PATCH] Add mobile platform capability planning --- README.md | 2 + ampbrowser/cli.py | 34 ++++++++++-- ampbrowser/docsgen.py | 15 +++++ ampbrowser/launch.py | 36 +++++++++--- ampbrowser/plan.py | 74 ++++++++++++++++++++----- ampbrowser/platforms.py | 72 ++++++++++++++++++++++++ docs/architecture.md | 1 + docs/generated/platform-capabilities.md | 26 +++++++++ docs/mobile-browser.md | 25 +++++++++ docs/open-flow.md | 1 + tests/test_docsgen.py | 6 +- tests/test_launch.py | 25 +++++++++ tests/test_plan.py | 35 ++++++++++++ tests/test_platforms.py | 29 ++++++++++ 14 files changed, 353 insertions(+), 28 deletions(-) create mode 100644 ampbrowser/platforms.py create mode 100644 docs/generated/platform-capabilities.md create mode 100644 docs/mobile-browser.md create mode 100644 tests/test_platforms.py diff --git a/README.md b/README.md index 97d61d8..7b83083 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ python3 -m ampbrowser plan gemini://wownero.org/ python3 -m ampbrowser open https://wownero.org/ python3 -m ampbrowser open http://example.b32.i2p/ --yes python3 -m ampbrowser open http://example.b32.i2p/ --config examples/config.toml +python3 -m ampbrowser open http://example.b32.i2p/ --platform android python3 -m ampbrowser docs generate python3 -m ampbrowser docs generate --check python3 tools/docs_check.py @@ -46,6 +47,7 @@ python3 -m unittest discover -s tests - [Architecture](docs/architecture.md) - [Configuration](docs/configuration.md) +- [Mobile browser](docs/mobile-browser.md) - [Open flow](docs/open-flow.md) - [Transport management](docs/transport-management.md) - [Test fixtures](docs/test-fixtures.md) diff --git a/ampbrowser/cli.py b/ampbrowser/cli.py index b3c9a90..a8389f4 100644 --- a/ampbrowser/cli.py +++ b/ampbrowser/cli.py @@ -8,6 +8,7 @@ from .config import load_config from .docsgen import generate_docs from .launch import prepare_open from .plan import plan_url +from .platforms import PLATFORM_CHOICES from .routing import route_url from .transports import inspect_transports @@ -22,10 +23,12 @@ def main(argv: list[str] | None = None) -> int: plan_parser = subcommands.add_parser("plan", help="Plan how a URL would be opened.") plan_parser.add_argument("url") plan_parser.add_argument("--config", type=Path, help="Path to an AMPB config file.") + plan_parser.add_argument("--platform", choices=PLATFORM_CHOICES, help="Target platform capability profile.") open_parser = subcommands.add_parser("open", help="Prepare a transport-aware open plan.") open_parser.add_argument("url") open_parser.add_argument("--config", type=Path, help="Path to an AMPB config file.") + open_parser.add_argument("--platform", choices=PLATFORM_CHOICES, help="Target platform capability profile.") open_parser.add_argument( "--yes", action="store_true", @@ -50,9 +53,15 @@ def main(argv: list[str] | None = None) -> int: if args.command == "route": return _cmd_route(args.url) if args.command == "plan": - return _cmd_plan(args.url, config_path=args.config) + return _cmd_plan(args.url, config_path=args.config, platform=args.platform) if args.command == "open": - return _cmd_open(args.url, consent=args.yes, dry_run=args.dry_run, config_path=args.config) + return _cmd_open( + args.url, + consent=args.yes, + dry_run=args.dry_run, + config_path=args.config, + platform=args.platform, + ) if args.command == "inspect": return _cmd_inspect() if args.command == "docs": @@ -75,9 +84,9 @@ def _cmd_route(url: str) -> int: return 0 -def _cmd_plan(url: str, *, config_path: Path | None) -> int: +def _cmd_plan(url: str, *, config_path: Path | None, platform: str | None) -> int: config = load_config(Path.cwd(), config_path) - plan = plan_url(url, config=config) + plan = plan_url(url, config=config, platform=platform) endpoint = plan.status.endpoint if plan.status else "-" running = str(plan.status.running).lower() if plan.status else "false" installed = str(plan.status.installed).lower() if plan.status else "false" @@ -90,6 +99,9 @@ def _cmd_plan(url: str, *, config_path: Path | None) -> int: f"installed={installed} " f"running={running} " f"endpoint={endpoint} " + f"platform={plan.platform_capability.platform} " + f"platform_browse={plan.platform_capability.browse} " + f"platform_manage={plan.platform_capability.manage} " f"policy={plan.policy_mode} " f"requires_consent={str(plan.requires_consent).lower()} " f"action=\"{plan.action}\" " @@ -98,9 +110,16 @@ def _cmd_plan(url: str, *, config_path: Path | None) -> int: return 0 -def _cmd_open(url: str, *, consent: bool, dry_run: bool, config_path: Path | None) -> int: +def _cmd_open( + url: str, + *, + consent: bool, + dry_run: bool, + config_path: Path | None, + platform: str | None, +) -> int: config = load_config(Path.cwd(), config_path) - open_plan = prepare_open(url, consent=consent, dry_run=dry_run, config=config) + open_plan = prepare_open(url, consent=consent, dry_run=dry_run, config=config, platform=platform) browse_plan = open_plan.browse_plan setup_steps = "|".join(open_plan.setup_steps) if open_plan.setup_steps else "-" message = _safe(open_plan.message) @@ -111,6 +130,9 @@ def _cmd_open(url: str, *, consent: bool, dry_run: bool, config_path: Path | Non f"profile={browse_plan.route.profile} " f"status={open_plan.status} " f"dry_run={str(open_plan.dry_run).lower()} " + f"platform={browse_plan.platform_capability.platform} " + f"platform_browse={browse_plan.platform_capability.browse} " + f"platform_manage={browse_plan.platform_capability.manage} " f"policy={browse_plan.policy_mode} " f"requires_consent={str(browse_plan.requires_consent).lower()} " f"consent_granted={str(open_plan.consent_granted).lower()} " diff --git a/ampbrowser/docsgen.py b/ampbrowser/docsgen.py index e38bdd8..2d6b8bd 100644 --- a/ampbrowser/docsgen.py +++ b/ampbrowser/docsgen.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path from .metadata import ROUTE_RULES, TRANSPORT_DEFINITIONS +from .platforms import PLATFORM_CAPABILITIES GENERATED_DIR = Path("docs/generated") @@ -12,6 +13,7 @@ def generate_docs(root: Path, *, check: bool = False) -> list[Path]: docs = { GENERATED_DIR / "route-rules.md": _route_rules_doc(), GENERATED_DIR / "transports.md": _transports_doc(), + GENERATED_DIR / "platform-capabilities.md": _platform_capabilities_doc(), } changed: list[Path] = [] for rel_path, content in docs.items(): @@ -57,3 +59,16 @@ def _transports_doc() -> str: f"`{transport.profile}` | {transport.note} |" ) return _header("Generated Transports") + "\n".join(rows) + "\n" + + +def _platform_capabilities_doc() -> str: + rows = [ + "| Transport | Platform | Browse | Adopt | Manage | Install | Note |", + "| --- | --- | --- | --- | --- | --- | --- |", + ] + for capability in PLATFORM_CAPABILITIES: + rows.append( + f"| `{capability.transport}` | `{capability.platform}` | `{capability.browse}` | " + f"`{capability.adopt}` | `{capability.manage}` | `{capability.install}` | {capability.note} |" + ) + return _header("Generated Platform Capabilities") + "\n".join(rows) + "\n" diff --git a/ampbrowser/launch.py b/ampbrowser/launch.py index d75950c..f810904 100644 --- a/ampbrowser/launch.py +++ b/ampbrowser/launch.py @@ -24,9 +24,10 @@ def prepare_open( consent: bool = False, dry_run: bool = True, config: AppConfig | None = None, + platform: str | None = None, ) -> OpenPlan: config = config or default_config() - browse_plan = plan_url(raw_url, config=config) + browse_plan = plan_url(raw_url, config=config, platform=platform) profile_path = config.profile_path(browse_plan.route.profile) proxy = _proxy_for(browse_plan) @@ -89,12 +90,31 @@ def _setup_steps(browse_plan: BrowsePlan) -> tuple[str, ...]: return () steps: list[str] = [] + platform = browse_plan.platform_capability.platform if not browse_plan.status.installed: - steps.append(f"install {browse_plan.route.transport}") - steps.extend( - [ - f"start managed {browse_plan.route.transport}", - f"wait for {browse_plan.status.endpoint}", - ] - ) + if platform == "android": + steps.append(f"install or enable Android {browse_plan.route.transport} provider") + elif platform == "ios": + steps.append(f"enable foreground iOS {browse_plan.route.transport} session") + else: + steps.append(f"install {browse_plan.route.transport}") + if platform == "android": + steps.append("start visible Android foreground service") + steps.extend( + [ + f"start managed Android {browse_plan.route.transport} transport", + f"wait for {browse_plan.status.endpoint}", + ] + ) + return tuple(steps) + elif platform == "ios": + steps.append("start foreground-only iOS session") + steps.extend( + [ + f"start foreground-only iOS {browse_plan.route.transport} transport", + f"wait for {browse_plan.status.endpoint}", + ] + ) + return tuple(steps) + steps.extend([f"start managed {browse_plan.route.transport}", f"wait for {browse_plan.status.endpoint}"]) return tuple(steps) diff --git a/ampbrowser/plan.py b/ampbrowser/plan.py index c0607af..d8a0090 100644 --- a/ampbrowser/plan.py +++ b/ampbrowser/plan.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from .config import AppConfig, default_config +from .platforms import PlatformCapability, capability_for from .routing import Route, route_url from .transports import TransportStatus, inspect_transport @@ -11,22 +12,39 @@ from .transports import TransportStatus, inspect_transport class BrowsePlan: route: Route status: TransportStatus | None + platform_capability: PlatformCapability action: str requires_consent: bool = False prompt: str = "" policy_mode: str = "adopt-or-prompt-manage" -def plan_url(raw_url: str, *, config: AppConfig | None = None) -> BrowsePlan: +def plan_url( + raw_url: str, + *, + config: AppConfig | None = None, + platform: str | None = None, +) -> BrowsePlan: config = config or default_config() route = route_url(raw_url) status = inspect_transport(route.transport) policy_mode = config.transport_mode(route.transport) + platform_capability = capability_for(route.transport, platform) + + if platform_capability.browse == "unsupported": + return BrowsePlan( + route=route, + status=status, + platform_capability=platform_capability, + action=f"blocked by platform: {route.transport} unsupported on {platform_capability.platform}", + policy_mode=policy_mode, + ) if policy_mode == "disabled": return BrowsePlan( route=route, status=status, + platform_capability=platform_capability, action=f"blocked by policy: {route.transport} disabled", policy_mode=policy_mode, ) @@ -34,6 +52,7 @@ def plan_url(raw_url: str, *, config: AppConfig | None = None) -> BrowsePlan: return BrowsePlan( route=route, status=status, + platform_capability=platform_capability, action="open with clearnet profile", policy_mode=policy_mode, ) @@ -41,6 +60,7 @@ def plan_url(raw_url: str, *, config: AppConfig | None = None) -> BrowsePlan: return BrowsePlan( route=route, status=status, + platform_capability=platform_capability, action="adopt existing transport", policy_mode=policy_mode, ) @@ -48,24 +68,51 @@ def plan_url(raw_url: str, *, config: AppConfig | None = None) -> BrowsePlan: return BrowsePlan( route=route, status=status, + platform_capability=platform_capability, action=f"blocked by policy: {route.transport} mode is adopt", policy_mode=policy_mode, ) - if status and status.manage_supported: - action = "prompt to start managed transport" - prompt = ( - f"{route.transport} is not running. AMPB can start a managed {route.transport} " - f"transport for {route.normalized}." - ) - if not status.installed: - action = "prompt to install and start managed transport" - prompt = ( - f"{route.transport} is not installed or running. AMPB can install and start " - f"a managed {route.transport} transport for {route.normalized}." - ) + if status and status.manage_supported and not platform_capability.can_manage_setup: return BrowsePlan( route=route, status=status, + platform_capability=platform_capability, + action=( + f"blocked by platform: {route.transport} managed setup is " + f"{platform_capability.manage} on {platform_capability.platform}" + ), + policy_mode=policy_mode, + ) + if status and status.manage_supported: + if platform_capability.manage == "foreground-only": + action = "prompt to start foreground-only transport session" + prompt = ( + f"{route.transport} is not running. AMPB can start a foreground-only " + f"{route.transport} session for {route.normalized} on {platform_capability.platform}." + ) + else: + action = "prompt to start managed transport" + prompt = ( + f"{route.transport} is not running. AMPB can start a managed {route.transport} " + f"transport for {route.normalized}." + ) + if not status.installed: + if platform_capability.manage == "foreground-only": + action = "prompt to enable foreground-only transport session" + prompt = ( + f"{route.transport} is not available. AMPB can enable a foreground-only " + f"{route.transport} session for {route.normalized} on {platform_capability.platform}." + ) + else: + action = "prompt to install and start managed transport" + prompt = ( + f"{route.transport} is not installed or running. AMPB can install and start " + f"a managed {route.transport} transport for {route.normalized}." + ) + return BrowsePlan( + route=route, + status=status, + platform_capability=platform_capability, action=action, requires_consent=True, prompt=prompt, @@ -74,6 +121,7 @@ def plan_url(raw_url: str, *, config: AppConfig | None = None) -> BrowsePlan: return BrowsePlan( route=route, status=status, + platform_capability=platform_capability, action="blocked until adapter is configured", policy_mode=policy_mode, ) diff --git a/ampbrowser/platforms.py b/ampbrowser/platforms.py new file mode 100644 index 0000000..61dca7a --- /dev/null +++ b/ampbrowser/platforms.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from dataclasses import dataclass +import sys + + +PLATFORM_CHOICES = ("desktop", "android", "ios") +MANAGED_SETUP_VALUES = {"ready", "planned", "foreground-only"} + + +@dataclass(frozen=True) +class PlatformCapability: + transport: str + platform: str + browse: str + adopt: str + manage: str + install: str + note: str + + @property + def can_manage_setup(self) -> bool: + return self.manage in MANAGED_SETUP_VALUES + + +PLATFORM_CAPABILITIES = ( + PlatformCapability("clearnet", "desktop", "ready", "ready", "unsupported", "unsupported", "Use system browser or isolated desktop profile."), + PlatformCapability("clearnet", "android", "ready", "ready", "unsupported", "unsupported", "Use Android browser shell, WebView, or Custom Tabs."), + PlatformCapability("clearnet", "ios", "ready", "ready", "unsupported", "unsupported", "Use iOS browser shell with platform web constraints."), + PlatformCapability("tor", "desktop", "ready", "ready", "planned", "planned", "Adopt local Tor or prompt before managed desktop daemon setup."), + PlatformCapability("tor", "android", "planned", "planned", "planned", "planned", "Use an app-owned foreground service or compatible installed Tor provider."), + PlatformCapability("tor", "ios", "foreground-only", "planned", "foreground-only", "constrained", "Plan for in-app foreground sessions; do not assume always-on background service."), + PlatformCapability("i2p", "desktop", "ready", "ready", "planned", "planned", "Adopt local I2P proxy or prompt before managed desktop daemon setup."), + PlatformCapability("i2p", "android", "planned", "planned", "planned", "planned", "Use an app-owned foreground service or compatible installed I2P provider."), + PlatformCapability("i2p", "ios", "constrained", "planned", "foreground-only", "constrained", "Treat iOS I2P support as foreground-only until native adapter constraints are proven."), + PlatformCapability("gemini", "desktop", "ready", "ready", "ready", "unsupported", "Built-in fetch/render path can be shared by desktop shell."), + PlatformCapability("gemini", "android", "ready", "ready", "ready", "unsupported", "Built-in fetch/render path can be shared by Android shell."), + PlatformCapability("gemini", "ios", "ready", "ready", "ready", "unsupported", "Built-in fetch/render path can be shared by iOS shell."), + PlatformCapability("reticulum", "desktop", "planned", "planned", "planned", "planned", "Adapter and local interface readiness checks are planned."), + PlatformCapability("reticulum", "android", "planned", "planned", "planned", "planned", "Mobile adapter should use explicit foreground service consent."), + PlatformCapability("reticulum", "ios", "constrained", "planned", "foreground-only", "constrained", "Do not assume background Reticulum operation on iOS."), + PlatformCapability("unknown", "desktop", "unsupported", "unsupported", "unsupported", "unsupported", "No platform route is available."), + PlatformCapability("unknown", "android", "unsupported", "unsupported", "unsupported", "unsupported", "No platform route is available."), + PlatformCapability("unknown", "ios", "unsupported", "unsupported", "unsupported", "unsupported", "No platform route is available."), +) + + +def detect_platform() -> str: + if hasattr(sys, "getandroidapilevel"): + return "android" + if sys.platform in {"ios", "watchos", "tvos"}: + return "ios" + return "desktop" + + +def normalize_platform(platform: str | None) -> str: + selected = platform or detect_platform() + if selected not in PLATFORM_CHOICES: + choices = ", ".join(PLATFORM_CHOICES) + raise ValueError(f"platform must be one of: {choices}") + return selected + + +def capability_for(transport: str, platform: str | None = None) -> PlatformCapability: + selected = normalize_platform(platform) + for capability in PLATFORM_CAPABILITIES: + if capability.transport == transport and capability.platform == selected: + return capability + for capability in PLATFORM_CAPABILITIES: + if capability.transport == "unknown" and capability.platform == selected: + return capability + raise ValueError(f"no capability for transport={transport} platform={selected}") diff --git a/docs/architecture.md b/docs/architecture.md index 3b00640..44c137d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,6 +22,7 @@ browser engine until those contracts are stable. - `ampbrowser.transports`: local readiness checks for Tor, I2P, Reticulum, and Gemini. - `ampbrowser.plan`: dry-run action planner for browser launches. - `ampbrowser.launch`: side-effect-free launch specs and first-use consent state. +- `ampbrowser.platforms`: platform capability matrix for desktop, Android, and iOS. - `ampbrowser.docsgen`: generated public documentation from code-owned metadata. ## Browser Strategy diff --git a/docs/generated/platform-capabilities.md b/docs/generated/platform-capabilities.md new file mode 100644 index 0000000..eea90e0 --- /dev/null +++ b/docs/generated/platform-capabilities.md @@ -0,0 +1,26 @@ +# Generated Platform Capabilities + +> Status: generated | Updated by `python3 -m ampbrowser docs generate` | Applies to: AMPB + +This file is generated from code. Do not edit it by hand. + +| Transport | Platform | Browse | Adopt | Manage | Install | Note | +| --- | --- | --- | --- | --- | --- | --- | +| `clearnet` | `desktop` | `ready` | `ready` | `unsupported` | `unsupported` | Use system browser or isolated desktop profile. | +| `clearnet` | `android` | `ready` | `ready` | `unsupported` | `unsupported` | Use Android browser shell, WebView, or Custom Tabs. | +| `clearnet` | `ios` | `ready` | `ready` | `unsupported` | `unsupported` | Use iOS browser shell with platform web constraints. | +| `tor` | `desktop` | `ready` | `ready` | `planned` | `planned` | Adopt local Tor or prompt before managed desktop daemon setup. | +| `tor` | `android` | `planned` | `planned` | `planned` | `planned` | Use an app-owned foreground service or compatible installed Tor provider. | +| `tor` | `ios` | `foreground-only` | `planned` | `foreground-only` | `constrained` | Plan for in-app foreground sessions; do not assume always-on background service. | +| `i2p` | `desktop` | `ready` | `ready` | `planned` | `planned` | Adopt local I2P proxy or prompt before managed desktop daemon setup. | +| `i2p` | `android` | `planned` | `planned` | `planned` | `planned` | Use an app-owned foreground service or compatible installed I2P provider. | +| `i2p` | `ios` | `constrained` | `planned` | `foreground-only` | `constrained` | Treat iOS I2P support as foreground-only until native adapter constraints are proven. | +| `gemini` | `desktop` | `ready` | `ready` | `ready` | `unsupported` | Built-in fetch/render path can be shared by desktop shell. | +| `gemini` | `android` | `ready` | `ready` | `ready` | `unsupported` | Built-in fetch/render path can be shared by Android shell. | +| `gemini` | `ios` | `ready` | `ready` | `ready` | `unsupported` | Built-in fetch/render path can be shared by iOS shell. | +| `reticulum` | `desktop` | `planned` | `planned` | `planned` | `planned` | Adapter and local interface readiness checks are planned. | +| `reticulum` | `android` | `planned` | `planned` | `planned` | `planned` | Mobile adapter should use explicit foreground service consent. | +| `reticulum` | `ios` | `constrained` | `planned` | `foreground-only` | `constrained` | Do not assume background Reticulum operation on iOS. | +| `unknown` | `desktop` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | No platform route is available. | +| `unknown` | `android` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | No platform route is available. | +| `unknown` | `ios` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | No platform route is available. | diff --git a/docs/mobile-browser.md b/docs/mobile-browser.md new file mode 100644 index 0000000..18da052 --- /dev/null +++ b/docs/mobile-browser.md @@ -0,0 +1,25 @@ +# Mobile Browser + +> Status: draft | Updated 2026-07-06 | Applies to: AMPB mobile shells + +Mobile is a first-class AMPB target. The core router, policy model, consent flow, profile +isolation, and fixture checks should stay shared across desktop, Android, and iOS. Native +mobile shells should implement only the platform-specific browser and transport pieces. + +## Android + +Android is the primary mobile target for full AMPB browsing. Tor, I2P, and Reticulum +adapters should run only after a URL selects that transport and the user approves setup. +Managed adapters must use visible, stoppable foreground services and app-owned state. + +## iOS + +iOS is a supported design target, but transport management is constrained. AMPB should +assume foreground-only sessions unless a reviewed platform capability proves otherwise. +The iOS shell should still share routing, policy, consent, fixture, Gemini, and clearnet +behavior with the rest of AMPB. + +## Capability Matrix + +Generated platform capability metadata lives in +[generated platform capabilities](generated/platform-capabilities.md). diff --git a/docs/open-flow.md b/docs/open-flow.md index 11ea0bc..84bfd7d 100644 --- a/docs/open-flow.md +++ b/docs/open-flow.md @@ -27,4 +27,5 @@ python3 -m ampbrowser open https://wownero.org/ python3 -m ampbrowser open http://example.b32.i2p/ python3 -m ampbrowser open http://example.b32.i2p/ --yes python3 -m ampbrowser open http://example.b32.i2p/ --config examples/config.toml +python3 -m ampbrowser open http://example.b32.i2p/ --platform android ``` diff --git a/tests/test_docsgen.py b/tests/test_docsgen.py index af7e415..644eca4 100644 --- a/tests/test_docsgen.py +++ b/tests/test_docsgen.py @@ -14,7 +14,11 @@ class DocsGenTest(unittest.TestCase): changed = generate_docs(root) self.assertEqual( - {Path("docs/generated/route-rules.md"), Path("docs/generated/transports.md")}, + { + Path("docs/generated/platform-capabilities.md"), + Path("docs/generated/route-rules.md"), + Path("docs/generated/transports.md"), + }, set(changed), ) self.assertEqual([], generate_docs(root, check=True)) diff --git a/tests/test_launch.py b/tests/test_launch.py index 505bb6e..12354a5 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -62,6 +62,31 @@ class PrepareOpenTest(unittest.TestCase): self.assertEqual(".local/ampb/profiles/clearnet", plan.profile_path) + def test_android_setup_steps_include_foreground_service(self) -> None: + status = TransportStatus( + transport="i2p", + installed=False, + running=False, + endpoint="http://127.0.0.1:4444", + adoptable=False, + manage_supported=True, + note="I2P HTTP proxy", + ) + + with patch("ampbrowser.plan.inspect_transport", return_value=status): + plan = prepare_open("http://example.b32.i2p/", platform="android") + + self.assertEqual("android", plan.browse_plan.platform_capability.platform) + self.assertEqual( + ( + "install or enable Android i2p provider", + "start visible Android foreground service", + "start managed Android i2p transport", + "wait for http://127.0.0.1:4444", + ), + plan.setup_steps, + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_plan.py b/tests/test_plan.py index 1cddcd8..22edd7e 100644 --- a/tests/test_plan.py +++ b/tests/test_plan.py @@ -89,6 +89,41 @@ class PlanUrlTest(unittest.TestCase): self.assertEqual("blocked by policy: tor mode is adopt", plan.action) self.assertFalse(plan.requires_consent) + def test_android_platform_is_reported_on_plan(self) -> None: + status = TransportStatus( + transport="tor", + installed=False, + running=False, + endpoint="socks5://127.0.0.1:9050", + adoptable=False, + manage_supported=True, + note="Tor SOCKS proxy", + ) + + with patch("ampbrowser.plan.inspect_transport", return_value=status): + plan = plan_url("http://example.onion/", platform="android") + + self.assertEqual("android", plan.platform_capability.platform) + self.assertEqual("planned", plan.platform_capability.manage) + self.assertTrue(plan.requires_consent) + + def test_ios_foreground_only_transport_prompt(self) -> None: + status = TransportStatus( + transport="tor", + installed=False, + running=False, + endpoint="socks5://127.0.0.1:9050", + adoptable=False, + manage_supported=True, + note="Tor SOCKS proxy", + ) + + with patch("ampbrowser.plan.inspect_transport", return_value=status): + plan = plan_url("http://example.onion/", platform="ios") + + self.assertEqual("prompt to enable foreground-only transport session", plan.action) + self.assertIn("foreground-only", plan.prompt) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_platforms.py b/tests/test_platforms.py new file mode 100644 index 0000000..85a1034 --- /dev/null +++ b/tests/test_platforms.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import unittest + +from ampbrowser.platforms import capability_for, normalize_platform + + +class PlatformsTest(unittest.TestCase): + def test_android_tor_is_managed_setup_capable(self) -> None: + capability = capability_for("tor", "android") + + self.assertEqual("android", capability.platform) + self.assertEqual("planned", capability.manage) + self.assertTrue(capability.can_manage_setup) + + def test_ios_tor_is_foreground_only(self) -> None: + capability = capability_for("tor", "ios") + + self.assertEqual("foreground-only", capability.manage) + self.assertTrue(capability.can_manage_setup) + self.assertIn("foreground", capability.note) + + def test_unknown_platform_is_rejected(self) -> None: + with self.assertRaises(ValueError): + normalize_platform("watch") + + +if __name__ == "__main__": + unittest.main()