Compare commits
10 Commits
boss-app-v
...
codex/mast
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36a2cd8dfd | ||
|
|
e8304faebc | ||
|
|
4bedf75dc2 | ||
|
|
363f732e29 | ||
|
|
f0490de180 | ||
|
|
504d112218 | ||
|
|
de6257a819 | ||
|
|
f496838ced | ||
|
|
514971bef8 | ||
|
|
39be49630f |
15
.gitignore
vendored
15
.gitignore
vendored
@@ -19,28 +19,13 @@
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist/
|
||||
apps/boss-admin-web/dist/
|
||||
apps/boss-admin-web/node_modules/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.playwright-cli/
|
||||
.playwright-mcp/
|
||||
.superpowers/
|
||||
output/
|
||||
outputs/
|
||||
admin-redesign*.png
|
||||
main-*.js
|
||||
android/.project
|
||||
android/.settings/
|
||||
android/app/.classpath
|
||||
android/app/.project
|
||||
android/app/.settings/
|
||||
data/*.json
|
||||
data/*.json.bak
|
||||
data/backups/*.json
|
||||
android/.gradle/
|
||||
android/**/build/
|
||||
android/local.properties
|
||||
|
||||
95
README.md
95
README.md
@@ -10,10 +10,8 @@
|
||||
2. `docs/architecture/repo_map_cn.md`
|
||||
3. `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
4. `docs/architecture/api_and_service_inventory_cn.md`
|
||||
5. `docs/architecture/enterprise_ai_ops_architecture_cn.md`
|
||||
6. `docs/architecture/rbac_skill_regression_matrix_cn.md`
|
||||
7. `docs/architecture/boss_server_connection_and_deploy_cn.md`
|
||||
8. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md`
|
||||
5. `docs/architecture/boss_server_connection_and_deploy_cn.md`
|
||||
6. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md`
|
||||
|
||||
## 当前有效目录
|
||||
|
||||
@@ -55,52 +53,17 @@
|
||||
- `POST http://127.0.0.1:3000/api/auth/restore` 正常,已验证可用原生 restore token 恢复登录态
|
||||
- `POST http://127.0.0.1:3000/api/v1/projects/master-agent/messages` 正常,已验证可通过 `Mac Studio local-agent -> 本机 Master Codex Node -> 回写项目账本` 返回真实主 Agent 回复
|
||||
- `POST http://127.0.0.1:3000/api/v1/projects/[projectId]/messages` 正常,普通单线程会话当前会返回 `conversation_reply` 任务,并等待绑定设备上的真实 Codex 线程回写
|
||||
- `POST http://127.0.0.1:3000/api/v1/integrations/telegram/webhook` 正常,已支持 Telegram Bot 私聊消息直连 Boss 主 Agent;快速回复会立即回 Telegram,异步任务完成后也会自动回推
|
||||
- `GET/POST http://127.0.0.1:3000/api/v1/integrations/telegram` 正常,已支持最高管理员读取和保存 Telegram 接入配置,返回默认脱敏视图;保存 webhook 模式时会自动调用 Telegram `setWebhook`,切回 polling/关闭时会自动调用 `deleteWebhook`;Web `/me/telegram` 与原生 Android `我的 > Telegram 接入` 都已接入这条配置链路,并支持把群 / Topic 路由到指定 Boss 项目
|
||||
- `POST http://127.0.0.1:3000/api/auth/logout` 正常,退出后访问受保护 `/api/v1/*` 会返回 `401`
|
||||
- `GET/POST http://127.0.0.1:3000/api/v1/auth/sessions` 正常,已支持查看当前账号登录会话、最高管理员查看全部活跃会话,以及撤销单个登录端;返回内容不会暴露 `sessionToken / restoreToken`
|
||||
- 当前多用户 / RBAC 第一阶段已经落地:`BossState` 新增 `accountDeviceGrants / accountProjectGrants / accountSkillGrants / skillCatalog / permissionAuditLogs`,所有会话、设备、项目详情、消息读写、设备 Skill 和 `/api/state` 都会按当前登录账号过滤;最高管理员仍保持全局可见
|
||||
- `GET/POST http://127.0.0.1:3000/api/v1/admin/access` 正常,仅最高管理员可用;当前支持创建/更新子账号、公司启用/停用、账号/设备归属、批量导入预览、批量导入、重置子账号密码、离职回收、授予设备/项目/Skill 权限、套用权限模板和撤销授权,返回账号时不会暴露 `passwordHash`
|
||||
- `GET http://127.0.0.1:3000/api/v1/admin/overview`、`POST http://127.0.0.1:3000/api/v1/admin/risks/scan` 和 `POST http://127.0.0.1:3000/api/v1/admin/notifications/dispatch` 正常,仅最高管理员可用;风险扫描会把超时 SLA 幂等写入 `adminNotifications`,派发结果和处置动作写入 `adminRiskTimeline`
|
||||
- `GET/POST http://127.0.0.1:3000/api/v1/admin/skills/requests` 正常,仅最高管理员可用;当前支持对指定设备创建 `install / update / uninstall / rollback / version_lock` 请求,local-agent 会通过设备 token 认领、执行本机 Skill 文件操作或 Git 操作,并把完成状态和最新 Skill 清单回写
|
||||
- 当前 Web `/me/access` 和原生 Android `我的 > 用户与权限` 已接入授权管理:最高管理员可在前台创建子账号、授予设备/项目/Skill 权限、套用 `只读观察员 / 项目开发者 / 设备操作者` 模板、查看同名 Skill 跨设备聚合和撤销授权;`admin/member` 不显示该入口
|
||||
- 当前主 Agent 执行上下文已接入授权快照:主 Agent 生成提示词和任务时只带当前账号可见的设备、项目、线程状态文档、进展事件和 Skill,并在 `MasterAgentTask` 上记录 `authorizedDeviceIds / authorizedProjectIds / authorizedSkillIds / requiredPermissions`
|
||||
- 多用户 / RBAC / Skill / 主 Agent 权限和多设备控制的集中状态、回归矩阵与缺口清单见 `docs/architecture/rbac_skill_regression_matrix_cn.md`
|
||||
- `GET http://127.0.0.1:3000/api/v1/user/ota/package` 正常,当前会返回最新 APK 包
|
||||
- 当前这台开发机的 `launchd` 常驻 `local-agent` 已恢复:`GET http://127.0.0.1:4317/health` 现在可在数十毫秒内返回,且在手动 heartbeat 执行期间仍能正常回包
|
||||
- 当前 Boss 已新增 `src/lib/execution/` 执行底座抽象层;当前生产主链仍然沿用 `local-agent -> codex exec resume`,只是执行责任已开始通过 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend` 默认实现收束
|
||||
- 当前 `claw-code` 已以最小 `ClawBackendAdapter` 形式接入执行底座,但默认关闭;只有在显式配置 `BOSS_CLAW_*` 且可用性探测通过时,`master-agent` 当前对话里才会出现并允许选择 `claw-runtime`
|
||||
- 当前已新增最小 `Telegram Gateway`:Boss 服务器可直接作为 Telegram Bot webhook 入口,把 Telegram 私聊或受控群聊文本桥接进 `master-agent` 或指定 Boss 项目,并在 `master-agent task complete` 后自动把结果回推给 Telegram 用户;Android 原生端已提供 `TelegramIntegrationActivity`,可查看 Bot 状态、配置 webhook、白名单、群聊触发策略和群 / Topic 路由
|
||||
- 如果历史上已经保存过 `backendOverride=claw-runtime`,但当前 `Claw Runtime` 不可用,运行时会自动回退到默认后端,并在前台显示明确原因
|
||||
- 当前 `oh-my-codex` 已以最小 `OmxTeamBackendAdapter` 形式接入执行底座,但默认关闭;当前已经接到 Web 群聊详情页 / 原生群资料页的编排后端选择卡,可在 `Boss Native` 与 `OMX Team` 间切换,OMX 不可用时会自动回退到默认后端并明确提示原因
|
||||
- 当前仓库已自带一个本地 OMX smoke runtime:`scripts/omx-team-smoke.mjs`。在还没有真实 `oh-my-codex` 可执行文件时,可以先用它验证 `OmxTeamBackendAdapter -> selector -> dispatch_execution -> 回写群聊账本` 这条链
|
||||
- 当前仓库已自带一个本地 smoke runtime:`scripts/claw-runtime-smoke.mjs`。在还没有真实 `claw-code` 可执行文件时,可以先用它验证 `ClawBackendAdapter -> backendOverride -> 异步回流` 整条链
|
||||
- 当前已新增“Boss 统一电脑控制中枢”第一批能力:主 Agent 已能把聊天输入区分为 `discussion / development / browser / desktop` 四类意图,并能把 `browser_control / desktop_control` 作为正式任务排入 `MasterAgentTask` 队列;本机 `local-agent` 已补上 `browser-control-task-runner / computer-use-task-runner` 外部 runtime 桥,可通过 `browserControl* / computerUse*` 配置接入真实 Browser Automation 与 Computer Use 执行器,未启用时会 fail closed,不再假装执行成功
|
||||
- 当前电脑控制中枢的生产范围先明确收敛为 `macOS`:意图路由会给 browser/desktop 控制任务写入 `controlPlatform=macos`,其中浏览器控制仍走 `openai-computer-use`,桌面 GUI 控制默认走 `codex-computer-use`,Codex Computer Use 不可用时再回退 `cua-driver-computer-use`;Windows 控制入口暂不参与当前运行链路,后续再单独做平台分支
|
||||
- 当前 browser/desktop 控制结果已经会作为 `control_summary` 正式写回会话账本,并保留目标 URL / 应用名;Android 原生端会以单独控制结果卡片展示,便于把“执行什么”和“执行结果”与普通聊天正文区分开
|
||||
- 当前 `scripts/browser-control-smoke.mjs` 已经能对目标 URL 做一次真实最小探测:抓取页面标题并写回聊天结果;桌面 GUI 控制默认先走 `scripts/codex-computer-use-runtime.mjs`,由 Codex App Server 发起 Codex Computer Use 执行;失败后自动回退 `scripts/cua-driver-computer-use-runtime.mjs`,通过外部 `cua-driver` 执行 `launch_app -> get_window_state -> 可选 type_text/press_key -> get_window_state` 闭环;`scripts/computer-use-smoke.mjs` 仍保留为旧兜底和回归资产
|
||||
- 受控 Mac 需要先安装并授权 `cua-driver`;Boss runtime 会优先搜索 `PATH`,再搜索 `~/.local/bin/cua-driver`、`/usr/local/bin/cua-driver`、`/opt/homebrew/bin/cua-driver` 和 `/Applications/CuaDriver.app/Contents/MacOS/cua-driver`;如果仍找不到,会明确返回 `CUA_DRIVER_COMMAND_NOT_FOUND`,不会伪装成执行成功
|
||||
- 当前默认本机配置已把 `browserAutomation / computerUse` 两项能力直接上报为在线起步态,所以 Boss App 里这台 Mac 会显示“可做浏览器控制 / 桌面控制”;如果某条链路要临时收起,只需要改 `local-agent/config.cloud.json`
|
||||
- 当前 `local-agent` 已新增 `Codex App Server` runner:boss-agent 默认打开 `codexAppServerEnabled`,通过 `codex app-server` stdio 接入 `conversation_reply / dispatch_execution`,也可灰度切到 `ws://127.0.0.1:<port>` 或 `unix://PATH` 本机长驻 App Server;WebSocket/Unix WebSocket handshake 支持 `Authorization: Bearer <token>`,优先用 `codexAppServerAuthTokenFile` 保存本地 token。失败时只在 turn 未启动前回退 `codex exec resume`,避免重复执行同一轮对话。设备 heartbeat 会单独上报 `codexAppServer` capability,并按 `codexAppServerDiscoveryTtlMs` 缓存 `model/list / skills/list / plugin/list / app/list / modelProvider/capabilities/read` 的能力摘要,供 APP/后台模型选择和治理页读取。2026-05-31 起,runner 会吸收 App Server 的 plan / diff / item / subagent 事件并归一到 Boss `execution_progress` 进度卡,执行中通过 `POST /api/v1/master-agent/tasks/[taskId]/progress` 实时刷新;后续已补 `approvals / warnings / fileChanges / threadStatus / realtime / modelRoute / tokenUsage / mcpServers / remoteControl / threadGoal / threadSettings / compaction / accountStatus / modelVerification / threadCollaboration / toolActivities / reasoningSummary / windowsSandbox` 等结构化摘要。Android 原生进度卡可显示线程状态、实时状态、线程配置、线程协作、工具活动、思考摘要、账号状态、运行状态、Windows 沙箱状态、安全提醒、审批状态和文件变更摘要,且不展示完整命令、diff、系统提示词、密钥、SDP、音频原始数据、raw realtime item、remote installationId、本地绝对路径或 Windows sandbox sourcePath。本机 `codex-cli 0.136.0-alpha.2` 协议快照已生成在 `docs/protocol-snapshots/codex-app-server/0.136.0-alpha.2/`,新增确认 `skills/extraRoots/set`。配置 `codexAppServerSkillExtraRoots` / `BOSS_CODEX_APP_SERVER_SKILL_EXTRA_ROOTS` 后,runner 会先下发共享 Skill 根,再拉取 `skills/list`;metadata 只保留根目录数量、basename 和下发状态。当前 Inter-Thread Broker:任务携带源/目标 Codex 线程时可通过 `thread/read -> thread/inject_items -> turn/start` 完成受控线程协作;服务端新增 `POST /api/v1/projects/[projectId]/thread-collaboration` 作为 APP/后台可调用入口;任务携带 `targetCodexTurnId` 时 runner 会改用 `turn/steer` 干预活跃 turn;新版官方 `ThreadItem.collabToolCall` 会额外提取目标数量和 agent 状态集合,但仍不保存源/目标线程 ID、prompt 或 agent 私有消息。
|
||||
- 当前 App Server heartbeat discovery 已扩展到 `experimentalFeature/list / collaborationMode/list / permissionProfile/list / mcpServerStatus/list`,设备详情页会展示“治理:实验特性 / 协作模式 / MCP / 权限”摘要;MCP 只保留服务名、工具数量、资源数量和认证状态,permission profile 只保留 id/description,不保存本地路径、resource URI、文件规则、token 或工具参数。
|
||||
- 当前 App Server heartbeat discovery 已继续扩展到 `account/read / account/rateLimits/read / config/read / configRequirements/read / externalAgentConfig/detect`,设备详情页会展示账号、套餐、额度、App 配置、托管要求和外部 Agent 迁移候选摘要;该链路只保存计数、开关和状态,不保存邮箱、API key、完整 config、本地路径、迁移描述或外部 Agent 原始内容。
|
||||
- 当前 App Server heartbeat discovery 已新增 `thread/list / thread/loaded/list` 线程可见性摘要,设备详情页会展示线程总数、已加载线程、活跃线程和最新更新时间;metadata 只保留非归档线程的 `id / name / sourceKind / status / updatedAt / loaded` 轻量目录,不保存 cwd、本地路径、turn 内容、用户正文或内部 prompt。
|
||||
- 当前 App Server heartbeat discovery 已新增 `thread/turns/list` turn 运行态摘要,设备详情页会展示总轮次、运行中轮次、完成轮次和最新 turn 更新时间;请求固定使用 `itemsView=summary`,metadata 只保留每个线程的 turn 计数、最近状态、更新时间和最终 `agentMessage` 安全摘要,不保存用户输入、reasoning 原文、命令输出、原始 items、内部 prompt 或系统提示词。
|
||||
- 当前 App Server heartbeat discovery 会把非归档可见线程的最终 `agentMessage` 合并进 `projectCandidates.recentAssistantMessages`;服务端据此把 Codex Desktop 自己产生的新回复反向同步到 Boss APP 会话列表、preview、lastMessageAt 和未读数。已有本地扫描候选优先保留 folder/thread 映射,App Server 只补充最新回复摘要。
|
||||
- 当前 App Server heartbeat discovery 已新增线程操作能力摘要,设备详情页会展示“线程操作”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `thread/archive / thread/compact/start / thread/shellCommand / turn/interrupt` 等写操作。
|
||||
- 当前 App Server heartbeat discovery 已支持 `skills/extraRoots/set` 共享 Skill 根目录下发摘要,设备详情页会展示“共享 Skill 根”;metadata 不保存根目录绝对路径、Skill 文件路径、token 或配置原文。
|
||||
- 当前 App Server heartbeat discovery 已支持 `hooks/list` 钩子治理摘要,设备详情页会展示“Hook”;metadata 只保留 hook 数、启用数、受管 / 可信 / 修改 / 未信任计数、warning / error 计数和事件 / handler 类型,不保存 hook key、command、sourcePath、statusMessage、hash、error message 或本地路径。
|
||||
- 当前 App Server heartbeat discovery 已新增插件治理能力摘要,设备详情页会展示“插件治理”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `plugin/install / plugin/uninstall / plugin/share/*` 等写操作。
|
||||
- 当前 App Server heartbeat discovery 已新增账号与配置治理能力摘要,设备详情页会展示“账号治理 / 配置治理”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `account/login/* / account/logout / config/* / skills/config/write` 等写操作。
|
||||
- 当前 App Server heartbeat discovery 已新增文件系统与命令会话治理能力摘要,设备详情页会展示“文件治理 / 命令会话”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `fs/*`、`command/exec/write`、`command/exec/terminate` 等读写或命令控制 API。
|
||||
- 当前 App Server heartbeat discovery 已新增外部 Agent 迁移、Marketplace 和实验特性治理能力摘要,设备详情页会展示“迁移治理 / 市场治理 / 实验特性治理”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `externalAgentConfig/import`、`marketplace/*` 或 `experimentalFeature/enablement/set` 等写操作。
|
||||
- 当前 App Server heartbeat discovery 已新增审查、Windows 沙箱和文件搜索事件能力摘要,设备详情页会展示“审查治理 / Windows 沙箱 / 文件搜索事件”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `review/start`、`windowsSandbox/setupStart` 等动作。
|
||||
- 当前 App Server heartbeat discovery 已新增 MCP、用户交互和 Guardian 治理能力摘要,设备详情页会展示“MCP 治理 / 用户交互 / Guardian 治理”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `mcpServer/tool/call`、`item/tool/requestUserInput` 或 `thread/approveGuardianDeniedAction` 等动作。
|
||||
- 当前 App Server heartbeat discovery 已新增运行事件、扩展事件和线程生命周期事件能力摘要,设备详情页会展示“运行事件 / 扩展事件 / 线程生命周期”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中主动触发进程、插件、Skill 或线程生命周期动作。
|
||||
- 当前 App Server heartbeat discovery 已新增流式增量事件能力摘要,设备详情页会展示“流式增量”;该摘要只来自 runner 安全 catalog 和协议快照证明,用于识别 agent delta、plan delta、reasoning delta、MCP progress、command output 和 file output 这类实时事件,不保存或展示原始增量内容。
|
||||
- `GET http://127.0.0.1:4317/api/v1/skills` 正常,已返回本机扫描到的 Codex Skill
|
||||
- `POST http://127.0.0.1:4317/api/v1/heartbeat` 正常,且会顺带触发 `thread-context` 上报
|
||||
- `local-agent` 当前每 5 秒轮询一次本机 Skill lifecycle 请求;默认打开 `skillLifecycleEnabled=true`。远程 `install` 或带 `sourceUrl` 的更新必须命中 `skillLifecycleAllowedSources` 或 `skillLifecycleTrustedSources`,为空时只允许既有本地 Skill 的 `update / rollback / uninstall / version_lock`;请求携带 `checksum / expectedChecksum` 时会校验 `manifest.json` 或 `SKILL.md` 的 sha256,失败会清理半安装目录或尽量恢复备份。卸载 / 更新 / 回滚前会在 `skillsDir/.boss-skill-backups` 保留备份,卸载仍限制在 `skillsDir` 目录内,版本锁写入 `.boss-skill-locks.json`
|
||||
- `launchd` 已加载:`~/Library/LaunchAgents/com.hyzq.boss.local-agent.plist`
|
||||
|
||||
服务器:
|
||||
@@ -140,14 +103,12 @@ Android APK:
|
||||
- `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
|
||||
- 当前最新 release 构建版本:`2.5.11`(`versionCode=24`)
|
||||
- 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局`
|
||||
- 真机开发约束:用户已明确切换到当前连接的 OPPO `PHZ110`(ADB serial `U84XJRIB7D65ZH45`);除非用户再次要求切换设备,后续 Android 开发、ADB 安装、交互回归与问题复现统一使用这台 OPPO,不再回退到原 `PLB110`
|
||||
- OPPO 权限回归建议命令:`adb -s U84XJRIB7D65ZH45 devices -l`、`./gradlew :app:assembleDebug`、`adb -s U84XJRIB7D65ZH45 install -r android/app/build/outputs/apk/debug/app-debug.apk`、`adb -s U84XJRIB7D65ZH45 shell am start -W -n com.hyzq.boss/.MainActivity -e initial_tab me`,再从 `我的 > 用户与权限` 确认最高管理员可进入权限页。
|
||||
- Android 真机无线调试如果要尽量稳定,优先使用“同一局域网 + 初次 USB 启用后执行 `adb tcpip 5555` + `adb connect <phone-ip>:5555`”这条链路;它通常比只依赖系统“无线调试配对码”更稳
|
||||
- Android 系统层面对“无线调试”没有真正的永久不掉线开关;重启手机、切 Wi‑Fi、切热点、ADB server 重启、USB 调试被重新切换后,都可能导致无线调试自动失效
|
||||
- 真机调试时建议固定同一 SSID、避免代理/VPN 改路、开发者选项里开启“保持唤醒”,并在需要长时间稳定调试时优先保留 USB 兜底;如果必须完全避免自动断开,不要只依赖无线调试
|
||||
- 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、主 Agent 提示词 / 记忆、技能、运维中心、关于
|
||||
- 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、主 Agent 提示词、主 Agent 记忆、全局接管、主 Agent 自动进化、技能、运维中心、关于
|
||||
- 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab,会话首页是简单聊天列表,`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口
|
||||
- 当前会话首页右上角已切回 `+` 入口:直接从首页发起独立群聊;设备页右上角 `+添加` 仅最高管理员可见,子账号只保留刷新。
|
||||
- 当前会话首页右上角已切回 `+` 入口:直接从首页发起独立群聊;设备页右上角仍是 `+添加`
|
||||
- 当前会话首页已升级成“项目聚合 + 线程下钻”的结构:如果某个 Codex 文件夹只导入了 1 个线程,会话列表直接显示这个线程;如果同一文件夹导入了多个线程,会话首页只显示该文件夹归档项,点进去再看这个项目下的全部线程
|
||||
- 当前会话首页的数据源已分成两层:`/api/v1/conversations` 继续保留平铺线程视图给群聊创建、转发等内部能力使用;首页和原生根页改走 `/api/v1/conversations/home`,文件夹归档详情走 `/api/v1/conversation-folders/[folderKey]`
|
||||
- 当前会话搜索仍然保留线程可达性:如果命中单线程项目,会直接进入该线程;如果命中多线程项目里的某条线程,结果会显示 `项目 / 线程`,点击后先进入项目文件夹页并定位到对应线程,不会把首页重新打平成线程列表
|
||||
@@ -166,7 +127,7 @@ Android APK:
|
||||
- 当前 `AI 账号` 页顶部会显式展示“当前主控身份”,并提供 `校验主控 / 测试主 Agent 对话` 两个动作,切换主控后可直接验证聊天通路
|
||||
- 当前阿里百炼备用链已完成一次真实线上闭环验证:手动切到 `aliyun-qwen-backup` 后,`POST /api/v1/projects/master-agent/messages` 会返回 `queued`,并已实际回流 `阿里备用链正常。` 到 `master-agent` 会话
|
||||
- 当前 `我的 > AI 账号` 已把阿里百炼备用模型切成预设选择:Web 和原生 Android 都支持直接切换 `qwen3.5-plus / qwen3.5-flash`,只有在预设不适用时才需要填自定义模型
|
||||
- 当前 `我的 > 主 Agent 提示词 / 记忆` 页面已补:管理员全局主提示词只读展示、用户主提示词、当前对话附加提示词,以及用户通用记忆 / 跨项目项目记忆的新增、编辑、删除接口;当前对话设置按登录账号隔离,管理员全局主提示词不可覆盖
|
||||
- 当前 `我的` 根页已拆出 `主 Agent 提示词 / 主 Agent 记忆 / 全局接管 / 主 Agent 自动进化` 四个独立入口;其中提示词页支持管理员全局主提示词只读展示、用户主提示词、当前对话附加提示词与执行后端切换,记忆页支持用户通用记忆 / 跨项目项目记忆的新增、编辑与归档
|
||||
- 当前 Web 端 `master-agent` 会话页右上角也已补齐微信式三点菜单,支持直接进入 `提示词 / 模型 / 推理强度 / 记忆 / 刷新`
|
||||
- 当前 `approval_required` 群聊在 Web 端已统一用单一状态快照驱动:如果有新的待确认推荐,会自动折叠旧的拒绝态;如果上次推荐已拒绝,会明确展示“重新生成新的推荐”的恢复入口
|
||||
- 当前 `OpenAiOnboardingActivity` 在登录成功后会直接给出 `测试主 Agent 对话` 入口,可一键跳到 `master-agent` 聊天页
|
||||
@@ -186,7 +147,6 @@ Android APK:
|
||||
- 当前设备导入 `review` 已补 owner/admin 鉴权,并改成真正的异步审核链:`review` 只负责排队 `device_import_resolution` 任务并返回 queued 状态,等 local-agent 完成回写后才把决议写回草稿和会话账本
|
||||
- 当前原生 APP 会话页的“刷新失败”已按当前 tab 的主数据源独立判错:`会话` 只看会话请求本身,`设备` 只看设备请求,`我的` 只在 `settings + ota` 同时失败时才提示刷新失败
|
||||
- 当前 `设备` 和 `我的` 根页已收口为简单列表;`运维与修复 / AI 账号 / 技能` 保留在一级 `我的`,`审计对话` 作为置顶会话保留在会话首页
|
||||
- 当前原生 `我的` 根页已开始按登录角色过滤入口:`member` 只显示个人安全、设置、已授权 Skill 和关于;`admin / highest_admin` 才显示运维、AI 账号、附件存储和 Telegram 管理入口;`用户与权限` 仅 `highest_admin` 可见
|
||||
- 原生客户端当前直接调用 `https://boss.hyzq.net` 的 Boss API,不再打开 WebView
|
||||
- `2.0.1` 已修复华为真机上因 `Theme.SplashScreen` 与 `AppCompatActivity` 不兼容导致的启动闪退
|
||||
- `2.1.0` 已在本机连接的华为真机上完成签名包覆盖安装与启动复核,原生三栏入口和子活动页声明已全部接通
|
||||
@@ -199,7 +159,6 @@ Android APK:
|
||||
- `2.5.1` 继续收口微信式原生 UI:聊天页普通态顶部已隐藏刷新按钮,只保留右上角“信息”;发起群聊页顶部说明和选择区已压成更轻的会话式密度,候选线程继续复用微信式会话卡片
|
||||
- `2.5.2` 继续补齐深层原生页:`项目目标 / 版本迭代记录 / 会话信息 / 群资料` 已进一步向设计图收口;附件消息卡片的分析状态和动作文案也压成了更轻的微信式层级
|
||||
- `2.5.4` 已把 `我的` 根页收口成微信式资料区 + 白底菜单列表,并同步把 `设置 / 账号与安全 / AI 账号 / 技能 / 运维与修复` 的顶部说明从重 `soft panel` 降成轻量列表说明
|
||||
- `2.5.11` 已补齐第一批遗漏功能:聊天长按“删除”接通服务端账本删除与实时刷新;原生 `我的 > 附件与存储` 可直接切换服务器文件存储 / 阿里 OSS;后台通知覆盖所有会话里的主 Agent 回复;browser/desktop runtime 未配置时改为明确失败而不是占位成功
|
||||
- `2.5.5` 已补上群资料页的“修复群成员”主链:历史脏群会明确提示失效成员,并允许重新选择真实线程成员写回群资料;`approval_required` 群聊也已补齐“确认 / 拒绝”两条审批动作
|
||||
|
||||
## 本地启动
|
||||
@@ -229,7 +188,6 @@ npm start
|
||||
- 登录页:[http://127.0.0.1:3000/auth/login](http://127.0.0.1:3000/auth/login)
|
||||
- 会话页:[http://127.0.0.1:3000/conversations](http://127.0.0.1:3000/conversations)
|
||||
- 设备页:[http://127.0.0.1:3000/devices](http://127.0.0.1:3000/devices)
|
||||
- 平台总后台入口:[http://127.0.0.1:3000/enterprise-admin](http://127.0.0.1:3000/enterprise-admin),生产域名 `https://admin.boss.hyzq.net/` 根路径直接承载新独立 PC 后台;`/admin` 仅保留为跳转到根域的兼容入口
|
||||
|
||||
## 设备端本地服务
|
||||
|
||||
@@ -254,23 +212,6 @@ cd /Users/kris/code/boss
|
||||
./scripts/install-local-launchagent.sh /Users/kris/code/boss/local-agent/config.example.json
|
||||
```
|
||||
|
||||
构建 macOS 桌面状态应用 `boss-agent.app`:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run mac:agent
|
||||
open dist/boss-agent.app
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `boss-agent.app` 是本机 `local-agent` 的 macOS WebView 外壳,默认打开 `http://127.0.0.1:4317/boss-agent`
|
||||
- 未绑定账号时会显示可扫码的 Boss APP 绑定二维码;已绑定后显示账号、API、服务器、授权、本机权限获取和本机 Skill 部署情况
|
||||
- boss-agent 已支持 Mac 端 OTA:打包脚本会发布 `public/downloads/boss-agent-mac-latest.zip` 与 `boss-agent-mac-latest.json`;本机 agent 通过 `/api/v1/boss-agent/ota/check` 检查更新,通过 `/api/v1/boss-agent/ota/apply` 下载、校验并拉起安装器。安装器会保留所有 `config*.json`,并优先沿用当前 LaunchAgent active config 或自定义设备配置,避免多台 Mac 覆盖安装时误切回默认设备身份。
|
||||
- 正式分发可设置 `BOSS_AGENT_CODESIGN_IDENTITY='Developer ID Application: ...'` 与 `BOSS_AGENT_NOTARIZE=1`,再用 `BOSS_AGENT_NOTARY_PROFILE` 或 Apple ID/team/password 环境变量走 `notarytool + stapler` 公证;未设置时仍保留本地开发签名 / ad-hoc 回退。
|
||||
- 本机权限按 Codex Computer Use 的最小权限模型收敛为 `辅助功能 + 屏幕录制` 两项;权限页会打开对应 macOS 隐私设置入口,授权完成后由系统持久保存,后续控制过程只静默校验并使用,不在任务执行中临时申请更多权限。
|
||||
- 本机状态 JSON 可通过 `GET http://127.0.0.1:4317/api/v1/boss-agent/status` 查看,不会返回设备 token 明文
|
||||
|
||||
device-agent 当前职责:
|
||||
|
||||
- 上报设备状态、账号、5h/7d 额度和项目列表
|
||||
@@ -280,8 +221,6 @@ device-agent 当前职责:
|
||||
- 轮询云端 `/api/v1/master-agent/tasks/claim`,并用当前电脑已登录的 `codex` 账号执行主 Agent 任务
|
||||
- 将主 Agent 执行结果回写到云端 `/api/v1/master-agent/tasks/[taskId]/complete`
|
||||
- 对普通单线程会话,认领到的 `conversation_reply` 任务会直接恢复到目标 Codex 线程,并把线程原始回复回写到对应聊天窗口
|
||||
- 对已绑定 `codexThreadRef` 的普通单线程会话,`local-agent` 会在执行 `codex exec resume` 前先把 Boss App 里的用户消息镜像进目标 Codex Desktop 线程 rollout,避免 APP 和桌面版同线程历史割裂;定位 rollout 时优先用 `state_5.sqlite`,不可用时回退扫描 `~/.codex/sessions`,并尽量刷新线程活跃时间。镜像成功后会优先调用本机常驻 `Codex Desktop Bridge` endpoint,再打开 `codex://threads/{threadId}` 并发送一次安全刷新提示,让桌面版切到目标线程后重新读取记录;endpoint 不可用时回退原命令式刷新。刷新桥默认对短暂失败重试 2 次、间隔 120ms,并保留 deep link 与尝试次数,便于追踪桌面同步是否真正触发。bridge 同时提供 `GET /api/v1/codex-desktop/events` SSE 和 recent 缓冲,后续 Codex Desktop 插件可直接订阅安全元数据事件;`scripts/codex-desktop-event-consumer.mjs` 可作为本机订阅 smoke
|
||||
- `scripts/codex-desktop-integration-probe.mjs` 可探测本机 Codex Desktop 能力,bridge 也提供 `GET /api/v1/codex-desktop/capabilities`;探测只读 `Info.plist` 和 app 资源,明确不修改 Codex.app 签名包体
|
||||
- 对群聊线程分发任务,认领到的 `dispatch_execution` 任务会把原始线程结果和主 Agent 汇总一起回写到群聊消息账本
|
||||
- `local-agent` 对 `conversation_reply` 当前会优先使用 `codex exec resume <targetCodexThreadRef>`,只有缺失真实线程引用时才退回 `--ephemeral`
|
||||
- `local-agent` 对 `dispatch_execution` 当前会按 `orchestrationBackendId` 分流:默认继续走 `codex exec resume`;当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议执行
|
||||
@@ -293,7 +232,7 @@ device-agent 当前职责:
|
||||
- Codex 项目/线程扫描当前已搬到 worker 线程执行,避免 `.codex/logs_1.sqlite` 和 `state_5.sqlite` 的同步扫描阻塞主线程 HTTP 响应
|
||||
- 如果某个历史群聊里已经没有真实线程成员,当前不会再表现成“发了没反应”,而是会在群里追加一条 `system_notice`,提示用户先重新整理群成员
|
||||
- 设备导入审核当前已经升级成 `local-agent -> codex exec -> complete` 的真实任务链;Web 和 Android 前台都会在 `pending_resolution` 阶段显示“主 Agent 审核中”并自动刷新,审核失败时保留当前勾选以便重新生成
|
||||
- 提供本地 `/boss-agent`、`/api/v1/boss-agent/status`、`/api/v1/boss-agent/ota/check`、`/api/v1/boss-agent/ota/apply`、`/health`、`/api/v1/device`、`/api/v1/skills`、`/api/v1/heartbeat`
|
||||
- 提供本地 `/health`、`/api/v1/device`、`/api/v1/skills`、`/api/v1/heartbeat`
|
||||
|
||||
当前常驻默认值:
|
||||
|
||||
@@ -310,8 +249,6 @@ device-agent 当前职责:
|
||||
- APK 发布脚本:`scripts/publish-apk-to-public.sh`
|
||||
- `systemd` 配置:`deployment/systemd/boss-web.service`
|
||||
- `Caddy` 配置:`deployment/Caddyfile`
|
||||
- 平台总后台域名解析:`admin.boss.hyzq.net` 当前已解析到 `106.53.170.158`,Caddy 独立站点会把根路径内部 rewrite 到 `/admin-web/index.html`,浏览器地址栏保持 `https://admin.boss.hyzq.net/`
|
||||
- 服务器 Caddy 还有 `gptpluscontrol-boss-caddy-reconcile.timer` 周期性重写:如果改域名入口,必须同步更新 `/home/ubuntu/build/gptpluscontrol/deploy/server/caddy.boss_hyzq_net.gptpluscontrol.conf`,否则会再次生成重复站点块
|
||||
- 邮件配置:`deployment/mail/`
|
||||
- Android 原生入口:`android/app/src/main/java/com/hyzq/boss/MainActivity.java`
|
||||
- Android API 客户端:`android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
@@ -372,7 +309,6 @@ npm run aab:release
|
||||
- Web 生产启动、服务器 `systemd` 和部署构建当前都显式设置了 `BOSS_RUNTIME_ROOT`,避免 `process.cwd()` 在 standalone / 服务器构建阶段误把整个仓库根目录带进 tracing
|
||||
- `next.config.ts` 已显式排除 `deployment / docs / design / local-agent / prompts / scripts / android` 等非运行时目录,避免服务器端 standalone tracing 卷入运维资产导致构建失败
|
||||
- 文件写入已经改成串行事务队列 + 原子写入 + `data/boss-state.json.bak` 备份恢复,`heartbeat` 和 APP 日志并发写不会再互相覆盖
|
||||
- 文件状态写入层已默认开启自动历史快照,按 `BOSS_STATE_AUTO_BACKUP_INTERVAL_MS` 节流生成 `data/backups/state-snapshot-*.json`,并按 `BOSS_STATE_AUTO_BACKUP_KEEP` 控制保留数量;最高管理员后台“备份与回退”页可创建手动快照、查看自动快照和恢复到指定快照
|
||||
- 当前文件存储里已经包含:
|
||||
- `projects / messages / goals / versions`
|
||||
- `authAccounts / otaUpdates / otaUpdateLogs`
|
||||
@@ -390,7 +326,7 @@ npm run aab:release
|
||||
- 登录成功后的进入首页链路已做稳态处理:会先确认 `/api/auth/session` 可读,再执行 `replace(/conversations)`,并附带一次原生级兜底跳转,避免真机 WebView 偶发停留在“正在进入会话首页”
|
||||
- `/api/v1/events` 已作为 SSE 出口使用,会话页、设备页、技能页和项目详情页会按事件自动刷新,不再只靠手动刷新
|
||||
- 我的页新增 `技能` 入口,`/me/skills` 会按设备分组展示 Skill,并支持一键复制调用语句
|
||||
- 我的页新增 `主 Agent 提示词 / 记忆` 入口,`/me/master-agent` 会展示管理员全局主提示词、用户主提示词、当前对话附加提示词、组合预览,以及当前用户的通用记忆和跨项目项目记忆
|
||||
- 我的页已拆出 `主 Agent 提示词 / 主 Agent 记忆 / 全局接管 / 主 Agent 自动进化` 入口;`/me/master-agent` 继续展示管理员全局主提示词、用户主提示词、当前对话附加提示词、组合预览,以及当前用户的通用记忆和跨项目项目记忆
|
||||
- 我的页新增 `AI 账号` 入口,`/me/ai-accounts` 会展示主 GPT / 备用 GPT / API 容灾,并明确主链路优先走已登录 `ChatGPT Plus / Codex` 的 `Master Codex Node`
|
||||
- `AI 账号` 页面当前已补上显式 `登录指引`:手机端不会直接弹出 ChatGPT OAuth;主 GPT 的登录动作必须在绑定电脑上的 Codex / ChatGPT Plus 会话里完成,再回手机端点“测试连接 / 校验连接”
|
||||
- `AI 账号` 页面当前已升级成双入口:首页会显式展示 `登录 OpenAI 平台账号` 和 `绑定电脑上的 Codex 节点`
|
||||
@@ -407,21 +343,21 @@ npm run aab:release
|
||||
- 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;因此会话页会自动出现这台设备当前真实运行的 Codex 线程窗口
|
||||
- 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;如果某个项目下存在多个线程,会话首页会先显示项目归档项,而不是把所有线程平铺在首页
|
||||
- 对已经绑定的生产设备,如果某些自动导入线程已经不再出现在最新 `projectCandidates[]` 中,服务端会在下一次 heartbeat 自动清理这些过时会话,避免旧线程长期滞留首页
|
||||
- 认证现在已经有最小会话链路:登录后会写入 `boss_session` Cookie,默认保持 30 天,`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 接口都要求有效会话;临时免验证登录默认关闭,仅在显式设置 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 时启用
|
||||
- 登录页当前已临时切到免验证模式,点击“登录”会直接进入会话首页
|
||||
- 认证现在已经有最小会话链路:登录后会写入 `boss_session` Cookie,默认保持 30 天,`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 接口都要求有效会话
|
||||
- 新增 `GET /api/auth/session`、`POST /api/auth/logout` 与 `POST /api/auth/restore`
|
||||
- 当前同一账号已经支持多个登录端并存;Web 与原生 Android 的 `我的 > 账号与安全` 可查看和撤销登录会话,最高管理员可以管理所有活跃会话
|
||||
- 原生 Android 客户端当前会把 `boss_session / restore token / account` 存到 `SharedPreferences`,用于重启后恢复会话
|
||||
- 验证码新增防刷与防重放:60 秒冷却、15 分钟窗口限流,登录连续失败 5 次后会锁定 10 分钟
|
||||
- `POST /api/auth/send-code` 现在会先按用途校验账号状态:登录 / 忘记密码要求账号已存在,注册要求账号尚未注册
|
||||
- 当前登录页默认走账号密码或验证码校验,不再把开发兜底作为生产默认能力
|
||||
- `POST /api/auth/send-code` 当前仍支持 fixed 模式,但验证码登录也必须先申请验证码并消费账本里的有效记录;不能只靠固定码直接登录
|
||||
- 当前登录页已临时放开成“一键进入”,账号密码和验证码输入暂时不作为拦截条件
|
||||
- `POST /api/auth/send-code` 与固定验证码 `000000` 仍保留给注册 / 重置密码和后续认证收口,不作为当前登录页前置条件
|
||||
- 新注册和重置密码现在使用 `scrypt` 哈希;历史 `sha256` 密码会在下一次密码登录时自动迁移
|
||||
- 原生 Android 当前把 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等复杂能力下沉到二级或更深层入口,不再把线程预算 / 转发 / 运维说明堆在主聊天页和一级我的页
|
||||
- 原生 OTA 当前除了整包下载和系统安装器拉起,还会在关于页保留本地下载状态;离开关于页再回来时,仍能看到进行中 / 失败 / 待授权 / 可安装状态
|
||||
- Android 本地 Gradle 验证当前必须串行执行,避免并发 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug` 相互踩坏中间产物
|
||||
- 当前默认最高管理员账号:`krisolo`
|
||||
- 当前默认测试密码由线上初始化配置管理,文档不再明文记录
|
||||
- 当前本机 Codex 节点 `mac-studio` 已绑定到 `krisolo`
|
||||
- 当前默认最高管理员账号:`17600003315`
|
||||
- 当前默认测试密码:`boss123456`
|
||||
- 当前本机 Codex 节点 `mac-studio` 已绑定到 `17600003315`
|
||||
- 主 Agent 对话当前真实执行链路是:`Boss Web -> 写入用户消息 -> 返回 queued/running -> master-agent task queue -> local-agent / OpenAI API -> complete task -> project ledger`
|
||||
- `master-agent` 单聊当前已改成“快速入队 + 异步回流”:发送后会立即返回任务包和 `masterReplyState`,前台先显示“主 Agent 思考中”,真实回复稍后自动回写到账本
|
||||
- 原生 Android 当前会把 `master-agent` 的等待态保留在消息流里:发送后常驻显示“主 Agent 思考中”,超时后改成“主 Agent 回复超时 + 重试等待”,收到新回复后会自动清掉,不再只靠 toast 提示
|
||||
@@ -432,8 +368,7 @@ npm run aab:release
|
||||
- 应用内 `GET /api/v1/user/ota` / `POST /api/v1/user/ota` / `GET /api/v1/user/ota/package` 现在已经支持 OTA 状态、检查更新、执行升级和 APK 包下载
|
||||
- `GET /api/v1/app-logs` 现在已支持登录态下按 `deviceId / projectId / level / category / source / cursor` 查询日志分页
|
||||
- 设备写接口 `POST /api/v1/app-logs`、`POST /api/v1/devices/[deviceId]/skills`、`POST /api/v1/workers/[workerId]/thread-context` 现在都要求有效设备 token 或匹配登录会话
|
||||
- 当前认证已具备最小会话 Cookie、restore token 轮换、浏览器 CSRF 基础防护、子账号 MFA 开关、基础跨端会话治理和后台高危操作审计;后续仍可继续补企业 SSO / IdP
|
||||
- 当前状态存储默认继续使用 `data/boss-state.json`;已新增 `BOSS_STATE_STORE=postgres` 适配层,生产切换 PostgreSQL 时必须配置 `BOSS_DATABASE_URL`,并先使用 `scripts/boss-state-store-maintenance.mjs` 做备份、dry-run 迁移和回滚演练
|
||||
- 当前认证仍是 MVP:已有最小会话 Cookie,但还没有刷新令牌、跨端会话治理、吊销审计和 CSRF 防护
|
||||
- 聊天附件当前已支持真实上传、消息落账本、受保护下载和原生打开;默认存储后端为服务器文件存储
|
||||
- 当前用户已可在 `我的 > 附件与存储` 切到阿里 OSS 私有桶,下载链会按附件快照生成签名地址,避免用户后续修改配置后旧附件失效
|
||||
- 图片 / PDF / 文本默认自动进入主 Agent 附件分析;视频 / Office / 大文件默认手动触发
|
||||
|
||||
@@ -2,25 +2,15 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:name=".BossApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:forceDarkAllowed="false">
|
||||
|
||||
<service
|
||||
android:name=".BossBackgroundRealtimeService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||
@@ -58,15 +48,13 @@
|
||||
<activity android:name=".DeviceImportDraftActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".SkillInventoryActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".SecurityActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".AccessManagementActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".SettingsActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".StorageSettingsActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".AiAccountsActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".TelegramIntegrationActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".OpenAiOnboardingActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".MasterAgentPromptActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".MasterAgentTakeoverActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".MasterAgentMemoryActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".MasterAgentEvolutionActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".OpsCenterActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".AboutActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import android.provider.Settings;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
@@ -77,7 +76,11 @@ public class AboutActivity extends BossScreenActivity {
|
||||
restoreDownloadUiState();
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
|
||||
ContextCompat.registerReceiver(this, otaDownloadReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(otaDownloadReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
|
||||
} else {
|
||||
registerReceiver(otaDownloadReceiver, filter);
|
||||
}
|
||||
reload();
|
||||
}
|
||||
|
||||
@@ -488,9 +491,11 @@ public class AboutActivity extends BossScreenActivity {
|
||||
persistDownloadUiState();
|
||||
refreshDownloadStateSection();
|
||||
|
||||
if (!canInstallDownloadedPackages()) {
|
||||
if (!getPackageManager().canRequestPackageInstalls()) {
|
||||
showMessage("请先允许 Boss 安装未知来源应用,然后重新打开安装包。");
|
||||
openUnknownAppSourcesSettings();
|
||||
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -561,7 +566,7 @@ public class AboutActivity extends BossScreenActivity {
|
||||
return OtaDownloadStateMapper.failed(fileName);
|
||||
}
|
||||
if (downloadedApkUri != null) {
|
||||
if (!canInstallDownloadedPackages()) {
|
||||
if (!getPackageManager().canRequestPackageInstalls()) {
|
||||
return OtaDownloadStateMapper.waitingInstallPermission(fileName);
|
||||
}
|
||||
return OtaDownloadStateMapper.readyToInstall(fileName);
|
||||
@@ -575,7 +580,9 @@ public class AboutActivity extends BossScreenActivity {
|
||||
downloadLatestApk();
|
||||
break;
|
||||
case OPEN_INSTALL_PERMISSION:
|
||||
openUnknownAppSourcesSettings();
|
||||
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
break;
|
||||
case INSTALL_APK:
|
||||
installDownloadedApk();
|
||||
@@ -591,9 +598,11 @@ public class AboutActivity extends BossScreenActivity {
|
||||
showMessage("当前没有可安装的更新包");
|
||||
return;
|
||||
}
|
||||
if (!canInstallDownloadedPackages()) {
|
||||
if (!getPackageManager().canRequestPackageInstalls()) {
|
||||
showMessage("请先开启安装未知来源应用权限");
|
||||
openUnknownAppSourcesSettings();
|
||||
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
return;
|
||||
}
|
||||
Intent installIntent = new Intent(Intent.ACTION_VIEW);
|
||||
@@ -613,19 +622,6 @@ public class AboutActivity extends BossScreenActivity {
|
||||
return "boss-android-latest.apk";
|
||||
}
|
||||
|
||||
private boolean canInstallDownloadedPackages() {
|
||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.O
|
||||
|| getPackageManager().canRequestPackageInstalls();
|
||||
}
|
||||
|
||||
private void openUnknownAppSourcesSettings() {
|
||||
Intent intent = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
? new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()))
|
||||
: new Intent(Settings.ACTION_SECURITY_SETTINGS);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void restoreDownloadUiState() {
|
||||
android.content.SharedPreferences prefs = getSharedPreferences(OTA_UI_PREFS, Context.MODE_PRIVATE);
|
||||
activeDownloadId = prefs.getLong(KEY_ACTIVE_DOWNLOAD_ID, -1L);
|
||||
|
||||
@@ -1,598 +0,0 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.os.Bundle;
|
||||
import android.text.InputType;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.Spinner;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
public class AccessManagementActivity extends BossScreenActivity {
|
||||
@Nullable private JSONObject accessPayload;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("用户与权限", "子账号、设备、项目与 Skill");
|
||||
setHeaderAction("新增", v -> showAccountDialog());
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getAdminAccess();
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> renderAccess(response.json));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "权限配置加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderAccess(JSONObject payload) {
|
||||
accessPayload = payload;
|
||||
JSONArray accounts = payload.optJSONArray("accounts");
|
||||
JSONArray devices = payload.optJSONArray("devices");
|
||||
JSONArray projects = payload.optJSONArray("projects");
|
||||
JSONArray skills = payload.optJSONArray("skills");
|
||||
JSONArray skillCatalog = payload.optJSONArray("skillCatalog");
|
||||
JSONArray permissionTemplates = payload.optJSONArray("permissionTemplates");
|
||||
|
||||
replaceContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"权限总览",
|
||||
"子账号 " + lengthOf(accounts) + " 个 · 设备 " + lengthOf(devices) + " 台 · 项目 " + lengthOf(projects) + " 个",
|
||||
"Skill 类目 " + lengthOf(skillCatalog) + " 类 · 设备 Skill 实例 " + lengthOf(skills) + " 个",
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
Button accountButton = BossUi.buildMiniActionButton(this, "创建子账号", true);
|
||||
Button deviceButton = BossUi.buildMiniActionButton(this, "授权设备", false);
|
||||
Button projectButton = BossUi.buildMiniActionButton(this, "授权项目", false);
|
||||
Button skillButton = BossUi.buildMiniActionButton(this, "分配 Skill", false);
|
||||
Button templateButton = BossUi.buildMiniActionButton(this, "套用模板", true);
|
||||
accountButton.setOnClickListener(v -> showAccountDialog());
|
||||
deviceButton.setOnClickListener(v -> showDeviceGrantDialog());
|
||||
projectButton.setOnClickListener(v -> showProjectGrantDialog());
|
||||
skillButton.setOnClickListener(v -> showSkillGrantDialog());
|
||||
templateButton.setOnClickListener(v -> showTemplateGrantDialog());
|
||||
appendContent(buildActionRow(accountButton, deviceButton));
|
||||
appendContent(buildActionRow(projectButton, skillButton));
|
||||
if (!isEmpty(permissionTemplates)) {
|
||||
Button refreshAccessButton = BossUi.buildMiniActionButton(this, "刷新权限", false);
|
||||
refreshAccessButton.setOnClickListener(v -> reload());
|
||||
appendContent(buildActionRow(templateButton, refreshAccessButton));
|
||||
templateButton.setOnClickListener(v -> showTemplateGrantDialog());
|
||||
}
|
||||
|
||||
if (!isEmpty(permissionTemplates)) {
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"权限模板",
|
||||
lengthOf(permissionTemplates) + " 个模板可用",
|
||||
"一次性给账号分配设备、项目和 Skill 权限",
|
||||
null,
|
||||
v -> showTemplateGrantDialog()
|
||||
));
|
||||
} else {
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"暂无权限模板",
|
||||
"模板列表为空,仍可使用单项授权。",
|
||||
"等待服务端同步只读观察员、项目开发者、设备操作者等模板。",
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
appendUnavailableTargetHints(devices, projects, skills);
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"已配置账号",
|
||||
summarizeAccounts(accounts),
|
||||
"点击右上角新增,可创建或更新子账号",
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
JSONArray deviceGrants = grantsArray(payload, "devices");
|
||||
JSONArray projectGrants = grantsArray(payload, "projects");
|
||||
JSONArray skillGrants = grantsArray(payload, "skills");
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"当前授权",
|
||||
"设备 " + lengthOf(deviceGrants) + " 条 · 项目 " + lengthOf(projectGrants) + " 条 · Skill " + lengthOf(skillGrants) + " 条",
|
||||
"点击授权记录可撤销当前这条授权",
|
||||
null,
|
||||
null
|
||||
));
|
||||
appendGrantRows(deviceGrants, "设备");
|
||||
appendGrantRows(projectGrants, "项目");
|
||||
appendGrantRows(skillGrants, "Skill");
|
||||
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void appendUnavailableTargetHints(JSONArray devices, JSONArray projects, JSONArray skills) {
|
||||
if (isEmpty(devices)) {
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"暂无可授权设备",
|
||||
"设备列表为空,无法分配 device.view 或 computer.control。",
|
||||
"请先完成设备绑定或等待授权范围刷新。",
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
if (isEmpty(projects)) {
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"暂无可授权项目",
|
||||
"项目列表为空,无法分配 project.view、thread.chat 或主 Agent 协同。",
|
||||
"请先导入项目或等待设备线程同步。",
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
if (isEmpty(skills)) {
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"暂无可分配 Skill",
|
||||
"Skill 实例为空,无法分配 skill.use。",
|
||||
"请确认 local-agent 已同步 ~/.codex/skills。",
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private LinearLayout buildActionRow(Button left, Button right) {
|
||||
LinearLayout row = new LinearLayout(this);
|
||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||
row.setPadding(BossUi.dp(this, 12), 0, BossUi.dp(this, 12), BossUi.dp(this, 10));
|
||||
LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
row.setLayoutParams(rowParams);
|
||||
LinearLayout.LayoutParams leftParams = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f);
|
||||
LinearLayout.LayoutParams rightParams = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f);
|
||||
rightParams.leftMargin = BossUi.dp(this, 8);
|
||||
row.addView(left, leftParams);
|
||||
row.addView(right, rightParams);
|
||||
return row;
|
||||
}
|
||||
|
||||
private void appendGrantRows(JSONArray grants, String scopeLabel) {
|
||||
if (grants == null || grants.length() == 0) {
|
||||
return;
|
||||
}
|
||||
int max = Math.min(8, grants.length());
|
||||
for (int index = 0; index < max; index += 1) {
|
||||
JSONObject grant = grants.optJSONObject(index);
|
||||
if (grant == null) {
|
||||
continue;
|
||||
}
|
||||
String grantId = grant.optString("grantId", "");
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
scopeLabel + "授权 · " + grant.optString("account", ""),
|
||||
grantTargetSummary(grant),
|
||||
joinJsonArray(grant.optJSONArray("permissions")),
|
||||
null,
|
||||
TextUtils.isEmpty(grantId) ? null : v -> confirmRevoke(grantId)
|
||||
));
|
||||
}
|
||||
if (grants.length() > max) {
|
||||
appendContent(BossUi.buildHintPill(this, "还有 " + (grants.length() - max) + " 条授权未展开,可在 Web 端查看完整审计。"));
|
||||
}
|
||||
}
|
||||
|
||||
private void showAccountDialog() {
|
||||
LinearLayout form = buildDialogForm();
|
||||
EditText accountInput = BossUi.buildInput(this, "子账号,例如 worker@example.com", false);
|
||||
EditText displayInput = BossUi.buildInput(this, "显示名", false);
|
||||
EditText passwordInput = BossUi.buildInput(this, "初始密码 / 新密码", false);
|
||||
passwordInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
Spinner roleSpinner = spinnerWith(new String[]{"成员", "管理员"});
|
||||
form.addView(BossUi.buildFormCell(this, "账号", null, accountInput));
|
||||
form.addView(BossUi.buildFormCell(this, "显示名", null, displayInput));
|
||||
form.addView(BossUi.buildFormCell(this, "角色", "最高管理员不在手机端创建,避免误提权。", roleSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "密码", "创建账号时必填;更新账号时留空表示不改密码。", passwordInput));
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("创建 / 更新子账号")
|
||||
.setView(form)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("保存", (dialog, which) -> {
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("action", "upsert_account");
|
||||
payload.put("account", accountInput.getText().toString().trim());
|
||||
payload.put("displayName", displayInput.getText().toString().trim());
|
||||
payload.put("password", passwordInput.getText().toString());
|
||||
payload.put("role", roleSpinner.getSelectedItemPosition() == 1 ? "admin" : "member");
|
||||
runAdminAction(payload);
|
||||
} catch (JSONException error) {
|
||||
showMessage("保存失败:" + error.getMessage());
|
||||
}
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
private void showDeviceGrantDialog() {
|
||||
JSONObject payload = requireAccessPayload();
|
||||
if (payload == null) return;
|
||||
JSONArray accounts = payload.optJSONArray("accounts");
|
||||
JSONArray devices = payload.optJSONArray("devices");
|
||||
if (isEmpty(accounts) || isEmpty(devices)) {
|
||||
showMessage("需要先有账号和设备。");
|
||||
return;
|
||||
}
|
||||
LinearLayout form = buildDialogForm();
|
||||
Spinner accountSpinner = spinnerWith(labelsFor(accounts, "account", "displayName"));
|
||||
Spinner deviceSpinner = spinnerWith(labelsFor(devices, "id", "name"));
|
||||
Spinner permissionSpinner = spinnerWith(new String[]{"只读查看", "管理设备", "允许电脑控制"});
|
||||
form.addView(BossUi.buildFormCell(this, "账号", null, accountSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "设备", null, deviceSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "权限模板", null, permissionSpinner));
|
||||
confirmGrant("授权设备", form, () -> {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("action", "grant_device");
|
||||
body.put("account", valueAt(accounts, accountSpinner.getSelectedItemPosition(), "account"));
|
||||
body.put("deviceId", valueAt(devices, deviceSpinner.getSelectedItemPosition(), "id"));
|
||||
body.put("permissions", new JSONArray(permissionSpinner.getSelectedItemPosition() == 1
|
||||
? Arrays.asList("device.view", "device.manage")
|
||||
: permissionSpinner.getSelectedItemPosition() == 2
|
||||
? Arrays.asList("device.view", "computer.control")
|
||||
: Arrays.asList("device.view")));
|
||||
return body;
|
||||
});
|
||||
}
|
||||
|
||||
private void showProjectGrantDialog() {
|
||||
JSONObject payload = requireAccessPayload();
|
||||
if (payload == null) return;
|
||||
JSONArray accounts = payload.optJSONArray("accounts");
|
||||
JSONArray projects = payload.optJSONArray("projects");
|
||||
if (isEmpty(accounts) || isEmpty(projects)) {
|
||||
showMessage("需要先有账号和项目。");
|
||||
return;
|
||||
}
|
||||
LinearLayout form = buildDialogForm();
|
||||
Spinner accountSpinner = spinnerWith(labelsFor(accounts, "account", "displayName"));
|
||||
Spinner projectSpinner = spinnerWith(labelsFor(projects, "id", "name"));
|
||||
Spinner permissionSpinner = spinnerWith(new String[]{"只读查看", "允许聊天", "主 Agent 协同", "电脑控制"});
|
||||
form.addView(BossUi.buildFormCell(this, "账号", null, accountSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "项目", null, projectSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "权限模板", null, permissionSpinner));
|
||||
confirmGrant("授权项目", form, () -> {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("action", "grant_project");
|
||||
body.put("account", valueAt(accounts, accountSpinner.getSelectedItemPosition(), "account"));
|
||||
body.put("projectId", valueAt(projects, projectSpinner.getSelectedItemPosition(), "id"));
|
||||
body.put("permissions", projectPermissionsFor(permissionSpinner.getSelectedItemPosition()));
|
||||
return body;
|
||||
});
|
||||
}
|
||||
|
||||
private void showSkillGrantDialog() {
|
||||
JSONObject payload = requireAccessPayload();
|
||||
if (payload == null) return;
|
||||
JSONArray accounts = payload.optJSONArray("accounts");
|
||||
JSONArray skills = payload.optJSONArray("skills");
|
||||
if (isEmpty(accounts) || isEmpty(skills)) {
|
||||
showMessage("需要先有账号和已同步 Skill。");
|
||||
return;
|
||||
}
|
||||
LinearLayout form = buildDialogForm();
|
||||
Spinner accountSpinner = spinnerWith(labelsFor(accounts, "account", "displayName"));
|
||||
Spinner skillSpinner = spinnerWith(labelsFor(skills, "skillId", "name"));
|
||||
Spinner permissionSpinner = spinnerWith(new String[]{"可调用", "可管理"});
|
||||
form.addView(BossUi.buildFormCell(this, "账号", null, accountSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "Skill", null, skillSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "权限模板", null, permissionSpinner));
|
||||
confirmGrant("分配 Skill", form, () -> {
|
||||
JSONObject skill = skills.optJSONObject(skillSpinner.getSelectedItemPosition());
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("action", "grant_skill");
|
||||
body.put("account", valueAt(accounts, accountSpinner.getSelectedItemPosition(), "account"));
|
||||
body.put("skillId", skill == null ? "" : skill.optString("skillId", ""));
|
||||
body.put("deviceId", skill == null ? "" : skill.optString("deviceId", ""));
|
||||
body.put("permissions", new JSONArray(permissionSpinner.getSelectedItemPosition() == 1
|
||||
? Arrays.asList("skill.view", "skill.use", "skill.manage")
|
||||
: Arrays.asList("skill.view", "skill.use")));
|
||||
return body;
|
||||
});
|
||||
}
|
||||
|
||||
private void showTemplateGrantDialog() {
|
||||
JSONObject payload = requireAccessPayload();
|
||||
if (payload == null) return;
|
||||
JSONArray accounts = payload.optJSONArray("accounts");
|
||||
JSONArray templates = payload.optJSONArray("permissionTemplates");
|
||||
JSONArray devices = payload.optJSONArray("devices");
|
||||
JSONArray projects = payload.optJSONArray("projects");
|
||||
JSONArray skills = payload.optJSONArray("skills");
|
||||
if (isEmpty(accounts) || isEmpty(templates)) {
|
||||
showMessage("需要先有账号和权限模板。");
|
||||
return;
|
||||
}
|
||||
if (isEmpty(devices) && isEmpty(projects) && isEmpty(skills)) {
|
||||
showMessage("需要至少有设备、项目或 Skill。");
|
||||
return;
|
||||
}
|
||||
|
||||
LinearLayout form = buildDialogForm();
|
||||
Spinner accountSpinner = spinnerWith(labelsFor(accounts, "account", "displayName"));
|
||||
Spinner templateSpinner = spinnerWith(labelsFor(templates, "templateId", "name"));
|
||||
Spinner deviceSpinner = spinnerWith(optionalLabelsFor(devices, "id", "name", "不授权设备"));
|
||||
Spinner projectSpinner = spinnerWith(optionalLabelsFor(projects, "id", "name", "不授权项目"));
|
||||
Spinner skillSpinner = spinnerWith(optionalLabelsFor(skills, "skillId", "name", "不分配 Skill"));
|
||||
if (!isEmpty(devices)) {
|
||||
deviceSpinner.setSelection(1);
|
||||
}
|
||||
if (!isEmpty(projects)) {
|
||||
projectSpinner.setSelection(1);
|
||||
}
|
||||
if (!isEmpty(skills)) {
|
||||
skillSpinner.setSelection(1);
|
||||
}
|
||||
form.addView(BossUi.buildFormCell(this, "账号", null, accountSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "模板", "模板只作用于本次选择的账号和目标,不会全局放行。", templateSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "设备", null, deviceSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "项目", null, projectSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "Skill", null, skillSpinner));
|
||||
|
||||
confirmGrant("套用权限模板", form, () -> buildTemplateApplyPayload(
|
||||
valueAt(accounts, accountSpinner.getSelectedItemPosition(), "account"),
|
||||
objectAt(templates, templateSpinner.getSelectedItemPosition()),
|
||||
optionalObjectAt(devices, deviceSpinner.getSelectedItemPosition()),
|
||||
optionalObjectAt(projects, projectSpinner.getSelectedItemPosition()),
|
||||
optionalObjectAt(skills, skillSpinner.getSelectedItemPosition())
|
||||
));
|
||||
}
|
||||
|
||||
static JSONObject buildTemplateApplyPayload(
|
||||
String account,
|
||||
JSONObject template,
|
||||
@Nullable JSONObject device,
|
||||
@Nullable JSONObject project,
|
||||
@Nullable JSONObject skill
|
||||
) throws JSONException {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("action", "apply_template");
|
||||
body.put("account", account == null ? "" : account.trim());
|
||||
body.put("templateId", template == null ? "" : template.optString("templateId", ""));
|
||||
JSONArray deviceIds = new JSONArray();
|
||||
if (device != null && !TextUtils.isEmpty(device.optString("id", ""))) {
|
||||
deviceIds.put(device.optString("id", ""));
|
||||
}
|
||||
JSONArray projectIds = new JSONArray();
|
||||
if (project != null && !TextUtils.isEmpty(project.optString("id", ""))) {
|
||||
projectIds.put(project.optString("id", ""));
|
||||
}
|
||||
JSONArray skillIds = new JSONArray();
|
||||
if (skill != null && !TextUtils.isEmpty(skill.optString("skillId", ""))) {
|
||||
skillIds.put(skill.optString("skillId", ""));
|
||||
}
|
||||
body.put("deviceIds", deviceIds);
|
||||
body.put("projectIds", projectIds);
|
||||
body.put("skillIds", skillIds);
|
||||
return body;
|
||||
}
|
||||
|
||||
private void confirmGrant(String title, LinearLayout form, PayloadFactory factory) {
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle(title)
|
||||
.setView(form)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("保存", (dialog, which) -> {
|
||||
try {
|
||||
runAdminAction(factory.create());
|
||||
} catch (JSONException error) {
|
||||
showMessage("保存失败:" + error.getMessage());
|
||||
}
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
private void confirmRevoke(String grantId) {
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("撤销授权")
|
||||
.setMessage("只撤销当前这条授权,不影响其他设备、项目或 Skill。")
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("撤销", (dialog, which) -> {
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("action", "revoke_grant");
|
||||
payload.put("grantId", grantId);
|
||||
runAdminAction(payload);
|
||||
} catch (JSONException error) {
|
||||
showMessage("撤销失败:" + error.getMessage());
|
||||
}
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
private void runAdminAction(JSONObject payload) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.updateAdminAccess(payload);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showMessage("已保存");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("操作失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private JSONObject requireAccessPayload() {
|
||||
if (accessPayload == null) {
|
||||
showMessage("权限数据还没加载完成。");
|
||||
}
|
||||
return accessPayload;
|
||||
}
|
||||
|
||||
private LinearLayout buildDialogForm() {
|
||||
LinearLayout form = new LinearLayout(this);
|
||||
form.setOrientation(LinearLayout.VERTICAL);
|
||||
return form;
|
||||
}
|
||||
|
||||
private Spinner spinnerWith(String[] values) {
|
||||
Spinner spinner = new Spinner(this);
|
||||
spinner.setAdapter(new ArrayAdapter<>(
|
||||
this,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
values
|
||||
));
|
||||
return spinner;
|
||||
}
|
||||
|
||||
private JSONArray projectPermissionsFor(int position) {
|
||||
if (position == 3) {
|
||||
return new JSONArray(Arrays.asList("project.view", "thread.chat", "master_agent.ask", "master_agent.takeover", "computer.control"));
|
||||
}
|
||||
if (position == 2) {
|
||||
return new JSONArray(Arrays.asList("project.view", "thread.chat", "master_agent.ask", "master_agent.takeover"));
|
||||
}
|
||||
if (position == 1) {
|
||||
return new JSONArray(Arrays.asList("project.view", "thread.chat"));
|
||||
}
|
||||
return new JSONArray(Arrays.asList("project.view"));
|
||||
}
|
||||
|
||||
private String[] labelsFor(JSONArray array, String idKey, String nameKey) {
|
||||
List<String> labels = new ArrayList<>();
|
||||
if (array == null) {
|
||||
return new String[0];
|
||||
}
|
||||
for (int index = 0; index < array.length(); index += 1) {
|
||||
JSONObject item = array.optJSONObject(index);
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
String id = item.optString(idKey, "");
|
||||
String name = item.optString(nameKey, "");
|
||||
labels.add(TextUtils.isEmpty(name) || name.equals(id) ? id : name + " · " + id);
|
||||
}
|
||||
return labels.toArray(new String[0]);
|
||||
}
|
||||
|
||||
private String[] optionalLabelsFor(JSONArray array, String idKey, String nameKey, String emptyLabel) {
|
||||
List<String> labels = new ArrayList<>();
|
||||
labels.add(emptyLabel);
|
||||
if (array != null) {
|
||||
labels.addAll(Arrays.asList(labelsFor(array, idKey, nameKey)));
|
||||
}
|
||||
return labels.toArray(new String[0]);
|
||||
}
|
||||
|
||||
private String valueAt(JSONArray array, int position, String key) {
|
||||
JSONObject item = array == null ? null : array.optJSONObject(position);
|
||||
return item == null ? "" : item.optString(key, "");
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private JSONObject objectAt(JSONArray array, int position) {
|
||||
return array == null ? null : array.optJSONObject(position);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private JSONObject optionalObjectAt(JSONArray array, int position) {
|
||||
if (position <= 0 || array == null) {
|
||||
return null;
|
||||
}
|
||||
return array.optJSONObject(position - 1);
|
||||
}
|
||||
|
||||
private JSONArray grantsArray(JSONObject payload, String key) {
|
||||
JSONObject grants = payload.optJSONObject("grants");
|
||||
return grants == null ? new JSONArray() : grants.optJSONArray(key);
|
||||
}
|
||||
|
||||
private String summarizeAccounts(JSONArray accounts) {
|
||||
if (accounts == null || accounts.length() == 0) {
|
||||
return "暂无子账号";
|
||||
}
|
||||
List<String> parts = new ArrayList<>();
|
||||
int max = Math.min(4, accounts.length());
|
||||
for (int index = 0; index < max; index += 1) {
|
||||
JSONObject account = accounts.optJSONObject(index);
|
||||
if (account == null) continue;
|
||||
parts.add(account.optString("displayName", account.optString("account", "")) + " · " + BossUi.formatRoleLabel(account.optString("role", "")));
|
||||
}
|
||||
if (accounts.length() > max) {
|
||||
parts.add("+" + (accounts.length() - max));
|
||||
}
|
||||
return TextUtils.join("\n", parts);
|
||||
}
|
||||
|
||||
private String grantTargetSummary(JSONObject grant) {
|
||||
if (!TextUtils.isEmpty(grant.optString("skillId", ""))) {
|
||||
return "Skill:" + grant.optString("skillId", "");
|
||||
}
|
||||
if (!TextUtils.isEmpty(grant.optString("projectId", ""))) {
|
||||
return "项目:" + grant.optString("projectId", "");
|
||||
}
|
||||
return "设备:" + grant.optString("deviceId", "");
|
||||
}
|
||||
|
||||
private String joinJsonArray(JSONArray values) {
|
||||
if (values == null || values.length() == 0) {
|
||||
return "未设置权限";
|
||||
}
|
||||
List<String> parts = new ArrayList<>();
|
||||
for (int index = 0; index < values.length(); index += 1) {
|
||||
parts.add(values.optString(index, ""));
|
||||
}
|
||||
return TextUtils.join(" / ", parts);
|
||||
}
|
||||
|
||||
private int lengthOf(@Nullable JSONArray array) {
|
||||
return array == null ? 0 : array.length();
|
||||
}
|
||||
|
||||
private boolean isEmpty(@Nullable JSONArray array) {
|
||||
return array == null || array.length() == 0;
|
||||
}
|
||||
|
||||
private interface PayloadFactory {
|
||||
JSONObject create() throws JSONException;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -66,53 +66,6 @@ public class BossApiClient {
|
||||
return response;
|
||||
}
|
||||
|
||||
public ApiResponse loginWithPassword(String account, String password) throws IOException, JSONException {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("account", account);
|
||||
body.put("password", password);
|
||||
body.put("method", "password");
|
||||
ApiResponse response = request("POST", "/api/auth/login", body, false);
|
||||
if (response.ok()) {
|
||||
rememberIdentity(response.json);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
public ApiResponse sendVerificationCode(String account, String purpose) throws IOException, JSONException {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("account", account);
|
||||
body.put("purpose", purpose);
|
||||
return request("POST", "/api/auth/send-code", body, false);
|
||||
}
|
||||
|
||||
public ApiResponse registerAccount(
|
||||
String account,
|
||||
String password,
|
||||
String confirmPassword,
|
||||
String code
|
||||
) throws IOException, JSONException {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("account", account);
|
||||
body.put("password", password);
|
||||
body.put("confirmPassword", confirmPassword);
|
||||
body.put("code", code);
|
||||
return request("POST", "/api/auth/register", body, false);
|
||||
}
|
||||
|
||||
public ApiResponse resetPassword(
|
||||
String account,
|
||||
String password,
|
||||
String confirmPassword,
|
||||
String code
|
||||
) throws IOException, JSONException {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("account", account);
|
||||
body.put("password", password);
|
||||
body.put("confirmPassword", confirmPassword);
|
||||
body.put("code", code);
|
||||
return request("POST", "/api/auth/forgot-password", body, false);
|
||||
}
|
||||
|
||||
public ApiResponse restoreSession() throws IOException, JSONException {
|
||||
if (getRestoreToken().isEmpty()) {
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_RESTORE_TOKEN"));
|
||||
@@ -130,17 +83,6 @@ public class BossApiClient {
|
||||
return request("GET", "/api/auth/session", null, true);
|
||||
}
|
||||
|
||||
public ApiResponse getAuthSessions() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/auth/sessions", null);
|
||||
}
|
||||
|
||||
public ApiResponse revokeAuthSession(String sessionId) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("action", "revoke_session");
|
||||
payload.put("sessionId", sessionId);
|
||||
return requestWithRestore("POST", "/api/v1/auth/sessions", payload);
|
||||
}
|
||||
|
||||
public ApiResponse getConversations() throws IOException, JSONException {
|
||||
return requestWithRestoreRaw(
|
||||
"GET",
|
||||
@@ -165,36 +107,22 @@ public class BossApiClient {
|
||||
return requestWithRestore("GET", "/api/v1/conversation-folders/" + encode(folderKey), null);
|
||||
}
|
||||
|
||||
public ApiResponse markConversationRead(String projectId) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("action", "mark_read");
|
||||
return requestWithRestore("POST", "/api/v1/conversations/" + encode(projectId) + "/actions", payload);
|
||||
}
|
||||
|
||||
public ApiResponse getProjectDetail(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestoreRaw(
|
||||
"GET",
|
||||
"/api/v1/projects/" + encode(projectId),
|
||||
null,
|
||||
DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
CONVERSATIONS_READ_TIMEOUT_MS
|
||||
);
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null);
|
||||
}
|
||||
|
||||
public ApiResponse getProjectMessages(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestoreRaw(
|
||||
"GET",
|
||||
"/api/v1/projects/" + encode(projectId) + "/messages",
|
||||
null,
|
||||
DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
CONVERSATIONS_READ_TIMEOUT_MS
|
||||
);
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/messages", null);
|
||||
}
|
||||
|
||||
public ApiResponse getDispatchPlans(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/dispatch-plans", null);
|
||||
}
|
||||
|
||||
public ApiResponse getConversationParticipants(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/participants", null);
|
||||
}
|
||||
|
||||
public ApiResponse getProjectAgentControls(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/agent-controls", null);
|
||||
}
|
||||
@@ -210,6 +138,76 @@ public class BossApiClient {
|
||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
|
||||
}
|
||||
|
||||
public ApiResponse updateProjectAgentControls(
|
||||
String projectId,
|
||||
@Nullable String modelOverride,
|
||||
@Nullable String reasoningEffortOverride,
|
||||
@Nullable String fastModelOverride,
|
||||
@Nullable String fastReasoningEffortOverride,
|
||||
@Nullable String smartModelOverride,
|
||||
@Nullable String smartReasoningEffortOverride
|
||||
) throws IOException, JSONException {
|
||||
JSONObject payload = buildProjectAgentControlsPayload(
|
||||
modelOverride,
|
||||
reasoningEffortOverride,
|
||||
fastModelOverride,
|
||||
fastReasoningEffortOverride,
|
||||
smartModelOverride,
|
||||
smartReasoningEffortOverride,
|
||||
false
|
||||
);
|
||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
|
||||
}
|
||||
|
||||
public ApiResponse updateProjectAgentControls(
|
||||
String projectId,
|
||||
@Nullable String modelOverride,
|
||||
@Nullable String reasoningEffortOverride,
|
||||
@Nullable String fastModelOverride,
|
||||
@Nullable String fastReasoningEffortOverride,
|
||||
@Nullable String smartModelOverride,
|
||||
@Nullable String smartReasoningEffortOverride,
|
||||
boolean includeAdvancedOverrides
|
||||
) throws IOException, JSONException {
|
||||
JSONObject payload = buildProjectAgentControlsPayload(
|
||||
modelOverride,
|
||||
reasoningEffortOverride,
|
||||
fastModelOverride,
|
||||
fastReasoningEffortOverride,
|
||||
smartModelOverride,
|
||||
smartReasoningEffortOverride,
|
||||
includeAdvancedOverrides
|
||||
);
|
||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
|
||||
}
|
||||
|
||||
static JSONObject buildProjectAgentControlsPayload(
|
||||
@Nullable String modelOverride,
|
||||
@Nullable String reasoningEffortOverride,
|
||||
@Nullable String fastModelOverride,
|
||||
@Nullable String fastReasoningEffortOverride,
|
||||
@Nullable String smartModelOverride,
|
||||
@Nullable String smartReasoningEffortOverride,
|
||||
boolean includeAdvancedOverrides
|
||||
) throws JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("modelOverride", modelOverride == null ? JSONObject.NULL : modelOverride);
|
||||
payload.put("reasoningEffortOverride", reasoningEffortOverride == null ? JSONObject.NULL : reasoningEffortOverride);
|
||||
|
||||
boolean hasAdvancedOverrides =
|
||||
fastModelOverride != null
|
||||
|| fastReasoningEffortOverride != null
|
||||
|| smartModelOverride != null
|
||||
|| smartReasoningEffortOverride != null;
|
||||
if (includeAdvancedOverrides || hasAdvancedOverrides) {
|
||||
payload.put("fastModelOverride", fastModelOverride == null ? JSONObject.NULL : fastModelOverride);
|
||||
payload.put("fastReasoningEffortOverride", fastReasoningEffortOverride == null ? JSONObject.NULL : fastReasoningEffortOverride);
|
||||
payload.put("smartModelOverride", smartModelOverride == null ? JSONObject.NULL : smartModelOverride);
|
||||
payload.put("smartReasoningEffortOverride", smartReasoningEffortOverride == null ? JSONObject.NULL : smartReasoningEffortOverride);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
public ApiResponse updateProjectAgentControls(
|
||||
String projectId,
|
||||
@Nullable String modelOverride,
|
||||
@@ -223,22 +221,6 @@ public class BossApiClient {
|
||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
|
||||
}
|
||||
|
||||
public ApiResponse updateMasterAgentModeModels(
|
||||
@Nullable String fastModelOverride,
|
||||
@Nullable String deepModelOverride,
|
||||
@Nullable String modelOverride,
|
||||
@Nullable String reasoningEffortOverride
|
||||
) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("fastModelOverride", fastModelOverride == null ? JSONObject.NULL : fastModelOverride);
|
||||
payload.put("deepModelOverride", deepModelOverride == null ? JSONObject.NULL : deepModelOverride);
|
||||
if (modelOverride != null || reasoningEffortOverride != null) {
|
||||
payload.put("modelOverride", modelOverride == null ? JSONObject.NULL : modelOverride);
|
||||
payload.put("reasoningEffortOverride", reasoningEffortOverride == null ? JSONObject.NULL : reasoningEffortOverride);
|
||||
}
|
||||
return requestWithRestore("POST", "/api/v1/projects/master-agent/agent-controls", payload);
|
||||
}
|
||||
|
||||
public ApiResponse updateProjectTakeoverSettings(
|
||||
String projectId,
|
||||
@Nullable Boolean takeoverEnabled,
|
||||
@@ -283,6 +265,32 @@ public class BossApiClient {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/memories", null);
|
||||
}
|
||||
|
||||
public ApiResponse getMasterAgentEvolution() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/master-agent/evolution", null);
|
||||
}
|
||||
|
||||
public ApiResponse updateMasterAgentEvolutionMode(String mode) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("mode", mode);
|
||||
return requestWithRestore("POST", "/api/v1/master-agent/evolution/config", payload);
|
||||
}
|
||||
|
||||
public ApiResponse approveMasterAgentEvolutionProposal(String proposalId) throws IOException, JSONException {
|
||||
return requestWithRestore(
|
||||
"POST",
|
||||
"/api/v1/master-agent/evolution/proposals/" + encode(proposalId) + "/approve",
|
||||
new JSONObject()
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse rejectMasterAgentEvolutionProposal(String proposalId) throws IOException, JSONException {
|
||||
return requestWithRestore(
|
||||
"POST",
|
||||
"/api/v1/master-agent/evolution/proposals/" + encode(proposalId) + "/reject",
|
||||
new JSONObject()
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse createMasterAgentMemory(String projectId, JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/memories", payload);
|
||||
}
|
||||
@@ -330,16 +338,6 @@ public class BossApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse decideDialogGuardIntervention(String interventionId, String decision) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("decision", decision);
|
||||
return requestWithRestore(
|
||||
"POST",
|
||||
"/api/v1/dialog-guard/interventions/" + encode(interventionId) + "/decision",
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse retryDispatchPlan(String projectId, String planId) throws IOException, JSONException {
|
||||
return requestWithRestoreRaw(
|
||||
"POST",
|
||||
@@ -365,10 +363,6 @@ public class BossApiClient {
|
||||
return requestWithRestore("POST", "/api/v1/group-chats", payload == null ? new JSONObject() : payload);
|
||||
}
|
||||
|
||||
public ApiResponse getConversationParticipants(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/participants", null);
|
||||
}
|
||||
|
||||
public ApiResponse getThreadStatus(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/thread-status", null);
|
||||
}
|
||||
@@ -385,18 +379,6 @@ public class BossApiClient {
|
||||
return requestWithRestore("PATCH", "/api/v1/projects/" + encode(projectId) + "/dispatch-reminder", payload);
|
||||
}
|
||||
|
||||
public ApiResponse getAttachmentStorageConfig() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/storage/config", null);
|
||||
}
|
||||
|
||||
public ApiResponse saveAttachmentStorageConfig(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("PATCH", "/api/v1/storage/config", payload);
|
||||
}
|
||||
|
||||
public ApiResponse validateAttachmentStorageConfig(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/storage/config/validate", payload);
|
||||
}
|
||||
|
||||
public ApiResponse sendProjectMessage(String projectId, String body, String kind) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("body", body);
|
||||
@@ -410,14 +392,6 @@ public class BossApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse deleteProjectMessage(String projectId, String messageId) throws IOException, JSONException {
|
||||
return requestWithRestore(
|
||||
"DELETE",
|
||||
"/api/v1/projects/" + encode(projectId) + "/messages?messageId=" + encode(messageId),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse uploadAttachment(
|
||||
String projectId,
|
||||
String fileName,
|
||||
@@ -556,30 +530,10 @@ public class BossApiClient {
|
||||
return updateDevice(deviceId, payload);
|
||||
}
|
||||
|
||||
public ApiResponse queueCodexRemoteControl(String deviceId, String action, String reason) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("action", action);
|
||||
payload.put("confirmed", true);
|
||||
payload.put("reason", reason == null ? "" : reason);
|
||||
return requestWithRestore(
|
||||
"POST",
|
||||
"/api/v1/devices/" + encode(deviceId) + "/codex-remote-control",
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse getDeviceSkills(String deviceId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/devices/" + encode(deviceId) + "/skills", null);
|
||||
}
|
||||
|
||||
public ApiResponse getSkillLifecycleRequests() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/admin/skills/requests", null);
|
||||
}
|
||||
|
||||
public ApiResponse createSkillLifecycleRequest(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/admin/skills/requests", payload == null ? new JSONObject() : payload);
|
||||
}
|
||||
|
||||
public ApiResponse getDeviceEnrollments() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/devices/enrollments", null);
|
||||
}
|
||||
@@ -610,14 +564,6 @@ public class BossApiClient {
|
||||
return requestWithRestore("GET", "/api/v1/accounts", null);
|
||||
}
|
||||
|
||||
public ApiResponse getAdminAccess() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/admin/access", null);
|
||||
}
|
||||
|
||||
public ApiResponse updateAdminAccess(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/admin/access", payload);
|
||||
}
|
||||
|
||||
public ApiResponse createAccount(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/accounts", payload);
|
||||
}
|
||||
@@ -640,10 +586,6 @@ public class BossApiClient {
|
||||
return requestWithRestore("POST", "/api/v1/accounts/" + encode(accountId) + "/validate", new JSONObject());
|
||||
}
|
||||
|
||||
public ApiResponse validateDraftAccount(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/accounts/validate-draft", payload);
|
||||
}
|
||||
|
||||
public ApiResponse onboardOpenAiApiAccount(JSONObject payload) throws IOException, JSONException {
|
||||
return onboardAccount("/api/v1/accounts/onboard/openai-api", payload);
|
||||
}
|
||||
@@ -680,14 +622,6 @@ public class BossApiClient {
|
||||
return requestWithRestore("POST", "/api/v1/settings", payload);
|
||||
}
|
||||
|
||||
public ApiResponse getTelegramIntegration() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/integrations/telegram", null);
|
||||
}
|
||||
|
||||
public ApiResponse updateTelegramIntegration(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/integrations/telegram", payload);
|
||||
}
|
||||
|
||||
public ApiResponse getOtaStatus() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/user/ota", null);
|
||||
}
|
||||
@@ -705,19 +639,13 @@ public class BossApiClient {
|
||||
}
|
||||
|
||||
public ApiResponse logout() throws IOException, JSONException {
|
||||
try {
|
||||
return request("POST", "/api/auth/logout", new JSONObject(), false);
|
||||
} finally {
|
||||
clearSession();
|
||||
}
|
||||
}
|
||||
|
||||
public void clearLocalAuthState() {
|
||||
ApiResponse response = request("POST", "/api/auth/logout", new JSONObject(), false);
|
||||
clearSession();
|
||||
return response;
|
||||
}
|
||||
|
||||
public String getAccountLabel() {
|
||||
return prefs.getString(KEY_ACCOUNT, "krisolo");
|
||||
return prefs.getString(KEY_ACCOUNT, "17600003315");
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
@@ -762,9 +690,9 @@ public class BossApiClient {
|
||||
int readTimeoutMs
|
||||
) throws IOException, JSONException {
|
||||
ApiResponse response = requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
|
||||
if (response.statusCode == 401) {
|
||||
ApiResponse recovered = !getRestoreToken().isEmpty() ? restoreSession() : autoLogin();
|
||||
if (recovered.ok()) {
|
||||
if (response.statusCode == 401 && !getRestoreToken().isEmpty()) {
|
||||
ApiResponse restored = restoreSession();
|
||||
if (restored.ok()) {
|
||||
return requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
|
||||
}
|
||||
}
|
||||
@@ -857,16 +785,7 @@ public class BossApiClient {
|
||||
private ApiResponse executeConnection(HttpURLConnection connection, boolean expectProtected) throws IOException, JSONException {
|
||||
int statusCode = connection.getResponseCode();
|
||||
captureSessionCookie(connection.getHeaderFields());
|
||||
JsonBody jsonBody = readJsonBody(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
|
||||
JSONObject json = jsonBody.json;
|
||||
if (!jsonBody.validJson) {
|
||||
int normalizedStatusCode = expectProtected && statusCode < 400 ? 401 : statusCode;
|
||||
json = new JSONObject()
|
||||
.put("ok", false)
|
||||
.put("message", "NON_JSON_RESPONSE")
|
||||
.put("statusCode", statusCode);
|
||||
statusCode = normalizedStatusCode;
|
||||
}
|
||||
JSONObject json = readJson(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
|
||||
|
||||
if (statusCode == 401 && !expectProtected) {
|
||||
clearSession();
|
||||
@@ -979,12 +898,8 @@ public class BossApiClient {
|
||||
}
|
||||
|
||||
private JSONObject readJson(InputStream stream) throws IOException, JSONException {
|
||||
return readJsonBody(stream).json;
|
||||
}
|
||||
|
||||
private JsonBody readJsonBody(InputStream stream) throws IOException, JSONException {
|
||||
if (stream == null) {
|
||||
return new JsonBody(new JSONObject(), true);
|
||||
return new JSONObject();
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
||||
@@ -995,13 +910,9 @@ public class BossApiClient {
|
||||
}
|
||||
String raw = builder.toString().trim();
|
||||
if (raw.isEmpty()) {
|
||||
return new JsonBody(new JSONObject(), true);
|
||||
}
|
||||
try {
|
||||
return new JsonBody(new JSONObject(raw), true);
|
||||
} catch (JSONException error) {
|
||||
return new JsonBody(new JSONObject(), false);
|
||||
return new JSONObject();
|
||||
}
|
||||
return new JSONObject(raw);
|
||||
}
|
||||
|
||||
private String readText(InputStream stream) throws IOException {
|
||||
@@ -1035,13 +946,9 @@ public class BossApiClient {
|
||||
|
||||
private void captureSessionCookie(Map<String, List<String>> headers) {
|
||||
if (headers == null) return;
|
||||
List<String> setCookieHeaders = null;
|
||||
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
|
||||
String headerName = entry.getKey();
|
||||
if (headerName != null && "set-cookie".equalsIgnoreCase(headerName)) {
|
||||
setCookieHeaders = entry.getValue();
|
||||
break;
|
||||
}
|
||||
List<String> setCookieHeaders = headers.get("Set-Cookie");
|
||||
if (setCookieHeaders == null) {
|
||||
setCookieHeaders = headers.get("set-cookie");
|
||||
}
|
||||
if (setCookieHeaders == null) return;
|
||||
|
||||
@@ -1105,8 +1012,6 @@ public class BossApiClient {
|
||||
prefs.edit()
|
||||
.remove(KEY_SESSION_COOKIE)
|
||||
.remove(KEY_RESTORE_TOKEN)
|
||||
.remove(KEY_ACCOUNT)
|
||||
.remove(KEY_DISPLAY_NAME)
|
||||
.apply();
|
||||
}
|
||||
|
||||
@@ -1136,16 +1041,6 @@ public class BossApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
private static class JsonBody {
|
||||
final JSONObject json;
|
||||
final boolean validJson;
|
||||
|
||||
JsonBody(JSONObject json, boolean validJson) {
|
||||
this.json = json;
|
||||
this.validJson = validJson;
|
||||
}
|
||||
}
|
||||
|
||||
public static class DownloadedAttachment {
|
||||
public final int statusCode;
|
||||
public final String fileName;
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
final class BossAppVisibilityTracker {
|
||||
private volatile boolean appInForeground;
|
||||
private volatile @Nullable String visibleProjectId;
|
||||
|
||||
void onAppForegrounded() {
|
||||
appInForeground = true;
|
||||
}
|
||||
|
||||
void onAppBackgrounded() {
|
||||
appInForeground = false;
|
||||
}
|
||||
|
||||
boolean isAppInForeground() {
|
||||
return appInForeground;
|
||||
}
|
||||
|
||||
void setVisibleProjectId(@Nullable String projectId) {
|
||||
if (projectId == null) {
|
||||
visibleProjectId = null;
|
||||
return;
|
||||
}
|
||||
String normalized = projectId.trim();
|
||||
visibleProjectId = normalized.isEmpty() ? null : normalized;
|
||||
}
|
||||
|
||||
void clearVisibleProjectId(@Nullable String projectId) {
|
||||
if (visibleProjectId == null) {
|
||||
return;
|
||||
}
|
||||
if (projectId == null) {
|
||||
visibleProjectId = null;
|
||||
return;
|
||||
}
|
||||
String normalized = projectId.trim();
|
||||
if (normalized.isEmpty() || visibleProjectId.equals(normalized)) {
|
||||
visibleProjectId = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
String getVisibleProjectId() {
|
||||
return visibleProjectId;
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.Application;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
|
||||
public final class BossApplication extends Application {
|
||||
private final BossAppVisibilityTracker visibilityTracker = new BossAppVisibilityTracker();
|
||||
private BossNotificationRouter notificationRouter;
|
||||
private int startedActivityCount;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
|
||||
super.onCreate();
|
||||
notificationRouter = new BossNotificationRouter(this, visibilityTracker);
|
||||
registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
|
||||
@Override
|
||||
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {}
|
||||
|
||||
@Override
|
||||
public void onActivityStarted(Activity activity) {
|
||||
startedActivityCount += 1;
|
||||
if (startedActivityCount == 1) {
|
||||
visibilityTracker.onAppForegrounded();
|
||||
BossBackgroundRealtimeService.stop(BossApplication.this);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResumed(Activity activity) {}
|
||||
|
||||
@Override
|
||||
public void onActivityPaused(Activity activity) {}
|
||||
|
||||
@Override
|
||||
public void onActivityStopped(Activity activity) {
|
||||
startedActivityCount = Math.max(0, startedActivityCount - 1);
|
||||
if (startedActivityCount == 0) {
|
||||
visibilityTracker.onAppBackgrounded();
|
||||
BossBackgroundRealtimeService.start(BossApplication.this);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivitySaveInstanceState(Activity activity, Bundle outState) {}
|
||||
|
||||
@Override
|
||||
public void onActivityDestroyed(Activity activity) {}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTerminate() {
|
||||
BossBackgroundRealtimeService.stop(this);
|
||||
super.onTerminate();
|
||||
}
|
||||
|
||||
BossAppVisibilityTracker visibilityTracker() {
|
||||
return visibilityTracker;
|
||||
}
|
||||
|
||||
BossNotificationRouter notificationRouter() {
|
||||
return notificationRouter;
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
|
||||
public class BossBackgroundRealtimeService extends Service {
|
||||
static final String ACTION_START = "com.hyzq.boss.action.START_BACKGROUND_REALTIME";
|
||||
static final String ACTION_STOP = "com.hyzq.boss.action.STOP_BACKGROUND_REALTIME";
|
||||
static final String SERVICE_CHANNEL_ID = "boss_background_sync";
|
||||
static final int SERVICE_NOTIFICATION_ID = 2002;
|
||||
|
||||
interface BossRealtimeRuntime {
|
||||
void start();
|
||||
|
||||
void stop();
|
||||
}
|
||||
|
||||
private @Nullable BossApiClient apiClient;
|
||||
private @Nullable BossRealtimeRuntime realtimeRuntime;
|
||||
private boolean realtimeStarted;
|
||||
|
||||
static void start(Context context) {
|
||||
Intent intent = new Intent(context, BossBackgroundRealtimeService.class).setAction(ACTION_START);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent);
|
||||
return;
|
||||
}
|
||||
context.startService(intent);
|
||||
}
|
||||
|
||||
static void stop(Context context) {
|
||||
Intent intent = new Intent(context, BossBackgroundRealtimeService.class).setAction(ACTION_STOP);
|
||||
context.startService(intent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
apiClient = createApiClient();
|
||||
BossNotificationRouter notificationRouter = createNotificationRouter();
|
||||
realtimeRuntime = createRealtimeRuntime(apiClient, notificationRouter);
|
||||
}
|
||||
|
||||
BossApiClient createApiClient() {
|
||||
return new BossApiClient(this);
|
||||
}
|
||||
|
||||
BossNotificationRouter createNotificationRouter() {
|
||||
BossAppVisibilityTracker tracker = getApplication() instanceof BossApplication
|
||||
? ((BossApplication) getApplication()).visibilityTracker()
|
||||
: new BossAppVisibilityTracker();
|
||||
return new BossNotificationRouter(this, tracker);
|
||||
}
|
||||
|
||||
BossRealtimeRuntime createRealtimeRuntime(BossApiClient apiClient, BossNotificationRouter router) {
|
||||
BossRealtimeClient realtimeClient = new BossRealtimeClient(apiClient, router::maybeNotifyForRealtimeEvent);
|
||||
return new BossRealtimeRuntime() {
|
||||
@Override
|
||||
public void start() {
|
||||
realtimeClient.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
realtimeClient.stop();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(@Nullable Intent intent, int flags, int startId) {
|
||||
String action = intent == null ? ACTION_START : intent.getAction();
|
||||
if (ACTION_STOP.equals(action)) {
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
if (apiClient == null || realtimeRuntime == null || !apiClient.hasSessionHints()) {
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
startForeground(SERVICE_NOTIFICATION_ID, buildForegroundNotification());
|
||||
if (!realtimeStarted) {
|
||||
realtimeRuntime.start();
|
||||
realtimeStarted = true;
|
||||
}
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
if (realtimeRuntime != null && realtimeStarted) {
|
||||
realtimeRuntime.stop();
|
||||
realtimeStarted = false;
|
||||
}
|
||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
private Notification buildForegroundNotification() {
|
||||
ensureChannel();
|
||||
return new NotificationCompat.Builder(this, SERVICE_CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("Boss 后台同步中")
|
||||
.setContentText("主 Agent 新回复会通过系统通知提醒")
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(buildContentIntent())
|
||||
.build();
|
||||
}
|
||||
|
||||
private PendingIntent buildContentIntent() {
|
||||
Intent intent = new Intent(this, MainActivity.class)
|
||||
.putExtra(MainActivity.EXTRA_INITIAL_TAB, "conversations")
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
return PendingIntent.getActivity(
|
||||
this,
|
||||
902,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
||||
);
|
||||
}
|
||||
|
||||
private void ensureChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return;
|
||||
}
|
||||
NotificationManager notificationManager = getSystemService(NotificationManager.class);
|
||||
if (notificationManager == null || notificationManager.getNotificationChannel(SERVICE_CHANNEL_ID) != null) {
|
||||
return;
|
||||
}
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
SERVICE_CHANNEL_ID,
|
||||
"Boss 后台同步",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
);
|
||||
channel.setDescription("保持主 Agent 后台同步与消息提醒");
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Typeface;
|
||||
import android.os.Build;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.SpannedString;
|
||||
@@ -28,8 +27,6 @@ public final class BossMarkdown {
|
||||
private static final Pattern HEADING_PATTERN = Pattern.compile("^(#{1,3})\\s+(.+)$");
|
||||
private static final Pattern BULLET_PATTERN = Pattern.compile("^[-*]\\s+(.+)$");
|
||||
private static final Pattern ORDERED_PATTERN = Pattern.compile("^(\\d+)\\.\\s+(.+)$");
|
||||
private static final Pattern LABEL_SECTION_PATTERN = Pattern.compile("^([^::\\n]{1,24})[::]\\s*(.+)$");
|
||||
private static final Pattern MARKDOWN_LINK_PATTERN = Pattern.compile("\\[([^\\]\\n]{1,90})\\]\\((https?://[^\\s)]+)\\)");
|
||||
private static final Pattern INLINE_TOKEN_PATTERN = Pattern.compile("(\\*\\*([^*]+)\\*\\*)|(`([^`]+)`)");;
|
||||
private static final LruCache<String, CharSequence> RENDER_CACHE = new LruCache<>(180);
|
||||
|
||||
@@ -46,7 +43,7 @@ public final class BossMarkdown {
|
||||
}
|
||||
Palette palette = Palette.resolve(context, outgoing);
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder();
|
||||
String normalized = normalizeMarkdownLinks(markdown).replace("\r\n", "\n").replace('\r', '\n');
|
||||
String normalized = markdown.replace("\r\n", "\n").replace('\r', '\n');
|
||||
String[] lines = normalized.split("\n", -1);
|
||||
boolean inCodeFence = false;
|
||||
List<String> codeLines = new ArrayList<>();
|
||||
@@ -89,12 +86,6 @@ public final class BossMarkdown {
|
||||
continue;
|
||||
}
|
||||
|
||||
Matcher labelMatcher = LABEL_SECTION_PATTERN.matcher(trimmed);
|
||||
if (labelMatcher.matches()) {
|
||||
appendLabelSection(builder, labelMatcher.group(1), labelMatcher.group(2), palette);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith(">")) {
|
||||
appendQuote(builder, trimmed.substring(1).trim(), palette);
|
||||
continue;
|
||||
@@ -118,21 +109,6 @@ public final class BossMarkdown {
|
||||
return (outgoing ? "out" : "in") + "|" + uiMode + "|" + markdown;
|
||||
}
|
||||
|
||||
private static String normalizeMarkdownLinks(String markdown) {
|
||||
Matcher matcher = MARKDOWN_LINK_PATTERN.matcher(markdown);
|
||||
StringBuffer buffer = new StringBuffer();
|
||||
while (matcher.find()) {
|
||||
String label = matcher.group(1) == null ? "链接" : matcher.group(1).trim();
|
||||
label = label.replace("`", "").trim();
|
||||
if (TextUtils.isEmpty(label)) {
|
||||
label = "链接";
|
||||
}
|
||||
matcher.appendReplacement(buffer, Matcher.quoteReplacement(label));
|
||||
}
|
||||
matcher.appendTail(buffer);
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
private static void appendHeading(SpannableStringBuilder builder, String text, int level, Palette palette) {
|
||||
ensureBlockSeparation(builder, true);
|
||||
int start = builder.length();
|
||||
@@ -172,26 +148,8 @@ public final class BossMarkdown {
|
||||
ensureBlockSeparation(builder, false);
|
||||
int start = builder.length();
|
||||
appendInlineStyled(builder, TextUtils.isEmpty(text) ? "引用" : text, palette);
|
||||
QuoteSpan quoteSpan = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
|
||||
? new QuoteSpan(palette.quoteColor, BossUi.dp(palette.context, 3), BossUi.dp(palette.context, 8))
|
||||
: new QuoteSpan(palette.quoteColor);
|
||||
builder.setSpan(quoteSpan, start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.append('\n');
|
||||
}
|
||||
|
||||
private static void appendLabelSection(
|
||||
SpannableStringBuilder builder,
|
||||
String label,
|
||||
String content,
|
||||
Palette palette
|
||||
) {
|
||||
ensureBlockSeparation(builder, true);
|
||||
int labelStart = builder.length();
|
||||
builder.append(label.trim());
|
||||
builder.setSpan(new StyleSpan(Typeface.BOLD), labelStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.setSpan(new RelativeSizeSpan(1.03f), labelStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.append('\n');
|
||||
appendInlineStyled(builder, content.trim(), palette);
|
||||
builder.setSpan(new QuoteSpan(palette.quoteColor, BossUi.dp(palette.context, 3), BossUi.dp(palette.context, 8)),
|
||||
start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.append('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
final class BossNotificationRouter {
|
||||
static final String CHANNEL_ID = "boss_master_agent_messages";
|
||||
static final int MASTER_AGENT_NOTIFICATION_ID = 2001;
|
||||
|
||||
private final Context appContext;
|
||||
private final BossAppVisibilityTracker visibilityTracker;
|
||||
private @Nullable String lastNotifiedMessageId;
|
||||
|
||||
BossNotificationRouter(Context context, BossAppVisibilityTracker visibilityTracker) {
|
||||
this.appContext = context.getApplicationContext();
|
||||
this.visibilityTracker = visibilityTracker;
|
||||
}
|
||||
|
||||
boolean maybeNotifyForRealtimeEvent(@Nullable BossRealtimeEvent event) {
|
||||
NotificationCandidate candidate = latestMasterAgentMessage(event);
|
||||
if (candidate == null) {
|
||||
return false;
|
||||
}
|
||||
if (candidate.messageId.isEmpty() || TextUtils.equals(candidate.messageId, lastNotifiedMessageId)) {
|
||||
return false;
|
||||
}
|
||||
if (visibilityTracker.isAppInForeground()) {
|
||||
return false;
|
||||
}
|
||||
if (!canPostNotifications()) {
|
||||
return false;
|
||||
}
|
||||
ensureChannel();
|
||||
try {
|
||||
NotificationManagerCompat.from(appContext).notify(
|
||||
MASTER_AGENT_NOTIFICATION_ID,
|
||||
new NotificationCompat.Builder(appContext, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(candidate.title)
|
||||
.setContentText(candidate.body)
|
||||
.setStyle(new NotificationCompat.BigTextStyle().bigText(
|
||||
candidate.body
|
||||
))
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(buildContentIntent(candidate))
|
||||
.build()
|
||||
);
|
||||
} catch (SecurityException ignored) {
|
||||
return false;
|
||||
}
|
||||
lastNotifiedMessageId = candidate.messageId;
|
||||
return true;
|
||||
}
|
||||
|
||||
void resetLastNotifiedMessageId() {
|
||||
lastNotifiedMessageId = null;
|
||||
}
|
||||
|
||||
void clearMasterAgentNotification() {
|
||||
NotificationManagerCompat.from(appContext).cancel(MASTER_AGENT_NOTIFICATION_ID);
|
||||
}
|
||||
|
||||
private boolean canPostNotifications() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||
&& ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
return false;
|
||||
}
|
||||
return NotificationManagerCompat.from(appContext).areNotificationsEnabled();
|
||||
}
|
||||
|
||||
private @Nullable NotificationCandidate latestMasterAgentMessage(@Nullable BossRealtimeEvent event) {
|
||||
if (event == null || !"project.messages.updated".equals(event.eventName)) {
|
||||
return null;
|
||||
}
|
||||
String projectId = event.payload.optString("projectId", "").trim();
|
||||
JSONObject projectMessagesPayload = event.payload.optJSONObject("projectMessagesPayload");
|
||||
JSONObject project = projectMessagesPayload == null ? null : projectMessagesPayload.optJSONObject("project");
|
||||
JSONArray messages = project == null ? null : project.optJSONArray("messages");
|
||||
if (messages == null || messages.length() <= 0) {
|
||||
return null;
|
||||
}
|
||||
JSONObject latestMessage = messages.optJSONObject(messages.length() - 1);
|
||||
if (latestMessage == null) {
|
||||
return null;
|
||||
}
|
||||
String sender = latestMessage.optString("sender", "");
|
||||
String senderLabel = latestMessage.optString("senderLabel", "");
|
||||
if (!"master".equals(sender) && !senderLabel.contains("主 Agent")) {
|
||||
return null;
|
||||
}
|
||||
String messageId = latestMessage.optString("id", "").trim();
|
||||
String projectName = project == null ? "" : project.optString("name", "").trim();
|
||||
String title = "master-agent".equals(projectId) || projectName.isEmpty()
|
||||
? "主 Agent"
|
||||
: "主 Agent · " + projectName;
|
||||
String body = latestMessage.optString("body", "你有一条新的主 Agent 回复");
|
||||
return new NotificationCandidate(projectId, projectName, messageId, title, TextUtils.isEmpty(body) ? "你有一条新的主 Agent 回复" : body);
|
||||
}
|
||||
|
||||
private PendingIntent buildContentIntent(NotificationCandidate candidate) {
|
||||
Intent intent = new Intent(appContext, ProjectDetailActivity.class)
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, candidate.projectId)
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, candidate.projectName)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
return PendingIntent.getActivity(
|
||||
appContext,
|
||||
901 + Math.abs(candidate.projectId.hashCode() % 97),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
||||
);
|
||||
}
|
||||
|
||||
private void ensureChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return;
|
||||
}
|
||||
NotificationManager notificationManager = appContext.getSystemService(NotificationManager.class);
|
||||
if (notificationManager == null || notificationManager.getNotificationChannel(CHANNEL_ID) != null) {
|
||||
return;
|
||||
}
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"主 Agent 消息",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
);
|
||||
channel.setDescription("Boss 主 Agent 后台消息提醒");
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
private static final class NotificationCandidate {
|
||||
final String projectId;
|
||||
final String projectName;
|
||||
final String messageId;
|
||||
final String title;
|
||||
final String body;
|
||||
|
||||
NotificationCandidate(String projectId, String projectName, String messageId, String title, String body) {
|
||||
this.projectId = projectId == null || projectId.trim().isEmpty() ? "master-agent" : projectId.trim();
|
||||
this.projectName = projectName == null || projectName.trim().isEmpty() ? "主 Agent" : projectName.trim();
|
||||
this.messageId = messageId == null ? "" : messageId.trim();
|
||||
this.title = title == null || title.trim().isEmpty() ? "主 Agent" : title.trim();
|
||||
this.body = body == null || body.trim().isEmpty() ? "你有一条新的主 Agent 回复" : body.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Iterator;
|
||||
@@ -91,11 +90,6 @@ final class BossRealtimeClient {
|
||||
if (!running) {
|
||||
return;
|
||||
}
|
||||
if (shouldReconnectImmediately(error)) {
|
||||
Log.i(TAG, "Realtime stream timed out while idle; reconnecting immediately");
|
||||
backoffMs = INITIAL_BACKOFF_MS;
|
||||
continue;
|
||||
}
|
||||
if (shouldAttemptSessionRestore(error)) {
|
||||
try {
|
||||
BossApiClient.ApiResponse restored = apiClient.restoreSession();
|
||||
@@ -136,7 +130,6 @@ final class BossRealtimeClient {
|
||||
connection.setRequestProperty("Accept", "text/event-stream");
|
||||
connection.setRequestProperty("Cache-Control", "no-cache");
|
||||
connection.setRequestProperty("x-boss-native-app", "1");
|
||||
connection.setRequestProperty("x-boss-realtime-capabilities", "message-patch-v1");
|
||||
String cookie = apiClient.getSessionCookie();
|
||||
if (!cookie.isEmpty()) {
|
||||
connection.setRequestProperty("Cookie", cookie);
|
||||
@@ -181,10 +174,6 @@ final class BossRealtimeClient {
|
||||
&& apiClient.hasRestoreToken();
|
||||
}
|
||||
|
||||
static boolean shouldReconnectImmediately(@Nullable Exception error) {
|
||||
return error instanceof SocketTimeoutException;
|
||||
}
|
||||
|
||||
private void dispatchEventBlock(String rawBlock) {
|
||||
BossRealtimeEvent event = parseEventBlock(rawBlock);
|
||||
if (event == null || event.eventName.isEmpty()) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -253,6 +253,12 @@ public class ConversationFolderActivity extends BossScreenActivity {
|
||||
folderDeviceId = folder.optString("deviceId", folderDeviceId == null ? "" : folderDeviceId).trim();
|
||||
int threadCount = folder.optInt("threadCount", 0);
|
||||
configureScreen(resolvedFolderName, threadCount + " 个线程");
|
||||
appendContent(BossUi.buildSoftPanel(
|
||||
this,
|
||||
"项目内部线程页",
|
||||
resolvedFolderName,
|
||||
"点击线程后进入具体聊天窗口。"
|
||||
));
|
||||
|
||||
JSONArray threads = folder.optJSONArray("threads");
|
||||
updateTrackedProjectIds(threads);
|
||||
@@ -263,6 +269,24 @@ public class ConversationFolderActivity extends BossScreenActivity {
|
||||
}
|
||||
|
||||
ArrayList<Integer> targetIndices = resolveTargetThreadIndices(threads);
|
||||
if (!targetIndices.isEmpty()) {
|
||||
String matchedLabel = targetProjectLabel;
|
||||
if ((matchedLabel == null || matchedLabel.isEmpty())) {
|
||||
JSONObject firstTarget = threads.optJSONObject(targetIndices.get(0));
|
||||
if (firstTarget != null) {
|
||||
matchedLabel = firstTarget.optString("threadTitle", "");
|
||||
}
|
||||
}
|
||||
appendContent(BossUi.buildSoftPanel(
|
||||
this,
|
||||
"已定位到目标线程",
|
||||
matchedLabel == null || matchedLabel.isEmpty()
|
||||
? "文件夹页已打开,并将匹配线程置顶显示。"
|
||||
: matchedLabel,
|
||||
targetIndices.size() + " 个匹配项已置顶"
|
||||
));
|
||||
}
|
||||
|
||||
for (int i = 0; i < targetIndices.size(); i++) {
|
||||
renderThreadAtIndex(threads, targetIndices.get(i), true);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import java.util.Map;
|
||||
public class ConversationInfoActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
public static final String EXTRA_TAKEOVER_ENABLED = "takeover_enabled";
|
||||
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
|
||||
|
||||
private String projectId;
|
||||
@@ -77,7 +76,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
try {
|
||||
LoadedConversation loadedConversation = loadConversation();
|
||||
BossApiClient.ApiResponse detailResponse = loadedConversation.detailResponse;
|
||||
BossApiClient.ApiResponse participantsResponse = loadedConversation.participantsResponse;
|
||||
JSONObject participantsPayload = loadedConversation.participantsPayload;
|
||||
JSONObject threadStatusPayload = null;
|
||||
try {
|
||||
BossApiClient.ApiResponse threadStatusResponse = loadedConversation.threadStatusResponse;
|
||||
@@ -88,7 +87,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
threadStatusPayload = null;
|
||||
}
|
||||
JSONObject finalThreadStatusPayload = threadStatusPayload;
|
||||
runOnUiThread(() -> renderConversation(detailResponse.json, participantsResponse.json, finalThreadStatusPayload));
|
||||
runOnUiThread(() -> renderConversation(detailResponse.json, participantsPayload, finalThreadStatusPayload));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
@@ -179,9 +178,17 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
takeoverInheritedFromGlobal = agentControls != null && agentControls.optBoolean("takeoverInheritedFromGlobal", false);
|
||||
configureScreen("会话信息", buildSubtitle(threadMeta, participantCount));
|
||||
|
||||
appendContent(BossUi.buildSimpleProfileHeader(
|
||||
this,
|
||||
projectName,
|
||||
"单线程会话",
|
||||
buildHeaderDetail(project, threadMeta, participantCount)
|
||||
));
|
||||
|
||||
appendThreadStatusSummary(threadStatusPayload);
|
||||
appendTakeoverControl();
|
||||
|
||||
appendConversationInfoItem(BossUi.buildWechatMenuRow(
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"发起群聊",
|
||||
"选择其他线程加入新群",
|
||||
@@ -190,7 +197,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
v -> openGroupCreate()
|
||||
));
|
||||
|
||||
appendConversationInfoItem(BossUi.buildWechatMenuRow(
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"线程详情",
|
||||
"查看当前线程聊天与项目",
|
||||
@@ -199,7 +206,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
v -> openProject(projectId, projectName)
|
||||
));
|
||||
|
||||
appendConversationInfoItem(BossUi.buildWechatMenuRow(
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"线程状态",
|
||||
"状态文档和最近进展事件",
|
||||
@@ -208,7 +215,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
v -> openThreadStatus()
|
||||
));
|
||||
|
||||
appendConversationInfoItem(BossUi.buildWechatMenuRow(
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"参与线程",
|
||||
participantCount <= 0 ? "暂无参与线程" : "共 " + participantCount + " 个",
|
||||
@@ -218,7 +225,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
));
|
||||
|
||||
if (participants == null || participants.length() == 0) {
|
||||
appendConversationInfoItem(BossUi.buildWechatMenuRow(
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"暂无参与线程",
|
||||
"下拉刷新后重试",
|
||||
@@ -230,7 +237,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
for (int i = 0; i < participants.length(); i++) {
|
||||
JSONObject participant = participants.optJSONObject(i);
|
||||
if (participant == null) continue;
|
||||
appendConversationInfoItem(buildParticipantRow(participant));
|
||||
appendContent(buildParticipantRow(participant));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,34 +246,86 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
|
||||
private void appendTakeoverControl() {
|
||||
SwitchCompat takeoverSwitch = new SwitchCompat(this);
|
||||
takeoverSwitch.setShowText(false);
|
||||
takeoverSwitch.setText(null);
|
||||
takeoverSwitch.setText("开启");
|
||||
takeoverSwitch.setChecked(takeoverEnabled);
|
||||
takeoverSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> saveTakeoverSetting(isChecked));
|
||||
appendConversationInfoItem(BossUi.buildWechatSwitchRow(
|
||||
appendContent(BossUi.buildFormCell(
|
||||
this,
|
||||
"主 Agent 协同接管",
|
||||
takeoverInheritedFromGlobal
|
||||
? "跟随全局默认开启"
|
||||
: "为此线程单独开启",
|
||||
? "当前跟随全局默认开启。主 Agent 会协同推进,但不会抢走你直接控制线程开发的能力。"
|
||||
: "为这个线程单独开启主 Agent 协同推进。不会抢走你直接控制线程开发的能力。",
|
||||
takeoverSwitch
|
||||
));
|
||||
}
|
||||
|
||||
private void appendConversationInfoItem(android.view.View view) {
|
||||
android.view.ViewGroup.LayoutParams currentParams = view.getLayoutParams();
|
||||
LinearLayout.LayoutParams params;
|
||||
if (currentParams instanceof LinearLayout.LayoutParams) {
|
||||
params = (LinearLayout.LayoutParams) currentParams;
|
||||
} else {
|
||||
params = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
private void appendThreadStatusSummary(@Nullable JSONObject threadStatusPayload) {
|
||||
if (threadStatusPayload == null) {
|
||||
return;
|
||||
}
|
||||
JSONObject document = threadStatusPayload.optJSONObject("threadStatusDocument");
|
||||
if (document == null) {
|
||||
return;
|
||||
}
|
||||
JSONArray recentProgressEvents = threadStatusPayload.optJSONArray("recentProgressEvents");
|
||||
int eventCount = recentProgressEvents == null ? 0 : recentProgressEvents.length();
|
||||
String body = buildThreadStatusSummaryBody(document, eventCount);
|
||||
String meta = buildThreadStatusSummaryMeta(document, eventCount);
|
||||
appendContent(BossUi.buildCard(this, "线程状态摘要", body, meta));
|
||||
}
|
||||
|
||||
private String buildThreadStatusSummaryBody(JSONObject document, int eventCount) {
|
||||
return joinNonEmptyLines(
|
||||
formatSummaryLine("当前目标", document.optString("projectGoal", "")),
|
||||
formatSummaryLine("当前进度", document.optString("currentProgress", "")),
|
||||
formatSummaryLine("当前阻塞", document.optString("currentBlockers", "")),
|
||||
formatSummaryLine("建议下一步", document.optString("recommendedNextStep", "")),
|
||||
eventCount > 0 ? "最近进展:" + eventCount + " 条" : ""
|
||||
);
|
||||
}
|
||||
params.bottomMargin = BossUi.dp(this, 8);
|
||||
view.setLayoutParams(params);
|
||||
appendContent(view);
|
||||
|
||||
private String buildThreadStatusSummaryMeta(JSONObject document, int eventCount) {
|
||||
return joinNonEmptyParts(
|
||||
projectFolderName,
|
||||
eventCount > 0 ? "最近 " + eventCount + " 条进展" : "暂无进展",
|
||||
document.optString("updatedAt", "").isEmpty() ? "" : "更新于 " + document.optString("updatedAt", "")
|
||||
);
|
||||
}
|
||||
|
||||
private String formatSummaryLine(String label, String value) {
|
||||
String trimmed = value == null ? "" : value.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return label + ":" + trimmed;
|
||||
}
|
||||
|
||||
private String joinNonEmptyLines(String... values) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (String value : values) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append('\n');
|
||||
}
|
||||
builder.append(value.trim());
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String joinNonEmptyParts(String... values) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (String value : values) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(value.trim());
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private LinearLayout buildParticipantRow(JSONObject participant) {
|
||||
@@ -386,10 +445,6 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
Intent result = new Intent();
|
||||
result.putExtra(EXTRA_PROJECT_NAME, projectName);
|
||||
result.putExtra(EXTRA_TAKEOVER_ENABLED, enabled);
|
||||
setResult(RESULT_OK, result);
|
||||
showMessage(enabled ? "已开启主 Agent 协同接管" : "已关闭主 Agent 协同接管");
|
||||
reload();
|
||||
});
|
||||
@@ -416,13 +471,14 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
throw new IllegalStateException(detailResponse.message());
|
||||
}
|
||||
|
||||
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId);
|
||||
if (!participantsResponse.ok()) {
|
||||
throw new IllegalStateException(participantsResponse.message());
|
||||
JSONObject participantsPayload = extractParticipantsPayload(detailResponse.json);
|
||||
BossApiClient.ApiResponse threadStatusResponse = apiClient.getThreadStatus(projectId);
|
||||
return new LoadedConversation(detailResponse, participantsPayload, threadStatusResponse);
|
||||
}
|
||||
|
||||
BossApiClient.ApiResponse threadStatusResponse = apiClient.getThreadStatus(projectId);
|
||||
return new LoadedConversation(detailResponse, participantsResponse, threadStatusResponse);
|
||||
private JSONObject extractParticipantsPayload(JSONObject detailPayload) {
|
||||
JSONObject participantsPayload = detailPayload == null ? null : detailPayload.optJSONObject("participantsPayload");
|
||||
return participantsPayload == null ? new JSONObject() : participantsPayload;
|
||||
}
|
||||
|
||||
private BossApiClient.ApiResponse saveTakeoverSettingsWithRetry(
|
||||
@@ -462,6 +518,25 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
return folder + " · " + suffix;
|
||||
}
|
||||
|
||||
private String buildHeaderDetail(JSONObject project, @Nullable JSONObject threadMeta, int count) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
String threadId = resolveThreadId(project, threadMeta);
|
||||
if (!threadId.isEmpty()) {
|
||||
builder.append(threadId);
|
||||
}
|
||||
if (!projectFolderName.isEmpty()) {
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(projectFolderName);
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(count <= 0 ? "暂无参与线程" : count + " 个参与线程");
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String resolveThreadId(JSONObject project, @Nullable JSONObject threadMeta) {
|
||||
if (threadMeta != null) {
|
||||
String threadId = threadMeta.optString("threadId", "");
|
||||
@@ -474,16 +549,16 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
|
||||
private static final class LoadedConversation {
|
||||
private final BossApiClient.ApiResponse detailResponse;
|
||||
private final BossApiClient.ApiResponse participantsResponse;
|
||||
private final JSONObject participantsPayload;
|
||||
private final BossApiClient.ApiResponse threadStatusResponse;
|
||||
|
||||
private LoadedConversation(
|
||||
BossApiClient.ApiResponse detailResponse,
|
||||
BossApiClient.ApiResponse participantsResponse,
|
||||
JSONObject participantsPayload,
|
||||
BossApiClient.ApiResponse threadStatusResponse
|
||||
) {
|
||||
this.detailResponse = detailResponse;
|
||||
this.participantsResponse = participantsResponse;
|
||||
this.participantsPayload = participantsPayload;
|
||||
this.threadStatusResponse = threadStatusResponse;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,90 +178,6 @@ public class DeviceDetailActivity extends BossScreenActivity {
|
||||
null,
|
||||
null
|
||||
));
|
||||
if (WechatSurfaceMapper.hasCodexAppServerCapability(device)) {
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"Codex App Server",
|
||||
WechatSurfaceMapper.deviceCodexAppServerStatusLabel(device),
|
||||
WechatSurfaceMapper.deviceCodexAppServerDetailLabel(device),
|
||||
null,
|
||||
null
|
||||
));
|
||||
if (WechatSurfaceMapper.hasCodexAppServerMetadata(device)) {
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"模型",
|
||||
WechatSurfaceMapper.deviceCodexModelSummary(device),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"扩展",
|
||||
WechatSurfaceMapper.deviceCodexExtensionSummary(device),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"治理",
|
||||
WechatSurfaceMapper.deviceCodexGovernanceSummary(device),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"账号",
|
||||
WechatSurfaceMapper.deviceCodexAccountSummary(device),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"线程",
|
||||
WechatSurfaceMapper.deviceCodexThreadSummary(device),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"轮次",
|
||||
WechatSurfaceMapper.deviceCodexTurnSummary(device),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"线程操作",
|
||||
WechatSurfaceMapper.deviceCodexThreadActionSummary(device),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"线程协作",
|
||||
WechatSurfaceMapper.deviceCodexThreadCollaborationSummary(device),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"协议漂移",
|
||||
WechatSurfaceMapper.deviceCodexProtocolDriftSummary(device),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"默认执行模式",
|
||||
@@ -270,30 +186,6 @@ public class DeviceDetailActivity extends BossScreenActivity {
|
||||
null,
|
||||
v -> showPreferredExecutionModeDialog(device)
|
||||
));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"Codex 远程控制",
|
||||
"默认走 Codex Computer Use;失效时回退 boss-agent 本机控制",
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"启动远控",
|
||||
"拉起本机 Codex Remote Control 守护进程",
|
||||
"需在线设备",
|
||||
null,
|
||||
v -> showCodexRemoteControlConfirmDialog("start")
|
||||
));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"停止远控",
|
||||
"停止本机 Codex Remote Control 守护进程",
|
||||
"需在线设备",
|
||||
null,
|
||||
v -> showCodexRemoteControlConfirmDialog("stop")
|
||||
));
|
||||
if (primaryPolicy != null) {
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
@@ -391,19 +283,6 @@ public class DeviceDetailActivity extends BossScreenActivity {
|
||||
.show();
|
||||
}
|
||||
|
||||
private void showCodexRemoteControlConfirmDialog(String action) {
|
||||
String normalizedAction = "stop".equals(action) ? "stop" : "start";
|
||||
boolean startAction = "start".equals(normalizedAction);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle(startAction ? "启动 Codex 远控" : "停止 Codex 远控")
|
||||
.setMessage("该操作会由这台电脑的 boss-agent 本机执行,并进入权限审计。")
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton(startAction ? "确认启动" : "确认停止", (dialog, which) ->
|
||||
queueCodexRemoteControl(normalizedAction)
|
||||
)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void openEditDialog() {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
@@ -419,34 +298,6 @@ public class DeviceDetailActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void queueCodexRemoteControl(String action) {
|
||||
if (deviceId == null || deviceId.trim().isEmpty()) {
|
||||
showMessage("缺少设备 ID");
|
||||
return;
|
||||
}
|
||||
boolean startAction = "start".equals(action);
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.queueCodexRemoteControl(
|
||||
deviceId,
|
||||
action,
|
||||
startAction ? "APP 设备详情页确认启动 Codex 远控" : "APP 设备详情页确认停止 Codex 远控"
|
||||
);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage(startAction ? "已提交启动远控" : "已提交停止远控");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage((startAction ? "启动远控失败:" : "停止远控失败:") + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void savePreferredExecutionMode(String preferredExecutionMode) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
|
||||
@@ -11,6 +11,7 @@ import android.widget.TextView;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -70,9 +71,10 @@ public class GroupCreateActivity extends BossScreenActivity {
|
||||
runOnUiThread(() -> renderCreatePage(null, conversationsResponse.json, true));
|
||||
return;
|
||||
}
|
||||
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(sourceProjectId);
|
||||
if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message());
|
||||
runOnUiThread(() -> renderCreatePage(participantsResponse.json, conversationsResponse.json, true));
|
||||
BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(sourceProjectId);
|
||||
if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message());
|
||||
JSONObject participantsPayload = extractParticipantsPayload(detailResponse.json, sourceProjectId);
|
||||
runOnUiThread(() -> renderCreatePage(participantsPayload, conversationsResponse.json, true));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
@@ -155,6 +157,23 @@ public class GroupCreateActivity extends BossScreenActivity {
|
||||
updateCreateButtonState();
|
||||
}
|
||||
|
||||
private JSONObject extractParticipantsPayload(JSONObject detailPayload, String fallbackProjectId) {
|
||||
JSONObject participantsPayload = detailPayload == null ? null : detailPayload.optJSONObject("participantsPayload");
|
||||
if (participantsPayload != null) {
|
||||
return participantsPayload;
|
||||
}
|
||||
JSONObject fallback = new JSONObject();
|
||||
JSONObject project = detailPayload == null ? null : detailPayload.optJSONObject("project");
|
||||
JSONObject threadMeta = project == null ? null : project.optJSONObject("threadMeta");
|
||||
try {
|
||||
fallback.put("projectId", fallbackProjectId == null ? "" : fallbackProjectId);
|
||||
fallback.put("threadMeta", threadMeta == null ? new JSONObject() : threadMeta);
|
||||
fallback.put("participants", new JSONArray());
|
||||
} catch (JSONException ignored) {
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private View buildHeaderView(
|
||||
boolean hasSourceProject,
|
||||
@Nullable String sourceProjectId,
|
||||
|
||||
@@ -22,6 +22,7 @@ public class GroupInfoActivity extends BossScreenActivity {
|
||||
|
||||
private String projectId;
|
||||
private String projectName;
|
||||
private boolean groupRepairJustApplied;
|
||||
private @Nullable BossRealtimeClient realtimeClient;
|
||||
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
|
||||
|
||||
@@ -72,13 +73,12 @@ public class GroupInfoActivity extends BossScreenActivity {
|
||||
try {
|
||||
BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(projectId);
|
||||
if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message());
|
||||
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId);
|
||||
if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message());
|
||||
JSONObject participantsPayload = extractParticipantsPayload(detailResponse.json);
|
||||
BossApiClient.ApiResponse orchestrationResponse = apiClient.getProjectOrchestrationBackend(projectId);
|
||||
JSONObject orchestrationBackend = orchestrationResponse.ok()
|
||||
? orchestrationResponse.json
|
||||
: buildFallbackOrchestrationBackendPayload(orchestrationResponse.message());
|
||||
runOnUiThread(() -> renderGroup(detailResponse.json, participantsResponse.json, orchestrationBackend));
|
||||
runOnUiThread(() -> renderGroup(detailResponse.json, participantsPayload, orchestrationBackend));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
@@ -149,6 +149,11 @@ public class GroupInfoActivity extends BossScreenActivity {
|
||||
}
|
||||
}
|
||||
|
||||
private JSONObject extractParticipantsPayload(JSONObject detailPayload) {
|
||||
JSONObject participantsPayload = detailPayload == null ? null : detailPayload.optJSONObject("participantsPayload");
|
||||
return participantsPayload == null ? new JSONObject() : participantsPayload;
|
||||
}
|
||||
|
||||
private void renderGroup(JSONObject detail, JSONObject participantsPayload) {
|
||||
renderGroup(detail, participantsPayload, null);
|
||||
}
|
||||
@@ -174,6 +179,11 @@ public class GroupInfoActivity extends BossScreenActivity {
|
||||
int invalidParticipantCount = participantsPayload.optInt("invalidParticipantCount", 0);
|
||||
configureScreen("群资料", buildSubtitle(folderName, participantCount));
|
||||
|
||||
if (groupRepairJustApplied) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "群成员已更新,当前群聊已经切换到新的真实线程成员。"));
|
||||
groupRepairJustApplied = false;
|
||||
}
|
||||
|
||||
appendContent(BossUi.buildSimpleProfileHeader(
|
||||
this,
|
||||
projectName,
|
||||
@@ -367,6 +377,7 @@ public class GroupInfoActivity extends BossScreenActivity {
|
||||
BossApiClient.ApiResponse response = apiClient.replaceConversationParticipants(projectId, memberProjectIds);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
groupRepairJustApplied = true;
|
||||
showMessage("群成员已更新");
|
||||
reload();
|
||||
});
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.text.Editable;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewParent;
|
||||
import android.view.animation.AccelerateDecelerateInterpolator;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.FrameLayout;
|
||||
@@ -30,7 +21,6 @@ import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.recyclerview.widget.DiffUtil;
|
||||
@@ -53,18 +43,14 @@ import java.util.function.Supplier;
|
||||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
public static final String EXTRA_INITIAL_TAB = "initial_tab";
|
||||
public static final String EXTRA_FORCE_LOGOUT = "force_logout";
|
||||
private static final int REQUEST_POST_NOTIFICATIONS = 2101;
|
||||
private static final String UI_PREFS = "boss_native_client";
|
||||
private static final String KEY_LAST_ROOT_TAB = "last_root_tab";
|
||||
private static final String KEY_NOTIFICATION_PERMISSION_REQUESTED = "notification_permission_requested";
|
||||
private static final long ROOT_BACK_EXIT_WINDOW_MS = 1_500L;
|
||||
private static final long CONVERSATION_AUTO_REFRESH_MS = 12_000L;
|
||||
private static final long REALTIME_REFRESH_DEBOUNCE_MS = 350L;
|
||||
private static final long REALTIME_REFRESH_THROTTLE_MS = 900L;
|
||||
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
private final ExecutorService sessionExecutor = Executors.newSingleThreadExecutor();
|
||||
private final Handler uiHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
private BossApiClient apiClient;
|
||||
@@ -76,16 +62,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
private View mainTopBar;
|
||||
private TextView loginTitle;
|
||||
private TextView loginHint;
|
||||
private EditText loginAccountInput;
|
||||
private EditText loginPasswordInput;
|
||||
private EditText loginConfirmPasswordInput;
|
||||
private EditText loginCodeInput;
|
||||
private View loginCodeRow;
|
||||
private Button loginSendCodeButton;
|
||||
private Button loginButton;
|
||||
private Button loginModeButton;
|
||||
private Button registerModeButton;
|
||||
private Button forgotModeButton;
|
||||
private ProgressBar loginProgress;
|
||||
|
||||
private ImageButton backButton;
|
||||
@@ -113,7 +90,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
private String activeTab = "conversations";
|
||||
private String preferredEntryTab = "conversations";
|
||||
private @Nullable String requestedInitialTab;
|
||||
private String authMode = "login";
|
||||
private boolean userSelectedTab = false;
|
||||
private long lastRootBackPressedAt = 0L;
|
||||
private @Nullable JSONObject sessionData;
|
||||
@@ -129,11 +105,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
private boolean conversationQuickActionsVisible = false;
|
||||
private boolean conversationAutoRefreshArmed = false;
|
||||
private boolean conversationAutoRefreshEnabled = false;
|
||||
private boolean conversationRootUsesGroupedHomeFeed = false;
|
||||
private boolean rootTabRefreshInFlight = false;
|
||||
private boolean pendingRootTabRefresh = false;
|
||||
private boolean realtimeRefreshScheduled = false;
|
||||
private boolean notificationPermissionRequestScheduled = false;
|
||||
private final java.util.HashMap<String, Long> recentRealtimeEventTimestamps = new java.util.HashMap<>();
|
||||
private final Set<String> selectedConversationProjectIds = new LinkedHashSet<>();
|
||||
private @Nullable RootPagerAdapter rootPagerAdapter;
|
||||
@@ -166,45 +140,18 @@ public class MainActivity extends AppCompatActivity {
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_main);
|
||||
apiClient = createApiClient();
|
||||
realtimeClient = createRealtimeClient(apiClient);
|
||||
apiClient = new BossApiClient(this);
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
bindViews();
|
||||
bindActions();
|
||||
configureBackNavigation();
|
||||
if (isForceLogoutIntent(getIntent())) {
|
||||
forceLogoutToLoginPanel();
|
||||
return;
|
||||
}
|
||||
applyInitialTab(getIntent());
|
||||
bootstrapSession();
|
||||
}
|
||||
|
||||
BossApiClient createApiClient() {
|
||||
return new BossApiClient(this);
|
||||
}
|
||||
|
||||
BossRealtimeClient createRealtimeClient(BossApiClient client) {
|
||||
return new BossRealtimeClient(client, new BossRealtimeClient.Listener() {
|
||||
@Override
|
||||
public void onRealtimeEvent(BossRealtimeEvent event) {
|
||||
handleRealtimeEvent(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRealtimeConnectionChanged(boolean connected) {
|
||||
runOnUiThread(() -> handleRealtimeConnectionChanged(connected));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
if (isForceLogoutIntent(intent)) {
|
||||
forceLogoutToLoginPanel();
|
||||
return;
|
||||
}
|
||||
applyInitialTab(intent);
|
||||
if (contentPanel.getVisibility() == View.VISIBLE) {
|
||||
maybeApplyPreferredEntry();
|
||||
@@ -212,57 +159,32 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isForceLogoutIntent(@Nullable Intent intent) {
|
||||
return intent != null && intent.getBooleanExtra(EXTRA_FORCE_LOGOUT, false);
|
||||
}
|
||||
|
||||
private void forceLogoutToLoginPanel() {
|
||||
apiClient.clearLocalAuthState();
|
||||
sessionData = null;
|
||||
conversationsData = null;
|
||||
devicesData = null;
|
||||
otaData = null;
|
||||
showLogin("已退出登录。点击登录可重新进入系统。");
|
||||
}
|
||||
|
||||
private void configureBackNavigation() {
|
||||
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
|
||||
@Override
|
||||
public void handleOnBackPressed() {
|
||||
if (handleRootBackPressed()) {
|
||||
return;
|
||||
}
|
||||
setEnabled(false);
|
||||
getOnBackPressedDispatcher().onBackPressed();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private boolean handleRootBackPressed() {
|
||||
public void onBackPressed() {
|
||||
if (contentPanel.getVisibility() == View.VISIBLE && conversationSearchMode) {
|
||||
exitConversationSearchMode(true);
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
if (contentPanel.getVisibility() == View.VISIBLE && conversationQuickActionsVisible) {
|
||||
hideConversationQuickActions(true);
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
if (contentPanel.getVisibility() == View.VISIBLE && !"conversations".equals(activeTab)) {
|
||||
setActiveTab("conversations", false);
|
||||
persistLastRootTab("conversations");
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
if (contentPanel.getVisibility() == View.VISIBLE) {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastRootBackPressedAt < ROOT_BACK_EXIT_WINDOW_MS) {
|
||||
moveTaskToBack(true);
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
lastRootBackPressedAt = now;
|
||||
showMessage("再按一次返回,应用进入后台");
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
return false;
|
||||
super.onBackPressed();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -270,7 +192,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
cancelConversationAutoRefresh();
|
||||
cancelRealtimeRefreshSchedule();
|
||||
stopRealtimeUpdates();
|
||||
sessionExecutor.shutdownNow();
|
||||
executor.shutdownNow();
|
||||
super.onDestroy();
|
||||
}
|
||||
@@ -281,17 +202,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
conversationAutoRefreshEnabled = true;
|
||||
updateConversationAutoRefresh();
|
||||
updateRealtimeSubscription();
|
||||
maybeRequestNotificationPermission();
|
||||
if (
|
||||
contentPanel != null &&
|
||||
contentPanel.getVisibility() == View.VISIBLE &&
|
||||
"conversations".equals(activeTab) &&
|
||||
apiClient != null &&
|
||||
apiClient.hasSessionHints() &&
|
||||
!rootTabRefreshInFlight
|
||||
) {
|
||||
refreshConversationsData();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -310,16 +220,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
mainTopBar = findViewById(R.id.main_top_bar);
|
||||
loginTitle = findViewById(R.id.login_title);
|
||||
loginHint = findViewById(R.id.login_hint);
|
||||
loginAccountInput = findViewById(R.id.login_account_input);
|
||||
loginPasswordInput = findViewById(R.id.login_password_input);
|
||||
loginConfirmPasswordInput = findViewById(R.id.login_confirm_password_input);
|
||||
loginCodeInput = findViewById(R.id.login_code_input);
|
||||
loginCodeRow = findViewById(R.id.login_code_row);
|
||||
loginSendCodeButton = findViewById(R.id.login_send_code_button);
|
||||
loginButton = findViewById(R.id.login_button);
|
||||
loginModeButton = findViewById(R.id.login_mode_button);
|
||||
registerModeButton = findViewById(R.id.register_mode_button);
|
||||
forgotModeButton = findViewById(R.id.forgot_mode_button);
|
||||
loginProgress = findViewById(R.id.login_progress);
|
||||
backButton = findViewById(R.id.back_button);
|
||||
topTitle = findViewById(R.id.top_title);
|
||||
@@ -346,17 +247,12 @@ public class MainActivity extends AppCompatActivity {
|
||||
loginTitle.setText(WechatSurfaceMapper.loginTitle());
|
||||
loginHint.setText(WechatSurfaceMapper.loginHintText());
|
||||
loginButton.setText(WechatSurfaceMapper.loginButtonLabel());
|
||||
setAuthMode("login", WechatSurfaceMapper.loginHintText());
|
||||
BossWindowInsets.applyStatusBarInset(loginShell);
|
||||
BossWindowInsets.applyStatusBarInset(mainTopBar);
|
||||
}
|
||||
|
||||
private void bindActions() {
|
||||
loginButton.setOnClickListener(v -> performPrimaryAuthAction());
|
||||
loginSendCodeButton.setOnClickListener(v -> sendAuthVerificationCode());
|
||||
loginModeButton.setOnClickListener(v -> setAuthMode("login", "请输入账号和密码登录。"));
|
||||
registerModeButton.setOnClickListener(v -> setAuthMode("register", "注册后会自动登录并进入会话。"));
|
||||
forgotModeButton.setOnClickListener(v -> setAuthMode("forgot", "通过验证码重置密码后再登录。"));
|
||||
loginButton.setOnClickListener(v -> performAutoLogin());
|
||||
backButton.setVisibility(View.GONE);
|
||||
backButton.setOnClickListener(v -> {
|
||||
if (conversationSearchMode) {
|
||||
@@ -440,7 +336,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
setLoginLoading(true, "正在恢复上次登录状态...");
|
||||
sessionExecutor.execute(() -> {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse sessionResponse = apiClient.getSession();
|
||||
if (!sessionResponse.ok()) {
|
||||
@@ -457,52 +353,15 @@ public class MainActivity extends AppCompatActivity {
|
||||
} catch (Exception ignored) {
|
||||
// Fall back to login panel.
|
||||
}
|
||||
runOnUiThread(() -> setLoginLoading(false, "登录已过期,请重新输入账号密码。"));
|
||||
runOnUiThread(() -> setLoginLoading(false, WechatSurfaceMapper.loginHintText()));
|
||||
});
|
||||
}
|
||||
|
||||
private void performPrimaryAuthAction() {
|
||||
String account = inputText(loginAccountInput);
|
||||
String password = inputText(loginPasswordInput);
|
||||
String confirmPassword = inputText(loginConfirmPasswordInput);
|
||||
String code = inputText(loginCodeInput);
|
||||
if (account.isEmpty()) {
|
||||
setLoginLoading(false, "请先填写账号。");
|
||||
return;
|
||||
}
|
||||
if (password.isEmpty()) {
|
||||
setLoginLoading(false, "请先填写密码。");
|
||||
return;
|
||||
}
|
||||
if (!"login".equals(authMode) && confirmPassword.isEmpty()) {
|
||||
setLoginLoading(false, "请再次确认密码。");
|
||||
return;
|
||||
}
|
||||
if (!"login".equals(authMode) && !password.equals(confirmPassword)) {
|
||||
setLoginLoading(false, "两次输入的密码不一致。");
|
||||
return;
|
||||
}
|
||||
if (!"login".equals(authMode) && code.isEmpty()) {
|
||||
setLoginLoading(false, "请先填写验证码。");
|
||||
return;
|
||||
}
|
||||
|
||||
if ("register".equals(authMode)) {
|
||||
performRegisterAndLogin(account, password, confirmPassword, code);
|
||||
return;
|
||||
}
|
||||
if ("forgot".equals(authMode)) {
|
||||
performPasswordReset(account, password, confirmPassword, code);
|
||||
return;
|
||||
}
|
||||
performPasswordLogin(account, password);
|
||||
}
|
||||
|
||||
private void performPasswordLogin(String account, String password) {
|
||||
setLoginLoading(true, "正在登录...");
|
||||
sessionExecutor.execute(() -> {
|
||||
private void performAutoLogin() {
|
||||
setLoginLoading(true, "正在创建会话...");
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.loginWithPassword(account, password);
|
||||
BossApiClient.ApiResponse response = apiClient.autoLogin();
|
||||
if (response.ok()) {
|
||||
JSONObject session = response.json.optJSONObject("session");
|
||||
runOnUiThread(() -> {
|
||||
@@ -518,78 +377,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void performRegisterAndLogin(String account, String password, String confirmPassword, String code) {
|
||||
setLoginLoading(true, "正在注册...");
|
||||
sessionExecutor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse registerResponse = apiClient.registerAccount(
|
||||
account,
|
||||
password,
|
||||
confirmPassword,
|
||||
code
|
||||
);
|
||||
if (!registerResponse.ok()) {
|
||||
runOnUiThread(() -> setLoginLoading(false, "注册失败:" + registerResponse.message()));
|
||||
return;
|
||||
}
|
||||
BossApiClient.ApiResponse loginResponse = apiClient.loginWithPassword(account, password);
|
||||
if (loginResponse.ok()) {
|
||||
JSONObject session = loginResponse.json.optJSONObject("session");
|
||||
runOnUiThread(() -> {
|
||||
showContent();
|
||||
refreshAllData(session);
|
||||
});
|
||||
return;
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
setAuthMode("login", "注册成功,请用刚才的账号密码登录。");
|
||||
setLoginLoading(false, "注册成功,请用刚才的账号密码登录。");
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> setLoginLoading(false, "注册链路异常:" + error.getMessage()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void performPasswordReset(String account, String password, String confirmPassword, String code) {
|
||||
setLoginLoading(true, "正在重置密码...");
|
||||
sessionExecutor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.resetPassword(account, password, confirmPassword, code);
|
||||
if (response.ok()) {
|
||||
runOnUiThread(() -> {
|
||||
clearSecretInputs();
|
||||
setAuthMode("login", "密码已重置,请使用新密码登录。");
|
||||
});
|
||||
return;
|
||||
}
|
||||
runOnUiThread(() -> setLoginLoading(false, "重置失败:" + response.message()));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> setLoginLoading(false, "重置链路异常:" + error.getMessage()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void sendAuthVerificationCode() {
|
||||
String account = inputText(loginAccountInput);
|
||||
if (account.isEmpty()) {
|
||||
setLoginLoading(false, "请先填写账号。");
|
||||
return;
|
||||
}
|
||||
String purpose = "forgot".equals(authMode) ? "forgot-password" : "register";
|
||||
setLoginLoading(true, "正在发送验证码...");
|
||||
sessionExecutor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.sendVerificationCode(account, purpose);
|
||||
runOnUiThread(() -> setLoginLoading(false, response.ok()
|
||||
? "验证码已发送,请查看对应邮箱或短信。"
|
||||
: "验证码发送失败:" + response.message()));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> setLoginLoading(false, "验证码链路异常:" + error.getMessage()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void refreshCurrentTab() {
|
||||
if (rootTabRefreshInFlight) {
|
||||
pendingRootTabRefresh = true;
|
||||
@@ -614,11 +401,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
JSONObject session = ensureActiveSession();
|
||||
BossApiClient.ApiResponse conversations = null;
|
||||
boolean conversationsOk = false;
|
||||
boolean usedGroupedHomeFeed = false;
|
||||
try {
|
||||
conversations = apiClient.getConversationHome();
|
||||
conversationsOk = conversations.ok();
|
||||
usedGroupedHomeFeed = conversationsOk;
|
||||
} catch (Exception ignored) {
|
||||
conversationsOk = false;
|
||||
}
|
||||
@@ -628,7 +413,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
if (fallbackConversations.ok()) {
|
||||
conversations = fallbackConversations;
|
||||
conversationsOk = true;
|
||||
usedGroupedHomeFeed = false;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
conversationsOk = false;
|
||||
@@ -637,13 +421,10 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
BossApiClient.ApiResponse finalConversations = conversations;
|
||||
final boolean finalConversationsOk = conversationsOk;
|
||||
final boolean finalUsedGroupedHomeFeed = usedGroupedHomeFeed;
|
||||
runOnUiThread(() -> {
|
||||
sessionData = session;
|
||||
JSONArray refreshedConversations = finalConversations == null
|
||||
? null
|
||||
: finalUsedGroupedHomeFeed
|
||||
? finalConversations.json.optJSONArray("conversations")
|
||||
: WechatSurfaceMapper.normalizeConversationHomeFeed(
|
||||
finalConversations.json.optJSONArray("conversations")
|
||||
);
|
||||
@@ -652,9 +433,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
refreshedConversations,
|
||||
finalConversationsOk
|
||||
);
|
||||
if (finalConversationsOk) {
|
||||
conversationRootUsesGroupedHomeFeed = finalUsedGroupedHomeFeed;
|
||||
}
|
||||
maybeApplyPreferredEntry();
|
||||
renderCurrentTab();
|
||||
startRefreshing(false);
|
||||
@@ -822,11 +600,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
return false;
|
||||
}
|
||||
JSONObject conversationItem = event.payload.optJSONObject("conversationItem");
|
||||
JSONObject threadConversationItem = event.payload.optJSONObject("threadConversationItem");
|
||||
JSONObject patchItem = conversationRootUsesGroupedHomeFeed
|
||||
? (conversationItem != null ? conversationItem : threadConversationItem)
|
||||
: (threadConversationItem != null ? threadConversationItem : conversationItem);
|
||||
if (patchItem == null) {
|
||||
if (conversationItem == null) {
|
||||
return false;
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
@@ -836,7 +610,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
conversationsData = WechatSurfaceMapper.mergeConversationHomeItem(
|
||||
conversationsData,
|
||||
patchItem,
|
||||
conversationItem,
|
||||
affectedProjectId
|
||||
);
|
||||
renderCurrentTab();
|
||||
@@ -879,9 +653,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private boolean shouldRefreshConversationsTab(BossRealtimeEvent event) {
|
||||
if ("conversation.context_indicator.updated".equals(event.eventName)) {
|
||||
return hasProjectId(event)
|
||||
|| hasDeviceId(event)
|
||||
|| event.payload.optJSONArray("conversations") != null;
|
||||
return false;
|
||||
}
|
||||
if ("conversation.updated".equals(event.eventName)) {
|
||||
return hasProjectId(event) || hasDeviceId(event);
|
||||
@@ -946,7 +718,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
BossApiClient.ApiResponse ota = null;
|
||||
BossApiClient.ApiResponse settings = null;
|
||||
boolean conversationsOk = false;
|
||||
boolean usedGroupedHomeFeed = false;
|
||||
boolean devicesOk = false;
|
||||
boolean otaOk = false;
|
||||
boolean settingsOk = false;
|
||||
@@ -954,7 +725,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
try {
|
||||
conversations = apiClient.getConversationHome();
|
||||
conversationsOk = conversations.ok();
|
||||
usedGroupedHomeFeed = conversationsOk;
|
||||
} catch (Exception ignored) {
|
||||
conversationsOk = false;
|
||||
}
|
||||
@@ -964,7 +734,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
if (fallbackConversations.ok()) {
|
||||
conversations = fallbackConversations;
|
||||
conversationsOk = true;
|
||||
usedGroupedHomeFeed = false;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
conversationsOk = false;
|
||||
@@ -995,7 +764,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
BossApiClient.ApiResponse finalOta = ota;
|
||||
BossApiClient.ApiResponse finalSettings = settings;
|
||||
final boolean finalConversationsOk = conversationsOk;
|
||||
final boolean finalUsedGroupedHomeFeed = usedGroupedHomeFeed;
|
||||
final boolean finalDevicesOk = devicesOk;
|
||||
final boolean finalOtaOk = otaOk;
|
||||
final boolean finalSettingsOk = settingsOk;
|
||||
@@ -1003,8 +771,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
sessionData = finalSession;
|
||||
JSONArray refreshedConversations = finalConversations == null
|
||||
? null
|
||||
: finalUsedGroupedHomeFeed
|
||||
? finalConversations.json.optJSONArray("conversations")
|
||||
: WechatSurfaceMapper.normalizeConversationHomeFeed(
|
||||
finalConversations.json.optJSONArray("conversations")
|
||||
);
|
||||
@@ -1013,9 +779,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
refreshedConversations,
|
||||
finalConversationsOk
|
||||
);
|
||||
if (finalConversationsOk) {
|
||||
conversationRootUsesGroupedHomeFeed = finalUsedGroupedHomeFeed;
|
||||
}
|
||||
devicesData = WechatSurfaceMapper.resolveRefreshValue(
|
||||
devicesData,
|
||||
finalDevices == null ? null : finalDevices.json.optJSONArray("devices"),
|
||||
@@ -1091,7 +854,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
private void showLogin(String hint) {
|
||||
loginPanel.setVisibility(View.VISIBLE);
|
||||
contentPanel.setVisibility(View.GONE);
|
||||
setAuthMode("login", hint);
|
||||
setLoginLoading(false, hint);
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
@@ -1102,76 +864,15 @@ public class MainActivity extends AppCompatActivity {
|
||||
setActiveTab(activeTab, false);
|
||||
updateConversationAutoRefresh();
|
||||
updateRealtimeSubscription();
|
||||
scheduleNotificationPermissionRequest();
|
||||
}
|
||||
|
||||
private void setLoginLoading(boolean loading, String hint) {
|
||||
loginProgress.setVisibility(loading ? View.VISIBLE : View.GONE);
|
||||
loginButton.setEnabled(!loading);
|
||||
loginSendCodeButton.setEnabled(!loading);
|
||||
loginModeButton.setEnabled(!loading);
|
||||
registerModeButton.setEnabled(!loading);
|
||||
forgotModeButton.setEnabled(!loading);
|
||||
loginButton.setText(loading ? "处理中..." : primaryAuthButtonLabel());
|
||||
loginButton.setText(loading ? "处理中..." : WechatSurfaceMapper.loginButtonLabel());
|
||||
loginHint.setText(hint);
|
||||
}
|
||||
|
||||
private void setAuthMode(String mode, String hint) {
|
||||
authMode = ("register".equals(mode) || "forgot".equals(mode)) ? mode : "login";
|
||||
boolean codeMode = !"login".equals(authMode);
|
||||
loginTitle.setText(authTitle());
|
||||
loginButton.setText(primaryAuthButtonLabel());
|
||||
loginPasswordInput.setHint("forgot".equals(authMode) ? "新密码" : "密码");
|
||||
loginConfirmPasswordInput.setVisibility(codeMode ? View.VISIBLE : View.GONE);
|
||||
loginCodeRow.setVisibility(codeMode ? View.VISIBLE : View.GONE);
|
||||
loginHint.setText(hint);
|
||||
tintAuthModeButtons();
|
||||
}
|
||||
|
||||
private String authTitle() {
|
||||
if ("register".equals(authMode)) {
|
||||
return "注册账号";
|
||||
}
|
||||
if ("forgot".equals(authMode)) {
|
||||
return "找回密码";
|
||||
}
|
||||
return "登录 Boss";
|
||||
}
|
||||
|
||||
private String primaryAuthButtonLabel() {
|
||||
if ("register".equals(authMode)) {
|
||||
return "注册并登录";
|
||||
}
|
||||
if ("forgot".equals(authMode)) {
|
||||
return "重置密码";
|
||||
}
|
||||
return "登录";
|
||||
}
|
||||
|
||||
private void tintAuthModeButtons() {
|
||||
int selectedColor = getColor(R.color.boss_green);
|
||||
int mutedColor = getColor(R.color.boss_text_muted);
|
||||
loginModeButton.setTextColor("login".equals(authMode) ? selectedColor : mutedColor);
|
||||
registerModeButton.setTextColor("register".equals(authMode) ? selectedColor : mutedColor);
|
||||
forgotModeButton.setTextColor("forgot".equals(authMode) ? selectedColor : mutedColor);
|
||||
}
|
||||
|
||||
private String inputText(EditText input) {
|
||||
return input == null || input.getText() == null ? "" : input.getText().toString().trim();
|
||||
}
|
||||
|
||||
private void clearSecretInputs() {
|
||||
if (loginPasswordInput != null) {
|
||||
loginPasswordInput.setText("");
|
||||
}
|
||||
if (loginConfirmPasswordInput != null) {
|
||||
loginConfirmPasswordInput.setText("");
|
||||
}
|
||||
if (loginCodeInput != null) {
|
||||
loginCodeInput.setText("");
|
||||
}
|
||||
}
|
||||
|
||||
private void setActiveTab(String tab, boolean fromUser) {
|
||||
if (!"conversations".equals(tab)) {
|
||||
exitConversationSelectionMode();
|
||||
@@ -1230,27 +931,14 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
private void updateTabStyles() {
|
||||
styleTab(tabConversations, "conversations".equals(activeTab), R.drawable.ic_boss_tab_chat);
|
||||
styleTab(tabDevices, "devices".equals(activeTab), R.drawable.ic_boss_tab_devices);
|
||||
styleTab(tabMe, "me".equals(activeTab), R.drawable.ic_boss_tab_me);
|
||||
styleTab(tabConversations, "conversations".equals(activeTab));
|
||||
styleTab(tabDevices, "devices".equals(activeTab));
|
||||
styleTab(tabMe, "me".equals(activeTab));
|
||||
}
|
||||
|
||||
private void styleTab(Button button, boolean active, int iconRes) {
|
||||
int color = getColor(active ? R.color.boss_green : R.color.boss_text_muted);
|
||||
button.setBackgroundColor(Color.TRANSPARENT);
|
||||
button.setTextColor(color);
|
||||
button.setTextSize(10);
|
||||
button.setAllCaps(false);
|
||||
button.setGravity(android.view.Gravity.CENTER);
|
||||
Drawable topIcon = getDrawable(iconRes);
|
||||
if (topIcon != null) {
|
||||
int iconSize = BossUi.dp(this, 18);
|
||||
topIcon.setBounds(0, 0, iconSize, iconSize);
|
||||
}
|
||||
button.setCompoundDrawables(null, topIcon, null, null);
|
||||
button.setCompoundDrawablePadding(BossUi.dp(this, 2));
|
||||
button.setCompoundDrawableTintList(ColorStateList.valueOf(color));
|
||||
button.setPadding(0, BossUi.dp(this, 3), 0, BossUi.dp(this, 1));
|
||||
private void styleTab(Button button, boolean active) {
|
||||
button.setBackgroundResource(active ? R.drawable.bg_tab_active : R.drawable.bg_tab_inactive);
|
||||
button.setTextColor(getColor(active ? R.color.boss_green : R.color.boss_text_muted));
|
||||
}
|
||||
|
||||
private void configureTopAction(WechatSurfaceMapper.RootTopAction action) {
|
||||
@@ -1271,7 +959,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
topSearchInput.setVisibility(View.GONE);
|
||||
backButton.setVisibility(View.GONE);
|
||||
searchButton.setVisibility(View.GONE);
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, false, false, currentSessionRole());
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, false);
|
||||
refreshButton.setVisibility(View.VISIBLE);
|
||||
configureTopAction(action);
|
||||
}
|
||||
@@ -1306,7 +994,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
refreshButton.setEnabled(true);
|
||||
return;
|
||||
}
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing, conversationSelectionMode, currentSessionRole());
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing, conversationSelectionMode);
|
||||
configureTopAction(action);
|
||||
refreshButton.setEnabled(!"refresh".equals(action.actionKey) || !refreshing);
|
||||
refreshButton.setAlpha(refreshing && "refresh".equals(action.actionKey) ? 0.45f : 1f);
|
||||
@@ -1337,7 +1025,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
toggleConversationQuickActions();
|
||||
return;
|
||||
}
|
||||
String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode, currentSessionRole()).actionKey;
|
||||
String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode).actionKey;
|
||||
if ("add_device".equals(actionKey)) {
|
||||
startActivity(new Intent(this, DeviceEnrollmentActivity.class));
|
||||
return;
|
||||
@@ -1478,24 +1166,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
showMessage("缺少 folderKey");
|
||||
return;
|
||||
}
|
||||
if (conversationSearchMode) {
|
||||
String matchedProjectId = item.optString("searchMatchProjectId", "").trim();
|
||||
String matchedProjectLabel = item.optString("searchMatchLabel", "").trim();
|
||||
if (!matchedProjectId.isEmpty() && !matchedProjectLabel.isEmpty()) {
|
||||
openProject(matchedProjectId, matchedProjectLabel);
|
||||
exitConversationSearchMode(true);
|
||||
return;
|
||||
}
|
||||
openConversationFolder(
|
||||
folderKey,
|
||||
resolveConversationFolderName(item, row),
|
||||
item.optString("searchMatchProjectId", ""),
|
||||
item.optJSONArray("searchMatchProjectIds"),
|
||||
item.optString("searchMatchLabel", "")
|
||||
);
|
||||
exitConversationSearchMode(true);
|
||||
return;
|
||||
}
|
||||
openConversationFolder(
|
||||
folderKey,
|
||||
resolveConversationFolderName(item, row),
|
||||
@@ -1510,9 +1180,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
return;
|
||||
}
|
||||
String projectName = finalDisplayRow.threadTitle.isEmpty() ? "未命名会话" : finalDisplayRow.threadTitle;
|
||||
if (conversationSearchMode) {
|
||||
exitConversationSearchMode(true);
|
||||
}
|
||||
openProject(projectId, projectName);
|
||||
})
|
||||
));
|
||||
@@ -1580,14 +1247,14 @@ public class MainActivity extends AppCompatActivity {
|
||||
if (count > 0) {
|
||||
TextView selectedView = new TextView(this);
|
||||
selectedView.setText("已选 " + count + " 个线程");
|
||||
selectedView.setTextSize(12);
|
||||
selectedView.setTextSize(13);
|
||||
selectedView.setTextColor(getColor(R.color.boss_text_primary));
|
||||
summaryWrap.addView(selectedView);
|
||||
}
|
||||
if (count < 2) {
|
||||
TextView hintView = new TextView(this);
|
||||
hintView.setText("至少选择 2 个线程");
|
||||
hintView.setTextSize(11);
|
||||
hintView.setTextSize(12);
|
||||
hintView.setTextColor(getColor(R.color.boss_text_muted));
|
||||
if (count > 0) {
|
||||
hintView.setPadding(0, BossUi.dp(this, 4), 0, 0);
|
||||
@@ -1625,7 +1292,10 @@ public class MainActivity extends AppCompatActivity {
|
||||
hideConversationQuickActions(false);
|
||||
conversationSearchMode = true;
|
||||
syncTopActionVisualState(screenRefresh.isRefreshing());
|
||||
showConversationSearchKeyboard();
|
||||
topSearchInput.post(() -> {
|
||||
topSearchInput.requestFocus();
|
||||
topSearchInput.setSelection(topSearchInput.getText().length());
|
||||
});
|
||||
}
|
||||
|
||||
private void exitConversationSearchMode(boolean clearQuery) {
|
||||
@@ -1638,40 +1308,12 @@ public class MainActivity extends AppCompatActivity {
|
||||
conversationSearchQuery = "";
|
||||
topSearchInput.setText("");
|
||||
}
|
||||
hideConversationSearchKeyboard();
|
||||
syncTopActionVisualState(screenRefresh != null && screenRefresh.isRefreshing());
|
||||
if (queryChanged && "conversations".equals(activeTab) && contentPanel.getVisibility() == View.VISIBLE) {
|
||||
renderConversationsRoot();
|
||||
}
|
||||
}
|
||||
|
||||
private void showConversationSearchKeyboard() {
|
||||
if (topSearchInput == null) {
|
||||
return;
|
||||
}
|
||||
topSearchInput.post(() -> {
|
||||
topSearchInput.requestFocus();
|
||||
topSearchInput.setSelection(topSearchInput.getText().length());
|
||||
InputMethodManager inputMethodManager =
|
||||
(InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
if (inputMethodManager != null) {
|
||||
inputMethodManager.showSoftInput(topSearchInput, InputMethodManager.SHOW_IMPLICIT);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void hideConversationSearchKeyboard() {
|
||||
if (topSearchInput == null) {
|
||||
return;
|
||||
}
|
||||
InputMethodManager inputMethodManager =
|
||||
(InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
if (inputMethodManager != null) {
|
||||
inputMethodManager.hideSoftInputFromWindow(topSearchInput.getWindowToken(), 0);
|
||||
}
|
||||
topSearchInput.clearFocus();
|
||||
}
|
||||
|
||||
private void toggleConversationSelection(String projectId) {
|
||||
if (selectedConversationProjectIds.contains(projectId)) {
|
||||
selectedConversationProjectIds.remove(projectId);
|
||||
@@ -1852,7 +1494,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
private void prepareConversationQuickActionMenu() {
|
||||
quickActionAddDevice.setVisibility("highest_admin".equals(currentSessionRole()) ? View.VISIBLE : View.GONE);
|
||||
conversationQuickActionsMenu.setVisibility(View.VISIBLE);
|
||||
conversationQuickActionsMenu.setAlpha(0f);
|
||||
conversationQuickActionsMenu.setTranslationY(-BossUi.dp(this, 6));
|
||||
@@ -1863,7 +1504,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
conversationQuickActionsMenu.setAlpha(0f);
|
||||
conversationQuickActionsMenu.setTranslationY(-BossUi.dp(this, 6));
|
||||
conversationQuickActionsMenu.setVisibility(View.GONE);
|
||||
quickActionAddDevice.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
static boolean matchesConversationQuery(JSONObject item, String rawQuery) {
|
||||
@@ -2017,7 +1657,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
(roleLabel.isEmpty() ? "主控账号已启用安全保护" : roleLabel + " · 主控账号已启用安全保护")
|
||||
));
|
||||
|
||||
for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItemsForRole(currentSessionRole())) {
|
||||
for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItems()) {
|
||||
screenContent.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
item.title,
|
||||
@@ -2167,66 +1807,33 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
|
||||
private void maybeRequestNotificationPermission() {
|
||||
notificationPermissionRequestScheduled = false;
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
return;
|
||||
}
|
||||
if (contentPanel == null || contentPanel.getVisibility() != View.VISIBLE) {
|
||||
return;
|
||||
}
|
||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
||||
return;
|
||||
}
|
||||
android.content.SharedPreferences prefs = getSharedPreferences(UI_PREFS, Context.MODE_PRIVATE);
|
||||
if (prefs.getBoolean(KEY_NOTIFICATION_PERMISSION_REQUESTED, false)) {
|
||||
return;
|
||||
}
|
||||
prefs.edit().putBoolean(KEY_NOTIFICATION_PERMISSION_REQUESTED, true).apply();
|
||||
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, REQUEST_POST_NOTIFICATIONS);
|
||||
}
|
||||
|
||||
private void scheduleNotificationPermissionRequest() {
|
||||
if (notificationPermissionRequestScheduled) {
|
||||
return;
|
||||
}
|
||||
notificationPermissionRequestScheduled = true;
|
||||
uiHandler.postDelayed(this::maybeRequestNotificationPermission, 450L);
|
||||
}
|
||||
|
||||
void handleRealtimeConnectionChanged(boolean connected) {
|
||||
if (!connected
|
||||
&& shouldMaintainConversationAutoRefresh()
|
||||
&& !rootTabRefreshInFlight
|
||||
&& screenRefresh != null
|
||||
&& !screenRefresh.isRefreshing()) {
|
||||
refreshCurrentTab();
|
||||
}
|
||||
updateConversationAutoRefresh();
|
||||
}
|
||||
|
||||
private void openMeEntry(String key) {
|
||||
if (!WechatSurfaceMapper.canOpenMeEntryForRole(key, currentSessionRole())) {
|
||||
showMessage("当前账号没有权限打开这个入口。");
|
||||
return;
|
||||
}
|
||||
Intent intent;
|
||||
switch (key) {
|
||||
case "security":
|
||||
intent = new Intent(this, SecurityActivity.class);
|
||||
break;
|
||||
case "access":
|
||||
intent = new Intent(this, AccessManagementActivity.class);
|
||||
case "master_agent_prompt":
|
||||
intent = new Intent(this, MasterAgentPromptActivity.class);
|
||||
intent.putExtra(MasterAgentPromptActivity.EXTRA_PROJECT_ID, "master-agent");
|
||||
intent.putExtra(MasterAgentPromptActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
break;
|
||||
case "master_agent_memory":
|
||||
intent = new Intent(this, MasterAgentMemoryActivity.class);
|
||||
intent.putExtra(MasterAgentMemoryActivity.EXTRA_PROJECT_ID, "master-agent");
|
||||
intent.putExtra(MasterAgentMemoryActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
break;
|
||||
case "master_agent_takeover":
|
||||
intent = new Intent(this, MasterAgentTakeoverActivity.class);
|
||||
intent.putExtra(MasterAgentTakeoverActivity.EXTRA_PROJECT_ID, "master-agent");
|
||||
intent.putExtra(MasterAgentTakeoverActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
break;
|
||||
case "master_agent_evolution":
|
||||
intent = new Intent(this, MasterAgentEvolutionActivity.class);
|
||||
break;
|
||||
case "ai_accounts":
|
||||
intent = new Intent(this, AiAccountsActivity.class);
|
||||
break;
|
||||
case "storage":
|
||||
intent = new Intent(this, StorageSettingsActivity.class);
|
||||
break;
|
||||
case "telegram":
|
||||
intent = new Intent(this, TelegramIntegrationActivity.class);
|
||||
break;
|
||||
case "settings":
|
||||
intent = new Intent(this, SettingsActivity.class);
|
||||
break;
|
||||
@@ -2246,13 +1853,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private String currentSessionRole() {
|
||||
if (sessionData == null) {
|
||||
return "member";
|
||||
}
|
||||
return sessionData.optString("role", "member");
|
||||
}
|
||||
|
||||
private void openSkillInventoryFromMe() {
|
||||
String targetDeviceId = resolveSkillTargetDeviceId();
|
||||
if (targetDeviceId == null || targetDeviceId.isEmpty()) {
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class MasterAgentEvolutionActivity extends BossScreenActivity {
|
||||
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
|
||||
|
||||
private LinearLayout contentRoot;
|
||||
private @Nullable BossRealtimeClient realtimeClient;
|
||||
private long lastRealtimeReloadAt;
|
||||
private boolean contentLoaded;
|
||||
private @Nullable String currentMode;
|
||||
private @Nullable String statusMessage;
|
||||
private boolean statusIsError;
|
||||
private boolean canManageEvolution = true;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("主 Agent 自动进化", "信号、提案与生效规则");
|
||||
setHeaderAction("刷新", v -> reload());
|
||||
contentRoot = new LinearLayout(this);
|
||||
contentRoot.setOrientation(LinearLayout.VERTICAL);
|
||||
replaceContent(contentRoot);
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
updateRealtimeSubscription();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
stopRealtimeUpdates();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
stopRealtimeUpdates();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getMasterAgentEvolution();
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
clearStatusMessage();
|
||||
renderDashboard(response.json);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
contentLoaded = false;
|
||||
showStatusMessage("自动进化加载失败:" + error.getMessage(), true);
|
||||
renderLoadErrorState(error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateRealtimeSubscription() {
|
||||
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
|
||||
realtimeClient.start();
|
||||
return;
|
||||
}
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
|
||||
private void stopRealtimeUpdates() {
|
||||
if (realtimeClient != null) {
|
||||
realtimeClient.stop();
|
||||
}
|
||||
}
|
||||
|
||||
void handleRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (event == null || !"master_agent.settings.updated".equals(event.eventName)) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastRealtimeReloadAt < REALTIME_RELOAD_THROTTLE_MS) {
|
||||
return;
|
||||
}
|
||||
lastRealtimeReloadAt = now;
|
||||
runOnUiThread(this::reload);
|
||||
}
|
||||
|
||||
private void renderDashboard(JSONObject payload) {
|
||||
JSONObject config = payload.optJSONObject("config");
|
||||
JSONArray signals = payload.optJSONArray("signals");
|
||||
JSONArray proposals = payload.optJSONArray("proposals");
|
||||
JSONArray rules = payload.optJSONArray("rules");
|
||||
canManageEvolution = payload.optBoolean("canManage", true);
|
||||
|
||||
currentMode = config == null ? "controlled" : config.optString("mode", "controlled");
|
||||
boolean autoApplyLowRiskRules = config != null && config.optBoolean("autoApplyLowRiskRules", false);
|
||||
|
||||
replaceContent(contentRoot);
|
||||
contentRoot.removeAllViews();
|
||||
|
||||
contentRoot.addView(BossUi.buildSimpleProfileHeader(
|
||||
this,
|
||||
"主 Agent 自动进化",
|
||||
"最近在学什么、打算怎么改、已经生效了什么",
|
||||
canManageEvolution
|
||||
? "支持在这里切换 controlled / autonomous,并直接审核待处理提案。"
|
||||
: "当前是只读视角,可以查看主 Agent 正在学习和生效的规则。"
|
||||
));
|
||||
|
||||
contentRoot.addView(BossUi.buildSoftPanel(
|
||||
this,
|
||||
"当前模式",
|
||||
"autonomous".equals(currentMode) ? "完全自我进化" : "受控自动进化",
|
||||
autoApplyLowRiskRules ? "低风险提案会自动采纳。" : "所有提案都需要人工确认。"
|
||||
));
|
||||
maybeRenderStatusBanner();
|
||||
|
||||
if (canManageEvolution) {
|
||||
Button controlledButton = BossUi.buildMiniActionButton(this, "切到受控模式", false);
|
||||
controlledButton.setEnabled(!"controlled".equals(currentMode));
|
||||
controlledButton.setOnClickListener(v -> switchMode("controlled"));
|
||||
Button autonomousButton = BossUi.buildMiniActionButton(this, "切到全自动模式", true);
|
||||
autonomousButton.setEnabled(!"autonomous".equals(currentMode));
|
||||
autonomousButton.setOnClickListener(v -> switchMode("autonomous"));
|
||||
contentRoot.addView(BossUi.buildInlineActionRow(this, controlledButton, autonomousButton));
|
||||
} else {
|
||||
contentRoot.addView(BossUi.buildEmptyCard(this, "你当前没有管理权限,模式切换和提案审批仅管理员可操作。"));
|
||||
}
|
||||
|
||||
contentRoot.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"待处理提案",
|
||||
String.valueOf(countPendingProposals(proposals)) + " 条",
|
||||
"待审核的策略变更会在这里集中展示。",
|
||||
null,
|
||||
null
|
||||
));
|
||||
renderPendingProposals(proposals);
|
||||
|
||||
contentRoot.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"最近信号",
|
||||
signals == null ? "0 条" : signals.length() + " 条",
|
||||
"主 Agent 最近捕获到的问题和自我修正线索。",
|
||||
null,
|
||||
null
|
||||
));
|
||||
renderSignals(signals);
|
||||
|
||||
contentRoot.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"已生效规则",
|
||||
rules == null ? "0 条" : rules.length() + " 条",
|
||||
"已经落进系统并开始影响主 Agent 行为的规则。",
|
||||
null,
|
||||
null
|
||||
));
|
||||
renderRules(rules);
|
||||
|
||||
contentLoaded = true;
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void renderPendingProposals(@Nullable JSONArray proposals) {
|
||||
if (proposals == null || proposals.length() == 0) {
|
||||
contentRoot.addView(BossUi.buildEmptyCard(this, "当前没有待审批提案。"));
|
||||
return;
|
||||
}
|
||||
boolean rendered = false;
|
||||
for (int i = 0; i < proposals.length(); i++) {
|
||||
JSONObject proposal = proposals.optJSONObject(i);
|
||||
if (proposal == null || !"pending_review".equals(proposal.optString("status", ""))) {
|
||||
continue;
|
||||
}
|
||||
rendered = true;
|
||||
String proposalId = proposal.optString("proposalId", "");
|
||||
contentRoot.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
proposal.optString("title", "待审批提案"),
|
||||
proposal.optString("summary", "暂无摘要"),
|
||||
proposal.optString("proposalType", "-")
|
||||
+ " · " + proposal.optString("riskLevel", "-")
|
||||
+ " · " + formatTime(proposal.optString("createdAt", "-")),
|
||||
null,
|
||||
null
|
||||
));
|
||||
if (canManageEvolution) {
|
||||
Button rejectButton = BossUi.buildMiniActionButton(this, "拒绝", false);
|
||||
rejectButton.setOnClickListener(v -> reviewProposal(proposalId, false));
|
||||
Button approveButton = BossUi.buildMiniActionButton(this, "批准", true);
|
||||
approveButton.setOnClickListener(v -> reviewProposal(proposalId, true));
|
||||
contentRoot.addView(BossUi.buildInlineActionRow(this, rejectButton, approveButton));
|
||||
}
|
||||
}
|
||||
if (!rendered) {
|
||||
contentRoot.addView(BossUi.buildEmptyCard(this, "当前没有待审批提案。"));
|
||||
}
|
||||
}
|
||||
|
||||
private void renderSignals(@Nullable JSONArray signals) {
|
||||
if (signals == null || signals.length() == 0) {
|
||||
contentRoot.addView(BossUi.buildEmptyCard(this, "当前还没有进化信号。"));
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < Math.min(signals.length(), 8); i++) {
|
||||
JSONObject signal = signals.optJSONObject(i);
|
||||
if (signal == null) continue;
|
||||
contentRoot.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
signal.optString("kind", "signal"),
|
||||
signal.optString("requestText", ""),
|
||||
formatTime(signal.optString("createdAt", "-")),
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private void renderRules(@Nullable JSONArray rules) {
|
||||
if (rules == null || rules.length() == 0) {
|
||||
contentRoot.addView(BossUi.buildEmptyCard(this, "当前还没有已生效规则。"));
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < Math.min(rules.length(), 8); i++) {
|
||||
JSONObject rule = rules.optJSONObject(i);
|
||||
if (rule == null) continue;
|
||||
contentRoot.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
rule.optString("ruleType", "rule"),
|
||||
rule.optString("sourceProposalId", "直接创建"),
|
||||
formatTime(rule.optString("createdAt", "-")),
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private int countPendingProposals(@Nullable JSONArray proposals) {
|
||||
if (proposals == null) {
|
||||
return 0;
|
||||
}
|
||||
int count = 0;
|
||||
for (int i = 0; i < proposals.length(); i++) {
|
||||
JSONObject proposal = proposals.optJSONObject(i);
|
||||
if (proposal != null && "pending_review".equals(proposal.optString("status", ""))) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private void switchMode(String mode) {
|
||||
if (!contentLoaded) {
|
||||
showMessage("自动进化尚未加载完成。");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.updateMasterAgentEvolutionMode(mode);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showStatusMessage("已切到 " + ("autonomous".equals(mode) ? "完全自我进化" : "受控自动进化"), false);
|
||||
setResult(RESULT_OK);
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showStatusMessage("切换失败:" + error.getMessage(), true);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void reviewProposal(String proposalId, boolean approve) {
|
||||
if (proposalId == null || proposalId.isEmpty()) {
|
||||
showMessage("缺少 proposalId");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = approve
|
||||
? apiClient.approveMasterAgentEvolutionProposal(proposalId)
|
||||
: apiClient.rejectMasterAgentEvolutionProposal(proposalId);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showStatusMessage(approve ? "提案已批准" : "提案已拒绝", false);
|
||||
setResult(RESULT_OK);
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showStatusMessage((approve ? "批准失败:" : "拒绝失败:") + error.getMessage(), true);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void maybeRenderStatusBanner() {
|
||||
if (statusMessage == null || statusMessage.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
contentRoot.addView(BossUi.buildSoftPanel(
|
||||
this,
|
||||
statusIsError ? "最近状态" : "最近操作",
|
||||
statusMessage,
|
||||
statusIsError ? "你可以直接点顶部刷新重试,或继续切换模式/审批提案。" : "本页已经同步到最新自动进化状态。"
|
||||
));
|
||||
}
|
||||
|
||||
private void renderLoadErrorState(String message) {
|
||||
replaceContent(contentRoot);
|
||||
contentRoot.removeAllViews();
|
||||
contentRoot.addView(BossUi.buildSimpleProfileHeader(
|
||||
this,
|
||||
"主 Agent 自动进化",
|
||||
"信号、提案与生效规则",
|
||||
"当前加载失败,保留在这个页面直接重试即可。"
|
||||
));
|
||||
maybeRenderStatusBanner();
|
||||
Button retryButton = BossUi.buildMiniActionButton(this, "重新加载", true);
|
||||
retryButton.setOnClickListener(v -> reload());
|
||||
contentRoot.addView(BossUi.buildInlineActionRow(this, retryButton));
|
||||
contentRoot.addView(BossUi.buildEmptyCard(this, "自动进化中心暂时不可用:" + message));
|
||||
}
|
||||
|
||||
private void showStatusMessage(String message, boolean isError) {
|
||||
statusMessage = message;
|
||||
statusIsError = isError;
|
||||
showMessage(message);
|
||||
}
|
||||
|
||||
private void clearStatusMessage() {
|
||||
if (!statusIsError) {
|
||||
statusMessage = null;
|
||||
}
|
||||
statusIsError = false;
|
||||
}
|
||||
|
||||
private String formatTime(String value) {
|
||||
if (value == null || value.isEmpty() || "-".equals(value)) {
|
||||
return "-";
|
||||
}
|
||||
String normalized = value.replace('T', ' ');
|
||||
int plusIndex = normalized.indexOf('+');
|
||||
if (plusIndex > 0) {
|
||||
return normalized.substring(0, plusIndex);
|
||||
}
|
||||
int zIndex = normalized.indexOf('Z');
|
||||
if (zIndex > 0) {
|
||||
return normalized.substring(0, zIndex);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ public class MasterAgentMemoryActivity extends BossScreenActivity {
|
||||
appendContent(BossUi.buildSoftPanel(
|
||||
this,
|
||||
"记忆说明",
|
||||
"主 Agent 会自动沉淀长期有用的信息。你也可以在这里手动新增、编辑或删除。",
|
||||
"主 Agent 会自动沉淀长期有用的信息。你也可以在这里手动新增、编辑或归档。",
|
||||
"底层是结构化存储,项目记忆会显示真实 projectId。"
|
||||
));
|
||||
|
||||
@@ -242,23 +242,23 @@ public class MasterAgentMemoryActivity extends BossScreenActivity {
|
||||
));
|
||||
|
||||
if (memory != null) {
|
||||
builder.setNeutralButton("删除", (dialog, which) -> confirmDeleteMemory(memory));
|
||||
builder.setNeutralButton("归档", (dialog, which) -> confirmArchiveMemory(memory));
|
||||
}
|
||||
|
||||
builder.show();
|
||||
}
|
||||
|
||||
private void confirmDeleteMemory(JSONObject memory) {
|
||||
private void confirmArchiveMemory(JSONObject memory) {
|
||||
final String memoryId = memory.optString("memoryId", "");
|
||||
if (memoryId.isEmpty()) {
|
||||
showMessage("缺少 memoryId");
|
||||
return;
|
||||
}
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("删除记忆")
|
||||
.setMessage("确定删除这条记忆吗?")
|
||||
.setTitle("归档记忆")
|
||||
.setMessage("确定归档这条记忆吗?归档后会从当前列表移除,不是永久删除。")
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("删除", (dialog, which) -> deleteMemory(memoryId))
|
||||
.setPositiveButton("归档", (dialog, which) -> archiveMemory(memoryId))
|
||||
.show();
|
||||
}
|
||||
|
||||
@@ -329,7 +329,7 @@ public class MasterAgentMemoryActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void deleteMemory(String memoryId) {
|
||||
private void archiveMemory(String memoryId) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
@@ -338,14 +338,14 @@ public class MasterAgentMemoryActivity extends BossScreenActivity {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showMessage("记忆已删除");
|
||||
showMessage("记忆已归档");
|
||||
setResult(RESULT_OK);
|
||||
finish();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("记忆删除失败:" + error.getMessage());
|
||||
showMessage("记忆归档失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
final class MasterAgentModePresets {
|
||||
static final class ModePreset {
|
||||
final String key;
|
||||
final String label;
|
||||
@Nullable final String modelOverride;
|
||||
@Nullable final String reasoningEffortOverride;
|
||||
|
||||
ModePreset(
|
||||
String key,
|
||||
String label,
|
||||
@Nullable String modelOverride,
|
||||
@Nullable String reasoningEffortOverride
|
||||
) {
|
||||
this.key = key;
|
||||
this.label = label;
|
||||
this.modelOverride = modelOverride;
|
||||
this.reasoningEffortOverride = reasoningEffortOverride;
|
||||
}
|
||||
}
|
||||
|
||||
static final ModePreset DEFAULT = new ModePreset("default", "沿用默认", null, null);
|
||||
private static final String DEFAULT_FAST_MODEL = "gpt-5.4-mini";
|
||||
private static final String DEFAULT_DEEP_MODEL = "gpt-5.4";
|
||||
|
||||
private MasterAgentModePresets() {}
|
||||
|
||||
static ModePreset[] primaryChoices(@Nullable String fastModelOverride, @Nullable String deepModelOverride) {
|
||||
return new ModePreset[]{
|
||||
DEFAULT,
|
||||
new ModePreset("fast", "快速反应", resolveFastModel(fastModelOverride), "low"),
|
||||
new ModePreset("deep", "深度思考", resolveDeepModel(deepModelOverride), "high")
|
||||
};
|
||||
}
|
||||
|
||||
static String[] primaryChoiceLabels(@Nullable String fastModelOverride, @Nullable String deepModelOverride) {
|
||||
return new String[]{
|
||||
"沿用默认",
|
||||
"快速反应(" + resolveFastModel(fastModelOverride) + ")",
|
||||
"深度思考(" + resolveDeepModel(deepModelOverride) + ")",
|
||||
"更多模型..."
|
||||
};
|
||||
}
|
||||
|
||||
static int findPrimaryChoiceIndex(
|
||||
@Nullable String modelOverride,
|
||||
@Nullable String reasoningEffortOverride,
|
||||
@Nullable String fastModelOverride,
|
||||
@Nullable String deepModelOverride
|
||||
) {
|
||||
ModePreset preset = matchPreset(modelOverride, reasoningEffortOverride, fastModelOverride, deepModelOverride);
|
||||
if (preset == null) {
|
||||
return primaryChoiceLabels(fastModelOverride, deepModelOverride).length - 1;
|
||||
}
|
||||
ModePreset[] choices = primaryChoices(fastModelOverride, deepModelOverride);
|
||||
for (int index = 0; index < choices.length; index += 1) {
|
||||
if (choices[index].key.equals(preset.key)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
static ModePreset matchPreset(
|
||||
@Nullable String modelOverride,
|
||||
@Nullable String reasoningEffortOverride,
|
||||
@Nullable String fastModelOverride,
|
||||
@Nullable String deepModelOverride
|
||||
) {
|
||||
String model = normalize(modelOverride);
|
||||
String reasoning = normalize(reasoningEffortOverride);
|
||||
if (TextUtils.isEmpty(model) && TextUtils.isEmpty(reasoning)) {
|
||||
return DEFAULT;
|
||||
}
|
||||
for (ModePreset preset : primaryChoices(fastModelOverride, deepModelOverride)) {
|
||||
if (TextUtils.equals(normalize(preset.modelOverride), model)
|
||||
&& TextUtils.equals(normalize(preset.reasoningEffortOverride), reasoning)) {
|
||||
return preset;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String describeCurrentMode(
|
||||
@Nullable String modelOverride,
|
||||
@Nullable String reasoningEffortOverride,
|
||||
@Nullable String fastModelOverride,
|
||||
@Nullable String deepModelOverride
|
||||
) {
|
||||
ModePreset preset = matchPreset(modelOverride, reasoningEffortOverride, fastModelOverride, deepModelOverride);
|
||||
return preset == null ? "自定义" : preset.label;
|
||||
}
|
||||
|
||||
static String resolveFastModel(@Nullable String fastModelOverride) {
|
||||
String resolved = normalize(fastModelOverride);
|
||||
return TextUtils.isEmpty(resolved) ? DEFAULT_FAST_MODEL : resolved;
|
||||
}
|
||||
|
||||
static String resolveDeepModel(@Nullable String deepModelOverride) {
|
||||
String resolved = normalize(deepModelOverride);
|
||||
return TextUtils.isEmpty(resolved) ? DEFAULT_DEEP_MODEL : resolved;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String normalize(@Nullable String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
if (trimmed.isEmpty() || "null".equalsIgnoreCase(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ public class MasterAgentPromptActivity extends BossScreenActivity {
|
||||
private @Nullable String backendOverrideText;
|
||||
private boolean clawSelectable;
|
||||
private @Nullable String clawReasonLabel;
|
||||
private boolean hermesSelectable;
|
||||
private @Nullable String hermesReasonLabel;
|
||||
private final List<String> backendOverrideValues = new ArrayList<>();
|
||||
private EditText userPromptInput;
|
||||
private EditText projectPromptInput;
|
||||
@@ -84,6 +86,7 @@ public class MasterAgentPromptActivity extends BossScreenActivity {
|
||||
userPrompt = payload.optJSONObject("userPrompt");
|
||||
projectControls = payload.optJSONObject("projectControls");
|
||||
JSONObject clawAvailability = payload.optJSONObject("clawAvailability");
|
||||
JSONObject hermesAvailability = payload.optJSONObject("hermesAvailability");
|
||||
adminPromptText = promptPolicy == null ? null : promptPolicy.optString("globalPrompt", "");
|
||||
userPromptText = userPrompt == null ? "" : userPrompt.optString("content", "");
|
||||
projectPromptOverrideText = payload.optString(
|
||||
@@ -93,6 +96,8 @@ public class MasterAgentPromptActivity extends BossScreenActivity {
|
||||
backendOverrideText = projectControls == null ? "" : projectControls.optString("backendOverride", "");
|
||||
clawSelectable = clawAvailability != null && clawAvailability.optBoolean("selectable", false);
|
||||
clawReasonLabel = clawAvailability == null ? "" : clawAvailability.optString("reasonLabel", "");
|
||||
hermesSelectable = hermesAvailability != null && hermesAvailability.optBoolean("selectable", false);
|
||||
hermesReasonLabel = hermesAvailability == null ? "" : hermesAvailability.optString("reasonLabel", "");
|
||||
|
||||
replaceContent();
|
||||
appendContent(BossUi.buildSimpleProfileHeader(
|
||||
@@ -138,6 +143,10 @@ public class MasterAgentPromptActivity extends BossScreenActivity {
|
||||
backendOverrideValues.add("claw-runtime");
|
||||
backendLabels.add("Claw Runtime");
|
||||
}
|
||||
if (hermesSelectable) {
|
||||
backendOverrideValues.add("hermes-runtime");
|
||||
backendLabels.add("Hermes Runtime");
|
||||
}
|
||||
|
||||
backendSpinner = new Spinner(this);
|
||||
backendSpinner.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, backendLabels));
|
||||
@@ -170,6 +179,16 @@ public class MasterAgentPromptActivity extends BossScreenActivity {
|
||||
: "恢复可用后,执行后端下拉框会重新出现 Claw Runtime。"
|
||||
));
|
||||
}
|
||||
if (!hermesSelectable) {
|
||||
appendContent(BossUi.buildSoftPanel(
|
||||
this,
|
||||
"Hermes Runtime 当前不可用",
|
||||
TextUtils.isEmpty(hermesReasonLabel) ? "当前环境未满足 Hermes Runtime 的启动条件。" : hermesReasonLabel,
|
||||
TextUtils.equals(backendOverrideText, "hermes-runtime")
|
||||
? "当前对话之前保存过 Hermes Runtime,运行时会自动回退到默认后端。"
|
||||
: "恢复可用后,执行后端下拉框会重新出现 Hermes Runtime。"
|
||||
));
|
||||
}
|
||||
|
||||
previewTextView = new TextView(this);
|
||||
previewTextView.setText(buildPreviewText());
|
||||
@@ -235,6 +254,8 @@ public class MasterAgentPromptActivity extends BossScreenActivity {
|
||||
builder.append("【执行后端】\n").append(backendValue).append("\n\n");
|
||||
} else if (TextUtils.equals(backendOverrideText, "claw-runtime") && !clawSelectable) {
|
||||
builder.append("【执行后端】\n默认(Claw Runtime 当前不可用,运行时会自动回退)\n\n");
|
||||
} else if (TextUtils.equals(backendOverrideText, "hermes-runtime") && !hermesSelectable) {
|
||||
builder.append("【执行后端】\n默认(Hermes Runtime 当前不可用,运行时会自动回退)\n\n");
|
||||
}
|
||||
if (builder.length() == 0) {
|
||||
return "当前没有任何提示词内容。";
|
||||
|
||||
@@ -14,30 +14,6 @@ import java.util.Set;
|
||||
public final class ProjectChatUiState {
|
||||
private ProjectChatUiState() {}
|
||||
|
||||
public static final class MessageDisplayItem {
|
||||
public static final String TYPE_MESSAGE = "message";
|
||||
public static final String TYPE_PROCESS_GROUP = "process_group";
|
||||
|
||||
public final String type;
|
||||
@Nullable
|
||||
public final JSONObject message;
|
||||
public final List<JSONObject> processMessages;
|
||||
|
||||
private MessageDisplayItem(String type, @Nullable JSONObject message, List<JSONObject> processMessages) {
|
||||
this.type = type;
|
||||
this.message = message;
|
||||
this.processMessages = Collections.unmodifiableList(new ArrayList<>(processMessages));
|
||||
}
|
||||
|
||||
private static MessageDisplayItem message(JSONObject message) {
|
||||
return new MessageDisplayItem(TYPE_MESSAGE, message, Collections.emptyList());
|
||||
}
|
||||
|
||||
private static MessageDisplayItem processGroup(List<JSONObject> processMessages) {
|
||||
return new MessageDisplayItem(TYPE_PROCESS_GROUP, null, processMessages);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class SelectionState {
|
||||
public final boolean multiSelecting;
|
||||
public final Set<String> selectedMessageIds;
|
||||
@@ -55,7 +31,6 @@ public final class ProjectChatUiState {
|
||||
public final boolean showMultiSelectBar;
|
||||
public final boolean showRefresh;
|
||||
public final boolean showHeaderAction;
|
||||
public final boolean copyEnabled;
|
||||
public final boolean forwardEnabled;
|
||||
public final String backLabel;
|
||||
public final String title;
|
||||
@@ -67,7 +42,6 @@ public final class ProjectChatUiState {
|
||||
boolean showMultiSelectBar,
|
||||
boolean showRefresh,
|
||||
boolean showHeaderAction,
|
||||
boolean copyEnabled,
|
||||
boolean forwardEnabled,
|
||||
String backLabel,
|
||||
String title,
|
||||
@@ -78,7 +52,6 @@ public final class ProjectChatUiState {
|
||||
this.showMultiSelectBar = showMultiSelectBar;
|
||||
this.showRefresh = showRefresh;
|
||||
this.showHeaderAction = showHeaderAction;
|
||||
this.copyEnabled = copyEnabled;
|
||||
this.forwardEnabled = forwardEnabled;
|
||||
this.backLabel = backLabel;
|
||||
this.title = title;
|
||||
@@ -89,10 +62,26 @@ public final class ProjectChatUiState {
|
||||
public static final class ReplyWaitSpec {
|
||||
public final boolean shouldWait;
|
||||
public final String baselineMessageId;
|
||||
public final List<String> executionIds;
|
||||
|
||||
private ReplyWaitSpec(boolean shouldWait, @Nullable String baselineMessageId) {
|
||||
this.shouldWait = shouldWait && !isBlank(baselineMessageId);
|
||||
this.baselineMessageId = this.shouldWait ? baselineMessageId.trim() : "";
|
||||
private ReplyWaitSpec(
|
||||
boolean shouldWait,
|
||||
@Nullable String baselineMessageId,
|
||||
@Nullable List<String> executionIds
|
||||
) {
|
||||
ArrayList<String> normalizedExecutionIds = new ArrayList<>();
|
||||
if (executionIds != null) {
|
||||
for (String executionId : executionIds) {
|
||||
if (!isBlank(executionId)) {
|
||||
normalizedExecutionIds.add(executionId.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean hasBaseline = !isBlank(baselineMessageId);
|
||||
boolean hasExecutionIds = !normalizedExecutionIds.isEmpty();
|
||||
this.shouldWait = shouldWait && (hasBaseline || hasExecutionIds);
|
||||
this.baselineMessageId = hasBaseline ? baselineMessageId.trim() : "";
|
||||
this.executionIds = Collections.unmodifiableList(normalizedExecutionIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,77 +97,6 @@ public final class ProjectChatUiState {
|
||||
return nearBottom || forced;
|
||||
}
|
||||
|
||||
public static List<MessageDisplayItem> buildMessageDisplayItems(@Nullable JSONArray messages) {
|
||||
ArrayList<MessageDisplayItem> items = new ArrayList<>();
|
||||
if (messages == null || messages.length() == 0) {
|
||||
return items;
|
||||
}
|
||||
ArrayList<JSONObject> pendingProcessMessages = new ArrayList<>();
|
||||
for (int i = 0; i < messages.length(); i++) {
|
||||
JSONObject message = messages.optJSONObject(i);
|
||||
if (message == null) {
|
||||
continue;
|
||||
}
|
||||
if (isThreadProcessMessage(message)) {
|
||||
pendingProcessMessages.add(message);
|
||||
continue;
|
||||
}
|
||||
flushProcessGroup(items, pendingProcessMessages);
|
||||
items.add(MessageDisplayItem.message(message));
|
||||
}
|
||||
flushProcessGroup(items, pendingProcessMessages);
|
||||
return items;
|
||||
}
|
||||
|
||||
public static boolean hasThreadProcessFoldCandidates(@Nullable JSONArray messages, int startIndex) {
|
||||
if (messages == null || messages.length() == 0) {
|
||||
return false;
|
||||
}
|
||||
int firstIndex = Math.max(0, startIndex);
|
||||
for (int i = firstIndex; i < messages.length(); i++) {
|
||||
JSONObject message = messages.optJSONObject(i);
|
||||
if (message != null && isThreadProcessMessage(message)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static String processGroupPreview(@Nullable MessageDisplayItem item) {
|
||||
if (item == null || item.processMessages.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
JSONObject latestMessage = item.processMessages.get(item.processMessages.size() - 1);
|
||||
return truncate(latestMessage.optString("body", ""), 52);
|
||||
}
|
||||
|
||||
public static String processGroupDetail(@Nullable MessageDisplayItem item) {
|
||||
if (item == null || item.processMessages.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < item.processMessages.size(); i++) {
|
||||
JSONObject message = item.processMessages.get(i);
|
||||
String body = compactBody(message.optString("body", ""));
|
||||
if (body.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append("\n\n");
|
||||
}
|
||||
builder.append(i + 1).append(". ").append(body);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static void flushProcessGroup(List<MessageDisplayItem> items, List<JSONObject> pendingProcessMessages) {
|
||||
if (pendingProcessMessages.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
items.add(MessageDisplayItem.processGroup(pendingProcessMessages));
|
||||
pendingProcessMessages.clear();
|
||||
}
|
||||
|
||||
public static String threadExecutionConflictTitle(@Nullable JSONObject conflict) {
|
||||
if (conflict == null) {
|
||||
return "当前线程命中冲突保护";
|
||||
@@ -247,10 +165,6 @@ public final class ProjectChatUiState {
|
||||
return state != null && state.multiSelecting && state.selectedMessageIds.size() >= 2;
|
||||
}
|
||||
|
||||
public static boolean canCopySelection(@Nullable SelectionState state) {
|
||||
return state != null && state.multiSelecting && !state.selectedMessageIds.isEmpty();
|
||||
}
|
||||
|
||||
public static SelectionState reconcileSelection(
|
||||
@Nullable SelectionState current,
|
||||
@Nullable List<String> availableMessageIds
|
||||
@@ -283,7 +197,6 @@ public final class ProjectChatUiState {
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
canCopySelection(selectionState),
|
||||
canForwardSelection(selectionState),
|
||||
"取消",
|
||||
"已选 " + selectedCount + " 条",
|
||||
@@ -297,7 +210,6 @@ public final class ProjectChatUiState {
|
||||
!conversationInfoReady,
|
||||
conversationInfoReady,
|
||||
false,
|
||||
false,
|
||||
"返回",
|
||||
isBlank(defaultTitle) ? "项目详情" : defaultTitle,
|
||||
isBlank(defaultSubtitle) ? "原生页面" : defaultSubtitle
|
||||
@@ -514,37 +426,81 @@ public final class ProjectChatUiState {
|
||||
|
||||
public static ReplyWaitSpec resolveReplyWaitAfterSend(@Nullable JSONObject response) {
|
||||
if (response == null) {
|
||||
return new ReplyWaitSpec(false, null);
|
||||
return new ReplyWaitSpec(false, null, null);
|
||||
}
|
||||
JSONObject task = response.optJSONObject("task");
|
||||
if (task == null) {
|
||||
return new ReplyWaitSpec(false, null);
|
||||
return new ReplyWaitSpec(false, null, null);
|
||||
}
|
||||
String taskStatus = task.optString("status", "");
|
||||
if ("completed".equals(taskStatus) || "failed".equals(taskStatus)) {
|
||||
return new ReplyWaitSpec(false, null);
|
||||
}
|
||||
JSONObject replyMessage = response.optJSONObject("replyMessage");
|
||||
if (replyMessage != null) {
|
||||
String replyMessageId = replyMessage.optString("id", "").trim();
|
||||
if (!replyMessageId.isEmpty()) {
|
||||
return new ReplyWaitSpec(true, replyMessageId);
|
||||
}
|
||||
return new ReplyWaitSpec(false, null, null);
|
||||
}
|
||||
JSONObject message = response.optJSONObject("message");
|
||||
return new ReplyWaitSpec(true, message == null ? null : message.optString("id", ""));
|
||||
return new ReplyWaitSpec(true, message == null ? null : message.optString("id", ""), null);
|
||||
}
|
||||
|
||||
public static ReplyWaitSpec resolveReplyWaitAfterDispatchConfirm(@Nullable JSONObject response) {
|
||||
if (response == null) {
|
||||
return new ReplyWaitSpec(false, null);
|
||||
return new ReplyWaitSpec(false, null, null);
|
||||
}
|
||||
JSONArray executions = response.optJSONArray("executions");
|
||||
if (executions == null || executions.length() == 0) {
|
||||
return new ReplyWaitSpec(false, null);
|
||||
return new ReplyWaitSpec(false, null, null);
|
||||
}
|
||||
JSONObject notice = response.optJSONObject("notice");
|
||||
return new ReplyWaitSpec(true, notice == null ? null : notice.optString("id", ""));
|
||||
return new ReplyWaitSpec(
|
||||
true,
|
||||
notice == null ? null : notice.optString("id", ""),
|
||||
collectExecutionIds(executions)
|
||||
);
|
||||
}
|
||||
|
||||
public static ReplyWaitSpec replyWaitFromBaseline(@Nullable String baselineMessageId) {
|
||||
return new ReplyWaitSpec(true, baselineMessageId, null);
|
||||
}
|
||||
|
||||
public static boolean hasTrackedDispatchExecutionReply(
|
||||
@Nullable JSONArray dispatchPlans,
|
||||
@Nullable List<String> executionIds
|
||||
) {
|
||||
if (dispatchPlans == null || executionIds == null || executionIds.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
LinkedHashSet<String> trackedIds = new LinkedHashSet<>();
|
||||
for (String executionId : executionIds) {
|
||||
if (!isBlank(executionId)) {
|
||||
trackedIds.add(executionId.trim());
|
||||
}
|
||||
}
|
||||
if (trackedIds.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < dispatchPlans.length(); i++) {
|
||||
JSONObject plan = dispatchPlans.optJSONObject(i);
|
||||
if (plan == null) {
|
||||
continue;
|
||||
}
|
||||
JSONArray executions = plan.optJSONArray("executions");
|
||||
if (executions == null) {
|
||||
continue;
|
||||
}
|
||||
for (int j = 0; j < executions.length(); j++) {
|
||||
JSONObject execution = executions.optJSONObject(j);
|
||||
if (execution == null) {
|
||||
continue;
|
||||
}
|
||||
String executionId = execution.optString("executionId", "").trim();
|
||||
if (!trackedIds.contains(executionId)) {
|
||||
continue;
|
||||
}
|
||||
String status = execution.optString("status", "").trim();
|
||||
if ("completed".equals(status) || "failed".equals(status)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean hasReplyBeyondBaseline(@Nullable JSONObject project, @Nullable String baselineMessageId) {
|
||||
@@ -555,14 +511,6 @@ public final class ProjectChatUiState {
|
||||
return !isBlank(latestMessageId) && !baselineMessageId.trim().equals(latestMessageId);
|
||||
}
|
||||
|
||||
public static boolean shouldAutoRefreshConversation(
|
||||
boolean shouldMaintainAutoRefresh,
|
||||
boolean realtimeConnected,
|
||||
boolean trackedMasterReplyTimedOut
|
||||
) {
|
||||
return shouldMaintainAutoRefresh && (!realtimeConnected || trackedMasterReplyTimedOut);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String latestMessageId(@Nullable JSONArray messages) {
|
||||
if (messages == null || messages.length() == 0) {
|
||||
@@ -576,110 +524,28 @@ public final class ProjectChatUiState {
|
||||
return messageId.isEmpty() ? null : messageId;
|
||||
}
|
||||
|
||||
private static boolean isThreadProcessMessage(@Nullable JSONObject message) {
|
||||
if (message == null) {
|
||||
return false;
|
||||
private static List<String> collectExecutionIds(@Nullable JSONArray executions) {
|
||||
ArrayList<String> executionIds = new ArrayList<>();
|
||||
if (executions == null) {
|
||||
return executionIds;
|
||||
}
|
||||
String kind = message.optString("kind", "").trim();
|
||||
if ("thread_process".equals(kind)) {
|
||||
return true;
|
||||
for (int i = 0; i < executions.length(); i++) {
|
||||
JSONObject execution = executions.optJSONObject(i);
|
||||
if (execution == null) {
|
||||
continue;
|
||||
}
|
||||
if (!isBlank(kind)
|
||||
&& !"text".equals(kind)
|
||||
&& !"conversation_reply".equals(kind)
|
||||
&& !"thread_reply".equals(kind)) {
|
||||
return false;
|
||||
String executionId = execution.optString("executionId", "").trim();
|
||||
if (!executionId.isEmpty()) {
|
||||
executionIds.add(executionId);
|
||||
}
|
||||
String sender = message.optString("sender", "").trim().toLowerCase(java.util.Locale.ROOT);
|
||||
String senderLabel = message.optString("senderLabel", "").trim();
|
||||
if ("user".equals(sender)
|
||||
|| "master".equals(sender)
|
||||
|| "ops".equals(sender)
|
||||
|| "audit".equals(sender)
|
||||
|| senderLabel.contains("主 Agent")
|
||||
|| senderLabel.contains("审计")
|
||||
|| senderLabel.contains("你")) {
|
||||
return false;
|
||||
}
|
||||
String body = compactBody(message.optString("body", ""));
|
||||
if (body.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (isStructuredNumberedProcessBody(body)) {
|
||||
return true;
|
||||
}
|
||||
if (containsAny(body, FOLD_BLOCK_MARKERS)) {
|
||||
return false;
|
||||
}
|
||||
return hasProcessProgressMarker(body);
|
||||
return executionIds;
|
||||
}
|
||||
|
||||
private static boolean isBlank(@Nullable String value) {
|
||||
return value == null || value.trim().isEmpty();
|
||||
}
|
||||
|
||||
private static String compactBody(@Nullable String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.replaceAll("\\n{2,}", "\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
private static boolean containsAny(String body, String[] markers) {
|
||||
String normalizedBody = body.toLowerCase(java.util.Locale.ROOT);
|
||||
for (String marker : markers) {
|
||||
if (normalizedBody.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isStructuredNumberedProcessBody(String body) {
|
||||
String[] rawLines = body
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.split("\n");
|
||||
ArrayList<String> numberedLines = new ArrayList<>();
|
||||
for (String rawLine : rawLines) {
|
||||
String normalizedLine = compactBody(rawLine);
|
||||
if (normalizedLine.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (normalizedLine.matches("^\\d+[.)、]\\s*.+$")) {
|
||||
numberedLines.add(normalizedLine);
|
||||
}
|
||||
}
|
||||
if (numberedLines.size() < 2) {
|
||||
return false;
|
||||
}
|
||||
String merged = android.text.TextUtils.join(" ", numberedLines)
|
||||
.toLowerCase(java.util.Locale.ROOT);
|
||||
return containsAny(merged, PROCESS_PROGRESS_NUMBERED_HINTS);
|
||||
}
|
||||
|
||||
private static boolean hasProcessProgressMarker(String body) {
|
||||
String normalizedBody = body.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
if (isStructuredNumberedProcessBody(body)) {
|
||||
return true;
|
||||
}
|
||||
for (String marker : PROCESS_PROGRESS_PREFIXES) {
|
||||
if (normalizedBody.startsWith(marker.toLowerCase(java.util.Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (String marker : PROCESS_PROGRESS_CONTAINS) {
|
||||
if (normalizedBody.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String truncate(@Nullable String value, int maxLength) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
if (normalized.length() <= maxLength) {
|
||||
@@ -687,83 +553,4 @@ public final class ProjectChatUiState {
|
||||
}
|
||||
return normalized.substring(0, maxLength) + "…";
|
||||
}
|
||||
|
||||
private static final String[] PROCESS_PROGRESS_PREFIXES = new String[] {
|
||||
"我先",
|
||||
"我现在",
|
||||
"我会先",
|
||||
"我发现",
|
||||
"我准备",
|
||||
"接下来",
|
||||
"正在",
|
||||
"先看",
|
||||
"先读",
|
||||
"我把",
|
||||
"我再",
|
||||
"目前在",
|
||||
"现在在",
|
||||
"补一组",
|
||||
"处理一下",
|
||||
"先确认",
|
||||
"准备",
|
||||
"同步一下",
|
||||
"我这边已经"
|
||||
};
|
||||
|
||||
private static final String[] PROCESS_PROGRESS_CONTAINS = new String[] {
|
||||
"我继续",
|
||||
"我已经在",
|
||||
"正在跑",
|
||||
"正在检查",
|
||||
"正在处理",
|
||||
"正在同步",
|
||||
"我会直接",
|
||||
"我先把",
|
||||
"先补",
|
||||
"再接"
|
||||
};
|
||||
|
||||
private static final String[] PROCESS_PROGRESS_NUMBERED_HINTS = new String[] {
|
||||
"先",
|
||||
"再",
|
||||
"接下来",
|
||||
"然后",
|
||||
"检查",
|
||||
"确认",
|
||||
"处理",
|
||||
"同步",
|
||||
"补",
|
||||
"排查",
|
||||
"推进",
|
||||
"回你",
|
||||
"回传",
|
||||
"会把",
|
||||
"我会"
|
||||
};
|
||||
|
||||
private static final String[] FOLD_BLOCK_MARKERS = new String[] {
|
||||
"失败",
|
||||
"报错",
|
||||
"错误",
|
||||
"阻塞",
|
||||
"不能",
|
||||
"无法",
|
||||
"崩溃",
|
||||
"超时",
|
||||
"exception",
|
||||
"error",
|
||||
"fatal",
|
||||
"结论",
|
||||
"最终",
|
||||
"总结",
|
||||
"已完成",
|
||||
"已经完成",
|
||||
"验证通过",
|
||||
"测试通过",
|
||||
"已修复",
|
||||
"修好了",
|
||||
"已部署",
|
||||
"已安装",
|
||||
"可以直接"
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -168,25 +168,6 @@ public class ProjectGoalsActivity extends BossScreenActivity {
|
||||
""
|
||||
));
|
||||
|
||||
JSONObject understanding = project.optJSONObject("projectUnderstanding");
|
||||
if (understanding != null) {
|
||||
String projectGoal = understanding.optString("projectGoal").trim();
|
||||
String currentProgress = understanding.optString("currentProgress").trim();
|
||||
String recommendedNextStep = understanding.optString("recommendedNextStep").trim();
|
||||
if (!projectGoal.isEmpty() || !currentProgress.isEmpty() || !recommendedNextStep.isEmpty()) {
|
||||
StringBuilder summary = new StringBuilder();
|
||||
appendSummaryLine(summary, "项目目标", projectGoal);
|
||||
appendSummaryLine(summary, "当前进度", currentProgress);
|
||||
appendSummaryLine(summary, "建议下一步", recommendedNextStep);
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"同步项目摘要",
|
||||
summary.toString().trim(),
|
||||
understanding.optString("updatedAt", "")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (goals == null || goals.length() == 0) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前项目还没有目标。点击右上角新增即可。"));
|
||||
} else {
|
||||
@@ -206,16 +187,6 @@ public class ProjectGoalsActivity extends BossScreenActivity {
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void appendSummaryLine(StringBuilder builder, String label, String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append('\n');
|
||||
}
|
||||
builder.append(label).append(":").append(value.trim());
|
||||
}
|
||||
|
||||
private LinearLayout buildGoalChecklistCard(JSONObject goal) {
|
||||
LinearLayout card = BossUi.buildCard(this, "", "", "");
|
||||
card.removeAllViews();
|
||||
@@ -242,7 +213,7 @@ public class ProjectGoalsActivity extends BossScreenActivity {
|
||||
indicator.setLayoutParams(indicatorParams);
|
||||
indicator.setGravity(Gravity.CENTER);
|
||||
indicator.setText(completed ? "✓" : "○");
|
||||
indicator.setTextSize(14);
|
||||
indicator.setTextSize(18);
|
||||
indicator.setTextColor(getColor(completed ? R.color.boss_green : R.color.boss_text_muted));
|
||||
row.addView(indicator);
|
||||
|
||||
@@ -257,14 +228,14 @@ public class ProjectGoalsActivity extends BossScreenActivity {
|
||||
|
||||
TextView title = new TextView(this);
|
||||
title.setText(goal.optString("text", "未命名目标"));
|
||||
title.setTextSize(14);
|
||||
title.setTextSize(16);
|
||||
title.setTextColor(getColor(R.color.boss_text_primary));
|
||||
title.setLineSpacing(0f, 1.2f);
|
||||
texts.addView(title);
|
||||
|
||||
TextView note = new TextView(this);
|
||||
note.setText(goal.optString("note", "暂无备注"));
|
||||
note.setTextSize(12);
|
||||
note.setTextSize(13);
|
||||
note.setTextColor(getColor(R.color.boss_text_muted));
|
||||
note.setPadding(0, BossUi.dp(this, 8), 0, 0);
|
||||
texts.addView(note);
|
||||
|
||||
@@ -13,7 +13,7 @@ import java.util.Map;
|
||||
public class ProjectVersionsActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
private static final String VERSION_REFRESH_NOTE = "project_versions.updated";
|
||||
private static final String GOAL_REFRESH_NOTE = "project_goals.updated";
|
||||
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
|
||||
|
||||
private String projectId;
|
||||
@@ -24,7 +24,7 @@ public class ProjectVersionsActivity extends BossScreenActivity {
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
|
||||
configureScreen("版本记录", getIntent().getStringExtra(EXTRA_PROJECT_NAME));
|
||||
configureScreen("版本迭代记录", getIntent().getStringExtra(EXTRA_PROJECT_NAME));
|
||||
setHeaderAction("只读", v -> showMessage("版本记录只读"));
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
reload();
|
||||
@@ -111,7 +111,7 @@ public class ProjectVersionsActivity extends BossScreenActivity {
|
||||
return false;
|
||||
}
|
||||
String payloadNote = event.payload.optString("note", "").trim();
|
||||
return payloadProjectId.equals(projectId) && VERSION_REFRESH_NOTE.equals(payloadNote);
|
||||
return payloadProjectId.equals(projectId) && GOAL_REFRESH_NOTE.equals(payloadNote);
|
||||
}
|
||||
|
||||
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class SecurityActivity extends BossScreenActivity {
|
||||
@@ -23,11 +22,7 @@ public class SecurityActivity extends BossScreenActivity {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getSession();
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
BossApiClient.ApiResponse sessionsResponse = apiClient.getAuthSessions();
|
||||
JSONArray sessions = sessionsResponse.ok()
|
||||
? sessionsResponse.json.optJSONArray("sessions")
|
||||
: new JSONArray();
|
||||
runOnUiThread(() -> renderSecurity(response.json.optJSONObject("session"), sessions));
|
||||
runOnUiThread(() -> renderSecurity(response.json.optJSONObject("session")));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
@@ -37,7 +32,7 @@ public class SecurityActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void renderSecurity(@Nullable JSONObject session, @Nullable JSONArray sessions) {
|
||||
private void renderSecurity(@Nullable JSONObject session) {
|
||||
replaceContent();
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
@@ -60,33 +55,6 @@ public class SecurityActivity extends BossScreenActivity {
|
||||
));
|
||||
}
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"登录会话",
|
||||
"当前可管理 " + (sessions == null ? 0 : sessions.length()) + " 个登录端",
|
||||
"点击非当前会话可撤销;撤销当前会话会回到登录页。",
|
||||
null,
|
||||
null
|
||||
));
|
||||
if (sessions != null) {
|
||||
for (int index = 0; index < sessions.length(); index += 1) {
|
||||
JSONObject item = sessions.optJSONObject(index);
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
buildSessionTitle(item),
|
||||
item.optString("account", "-")
|
||||
+ " · " + BossUi.formatRoleLabel(item.optString("role", "-")),
|
||||
"最近 " + item.optString("lastSeenAt", "-")
|
||||
+ " · 到期 " + item.optString("expiresAt", "-"),
|
||||
item.optBoolean("current", false) ? "当前" : null,
|
||||
v -> confirmRevokeSession(item.optString("sessionId", ""), item.optBoolean("current", false))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
appendContent(BossUi.buildMenuRow(this, "打开设备页", "查看已绑定设备与状态", null, v -> {
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.putExtra(MainActivity.EXTRA_INITIAL_TAB, "devices");
|
||||
@@ -100,57 +68,6 @@ public class SecurityActivity extends BossScreenActivity {
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private String buildSessionTitle(JSONObject session) {
|
||||
String method = "code".equals(session.optString("loginMethod", "password")) ? "验证码登录" : "账号密码登录";
|
||||
String name = session.optString("displayName", session.optString("account", "登录端"));
|
||||
return name + " · " + method;
|
||||
}
|
||||
|
||||
private void confirmRevokeSession(String sessionId, boolean current) {
|
||||
if (sessionId == null || sessionId.isEmpty()) {
|
||||
showMessage("会话 ID 缺失,无法撤销。");
|
||||
return;
|
||||
}
|
||||
new androidx.appcompat.app.AlertDialog.Builder(this)
|
||||
.setTitle(current ? "退出当前会话" : "撤销登录会话")
|
||||
.setMessage(current ? "撤销当前会话后需要重新登录。" : "只撤销这一端的登录态,不影响其他会话。")
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton(current ? "退出" : "撤销", (dialog, which) -> revokeSession(sessionId, current))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void revokeSession(String sessionId, boolean current) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.revokeAuthSession(sessionId);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
if (current) {
|
||||
apiClient.logout();
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showMessage("会话已撤销");
|
||||
if (current) {
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.putExtra(MainActivity.EXTRA_FORCE_LOGOUT, true);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
finish();
|
||||
} else {
|
||||
reload();
|
||||
}
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("撤销失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void logout() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
@@ -162,7 +79,6 @@ public class SecurityActivity extends BossScreenActivity {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.putExtra(MainActivity.EXTRA_FORCE_LOGOUT, true);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
finish();
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.text.InputType;
|
||||
import android.text.TextUtils;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -64,18 +58,8 @@ public class SkillInventoryActivity extends BossScreenActivity {
|
||||
String targetDeviceId = resolveTargetDeviceId();
|
||||
BossApiClient.ApiResponse response = apiClient.getDeviceSkills(targetDeviceId);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
JSONObject lifecyclePayload = null;
|
||||
try {
|
||||
BossApiClient.ApiResponse lifecycleResponse = apiClient.getSkillLifecycleRequests();
|
||||
if (lifecycleResponse.ok()) {
|
||||
lifecyclePayload = lifecycleResponse.json;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
lifecyclePayload = null;
|
||||
}
|
||||
deviceId = targetDeviceId;
|
||||
JSONObject finalLifecyclePayload = lifecyclePayload;
|
||||
runOnUiThread(() -> renderSkills(response.json, finalLifecyclePayload));
|
||||
runOnUiThread(() -> renderSkills(response.json));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
@@ -219,14 +203,9 @@ public class SkillInventoryActivity extends BossScreenActivity {
|
||||
}
|
||||
|
||||
private void renderSkills(JSONObject payload) {
|
||||
renderSkills(payload, null);
|
||||
}
|
||||
|
||||
private void renderSkills(JSONObject payload, @Nullable JSONObject lifecyclePayload) {
|
||||
replaceContent();
|
||||
JSONObject device = payload.optJSONObject("device");
|
||||
JSONArray skills = payload.optJSONArray("skills");
|
||||
boolean canManageLifecycle = lifecyclePayload != null;
|
||||
|
||||
if (device != null) {
|
||||
deviceName = device.optString("name", deviceId);
|
||||
@@ -241,10 +220,6 @@ public class SkillInventoryActivity extends BossScreenActivity {
|
||||
));
|
||||
}
|
||||
|
||||
if (canManageLifecycle) {
|
||||
appendSkillManagementWorkspace(lifecyclePayload);
|
||||
}
|
||||
|
||||
if (skills == null || skills.length() == 0) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前设备还没有同步 Skill。"));
|
||||
setRefreshing(false);
|
||||
@@ -269,217 +244,8 @@ public class SkillInventoryActivity extends BossScreenActivity {
|
||||
Button copyPath = BossUi.buildMiniActionButton(this, "复制路径", false);
|
||||
copyPath.setOnClickListener(v -> BossUi.copyText(this, "Skill 路径", skill.optString("path", "")));
|
||||
card.addView(BossUi.buildInlineActionRow(this, copyInvocation, copyPath));
|
||||
if (canManageLifecycle) {
|
||||
Button update = BossUi.buildMiniActionButton(this, "更新下发", true);
|
||||
update.setOnClickListener(v -> queueSkillLifecycleRequest("update", skill, null, null, null, null, null));
|
||||
Button rollback = BossUi.buildMiniActionButton(this, "回滚", false);
|
||||
rollback.setOnClickListener(v -> showVersionedSkillRequestDialog("rollback", skill, "回滚", "rollbackToVersion"));
|
||||
Button versionLock = BossUi.buildMiniActionButton(this, "版本锁定", false);
|
||||
versionLock.setOnClickListener(v -> showVersionedSkillRequestDialog("version_lock", skill, "版本锁定", "lockedVersion"));
|
||||
card.addView(BossUi.buildInlineActionRow(this, update, rollback, versionLock));
|
||||
}
|
||||
appendContent(card);
|
||||
}
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void appendSkillManagementWorkspace(JSONObject lifecyclePayload) {
|
||||
JSONArray requests = lifecyclePayload.optJSONArray("requests");
|
||||
int requestCount = requests == null ? 0 : requests.length();
|
||||
int queuedCount = countRequestsByStatus(requests, "queued");
|
||||
int runningCount = countRunningRequests(requests);
|
||||
|
||||
LinearLayout card = new LinearLayout(this);
|
||||
card.setOrientation(LinearLayout.VERTICAL);
|
||||
card.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"Skill 管理分发",
|
||||
"安装、更新、回滚、版本锁定和账号权限分配统一在这里处理。",
|
||||
"Skill 请求状态:待执行 " + queuedCount + " · 执行中 " + runningCount + " · 最近请求 " + requestCount,
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
Button installRemote = BossUi.buildMiniActionButton(this, "安装远端 Skill", true);
|
||||
installRemote.setOnClickListener(v -> showInstallSkillDialog());
|
||||
Button grantPermission = BossUi.buildMiniActionButton(this, "分配权限", false);
|
||||
grantPermission.setOnClickListener(v -> startActivity(new Intent(this, AccessManagementActivity.class)));
|
||||
card.addView(BossUi.buildInlineActionRow(this, installRemote, grantPermission));
|
||||
|
||||
if (requests != null && requests.length() > 0) {
|
||||
int maxRows = Math.min(3, requests.length());
|
||||
for (int index = 0; index < maxRows; index += 1) {
|
||||
JSONObject request = requests.optJSONObject(index);
|
||||
if (request == null) continue;
|
||||
card.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"Skill 请求状态",
|
||||
request.optString("action", "-") + " · " + request.optString("status", "-"),
|
||||
request.optString("skillId", request.optString("sourceUrl", "-"))
|
||||
+ " · " + request.optString("requestedAt", "-"),
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
appendContent(card);
|
||||
}
|
||||
|
||||
private void showInstallSkillDialog() {
|
||||
LinearLayout form = new LinearLayout(this);
|
||||
form.setOrientation(LinearLayout.VERTICAL);
|
||||
int padding = BossUi.dp(this, 12);
|
||||
form.setPadding(padding, padding, padding, 0);
|
||||
|
||||
EditText sourceUrl = buildSingleLineInput("Git URL 或可信来源 URL");
|
||||
EditText targetVersion = buildSingleLineInput("目标版本,可选");
|
||||
EditText checksum = buildSingleLineInput("SHA256 校验和,可选");
|
||||
form.addView(sourceUrl);
|
||||
form.addView(targetVersion);
|
||||
form.addView(checksum);
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("安装远端 Skill")
|
||||
.setView(form)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("下发", (dialog, which) -> {
|
||||
String source = sourceUrl.getText().toString().trim();
|
||||
if (TextUtils.isEmpty(source)) {
|
||||
showMessage("请输入 Skill 来源 URL");
|
||||
return;
|
||||
}
|
||||
queueSkillLifecycleRequest(
|
||||
"install",
|
||||
null,
|
||||
source,
|
||||
targetVersion.getText().toString().trim(),
|
||||
checksum.getText().toString().trim(),
|
||||
null,
|
||||
null
|
||||
);
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
private void showVersionedSkillRequestDialog(
|
||||
String action,
|
||||
JSONObject skill,
|
||||
String title,
|
||||
String versionField
|
||||
) {
|
||||
EditText input = buildSingleLineInput("请输入版本号");
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle(title)
|
||||
.setView(input)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("下发", (dialog, which) -> {
|
||||
String version = input.getText().toString().trim();
|
||||
if (TextUtils.isEmpty(version)) {
|
||||
showMessage("请输入版本号");
|
||||
return;
|
||||
}
|
||||
if ("rollbackToVersion".equals(versionField)) {
|
||||
queueSkillLifecycleRequest(action, skill, null, null, null, version, null);
|
||||
} else {
|
||||
queueSkillLifecycleRequest(action, skill, null, null, null, null, version);
|
||||
}
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
private EditText buildSingleLineInput(String hint) {
|
||||
EditText input = new EditText(this);
|
||||
input.setHint(hint);
|
||||
input.setSingleLine(true);
|
||||
input.setInputType(InputType.TYPE_CLASS_TEXT);
|
||||
int verticalPadding = BossUi.dp(this, 8);
|
||||
input.setPadding(0, verticalPadding, 0, verticalPadding);
|
||||
return input;
|
||||
}
|
||||
|
||||
private void queueSkillLifecycleRequest(
|
||||
String action,
|
||||
@Nullable JSONObject skill,
|
||||
@Nullable String sourceUrl,
|
||||
@Nullable String targetVersion,
|
||||
@Nullable String checksum,
|
||||
@Nullable String rollbackToVersion,
|
||||
@Nullable String lockedVersion
|
||||
) {
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("action", action);
|
||||
payload.put("deviceId", deviceId == null ? "" : deviceId);
|
||||
if (skill != null) {
|
||||
putIfNotBlank(payload, "skillId", skill.optString("skillId", ""));
|
||||
}
|
||||
putIfNotBlank(payload, "sourceUrl", sourceUrl);
|
||||
putIfNotBlank(payload, "targetVersion", targetVersion);
|
||||
putIfNotBlank(payload, "checksum", checksum);
|
||||
putIfNotBlank(payload, "rollbackToVersion", rollbackToVersion);
|
||||
putIfNotBlank(payload, "lockedVersion", lockedVersion);
|
||||
putIfNotBlank(payload, "note", "boss-app-skill-management");
|
||||
submitSkillLifecycleRequest(payload);
|
||||
} catch (JSONException error) {
|
||||
showMessage("Skill 请求构建失败:" + error.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void putIfNotBlank(JSONObject payload, String key, @Nullable String value) throws JSONException {
|
||||
if (!TextUtils.isEmpty(value)) {
|
||||
payload.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private void submitSkillLifecycleRequest(JSONObject payload) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.createSkillLifecycleRequest(payload);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage("Skill 请求已下发");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("Skill 请求失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static int countRequestsByStatus(@Nullable JSONArray requests, String status) {
|
||||
if (requests == null) {
|
||||
return 0;
|
||||
}
|
||||
int count = 0;
|
||||
for (int index = 0; index < requests.length(); index += 1) {
|
||||
JSONObject request = requests.optJSONObject(index);
|
||||
if (request != null && status.equalsIgnoreCase(request.optString("status", ""))) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static int countRunningRequests(@Nullable JSONArray requests) {
|
||||
if (requests == null) {
|
||||
return 0;
|
||||
}
|
||||
int count = 0;
|
||||
for (int index = 0; index < requests.length(); index += 1) {
|
||||
JSONObject request = requests.optJSONObject(index);
|
||||
if (request == null) continue;
|
||||
String status = request.optString("status", "");
|
||||
if ("claimed".equalsIgnoreCase(status)
|
||||
|| "running".equalsIgnoreCase(status)
|
||||
|| "processing".equalsIgnoreCase(status)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.text.InputType;
|
||||
import android.text.TextUtils;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class StorageSettingsActivity extends BossScreenActivity {
|
||||
private String storageMode = "server_file";
|
||||
private boolean configLoaded;
|
||||
private LinearLayout ossForm;
|
||||
private Button serverModeButton;
|
||||
private Button ossModeButton;
|
||||
private EditText accessKeyIdField;
|
||||
private EditText accessKeySecretField;
|
||||
private EditText bucketField;
|
||||
private EditText endpointField;
|
||||
private EditText regionField;
|
||||
private EditText prefixField;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("附件与存储", "附件上传位置与 OSS 配置");
|
||||
setHeaderAction("保存", v -> saveConfig(false));
|
||||
buildFormContent();
|
||||
updateSaveAvailability();
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getAttachmentStorageConfig();
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> populate(response.json.optJSONObject("config")));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
configLoaded = false;
|
||||
updateSaveAvailability();
|
||||
replaceContent(BossUi.buildEmptyCard(this, "附件与存储加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void buildFormContent() {
|
||||
serverModeButton = BossUi.buildMiniActionButton(this, "服务器文件存储", true);
|
||||
ossModeButton = BossUi.buildMiniActionButton(this, "阿里 OSS", false);
|
||||
serverModeButton.setOnClickListener(v -> switchMode("server_file"));
|
||||
ossModeButton.setOnClickListener(v -> switchMode("oss"));
|
||||
|
||||
accessKeyIdField = buildTextField("AccessKey ID");
|
||||
accessKeySecretField = buildTextField("AccessKey Secret");
|
||||
accessKeySecretField.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
bucketField = buildTextField("Bucket");
|
||||
endpointField = buildTextField("Endpoint,例如 oss-cn-hangzhou.aliyuncs.com");
|
||||
regionField = buildTextField("Region,例如 oss-cn-hangzhou");
|
||||
prefixField = buildTextField("Prefix,例如 boss/");
|
||||
|
||||
ossForm = new LinearLayout(this);
|
||||
ossForm.setOrientation(LinearLayout.VERTICAL);
|
||||
ossForm.addView(BossUi.buildFormCell(this, "AccessKey ID", "阿里 OSS AccessKey ID", accessKeyIdField));
|
||||
ossForm.addView(BossUi.buildFormCell(this, "AccessKey Secret", "不会回显;留空表示沿用已保存密钥", accessKeySecretField));
|
||||
ossForm.addView(BossUi.buildFormCell(this, "Bucket", "附件所在 Bucket", bucketField));
|
||||
ossForm.addView(BossUi.buildFormCell(this, "Endpoint", "OSS Endpoint,不需要填写 https://", endpointField));
|
||||
ossForm.addView(BossUi.buildFormCell(this, "Region", "Bucket 所在地域", regionField));
|
||||
ossForm.addView(BossUi.buildFormCell(this, "Prefix", "可选,默认 boss/", prefixField));
|
||||
|
||||
Button validateButton = BossUi.buildMiniActionButton(this, "测试并保存", true);
|
||||
validateButton.setOnClickListener(v -> saveConfig(true));
|
||||
|
||||
replaceContent(
|
||||
BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"当前使用方式",
|
||||
"服务器文件存储适合内测;OSS 适合长期附件归档。",
|
||||
"切换后点击保存生效",
|
||||
null,
|
||||
null
|
||||
),
|
||||
BossUi.buildInlineActionRow(this, serverModeButton, ossModeButton),
|
||||
ossForm,
|
||||
BossUi.buildInlineActionRow(this, validateButton)
|
||||
);
|
||||
updateModeUi();
|
||||
}
|
||||
|
||||
private EditText buildTextField(String hint) {
|
||||
EditText field = new EditText(this);
|
||||
field.setSingleLine(true);
|
||||
field.setHint(hint);
|
||||
field.setTextSize(14);
|
||||
field.setInputType(InputType.TYPE_CLASS_TEXT);
|
||||
return field;
|
||||
}
|
||||
|
||||
private void populate(@Nullable JSONObject config) {
|
||||
buildFormContent();
|
||||
if (config != null) {
|
||||
storageMode = config.optString("mode", "server_file");
|
||||
JSONObject aliyunOss = config.optJSONObject("aliyunOss");
|
||||
if (aliyunOss != null) {
|
||||
accessKeyIdField.setText(aliyunOss.optString("accessKeyId", ""));
|
||||
bucketField.setText(aliyunOss.optString("bucket", ""));
|
||||
endpointField.setText(aliyunOss.optString("endpoint", ""));
|
||||
regionField.setText(aliyunOss.optString("region", ""));
|
||||
prefixField.setText(aliyunOss.optString("prefix", "boss/"));
|
||||
}
|
||||
}
|
||||
configLoaded = config != null;
|
||||
updateModeUi();
|
||||
updateSaveAvailability();
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void switchMode(String mode) {
|
||||
storageMode = mode;
|
||||
updateModeUi();
|
||||
}
|
||||
|
||||
private void updateModeUi() {
|
||||
boolean oss = "oss".equals(storageMode);
|
||||
if (serverModeButton != null) {
|
||||
serverModeButton.setText(oss ? "服务器文件存储" : "已选 服务器文件存储");
|
||||
}
|
||||
if (ossModeButton != null) {
|
||||
ossModeButton.setText(oss ? "已选 阿里 OSS" : "阿里 OSS");
|
||||
}
|
||||
if (ossForm != null) {
|
||||
ossForm.setVisibility(oss ? android.view.View.VISIBLE : android.view.View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveConfig(boolean validateFirst) {
|
||||
if (!configLoaded) {
|
||||
showMessage("配置尚未加载完成,请先刷新成功后再保存。");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
JSONObject payload = buildPayload();
|
||||
BossApiClient.ApiResponse response = validateFirst && "oss".equals(storageMode)
|
||||
? apiClient.validateAttachmentStorageConfig(payload)
|
||||
: apiClient.saveAttachmentStorageConfig(payload);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage(validateFirst && "oss".equals(storageMode) ? "测试通过,配置已保存" : "附件存储配置已保存");
|
||||
populate(response.json.optJSONObject("config"));
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("保存失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private JSONObject buildPayload() throws org.json.JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("mode", storageMode);
|
||||
if (!"oss".equals(storageMode)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
JSONObject aliyunOss = new JSONObject();
|
||||
aliyunOss.put("accessKeyId", textOf(accessKeyIdField));
|
||||
aliyunOss.put("bucket", textOf(bucketField));
|
||||
aliyunOss.put("endpoint", textOf(endpointField));
|
||||
aliyunOss.put("region", textOf(regionField));
|
||||
aliyunOss.put("prefix", textOf(prefixField));
|
||||
String secret = textOf(accessKeySecretField);
|
||||
if (!TextUtils.isEmpty(secret)) {
|
||||
aliyunOss.put("accessKeySecret", secret);
|
||||
}
|
||||
payload.put("ossProvider", "aliyun_oss");
|
||||
payload.put("aliyunOss", aliyunOss);
|
||||
return payload;
|
||||
}
|
||||
|
||||
private String textOf(EditText field) {
|
||||
return field == null || field.getText() == null ? "" : field.getText().toString().trim();
|
||||
}
|
||||
|
||||
private void updateSaveAvailability() {
|
||||
if (headerActionButton != null) {
|
||||
headerActionButton.setEnabled(configLoaded);
|
||||
headerActionButton.setAlpha(configLoaded ? 1f : 0.45f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.text.InputType;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.Spinner;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.SwitchCompat;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class TelegramIntegrationActivity extends BossScreenActivity {
|
||||
private SwitchCompat enabledSwitch;
|
||||
private Spinner modeSpinner;
|
||||
private Spinner dmPolicySpinner;
|
||||
private Spinner groupPolicySpinner;
|
||||
private SwitchCompat requireMentionSwitch;
|
||||
private EditText botTokenInput;
|
||||
private EditText webhookSecretInput;
|
||||
private EditText webhookUrlInput;
|
||||
private EditText defaultProjectIdInput;
|
||||
private EditText allowFromInput;
|
||||
private EditText groupsInput;
|
||||
private EditText groupProjectRoutesInput;
|
||||
|
||||
@Nullable private JSONObject currentTelegram;
|
||||
private boolean telegramLoaded = false;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("Telegram 接入", "Bot 网关与白名单");
|
||||
setHeaderAction("保存", v -> saveTelegram(false));
|
||||
buildFormContent();
|
||||
updateActionAvailability();
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getTelegramIntegration();
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> populate(response.json.optJSONObject("telegram")));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
telegramLoaded = false;
|
||||
updateActionAvailability();
|
||||
replaceContent(BossUi.buildEmptyCard(this, "Telegram 配置加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void buildFormContent() {
|
||||
if (enabledSwitch == null) {
|
||||
enabledSwitch = new SwitchCompat(this);
|
||||
enabledSwitch.setText("开启 Telegram 接入");
|
||||
}
|
||||
if (requireMentionSwitch == null) {
|
||||
requireMentionSwitch = new SwitchCompat(this);
|
||||
requireMentionSwitch.setText("群聊要求 @Bot 或回复 Bot");
|
||||
}
|
||||
if (modeSpinner == null) {
|
||||
modeSpinner = new Spinner(this);
|
||||
modeSpinner.setAdapter(new ArrayAdapter<>(
|
||||
this,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
new String[]{"webhook", "polling"}
|
||||
));
|
||||
}
|
||||
if (dmPolicySpinner == null) {
|
||||
dmPolicySpinner = new Spinner(this);
|
||||
dmPolicySpinner.setAdapter(new ArrayAdapter<>(
|
||||
this,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
new String[]{"allowlist", "open", "disabled"}
|
||||
));
|
||||
}
|
||||
if (groupPolicySpinner == null) {
|
||||
groupPolicySpinner = new Spinner(this);
|
||||
groupPolicySpinner.setAdapter(new ArrayAdapter<>(
|
||||
this,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
new String[]{"allowlist", "open", "disabled"}
|
||||
));
|
||||
}
|
||||
if (botTokenInput == null) {
|
||||
botTokenInput = BossUi.buildInput(this, "输入 Telegram Bot Token", false);
|
||||
botTokenInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
}
|
||||
if (webhookSecretInput == null) {
|
||||
webhookSecretInput = BossUi.buildInput(this, "留空则沿用当前 secret", false);
|
||||
webhookSecretInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
}
|
||||
if (webhookUrlInput == null) {
|
||||
webhookUrlInput = BossUi.buildInput(this, "例如 https://boss.hyzq.net/api/v1/integrations/telegram/webhook", false);
|
||||
}
|
||||
if (defaultProjectIdInput == null) {
|
||||
defaultProjectIdInput = BossUi.buildInput(this, "默认 master-agent", false);
|
||||
}
|
||||
if (allowFromInput == null) {
|
||||
allowFromInput = BossUi.buildInput(this, "每行一个 Telegram 用户 ID", true);
|
||||
}
|
||||
if (groupsInput == null) {
|
||||
groupsInput = BossUi.buildInput(this, "每行一个 Telegram 群 chat id", true);
|
||||
}
|
||||
if (groupProjectRoutesInput == null) {
|
||||
groupProjectRoutesInput = BossUi.buildInput(this, "chatId[#topicId] projectId 可选备注", true);
|
||||
}
|
||||
|
||||
replaceContent(buildStatusRow(currentTelegram));
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"Telegram Bot 网关",
|
||||
"主 Agent 可通过 Telegram 私聊或受控群聊接收消息。",
|
||||
"保存 webhook 模式后会自动同步 Telegram Webhook",
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildWechatSwitchRow(this, "开启接入", "关闭后 Boss 不再接收 Telegram 消息", enabledSwitch));
|
||||
appendContent(BossUi.buildFormCell(this, "接入模式", "Webhook 推荐用于正式运行;Polling 仅作兜底。", modeSpinner));
|
||||
appendContent(BossUi.buildFormCell(this, "Bot Token", "留空表示沿用当前已保存 token,不会主动清空。", botTokenInput));
|
||||
appendContent(BossUi.buildFormCell(this, "Webhook Secret", "Telegram webhook secret,建议启用。", webhookSecretInput));
|
||||
appendContent(BossUi.buildFormCell(this, "Webhook URL", "Webhook 模式下使用的公开地址。", webhookUrlInput));
|
||||
appendContent(BossUi.buildFormCell(this, "默认项目", "当前默认路由到 master-agent。", defaultProjectIdInput));
|
||||
appendContent(BossUi.buildFormCell(this, "私聊策略", "allowlist 更安全。", dmPolicySpinner));
|
||||
appendContent(BossUi.buildFormCell(this, "允许私聊用户 ID", "每行一个 Telegram 用户 ID。", allowFromInput));
|
||||
appendContent(BossUi.buildFormCell(this, "群聊策略", "群白名单建议配合 requireMention 使用。", groupPolicySpinner));
|
||||
appendContent(BossUi.buildFormCell(this, "允许群聊 chat id", "每行一个 Telegram 群 chat id。", groupsInput));
|
||||
appendContent(BossUi.buildFormCell(this, "群 / Topic 路由", "每行格式:chatId[#topicId] projectId 可选备注;未命中时回到默认项目。", groupProjectRoutesInput));
|
||||
appendContent(BossUi.buildWechatSwitchRow(this, "群聊要求 @Bot", "开启后只有 @bot_username 或回复当前 Bot 的消息才会进入主 Agent。", requireMentionSwitch));
|
||||
|
||||
android.widget.Button testButton = BossUi.buildSecondaryButton(this, "测试连接");
|
||||
testButton.setOnClickListener(v -> saveTelegram(true));
|
||||
appendContent(testButton);
|
||||
|
||||
TextView noteView = BossUi.buildHintPill(this, "提示:保存为 webhook 模式时会自动 setWebhook;切回 polling/关闭时会自动 deleteWebhook。");
|
||||
appendContent(noteView);
|
||||
}
|
||||
|
||||
private void populate(@Nullable JSONObject telegram) {
|
||||
currentTelegram = telegram;
|
||||
buildFormContent();
|
||||
|
||||
if (telegram != null) {
|
||||
enabledSwitch.setChecked(telegram.optBoolean("enabled", false));
|
||||
|
||||
String mode = telegram.optString("mode", "webhook");
|
||||
modeSpinner.setSelection("polling".equals(mode) ? 1 : 0);
|
||||
|
||||
String dmPolicy = telegram.optString("dmPolicy", "allowlist");
|
||||
dmPolicySpinner.setSelection(policySelection(dmPolicy));
|
||||
|
||||
String groupPolicy = telegram.optString("groupPolicy", "allowlist");
|
||||
groupPolicySpinner.setSelection(policySelection(groupPolicy));
|
||||
|
||||
requireMentionSwitch.setChecked(telegram.optBoolean("requireMentionInGroups", true));
|
||||
webhookUrlInput.setText(telegram.optString("webhookUrl", ""));
|
||||
defaultProjectIdInput.setText(telegram.optString("defaultProjectId", "master-agent"));
|
||||
allowFromInput.setText(joinLines(telegram.optJSONArray("allowFrom")));
|
||||
groupsInput.setText(joinLines(telegram.optJSONArray("groups")));
|
||||
groupProjectRoutesInput.setText(formatGroupProjectRoutes(telegram.optJSONArray("groupProjectRoutes")));
|
||||
}
|
||||
|
||||
telegramLoaded = telegram != null;
|
||||
updateActionAvailability();
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private LinearLayout buildStatusRow(@Nullable JSONObject telegram) {
|
||||
return BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"当前状态",
|
||||
buildStatusSummary(telegram),
|
||||
buildStatusMeta(telegram),
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private String buildStatusSummary(@Nullable JSONObject telegram) {
|
||||
if (telegram == null) {
|
||||
return "接入:加载中\n模式:未加载\nBot:未识别";
|
||||
}
|
||||
String botUsername = telegram.optString("botUsername", "").trim();
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("接入:").append(telegram.optBoolean("enabled", false) ? "已开启" : "已关闭");
|
||||
builder.append("\n模式:").append("polling".equals(telegram.optString("mode", "webhook")) ? "Polling" : "Webhook");
|
||||
builder.append("\nBot:").append(botUsername.isEmpty() ? "未识别" : "@" + botUsername);
|
||||
builder.append("\nToken:").append(telegram.optBoolean("botTokenConfigured", false) ? "已配置" : "未配置");
|
||||
builder.append("\nWebhook Secret:").append(telegram.optBoolean("webhookSecretConfigured", false) ? "已配置" : "未配置");
|
||||
builder.append("\n默认项目:").append(telegram.optString("defaultProjectId", "master-agent"));
|
||||
builder.append("\n已处理 update:").append(telegram.optInt("processedUpdateCount", 0));
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String buildStatusMeta(@Nullable JSONObject telegram) {
|
||||
if (telegram == null) {
|
||||
return "加载完成后可测试连接或保存配置。";
|
||||
}
|
||||
String lastError = telegram.optString("lastError", "").trim();
|
||||
if (!lastError.isEmpty()) {
|
||||
return "最近错误:" + lastError;
|
||||
}
|
||||
return "状态正常时,Telegram 消息会进入主 Agent。";
|
||||
}
|
||||
|
||||
private int policySelection(String policy) {
|
||||
switch (policy) {
|
||||
case "open":
|
||||
return 1;
|
||||
case "disabled":
|
||||
return 2;
|
||||
case "allowlist":
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private String joinLines(@Nullable org.json.JSONArray array) {
|
||||
if (array == null || array.length() == 0) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int index = 0; index < array.length(); index += 1) {
|
||||
String value = array.optString(index, "").trim();
|
||||
if (value.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append("\n");
|
||||
}
|
||||
builder.append(value);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private org.json.JSONArray parseLines(EditText input) {
|
||||
org.json.JSONArray array = new org.json.JSONArray();
|
||||
String[] lines = input.getText().toString().split("\\r?\\n");
|
||||
for (String line : lines) {
|
||||
String trimmed = line == null ? "" : line.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
array.put(trimmed);
|
||||
}
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
private String formatGroupProjectRoutes(@Nullable org.json.JSONArray routes) {
|
||||
if (routes == null || routes.length() == 0) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int index = 0; index < routes.length(); index += 1) {
|
||||
JSONObject route = routes.optJSONObject(index);
|
||||
if (route == null) {
|
||||
continue;
|
||||
}
|
||||
String chatId = route.optString("chatId", "").trim();
|
||||
String projectId = route.optString("projectId", "").trim();
|
||||
if (chatId.isEmpty() || projectId.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append("\n");
|
||||
}
|
||||
builder.append(chatId);
|
||||
if (route.has("threadId")) {
|
||||
builder.append("#").append(route.optInt("threadId"));
|
||||
}
|
||||
builder.append(" ").append(projectId);
|
||||
String label = route.optString("label", "").trim();
|
||||
if (!label.isEmpty()) {
|
||||
builder.append(" ").append(label);
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private org.json.JSONArray parseGroupProjectRoutes(EditText input) throws org.json.JSONException {
|
||||
org.json.JSONArray array = new org.json.JSONArray();
|
||||
String[] lines = input.getText().toString().split("\\r?\\n");
|
||||
for (String line : lines) {
|
||||
String trimmed = line == null ? "" : line.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
String[] parts = trimmed.split("\\s+", 3);
|
||||
if (parts.length < 2) {
|
||||
continue;
|
||||
}
|
||||
String[] chatParts = parts[0].split("#", 2);
|
||||
String chatId = chatParts[0].trim();
|
||||
String projectId = parts[1].trim();
|
||||
if (chatId.isEmpty() || projectId.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
JSONObject route = new JSONObject();
|
||||
route.put("chatId", chatId);
|
||||
if (chatParts.length > 1) {
|
||||
try {
|
||||
route.put("threadId", Integer.parseInt(chatParts[1].trim()));
|
||||
} catch (NumberFormatException ignored) {
|
||||
// Invalid topic id is ignored so the chat-level route can still be saved.
|
||||
}
|
||||
}
|
||||
route.put("projectId", projectId);
|
||||
if (parts.length > 2 && !parts[2].trim().isEmpty()) {
|
||||
route.put("label", parts[2].trim());
|
||||
}
|
||||
array.put(route);
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
private void saveTelegram(boolean testConnection) {
|
||||
if (!telegramLoaded) {
|
||||
showMessage("配置尚未加载完成,请先刷新成功后再保存。");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("enabled", enabledSwitch.isChecked());
|
||||
payload.put("mode", String.valueOf(modeSpinner.getSelectedItem()));
|
||||
payload.put("botToken", emptyToNull(botTokenInput.getText().toString()));
|
||||
payload.put("webhookSecret", emptyToNull(webhookSecretInput.getText().toString()));
|
||||
payload.put("webhookUrl", emptyToNull(webhookUrlInput.getText().toString()));
|
||||
payload.put("defaultProjectId", emptyToNull(defaultProjectIdInput.getText().toString()));
|
||||
payload.put("dmPolicy", String.valueOf(dmPolicySpinner.getSelectedItem()));
|
||||
payload.put("allowFrom", parseLines(allowFromInput));
|
||||
payload.put("groupPolicy", String.valueOf(groupPolicySpinner.getSelectedItem()));
|
||||
payload.put("groups", parseLines(groupsInput));
|
||||
payload.put("groupProjectRoutes", parseGroupProjectRoutes(groupProjectRoutesInput));
|
||||
payload.put("requireMentionInGroups", requireMentionSwitch.isChecked());
|
||||
payload.put("testConnection", testConnection);
|
||||
BossApiClient.ApiResponse response = apiClient.updateTelegramIntegration(payload);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
JSONObject telegram = response.json.optJSONObject("telegram");
|
||||
populate(telegram);
|
||||
String probeUsername = "";
|
||||
JSONObject probe = response.json.optJSONObject("probe");
|
||||
if (probe != null) {
|
||||
probeUsername = probe.optString("username", "");
|
||||
}
|
||||
showMessage(testConnection
|
||||
? (probeUsername.isEmpty() ? "Telegram 连接测试通过" : "连接测试通过:@" + probeUsername)
|
||||
: "Telegram 配置已保存");
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("Telegram 配置失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Object emptyToNull(String value) {
|
||||
String trimmed = value == null ? "" : value.trim();
|
||||
return trimmed.isEmpty() ? JSONObject.NULL : trimmed;
|
||||
}
|
||||
|
||||
private void updateActionAvailability() {
|
||||
if (headerActionButton != null) {
|
||||
headerActionButton.setEnabled(telegramLoaded);
|
||||
headerActionButton.setAlpha(telegramLoaded ? 1f : 0.45f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,101 +12,6 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class WechatSurfaceMapper {
|
||||
private static final String[] PROCESS_PREVIEW_PREFIXES = new String[] {
|
||||
"我先",
|
||||
"我现在",
|
||||
"我会先",
|
||||
"我发现",
|
||||
"我准备",
|
||||
"接下来",
|
||||
"正在",
|
||||
"先看",
|
||||
"先读",
|
||||
"我把",
|
||||
"我再",
|
||||
"目前在",
|
||||
"现在在",
|
||||
"补一组",
|
||||
"处理一下",
|
||||
"先确认",
|
||||
"准备",
|
||||
"同步一下",
|
||||
"我这边已经"
|
||||
};
|
||||
|
||||
private static final String[] PROCESS_PREVIEW_CONTAINS = new String[] {
|
||||
"我继续",
|
||||
"我已经在",
|
||||
"正在跑",
|
||||
"正在检查",
|
||||
"正在处理",
|
||||
"正在同步",
|
||||
"我会直接",
|
||||
"我先把",
|
||||
"先补",
|
||||
"再接"
|
||||
};
|
||||
|
||||
private static final String[] PROCESS_PREVIEW_NUMBERED_HINTS = new String[] {
|
||||
"先",
|
||||
"再",
|
||||
"接下来",
|
||||
"然后",
|
||||
"检查",
|
||||
"确认",
|
||||
"处理",
|
||||
"同步",
|
||||
"补",
|
||||
"排查",
|
||||
"推进",
|
||||
"回你",
|
||||
"回传",
|
||||
"会把",
|
||||
"我会"
|
||||
};
|
||||
|
||||
private static final String[] PROCESS_PREVIEW_BLOCK_MARKERS = new String[] {
|
||||
"失败",
|
||||
"报错",
|
||||
"错误",
|
||||
"阻塞",
|
||||
"不能",
|
||||
"无法",
|
||||
"崩溃",
|
||||
"超时",
|
||||
"exception",
|
||||
"error",
|
||||
"fatal",
|
||||
"结论",
|
||||
"最终",
|
||||
"总结",
|
||||
"已完成",
|
||||
"已经完成",
|
||||
"验证通过"
|
||||
};
|
||||
|
||||
private static final String[] LEAKED_TITLE_PREFIXES = new String[] {
|
||||
"你当前接手的项目根目录是",
|
||||
"你现在接手的项目根目录是",
|
||||
"你现在以目标线程身份直接回复用户",
|
||||
"你正在向主 Agent 同步当前项目状态",
|
||||
"只回复对用户真正有用的内容",
|
||||
"只输出 JSON"
|
||||
};
|
||||
|
||||
private static final String[] LEAKED_TITLE_CONTAINS = new String[] {
|
||||
"不要发送内部字段",
|
||||
"不要自称主 Agent",
|
||||
"不要解释系统如何分发",
|
||||
"不要输出 JSON",
|
||||
"项目名称:",
|
||||
"线程名称:",
|
||||
"文件夹:",
|
||||
"同步原因:",
|
||||
"当前消息:",
|
||||
"用户当前消息:"
|
||||
};
|
||||
|
||||
private static final List<String> ROOT_TAB_LABELS = Arrays.asList(
|
||||
"会话",
|
||||
"设备",
|
||||
@@ -114,13 +19,14 @@ public final class WechatSurfaceMapper {
|
||||
);
|
||||
|
||||
private static final List<MeMenuItem> ROOT_ME_MENU_ITEMS = Arrays.asList(
|
||||
new MeMenuItem("master_agent_prompt", "主 Agent 提示词", "配置全局主提示词和当前对话提示词"),
|
||||
new MeMenuItem("master_agent_memory", "主 Agent 记忆", "维护用户通用记忆和项目记忆"),
|
||||
new MeMenuItem("master_agent_takeover", "全局接管", "设置主 Agent 是否默认协同推进线程"),
|
||||
new MeMenuItem("master_agent_evolution", "主 Agent 自动进化", "查看进化信号、提案与自动采纳规则"),
|
||||
new MeMenuItem("security", "账号与安全", "修改登录密码、设备安全与身份校验"),
|
||||
new MeMenuItem("settings", "设置", "默认首页、提醒方式与危险操作确认"),
|
||||
new MeMenuItem("access", "用户与权限", "分配子账号、设备、项目与 Skill 权限"),
|
||||
new MeMenuItem("ops", "运维与修复", "查看运维会话、修复回放与 standby 切换"),
|
||||
new MeMenuItem("ai_accounts", "AI 账号", "管理主 GPT、备用 GPT 与 API 容灾"),
|
||||
new MeMenuItem("storage", "附件与存储", "配置附件上传位置、服务器文件与阿里 OSS"),
|
||||
new MeMenuItem("telegram", "Telegram 接入", "配置 Telegram Bot、Webhook 与白名单"),
|
||||
new MeMenuItem("skills", "技能", "按设备查看 Skill 清单"),
|
||||
new MeMenuItem("about", "关于", "当前版本、OTA 状态与更新内容")
|
||||
);
|
||||
@@ -157,20 +63,14 @@ public final class WechatSurfaceMapper {
|
||||
JSONObject avatar = source.optJSONObject("avatar");
|
||||
boolean isGroup = source.optBoolean("isGroup", groupAvatarMembers.size() > 1);
|
||||
String conversationType = source.optString("conversationType", "");
|
||||
String folderLabel = normalizeConversationTitle(source.optString("folderLabel", ""));
|
||||
String threadTitle = sanitizeConversationTitle(
|
||||
source.optString("threadTitle", source.optString("title", source.optString("projectTitle", ""))),
|
||||
folderLabel,
|
||||
source.optString("projectTitle", "")
|
||||
String threadTitle = trimLocalWorkspacePrefix(
|
||||
source.optString("threadTitle", source.optString("title", source.optString("projectTitle", "")))
|
||||
);
|
||||
String projectId = source.optString("projectId", "").trim();
|
||||
if ("folder_archive".equals(conversationType)) {
|
||||
threadTitle = source.optString(
|
||||
"projectTitle",
|
||||
source.optString("threadTitle", source.optString("title", source.optString("folderLabel", "")))
|
||||
);
|
||||
} else if (isPinnedSystemProject(projectId)) {
|
||||
threadTitle = source.optString("projectTitle", threadTitle);
|
||||
}
|
||||
String pinnedLabel = source.optString("topPinnedLabel", "");
|
||||
return new ConversationRow(
|
||||
@@ -248,116 +148,6 @@ public final class WechatSurfaceMapper {
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
public static boolean hasCodexAppServerCapability(JSONObject device) {
|
||||
return resolveDeviceCapability(device, "codexAppServer") != null;
|
||||
}
|
||||
|
||||
public static boolean hasCodexAppServerMetadata(JSONObject device) {
|
||||
return resolveCodexAppServerMetadata(device) != null;
|
||||
}
|
||||
|
||||
public static String deviceCodexAppServerStatusLabel(JSONObject device) {
|
||||
JSONObject capability = resolveDeviceCapability(device, "codexAppServer");
|
||||
boolean connected = capability != null && capability.optBoolean("connected", false);
|
||||
return connected ? "已连接" : "未连接";
|
||||
}
|
||||
|
||||
public static String deviceCodexAppServerDetailLabel(JSONObject device) {
|
||||
JSONObject capability = resolveDeviceCapability(device, "codexAppServer");
|
||||
if (capability == null) {
|
||||
return "等待 boss-agent 上报 App Server 能力";
|
||||
}
|
||||
String lastSeenAt = capability.optString("lastSeenAt", "").trim();
|
||||
if (lastSeenAt.isEmpty()) {
|
||||
return "线程、模型、Skill 与协议探测";
|
||||
}
|
||||
return "最近上报 " + lastSeenAt;
|
||||
}
|
||||
|
||||
public static String deviceCodexModelSummary(JSONObject device) {
|
||||
JSONObject metadata = resolveCodexAppServerMetadata(device);
|
||||
int modelCount = metadataArrayLength(metadata, "models");
|
||||
if (modelCount <= 0) {
|
||||
return "未发现";
|
||||
}
|
||||
return modelCount + " 个"
|
||||
+ " · 默认 " + metadataText(metadata, "defaultModelId")
|
||||
+ " · 快速 " + metadataText(metadata, "fastModelId")
|
||||
+ " · 深度 " + metadataText(metadata, "deepModelId");
|
||||
}
|
||||
|
||||
public static String deviceCodexExtensionSummary(JSONObject device) {
|
||||
JSONObject metadata = resolveCodexAppServerMetadata(device);
|
||||
return "Skill " + metadataArrayLength(metadata, "skills") + " 个"
|
||||
+ " · Plugin " + metadataArrayLength(metadata, "plugins") + " 个"
|
||||
+ " · App " + metadataArrayLength(metadata, "apps") + " 个";
|
||||
}
|
||||
|
||||
public static String deviceCodexGovernanceSummary(JSONObject device) {
|
||||
JSONObject metadata = resolveCodexAppServerMetadata(device);
|
||||
return "实验特性 " + metadataArrayLength(metadata, "experimentalFeatures") + " 个"
|
||||
+ " · 协作模式 " + metadataArrayLength(metadata, "collaborationModes") + " 个"
|
||||
+ " · MCP " + metadataArrayLength(metadata, "mcpServers") + " 个"
|
||||
+ " · 权限 " + metadataArrayLength(metadata, "permissionProfiles") + " 个";
|
||||
}
|
||||
|
||||
public static String deviceCodexAccountSummary(JSONObject device) {
|
||||
JSONObject metadata = resolveCodexAppServerMetadata(device);
|
||||
JSONObject accountSummary = metadata == null ? null : metadata.optJSONObject("accountSummary");
|
||||
JSONObject rateLimitSummary = metadata == null ? null : metadata.optJSONObject("rateLimitSummary");
|
||||
return objectText(accountSummary, "authMode")
|
||||
+ " · 套餐 " + objectText(accountSummary, "planType")
|
||||
+ " · 额度 " + objectInt(rateLimitSummary, "maxUsedPercent") + "%";
|
||||
}
|
||||
|
||||
public static String deviceCodexThreadSummary(JSONObject device) {
|
||||
JSONObject summary = resolveCodexAppServerMetadataObject(device, "threadSummary");
|
||||
return objectInt(summary, "threadCount") + " 个"
|
||||
+ " · 已加载 " + objectInt(summary, "loadedThreadCount") + " 个"
|
||||
+ " · 活跃 " + objectInt(summary, "activeThreadCount") + " 个";
|
||||
}
|
||||
|
||||
public static String deviceCodexTurnSummary(JSONObject device) {
|
||||
JSONObject summary = resolveCodexAppServerMetadataObject(device, "threadTurnSummary");
|
||||
return objectInt(summary, "totalTurnCount") + " 个"
|
||||
+ " · 运行中 " + objectInt(summary, "runningTurnCount") + " 个"
|
||||
+ " · 完成 " + objectInt(summary, "completedTurnCount") + " 个";
|
||||
}
|
||||
|
||||
public static String deviceCodexThreadActionSummary(JSONObject device) {
|
||||
JSONObject summary = resolveCodexAppServerMetadataObject(device, "threadActionSummary");
|
||||
return objectInt(summary, "actionCount") + " 项"
|
||||
+ " · 生命周期 " + objectInt(summary, "lifecycleActionCount") + " 项"
|
||||
+ " · 活跃干预 " + objectInt(summary, "liveTurnActionCount") + " 项"
|
||||
+ " · " + (summary != null && summary.optBoolean("shellActionAvailable", false) ? "Shell 可用" : "Shell 不可用");
|
||||
}
|
||||
|
||||
public static String deviceCodexThreadCollaborationSummary(JSONObject device) {
|
||||
JSONObject summary = resolveCodexAppServerMetadataObject(device, "threadCollaborationSummary");
|
||||
if (summary == null) {
|
||||
return "等待线程协作探测";
|
||||
}
|
||||
String broker = summary.optBoolean("bossBrokerAvailable", false) ? "Boss Broker 可用" : "Boss Broker 不可用";
|
||||
String handler = summary.optBoolean("collabToolCallHandlerAvailable", false) ? "协作事件可处理" : "协作事件不可处理";
|
||||
String modes = summary.optInt("collaborationModeCount", 0) + " 种模式";
|
||||
String direct = summary.optBoolean("directThreadChatSupported", false) ? "原生私聊" : "非原生私聊";
|
||||
return broker + " · " + handler + " · " + modes + " · " + direct;
|
||||
}
|
||||
|
||||
public static String deviceCodexProtocolDriftSummary(JSONObject device) {
|
||||
JSONObject summary = resolveCodexAppServerMetadataObject(device, "protocolDriftSummary");
|
||||
if (summary == null) {
|
||||
return "等待协议探测";
|
||||
}
|
||||
String drift = "compatible".equals(summary.optString("driftLevel", "")) ? "兼容" : "告警";
|
||||
String failedProbeCount = "失败探针 " + summary.optInt("failedProbeCount", 0) + " 个";
|
||||
String docFollowupCount = "文档跟进 " + summary.optInt("docFollowupCount", 0) + " 项";
|
||||
String fallbackStrategy = summary.optString("fallbackStrategy", "").contains("Boss Broker")
|
||||
? "Boss Broker 兜底"
|
||||
: "App Server 兜底";
|
||||
return drift + " · " + failedProbeCount + " · " + docFollowupCount + " · " + fallbackStrategy;
|
||||
}
|
||||
|
||||
public static String devicePreferredExecutionModeLabel(JSONObject device) {
|
||||
return "gui".equals(device == null ? "" : device.optString("preferredExecutionMode", "")) ? "GUI" : "CLI";
|
||||
}
|
||||
@@ -402,36 +192,10 @@ public final class WechatSurfaceMapper {
|
||||
return titles;
|
||||
}
|
||||
|
||||
public static String[] rootMeMenuTitlesForRole(String role) {
|
||||
List<MeMenuItem> items = rootMeMenuItemsForRoleList(role);
|
||||
String[] titles = new String[items.size()];
|
||||
for (int i = 0; i < items.size(); i++) {
|
||||
titles[i] = items.get(i).title;
|
||||
}
|
||||
return titles;
|
||||
}
|
||||
|
||||
public static MeMenuItem[] rootMeMenuItems() {
|
||||
return ROOT_ME_MENU_ITEMS.toArray(new MeMenuItem[0]);
|
||||
}
|
||||
|
||||
public static MeMenuItem[] rootMeMenuItemsForRole(String role) {
|
||||
List<MeMenuItem> items = rootMeMenuItemsForRoleList(role);
|
||||
return items.toArray(new MeMenuItem[0]);
|
||||
}
|
||||
|
||||
public static boolean canOpenMeEntryForRole(String key, String role) {
|
||||
if (key == null || key.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (MeMenuItem item : rootMeMenuItemsForRoleList(role)) {
|
||||
if (item.key.equals(key)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static MeMenuItem findMeMenuItem(String key) {
|
||||
for (MeMenuItem item : ROOT_ME_MENU_ITEMS) {
|
||||
if (item.key.equals(key)) {
|
||||
@@ -441,34 +205,6 @@ public final class WechatSurfaceMapper {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<MeMenuItem> rootMeMenuItemsForRoleList(String role) {
|
||||
if ("highest_admin".equals(role)) {
|
||||
return ROOT_ME_MENU_ITEMS;
|
||||
}
|
||||
List<MeMenuItem> visible = new ArrayList<>();
|
||||
for (MeMenuItem item : ROOT_ME_MENU_ITEMS) {
|
||||
if (!isHighestAdminOnlyMeEntry(item.key) && (isAdministratorRole(role) || !isAdministratorOnlyMeEntry(item.key))) {
|
||||
visible.add(item);
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
private static boolean isAdministratorRole(String role) {
|
||||
return "highest_admin".equals(role) || "admin".equals(role);
|
||||
}
|
||||
|
||||
private static boolean isAdministratorOnlyMeEntry(String key) {
|
||||
return "ops".equals(key)
|
||||
|| "ai_accounts".equals(key)
|
||||
|| "storage".equals(key)
|
||||
|| "telegram".equals(key);
|
||||
}
|
||||
|
||||
private static boolean isHighestAdminOnlyMeEntry(String key) {
|
||||
return "access".equals(key);
|
||||
}
|
||||
|
||||
public static String[] projectQuickActions() {
|
||||
return PROJECT_QUICK_ACTIONS.toArray(new String[0]);
|
||||
}
|
||||
@@ -517,10 +253,6 @@ public final class WechatSurfaceMapper {
|
||||
return "cancel_on_detach";
|
||||
}
|
||||
|
||||
private static boolean isPinnedSystemProject(String projectId) {
|
||||
return "master-agent".equals(projectId) || "audit-collab".equals(projectId);
|
||||
}
|
||||
|
||||
private static String buildContextStatusLabel(JSONObject source) {
|
||||
if (source.optBoolean("mustFinishBeforeCompaction", false)) {
|
||||
return "必须收尾";
|
||||
@@ -589,57 +321,12 @@ public final class WechatSurfaceMapper {
|
||||
return capabilities.optJSONObject(capabilityKey);
|
||||
}
|
||||
|
||||
private static JSONObject resolveCodexAppServerMetadataObject(JSONObject device, String key) {
|
||||
JSONObject metadata = resolveCodexAppServerMetadata(device);
|
||||
return metadata == null ? null : metadata.optJSONObject(key);
|
||||
}
|
||||
|
||||
private static JSONObject resolveCodexAppServerMetadata(JSONObject device) {
|
||||
JSONObject capability = resolveDeviceCapability(device, "codexAppServer");
|
||||
return capability == null ? null : capability.optJSONObject("metadata");
|
||||
}
|
||||
|
||||
private static int metadataArrayLength(JSONObject metadata, String key) {
|
||||
if (metadata == null) {
|
||||
return 0;
|
||||
}
|
||||
JSONArray array = metadata.optJSONArray(key);
|
||||
return array == null ? 0 : array.length();
|
||||
}
|
||||
|
||||
private static String metadataText(JSONObject metadata, String key) {
|
||||
if (metadata == null) {
|
||||
return "未知";
|
||||
}
|
||||
String value = metadata.optString(key, "").trim();
|
||||
return value.isEmpty() ? "未知" : value;
|
||||
}
|
||||
|
||||
private static String objectText(JSONObject object, String key) {
|
||||
if (object == null) {
|
||||
return "未知";
|
||||
}
|
||||
String value = object.optString(key, "").trim();
|
||||
return value.isEmpty() ? "未知" : value;
|
||||
}
|
||||
|
||||
private static int objectInt(JSONObject object, String key) {
|
||||
return object == null ? 0 : object.optInt(key, 0);
|
||||
}
|
||||
|
||||
public static RootTopAction rootTopAction(String activeTab, boolean refreshing) {
|
||||
return rootTopAction(activeTab, refreshing, false);
|
||||
}
|
||||
|
||||
public static RootTopAction rootTopAction(String activeTab, boolean refreshing, boolean selectionMode) {
|
||||
return rootTopAction(activeTab, refreshing, selectionMode, "highest_admin");
|
||||
}
|
||||
|
||||
public static RootTopAction rootTopAction(String activeTab, boolean refreshing, boolean selectionMode, String role) {
|
||||
if ("devices".equals(activeTab)) {
|
||||
if (!"highest_admin".equals(role)) {
|
||||
return new RootTopAction("刷新", false, true, "refresh", "refresh");
|
||||
}
|
||||
return new RootTopAction("添加设备", false, true, "add", "add_device");
|
||||
}
|
||||
if ("conversations".equals(activeTab)) {
|
||||
@@ -1031,156 +718,9 @@ public final class WechatSurfaceMapper {
|
||||
if (preview.matches("^已从设备.+导入线程《.+》[。.]?$")) {
|
||||
return "已导入线程";
|
||||
}
|
||||
if (isLikelyProcessPreview(preview)) {
|
||||
return "";
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
private static boolean isLikelyProcessPreview(String value) {
|
||||
String preview = value == null ? "" : value
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.replaceAll("\\n{2,}", "\n")
|
||||
.trim();
|
||||
if (preview.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (containsMarker(preview, PROCESS_PREVIEW_BLOCK_MARKERS)) {
|
||||
return false;
|
||||
}
|
||||
if (isStructuredNumberedProcessPreview(preview)) {
|
||||
return true;
|
||||
}
|
||||
String normalized = preview.toLowerCase(java.util.Locale.ROOT);
|
||||
for (String marker : PROCESS_PREVIEW_PREFIXES) {
|
||||
if (normalized.startsWith(marker.toLowerCase(java.util.Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (String marker : PROCESS_PREVIEW_CONTAINS) {
|
||||
if (normalized.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isStructuredNumberedProcessPreview(String value) {
|
||||
String[] rawLines = value
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.split("\n");
|
||||
ArrayList<String> numberedLines = new ArrayList<>();
|
||||
for (String rawLine : rawLines) {
|
||||
String normalizedLine = rawLine == null ? "" : rawLine.trim();
|
||||
if (normalizedLine.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (normalizedLine.matches("^\\d+[.)、]\\s*.+$")) {
|
||||
numberedLines.add(normalizedLine);
|
||||
}
|
||||
}
|
||||
if (numberedLines.size() < 2) {
|
||||
return false;
|
||||
}
|
||||
String merged = android.text.TextUtils.join(" ", numberedLines)
|
||||
.toLowerCase(java.util.Locale.ROOT);
|
||||
return containsMarker(merged, PROCESS_PREVIEW_NUMBERED_HINTS);
|
||||
}
|
||||
|
||||
private static boolean containsMarker(String value, String[] markers) {
|
||||
String normalized = value == null ? "" : value.toLowerCase(java.util.Locale.ROOT);
|
||||
for (String marker : markers) {
|
||||
if (normalized.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String normalizeConversationTitle(String value) {
|
||||
String source = value == null ? "" : value.replace("\u0000", "");
|
||||
String[] lines = source.split("\\r?\\n");
|
||||
for (String line : lines) {
|
||||
if (line == null) {
|
||||
continue;
|
||||
}
|
||||
String trimmed = line.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
return trimmed.replaceAll("\\s+", " ");
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String stripTrailingConversationTitleNoise(String value) {
|
||||
return value == null ? "" : value.replaceAll("['\"}\\]]{2,}$", "").trim();
|
||||
}
|
||||
|
||||
private static boolean looksLikeLeakedConversationTitle(String value) {
|
||||
String normalized = normalizeConversationTitle(value);
|
||||
if (normalized.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (String marker : LEAKED_TITLE_PREFIXES) {
|
||||
if (normalized.startsWith(marker)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (String marker : LEAKED_TITLE_CONTAINS) {
|
||||
if (normalized.contains(marker)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String extractWorkspaceProjectName(String value) {
|
||||
String normalized = normalizeConversationTitle(value).replace('\\', '/');
|
||||
if (normalized.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
String[] patterns = new String[] {
|
||||
".*/Users/[^/]+/code/([^/\\s\"'`,。;!?]+).*",
|
||||
".*/home/[^/]+/code/([^/\\s\"'`,。;!?]+).*",
|
||||
".*[A-Za-z]:/Users/[^/]+/code/([^/\\s\"'`,。;!?]+).*"
|
||||
};
|
||||
for (String pattern : patterns) {
|
||||
if (normalized.matches(pattern)) {
|
||||
return normalized.replaceFirst(pattern, "$1").split("/")[0].trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String sanitizeConversationTitle(String value, String... fallbackCandidates) {
|
||||
String normalized = normalizeConversationTitle(value);
|
||||
String trimmed = stripTrailingConversationTitleNoise(trimLocalWorkspacePrefix(normalized));
|
||||
if (!trimmed.isEmpty() && !looksLikeLeakedConversationTitle(normalized) && !looksLikeLeakedConversationTitle(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
String extractedProject = extractWorkspaceProjectName(normalized);
|
||||
if (!extractedProject.isEmpty() && !looksLikeLeakedConversationTitle(extractedProject)) {
|
||||
return extractedProject;
|
||||
}
|
||||
|
||||
for (String fallbackCandidate : fallbackCandidates) {
|
||||
String extractedFallback = extractWorkspaceProjectName(fallbackCandidate);
|
||||
if (!extractedFallback.isEmpty() && !looksLikeLeakedConversationTitle(extractedFallback)) {
|
||||
return extractedFallback;
|
||||
}
|
||||
String normalizedFallback = stripTrailingConversationTitleNoise(
|
||||
trimLocalWorkspacePrefix(normalizeConversationTitle(fallbackCandidate))
|
||||
);
|
||||
if (!normalizedFallback.isEmpty() && !looksLikeLeakedConversationTitle(normalizedFallback)) {
|
||||
return normalizedFallback;
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static String trimLocalWorkspacePrefix(String value) {
|
||||
String label = value == null ? "" : value.trim();
|
||||
if (label.isEmpty()) {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/boss_surface" />
|
||||
<corners android:radius="24dp" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/boss_card_stroke" />
|
||||
</shape>
|
||||
@@ -1,10 +0,0 @@
|
||||
<?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="#FF111111"
|
||||
android:pathData="M7.41,8.59L6,10l6,6 6,-6 -1.41,-1.41L12,13.17 7.41,8.59Z" />
|
||||
</vector>
|
||||
@@ -1,10 +0,0 @@
|
||||
<?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="@color/boss_text_muted"
|
||||
android:pathData="M5,6.5C5,4.57 6.57,3 8.5,3H15.5C17.43,3 19,4.57 19,6.5V11.2C19,13.13 17.43,14.7 15.5,14.7H11.25L7.92,18.03C7.55,18.4 6.92,18.14 6.92,17.62V14.54C5.8,14.04 5,12.91 5,11.6V6.5ZM8.4,8.1C7.82,8.1 7.35,8.57 7.35,9.15C7.35,9.73 7.82,10.2 8.4,10.2C8.98,10.2 9.45,9.73 9.45,9.15C9.45,8.57 8.98,8.1 8.4,8.1ZM12,8.1C11.42,8.1 10.95,8.57 10.95,9.15C10.95,9.73 11.42,10.2 12,10.2C12.58,10.2 13.05,9.73 13.05,9.15C13.05,8.57 12.58,8.1 12,8.1ZM15.6,8.1C15.02,8.1 14.55,8.57 14.55,9.15C14.55,9.73 15.02,10.2 15.6,10.2C16.18,10.2 16.65,9.73 16.65,9.15C16.65,8.57 16.18,8.1 15.6,8.1Z" />
|
||||
</vector>
|
||||
@@ -1,10 +0,0 @@
|
||||
<?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="@color/boss_text_muted"
|
||||
android:pathData="M12,2.8L20,7.1V16.9L12,21.2L4,16.9V7.1L12,2.8ZM6.2,8.42V15.58L10.9,18.11V10.95L6.2,8.42ZM12,9.05L16.78,6.48L12,3.91L7.22,6.48L12,9.05ZM13.1,10.95V18.11L17.8,15.58V8.42L13.1,10.95Z" />
|
||||
</vector>
|
||||
@@ -1,10 +0,0 @@
|
||||
<?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="@color/boss_text_muted"
|
||||
android:pathData="M12,12.1C9.65,12.1 7.75,10.2 7.75,7.85C7.75,5.5 9.65,3.6 12,3.6C14.35,3.6 16.25,5.5 16.25,7.85C16.25,10.2 14.35,12.1 12,12.1ZM4.8,19.5C5.44,16.13 8.39,13.58 12,13.58C15.61,13.58 18.56,16.13 19.2,19.5C19.31,20.09 18.85,20.63 18.25,20.63H5.75C5.15,20.63 4.69,20.09 4.8,19.5Z" />
|
||||
</vector>
|
||||
@@ -12,18 +12,18 @@
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="7dp">
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_back_button"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="返回"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_boss_back"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
@@ -37,49 +37,43 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:text="会话信息"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:ellipsize="end"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:text="单线程会话信息页"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="11sp" />
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="更多"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_boss_more"
|
||||
android:tint="@color/boss_text_primary"
|
||||
android:visibility="gone" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_refresh_button"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="刷新"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_boss_refresh"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="7dp">
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_back_button"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="返回"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_boss_back"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
@@ -37,49 +37,43 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:text="标题"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:ellipsize="end"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:text="副标题"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="11sp" />
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="更多"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_boss_more"
|
||||
android:tint="@color/boss_text_primary"
|
||||
android:visibility="gone" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_refresh_button"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="刷新"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_boss_refresh"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="7dp">
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_back_button"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="返回"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_boss_back"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
@@ -37,49 +37,43 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:text="发起群聊"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:ellipsize="end"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:text="从当前会话选择其他线程"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="11sp" />
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="更多"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_boss_more"
|
||||
android:tint="@color/boss_text_primary"
|
||||
android:visibility="gone" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_refresh_button"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="刷新"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_boss_refresh"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="7dp">
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_back_button"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="返回"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_boss_back"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
@@ -37,49 +37,43 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:text="群资料"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:ellipsize="end"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:text="群聊资料页"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="11sp" />
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="更多"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_boss_more"
|
||||
android:tint="@color/boss_text_primary"
|
||||
android:visibility="gone" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_refresh_button"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="刷新"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_boss_refresh"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -22,23 +22,23 @@
|
||||
android:paddingBottom="40dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="60dp"
|
||||
android:layout_height="60dp"
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="72dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:gravity="center"
|
||||
android:text="B"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textSize="18sp"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/login_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:layout_marginTop="22dp"
|
||||
android:text=""
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textSize="30sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
@@ -50,98 +50,13 @@
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:text=""
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/login_account_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginTop="28dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:hint="账号"
|
||||
android:imeOptions="actionNext"
|
||||
android:inputType="text"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:singleLine="true"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textColorHint="@color/boss_text_muted"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/login_password_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:hint="密码"
|
||||
android:imeOptions="actionDone"
|
||||
android:inputType="textPassword"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:singleLine="true"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textColorHint="@color/boss_text_muted"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/login_confirm_password_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:hint="确认密码"
|
||||
android:imeOptions="actionNext"
|
||||
android:inputType="textPassword"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:singleLine="true"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textColorHint="@color/boss_text_muted"
|
||||
android:textSize="14sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/login_code_row"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:orientation="horizontal"
|
||||
android:visibility="gone">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/login_code_input"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:hint="验证码"
|
||||
android:imeOptions="actionDone"
|
||||
android:inputType="number"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:singleLine="true"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textColorHint="@color/boss_text_muted"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/login_send_code_button"
|
||||
android:layout_width="104dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginLeft="10dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:text="获取验证码"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/login_progress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:layout_marginTop="28dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<Button
|
||||
@@ -150,53 +65,13 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="22dp"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="12dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingBottom="14dp"
|
||||
android:text=""
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textSize="14sp"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/login_mode_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:text="账号登录"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/register_mode_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:text="注册账号"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/forgot_mode_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:text="忘记密码"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
@@ -214,19 +89,19 @@
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="7dp">
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/back_button"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="返回"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_boss_back"
|
||||
android:tint="@color/boss_text_primary"
|
||||
android:visibility="gone" />
|
||||
@@ -246,7 +121,7 @@
|
||||
android:maxLines="1"
|
||||
android:text="会话"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
@@ -256,46 +131,46 @@
|
||||
android:layout_marginTop="4dp"
|
||||
android:text=""
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="11sp"
|
||||
android:textSize="12sp"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/top_search_input"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:hint="搜索项目或线程"
|
||||
android:imeOptions="actionSearch"
|
||||
android:inputType="text"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="7dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="7dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="14dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textColorHint="@color/boss_text_muted"
|
||||
android:textSize="14sp"
|
||||
android:textSize="15sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/search_button"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="搜索"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_boss_search"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/refresh_button"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="快捷操作"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_boss_add"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
</LinearLayout>
|
||||
@@ -313,50 +188,51 @@
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="54dp"
|
||||
android:layout_height="72dp"
|
||||
android:background="@color/boss_surface"
|
||||
android:elevation="10dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="3dp"
|
||||
android:paddingBottom="3dp"
|
||||
android:paddingLeft="10dp"
|
||||
android:paddingRight="10dp">
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingRight="12dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/tab_conversations"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginRight="6dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@android:color/transparent"
|
||||
android:background="@drawable/bg_tab_active"
|
||||
android:text="会话"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/tab_devices"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginLeft="6dp"
|
||||
android:layout_marginRight="6dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@android:color/transparent"
|
||||
android:background="@drawable/bg_tab_inactive"
|
||||
android:text="设备"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/tab_me"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginLeft="6dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@android:color/transparent"
|
||||
android:background="@drawable/bg_tab_inactive"
|
||||
android:text="我的"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -409,7 +285,7 @@
|
||||
android:text="添加设备"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_quick_actions_menu_text"
|
||||
android:textSize="13sp" />
|
||||
android:textSize="15sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/quick_action_scan"
|
||||
@@ -423,7 +299,7 @@
|
||||
android:text="扫一扫"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_quick_actions_menu_text"
|
||||
android:textSize="13sp" />
|
||||
android:textSize="15sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/quick_action_group_chat"
|
||||
@@ -437,7 +313,7 @@
|
||||
android:text="发起群聊"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_quick_actions_menu_text"
|
||||
android:textSize="13sp" />
|
||||
android:textSize="15sp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</FrameLayout>
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="7dp">
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_back_button"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="返回"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_boss_back"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
@@ -40,135 +40,84 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:text="项目详情"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:ellipsize="end"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:text="设备"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="11sp" />
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="更多"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_boss_more"
|
||||
android:tint="@color/boss_text_primary"
|
||||
android:visibility="gone" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_refresh_button"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="刷新"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_boss_refresh"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false">
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/screen_refresh_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
<LinearLayout
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/project_chat_scroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="ifContentScrolls">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_quick_actions_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_bg_app"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="8dp">
|
||||
android:paddingBottom="20dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_quick_actions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:orientation="horizontal" />
|
||||
</LinearLayout>
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/project_chat_scroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="ifContentScrolls">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="0dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="20dp" />
|
||||
</ScrollView>
|
||||
android:orientation="vertical" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/project_chat_scroll_bottom"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_gravity="bottom|left"
|
||||
android:layout_marginLeft="12dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="@drawable/bg_chat_scroll_bottom_button"
|
||||
android:contentDescription="回到底部"
|
||||
android:elevation="8dp"
|
||||
android:padding="11dp"
|
||||
android:scaleType="center"
|
||||
android:src="@drawable/ic_boss_arrow_down"
|
||||
android:tint="@color/boss_text_primary"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_mention_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:elevation="8dp"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_composer_row"
|
||||
android:layout_width="match_parent"
|
||||
@@ -177,21 +126,22 @@
|
||||
android:gravity="bottom"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="10dp">
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
<Button
|
||||
android:id="@+id/project_chat_attach"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:contentDescription="发送附件"
|
||||
android:padding="10dp"
|
||||
android:scaleType="center"
|
||||
android:src="@drawable/ic_boss_add"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
android:minWidth="0dp"
|
||||
android:text="+"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/project_chat_input"
|
||||
@@ -203,25 +153,23 @@
|
||||
android:hint="输入消息"
|
||||
android:inputType="textCapSentences|textMultiLine"
|
||||
android:maxLines="4"
|
||||
android:minHeight="40dp"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:minHeight="44dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="14dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textColorHint="@color/boss_text_muted"
|
||||
android:textSize="14sp" />
|
||||
android:textColorHint="@color/boss_text_muted" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/project_chat_send"
|
||||
android:layout_width="68dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:text="发送"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -233,34 +181,19 @@
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:paddingBottom="12dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<Button
|
||||
android:id="@+id/project_chat_multi_copy"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:text="复制"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/project_chat_multi_forward"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:text="转发"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="7dp">
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_back_button"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="返回"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_boss_back"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
@@ -37,49 +37,43 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:text="标题"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:ellipsize="end"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:text="副标题"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="11sp" />
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="更多"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_boss_more"
|
||||
android:tint="@color/boss_text_primary"
|
||||
android:visibility="gone" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_refresh_button"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="刷新"
|
||||
android:padding="6dp"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_boss_refresh"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
</LinearLayout>
|
||||
@@ -101,8 +95,6 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_panel"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="24dp" />
|
||||
</ScrollView>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:windowBackground">@color/boss_bg_app</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -9,7 +9,7 @@
|
||||
<item name="android:windowBackground">@color/boss_bg_app</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:windowBackground">@color/boss_bg_app</item>
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class AccessManagementActivityTest {
|
||||
@Test
|
||||
public void renderAccessShowsTemplateApplyEntryWhenTemplatesAreAvailable() throws Exception {
|
||||
TestAccessManagementActivity activity = Robolectric
|
||||
.buildActivity(TestAccessManagementActivity.class, new Intent())
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccess",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAccessPayload())
|
||||
);
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "套用模板"));
|
||||
assertTrue(viewTreeContainsText(content, "一次性给账号分配设备、项目和 Skill 权限"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderAccessExplainsUnavailableTargetsInsteadOfBlankState() throws Exception {
|
||||
TestAccessManagementActivity activity = Robolectric
|
||||
.buildActivity(TestAccessManagementActivity.class, new Intent())
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccess",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject()
|
||||
.put("accounts", new JSONArray())
|
||||
.put("devices", new JSONArray())
|
||||
.put("projects", new JSONArray())
|
||||
.put("skills", new JSONArray())
|
||||
.put("skillCatalog", new JSONArray())
|
||||
.put("permissionTemplates", new JSONArray())
|
||||
.put("grants", new JSONObject()
|
||||
.put("devices", new JSONArray())
|
||||
.put("projects", new JSONArray())
|
||||
.put("skills", new JSONArray())))
|
||||
);
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "暂无权限模板"));
|
||||
assertTrue(viewTreeContainsText(content, "暂无可授权设备"));
|
||||
assertTrue(viewTreeContainsText(content, "暂无可授权项目"));
|
||||
assertTrue(viewTreeContainsText(content, "暂无可分配 Skill"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void buildTemplateApplyPayloadWritesServerTemplateContract() throws Exception {
|
||||
JSONObject payload = AccessManagementActivity.buildTemplateApplyPayload(
|
||||
"developer@example.com",
|
||||
new JSONObject().put("templateId", "developer"),
|
||||
new JSONObject().put("id", "mac-studio"),
|
||||
new JSONObject().put("id", "master-agent"),
|
||||
new JSONObject().put("skillId", "mac-studio:boss-server-debug")
|
||||
);
|
||||
|
||||
assertEquals("apply_template", payload.optString("action"));
|
||||
assertEquals("developer@example.com", payload.optString("account"));
|
||||
assertEquals("developer", payload.optString("templateId"));
|
||||
assertEquals("mac-studio", payload.optJSONArray("deviceIds").optString(0));
|
||||
assertEquals("master-agent", payload.optJSONArray("projectIds").optString(0));
|
||||
assertEquals("mac-studio:boss-server-debug", payload.optJSONArray("skillIds").optString(0));
|
||||
}
|
||||
|
||||
private static JSONObject buildAccessPayload() throws Exception {
|
||||
return new JSONObject()
|
||||
.put("accounts", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("account", "developer@example.com")
|
||||
.put("displayName", "Developer")
|
||||
.put("role", "member")))
|
||||
.put("devices", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "mac-studio")
|
||||
.put("name", "Mac Studio")))
|
||||
.put("projects", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "master-agent")
|
||||
.put("name", "主 Agent")))
|
||||
.put("skills", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("skillId", "mac-studio:boss-server-debug")
|
||||
.put("deviceId", "mac-studio")
|
||||
.put("name", "boss-server-debug")))
|
||||
.put("skillCatalog", new JSONArray())
|
||||
.put("permissionTemplates", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("templateId", "developer")
|
||||
.put("name", "项目开发者")
|
||||
.put("description", "允许聊天和 Skill 调用")))
|
||||
.put("grants", new JSONObject()
|
||||
.put("devices", new JSONArray())
|
||||
.put("projects", new JSONArray())
|
||||
.put("skills", new JSONArray()));
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
if (text != null && text.toString().contains(expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return false;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static final class TestAccessManagementActivity extends AccessManagementActivity {
|
||||
@Override
|
||||
protected void reload() {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -12,6 +10,7 @@ import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.EditText;
|
||||
import android.widget.Spinner;
|
||||
import android.widget.SpinnerAdapter;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
@@ -36,7 +35,6 @@ import java.net.HttpURLConnection;
|
||||
import java.net.ProtocolException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -48,15 +46,78 @@ import java.util.concurrent.TimeUnit;
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class AiAccountsActivityTest {
|
||||
@Test
|
||||
public void submitOpenAiOnboarding_reportsExplicitPrimaryControllerSuccessAndRefreshesSummary() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(
|
||||
new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"),
|
||||
200,
|
||||
"{\"ok\":true,\"accountId\":\"acc-1\"}",
|
||||
"{\"ok\":false,\"message\":\"ONBOARD_FAILED\"}"
|
||||
),
|
||||
new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/accounts/acc-1/validate"),
|
||||
200,
|
||||
"{\"ok\":true,\"message\":\"校验通过\"}",
|
||||
"{\"ok\":false,\"message\":\"VALIDATION_FAILED\"}"
|
||||
)
|
||||
));
|
||||
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
|
||||
int initialReloadCount = activity.reloadCount;
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"submitOpenAiOnboarding",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "主 GPT"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "OpenAI 平台账号"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "sk-test"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "sk-test-key")
|
||||
);
|
||||
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
assertEquals("OpenAI 平台账号已登录,并设为当前主控。", ShadowToast.getTextOfLatestToast());
|
||||
assertEquals(initialReloadCount + 1, activity.reloadCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void submitOpenAiOnboarding_showsClearChineseFailurePrefix() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(
|
||||
new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"),
|
||||
403,
|
||||
"{\"ok\":false,\"message\":\"API Key 无效\"}",
|
||||
"{\"ok\":false,\"message\":\"API Key 无效\"}"
|
||||
)
|
||||
));
|
||||
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"submitOpenAiOnboarding",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "主 GPT"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "OpenAI 平台账号"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "sk-test"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "bad-key")
|
||||
);
|
||||
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
assertEquals("OpenAI 平台账号登录失败:API Key 无效", ShadowToast.getTextOfLatestToast());
|
||||
assertEquals(1, activity.reloadCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void activeIdentityCardOffersMainAgentTestEntry() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
JSONObject activeIdentity = new JSONObject()
|
||||
.put("accountId", "acc-1")
|
||||
.put("label", "主Agent")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("label", "主 GPT")
|
||||
.put("displayName", "OpenAI 平台账号")
|
||||
.put("roleLabel", "主 GPT")
|
||||
.put("providerLabel", "OpenAI API")
|
||||
.put("statusLabel", "ready")
|
||||
.put("note", "当前账号可直接生成主 Agent 回复。")
|
||||
.put("canGenerate", true);
|
||||
@@ -79,547 +140,62 @@ public class AiAccountsActivityTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderAccountsShowsStructuredSectionsAndExpandedEntries() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("activeIdentity", new JSONObject()
|
||||
.put("accountId", "chatgpt-primary")
|
||||
.put("label", "主Agent")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("statusLabel", "ready")
|
||||
.put("canGenerate", true))
|
||||
.put("accounts", new org.json.JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "chatgpt-primary")
|
||||
.put("label", "主Agent")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("provider", "chatgpt_oauth")
|
||||
.put("role", "primary")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", true))
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "hyzq-backup")
|
||||
.put("label", "备用API")
|
||||
.put("displayName", "环宇智擎 备用账号")
|
||||
.put("roleLabel", "备用链路")
|
||||
.put("providerLabel", "环宇智擎")
|
||||
.put("provider", "hyzq_api")
|
||||
.put("role", "backup")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", false)
|
||||
.put("apiKeyConfigured", true)
|
||||
.put("apiBaseUrl", "https://api.hyzq2046.com/v1"))
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "master-node")
|
||||
.put("label", "主Agent")
|
||||
.put("displayName", "绑定电脑上的 Codex 节点")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "主Agent 节点")
|
||||
.put("provider", "master_codex_node")
|
||||
.put("role", "primary")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", false)));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
assertNotNull(root);
|
||||
assertTrue(viewTreeContainsText(root, "主要API配置"));
|
||||
assertTrue(viewTreeContainsText(root, "备用API配置"));
|
||||
assertFalse(viewTreeContainsText(root, "OAuth 登录"));
|
||||
assertFalse(viewTreeContainsText(root, "API 接入"));
|
||||
assertFalse(viewTreeContainsText(root, "谷歌登录"));
|
||||
assertFalse(viewTreeContainsText(root, "ChatGPT登录"));
|
||||
assertFalse(viewTreeContainsText(root, "阿里"));
|
||||
assertFalse(viewTreeContainsText(root, "Minimax"));
|
||||
assertFalse(viewTreeContainsText(root, "GLM"));
|
||||
assertFalse(viewTreeContainsText(root, "环宇智擎"));
|
||||
assertFalse(viewTreeContainsText(root, "自定义"));
|
||||
assertFalse(viewTreeContainsText(root, "绑定设备节点"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tappingPrimaryConfigEntryOpensPrimaryDetailPage() throws Exception {
|
||||
public void openAliyunQwenOnboardingDialogUsesPresetModelsWithCustomFallback() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray()))
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
View entry = findClickableViewContainingText(root, "主要API配置");
|
||||
assertNotNull(entry);
|
||||
entry.performClick();
|
||||
|
||||
ShadowActivity shadowActivity = Shadows.shadowOf(activity);
|
||||
Intent nextIntent = shadowActivity.getNextStartedActivity();
|
||||
assertNotNull(nextIntent);
|
||||
assertEquals(AiAccountsActivity.class.getName(), nextIntent.getComponent().getClassName());
|
||||
assertEquals("primary", nextIntent.getStringExtra("ai_accounts_role"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void detailPageShowsOnlySelectedRoleConfiguration() throws Exception {
|
||||
Intent intent = new Intent(
|
||||
org.robolectric.RuntimeEnvironment.getApplication(),
|
||||
TestAiAccountsActivity.class
|
||||
);
|
||||
intent.putExtra("ai_accounts_role", "primary");
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("accounts", new org.json.JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "chatgpt-primary")
|
||||
.put("label", "主要API")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("provider", "chatgpt_oauth")
|
||||
.put("role", "primary")
|
||||
.put("model", "gpt-5.4-mini")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", true))
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "hyzq-primary")
|
||||
.put("label", "主要API")
|
||||
.put("displayName", "环宇智擎 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "环宇智擎")
|
||||
.put("provider", "hyzq_api")
|
||||
.put("role", "primary")
|
||||
.put("model", "gpt-5.4")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", false)
|
||||
.put("apiKeyConfigured", true)
|
||||
.put("apiBaseUrl", "https://api.hyzq2046.com/v1")));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
assertNotNull(root);
|
||||
assertTrue(viewTreeContainsText(root, "当前使用方式"));
|
||||
assertTrue(viewTreeContainsText(root, "主Agent模式"));
|
||||
assertTrue(viewTreeContainsText(root, "快速反应模型"));
|
||||
assertTrue(viewTreeContainsText(root, "深度思考模型"));
|
||||
assertTrue(viewTreeContainsText(root, "ChatGPT登录"));
|
||||
assertTrue(viewTreeContainsText(root, "OAuth 登录"));
|
||||
assertTrue(viewTreeContainsText(root, "当前模型:gpt-5.4-mini"));
|
||||
assertTrue(viewTreeContainsText(root, "当前:沿用默认"));
|
||||
assertTrue(viewTreeContainsText(root, "当前:gpt-5.4-mini"));
|
||||
assertTrue(viewTreeContainsText(root, "当前:gpt-5.4"));
|
||||
assertTrue(viewTreeContainsText(root, "API 接入"));
|
||||
assertTrue(viewTreeContainsText(root, "已配置:ChatGPT登录"));
|
||||
assertTrue(viewTreeContainsText(root, "已配置:环宇智擎"));
|
||||
assertFalse(viewTreeContainsText(root, "谷歌登录"));
|
||||
assertFalse(viewTreeContainsText(root, "阿里"));
|
||||
assertFalse(viewTreeContainsText(root, "Minimax"));
|
||||
assertFalse(viewTreeContainsText(root, "GLM"));
|
||||
assertFalse(viewTreeContainsText(root, "自定义"));
|
||||
assertFalse(viewTreeContainsText(root, "可编辑配置"));
|
||||
assertFalse(viewTreeContainsText(root, "当前已保存"));
|
||||
assertFalse(viewTreeContainsText(root, "只读状态"));
|
||||
assertFalse(viewTreeContainsText(root, "备用API配置"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void currentMethodEntryOpensCurrentAccountEditor() throws Exception {
|
||||
Intent intent = new Intent(
|
||||
org.robolectric.RuntimeEnvironment.getApplication(),
|
||||
TestAiAccountsActivity.class
|
||||
);
|
||||
intent.putExtra("ai_accounts_role", "primary");
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
|
||||
ReflectionHelpers.setField(activity, "currentMasterAgentModelOverride", "gpt-5.4-mini");
|
||||
ReflectionHelpers.setField(activity, "currentMasterAgentReasoningEffortOverride", "low");
|
||||
ReflectionHelpers.setField(activity, "currentFastModelOverride", "gpt-5.4-mini");
|
||||
ReflectionHelpers.setField(activity, "currentDeepModelOverride", "gpt-5.4");
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("accounts", new org.json.JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "chatgpt-primary")
|
||||
.put("label", "主要API")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("provider", "chatgpt_oauth")
|
||||
.put("role", "primary")
|
||||
.put("model", "gpt-5.4-mini")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", true)));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
View entry = findClickableViewContainingText(root, "当前使用方式");
|
||||
assertNotNull(entry);
|
||||
entry.performClick();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "openAliyunQwenOnboardingDialog");
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
View dialogRoot = dialog.getWindow().getDecorView();
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "账号快捷登录"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "选择模型"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fastModeEntryOpensDedicatedModelPicker() throws Exception {
|
||||
Intent intent = new Intent(
|
||||
org.robolectric.RuntimeEnvironment.getApplication(),
|
||||
TestAiAccountsActivity.class
|
||||
);
|
||||
intent.putExtra("ai_accounts_role", "primary");
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
|
||||
ReflectionHelpers.setField(activity, "currentFastModelOverride", "gpt-4.1");
|
||||
ReflectionHelpers.setField(activity, "currentDeepModelOverride", "gpt-5.4");
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject()
|
||||
.put("accounts", new org.json.JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "chatgpt-primary")
|
||||
.put("label", "主要API")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("provider", "chatgpt_oauth")
|
||||
.put("role", "primary")
|
||||
.put("model", "gpt-5.4-mini")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", true))))
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
View entry = findClickableViewContainingText(root, "快速反应模型");
|
||||
assertNotNull(entry);
|
||||
entry.performClick();
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
View dialogRoot = dialog.getWindow().getDecorView();
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "快速反应模型"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "gpt-4.1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tappingOauthEntryShowsOauthProviderChooser() throws Exception {
|
||||
Intent intent = new Intent(
|
||||
org.robolectric.RuntimeEnvironment.getApplication(),
|
||||
TestAiAccountsActivity.class
|
||||
);
|
||||
intent.putExtra("ai_accounts_role", "primary");
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray()))
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
View entry = findClickableViewContainingText(root, "OAuth 登录");
|
||||
assertNotNull(entry);
|
||||
entry.performClick();
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
View dialogRoot = dialog.getWindow().getDecorView();
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "谷歌登录"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "ChatGPT登录"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tappingApiEntryShowsApiProviderChooser() throws Exception {
|
||||
Intent intent = new Intent(
|
||||
org.robolectric.RuntimeEnvironment.getApplication(),
|
||||
TestAiAccountsActivity.class
|
||||
);
|
||||
intent.putExtra("ai_accounts_role", "primary");
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray()))
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
View entry = findClickableViewContainingText(root, "API 接入");
|
||||
assertNotNull(entry);
|
||||
entry.performClick();
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
View dialogRoot = dialog.getWindow().getDecorView();
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "阿里"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "Minimax"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "GLM"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "环宇智擎"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "自定义"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void defaultApiBaseUrlForProviderSupportsExpandedApiProviders() {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
|
||||
String openai = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"defaultApiBaseUrlForProvider",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "openai_api")
|
||||
);
|
||||
String aliyun = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"defaultApiBaseUrlForProvider",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "aliyun_qwen_api")
|
||||
);
|
||||
String minimax = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"defaultApiBaseUrlForProvider",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "minimax_api")
|
||||
);
|
||||
String glm = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"defaultApiBaseUrlForProvider",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "glm_api")
|
||||
);
|
||||
String hyzq = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"defaultApiBaseUrlForProvider",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "hyzq_api")
|
||||
);
|
||||
String custom = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"defaultApiBaseUrlForProvider",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "custom_api")
|
||||
);
|
||||
|
||||
assertEquals("https://api.openai.com/v1", openai);
|
||||
assertEquals("https://dashscope.aliyuncs.com/compatible-mode/v1", aliyun);
|
||||
assertEquals("https://api.minimaxi.com/v1", minimax);
|
||||
assertEquals("https://open.bigmodel.cn/api/paas/v4", glm);
|
||||
assertEquals("https://api.hyzq2046.com/v1", hyzq);
|
||||
assertEquals("", custom);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openOauthAccountDialogShowsLoginAction() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"openOauthAccountDialog",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "primary"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "google_oauth"),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
|
||||
);
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
View root = dialog.getWindow().getDecorView();
|
||||
assertTrue(viewTreeContainsText(root, "账号快捷登录"));
|
||||
assertTrue(viewTreeContainsText(root, "谷歌登录"));
|
||||
Spinner modelSpinner = findSpinner(root);
|
||||
Spinner modelSpinner = findSpinnerContainingItem(root, "qwen3.5-plus");
|
||||
assertNotNull(modelSpinner);
|
||||
assertFalse(modelSpinner.isEnabled());
|
||||
assertFalse(modelSpinner.isClickable());
|
||||
SpinnerAdapter adapter = modelSpinner.getAdapter();
|
||||
assertNotNull(adapter);
|
||||
assertEquals(3, adapter.getCount());
|
||||
assertEquals("qwen3.5-plus", adapter.getItem(0).toString());
|
||||
assertEquals("qwen3.5-flash", adapter.getItem(1).toString());
|
||||
assertEquals("自定义模型", adapter.getItem(2).toString());
|
||||
assertEquals("qwen3.5-plus", modelSpinner.getSelectedItem().toString());
|
||||
|
||||
EditText customModelInput = findEditTextWithHint(root, "自定义模型");
|
||||
assertNotNull(customModelInput);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openOauthAccountDialogEnablesModelSelectionWhenAccountIsReady() throws Exception {
|
||||
public void openAccountEditorShowsCustomFallbackForNonPresetAliyunModel() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
JSONObject existing = new JSONObject()
|
||||
.put("label", "主要API")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("accountIdentifier", "kris@example.com")
|
||||
.put("model", "gpt-5.4")
|
||||
.put("loginStatusNote", "已登录")
|
||||
.put("enabled", true)
|
||||
.put("isActive", true)
|
||||
.put("status", "ready")
|
||||
.put("statusLabel", "ready");
|
||||
.put("accountId", "acc-1")
|
||||
.put("label", "备用 GPT")
|
||||
.put("displayName", "阿里百炼备用账号")
|
||||
.put("provider", "aliyun_qwen_api")
|
||||
.put("model", "qwen-custom-x");
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"openOauthAccountDialog",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "primary"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "chatgpt_oauth"),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, existing)
|
||||
);
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
View root = dialog.getWindow().getDecorView();
|
||||
Spinner modelSpinner = findSpinner(root);
|
||||
assertNotNull(modelSpinner);
|
||||
assertTrue(modelSpinner.isEnabled());
|
||||
assertTrue(modelSpinner.isClickable());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openApiAccountDialogLocksModelSelectionBeforeValidation() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"openApiAccountDialog",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "backup"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "hyzq_api"),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
|
||||
"openAccountEditor",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, existing),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, null)
|
||||
);
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
|
||||
View root = dialog.getWindow().getDecorView();
|
||||
assertNotNull(findEditTextWithHint(root, "账号标识 / 备注"));
|
||||
assertNotNull(findEditTextWithHint(root, "API Key"));
|
||||
Spinner modelSpinner = findSpinner(root);
|
||||
Spinner modelSpinner = findSpinnerContainingItem(root, "自定义模型");
|
||||
assertNotNull(modelSpinner);
|
||||
assertFalse(modelSpinner.isEnabled());
|
||||
assertEquals(0, ((android.widget.ArrayAdapter<?>) modelSpinner.getAdapter()).getCount());
|
||||
}
|
||||
SpinnerAdapter adapter = modelSpinner.getAdapter();
|
||||
assertNotNull(adapter);
|
||||
assertEquals(3, adapter.getCount());
|
||||
assertEquals("自定义模型", modelSpinner.getSelectedItem().toString());
|
||||
|
||||
@Test
|
||||
public void applyDraftValidatedModelsEnablesModelSelection() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
Spinner spinner = new Spinner(activity);
|
||||
android.widget.ArrayAdapter<String> adapter = new android.widget.ArrayAdapter<>(
|
||||
activity,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
new ArrayList<>()
|
||||
);
|
||||
spinner.setAdapter(adapter);
|
||||
spinner.setEnabled(false);
|
||||
org.json.JSONArray models = new org.json.JSONArray().put("gpt-5.4-mini").put("gpt-5.4");
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"applyValidatedApiModels",
|
||||
ReflectionHelpers.ClassParameter.from(Spinner.class, spinner),
|
||||
ReflectionHelpers.ClassParameter.from(android.widget.ArrayAdapter.class, adapter),
|
||||
ReflectionHelpers.ClassParameter.from(org.json.JSONArray.class, models),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4")
|
||||
);
|
||||
|
||||
assertTrue(spinner.isEnabled());
|
||||
assertEquals(2, adapter.getCount());
|
||||
assertEquals("gpt-5.4", spinner.getSelectedItem());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void saveExpandedApiProviderUsesGenericCreateFlowAndAutoFillsBaseUrl() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
RecordingConnection createConnection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/accounts"),
|
||||
200,
|
||||
"{\"ok\":true,\"accountId\":\"acc-1\"}",
|
||||
"{\"ok\":false,\"message\":\"SAVE_FAILED\"}"
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(createConnection));
|
||||
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
|
||||
int initialReloadCount = activity.reloadCount;
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"saveAccount",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "备用API"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "环宇智擎备用账号"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "fallback@example.com"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, ""),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, ""),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, ""),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "hyzq-secret"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "待校验"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, true),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "backup"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "hyzq_api")
|
||||
);
|
||||
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
assertEquals("AI 账号已新增", ShadowToast.getTextOfLatestToast());
|
||||
assertEquals(initialReloadCount + 1, activity.reloadCount);
|
||||
|
||||
JSONObject requestJson = new JSONObject(createConnection.getCapturedRequestBody());
|
||||
assertEquals("hyzq_api", requestJson.getString("provider"));
|
||||
assertEquals("backup", requestJson.getString("role"));
|
||||
assertEquals("https://api.hyzq2046.com/v1", requestJson.getString("apiBaseUrl"));
|
||||
assertEquals("hyzq-secret", requestJson.getString("apiKey"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void saveOauthAccountUsesGenericCreateFlow() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
RecordingConnection createConnection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/accounts"),
|
||||
200,
|
||||
"{\"ok\":true,\"accountId\":\"acc-2\"}",
|
||||
"{\"ok\":false,\"message\":\"SAVE_FAILED\"}"
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(createConnection));
|
||||
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"saveAccount",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "主Agent"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "ChatGPT OAuth 主链路账号"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "kris@example.com"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, ""),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, ""),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, ""),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, ""),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "待网页登录"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, true),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, true),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "primary"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "chatgpt_oauth")
|
||||
);
|
||||
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
assertEquals("AI 账号已新增", ShadowToast.getTextOfLatestToast());
|
||||
JSONObject requestJson = new JSONObject(createConnection.getCapturedRequestBody());
|
||||
assertEquals("chatgpt_oauth", requestJson.getString("provider"));
|
||||
assertEquals("primary", requestJson.getString("role"));
|
||||
assertEquals("待网页登录", requestJson.getString("loginStatusNote"));
|
||||
assertEquals("", requestJson.getString("apiBaseUrl"));
|
||||
EditText customModelInput = findEditTextWithHint(root, "自定义模型");
|
||||
assertNotNull(customModelInput);
|
||||
assertEquals("qwen-custom-x", customModelInput.getText().toString());
|
||||
}
|
||||
|
||||
private static final class TestAiAccountsActivity extends AiAccountsActivity {
|
||||
@@ -703,6 +279,8 @@ public class AiAccountsActivityTest {
|
||||
private final int responseCodeValue;
|
||||
private final String responseBody;
|
||||
private final String errorBody;
|
||||
private String requestMethodValue = "GET";
|
||||
private String contentTypeValue = "";
|
||||
|
||||
RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) {
|
||||
super(url);
|
||||
@@ -723,11 +301,16 @@ public class AiAccountsActivityTest {
|
||||
public void connect() {}
|
||||
|
||||
@Override
|
||||
public void setRequestMethod(String method) throws ProtocolException {}
|
||||
public void setRequestMethod(String method) throws ProtocolException {
|
||||
requestMethodValue = method;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setRequestProperty(String key, String value) {
|
||||
requestHeaders.put(key, value);
|
||||
if ("Content-Type".equalsIgnoreCase(key)) {
|
||||
contentTypeValue = value;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -754,10 +337,6 @@ public class AiAccountsActivityTest {
|
||||
public Map<String, List<String>> getHeaderFields() {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
String getCapturedRequestBody() {
|
||||
return requestBody.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class InMemorySharedPreferences implements SharedPreferences {
|
||||
@@ -905,6 +484,32 @@ public class AiAccountsActivityTest {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Spinner findSpinnerContainingItem(View root, String expectedText) {
|
||||
if (root instanceof Spinner) {
|
||||
Spinner spinner = (Spinner) root;
|
||||
SpinnerAdapter adapter = spinner.getAdapter();
|
||||
if (adapter != null) {
|
||||
for (int index = 0; index < adapter.getCount(); index += 1) {
|
||||
Object item = adapter.getItem(index);
|
||||
if (item != null && item.toString().contains(expectedText)) {
|
||||
return spinner;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return null;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
Spinner match = findSpinnerContainingItem(group.getChildAt(index), expectedText);
|
||||
if (match != null) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static EditText findEditTextWithHint(View root, String expectedText) {
|
||||
if (root instanceof EditText) {
|
||||
CharSequence hint = ((EditText) root).getHint();
|
||||
@@ -924,41 +529,4 @@ public class AiAccountsActivityTest {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static EditText findEditTextWithText(View root, String expectedText) {
|
||||
if (root instanceof EditText) {
|
||||
CharSequence text = ((EditText) root).getText();
|
||||
if (text != null && text.toString().contains(expectedText)) {
|
||||
return (EditText) root;
|
||||
}
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return null;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
EditText match = findEditTextWithText(group.getChildAt(index), expectedText);
|
||||
if (match != null) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Spinner findSpinner(View root) {
|
||||
if (root instanceof Spinner) {
|
||||
return (Spinner) root;
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return null;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
Spinner match = findSpinner(group.getChildAt(index));
|
||||
if (match != null) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,23 +58,6 @@ public class BossApiClientDeviceModeTest {
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void queueCodexRemoteControlWritesConfirmedActionBody() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/devices/device-1/codex-remote-control")
|
||||
);
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
apiClient.queueCodexRemoteControl("device-1", "start", "APP 设备详情页确认启动");
|
||||
|
||||
assertEquals("/api/v1/devices/device-1/codex-remote-control", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals(
|
||||
"{\"action\":\"start\",\"confirmed\":true,\"reason\":\"APP 设备详情页确认启动\"}",
|
||||
connection.requestBody()
|
||||
);
|
||||
}
|
||||
|
||||
private static final class RecordingBossApiClient extends BossApiClient {
|
||||
private final RecordingConnection connection;
|
||||
private String lastPath = "";
|
||||
|
||||
@@ -81,22 +81,6 @@ public class BossApiClientDispatchPlansTest {
|
||||
assertEquals("no-cache", connection.getRequestProperty("Pragma"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void protectedHtmlResponseReturnsJsonErrorInsteadOfThrowing() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/auth/session"),
|
||||
200,
|
||||
"<!DOCTYPE html><html><body>login</body></html>",
|
||||
""
|
||||
);
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.getSession();
|
||||
|
||||
assertEquals(401, response.statusCode);
|
||||
assertEquals("NON_JSON_RESPONSE", response.message());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void confirmDispatchPlanWritesApprovedTargetProjectIds() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/confirm"));
|
||||
@@ -130,19 +114,6 @@ public class BossApiClientDispatchPlansTest {
|
||||
assertEquals("{}", connection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void decideDialogGuardInterventionUsesContractEndpointAndDecisionBody() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/dialog-guard/interventions/intervention-1/decision"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.decideDialogGuardIntervention("intervention-1", "allow_once");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/dialog-guard/interventions/intervention-1/decision", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals("{\"decision\":\"allow_once\"}", connection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retryDispatchPlanUsesProjectScopedRetryEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/retry"));
|
||||
@@ -199,27 +170,6 @@ public class BossApiClientDispatchPlansTest {
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updateMasterAgentModeModelsWritesFastAndDeepModelMappings() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/agent-controls"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.updateMasterAgentModeModels(
|
||||
"gpt-4.1",
|
||||
"gpt-5.1",
|
||||
"gpt-4.1",
|
||||
"low"
|
||||
);
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/master-agent/agent-controls", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals(
|
||||
"{\"fastModelOverride\":\"gpt-4.1\",\"deepModelOverride\":\"gpt-5.1\",\"modelOverride\":\"gpt-4.1\",\"reasoningEffortOverride\":\"low\"}",
|
||||
connection.requestBody()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getMasterAgentPromptProfileUsesScopedEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/prompt-profile"));
|
||||
@@ -261,31 +211,41 @@ public class BossApiClientDispatchPlansTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getProjectDetailUsesExtendedReadTimeoutForChatPages() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/thread-1"));
|
||||
public void getMasterAgentEvolutionUsesDashboardEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/master-agent/evolution"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.getProjectDetail("thread-1");
|
||||
BossApiClient.ApiResponse response = apiClient.getMasterAgentEvolution();
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/thread-1", apiClient.lastPath);
|
||||
assertEquals("/api/v1/master-agent/evolution", apiClient.lastPath);
|
||||
assertEquals("GET", connection.requestMethodValue);
|
||||
assertEquals(12000, connection.connectTimeoutValue);
|
||||
assertEquals(30000, connection.readTimeoutValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getProjectMessagesUsesExtendedReadTimeoutForRealtimeRefreshes() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/thread-1/messages"));
|
||||
public void updateMasterAgentEvolutionModeWritesModePayload() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/master-agent/evolution/config"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.getProjectMessages("thread-1");
|
||||
BossApiClient.ApiResponse response = apiClient.updateMasterAgentEvolutionMode("controlled");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/thread-1/messages", apiClient.lastPath);
|
||||
assertEquals("GET", connection.requestMethodValue);
|
||||
assertEquals(12000, connection.connectTimeoutValue);
|
||||
assertEquals(30000, connection.readTimeoutValue);
|
||||
assertEquals("/api/v1/master-agent/evolution/config", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals("{\"mode\":\"controlled\"}", connection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void approveMasterAgentEvolutionProposalUsesProposalEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/master-agent/evolution/proposals/proposal-1/approve"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.approveMasterAgentEvolutionProposal("proposal-1");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/master-agent/evolution/proposals/proposal-1/approve", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals("{}", connection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -339,153 +299,6 @@ public class BossApiClientDispatchPlansTest {
|
||||
assertEquals(20000, connection.readTimeoutValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void deleteProjectMessageUsesProjectScopedDeleteEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/thread-1/messages"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.deleteProjectMessage("thread-1", "msg-1");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/thread-1/messages?messageId=msg-1", apiClient.lastPath);
|
||||
assertEquals("DELETE", connection.requestMethodValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void storageConfigMethodsUseDedicatedStorageEndpoints() throws Exception {
|
||||
RecordingConnection getConnection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/storage/config"));
|
||||
RecordingBossApiClient getClient = new RecordingBossApiClient(getConnection);
|
||||
getClient.getAttachmentStorageConfig();
|
||||
assertEquals("/api/v1/storage/config", getClient.lastPath);
|
||||
assertEquals("GET", getConnection.requestMethodValue);
|
||||
|
||||
RecordingConnection saveConnection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/storage/config"));
|
||||
RecordingBossApiClient saveClient = new RecordingBossApiClient(saveConnection);
|
||||
saveClient.saveAttachmentStorageConfig(new JSONObject().put("mode", "server_file"));
|
||||
assertEquals("/api/v1/storage/config", saveClient.lastPath);
|
||||
assertEquals("PATCH", saveConnection.requestMethodValue);
|
||||
assertEquals("{\"mode\":\"server_file\"}", saveConnection.requestBody());
|
||||
|
||||
RecordingConnection validateConnection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/storage/config/validate"));
|
||||
RecordingBossApiClient validateClient = new RecordingBossApiClient(validateConnection);
|
||||
validateClient.validateAttachmentStorageConfig(new JSONObject().put("mode", "oss"));
|
||||
assertEquals("/api/v1/storage/config/validate", validateClient.lastPath);
|
||||
assertEquals("POST", validateConnection.requestMethodValue);
|
||||
assertEquals("{\"mode\":\"oss\"}", validateConnection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void protectedRequestFallsBackToAutoLoginWhenNoRestoreTokenExists() throws Exception {
|
||||
SequencedBossApiClient apiClient = new SequencedBossApiClient(
|
||||
new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/projects/project-1"),
|
||||
401,
|
||||
"{\"ok\":false,\"message\":\"UNAUTHORIZED\"}",
|
||||
"{\"ok\":false,\"message\":\"UNAUTHORIZED\"}"
|
||||
),
|
||||
new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/projects/project-1"),
|
||||
200,
|
||||
"{\"ok\":true,\"project\":{\"id\":\"project-1\",\"name\":\"北区试产线\"}}",
|
||||
"{\"ok\":false}"
|
||||
)
|
||||
);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.getProjectDetail("project-1");
|
||||
|
||||
assertEquals(1, apiClient.autoLoginCalls);
|
||||
assertEquals(2, apiClient.protectedRequestCount);
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("北区试产线", response.json.optJSONObject("project").optString("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void autoLoginCapturesSessionCookieFromMixedCaseHeaderNames() throws Exception {
|
||||
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/auth/login"));
|
||||
connection.responseHeaders.put(
|
||||
"Set-cookie",
|
||||
Collections.singletonList("boss_session=session-from-mixed-case; Path=/; HttpOnly")
|
||||
);
|
||||
IdentityCapturingBossApiClient apiClient = new IdentityCapturingBossApiClient(connection, prefs);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.autoLogin();
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("boss_session=session-from-mixed-case", prefs.getString("session_cookie", ""));
|
||||
assertEquals("krisolo", prefs.getString("account", ""));
|
||||
assertEquals("Boss 超级管理员", prefs.getString("display_name", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loginWithPasswordPostsCredentialsAndCapturesNativeRestoreToken() throws Exception {
|
||||
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/auth/login"),
|
||||
200,
|
||||
"{\"ok\":true,\"account\":\"krisolo\",\"displayName\":\"Boss 超级管理员\",\"restoreToken\":\"restore-login\"}",
|
||||
"{\"ok\":false}"
|
||||
);
|
||||
connection.responseHeaders.put(
|
||||
"Set-cookie",
|
||||
Collections.singletonList("boss_session=session-from-login; Path=/; HttpOnly")
|
||||
);
|
||||
IdentityCapturingBossApiClient apiClient = new IdentityCapturingBossApiClient(connection, prefs);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.loginWithPassword("krisolo", "Admin_yqs_asd.");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/auth/login", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals(
|
||||
"{\"account\":\"krisolo\",\"password\":\"Admin_yqs_asd.\",\"method\":\"password\"}",
|
||||
connection.requestBody()
|
||||
);
|
||||
assertEquals("boss_session=session-from-login", prefs.getString("session_cookie", ""));
|
||||
assertEquals("restore-login", prefs.getString("restore_token", ""));
|
||||
assertEquals("krisolo", prefs.getString("account", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void authRegistrationAndPasswordResetUseDedicatedNativeRoutes() throws Exception {
|
||||
ScriptedBossApiClient apiClient = new ScriptedBossApiClient(
|
||||
new RecordingConnection(new URL("https://boss.hyzq.net/api/auth/send-code")),
|
||||
new RecordingConnection(new URL("https://boss.hyzq.net/api/auth/register")),
|
||||
new RecordingConnection(new URL("https://boss.hyzq.net/api/auth/forgot-password"))
|
||||
);
|
||||
|
||||
BossApiClient.ApiResponse codeResponse = apiClient.sendVerificationCode("new-user", "register");
|
||||
assertEquals(200, codeResponse.statusCode);
|
||||
assertEquals("/api/auth/send-code", apiClient.lastPath);
|
||||
assertEquals("{\"account\":\"new-user\",\"purpose\":\"register\"}", apiClient.lastConnection.requestBody());
|
||||
|
||||
BossApiClient.ApiResponse registerResponse = apiClient.registerAccount(
|
||||
"new-user",
|
||||
"New_password_123",
|
||||
"New_password_123",
|
||||
"123456"
|
||||
);
|
||||
assertEquals(200, registerResponse.statusCode);
|
||||
assertEquals("/api/auth/register", apiClient.lastPath);
|
||||
assertEquals(
|
||||
"{\"account\":\"new-user\",\"password\":\"New_password_123\",\"confirmPassword\":\"New_password_123\",\"code\":\"123456\"}",
|
||||
apiClient.lastConnection.requestBody()
|
||||
);
|
||||
|
||||
BossApiClient.ApiResponse resetResponse = apiClient.resetPassword(
|
||||
"new-user",
|
||||
"Reset_password_123",
|
||||
"Reset_password_123",
|
||||
"654321"
|
||||
);
|
||||
assertEquals(200, resetResponse.statusCode);
|
||||
assertEquals("/api/auth/forgot-password", apiClient.lastPath);
|
||||
assertEquals(
|
||||
"{\"account\":\"new-user\",\"password\":\"Reset_password_123\",\"confirmPassword\":\"Reset_password_123\",\"code\":\"654321\"}",
|
||||
apiClient.lastConnection.requestBody()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onboardOpenAiApiAccountUsesDedicatedRouteAndSetsActive() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"));
|
||||
@@ -512,7 +325,7 @@ public class BossApiClientDispatchPlansTest {
|
||||
public void rememberIdentityDoesNotOverwriteSessionIdentityFromAiAccountOnboardingResponse() throws Exception {
|
||||
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
|
||||
prefs.edit()
|
||||
.putString("account", "krisolo")
|
||||
.putString("account", "17600003315")
|
||||
.putString("display_name", "Boss 超级管理员")
|
||||
.apply();
|
||||
BossApiClient apiClient = new BossApiClient(prefs, "https://boss.hyzq.net");
|
||||
@@ -525,7 +338,7 @@ public class BossApiClientDispatchPlansTest {
|
||||
|
||||
apiClient.rememberIdentity(onboardingResponse);
|
||||
|
||||
assertEquals("krisolo", apiClient.getAccountLabel());
|
||||
assertEquals("17600003315", apiClient.getAccountLabel());
|
||||
assertEquals("Boss 超级管理员", apiClient.getDisplayName());
|
||||
}
|
||||
|
||||
@@ -563,11 +376,7 @@ public class BossApiClientDispatchPlansTest {
|
||||
private String lastPath = "";
|
||||
|
||||
RecordingBossApiClient(RecordingConnection connection) {
|
||||
this(connection, new InMemorySharedPreferences());
|
||||
}
|
||||
|
||||
RecordingBossApiClient(RecordingConnection connection, SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@@ -591,7 +400,6 @@ public class BossApiClientDispatchPlansTest {
|
||||
private static final class ScriptedBossApiClient extends BossApiClient {
|
||||
private final Map<String, RecordingConnection> connections;
|
||||
private String lastPath = "";
|
||||
private RecordingConnection lastConnection;
|
||||
|
||||
ScriptedBossApiClient(RecordingConnection... connections) {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
@@ -608,7 +416,6 @@ public class BossApiClientDispatchPlansTest {
|
||||
if (connection == null) {
|
||||
throw new IllegalStateException("Missing scripted connection for " + path);
|
||||
}
|
||||
lastConnection = connection;
|
||||
return connection;
|
||||
}
|
||||
|
||||
@@ -623,65 +430,6 @@ public class BossApiClientDispatchPlansTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SequencedBossApiClient extends BossApiClient {
|
||||
private final java.util.ArrayDeque<RecordingConnection> protectedConnections = new java.util.ArrayDeque<>();
|
||||
private int autoLoginCalls;
|
||||
private int protectedRequestCount;
|
||||
|
||||
SequencedBossApiClient(RecordingConnection... protectedConnections) {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
Collections.addAll(this.protectedConnections, protectedConnections);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse autoLogin() throws org.json.JSONException {
|
||||
autoLoginCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("account", "krisolo")
|
||||
.put("displayName", "Boss 超级管理员"));
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) {
|
||||
if (!"/api/v1/projects/project-1".equals(path)) {
|
||||
throw new IllegalStateException("Unexpected path " + path);
|
||||
}
|
||||
protectedRequestCount += 1;
|
||||
RecordingConnection connection = protectedConnections.pollFirst();
|
||||
if (connection == null) {
|
||||
throw new IllegalStateException("No more scripted protected responses");
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
String encode(String value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
void rememberIdentity(JSONObject json) {
|
||||
// no-op for JVM unit test
|
||||
}
|
||||
}
|
||||
|
||||
private static final class IdentityCapturingBossApiClient extends BossApiClient {
|
||||
private final RecordingConnection connection;
|
||||
private String lastPath = "";
|
||||
|
||||
IdentityCapturingBossApiClient(RecordingConnection connection, SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) {
|
||||
lastPath = path;
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingConnection extends HttpURLConnection {
|
||||
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
|
||||
private final Map<String, String> requestHeaders = new HashMap<>();
|
||||
@@ -691,15 +439,9 @@ public class BossApiClientDispatchPlansTest {
|
||||
private final int responseCodeValue;
|
||||
private final String responseBody;
|
||||
private final String errorBody;
|
||||
private final Map<String, java.util.List<String>> responseHeaders = new HashMap<>();
|
||||
|
||||
RecordingConnection(URL url) {
|
||||
this(
|
||||
url,
|
||||
200,
|
||||
"{\"ok\":true,\"account\":\"krisolo\",\"displayName\":\"Boss 超级管理员\"}",
|
||||
"{\"ok\":false}"
|
||||
);
|
||||
this(url, 200, "{\"ok\":true}", "{\"ok\":false}");
|
||||
}
|
||||
|
||||
RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) {
|
||||
@@ -768,11 +510,6 @@ public class BossApiClientDispatchPlansTest {
|
||||
return new ByteArrayInputStream(errorBody.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, java.util.List<String>> getHeaderFields() {
|
||||
return responseHeaders;
|
||||
}
|
||||
|
||||
String requestBody() {
|
||||
return requestBody.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.ProtocolException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class BossApiClientLogoutTest {
|
||||
@Test
|
||||
public void logoutClearsAllCachedIdentityHints() throws Exception {
|
||||
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
|
||||
prefs.edit().putString("session_cookie", "boss_session=session-token").apply();
|
||||
BossApiClient apiClient = new RecordingBossApiClient(prefs);
|
||||
apiClient.rememberIdentity(new JSONObject()
|
||||
.put("restoreToken", "restore-token")
|
||||
.put("account", "honor_user")
|
||||
.put("displayName", "荣耀测试账号"));
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.logout();
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertFalse(prefs.contains("session_cookie"));
|
||||
assertFalse(prefs.contains("restore_token"));
|
||||
assertFalse(prefs.contains("account"));
|
||||
assertFalse(prefs.contains("display_name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void logoutClearsLocalAuthEvenWhenServerRequestFails() throws Exception {
|
||||
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
|
||||
prefs.edit()
|
||||
.putString("session_cookie", "boss_session=session-token")
|
||||
.putString("restore_token", "restore-token")
|
||||
.putString("account", "honor_user")
|
||||
.putString("display_name", "荣耀测试账号")
|
||||
.apply();
|
||||
BossApiClient apiClient = new FailingLogoutBossApiClient(prefs);
|
||||
|
||||
try {
|
||||
apiClient.logout();
|
||||
} catch (IOException expected) {
|
||||
// Local logout state must still be cleared if the network request fails.
|
||||
}
|
||||
|
||||
assertFalse(prefs.contains("session_cookie"));
|
||||
assertFalse(prefs.contains("restore_token"));
|
||||
assertFalse(prefs.contains("account"));
|
||||
assertFalse(prefs.contains("display_name"));
|
||||
}
|
||||
|
||||
private static final class RecordingBossApiClient extends BossApiClient {
|
||||
RecordingBossApiClient(SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) throws java.io.IOException {
|
||||
return new RecordingConnection(new URL("https://boss.hyzq.net" + path));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FailingLogoutBossApiClient extends BossApiClient {
|
||||
FailingLogoutBossApiClient(SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) throws IOException {
|
||||
throw new IOException("network down");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingConnection extends HttpURLConnection {
|
||||
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
|
||||
private String requestMethodValue = "GET";
|
||||
|
||||
RecordingConnection(URL url) {
|
||||
super(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disconnect() {}
|
||||
|
||||
@Override
|
||||
public boolean usingProxy() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connect() {}
|
||||
|
||||
@Override
|
||||
public void setRequestMethod(String method) throws ProtocolException {
|
||||
requestMethodValue = method;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRequestMethod() {
|
||||
return requestMethodValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getResponseCode() {
|
||||
return 200;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutputStream getOutputStream() {
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, List<String>> getHeaderFields() {
|
||||
return Map.of("Set-Cookie", List.of("boss_session=; Max-Age=0; Path=/"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class InMemorySharedPreferences implements SharedPreferences {
|
||||
private final Map<String, String> values = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public Map<String, ?> getAll() {
|
||||
return Collections.unmodifiableMap(values);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getString(String key, String defValue) {
|
||||
return values.getOrDefault(key, defValue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getStringSet(String key, Set<String> defValues) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getInt(String key, int defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getLong(String key, long defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloat(String key, float defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getBoolean(String key, boolean defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(String key) {
|
||||
return values.containsKey(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor edit() {
|
||||
return new Editor() {
|
||||
@Override
|
||||
public Editor putString(String key, String value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor remove(String key) {
|
||||
values.remove(key);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor clear() {
|
||||
values.clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply() {}
|
||||
|
||||
@Override
|
||||
public boolean commit() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putStringSet(String key, Set<String> values) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putInt(String key, int value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putLong(String key, long value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putFloat(String key, float value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putBoolean(String key, boolean value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
|
||||
|
||||
@Override
|
||||
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class BossApiClientTest {
|
||||
@Test
|
||||
public void buildProjectAgentControlsPayload_omitsAdvancedOverridesWhenAllAreNull() throws Exception {
|
||||
JSONObject payload = BossApiClient.buildProjectAgentControlsPayload(
|
||||
"gpt-5.4-mini",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
assertEquals("gpt-5.4-mini", payload.getString("modelOverride"));
|
||||
assertTrue(payload.has("reasoningEffortOverride"));
|
||||
assertFalse(payload.has("fastModelOverride"));
|
||||
assertFalse(payload.has("fastReasoningEffortOverride"));
|
||||
assertFalse(payload.has("smartModelOverride"));
|
||||
assertFalse(payload.has("smartReasoningEffortOverride"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void buildProjectAgentControlsPayload_includesAdvancedOverridesWhenPresent() throws Exception {
|
||||
JSONObject payload = BossApiClient.buildProjectAgentControlsPayload(
|
||||
null,
|
||||
null,
|
||||
"gpt-5.4-mini",
|
||||
null,
|
||||
"gpt-5.4",
|
||||
"high",
|
||||
false
|
||||
);
|
||||
|
||||
assertTrue(payload.has("fastModelOverride"));
|
||||
assertEquals("gpt-5.4-mini", payload.getString("fastModelOverride"));
|
||||
assertTrue(payload.has("smartModelOverride"));
|
||||
assertEquals("gpt-5.4", payload.getString("smartModelOverride"));
|
||||
assertTrue(payload.has("smartReasoningEffortOverride"));
|
||||
assertEquals("high", payload.getString("smartReasoningEffortOverride"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void buildProjectAgentControlsPayload_includesAdvancedNullsWhenExplicitlyRequested() throws Exception {
|
||||
JSONObject payload = BossApiClient.buildProjectAgentControlsPayload(
|
||||
"gpt-5.4-mini",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
true
|
||||
);
|
||||
|
||||
assertTrue(payload.has("fastModelOverride"));
|
||||
assertTrue(payload.isNull("fastModelOverride"));
|
||||
assertTrue(payload.has("fastReasoningEffortOverride"));
|
||||
assertTrue(payload.isNull("fastReasoningEffortOverride"));
|
||||
assertTrue(payload.has("smartModelOverride"));
|
||||
assertTrue(payload.isNull("smartModelOverride"));
|
||||
assertTrue(payload.has("smartReasoningEffortOverride"));
|
||||
assertTrue(payload.isNull("smartReasoningEffortOverride"));
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import org.junit.After;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.RuntimeEnvironment;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.shadows.ShadowNotificationManager;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class BossBackgroundRealtimeServiceTest {
|
||||
@After
|
||||
public void tearDown() {
|
||||
TestBossBackgroundRealtimeService.runtimeOverride = null;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void manifestDeclaresForegroundDataSyncPermission() throws Exception {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
PackageManager packageManager = context.getPackageManager();
|
||||
PackageInfo packageInfo = packageManager.getPackageInfo(
|
||||
context.getPackageName(),
|
||||
PackageManager.GET_PERMISSIONS
|
||||
);
|
||||
|
||||
assertNotNull(packageInfo.requestedPermissions);
|
||||
org.junit.Assert.assertTrue(
|
||||
java.util.Arrays.asList(packageInfo.requestedPermissions)
|
||||
.contains("android.permission.FOREGROUND_SERVICE_DATA_SYNC")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startCommandStartsForegroundSyncAndRealtimeWhenSessionExists() {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
SharedPreferences prefs = context.getSharedPreferences("boss-background-service", Context.MODE_PRIVATE);
|
||||
prefs.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.putString("restore_token", "restore-test")
|
||||
.apply();
|
||||
RecordingRealtimeRuntime runtime = new RecordingRealtimeRuntime();
|
||||
TestBossBackgroundRealtimeService.runtimeOverride = runtime;
|
||||
|
||||
TestBossBackgroundRealtimeService service = Robolectric
|
||||
.buildService(TestBossBackgroundRealtimeService.class)
|
||||
.create()
|
||||
.startCommand(0, 1)
|
||||
.get();
|
||||
|
||||
ShadowNotificationManager notificationManager = Shadows.shadowOf(
|
||||
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
|
||||
);
|
||||
|
||||
assertEquals(1, runtime.startCount);
|
||||
assertEquals(
|
||||
1,
|
||||
notificationManager.size()
|
||||
);
|
||||
assertEquals(
|
||||
"Boss 后台同步中",
|
||||
String.valueOf(
|
||||
notificationManager
|
||||
.getNotification(BossBackgroundRealtimeService.SERVICE_NOTIFICATION_ID)
|
||||
.extras
|
||||
.getCharSequence(android.app.Notification.EXTRA_TITLE)
|
||||
)
|
||||
);
|
||||
|
||||
service.onDestroy();
|
||||
assertEquals(1, runtime.stopCount);
|
||||
}
|
||||
|
||||
public static class TestBossBackgroundRealtimeService extends BossBackgroundRealtimeService {
|
||||
static RecordingRealtimeRuntime runtimeOverride;
|
||||
|
||||
@Override
|
||||
BossRealtimeRuntime createRealtimeRuntime(BossApiClient apiClient, BossNotificationRouter router) {
|
||||
return runtimeOverride == null ? super.createRealtimeRuntime(apiClient, router) : runtimeOverride;
|
||||
}
|
||||
|
||||
@Override
|
||||
BossApiClient createApiClient() {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
SharedPreferences prefs = context.getSharedPreferences("boss-background-service", Context.MODE_PRIVATE);
|
||||
return new BossApiClient(prefs, "https://boss.hyzq.net");
|
||||
}
|
||||
}
|
||||
|
||||
static final class RecordingRealtimeRuntime implements BossBackgroundRealtimeService.BossRealtimeRuntime {
|
||||
int startCount;
|
||||
int stopCount;
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
startCount += 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
stopCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,32 +60,4 @@ public class BossMarkdownTest {
|
||||
|
||||
assertEquals("(空消息)", rendered.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void render_normalizesColonSectionsIntoReadableBlocks() {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
|
||||
CharSequence rendered = BossMarkdown.render(
|
||||
context,
|
||||
"项目目标:完成 Boss 真机回归\n" +
|
||||
"当前进度:已完成 UI 调整\n" +
|
||||
"下一步:推送到 Gitea",
|
||||
false
|
||||
);
|
||||
|
||||
assertTrue(rendered instanceof Spanned);
|
||||
Spanned spanned = (Spanned) rendered;
|
||||
String text = spanned.toString();
|
||||
|
||||
assertTrue(text.contains("项目目标"));
|
||||
assertTrue(text.contains("完成 Boss 真机回归"));
|
||||
assertTrue(text.indexOf("项目目标") < text.indexOf("完成 Boss 真机回归"));
|
||||
assertTrue(text.contains("当前进度"));
|
||||
assertTrue(text.contains("已完成 UI 调整"));
|
||||
assertTrue(text.indexOf("当前进度") < text.indexOf("已完成 UI 调整"));
|
||||
assertTrue(text.contains("下一步"));
|
||||
assertTrue(text.contains("推送到 Gitea"));
|
||||
assertTrue(text.indexOf("下一步") < text.indexOf("推送到 Gitea"));
|
||||
assertTrue(text.contains("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Context;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RuntimeEnvironment;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.shadows.ShadowNotificationManager;
|
||||
import org.robolectric.shadows.ShadowApplication;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class BossNotificationRouterTest {
|
||||
@Test
|
||||
public void visibilityTrackerMarksForegroundAndVisibleProject() {
|
||||
BossAppVisibilityTracker tracker = new BossAppVisibilityTracker();
|
||||
|
||||
tracker.onAppForegrounded();
|
||||
tracker.setVisibleProjectId("master-agent");
|
||||
|
||||
assertTrue(tracker.isAppInForeground());
|
||||
assertEquals("master-agent", tracker.getVisibleProjectId());
|
||||
|
||||
tracker.clearVisibleProjectId("master-agent");
|
||||
tracker.onAppBackgrounded();
|
||||
|
||||
assertFalse(tracker.isAppInForeground());
|
||||
assertNull(tracker.getVisibleProjectId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void routerNotifiesOnlyForNewMasterAgentRepliesWhileBackgrounded() throws Exception {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
BossAppVisibilityTracker tracker = new BossAppVisibilityTracker();
|
||||
tracker.onAppBackgrounded();
|
||||
BossNotificationRouter router = new BossNotificationRouter(context, tracker);
|
||||
ShadowNotificationManager notificationManager = Shadows.shadowOf(
|
||||
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
|
||||
);
|
||||
ShadowApplication.getInstance().grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS);
|
||||
|
||||
JSONObject message = new JSONObject()
|
||||
.put("id", "m-2")
|
||||
.put("sender", "master")
|
||||
.put("senderLabel", "主 Agent · gpt-5.4-mini")
|
||||
.put("body", "主 Agent 已完成同步。")
|
||||
.put("sentAt", "2026-04-21T10:00:00.000Z");
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("projectId", "master-agent")
|
||||
.put("projectMessagesPayload", new JSONObject().put(
|
||||
"project",
|
||||
new JSONObject().put("messages", new JSONArray().put(message))
|
||||
));
|
||||
|
||||
assertTrue(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload)));
|
||||
assertFalse(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload)));
|
||||
assertEquals(1, notificationManager.size());
|
||||
Notification posted = notificationManager.getNotification(BossNotificationRouter.MASTER_AGENT_NOTIFICATION_ID);
|
||||
assertEquals("主 Agent", String.valueOf(posted.extras.getCharSequence(Notification.EXTRA_TITLE)));
|
||||
assertEquals("主 Agent 已完成同步。", String.valueOf(posted.extras.getCharSequence(Notification.EXTRA_TEXT)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void routerNotifiesForMasterAgentRepliesInsideThreadConversationsWhileBackgrounded() throws Exception {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
BossAppVisibilityTracker tracker = new BossAppVisibilityTracker();
|
||||
tracker.onAppBackgrounded();
|
||||
BossNotificationRouter router = new BossNotificationRouter(context, tracker);
|
||||
ShadowNotificationManager notificationManager = Shadows.shadowOf(
|
||||
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
|
||||
);
|
||||
ShadowApplication.getInstance().grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS);
|
||||
|
||||
JSONObject message = new JSONObject()
|
||||
.put("id", "thread-master-reply-1")
|
||||
.put("sender", "master")
|
||||
.put("senderLabel", "主 Agent · gpt-5.4-mini")
|
||||
.put("body", "我已接管这个线程,下一步先核对当前目标。");
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("projectId", "aiyanjing-thread")
|
||||
.put("projectMessagesPayload", new JSONObject().put(
|
||||
"project",
|
||||
new JSONObject()
|
||||
.put("name", "AI 眼镜线程")
|
||||
.put("messages", new JSONArray().put(message))
|
||||
));
|
||||
|
||||
assertTrue(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload)));
|
||||
|
||||
Notification posted = notificationManager.getNotification(BossNotificationRouter.MASTER_AGENT_NOTIFICATION_ID);
|
||||
assertEquals("主 Agent · AI 眼镜线程", String.valueOf(posted.extras.getCharSequence(Notification.EXTRA_TITLE)));
|
||||
assertEquals("我已接管这个线程,下一步先核对当前目标。", String.valueOf(posted.extras.getCharSequence(Notification.EXTRA_TEXT)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void routerSuppressesNotificationWhileAppIsForeground() throws Exception {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
BossAppVisibilityTracker tracker = new BossAppVisibilityTracker();
|
||||
tracker.onAppForegrounded();
|
||||
BossNotificationRouter router = new BossNotificationRouter(context, tracker);
|
||||
ShadowNotificationManager notificationManager = Shadows.shadowOf(
|
||||
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
|
||||
);
|
||||
ShadowApplication.getInstance().grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS);
|
||||
|
||||
JSONObject message = new JSONObject()
|
||||
.put("id", "m-3")
|
||||
.put("sender", "master")
|
||||
.put("senderLabel", "主 Agent · gpt-5.4-mini")
|
||||
.put("body", "这条前台不该弹通知。");
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("projectId", "master-agent")
|
||||
.put("projectMessagesPayload", new JSONObject().put(
|
||||
"project",
|
||||
new JSONObject().put("messages", new JSONArray().put(message))
|
||||
));
|
||||
|
||||
assertFalse(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload)));
|
||||
assertEquals(0, notificationManager.size());
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class BossRbacVisibilityTest {
|
||||
@Test
|
||||
public void memberMeMenuHidesAdministratorControlEntries() {
|
||||
assertArrayEquals(
|
||||
new String[]{"账号与安全", "设置", "技能", "关于"},
|
||||
WechatSurfaceMapper.rootMeMenuTitlesForRole("member")
|
||||
);
|
||||
|
||||
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("access", "member"));
|
||||
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("ai_accounts", "member"));
|
||||
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("ops", "member"));
|
||||
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("storage", "member"));
|
||||
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("telegram", "member"));
|
||||
assertTrue(WechatSurfaceMapper.canOpenMeEntryForRole("skills", "member"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void administratorMeMenuKeepsControlEntries() {
|
||||
assertArrayEquals(
|
||||
new String[]{"账号与安全", "设置", "用户与权限", "运维与修复", "AI 账号", "附件与存储", "Telegram 接入", "技能", "关于"},
|
||||
WechatSurfaceMapper.rootMeMenuTitlesForRole("highest_admin")
|
||||
);
|
||||
|
||||
assertTrue(WechatSurfaceMapper.canOpenMeEntryForRole("access", "highest_admin"));
|
||||
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("access", "admin"));
|
||||
assertTrue(WechatSurfaceMapper.canOpenMeEntryForRole("ai_accounts", "highest_admin"));
|
||||
assertTrue(WechatSurfaceMapper.canOpenMeEntryForRole("ops", "admin"));
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,13 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.io.IOException;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class BossRealtimeClientTest {
|
||||
@@ -42,10 +37,4 @@ public class BossRealtimeClientTest {
|
||||
public void parseEventBlockReturnsNullForEmptyEventPayloads() {
|
||||
assertNull(BossRealtimeClient.parseEventBlock("event: conversation.updated\n\n"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void socketTimeoutReconnectsImmediately() {
|
||||
assertTrue(BossRealtimeClient.shouldReconnectImmediately(new SocketTimeoutException("timeout")));
|
||||
assertFalse(BossRealtimeClient.shouldReconnectImmediately(new IOException("boom")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,29 +50,18 @@ public class BossUiConversationRowTest {
|
||||
TextView previewView = (TextView) centerColumn.getChildAt(centerColumn.getChildCount() - 1);
|
||||
|
||||
String metrics = String.format(
|
||||
"row=%d height=%d avatarHeight=%d center=%dx%d trailing=%dx%d title=%dx%d preview=%dx%d",
|
||||
"row=%d center=%d trailing=%d title=%d preview=%d",
|
||||
rowView.getMeasuredWidth(),
|
||||
rowView.getMeasuredHeight(),
|
||||
rowView.getChildAt(0).getMeasuredHeight(),
|
||||
centerColumn.getMeasuredWidth(),
|
||||
centerColumn.getMeasuredHeight(),
|
||||
trailingColumn.getMeasuredWidth(),
|
||||
trailingColumn.getMeasuredHeight(),
|
||||
titleView.getMeasuredWidth(),
|
||||
titleView.getMeasuredHeight(),
|
||||
previewView.getMeasuredWidth(),
|
||||
previewView.getMeasuredHeight()
|
||||
previewView.getMeasuredWidth()
|
||||
);
|
||||
assertEquals("列表项应对标微信当前紧凑密度: " + metrics, BossUi.dp(context, 12), rowView.getPaddingLeft());
|
||||
assertEquals("列表项应对标微信当前紧凑密度: " + metrics, BossUi.dp(context, 6), rowView.getPaddingTop());
|
||||
assertEquals("列表项应对标微信当前紧凑密度: " + metrics, BossUi.dp(context, 12), rowView.getPaddingRight());
|
||||
assertEquals("列表项应对标微信当前紧凑密度: " + metrics, BossUi.dp(context, 6), rowView.getPaddingBottom());
|
||||
assertEquals("列表项应使用微信式扁平 padding: " + metrics, BossUi.dp(context, 16), rowView.getPaddingLeft());
|
||||
assertEquals("列表项应使用微信式扁平 padding: " + metrics, BossUi.dp(context, 12), rowView.getPaddingTop());
|
||||
assertEquals("列表项应使用微信式扁平 padding: " + metrics, BossUi.dp(context, 16), rowView.getPaddingRight());
|
||||
assertEquals("列表项应使用微信式扁平 padding: " + metrics, BossUi.dp(context, 12), rowView.getPaddingBottom());
|
||||
assertEquals("列表项不应保持卡片式浮层感: " + metrics, 0f, rowView.getElevation(), 0.01f);
|
||||
assertTrue("单条会话行应维持微信式有效信息面积: " + metrics, rowView.getMeasuredHeight() <= BossUi.dp(context, 68));
|
||||
assertEquals("会话头像应回到微信列表的小尺寸: " + metrics, BossUi.dp(context, 40), rowView.getChildAt(0).getLayoutParams().width);
|
||||
assertEquals("会话头像应回到微信列表的小尺寸: " + metrics, BossUi.dp(context, 40), rowView.getChildAt(0).getLayoutParams().height);
|
||||
assertEquals("标题字号应对标微信列表主标题", 14f, titleView.getTextSize() / context.getResources().getDisplayMetrics().scaledDensity, 0.5f);
|
||||
assertEquals("预览字号应对标微信列表辅助文字", 11f, previewView.getTextSize() / context.getResources().getDisplayMetrics().scaledDensity, 0.5f);
|
||||
assertTrue("中间文字列不应被挤成 0 宽: " + metrics, centerColumn.getMeasuredWidth() > 0);
|
||||
assertTrue("标题需要保留可见宽度: " + metrics, titleView.getMeasuredWidth() > 0);
|
||||
assertTrue("预览需要保留可见宽度: " + metrics, previewView.getMeasuredWidth() > 0);
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertSame;
|
||||
|
||||
import android.content.Context;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.RuntimeEnvironment;
|
||||
import org.robolectric.annotation.Config;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class BossUiFormCellTest {
|
||||
@Test
|
||||
public void buildFormCell_detachesFieldFromPreviousParentBeforeReusingIt() {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
EditText field = new EditText(context);
|
||||
|
||||
BossUi.buildFormCell(context, "模型", "第一次渲染", field);
|
||||
LinearLayout secondCell = BossUi.buildFormCell(context, "模型", "刷新后重建", field);
|
||||
|
||||
assertSame(secondCell, field.getParent());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Context;
|
||||
@@ -40,28 +38,4 @@ public class BossUiMessageBubbleTest {
|
||||
assertTrue(bodyView.getText().toString().contains("重点"));
|
||||
assertTrue(bodyView.getText().toString().contains("代码"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void buildMessageBubble_usesWechatCompactTextMetrics() {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
|
||||
LinearLayout wrapper = BossUi.buildMessageBubble(
|
||||
context,
|
||||
"线程",
|
||||
"这是一条普通对话内容",
|
||||
"10:26",
|
||||
false,
|
||||
"结果"
|
||||
);
|
||||
|
||||
TextView metaView = (TextView) wrapper.getChildAt(0);
|
||||
LinearLayout bubble = (LinearLayout) wrapper.getChildAt(1);
|
||||
TextView kindView = (TextView) bubble.getChildAt(0);
|
||||
TextView bodyView = (TextView) bubble.getChildAt(1);
|
||||
|
||||
assertFalse("消息时间/发送人不应保留默认字体留白", metaView.getIncludeFontPadding());
|
||||
assertFalse("消息类型标签不应保留默认字体留白", kindView.getIncludeFontPadding());
|
||||
assertFalse("消息正文不应保留默认字体留白", bodyView.getIncludeFontPadding());
|
||||
assertEquals("消息正文应对标微信聊天正文", 14f, bodyView.getTextSize() / context.getResources().getDisplayMetrics().scaledDensity, 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.view.View;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.Button;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.json.JSONObject;
|
||||
@@ -16,7 +13,6 @@ import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@@ -38,32 +34,29 @@ public class BossUiRootSurfaceTest {
|
||||
"sessionData",
|
||||
new JSONObject()
|
||||
.put("displayName", "Kris")
|
||||
.put("account", "krisolo")
|
||||
.put("account", "17600003315")
|
||||
.put("role", "highest_admin")
|
||||
);
|
||||
ReflectionHelpers.callInstanceMethod(activity, "renderMeRoot");
|
||||
|
||||
LinearLayout content = ReflectionHelpers.getField(activity, "screenContent");
|
||||
assertEquals("我的页应是资料头 + 9 条菜单", 10, content.getChildCount());
|
||||
assertEquals("我的页应是资料头 + 10 条菜单", 11, content.getChildCount());
|
||||
|
||||
View header = content.getChildAt(0);
|
||||
assertEquals("资料头不应保留浮层卡片感", 0f, header.getElevation(), 0.01f);
|
||||
assertTrue("资料头应保持横向布局", header instanceof LinearLayout);
|
||||
View avatar = ((LinearLayout) header).getChildAt(0);
|
||||
assertEquals("我的页头像应对标微信资料入口尺寸", BossUi.dp(activity, 52), avatar.getLayoutParams().width);
|
||||
assertEquals("我的页头像应对标微信资料入口尺寸", BossUi.dp(activity, 52), avatar.getLayoutParams().height);
|
||||
assertTrue(viewTreeContainsText(header, "Kris"));
|
||||
assertTrue(viewTreeContainsText(header, "krisolo"));
|
||||
assertTrue(viewTreeContainsText(header, "17600003315"));
|
||||
assertTrue(viewTreeContainsText(header, "最高管理员"));
|
||||
assertTrue(viewTreeContainsText(header, "主控账号已启用安全保护"));
|
||||
|
||||
assertTrue(viewTreeContainsText(content, "主 Agent 提示词"));
|
||||
assertTrue(viewTreeContainsText(content, "主 Agent 记忆"));
|
||||
assertTrue(viewTreeContainsText(content, "全局接管"));
|
||||
assertTrue(viewTreeContainsText(content, "主 Agent 自动进化"));
|
||||
assertTrue(viewTreeContainsText(content, "账号与安全"));
|
||||
assertTrue(viewTreeContainsText(content, "设置"));
|
||||
assertTrue(viewTreeContainsText(content, "用户与权限"));
|
||||
assertTrue(viewTreeContainsText(content, "运维与修复"));
|
||||
assertTrue(viewTreeContainsText(content, "AI 账号"));
|
||||
assertTrue(viewTreeContainsText(content, "附件与存储"));
|
||||
assertTrue(viewTreeContainsText(content, "Telegram 接入"));
|
||||
assertTrue(viewTreeContainsText(content, "技能"));
|
||||
assertTrue(viewTreeContainsText(content, "关于"));
|
||||
|
||||
@@ -73,46 +66,6 @@ public class BossUiRootSurfaceTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openMeEntry_storageStartsAttachmentStorageSettings() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(
|
||||
activity,
|
||||
"sessionData",
|
||||
new JSONObject().put("role", "highest_admin")
|
||||
);
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"openMeEntry",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "storage")
|
||||
);
|
||||
|
||||
Intent started = Shadows.shadowOf(activity).getNextStartedActivity();
|
||||
assertNotNull(started);
|
||||
assertEquals(StorageSettingsActivity.class.getName(), started.getComponent().getClassName());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootTabs_useWechatIconLabelNavigation() {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
|
||||
Button conversations = activity.findViewById(R.id.tab_conversations);
|
||||
Button devices = activity.findViewById(R.id.tab_devices);
|
||||
Button me = activity.findViewById(R.id.tab_me);
|
||||
|
||||
assertEquals("会话", conversations.getText().toString());
|
||||
assertEquals("设备", devices.getText().toString());
|
||||
assertEquals("我的", me.getText().toString());
|
||||
assertEquals("顶部标题应对标微信页面标题", 18f, ((TextView) activity.findViewById(R.id.top_title)).getTextSize() / activity.getResources().getDisplayMetrics().scaledDensity, 0.5f);
|
||||
assertEquals("底栏高度应对标微信底栏有效面积", BossUi.dp(activity, 54), ((View) conversations.getParent()).getLayoutParams().height);
|
||||
assertNotNull("会话 tab 应显示顶部图标", conversations.getCompoundDrawables()[1]);
|
||||
assertNotNull("设备 tab 应显示顶部图标", devices.getCompoundDrawables()[1]);
|
||||
assertNotNull("我的 tab 应显示顶部图标", me.getCompoundDrawables()[1]);
|
||||
assertEquals("底栏文字应对标微信底栏标签", 10f, conversations.getTextSize() / activity.getResources().getDisplayMetrics().scaledDensity, 0.5f);
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
|
||||
@@ -21,9 +21,9 @@ public class BossUiTopActionStyleTest {
|
||||
|
||||
BossUi.applyTopIconButtonStyle(context, button);
|
||||
|
||||
assertEquals(BossUi.dp(context, 34), button.getMinimumWidth());
|
||||
assertEquals(BossUi.dp(context, 34), button.getMinimumHeight());
|
||||
assertEquals(BossUi.dp(context, 6), button.getPaddingLeft());
|
||||
assertEquals(BossUi.dp(context, 6), button.getPaddingTop());
|
||||
assertEquals(BossUi.dp(context, 40), button.getMinimumWidth());
|
||||
assertEquals(BossUi.dp(context, 40), button.getMinimumHeight());
|
||||
assertEquals(BossUi.dp(context, 8), button.getPaddingLeft());
|
||||
assertEquals(BossUi.dp(context, 8), button.getPaddingTop());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Intent;
|
||||
@@ -23,9 +22,10 @@ import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.shadows.ShadowDialog;
|
||||
import java.time.Duration;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class ConversationFolderActivityTest {
|
||||
@@ -55,8 +55,8 @@ public class ConversationFolderActivityTest {
|
||||
assertEquals(View.GONE, refreshButton.getVisibility());
|
||||
assertEquals("Talking", String.valueOf(titleView.getText()));
|
||||
assertEquals("3 个线程", String.valueOf(subtitleView.getText()));
|
||||
assertFalse("项目抽屉页不应展示冗余说明卡片标题", viewTreeContainsText(content, "项目内部线程页"));
|
||||
assertFalse("项目抽屉页不应展示冗余说明卡片文案", viewTreeContainsText(content, "点击线程后进入具体聊天窗口。"));
|
||||
assertTrue(viewTreeContainsText(content, "Talking"));
|
||||
assertTrue(viewTreeContainsText(content, "项目内部线程页"));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showMoreMenu");
|
||||
|
||||
@@ -86,12 +86,11 @@ public class ConversationFolderActivityTest {
|
||||
);
|
||||
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
assertFalse("项目抽屉页不应展示搜索定位说明卡片", viewTreeContainsText(content, "已定位到目标线程"));
|
||||
assertFalse("项目抽屉页不应展示匹配项置顶说明文案", viewTreeContainsText(content, "个匹配项已置顶"));
|
||||
assertTrue(viewTreeContainsText(content, "已定位到目标线程"));
|
||||
assertTrue(viewTreeContainsText(content, "目标线程"));
|
||||
assertTrue(viewTreeContainsText(content, "发布回滚"));
|
||||
assertEquals(2, countTextOccurrences(content, "目标线程"));
|
||||
assertEquals(2, countTextOccurrences(content, "发布回滚"));
|
||||
assertTrue(countTextOccurrences(content, "发布回滚") >= 3);
|
||||
assertEquals(0, countTextOccurrences(content, "project-1"));
|
||||
}
|
||||
|
||||
@@ -115,7 +114,7 @@ public class ConversationFolderActivityTest {
|
||||
);
|
||||
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
assertFalse("项目抽屉页不应展示搜索定位说明卡片", viewTreeContainsText(content, "已定位到目标线程"));
|
||||
assertTrue(viewTreeContainsText(content, "已定位到目标线程"));
|
||||
assertTrue(viewTreeContainsText(content, "日志收口"));
|
||||
assertEquals(0, countTextOccurrences(content, "project-99"));
|
||||
assertEquals(1, countTextOccurrences(content, "目标线程"));
|
||||
@@ -148,7 +147,7 @@ public class ConversationFolderActivityTest {
|
||||
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(400, TimeUnit.MILLISECONDS);
|
||||
|
||||
assertEquals(1, activity.reloadCount);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageButton;
|
||||
@@ -17,7 +15,6 @@ import android.widget.ListView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.SwitchCompat;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
@@ -40,7 +37,7 @@ import java.util.concurrent.TimeUnit;
|
||||
@Config(sdk = 34)
|
||||
public class ConversationInfoActivityTest {
|
||||
@Test
|
||||
public void renderConversationOmitsProfileHeaderAndStartsWithUsefulSettings() throws Exception {
|
||||
public void renderConversationUsesLightweightHeaderMenuAndThreadList() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
@@ -58,81 +55,22 @@ public class ConversationInfoActivityTest {
|
||||
);
|
||||
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
assertFalse(viewTreeContainsText(content, "线程状态摘要"));
|
||||
assertFalse(viewTreeContainsTextFragment(content, "当前进度:已经记录最近 2 条进展"));
|
||||
assertFalse(viewTreeContainsTextFragment(content, "建议下一步:继续同步 Android 只读页"));
|
||||
assertFalse(viewTreeContainsText(content, "单线程会话"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "主 Agent 协同接管"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(1), "发起群聊"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(1), "选择其他线程加入新群"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(2), "线程详情"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(2), "查看当前线程聊天与项目"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "北区试产线回归"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "单线程会话"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(1), "线程状态摘要"));
|
||||
assertTrue(viewTreeContainsTextFragment(content.getChildAt(1), "当前进度:已经记录最近 2 条进展"));
|
||||
assertTrue(viewTreeContainsTextFragment(content.getChildAt(1), "建议下一步:继续同步 Android 只读页"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(2), "主 Agent 协同接管"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(3), "发起群聊"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(3), "选择其他线程加入新群"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(4), "线程详情"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(4), "查看当前线程聊天与项目"));
|
||||
assertTrue(viewTreeContainsText(content, "参与线程"));
|
||||
assertTrue(viewTreeContainsText(content, "硬件审计协作"));
|
||||
assertFalse(viewTreeContainsText(content, "从当前会话选择其他线程,创建新的独立群聊"));
|
||||
assertFalse(viewTreeContainsText(content, "以下线程参与当前会话,点击可查看对应项目详情。"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void takeoverControlUsesWechatRowVisualSystem() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestConversationInfoActivity activity = Robolectric
|
||||
.buildActivity(TestConversationInfoActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderConversation",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildThreadStatusPayload())
|
||||
);
|
||||
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
LinearLayout takeoverRow = (LinearLayout) content.getChildAt(0);
|
||||
SwitchCompat takeoverSwitch = findFirstSwitch(takeoverRow);
|
||||
|
||||
assertEquals(LinearLayout.HORIZONTAL, takeoverRow.getOrientation());
|
||||
assertEquals(BossUi.dp(activity, 12), takeoverRow.getPaddingLeft());
|
||||
assertEquals(BossUi.dp(activity, 12), takeoverRow.getPaddingRight());
|
||||
assertNotNull(takeoverSwitch);
|
||||
assertEquals("", String.valueOf(takeoverSwitch.getText()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void conversationInfoRowsUseConsistentSpacingAndTakeoverHasNoDividerLines() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestConversationInfoActivity activity = Robolectric
|
||||
.buildActivity(TestConversationInfoActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderConversation",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildThreadStatusPayload())
|
||||
);
|
||||
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
int expectedBottomMargin = BossUi.dp(activity, 8);
|
||||
for (int index = 0; index < Math.min(content.getChildCount(), 6); index += 1) {
|
||||
View child = content.getChildAt(index);
|
||||
assertTrue(child.getLayoutParams() instanceof LinearLayout.LayoutParams);
|
||||
assertEquals(expectedBottomMargin, ((LinearLayout.LayoutParams) child.getLayoutParams()).bottomMargin);
|
||||
}
|
||||
|
||||
View takeoverRow = content.getChildAt(0);
|
||||
assertTrue(takeoverRow.getBackground() instanceof ColorDrawable);
|
||||
assertEquals(Color.WHITE, ((ColorDrawable) takeoverRow.getBackground()).getColor());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void threadDetailMenuRowStillOpensProjectDetail() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
@@ -297,42 +235,6 @@ public class ConversationInfoActivityTest {
|
||||
assertEquals(1, apiClient.autoLoginCalls);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void saveTakeoverSettingReturnsUpdatedResultState() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestConversationInfoActivity activity = Robolectric
|
||||
.buildActivity(TestConversationInfoActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(
|
||||
activity.getSharedPreferences("conversation-info-save-result-test", Context.MODE_PRIVATE),
|
||||
"https://boss.hyzq.net"
|
||||
);
|
||||
apiClient.failFirstLoad = false;
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
ReflectionHelpers.setField(activity, "reloadEnabled", true);
|
||||
ReflectionHelpers.setField(activity, "delegateReloadToSuper", true);
|
||||
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
|
||||
|
||||
activity.reload();
|
||||
ShadowLooper.shadowMainLooper().idle();
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"saveTakeoverSetting",
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, true)
|
||||
);
|
||||
ShadowLooper.shadowMainLooper().idle();
|
||||
|
||||
assertEquals(android.app.Activity.RESULT_OK, Shadows.shadowOf(activity).getResultCode());
|
||||
Intent resultIntent = Shadows.shadowOf(activity).getResultIntent();
|
||||
assertNotNull(resultIntent);
|
||||
assertTrue(resultIntent.getBooleanExtra(ConversationInfoActivity.EXTRA_TAKEOVER_ENABLED, false));
|
||||
assertEquals("北区试产线回归", resultIntent.getStringExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void matchingProjectMessagesUpdatedEventTriggersReload() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
@@ -491,23 +393,6 @@ public class ConversationInfoActivityTest {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static SwitchCompat findFirstSwitch(View root) {
|
||||
if (root instanceof SwitchCompat) {
|
||||
return (SwitchCompat) root;
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return null;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
SwitchCompat match = findFirstSwitch(group.getChildAt(index));
|
||||
if (match != null) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static class TestConversationInfoActivity extends ConversationInfoActivity {
|
||||
private boolean reloadEnabled;
|
||||
private boolean delegateReloadToSuper;
|
||||
@@ -589,7 +474,7 @@ public class ConversationInfoActivityTest {
|
||||
200,
|
||||
new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("session", new JSONObject().put("account", "krisolo"))
|
||||
.put("session", new JSONObject().put("account", "17600003315"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -76,72 +76,6 @@ public class DeviceDetailActivityTest {
|
||||
assertTrue(viewTreeContainsText(content, "未连接"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderDeviceShowsCodexRemoteControlActions() throws Exception {
|
||||
TestDeviceDetailActivity activity = Robolectric
|
||||
.buildActivity(
|
||||
TestDeviceDetailActivity.class,
|
||||
new Intent()
|
||||
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
|
||||
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")
|
||||
)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderDevice",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDevicePayload())
|
||||
);
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "Codex 远程控制"));
|
||||
assertTrue(viewTreeContainsText(content, "启动远控"));
|
||||
assertTrue(viewTreeContainsText(content, "停止远控"));
|
||||
assertTrue(viewTreeContainsText(content, "默认走 Codex Computer Use"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderDeviceShowsCodexAppServerProtocolAndCollaborationSummary() throws Exception {
|
||||
TestDeviceDetailActivity activity = Robolectric
|
||||
.buildActivity(
|
||||
TestDeviceDetailActivity.class,
|
||||
new Intent()
|
||||
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
|
||||
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")
|
||||
)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderDevice",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildCodexAppServerPayload())
|
||||
);
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "Codex App Server"));
|
||||
assertTrue(viewTreeContainsText(content, "已连接"));
|
||||
assertTrue(viewTreeContainsText(content, "模型"));
|
||||
assertTrue(viewTreeContainsText(content, "2 个 · 默认 gpt-5.4 · 快速 gpt-5.4-mini · 深度 gpt-5.4"));
|
||||
assertTrue(viewTreeContainsText(content, "扩展"));
|
||||
assertTrue(viewTreeContainsText(content, "Skill 1 个 · Plugin 1 个 · App 1 个"));
|
||||
assertTrue(viewTreeContainsText(content, "治理"));
|
||||
assertTrue(viewTreeContainsText(content, "实验特性 2 个 · 协作模式 2 个 · MCP 2 个 · 权限 1 个"));
|
||||
assertTrue(viewTreeContainsText(content, "账号"));
|
||||
assertTrue(viewTreeContainsText(content, "chatgpt · 套餐 pro · 额度 42%"));
|
||||
assertTrue(viewTreeContainsText(content, "线程"));
|
||||
assertTrue(viewTreeContainsText(content, "3 个 · 已加载 2 个 · 活跃 1 个"));
|
||||
assertTrue(viewTreeContainsText(content, "轮次"));
|
||||
assertTrue(viewTreeContainsText(content, "3 个 · 运行中 1 个 · 完成 2 个"));
|
||||
assertTrue(viewTreeContainsText(content, "线程操作"));
|
||||
assertTrue(viewTreeContainsText(content, "11 项 · 生命周期 5 项 · 活跃干预 2 项 · Shell 可用"));
|
||||
assertTrue(viewTreeContainsText(content, "线程协作"));
|
||||
assertTrue(viewTreeContainsText(content, "Boss Broker 可用 · 协作事件可处理 · 2 种模式 · 非原生私聊"));
|
||||
assertTrue(viewTreeContainsText(content, "协议漂移"));
|
||||
assertTrue(viewTreeContainsText(content, "兼容 · 失败探针 0 个 · 文档跟进 3 项 · Boss Broker 兜底"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderDeviceShowsProjectScopedConflictCardAndActions() throws Exception {
|
||||
TestDeviceDetailActivity activity = Robolectric
|
||||
@@ -253,43 +187,6 @@ public class DeviceDetailActivityTest {
|
||||
assertEquals("allow_always", apiClient.lastPayload.optString("conflictDecision"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void codexRemoteControlConfirmDialogQueuesStartAction() throws Exception {
|
||||
TestDeviceDetailActivity activity = Robolectric
|
||||
.buildActivity(
|
||||
TestDeviceDetailActivity.class,
|
||||
new Intent()
|
||||
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
|
||||
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")
|
||||
)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(
|
||||
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE),
|
||||
"https://boss.hyzq.net"
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"showCodexRemoteControlConfirmDialog",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "start")
|
||||
);
|
||||
|
||||
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
|
||||
assertTrue(latestDialog instanceof AlertDialog);
|
||||
AlertDialog dialog = (AlertDialog) latestDialog;
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).performClick();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
assertEquals(1, apiClient.queueCodexRemoteControlCalls);
|
||||
assertEquals("device-1", apiClient.lastCodexRemoteControlDeviceId);
|
||||
assertEquals("start", apiClient.lastCodexRemoteControlAction);
|
||||
assertEquals("APP 设备详情页确认启动 Codex 远控", apiClient.lastCodexRemoteControlReason);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void matchingDevicesUpdatedEventTriggersReload() throws Exception {
|
||||
TestDeviceDetailActivity activity = Robolectric
|
||||
@@ -400,7 +297,7 @@ public class DeviceDetailActivityTest {
|
||||
.put("id", "device-1")
|
||||
.put("name", "Mac Studio")
|
||||
.put("avatar", "M")
|
||||
.put("account", "krisolo")
|
||||
.put("account", "17600003315")
|
||||
.put("status", "online")
|
||||
.put("quota5h", 75)
|
||||
.put("quota7d", 88)
|
||||
@@ -436,67 +333,6 @@ public class DeviceDetailActivityTest {
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static JSONObject buildCodexAppServerPayload() throws Exception {
|
||||
JSONObject payload = buildDevicePayload();
|
||||
JSONObject capabilities = payload
|
||||
.getJSONObject("workspace")
|
||||
.getJSONObject("selectedDevice")
|
||||
.getJSONObject("capabilities");
|
||||
capabilities.put(
|
||||
"codexAppServer",
|
||||
new JSONObject()
|
||||
.put("connected", true)
|
||||
.put("lastSeenAt", "2026-06-04T10:00:00+08:00")
|
||||
.put("metadata", new JSONObject()
|
||||
.put("models", new JSONArray()
|
||||
.put(new JSONObject().put("id", "gpt-5.4"))
|
||||
.put(new JSONObject().put("id", "gpt-5.4-mini")))
|
||||
.put("defaultModelId", "gpt-5.4")
|
||||
.put("fastModelId", "gpt-5.4-mini")
|
||||
.put("deepModelId", "gpt-5.4")
|
||||
.put("skills", new JSONArray().put(new JSONObject().put("name", "image2-ui-prototype")))
|
||||
.put("plugins", new JSONArray().put(new JSONObject().put("id", "github")))
|
||||
.put("apps", new JSONArray().put(new JSONObject().put("id", "canva")))
|
||||
.put("experimentalFeatures", new JSONArray()
|
||||
.put(new JSONObject().put("name", "multi_agent"))
|
||||
.put(new JSONObject().put("name", "apps")))
|
||||
.put("collaborationModes", new JSONArray()
|
||||
.put(new JSONObject().put("id", "solo"))
|
||||
.put(new JSONObject().put("id", "plan")))
|
||||
.put("mcpServers", new JSONArray()
|
||||
.put(new JSONObject().put("name", "github"))
|
||||
.put(new JSONObject().put("name", "figma")))
|
||||
.put("permissionProfiles", new JSONArray().put(new JSONObject().put("id", ":workspace")))
|
||||
.put("accountSummary", new JSONObject()
|
||||
.put("authMode", "chatgpt")
|
||||
.put("planType", "pro"))
|
||||
.put("rateLimitSummary", new JSONObject().put("maxUsedPercent", 42))
|
||||
.put("threadSummary", new JSONObject()
|
||||
.put("threadCount", 3)
|
||||
.put("loadedThreadCount", 2)
|
||||
.put("activeThreadCount", 1))
|
||||
.put("threadTurnSummary", new JSONObject()
|
||||
.put("totalTurnCount", 3)
|
||||
.put("runningTurnCount", 1)
|
||||
.put("completedTurnCount", 2))
|
||||
.put("threadActionSummary", new JSONObject()
|
||||
.put("actionCount", 11)
|
||||
.put("lifecycleActionCount", 5)
|
||||
.put("liveTurnActionCount", 2)
|
||||
.put("shellActionAvailable", true))
|
||||
.put("threadCollaborationSummary", new JSONObject()
|
||||
.put("bossBrokerAvailable", true)
|
||||
.put("collabToolCallHandlerAvailable", true)
|
||||
.put("directThreadChatSupported", false)
|
||||
.put("collaborationModeCount", 2))
|
||||
.put("protocolDriftSummary", new JSONObject()
|
||||
.put("driftLevel", "compatible")
|
||||
.put("failedProbeCount", 0)
|
||||
.put("docFollowupCount", 3)
|
||||
.put("fallbackStrategy", "Boss Broker + App Server 注入/执行"))));
|
||||
return payload;
|
||||
}
|
||||
|
||||
public static class TestDeviceDetailActivity extends DeviceDetailActivity {
|
||||
boolean reloadEnabled = true;
|
||||
int reloadCount;
|
||||
@@ -524,10 +360,6 @@ public class DeviceDetailActivityTest {
|
||||
private int updateDeviceCalls;
|
||||
private String lastDeviceId;
|
||||
private JSONObject lastPayload;
|
||||
private int queueCodexRemoteControlCalls;
|
||||
private String lastCodexRemoteControlDeviceId;
|
||||
private String lastCodexRemoteControlAction;
|
||||
private String lastCodexRemoteControlReason;
|
||||
|
||||
RecordingBossApiClient(android.content.SharedPreferences prefs, String baseUrl) {
|
||||
super(prefs, baseUrl);
|
||||
@@ -544,19 +376,6 @@ public class DeviceDetailActivityTest {
|
||||
throw new RuntimeException(error);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse queueCodexRemoteControl(String deviceId, String action, String reason) {
|
||||
queueCodexRemoteControlCalls += 1;
|
||||
lastCodexRemoteControlDeviceId = deviceId;
|
||||
lastCodexRemoteControlAction = action;
|
||||
lastCodexRemoteControlReason = reason;
|
||||
try {
|
||||
return new ApiResponse(200, new JSONObject().put("ok", true));
|
||||
} catch (Exception error) {
|
||||
throw new RuntimeException(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class DirectExecutorService extends AbstractExecutorService {
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.view.View;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class MainActivityBootstrapSessionTest {
|
||||
@Test
|
||||
public void bootstrapSession_withoutSessionHints_showsLoginFormAndDoesNotAutoLogin() throws Exception {
|
||||
TestBootstrapSessionMainActivity activity =
|
||||
Robolectric.buildActivity(TestBootstrapSessionMainActivity.class).setup().get();
|
||||
SharedPreferences prefs = activity.getSharedPreferences("test-bootstrap-session", Context.MODE_PRIVATE);
|
||||
prefs.edit().clear().apply();
|
||||
|
||||
Shadows.shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(200));
|
||||
|
||||
View loginPanel = activity.findViewById(R.id.login_panel);
|
||||
View contentPanel = activity.findViewById(R.id.content_panel);
|
||||
android.widget.EditText accountInput = activity.findViewById(R.id.login_account_input);
|
||||
android.widget.EditText passwordInput = activity.findViewById(R.id.login_password_input);
|
||||
|
||||
assertEquals(0, activity.apiClient.autoLoginCalls);
|
||||
assertEquals(View.VISIBLE, loginPanel.getVisibility());
|
||||
assertEquals(View.GONE, contentPanel.getVisibility());
|
||||
assertNotNull(accountInput);
|
||||
assertNotNull(passwordInput);
|
||||
assertFalse(accountInput.getHint().toString().isEmpty());
|
||||
assertFalse(passwordInput.getHint().toString().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bootstrapSession_withSessionHints_prefersRestoreAndDoesNotAutoLogin() throws Exception {
|
||||
TestRestoreBootstrapSessionMainActivity activity =
|
||||
Robolectric.buildActivity(TestRestoreBootstrapSessionMainActivity.class).setup().get();
|
||||
|
||||
waitFor(() -> activity.apiClient.restoreCalls > 0 && activity.apiClient.homeCalls > 0);
|
||||
|
||||
View loginPanel = activity.findViewById(R.id.login_panel);
|
||||
View contentPanel = activity.findViewById(R.id.content_panel);
|
||||
JSONObject sessionData = ReflectionHelpers.getField(activity, "sessionData");
|
||||
|
||||
assertEquals(0, activity.apiClient.autoLoginCalls);
|
||||
assertEquals(1, activity.apiClient.getSessionCalls);
|
||||
assertEquals(1, activity.apiClient.restoreCalls);
|
||||
assertEquals(View.GONE, loginPanel.getVisibility());
|
||||
assertEquals(View.VISIBLE, contentPanel.getVisibility());
|
||||
assertNotNull(sessionData);
|
||||
assertEquals("krisolo", sessionData.optString("account", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void forceLogoutIntentClearsExistingContentSessionAndShowsLogin() throws Exception {
|
||||
TestRestoreBootstrapSessionMainActivity activity =
|
||||
Robolectric.buildActivity(TestRestoreBootstrapSessionMainActivity.class).setup().get();
|
||||
|
||||
waitFor(() -> activity.apiClient.restoreCalls > 0 && activity.apiClient.homeCalls > 0);
|
||||
|
||||
Intent intent = new Intent(activity, MainActivity.class);
|
||||
intent.putExtra("force_logout", true);
|
||||
activity.onNewIntent(intent);
|
||||
Shadows.shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(200));
|
||||
|
||||
View loginPanel = activity.findViewById(R.id.login_panel);
|
||||
View contentPanel = activity.findViewById(R.id.content_panel);
|
||||
JSONObject sessionData = ReflectionHelpers.getField(activity, "sessionData");
|
||||
|
||||
assertEquals(View.VISIBLE, loginPanel.getVisibility());
|
||||
assertEquals(View.GONE, contentPanel.getVisibility());
|
||||
assertEquals(null, sessionData);
|
||||
}
|
||||
|
||||
private static void waitFor(BooleanSupplier condition) {
|
||||
long deadline = System.currentTimeMillis() + 5_000L;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
Shadows.shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(50));
|
||||
if (condition.getAsBoolean()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new AssertionError("Condition not met before timeout");
|
||||
}
|
||||
|
||||
public static class TestBootstrapSessionMainActivity extends MainActivity {
|
||||
RecordingBootstrapApiClient apiClient;
|
||||
|
||||
@Override
|
||||
BossApiClient createApiClient() {
|
||||
apiClient = new RecordingBootstrapApiClient(
|
||||
getSharedPreferences("test-bootstrap-session", Context.MODE_PRIVATE)
|
||||
);
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
BossRealtimeClient createRealtimeClient(BossApiClient client) {
|
||||
return new BossRealtimeClient(client, new BossRealtimeClient.Listener() {
|
||||
@Override
|
||||
public void onRealtimeEvent(BossRealtimeEvent event) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public static class TestRestoreBootstrapSessionMainActivity extends MainActivity {
|
||||
RecordingRestoreBootstrapApiClient apiClient;
|
||||
|
||||
@Override
|
||||
BossApiClient createApiClient() {
|
||||
apiClient = new RecordingRestoreBootstrapApiClient(
|
||||
getSharedPreferences("test-bootstrap-session-restore", Context.MODE_PRIVATE)
|
||||
);
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
BossRealtimeClient createRealtimeClient(BossApiClient client) {
|
||||
return new BossRealtimeClient(client, new BossRealtimeClient.Listener() {
|
||||
@Override
|
||||
public void onRealtimeEvent(BossRealtimeEvent event) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingBootstrapApiClient extends BossApiClient {
|
||||
int autoLoginCalls;
|
||||
int homeCalls;
|
||||
int devicesCalls;
|
||||
int otaCalls;
|
||||
int settingsCalls;
|
||||
|
||||
RecordingBootstrapApiClient(SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasSessionHints() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse autoLogin() throws java.io.IOException, org.json.JSONException {
|
||||
autoLoginCalls += 1;
|
||||
JSONObject session = new JSONObject()
|
||||
.put("account", "krisolo")
|
||||
.put("displayName", "Boss 超级管理员")
|
||||
.put("restoreToken", "restore-auto");
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("session", session));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse restoreSession() throws java.io.IOException, org.json.JSONException {
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_RESTORE_TOKEN"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getSession() throws java.io.IOException, org.json.JSONException {
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_SESSION"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getConversationHome() throws java.io.IOException, org.json.JSONException {
|
||||
homeCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("conversations", new JSONArray().put(new JSONObject()
|
||||
.put("projectId", "master-agent")
|
||||
.put("conversationType", "master_agent")
|
||||
.put("projectTitle", "主 Agent")
|
||||
.put("threadTitle", "主 Agent 汇总")
|
||||
.put("lastMessagePreview", "最近会话已恢复")
|
||||
.put("latestReplyLabel", "刚刚"))));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getDevices() throws java.io.IOException, org.json.JSONException {
|
||||
devicesCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("devices", new JSONArray()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getOtaStatus() throws java.io.IOException, org.json.JSONException {
|
||||
otaCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("hasOta", false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getSettings() throws java.io.IOException, org.json.JSONException {
|
||||
settingsCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("settings", new JSONObject().put("preferredEntryPoint", "conversations"))
|
||||
.put("user", new JSONObject()));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingRestoreBootstrapApiClient extends BossApiClient {
|
||||
int autoLoginCalls;
|
||||
int getSessionCalls;
|
||||
int restoreCalls;
|
||||
int homeCalls;
|
||||
int devicesCalls;
|
||||
int otaCalls;
|
||||
int settingsCalls;
|
||||
|
||||
RecordingRestoreBootstrapApiClient(SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
prefs.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.putString("restore_token", "restore-test")
|
||||
.apply();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse autoLogin() throws java.io.IOException, org.json.JSONException {
|
||||
autoLoginCalls += 1;
|
||||
return ApiResponse.error(500, new JSONObject().put("ok", false).put("message", "AUTO_LOGIN_SHOULD_NOT_RUN"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getSession() throws java.io.IOException, org.json.JSONException {
|
||||
getSessionCalls += 1;
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "SESSION_EXPIRED"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse restoreSession() throws java.io.IOException, org.json.JSONException {
|
||||
restoreCalls += 1;
|
||||
JSONObject session = new JSONObject()
|
||||
.put("account", "krisolo")
|
||||
.put("displayName", "Boss 超级管理员")
|
||||
.put("restoreToken", "restore-test");
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("session", session));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getConversationHome() throws java.io.IOException, org.json.JSONException {
|
||||
homeCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("conversations", new JSONArray().put(new JSONObject()
|
||||
.put("projectId", "master-agent")
|
||||
.put("conversationType", "master_agent")
|
||||
.put("projectTitle", "主 Agent")
|
||||
.put("threadTitle", "主 Agent 汇总")
|
||||
.put("lastMessagePreview", "最近会话已恢复")
|
||||
.put("latestReplyLabel", "刚刚"))));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getDevices() throws java.io.IOException, org.json.JSONException {
|
||||
devicesCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("devices", new JSONArray()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getOtaStatus() throws java.io.IOException, org.json.JSONException {
|
||||
otaCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("hasOta", false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getSettings() throws java.io.IOException, org.json.JSONException {
|
||||
settingsCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("settings", new JSONObject().put("preferredEntryPoint", "conversations"))
|
||||
.put("user", new JSONObject()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
|
||||
import org.junit.Test;
|
||||
@@ -15,7 +11,6 @@ import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.shadows.ShadowApplication;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@@ -28,72 +23,28 @@ public class MainActivityConversationAutoRefreshTest {
|
||||
MainActivity activity = controller.get();
|
||||
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.putString("restore_token", "test-restore-token")
|
||||
.apply();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false));
|
||||
|
||||
assertTrue(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "devices"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false));
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
assertFalse(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false));
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
assertTrue(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
|
||||
|
||||
controller.pause();
|
||||
assertFalse(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void returningToVisibleConversationRootRefreshesImmediatelyOnResume() {
|
||||
org.robolectric.android.controller.ActivityController<TestResumeRefreshMainActivity> controller =
|
||||
Robolectric.buildActivity(TestResumeRefreshMainActivity.class).setup().resume();
|
||||
TestResumeRefreshMainActivity activity = controller.get();
|
||||
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.apply();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
activity.conversationRefreshCount = 0;
|
||||
|
||||
controller.pause();
|
||||
controller.resume();
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void showContent_doesNotRequestNotificationPermissionInSameTapFrame() {
|
||||
ShadowApplication.getInstance().denyPermissions(Manifest.permission.POST_NOTIFICATIONS);
|
||||
org.robolectric.android.controller.ActivityController<MainActivity> controller =
|
||||
Robolectric.buildActivity(MainActivity.class).setup();
|
||||
MainActivity activity = controller.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
|
||||
assertNull(Shadows.shadowOf(activity).getLastRequestedPermission());
|
||||
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(java.time.Duration.ofMillis(500));
|
||||
|
||||
assertNotNull(Shadows.shadowOf(activity).getLastRequestedPermission());
|
||||
assertEquals(1, Shadows.shadowOf(activity).getLastRequestedPermission().requestedPermissions.length);
|
||||
assertEquals(
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
Shadows.shadowOf(activity).getLastRequestedPermission().requestedPermissions[0]
|
||||
);
|
||||
}
|
||||
|
||||
public static class TestResumeRefreshMainActivity extends MainActivity {
|
||||
int conversationRefreshCount;
|
||||
|
||||
@Override
|
||||
void refreshConversationsData() {
|
||||
conversationRefreshCount += 1;
|
||||
completeRealtimeTabRefresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,8 @@ import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.Manifest;
|
||||
import android.view.View;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.EditText;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.LinearLayout;
|
||||
@@ -24,9 +21,6 @@ import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.shadow.api.Shadow;
|
||||
import org.robolectric.shadows.ShadowInputMethodManager;
|
||||
import org.robolectric.shadows.ShadowApplication;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@@ -130,60 +124,7 @@ public class MainActivityConversationSearchTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void searchMode_showsSoftKeyboardWhenActivated() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
ImageButton searchButton = activity.findViewById(R.id.search_button);
|
||||
searchButton.performClick();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
EditText searchInput = activity.findViewById(R.id.top_search_input);
|
||||
InputMethodManager inputMethodManager = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
ShadowInputMethodManager shadowInputMethodManager = Shadow.extract(inputMethodManager);
|
||||
|
||||
assertTrue(searchInput.isFocused());
|
||||
assertTrue(shadowInputMethodManager.isSoftInputVisible());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void searchHitOnSingleThread_exitsSearchModeAndOpensProjectDetail() throws Exception {
|
||||
ShadowApplication.getInstance().grantPermissions(Manifest.permission.POST_NOTIFICATIONS);
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
ImageButton searchButton = activity.findViewById(R.id.search_button);
|
||||
searchButton.performClick();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
EditText searchInput = activity.findViewById(R.id.top_search_input);
|
||||
searchInput.setText("树莓派");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
|
||||
View row = getRecyclerChild(list, 0);
|
||||
row.performClick();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
|
||||
InputMethodManager inputMethodManager = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
ShadowInputMethodManager shadowInputMethodManager = Shadow.extract(inputMethodManager);
|
||||
|
||||
assertEquals(ProjectDetailActivity.class.getName(), nextIntent.getComponent().getClassName());
|
||||
assertEquals("p1", nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_ID));
|
||||
assertFalse(ReflectionHelpers.getField(activity, "conversationSearchMode"));
|
||||
assertEquals("", searchInput.getText().toString());
|
||||
assertFalse(shadowInputMethodManager.isSoftInputVisible());
|
||||
assertFalse(activity.isFinishing());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void searchHitInsideArchivedProject_opensMatchedThreadDetailAndClearsSearchState() throws Exception {
|
||||
ShadowApplication.getInstance().grantPermissions(Manifest.permission.POST_NOTIFICATIONS);
|
||||
public void searchHitInsideArchivedProject_keepsProjectContextAndOpensFolderPage() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "conversationsData", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
@@ -214,51 +155,14 @@ public class MainActivityConversationSearchTest {
|
||||
row.performClick();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
|
||||
assertEquals(ProjectDetailActivity.class.getName(), nextIntent.getComponent().getClassName());
|
||||
assertEquals("thread-revert-1", nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_ID));
|
||||
assertEquals("发布回滚", nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME));
|
||||
assertFalse(ReflectionHelpers.getField(activity, "conversationSearchMode"));
|
||||
assertEquals("", searchInput.getText().toString());
|
||||
assertFalse(activity.isFinishing());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void archivedProjectSearchByFolderName_stillOpensFolderPage() throws Exception {
|
||||
ShadowApplication.getInstance().grantPermissions(Manifest.permission.POST_NOTIFICATIONS);
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "conversationsData", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("projectId", "folder-boss")
|
||||
.put("conversationType", "folder_archive")
|
||||
.put("folderKey", "mac-studio:boss")
|
||||
.put("folderLabel", "Boss")
|
||||
.put("projectTitle", "Boss")
|
||||
.put("threadTitle", "Boss")
|
||||
.put("lastMessagePreview", "最近:发布回滚")
|
||||
.put("latestReplyLabel", "11:00")
|
||||
.put("searchAliases", new JSONArray().put("发布回滚"))
|
||||
.put("searchTargetProjectIds", new JSONArray().put("thread-revert-1"))));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "enterConversationSearchMode");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
EditText searchInput = activity.findViewById(R.id.top_search_input);
|
||||
searchInput.setText("Boss");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
|
||||
View row = getRecyclerChild(list, 0);
|
||||
row.performClick();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
|
||||
assertEquals(ConversationFolderActivity.class.getName(), nextIntent.getComponent().getClassName());
|
||||
assertEquals("mac-studio:boss", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY));
|
||||
assertFalse(ReflectionHelpers.getField(activity, "conversationSearchMode"));
|
||||
assertEquals("", searchInput.getText().toString());
|
||||
assertEquals("Boss", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_FOLDER_NAME));
|
||||
assertEquals("thread-revert-1", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_ID));
|
||||
assertEquals(2, nextIntent.getStringArrayExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_IDS).length);
|
||||
assertEquals("thread-revert-2", nextIntent.getStringArrayExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_IDS)[1]);
|
||||
assertEquals("发布回滚", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_LABEL));
|
||||
}
|
||||
|
||||
private static JSONArray buildConversations() throws Exception {
|
||||
|
||||
@@ -90,7 +90,6 @@ public class MainActivityConversationSelectionTest {
|
||||
public void topPlusAction_opensWechatStyleDropdownMenu() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
|
||||
ReflectionHelpers.setField(activity, "sessionData", new JSONObject().put("role", "highest_admin"));
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
@@ -107,27 +106,6 @@ public class MainActivityConversationSelectionTest {
|
||||
assertTrue(viewTreeContainsText(menu, "发起群聊"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void topPlusAction_hidesAddDeviceForSubAccount() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
|
||||
ReflectionHelpers.setField(activity, "sessionData", new JSONObject().put("role", "member"));
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
ImageButton actionButton = activity.findViewById(R.id.refresh_button);
|
||||
actionButton.performClick();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
View overlay = activity.findViewById(R.id.conversation_quick_actions_overlay);
|
||||
View menu = activity.findViewById(R.id.conversation_quick_actions_menu);
|
||||
assertEquals(View.VISIBLE, overlay.getVisibility());
|
||||
assertEquals(View.VISIBLE, menu.getVisibility());
|
||||
assertFalse(viewTreeContainsVisibleText(menu, "添加设备"));
|
||||
assertTrue(viewTreeContainsVisibleText(menu, "扫一扫"));
|
||||
assertTrue(viewTreeContainsVisibleText(menu, "发起群聊"));
|
||||
}
|
||||
|
||||
private static View getRecyclerChild(RecyclerView recyclerView, int position) {
|
||||
RecyclerView.Adapter adapter = recyclerView.getAdapter();
|
||||
int viewType = adapter.getItemViewType(position);
|
||||
@@ -210,28 +188,6 @@ public class MainActivityConversationSelectionTest {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsVisibleText(View root, String expectedText) {
|
||||
if (root.getVisibility() != View.VISIBLE) {
|
||||
return false;
|
||||
}
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
if (expectedText.contentEquals(text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!(root instanceof LinearLayout)) {
|
||||
return false;
|
||||
}
|
||||
LinearLayout group = (LinearLayout) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
if (viewTreeContainsVisibleText(group.getChildAt(index), expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsContentDescription(View root, String expectedText) {
|
||||
CharSequence description = root.getContentDescription();
|
||||
if (expectedText.contentEquals(description)) {
|
||||
|
||||
@@ -31,7 +31,7 @@ public class MainActivityDevicesRootTest {
|
||||
.put("name", "Mac Studio")
|
||||
.put("status", "online")
|
||||
.put("platform", "macOS")
|
||||
.put("account", "krisolo")));
|
||||
.put("account", "17600003315")));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import android.content.Intent;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class MainActivityMeEntryNavigationTest {
|
||||
@Test
|
||||
public void masterAgentPromptMeEntryOpensPromptActivityForMasterAgent() {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"openMeEntry",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "master_agent_prompt")
|
||||
);
|
||||
|
||||
Intent started = Shadows.shadowOf(activity).getNextStartedActivity();
|
||||
assertEquals(MasterAgentPromptActivity.class.getName(), started.getComponent().getClassName());
|
||||
assertEquals("master-agent", started.getStringExtra(MasterAgentPromptActivity.EXTRA_PROJECT_ID));
|
||||
assertEquals("主 Agent", started.getStringExtra(MasterAgentPromptActivity.EXTRA_PROJECT_NAME));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masterAgentMemoryMeEntryOpensMemoryActivityForMasterAgent() {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"openMeEntry",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "master_agent_memory")
|
||||
);
|
||||
|
||||
Intent started = Shadows.shadowOf(activity).getNextStartedActivity();
|
||||
assertEquals(MasterAgentMemoryActivity.class.getName(), started.getComponent().getClassName());
|
||||
assertEquals("master-agent", started.getStringExtra(MasterAgentMemoryActivity.EXTRA_PROJECT_ID));
|
||||
assertEquals("主 Agent", started.getStringExtra(MasterAgentMemoryActivity.EXTRA_PROJECT_NAME));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masterAgentTakeoverMeEntryOpensTakeoverActivityForMasterAgent() {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"openMeEntry",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "master_agent_takeover")
|
||||
);
|
||||
|
||||
Intent started = Shadows.shadowOf(activity).getNextStartedActivity();
|
||||
assertEquals(MasterAgentTakeoverActivity.class.getName(), started.getComponent().getClassName());
|
||||
assertEquals("master-agent", started.getStringExtra(MasterAgentTakeoverActivity.EXTRA_PROJECT_ID));
|
||||
assertEquals("主 Agent", started.getStringExtra(MasterAgentTakeoverActivity.EXTRA_PROJECT_NAME));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masterAgentEvolutionMeEntryOpensEvolutionActivity() {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"openMeEntry",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "master_agent_evolution")
|
||||
);
|
||||
|
||||
Intent started = Shadows.shadowOf(activity).getNextStartedActivity();
|
||||
assertEquals(MasterAgentEvolutionActivity.class.getName(), started.getComponent().getClassName());
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package com.hyzq.boss;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Looper;
|
||||
import org.json.JSONObject;
|
||||
import org.json.JSONArray;
|
||||
@@ -16,15 +15,17 @@ import org.robolectric.annotation.Config;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class MainActivityRealtimeTest {
|
||||
@Test
|
||||
public void conversationRealtimeEventRefreshesVisibleConversationTab() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
private static void showConversationTab(MainActivity activity) {
|
||||
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("restore_token", "test-restore-token")
|
||||
.apply();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
@@ -32,9 +33,17 @@ public class MainActivityRealtimeTest {
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
activity.conversationRefreshCount = 0;
|
||||
activity.deviceRefreshCount = 0;
|
||||
activity.meRefreshCount = 0;
|
||||
}
|
||||
|
||||
private static void flushRealtimeDebounce(MainActivity activity) {
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(400, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void conversationRealtimeEventRefreshesVisibleConversationTab() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
showConversationTab(activity);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
@@ -43,9 +52,7 @@ public class MainActivityRealtimeTest {
|
||||
new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-1"))
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
flushRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
@@ -55,7 +62,8 @@ public class MainActivityRealtimeTest {
|
||||
@Test
|
||||
public void devicesRealtimeEventDoesNotRefreshConversationTab() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
showConversationTab(activity);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
@@ -64,7 +72,7 @@ public class MainActivityRealtimeTest {
|
||||
new BossRealtimeEvent("devices.updated", new JSONObject().put("deviceId", "mac-studio"))
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
flushRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
@@ -73,7 +81,8 @@ public class MainActivityRealtimeTest {
|
||||
@Test
|
||||
public void blankProjectIdConversationEventDoesNotRefreshVisibleConversationTab() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
showConversationTab(activity);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
@@ -82,7 +91,7 @@ public class MainActivityRealtimeTest {
|
||||
new BossRealtimeEvent("conversation.updated", new JSONObject())
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
flushRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
}
|
||||
@@ -90,16 +99,8 @@ public class MainActivityRealtimeTest {
|
||||
@Test
|
||||
public void deviceScopedConversationEventRefreshesVisibleConversationTab() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"setActiveTab",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
activity.conversationRefreshCount = 0;
|
||||
activity.deviceRefreshCount = 0;
|
||||
activity.meRefreshCount = 0;
|
||||
showConversationTab(activity);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
@@ -108,9 +109,7 @@ public class MainActivityRealtimeTest {
|
||||
new BossRealtimeEvent("conversation.updated", new JSONObject().put("deviceId", "mac-studio"))
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
flushRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
@@ -119,7 +118,8 @@ public class MainActivityRealtimeTest {
|
||||
@Test
|
||||
public void contextIndicatorEventRefreshesVisibleConversationTab() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
showConversationTab(activity);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
@@ -131,18 +131,17 @@ public class MainActivityRealtimeTest {
|
||||
)
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
flushRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void contextIndicatorSnapshotWithoutProjectIdRefreshesVisibleConversationTab() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
showConversationTab(activity);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
@@ -154,18 +153,17 @@ public class MainActivityRealtimeTest {
|
||||
)
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
flushRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void distinctConversationEventsBackToBackCoalesceIntoSingleVisibleConversationRefresh() throws Exception {
|
||||
public void distinctConversationEventsBackToBackBothRefreshVisibleConversationTab() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
showConversationTab(activity);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
@@ -188,9 +186,7 @@ public class MainActivityRealtimeTest {
|
||||
)
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
flushRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
@@ -199,16 +195,15 @@ public class MainActivityRealtimeTest {
|
||||
@Test
|
||||
public void devicesRealtimeEventRefreshesVisibleDevicesTabOnly() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
showConversationTab(activity);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"setActiveTab",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "devices"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
activity.conversationRefreshCount = 0;
|
||||
activity.deviceRefreshCount = 0;
|
||||
activity.meRefreshCount = 0;
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
@@ -217,11 +212,9 @@ public class MainActivityRealtimeTest {
|
||||
new BossRealtimeEvent("devices.updated", new JSONObject().put("deviceId", "mac-studio"))
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
flushRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
assertEquals(1, activity.deviceRefreshCount);
|
||||
assertEquals(0, activity.meRefreshCount);
|
||||
}
|
||||
@@ -229,16 +222,15 @@ public class MainActivityRealtimeTest {
|
||||
@Test
|
||||
public void otaRealtimeEventRefreshesVisibleMeTabOnly() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
showConversationTab(activity);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"setActiveTab",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "me"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
activity.conversationRefreshCount = 0;
|
||||
activity.deviceRefreshCount = 0;
|
||||
activity.meRefreshCount = 0;
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
@@ -247,28 +239,18 @@ public class MainActivityRealtimeTest {
|
||||
new BossRealtimeEvent("ota.updated", new JSONObject())
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
flushRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
assertEquals(0, activity.meRefreshCount);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
assertEquals(1, activity.meRefreshCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void burstConversationRealtimeEventsCoalesceIntoSingleFollowUpRefresh() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"setActiveTab",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
activity.conversationRefreshCount = 0;
|
||||
activity.deviceRefreshCount = 0;
|
||||
activity.meRefreshCount = 0;
|
||||
showConversationTab(activity);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
ReflectionHelpers.setField(activity, "rootTabRefreshInFlight", true);
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
@@ -279,7 +261,7 @@ public class MainActivityRealtimeTest {
|
||||
new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-1"))
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
flushRealtimeDebounce(activity);
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
@@ -302,7 +284,6 @@ public class MainActivityRealtimeTest {
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
|
||||
activity.completeRealtimeTabRefresh();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
waitFor(() -> activity.conversationRefreshCount == 1);
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
@@ -311,28 +292,7 @@ public class MainActivityRealtimeTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void realtimeDisconnectTriggersImmediateConversationFallbackRefresh() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.apply();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeConnectionChanged",
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
assertEquals(0, activity.meRefreshCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshConversationsData_prefersGroupedHomeFeedOverFlatConversationFeed() throws Exception {
|
||||
public void refreshConversationsData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
@@ -344,47 +304,18 @@ public class MainActivityRealtimeTest {
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
activity.refreshConversationsData();
|
||||
waitFor(() -> apiClient.homeCalls > 0);
|
||||
waitFor(() -> apiClient.homeCalls > 0 || apiClient.conversationsCalls > 0);
|
||||
|
||||
assertEquals(1, apiClient.homeCalls);
|
||||
assertEquals(0, apiClient.conversationsCalls);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshConversationsData_prefersGroupedHomeFeedForRootList() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
SharedPreferences prefs = activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE);
|
||||
prefs.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.putString("restore_token", "restore-test")
|
||||
.apply();
|
||||
RecordingConversationSourceClient apiClient = new RecordingConversationSourceClient(
|
||||
prefs
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
activity.refreshConversationsData();
|
||||
waitFor(() -> apiClient.homeCalls > 0);
|
||||
|
||||
assertEquals(1, apiClient.homeCalls);
|
||||
assertEquals(0, apiClient.conversationsCalls);
|
||||
JSONArray conversationsData = ReflectionHelpers.getField(activity, "conversationsData");
|
||||
assertEquals(1, conversationsData.length());
|
||||
assertEquals("folder_archive", conversationsData.optJSONObject(0).optString("conversationType", ""));
|
||||
assertEquals("mac-studio:boss", conversationsData.optJSONObject(0).optString("projectId", ""));
|
||||
assertEquals(2, conversationsData.optJSONObject(0).optInt("threadCount", 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshConversationsData_groupsFlatFallbackFeedWhenHomeFeedFails() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
RecordingRejectedHomeConversationSourceClient apiClient = new RecordingRejectedHomeConversationSourceClient(
|
||||
RecordingRejectedConversationSourceClient apiClient = new RecordingRejectedConversationSourceClient(
|
||||
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
@@ -407,7 +338,7 @@ public class MainActivityRealtimeTest {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
RecordingIOExceptionHomeConversationSourceClient apiClient = new RecordingIOExceptionHomeConversationSourceClient(
|
||||
RecordingIOExceptionConversationSourceClient apiClient = new RecordingIOExceptionConversationSourceClient(
|
||||
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
@@ -426,7 +357,7 @@ public class MainActivityRealtimeTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshAllData_prefersGroupedHomeFeedOverFlatConversationFeed() throws Exception {
|
||||
public void refreshAllData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
@@ -442,51 +373,18 @@ public class MainActivityRealtimeTest {
|
||||
"refreshAllData",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject())
|
||||
);
|
||||
waitFor(() -> apiClient.homeCalls > 0);
|
||||
waitFor(() -> apiClient.homeCalls > 0 || apiClient.conversationsCalls > 0);
|
||||
|
||||
assertEquals(1, apiClient.homeCalls);
|
||||
assertEquals(0, apiClient.conversationsCalls);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshAllData_prefersGroupedHomeFeedForRootList() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
SharedPreferences prefs = activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE);
|
||||
prefs.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.putString("restore_token", "restore-test")
|
||||
.apply();
|
||||
RecordingConversationSourceClient apiClient = new RecordingConversationSourceClient(
|
||||
prefs
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"refreshAllData",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject())
|
||||
);
|
||||
waitFor(() -> apiClient.homeCalls > 0);
|
||||
|
||||
assertEquals(1, apiClient.homeCalls);
|
||||
assertEquals(0, apiClient.conversationsCalls);
|
||||
JSONArray conversationsData = ReflectionHelpers.getField(activity, "conversationsData");
|
||||
assertEquals(1, conversationsData.length());
|
||||
assertEquals("folder_archive", conversationsData.optJSONObject(0).optString("conversationType", ""));
|
||||
assertEquals("mac-studio:boss", conversationsData.optJSONObject(0).optString("projectId", ""));
|
||||
assertEquals(2, conversationsData.optJSONObject(0).optInt("threadCount", 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshAllData_groupsFlatFallbackFeedWhenHomeFeedFails() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
RecordingRejectedHomeConversationSourceClient apiClient = new RecordingRejectedHomeConversationSourceClient(
|
||||
RecordingRejectedConversationSourceClient apiClient = new RecordingRejectedConversationSourceClient(
|
||||
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
@@ -513,7 +411,7 @@ public class MainActivityRealtimeTest {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
RecordingIOExceptionHomeConversationSourceClient apiClient = new RecordingIOExceptionHomeConversationSourceClient(
|
||||
RecordingIOExceptionConversationSourceClient apiClient = new RecordingIOExceptionConversationSourceClient(
|
||||
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
@@ -557,13 +455,6 @@ public class MainActivityRealtimeTest {
|
||||
int deviceRefreshCount;
|
||||
int meRefreshCount;
|
||||
|
||||
@Override
|
||||
BossApiClient createApiClient() {
|
||||
SharedPreferences prefs = getSharedPreferences("boss_native_client", Context.MODE_PRIVATE);
|
||||
prefs.edit().clear().apply();
|
||||
return new InertBootstrapApiClient(prefs);
|
||||
}
|
||||
|
||||
@Override
|
||||
void refreshConversationsData() {
|
||||
conversationRefreshCount += 1;
|
||||
@@ -583,28 +474,7 @@ public class MainActivityRealtimeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class InertBootstrapApiClient extends BossApiClient {
|
||||
InertBootstrapApiClient(SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse autoLogin() throws IOException, org.json.JSONException {
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "TEST_BOOTSTRAP_DISABLED"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse restoreSession() throws IOException, org.json.JSONException {
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "TEST_BOOTSTRAP_DISABLED"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getSession() throws IOException, org.json.JSONException {
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "TEST_BOOTSTRAP_DISABLED"));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingRejectedHomeConversationSourceClient extends BossApiClient {
|
||||
private static final class RecordingRejectedConversationSourceClient extends BossApiClient {
|
||||
int homeCalls;
|
||||
int conversationsCalls;
|
||||
int sessionCalls;
|
||||
@@ -612,7 +482,7 @@ public class MainActivityRealtimeTest {
|
||||
int settingsCalls;
|
||||
int otaCalls;
|
||||
|
||||
RecordingRejectedHomeConversationSourceClient(android.content.SharedPreferences prefs) {
|
||||
RecordingRejectedConversationSourceClient(android.content.SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@@ -629,7 +499,7 @@ public class MainActivityRealtimeTest {
|
||||
conversationsCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("conversations", RecordingConversationSourceClient.buildFlatConversations()));
|
||||
.put("conversations", buildFlatConversations()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -638,7 +508,7 @@ public class MainActivityRealtimeTest {
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("session", new JSONObject()
|
||||
.put("account", "krisolo")
|
||||
.put("account", "17600003315")
|
||||
.put("displayName", "Boss 超级管理员")));
|
||||
}
|
||||
|
||||
@@ -666,6 +536,32 @@ public class MainActivityRealtimeTest {
|
||||
.put("ok", true)
|
||||
.put("hasOta", false));
|
||||
}
|
||||
|
||||
private static JSONArray buildFlatConversations() throws org.json.JSONException {
|
||||
return new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("projectId", "thread-revert")
|
||||
.put("conversationType", "single_device")
|
||||
.put("projectTitle", "发布回滚")
|
||||
.put("threadTitle", "发布回滚")
|
||||
.put("folderLabel", "Boss")
|
||||
.put("folderKey", "mac-studio:boss")
|
||||
.put("lastMessagePreview", "最近:发布回滚")
|
||||
.put("latestReplyAt", "2026-04-06T10:00:00.000Z")
|
||||
.put("latestReplyLabel", "11:00")
|
||||
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 80).put("level", "watch")))
|
||||
.put(new JSONObject()
|
||||
.put("projectId", "thread-ui")
|
||||
.put("conversationType", "single_device")
|
||||
.put("projectTitle", "Android UI 收尾")
|
||||
.put("threadTitle", "Android UI 收尾")
|
||||
.put("folderLabel", "Boss")
|
||||
.put("folderKey", "mac-studio:boss")
|
||||
.put("lastMessagePreview", "最近:Android UI 收尾")
|
||||
.put("latestReplyAt", "2026-04-06T09:59:00.000Z")
|
||||
.put("latestReplyLabel", "10:59")
|
||||
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 95).put("level", "safe")));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingConversationSourceClient extends BossApiClient {
|
||||
@@ -702,7 +598,7 @@ public class MainActivityRealtimeTest {
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("session", new JSONObject()
|
||||
.put("account", "krisolo")
|
||||
.put("account", "17600003315")
|
||||
.put("displayName", "Boss 超级管理员")));
|
||||
}
|
||||
|
||||
@@ -733,15 +629,13 @@ public class MainActivityRealtimeTest {
|
||||
|
||||
private static JSONArray buildHomeConversations() throws org.json.JSONException {
|
||||
return new JSONArray().put(new JSONObject()
|
||||
.put("projectId", "mac-studio:boss")
|
||||
.put("projectId", "folder-boss")
|
||||
.put("conversationType", "folder_archive")
|
||||
.put("folderKey", "mac-studio:boss")
|
||||
.put("projectTitle", "Boss")
|
||||
.put("threadTitle", "Boss")
|
||||
.put("threadCount", 2)
|
||||
.put("folderLabel", "2 个线程 · 最近:发布回滚")
|
||||
.put("searchAliases", new JSONArray().put("发布回滚").put("Android UI 收尾"))
|
||||
.put("searchTargetProjectIds", new JSONArray().put("thread-revert").put("thread-ui"))
|
||||
.put("lastMessagePreview", "最近:发布回滚")
|
||||
.put("latestReplyLabel", "11:00"));
|
||||
}
|
||||
@@ -773,7 +667,7 @@ public class MainActivityRealtimeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingIOExceptionHomeConversationSourceClient extends BossApiClient {
|
||||
private static final class RecordingIOExceptionConversationSourceClient extends BossApiClient {
|
||||
int homeCalls;
|
||||
int conversationsCalls;
|
||||
int sessionCalls;
|
||||
@@ -781,7 +675,7 @@ public class MainActivityRealtimeTest {
|
||||
int settingsCalls;
|
||||
int otaCalls;
|
||||
|
||||
RecordingIOExceptionHomeConversationSourceClient(android.content.SharedPreferences prefs) {
|
||||
RecordingIOExceptionConversationSourceClient(android.content.SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@@ -796,7 +690,7 @@ public class MainActivityRealtimeTest {
|
||||
conversationsCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("conversations", RecordingConversationSourceClient.buildFlatConversations()));
|
||||
.put("conversations", buildFlatConversations()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -805,7 +699,7 @@ public class MainActivityRealtimeTest {
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("session", new JSONObject()
|
||||
.put("account", "krisolo")
|
||||
.put("account", "17600003315")
|
||||
.put("displayName", "Boss 超级管理员")));
|
||||
}
|
||||
|
||||
@@ -833,5 +727,31 @@ public class MainActivityRealtimeTest {
|
||||
.put("ok", true)
|
||||
.put("hasOta", false));
|
||||
}
|
||||
|
||||
private static JSONArray buildFlatConversations() throws org.json.JSONException {
|
||||
return new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("projectId", "thread-revert")
|
||||
.put("conversationType", "single_device")
|
||||
.put("projectTitle", "发布回滚")
|
||||
.put("threadTitle", "发布回滚")
|
||||
.put("folderLabel", "Boss")
|
||||
.put("folderKey", "mac-studio:boss")
|
||||
.put("lastMessagePreview", "最近:发布回滚")
|
||||
.put("latestReplyAt", "2026-04-06T10:00:00.000Z")
|
||||
.put("latestReplyLabel", "11:00")
|
||||
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 80).put("level", "watch")))
|
||||
.put(new JSONObject()
|
||||
.put("projectId", "thread-ui")
|
||||
.put("conversationType", "single_device")
|
||||
.put("projectTitle", "Android UI 收尾")
|
||||
.put("threadTitle", "Android UI 收尾")
|
||||
.put("folderLabel", "Boss")
|
||||
.put("folderKey", "mac-studio:boss")
|
||||
.put("lastMessagePreview", "最近:Android UI 收尾")
|
||||
.put("latestReplyAt", "2026-04-06T09:59:00.000Z")
|
||||
.put("latestReplyLabel", "10:59")
|
||||
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 95).put("level", "safe")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class MasterAgentEvolutionActivityTest {
|
||||
@Test
|
||||
public void renderDashboardShowsModePendingProposalSignalAndRule() throws Exception {
|
||||
TestMasterAgentEvolutionActivity activity = Robolectric
|
||||
.buildActivity(TestMasterAgentEvolutionActivity.class, new Intent())
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("config", new JSONObject()
|
||||
.put("mode", "autonomous")
|
||||
.put("autoApplyLowRiskRules", true))
|
||||
.put("signals", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("signalId", "signal-1")
|
||||
.put("kind", "repeated_question")
|
||||
.put("requestText", "当前主节点在线吗")
|
||||
.put("createdAt", "2026-04-16T12:00:00+08:00")))
|
||||
.put("proposals", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("proposalId", "proposal-1")
|
||||
.put("status", "pending_review")
|
||||
.put("proposalType", "fast_path_rule")
|
||||
.put("riskLevel", "low")
|
||||
.put("createdAt", "2026-04-16T12:01:00+08:00")
|
||||
.put("title", "新增 Fast Path")
|
||||
.put("summary", "把状态查询加入本地直答")))
|
||||
.put("rules", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("ruleId", "rule-1")
|
||||
.put("ruleType", "routing_preference_patch")
|
||||
.put("sourceProposalId", "proposal-2")
|
||||
.put("createdAt", "2026-04-16T12:02:00+08:00")));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderDashboard",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
|
||||
);
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "完全自我进化"));
|
||||
assertTrue(viewTreeContainsText(content, "待处理提案"));
|
||||
assertTrue(viewTreeContainsText(content, "新增 Fast Path"));
|
||||
assertTrue(viewTreeContainsText(content, "repeated_question"));
|
||||
assertTrue(viewTreeContainsText(content, "routing_preference_patch"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void matchingRealtimeEventTriggersReload() throws Exception {
|
||||
TestMasterAgentEvolutionActivity activity = Robolectric
|
||||
.buildActivity(TestMasterAgentEvolutionActivity.class, new Intent())
|
||||
.setup()
|
||||
.resume()
|
||||
.get();
|
||||
activity.reloadEnabled = true;
|
||||
activity.reloadCount = 0;
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
ReflectionHelpers.ClassParameter.from(
|
||||
BossRealtimeEvent.class,
|
||||
new BossRealtimeEvent("master_agent.settings.updated", new JSONObject())
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
assertEquals(1, activity.reloadCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderDashboardHidesManageActionsForReadonlyView() throws Exception {
|
||||
TestMasterAgentEvolutionActivity activity = Robolectric
|
||||
.buildActivity(TestMasterAgentEvolutionActivity.class, new Intent())
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("canManage", false)
|
||||
.put("config", new JSONObject()
|
||||
.put("mode", "controlled")
|
||||
.put("autoApplyLowRiskRules", false))
|
||||
.put("signals", new JSONArray())
|
||||
.put("proposals", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("proposalId", "proposal-1")
|
||||
.put("status", "pending_review")
|
||||
.put("proposalType", "fast_path_rule")
|
||||
.put("riskLevel", "low")
|
||||
.put("createdAt", "2026-04-16T12:01:00+08:00")
|
||||
.put("title", "新增 Fast Path")
|
||||
.put("summary", "把状态查询加入本地直答")))
|
||||
.put("rules", new JSONArray());
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderDashboard",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
|
||||
);
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "当前是只读视角"));
|
||||
assertTrue(viewTreeContainsText(content, "你当前没有管理权限"));
|
||||
assertFalse(viewTreeContainsText(content, "切到全自动模式"));
|
||||
assertFalse(viewTreeContainsText(content, "批准"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderDashboardShowsStatusBannerAndFormattedTimes() throws Exception {
|
||||
TestMasterAgentEvolutionActivity activity = Robolectric
|
||||
.buildActivity(TestMasterAgentEvolutionActivity.class, new Intent())
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.setField(activity, "statusMessage", "提案已批准");
|
||||
ReflectionHelpers.setField(activity, "statusIsError", false);
|
||||
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("config", new JSONObject()
|
||||
.put("mode", "controlled")
|
||||
.put("autoApplyLowRiskRules", false))
|
||||
.put("signals", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("signalId", "signal-1")
|
||||
.put("kind", "backend_fallback")
|
||||
.put("requestText", "主节点暂时不可用")
|
||||
.put("createdAt", "2026-04-16T12:00:00+08:00")))
|
||||
.put("proposals", new JSONArray())
|
||||
.put("rules", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("ruleId", "rule-1")
|
||||
.put("ruleType", "fast_path_rule")
|
||||
.put("createdAt", "2026-04-16T12:02:00+08:00")));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderDashboard",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
|
||||
);
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "最近操作"));
|
||||
assertTrue(viewTreeContainsText(content, "提案已批准"));
|
||||
assertTrue(viewTreeContainsText(content, "2026-04-16 12:00:00"));
|
||||
assertTrue(viewTreeContainsText(content, "2026-04-16 12:02:00"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderLoadErrorStateKeepsRetryEntryVisible() throws Exception {
|
||||
TestMasterAgentEvolutionActivity activity = Robolectric
|
||||
.buildActivity(TestMasterAgentEvolutionActivity.class, new Intent())
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.setField(activity, "statusMessage", "自动进化加载失败:network down");
|
||||
ReflectionHelpers.setField(activity, "statusIsError", true);
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderLoadErrorState",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "network down")
|
||||
);
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "当前加载失败,保留在这个页面直接重试即可。"));
|
||||
assertTrue(viewTreeContainsText(content, "重新加载"));
|
||||
assertTrue(viewTreeContainsText(content, "自动进化中心暂时不可用:network down"));
|
||||
assertTrue(viewTreeContainsText(content, "最近状态"));
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
if (text != null && text.toString().contains(expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return false;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static class TestMasterAgentEvolutionActivity extends MasterAgentEvolutionActivity {
|
||||
private boolean reloadEnabled;
|
||||
private int reloadCount;
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (!reloadEnabled) {
|
||||
return;
|
||||
}
|
||||
reloadCount += 1;
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,86 @@ public class MasterAgentPromptActivityTest {
|
||||
assertTrue(viewTreeContainsText(content, "未检测到有效的 Claw 启动脚本"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderPromptProfileIncludesHermesRuntimeWhenSelectable() throws Exception {
|
||||
TestMasterAgentPromptActivity activity = Robolectric
|
||||
.buildActivity(
|
||||
TestMasterAgentPromptActivity.class,
|
||||
new Intent()
|
||||
.putExtra(MasterAgentPromptActivity.EXTRA_PROJECT_ID, "master-agent")
|
||||
.putExtra(MasterAgentPromptActivity.EXTRA_PROJECT_NAME, "主 Agent")
|
||||
)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("promptPolicy", new JSONObject().put("globalPrompt", "全局主提示词"))
|
||||
.put("userPrompt", new JSONObject().put("content", "用户私有主提示词"))
|
||||
.put("projectControls", new JSONObject()
|
||||
.put("promptOverride", "当前对话提示词")
|
||||
.put("backendOverride", "hermes-runtime"))
|
||||
.put("clawAvailability", new JSONObject()
|
||||
.put("status", "ready")
|
||||
.put("selectable", true)
|
||||
.put("reasonLabel", "Claw Runtime 可用。"))
|
||||
.put("hermesAvailability", new JSONObject()
|
||||
.put("status", "ready")
|
||||
.put("selectable", true)
|
||||
.put("reasonLabel", "Hermes Runtime 可用。"));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderPromptProfile",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
|
||||
);
|
||||
|
||||
Spinner backendSpinner = ReflectionHelpers.getField(activity, "backendSpinner");
|
||||
assertEquals(3, backendSpinner.getAdapter().getCount());
|
||||
assertEquals(2, backendSpinner.getSelectedItemPosition());
|
||||
assertEquals("Hermes Runtime", String.valueOf(backendSpinner.getAdapter().getItem(2)));
|
||||
TextView previewTextView = ReflectionHelpers.getField(activity, "previewTextView");
|
||||
assertTrue(String.valueOf(previewTextView.getText()).contains("【执行后端】"));
|
||||
assertTrue(String.valueOf(previewTextView.getText()).contains("hermes-runtime"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderPromptProfileShowsHermesUnavailableHintWhenStoredOverrideCannotBeSelected() throws Exception {
|
||||
TestMasterAgentPromptActivity activity = Robolectric
|
||||
.buildActivity(
|
||||
TestMasterAgentPromptActivity.class,
|
||||
new Intent()
|
||||
.putExtra(MasterAgentPromptActivity.EXTRA_PROJECT_ID, "master-agent")
|
||||
.putExtra(MasterAgentPromptActivity.EXTRA_PROJECT_NAME, "主 Agent")
|
||||
)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("promptPolicy", new JSONObject().put("globalPrompt", "全局主提示词"))
|
||||
.put("userPrompt", new JSONObject().put("content", "用户私有主提示词"))
|
||||
.put("projectControls", new JSONObject()
|
||||
.put("promptOverride", "当前对话提示词")
|
||||
.put("backendOverride", "hermes-runtime"))
|
||||
.put("hermesAvailability", new JSONObject()
|
||||
.put("status", "misconfigured")
|
||||
.put("selectable", false)
|
||||
.put("reason", "script_not_found")
|
||||
.put("reasonLabel", "未检测到有效的 Hermes 启动脚本,将自动回退到默认后端。"));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderPromptProfile",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
|
||||
);
|
||||
|
||||
Spinner backendSpinner = ReflectionHelpers.getField(activity, "backendSpinner");
|
||||
assertEquals(1, backendSpinner.getAdapter().getCount());
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "Hermes Runtime 当前不可用"));
|
||||
assertTrue(viewTreeContainsText(content, "未检测到有效的 Hermes 启动脚本"));
|
||||
assertTrue(viewTreeContainsText(content, "当前对话之前保存过 Hermes Runtime"));
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
|
||||
@@ -176,7 +176,7 @@ public class MasterAgentTakeoverActivityTest {
|
||||
200,
|
||||
new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("session", new JSONObject().put("account", "krisolo"))
|
||||
.put("session", new JSONObject().put("account", "17600003315"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,14 +59,6 @@ public class ProjectChatUiStateTest {
|
||||
assertTrue(ProjectChatUiState.canForwardSelection(next));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void copySelectionRequiresAtLeastOneMessage() {
|
||||
assertFalse(ProjectChatUiState.canCopySelection(ProjectChatUiState.emptySelection()));
|
||||
|
||||
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
|
||||
assertTrue(ProjectChatUiState.canCopySelection(state));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void selectionPreservesInsertionOrder() {
|
||||
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m2");
|
||||
@@ -112,7 +104,6 @@ public class ProjectChatUiStateTest {
|
||||
assertTrue(chromeState.showMultiSelectBar);
|
||||
assertFalse(chromeState.showRefresh);
|
||||
assertFalse(chromeState.showHeaderAction);
|
||||
assertTrue(chromeState.copyEnabled);
|
||||
assertTrue(chromeState.forwardEnabled);
|
||||
assertEquals("取消", chromeState.backLabel);
|
||||
assertEquals("已选 2 条", chromeState.title);
|
||||
@@ -129,7 +120,6 @@ public class ProjectChatUiStateTest {
|
||||
assertFalse(chromeState.showMultiSelectBar);
|
||||
assertFalse(chromeState.showRefresh);
|
||||
assertTrue(chromeState.showHeaderAction);
|
||||
assertFalse(chromeState.copyEnabled);
|
||||
assertFalse(chromeState.forwardEnabled);
|
||||
assertEquals("返回", chromeState.backLabel);
|
||||
assertEquals("北区试产线回归", chromeState.title);
|
||||
@@ -146,7 +136,6 @@ public class ProjectChatUiStateTest {
|
||||
assertFalse(chromeState.showMultiSelectBar);
|
||||
assertTrue(chromeState.showRefresh);
|
||||
assertFalse(chromeState.showHeaderAction);
|
||||
assertFalse(chromeState.copyEnabled);
|
||||
assertFalse(chromeState.forwardEnabled);
|
||||
assertEquals("返回", chromeState.backLabel);
|
||||
assertEquals("北区试产线回归", chromeState.title);
|
||||
@@ -207,10 +196,9 @@ public class ProjectChatUiStateTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void queuedReplyTaskStartsReplyWaitFromImmediateReplyWhenPresent() throws Exception {
|
||||
public void queuedReplyTaskStartsReplyWaitFromRequestMessageId() throws Exception {
|
||||
JSONObject response = new JSONObject()
|
||||
.put("message", new JSONObject().put("id", "msg-user-1"))
|
||||
.put("replyMessage", new JSONObject().put("id", "msg-master-ack-1"))
|
||||
.put("task", new JSONObject()
|
||||
.put("taskId", "task-1")
|
||||
.put("taskType", "conversation_reply")
|
||||
@@ -219,7 +207,7 @@ public class ProjectChatUiStateTest {
|
||||
ProjectChatUiState.ReplyWaitSpec waitSpec = ProjectChatUiState.resolveReplyWaitAfterSend(response);
|
||||
|
||||
assertTrue(waitSpec.shouldWait);
|
||||
assertEquals("msg-master-ack-1", waitSpec.baselineMessageId);
|
||||
assertEquals("msg-user-1", waitSpec.baselineMessageId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -262,318 +250,6 @@ public class ProjectChatUiStateTest {
|
||||
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void replyWaitIgnoresDuplicateBaselineMessages() throws Exception {
|
||||
JSONObject project = new JSONObject()
|
||||
.put("messages", new JSONArray()
|
||||
.put(new JSONObject().put("id", "msg-user-1"))
|
||||
.put(new JSONObject().put("id", "msg-user-1")));
|
||||
|
||||
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, "msg-user-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void timedOutMasterRelayKeepsConversationPollingEvenWhenRealtimeConnected() {
|
||||
assertTrue(ProjectChatUiState.shouldAutoRefreshConversation(true, true, true));
|
||||
assertTrue(ProjectChatUiState.shouldAutoRefreshConversation(true, false, false));
|
||||
assertFalse(ProjectChatUiState.shouldAutoRefreshConversation(true, true, false));
|
||||
assertFalse(ProjectChatUiState.shouldAutoRefreshConversation(false, true, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void threadProcessMessagesAreCollapsedBeforeFinalResult() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "u1")
|
||||
.put("sender", "user")
|
||||
.put("body", "继续"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("body", "我先看一下当前聊天渲染链路和消息结构。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p2")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("body", "接下来我会补一组单元测试,再把折叠 UI 接上。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "r1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("body", "这轮已经接好过程折叠,最终结果现在直接显示在主消息流里。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(3, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
|
||||
assertEquals("u1", items.get(0).message.optString("id"));
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
|
||||
assertEquals(2, items.get(1).processMessages.size());
|
||||
assertEquals("p1", items.get(1).processMessages.get(0).optString("id"));
|
||||
assertEquals("p2", items.get(1).processMessages.get(1).optString("id"));
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
|
||||
assertEquals("r1", items.get(2).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void errorMessagesStayVisibleInsteadOfBeingCollapsed() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "e1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("body", "当前执行失败,构建报错,需要先补依赖。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(1, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
|
||||
assertEquals("e1", items.get(0).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void processGroupPreviewUsesLatestProgressLine() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("body", "我先检查项目结构。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p2")
|
||||
.put("sender", "device")
|
||||
.put("body", "接下来开始补聊天折叠按钮。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(1, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
|
||||
assertEquals("接下来开始补聊天折叠按钮。", ProjectChatUiState.processGroupPreview(items.get(0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitThreadProcessKindIsCollapsedEvenWhenCopyLooksLikeACompletionUpdate() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "thread_process")
|
||||
.put("body", "工程骨架已经建好了,我现在开始写核心代码。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p2")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "thread_process")
|
||||
.put("body", "编译错误已定位到导入问题,我已修复并正在重新构建确认。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "r1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "text")
|
||||
.put("body", "已完成折叠修复,过程消息会收进按钮里,未读只增加一次。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(2, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
|
||||
assertEquals(2, items.get(0).processMessages.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(1).type);
|
||||
assertEquals("r1", items.get(1).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void executionProgressCardsStayVisibleBetweenProcessGroups() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "thread_process")
|
||||
.put("body", "我先检查当前执行链路。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "progress-1")
|
||||
.put("sender", "master")
|
||||
.put("senderLabel", "主 Agent")
|
||||
.put("kind", "execution_progress")
|
||||
.put("body", "执行进度")
|
||||
.put("executionProgress", new JSONObject()
|
||||
.put("status", "running")
|
||||
.put("steps", new JSONArray()
|
||||
.put(new JSONObject().put("text", "接收对话任务").put("status", "done"))
|
||||
.put(new JSONObject().put("text", "等待目标线程回复").put("status", "running")))))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p2")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "thread_process")
|
||||
.put("body", "我继续执行验证。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(3, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(1).type);
|
||||
assertEquals("progress-1", items.get(1).message.optString("id"));
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(2).type);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void processGroupKeepsFinalResultVisibleWhenProcessMessagesCarryThreadProcessKind() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "u1")
|
||||
.put("sender", "user")
|
||||
.put("body", "继续推进"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "thread_process")
|
||||
.put("body", "我先检查聊天折叠链路,确认过程消息不会直接展开。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "r1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "text")
|
||||
.put("body", "这轮已经完成折叠修复,未读现在只会算最终结果。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(3, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
|
||||
assertEquals(1, items.get(1).processMessages.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
|
||||
assertEquals("r1", items.get(2).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void numberedProgressUpdatesAreCollapsedWhenMarkedAsThreadProcess() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "u1")
|
||||
.put("sender", "user")
|
||||
.put("body", "继续处理"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "thread_process")
|
||||
.put("body", "1. 先检查当前消息折叠链路。\\n2. 再确认 Android 端只把最终结果记成未读。\\n3. 处理完成后我会回你最终结果。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "r1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "text")
|
||||
.put("body", "这轮已经处理完成,最终结果已回写。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(3, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
|
||||
assertEquals(1, items.get(1).processMessages.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
|
||||
assertEquals("r1", items.get(2).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void numberedProgressUpdatesWithoutKindStillCollapseBeforeFinalResult() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "u1")
|
||||
.put("sender", "user")
|
||||
.put("body", "继续处理"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("body", "1. 先检查当前消息折叠链路。\n2. 再确认 Android 端只把最终结果记成未读。\n3. 处理完成后我会回你最终结果。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "r1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("body", "这轮已经处理完成,最终结果已回写。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(3, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
|
||||
assertEquals(1, items.get(1).processMessages.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
|
||||
assertEquals("r1", items.get(2).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void progressUpdatesStartingWithWoZheBianYiJingStillCollapseIntoProcessGroup() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "u1")
|
||||
.put("sender", "user")
|
||||
.put("body", "继续"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("body", "我这边已经查了,adb 现在还只看到一台 USB 连着的 PHZ110,PLB110 的无线目标还没有被发现出来。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "r1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "text")
|
||||
.put("body", "无线调试已经接通,最新 debug 包也装好了。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(3, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
|
||||
assertEquals(1, items.get(1).processMessages.size());
|
||||
assertEquals("p1", items.get(1).processMessages.get(0).optString("id"));
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
|
||||
assertEquals("r1", items.get(2).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void realThreadPlanningCopyIsCollapsedButSavedResultStaysVisible() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Andorid")
|
||||
.put("body", "我发现当前这个仓库快照里没有 ios/ 目录,所以这份报告会明确分成两层。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p2")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Andorid")
|
||||
.put("body", "我准备新增一份 doc/iOS实时转写开发交接报告_20260419.md。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "r1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Andorid")
|
||||
.put("body", "报告已经落盘了。我再快速过一遍这份文档的结构和措辞。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(2, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
|
||||
assertEquals(2, items.get(0).processMessages.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(1).type);
|
||||
assertEquals("r1", items.get(1).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void threadExecutionConflictCopyExplainsPreferredGuiModeAsProjectScoped() throws Exception {
|
||||
JSONObject conflict = new JSONObject()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
@@ -19,7 +18,6 @@ import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.shadows.ShadowDialog;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
@@ -47,74 +45,15 @@ public class ProjectDetailActivityMasterAgentMenuTest {
|
||||
assertMenuItem(listView, 0, "模型");
|
||||
assertMenuItem(listView, 1, "推理强度");
|
||||
assertMenuItem(listView, 2, "全局接管");
|
||||
assertMenuItem(listView, 3, "提示词");
|
||||
assertMenuItem(listView, 4, "记忆");
|
||||
assertMenuItem(listView, 5, "会话信息");
|
||||
assertMenuItem(listView, 6, "刷新");
|
||||
assertMenuItem(listView, 3, "自动进化");
|
||||
assertMenuItem(listView, 4, "提示词");
|
||||
assertMenuItem(listView, 5, "记忆");
|
||||
assertMenuItem(listView, 6, "会话信息");
|
||||
assertMenuItem(listView, 7, "刷新");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masterAgentModelOptionsIncludeFastAndDeepChoices() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
String[] options = ReflectionHelpers.callInstanceMethod(activity, "buildMasterAgentModelOptions");
|
||||
|
||||
assertArrayEquals(
|
||||
new String[]{"沿用默认", "gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1", "自定义..."},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masterAgentModelOptionsKeepCurrentCustomChoice() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
ReflectionHelpers.setField(activity, "currentAgentModelOverride", "gpt-4.1-mini");
|
||||
|
||||
String[] options = ReflectionHelpers.callInstanceMethod(activity, "buildMasterAgentModelOptions");
|
||||
|
||||
assertArrayEquals(
|
||||
new String[]{"沿用默认", "gpt-4.1-mini", "gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1", "自定义..."},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masterAgentModelPickerShowsFastAndDeepModes() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showMasterAgentModelPicker");
|
||||
|
||||
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
|
||||
assertTrue(latestDialog instanceof AlertDialog);
|
||||
AlertDialog actionDialog = (AlertDialog) latestDialog;
|
||||
ListView listView = actionDialog.getListView();
|
||||
|
||||
assertMenuItem(listView, 0, "沿用默认");
|
||||
assertMenuItem(listView, 1, "快速反应(gpt-5.4-mini)");
|
||||
assertMenuItem(listView, 2, "深度思考(gpt-5.4)");
|
||||
assertMenuItem(listView, 3, "更多模型...");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void normalConversationHeaderActionOpensConversationInfoDirectly() {
|
||||
public void normalConversationMoreMenuShowsInfoAndRefresh() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss 移动控制台");
|
||||
@@ -123,11 +62,15 @@ public class ProjectDetailActivityMasterAgentMenuTest {
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "openConversationInfo");
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showConversationMoreMenu");
|
||||
|
||||
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
|
||||
assertNotNull(nextIntent);
|
||||
assertEquals(ConversationInfoActivity.class.getName(), nextIntent.getComponent().getClassName());
|
||||
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
|
||||
assertTrue(latestDialog instanceof AlertDialog);
|
||||
AlertDialog actionDialog = (AlertDialog) latestDialog;
|
||||
ListView listView = actionDialog.getListView();
|
||||
|
||||
assertMenuItem(listView, 0, "会话信息");
|
||||
assertMenuItem(listView, 1, "刷新");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.fail;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Looper;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
@@ -21,12 +13,8 @@ import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.RuntimeEnvironment;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.shadows.ShadowApplication;
|
||||
import org.robolectric.shadows.ShadowDialog;
|
||||
import org.robolectric.shadows.ShadowNotificationManager;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
@@ -36,6 +24,10 @@ import java.util.function.BooleanSupplier;
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class ProjectDetailActivityRealtimeTest {
|
||||
private static void flushRealtimeDebounce(ProjectDetailActivity activity) {
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(400, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void matchingProjectMessageEventTriggersReload() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
@@ -55,11 +47,11 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
|
||||
)
|
||||
);
|
||||
drainRealtimeDebounce(activity);
|
||||
flushRealtimeDebounce(activity);
|
||||
|
||||
waitFor(() -> activity.messageReloadCount == 1);
|
||||
assertEquals(0, activity.reloadCount);
|
||||
assertEquals(1, activity.messageReloadCount);
|
||||
assertEquals(1, activity.loadCallCount);
|
||||
assertEquals(1, activity.renderCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -81,7 +73,7 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
|
||||
)
|
||||
);
|
||||
drainRealtimeDebounce(activity);
|
||||
flushRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(0, activity.reloadCount);
|
||||
}
|
||||
@@ -105,7 +97,7 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-2"))
|
||||
)
|
||||
);
|
||||
drainRealtimeDebounce(activity);
|
||||
flushRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(0, activity.reloadCount);
|
||||
}
|
||||
@@ -143,11 +135,11 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
)
|
||||
)
|
||||
);
|
||||
drainRealtimeDebounce(activity);
|
||||
flushRealtimeDebounce(activity);
|
||||
|
||||
waitFor(() -> activity.loadCallCount == 1);
|
||||
assertEquals(1, activity.reloadCount);
|
||||
assertEquals(1, activity.loadCallCount);
|
||||
assertEquals(0, activity.messageReloadCount);
|
||||
assertEquals(1, activity.renderCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -172,11 +164,11 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
)
|
||||
)
|
||||
);
|
||||
drainRealtimeDebounce(activity);
|
||||
flushRealtimeDebounce(activity);
|
||||
|
||||
waitFor(() -> activity.loadCallCount == 1);
|
||||
assertEquals(1, activity.reloadCount);
|
||||
assertEquals(1, activity.loadCallCount);
|
||||
assertEquals(0, activity.messageReloadCount);
|
||||
assertEquals(1, activity.renderCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -212,161 +204,11 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
)
|
||||
)
|
||||
);
|
||||
drainRealtimeDebounce(activity);
|
||||
flushRealtimeDebounce(activity);
|
||||
|
||||
waitFor(() -> activity.messageReloadCount == 1);
|
||||
assertEquals(0, activity.reloadCount);
|
||||
assertEquals(1, activity.messageReloadCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void dialogGuardInterventionRequiredShowsBlockedSafeActionDialog() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
|
||||
TestRealtimeProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.resume()
|
||||
.get();
|
||||
RecordingDialogGuardApiClient apiClient = new RecordingDialogGuardApiClient();
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
ReflectionHelpers.ClassParameter.from(
|
||||
BossRealtimeEvent.class,
|
||||
new BossRealtimeEvent(
|
||||
"desktop.dialog_guard.intervention_required",
|
||||
new JSONObject()
|
||||
.put("interventionId", "intervention-1")
|
||||
.put("dialogId", "dialog-1")
|
||||
.put("requestId", "request-1")
|
||||
.put("taskId", "task-1")
|
||||
.put("deviceId", "mac-studio")
|
||||
.put("projectId", "project-1")
|
||||
.put("appName", "微信")
|
||||
.put("platform", "macos")
|
||||
.put("risk", "blocked")
|
||||
.put("summary", "微信正在请求读取敏感通讯录权限")
|
||||
.put("recommendedAction", "handled_on_device")
|
||||
.put("availableActions", new JSONArray()
|
||||
.put("allow_once")
|
||||
.put("allow_for_device_dialog")
|
||||
.put("deny")
|
||||
.put("handled_on_device")
|
||||
.put("cancel_task"))
|
||||
)
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
Dialog latestDialog = ShadowDialog.getLatestDialog();
|
||||
assertTrue(latestDialog instanceof AlertDialog);
|
||||
AlertDialog dialog = (AlertDialog) latestDialog;
|
||||
assertTrue(dialog.isShowing());
|
||||
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "微信"));
|
||||
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "微信正在请求读取敏感通讯录权限"));
|
||||
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "我已在电脑上处理"));
|
||||
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "取消任务"));
|
||||
assertFalse(viewTreeContainsText(dialog.getWindow().getDecorView(), "允许本次"));
|
||||
assertFalse(viewTreeContainsText(dialog.getWindow().getDecorView(), "当前设备此弹窗允许"));
|
||||
|
||||
View handledButton = findClickableViewContainingText(dialog.getWindow().getDecorView(), "我已在电脑上处理");
|
||||
assertNotNull(handledButton);
|
||||
handledButton.performClick();
|
||||
waitFor(() -> apiClient.decisionCallCount == 1);
|
||||
|
||||
assertEquals("intervention-1", apiClient.lastInterventionId);
|
||||
assertEquals("handled_on_device", apiClient.lastDecision);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void dialogGuardResolvedEventClosesMatchingDialog() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
|
||||
TestRealtimeProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.resume()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
ReflectionHelpers.ClassParameter.from(
|
||||
BossRealtimeEvent.class,
|
||||
new BossRealtimeEvent(
|
||||
"desktop.dialog_guard.intervention_required",
|
||||
new JSONObject()
|
||||
.put("interventionId", "intervention-2")
|
||||
.put("projectId", "project-1")
|
||||
.put("appName", "访达")
|
||||
.put("risk", "safe")
|
||||
.put("summary", "确认打开下载文件")
|
||||
.put("availableActions", new JSONArray().put("allow_once").put("deny"))
|
||||
)
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertTrue(dialog.isShowing());
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
ReflectionHelpers.ClassParameter.from(
|
||||
BossRealtimeEvent.class,
|
||||
new BossRealtimeEvent(
|
||||
"desktop.dialog_guard.intervention_resolved",
|
||||
new JSONObject()
|
||||
.put("interventionId", "intervention-2")
|
||||
.put("projectId", "project-1")
|
||||
)
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
assertFalse(dialog.isShowing());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openingMasterAgentConversationClearsPendingMasterAgentNotification() throws Exception {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
BossApplication application = (BossApplication) context.getApplicationContext();
|
||||
ShadowApplication.getInstance().grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS);
|
||||
ShadowNotificationManager notificationManager = Shadows.shadowOf(
|
||||
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
|
||||
);
|
||||
application.visibilityTracker().onAppBackgrounded();
|
||||
|
||||
JSONObject message = new JSONObject()
|
||||
.put("id", "master-msg-1")
|
||||
.put("sender", "master")
|
||||
.put("senderLabel", "主 Agent · gpt-5.4-mini")
|
||||
.put("body", "主 Agent 后台回复");
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("projectId", "master-agent")
|
||||
.put("projectMessagesPayload", new JSONObject().put(
|
||||
"project",
|
||||
new JSONObject().put("messages", new JSONArray().put(message))
|
||||
));
|
||||
assertTrue(application.notificationRouter().maybeNotifyForRealtimeEvent(
|
||||
new BossRealtimeEvent("project.messages.updated", payload)
|
||||
));
|
||||
assertEquals(1, notificationManager.size());
|
||||
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
Robolectric.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.resume()
|
||||
.get();
|
||||
|
||||
assertEquals(0, notificationManager.size());
|
||||
assertEquals(1, activity.loadCallCount);
|
||||
assertEquals(1, activity.renderCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -390,7 +232,7 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
|
||||
)
|
||||
);
|
||||
drainRealtimeDebounce(activity);
|
||||
flushRealtimeDebounce(activity);
|
||||
assertTrue(activity.awaitFirstLoadStarted());
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
@@ -409,69 +251,18 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-1"))
|
||||
)
|
||||
);
|
||||
drainRealtimeDebounce(activity);
|
||||
flushRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(0, activity.loadCallCount);
|
||||
assertEquals(1, activity.messageLoadCallCount);
|
||||
assertEquals(1, activity.loadCallCount);
|
||||
assertEquals(0, activity.renderCount);
|
||||
|
||||
activity.releaseFirstLoad();
|
||||
waitFor(() -> activity.renderCount == 2 && activity.messageLoadCallCount == 1 && activity.loadCallCount == 1);
|
||||
waitFor(() -> activity.renderCount == 2 && activity.loadCallCount == 2);
|
||||
|
||||
assertEquals(1, activity.loadCallCount);
|
||||
assertEquals(1, activity.messageLoadCallCount);
|
||||
assertEquals(2, activity.loadCallCount);
|
||||
assertEquals(2, activity.renderCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void realtimeDisconnectTriggersImmediateConversationFallbackReload() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
|
||||
TestRealtimeProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.resume()
|
||||
.get();
|
||||
activity.getSharedPreferences("boss_native_client", android.content.Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.apply();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeConnectionChanged",
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
drainRealtimeDebounce(activity);
|
||||
|
||||
waitFor(() -> activity.loadCallCount == 1);
|
||||
assertEquals(1, activity.loadCallCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reloadSnapshotAfterDestroyDoesNotCrashWhenExecutorsAreShutdown() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
|
||||
TestRealtimeProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.resume()
|
||||
.pause()
|
||||
.destroy()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"reloadSnapshot",
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
|
||||
assertEquals(0, activity.loadCallCount);
|
||||
}
|
||||
|
||||
private static void waitFor(BooleanSupplier condition) throws Exception {
|
||||
long deadlineAt = System.currentTimeMillis() + 2_000L;
|
||||
while (System.currentTimeMillis() < deadlineAt) {
|
||||
@@ -484,54 +275,9 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
fail("condition not met before timeout");
|
||||
}
|
||||
|
||||
private static void drainRealtimeDebounce(TestRealtimeProjectDetailActivity activity) {
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(350, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
if (root instanceof android.widget.TextView) {
|
||||
CharSequence text = ((android.widget.TextView) root).getText();
|
||||
if (expectedText.contentEquals(text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!(root instanceof android.view.ViewGroup)) {
|
||||
return false;
|
||||
}
|
||||
android.view.ViewGroup group = (android.view.ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static View findClickableViewContainingText(View root, String expectedText) {
|
||||
if (root == null) {
|
||||
return null;
|
||||
}
|
||||
if (viewTreeContainsText(root, expectedText) && root.isClickable()) {
|
||||
return root;
|
||||
}
|
||||
if (!(root instanceof android.view.ViewGroup)) {
|
||||
return null;
|
||||
}
|
||||
android.view.ViewGroup group = (android.view.ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
View match = findClickableViewContainingText(group.getChildAt(index), expectedText);
|
||||
if (match != null) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static class TestRealtimeProjectDetailActivity extends ProjectDetailActivity {
|
||||
int reloadCount;
|
||||
int messageReloadCount;
|
||||
volatile int loadCallCount;
|
||||
volatile int messageLoadCallCount;
|
||||
volatile int renderCount;
|
||||
private CountDownLatch firstLoadStarted;
|
||||
private CountDownLatch releaseFirstLoad;
|
||||
@@ -562,26 +308,6 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
super.reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
ProjectSnapshot loadProjectMessagesSnapshotForRefresh() throws Exception {
|
||||
messageReloadCount += 1;
|
||||
messageLoadCallCount += 1;
|
||||
if (messageLoadCallCount == 1 && firstLoadStarted != null && releaseFirstLoad != null) {
|
||||
firstLoadStarted.countDown();
|
||||
releaseFirstLoad.await(2, TimeUnit.SECONDS);
|
||||
}
|
||||
return new ProjectSnapshot(
|
||||
new JSONObject().put(
|
||||
"project",
|
||||
new JSONObject()
|
||||
.put("name", "北区试产线")
|
||||
.put("messages", new JSONArray())
|
||||
),
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
ProjectSnapshot loadProjectSnapshotForRefresh() throws Exception {
|
||||
loadCallCount += 1;
|
||||
@@ -601,28 +327,15 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
ProjectSnapshot loadProjectMessagesSnapshotForRefresh() throws Exception {
|
||||
return loadProjectSnapshotForRefresh();
|
||||
}
|
||||
|
||||
@Override
|
||||
void renderLoadedProjectSnapshot(ProjectSnapshot snapshot) {
|
||||
renderCount += 1;
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingDialogGuardApiClient extends BossApiClient {
|
||||
int decisionCallCount;
|
||||
String lastInterventionId;
|
||||
String lastDecision;
|
||||
|
||||
RecordingDialogGuardApiClient() {
|
||||
super(RuntimeEnvironment.getApplication().getSharedPreferences("dialog_guard_test", Context.MODE_PRIVATE), "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse decideDialogGuardIntervention(String interventionId, String decision) throws org.json.JSONException {
|
||||
decisionCallCount += 1;
|
||||
lastInterventionId = interventionId;
|
||||
lastDecision = decision;
|
||||
return new ApiResponse(200, new JSONObject().put("ok", true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,7 @@ public class ProjectGoalsActivityUiTest {
|
||||
TestProjectGoalsActivity activity = Robolectric
|
||||
.buildActivity(TestProjectGoalsActivity.class, new Intent()
|
||||
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, "北区试产线回归需要只展示一行避免堆叠"))
|
||||
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, "北区试产线回归"))
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
@@ -38,48 +38,16 @@ public class ProjectGoalsActivityUiTest {
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildProject())
|
||||
);
|
||||
|
||||
activity.configureScreen("项目目标", "北区试产线回归需要只展示一行避免堆叠");
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
TextView subtitle = activity.findViewById(R.id.screen_subtitle);
|
||||
assertTrue(viewTreeContainsText(content, "主 Agent 已整理项目目标 · 已完成 1/3"));
|
||||
assertTrue(viewTreeContainsSubstring(content, "完成北区试产线全链路回归"));
|
||||
assertTrue(viewTreeContainsSubstring(content, "已完成 · 09:12 由主 Agent 复核"));
|
||||
assertTrue(viewTreeContainsText(content, "当前约束"));
|
||||
assertTrue(hasHorizontalContentPadding(content, BossUi.dp(activity, 12)));
|
||||
assertTrue(subtitle.getMaxLines() <= 1);
|
||||
assertTrue(String.valueOf(subtitle.getEllipsize()).contains("END"));
|
||||
assertFalse(viewTreeContainsText(content, "标记完成"));
|
||||
assertFalse(viewTreeContainsText(content, "编辑目标"));
|
||||
assertFalse(((SwipeRefreshLayout) activity.findViewById(R.id.screen_refresh_layout)).isRefreshing());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderGoalsShowsSyncedProjectUnderstandingSummary() throws Exception {
|
||||
TestProjectGoalsActivity activity = Robolectric
|
||||
.buildActivity(TestProjectGoalsActivity.class, new Intent()
|
||||
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, "北区试产线回归需要只展示一行避免堆叠"))
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderGoals",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildProject()
|
||||
.put("projectUnderstanding", new JSONObject()
|
||||
.put("projectGoal", "完成北区试产线与主 Agent 接管回归")
|
||||
.put("currentProgress", "已把最新核对结果同步到项目目标页顶部")
|
||||
.put("recommendedNextStep", "继续完成 Gitea 推送和真机回归")
|
||||
.put("updatedAt", "2026-04-18T10:28:00.000Z")))
|
||||
);
|
||||
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "同步项目摘要"));
|
||||
assertTrue(viewTreeContainsSubstring(content, "完成北区试产线与主 Agent 接管回归"));
|
||||
assertTrue(viewTreeContainsSubstring(content, "已把最新核对结果同步到项目目标页顶部"));
|
||||
assertTrue(viewTreeContainsSubstring(content, "继续完成 Gitea 推送和真机回归"));
|
||||
}
|
||||
|
||||
private static JSONObject buildProject() throws Exception {
|
||||
JSONArray goals = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
@@ -138,10 +106,6 @@ public class ProjectGoalsActivityUiTest {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean hasHorizontalContentPadding(LinearLayout content, int minPaddingPx) {
|
||||
return content.getPaddingLeft() >= minPaddingPx && content.getPaddingRight() >= minPaddingPx;
|
||||
}
|
||||
|
||||
public static class TestProjectGoalsActivity extends ProjectGoalsActivity {
|
||||
@Override
|
||||
protected void reload() {
|
||||
|
||||
@@ -19,7 +19,7 @@ import java.lang.reflect.Method;
|
||||
@Config(sdk = 34)
|
||||
public class ProjectVersionsActivityTest {
|
||||
@Test
|
||||
public void matchingVersionRefreshMarkerTriggersReload() throws Exception {
|
||||
public void matchingGoalRefreshMarkerTriggersReload() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入");
|
||||
@@ -40,7 +40,7 @@ public class ProjectVersionsActivityTest {
|
||||
"conversation.updated",
|
||||
new JSONObject()
|
||||
.put("projectId", "project-1")
|
||||
.put("note", "project_versions.updated")
|
||||
.put("note", "project_goals.updated")
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
@@ -49,7 +49,7 @@ public class ProjectVersionsActivityTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sameProjectNonVersionEventDoesNotTriggerReload() throws Exception {
|
||||
public void sameProjectNonGoalEventDoesNotTriggerReload() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入");
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Intent;
|
||||
@@ -29,7 +28,7 @@ public class ProjectVersionsActivityUiTest {
|
||||
TestProjectVersionsActivity activity = Robolectric
|
||||
.buildActivity(TestProjectVersionsActivity.class, new Intent()
|
||||
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "北区试产线回归需要只展示一行避免堆叠"))
|
||||
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "北区试产线回归"))
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
@@ -39,18 +38,11 @@ public class ProjectVersionsActivityUiTest {
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildProject())
|
||||
);
|
||||
|
||||
activity.configureScreen("版本记录", "北区试产线回归需要只展示一行避免堆叠");
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
TextView title = activity.findViewById(R.id.screen_title);
|
||||
TextView subtitle = activity.findViewById(R.id.screen_subtitle);
|
||||
assertEquals("版本记录", String.valueOf(title.getText()));
|
||||
assertTrue(viewTreeContainsText(content, "仅主 Agent 可发布迭代记录"));
|
||||
assertTrue(viewTreeContainsText(content, "v1.2.8 已发布"));
|
||||
assertTrue(viewTreeContainsSubstring(content, "• 优化 OTA 实时提示"));
|
||||
assertTrue(viewTreeContainsText(content, "主 Agent 复核记录"));
|
||||
assertTrue(hasHorizontalContentPadding(content, BossUi.dp(activity, 12)));
|
||||
assertTrue(subtitle.getMaxLines() <= 1);
|
||||
assertTrue(String.valueOf(subtitle.getEllipsize()).contains("END"));
|
||||
assertFalse(viewTreeContainsText(content, "版本记录只读"));
|
||||
assertFalse(((SwipeRefreshLayout) activity.findViewById(R.id.screen_refresh_layout)).isRefreshing());
|
||||
}
|
||||
@@ -106,10 +98,6 @@ public class ProjectVersionsActivityUiTest {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean hasHorizontalContentPadding(LinearLayout content, int minPaddingPx) {
|
||||
return content.getPaddingLeft() >= minPaddingPx && content.getPaddingRight() >= minPaddingPx;
|
||||
}
|
||||
|
||||
public static class TestProjectVersionsActivity extends ProjectVersionsActivity {
|
||||
@Override
|
||||
protected void reload() {
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.json.JSONArray;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
@@ -73,61 +68,6 @@ public class SkillInventoryActivityTest {
|
||||
assertEquals(0, activity.reloadCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderSkillsShowsManagementDispatchWorkspaceWhenLifecycleRequestsAreAvailable() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(SkillInventoryActivity.EXTRA_DEVICE_ID, "device-1")
|
||||
.putExtra(SkillInventoryActivity.EXTRA_DEVICE_NAME, "Mac Studio");
|
||||
TestSkillInventoryActivity activity = Robolectric
|
||||
.buildActivity(TestSkillInventoryActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderSkills",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildSkillPayload()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildLifecyclePayload())
|
||||
);
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "Skill 管理分发"));
|
||||
assertTrue(viewTreeContainsText(content, "安装远端 Skill"));
|
||||
assertTrue(viewTreeContainsText(content, "分配权限"));
|
||||
assertTrue(viewTreeContainsText(content, "Skill 请求状态"));
|
||||
assertTrue(viewTreeContainsText(content, "更新下发"));
|
||||
assertTrue(viewTreeContainsText(content, "版本锁定"));
|
||||
}
|
||||
|
||||
private static JSONObject buildSkillPayload() throws Exception {
|
||||
return new JSONObject()
|
||||
.put("device", new JSONObject()
|
||||
.put("id", "device-1")
|
||||
.put("name", "Mac Studio"))
|
||||
.put("skills", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("skillId", "device-1:boss-server-debug")
|
||||
.put("name", "boss-server-debug")
|
||||
.put("description", "服务器排障")
|
||||
.put("category", "ops")
|
||||
.put("path", "/Users/kris/.codex/skills/boss-server-debug/SKILL.md")
|
||||
.put("invocation", "使用 boss-server-debug")
|
||||
.put("updatedAt", "2026-06-08T10:00:00+08:00")));
|
||||
}
|
||||
|
||||
private static JSONObject buildLifecyclePayload() throws Exception {
|
||||
return new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("requests", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("requestId", "skill-request-1")
|
||||
.put("action", "update")
|
||||
.put("status", "queued")
|
||||
.put("deviceId", "device-1")
|
||||
.put("skillId", "device-1:boss-server-debug")
|
||||
.put("requestedAt", "2026-06-08T10:01:00+08:00")));
|
||||
}
|
||||
|
||||
public static class TestSkillInventoryActivity extends SkillInventoryActivity {
|
||||
private boolean reloadEnabled;
|
||||
private int reloadCount;
|
||||
@@ -141,23 +81,4 @@ public class SkillInventoryActivityTest {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
if (text != null && text.toString().contains(expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return false;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class TelegramIntegrationActivityTest {
|
||||
@Test
|
||||
public void populateShowsCurrentTelegramStatusBeforeEditableForm() throws Exception {
|
||||
TestTelegramIntegrationActivity activity = Robolectric
|
||||
.buildActivity(TestTelegramIntegrationActivity.class, new Intent())
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
JSONObject telegram = new JSONObject()
|
||||
.put("enabled", true)
|
||||
.put("mode", "webhook")
|
||||
.put("botTokenConfigured", true)
|
||||
.put("webhookSecretConfigured", true)
|
||||
.put("botUsername", "boss_demo_bot")
|
||||
.put("defaultProjectId", "master-agent")
|
||||
.put("processedUpdateCount", 3)
|
||||
.put("lastError", "上次 webhook 同步失败")
|
||||
.put("allowFrom", new JSONArray().put("123456"))
|
||||
.put("groups", new JSONArray().put("-10001"))
|
||||
.put(
|
||||
"groupProjectRoutes",
|
||||
new JSONArray().put(
|
||||
new JSONObject()
|
||||
.put("chatId", "-10001")
|
||||
.put("threadId", 12)
|
||||
.put("projectId", "audit-collab")
|
||||
.put("label", "审计 Topic")
|
||||
)
|
||||
)
|
||||
.put("dmPolicy", "allowlist")
|
||||
.put("groupPolicy", "allowlist")
|
||||
.put("requireMentionInGroups", true);
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"populate",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, telegram)
|
||||
);
|
||||
|
||||
ViewGroup content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "当前状态"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "接入:已开启"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "模式:Webhook"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "Bot:@boss_demo_bot"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "Token:已配置"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "Webhook Secret:已配置"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "已处理 update:3"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "最近错误:上次 webhook 同步失败"));
|
||||
assertTrue(viewTreeContainsText(content, "群 / Topic 路由"));
|
||||
assertTrue(viewTreeContainsText(content, "-10001#12 audit-collab 审计 Topic"));
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View view, String text) {
|
||||
if (view instanceof android.widget.TextView) {
|
||||
CharSequence value = ((android.widget.TextView) view).getText();
|
||||
if (value != null && value.toString().contains(text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (view instanceof ViewGroup) {
|
||||
ViewGroup group = (ViewGroup) view;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
if (viewTreeContainsText(group.getChildAt(index), text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static class TestTelegramIntegrationActivity extends TelegramIntegrationActivity {
|
||||
@Override
|
||||
protected void reload() {
|
||||
// Tests drive rendering directly through populate().
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class WechatSurfaceMapperMeMenuTest {
|
||||
@Test
|
||||
public void rootMeMenuIncludesMasterAgentEvolutionEntryAfterPromptMemory() {
|
||||
WechatSurfaceMapper.MeMenuItem[] items = WechatSurfaceMapper.rootMeMenuItems();
|
||||
|
||||
assertEquals("master_agent_prompt", items[0].key);
|
||||
assertEquals("主 Agent 提示词", items[0].title);
|
||||
assertEquals("master_agent_memory", items[1].key);
|
||||
assertEquals("主 Agent 记忆", items[1].title);
|
||||
assertEquals("master_agent_takeover", items[2].key);
|
||||
assertEquals("全局接管", items[2].title);
|
||||
assertEquals("master_agent_evolution", items[3].key);
|
||||
assertEquals("主 Agent 自动进化", items[3].title);
|
||||
}
|
||||
}
|
||||
@@ -137,96 +137,13 @@ public class WechatSurfaceMapperTest {
|
||||
assertEquals("已导入线程", row.lastMessagePreview);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toConversationRow_sanitizesLeakedPromptTitleToFolderFallback() throws Exception {
|
||||
JSONObject item = new JSONObject()
|
||||
.put("conversationType", "single_device")
|
||||
.put("projectTitle", "你当前接手的项目根目录是:")
|
||||
.put("threadTitle", "你当前接手的项目根目录是:")
|
||||
.put("folderLabel", "boss")
|
||||
.put("latestReplyLabel", "17:35");
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("boss", row.threadTitle);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toConversationRow_extractsWorkspaceFolderFromPromptLeakTitle() throws Exception {
|
||||
JSONObject item = new JSONObject()
|
||||
.put("conversationType", "single_device")
|
||||
.put("projectTitle", "你现在接手的项目根目录是 /Users/kris/code/yuandi。")
|
||||
.put("threadTitle", "你现在接手的项目根目录是 /Users/kris/code/yuandi。")
|
||||
.put("latestReplyLabel", "17:36");
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("yuandi", row.threadTitle);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toConversationRow_prefersStableMasterAgentProjectTitleOverOperationalThreadTitle() throws Exception {
|
||||
JSONObject item = new JSONObject()
|
||||
.put("projectId", "master-agent")
|
||||
.put("projectTitle", "主 Agent")
|
||||
.put("threadTitle", "主 Agent 汇总")
|
||||
.put("lastMessagePreview", "同步已完成")
|
||||
.put("latestReplyLabel", "10:18");
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("主 Agent", row.threadTitle);
|
||||
assertEquals("同步已完成", row.lastMessagePreview);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toConversationRow_prefersStableAuditProjectTitleOverOperationalThreadTitle() throws Exception {
|
||||
JSONObject item = new JSONObject()
|
||||
.put("projectId", "audit-collab")
|
||||
.put("projectTitle", "硬件审计协作")
|
||||
.put("threadTitle", "审计对话")
|
||||
.put("lastMessagePreview", "审计结果已回写")
|
||||
.put("latestReplyLabel", "10:20");
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("硬件审计协作", row.threadTitle);
|
||||
assertEquals("审计结果已回写", row.lastMessagePreview);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toConversationRow_hidesProcessLikePreviewFallback() throws Exception {
|
||||
JSONObject item = new JSONObject()
|
||||
.put("projectTitle", "Boss")
|
||||
.put("threadTitle", "Boss开发主线程")
|
||||
.put("lastMessagePreview", "我继续往下收,这一轮先检查折叠链路,再确认未读逻辑,随后回你结果。")
|
||||
.put("latestReplyLabel", "10:20");
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("", row.lastMessagePreview);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toConversationRow_keepsFinalSummaryPreviewVisible() throws Exception {
|
||||
JSONObject item = new JSONObject()
|
||||
.put("projectTitle", "Boss")
|
||||
.put("threadTitle", "Boss开发主线程")
|
||||
.put("lastMessagePreview", "折叠修复已部署,未读数现在只按最终结果计数。")
|
||||
.put("latestReplyLabel", "10:22");
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("折叠修复已部署,未读数现在只按最终结果计数。", row.lastMessagePreview);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toDeviceRow_mapsLegacyWechatThreeLineSummary() throws Exception {
|
||||
JSONObject item = new StubJSONObject()
|
||||
.withString("name", "Mac Studio")
|
||||
.withString("avatar", "M")
|
||||
.withString("status", "online")
|
||||
.withString("account", "krisolo")
|
||||
.withString("account", "17600003315")
|
||||
.withStringArray("projects", "北区试产线回归", "容灾切换验证")
|
||||
.withInt("quota5h", 8)
|
||||
.withInt("quota7d", 22);
|
||||
@@ -234,7 +151,7 @@ public class WechatSurfaceMapperTest {
|
||||
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
|
||||
|
||||
assertEquals("Mac Studio", row.title);
|
||||
assertEquals("账号: krisolo · 项目: 北区试产线回归 / 容灾切换验证", row.subtitle);
|
||||
assertEquals("账号: 17600003315 · 项目: 北区试产线回归 / 容灾切换验证", row.subtitle);
|
||||
assertEquals("额度: 5h 8% · 7d 22%", row.meta);
|
||||
assertEquals("M", row.avatarLabel);
|
||||
assertEquals("online", row.statusKey);
|
||||
@@ -245,12 +162,12 @@ public class WechatSurfaceMapperTest {
|
||||
JSONObject item = new StubJSONObject()
|
||||
.withString("name", "Mac Studio")
|
||||
.withString("status", "abnormal")
|
||||
.withString("account", "krisolo");
|
||||
.withString("account", "17600003315");
|
||||
|
||||
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
|
||||
|
||||
assertEquals("Mac Studio", row.title);
|
||||
assertEquals("账号: krisolo", row.subtitle);
|
||||
assertEquals("账号: 17600003315", row.subtitle);
|
||||
assertEquals("额度: 暂无 · 状态异常", row.meta);
|
||||
assertEquals("abnormal", row.statusKey);
|
||||
}
|
||||
@@ -260,7 +177,7 @@ public class WechatSurfaceMapperTest {
|
||||
JSONObject item = new StubJSONObject()
|
||||
.withString("name", "Mac Studio")
|
||||
.withString("status", "online")
|
||||
.withString("account", "krisolo")
|
||||
.withString("account", "17600003315")
|
||||
.withString("note", "书房主机")
|
||||
.withString("endpoint", "https://boss.hyzq.net/device/mac-studio")
|
||||
.withStringArray("projects", "master-agent", "android-app");
|
||||
@@ -268,14 +185,14 @@ public class WechatSurfaceMapperTest {
|
||||
WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(item);
|
||||
|
||||
assertEquals("Mac Studio", summary.title);
|
||||
assertEquals("账号: krisolo · 项目: master-agent / android-app", summary.subtitle);
|
||||
assertEquals("账号: 17600003315 · 项目: master-agent / android-app", summary.subtitle);
|
||||
assertEquals("额度: 暂无 · 书房主机 · https://boss.hyzq.net/device/mac-studio · 项目 master-agent, android-app", summary.meta);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootMeMenuTitles_matchLegacyWechatMenuWithOpsEntry() throws Exception {
|
||||
assertArrayEquals(
|
||||
new String[]{"账号与安全", "设置", "用户与权限", "运维与修复", "AI 账号", "附件与存储", "Telegram 接入", "技能", "关于"},
|
||||
new String[]{"主 Agent 提示词", "主 Agent 记忆", "全局接管", "主 Agent 自动进化", "账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
|
||||
WechatSurfaceMapper.rootMeMenuTitles()
|
||||
);
|
||||
}
|
||||
@@ -291,7 +208,7 @@ public class WechatSurfaceMapperTest {
|
||||
@Test
|
||||
public void mainPage_keepsOpsEntryInStableWechatMenuOrder() throws Exception {
|
||||
assertArrayEquals(
|
||||
new String[]{"账号与安全", "设置", "用户与权限", "运维与修复", "AI 账号", "附件与存储", "Telegram 接入", "技能", "关于"},
|
||||
new String[]{"主 Agent 提示词", "主 Agent 记忆", "全局接管", "主 Agent 自动进化", "账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
|
||||
WechatSurfaceMapper.rootMeMenuTitles()
|
||||
);
|
||||
}
|
||||
@@ -375,7 +292,7 @@ public class WechatSurfaceMapperTest {
|
||||
JSONArray devices = new StubObjectArray(
|
||||
new StubJSONObject()
|
||||
.withString("id", "device-b")
|
||||
.withString("account", "krisolo"),
|
||||
.withString("account", "17600003315"),
|
||||
new StubJSONObject()
|
||||
.withString("id", "device-c")
|
||||
.withString("account", "other-account")
|
||||
@@ -394,7 +311,7 @@ public class WechatSurfaceMapperTest {
|
||||
null,
|
||||
"stale-device-id",
|
||||
"missing-bound-device",
|
||||
"krisolo",
|
||||
"17600003315",
|
||||
devices
|
||||
);
|
||||
|
||||
@@ -463,20 +380,22 @@ public class WechatSurfaceMapperTest {
|
||||
public void meMenuItems_useStableKeysInsteadOfDisplayTitlesForRouting() throws Exception {
|
||||
WechatSurfaceMapper.MeMenuItem[] items = WechatSurfaceMapper.rootMeMenuItems();
|
||||
|
||||
assertEquals(9, items.length);
|
||||
assertEquals("security", items[0].key);
|
||||
assertEquals("账号与安全", items[0].title);
|
||||
assertEquals("settings", items[1].key);
|
||||
assertEquals("access", items[2].key);
|
||||
assertEquals("用户与权限", items[2].title);
|
||||
assertEquals("ops", items[3].key);
|
||||
assertEquals("运维与修复", items[3].title);
|
||||
assertEquals("ai_accounts", items[4].key);
|
||||
assertEquals("storage", items[5].key);
|
||||
assertEquals("附件与存储", items[5].title);
|
||||
assertEquals("telegram", items[6].key);
|
||||
assertEquals("skills", items[7].key);
|
||||
assertEquals("about", items[8].key);
|
||||
assertEquals(10, items.length);
|
||||
assertEquals("master_agent_prompt", items[0].key);
|
||||
assertEquals("主 Agent 提示词", items[0].title);
|
||||
assertEquals("master_agent_memory", items[1].key);
|
||||
assertEquals("主 Agent 记忆", items[1].title);
|
||||
assertEquals("master_agent_takeover", items[2].key);
|
||||
assertEquals("全局接管", items[2].title);
|
||||
assertEquals("master_agent_evolution", items[3].key);
|
||||
assertEquals("主 Agent 自动进化", items[3].title);
|
||||
assertEquals("security", items[4].key);
|
||||
assertEquals("settings", items[5].key);
|
||||
assertEquals("ops", items[6].key);
|
||||
assertEquals("运维与修复", items[6].title);
|
||||
assertEquals("ai_accounts", items[7].key);
|
||||
assertEquals("skills", items[8].key);
|
||||
assertEquals("about", items[9].key);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -40,17 +40,6 @@ public class WechatSurfaceMapperTopActionTest {
|
||||
assertEquals("add_device", action.actionKey);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootTopAction_hidesAddDeviceForSubAccounts() {
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("devices", false, false, "member");
|
||||
|
||||
assertEquals("刷新", action.label);
|
||||
assertEquals("refresh", action.iconKey);
|
||||
assertFalse(action.primaryStyle);
|
||||
assertTrue(action.compactStyle);
|
||||
assertEquals("refresh", action.actionKey);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootTopAction_keepsRefreshOnMeTab() {
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("me", true);
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Boss 企业后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1762
apps/boss-admin-web/package-lock.json
generated
1762
apps/boss-admin-web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"name": "@boss/admin-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5174",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0 --port 4174",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,230 +0,0 @@
|
||||
export interface BossAdminMenuItem {
|
||||
key: string;
|
||||
label: string;
|
||||
children?: BossAdminMenuItem[];
|
||||
}
|
||||
|
||||
export interface BossAdminTaskSlaRow extends Record<string, unknown> {
|
||||
taskId: string;
|
||||
riskId: string;
|
||||
projectId: string;
|
||||
deviceId: string;
|
||||
taskType: string;
|
||||
status: string;
|
||||
phase: string;
|
||||
summary: string;
|
||||
slaLevel: "ok" | "watch" | "breached" | "recoverable" | "terminal";
|
||||
severity: "info" | "warning" | "critical";
|
||||
slaDueAt: string;
|
||||
lastProgressAt: string;
|
||||
attemptLabel: string;
|
||||
stale: boolean;
|
||||
recoverable: boolean;
|
||||
autoRecoverable: boolean;
|
||||
recommendedAction: string;
|
||||
}
|
||||
|
||||
export interface BossAdminTaskSlaPanel {
|
||||
generatedAt: string;
|
||||
summary: Record<string, number>;
|
||||
rows: BossAdminTaskSlaRow[];
|
||||
}
|
||||
|
||||
export interface BossAdminBackofficePayload {
|
||||
ok: boolean;
|
||||
surface: "platform" | "enterprise";
|
||||
currentCompany: Record<string, unknown> | null;
|
||||
menuTree: BossAdminMenuItem[];
|
||||
insights: {
|
||||
onboardingSteps: string[];
|
||||
serviceStatuses: Array<Record<string, unknown>>;
|
||||
openingPreview: Array<Record<string, unknown>>;
|
||||
deliveryChecklist: Array<Record<string, unknown>>;
|
||||
recentCompanies: Array<Record<string, unknown>>;
|
||||
customerHealthRows: Array<Record<string, unknown>>;
|
||||
riskAggregates: Array<Record<string, unknown>>;
|
||||
customerFollowups: Array<Record<string, unknown>>;
|
||||
enterpriseGoals: Array<Record<string, unknown>>;
|
||||
organizationUnits: string[];
|
||||
departmentProgress: Array<Record<string, unknown>>;
|
||||
masterAgentSummary: string[];
|
||||
permissionHighlights: string[];
|
||||
agentFlowSteps: string[];
|
||||
skillUsageAudit: Array<Record<string, unknown>>;
|
||||
recoveryActions: string[];
|
||||
backupStatus: Record<string, unknown>;
|
||||
dataSafetySummary: Record<string, unknown>;
|
||||
taskRiskSummary: Record<string, unknown>;
|
||||
taskSlaPanel: BossAdminTaskSlaPanel;
|
||||
capabilitySummary: Record<string, number>;
|
||||
surface: "platform" | "enterprise";
|
||||
};
|
||||
workbench: {
|
||||
summary: Record<string, number>;
|
||||
companies: Array<Record<string, unknown>>;
|
||||
devices: Array<Record<string, unknown>>;
|
||||
risks: Array<Record<string, unknown>>;
|
||||
notifications: Array<Record<string, unknown>>;
|
||||
grantsSummary: Record<string, number>;
|
||||
};
|
||||
tenants: Array<Record<string, unknown>>;
|
||||
users: Array<Record<string, unknown>>;
|
||||
roles: {
|
||||
builtInRoles: Array<Record<string, unknown>>;
|
||||
permissionTemplates: Array<Record<string, unknown>>;
|
||||
};
|
||||
resourceGroups: {
|
||||
devices: Array<Record<string, unknown>>;
|
||||
projects: Array<Record<string, unknown>>;
|
||||
skills: Array<Record<string, unknown>>;
|
||||
grants: Record<string, Array<Record<string, unknown>>>;
|
||||
};
|
||||
audit: {
|
||||
risks: Array<Record<string, unknown>>;
|
||||
notifications: Array<Record<string, unknown>>;
|
||||
riskTimeline: Array<Record<string, unknown>>;
|
||||
permissionLogs: Array<Record<string, unknown>>;
|
||||
};
|
||||
yudaoMapping: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface BossAdminBackupSnapshot {
|
||||
snapshotId: string;
|
||||
fileName: string;
|
||||
absolutePath: string;
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
createdAt: string;
|
||||
actorAccount?: string;
|
||||
reason?: string;
|
||||
schemaVersion?: number;
|
||||
}
|
||||
|
||||
export interface BossAdminBackupStatus {
|
||||
mode: "file";
|
||||
backupDir: string;
|
||||
stateFile: string;
|
||||
restorePointCount: number;
|
||||
lastBackupAt?: string;
|
||||
status: "ready" | "empty" | "error";
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface BossAdminBackupsPayload {
|
||||
ok: boolean;
|
||||
status: BossAdminBackupStatus;
|
||||
snapshots: BossAdminBackupSnapshot[];
|
||||
}
|
||||
|
||||
export interface BossAdminSkillLifecycleRequest extends Record<string, unknown> {
|
||||
requestId: string;
|
||||
action: string;
|
||||
status: string;
|
||||
deviceId: string;
|
||||
skillId?: string;
|
||||
sourceUrl?: string;
|
||||
targetVersion?: string;
|
||||
rollbackToVersion?: string;
|
||||
lockedVersion?: string;
|
||||
requestedAt?: string;
|
||||
completedAt?: string;
|
||||
resultSummary?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface BossAdminSkillLifecycleRequestsPayload {
|
||||
ok: boolean;
|
||||
requests: BossAdminSkillLifecycleRequest[];
|
||||
}
|
||||
|
||||
async function requestJson<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
credentials: "include",
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (response.status === 401) {
|
||||
window.location.href = "/auth/login";
|
||||
throw new Error("UNAUTHORIZED");
|
||||
}
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
throw new Error(payload?.message ?? `HTTP_${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchBossAdminBackoffice(scope: "platform" | "enterprise" = "platform"): Promise<BossAdminBackofficePayload> {
|
||||
return requestJson<BossAdminBackofficePayload>(`/api/v1/admin/backoffice?scope=${scope}`);
|
||||
}
|
||||
|
||||
export async function postAdminAccess(payload: Record<string, unknown>) {
|
||||
return requestJson<Record<string, unknown>>("/api/v1/admin/access", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function postRiskAction(payload: Record<string, unknown>) {
|
||||
return requestJson<Record<string, unknown>>("/api/v1/admin/risks/actions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function postSkillLifecycleRequest(payload: Record<string, unknown>) {
|
||||
return requestJson<Record<string, unknown>>("/api/v1/admin/skills/requests", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchSkillLifecycleRequests(): Promise<BossAdminSkillLifecycleRequestsPayload> {
|
||||
return requestJson<BossAdminSkillLifecycleRequestsPayload>("/api/v1/admin/skills/requests", {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
export async function postDeviceCodexRemoteControl(
|
||||
deviceId: string,
|
||||
payload: { action: "start" | "stop"; reason?: string },
|
||||
) {
|
||||
return requestJson<Record<string, unknown>>(
|
||||
`/api/v1/devices/${encodeURIComponent(deviceId)}/codex-remote-control`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
confirmed: true,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchAdminBackups(): Promise<BossAdminBackupsPayload> {
|
||||
return requestJson<BossAdminBackupsPayload>("/api/v1/admin/backups");
|
||||
}
|
||||
|
||||
export async function createAdminBackup(reason: string) {
|
||||
return requestJson<Record<string, unknown>>("/api/v1/admin/backups", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
action: "create_snapshot",
|
||||
reason,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function restoreAdminBackup(snapshotId: string) {
|
||||
return requestJson<Record<string, unknown>>("/api/v1/admin/backups", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
action: "restore_snapshot",
|
||||
snapshotId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { createApp } from "vue";
|
||||
import Antd from "ant-design-vue";
|
||||
import "ant-design-vue/dist/reset.css";
|
||||
import App from "./App.vue";
|
||||
import "./styles.css";
|
||||
|
||||
createApp(App).use(Antd).mount("#app");
|
||||
@@ -1,426 +0,0 @@
|
||||
:root {
|
||||
color: #102018;
|
||||
background: #eef4ef;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 1360px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(16, 185, 129, 0.16), transparent 30%),
|
||||
linear-gradient(135deg, #f7fbf8 0%, #eef4ef 54%, #e6f0ec 100%);
|
||||
}
|
||||
|
||||
.boss-admin-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.boss-admin-sidebar {
|
||||
padding: 28px 20px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border-right: 1px solid rgba(16, 32, 24, 0.08);
|
||||
backdrop-filter: blur(22px);
|
||||
}
|
||||
|
||||
.boss-admin-brand {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.boss-admin-brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
color: white;
|
||||
font-weight: 900;
|
||||
background: #10b981;
|
||||
border-radius: 17px;
|
||||
box-shadow: 0 14px 28px rgba(16, 185, 129, 0.24);
|
||||
}
|
||||
|
||||
.boss-admin-brand h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.boss-admin-brand p,
|
||||
.boss-admin-eyebrow {
|
||||
margin: 0;
|
||||
color: #68766e;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.boss-admin-surface-switch {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.boss-admin-surface-card {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
color: #526158;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: rgba(247, 250, 248, 0.78);
|
||||
border: 1px solid rgba(16, 32, 24, 0.08);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.boss-admin-surface-card span {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.boss-admin-surface-card small {
|
||||
color: #7b8780;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.boss-admin-surface-card.active {
|
||||
color: #0b6b4c;
|
||||
background: #e5f8ef;
|
||||
border-color: rgba(16, 185, 129, 0.36);
|
||||
box-shadow: 0 12px 30px rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.boss-admin-menu {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.boss-admin-visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.boss-admin-menu-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
color: #56615a;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.boss-admin-menu-item.active,
|
||||
.boss-admin-menu-item:hover {
|
||||
color: #0f7a55;
|
||||
background: #e7f8ef;
|
||||
}
|
||||
|
||||
.boss-admin-main {
|
||||
padding: 28px 34px 44px;
|
||||
}
|
||||
|
||||
.boss-admin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.boss-admin-header h2 {
|
||||
margin: 5px 0 0;
|
||||
font-size: 30px;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.boss-admin-header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.boss-admin-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.boss-admin-section,
|
||||
.boss-admin-section-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.boss-admin-section-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.boss-admin-hero {
|
||||
grid-column: 1 / -1;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(16, 185, 129, 0.16), rgba(255, 255, 255, 0.94)),
|
||||
white;
|
||||
}
|
||||
|
||||
.boss-admin-hero h3 {
|
||||
max-width: 820px;
|
||||
margin: 8px 0 22px;
|
||||
font-size: 25px;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.boss-admin-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.boss-admin-metrics.compact {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.boss-admin-metric {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 18px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border: 1px solid rgba(16, 32, 24, 0.08);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.boss-admin-metric span {
|
||||
color: #6a766f;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.boss-admin-metric strong {
|
||||
color: #102018;
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.boss-admin-metric.green strong {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.boss-admin-metric.red strong {
|
||||
color: #f04452;
|
||||
}
|
||||
|
||||
.boss-admin-metric.orange strong {
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.boss-admin-form {
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
.boss-admin-form-gap {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.boss-admin-steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.boss-admin-step {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
color: #51625a;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
border: 1px solid rgba(16, 32, 24, 0.08);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.boss-admin-step span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
color: #0f7a55;
|
||||
background: #dbf7ea;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.boss-admin-step.active {
|
||||
color: #0b6b4c;
|
||||
border-color: rgba(16, 185, 129, 0.34);
|
||||
box-shadow: 0 10px 24px rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.boss-admin-action-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.boss-admin-status-list,
|
||||
.boss-admin-check-list,
|
||||
.boss-admin-goal-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.boss-admin-status-row,
|
||||
.boss-admin-check-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
background: #f7faf8;
|
||||
border: 1px solid rgba(16, 32, 24, 0.06);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.boss-admin-check-row {
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.boss-admin-check-row span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #f97316;
|
||||
background: #fff7ed;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.boss-admin-check-row span.done {
|
||||
color: #10b981;
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.boss-admin-goal-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
background: #f7faf8;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.boss-admin-goal-row > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.boss-admin-org-grid,
|
||||
.boss-admin-capability-grid,
|
||||
.boss-admin-recovery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.boss-admin-org-node,
|
||||
.boss-admin-recovery-card,
|
||||
.boss-admin-capability-grid > div {
|
||||
padding: 16px;
|
||||
color: #18352a;
|
||||
font-weight: 800;
|
||||
background: #f7faf8;
|
||||
border: 1px solid rgba(16, 32, 24, 0.06);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.boss-admin-capability-grid span {
|
||||
display: block;
|
||||
color: #68766e;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.boss-admin-capability-grid strong {
|
||||
color: #10b981;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.boss-admin-permission-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.boss-admin-flow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.boss-admin-flow-node {
|
||||
padding: 14px 16px;
|
||||
color: #0b6b4c;
|
||||
font-weight: 800;
|
||||
background: #e5f8ef;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.boss-admin-flow-arrow {
|
||||
color: #8b9790;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.boss-admin-backup-status {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.ant-card {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 18px 50px rgba(16, 32, 24, 0.07);
|
||||
}
|
||||
|
||||
.boss-admin-wide-card {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ant-card .ant-card-body {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-table-wrapper {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-table {
|
||||
min-width: 720px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-table-cell {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-btn,
|
||||
.boss-admin-action-strip .ant-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/admin-web/",
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
outDir: "../../public/admin-web",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: process.env.BOSS_WEB_ORIGIN ?? "http://localhost:3000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/auth": {
|
||||
target: process.env.BOSS_WEB_ORIGIN ?? "http://localhost:3000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,368 +0,0 @@
|
||||
import Cocoa
|
||||
import WebKit
|
||||
import ApplicationServices
|
||||
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate {
|
||||
private var window: NSWindow?
|
||||
private var webView: WKWebView?
|
||||
private var activeTab = "overview"
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleApplicationDidBecomeActive),
|
||||
name: NSApplication.didBecomeActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
NSAppleEventManager.shared().setEventHandler(
|
||||
self,
|
||||
andSelector: #selector(handleGetUrlEvent(_:withReplyEvent:)),
|
||||
forEventClass: AEEventClass(kInternetEventClass),
|
||||
andEventID: AEEventID(kAEGetURL)
|
||||
)
|
||||
|
||||
let webConfiguration = WKWebViewConfiguration()
|
||||
let webView = WKWebView(frame: .zero, configuration: webConfiguration)
|
||||
webView.setValue(false, forKey: "drawsBackground")
|
||||
webView.navigationDelegate = self
|
||||
self.webView = webView
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 1180, height: 780),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "boss-agent"
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.isMovableByWindowBackground = true
|
||||
window.contentView = webView
|
||||
window.center()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
self.window = window
|
||||
|
||||
loadAgentPanel(tab: activeTab)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
handleLaunchPermissionRequestIfNeeded()
|
||||
}
|
||||
|
||||
private func loadAgentPanel(tab: String? = nil) {
|
||||
activeTab = normalizedTab(tab ?? activeTab)
|
||||
var components = URLComponents()
|
||||
components.scheme = "http"
|
||||
components.host = "127.0.0.1"
|
||||
components.port = 4317
|
||||
components.path = "/boss-agent"
|
||||
components.queryItems = [URLQueryItem(name: "tab", value: activeTab)] + nativePermissionQueryItems()
|
||||
guard let url = components.url else {
|
||||
loadFallback()
|
||||
return
|
||||
}
|
||||
webView?.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData))
|
||||
}
|
||||
|
||||
@objc private func handleApplicationDidBecomeActive() {
|
||||
loadAgentPanel(tab: activeTab)
|
||||
}
|
||||
|
||||
private func handleLaunchPermissionRequestIfNeeded() {
|
||||
let arguments = CommandLine.arguments
|
||||
guard
|
||||
let targetIndex = arguments.firstIndex(of: "--request-permission"),
|
||||
arguments.indices.contains(targetIndex + 1)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let target = arguments[targetIndex + 1]
|
||||
let returnTab = commandLineValue(after: "--return-tab", in: arguments) ?? "permissions"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
||||
self?.handlePermissionTarget(target, returnTab: returnTab)
|
||||
}
|
||||
}
|
||||
|
||||
private func commandLineValue(after flag: String, in arguments: [String]) -> String? {
|
||||
guard let index = arguments.firstIndex(of: flag), arguments.indices.contains(index + 1) else {
|
||||
return nil
|
||||
}
|
||||
return arguments[index + 1]
|
||||
}
|
||||
|
||||
@objc private func handleGetUrlEvent(_ event: NSAppleEventDescriptor, withReplyEvent replyEvent: NSAppleEventDescriptor) {
|
||||
guard
|
||||
let urlString = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue,
|
||||
let url = URL(string: urlString),
|
||||
isBossAgentDeepLink(url)
|
||||
else {
|
||||
return
|
||||
}
|
||||
handleBossAgentDeepLink(url)
|
||||
}
|
||||
|
||||
func application(_ application: NSApplication, open urls: [URL]) {
|
||||
for url in urls where isBossAgentDeepLink(url) {
|
||||
handleBossAgentDeepLink(url)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
loadFallback()
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
loadFallback()
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
decidePolicyFor navigationAction: WKNavigationAction,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
||||
) {
|
||||
guard let url = navigationAction.request.url else {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
|
||||
if isPermissionSetupUrl(url) {
|
||||
decisionHandler(.cancel)
|
||||
handlePermissionSetupNavigation(url)
|
||||
return
|
||||
}
|
||||
|
||||
if isAgentPanelUrl(url) && !hasNativePermissionQuery(url) {
|
||||
decisionHandler(.cancel)
|
||||
loadAgentPanel(tab: queryValue("tab", in: url))
|
||||
return
|
||||
}
|
||||
|
||||
if isAgentPanelUrl(url), let tab = queryValue("tab", in: url) {
|
||||
activeTab = normalizedTab(tab)
|
||||
}
|
||||
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
|
||||
private func isPermissionSetupUrl(_ url: URL) -> Bool {
|
||||
url.path == "/api/v1/boss-agent/permissions/open"
|
||||
}
|
||||
|
||||
private func isBossAgentDeepLink(_ url: URL) -> Bool {
|
||||
url.scheme == "boss-agent"
|
||||
}
|
||||
|
||||
private func handleBossAgentDeepLink(_ url: URL) {
|
||||
if url.host == "permissions" && url.path == "/open" {
|
||||
handlePermissionTarget(
|
||||
queryValue("target", in: url) ?? "core",
|
||||
returnTab: queryValue("returnTab", in: url) ?? "permissions"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if url.host == "tab" {
|
||||
loadAgentPanel(tab: String(url.path.dropFirst()))
|
||||
}
|
||||
}
|
||||
|
||||
private func isAgentPanelUrl(_ url: URL) -> Bool {
|
||||
let host = url.host ?? ""
|
||||
return (host == "127.0.0.1" || host == "localhost") && url.port == 4317 && (url.path == "/boss-agent" || url.path == "/")
|
||||
}
|
||||
|
||||
private func hasNativePermissionQuery(_ url: URL) -> Bool {
|
||||
URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
||||
.queryItems?
|
||||
.contains(where: { $0.name.hasPrefix("native") }) == true
|
||||
}
|
||||
|
||||
private func queryValue(_ name: String, in url: URL) -> String? {
|
||||
URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
||||
.queryItems?
|
||||
.first(where: { $0.name == name })?
|
||||
.value
|
||||
}
|
||||
|
||||
private func normalizedTab(_ value: String?) -> String {
|
||||
switch value {
|
||||
case "permissions", "skills", "license", "logs", "overview":
|
||||
return value ?? "overview"
|
||||
default:
|
||||
return "overview"
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePermissionSetupNavigation(_ url: URL) {
|
||||
handlePermissionTarget(
|
||||
queryValue("target", in: url) ?? "core",
|
||||
returnTab: queryValue("returnTab", in: url) ?? activeTab
|
||||
)
|
||||
}
|
||||
|
||||
private func handlePermissionTarget(_ target: String, returnTab rawReturnTab: String) {
|
||||
let permissionTarget = normalizedPermissionTarget(target)
|
||||
let returnTab = normalizedTab(rawReturnTab)
|
||||
activeTab = returnTab
|
||||
|
||||
UserDefaults.standard.set(permissionTarget, forKey: "lastPermissionRequestTarget")
|
||||
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "lastPermissionRequestAt")
|
||||
NSLog("boss-agent permission request target=%@ returnTab=%@", permissionTarget, returnTab)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
requestNativePermission(for: permissionTarget)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in
|
||||
if let settingsUrl = self?.systemSettingsUrl(for: permissionTarget) {
|
||||
NSWorkspace.shared.open(settingsUrl)
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
|
||||
self?.loadAgentPanel(tab: returnTab)
|
||||
}
|
||||
}
|
||||
|
||||
private func nativePermissionQueryItems() -> [URLQueryItem] {
|
||||
let accessibility = AXIsProcessTrusted() ? "granted" : "missing"
|
||||
let screenRecording = screenRecordingStatus()
|
||||
UserDefaults.standard.set(accessibility, forKey: "native.accessibility")
|
||||
UserDefaults.standard.set(screenRecording, forKey: "native.screenRecording")
|
||||
|
||||
return [
|
||||
URLQueryItem(name: "nativeAccessibility", value: accessibility),
|
||||
URLQueryItem(name: "nativeScreenRecording", value: screenRecording),
|
||||
]
|
||||
}
|
||||
|
||||
private func screenRecordingStatus() -> String {
|
||||
if #available(macOS 10.15, *) {
|
||||
if CGPreflightScreenCaptureAccess() {
|
||||
return "granted"
|
||||
}
|
||||
if CGRequestScreenCaptureAccess() {
|
||||
return "granted"
|
||||
}
|
||||
return tccPermissionStatus(service: "kTCCServiceScreenCapture") ?? "missing"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
private func tccPermissionStatus(service: String) -> String? {
|
||||
let clients = service == "kTCCServiceScreenCapture"
|
||||
? "'com.hyzq.boss.agent','site.hyzq.boss.computer-use-helper'"
|
||||
: "'com.hyzq.boss.agent'"
|
||||
let query = "select auth_value from access where client in (\(clients)) and service='\(service)' order by auth_value desc limit 1;"
|
||||
let databasePaths = [
|
||||
"/Library/Application Support/com.apple.TCC/TCC.db",
|
||||
"\(NSHomeDirectory())/Library/Application Support/com.apple.TCC/TCC.db",
|
||||
]
|
||||
|
||||
for databasePath in databasePaths where FileManager.default.fileExists(atPath: databasePath) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3")
|
||||
process.arguments = [databasePath, query]
|
||||
let output = Pipe()
|
||||
process.standardOutput = output
|
||||
process.standardError = Pipe()
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
let data = output.fileHandleForReading.readDataToEndOfFile()
|
||||
let value = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value == "2" {
|
||||
return "granted"
|
||||
}
|
||||
if value == "0" {
|
||||
return "missing"
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func requestNativePermission(for target: String) {
|
||||
let targets: [String]
|
||||
if target == "core" {
|
||||
targets = ["accessibility", "screenRecording"]
|
||||
} else {
|
||||
targets = [target]
|
||||
}
|
||||
|
||||
for permission in targets {
|
||||
requestSingleNativePermission(permission)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestSingleNativePermission(_ permission: String) {
|
||||
switch permission {
|
||||
case "accessibility":
|
||||
requestAccessibilityPermission()
|
||||
case "screenRecording":
|
||||
requestScreenRecordingPermission()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func requestAccessibilityPermission() {
|
||||
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
|
||||
let options = [promptKey: true] as CFDictionary
|
||||
_ = AXIsProcessTrustedWithOptions(options)
|
||||
}
|
||||
|
||||
private func requestScreenRecordingPermission() {
|
||||
if #available(macOS 10.15, *) {
|
||||
_ = CGRequestScreenCaptureAccess()
|
||||
} else {
|
||||
_ = CGPreflightScreenCaptureAccess()
|
||||
}
|
||||
}
|
||||
|
||||
private func systemSettingsUrl(for target: String) -> URL? {
|
||||
let mapping = [
|
||||
"core": "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Accessibility",
|
||||
"accessibility": "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Accessibility",
|
||||
"screenRecording": "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ScreenCapture",
|
||||
]
|
||||
return URL(string: mapping[normalizedPermissionTarget(target)] ?? mapping["core"]!)
|
||||
}
|
||||
|
||||
private func normalizedPermissionTarget(_ target: String) -> String {
|
||||
switch target {
|
||||
case "accessibility", "screenRecording", "core":
|
||||
return target
|
||||
default:
|
||||
return "core"
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFallback() {
|
||||
let html = """
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { margin:0; min-height:100vh; display:grid; place-items:center; background:#f6f8f5; font-family:-apple-system,BlinkMacSystemFont,'PingFang SC',sans-serif; color:#111418; }
|
||||
.card { width:520px; padding:32px; border-radius:24px; background:white; border:1px solid #e8ece9; box-shadow:0 24px 70px rgba(22,38,29,.12); }
|
||||
h1 { margin:0 0 10px; font-size:28px; letter-spacing:-.04em; }
|
||||
p { color:#707982; line-height:1.7; margin:0; }
|
||||
</style>
|
||||
<body>
|
||||
<section class="card">
|
||||
<h1>boss-agent 未启动</h1>
|
||||
<p>请先启动本机 local-agent 服务,然后重新打开 boss-agent。</p>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
webView?.loadHTMLString(html, baseURL: nil)
|
||||
}
|
||||
}
|
||||
|
||||
let app = NSApplication.shared
|
||||
let delegate = AppDelegate()
|
||||
app.delegate = delegate
|
||||
app.run()
|
||||
@@ -10,24 +10,6 @@ boss.hyzq.net {
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
|
||||
admin.boss.hyzq.net {
|
||||
encode zstd gzip
|
||||
|
||||
handle /admin-web/* {
|
||||
root * /opt/boss/public
|
||||
file_server
|
||||
}
|
||||
|
||||
@adminRoot path /
|
||||
handle @adminRoot {
|
||||
root * /opt/boss/public
|
||||
rewrite * /admin-web/index.html
|
||||
file_server
|
||||
}
|
||||
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
|
||||
http://106.53.170.158 {
|
||||
encode zstd gzip
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.hyzq.boss.codex-desktop-bridge</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>BOSS_CODEX_DESKTOP_BRIDGE_HOST</key>
|
||||
<string>127.0.0.1</string>
|
||||
<key>BOSS_CODEX_DESKTOP_BRIDGE_PORT</key>
|
||||
<string>4318</string>
|
||||
</dict>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/zsh</string>
|
||||
<string>-lc</string>
|
||||
<string>cd __BOSS_AGENT_ROOT__ && node scripts/codex-desktop-refresh-bridge-daemon.mjs</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/boss-codex-desktop-bridge.out</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/boss-codex-desktop-bridge.err</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user