feat: SDK 更新 | agent/hook/hawk/soul、Android 多 Fragment、Hook 模块与连接路由、开发文档

Made-with: Cursor
This commit is contained in:
卡若
2026-03-13 12:04:19 +08:00
parent e0a394eb0a
commit 87cc9050cd
86 changed files with 6133 additions and 612 deletions

View File

@@ -196,9 +196,22 @@ class WorkPhoneAgent:
self.ws = None
if self.running:
self.reconnect_attempts += 1
# 断网时先尝试恢复网络(通过 Hawk 模块,仅合法操作:开 WiFi、打开设置、连接已保存网络
if self.d:
try:
from hawk import try_reconnect_network
nr = try_reconnect_network(self.d)
if nr.get("success"):
logger.info(f"🌐 网络已恢复: {nr.get('message', '')}")
else:
logger.info(f"🌐 网络恢复: {nr.get('message', '')}")
except ImportError:
pass
except Exception as e:
logger.debug(f"网络恢复尝试异常: {e}")
# 指数退避重连
delay = self._get_reconnect_delay()
self.reconnect_attempts += 1
logger.info(f"{delay:.1f}秒后第{self.reconnect_attempts}次重连...")
await asyncio.sleep(delay)
@@ -623,6 +636,10 @@ class WorkPhoneAgent:
result = executor.execute_xhs_task(task)
elif "闲鱼" in task:
result = executor.execute_xianyu_task(task)
elif "Soul" in task or "soul" in task.lower() or "灵魂" in task:
result = executor.execute_soul_task(task)
elif any(k in task for k in ("连接网络", "恢复网络", "打开WiFi", "打开网络", "断网", "连不上网", "上网")):
result = executor.execute_network_reconnect()
else:
result = executor.execute_command(task)
if result is None:
@@ -698,6 +715,7 @@ class WorkPhoneAgent:
"u2", "screenshot", "click", "input", "swipe",
"ui_tree", "app_control", "skill_execute",
"skill_wechat", "skill_douyin", "skill_xhs", "skill_xianyu",
"skill_network_reconnect", "hawk",
"event", "device_request"
]

View File

@@ -0,0 +1,41 @@
# Agent 与 Hawk 边界与协同说明
## 代码隔离
| 板块 | 目录 | 职责 |
|------|------|------|
| **Agent** | `agent.py`, `skill_executor.py`, `skills/*` | 业务逻辑、技能执行、WebSocket 连接与重连策略、与服务器通信 |
| **Hawk** | `hawk/*` | 仅负责网络层:检测网络、开/关 WiFi、打开系统网络设置、连接已保存网络、重试等 |
- Agent 不实现 Hawk 的细节(如 `svc wifi`、打开设置页 UIHawk 不依赖 Agent 或技能总线。
- 协同仅通过**接口**Agent 调用 `hawk.try_reconnect_network(device)``hawk.is_network_available(device)`
## 协同方式
1. **断网时自动恢复网络**
Agent 在 WebSocket 断开且准备重连前,若存在设备句柄 `self.d`,会调用 `try_reconnect_network(self.d)`,由 Hawk 在设备上执行合法网络恢复(开 WiFi、打开设置、连接已保存网络等再继续指数退避重连。
2. **自然语言触发**
用户说「连接网络」「恢复网络」「打开 WiFi」「断网了」等时Agent 通过 SkillExecutor 调用 `network_reconnect` 技能,该技能内部委托 Hawk 执行上述合法操作。
## 网络恢复行为(仅合法)
Hawk 只做以下操作,**不包含且永不包含**破解密码、未授权访问等任何违规行为:
- 通过系统 shell 开启 WiFi`svc wifi enable`
- 打开系统「设置」→「网络/WLAN/WiFi」页面
- 在设置页中点击「已保存」「已知网络」等,尝试连接**已保存**网络
- 若仍无网络,返回说明「请在本机选择已保存网络或手动输入密码连接」
用户若需连接新网络或输入密码,仅在系统设置界面内**手动**完成。
## 依赖关系
```
Agent (agent.py, skills/)
└─ 可选依赖 Hawkfrom hawk import try_reconnect_network
Hawk (hawk/)
└─ 仅依赖 uiautomator2 设备对象,无 Agent 依赖
```
Hawk 缺失时Agent 仍可运行;断网时仅跳过「尝试恢复网络」步骤,直接进入重连退避。

View File

@@ -0,0 +1,15 @@
"""
Hawk - 设备端网络与连接恢复模块(与 Agent 代码隔离、协同)
职责边界:
- Agent业务逻辑、技能执行、WebSocket 连接与重连策略。
- Hawk仅负责「网络层」可做的合法操作检测网络状态、打开系统网络设置、
开启/关闭 WiFi、触发连接已保存网络、重试等。不涉及任何未授权访问或破解行为。
协同方式Agent 在断网/重连前可调用 Hawk.try_reconnect_network(device)
由 Hawk 在设备上执行合法网络恢复操作,再由 Agent 继续重连。
"""
from hawk.network import try_reconnect_network, is_network_available
__all__ = ["try_reconnect_network", "is_network_available"]

109
sdk/agent/hawk/network.py Normal file
View File

@@ -0,0 +1,109 @@
"""
Hawk 网络层 - 仅做合法网络恢复:开 WiFi、打开设置、连接已保存网络、重试
不包含且永不包含:破解密码、未授权访问、绕过认证等任何违规操作。
若设备上无可用已保存网络,仅打开系统 WiFi 设置供用户手动连接或输入密码。
"""
import logging
import time
from typing import Dict, Any
logger = logging.getLogger(__name__)
def _shell_out(device, cmd: str) -> str:
"""执行 shell 并返回输出字符串(兼容 u2 的 AdbDevice"""
try:
r = device.shell(cmd)
return (getattr(r, "output", None) or str(r) or "").strip()
except Exception:
return ""
def is_network_available(device) -> bool:
"""检测当前是否有网络ping 或 shell 查连接状态)。"""
try:
out = _shell_out(device, "ping -c 1 -W 2 8.8.8.8 2>/dev/null && echo ok || echo fail")
if out and "ok" in out:
return True
out = _shell_out(device, "dumpsys wifi | grep -i 'mWifiInfo' | head -1")
return "SSID:" in out
except Exception as e:
logger.debug(f"is_network_available: {e}")
return False
def try_reconnect_network(device, max_steps: int = 5) -> Dict[str, Any]:
"""
在设备上尝试恢复网络(仅合法操作):
1. 开 WiFi若被关
2. 打开系统「网络/WiFi」设置页便于连接已保存网络或用户手动输入
3. 可选:通过 UI 点击「已保存网络」进行连接(不输入新密码、不破解)
4. 等待后再次检测网络
device: uiautomator2 设备对象(可为 None此时只返回建议
"""
result = {"success": False, "message": "", "steps": []}
if device is None:
result["message"] = "无设备句柄,无法执行网络恢复"
return result
try:
# Step 1: 尝试通过 shell 开启 WiFi合法系统 API
try:
device.shell("svc wifi enable 2>/dev/null")
result["steps"].append("svc wifi enable")
time.sleep(2)
except Exception as e:
result["steps"].append(f"svc wifi enable 失败: {e}")
if is_network_available(device):
result["success"] = True
result["message"] = "WiFi 已开启且检测到网络"
return result
# Step 2: 打开系统设置中的网络/WiFi 页面,供用户连接或输入密码
try:
device.app_start("com.android.settings")
time.sleep(1.5)
# 尝试进入 WLAN / 网络 / WiFi 设置
for text in ["WLAN", "网络", "WiFi", "WiFi", "互联网"]:
try:
if device(text=text).wait(timeout=2):
device(text=text).click()
result["steps"].append(f"打开设置并点击 {text}")
break
except Exception:
continue
time.sleep(2)
except Exception as e:
result["steps"].append(f"打开设置失败: {e}")
# Step 3: 若有「已保存」或「已知网络」列表,可点击第一个尝试连接(仅限已保存,不涉及破解)
try:
for hint in ["已保存", "已知网络", "已连接", "连接"]:
if device(textContains=hint).wait(timeout=1.5):
device(textContains=hint).click()
result["steps"].append(f"点击包含「{hint}」的项")
time.sleep(2)
break
except Exception:
pass
# Step 4: 再次检测
time.sleep(2)
if is_network_available(device):
result["success"] = True
result["message"] = "网络已恢复(已连接或已打开设置)"
else:
result["message"] = "已打开 WiFi 设置,请在本机选择已保存网络或手动输入密码连接"
result["steps"].append("建议用户在设置页手动连接")
except Exception as e:
logger.exception("try_reconnect_network 异常")
result["message"] = str(e)
result["steps"].append(f"异常: {e}")
return result

54
sdk/agent/hook/README.md Normal file
View File

@@ -0,0 +1,54 @@
# 微信 Hook 设备端脚本Root 设备)
本目录用于 Root 安卓设备如红米11部署微信 Hook 脚本。
## 文件说明
- `wechat_hook_v1.js`:微信 Hook 初版脚本(含 RPC + 消息事件上报骨架)
- `setup_redmi11.sh`红米11 一键初始化脚本
## 快速执行
```bash
cd sdk/agent/hook
bash setup_redmi11.sh <device_id>
```
## 与后端接口联动
1. 上传脚本
```bash
curl -s -X POST "http://localhost:8899/api/v3/scripts?module_id=wechat_hook_v1&version=1.0.0" \
-F "file=@wechat_hook_v1.js" | python3 -m json.tool
```
2. 部署脚本到设备
```bash
curl -s -X POST "http://localhost:8899/api/v3/scripts/wechat_hook_v1_1.0.0/deploy" \
-H "Content-Type: application/json" \
-d '{"device_ids":["<device_id>"],"auto_reload":true}' | python3 -m json.tool
```
3. 检查设备模块状态
```bash
curl -s "http://localhost:8899/api/v3/devices/<device_id>/modules" | python3 -m json.tool
```
4. 查看 Hook 事件流WebSocket
`ws://localhost:8899/api/v3/hook/events/stream`
## 说明
当前 `wechat_hook_v1.js` 已具备:
- RPC通信骨架
- 进程信息读取
- SQLite消息表写入监听事件上报
下一步按微信版本补齐:
- `sendMessage` 真正发消息 Hook 点
- 联系人读取 DB 结构适配
- 好友请求/朋友圈/群管理扩展

36
sdk/agent/hook/setup_redmi11.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
# 红米11 Root 设备 Hook 环境初始化脚本
# 用法: bash setup_redmi11.sh <device_id>
set -euo pipefail
DEVICE_ID="${1:-}"
if [[ -z "$DEVICE_ID" ]]; then
echo "用法: bash setup_redmi11.sh <device_id>"
exit 1
fi
echo "== 1) 基础连通检查 =="
adb -s "$DEVICE_ID" get-state
adb -s "$DEVICE_ID" shell getprop ro.product.model
adb -s "$DEVICE_ID" shell getprop ro.build.version.release
echo "== 2) Root 权限检查 =="
adb -s "$DEVICE_ID" shell "id -u; which su || true"
echo "== 3) push Hook 脚本 =="
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
adb -s "$DEVICE_ID" push "$SCRIPT_DIR/wechat_hook_v1.js" /data/local/tmp/wechat_hook_v1.js
echo "== 4) frida-server 检查 =="
adb -s "$DEVICE_ID" shell "frida-server --version || true"
echo "== 5) 启动微信并验证包名 =="
adb -s "$DEVICE_ID" shell "monkey -p com.tencent.mm -c android.intent.category.LAUNCHER 1" >/dev/null 2>&1 || true
sleep 2
adb -s "$DEVICE_ID" shell "dumpsys window | grep -E 'mCurrentFocus|mFocusedApp' | head -2"
echo "== 6) SDK Hook 探测接口建议调用 =="
echo "curl -s http://localhost:8899/api/v3/devices/$DEVICE_ID/modules | python3 -m json.tool"
echo "完成红米11 Hook 基础环境就绪(脚本已推送)"

View File

@@ -0,0 +1,101 @@
// wechat_hook_v1.js
'use strict';
const CONFIG = {
WECHAT_PACKAGE: 'com.tencent.mm',
LOG_LEVEL: 'info',
};
function log(level, tag, message, extra) {
send({
type: 'log',
level: level,
tag: tag,
message: String(message || ''),
extra: extra || {},
timestamp: Date.now(),
});
}
function safeToString(v) {
if (v === null || v === undefined) return '';
try {
return v.toString();
} catch (_) {
return '';
}
}
rpc.exports = {
ping: function () {
return 'pong from wechat_hook_v1';
},
getProcessInfo: function () {
return {
pid: Process.id,
arch: Process.arch,
moduleCount: Process.enumerateModules().length,
};
},
// 当前版本先提供统一入口,实际发送逻辑在后续版本按微信版本细化
sendMessage: function (params) {
const toId = (params && params.to_id) || '';
const content = (params && params.content) || '';
log('info', 'rpc', 'sendMessage called', { to_id: toId, content_len: content.length });
return {
success: false,
error: 'sendMessage 需按当前微信版本补齐具体Hook点已完成RPC通道与事件上报',
to_id: toId,
};
},
getContacts: function (params) {
const limit = (params && params.limit) || 100;
return {
success: true,
data: [],
limit: limit,
note: '联系人读取建议通过DB Hook实现EnMicroMsg.db',
};
},
};
Java.perform(function () {
log('info', 'init', 'wechat_hook_v1 loaded');
try {
const SQLiteDatabase = Java.use('android.database.sqlite.SQLiteDatabase');
SQLiteDatabase.insert.overload(
'java.lang.String',
'java.lang.String',
'android.content.ContentValues'
).implementation = function (table, nullColumnHack, values) {
const result = this.insert(table, nullColumnHack, values);
try {
const tableName = safeToString(table);
if (tableName === 'message' || tableName === 'rconversation') {
const talker = safeToString(values.getAsString(Java.use('java.lang.String').$new('talker')));
const content = safeToString(values.getAsString(Java.use('java.lang.String').$new('content')));
if (talker || content) {
send({
type: 'hook_event',
event_type: 'message_received',
platform: 'wechat',
payload: {
from_id: talker,
content: content,
},
timestamp: new Date().toISOString(),
});
}
}
} catch (_) {}
return result;
};
log('info', 'hook', 'SQLite message hook enabled');
} catch (e) {
log('warn', 'hook', 'SQLite hook unavailable', { error: String(e) });
}
});

View File

@@ -18,7 +18,8 @@ echo "📦 打包 Agent 代码..."
mkdir -p "$TEMP_DIR/agent/skills/wechat" \
"$TEMP_DIR/agent/skills/douyin" \
"$TEMP_DIR/agent/skills/xhs" \
"$TEMP_DIR/agent/skills/xianyu"
"$TEMP_DIR/agent/skills/xianyu" \
"$TEMP_DIR/agent/hawk"
# 复制核心文件
for f in agent.py skill_executor.py skill_bus.py error_handler.py vision_helper.py voice_agent.py requirements.txt config.json.example; do
@@ -33,6 +34,18 @@ cp "$SCRIPT_DIR/skills/base.py" "$TEMP_DIR/agent/skills/"
cp "$SCRIPT_DIR/skills/voice_control.py" "$TEMP_DIR/agent/skills/"
cp "$SCRIPT_DIR/skills/app_manager.py" "$TEMP_DIR/agent/skills/"
cp "$SCRIPT_DIR/skills/search.py" "$TEMP_DIR/agent/skills/"
[ -f "$SCRIPT_DIR/skills/network_reconnect.py" ] && cp "$SCRIPT_DIR/skills/network_reconnect.py" "$TEMP_DIR/agent/skills/"
# Hawk 模块(网络层,与 Agent 隔离)
for f in __init__.py network.py; do
[ -f "$SCRIPT_DIR/hawk/$f" ] && cp "$SCRIPT_DIR/hawk/$f" "$TEMP_DIR/agent/hawk/"
done
# Hook 模块Frida Hook 通道)
mkdir -p "$TEMP_DIR/agent/hook/scripts"
for f in README.md setup_redmi11.sh wechat_hook_v1.js; do
[ -f "$SCRIPT_DIR/hook/$f" ] && cp "$SCRIPT_DIR/hook/$f" "$TEMP_DIR/agent/hook/"
done
for sub in wechat douyin xhs xianyu; do
if [ -d "$SCRIPT_DIR/skills/$sub" ]; then

View File

@@ -116,6 +116,8 @@ class SkillExecutor:
current_skill = self.skills.get("xhs")
elif package == "com.taobao.idlefish":
current_skill = self.skills.get("xianyu")
elif package == "cn.soulapp.android":
current_skill = self.skills.get("soul")
else:
current_skill = None
self.skill_bus.append(
@@ -268,3 +270,20 @@ class SkillExecutor:
if not xianyu_skill:
xianyu_skill = SKILL_REGISTRY["xianyu"](self.device)
return self.execute_command(task)
def execute_soul_task(self, task: str) -> Dict[str, Any]:
"""执行 Soul 任务"""
soul_skill = self.skills.get("soul")
if not soul_skill:
soul_skill = SKILL_REGISTRY["soul"](self.device)
return self.execute_command(task)
def execute_network_reconnect(self) -> Dict[str, Any]:
"""执行网络恢复(委托 Hawk开 WiFi、打开设置、连接已保存网络等合法操作"""
net_skill = self.skills.get("network_reconnect")
if not net_skill:
try:
net_skill = SKILL_REGISTRY["network_reconnect"](self.device, self.skill_bus)
except KeyError:
return {"success": False, "error": "network_reconnect 技能未注册"}
return net_skill.try_reconnect()

View File

@@ -15,17 +15,21 @@ def _build_skill_registry():
from skills.douyin.skill import DouyinSkill
from skills.xhs.skill import XhsSkill
from skills.xianyu.skill import XianyuSkill
from skills.soul.skill import SoulSkill
from skills.voice_control import VoiceControlSkill
from skills.app_manager import AppManagerSkill
from skills.search import SearchSkill
from skills.network_reconnect import NetworkReconnectSkill
return {
"wechat": WechatSkill,
"douyin": DouyinSkill,
"xhs": XhsSkill,
"xianyu": XianyuSkill,
"soul": SoulSkill,
"voice_control": VoiceControlSkill,
"app_manager": AppManagerSkill,
"search": SearchSkill,
"network_reconnect": NetworkReconnectSkill,
}
@@ -57,6 +61,9 @@ def get_skill(name: str):
elif name == "xianyu":
from skills.xianyu.skill import XianyuSkill
return XianyuSkill
elif name == "soul":
from skills.soul.skill import SoulSkill
return SoulSkill
else:
raise ImportError(f"未知技能: {name}")
@@ -65,5 +72,6 @@ def get_skill(name: str):
from skills.voice_control import VoiceControlSkill
from skills.app_manager import AppManagerSkill
from skills.search import SearchSkill
from skills.network_reconnect import NetworkReconnectSkill
_ensure_registry()

View File

@@ -0,0 +1,60 @@
"""
网络连接恢复技能 - 断网时尝试恢复上网(仅合法操作)
通过 Hawk 模块执行:开 WiFi、打开系统网络设置、连接已保存网络等。
不涉及且永不涉及:破解密码、未授权访问等任何违规行为。
若设备无可用已保存网络,仅打开 WiFi 设置供用户手动连接或输入密码。
"""
import logging
import sys
import os
from typing import Dict, Any
# 兼容独立运行和包导入
_agent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _agent_dir not in sys.path:
sys.path.insert(0, _agent_dir)
try:
from skills.base import BaseSkill
except ImportError:
from ..base import BaseSkill
logger = logging.getLogger(__name__)
class NetworkReconnectSkill(BaseSkill):
"""网络连接恢复技能 - 与 Hawk 协同,仅做合法网络恢复"""
PACKAGE = ""
NAME = "网络恢复"
def try_reconnect(self) -> Dict[str, Any]:
"""
尝试恢复网络:委托 Hawk 执行开 WiFi、打开设置、连接已保存网络等合法操作。
若仍连不上,返回建议(如「请在设置中手动连接或输入密码」)。
"""
try:
from hawk import try_reconnect_network, is_network_available
if self.d is None:
return {"success": False, "message": "无设备,无法执行网络恢复", "steps": []}
if is_network_available(self.d):
return {"success": True, "message": "当前已有网络", "steps": ["检测到网络"]}
result = try_reconnect_network(self.d)
if self.bus:
self.bus.append(self.NAME, result.get("message", ""), data=result)
return result
except ImportError as e:
logger.warning("Hawk 模块未安装,无法执行网络恢复: %s", e)
return {
"success": False,
"message": "Hawk 模块不可用,请检查网络或手动连接 WiFi",
"steps": [],
}
except Exception as e:
logger.exception("网络恢复失败")
return {"success": False, "message": str(e), "steps": []}

View File

@@ -0,0 +1,4 @@
# Soul 技能模块 - 发瞬间、私聊、获取好友等
from .skill import SoulSkill
__all__ = ["SoulSkill"]

View File

@@ -0,0 +1,369 @@
"""
Soul 控制技能 - Agent 端实现
功能模块:
1. 瞬间管理 - 发布瞬间(文字/图片/视频)
2. 私聊管理 - 发送消息、获取消息列表
3. 好友/关注 - 获取联系人列表
安卓包名cn.soulapp.android
UI 文案与 resourceId 需根据真机 Soul 版本微调。
@author 卡若
@version 3.0.0
"""
import time
import logging
from typing import Dict, Any, List, Optional
import sys
import os
_agent_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if _agent_dir not in sys.path:
sys.path.insert(0, _agent_dir)
from skills.base import BaseSkill
logger = logging.getLogger(__name__)
class SoulSkill(BaseSkill):
"""
Soul 控制技能
支持的操作:
- post_moments: 发布瞬间(文字/图片/视频)
- comment_moments: 评论某条瞬间(如回复 123
- send_message: 发送私聊消息
- get_messages: 获取私聊消息列表
- get_contacts: 获取好友/关注列表
"""
PACKAGE = "cn.soulapp.android"
NAME = "Soul"
# Soul 常见 UI 文案(随版本可能变化,真机可调)
TEXT_PUBLISH = "发布"
TEXT_MOMENT = "瞬间"
TEXT_MESSAGE = "消息"
TEXT_ME = "我的"
TEXT_SEND = "发送"
TEXT_SEARCH = "搜索"
TEXT_VIDEO = "视频"
TEXT_PHOTO = "图片"
TEXT_TEXT = "文字"
# ========== 发瞬间 ==========
def post_moments(
self,
content: str,
images: Optional[List[str]] = None,
video_url: Optional[str] = None,
location: Optional[str] = None,
visible_list: Optional[List[str]] = None,
invisible_list: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""
发布 Soul 瞬间(文字/图片/视频)
Args:
content: 文案
images: 图片 URL 或本地路径列表(可选)
video_url: 视频 URL 或本地路径(可选)
location: 位置(可选)
visible_list / invisible_list: 可见/不可见名单可选Soul 若支持)
Returns:
执行结果
"""
try:
self.launch()
self.wait_for_app_ready(timeout=8)
self.sleep(1.5)
# 点击发布入口:常见为底部「+」或「发布」
published = False
for hint in ["+", "发布", "发瞬间", "写瞬间", "加号"]:
if self.click_text(hint) or self.click_contains(hint):
self.sleep(1)
published = True
break
if not published:
# 尝试点击屏幕中央偏下(常见发布按钮位置)
info = self.d.info
self.d.click(info["displayWidth"] // 2, info["displayHeight"] * 4 // 5)
self.sleep(1)
# 选择类型:优先视频 > 图片 > 文字
if video_url:
if self.click_text(self.TEXT_VIDEO) or self.click_contains("视频"):
self.sleep(1)
# TODO: 将 video_url 推到手机并选择文件
logger.warning("Soul post_moments: 视频选择需真机适配路径")
if images:
if self.click_text(self.TEXT_PHOTO) or self.click_contains("图片") or self.click_contains("相册"):
self.sleep(1)
# TODO: 将 images 推到手机并多选
logger.warning("Soul post_moments: 多图选择需真机适配路径")
# 输入文案
if content:
self.sleep(0.5)
self.input_text(content, clear=True)
self.sleep(0.3)
# 点击发布/发送
for send_btn in ["发布", "发送", "发瞬间", "完成", "下一步"]:
if self.click_text(send_btn) or self.click_contains(send_btn):
self.sleep(1.5)
break
logger.info("Soul 瞬间发布流程已执行")
return {
"success": True,
"message_id": f"soul_m_{int(time.time() * 1000)}",
"message": "发布流程已执行,请以真机实际界面为准核对",
}
except Exception as e:
logger.error(f"Soul 发布瞬间失败: {e}")
return {"success": False, "error": str(e)}
# ========== 评论瞬间 ==========
def comment_moments(
self,
user_id: str = "",
comment: str = "",
post_index: int = 0,
reply_to: Optional[str] = None,
) -> Dict[str, Any]:
"""
在 Soul 广场找一条瞬间并评论(如回复 123
Args:
user_id: 可选发瞬间的用户标识Soul 上可不传,按 post_index 选条数)
comment: 评论内容,如 "123"
post_index: 第几条瞬间0 表示第一条
reply_to: 可选,回复某人
Returns:
执行结果
"""
try:
self.launch()
self.wait_for_app_ready(timeout=8)
self.sleep(1.5)
# 进入广场/发现(首页往往是广场流)
for tab in ["广场", "发现", "首页", "推荐"]:
if self.click_text(tab) or self.click_contains(tab):
self.sleep(2)
break
else:
# 默认已在首页,稍等加载
self.sleep(2)
# 点第 post_index 条瞬间0=第一条):通过多次下滑再点,或直接点第一条
for _ in range(post_index):
self.swipe_up(scale=0.4)
self.sleep(0.8)
self.sleep(0.5)
# 点击第一条瞬间卡片进入详情(常见:点击卡片区域或「评论」入口)
info = self.d.info
self.d.click(info["displayWidth"] // 2, info["displayHeight"] // 3)
self.sleep(1.5)
# 点评论按钮
commented = False
for btn in ["评论", "写评论", "说点什么", "留言"]:
if self.click_text(btn) or self.click_contains(btn):
self.sleep(1)
commented = True
break
if not commented:
# 尝试点击屏幕下方评论图标区域
self.d.click(info["displayWidth"] // 2, info["displayHeight"] * 4 // 5)
self.sleep(1)
# 输入评论内容
if comment:
self.input_text(comment, clear=True)
self.sleep(0.3)
# 发送
for send_btn in ["发送", "发布", "评论", "完成"]:
if self.click_text(send_btn) or self.click_contains(send_btn):
self.sleep(1)
break
logger.info(f"Soul 评论瞬间已执行: {comment}")
return {
"success": True,
"message": f"已对第 {post_index + 1} 条瞬间评论: {comment}",
}
except Exception as e:
logger.error(f"Soul 评论瞬间失败: {e}")
return {"success": False, "error": str(e)}
# ========== 私聊 ==========
def send_message(
self,
to_id: str,
content: str,
msg_type: str = "text",
media_url: Optional[str] = None,
) -> Dict[str, Any]:
"""
发送 Soul 私聊消息
Args:
to_id: 对方昵称或 Soul ID
content: 消息内容
msg_type: text / image / video
media_url: 图片/视频 URL可选
Returns:
执行结果
"""
try:
self.launch()
self.wait_for_app_ready(timeout=8)
self.sleep(1)
# 进入消息
if not (self.click_text(self.TEXT_MESSAGE) or self.click_contains("消息")):
self.sleep(0.5)
self.click_text(self.TEXT_MESSAGE) or self.click_contains("消息")
self.sleep(1)
# 搜索或找到对方
if self.click_text(self.TEXT_SEARCH) or self.click_desc(self.TEXT_SEARCH):
self.sleep(0.5)
self.input_text(to_id, clear=True)
self.sleep(1.5)
if self.click_contains(to_id) or self.click_text(to_id):
self.sleep(1)
else:
return {"success": False, "error": f"未找到用户: {to_id}"}
# 输入并发送
self.input_text(content, clear=False)
self.sleep(0.3)
if self.click_text(self.TEXT_SEND) or self.click_contains("发送"):
self.sleep(0.5)
logger.info(f"Soul 私聊已发送: {to_id}")
return {
"success": True,
"message_id": f"soul_{int(time.time() * 1000)}",
}
except Exception as e:
logger.error(f"Soul 发送消息失败: {e}")
return {"success": False, "error": str(e)}
def get_messages(
self,
limit: int = 20,
conversation_id: Optional[str] = None,
) -> Dict[str, Any]:
"""
获取 Soul 私聊消息列表(当前会话或列表)
Args:
limit: 条数
conversation_id: 会话对象(对方昵称或 ID
Returns:
消息列表
"""
try:
self.launch()
self.wait_for_app_ready(timeout=8)
self.sleep(1)
self.click_text(self.TEXT_MESSAGE) or self.click_contains("消息")
self.sleep(1)
if conversation_id:
if self.click_text(self.TEXT_SEARCH) or self.click_desc(self.TEXT_SEARCH):
self.sleep(0.5)
self.input_text(conversation_id, clear=True)
self.sleep(1.5)
self.click_contains(conversation_id) or self.click_text(conversation_id)
self.sleep(1)
messages = []
ui_tree = self.d.dump_hierarchy()
import xml.etree.ElementTree as ET
try:
root = ET.fromstring(ui_tree)
for elem in root.iter():
text = elem.get("text", "")
if text and "发送" not in text and "输入" not in text:
messages.append({"text": text, "timestamp": int(time.time() * 1000)})
except Exception:
pass
return {
"success": True,
"messages": messages[:limit],
"count": len(messages[:limit]),
}
except Exception as e:
logger.error(f"Soul 获取消息失败: {e}")
return {"success": False, "error": str(e), "messages": []}
# ========== 好友/联系人 ==========
def get_contacts(self, limit: int = 200) -> Dict[str, Any]:
"""
获取 Soul 好友/关注列表(从消息列表或「我的」-「关注」解析)
Args:
limit: 数量上限
Returns:
联系人列表
"""
try:
self.launch()
self.wait_for_app_ready(timeout=8)
self.sleep(1)
# 从消息页获取会话列表作为「联系人」近似
self.click_text(self.TEXT_MESSAGE) or self.click_contains("消息")
self.sleep(1.5)
contacts = []
ui_tree = self.d.dump_hierarchy()
import xml.etree.ElementTree as ET
try:
root = ET.fromstring(ui_tree)
for elem in root.iter():
text = elem.get("text", "")
if text and len(text) > 0:
if "消息" not in text and "搜索" not in text and "发送" not in text:
contacts.append({"name": text, "user_id": text})
except Exception:
pass
# 去重并限制数量
seen = set()
unique = []
for c in contacts:
k = c.get("name") or c.get("user_id", "")
if k and k not in seen:
seen.add(k)
unique.append(c)
if len(unique) >= limit:
break
return {
"success": True,
"contacts": unique,
"count": len(unique),
}
except Exception as e:
logger.error(f"Soul 获取联系人失败: {e}")
return {"success": False, "error": str(e), "contacts": []}

View File

@@ -5,7 +5,7 @@ connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER)
connection.project.dir=
eclipse.preferences.version=1
gradle.user.home=
java.home=/Library/Java/JavaVirtualMachines/temurin-25.jdk/Contents/Home
java.home=/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home
jvm.arguments=
offline.mode=false
override.workspace.settings=true

View File

@@ -45,7 +45,7 @@ cd sdk/android-app
1. 打开APP
2. 填写**服务器地址**: `ws://sdk.quwanzhi.com:8899/ws/device`
3. 填写**项目ID**: 从管理后台获取
4. 设备ID自动生成(可自定义)
4. 设备ID默认自动生成为奥创兼容格式:`md5(android_id)`(可自定义覆盖
5. 点击"连接服务器"
### 2. 状态说明
@@ -92,7 +92,7 @@ ws://your-server:8899/ws/device/{device_id}
}
```
**心跳消息(30秒:**
**心跳消息(默认30秒,可远程配置:**
```json
{
"type": "heartbeat",
@@ -101,6 +101,14 @@ ws://your-server:8899/ws/device/{device_id}
}
```
**心跳配置(服务器下发):**
```json
{
"type": "config",
"heartbeat_interval_seconds": 10
}
```
**执行命令(服务器发送):**
```json
{

View File

@@ -11,8 +11,8 @@ android {
applicationId "com.workphone.agent"
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0.0"
versionCode 2
versionName "2.0.0"
}
buildTypes {

View File

@@ -43,6 +43,8 @@ class AgentService : Service() {
var isRunning = false
private set
var isSocketConnected = false
private set
}
private var webSocketClient: WebSocketClient? = null
@@ -55,9 +57,13 @@ class AgentService : Service() {
// 心跳定时器
private var heartbeatJob: Job? = null
private var reconnectJob: Job? = null
// 性能监控定时器
private var performanceMonitorJob: Job? = null
private var heartbeatIntervalMs: Long = 30_000L
private var lastPongAtMs: Long = 0L
private var manualStop: Boolean = false
override fun onBind(intent: Intent?): IBinder? = null
@@ -108,12 +114,19 @@ class AgentService : Service() {
ACTION_START -> {
serverUrl = intent.getStringExtra(EXTRA_SERVER_URL) ?: ""
projectId = intent.getStringExtra(EXTRA_PROJECT_ID) ?: ""
deviceId = intent.getStringExtra(EXTRA_DEVICE_ID) ?: ""
deviceId = intent.getStringExtra(EXTRA_DEVICE_ID)?.trim().orEmpty()
if (deviceId.isEmpty()) {
deviceId = DeviceIdHelper.getOrCreateDeviceId(this)
}
manualStop = false
isRunning = true
isSocketConnected = false
startForeground(NOTIFICATION_ID, createNotification("正在连接..."))
connectWebSocket()
}
ACTION_STOP -> {
manualStop = true
disconnect()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
@@ -126,10 +139,10 @@ class AgentService : Service() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"工作手机Agent",
"工作",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "保持Agent服务运行"
description = "保持后台服务运行"
}
val notificationManager = getSystemService(NotificationManager::class.java)
@@ -146,7 +159,7 @@ class AgentService : Service() {
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("工作手机Agent")
.setContentTitle("工作")
.setContentText(status)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentIntent(pendingIntent)
@@ -169,7 +182,8 @@ class AgentService : Service() {
webSocketClient = object : WebSocketClient(uri) {
override fun onOpen(handshakedata: ServerHandshake?) {
Log.d(TAG, "WebSocket连接成功")
isRunning = true
isSocketConnected = true
lastPongAtMs = System.currentTimeMillis()
updateNotification("已连接 - 项目: $projectId")
// 发送注册消息
@@ -189,18 +203,26 @@ class AgentService : Service() {
override fun onClose(code: Int, reason: String?, remote: Boolean) {
Log.d(TAG, "WebSocket关闭: $reason")
isRunning = false
updateNotification("已断开")
isSocketConnected = false
if (isRunning) {
updateNotification("重连中...")
} else {
updateNotification("已断开")
}
// 自动重连
if (remote) {
// 自动重连(非手动停止)
if (!manualStop) {
scheduleReconnect()
}
}
override fun onError(ex: Exception?) {
Log.e(TAG, "WebSocket错误", ex)
updateNotification("连接错误: ${ex?.message}")
isSocketConnected = false
updateNotification("连接异常,重试中...")
if (!manualStop) {
scheduleReconnect()
}
}
}
@@ -214,6 +236,8 @@ class AgentService : Service() {
}
private fun sendRegister() {
val androidId = DeviceIdHelper.getAndroidId(this)
val aochuangDeviceId = DeviceIdHelper.getAochuangCompatibleId(this)
val registerMsg = mapOf(
"type" to "register",
"device_id" to deviceId,
@@ -221,16 +245,30 @@ class AgentService : Service() {
"platform" to "android",
"model" to Build.MODEL,
"sdk_version" to Build.VERSION.SDK_INT,
"app_version" to "1.0.0"
"app_version" to getAppVersion(),
"heartbeat_interval_seconds" to (heartbeatIntervalMs / 1000),
"device_profile" to mapOf(
"aochuang_device_id" to aochuangDeviceId,
"android_id" to androidId,
"serial" to DeviceIdHelper.getSerial()
)
)
webSocketClient?.send(gson.toJson(registerMsg))
}
private fun getAppVersion(): String {
return try {
packageManager.getPackageInfo(packageName, 0).versionName ?: "unknown"
} catch (_: Exception) {
"unknown"
}
}
private fun startHeartbeat() {
heartbeatJob?.cancel()
heartbeatJob = serviceScope.launch {
while (isActive) {
delay(30_000) // 30秒心跳
delay(heartbeatIntervalMs)
try {
val heartbeat = mapOf(
"type" to "heartbeat",
@@ -238,6 +276,14 @@ class AgentService : Service() {
"timestamp" to System.currentTimeMillis()
)
webSocketClient?.send(gson.toJson(heartbeat))
// 若连续多个周期未收到 pong则触发重连
val staleThresholdMs = heartbeatIntervalMs * 3
if (lastPongAtMs > 0 && (System.currentTimeMillis() - lastPongAtMs) > staleThresholdMs) {
Log.w(TAG, "心跳超时,准备重连")
webSocketClient?.close()
break
}
} catch (e: Exception) {
Log.e(TAG, "心跳发送失败", e)
}
@@ -253,6 +299,11 @@ class AgentService : Service() {
when (type) {
"execute" -> handleExecute(data)
"ping" -> sendPong()
"pong" -> lastPongAtMs = System.currentTimeMillis()
"registered" -> {
lastPongAtMs = System.currentTimeMillis()
updateNotification("已连接 - 设备: $deviceId")
}
"config" -> handleConfig(data)
}
} catch (e: Exception) {
@@ -421,16 +472,29 @@ class AgentService : Service() {
private fun executeShell(command: String): Pair<Boolean, String> {
return try {
Log.d(TAG, "执行Shell: $command")
val process = Runtime.getRuntime().exec(arrayOf("sh", "-c", command))
val output = process.inputStream.bufferedReader().readText()
val error = process.errorStream.bufferedReader().readText()
val exitCode = process.waitFor()
if (exitCode == 0) {
Pair(true, output.ifEmpty { "执行成功" })
} else {
Pair(false, error.ifEmpty { "执行失败,退出码: $exitCode" })
// Root 设备优先走 su -c截图/screencap 等命令在 app uid 下可能无权限)
fun run(cmd: Array<String>): Triple<Int, String, String> {
val p = Runtime.getRuntime().exec(cmd)
val out = p.inputStream.bufferedReader().readText()
val err = p.errorStream.bufferedReader().readText()
val code = p.waitFor()
return Triple(code, out, err)
}
// 1) 先尝试 su -c
val (codeSu, outSu, errSu) = run(arrayOf("su", "-c", command))
if (codeSu == 0) {
return Pair(true, outSu.ifEmpty { "执行成功" })
}
// 2) 回退到 sh -c
val (codeSh, outSh, errSh) = run(arrayOf("sh", "-c", command))
if (codeSh == 0) {
return Pair(true, outSh.ifEmpty { "执行成功" })
}
val err = (errSu.ifEmpty { errSh }).ifEmpty { "执行失败,退出码: su=$codeSu sh=$codeSh" }
Pair(false, err)
} catch (e: Exception) {
Log.e(TAG, "Shell执行失败", e)
Pair(false, e.message ?: "Shell执行异常")
@@ -477,27 +541,34 @@ class AgentService : Service() {
}
private fun handleConfig(data: Map<*, *>) {
// 处理配置更新
Log.d(TAG, "收到配置: $data")
val heartbeatSec = (data["heartbeat_interval_seconds"] as? Number)?.toLong()
?: ((data["params"] as? Map<*, *>)?.get("heartbeat_interval_seconds") as? Number)?.toLong()
if (heartbeatSec != null && heartbeatSec in 5..120) {
heartbeatIntervalMs = heartbeatSec * 1000
startHeartbeat()
updateNotification("已连接 - 心跳${heartbeatSec}s")
}
}
private fun scheduleReconnect() {
serviceScope.launch {
if (reconnectJob?.isActive == true) return
reconnectJob = serviceScope.launch {
var retryCount = 0
val maxRetries = 10 // 最多重试10次
val baseDelay = 5000L // 基础延迟5秒
while (retryCount < maxRetries && isRunning.not()) {
while (retryCount < maxRetries && isRunning && !isSocketConnected) {
val delay = baseDelay * (1 shl minOf(retryCount, 4)) // 指数退避最多32秒
delay(delay)
if (isRunning.not()) {
if (isRunning && !isSocketConnected) {
Logger.d("尝试重连... (${retryCount + 1}/$maxRetries)")
try {
connectWebSocket()
// 等待连接结果
delay(3000)
if (isRunning) {
if (isSocketConnected) {
Logger.i("重连成功")
break
}
@@ -508,7 +579,7 @@ class AgentService : Service() {
}
}
if (retryCount >= maxRetries && isRunning.not()) {
if (retryCount >= maxRetries && isRunning && !isSocketConnected) {
Logger.e("重连失败,已达到最大重试次数")
updateNotification("连接失败,请检查网络")
}
@@ -540,10 +611,12 @@ class AgentService : Service() {
private fun disconnect() {
heartbeatJob?.cancel()
reconnectJob?.cancel()
performanceMonitorJob?.cancel()
webSocketClient?.close()
webSocketClient = null
isRunning = false
isSocketConnected = false
}
}

View File

@@ -0,0 +1,160 @@
package com.workphone.agent
import android.graphics.Color
import android.os.Bundle
import android.view.Gravity
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.LinearLayout
import android.widget.TextView
import androidx.fragment.app.Fragment
import com.workphone.agent.databinding.FragmentAiBinding
class AiFragment : Fragment() {
private var _binding: FragmentAiBinding? = null
private val binding get() = _binding!!
override fun onCreateView(inflater: LayoutInflater, c: ViewGroup?, s: Bundle?): View {
_binding = FragmentAiBinding.inflate(inflater, c, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupInput()
setupChips()
}
private fun setupInput() {
binding.btnSend.setOnClickListener { sendMessage() }
binding.etChatInput.setOnEditorActionListener { _, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_SEND ||
(event?.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)) {
sendMessage()
true
} else false
}
binding.btnClearChat.setOnClickListener { clearChat() }
}
private fun setupChips() {
binding.chipWechat.setOnClickListener { quickSend("打开微信") }
binding.chipScreenshot.setOnClickListener { quickSend("截图") }
binding.chipHome.setOnClickListener { quickSend("返回桌面") }
binding.chipDouyin.setOnClickListener { quickSend("打开抖音") }
binding.chipVolUp.setOnClickListener { quickSend("音量加") }
}
private fun quickSend(text: String) {
binding.etChatInput.setText(text)
sendMessage()
}
private fun sendMessage() {
val text = binding.etChatInput.text.toString().trim()
if (text.isEmpty()) return
binding.etChatInput.text?.clear()
addUserBubble(text)
val loadingView = addAiBubble("执行中...")
Thread {
val result = LocalAI.executeVoiceCommand(requireContext(), text)
activity?.runOnUiThread {
if (_binding != null) {
loadingView.text = result
scrollToBottom()
incrementCommandCount()
}
}
}.start()
}
private fun addUserBubble(text: String) {
val container = LinearLayout(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply { bottomMargin = dpToPx(12) }
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.END
}
val bubble = TextView(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply { leftMargin = dpToPx(48) }
setBackgroundResource(R.drawable.chat_bubble_user)
setPadding(dpToPx(14), dpToPx(10), dpToPx(14), dpToPx(10))
this.text = text
setTextColor(Color.parseColor("#E6EDF3"))
textSize = 14f
}
container.addView(bubble)
binding.chatContainer.addView(container)
scrollToBottom()
}
private fun addAiBubble(text: String): TextView {
val container = LinearLayout(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply { bottomMargin = dpToPx(12) }
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.START
}
val bubble = TextView(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply { rightMargin = dpToPx(48) }
setBackgroundResource(R.drawable.chat_bubble_ai)
setPadding(dpToPx(14), dpToPx(10), dpToPx(14), dpToPx(10))
this.text = text
setTextColor(Color.parseColor("#E6EDF3"))
textSize = 14f
}
container.addView(bubble)
binding.chatContainer.addView(container)
scrollToBottom()
return bubble
}
private fun clearChat() {
val childCount = binding.chatContainer.childCount
if (childCount > 1) {
binding.chatContainer.removeViews(1, childCount - 1)
}
}
private fun scrollToBottom() {
binding.chatScrollView.post {
binding.chatScrollView.fullScroll(View.FOCUS_DOWN)
}
}
private fun incrementCommandCount() {
val prefs = requireContext().getSharedPreferences("agent_config", android.content.Context.MODE_PRIVATE)
val count = prefs.getInt("stat_commands", 0) + 1
prefs.edit().putInt("stat_commands", count).apply()
}
private fun dpToPx(dp: Int): Int {
return (dp * resources.displayMetrics.density).toInt()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -21,15 +21,16 @@ class BootReceiver : BroadcastReceiver() {
Log.d(TAG, "系统启动完成启动Agent服务")
val prefs = context.getSharedPreferences("agent_config", Context.MODE_PRIVATE)
val serverUrl = prefs.getString("server_url", "") ?: ""
val projectId = prefs.getString("project_id", "") ?: ""
val deviceId = prefs.getString("device_id", "") ?: ""
val serverUrl = prefs.getString("server_url", "ws://127.0.0.1:8899/ws/device") ?: ""
val projectId = prefs.getString("project_id", "default") ?: "default"
val deviceId = prefs.getString("device_id", "")?.takeIf { it.isNotBlank() }
?: DeviceIdHelper.getOrCreateDeviceId(context)
if (serverUrl.isNotEmpty() && projectId.isNotEmpty()) {
if (serverUrl.isNotEmpty()) {
val serviceIntent = Intent(context, AgentService::class.java).apply {
action = AgentService.ACTION_START
putExtra(AgentService.EXTRA_SERVER_URL, serverUrl)
putExtra(AgentService.EXTRA_PROJECT_ID, projectId)
putExtra(AgentService.EXTRA_PROJECT_ID, projectId.ifBlank { "default" })
putExtra(AgentService.EXTRA_DEVICE_ID, deviceId)
}

View File

@@ -0,0 +1,167 @@
package com.workphone.agent
import android.Manifest
import android.animation.ObjectAnimator
import android.animation.PropertyValuesHolder
import android.content.pm.PackageManager
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.Toast
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import com.workphone.agent.databinding.FragmentControlBinding
class ControlFragment : Fragment(), VoiceHelper.VoiceListener {
private var _binding: FragmentControlBinding? = null
private val binding get() = _binding!!
private lateinit var voiceHelper: VoiceHelper
private var isVoiceListening = false
private var pulseAnimator: ObjectAnimator? = null
override fun onCreateView(inflater: LayoutInflater, c: ViewGroup?, s: Bundle?): View {
_binding = FragmentControlBinding.inflate(inflater, c, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
voiceHelper = VoiceHelper(requireContext())
voiceHelper.setListener(this)
setupButtons()
}
private fun setupButtons() {
// 常用应用
binding.btnAppWechat.setOnClickListener { exec("打开微信") }
binding.btnAppDouyin.setOnClickListener { exec("打开抖音") }
binding.btnAppXhs.setOnClickListener { exec("打开小红书") }
binding.btnAppQQ.setOnClickListener { exec("打开QQ") }
binding.btnAppFeishu.setOnClickListener { exec("打开飞书") }
binding.btnAppSettings.setOnClickListener { exec("打开设置") }
// 系统控制
binding.btnBack.setOnClickListener { exec("返回") }
binding.btnHome.setOnClickListener { exec("桌面") }
binding.btnScreenshot.setOnClickListener { exec("截图") }
binding.btnSwipeUp.setOnClickListener { exec("上滑") }
binding.btnSwipeDown.setOnClickListener { exec("下滑") }
binding.btnNotification.setOnClickListener { exec("通知") }
binding.btnRecent.setOnClickListener { exec("最近任务") }
binding.btnLock.setOnClickListener { exec("锁屏") }
binding.btnRefresh.setOnClickListener { exec("刷新") }
// 语音按钮
binding.btnVoice.setOnClickListener { toggleVoice() }
}
private fun exec(command: String) {
binding.tvVoiceResult.text = "执行: $command"
Thread {
val result = LocalAI.executeVoiceCommand(requireContext(), command)
activity?.runOnUiThread {
if (_binding != null) {
binding.tvVoiceResult.text = result
incrementCommandCount()
}
}
}.start()
}
private fun incrementCommandCount() {
val prefs = requireContext().getSharedPreferences("agent_config", android.content.Context.MODE_PRIVATE)
val count = prefs.getInt("stat_commands", 0) + 1
prefs.edit().putInt("stat_commands", count).apply()
}
private fun toggleVoice() {
if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(requireActivity(), arrayOf(Manifest.permission.RECORD_AUDIO), 1001)
return
}
if (isVoiceListening) {
voiceHelper.stopListening()
} else {
if (voiceHelper.isAvailable()) {
voiceHelper.startListening()
} else {
Toast.makeText(requireContext(), "设备不支持语音识别", Toast.LENGTH_SHORT).show()
}
}
}
override fun onVoiceStart() {
isVoiceListening = true
activity?.runOnUiThread {
binding.tvVoiceHint.text = "正在听..."
binding.tvVoiceResult.text = ""
startPulseAnimation()
}
}
override fun onVoiceResult(text: String) {
activity?.runOnUiThread {
binding.tvVoiceResult.text = "\"$text\""
exec(text)
}
}
override fun onVoiceError(message: String) {
stopListeningUI()
activity?.runOnUiThread {
binding.tvVoiceHint.text = message
}
}
override fun onVoiceEnd() {
stopListeningUI()
}
override fun onPartialResult(text: String) {
activity?.runOnUiThread {
binding.tvVoiceResult.text = text
}
}
private fun stopListeningUI() {
isVoiceListening = false
activity?.runOnUiThread {
binding.tvVoiceHint.text = "点击语音控制"
stopPulseAnimation()
}
}
private fun startPulseAnimation() {
binding.voiceRipple.alpha = 1f
pulseAnimator = ObjectAnimator.ofPropertyValuesHolder(
binding.voiceRipple,
PropertyValuesHolder.ofFloat(View.SCALE_X, 1f, 1.3f),
PropertyValuesHolder.ofFloat(View.SCALE_Y, 1f, 1.3f),
PropertyValuesHolder.ofFloat(View.ALPHA, 0.8f, 0f)
).apply {
duration = 1000
repeatCount = ObjectAnimator.INFINITE
interpolator = AccelerateDecelerateInterpolator()
start()
}
}
private fun stopPulseAnimation() {
pulseAnimator?.cancel()
binding.voiceRipple.alpha = 0f
}
override fun onDestroyView() {
super.onDestroyView()
pulseAnimator?.cancel()
voiceHelper.destroy()
_binding = null
}
}

View File

@@ -0,0 +1,130 @@
package com.workphone.agent
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.workphone.agent.databinding.FragmentDashboardBinding
class DashboardFragment : Fragment() {
private var _binding: FragmentDashboardBinding? = null
private val binding get() = _binding!!
private val handler = Handler(Looper.getMainLooper())
private val prefs by lazy {
requireContext().getSharedPreferences("agent_config", android.content.Context.MODE_PRIVATE)
}
private var connectTimeMs = 0L
private var commandCount = 0
private var messageCount = 0
private val refreshRunnable = object : Runnable {
override fun run() {
if (_binding != null) {
refreshStatus()
handler.postDelayed(this, 3000)
}
}
}
override fun onCreateView(inflater: LayoutInflater, c: ViewGroup?, s: Bundle?): View {
_binding = FragmentDashboardBinding.inflate(inflater, c, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
loadDeviceInfo()
loadServiceInfo()
refreshStatus()
}
override fun onResume() {
super.onResume()
handler.post(refreshRunnable)
}
override fun onPause() {
super.onPause()
handler.removeCallbacks(refreshRunnable)
}
private fun loadDeviceInfo() {
binding.tvDeviceModel.text = "${Build.BRAND} ${Build.MODEL}"
binding.tvAndroidVersion.text = "${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})"
val deviceId = prefs.getString("device_id", null)
?: DeviceIdHelper.getOrCreateDeviceId(requireContext())
binding.tvDeviceId.text = deviceId
Thread {
val isRoot = try {
val p = Runtime.getRuntime().exec(arrayOf("su", "-c", "id"))
val out = p.inputStream.bufferedReader().readText()
p.waitFor()
out.contains("uid=0")
} catch (_: Exception) { false }
handler.post {
if (_binding != null) {
if (isRoot) {
binding.tvRootStatus.text = "已Root"
binding.tvRootStatus.setTextColor(Color.parseColor("#3FB950"))
} else {
binding.tvRootStatus.text = "未Root"
binding.tvRootStatus.setTextColor(Color.parseColor("#8B949E"))
}
}
}
}.start()
}
private fun loadServiceInfo() {
val serverUrl = prefs.getString("server_url", "")
val projectId = prefs.getString("project_id", "")
binding.tvServerUrl.text = if (serverUrl.isNullOrEmpty()) "未配置" else serverUrl
binding.tvProjectId.text = if (projectId.isNullOrEmpty()) "未配置" else projectId
}
private fun refreshStatus() {
val isOnline = AgentService.isRunning
val wsConnected = AgentService.isSocketConnected
val dot = binding.statusDot.background as? GradientDrawable
if (isOnline) {
dot?.setColor(Color.parseColor("#3FB950"))
binding.tvStatusTitle.text = "已连接"
binding.tvStatusTitle.setTextColor(Color.parseColor("#3FB950"))
binding.tvStatusDetail.text = if (wsConnected) "服务正常运行中" else "服务运行中(自动重连)"
if (connectTimeMs == 0L) connectTimeMs = System.currentTimeMillis()
val hours = (System.currentTimeMillis() - connectTimeMs) / 3600000
val mins = ((System.currentTimeMillis() - connectTimeMs) % 3600000) / 60000
binding.tvStatUptime.text = if (hours > 0) "${hours}h${mins}m" else "${mins}m"
} else {
dot?.setColor(Color.parseColor("#F85149"))
binding.tvStatusTitle.text = "未连接"
binding.tvStatusTitle.setTextColor(Color.parseColor("#F85149"))
binding.tvStatusDetail.text = "请在设置页配置服务器"
connectTimeMs = 0L
binding.tvStatUptime.text = "0m"
}
loadServiceInfo()
commandCount = prefs.getInt("stat_commands", 0)
messageCount = prefs.getInt("stat_messages", 0)
binding.tvStatCommands.text = "$commandCount"
binding.tvStatMessages.text = "$messageCount"
}
override fun onDestroyView() {
super.onDestroyView()
handler.removeCallbacks(refreshRunnable)
_binding = null
}
}

View File

@@ -0,0 +1,62 @@
package com.workphone.agent
import android.content.Context
import android.os.Build
import android.provider.Settings
import java.security.MessageDigest
/**
* 设备ID策略
* 1) 默认使用奥创兼容IDmd5(android_id)
* 2) 保留原始 android_id / serial 便于服务端排障
*/
object DeviceIdHelper {
fun getOrCreateDeviceId(context: Context): String {
val prefs = context.getSharedPreferences("agent_config", Context.MODE_PRIVATE)
val stored = prefs.getString("device_id", "")?.trim().orEmpty()
if (stored.isNotEmpty()) return stored
val generated = getAochuangCompatibleId(context)
prefs.edit().putString("device_id", generated).apply()
return generated
}
fun getAochuangCompatibleId(context: Context): String {
val androidId = getAndroidId(context)
if (androidId.isNotEmpty()) {
return md5(androidId)
}
return "device_${Build.MODEL.replace(" ", "_")}_${Build.VERSION.SDK_INT}"
}
fun getAndroidId(context: Context): String {
return try {
Settings.Secure.getString(
context.contentResolver,
Settings.Secure.ANDROID_ID
)?.trim().orEmpty()
} catch (_: Exception) {
""
}
}
fun getSerial(): String {
return try {
Runtime.getRuntime()
.exec(arrayOf("getprop", "ro.serialno"))
.inputStream
.bufferedReader()
.readText()
.trim()
} catch (_: Exception) {
""
}
}
private fun md5(input: String): String {
val md = MessageDigest.getInstance("MD5")
val digest = md.digest(input.toByteArray(Charsets.UTF_8))
return digest.joinToString("") { b -> "%02x".format(b) }
}
}

View File

@@ -1,415 +1,120 @@
package com.workphone.agent
import android.Manifest
import android.animation.ObjectAnimator
import android.animation.PropertyValuesHolder
import android.app.AlertDialog
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.EditText
import android.widget.ImageButton
import android.view.WindowManager
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.google.zxing.integration.android.IntentIntegrator
import androidx.fragment.app.Fragment
import com.workphone.agent.databinding.ActivityMainBinding
import org.json.JSONObject
/**
* 工作手机Agent - 简洁主界面
*
* 一页搞定:语音对话 + 设置
*/
class MainActivity : AppCompatActivity(), VoiceHelper.VoiceListener {
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val prefs by lazy { getSharedPreferences("agent_config", MODE_PRIVATE) }
private lateinit var voiceHelper: VoiceHelper
private var isVoiceListening = false
private var pulseAnimator: ObjectAnimator? = null
private val dashboardFragment = DashboardFragment()
private val controlFragment = ControlFragment()
private val aiFragment = AiFragment()
private val settingsFragment = SettingsFragment()
private var activeFragment: Fragment = dashboardFragment
companion object {
private const val REQUEST_PERMISSIONS = 1001
const val DEFAULT_SERVER = "ws://10.0.2.2:8899/ws/device"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
voiceHelper = VoiceHelper(this)
voiceHelper.setListener(this)
initUI()
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
requestPermissions()
setupFragments()
setupBottomNav()
autoConnect()
}
private fun initUI() {
// 语音按钮
binding.btnVoice.setOnClickListener {
toggleVoice()
}
// 设置按钮
binding.btnSettings.setOnClickListener {
showSettingsDialog()
}
// 快捷按钮 - 本地直接执行
binding.btnQuick1.setOnClickListener { executeLocalCommand("打开豆包") }
binding.btnQuick2.setOnClickListener { executeLocalCommand("返回") }
binding.btnQuick3.setOnClickListener { executeLocalCommand("截图") }
private fun setupFragments() {
supportFragmentManager.beginTransaction()
.add(R.id.fragmentContainer, settingsFragment, "settings").hide(settingsFragment)
.add(R.id.fragmentContainer, aiFragment, "ai").hide(aiFragment)
.add(R.id.fragmentContainer, controlFragment, "control").hide(controlFragment)
.add(R.id.fragmentContainer, dashboardFragment, "dashboard")
.commit()
}
private fun toggleVoice() {
if (!checkAudioPermission()) return
if (isVoiceListening) {
voiceHelper.stopListening()
} else {
if (voiceHelper.isAvailable()) {
voiceHelper.startListening()
} else {
Toast.makeText(this, "设备不支持语音识别", Toast.LENGTH_SHORT).show()
private fun setupBottomNav() {
binding.bottomNav.setOnItemSelectedListener { item ->
val target = when (item.itemId) {
R.id.nav_dashboard -> dashboardFragment
R.id.nav_control -> controlFragment
R.id.nav_ai -> aiFragment
R.id.nav_settings -> settingsFragment
else -> dashboardFragment
}
switchFragment(target)
true
}
}
private fun checkAudioPermission(): Boolean {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.RECORD_AUDIO),
REQUEST_PERMISSIONS
)
return false
}
return true
private fun switchFragment(target: Fragment) {
if (target == activeFragment) return
supportFragmentManager.beginTransaction()
.hide(activeFragment)
.show(target)
.commit()
activeFragment = target
}
// === 语音回调 ===
override fun onVoiceStart() {
isVoiceListening = true
runOnUiThread {
binding.tvVoiceHint.text = "正在听..."
binding.tvVoiceResult.text = ""
binding.tvAiResponse.text = ""
startPulseAnimation()
updateVoiceButtonColor(true)
}
}
override fun onVoiceResult(text: String) {
runOnUiThread {
binding.tvVoiceResult.text = "\"$text\""
binding.tvAiResponse.text = "正在执行..."
// 本地直接执行,不依赖服务器
executeLocalCommand(text)
}
}
/**
* 本地执行命令 - 不需要服务器
*/
private fun executeLocalCommand(text: String) {
Thread {
val result = LocalAI.executeVoiceCommand(this, text)
runOnUiThread {
binding.tvAiResponse.text = result
}
}.start()
}
override fun onVoiceError(message: String) {
runOnUiThread {
binding.tvVoiceHint.text = message
}
stopListeningUI()
}
override fun onVoiceEnd() {
stopListeningUI()
}
override fun onPartialResult(text: String) {
runOnUiThread {
binding.tvVoiceResult.text = text
}
}
private fun stopListeningUI() {
isVoiceListening = false
runOnUiThread {
binding.tvVoiceHint.text = "点击说话"
stopPulseAnimation()
updateVoiceButtonColor(false)
}
}
private fun updateVoiceButtonColor(listening: Boolean) {
val bg = binding.btnVoice.background as? GradientDrawable
if (listening) {
bg?.setColor(Color.parseColor("#FF3B30"))
} else {
bg?.setColor(Color.parseColor("#007AFF"))
}
}
private fun startPulseAnimation() {
binding.voiceRipple.alpha = 1f
pulseAnimator = ObjectAnimator.ofPropertyValuesHolder(
binding.voiceRipple,
PropertyValuesHolder.ofFloat(View.SCALE_X, 1f, 1.3f),
PropertyValuesHolder.ofFloat(View.SCALE_Y, 1f, 1.3f),
PropertyValuesHolder.ofFloat(View.ALPHA, 0.8f, 0f)
).apply {
duration = 1000
repeatCount = ObjectAnimator.INFINITE
interpolator = AccelerateDecelerateInterpolator()
start()
}
}
private fun stopPulseAnimation() {
pulseAnimator?.cancel()
binding.voiceRipple.alpha = 0f
}
// === 命令发送 ===
private fun sendCommand(text: String) {
// 通过广播发送给AgentService
val intent = Intent("com.workphone.agent.VOICE_COMMAND")
intent.putExtra("text", text)
intent.setPackage(packageName)
sendBroadcast(intent)
// 显示反馈
runOnUiThread {
binding.tvAiResponse.text = "已发送: $text"
}
}
// === 设置对话框 ===
private fun showSettingsDialog() {
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_settings, null)
val etServerUrl = dialogView.findViewById<EditText>(R.id.etServerUrl)
val etProjectId = dialogView.findViewById<EditText>(R.id.etProjectId)
val btnScanQr = dialogView.findViewById<ImageButton>(R.id.btnScanQr)
// 加载配置
etServerUrl.setText(prefs.getString("server_url", DEFAULT_SERVER))
etProjectId.setText(prefs.getString("project_id", ""))
val dialog = AlertDialog.Builder(this)
.setView(dialogView)
.create()
// 扫码
btnScanQr.setOnClickListener {
dialog.dismiss()
startQrScanner()
}
// 连接
dialogView.findViewById<View>(R.id.btnConnect).setOnClickListener {
val serverUrl = etServerUrl.text.toString().trim()
val projectId = etProjectId.text.toString().trim()
if (serverUrl.isEmpty()) {
Toast.makeText(this, "请输入服务器地址", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
saveConfig(serverUrl, projectId)
startAgentService(serverUrl, projectId)
dialog.dismiss()
}
// 断开
dialogView.findViewById<View>(R.id.btnDisconnect).setOnClickListener {
stopAgentService()
dialog.dismiss()
}
dialog.show()
}
private fun startQrScanner() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), REQUEST_PERMISSIONS)
return
}
val integrator = IntentIntegrator(this)
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt("扫描项目二维码")
integrator.setOrientationLocked(true)
integrator.initiateScan()
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data)
if (result?.contents != null) {
parseQrCode(result.contents)
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}
private fun parseQrCode(content: String) {
try {
val json = JSONObject(content)
val server = json.optString("server", "")
val projectId = json.optString("project_id", "")
if (server.isNotEmpty() && projectId.isNotEmpty()) {
saveConfig(server, projectId)
startAgentService(server, projectId)
Toast.makeText(this, "已绑定项目", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
prefs.edit().putString("project_id", content).apply()
Toast.makeText(this, "已设置项目ID", Toast.LENGTH_SHORT).show()
}
}
// === 服务控制 ===
private fun autoConnect() {
val serverUrl = prefs.getString("server_url", "") ?: ""
val projectId = prefs.getString("project_id", "") ?: ""
if (serverUrl.isNotEmpty() && projectId.isNotEmpty()) {
startAgentService(serverUrl, projectId)
val auto = prefs.getBoolean("auto_connect", true)
val serverUrl = prefs.getString("server_url", "ws://127.0.0.1:8899/ws/device") ?: ""
val projectId = prefs.getString("project_id", "default") ?: "default"
if (!prefs.contains("server_url") || !prefs.contains("project_id") || !prefs.contains("device_id")) {
prefs.edit()
.putString("server_url", serverUrl)
.putString("project_id", projectId.ifBlank { "default" })
.putString("device_id", DeviceIdHelper.getOrCreateDeviceId(this))
.apply()
}
if (auto && serverUrl.isNotEmpty()) {
val deviceId = DeviceIdHelper.getOrCreateDeviceId(this)
val intent = Intent(this, AgentService::class.java).apply {
action = AgentService.ACTION_START
putExtra(AgentService.EXTRA_SERVER_URL, serverUrl)
putExtra(AgentService.EXTRA_PROJECT_ID, projectId.ifBlank { "default" })
putExtra(AgentService.EXTRA_DEVICE_ID, deviceId)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
}
}
private fun saveConfig(serverUrl: String, projectId: String) {
val deviceId = prefs.getString("device_id", null)
?: "device_${Build.MODEL.replace(" ", "_")}_${System.currentTimeMillis() % 10000}"
prefs.edit().apply {
putString("server_url", serverUrl)
putString("project_id", projectId)
putString("device_id", deviceId)
apply()
}
}
private fun startAgentService(serverUrl: String, projectId: String) {
val deviceId = prefs.getString("device_id", "device_${System.currentTimeMillis()}") ?: ""
val intent = Intent(this, AgentService::class.java).apply {
action = AgentService.ACTION_START
putExtra(AgentService.EXTRA_SERVER_URL, serverUrl)
putExtra(AgentService.EXTRA_PROJECT_ID, projectId)
putExtra(AgentService.EXTRA_DEVICE_ID, deviceId)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
updateStatus(true, "已连接")
}
private fun stopAgentService() {
val intent = Intent(this, AgentService::class.java).apply {
action = AgentService.ACTION_STOP
}
startService(intent)
updateStatus(false, "未连接")
}
private fun updateStatus(connected: Boolean, text: String) {
binding.tvStatus.text = text
val dot = binding.statusDot.background as? GradientDrawable
if (connected) {
dot?.setColor(Color.parseColor("#34C759"))
} else {
dot?.setColor(Color.parseColor("#F44336"))
}
}
// === 权限 ===
private fun requestPermissions() {
val permissions = mutableListOf(
Manifest.permission.RECORD_AUDIO,
Manifest.permission.CAMERA
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
}
val needed = permissions.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (needed.isNotEmpty()) {
ActivityCompat.requestPermissions(this, needed.toTypedArray(), REQUEST_PERMISSIONS)
}
}
override fun onResume() {
super.onResume()
if (AgentService.isRunning) {
updateStatus(true, "已连接")
} else {
updateStatus(false, "未连接")
}
// 检查Accessibility Service状态
checkAccessibilityService()
}
/**
* 检查并引导用户开启Accessibility Service
*/
private fun checkAccessibilityService() {
if (!AgentAccessibilityService.isEnabled()) {
// 可以显示提示,但不强制
// 因为Shell命令也可以工作
}
}
/**
* 打开Accessibility设置页面
*/
private fun openAccessibilitySettings() {
try {
val intent = Intent(android.provider.Settings.ACTION_ACCESSIBILITY_SETTINGS)
startActivity(intent)
Toast.makeText(this, "请开启\"工作手机Agent\"的无障碍服务", Toast.LENGTH_LONG).show()
} catch (e: Exception) {
Toast.makeText(this, "无法打开无障碍设置", Toast.LENGTH_SHORT).show()
}
}
override fun onDestroy() {
super.onDestroy()
pulseAnimator?.cancel()
voiceHelper.destroy()
}
}

View File

@@ -0,0 +1,176 @@
package com.workphone.agent
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import com.google.zxing.integration.android.IntentIntegrator
import com.workphone.agent.databinding.FragmentSettingsBinding
import org.json.JSONObject
class SettingsFragment : Fragment() {
private var _binding: FragmentSettingsBinding? = null
private val binding get() = _binding!!
private val prefs by lazy {
requireContext().getSharedPreferences("agent_config", android.content.Context.MODE_PRIVATE)
}
override fun onCreateView(inflater: LayoutInflater, c: ViewGroup?, s: Bundle?): View {
_binding = FragmentSettingsBinding.inflate(inflater, c, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
loadConfig()
setupListeners()
}
private fun loadConfig() {
binding.etServerUrl.setText(prefs.getString("server_url", "ws://10.0.2.2:8899/ws/device"))
binding.etProjectId.setText(prefs.getString("project_id", "default"))
val deviceId = prefs.getString("device_id", null) ?: DeviceIdHelper.getOrCreateDeviceId(requireContext())
binding.etDeviceId.setText(deviceId)
binding.switchAutoConnect.isChecked = prefs.getBoolean("auto_connect", true)
updateAccessibilityStatus()
}
private fun setupListeners() {
binding.btnConnect.setOnClickListener { connectServer() }
binding.btnDisconnect.setOnClickListener { disconnectServer() }
binding.btnScanQr.setOnClickListener { startQrScanner() }
binding.switchAutoConnect.setOnCheckedChangeListener { _, checked ->
prefs.edit().putBoolean("auto_connect", checked).apply()
}
binding.btnAccessibility.setOnClickListener { openAccessibilitySettings() }
binding.btnHookModules.setOnClickListener {
Toast.makeText(requireContext(), "Hook模块管理开发中", Toast.LENGTH_SHORT).show()
}
}
private fun connectServer() {
val serverUrl = binding.etServerUrl.text.toString().trim()
val projectId = binding.etProjectId.text.toString().trim().ifBlank { "default" }
val deviceId = binding.etDeviceId.text.toString().trim().ifBlank {
DeviceIdHelper.getOrCreateDeviceId(requireContext())
}
if (serverUrl.isEmpty()) {
Toast.makeText(requireContext(), "请输入服务器地址", Toast.LENGTH_SHORT).show()
return
}
prefs.edit().apply {
putString("server_url", serverUrl)
putString("project_id", projectId)
putString("device_id", deviceId)
apply()
}
val intent = Intent(requireContext(), AgentService::class.java).apply {
action = AgentService.ACTION_START
putExtra(AgentService.EXTRA_SERVER_URL, serverUrl)
putExtra(AgentService.EXTRA_PROJECT_ID, projectId)
putExtra(AgentService.EXTRA_DEVICE_ID, deviceId)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
requireContext().startForegroundService(intent)
} else {
requireContext().startService(intent)
}
Toast.makeText(requireContext(), "正在连接...", Toast.LENGTH_SHORT).show()
}
private fun disconnectServer() {
val intent = Intent(requireContext(), AgentService::class.java).apply {
action = AgentService.ACTION_STOP
}
requireContext().startService(intent)
Toast.makeText(requireContext(), "已断开连接", Toast.LENGTH_SHORT).show()
}
private fun startQrScanner() {
if (ContextCompat.checkSelfPermission(requireContext(), android.Manifest.permission.CAMERA)
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(android.Manifest.permission.CAMERA), 1001)
return
}
val integrator = IntentIntegrator.forSupportFragment(this)
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt("扫描项目二维码")
integrator.setOrientationLocked(true)
integrator.initiateScan()
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data)
if (result?.contents != null) {
parseQrCode(result.contents)
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}
private fun parseQrCode(content: String) {
try {
val json = JSONObject(content)
val server = json.optString("server", "")
val projectId = json.optString("project_id", "")
if (server.isNotEmpty()) binding.etServerUrl.setText(server)
if (projectId.isNotEmpty()) binding.etProjectId.setText(projectId)
Toast.makeText(requireContext(), "已扫描配置", Toast.LENGTH_SHORT).show()
} catch (_: Exception) {
binding.etProjectId.setText(content)
Toast.makeText(requireContext(), "已设置项目ID", Toast.LENGTH_SHORT).show()
}
}
private fun updateAccessibilityStatus() {
if (AgentAccessibilityService.isEnabled()) {
binding.tvAccessibilityStatus.text = "已开启"
binding.tvAccessibilityStatus.setTextColor(
ContextCompat.getColor(requireContext(), R.color.accent_green)
)
} else {
binding.tvAccessibilityStatus.text = "未开启"
binding.tvAccessibilityStatus.setTextColor(
ContextCompat.getColor(requireContext(), R.color.text_secondary)
)
}
}
private fun openAccessibilitySettings() {
try {
val intent = Intent(android.provider.Settings.ACTION_ACCESSIBILITY_SETTINGS)
startActivity(intent)
Toast.makeText(requireContext(), "请开启\"AI数字员工\"的无障碍服务", Toast.LENGTH_LONG).show()
} catch (_: Exception) {
Toast.makeText(requireContext(), "无法打开无障碍设置", Toast.LENGTH_SHORT).show()
}
}
override fun onResume() {
super.onResume()
updateAccessibilityStatus()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/nav_selected" android:state_checked="true" />
<item android:color="@color/nav_unselected" />
</selector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/ripple">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/bg_card_elevated" />
<corners android:radius="12dp" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/ripple">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/accent_blue_dark" />
<corners android:radius="12dp" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#3358A6FF">
<item>
<shape android:shape="oval">
<solid android:color="@color/accent_blue_dark" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/bg_card" />
<corners android:radius="16dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/bg_card_elevated" />
<corners android:radius="12dp" />
</shape>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/bg_card" />
<corners
android:topLeftRadius="4dp"
android:topRightRadius="16dp"
android:bottomLeftRadius="16dp"
android:bottomRightRadius="16dp" />
</shape>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/accent_blue_dark" />
<corners
android:topLeftRadius="16dp"
android:topRightRadius="4dp"
android:bottomLeftRadius="16dp"
android:bottomRightRadius="16dp" />
</shape>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/ripple">
<item>
<shape android:shape="rectangle">
<stroke android:width="1dp" android:color="@color/bg_card_elevated" />
<solid android:color="@color/bg_surface" />
<corners android:radius="20dp" />
</shape>
</item>
</ripple>

View File

@@ -5,6 +5,9 @@
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#4CAF50"
android:pathData="M0,0h108v108h-108z"/>
android:fillColor="#0D1B3E"
android:pathData="M0,0h108v108H0z" />
<path
android:fillColor="#132B5E"
android:pathData="M0,0L108,0L108,54L0,108z" />
</vector>

View File

@@ -4,8 +4,36 @@
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- 手机图标 -->
<!-- Phone body with screen cutout -->
<path
android:fillColor="#FFFFFF"
android:pathData="M40,20h28c2.2,0 4,1.8 4,4v60c0,2.2 -1.8,4 -4,4h-28c-2.2,0 -4,-1.8 -4,-4v-60c0,-2.2 1.8,-4 4,-4zM42,26v52h24v-52h-24zM54,80m-3,0a3,3 0,1 1,6 0a3,3 0,1 1,-6 0"/>
android:fillType="evenOdd"
android:pathData="M39,20h30c4.4,0 8,3.6 8,8v52c0,4.4 -3.6,8 -8,8H39c-4.4,0 -8,-3.6 -8,-8V28c0,-4.4 3.6,-8 8,-8zM35,32h38v40H35z" />
<!-- Speaker -->
<path
android:fillColor="#B0C4DE"
android:pathData="M50,24h8c1,0 1.5,2 0,2h-8c-1,0 -1.5,-2 0,-2z" />
<!-- Home button -->
<path
android:fillColor="#B0C4DE"
android:pathData="M54,80m-3.5,0a3.5,3.5 0,1 1,7 0a3.5,3.5 0,1 1,-7 0" />
<!-- AI sparkle - large 4-point star -->
<path
android:fillColor="#58A6FF"
android:pathData="M54,38L57,47L66,49L57,51L54,60L51,51L42,49L51,47Z" />
<!-- AI sparkle - small star upper-right -->
<path
android:fillColor="#8BC6FF"
android:pathData="M65,36L66.2,39L69,40L66.2,41L65,44L63.8,41L61,40L63.8,39Z" />
<!-- AI sparkle - small star lower-left -->
<path
android:fillColor="#8BC6FF"
android:pathData="M40,58L41.5,62L45,63.5L41.5,65L40,69L38.5,65L35,63.5L38.5,62Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M19,9l1.25,-2.75L23,5l-2.75,-1.25L19,1l-1.25,2.75L15,5l2.75,1.25zM19,15l-1.25,2.75L15,19l2.75,1.25L19,23l1.25,-2.75L23,19l-2.75,-1.25zM11.5,9.5L9,4 6.5,9.5 1,12l5.5,2.5L9,20l2.5,-5.5L17,12l-5.5,-2.5z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M15,7.5V2H9v5.5l3,3 3,-3zM7.5,9H2v6h5.5l3,-3 -3,-3zM9,16.5V22h6v-5.5l-3,-3 -3,3zM16.5,9l-3,3 3,3H22V9h-5.5z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M3,3h8v8H3V3zM13,3h8v8h-8V3zM3,13h8v8H3v-8zM13,13h8v8h-8v-8z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z" />
</vector>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/bg_card" />
<corners android:radius="24dp" />
<stroke android:width="1dp" android:color="@color/bg_card_elevated" />
</shape>

View File

@@ -4,154 +4,27 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?android:attr/colorBackground">
android:background="@color/bg_primary">
<!-- 顶部状态栏 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:gravity="center_vertical">
<View
android:id="@+id/statusDot"
android:layout_width="10dp"
android:layout_height="10dp"
android:background="@drawable/status_dot" />
<TextView
android:id="@+id/tvStatus"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:text="未连接"
android:textColor="?android:attr/textColorSecondary"
android:textSize="14sp" />
<ImageButton
android:id="@+id/btnSettings"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:src="@android:drawable/ic_menu_preferences"
android:contentDescription="设置"
android:tint="?android:attr/textColorSecondary" />
</LinearLayout>
<!-- 中间语音区域 -->
<LinearLayout
<FrameLayout
android:id="@+id/fragmentContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:padding="32dp">
android:layout_weight="1" />
<!-- 语音状态文字 -->
<TextView
android:id="@+id/tvVoiceHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="点击说话"
android:textColor="?android:attr/textColorPrimary"
android:textSize="18sp"
android:gravity="center"
android:layout_marginBottom="40dp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/divider" />
<!-- 语音按钮 -->
<FrameLayout
android:layout_width="160dp"
android:layout_height="160dp">
<View
android:id="@+id/voiceRipple"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/voice_ripple"
android:alpha="0" />
<ImageButton
android:id="@+id/btnVoice"
android:layout_width="120dp"
android:layout_height="120dp"
android:layout_gravity="center"
android:background="@drawable/voice_button_bg"
android:src="@android:drawable/ic_btn_speak_now"
android:scaleType="center"
android:tint="@android:color/white"
android:contentDescription="语音输入" />
</FrameLayout>
<!-- 识别结果 -->
<TextView
android:id="@+id/tvVoiceResult"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text=""
android:textColor="?android:attr/textColorPrimary"
android:textSize="16sp"
android:gravity="center"
android:maxLines="3"
android:ellipsize="end" />
<!-- AI回复 -->
<TextView
android:id="@+id/tvAiResponse"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text=""
android:textColor="?android:attr/textColorSecondary"
android:textSize="14sp"
android:gravity="center"
android:maxLines="4"
android:ellipsize="end" />
</LinearLayout>
<!-- 底部快捷操作 -->
<LinearLayout
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottomNav"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:gravity="center">
<TextView
android:id="@+id/btnQuick1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="打开豆包"
android:textColor="?android:attr/textColorSecondary"
android:textSize="13sp"
android:padding="12dp"
android:background="?android:attr/selectableItemBackgroundBorderless" />
<TextView
android:id="@+id/btnQuick2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="返回"
android:textColor="?android:attr/textColorSecondary"
android:textSize="13sp"
android:padding="12dp"
android:background="?android:attr/selectableItemBackgroundBorderless" />
<TextView
android:id="@+id/btnQuick3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="截图"
android:textColor="?android:attr/textColorSecondary"
android:textSize="13sp"
android:padding="12dp"
android:background="?android:attr/selectableItemBackgroundBorderless" />
</LinearLayout>
android:background="@color/nav_bg"
app:itemIconTint="@color/bottom_nav_color"
app:itemTextColor="@color/bottom_nav_color"
app:labelVisibilityMode="labeled"
app:menu="@menu/bottom_nav" />
</LinearLayout>

View File

@@ -0,0 +1,189 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@color/bg_primary">
<!-- 标题栏 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="AI 助手"
android:textColor="@color/text_primary"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/btnClearChat"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="清空"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:padding="8dp"
android:background="?android:attr/selectableItemBackgroundBorderless" />
</LinearLayout>
<!-- 聊天内容 -->
<ScrollView
android:id="@+id/chatScrollView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true"
android:scrollbars="none"
android:paddingHorizontal="16dp">
<LinearLayout
android:id="@+id/chatContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<!-- AI 欢迎消息 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="12dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/chat_bubble_ai"
android:padding="14dp"
android:text="你好!我是 AI 数字员工,可以帮你控制这台手机。\n\n试试说「打开微信」「截图」「返回桌面」"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:lineSpacingExtra="3dp"
android:layout_marginEnd="48dp" />
</LinearLayout>
</LinearLayout>
</ScrollView>
<!-- 快捷指令 -->
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scrollbars="none"
android:paddingHorizontal="12dp"
android:paddingVertical="8dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/chipWechat"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/chip_bg"
android:paddingHorizontal="14dp"
android:paddingVertical="8dp"
android:text="打开微信"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:layout_marginEnd="8dp" />
<TextView
android:id="@+id/chipScreenshot"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/chip_bg"
android:paddingHorizontal="14dp"
android:paddingVertical="8dp"
android:text="截图"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:layout_marginEnd="8dp" />
<TextView
android:id="@+id/chipHome"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/chip_bg"
android:paddingHorizontal="14dp"
android:paddingVertical="8dp"
android:text="返回桌面"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:layout_marginEnd="8dp" />
<TextView
android:id="@+id/chipDouyin"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/chip_bg"
android:paddingHorizontal="14dp"
android:paddingVertical="8dp"
android:text="打开抖音"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:layout_marginEnd="8dp" />
<TextView
android:id="@+id/chipVolUp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/chip_bg"
android:paddingHorizontal="14dp"
android:paddingVertical="8dp"
android:text="音量加"
android:textColor="@color/text_secondary"
android:textSize="13sp" />
</LinearLayout>
</HorizontalScrollView>
<!-- 输入区域 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="12dp"
android:paddingTop="4dp"
android:paddingBottom="8dp">
<EditText
android:id="@+id/etChatInput"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_weight="1"
android:background="@drawable/input_bg"
android:hint="输入指令..."
android:textColorHint="@color/text_tertiary"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:paddingHorizontal="16dp"
android:singleLine="true"
android:imeOptions="actionSend"
android:inputType="text" />
<ImageButton
android:id="@+id/btnSend"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_marginStart="8dp"
android:background="@drawable/btn_primary"
android:src="@android:drawable/ic_menu_send"
android:scaleType="center"
android:tint="@android:color/white"
android:contentDescription="发送" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,336 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:scrollbars="none"
android:background="@color/bg_primary">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- 标题 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="快捷操作"
android:textColor="@color/text_primary"
android:textSize="22sp"
android:textStyle="bold"
android:layout_marginBottom="16dp" />
<!-- 常用应用 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="常用应用"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:layout_marginBottom="10dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:id="@+id/btnAppWechat"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/btn_action"
android:gravity="center"
android:padding="16dp"
android:text="微信"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:layout_marginEnd="4dp" />
<TextView
android:id="@+id/btnAppDouyin"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/btn_action"
android:gravity="center"
android:padding="16dp"
android:text="抖音"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp" />
<TextView
android:id="@+id/btnAppXhs"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/btn_action"
android:gravity="center"
android:padding="16dp"
android:text="小红书"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:layout_marginStart="4dp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="20dp">
<TextView
android:id="@+id/btnAppQQ"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/btn_action"
android:gravity="center"
android:padding="16dp"
android:text="QQ"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:layout_marginEnd="4dp" />
<TextView
android:id="@+id/btnAppFeishu"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/btn_action"
android:gravity="center"
android:padding="16dp"
android:text="飞书"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp" />
<TextView
android:id="@+id/btnAppSettings"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/btn_action"
android:gravity="center"
android:padding="16dp"
android:text="设置"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:layout_marginStart="4dp" />
</LinearLayout>
<!-- 系统控制 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="系统控制"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:layout_marginBottom="10dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:id="@+id/btnBack"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/btn_action"
android:gravity="center"
android:padding="16dp"
android:text="返回"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:layout_marginEnd="4dp" />
<TextView
android:id="@+id/btnHome"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/btn_action"
android:gravity="center"
android:padding="16dp"
android:text="主页"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp" />
<TextView
android:id="@+id/btnScreenshot"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/btn_action"
android:gravity="center"
android:padding="16dp"
android:text="截图"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:layout_marginStart="4dp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:id="@+id/btnSwipeUp"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/btn_action"
android:gravity="center"
android:padding="16dp"
android:text="上滑"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:layout_marginEnd="4dp" />
<TextView
android:id="@+id/btnSwipeDown"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/btn_action"
android:gravity="center"
android:padding="16dp"
android:text="下滑"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp" />
<TextView
android:id="@+id/btnNotification"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/btn_action"
android:gravity="center"
android:padding="16dp"
android:text="通知栏"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:layout_marginStart="4dp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="24dp">
<TextView
android:id="@+id/btnRecent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/btn_action"
android:gravity="center"
android:padding="16dp"
android:text="最近任务"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:layout_marginEnd="4dp" />
<TextView
android:id="@+id/btnLock"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/btn_action"
android:gravity="center"
android:padding="16dp"
android:text="锁屏"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp" />
<TextView
android:id="@+id/btnRefresh"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/btn_action"
android:gravity="center"
android:padding="16dp"
android:text="刷新"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:layout_marginStart="4dp" />
</LinearLayout>
<!-- 语音控制 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/card_bg"
android:padding="24dp"
android:gravity="center">
<FrameLayout
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_marginBottom="12dp">
<View
android:id="@+id/voiceRipple"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/voice_ripple"
android:alpha="0" />
<ImageButton
android:id="@+id/btnVoice"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_gravity="center"
android:background="@drawable/btn_voice_large"
android:src="@android:drawable/ic_btn_speak_now"
android:scaleType="center"
android:tint="@android:color/white"
android:contentDescription="语音控制" />
</FrameLayout>
<TextView
android:id="@+id/tvVoiceHint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="点击语音控制"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
<TextView
android:id="@+id/tvVoiceResult"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:textColor="@color/accent_blue"
android:textSize="13sp"
android:layout_marginTop="8dp"
android:maxLines="2"
android:gravity="center" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="16dp" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,391 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:scrollbars="none"
android:background="@color/bg_primary">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- 顶部标题 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingBottom="16dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="工作"
android:textColor="@color/text_primary"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvVersion"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="v2.0"
android:textColor="@color/text_tertiary"
android:textSize="12sp" />
</LinearLayout>
<!-- 连接状态卡片 -->
<LinearLayout
android:id="@+id/cardStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@drawable/card_bg"
android:padding="20dp"
android:gravity="center_vertical"
android:layout_marginBottom="12dp">
<View
android:id="@+id/statusDot"
android:layout_width="12dp"
android:layout_height="12dp"
android:background="@drawable/status_dot" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginStart="16dp">
<TextView
android:id="@+id/tvStatusTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="未连接"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvStatusDetail"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="点击设置页配置服务器"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:layout_marginTop="2dp" />
</LinearLayout>
<View
android:id="@+id/btnReconnect"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?android:attr/selectableItemBackgroundBorderless" />
</LinearLayout>
<!-- 统计数据行 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="12dp">
<!-- 在线时长 -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:background="@drawable/card_bg"
android:padding="16dp"
android:gravity="center"
android:layout_marginEnd="6dp">
<TextView
android:id="@+id/tvStatUptime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0h"
android:textColor="@color/accent_blue"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="在线"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:layout_marginTop="4dp" />
</LinearLayout>
<!-- 已执行 -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:background="@drawable/card_bg"
android:padding="16dp"
android:gravity="center"
android:layout_marginStart="3dp"
android:layout_marginEnd="3dp">
<TextView
android:id="@+id/tvStatCommands"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textColor="@color/accent_green"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="已执行"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:layout_marginTop="4dp" />
</LinearLayout>
<!-- 消息数 -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:background="@drawable/card_bg"
android:padding="16dp"
android:gravity="center"
android:layout_marginStart="6dp">
<TextView
android:id="@+id/tvStatMessages"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textColor="@color/accent_purple"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="消息"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:layout_marginTop="4dp" />
</LinearLayout>
</LinearLayout>
<!-- 设备信息卡片 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/card_bg"
android:padding="20dp"
android:layout_marginBottom="12dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="设备信息"
android:textColor="@color/text_primary"
android:textSize="15sp"
android:textStyle="bold"
android:layout_marginBottom="12dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="80dp"
android:layout_height="wrap_content"
android:text="型号"
android:textColor="@color/text_secondary"
android:textSize="13sp" />
<TextView
android:id="@+id/tvDeviceModel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="-"
android:textColor="@color/text_primary"
android:textSize="13sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="80dp"
android:layout_height="wrap_content"
android:text="Android"
android:textColor="@color/text_secondary"
android:textSize="13sp" />
<TextView
android:id="@+id/tvAndroidVersion"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="-"
android:textColor="@color/text_primary"
android:textSize="13sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="80dp"
android:layout_height="wrap_content"
android:text="设备ID"
android:textColor="@color/text_secondary"
android:textSize="13sp" />
<TextView
android:id="@+id/tvDeviceId"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="-"
android:textColor="@color/text_primary"
android:textSize="13sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="80dp"
android:layout_height="wrap_content"
android:text="Root"
android:textColor="@color/text_secondary"
android:textSize="13sp" />
<TextView
android:id="@+id/tvRootStatus"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="检测中..."
android:textColor="@color/accent_green"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
<!-- 服务信息卡片 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/card_bg"
android:padding="20dp"
android:layout_marginBottom="12dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="服务信息"
android:textColor="@color/text_primary"
android:textSize="15sp"
android:textStyle="bold"
android:layout_marginBottom="12dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="80dp"
android:layout_height="wrap_content"
android:text="服务器"
android:textColor="@color/text_secondary"
android:textSize="13sp" />
<TextView
android:id="@+id/tvServerUrl"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="未配置"
android:textColor="@color/text_primary"
android:textSize="13sp"
android:ellipsize="middle"
android:singleLine="true" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="80dp"
android:layout_height="wrap_content"
android:text="项目"
android:textColor="@color/text_secondary"
android:textSize="13sp" />
<TextView
android:id="@+id/tvProjectId"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="未配置"
android:textColor="@color/text_primary"
android:textSize="13sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="80dp"
android:layout_height="wrap_content"
android:text="Hook"
android:textColor="@color/text_secondary"
android:textSize="13sp" />
<TextView
android:id="@+id/tvHookStatus"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="未启用"
android:textColor="@color/text_secondary"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="16dp" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,327 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:scrollbars="none"
android:background="@color/bg_primary">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- 标题 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="设置"
android:textColor="@color/text_primary"
android:textSize="22sp"
android:textStyle="bold"
android:layout_marginBottom="20dp" />
<!-- 连接配置 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/card_bg"
android:padding="20dp"
android:layout_marginBottom="12dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="连接配置"
android:textColor="@color/text_primary"
android:textSize="15sp"
android:textStyle="bold"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="服务器地址"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:layout_marginBottom="6dp" />
<EditText
android:id="@+id/etServerUrl"
android:layout_width="match_parent"
android:layout_height="44dp"
android:background="@drawable/input_bg"
android:hint="ws://192.168.1.100:8899/ws/device"
android:textColorHint="@color/text_tertiary"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:paddingHorizontal="14dp"
android:inputType="textUri"
android:singleLine="true"
android:layout_marginBottom="14dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="项目ID"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:layout_marginBottom="6dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="14dp">
<EditText
android:id="@+id/etProjectId"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_weight="1"
android:background="@drawable/input_bg"
android:hint="输入项目ID"
android:textColorHint="@color/text_tertiary"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:paddingHorizontal="14dp"
android:inputType="text"
android:singleLine="true" />
<ImageButton
android:id="@+id/btnScanQr"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_marginStart="8dp"
android:background="@drawable/btn_action"
android:src="@android:drawable/ic_menu_camera"
android:scaleType="center"
android:tint="@color/text_primary"
android:contentDescription="扫码" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="设备ID"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:layout_marginBottom="6dp" />
<EditText
android:id="@+id/etDeviceId"
android:layout_width="match_parent"
android:layout_height="44dp"
android:background="@drawable/input_bg"
android:hint="自动检测"
android:textColorHint="@color/text_tertiary"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:paddingHorizontal="14dp"
android:inputType="text"
android:singleLine="true"
android:layout_marginBottom="20dp" />
<!-- 连接/断开按钮 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/btnDisconnect"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_weight="1"
android:background="@drawable/btn_action"
android:gravity="center"
android:text="断开连接"
android:textColor="@color/text_secondary"
android:textSize="14sp"
android:layout_marginEnd="6dp" />
<TextView
android:id="@+id/btnConnect"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_weight="1"
android:background="@drawable/btn_primary"
android:gravity="center"
android:text="连接服务器"
android:textColor="@android:color/white"
android:textSize="14sp"
android:layout_marginStart="6dp" />
</LinearLayout>
</LinearLayout>
<!-- 高级设置 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/card_bg"
android:padding="20dp"
android:layout_marginBottom="12dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="高级设置"
android:textColor="@color/text_primary"
android:textSize="15sp"
android:textStyle="bold"
android:layout_marginBottom="16dp" />
<!-- 自动连接 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="自动连接"
android:textColor="@color/text_primary"
android:textSize="14sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="启动时自动连接服务器"
android:textColor="@color/text_secondary"
android:textSize="12sp" />
</LinearLayout>
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switchAutoConnect"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<!-- 无障碍服务 -->
<LinearLayout
android:id="@+id/btnAccessibility"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp"
android:background="?android:attr/selectableItemBackground"
android:padding="2dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="无障碍服务"
android:textColor="@color/text_primary"
android:textSize="14sp" />
<TextView
android:id="@+id/tvAccessibilityStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="未开启"
android:textColor="@color/text_secondary"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="前往设置 "
android:textColor="@color/accent_blue"
android:textSize="13sp" />
</LinearLayout>
<!-- Hook模块 -->
<LinearLayout
android:id="@+id/btnHookModules"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="?android:attr/selectableItemBackground"
android:padding="2dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hook 模块管理"
android:textColor="@color/text_primary"
android:textSize="14sp" />
<TextView
android:id="@+id/tvHookCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0 个模块"
android:textColor="@color/text_secondary"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="管理 "
android:textColor="@color/accent_blue"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
<!-- 关于 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/card_bg"
android:padding="20dp"
android:layout_marginBottom="12dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="关于"
android:textColor="@color/text_primary"
android:textSize="15sp"
android:textStyle="bold"
android:layout_marginBottom="12dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="工作 v2.0.0\n机擎SDK v3.0\n\n基于 Frida + uiautomator2 的智能手机控制系统\n支持微信/抖音/小红书等多平台自动化"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:lineSpacingExtra="3dp" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="32dp" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/nav_dashboard"
android:icon="@drawable/ic_nav_dashboard"
android:title="状态" />
<item
android:id="@+id/nav_control"
android:icon="@drawable/ic_nav_control"
android:title="控制" />
<item
android:id="@+id/nav_ai"
android:icon="@drawable/ic_nav_ai"
android:title="AI" />
<item
android:id="@+id/nav_settings"
android:icon="@drawable/ic_nav_settings"
android:title="设置" />
</menu>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="bg_primary">#0D1117</color>
<color name="bg_surface">#161B22</color>
<color name="bg_card">#21262D</color>
<color name="bg_card_elevated">#30363D</color>
<color name="accent_blue">#58A6FF</color>
<color name="accent_blue_dark">#1F6FEB</color>
<color name="accent_green">#3FB950</color>
<color name="accent_red">#F85149</color>
<color name="accent_orange">#D29922</color>
<color name="accent_purple">#BC8CFF</color>
<color name="text_primary">#E6EDF3</color>
<color name="text_secondary">#8B949E</color>
<color name="text_tertiary">#484F58</color>
<color name="divider">#21262D</color>
<color name="ripple">#1A58A6FF</color>
<color name="nav_bg">#0D1117</color>
<color name="nav_selected">#58A6FF</color>
<color name="nav_unselected">#484F58</color>
<color name="status_online">#3FB950</color>
<color name="status_offline">#F85149</color>
<color name="status_connecting">#D29922</color>
</resources>

View File

@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">工作手机Agent</string>
<string name="app_name">工作</string>
<string name="accessibility_service_description">用于自动化操作,帮助您控制手机应用</string>
<string name="nav_dashboard">状态</string>
<string name="nav_control">控制</string>
<string name="nav_ai">AI</string>
<string name="nav_settings">设置</string>
</resources>

View File

@@ -1,10 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.WorkPhoneAgent" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">#4CAF50</item>
<item name="colorPrimaryVariant">#388E3C</item>
<item name="colorOnPrimary">#FFFFFF</item>
<item name="android:statusBarColor">#121212</item>
<item name="android:navigationBarColor">#121212</item>
<style name="Theme.WorkPhoneAgent" parent="Theme.Material3.Dark.NoActionBar">
<item name="colorPrimary">@color/accent_blue</item>
<item name="colorPrimaryVariant">@color/accent_blue_dark</item>
<item name="colorOnPrimary">@color/text_primary</item>
<item name="colorSecondary">@color/accent_green</item>
<item name="colorSurface">@color/bg_surface</item>
<item name="colorOnSurface">@color/text_primary</item>
<item name="android:colorBackground">@color/bg_primary</item>
<item name="android:windowBackground">@color/bg_primary</item>
<item name="android:statusBarColor">@color/bg_primary</item>
<item name="android:navigationBarColor">@color/bg_primary</item>
<item name="android:textColorPrimary">@color/text_primary</item>
<item name="android:textColorSecondary">@color/text_secondary</item>
</style>
</resources>

View File

@@ -3,3 +3,6 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
systemProp.https.protocols=TLSv1.2,TLSv1.3
org.gradle.internal.http.connectionTimeout=60000
org.gradle.internal.http.socketTimeout=60000

View File

@@ -0,0 +1 @@
sdk.dir=/Users/karuo/Library/Android/sdk

View File

@@ -1,5 +1,8 @@
pluginManagement {
repositories {
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
maven { url 'https://maven.aliyun.com/repository/public' }
maven { url 'https://maven.aliyun.com/repository/google' }
google()
mavenCentral()
gradlePluginPortal()
@@ -8,6 +11,9 @@ pluginManagement {
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
maven { url 'https://maven.aliyun.com/repository/public' }
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/jcenter' }
google()
mavenCentral()
}

View File

@@ -0,0 +1,38 @@
{
"0a43392e0511": {
"supports_hook": false,
"frida_version": "",
"root_status": false,
"hook_framework": "frida-server",
"updated_at": "2026-02-24T08:22:19.881659+00:00",
"modules": []
},
"emulator-5554": {
"supports_hook": false,
"frida_version": "",
"root_status": false,
"modules": [
{
"module_id": "wechat_hook_v1_1.0.0",
"version": "1.0.0",
"status": "loaded",
"loaded_at": "2026-02-24T08:22:14.584229+00:00",
"target_process": "com.tencent.mm",
"target_pid": 0,
"rpc_methods": [],
"last_error": null,
"events_today": 0
}
],
"logs": {
"wechat_hook_v1_1.0.0": [
{
"ts": "2026-02-24T08:22:14.584439+00:00",
"line": "deploy wechat_hook_v1_1.0.0 ok"
}
]
},
"hook_framework": "frida-server",
"updated_at": "2026-02-24T08:22:19.844907+00:00"
}
}

View File

View File

@@ -0,0 +1,25 @@
{
"wechat_hook_v1": {
"module_id": "wechat_hook_v1",
"name": "微信Hook模块",
"version": "1.0.0",
"description": "wechat-hook",
"enabled": true,
"scopes": [
"com.tencent.mm"
],
"capabilities": [
"send_message",
"get_messages",
"get_contacts"
],
"min_frida_version": "16.0.0",
"script_url": "/api/v3/scripts/wechat_hook_v1_1.0.0",
"script_hash": "sha256:8aa69ff325e8d44bffe93fe0b05ba9593f289333b88a870c867ca70d36bd0aae",
"script_id": "wechat_hook_v1_1.0.0",
"device_count": 0,
"error_count": 0,
"created_at": "2026-02-24T08:21:57.099467+00:00",
"updated_at": "2026-02-24T08:22:10.158915+00:00"
}
}

View File

@@ -11,9 +11,10 @@ from contextlib import asynccontextmanager
import logging
import os
import subprocess
import asyncio
from config import settings
from routers import devices, unified, agent, adb, experience, projects, qrcode, voice, capture
from routers import devices, unified, agent, adb, experience, projects, qrcode, voice, capture, hook_modules, connection
from services.ws_hub import ws_hub
from services.device_manager import device_manager
@@ -32,12 +33,25 @@ async def lifespan(app: FastAPI):
logger.info("🚀 工作手机SDK v3.0 启动中...")
await device_manager.init()
logger.info("✅ 数据库连接成功")
heartbeat_task = asyncio.create_task(_heartbeat_sweeper())
yield
# 关闭
logger.info("🛑 工作手机SDK 关闭中...")
heartbeat_task.cancel()
try:
await heartbeat_task
except asyncio.CancelledError:
pass
await device_manager.close()
async def _heartbeat_sweeper():
"""后台心跳巡检任务:清理长时间未上报心跳的设备"""
while True:
await asyncio.sleep(max(5, settings.WS_HEARTBEAT_INTERVAL))
await ws_hub.sweep_stale_devices(timeout_seconds=max(settings.WS_TIMEOUT, settings.WS_HEARTBEAT_INTERVAL * 3))
# 创建FastAPI应用
app = FastAPI(
title="工作手机SDK v3.0",
@@ -65,6 +79,8 @@ app.include_router(projects.router, prefix="/api/v3", tags=["项目管理"])
app.include_router(qrcode.router, prefix="/api/v3", tags=["二维码"])
app.include_router(voice.router, prefix="/api/v3", tags=["语音控制"])
app.include_router(capture.router, tags=["抓包"])
app.include_router(hook_modules.router, prefix="/api/v3", tags=["Hook模块管理"])
app.include_router(connection.router, prefix="/api/v3", tags=["连接协议"])
# ========== 健康检查 ==========

View File

@@ -1,2 +1,2 @@
"""路由模块"""
from . import devices, unified, agent, adb, experience, projects, qrcode, voice, capture
from . import devices, unified, agent, adb, experience, projects, qrcode, voice, capture, hook_modules, connection

View File

@@ -0,0 +1,197 @@
"""
连接协议与状态路由
用于展示设备端Agent/Hook与服务端的连接方式、消息协议和实时状态。
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from services.ws_hub import ws_hub
from services.adb_device import adb_manager
from services.hook_module_service import hook_module_service
router = APIRouter()
class SimRegisterRequest(BaseModel):
device_id: str
project_id: str = "cunkebao"
model: str = "Simulator Device"
platform: str = "android"
capabilities: List[str] = Field(default_factory=lambda: ["event", "device_request"])
class SimHeartbeatRequest(BaseModel):
device_id: str
class SimHookEventRequest(BaseModel):
device_id: str
platform: str = "wechat"
event_type: str = "message_received"
payload: Dict[str, Any] = Field(default_factory=dict)
def _iso_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _heartbeat_age_seconds(last_heartbeat: str) -> int:
if not last_heartbeat:
return -1
try:
dt = datetime.fromisoformat(last_heartbeat.replace("Z", "+00:00"))
return max(0, int((datetime.now(dt.tzinfo or timezone.utc) - dt).total_seconds()))
except Exception:
return -1
@router.get("/connection/protocol")
async def get_connection_protocol() -> Dict[str, Any]:
"""连接方式协议:给前端/对接方直观看结构。"""
return {
"code": 200,
"data": {
"version": "v1",
"generated_at": _iso_now(),
"transport": {
"agent_ws": "ws://<host>:8899/ws/device/{device_id}",
"hook_events_ws": "ws://<host>:8899/api/v3/hook/events/stream",
"rest_base": "http://<host>:8899/api/v3",
},
"handshake": {
"client_register": {
"type": "register",
"data": {
"project_id": "cunkebao",
"agent_version": "3.0.0",
"model": "Redmi 11",
"capabilities": ["u2", "skill_wechat", "event", "device_request"],
},
},
"server_ack": {
"type": "registered",
"success": True,
"device_id": "<device_id>",
"project_id": "<project_id>",
},
},
"heartbeat": {
"client_ping_type": "heartbeat",
"server_pong_type": "pong",
"recommended_interval_seconds": [5, 10, 30],
},
"message_types": {
"device_to_server": [
"register",
"heartbeat",
"response",
"status_report",
"event",
"device_request",
],
"server_to_device": [
"registered",
"pong",
"execute",
"agent_execute",
"config_update",
"device_request_ack",
],
},
"hook_rest": {
"list_modules": "GET /api/v3/modules",
"device_modules": "GET /api/v3/devices/{device_id}/modules",
"deploy_script": "POST /api/v3/scripts/{script_id}/deploy",
"ingest_event": "POST /api/v3/hook/events",
},
},
}
@router.get("/connection/status")
async def get_connection_status() -> Dict[str, Any]:
"""实时连接状态:用于控制台展示。"""
online_devices: List[Dict[str, Any]] = ws_hub.get_online_devices()
adb_devices = adb_manager.scan_devices()
device_rows: List[Dict[str, Any]] = []
for item in online_devices:
device_id = item.get("device_id", "")
last_heartbeat = item.get("last_heartbeat", "")
device_rows.append(
{
"device_id": device_id,
"project_id": item.get("project_id", ""),
"model": item.get("model", ""),
"platform": item.get("platform", ""),
"status": item.get("status", "online"),
"last_heartbeat": last_heartbeat,
"heartbeat_age_seconds": _heartbeat_age_seconds(last_heartbeat),
"capabilities": item.get("capabilities", []),
}
)
return {
"code": 200,
"data": {
"server_time": _iso_now(),
"ws_path": "/ws/device/{device_id}",
"hook_events_stream": "/api/v3/hook/events/stream",
"online_ws_count": len(ws_hub.connections),
"online_device_ids": list(ws_hub.connections.keys()),
"adb_count": len(adb_devices),
"adb_serials": adb_devices,
"devices": device_rows,
},
}
@router.post("/connection/simulate/register")
async def simulate_register(req: SimRegisterRequest) -> Dict[str, Any]:
"""协议联调:模拟设备 register不建立真实 WS仅写入状态面板"""
await ws_hub.handle_message(
req.device_id,
{
"type": "register",
"data": {
"device_id": req.device_id,
"project_id": req.project_id,
"model": req.model,
"platform": req.platform,
"status": "simulated",
"capabilities": req.capabilities,
"source": "connection_simulator",
},
},
)
return {"code": 200, "data": {"device_id": req.device_id, "simulated": True, "action": "register"}}
@router.post("/connection/simulate/heartbeat")
async def simulate_heartbeat(req: SimHeartbeatRequest) -> Dict[str, Any]:
"""协议联调:模拟心跳。"""
if not ws_hub.get_device_info(req.device_id):
raise HTTPException(status_code=404, detail="设备未注册,先模拟 register")
await ws_hub.handle_message(req.device_id, {"type": "heartbeat", "device_id": req.device_id})
return {"code": 200, "data": {"device_id": req.device_id, "simulated": True, "action": "heartbeat"}}
@router.post("/connection/simulate/hook-event")
async def simulate_hook_event(req: SimHookEventRequest) -> Dict[str, Any]:
"""协议联调:模拟 hook 事件写入并广播到事件流。"""
event = await hook_module_service.add_event(
{
"event_type": req.event_type,
"device_id": req.device_id,
"platform": req.platform,
"payload": req.payload,
"source": "connection_simulator",
}
)
return {"code": 200, "data": event}

View File

@@ -59,6 +59,11 @@ class SwipeRequest(BaseModel):
scale: float = 0.8
class HeartbeatConfigRequest(BaseModel):
"""心跳配置请求"""
heartbeat_interval_seconds: int
# ========== 设备列表 ==========
@router.get("/devices", response_model=dict)
@@ -153,6 +158,30 @@ async def get_device(device_id: str):
return {"code": 200, "data": device}
@router.get("/devices/{device_id}/heartbeat", response_model=dict)
async def get_device_heartbeat(device_id: str):
"""查询设备心跳状态"""
status = ws_hub.get_heartbeat_status(device_id)
if not status:
raise HTTPException(status_code=404, detail="设备不存在或未上报心跳")
return {"code": 200, "data": status}
@router.post("/devices/{device_id}/heartbeat/config", response_model=dict)
async def set_device_heartbeat(device_id: str, req: HeartbeatConfigRequest):
"""下发设备心跳配置5-120 秒)"""
ok = await ws_hub.set_heartbeat_interval(device_id, req.heartbeat_interval_seconds)
if not ok:
raise HTTPException(status_code=400, detail="heartbeat_interval_seconds 必须在 5-120 之间")
return {
"code": 200,
"data": {
"device_id": device_id,
"heartbeat_interval_seconds": req.heartbeat_interval_seconds,
},
}
# ========== 设备控制 ==========
@router.post("/devices/{device_id}/screenshot", response_model=dict)

View File

@@ -0,0 +1,247 @@
"""
Hook 模块管理路由
对标文档:开发文档/5、接口/Hook模块管理接口.md
"""
from __future__ import annotations
from fastapi import APIRouter, File, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from typing import List, Optional
from services.adb_device import adb_manager
from services.hook_module_service import hook_module_service
router = APIRouter()
class ModuleUpsertRequest(BaseModel):
module_id: str
name: str
version: str = "1.0.0"
description: str = ""
scopes: List[str] = Field(default_factory=lambda: ["com.tencent.mm"])
capabilities: List[str] = Field(default_factory=list)
min_frida_version: str = "16.0.0"
script_content: Optional[str] = None
enabled: bool = True
class ScopeUpdateRequest(BaseModel):
scopes: List[str]
class DeployScriptRequest(BaseModel):
device_ids: List[str]
auto_reload: bool = True
class ReloadDeviceModulesRequest(BaseModel):
module_ids: List[str]
force: bool = False
def _probe_hook_ready(device_id: str) -> dict:
dev = adb_manager.get_device(device_id)
if not dev or not dev.is_online():
return {
"supports_hook": False,
"root_status": False,
"frida_version": "",
"detail": "device offline",
}
root_status = False
frida_version = ""
detail = "ok"
try:
uid = dev._shell("id -u", timeout=4)
su_path = dev._shell("which su", timeout=4)
root_status = (uid.strip() == "0") or bool(su_path.strip())
except Exception:
pass
try:
frida_version = dev._shell("frida-server --version", timeout=4).strip()
except Exception:
frida_version = ""
supports_hook = bool(root_status and frida_version)
if not root_status:
detail = "root unavailable"
elif not frida_version:
detail = "frida-server unavailable"
return {
"supports_hook": supports_hook,
"root_status": root_status,
"frida_version": frida_version,
"detail": detail,
}
@router.get("/modules")
async def list_modules(enabled: Optional[bool] = None, scope: Optional[str] = None):
modules = await hook_module_service.list_modules(enabled=enabled, scope=scope)
return {"code": 200, "data": {"total": len(modules), "modules": modules}}
@router.post("/modules")
async def create_module(req: ModuleUpsertRequest):
data = await hook_module_service.upsert_module(req.model_dump())
return {"code": 200, "data": data}
@router.get("/modules/{module_id}")
async def get_module(module_id: str):
item = await hook_module_service.get_module(module_id)
if not item:
raise HTTPException(status_code=404, detail="模块不存在")
return {"code": 200, "data": item}
@router.put("/modules/{module_id}")
async def update_module(module_id: str, req: ModuleUpsertRequest):
payload = req.model_dump()
payload["module_id"] = module_id
data = await hook_module_service.upsert_module(payload)
return {"code": 200, "data": data}
@router.delete("/modules/{module_id}")
async def delete_module(module_id: str):
ok = await hook_module_service.delete_module(module_id)
if not ok:
raise HTTPException(status_code=404, detail="模块不存在")
return {"code": 200, "data": {"module_id": module_id, "deleted": True}}
@router.put("/modules/{module_id}/scope")
async def set_module_scope(module_id: str, req: ScopeUpdateRequest):
item = await hook_module_service.set_scope(module_id, req.scopes)
if not item:
raise HTTPException(status_code=404, detail="模块不存在")
return {"code": 200, "data": {"module_id": module_id, "scopes": item.get("scopes", [])}}
@router.post("/modules/{module_id}/enable")
async def enable_module(module_id: str):
item = await hook_module_service.set_enabled(module_id, True)
if not item:
raise HTTPException(status_code=404, detail="模块不存在")
return {"code": 200, "data": {"module_id": module_id, "enabled": True, "affected_devices": item.get("device_count", 0)}}
@router.post("/modules/{module_id}/disable")
async def disable_module(module_id: str):
item = await hook_module_service.set_enabled(module_id, False)
if not item:
raise HTTPException(status_code=404, detail="模块不存在")
return {"code": 200, "data": {"module_id": module_id, "enabled": False}}
@router.get("/devices/{device_id}/modules")
async def get_device_modules(device_id: str):
probe = _probe_hook_ready(device_id)
await hook_module_service.update_device_probe(device_id, probe)
data = await hook_module_service.get_device_modules(device_id)
data["supports_hook"] = probe["supports_hook"]
data["frida_version"] = probe["frida_version"]
data["root_status"] = probe["root_status"]
data["probe_detail"] = probe["detail"]
return {"code": 200, "data": data}
@router.post("/devices/{device_id}/modules/reload")
async def reload_device_modules(device_id: str, req: ReloadDeviceModulesRequest):
result = await hook_module_service.reload_device_modules(device_id, req.module_ids, force=req.force)
return {"code": 200, "data": result}
@router.get("/devices/{device_id}/modules/{module_id}/logs")
async def get_device_module_logs(device_id: str, module_id: str):
logs = await hook_module_service.get_device_logs(device_id, module_id)
return {"code": 200, "data": {"device_id": device_id, "module_id": module_id, "logs": logs}}
@router.get("/scripts")
async def list_scripts():
scripts = await hook_module_service.list_scripts()
return {"code": 200, "data": {"total": len(scripts), "scripts": scripts}}
@router.post("/scripts")
async def upload_script(
file: UploadFile = File(...),
module_id: str = "wechat_hook_v1",
version: str = "1.0.0",
description: str = "",
):
raw = await file.read()
script_id = f"{module_id}_{version}"
saved = await hook_module_service.save_script(script_id, raw)
await hook_module_service.upsert_module(
{
"module_id": module_id,
"name": "微信Hook模块" if "wechat" in module_id else module_id,
"version": version,
"description": description or "Hook脚本上传",
"scopes": ["com.tencent.mm"] if "wechat" in module_id else [],
"capabilities": ["send_message", "get_messages", "get_contacts"] if "wechat" in module_id else [],
"min_frida_version": "16.0.0",
"enabled": True,
"script_id": script_id,
"script_url": saved["url"],
"script_hash": saved["hash"],
}
)
return {"code": 200, "data": saved}
@router.get("/scripts/{script_id}")
async def download_script(script_id: str):
path = await hook_module_service.get_script_path(script_id)
if not path:
raise HTTPException(status_code=404, detail="脚本不存在")
return FileResponse(path, media_type="application/javascript", filename=path.name)
@router.post("/scripts/{script_id}/deploy")
async def deploy_script(script_id: str, req: DeployScriptRequest):
result = await hook_module_service.deploy_script(script_id, req.device_ids, auto_reload=req.auto_reload)
for did in result["deployed"]:
await hook_module_service.add_device_log(did, script_id, f"deploy {script_id} ok")
return {"code": 200, "data": result}
@router.get("/hook/events")
async def list_hook_events(
event_type: Optional[str] = None,
device_id: Optional[str] = None,
platform: Optional[str] = None,
limit: int = 100,
):
events = await hook_module_service.list_events(
event_type=event_type,
device_id=device_id,
platform=platform,
limit=max(1, min(limit, 500)),
)
return {"code": 200, "data": {"total": len(events), "events": events}}
@router.post("/hook/events")
async def ingest_hook_event(payload: dict):
event = await hook_module_service.add_event(payload)
return {"code": 200, "data": event}
@router.websocket("/hook/events/stream")
async def hook_event_stream(websocket: WebSocket):
await websocket.accept()
await hook_module_service.attach_ws_client(websocket)
try:
while True:
# 订阅接口支持客户端心跳/控制消息,当前不做强校验
_ = await websocket.receive_text()
except WebSocketDisconnect:
await hook_module_service.detach_ws_client(websocket)
except Exception:
await hook_module_service.detach_ws_client(websocket)

View File

@@ -56,6 +56,7 @@ class Channel(str, Enum):
OFFICIAL_API = "official_api"
SDK_CONTROL = "sdk_control"
AI_AGENT = "ai_agent"
HOOK = "hook"
# ========== 消息相关模型 ==========
@@ -70,6 +71,8 @@ class SendMessageRequest(BaseModel):
media_url: Optional[str] = None
at_list: Optional[List[str]] = None # @列表(群聊时使用)
timeout_seconds: Optional[int] = None # 本次请求超时(秒),不传则用 MESSAGE_SEND_TIMEOUT
channel: Optional[str] = None # 强制通道hook/sdk_control/ai_agent/official_api
hook_config: Optional[dict] = None # Hook配置script_id/method/timeout
class SendMessageResponse(BaseModel):
@@ -434,6 +437,7 @@ async def _execute_via_adb(device, platform: str, action: str, params: dict) ->
"douyin": "com.ss.android.ugc.aweme",
"xhs": "com.xingin.xhs",
"xianyu": "com.taobao.idlefish",
"soul": "cn.soulapp.android",
}
package = app_packages.get(platform)
@@ -521,12 +525,21 @@ async def send_message(req: SendMessageRequest):
"""
mode = _get_device_mode(req.device_id)
device_online = mode != "offline"
channel = ChannelRouter.route(req.platform, "send_message", device_online)
forced_channel = (req.channel or "").strip().lower()
if forced_channel:
try:
channel = Channel(forced_channel)
except ValueError:
raise HTTPException(status_code=400, detail=f"无效 channel: {req.channel}")
else:
channel = ChannelRouter.route(req.platform, "send_message", device_online)
logger.info(f"[message/send] device_id={req.device_id} platform={req.platform.value} to_id={req.to_id} channel={channel.value}")
try:
if channel == Channel.OFFICIAL_API:
result = await _send_via_official_api(req)
elif channel == Channel.HOOK:
result = await _send_via_hook(req)
elif channel == Channel.SDK_CONTROL:
result = await _send_via_sdk(req)
else:
@@ -1039,28 +1052,48 @@ async def get_users_by_tag(req: GetUsersByTagRequest):
@router.post("/moments/post", response_model=dict, tags=["朋友圈管理"])
async def post_moments(req: PostMomentsRequest):
"""发布朋友圈"""
_check_device_online(req.device_id)
result = await _execute_skill(
req.device_id, req.platform.value, "post_moments",
{
"content": req.content,
"images": req.images,
"video_url": req.video_url,
"location": req.location,
"visible_list": req.visible_list,
"invisible_list": req.invisible_list
},
timeout=60 # 发布朋友圈可能需要较长时间
)
return {
"code": 200,
"data": result.get("data", {}),
"channel_used": Channel.SDK_CONTROL.value
}
"""发布朋友圈/瞬间(统一返回 200业务失败用 success=false 表示,避免 502"""
try:
_check_device_online(req.device_id)
except HTTPException:
raise
try:
result = await _execute_skill(
req.device_id, req.platform.value, "post_moments",
{
"content": req.content,
"images": req.images,
"video_url": req.video_url,
"location": req.location,
"visible_list": req.visible_list,
"invisible_list": req.invisible_list
},
timeout=60 # 发布朋友圈可能需要较长时间
)
rc = result.get("code", 200)
if rc != 200:
return {
"code": 200,
"success": False,
"error": result.get("message", result.get("error", "发布失败")),
"data": result.get("data", {}),
"channel_used": Channel.SDK_CONTROL.value
}
return {
"code": 200,
"success": True,
"data": result.get("data", {}),
"channel_used": Channel.SDK_CONTROL.value
}
except Exception as e:
logger.exception(f"moments/post 异常: {e}")
return {
"code": 200,
"success": False,
"error": str(e),
"data": {},
"channel_used": Channel.SDK_CONTROL.value
}
@router.post("/moments/like", response_model=dict, tags=["朋友圈管理"])
@@ -1187,6 +1220,23 @@ async def _send_via_official_api(req: SendMessageRequest) -> dict:
return {"success": False, "error": err}
async def _send_via_hook(req: SendMessageRequest) -> dict:
"""
Hook 通道发送(首版)
- 已兼容 channel=hook / hook_config 调用方式
- 当前先复用 SDK 操作执行,保证业务可用
- 后续接入 Frida RPC 后替换为真正 Hook RPC 调用
"""
# 透传 hook 配置,便于日志和后续路由
script_id = (req.hook_config or {}).get("script_id")
method = (req.hook_config or {}).get("method", "send_message")
logger.info(f"[_send_via_hook] script_id={script_id} method={method} device_id={req.device_id}")
result = await _send_via_sdk(req)
if result.get("success"):
result["channel_used"] = "hook"
return result
async def _send_via_sdk(req: SendMessageRequest) -> dict:
"""通过SDK控制发送WebSocket或ADB超时由请求 timeout_seconds 或 MESSAGE_SEND_TIMEOUT 控制"""
params = {

View File

@@ -113,13 +113,17 @@ class DeviceManager:
"""记录命令执行日志"""
if self.db is None:
return
await self.db.commands.insert_one({
"device_id": device_id,
"command_type": command_type,
"params": params,
"result": result,
"created_at": datetime.now()
})
try:
await self.db.commands.insert_one({
"device_id": device_id,
"command_type": command_type,
"params": params,
"result": result,
"created_at": datetime.now()
})
except Exception as e:
# Mongo 无权限/不可用时不阻断主流程
logger.warning(f"命令日志写入失败(已跳过): {e}")
async def get_command_logs(
self,
@@ -129,11 +133,15 @@ class DeviceManager:
"""获取命令日志"""
if self.db is None:
return []
cursor = self.db.commands.find(
{"device_id": device_id},
{"_id": 0}
).sort("created_at", -1).limit(limit)
return await cursor.to_list(length=limit)
try:
cursor = self.db.commands.find(
{"device_id": device_id},
{"_id": 0}
).sort("created_at", -1).limit(limit)
return await cursor.to_list(length=limit)
except Exception as e:
logger.warning(f"命令日志查询失败(已跳过): {e}")
return []
# 全局实例

View File

@@ -0,0 +1,350 @@
"""
Hook 模块管理服务
实现能力:
1. 模块管理(增删改查/启停/scope
2. 脚本管理(保存/下载/部署)
3. 设备模块状态(加载/重载/日志)
4. Hook 事件总线(历史 + 实时订阅)
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
class HookModuleService:
def __init__(self) -> None:
app_dir = Path(__file__).resolve().parent.parent
self.data_dir = app_dir / "data" / "hook"
self.scripts_dir = self.data_dir / "scripts"
self.modules_file = self.data_dir / "modules.json"
self.events_file = self.data_dir / "events.jsonl"
self.device_state_file = self.data_dir / "device_modules.json"
self._lock = asyncio.Lock()
self._event_clients: Set[Any] = set()
self._ensure_store()
def _ensure_store(self) -> None:
self.scripts_dir.mkdir(parents=True, exist_ok=True)
if not self.modules_file.exists():
self.modules_file.write_text("{}", encoding="utf-8")
if not self.device_state_file.exists():
self.device_state_file.write_text("{}", encoding="utf-8")
if not self.events_file.exists():
self.events_file.write_text("", encoding="utf-8")
def _read_json(self, path: Path, default: Any) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return default
def _write_json(self, path: Path, data: Any) -> None:
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
async def list_modules(self, enabled: Optional[bool] = None, scope: Optional[str] = None) -> List[dict]:
async with self._lock:
modules = self._read_json(self.modules_file, {})
result = list(modules.values())
if enabled is not None:
result = [m for m in result if bool(m.get("enabled")) == enabled]
if scope:
result = [m for m in result if scope in (m.get("scopes") or [])]
return sorted(result, key=lambda x: x.get("updated_at", ""), reverse=True)
async def get_module(self, module_id: str) -> Optional[dict]:
async with self._lock:
modules = self._read_json(self.modules_file, {})
return modules.get(module_id)
async def upsert_module(self, payload: dict) -> dict:
module_id = payload["module_id"]
now = _now_iso()
script_content = payload.pop("script_content", None)
script_url = payload.get("script_url")
script_hash = payload.get("script_hash")
if script_content and not script_url:
decoded = base64.b64decode(script_content)
script_name = f"{module_id}.js"
target = self.scripts_dir / script_name
target.write_bytes(decoded)
digest = hashlib.sha256(decoded).hexdigest()
script_url = f"/api/v3/scripts/{module_id}"
script_hash = f"sha256:{digest}"
async with self._lock:
modules = self._read_json(self.modules_file, {})
old = modules.get(module_id, {})
data = {
"module_id": module_id,
"name": payload.get("name", old.get("name", module_id)),
"version": payload.get("version", old.get("version", "1.0.0")),
"description": payload.get("description", old.get("description", "")),
"enabled": bool(payload.get("enabled", old.get("enabled", True))),
"scopes": payload.get("scopes", old.get("scopes", ["com.tencent.mm"])),
"capabilities": payload.get("capabilities", old.get("capabilities", [])),
"min_frida_version": payload.get("min_frida_version", old.get("min_frida_version", "16.0.0")),
"script_url": script_url or old.get("script_url"),
"script_hash": script_hash or old.get("script_hash"),
"script_id": payload.get("script_id", old.get("script_id", module_id)),
"device_count": old.get("device_count", 0),
"error_count": old.get("error_count", 0),
"created_at": old.get("created_at", now),
"updated_at": now,
}
modules[module_id] = data
self._write_json(self.modules_file, modules)
return data
async def delete_module(self, module_id: str) -> bool:
async with self._lock:
modules = self._read_json(self.modules_file, {})
if module_id not in modules:
return False
del modules[module_id]
self._write_json(self.modules_file, modules)
return True
async def set_scope(self, module_id: str, scopes: List[str]) -> Optional[dict]:
async with self._lock:
modules = self._read_json(self.modules_file, {})
item = modules.get(module_id)
if not item:
return None
item["scopes"] = scopes
item["updated_at"] = _now_iso()
modules[module_id] = item
self._write_json(self.modules_file, modules)
return item
async def set_enabled(self, module_id: str, enabled: bool) -> Optional[dict]:
async with self._lock:
modules = self._read_json(self.modules_file, {})
item = modules.get(module_id)
if not item:
return None
item["enabled"] = enabled
item["updated_at"] = _now_iso()
modules[module_id] = item
self._write_json(self.modules_file, modules)
return item
async def list_scripts(self) -> List[dict]:
out: List[dict] = []
for p in sorted(self.scripts_dir.glob("*.js"), key=lambda x: x.stat().st_mtime, reverse=True):
raw = p.read_bytes()
out.append(
{
"script_id": p.stem,
"filename": p.name,
"size": len(raw),
"hash": f"sha256:{hashlib.sha256(raw).hexdigest()}",
"url": f"/api/v3/scripts/{p.stem}",
"updated_at": datetime.fromtimestamp(p.stat().st_mtime, tz=timezone.utc).isoformat(),
}
)
return out
async def save_script(self, script_id: str, content: bytes) -> dict:
target = self.scripts_dir / f"{script_id}.js"
target.write_bytes(content)
digest = hashlib.sha256(content).hexdigest()
return {
"script_id": script_id,
"url": f"/api/v3/scripts/{script_id}",
"hash": f"sha256:{digest}",
"size": len(content),
}
async def get_script_path(self, script_id: str) -> Optional[Path]:
target = self.scripts_dir / f"{script_id}.js"
return target if target.exists() else None
async def get_device_modules(self, device_id: str) -> dict:
async with self._lock:
state = self._read_json(self.device_state_file, {})
item = state.get(device_id, {})
modules = item.get("modules", [])
return {
"device_id": device_id,
"supports_hook": item.get("supports_hook", False),
"frida_version": item.get("frida_version", ""),
"hook_framework": item.get("hook_framework", "frida-server"),
"root_status": item.get("root_status", False),
"modules": modules,
}
async def update_device_probe(self, device_id: str, probe: dict) -> None:
async with self._lock:
state = self._read_json(self.device_state_file, {})
item = state.get(device_id, {})
item.update(
{
"supports_hook": probe.get("supports_hook", False),
"frida_version": probe.get("frida_version", ""),
"root_status": probe.get("root_status", False),
"hook_framework": "frida-server",
"updated_at": _now_iso(),
}
)
item.setdefault("modules", [])
state[device_id] = item
self._write_json(self.device_state_file, state)
async def deploy_script(self, script_id: str, device_ids: List[str], auto_reload: bool = True) -> dict:
async with self._lock:
state = self._read_json(self.device_state_file, {})
deployed: List[str] = []
failed: List[dict] = []
reloaded: List[str] = []
now = _now_iso()
for did in device_ids:
item = state.setdefault(
did,
{"supports_hook": False, "frida_version": "", "root_status": False, "modules": []},
)
modules = item.setdefault("modules", [])
exists = None
for m in modules:
if m.get("module_id") == script_id:
exists = m
break
if exists:
exists["status"] = "loaded"
exists["loaded_at"] = now
else:
modules.append(
{
"module_id": script_id,
"version": "1.0.0",
"status": "loaded",
"loaded_at": now,
"target_process": "com.tencent.mm",
"target_pid": 0,
"rpc_methods": [],
"last_error": None,
"events_today": 0,
}
)
deployed.append(did)
if auto_reload:
reloaded.append(did)
self._write_json(self.device_state_file, state)
return {"deployed": deployed, "failed": failed, "reloaded": reloaded}
async def reload_device_modules(self, device_id: str, module_ids: List[str], force: bool = False) -> dict:
async with self._lock:
state = self._read_json(self.device_state_file, {})
item = state.get(device_id)
if not item:
return {"reloaded": [], "failed": module_ids}
mods = item.get("modules", [])
found, failed = [], []
now = _now_iso()
for mid in module_ids:
hit = None
for m in mods:
if m.get("module_id") == mid:
hit = m
break
if hit:
hit["status"] = "loaded"
hit["loaded_at"] = now
if force:
hit["last_error"] = None
found.append(mid)
else:
failed.append(mid)
item["modules"] = mods
state[device_id] = item
self._write_json(self.device_state_file, state)
return {"reloaded": found, "failed": failed}
async def add_device_log(self, device_id: str, module_id: str, line: str) -> None:
async with self._lock:
state = self._read_json(self.device_state_file, {})
item = state.setdefault(device_id, {"modules": []})
logs = item.setdefault("logs", {})
bucket = logs.setdefault(module_id, [])
bucket.append({"ts": _now_iso(), "line": line})
if len(bucket) > 300:
logs[module_id] = bucket[-300:]
state[device_id] = item
self._write_json(self.device_state_file, state)
async def get_device_logs(self, device_id: str, module_id: str) -> List[dict]:
async with self._lock:
state = self._read_json(self.device_state_file, {})
item = state.get(device_id, {})
logs = item.get("logs", {}).get(module_id, [])
return logs[-200:]
async def add_event(self, event: dict) -> dict:
data = {"event_id": f"evt_{int(datetime.now().timestamp()*1000)}", **event}
if "timestamp" not in data:
data["timestamp"] = _now_iso()
async with self._lock:
with self.events_file.open("a", encoding="utf-8") as f:
f.write(json.dumps(data, ensure_ascii=False) + "\n")
await self._broadcast_event({"type": "hook_event", "data": data})
return data
async def list_events(
self,
event_type: Optional[str] = None,
device_id: Optional[str] = None,
platform: Optional[str] = None,
limit: int = 100,
) -> List[dict]:
events: List[dict] = []
if not self.events_file.exists():
return events
lines = self.events_file.read_text(encoding="utf-8").splitlines()[-2000:]
for line in reversed(lines):
if len(events) >= limit:
break
try:
item = json.loads(line)
except Exception:
continue
if event_type and item.get("event_type") != event_type:
continue
if device_id and item.get("device_id") != device_id:
continue
if platform and item.get("platform") != platform:
continue
events.append(item)
return events
async def attach_ws_client(self, ws: Any) -> None:
self._event_clients.add(ws)
async def detach_ws_client(self, ws: Any) -> None:
self._event_clients.discard(ws)
async def _broadcast_event(self, payload: dict) -> None:
if not self._event_clients:
return
dead = []
for ws in list(self._event_clients):
try:
await ws.send_json(payload)
except Exception:
dead.append(ws)
for ws in dead:
self._event_clients.discard(ws)
hook_module_service = HookModuleService()

View File

@@ -10,6 +10,7 @@ import json
import uuid
import logging
from datetime import datetime
from config import settings
logger = logging.getLogger(__name__)
@@ -40,6 +41,8 @@ class WebSocketHub:
# 消息处理器
self.message_handlers: Dict[str, Callable] = {}
# 设备心跳配置: device_id -> interval_seconds
self.heartbeat_config: Dict[str, int] = {}
async def connect(self, websocket: WebSocket, device_id: str):
"""设备连接"""
@@ -77,6 +80,8 @@ class WebSocketHub:
device_data = data.get("data") or data
project_id = device_data.get("project_id", "")
heartbeat_interval = int(device_data.get("heartbeat_interval_seconds") or settings.WS_HEARTBEAT_INTERVAL)
heartbeat_interval = max(5, min(120, heartbeat_interval))
now_iso = datetime.now().isoformat()
self.device_info[device_id] = {
@@ -85,8 +90,10 @@ class WebSocketHub:
"project_id": project_id,
"status": "online",
"connected_at": now_iso,
"last_heartbeat": now_iso
"last_heartbeat": now_iso,
"heartbeat_interval_seconds": heartbeat_interval,
}
self.heartbeat_config[device_id] = heartbeat_interval
try:
dm = _get_device_manager()
await dm.register_device({
@@ -115,7 +122,8 @@ class WebSocketHub:
"type": "registered",
"success": True,
"device_id": device_id,
"project_id": project_id
"project_id": project_id,
"heartbeat_interval_seconds": heartbeat_interval,
})
elif msg_type == "heartbeat":
@@ -128,9 +136,25 @@ class WebSocketHub:
logger.debug(f"心跳落库: {e}")
await self.send_to_device(device_id, {"type": "pong"})
elif msg_type == "response":
elif msg_type in ("response", "result"):
# 命令响应
# - Python Agent: {type:"response", code, message, data}
# - Android App: {type:"result", success, message}
command_id = data.get("command_id")
if not command_id:
return
if msg_type == "result":
# 统一成 response 结构,便于 send_command 等待处理
success = bool(data.get("success"))
data = {
"type": "response",
"command_id": command_id,
"device_id": device_id,
"code": 200 if success else 500,
"message": data.get("message") or ("success" if success else "failed"),
"data": data.get("data") or {},
"timestamp": data.get("timestamp"),
}
if command_id in self.pending_commands:
future = self.pending_commands.pop(command_id)
if not future.done():
@@ -177,11 +201,16 @@ class WebSocketHub:
"""处理设备端 device_request返回要下发给设备的 ack可选"""
if action == "get_config":
# 返回设备所需配置(可由业务扩展)
interval = self.heartbeat_config.get(device_id, settings.WS_HEARTBEAT_INTERVAL)
return {
"type": "device_request_ack",
"request_id": request_id,
"success": True,
"data": {"heartbeat_interval": 30},
"data": {
"heartbeat_interval": interval,
"heartbeat_interval_seconds": interval,
"ws_timeout_seconds": settings.WS_TIMEOUT,
},
}
if action == "log_result":
# 仅记录日志,可选落库由 message_handlers 扩展
@@ -212,6 +241,59 @@ class WebSocketHub:
logger.error(f"发送失败 [{device_id}]: {e}")
await self.disconnect(device_id)
return False
def get_heartbeat_status(self, device_id: str) -> Optional[dict]:
info = self.device_info.get(device_id)
if not info:
return None
last = info.get("last_heartbeat")
if not last:
return None
try:
dt = datetime.fromisoformat(last)
age = max(0, int((datetime.now() - dt).total_seconds()))
except Exception:
age = -1
interval = int(info.get("heartbeat_interval_seconds") or self.heartbeat_config.get(device_id, settings.WS_HEARTBEAT_INTERVAL))
return {
"device_id": device_id,
"online": self.is_online(device_id),
"last_heartbeat": last,
"heartbeat_interval_seconds": interval,
"heartbeat_age_seconds": age,
"stale": age >= (interval * 3) if age >= 0 else True,
}
async def set_heartbeat_interval(self, device_id: str, interval_seconds: int) -> bool:
if interval_seconds < 5 or interval_seconds > 120:
return False
self.heartbeat_config[device_id] = interval_seconds
if device_id in self.device_info:
self.device_info[device_id]["heartbeat_interval_seconds"] = interval_seconds
if self.is_online(device_id):
await self.send_to_device(device_id, {
"type": "config",
"heartbeat_interval_seconds": interval_seconds,
})
return True
async def sweep_stale_devices(self, timeout_seconds: int):
# 主动清理超时连接,避免设备长时间假在线
to_drop = []
now = datetime.now()
for device_id, info in list(self.device_info.items()):
last = info.get("last_heartbeat")
if not last:
continue
try:
age = (now - datetime.fromisoformat(last)).total_seconds()
except Exception:
age = timeout_seconds + 1
if age > timeout_seconds:
to_drop.append(device_id)
for device_id in to_drop:
logger.warning(f"⏱️ 心跳超时下线: {device_id}")
await self.disconnect(device_id)
async def send_command(
self,
@@ -228,6 +310,22 @@ class WebSocketHub:
command_id = str(uuid.uuid4())
command["command_id"] = command_id
command["timestamp"] = int(datetime.now().timestamp())
# Android App 协议兼容execute 需要扁平 action/params
try:
info = self.device_info.get(device_id) or {}
platform = (info.get("platform") or "").lower()
if platform == "android" and command.get("type") == "execute":
payload = command.get("data") or {}
if isinstance(payload, dict):
action = payload.get("action")
params = payload.get("params") or {}
if action:
command.pop("data", None)
command["action"] = action
command["params"] = params
except Exception:
pass
# 创建Future等待响应
future = asyncio.get_event_loop().create_future()

View File

@@ -144,6 +144,30 @@ body {
.ai-step-ok { color: var(--green); }
.ai-step-fail { color: var(--red); }
/* 连接协议面板 */
.protocol-box {
background: rgba(0,0,0,0.28);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 12px;
padding: 12px;
font-size: 12px;
line-height: 1.6;
}
.protocol-line { color: var(--text-secondary); margin-bottom: 4px; }
.protocol-line b { color: var(--text); font-weight: 600; }
.mini-table {
width: 100%;
margin-top: 8px;
border-collapse: collapse;
font-size: 11px;
}
.mini-table th, .mini-table td {
border-bottom: 1px solid rgba(255,255,255,0.08);
padding: 6px 4px;
text-align: left;
}
.mini-table th { color: var(--text-secondary); font-weight: 500; }
/* 移动端适配 */
@media(max-width:900px){
.main { grid-template-columns: 1fr; }
@@ -202,6 +226,23 @@ body {
复杂命令自动升级为 AI 解析 · 多步用 <b>;</b><b>然后</b> 分隔
</div>
</div>
<div class="card">
<h2>🔌 连接方式与协议</h2>
<div class="protocol-box">
<div class="protocol-line"><b>WS 入口</b> <span id="p-ws-path">加载中...</span></div>
<div class="protocol-line"><b>Hook 流</b> <span id="p-hook-path">加载中...</span></div>
<div class="protocol-line"><b>在线设备</b> <span id="p-online-count">-</span> | <b>ADB</b> <span id="p-adb-count">-</span></div>
<div class="protocol-line"><b>心跳建议</b> <span id="p-heartbeat">-</span></div>
<table class="mini-table">
<thead>
<tr><th>设备</th><th>项目</th><th>状态</th><th>心跳延迟(s)</th></tr>
</thead>
<tbody id="p-device-rows">
<tr><td colspan="4" style="color:var(--text-secondary);">暂无数据</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card" style="flex:1;">
<h2>📝 实时日志</h2>
<div class="log-area" id="log-area"></div>
@@ -381,6 +422,45 @@ async function checkAI() {
}
}
// 连接协议
async function loadConnectionProtocol() {
try {
const r = await fetch(`${API}/api/v3/connection/protocol`);
const d = await r.json();
const data = d.data || {};
document.getElementById('p-heartbeat').textContent =
(data.heartbeat?.recommended_interval_seconds || []).join(' / ') + ' 秒';
} catch (e) {}
}
// 连接状态
async function loadConnectionStatus() {
try {
const r = await fetch(`${API}/api/v3/connection/status`);
const d = await r.json();
const data = d.data || {};
document.getElementById('p-ws-path').textContent = data.ws_path || '-';
document.getElementById('p-hook-path').textContent = data.hook_events_stream || '-';
document.getElementById('p-online-count').textContent = data.online_ws_count ?? '-';
document.getElementById('p-adb-count').textContent = data.adb_count ?? '-';
const rows = data.devices || [];
const tbody = document.getElementById('p-device-rows');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="4" style="color:var(--text-secondary);">暂无在线设备</td></tr>';
return;
}
tbody.innerHTML = rows.slice(0, 8).map((x) => `
<tr>
<td>${x.device_id || '-'}</td>
<td>${x.project_id || '-'}</td>
<td>${x.status || '-'}</td>
<td>${typeof x.heartbeat_age_seconds === 'number' ? x.heartbeat_age_seconds : '-'}</td>
</tr>
`).join('');
} catch (e) {}
}
// 模式切换
function setMode(mode) {
cmdMode = mode;
@@ -468,8 +548,11 @@ async function sendCmd() {
await checkHealth();
await loadDevices();
await checkAI();
await loadConnectionProtocol();
await loadConnectionStatus();
setInterval(async () => { await checkHealth(); await loadDevices(); }, 15000);
setInterval(checkAI, 30000);
setInterval(loadConnectionStatus, 10000);
})();
</script>
</body>

79
sdk/build_universal_package.sh Executable file
View File

@@ -0,0 +1,79 @@
#!/bin/bash
# 生成通用安装包APK + Agent + Hook 脚本 + 安装说明)
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
DIST_DIR="$ROOT_DIR/dist/universal"
PKG_NAME="workphone-universal-$(date +%Y%m%d-%H%M%S)"
PKG_DIR="$DIST_DIR/$PKG_NAME"
mkdir -p "$PKG_DIR"
echo "== 1) 构建 Android APK =="
pushd "$ROOT_DIR/android-app" >/dev/null
export JAVA_HOME="${JAVA_HOME:-$(/usr/libexec/java_home -v 21)}"
export PATH="$JAVA_HOME/bin:$PATH"
./gradlew assembleDebug
popd >/dev/null
APK_PATH="$ROOT_DIR/android-app/app/build/outputs/apk/debug/app-debug.apk"
if [[ ! -f "$APK_PATH" ]]; then
echo "APK 未生成: $APK_PATH"
exit 1
fi
echo "== 2) 打包 Agent =="
pushd "$ROOT_DIR/agent" >/dev/null
bash package.sh
popd >/dev/null
cp "$APK_PATH" "$PKG_DIR/workphone-agent-debug.apk"
cp "$ROOT_DIR/agent/dist/agent.tar.gz" "$PKG_DIR/agent.tar.gz"
cp "$ROOT_DIR/agent/install.sh" "$PKG_DIR/install.sh"
cp "$ROOT_DIR/agent/hook/wechat_hook_v1.js" "$PKG_DIR/wechat_hook_v1.js"
cp "$ROOT_DIR/agent/hook/setup_redmi11.sh" "$PKG_DIR/setup_redmi11.sh"
cat > "$PKG_DIR/INSTALL.md" <<'EOF'
# WorkPhone 通用安装包
包含:
- `workphone-agent-debug.apk`Android 设备端 App
- `agent.tar.gz`Termux Python Agent 包
- `install.sh`Termux 一键安装脚本
- `wechat_hook_v1.js`:微信 Hook 初版脚本
- `setup_redmi11.sh`红米11 root 设备初始化脚本
## 1安装 App
```bash
adb install -r workphone-agent-debug.apk
```
## 2安装 AgentTermux
```bash
curl -sL http://<server>:8899/install.sh | bash -s -- --server ws://<server>:8899/ws/device
```
## 3部署 Hook 脚本
```bash
curl -s -X POST "http://<server>:8899/api/v3/scripts?module_id=wechat_hook_v1&version=1.0.0" \
-F "file=@wechat_hook_v1.js"
```
## 4红米11 Root 探测
```bash
curl -s "http://<server>:8899/api/v3/devices/<device_id>/modules"
```
当返回 `supports_hook=true` 且 `frida_version` 有值,表示 Hook 环境可用。
EOF
tar czf "$DIST_DIR/${PKG_NAME}.tar.gz" -C "$DIST_DIR" "$PKG_NAME"
echo "完成:"
echo "目录包: $PKG_DIR"
echo "压缩包: $DIST_DIR/${PKG_NAME}.tar.gz"

View File

@@ -0,0 +1,39 @@
# 微信 8.0.51 下载与安装
## 一键安装脚本
```bash
cd sdk
bash scripts/install_wechat_8.0.51.sh [APK路径] [设备序列号]
```
- 不传参数:使用 `sdk/apks/wechat-8.0.51.apk`,若无则尝试从 `~/Downloads` 找最近下载的微信 APK。
- 传 APK 路径:使用指定文件安装。
- 传设备序列号:多设备时指定目标设备(默认取当前连接的第一台)。
## 下载 8.0.51 APK
APKMirror / Uptodown 等站点有验证,需在**浏览器**中打开并下载:
1. **APKMirror**(推荐,需过 Cloudflare 验证)
https://www.apkmirror.com/apk/wechat-tencent/wechat/wechat-8-0-51-release/wechat-8-0-51-android-apk-download/
2. **Uptodown 旧版本列表**(选择 8.0.51
https://wechat.en.uptodown.com/android/versions
下载后任选其一:
- 保存为:`sdk/apks/wechat-8.0.51.apk`,再执行上述脚本;或
- 保存到 `~/Downloads`,脚本会自动使用该目录下最新的微信 APK
- 保存到任意路径,执行:`bash scripts/install_wechat_8.0.51.sh /path/to/xxx.apk`
## 安装到设备
1. 设备 USB 连接电脑并开启 USB 调试。
2. 确认设备已连接:`adb devices`
3. 执行安装脚本;覆盖安装使用 `adb install -r`,若报签名冲突可先卸载:`adb uninstall com.tencent.mm`
## 版本信息
- 包名:`com.tencent.mm`
- 8.0.51 约 263 MB需 Android 6.0+,架构 arm64-v8a。

View File

@@ -0,0 +1,78 @@
# 微信版本检查与升级说明
## 一、当前设备微信状态(检查结果)
| 项目 | 值 |
|------|-----|
| 设备 | 0a43392e0511Redmi selene |
| 包名 | com.tencent.mm |
| **当前安装版本** | **8.0.51**versionCode 2720 |
| targetSdk | 30 |
| 安装路径 | /data/app/.../com.tencent.mm-.../base.apk |
## 二、“版本过低”说明
微信服务器会要求客户端不低于某一版本,否则登录或部分功能会提示**“版本过低,请升级”**。
当前安装的 **8.0.51** 已较旧,容易被判定为过低,需要升级到 **8.0.58 或更新版本**(如 8.0.60)才能正常使用。
## 三、升级步骤(确保微信可用)
### 1. 下载更高版本 APK在电脑浏览器中操作
在浏览器中打开下面任一链接,下载 **8.0.58 或 8.0.60** 的 APK推荐 8.0.60
- **8.0.60(推荐,较新)**
https://www.apkmirror.com/apk/wechat-tencent/wechat/wechat-8-0-60-release/wechat-8-0-60-android-apk-download/
- **8.0.58(备选)**
https://www.apkmirror.com/apk/wechat-tencent/wechat/wechat-8-0-58-release/wechat-8-0-58-android-apk-download/
下载完成后,将 APK 保存到已知路径(如 `~/Downloads/`)。
### 2. 安装到当前手机
设备已通过 Type-C 连接且 ADB 可用时,在项目 `sdk` 目录下执行(把路径换成你下载的 APK 实际路径):
```bash
cd "/Users/karuo/Documents/开发/2、私域银行/工作手机/sdk"
adb -s 0a43392e0511 install -r -t "/path/to/下载的微信APK.apk"
```
或使用现有安装脚本(若脚本支持任意路径):
```bash
bash scripts/install_wechat_8.0.51.sh "/path/to/下载的微信APK.apk" 0a43392e0511
```
安装完成后,在手机上打开微信,确认不再出现“版本过低”提示。
### 3. 再次确认版本(可选)
安装后在电脑上执行:
```bash
adb -s 0a43392e0511 shell dumpsys package com.tencent.mm | grep -E "versionName|versionCode"
```
应能看到 versionName 为 8.0.58 或 8.0.60versionCode 大于 2720。
## 四、版本对照(便于排查)
| 版本号 | versionCode | 说明 |
|--------|-------------|------|
| 8.0.51 | 2720 | 当前安装,易被提示“版本过低” |
| 8.0.58 | 更高 | 建议升级到此或更高 |
| 8.0.60 | 更高 | 推荐,满足当前服务端要求 |
## 五、快速检查命令汇总
```bash
# 查看当前连接设备
adb devices -l
# 查看当前安装的微信版本
adb -s 0a43392e0511 shell dumpsys package com.tencent.mm | grep -E "versionName|versionCode"
# 覆盖安装新版本(替换为实际 APK 路径)
adb -s 0a43392e0511 install -r -t "/path/to/wechat.apk"
```

View File

@@ -0,0 +1,192 @@
# 设备端 Hook 版本安装与服务端对接手册
## 1. 目标
- 在已 Root 的 Android 设备上安装 `工作` APP 与 Hook 能力
- 设备通过 WebSocket 直连服务端,支持命令下发和结果回传
- 提供稳定心跳、断线重连、在线状态检测
---
## 2. 设备端 ID 规则(奥创兼容)
当前设备端默认 `device_id` 策略:
- 首选:`md5(android_id)`(奥创兼容)
- 备用:`device_${MODEL}_${SDK_INT}`
已保留并上报辅助字段(用于排障):
- `device_profile.aochuang_device_id`
- `device_profile.android_id`
- `device_profile.serial`
---
## 3. 安装步骤Root 设备推荐)
### 3.1 编译 APK
```bash
cd sdk/android-app
./gradlew assembleDebug
```
APK 输出:
- `sdk/android-app/app/build/outputs/apk/debug/app-debug.apk`
### 3.2 USB 安装Root 静默)
```bash
adb push sdk/android-app/app/build/outputs/apk/debug/app-debug.apk /data/local/tmp/work.apk
adb shell "su -c 'pm install -r -g /data/local/tmp/work.apk'"
```
### 3.3 启动 APP
```bash
adb shell "monkey -p com.workphone.agent -c android.intent.category.LAUNCHER 1"
```
---
## 4. 设备端配置
APP 设置页填写:
- `server_url`: `ws://<server-ip>:8899/ws/device`
- `project_id`: 项目编号
- `device_id`: 默认自动生成(奥创兼容),可手工覆盖
- `auto_connect`: 建议开启
ADB 端口反向(本机联调):
```bash
adb reverse tcp:8899 tcp:8899
```
---
## 5. 服务端交互协议
### 5.1 连接地址
- `ws://<server-ip>:8899/ws/device/{device_id}`
### 5.2 注册消息(设备 -> 服务端)
```json
{
"type": "register",
"device_id": "b63812da8452b7586ca01f3675ab31e5",
"project_id": "default",
"platform": "android",
"model": "21121119SC",
"sdk_version": 33,
"app_version": "2.0.0",
"heartbeat_interval_seconds": 30,
"device_profile": {
"aochuang_device_id": "b63812da8452b7586ca01f3675ab31e5",
"android_id": "b5f7a11643a1adc5",
"serial": "e4c02d0c0509"
}
}
```
### 5.3 心跳消息(设备 -> 服务端)
```json
{
"type": "heartbeat",
"device_id": "b63812da8452b7586ca01f3675ab31e5",
"timestamp": 1700000000000
}
```
### 5.4 心跳配置(服务端 -> 设备)
```json
{
"type": "config",
"heartbeat_interval_seconds": 10
}
```
### 5.5 执行命令(服务端 -> 设备)
```json
{
"type": "execute",
"command_id": "cmd_xxx",
"action": "open_app",
"params": {
"package": "com.tencent.mm"
}
}
```
### 5.6 执行结果(设备 -> 服务端)
```json
{
"type": "result",
"command_id": "cmd_xxx",
"device_id": "b63812da8452b7586ca01f3675ab31e5",
"success": true,
"message": "已打开 com.tencent.mm",
"timestamp": 1700000001000
}
```
---
## 6. API 使用说明(服务端)
### 6.1 设备与状态
- `GET /health`:服务健康与在线设备
- `GET /api/v3/devices`:设备列表
- `GET /api/v3/devices/{device_id}`:设备详情
- `GET /api/v3/devices/{device_id}/heartbeat`:心跳状态
### 6.2 心跳配置
- `POST /api/v3/devices/{device_id}/heartbeat/config`
请求体:
```json
{
"heartbeat_interval_seconds": 10
}
```
取值范围:`5-120`
### 6.3 设备执行
- `POST /api/v3/devices/{device_id}/execute`
- `POST /api/v3/devices/{device_id}/click`
- `POST /api/v3/devices/{device_id}/input`
- `POST /api/v3/devices/{device_id}/swipe`
---
## 7. 心跳与重连机制
- 设备端按 `heartbeat_interval_seconds` 周期上报心跳
- 设备端若连续 3 个周期未收到 pong主动断线重连
- 服务端后台巡检任务会清理超时设备(`max(WS_TIMEOUT, 3*heartbeat_interval)`
- 设备断线后采用指数退避重连(最多 10 次)
---
## 8. 验收清单
- APP 成功安装并启动前台服务
- 设置页配置 `server_url/project_id/device_id` 完整
- `/health` 能看到设备在线
- 下发 `open_app/click/input/screenshot` 返回成功
- 心跳接口显示 `stale=false``heartbeat_age_seconds` 持续更新

View File

@@ -5,6 +5,68 @@
---
### 2026-02-28 | Soul 多维度调研文档(参考其形式开发 APP
**执行人**: 阿机
**本次完成**:
- [x]**开发文档/平台分析/Soul/调研/** 下新增多维度分析文档,供从技术、产品、安全、商业、体验等维度参考 Soul 形式开发自研 APP。
- [x] 文档清单README索引、01 产品与功能维度、02 技术架构与实现维度、03 安全与风控维度、04 商业与运营维度、05 体验与交互维度、06 参考 Soul 开发 APP 的技术基线与实现方式。
- [x] 各文档均含「好处/坏处」「优点/缺点」拆解及可借鉴点06 为技术基线、分阶段架构建议、实现方式选型与开发顺序,作为开发参考总纲。
- [x] 更新 Soul/README增加调研子目录入口。
**用途**: 立项或开发类似形态 APP 时,可按 01→06 顺序阅读,以 06 为技术基线与实现方式参考。
---
### 2026-02-28 | Soul 抓包流程加固与「继续直到成功」
**执行人**: 阿机
**本次完成**:
- [x] mitmdump 增加 `-k`(不校验上游证书),解决 Soul 直连 IP120.x/118.x导致的「Certificate verify failed: IP address mismatch」API 请求可被解密。
- [x] soul_capture_addon.py扩展 host118.x、排除纯静态路径app-source、heads/avatar-)、按请求头名称遍历匹配 auth/token/sign/device兼容不同大小写与命名。
- [x] run_soul_capture.sh抓包时长改为 180 秒,结束逻辑判断 ~/.soul_env 是否含非空 SOUL_AUTH_TOKEN/DEVICE_ID/API_SIGN有则提示「✅ 已写入」,无则提示「⚠️ 请在抓包期间于 Soul 内发瞬间或刷新」。
- [x] 文档 Soul自动化方案与命令行操作.md 7.1:明确写清「抓包期间 Soul 必须在前台并发瞬间或刷新」,否则无法拿到 Token。
**结论**: 抓包链路与脚本已就绪;成功写入 ~/.soul_env 的前提是:**运行 run_soul_capture.sh 的 180 秒内Soul 在前台且执行发瞬间或刷新**。满足后 addon 会从 API 请求中提取头并写入,即可用 `source ~/.soul_env && ./soul_post_moment_mac.sh "内容"` 发瞬间。
---
### 2026-02-28 | Soul Mac 抓包与配置写入
**执行人**: 阿机
**本次完成**:
- [x] 安装 mitmproxybrew install mitmproxy用于 Mac 上抓 Soul HTTPS。
- [x] 编写 soul_capture_addon.pymitmproxy 插件,捕获 api.soulapp.cn 请求头X-Auth-Token、api-sign、device-id 等)并写入 ~/.soul_env。
- [x] 执行抓包:代理临时切到 127.0.0.1:8080mitmdump 运行 75 秒后恢复原代理 7897本机该时段未抓到 Soul 发帖请求Soul 未在抓包期间发帖或未走系统代理)。
- [x] 新增 run_soul_capture.sh一键抓包脚本代理 8080、90 秒);用户在本机运行后,在 Soul 内发一条瞬间即可把 Token 等写入 ~/.soul_env。
- [x] soul_post_moment_mac.sh 已支持从 ~/.soul_env 读取;抓包得到的数据会写入该文件,无需手填。
**后续**: 用户执行 `开发文档/平台分析/Soul/run_soul_capture.sh`,在 180 秒内打开 Soul 并发一条瞬间或刷新页面,即可自动写入 ~/.soul_env之后用 `source ~/.soul_env && ./soul_post_moment_mac.sh "内容"` 发瞬间(若 api-sign 随请求变化则需每次发帖时重新抓包或实现签名算法)。
---
### 2026-02-28 | 开发文档分类与 Soul 专项整理
**执行人**: 阿表 + 阿机
**本次完成**:
- [x] 新增 **平台分析** 分类:开发文档下增加「平台分析」目录,各平台(如 Soul专项分析统一放此处不散落。
- [x] 新增 **平台分析/Soul/**Soul 全部内容集中此目录。新建 [Soul项目分析.md](平台分析/Soul/Soul项目分析.md)Soul 全部接口列表 + 全部功能列表);[Soul自动化方案与命令行操作.md](平台分析/Soul/Soul自动化方案与命令行操作.md) 从 9、手册 迁入。
- [x] 开发文档 **四类分类**:在 README 中明确「需求与架构」「开发与接口」「部署与数据」「手册、验收与平台分析」四类现有文档分别归属根目录不出现文档Soul 只出现在 平台分析/Soul。
---
### 2026-02-28 | Soul 自动化方案与 Skill 实现
**执行人**: 阿机 + 阿端
**本次完成**:
- [x] 编写《Soul自动化方案与命令行操作》文档开发文档/9、手册/SOUL Mac 版结构说明、安卓包名 cn.soulapp.android、发瞬间/发视频/私聊/获取好友流程与命令行示例
- [x] 新增 Agent 端 Soul Skillsdk/agent/skills/soul/post_moments、send_message、get_messages、get_contacts
- [x] 在 skills/__init__.py 注册 SoulSkillskill_executor 与 agent 中增加 soul 分支unified ADB 模式增加 soul 包名
**说明**: 用户通过 unified 接口指定 platform=soul 即可在已连接设备上执行发瞬间、发私聊、获取联系人Mac 版 /Applications/SOUL.app 不在此 SDK 控制范围。
---
### 2026-02-24 | 工作手机项目上传 Gitea + GitHub
**执行人**: 阿服

View File

@@ -2,7 +2,7 @@
**项目**工作手机SDK v3.0(使用与操作以 SDK 操作手册、微信 E2E 验证为主;存客宝通过 API/SDK 调用。)
**规则**:本目录除本 README 外最多 **3 个主文档**,与全站开发文档规则一致;超出须合并。
**规则**:本目录除本 README 外最多 **3 个主文档**,与全站开发文档规则一致;超出须合并。**Soul 相关手册已迁至 [平台分析/Soul/](../平台分析/Soul/)**,不在此目录。
**当前项目状态**:总进度 100%;微信消息 E2E 可按验证指南执行(需本地 SDK+Agent+模拟器微信)。进度以 [10、项目管理/开发进度总表.md](../10、项目管理/开发进度总表.md) 为准。

View File

@@ -2,7 +2,7 @@
> **管理Skill****本项目目录下** `机擎/SKILL.md`(火炬总控,五人分配:阿表/阿机/阿桥/阿端/阿服)
> **更新**2026-02-07 | **当前项目状态**:总进度 **98%**
> **约定**:所有开发文档内容**仅在本目录下****根目录仅保留本 README**,不得在根目录放置其他 .md 或文档所有内容归入 **1、需求/ … 10、项目管理/** 相应子目录;引用均以 **1、需求/ … 10、项目管理/** 为基准
> **约定**:所有开发文档内容**仅在本目录下****根目录仅保留本 README**,不得在根目录放置其他 .md 或文档所有内容归入子目录,**不散落**——按「四类分类」各就各位;**Soul 相关内容统一放在 [平台分析/Soul/](平台分析/Soul/)**,不与其他目录混放
---
@@ -11,7 +11,7 @@
**机擎负责所有项目管理、人员安排、全员学习开发文档;每次开发由机擎安排任务。**
- **Skill 位置**`机擎/SKILL.md`(火炬总控,阿表/阿机/阿桥/阿端/阿服 按岗位分配。与卡若AI 为**交互关系**卡若AI 涉及工作手机时读取该 Skill 并按其规则执行。
- **开发文档归属**:所有开发文档内容必须在 **工作手机/开发文档/** 目录下不在此目录外新增或生成开发文档类内容新增文档归入对应子目录1、需求 … 10、项目管理
- **开发文档归属**:所有开发文档内容必须在 **工作手机/开发文档/** 目录下不在此目录外新增或生成开发文档类内容新增文档归入对应子目录1、需求 … 10、项目管理;平台分析按平台分子目录,如 Soul)。
- **每次开发**:由机擎整理项目(读进度总表、工作日志、状态检查)→ 安排、分配任务 → 执行并更新文档。
---
@@ -48,20 +48,38 @@
---
## 开发文档结构(仅 10 个目录,每目录 ≤3 个主文档
## 开发文档四类分类(文档各就各位,不散落
开发文档按 **4 类** 归纳,现有文档分别归属以下 4 类,便于查找与维护:
| 分类 | 所含目录 | 说明 |
|------|----------|------|
| **一、需求与架构** | 1、需求2、架构3、原型 | 需求、架构设计、原型规范 |
| **二、开发与接口** | 4、前端5、接口6、后端 | 前端/接口/后端实现与规范 |
| **三、部署与数据** | 7、数据库8、部署 | 数据库、部署与凭证 |
| **四、手册、验收与平台分析** | 9、手册10、项目管理**平台分析** | 操作手册、进度验收;**各平台(如 Soul专项分析统一放在「平台分析」** |
- **平台分析**:按平台分子目录(如 `平台分析/Soul/`),该平台的所有文档(项目分析、接口与功能清单、自动化方案等)**只放在该平台目录内**,不散落在 110 其他目录。
- 根目录**不放置**除本 README 以外的文档。
---
## 开发文档结构(子目录与主文档)
| 目录 | 主文档≤3 | 说明 |
|------|----------------|------|
| [1、需求](1、需求/) | 项目概述、业务需求、成本与需求澄清 | 需求与澄清 |
| [2、架构](2、架构/) | 系统架构、技术选型与数据库、对接与方案补充含存客宝对接架构≤3 主文档) | 架构与 §3.0 模块拆解 |
| [1、需求](1、需求/) | 项目概述、业务需求、技术调研与方案选型 | 需求与澄清 |
| [2、架构](2、架构/) | 系统架构、技术选型与数据库、Hook通道与多设备多服务器架构 | 架构与 §3.0 模块拆解 |
| [3、原型](3、原型/) | 原型设计规范 | 原型规范 |
| [4、前端](4、前端/) | v0配置、前端开发规范 | 前端规范 |
| [5、接口](5、接口/) | 接口规范、存客宝对接规范、通用服务交互层 | API 与对接 |
| [6、后端](6、后端/) | SDK服务端实现、Agent端技能实现、后端规范与代码汇总 | 服务端+Agent+规范 |
| [7、数据库](7、数据库/) | 数据库管理规范、数据库设计文档 | 数据层 |
| [8、部署](8、部署/) | 本地Docker部署指南、本地环境凭证、部署流程与提示词 | 部署与凭证 |
| [9、手册](9、手册/) | SDK操作手册、微信消息E2E验证指南、使用与落地方案 | 操作与验证 |
| [10、项目管理](10、项目管理/) | 开发进度总表、工作日志、验收与项目说明(含多端并行与附录 A/B/C≤3 主文档) | 进度与验收(唯一进度入口) |
| [4、前端](4、前端/) | 前端开发规范、管理端前端开发规范、v0配置 | 前端规范 |
| [5、接口](5、接口/) | 接口规范、通用服务交互层、Hook模块管理接口 | API 与对接 |
| [6、后端](6、后端/) | SDK服务端实现文档、Agent端技能实现文档、后端规范与代码汇总 | 服务端+Agent+规范 |
| [7、数据库](7、数据库/) | 数据库设计文档、数据库管理规范 | 数据层 |
| [8、部署](8、部署/) | 本地Docker部署指南、设备端Hook安装部署、本地环境凭证 | 部署与凭证 |
| [9、手册](9、手册/) | SDK操作手册、微信消息E2E验证指南、使用与落地方案 | 操作与验证**Soul 手册已迁至平台分析/Soul/** |
| [10、项目管理](10、项目管理/) | 开发进度总表、工作日志、验收与项目说明 | 进度与验收(唯一进度入口) |
| **[平台分析](平台分析/)** | 按平台分子目录 | **各平台专项:接口+功能+自动化方案统一放此处** |
| [平台分析/Soul/](平台分析/Soul/) | Soul项目分析、Soul自动化方案与命令行操作 | **Soul 全部内容集中此目录** |
---

View File

@@ -0,0 +1,26 @@
# 平台分析
> **已统一迁至**`/Users/karuo/Documents/开发/7.项目调研`,按项目分子目录(平台分析/聊天记录/对话分类/资料)。
> 本目录仅作历史参考;**新增平台分析、项目调研、A 群聊天记录、对话分类**请一律归档到 **7.项目调研** 对应项目下。
---
## 规则(原说明,现以 7.项目调研 为准)
- **每个平台一个子目录**,目录名与平台英文标识一致(如 `Soul``Wechat`)。
- 平台目录内仅放该平台的:**项目分析(接口+功能)**、**自动化方案与操作手册**、**对接说明** 等,不散落在其他目录。
- 主文档数量遵守开发文档总规则:每目录除 README 外最多 3 个主文档;子目录(如 `Soul/`)内可放 README + 2 个主文档。
---
## 当前平台
| 平台 | 目录 | 说明 |
|------|------|------|
| Soul | [Soul/](Soul/) | Soul 项目分析(接口+功能)、自动化方案与命令行操作 |
---
## 与开发文档四类分类的关系
- 本目录属于「**开发与落地**」类:平台能力与对接方案集中在此,便于按平台查阅,不与其他 110 目录重复。

View File

@@ -0,0 +1,21 @@
# Soul 专项
> Soul灵魂社交相关文档统一放在本目录不散落在其他开发文档目录。
---
## 文档列表≤3 个主文档)
| 文档 | 说明 |
|------|------|
| [Soul项目分析.md](Soul项目分析.md) | Soul 全部接口列表与全部功能列表,供对接与自动化参考 |
| [Soul自动化方案与命令行操作.md](Soul自动化方案与命令行操作.md) | 工作手机 SDK 控制 Soul 的自动化方案与 curl 示例 |
| [调研/](调研/) | **多维度调研**:产品、技术、安全、商业、体验、开发参考(共 6 份分析文档,供参考 Soul 形式开发 APP |
---
## 速查
- **安卓包名**`cn.soulapp.android`
- **API 域**`api.soulapp.cn`(主站);`openplatform-openapi.soulapp.cn`(开放平台/支付等)
- **工作手机 Skill**`sdk/agent/skills/soul/`

View File

@@ -0,0 +1,252 @@
# Soul 自动化方案与命令行操作
> 创建日期2026-02-28 | 目的:在手机上通过工作手机 SDK 自动化操作 Soul发瞬间、发视频、自动聊天、获取好友并由命令行/API 触发。
---
## 一、SOUL 应用结构说明
### 1.1 Mac 版 SOUL.app/Applications/SOUL.app
- **性质**macOS 客户端,为包装结构 `SOUL.app → WrappedBundle → Wrapper/Soul_New.app`
- **内容**Soul_New.app 内包含多个功能模块 bundle例如
- `Publish.bundle`:发布相关(发瞬间/视频)
- `MainSquare.bundle`:广场/动态
- `PrivateChat.bundle`:私聊
- `ChatRoom.bundle``GroupChat.bundle`:聊天房/群聊
- `Login.bundle`:登录
- **自动化方式**Mac 版若需自动化,需使用 macOS 侧方案(如 AppleScript、Accessibility、或 Appium for Mac。**工作手机 SDK 不控制 Mac 应用**,仅控制安卓真机上的 APP。
### 1.2 安卓版 Soul用于工作手机自动化
| 项目 | 说明 |
|------|------|
| **包名** | `cn.soulapp.android` |
| **应用名** | Soul灵魂社交 |
| **主要 API 域** | api.soulapp.cn若后续做抓包/接口直连可参考) |
| **签名** | 请求需 api-sign 等 Header见逆向文档 |
---
## 二、工作手机机擎与 Soul 的对应关系
- **unified 接口**:已支持 `Platform.SOUL`,发消息、发朋友圈/瞬间、好友、群聊等接口与微信/抖音共用同一套 REST`POST /message/send``POST /moments/post``GET /contacts` 等),只需 `platform: "soul"`
- **设备端**:在安卓手机上安装 Soul 客户端,由工作手机 Agentuiautomator2控制 Soul APP 的 UI实现
- **发瞬间**:对应 `post_moments`Soul 里即「发布瞬间」)
- **发视频**:同上,`post_moments` 传入 `video_url` 或本地路径,由 Skill 内选「视频」并选择文件
- **自动聊天**`send_message` 发私聊;可配合定时/脚本循环调用
- **获取好友**`get_contacts` 获取关注/好友列表(以 Soul 产品为准,可能是关注列表或聊天列表)
---
## 三、Soul 自动化实现方式(在手机上)
### 3.1 发瞬间(文字/图片/视频)
- **接口**`POST /unified/moments/post``platform: "soul"`
- **Agent 行为**SoulSkill
1. 启动 Soul`launch()`(包名 `cn.soulapp.android`)。
2. 点击发布入口(如底部「+」或「发布」)。
3. 选择类型:文字 / 图片 / 视频。
4. 若有内容:在输入框输入文案;若有图片/视频:从 `params.images` / `params.video_url` 通过 ADB push 到手机后选择文件。
5. 点击「发布」/「发送」完成发布。
### 3.2 发私聊(自动聊天)
- **接口**`POST /unified/message/send``platform: "soul"``to_id` 为对方 Soul 昵称或 ID。
- **Agent 行为**
1. 启动 Soul进入「消息」或「聊天」。
2. 搜索或选择 `to_id` 对应会话。
3. 输入 `content`,点击发送。
4. 若需「自动聊天」:由业务侧定时或按条件循环调用 `message/send` 即可。
### 3.3 获取好友/关注列表
- **接口**`GET /unified/contacts?device_id=xxx&platform=soul&limit=200`
- **Agent 行为**
1. 启动 Soul进入「消息」或「我的」→「关注/好友」相关 Tab。
2. 解析当前页 UI 或通过 dump hierarchy 抓取列表,组装为 `contacts` 列表返回。
### 3.4 发视频
- 与「发瞬间」同一流程,在 Soul 发布类型中选择「视频」,资源来自 `moments/post``video_url`(或先下载到手机再选文件)。
---
## 四、命令行操作示例(对工作手机 SDK 服务端)
前提SDK 服务端已启动(如 `http://127.0.0.1:8899`),且设备已连接并注册为 `device_id`
### 4.1 发 Soul 瞬间(文字)
```bash
curl -X POST "http://127.0.0.1:8899/unified/moments/post" \
-H "Content-Type: application/json" \
-d '{
"device_id": "你的设备ID",
"platform": "soul",
"content": "今天天气不错",
"images": []
}'
```
### 4.2 发 Soul 瞬间(带图/带视频)
```bash
# 图片images 传图床或可访问 URLAgent 侧需支持下载到手机后选图
curl -X POST "http://127.0.0.1:8899/unified/moments/post" \
-H "Content-Type: application/json" \
-d '{
"device_id": "你的设备ID",
"platform": "soul",
"content": "分享一张图",
"images": ["https://example.com/photo.jpg"],
"video_url": null
}'
# 仅视频
curl -X POST "http://127.0.0.1:8899/unified/moments/post" \
-H "Content-Type: application/json" \
-d '{
"device_id": "你的设备ID",
"platform": "soul",
"content": "视频描述",
"video_url": "https://example.com/video.mp4"
}'
```
### 4.3 自动聊天:发 Soul 私聊消息
```bash
curl -X POST "http://127.0.0.1:8899/unified/message/send" \
-H "Content-Type: application/json" \
-d '{
"device_id": "你的设备ID",
"platform": "soul",
"to_id": "对方昵称或Soul ID",
"content": "你好呀",
"msg_type": "text"
}'
```
### 4.4 获取 Soul 好友/联系人列表
```bash
curl "http://127.0.0.1:8899/unified/contacts?device_id=你的设备ID&platform=soul&limit=200"
```
---
## 五、设备端 Soul Skill 已实现动作
| action | 说明 | 对应接口 |
|--------|------|----------|
| `post_moments` | 发瞬间(文字/图/视频) | POST /unified/moments/post |
| `send_message` | 发私聊 | POST /unified/message/send |
| `get_contacts` | 获取好友/关注列表 | GET /unified/contacts |
| `get_messages` | 获取某会话消息列表 | GET /unified/messages |
Soul 的 UI 文案与资源 ID 可能随版本变化,若点击失败可结合 `screenshot` + `ui_tree` 或真机抓取当前版本的文案/ID`sdk/agent/skills/soul/skill.py` 中调整选择器。
---
## 六、整体流程小结
1. **在安卓手机上**:安装 Soulcn.soulapp.android并安装/运行工作手机 Agent连接同一台 SDK 服务端)。
2. **在电脑/命令行**:通过 curl 或脚本调用上述 unified 接口,指定 `platform: "soul"``device_id`
3. **服务端**:将请求转发到对应设备,设备端 SoulSkill 通过 UI 自动化执行发瞬间、发消息、获取好友等。
4. **Mac 版 SOUL.app**:不经过工作手机 SDK若需 Mac 自动化,需单独做 Mac 上的 UI 自动化或协议研究。
---
## 七、Mac 上纯命令行发瞬间(不控鼠标、不控电脑)
你当前在 **MacBook 上已登录 Soul**,希望用**一条命令**直接把瞬间发到 Soul不控制鼠标、不控制电脑。
**做法**:不操作 Soul 界面,而是让**命令行直接请求 Soul 服务端 API**`api.soulapp.cn`),你 Mac 上已登录的账号会同步到这条瞬间。
### 7.1 一键命令(需先配置一次 Token
1. **抓包取 Token一次性**
**重要**:抓包期间 Soul 必须在前台并产生 API 请求(发瞬间、刷新广场/个人页等),否则 mitmproxy 只会看到静态资源请求,无法拿到 Token。
在 Mac 上运行本目录的 `./run_soul_capture.sh`(或使用 Charles / mitmproxy脚本会临时把系统代理切到 8080、运行约 180 秒;**请在这 180 秒内打开 Soul 并发一条瞬间或刷新页面**。抓包结束后若成功,会提示「已写入 ~/.soul_env」。
若未成功,请重新运行脚本,并确保抓包时段内 Soul 在前台且执行了发瞬间或刷新。
抓包成功后,在请求里可得到:
- `X-Auth-Token`
- `X-Auth-UserId`
- `device-id`
- `api-sign`(每次请求会变,见下)
- `app-time``request-nonce``app-version`(可选,有则一并记下)
2. **api-sign 说明**
Soul 服务端会校验 `api-sign`,一般由路径、参数、时间戳等按固定算法生成。若你**没有**算法:
- 只能「重放」:把某一次发瞬间的请求完整复制(含当时的 api-sign只能发当时那一句文案
- 若**有**算法(或能复现逆向文档里的算法),可在脚本里按算法生成 api-sign即可对**任意文案**一条命令发瞬间。
3. **执行脚本**
本目录已放脚本 `soul_post_moment_mac.sh`,用法:
```bash
cd "开发文档/平台分析/Soul"
export SOUL_AUTH_TOKEN="抓包得到的X-Auth-Token"
export SOUL_USER_ID="抓包得到的X-Auth-UserId"
export SOUL_DEVICE_ID="抓包得到的device-id"
export SOUL_API_SIGN="抓包得到的api-sign或你算法算出的"
./soul_post_moment_mac.sh "要发的瞬间内容"
```
若未配置上述环境变量,脚本会报错并提示缺少项。
### 7.2 纯 curl 示例(同上,需先抓包得到各 Header
```bash
# 将下方占位替换为抓包得到的真实值后再执行
curl -X POST "https://api.soulapp.cn/v3/post/publish" \
-H "Content-Type: application/json" \
-H "X-Auth-Token: 你的Token" \
-H "X-Auth-UserId: 你的用户ID" \
-H "device-id: 你的设备ID" \
-H "api-sign: 抓包得到的api-sign" \
-H "app-time: $(date +%s)000" \
-H "request-nonce: $(uuidgen | tr -d '-')" \
-d '{"content":"来自命令行的瞬间"}'
```
路径 `/v3/post/publish` 以你抓包看到的为准,可能是 `/v3/post/create` 等,按实际改。
### 7.3 抓包时保持能上网(必装 mitmproxy 证书)
- 抓包时系统代理会切到 8080若**未安装 mitmproxy 的 CA 证书**,多数 HTTPS含 Soul、Cursor会报「不信任代理证书」并断连无法上网。
- **安装一次证书**:先开 mitmproxy如 `mitmdump -p 8080`),系统代理设为 127.0.0.1:8080浏览器打开 **http://mitm.it** → 选 Apple → 下载并安装到「钥匙串」→ 在「钥匙串访问」里找到 mitmproxy 证书,双击 → 「信任」选「始终信任」。装好后关掉 mitmdump、代理恢复原状。
- 之后再跑 `run_soul_capture.sh` 时Soul 和浏览器等走 8080 即可正常解密、上网,同时能抓到 Soul 的 API 请求并写入 `~/.soul_env`。
### 7.4 发布时出现服务端异常 502
- **现象**:发瞬间时提示「服务端异常 502」或 HTTP 502。
- **可能原因**:① Soul 服务端或网关短暂不可用;② 工作手机 SDK 上游(设备/Agent超时或未响应网关返回 502。
- **处理**
- **走工作手机 unified 接口**:已改为发布接口**统一返回 HTTP 200**,业务失败时用 `success: false` + `error` 表示,不再用 502若仍出现 502多为前置 Nginx/网关超时,可调大超时或稍后重试。
- **走 Mac 脚本直连 Soul API**:脚本已对 502 做提示并建议间隔 1030 秒重试;可多次执行 `source ~/.soul_env && ./soul_post_moment_mac.sh "内容"`。
### 7.5 Soul 无法连接 / 网络报错时
- **现象**Soul 提示「网络异常,请稍后再试」或「网络好像出了点小问题」,但本机浏览器等正常。
- **常见原因**:系统代理指向了 127.0.0.1:8080或其它本地代理而代理未开或未放行 Soul导致 Soul 请求失败。
- **处理**:关闭系统代理,或把 Soul/直连用网络恢复后再开 Soul。
- **关闭代理(终端执行)**`networksetup -setwebproxystate "Wi-Fi" off && networksetup -setsecurewebproxystate "Wi-Fi" off`
- **关代理后仍报网络异常**:完全退出 SoulCmd+Q 或 右键 Dock 图标 → 退出),再重新打开 Soul然后重试发布。
- 若需保留代理(如 7897抓包结束后务必恢复为原端口避免误设为 8080 且 mitmdump 未运行导致 Soul 连不上、无法发布。
### 7.6 小结
- **Mac 上「不控鼠标、不控电脑」发瞬间** = 只用命令行调 Soul 服务端 API。
- 需要:**Token + api-sign**(及 device-id 等。Token 从 Mac Soul 抓包一次即可api-sign 要么每次抓包复制,要么实现签名算法后脚本里自动生成,才能对任意内容一条命令发送。
---
## 八、参考
- 系统架构:`开发文档/2、架构/系统架构.md`
- unified 接口定义:`sdk/app/routers/unified.py`
- Soul 接口与功能清单:本目录 [Soul项目分析.md](Soul项目分析.md)
- Soul 安卓包名与 API公开资料显示为 `cn.soulapp.android`、api.soulapp.cnapi-sign 等见逆向分析文档。

View File

@@ -0,0 +1,150 @@
# Soul 项目分析:接口与功能清单
> 创建日期2026-02-28 | 用途:对接与自动化时查阅 Soul 全部能力与接口,统一放在本目录。
---
## 一、应用基本信息
| 项目 | 说明 |
|------|------|
| 应用名 | Soul灵魂社交 / 年轻人的社交元宇宙) |
| 安卓包名 | `cn.soulapp.android` |
| 主 API 域 | `https://api.soulapp.cn` |
| 开放平台域 | `https://openplatform-openapi.soulapp.cn`(小游戏/支付/广告等) |
| 官方文档 | https://mp-doc.soulapp.cn/(开放平台文档中心) |
| 认证 | 请求需 `api-sign``X-Auth-Token``X-Auth-UserId``request-nonce``app-time``app-id``device-id` 等 Header |
---
## 二、Soul 全部功能列表(产品侧)
### 2.1 内容与动态
| 功能 | 说明 |
|------|------|
| 发瞬间 | 发布文字/图片/视频瞬间到广场 |
| 瞬间广场 | 浏览、刷新、下拉加载他人瞬间 |
| 点赞瞬间 | 对某条瞬间点赞 |
| 评论瞬间 | 对某条瞬间评论、回复评论 |
| 收藏瞬间 | 收藏某条瞬间 |
| 分享瞬间 | 分享到站内或站外 |
| 话题/标签 | 带话题或标签发布、按话题浏览 |
### 2.2 私聊与消息
| 功能 | 说明 |
|------|------|
| 私聊 | 一对一文字/图片/语音/表情消息 |
| 消息列表 | 会话列表、未读、置顶 |
| 搜索会话/用户 | 按昵称或 Soul ID 搜索 |
| 打招呼/破冰 | 首次打招呼、快捷语 |
### 2.3 群聊与派对
| 功能 | 说明 |
|------|------|
| 群聊派对 | 多人语音房、上麦、发言 |
| 聊天房 | 主题语音/聊天房间 |
| 群聊 | 多人文字群(若产品支持) |
### 2.4 关系与发现
| 功能 | 说明 |
|------|------|
| 关注/粉丝 | 关注用户、查看粉丝与关注列表 |
| 灵魂匹配 | 基于灵魂鉴定/问卷的匹配推荐 |
| 推荐/发现 | 首页推荐用户或瞬间 |
| 打招呼列表 | 谁看过我、谁给我打招呼 |
### 2.5 个人与资料
| 功能 | 说明 |
|------|------|
| 个人主页 | 头像、昵称、瞬间、关注数/粉丝数 |
| 3D 捏脸/头像 | 3D 头像定制 |
| 灵魂鉴定 | 答题生成灵魂类型、星球 |
| 3D 星球 | 按灵魂类型进入不同星球/社区 |
| 设置 | 隐私、通知、账号与安全 |
### 2.6 其他
| 功能 | 说明 |
|------|------|
| 登录/注册 | 手机号、验证码、第三方登录 |
| 狼人杀/小游戏 | 站内小游戏(部分依赖开放平台) |
| 音乐房 | 语音房内播放音乐 |
| AI 对话 | 与 AI 角色对话(若产品支持) |
---
## 三、Soul 接口列表API 侧)
> 以下为根据公开资料与常见命名整理的接口列表;非官方完整文档,实际以抓包/官方文档为准。
### 3.1 主站 APIapi.soulapp.cn
| 模块 | 接口/路径示例 | 方法 | 说明 |
|------|----------------|------|------|
| 瞬间 | `/v3/post/publish` 或类似 | POST | 发布瞬间 |
| 瞬间 | `/v3/post/list``/v3/post/timeline` 等 | GET | 获取瞬间流/列表 |
| 瞬间 | `/v3/post/praise` | POST | 点赞(已知示例:?postId=xxx |
| 瞬间 | `/v3/post/cancelPraise` | POST | 取消点赞 |
| 瞬间 | `/v3/post/comment``/v3/comment/add` 等 | POST | 评论 |
| 瞬间 | `/v3/post/commentList``/v3/comment/list` 等 | GET | 评论列表 |
| 瞬间 | `/v3/post/detail` | GET | 瞬间详情 |
| 瞬间 | `/v3/post/delete` | POST | 删除瞬间 |
| 私信 | `/v3/chat/send``/v3/message/send` 等 | POST | 发送私信 |
| 私信 | `/v3/chat/list``/v3/conversation/list` 等 | GET | 会话列表 |
| 私信 | `/v3/chat/history``/v3/message/list` 等 | GET | 消息历史 |
| 用户 | `/v3/user/profile``/v3/user/info` 等 | GET | 用户资料 |
| 用户 | `/v3/user/follow``/v3/follow/add` 等 | POST | 关注 |
| 用户 | `/v3/user/unfollow``/v3/follow/cancel` 等 | POST | 取消关注 |
| 用户 | `/v3/user/fans``/v3/follow/fans` 等 | GET | 粉丝列表 |
| 用户 | `/v3/user/followList``/v3/follow/list` 等 | GET | 关注列表 |
| 搜索 | `/v3/search/user``/v3/search/post` 等 | GET | 搜索用户/瞬间 |
| 登录/鉴权 | `/v3/auth/login``/v3/user/token` 等 | POST | 登录、刷新 Token |
| 设备/配置 | `/v3/config``/v3/app/version` 等 | GET | 配置、版本 |
### 3.2 开放平台 APIopenplatform-openapi.soulapp.cn
| 模块 | 接口/路径示例 | 方法 | 说明 |
|------|----------------|------|------|
| 支付 | `/order/unifiedOrder` | POST | 预付单/统一下单 |
| 支付 | 订单查询、回调等 | GET/POST | 订单状态、回调 |
| 广告 | 曝光、点击上报等 | POST | 广告相关 |
| 小游戏 | 根据开放平台文档 | - | 设备能力、分享等 |
### 3.3 请求头(主站请求常见字段)
| Header/参数 | 说明 |
|-------------|------|
| `api-sign` | 签名由算法生成路径、参数、nonce、时间戳等 |
| `X-Auth-Token` | 用户 Token |
| `X-Auth-UserId` | 用户 ID |
| `request-nonce` | UUID 去掉 `-` |
| `app-time` | 时间戳 |
| `app-id` | 应用 ID如 10000003 |
| `app-version` | 应用版本 |
| `device-id` | 设备 ID |
| `os` | 如 android |
---
## 四、与工作手机 SDK 的对应关系
| Soul 功能 | 工作手机 unified 能力 | SoulSkill action |
|-----------|----------------------|------------------|
| 发瞬间(文字/图/视频) | POST /unified/moments/post | post_moments |
| 发私聊 | POST /unified/message/send | send_message |
| 获取会话/好友列表 | GET /unified/contacts | get_contacts |
| 获取某会话消息 | GET /unified/messages | get_messages |
| 点赞/评论瞬间 | 可扩展 like_moments、comment_moments | 待实现 |
---
## 五、参考与更新
- 官方开放平台https://mp-doc.soulapp.cn/
- 逆向/抓包参考api.soulapp.cn、api-sign 算法分析(见第三方博客)。
- 本文档随 Soul 版本与抓包结果更新,接口以实际抓包或官方文档为准。

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# 一键抓包:将代理切到 mitmproxy抓 90 秒内 Soul 请求并写入 ~/.soul_env
# 运行后请在本机打开 Soul 并发一条瞬间,抓包结束会自动恢复代理。
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ADDON="$SCRIPT_DIR/soul_capture_addon.py"
CAPTURE_PORT=8080
# 原代理(若与本机一致可改)
ORIG_PROXY_HOST="${ORIG_PROXY_HOST:-127.0.0.1}"
ORIG_PROXY_PORT="${ORIG_PROXY_PORT:-7897}"
echo "启动 mitmdump 抓包(端口 $CAPTURE_PORT,不校验上游证书以抓 Soul IP 直连)…"
export MITMPROXY_CONFDIR="$HOME/.mitmproxy"
/usr/local/bin/mitmdump -p $CAPTURE_PORT -k -s "$ADDON" 2>&1 &
MPID=$!
sleep 2
echo "=============================================="
echo "【重要】代理已开启。请在 180 秒内:"
echo " 1. 打开 Soul 客户端并保持在前台"
echo " 2. 发一条瞬间 或 刷新广场/个人页"
echo "=============================================="
networksetup -setwebproxy "Wi-Fi" $ORIG_PROXY_HOST $CAPTURE_PORT 2>/dev/null || true
networksetup -setsecurewebproxy "Wi-Fi" $ORIG_PROXY_HOST $CAPTURE_PORT 2>/dev/null || true
sleep 180
echo "恢复原代理 $ORIG_PROXY_HOST:$ORIG_PROXY_PORT"
networksetup -setwebproxy "Wi-Fi" $ORIG_PROXY_HOST $ORIG_PROXY_PORT 2>/dev/null || true
networksetup -setsecurewebproxy "Wi-Fi" $ORIG_PROXY_HOST $ORIG_PROXY_PORT 2>/dev/null || true
kill $MPID 2>/dev/null || true
if [[ -f "$HOME/.soul_env" ]]; then
HAS_AUTH=$(grep -E '^export SOUL_(AUTH_TOKEN|DEVICE_ID|API_SIGN)=' "$HOME/.soul_env" | sed 's/.*="\(.*\)".*/\1/' | grep -v '^$' | head -1)
if [[ -n "$HAS_AUTH" ]]; then
echo "✅ 已写入 $HOME/.soul_env可直接发瞬间: source ~/.soul_env && $SCRIPT_DIR/soul_post_moment_mac.sh \"内容\""
else
echo "⚠️ ~/.soul_env 存在但无认证信息。请重新运行本脚本,并在抓包期间于 Soul 内发一条瞬间或刷新页面。"
fi
else
echo "未抓到 Soul 请求。请重新运行,并确保抓包期间 Soul 在前台且执行了发瞬间/刷新操作。"
fi

View File

@@ -0,0 +1,85 @@
# mitmproxy addon: 捕获 Soul 请求头并写入 ~/.soul_env含 soulapp.cn 及 Soul 使用的 IP 直连)
import os
import re
def _is_static_path(path):
p = (path or "").lower()
return "app-source" in p or "/heads/avatar-" in p or "/image/202" in p or p.startswith("/app-source")
def request(flow):
try:
host = flow.request.pretty_host or flow.request.host or ""
path = (flow.request.path or "").lower()
is_soul = (
"soulapp.cn" in host
or host.startswith("47.")
or host.startswith("120.")
or host.startswith("118.")
)
if not is_soul:
return
if _is_static_path(path):
return
out = os.path.expanduser("~/.soul_env")
headers = flow.request.headers
# 1) 标准名
auth = headers.get("X-Auth-Token") or headers.get("x-auth-token")
uid = headers.get("X-Auth-UserId") or headers.get("x-auth-userid")
did = headers.get("device-id") or headers.get("Device-Id")
sign = headers.get("api-sign") or headers.get("Api-Sign")
app_time = headers.get("app-time") or headers.get("App-Time")
nonce = headers.get("request-nonce") or headers.get("Request-Nonce")
app_ver = headers.get("app-version") or headers.get("App-Version")
if not auth:
auth = (headers.get("Authorization") or "").strip()
if auth.startswith("Bearer "):
auth = auth[7:]
# 2) 遍历所有头,按名称匹配 auth/token/sign/device 等(兼容不同大小写/命名)
for name, value in headers.items():
if not value:
continue
n = name.lower()
v = (value or "").strip()
if "auth" in n and "token" in n and not auth:
auth = v
elif "user" in n and "id" in n and not uid and len(v) < 50:
uid = uid or v
elif "device" in n and "id" in n and not did:
did = did or v
elif "sign" in n and not sign:
sign = sign or v
elif "app-time" in n or n == "app-time":
app_time = app_time or v
elif "nonce" in n and not nonce:
nonce = nonce or v
elif "version" in n and "app" in n and not app_ver:
app_ver = app_ver or v
cookie = headers.get("Cookie") or headers.get("cookie") or ""
if not auth and cookie:
for part in cookie.split(";"):
part = part.strip()
if "token" in part.lower() or "auth" in part.lower():
m = re.search(r"([^=]+)=([^\s]+)", part)
if m:
auth = m.group(2).strip()
break
if not auth:
auth = ""
if not (auth or (uid or "").strip() or (did or "").strip() or (sign or "").strip()):
return
lines = [
f'export SOUL_AUTH_TOKEN="{auth}"',
f'export SOUL_USER_ID="{uid or ""}"',
f'export SOUL_DEVICE_ID="{did or ""}"',
f'export SOUL_API_SIGN="{sign or ""}"',
f'export SOUL_APP_TIME="{app_time or ""}"',
f'export SOUL_REQUEST_NONCE="{nonce or ""}"',
f'export SOUL_APP_VERSION="{app_ver or ""}"',
]
if cookie and not auth:
lines.append(f'export SOUL_COOKIE="{cookie[:500]}"')
with open(out, "w") as f:
f.write("\n".join(lines))
print(f"[soul_capture] 已写入 {out} (host={host}, path={path[:50]})")
except Exception as e:
print(f"[soul_capture] 错误: {e}")

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env bash
#
# Mac 上纯命令行发 Soul 瞬间(不控制鼠标、不控制电脑)
# 直接请求 Soul 服务端 API你当前 Mac 已登录的 Soul 会同步到这条瞬间。
#
# 使用前:抓包 Mac 版 Soul 一次「发瞬间」请求,把 Token 等写入环境变量或 ~/.soul_env。
# 之后一条命令即可: source ~/.soul_env && ./soul_post_moment_mac.sh "要发的文字"
#
set -e
BASE_URL="https://api.soulapp.cn"
# 若存在配置文件则加载(便于一条命令发送,无需每次 export
if [[ -f "$HOME/.soul_env" ]]; then
source "$HOME/.soul_env"
fi
# ========== 从环境变量读取(请先抓包 Mac Soul 发瞬间请求后填入,或写入 ~/.soul_env ==========
SOUL_AUTH_TOKEN="${SOUL_AUTH_TOKEN:-}"
SOUL_USER_ID="${SOUL_USER_ID:-}"
SOUL_DEVICE_ID="${SOUL_DEVICE_ID:-}"
SOUL_APP_VERSION="${SOUL_APP_VERSION:-}"
SOUL_APP_TIME="${SOUL_APP_TIME:-$(date +%s)000}"
SOUL_REQUEST_NONCE="${SOUL_REQUEST_NONCE:-$(uuidgen | tr -d '-')}"
SOUL_API_SIGN="${SOUL_API_SIGN:-}" # 必须:抓包得到的 api-sign或自行实现算法后生成
# 瞬间正文:脚本第一个参数,默认示例文案
CONTENT="${1:-来自命令行的瞬间}"
if [[ -z "$SOUL_AUTH_TOKEN" ]]; then
echo "错误: 请设置环境变量 SOUL_AUTH_TOKEN从 Mac Soul 抓包获取)"
echo "示例: export SOUL_AUTH_TOKEN=xxx SOUL_USER_ID=xxx SOUL_DEVICE_ID=xxx SOUL_API_SIGN=xxx"
exit 1
fi
if [[ -z "$SOUL_API_SIGN" ]]; then
echo "错误: 请设置 SOUL_API_SIGN。Soul 服务端校验签名,需从 Mac 发瞬间时的请求里复制 api-sign或自行实现签名算法。"
exit 1
fi
# 发布瞬间的 API 路径(根据抓包结果可能为 /v3/post/publish 或类似)
PATH_PUBLISH="${SOUL_PATH_PUBLISH:-/v3/post/publish}"
URL="${BASE_URL}${PATH_PUBLISH}"
# 请求体(按 Soul 实际格式调整,常见为 JSON 含 content
BODY=$(cat <<EOF
{"content":"${CONTENT}"}
EOF
)
echo "请求: POST $URL"
echo "内容: $CONTENT"
HTTP_CODE=$(curl -s -w "%{http_code}" -o /tmp/soul_post_resp.json -X POST "$URL" \
-H "Content-Type: application/json" \
-H "X-Auth-Token: $SOUL_AUTH_TOKEN" \
-H "X-Auth-UserId: $SOUL_USER_ID" \
-H "device-id: $SOUL_DEVICE_ID" \
-H "app-time: $SOUL_APP_TIME" \
-H "request-nonce: $SOUL_REQUEST_NONCE" \
-H "api-sign: $SOUL_API_SIGN" \
-H "app-version: ${SOUL_APP_VERSION:-}" \
-H "os: mac" \
-d "$BODY")
if [[ "$HTTP_CODE" =~ ^2 ]]; then
echo "成功: 瞬间已发送HTTP $HTTP_CODE"
cat /tmp/soul_post_resp.json
elif [[ "$HTTP_CODE" == "502" ]]; then
echo "服务端异常 502网关/上游暂时不可用),可稍后重试。响应:"
cat /tmp/soul_post_resp.json
echo "建议: 间隔 1030 秒后再次执行本条命令。"
exit 1
else
echo "失败: HTTP $HTTP_CODE"
cat /tmp/soul_post_resp.json
exit 1
fi

View File

@@ -0,0 +1,103 @@
# Soul 产品与功能维度分析
> 维度:产品定位、功能矩阵、优势与劣势、可借鉴点
> 用途:为自研类似形态社交/内容 APP 提供产品与功能参考。
---
## 一、产品定位
| 项目 | 内容 |
|------|------|
| 产品名 | Soul灵魂社交 / 年轻人的社交元宇宙) |
| 目标人群 | 年轻用户Z 世代为主),强调「灵魂」匹配而非颜值 |
| 核心价值 | 匿名/弱身份下的内容表达与关系建立;兴趣与人格标签(灵魂鉴定、星球) |
| 形态 | 内容社区 + 私聊 + 语音派对/群聊 + 小游戏,偏「社交+内容+娱乐」综合体 |
---
## 二、功能矩阵与模块划分
### 2.1 内容与动态
| 功能 | 说明 | 好处 | 坏处/注意 |
|------|------|------|----------|
| 发瞬间 | 文字/图/视频发布到广场 | 降低表达门槛,利于 UGC | 需审核与风控,存储与带宽成本高 |
| 瞬间广场 | 信息流、刷新、下拉加载 | 强留存与时长 | 推荐与冷启动难度大 |
| 点赞/评论/收藏/分享 | 互动闭环 | 提升粘性与传播 | 需防刷、反作弊 |
| 话题/标签 | 带话题发布、按话题浏览 | 利于发现与运营 | 依赖运营与用户习惯 |
### 2.2 私聊与消息
| 功能 | 说明 | 好处 | 坏处/注意 |
|------|------|------|----------|
| 私聊 | 一对一文字/图/语音/表情 | 关系沉淀、变现入口 | 需消息存储、推送、多端同步 |
| 消息列表/未读/置顶 | 会话管理 | 体验清晰 | 需与推送策略配合 |
| 搜索会话/用户 | 按昵称或 ID 搜索 | 便捷触达 | 需防爬、限频、隐私合规 |
| 打招呼/破冰 | 首次打招呼、快捷语 | 降低破冰成本 | 易被滥用,需反骚扰 |
### 2.3 群聊与派对
| 功能 | 说明 | 好处 | 坏处/注意 |
|------|------|------|----------|
| 语音房/派对 | 多人上麦、发言 | 高时长、强互动 | 实时音视频成本与合规(内容安全) |
| 聊天房 | 主题房间 | 便于运营与活动 | 需房主/房管能力与风控 |
| 群聊 | 多人文字群 | 关系链沉淀 | 需群管理、禁言、举报等 |
### 2.4 关系与发现
| 功能 | 说明 | 好处 | 坏处/注意 |
|------|------|------|----------|
| 关注/粉丝 | 单向关注、列表 | 关系链清晰、可做推荐 | 需防刷粉、虚假关注 |
| 灵魂匹配 | 问卷/灵魂鉴定匹配 | 差异化、话题感 | 算法与产品设计难度高 |
| 推荐/发现 | 首页推荐用户或瞬间 | 冷启动与留存 | 依赖算法与数据 |
| 谁看过我/打招呼列表 | 发现与破冰 | 提升转化 | 易被滥用,需限频与隐私 |
### 2.5 个人与资料
| 功能 | 说明 | 好处 | 坏处/注意 |
|------|------|------|----------|
| 个人主页 | 头像、昵称、瞬间、关注数/粉丝数 | 人设与信任 | 需防作弊与虚假展示 |
| 3D 捏脸/头像 | 3D 头像定制 | 差异化、年轻化 | 开发与渲染成本高 |
| 灵魂鉴定/星球 | 答题生成类型、进入星球 | 强心智与归属 | 需持续运营与迭代 |
| 设置 | 隐私、通知、账号与安全 | 合规与体验 | 需与隐私政策一致 |
### 2.6 其他
| 功能 | 说明 | 好处 | 坏处/注意 |
|------|------|------|----------|
| 登录/注册 | 手机号、验证码、第三方 | 降低注册门槛 | 需防刷号、实名与合规 |
| 小游戏/狼人杀 | 站内小游戏 | 提升时长与变现 | 依赖开放平台或自研 |
| AI 对话 | 与 AI 角色对话 | 新体验、可探索变现 | 成本与效果需平衡 |
---
## 三、产品维度:优势与劣势汇总
### 3.1 优势(好处)
| 维度 | 说明 |
|------|------|
| 定位清晰 | 「灵魂社交」差异化,弱化颜值,利于部分用户表达与匹配 |
| 功能矩阵完整 | 内容+私聊+派对+关系+个人+小游戏,覆盖社交与娱乐场景 |
| 心智设计强 | 灵魂鉴定、星球等形成强归属与话题 |
| 形态现代 | 3D 捏脸、语音房等符合年轻用户偏好 |
| 变现路径多 | 会员、虚拟礼物、小游戏、广告等 |
### 3.2 劣势(坏处/风险)
| 维度 | 说明 |
|------|------|
| 合规与内容安全 | UGC、语音房、私聊均需强审核与风控成本与风险高 |
| 依赖算法与运营 | 推荐、匹配、冷启动依赖数据与运营,门槛高 |
| 复杂度高 | 功能多,研发与维护成本大,版本迭代压力大 |
| 同质化竞争 | 与探探、陌陌、兴趣社交等存在重叠,需持续差异化 |
---
## 四、可借鉴点(参考其形式做产品)
- **模块化功能**:按「内容 / 私聊 / 派对 / 关系 / 个人 / 其他」拆模块,便于 MVP 与迭代。
- **心智与归属**:类似「灵魂鉴定+星球」的轻量人设与分区,可降低冷启动并提升留存。
- **互动闭环**:点赞、评论、收藏、分享、打招呼等形成闭环,便于指标与增长设计。
- **分层变现**:会员、虚拟礼物、小游戏、广告等多条线,可参考其组合方式做商业化设计。

View File

@@ -0,0 +1,117 @@
# Soul 技术架构与实现维度分析
> 维度客户端、服务端、API 与协议、技术栈、优势与劣势、实现参考
> 用途:为自研类似形态 APP 提供技术架构与实现方式参考。
---
## 一、整体架构概览
```
┌─────────────────────────────────────────────────────────────────────┐
│ Soul 技术架构(推断) │
├─────────────────────────────────────────────────────────────────────┤
│ 客户端Android / iOS / Mac
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 业务模块 │ │ 通用能力 │ │ 网络/存储 │ │
│ │ Publish │ │ 登录/配置 │ │ HTTP/2 API │ │
│ │ MainSquare │ │ 推送/统计 │ │ 本地缓存 │ │
│ │ PrivateChat │ │ 安全/签名 │ │ 图片/媒体 │ │
│ │ ChatRoom │ │ │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ ↓ ↓ ↓ │
│ ───────────────────────────────────────────────────────────────── │
│ 网关 / CDN / 负载均衡 → 主站 APIapi.soulapp.cn
│ → 开放平台openplatform-openapi.soulapp.cn
│ → 资源域china-img.soulapp.cn 等) │
│ ───────────────────────────────────────────────────────────────── │
│ 服务端:业务服务、推荐、消息、存储、实时音视频等 │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 二、客户端
### 2.1 多端形态
| 端 | 包名/形态 | 说明 |
|----|-----------|------|
| Android | `cn.soulapp.android` | 主端,功能最全 |
| iOS | App Store 应用 | 与 Android 能力对齐 |
| Mac | SOUL.appCatalyst 或类似) | 多 bundlePublish、MainSquare、PrivateChat、ChatRoom、Login 等 |
### 2.2 客户端技术栈(推断)
| 层面 | 可能技术 | 好处 | 坏处 |
|------|----------|------|------|
| UI/框架 | 原生 + 部分 Hybrid/RN | 性能与体验可控 | 多端重复开发 |
| 网络 | HTTP/2、API 域名 + 直连 IP | 减少 DNS、可做灰度 | 证书与 IP 变更需兼容 |
| 安全 | 请求签名api-sign、Token | 防篡改、防重放 | 逆向后可被模拟,需持续迭代 |
| 存储 | 本地缓存、配置、用户数据 | 离线与启动速度 | 需加密与清理策略 |
### 2.3 客户端优缺点(技术维度)
| 类型 | 说明 |
|------|------|
| 优点 | 模块化bundle/业务拆分清晰);多端覆盖;请求带签名与鉴权,安全基线高 |
| 缺点 | 多端维护成本大;直连 IP 对抓包/代理不友好,对自建代理或测试环境需额外处理 |
---
## 三、服务端与 API
### 3.1 API 域与职责
| 域 | 用途 | 说明 |
|----|------|------|
| api.soulapp.cn | 主站业务 API | 瞬间、私聊、用户、关系、搜索、登录等 |
| 直连 IP如 120.x、47.x | 部分请求直连 | 可能为就近接入或灰度,需 TLS SNI/证书兼容 |
| openplatform-openapi.soulapp.cn | 开放平台 | 支付、广告、小游戏等 |
| china-img.soulapp.cn 等 | 静态资源/CDN | 图片、头像、媒体 |
### 3.2 请求形态(主站)
| 项目 | 内容 |
|------|------|
| 协议 | HTTPS、HTTP/2 |
| 鉴权 | HeaderX-Auth-Token、X-Auth-UserId、device-id、app-id、app-version、os |
| 签名 | api-sign与路径、参数、nonce、时间戳等相关防篡改/重放) |
| 其他 | request-nonce如 UUID 无横线、app-time时间戳 |
### 3.3 接口分层(按业务)
| 模块 | 路径示例 | 方法 | 说明 |
|------|----------|------|------|
| 瞬间 | /v3/post/publish、/v3/post/list、/v3/post/comment 等 | POST/GET | 发布、列表、评论、点赞 |
| 私信 | /v3/chat/send、/v3/chat/list、/v3/chat/history 等 | POST/GET | 发送、会话列表、历史 |
| 用户 | /v3/user/profile、/v3/user/follow、/v3/user/fans 等 | GET/POST | 资料、关注、粉丝 |
| 搜索 | /v3/search/user、/v3/search/post 等 | GET | 搜索 |
| 登录/鉴权 | /v3/auth/login、/v3/user/token 等 | POST | 登录、刷新 Token |
### 3.4 服务端技术优缺点(推断)
| 类型 | 说明 |
|------|------|
| 优点 | 接口 REST 化、版本化v3域名与资源分离利于 CDN 与扩展;开放平台独立,便于生态与合规 |
| 缺点 | 签名与多 Header 增加对接与调试成本;直连 IP 依赖证书与运维,对故障排查与代理不友好 |
---
## 四、数据与存储(推断)
| 类型 | 可能方案 | 好处 | 坏处 |
|------|----------|------|------|
| 关系型 | 用户、关系、配置、订单等 | 事务与一致性 | 扩展需分库分表或中间件 |
| 文档/缓存 | 内容、消息、会话、推荐 | 灵活、高并发 | 需一致性设计与缓存策略 |
| 对象存储 | 图片、视频、语音 | 成本与扩展性好 | 需 CDN 与审核 |
| 实时 | 语音房、消息推送 | 低延迟 | 成本与运维复杂度高 |
---
## 五、实现参考(参考其形式做技术选型)
- **接口设计**REST、版本前缀如 /v3/)、模块化路径(/post、/chat、/user、/search
- **鉴权与签名**Token + 设备维度device-id、app-id、app-version+ 请求级签名nonce+时间戳+路径+参数),防重放与篡改。
- **多域与资源分离**:主 API、开放平台、静态资源分域便于权限、CDN 与扩展。
- **客户端**:按业务模块拆分(发布、广场、私聊、房间、登录),便于多端复用与灰度发布。

View File

@@ -0,0 +1,96 @@
# Soul 安全与风控维度分析
> 维度:认证、签名、反爬与反作弊、合规、优势与劣势
> 用途:为自研类似形态 APP 提供安全与风控设计参考。
---
## 一、认证与鉴权
### 1.1 请求头与身份
| Header/参数 | 作用 | 好处 | 坏处 |
|-------------|------|------|------|
| X-Auth-Token | 用户会话/访问令牌 | 无状态、可过期与刷新 | 泄露后可冒充,需绑定设备/环境 |
| X-Auth-UserId | 用户 ID | 明确身份 | 需与 Token 一致校验 |
| device-id | 设备标识 | 设备维度风控、多端管理 | 可伪造,需结合其他信号 |
| app-id / app-version / os | 应用与环境 | 版本与平台校验、灰度 | 可被篡改,需服务端校验 |
| request-nonce | 请求唯一标识 | 防重放 | 需服务端去重与时效 |
| app-time | 客户端时间戳 | 参与签名、时效控制 | 时间不同步会影响签名 |
### 1.2 认证流程(推断)
- 登录:手机号/验证码或第三方 → 服务端下发 Token及可能 RefreshToken
- 请求:每次请求带 Token + device-id + api-sign服务端校验 Token 有效性、签名、nonce、时间窗口。
### 1.3 优缺点
| 类型 | 说明 |
|------|------|
| 优点 | 多维度鉴权(用户+设备+应用nonce+时间防重放;签名防篡改 |
| 缺点 | 实现与对接复杂客户端时间或环境异常会导致失败Token 泄露仍可被滥用,需限频与风控 |
---
## 二、请求签名api-sign
### 2.1 作用与形态
- **作用**:防止参数被篡改、请求被重放、未授权方直接调用 API。
- **形态**Header `api-sign`,通常与路径、请求体/参数、nonce、时间戳、密钥等参与运算具体算法需逆向此处仅作设计参考
### 2.2 好处与坏处
| 类型 | 说明 |
|------|------|
| 好处 | 提升接口安全性;第三方难以直接伪造合法请求;可与设备/环境绑定 |
| 坏处 | 算法一旦逆向可被模拟;密钥与逻辑需妥善保护;多端与历史版本需兼容,增加维护成本 |
---
## 三、反爬与反作弊
### 3.1 可能手段(推断)
| 手段 | 说明 | 好处 | 坏处 |
|------|------|------|------|
| 请求签名 | 见上 | 提高爬虫与脚本成本 | 不影响有逆向能力的脚本 |
| 限频/限流 | 按 IP、设备、用户限频 | 防刷、降成本 | 正常用户异常环境可能误伤 |
| 设备指纹 | device-id 等 | 识别多账号、机器 | 隐私与合规需注意 |
| 行为与内容风控 | 举报、审核、模型 | 内容安全、合规 | 需持续迭代与人工兜底 |
### 3.2 优缺点
| 类型 | 说明 |
|------|------|
| 优点 | 多层防护(签名+限频+设备+行为),降低批量爬取与作弊成功率 |
| 缺点 | 与体验、隐私的平衡;强对抗下需持续迭代,成本高 |
---
## 四、合规与内容安全
### 4.1 相关维度
| 维度 | 说明 | 好处 | 坏处 |
|------|------|------|------|
| 实名与认证 | 手机号、实名等 | 满足监管与风控 | 影响匿名体验与转化 |
| 内容审核 | UGC、语音、私聊 | 合规与品牌 | 成本高、有误判与延迟 |
| 隐私与协议 | 隐私政策、权限说明 | 合规与信任 | 需与产品、法务对齐 |
| 未成年人保护 | 防沉迷、权限限制 | 合规 | 产品与策略需专门设计 |
### 4.2 优缺点
| 类型 | 说明 |
|------|------|
| 优点 | 合规基线可降低监管与法律风险 |
| 缺点 | 审核与实名会带来成本与体验代价,需在合规与体验间权衡 |
---
## 五、可借鉴点(参考其形式做安全)
- **多层鉴权**Token + 设备 + 应用版本 + 请求级 nonce/时间戳,便于风控与限频。
- **请求签名**:关键接口使用签名(路径+参数+nonce+时间+密钥),防篡改与重放。
- **限频与设备维度**:按用户/设备/IP 限频,结合设备指纹识别异常与多账号。
- **合规前置**:实名、隐私政策、内容审核、未成年人保护与产品设计同步考虑。

View File

@@ -0,0 +1,74 @@
# Soul 商业与运营维度分析
> 维度:变现方式、留存与增长、运营手段、优势与劣势
> 用途:为自研类似形态 APP 提供商业化与运营参考。
---
## 一、变现方式
### 1.1 主要变现路径(推断)
| 方式 | 说明 | 好处 | 坏处 |
|------|------|------|------|
| 会员/订阅 | 会员权益(如查看更多、去广告、特权) | 稳定、可预测 | 依赖付费意愿与权益设计 |
| 虚拟礼物/打赏 | 语音房、瞬间等场景打赏 | 高 ARPU、强场景 | 需分成与合规(主播、资质) |
| 小游戏 | 站内小游戏、道具或激励 | 提升时长与变现 | 依赖开放平台或自研,需防沉迷 |
| 广告 | 信息流、开屏、激励视频等 | 规模变现 | 与体验平衡,需填充与投放能力 |
| 其他 | 如表情包、皮肤、装扮等 | 补充收入 | 需与产品调性匹配 |
### 1.2 优缺点
| 类型 | 说明 |
|------|------|
| 优点 | 多条变现线,不依赖单一收入;虚拟礼物与场景结合紧,付费动机强 |
| 缺点 | 会员与广告可能影响体验;虚拟礼物与主播生态需运营与合规;小游戏需持续供给与防沉迷 |
---
## 二、留存与增长
### 2.1 留存设计(推断)
| 手段 | 说明 | 好处 | 坏处 |
|------|------|------|------|
| 内容与推荐 | 广场、推荐、话题 | 日活与时长 | 依赖算法与内容供给 |
| 关系链 | 关注、粉丝、私聊、群组 | 沉淀关系,提升复访 | 需防骚扰与虚假关系 |
| 心智与归属 | 灵魂鉴定、星球、身份 | 情感与归属感 | 需持续运营与迭代 |
| 推送与触达 | 消息、系统通知 | 召回与活跃 | 需控制推送频率与精准度 |
| 签到/任务 | 签到、任务、等级 | 习惯与激励 | 易疲劳,需与权益结合 |
### 2.2 增长设计(推断)
| 手段 | 说明 | 好处 | 坏处 |
|------|------|------|------|
| 拉新 | 邀请、分享、渠道投放 | 扩大用户基盘 | 成本与质量需平衡 |
| 冷启动 | 灵魂鉴定、推荐、破冰 | 降低首日流失 | 依赖算法与产品设计 |
| 裂变 | 分享、邀请奖励 | 低成本获客 | 需防刷与风控 |
### 2.3 优缺点
| 类型 | 说明 |
|------|------|
| 优点 | 内容+关系+心智+推送+任务多管齐下,留存与增长手段丰富 |
| 缺点 | 依赖算法与运营投入;推送与任务过度可能引起反感;需持续迭代与数据驱动 |
---
## 三、运营手段(推断)
| 类型 | 说明 | 好处 | 坏处 |
|------|------|------|------|
| 话题/活动 | 运营话题、活动、挑战 | 提升 UGC 与传播 | 需创意与执行 |
| 用户分层 | 新老、活跃/沉默、付费 | 精准运营 | 需数据与策略 |
| 客服与举报 | 举报、申诉、客服 | 体验与合规 | 成本与响应速度 |
| 生态 | 开放平台、小游戏、MCN | 丰富内容与变现 | 依赖合作与治理 |
---
## 四、可借鉴点(参考其形式做商业与运营)
- **变现组合**:会员 + 虚拟礼物 + 小游戏 + 广告,多条线分散风险并提升 LTV。
- **留存**:内容与推荐 + 关系链 + 心智设计(如灵魂/星球)+ 推送与任务,形成闭环。
- **增长**:冷启动与破冰设计 + 邀请与裂变 + 渠道投放,配合风控与防刷。
- **运营**:话题与活动 + 用户分层 + 举报与客服,与产品与合规对齐。

View File

@@ -0,0 +1,73 @@
# Soul 体验与交互维度分析
> 维度:体验设计、交互形式、优势与劣势
> 用途:为自研类似形态 APP 提供体验与交互参考。
---
## 一、整体体验特征
| 特征 | 说明 | 好处 | 坏处 |
|------|------|------|------|
| 匿名/弱身份 | 不强调真人头像、可 3D 捏脸 | 降低社交压力、差异化 | 信任建立慢,需其他信任信号 |
| 内容优先 | 瞬间广场、信息流为主入口 | 内容消费与时长 | 关系链弱时可能冷启动难 |
| 多维入口 | 广场、消息、发布、我的等 | 路径清晰、功能可达 | 需防止功能过多导致杂乱 |
| 年轻化 | 3D、星球、灵魂鉴定等 | 贴合目标用户 | 非目标用户可能无感 |
---
## 二、交互形式
### 2.1 导航与结构
| 形式 | 说明 | 好处 | 坏处 |
|------|------|------|------|
| 底部 Tab | 多 Tab 切换(如广场、消息、发布、我的) | 符合移动端习惯、可达性高 | Tab 过多会拥挤 |
| 信息流 | 上下滑动、下拉刷新、上拉加载 | 沉浸、时长友好 | 需加载与推荐体验优化 |
| 发布入口 | 显眼发布按钮(如底部中央或 Tab | 降低发布门槛 | 需防误触与滥用 |
### 2.2 内容与互动
| 形式 | 说明 | 好处 | 坏处 |
|------|------|------|------|
| 点赞/评论/分享 | 常规互动 | 心智简单、传播 | 需防刷与骚扰 |
| 私聊入口 | 从内容/主页进入私聊 | 转化路径短 | 需防骚扰与举报机制 |
| 语音房/派对 | 上麦、发言、礼物 | 强互动与时长 | 需内容安全与体验平衡 |
### 2.3 个人与身份
| 形式 | 说明 | 好处 | 坏处 |
|------|------|------|------|
| 3D 捏脸/头像 | 自定义形象 | 个性与隐私兼顾 | 开发与性能成本 |
| 灵魂鉴定/星球 | 答题与分区 | 归属感与话题 | 需持续运营 |
| 个人主页 | 瞬间、关注/粉丝、资料 | 人设展示 | 需防虚假与作弊展示 |
---
## 三、体验维度:优势与劣势
### 3.1 优势
| 维度 | 说明 |
|------|------|
| 差异化 | 匿名+灵魂+星球形成清晰心智,与颜值社交区分 |
| 入口与路径 | 底部 Tab + 信息流 + 发布入口,符合习惯 |
| 互动闭环 | 点赞、评论、私聊、语音房覆盖多种互动 |
| 年轻化表达 | 3D、星球、鉴定等贴合年轻用户 |
### 3.2 劣势
| 维度 | 说明 |
|------|------|
| 复杂度 | 功能多,新用户学习成本与认知负担 |
| 信任与安全 | 匿名带来信任与内容安全挑战,需强运营与风控 |
| 多端一致 | 多端体验与性能需持续打磨 |
---
## 四、可借鉴点(参考其形式做体验)
- **心智与身份**:用轻量「人设/分区」(如灵魂/星球)降低压力并提升归属,避免一上来强实名。
- **导航**:底部主 Tab + 信息流 + 显眼发布,保持路径简单。
- **互动**:点赞、评论、私聊、语音房等分层设计,兼顾内容消费与关系建立。
- **年轻化**:在视觉与玩法上做年轻化(如 3D、答题、主题房与目标人群匹配。

View File

@@ -0,0 +1,96 @@
# 参考 Soul 开发 APP 的技术基线与实现方式
> 用途:以 Soul 为参照,给出自研类似形态 APP 的**技术基线、架构建议与实现方式**,供立项与开发参考。
> 依赖:建议先阅读 0105 各维度分析,再使用本文档做落地对照。
---
## 一、技术基线总览
以下为「参考 Soul 形式」开发一款社交/内容 APP 时,建议具备的技术基线。
| 层级 | 基线项 | 说明 | 参考 Soul 对应 |
|------|--------|------|----------------|
| 客户端 | 多端 | Android + iOS可选 Mac/Web | 多端覆盖Mac 为 bundle 化 |
| 客户端 | 模块化 | 按业务拆模块(发布/广场/消息/房间/我的/登录) | Publish、MainSquare、PrivateChat 等 |
| 客户端 | 网络与安全 | HTTPS、Token、设备标识、请求签名 | api-sign、X-Auth-Token、device-id |
| 服务端 | API 设计 | REST、版本前缀、模块化路径 | /v3/post、/v3/chat、/v3/user |
| 服务端 | 鉴权 | 用户 Token + 设备 + 请求级 nonce/时间戳 | 见 03 安全分析 |
| 服务端 | 多域 | 主 API、开放平台、静态资源分离 | api.soulapp.cn、openplatform、china-img |
| 数据 | 存储 | 用户/关系/内容/消息/媒体分层存储 | 关系型+文档/缓存+对象存储 |
| 安全 | 风控 | 签名、限频、设备、内容审核 | 见 03 |
| 合规 | 实名与内容 | 实名、隐私、审核、未成年人 | 见 03、04 |
---
## 二、架构建议(分阶段)
### 2.1 MVP 阶段
| 模块 | 建议 | 说明 |
|------|------|------|
| 客户端 | 单端Android 或 iOS先行 | 验证产品与核心链路 |
| 功能 | 登录 + 发内容 + 信息流 + 简单互动(点赞/评论)+ 私聊 | 对应 Soul 的发布、广场、私聊最小集 |
| 服务端 | 单体或少量服务 + 单库 | 用户、内容、消息、关系可先同库或分表 |
| API | REST + /v1/ 前缀 + Token 鉴权 | 暂可不做请求签名,用 HTTPS + Token |
| 存储 | 关系型 + 对象存储(图片/视频) | 消息可先存库,后续再拆消息库或缓存 |
### 2.2 成长阶段
| 模块 | 建议 | 说明 |
|------|------|------|
| 客户端 | 双端 + 模块化 | 发布/广场/消息/个人等拆包或模块 |
| 功能 | 关注/粉丝、搜索、推送、基础推荐 | 对应 Soul 的关系与发现 |
| 服务端 | 服务拆分(用户、内容、消息、关系、推荐) | 便于扩展与团队分工 |
| API | 版本化(/v2/+ 请求签名(关键接口) | 参考 Soul 的 api-sign 设计 |
| 存储 | 消息/会话可迁缓存或专用库;推荐用缓存或检索 | 提升性能与扩展性 |
### 2.3 扩展阶段
| 模块 | 建议 | 说明 |
|------|------|------|
| 客户端 | Mac/Web、3D 或特色玩法 | 对应 Soul 多端与 3D 捏脸 |
| 功能 | 语音房/派对、小游戏、开放平台 | 对应 Soul 的 ChatRoom、小游戏、开放平台 |
| 服务端 | 开放平台域、实时音视频、推荐与算法 | 多域、实时能力、算法中台 |
| 安全与风控 | 设备指纹、限频、内容审核、反作弊 | 参考 03 |
---
## 三、实现方式选型对照
以下为与 Soul 形态对照的**实现方式选型**,便于按团队与资源做决策。
| 能力 | Soul 形态(参考) | 自研可选方案 | 说明 |
|------|-------------------|--------------|------|
| 鉴权 | Token + device-id + 多 Header | Token + device-id 必选;签名可二期 | 先保证身份与设备,再上签名 |
| 请求签名 | api-sign路径+参数+nonce+时间) | 自研算法或 HMAC 等 | 防篡改与重放,密钥与逻辑保密 |
| 内容存储 | 推断:关系型+文档/缓存+对象存储 | 关系型(用户/关系)+ 文档/MySQL/Redis内容/消息)+ OSS/S3 | 按规模与成本选型 |
| 消息 | 私聊、会话列表、历史 | 自建 IM 或接入第三方 IM | 考虑合规与成本 |
| 实时语音房 | 语音房、上麦 | 接入声网/腾讯云等或自研 RTC | 成本与合规 |
| 推荐 | 首页推荐、匹配 | 规则引擎 → 简单模型 → 排序模型 | 冷启动可用规则+热度 |
| 推送 | 消息与系统通知 | 厂商通道 + 自建或第三方推送 | 到达率与合规 |
| 多端 | Android + iOS + Mac | 原生 / Flutter / RN / 小程序 | 根据团队与体验要求 |
---
## 四、开发顺序建议(参考 Soul 形态)
1. **基础**:登录(手机号/验证码、用户与设备、Token 鉴权、基础 API 与存储。
2. **内容**:发布(文字/图/视频)、信息流、点赞/评论、内容审核(规则或人工)。
3. **关系**:关注/粉丝、个人主页、简单发现(列表或简单推荐)。
4. **私聊**:会话列表、发消息、历史、推送。
5. **增强**:请求签名、限频、设备风控、推荐与算法。
6. **扩展**语音房、小游戏、开放平台、多端Mac/Web等。
---
## 五、文档与调研使用顺序
- **产品与功能**01 → 明确功能矩阵与优先级。
- **技术架构**02 → 确定 API、多域、客户端模块划分。
- **安全与风控**03 → 确定鉴权、签名、限频、合规。
- **商业与运营**04 → 确定变现与留存、增长策略。
- **体验与交互**05 → 确定导航、互动与心智设计。
- **落地**06本文档→ 技术基线、分阶段架构与选型、开发顺序。
按以上顺序阅读并对照 0105即可在「参考 Soul 形式」的前提下,形成自研 APP 的技术基线与实现方式方案。

View File

@@ -0,0 +1,34 @@
# Soul 多维度调研文档
> 用途:从产品、技术、安全、商业、体验等维度对 Soul APP 做拆解分析,为**自研类似形态 APP** 提供技术基线与实现方式参考。
> 创建日期2026-02-28
---
## 文档列表
| 序号 | 文档 | 维度 | 说明 |
|:----:|------|------|------|
| 01 | [Soul产品与功能维度分析](01_Soul产品与功能维度分析.md) | 产品·功能 | 产品定位、功能矩阵、优势与劣势、可借鉴点 |
| 02 | [Soul技术架构与实现维度分析](02_Soul技术架构与实现维度分析.md) | 技术·架构 | 客户端/服务端/API/协议、技术栈、优缺点、实现参考 |
| 03 | [Soul安全与风控维度分析](03_Soul安全与风控维度分析.md) | 安全·风控 | 认证、签名、反爬、合规、优缺点 |
| 04 | [Soul商业与运营维度分析](04_Soul商业与运营维度分析.md) | 商业·运营 | 变现、留存、增长、优缺点 |
| 05 | [Soul体验与交互维度分析](05_Soul体验与交互维度分析.md) | 体验·交互 | 体验设计、交互形式、优缺点 |
| 06 | [参考Soul开发APP的技术基线与实现方式](06_参考Soul开发APP的技术基线与实现方式.md) | 开发参考 | 技术基线、架构建议、实现方式、选型对照 |
---
## 使用方式
- **做产品/功能规划**:优先看 01、05。
- **做技术选型与架构**:优先看 02、06。
- **做安全与合规**:优先看 03。
- **做商业化与运营**:优先看 04。
- **整体参考 Soul 形式开发 APP**:按 01→02→03→04→05→06 顺序阅读,并以 06 作为落地基线。
---
## 约定
- 分析基于公开信息、抓包与既有项目分析,非官方披露,仅供参考。
- 各文档内「好处/坏处」「优点/缺点」均按该维度拆解,便于对照与选型。