From 66146c1d5d52f5c7452e241695ce62f2da7b0d6a Mon Sep 17 00:00:00 2001 From: Immanuel Date: Sun, 25 Oct 2026 07:57:36 +0100 Subject: [PATCH 1/2] fix(api): answer /api/v1/checkAppAgreement with accepted agreements The Android app requests GET /api/v1/checkAppAgreement when a device page is opened and deserializes it as UserAgreementInfoV1: {userAndPrivacyAgree, improvementPlanAgree, recommendationAgree, userPluginAgreementList} All fields have defaults, so the generic catch-all object deserializes silently into "nothing agreed" with an empty plugin agreement list. The app then logs "getPluginAgreements no data " and the device plugin stops before requesting the map. Add a dedicated route that reports the app agreements as accepted and one plugin agreement entry ({deviceId, type, version, extra}) per home device. Co-Authored-By: Claude Opus 5.5 --- .../https_server/endpoint_rules.py | 3 + .../https_server/routes/api/v1/agreement.py | 70 ++++++++++++ tests/test_check_app_agreement.py | 102 ++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 src/roborock_local_server/bundled_backend/https_server/routes/api/v1/agreement.py create mode 100644 tests/test_check_app_agreement.py diff --git a/src/roborock_local_server/bundled_backend/https_server/endpoint_rules.py b/src/roborock_local_server/bundled_backend/https_server/endpoint_rules.py index 7aa3c233..c4f88b68 100644 --- a/src/roborock_local_server/bundled_backend/https_server/endpoint_rules.py +++ b/src/roborock_local_server/bundled_backend/https_server/endpoint_rules.py @@ -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 @@ -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), diff --git a/src/roborock_local_server/bundled_backend/https_server/routes/api/v1/agreement.py b/src/roborock_local_server/bundled_backend/https_server/routes/api/v1/agreement.py new file mode 100644 index 00000000..d7a6379f --- /dev/null +++ b/src/roborock_local_server/bundled_backend/https_server/routes/api/v1/agreement.py @@ -0,0 +1,70 @@ +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 set of +# valid type values is not known: the Android app keeps this list as a raw +# JSON element and hands it to the device plugin. Reporting types 1-3 with +# version 1 is enough for the plugin to continue past its agreement check. +PLUGIN_AGREEMENT_TYPES = (1, 2, 3) +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": , "type": , "version": , "extra": }. + """ + 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 "). + return ok( + { + "userAndPrivacyAgree": True, + "improvementPlanAgree": True, + "recommendationAgree": True, + "userPluginAgreementList": plugin_agreements(ctx), + } + ) diff --git a/tests/test_check_app_agreement.py b/tests/test_check_app_agreement.py new file mode 100644 index 00000000..f497d4cb --- /dev/null +++ b/tests/test_check_app_agreement.py @@ -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"], int) + assert isinstance(entry["version"], int) + assert entry["extra"] == "" + assert sorted(entry["type"] for entry in agreements if entry["deviceId"] == "device-a") == [1, 2, 3] + + +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"] == [] From effc85ae639766e2cfff9f402cfa6f0630620e7e Mon Sep 17 00:00:00 2001 From: Immanuel Date: Fri, 30 Oct 2026 22:24:26 +0100 Subject: [PATCH 2/2] fix(api): report plugin agreement types as strings The Android app reads each userPluginAgreementList entry's "type" as a string (JsonPrimitive.content in UserPreferenceRepository.getPluginAgreements) and the device plugin bundle as well as the app's AgreementInfo bean use the constants USER_AGREEMENT and PRIVACY_POLICY. Report those two instead of the placeholder integers 1-3, as suggested in review. Co-Authored-By: Claude Fable 5.1 --- .../https_server/routes/api/v1/agreement.py | 13 +++++++------ tests/test_check_app_agreement.py | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/roborock_local_server/bundled_backend/https_server/routes/api/v1/agreement.py b/src/roborock_local_server/bundled_backend/https_server/routes/api/v1/agreement.py index d7a6379f..51011165 100644 --- a/src/roborock_local_server/bundled_backend/https_server/routes/api/v1/agreement.py +++ b/src/roborock_local_server/bundled_backend/https_server/routes/api/v1/agreement.py @@ -8,11 +8,12 @@ from ...auth.service import ok from ...user.homes.service import home_payload -# Agreement types reported per device in userPluginAgreementList. The set of -# valid type values is not known: the Android app keeps this list as a raw -# JSON element and hands it to the device plugin. Reporting types 1-3 with -# version 1 is enough for the plugin to continue past its agreement check. -PLUGIN_AGREEMENT_TYPES = (1, 2, 3) +# 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 @@ -37,7 +38,7 @@ 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": , "type": , "version": , "extra": }. + {"deviceId": , "type": , "version": , "extra": }. """ return [ { diff --git a/tests/test_check_app_agreement.py b/tests/test_check_app_agreement.py index f497d4cb..9fd680d8 100644 --- a/tests/test_check_app_agreement.py +++ b/tests/test_check_app_agreement.py @@ -83,10 +83,10 @@ def test_check_app_agreement_reports_all_agreements_accepted(tmp_path: Path) -> 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"], int) + 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") == [1, 2, 3] + 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: