Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from shared.http_helpers import wrap_response
from shared.inventory_io import WEB_API_INVENTORY_FILE, load_inventory, write_inventory

from .routes.api.v1.agreement import build_check_app_agreement as _build_check_app_agreement_v1
from .routes.api.v1.agreement import match_check_app_agreement as _match_check_app_agreement_v1
from .routes.api.v1.appfeatureplugin import build as _build_app_feature_plugin
from .routes.api.v1.appfeatureplugin import match as _match_app_feature_plugin
from .routes.api.v1.appconfig import build as _build_app_config_v1
Expand Down Expand Up @@ -571,6 +573,7 @@ def default_endpoint_rules() -> Sequence[EndpointRule]:
EndpointRule("country_version", _match_country_version_v1, _build_country_version_v1),
EndpointRule("country_list", _match_country_list_v1, _build_country_list_v1),
EndpointRule("agreement_latest_v4", _match_agreement_latest_v4, _build_agreement_latest_v4),
EndpointRule("check_app_agreement_v1", _match_check_app_agreement_v1, _build_check_app_agreement_v1),
EndpointRule("get_home_detail", _match_get_home_detail_v1, _build_get_home_detail_v1),
EndpointRule("user_info", _match_user_info_v1, _build_user_info_v1),
EndpointRule("app_config", _match_app_config_v1, _build_app_config_v1),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from __future__ import annotations

from typing import Any

from shared.context import ServerContext
from shared.data_helpers import get_value

from ...auth.service import ok
from ...user.homes.service import home_payload

# Agreement types reported per device in userPluginAgreementList. The Android
# app keeps this list as raw JSON and reads each entry's "type" as a string
# (JsonPrimitive.content) before handing it to the device plugin; the plugin
# bundle and the app's AgreementInfo bean use the string constants
# USER_AGREEMENT and PRIVACY_POLICY for agreement types.
PLUGIN_AGREEMENT_TYPES = ("USER_AGREEMENT", "PRIVACY_POLICY")
PLUGIN_AGREEMENT_VERSION = 1


def match_check_app_agreement(path: str) -> bool:
return path.rstrip("/") == "/api/v1/checkAppAgreement"


def _home_device_ids(ctx: ServerContext) -> list[str]:
devices_value = home_payload(ctx).get("devices")
devices = devices_value if isinstance(devices_value, list) else []
device_ids: list[str] = []
for device in devices:
if not isinstance(device, dict):
continue
duid = str(get_value(device, "duid", "did", default="")).strip()
if duid and duid not in device_ids:
device_ids.append(duid)
return device_ids


def plugin_agreements(ctx: ServerContext) -> list[dict[str, Any]]:
"""One accepted plugin agreement per agreement type and home device.

Entry shape follows the Android app's UserPreferenceRepository:
{"deviceId": <duid>, "type": <str>, "version": <int>, "extra": <str>}.
"""
return [
{
"deviceId": duid,
"type": agreement_type,
"version": PLUGIN_AGREEMENT_VERSION,
"extra": "",
}
for duid in _home_device_ids(ctx)
for agreement_type in PLUGIN_AGREEMENT_TYPES
]


def build_check_app_agreement(
ctx: ServerContext,
_query_params: dict[str, list[str]],
_body_params: dict[str, list[str]],
_clean_path: str,
) -> dict[str, Any]:
# UserAgreementInfoV1. Every field has a default in the app, so the generic
# catch-all payload deserializes silently into "nothing agreed" and the
# device plugin stops after getPluginAgreements ("no data <duid>").
return ok(
{
"userAndPrivacyAgree": True,
"improvementPlanAgree": True,
"recommendationAgree": True,
"userPluginAgreementList": plugin_agreements(ctx),
}
)
102 changes: 102 additions & 0 deletions tests/test_check_app_agreement.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import json
from pathlib import Path

from roborock_local_server.backend import (
ServerContext,
default_endpoint_rules,
resolve_route,
)


class _StubRuntimeCredentials:
"""Treat inventory devices as paired so home_payload keeps them."""

def __init__(self, paired: bool = True) -> None:
self._paired = paired

def resolve_device(self, *, duid: str = "", **_kwargs):
if not self._paired:
return None
return {"did": f"did-{duid}", "device_mqtt_usr": "mqtt-user"}

def __getattr__(self, _name):
return lambda *args, **kwargs: None


def _make_context(tmp_path: Path, *, paired: bool = True) -> ServerContext:
return ServerContext(
api_host="api.example.com",
mqtt_host="mqtt.example.com",
wood_host="wood.example.com",
region="us",
protocol_login_email="user@example.com",
localkey="key123",
duid="duid-1",
mqtt_usr="usr",
mqtt_passwd="pwd",
mqtt_clientid="cid",
https_port=443,
mqtt_tls_port=8883,
http_jsonl=tmp_path / "http.jsonl",
mqtt_jsonl=tmp_path / "mqtt.jsonl",
loggers={},
runtime_credentials=_StubRuntimeCredentials(paired),
)


def _write_inventory(tmp_path: Path, devices: list[dict[str, object]]) -> None:
(tmp_path / "web_api_inventory.json").write_text(
json.dumps({"home": {"id": 424242, "name": "Test Home"}, "devices": devices}) + "\n",
encoding="utf-8",
)


def _check_app_agreement(tmp_path: Path, *, paired: bool = True) -> tuple[str, dict]:
return resolve_route(
rules=default_endpoint_rules(),
context=_make_context(tmp_path, paired=paired),
clean_path="/api/v1/checkAppAgreement",
query_params={},
body_params={},
method="GET",
)


def test_check_app_agreement_reports_all_agreements_accepted(tmp_path: Path) -> None:
_write_inventory(
tmp_path,
[
{"duid": "device-a", "name": "Vacuum A", "model": "roborock.vacuum.a15", "product_id": "product-a"},
{"duid": "device-b", "name": "Vacuum B", "model": "roborock.vacuum.a15", "product_id": "product-a"},
],
)

route, payload = _check_app_agreement(tmp_path)

assert route == "check_app_agreement_v1"
assert payload["code"] == 200
data = payload["data"]
assert data["userAndPrivacyAgree"] is True
assert data["improvementPlanAgree"] is True
assert data["recommendationAgree"] is True
agreements = data["userPluginAgreementList"]
assert {entry["deviceId"] for entry in agreements} == {"device-a", "device-b"}
for entry in agreements:
assert set(entry) == {"deviceId", "type", "version", "extra"}
assert isinstance(entry["type"], str)
assert isinstance(entry["version"], int)
assert entry["extra"] == ""
assert sorted(entry["type"] for entry in agreements if entry["deviceId"] == "device-a") == ["PRIVACY_POLICY", "USER_AGREEMENT"]


def test_check_app_agreement_without_paired_devices_returns_empty_plugin_list(tmp_path: Path) -> None:
_write_inventory(
tmp_path,
[{"duid": "device-a", "name": "Vacuum A", "model": "roborock.vacuum.a15", "product_id": "product-a"}],
)

route, payload = _check_app_agreement(tmp_path, paired=False)

assert route == "check_app_agreement_v1"
assert payload["data"]["userAndPrivacyAgree"] is True
assert payload["data"]["userPluginAgreementList"] == []
Loading