489 lines
13 KiB
Markdown
489 lines
13 KiB
Markdown
# Device Profile Generation Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Build a local device profile generator that can create, persist, reload, and export stable synthetic Android device identities.
|
|
|
|
**Architecture:** Add a focused `core/device_profile.py` module for pure identity generation and validation. Add a small `tools/new_device.py` CLI that uses the core module without depending on APP runtime, Frida, HAR, or `out/`.
|
|
|
|
**Tech Stack:** Python 3.13, standard library only, pytest for tests.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
- Create `tests/test_device_profile.py`
|
|
- Unit tests for profile generation, validation, persistence, cloud override, and env export.
|
|
- Create `core/device_profile.py`
|
|
- Dataclass model, generator, validation helpers, JSON persistence, env export.
|
|
- Create `tools/new_device.py`
|
|
- CLI for generating one or more profiles.
|
|
- Modify `core/__init__.py`
|
|
- Export `DeviceProfile`, `DeviceProfileGenerator`, `load_device_profile`, and `save_device_profile`.
|
|
|
|
---
|
|
|
|
### Task 1: Add failing tests for device profile core
|
|
|
|
**Files:**
|
|
- Create: `tests/test_device_profile.py`
|
|
- Create later: `core/device_profile.py`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
```python
|
|
import hashlib
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from core.device_profile import (
|
|
DeviceProfile,
|
|
DeviceProfileGenerator,
|
|
load_device_profile,
|
|
save_device_profile,
|
|
)
|
|
|
|
|
|
def test_generator_creates_consistent_local_identity():
|
|
profile = DeviceProfileGenerator(seed=1234).new_profile()
|
|
|
|
assert len(profile.android_id) == 16
|
|
assert profile.android_id == profile.android_id.lower()
|
|
int(profile.android_id, 16)
|
|
assert profile.o_did == f"ANDROID_{profile.android_id}"
|
|
assert profile.local_did.startswith("ANDROID_")
|
|
assert profile.did == profile.local_did
|
|
|
|
expected_rdid = hashlib.md5(profile.g_rdi2.encode("utf-8")).hexdigest()[16:32]
|
|
assert profile.rdid == f"ANDROID_{expected_rdid}"
|
|
|
|
|
|
def test_profile_persistence_roundtrip(tmp_path):
|
|
path = tmp_path / "device.json"
|
|
profile = DeviceProfileGenerator(seed=5678).new_profile()
|
|
|
|
save_device_profile(profile, path)
|
|
loaded = load_device_profile(path)
|
|
|
|
assert loaded == profile
|
|
assert json.loads(path.read_text(encoding="utf-8"))["android_id"] == profile.android_id
|
|
|
|
|
|
def test_apply_cloud_identity_updates_only_server_fields():
|
|
profile = DeviceProfileGenerator(seed=9012).new_profile()
|
|
old_android_id = profile.android_id
|
|
old_o_did = profile.o_did
|
|
old_rdid = profile.rdid
|
|
|
|
profile.apply_cloud_identity(
|
|
did="ANDROID_e8dfd2f16b618053",
|
|
cdid_tag=2,
|
|
egid="DFP68CA12B5D3C714E4439D5E255B197DA809D63CB77139F1420A763F53FE718",
|
|
)
|
|
|
|
assert profile.did == "ANDROID_e8dfd2f16b618053"
|
|
assert profile.cdid_tag == 2
|
|
assert profile.egid == "DFP68CA12B5D3C714E4439D5E255B197DA809D63CB77139F1420A763F53FE718"
|
|
assert profile.android_id == old_android_id
|
|
assert profile.o_did == old_o_did
|
|
assert profile.rdid == old_rdid
|
|
|
|
|
|
def test_env_export_contains_expected_identity_keys():
|
|
profile = DeviceProfileGenerator(seed=3456).new_profile()
|
|
env_text = profile.to_env()
|
|
|
|
assert f"KS_ANDROID_ID={profile.android_id}" in env_text
|
|
assert f"KS_DID={profile.did}" in env_text
|
|
assert f"KS_ODID={profile.o_did}" in env_text
|
|
assert f"KS_RDID={profile.rdid}" in env_text
|
|
assert f"KS_LOCAL_DID={profile.local_did}" in env_text
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"field,value",
|
|
[
|
|
("android_id", "XYZ"),
|
|
("local_did", "BAD"),
|
|
("did", "BAD"),
|
|
("o_did", "BAD"),
|
|
("rdid", "BAD"),
|
|
],
|
|
)
|
|
def test_profile_validation_rejects_invalid_identity_fields(field, value):
|
|
data = DeviceProfileGenerator(seed=7890).new_profile().to_dict()
|
|
data[field] = value
|
|
|
|
with pytest.raises(ValueError):
|
|
DeviceProfile.from_dict(data)
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run:
|
|
|
|
```powershell
|
|
uv run pytest tests/test_device_profile.py -v
|
|
```
|
|
|
|
Expected:
|
|
|
|
```text
|
|
ModuleNotFoundError: No module named 'core.device_profile'
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Implement core device profile module
|
|
|
|
**Files:**
|
|
- Create: `core/device_profile.py`
|
|
- Modify: `core/__init__.py`
|
|
- Test: `tests/test_device_profile.py`
|
|
|
|
- [ ] **Step 1: Create `core/device_profile.py`**
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import random
|
|
import re
|
|
import time
|
|
import uuid
|
|
from dataclasses import asdict, dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
ANDROID_ID_RE = re.compile(r"^[0-9a-f]{16}$")
|
|
ANDROID_PREFIX_RE = re.compile(r"^ANDROID_[0-9a-f]{16}$")
|
|
EGID_RE = re.compile(r"^$|^DFP[0-9A-F]{61}$")
|
|
|
|
|
|
@dataclass
|
|
class DeviceProfile:
|
|
android_id: str
|
|
local_did: str
|
|
did: str
|
|
o_did: str
|
|
rdid: str
|
|
g_rdi2: str
|
|
cdid_tag: int = 0
|
|
egid: str = ""
|
|
install_time_ms: int = 0
|
|
cold_launch_time_ms: int = 0
|
|
sid: str = ""
|
|
|
|
def __post_init__(self) -> None:
|
|
self.validate()
|
|
|
|
def validate(self) -> None:
|
|
if not ANDROID_ID_RE.fullmatch(self.android_id):
|
|
raise ValueError(f"invalid android_id: {self.android_id!r}")
|
|
for name in ("local_did", "did", "o_did", "rdid"):
|
|
value = getattr(self, name)
|
|
if not ANDROID_PREFIX_RE.fullmatch(value):
|
|
raise ValueError(f"invalid {name}: {value!r}")
|
|
if self.o_did != f"ANDROID_{self.android_id}":
|
|
raise ValueError("o_did must equal ANDROID_<android_id>")
|
|
expected_rdid = hashlib.md5(self.g_rdi2.encode("utf-8")).hexdigest()[16:32]
|
|
if self.rdid != f"ANDROID_{expected_rdid}":
|
|
raise ValueError("rdid must equal ANDROID_<md5(g_rdi2)[16:32]>")
|
|
if not isinstance(self.cdid_tag, int) or self.cdid_tag < 0:
|
|
raise ValueError(f"invalid cdid_tag: {self.cdid_tag!r}")
|
|
if not EGID_RE.fullmatch(self.egid):
|
|
raise ValueError(f"invalid egid: {self.egid!r}")
|
|
|
|
def apply_cloud_identity(self, did: str, cdid_tag: int, egid: str = "") -> None:
|
|
self.did = did
|
|
self.cdid_tag = cdid_tag
|
|
if egid:
|
|
self.egid = egid
|
|
self.validate()
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict[str, Any]) -> "DeviceProfile":
|
|
return cls(
|
|
android_id=str(data["android_id"]),
|
|
local_did=str(data["local_did"]),
|
|
did=str(data["did"]),
|
|
o_did=str(data["o_did"]),
|
|
rdid=str(data["rdid"]),
|
|
g_rdi2=str(data["g_rdi2"]),
|
|
cdid_tag=int(data.get("cdid_tag", 0)),
|
|
egid=str(data.get("egid", "")),
|
|
install_time_ms=int(data.get("install_time_ms", 0)),
|
|
cold_launch_time_ms=int(data.get("cold_launch_time_ms", 0)),
|
|
sid=str(data.get("sid", "")),
|
|
)
|
|
|
|
def to_env(self) -> str:
|
|
lines = [
|
|
f"KS_ANDROID_ID={self.android_id}",
|
|
f"KS_DID={self.did}",
|
|
f"KS_LOCAL_DID={self.local_did}",
|
|
f"KS_ODID={self.o_did}",
|
|
f"KS_RDID={self.rdid}",
|
|
f"KS_GRDI2={self.g_rdi2}",
|
|
f"KS_CDID_TAG={self.cdid_tag}",
|
|
f"KS_EGID={self.egid}",
|
|
f"KS_INSTALL_TIME_MS={self.install_time_ms}",
|
|
f"KS_COLD_LAUNCH_TIME_MS={self.cold_launch_time_ms}",
|
|
f"KS_SID={self.sid}",
|
|
]
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
class DeviceProfileGenerator:
|
|
def __init__(self, seed: int | None = None) -> None:
|
|
self._random = random.Random(seed)
|
|
|
|
def new_profile(self) -> DeviceProfile:
|
|
now_ms = int(time.time() * 1000)
|
|
android_id = self._hex16()
|
|
local_did = f"ANDROID_{self._hex16()}"
|
|
g_rdi2 = self._g_rdi2()
|
|
rdid_suffix = hashlib.md5(g_rdi2.encode("utf-8")).hexdigest()[16:32]
|
|
return DeviceProfile(
|
|
android_id=android_id,
|
|
local_did=local_did,
|
|
did=local_did,
|
|
o_did=f"ANDROID_{android_id}",
|
|
rdid=f"ANDROID_{rdid_suffix}",
|
|
g_rdi2=g_rdi2,
|
|
install_time_ms=now_ms - self._random.randint(10_000, 600_000),
|
|
cold_launch_time_ms=now_ms,
|
|
sid=str(uuid.UUID(int=self._random.getrandbits(128))),
|
|
)
|
|
|
|
def _hex16(self) -> str:
|
|
return f"{self._random.getrandbits(64):016x}"
|
|
|
|
def _g_rdi2(self) -> str:
|
|
parts = []
|
|
for _ in range(5):
|
|
left = self._random.choice((7, 8, 9)) * 100_000_000 + self._random.randint(0, 999_999)
|
|
right = self._random.choice((4741, 8641))
|
|
parts.append(f"{left}::{right}")
|
|
return "|".join(parts)
|
|
|
|
|
|
def save_device_profile(profile: DeviceProfile, path: str | Path) -> None:
|
|
target = Path(path)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(
|
|
json.dumps(profile.to_dict(), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def load_device_profile(path: str | Path) -> DeviceProfile:
|
|
source = Path(path)
|
|
try:
|
|
data = json.loads(source.read_text(encoding="utf-8"))
|
|
except Exception as exc:
|
|
raise ValueError(f"failed to load device profile: {source}") from exc
|
|
if not isinstance(data, dict):
|
|
raise ValueError(f"device profile must be a JSON object: {source}")
|
|
return DeviceProfile.from_dict(data)
|
|
```
|
|
|
|
- [ ] **Step 2: Export from `core/__init__.py`**
|
|
|
|
```python
|
|
from .device_profile import (
|
|
DeviceProfile,
|
|
DeviceProfileGenerator,
|
|
load_device_profile,
|
|
save_device_profile,
|
|
)
|
|
|
|
__all__ = [
|
|
"DeviceProfile",
|
|
"DeviceProfileGenerator",
|
|
"load_device_profile",
|
|
"save_device_profile",
|
|
]
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests**
|
|
|
|
Run:
|
|
|
|
```powershell
|
|
uv run pytest tests/test_device_profile.py -v
|
|
```
|
|
|
|
Expected:
|
|
|
|
```text
|
|
8 passed
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Add CLI for generating profiles
|
|
|
|
**Files:**
|
|
- Create: `tools/new_device.py`
|
|
- Test with smoke commands.
|
|
|
|
- [ ] **Step 1: Create `tools/new_device.py`**
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from pathlib import Path
|
|
|
|
from core.device_profile import DeviceProfileGenerator, save_device_profile
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description="Generate local Kuaishou device profiles")
|
|
parser.add_argument("--count", type=int, default=1)
|
|
parser.add_argument("--out-dir", default="out/devices")
|
|
parser.add_argument("--prefix", default="device")
|
|
parser.add_argument("--seed", type=int, default=None)
|
|
parser.add_argument("--env", action="store_true", help="also write .env files")
|
|
parser.add_argument("--force", action="store_true", help="overwrite existing files")
|
|
return parser
|
|
|
|
|
|
def main() -> int:
|
|
args = build_parser().parse_args()
|
|
if args.count < 1:
|
|
raise SystemExit("--count must be >= 1")
|
|
|
|
out_dir = Path(args.out_dir)
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
generator = DeviceProfileGenerator(seed=args.seed)
|
|
|
|
for index in range(1, args.count + 1):
|
|
profile = generator.new_profile()
|
|
stem = f"{args.prefix}_{index:03d}"
|
|
json_path = out_dir / f"{stem}.json"
|
|
env_path = out_dir / f"{stem}.env"
|
|
|
|
if not args.force and json_path.exists():
|
|
raise SystemExit(f"refusing to overwrite existing file: {json_path}")
|
|
if args.env and not args.force and env_path.exists():
|
|
raise SystemExit(f"refusing to overwrite existing file: {env_path}")
|
|
|
|
save_device_profile(profile, json_path)
|
|
if args.env:
|
|
env_path.write_text(profile.to_env(), encoding="utf-8")
|
|
|
|
print(f"[OK] {json_path} did={profile.did} odid={profile.o_did} rdid={profile.rdid}")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|
|
```
|
|
|
|
- [ ] **Step 2: Run CLI smoke test**
|
|
|
|
Run:
|
|
|
|
```powershell
|
|
uv run python tools/new_device.py --count 2 --out-dir out/devices_test --seed 1 --env --force
|
|
```
|
|
|
|
Expected:
|
|
|
|
```text
|
|
[OK] out\devices_test\device_001.json ...
|
|
[OK] out\devices_test\device_002.json ...
|
|
```
|
|
|
|
- [ ] **Step 3: Compile check**
|
|
|
|
Run:
|
|
|
|
```powershell
|
|
uv run python -m compileall core tools tests
|
|
```
|
|
|
|
Expected:
|
|
|
|
```text
|
|
Listing 'core'...
|
|
Listing 'tools'...
|
|
Listing 'tests'...
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Final verification
|
|
|
|
**Files:**
|
|
- Verify: `core/device_profile.py`
|
|
- Verify: `tools/new_device.py`
|
|
- Verify: `tests/test_device_profile.py`
|
|
|
|
- [ ] **Step 1: Run focused tests**
|
|
|
|
Run:
|
|
|
|
```powershell
|
|
uv run pytest tests/test_device_profile.py -v
|
|
```
|
|
|
|
Expected:
|
|
|
|
```text
|
|
8 passed
|
|
```
|
|
|
|
- [ ] **Step 2: Run existing test suite**
|
|
|
|
Run:
|
|
|
|
```powershell
|
|
uv run pytest tests -v
|
|
```
|
|
|
|
Expected:
|
|
|
|
```text
|
|
all tests passed
|
|
```
|
|
|
|
- [ ] **Step 3: Generate sample profile**
|
|
|
|
Run:
|
|
|
|
```powershell
|
|
uv run python tools/new_device.py --count 1 --out-dir out/devices_sample --seed 20260711 --env --force
|
|
```
|
|
|
|
Expected:
|
|
|
|
```text
|
|
[OK] out\devices_sample\device_001.json did=ANDROID_...
|
|
```
|
|
|
|
Generated files:
|
|
|
|
```text
|
|
out/devices_sample/device_001.json
|
|
out/devices_sample/device_001.env
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
- Spec coverage: local generation, persistence, env export, cloud override, and validation are covered.
|
|
- Placeholder scan: no `TBD`, `TODO`, or unspecified code steps remain.
|
|
- Type consistency: `DeviceProfile`, `DeviceProfileGenerator`, `save_device_profile`, and `load_device_profile` names match across all tasks.
|
|
- Scope check: online DFP bootstrap remains out of phase 1 by design.
|