diff --git a/.gitignore b/.gitignore index 5ef6a52..e721375 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,14 @@ # misc .DS_Store *.pem +.playwright-cli/ +data/*.json +data/*.json.bak +android/.gradle/ +android/**/build/ +android/local.properties +android/keystores/ +android/signing/*.properties # debug npm-debug.log* diff --git a/AGENTS.md b/AGENTS.md index 8bd0e39..9fe8f4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,3 +3,38 @@ This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + +# Boss 项目接手规则 + +先读: + +1. `README.md` +2. `docs/architecture/ai_handoff_index_cn.md` +3. `docs/architecture/current_runtime_and_deploy_status_cn.md` +4. `docs/architecture/api_and_service_inventory_cn.md` + +当前有效实现: + +- Web MVP:`src/app`、`src/components`、`src/lib` +- 本地设备端:`local-agent` +- 部署资产:`deployment`、`scripts` + +当前不是有效运行时: + +- `docs/source-material` +- `deploy` +- `src/boss_control` +- `src/boss_device_agent` + +运行与部署约束: + +- Web 当前使用文件存储:`data/boss-state.json` +- 本地 agent 当前通过 `launchd` 常驻:`com.hyzq.boss.local-agent` +- 服务器固定是 `106.53.170.158` +- 优先使用 skill:`$HOME/.codex/skills/boss-server-debug/SKILL.md` + +如果你要继续开发: + +- 先验证 `npm run build` 和 `npm run lint` +- 先看当前路由和 API,再改文档 +- 对服务器、域名和 HTTPS 的判断以 `docs/architecture/current_runtime_and_deploy_status_cn.md` 为准,不要沿用旧上下文 diff --git a/README.md b/README.md index e215bc4..0d8f639 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,265 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Boss 控制台 MVP -## Getting Started +这个仓库当前已经收口成“下一条 AI 线程不需要重新摸索结构和部署方式”的状态。真实实现是一套基于 `Next.js App Router` 的移动控制台,加一个本地 `device-agent`,持久化仍然是 `data/boss-state.json`,部署链路是 `systemd + Caddy + launchd`。 -First, run the development server: +## 先读哪里 + +按这个顺序看,交接成本最低: + +1. `docs/architecture/ai_handoff_index_cn.md` +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/boss_server_connection_and_deploy_cn.md` +6. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md` + +## 当前有效目录 + +- `src/app`:当前 Web 页面和 API 路由 +- `src/components`:共享 UI 和交互组件 +- `src/lib`:文件型状态模型和聚合投影视图 +- `local-agent`:本地设备端心跳 + thread-context 上报服务 +- `deployment`:`Caddy`、`systemd`、`launchd` 配置 +- `scripts`:本地启动、安装、远端部署脚本 +- `design`:Pencil 原稿和导出图 +- `android`:原生 Android 客户端工程和 APK 构建目录 +- `docs/architecture`:当前权威中文文档 +- `prompts`:交给其他 Codex 线程的提示词 + +## 当前仅作参考或占位的目录 + +- `docs/source-material`:历史材料,不是运行时真相 +- `deploy`:空占位目录,不参与当前部署 +- `src/boss_control`:空占位目录,不参与当前运行 +- `src/boss_device_agent`:空占位目录,不参与当前运行 + +## 当前运行状态(2026-03-26) + +本地: + +- `npm run lint` 已通过 +- `npm run build` 已通过 +- `GET http://127.0.0.1:3000/api/health` 正常 +- `GET http://127.0.0.1:3000/api/v1/conversations` 正常 +- `GET http://127.0.0.1:3000/api/v1/projects/master-agent` 正常,主 Agent 项目页已能显示最近 APP 日志 +- `GET http://127.0.0.1:3000/api/v1/accounts` 正常,已返回主 GPT / 备用 GPT / API 容灾账号摘要 +- `GET http://127.0.0.1:3000/api/v1/devices/mac-studio/skills` 正常,已返回本机同步 Skill 列表 +- `POST http://127.0.0.1:3000/api/auth/login` 正常,会写入 `boss_session` Cookie +- `GET http://127.0.0.1:3000/api/auth/session` 正常 +- `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/auth/logout` 正常,退出后访问受保护 `/api/v1/*` 会返回 `401` +- `GET http://127.0.0.1:3000/api/v1/user/ota/package` 正常,当前会返回最新 APK 包 +- `GET http://127.0.0.1:4317/health` 正常 +- `GET http://127.0.0.1:4317/api/v1/skills` 正常,已返回本机扫描到的 Codex Skill +- `POST http://127.0.0.1:4317/api/v1/heartbeat` 正常,且会顺带触发 `thread-context` 上报 +- `launchd` 已加载:`~/Library/LaunchAgents/com.hyzq.boss.local-agent.plist` + +服务器: + +- 服务器地址:`106.53.170.158` +- 代码路径:`/opt/boss` +- `boss-web.service` 正常 +- `caddy.service` 正常 +- `postfix.service` 正常 +- `dovecot.service` 正常 +- `GET http://127.0.0.1:3000/api/health` 正常 +- `GET http://127.0.0.1:3000/api/v1/conversations` 正常 + +域名和 HTTPS: + +- 服务器本机 `dig +short boss.hyzq.net` 返回 `106.53.170.158` +- 服务器本机 `curl --resolve boss.hyzq.net:443:127.0.0.1 https://boss.hyzq.net -I` 返回 `307` 并跳转到 `/auth/login` +- 当前本机网络 `dig +short boss.hyzq.net` 仍返回 `198.18.1.188` +- 当前本机网络 `curl -I http://boss.hyzq.net` 返回 `308` +- 当前本机网络 `curl -I https://boss.hyzq.net` 返回 `HTTP/2 307` 并跳转到 `/auth/login` +- 当前本机网络 `curl https://boss.hyzq.net/api/health` 返回 `{"ok":true,...}` +- 当前本机网络 `curl https://boss.hyzq.net/api/v1/conversations` 已返回真实聚合数据 +- 当前本机网络 `nc -vz boss.hyzq.net 25 587 993` 全部成功 + +当前结论更新为: + +- 当前网络下 `boss.hyzq.net` 的 HTTP/HTTPS 已可达 +- `Caddy/TLS` 和外部 `443` 路径都已经能实际返回页面跳转 +- 公网域名下的 Web API 也已经能实际返回健康探针和业务数据 +- 服务器上的 `Postfix + Dovecot` 已部署完成,公网 `25 / 587 / 993` 当前也已经可达 +- 但当前网络下 `dig` 仍显示 `198.18.1.188`,说明这里可能存在代理层、分裂 DNS 或中间入口,不要再把这个解析值自动当成错误配置 + +Android APK: + +- 已生成 Android debug APK:`android/app/build/outputs/apk/debug/app-debug.apk` +- 已生成 Android signed release APK:`android/app/build/outputs/apk/release/app-release.apk` +- `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk` +- 当前最新 release 构建版本:`2.1.0`(`versionCode=7`) +- 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局` +- 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于 +- 原生客户端当前直接调用 `https://boss.hyzq.net` 的 Boss API,不再打开 WebView +- `2.0.1` 已修复华为真机上因 `Theme.SplashScreen` 与 `AppCompatActivity` 不兼容导致的启动闪退 +- `2.1.0` 已在本机连接的华为真机上完成签名包覆盖安装与启动复核,原生三栏入口和子活动页声明已全部接通 + +## 本地启动 + +开发态: ```bash +cd /Users/kris/code/boss +npm install npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +构建态: -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +```bash +cd /Users/kris/code/boss +npm run build +npm start +``` -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +说明: -## Learn More +- `npm run build` 前会自动清理 `.next`,避免旧目录残留导致 `ENOTEMPTY` -To learn more about Next.js, take a look at the following resources: +默认入口: -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +- 登录页:[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) -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## 设备端本地服务 -## Deploy on Vercel +手动启动: -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +```bash +cd /Users/kris/code/boss +./scripts/start-local-agent.sh ./local-agent/config.example.json +``` -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +安装常驻 `launchd`: + +```bash +cd /Users/kris/code/boss +./scripts/install-local-launchagent.sh +``` + +如需把常驻 agent 指回本地开发控制面: + +```bash +cd /Users/kris/code/boss +./scripts/install-local-launchagent.sh /Users/kris/code/boss/local-agent/config.example.json +``` + +device-agent 当前职责: + +- 上报设备状态、账号、5h/7d 额度和项目列表 +- 向云端 `/api/device-heartbeat` 发心跳 +- 向云端 `/api/v1/workers/{workerId}/thread-context` 发线程预算更新 +- 递归扫描本机 `~/.codex/skills`,并同步到云端 `/api/v1/devices/[deviceId]/skills` +- 轮询云端 `/api/v1/master-agent/tasks/claim`,并用当前电脑已登录的 `codex` 账号执行主 Agent 任务 +- 将主 Agent 执行结果回写到云端 `/api/v1/master-agent/tasks/[taskId]/complete` +- 提供本地 `/health`、`/api/v1/device`、`/api/v1/skills`、`/api/v1/heartbeat` + +当前常驻默认值: + +- `launchd` 默认加载 `local-agent/config.cloud.json`,控制面指向 `https://boss.hyzq.net` +- `local-agent/config.example.json` 保留给本地 `127.0.0.1:3000` 回环开发 + +## 部署入口 + +- 服务器连接与部署:`docs/architecture/boss_server_connection_and_deploy_cn.md` +- 服务器调试 skill:`$HOME/.codex/skills/boss-server-debug/SKILL.md` +- 远端部署脚本:`scripts/deploy-server.sh` +- 远端邮件部署脚本:`scripts/install-server-mail.sh` +- 远端初始化脚本:`scripts/bootstrap-server.sh` +- APK 发布脚本:`scripts/publish-apk-to-public.sh` +- `systemd` 配置:`deployment/systemd/boss-web.service` +- `Caddy` 配置:`deployment/Caddyfile` +- 邮件配置:`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` +- Android 原生会话页:`android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java` +- Android 原生设备页:`android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java` +- Android 原生我的页:`android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`、`android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`、`android/app/src/main/java/com/hyzq/boss/SettingsActivity.java` +- 服务器环境示例:`.env.server.example` + +当前 `scripts/deploy-server.sh`: + +- 优先从 macOS Keychain 读取 `ubuntu@106.53.170.158` 的密码 +- 如果 Keychain 不可用,再回退读取 `BOSS_SERVER_PASS` +- 当前已明确排除 `data/` 目录,部署不会再覆盖服务器上的 `boss-state.json` +- 部署脚本当前会先在本机执行 `npm run build`,再把本机已经验证通过的 `.next` 构建产物同步到服务器 +- 同步前会先在服务器上删除旧 `.next` 并修正 `/opt/boss` 所有权,避免 rsync 被历史 root 产物卡住 +- 服务器当前不再现编 Next standalone,而是直接重启使用本机同步过去的构建产物,避免服务器端 tracing / 权限差异导致部署失败 +- 远端重启服务后会自动执行一次 `curl -fsS http://127.0.0.1:3000/api/health` + +APK 构建: + +```bash +cd /Users/kris/code/boss +npm run apk:debug +``` + +```bash +cd /Users/kris/code/boss +npm run apk:release +``` + +说明: + +- `npm run apk:debug` 现在会在 Gradle 构建完成后自动执行 `scripts/publish-apk-to-public.sh` +- `npm run apk:release` 会先准备本机 release keystore,再构建 signed release APK,并发布到 `public/downloads` +- 最新 APK 会同步到 `public/downloads/boss-android-latest.apk` +- 同时也会额外保留一份带版本号的 APK:`public/downloads/boss-android-v{versionName}-{flavor}.apk` +- OTA 下载入口固定走受保护的 `GET /api/v1/user/ota/package` +- release 签名文件当前放在本机: + - `android/keystores/boss-release.keystore` + - `android/signing/release-signing.properties` + - 以上文件已加入 `.gitignore`,不会进仓库 + +## 关键实现说明 + +- 当前持久化是真正生效的文件存储:`data/boss-state.json` +- Web 生产启动和服务器 `systemd` 都显式设置了 `BOSS_STATE_FILE`,避免 Next standalone 误把状态写进 `.next/standalone/data/` +- 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 日志并发写不会再互相覆盖 +- 当前文件存储里已经包含: + - `projects / messages / goals / versions` + - `authAccounts / otaUpdates / otaUpdateLogs` + - `threadContextSnapshots / threadHandoffPackages / threadContextAlerts` + - `deviceEnrollments` + - `deviceSkills / appLogs` + - `opsFaults / opsRepairTickets / opsRepairVerifications` + - `auditRequests / auditResults / capabilities` +- 根布局会挂载 `AppLogBridge`,前端路由切换、运行时异常、发送消息、OTA 操作都会通过 `/api/v1/app-logs` 实时同步到服务器 +- Web 端根布局当前仍保留 `NativeAppBridge`,用于浏览器态与历史桥接兼容;当前正式 APK 已改为原生 Activity + 原生 API 客户端,不再依赖 WebView +- APP 日志桥已经改成会话感知:只会按当前登录账号解析绑定设备,不再在未登录页默认按全局管理员设备写日志 +- APP 外壳已经从“桌面预览卡片”切回真机态:移动端不再渲染假的 `9:41 / 5G` 状态栏,底部 `会话 / 设备 / 我的` 导航固定在视口底部,背景改为全屏 cover,不再出现圆角矩形外壳 +- 登录成功后的进入首页链路已做稳态处理:会先确认 `/api/auth/session` 可读,再执行 `replace(/conversations)`,并附带一次原生级兜底跳转,避免真机 WebView 偶发停留在“正在进入会话首页” +- `/api/v1/events` 已作为 SSE 出口使用,会话页、设备页、技能页和项目详情页会按事件自动刷新,不再只靠手动刷新 +- 我的页新增 `技能` 入口,`/me/skills` 会按设备分组展示 Skill,并支持一键复制调用语句 +- 我的页新增 `AI 账号` 入口,`/me/ai-accounts` 会展示主 GPT / 备用 GPT / API 容灾,并明确主链路优先走已登录 `ChatGPT Plus / Codex` 的 `Master Codex Node` +- API 容灾当前不走服务器预置 Key,而是由用户在 APP 的 `我的 > AI 账号` 中自行配置 `OpenAI API` 账号 +- 设备页当前只展示已接入生产链路的设备,历史演示脏数据已经从正式设备视图、运维视图和审计视图中剔除 +- 登录页当前已临时切到免验证模式,点击“登录”会直接进入会话首页 +- 认证现在已经有最小会话链路:登录后会写入 `boss_session` Cookie,默认保持 30 天,`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 接口都要求有效会话 +- 新增 `GET /api/auth/session`、`POST /api/auth/logout` 与 `POST /api/auth/restore` +- 原生 Android 客户端当前会把 `boss_session / restore token / account` 存到 `SharedPreferences`,用于重启后恢复会话 +- 验证码新增防刷与防重放:60 秒冷却、15 分钟窗口限流,登录连续失败 5 次后会锁定 10 分钟 +- `POST /api/auth/send-code` 现在会先按用途校验账号状态:登录 / 忘记密码要求账号已存在,注册要求账号尚未注册 +- 当前登录页已临时放开成“一键进入”,账号密码和验证码输入暂时不作为拦截条件 +- `POST /api/auth/send-code` 与固定验证码 `000000` 仍保留给注册 / 重置密码和后续认证收口,不作为当前登录页前置条件 +- 新注册和重置密码现在使用 `scrypt` 哈希;历史 `sha256` 密码会在下一次密码登录时自动迁移 +- 当前默认最高管理员账号:`17600003315` +- 当前默认测试密码:`boss123456` +- 当前本机 Codex 节点 `mac-studio` 已绑定到 `17600003315` +- 主 Agent 对话当前真实执行链路是:`Boss Web -> master-agent task queue -> local-agent -> codex exec -> complete task -> project ledger` +- 主 Agent 同步等待窗口已调到 55 秒;如果本机 Codex 节点执行较慢,项目页也会通过 SSE 在任务完成后自动刷新出真实回复 +- 服务器已经部署 `Postfix + Dovecot`,邮箱别名 `verify@boss.hyzq.net` / `no-reply@boss.hyzq.net` 当前会投递到本机 `bossmail` 邮箱 +- 应用内 `POST /api/auth/send-code` 已经支持 email 模式,并可通过 `/opt/boss/.env.server` 切换;本轮已临时切到 email 模式验证成功,随后恢复默认 fixed +- 应用内 `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 或匹配登录会话 +- 当前认证仍是 MVP:已有最小会话 Cookie,但还没有刷新令牌、跨端会话治理、吊销审计和 CSRF 防护 +- 当前图片 / 视频入口会写入消息账本,但真实文件上传还没有接对象存储 +- 当前采用“极轻云 + 本地设备端”的路线,云端只承载 Web、轻 API 和状态文件 +- 服务器侧主 Agent 对话能否返回真实大模型回复,依赖被绑定设备的 `local-agent` 在线并能执行 `codex exec`;服务器本身不直接持有主 GPT 会话 diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..48354a3 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,101 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Generated Config files +app/src/main/assets/capacitor.config.json +app/src/main/assets/capacitor.plugins.json +app/src/main/res/xml/config.xml diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 0000000..043df80 --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..f053c42 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,70 @@ +apply plugin: 'com.android.application' + +def releaseSigningProps = new Properties() +def releaseSigningPropsFile = rootProject.file('signing/release-signing.properties') +def hasReleaseSigning = releaseSigningPropsFile.exists() +if (hasReleaseSigning) { + releaseSigningProps.load(new FileInputStream(releaseSigningPropsFile)) +} + +android { + namespace = "com.hyzq.boss" + compileSdk = rootProject.ext.compileSdkVersion + buildFeatures { + buildConfig true + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } + signingConfigs { + if (hasReleaseSigning) { + release { + storeFile file(releaseSigningProps['storeFile']) + storePassword releaseSigningProps['storePassword'] + keyAlias releaseSigningProps['keyAlias'] + keyPassword releaseSigningProps['keyPassword'] + enableV1Signing true + enableV2Signing true + } + } + } + defaultConfig { + applicationId "com.hyzq.boss" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 7 + versionName "2.1.0" + buildConfigField "String", "BOSS_API_BASE_URL", "\"https://boss.hyzq.net\"" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + if (hasReleaseSigning) { + signingConfig signingConfigs.release + } + } + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" +} + +try { + def servicesJSON = file('google-services.json') + if (servicesJSON.text) { + apply plugin: 'com.google.gms.google-services' + } +} catch(Exception e) { + logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d52a9b4 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/hyzq/boss/AboutActivity.java b/android/app/src/main/java/com/hyzq/boss/AboutActivity.java new file mode 100644 index 0000000..b32ee97 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/AboutActivity.java @@ -0,0 +1,122 @@ +package com.hyzq.boss; + +import android.content.Intent; +import android.net.Uri; +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 AboutActivity extends BossScreenActivity { + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + configureScreen("关于 / OTA", "原生版本中心"); + reload(); + } + + @Override + protected void reload() { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse settings = apiClient.getSettings(); + BossApiClient.ApiResponse ota = apiClient.getOtaStatus(); + BossApiClient.ApiResponse session = apiClient.getSession(); + if (!settings.ok() || !ota.ok() || !session.ok()) { + throw new IllegalStateException("PROFILE_OR_OTA_LOAD_FAILED"); + } + runOnUiThread(() -> renderAbout( + settings.json.optJSONObject("user"), + ota.json, + session.json.optJSONObject("session") + )); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + replaceContent(BossUi.buildEmptyCard(this, "关于页加载失败:" + error.getMessage())); + }); + } + }); + } + + private void renderAbout(@Nullable JSONObject user, JSONObject ota, @Nullable JSONObject session) { + replaceContent(); + if (user != null) { + appendContent(BossUi.buildCard( + this, + "当前版本", + user.optString("version", "-") + + "\n当前账号:" + user.optString("account", "-") + + "\n绑定 Codex:" + user.optString("boundCodexNodeLabel", "未绑定"), + session == null ? "-" : "会话到期 " + session.optString("expiresAt", "-") + )); + } + + JSONObject availableRelease = ota.optJSONObject("availableRelease"); + String otaBody = availableRelease == null + ? "当前已经是最新版本。" + : availableRelease.optString("version", "未知版本") + + "\n" + availableRelease.optString("summary", "暂无摘要") + + "\n文件:" + availableRelease.optString("packageFileName", "-"); + appendContent(BossUi.buildCard( + this, + "OTA 状态", + otaBody, + "当前版本 " + ota.optString("currentVersion", "-") + )); + + LinearLayout actionCard = BossUi.buildCard(this, "OTA 操作", "可在原生页直接检查更新、登记 OTA 并下载 APK。", "当前接口:/api/v1/user/ota"); + Button check = BossUi.buildPrimaryButton(this, "检查更新"); + check.setOnClickListener(v -> performOtaAction("check")); + actionCard.addView(check); + Button apply = BossUi.buildSecondaryButton(this, "登记应用 OTA"); + apply.setOnClickListener(v -> performOtaAction("apply")); + actionCard.addView(apply); + Button download = BossUi.buildSecondaryButton(this, "下载最新 APK"); + download.setOnClickListener(v -> { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(apiClient.getProtectedOtaPackageUrl())); + startActivity(intent); + }); + actionCard.addView(download); + appendContent(actionCard); + + JSONArray logs = ota.optJSONArray("logs"); + if (logs != null) { + for (int i = 0; i < logs.length(); i++) { + JSONObject log = logs.optJSONObject(i); + if (log == null) continue; + appendContent(BossUi.buildCard( + this, + log.optString("version", "OTA"), + log.optString("summary", ""), + log.optString("status", "-") + " · " + log.optString("createdAt", "-") + )); + } + } + setRefreshing(false); + } + + private void performOtaAction(String action) { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = "check".equals(action) ? apiClient.checkOta() : apiClient.applyOta(); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + showMessage("check".equals(action) ? "已完成版本检查" : "已登记 OTA 应用"); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("OTA 操作失败:" + error.getMessage()); + }); + } + }); + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java b/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java new file mode 100644 index 0000000..946ffc8 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java @@ -0,0 +1,382 @@ +package com.hyzq.boss; + +import android.os.Bundle; +import android.view.View; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.Spinner; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.SwitchCompat; + +import org.json.JSONArray; +import org.json.JSONObject; + +public class AiAccountsActivity extends BossScreenActivity { + private static final String[] ROLE_VALUES = {"primary", "backup", "api_fallback"}; + private static final String[] ROLE_LABELS = {"主 GPT", "备用 GPT", "API 容灾"}; + private static final String[] PROVIDER_VALUES = {"master_codex_node", "openai_api"}; + private static final String[] PROVIDER_LABELS = {"Master Codex Node", "OpenAI API"}; + + private LinearLayout accountList; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + configureScreen("AI 账号", "主 GPT / 备用 GPT / API 容灾"); + setHeaderAction("新增", v -> openAccountEditor(null, null)); + replaceContent(buildIntroCard(), buildAccountListShell()); + reload(); + } + + @Override + protected void reload() { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.getAccounts(); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> renderAccounts(response.json)); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + replaceContent(BossUi.buildEmptyCard(this, "AI 账号加载失败:" + error.getMessage())); + }); + } + }); + } + + private LinearLayout buildIntroCard() { + return BossUi.buildCard( + this, + "账号说明", + "当前页面管理 Boss 的主控 AI 账号。主链路优先使用已绑定电脑上的 Master Codex Node,API 容灾在同页可补充配置。", + "支持新增、编辑、激活、校验和删除" + ); + } + + private LinearLayout buildAccountListShell() { + LinearLayout wrapper = new LinearLayout(this); + wrapper.setOrientation(LinearLayout.VERTICAL); + accountList = new LinearLayout(this); + accountList.setOrientation(LinearLayout.VERTICAL); + wrapper.addView(accountList); + return wrapper; + } + + private void renderAccounts(JSONObject payload) { + JSONArray accounts = payload.optJSONArray("accounts"); + JSONObject activeIdentity = payload.optJSONObject("activeIdentity"); + JSONArray switchHistory = payload.optJSONArray("switchHistory"); + + accountList.removeAllViews(); + replaceContent(buildIntroCard(), buildActiveIdentityCard(activeIdentity), buildAccountsSection(accounts), buildSwitchHistoryCard(switchHistory)); + setRefreshing(false); + } + + private LinearLayout buildActiveIdentityCard(@Nullable JSONObject activeIdentity) { + String body = activeIdentity == null + ? "当前没有可用的主控身份。" + : activeIdentity.optString("label", "AI 账号") + + "\n" + activeIdentity.optString("displayName", "-") + + "\n" + activeIdentity.optString("providerLabel", "-") + + (activeIdentity.optString("nodeLabel").isEmpty() ? "" : "\n节点:" + activeIdentity.optString("nodeLabel")); + String meta = activeIdentity == null + ? "请先配置一个可用账号" + : activeIdentity.optString("roleLabel", "-") + " · " + activeIdentity.optString("statusLabel", "-"); + return BossUi.buildCard(this, "当前主控身份", body, meta); + } + + private LinearLayout buildAccountsSection(@Nullable JSONArray accounts) { + LinearLayout section = new LinearLayout(this); + section.setOrientation(LinearLayout.VERTICAL); + + section.addView(BossUi.buildCard( + this, + "账号列表", + accounts == null || accounts.length() == 0 ? "当前还没有 AI 账号。" : "点击卡片可编辑,按钮可激活 / 校验 / 删除。", + "当前 API:/api/v1/accounts" + )); + + if (accounts == null || accounts.length() == 0) { + section.addView(BossUi.buildEmptyCard(this, "尚未配置任何 AI 账号。")); + return section; + } + + for (int i = 0; i < accounts.length(); i++) { + JSONObject account = accounts.optJSONObject(i); + if (account == null) continue; + section.addView(buildAccountCard(account)); + } + return section; + } + + private LinearLayout buildAccountCard(JSONObject account) { + String statusLabel = account.optString("statusLabel", account.optString("status", "-")); + String meta = account.optString("roleLabel", "-") + + " · " + account.optString("providerLabel", "-") + + " · " + statusLabel + + (account.optBoolean("isActive") ? " · 当前主控" : "") + + (account.optBoolean("apiKeyConfigured") ? " · 已配置 Key" : ""); + String body = account.optString("displayName", "-") + + "\n账号:" + account.optString("accountIdentifier", "-") + + (account.optString("nodeLabel").isEmpty() ? "" : "\n节点:" + account.optString("nodeLabel")) + + (account.optString("loginStatusNote").isEmpty() ? "" : "\n" + account.optString("loginStatusNote")); + + LinearLayout card = BossUi.buildCard( + this, + account.optString("label", "未命名账号"), + body, + meta, + v -> openAccountEditor(account, null) + ); + + Button activate = BossUi.buildPrimaryButton(this, account.optBoolean("isActive") ? "已激活" : "设为当前主控"); + activate.setEnabled(!account.optBoolean("isActive")); + activate.setOnClickListener(v -> activateAccount(account)); + card.addView(activate); + + Button validate = BossUi.buildSecondaryButton(this, "校验连接"); + validate.setOnClickListener(v -> validateAccount(account)); + card.addView(validate); + + Button edit = BossUi.buildSecondaryButton(this, "编辑账号"); + edit.setOnClickListener(v -> openAccountEditor(account, null)); + card.addView(edit); + + Button delete = BossUi.buildSecondaryButton(this, "删除账号"); + delete.setOnClickListener(v -> confirmDeleteAccount(account)); + card.addView(delete); + + return card; + } + + private LinearLayout buildSwitchHistoryCard(@Nullable JSONArray switchHistory) { + LinearLayout section = new LinearLayout(this); + section.setOrientation(LinearLayout.VERTICAL); + section.addView(BossUi.buildCard( + this, + "切换历史", + switchHistory == null || switchHistory.length() == 0 ? "当前没有切换记录。" : "最近切换记录会保留 40 条。", + "用于追踪主控身份变化" + )); + + if (switchHistory == null || switchHistory.length() == 0) { + section.addView(BossUi.buildEmptyCard(this, "当前没有 AI 账号切换历史。")); + return section; + } + + for (int i = 0; i < switchHistory.length(); i++) { + JSONObject record = switchHistory.optJSONObject(i); + if (record == null) continue; + String body = "从 " + record.optString("fromLabel", "无") + + "\n到 " + record.optString("toLabel", "-") + + "\n原因:" + record.optString("reason", "-"); + String meta = record.optString("role", "-") + " · " + record.optString("switchedAt", "-"); + section.addView(BossUi.buildCard(this, "切换记录", body, meta)); + } + return section; + } + + private void openAccountEditor(@Nullable JSONObject existing, @Nullable String apiKeyHint) { + final android.widget.EditText labelInput = BossUi.buildInput(this, "标签,例如 主 GPT", false); + final android.widget.EditText displayNameInput = BossUi.buildInput(this, "显示名称", false); + final android.widget.EditText accountIdentifierInput = BossUi.buildInput(this, "账号标识 / 邮箱 / 登录名", false); + final android.widget.EditText nodeIdInput = BossUi.buildInput(this, "节点 ID", false); + final android.widget.EditText nodeLabelInput = BossUi.buildInput(this, "节点名称", false); + final android.widget.EditText modelInput = BossUi.buildInput(this, "模型,例如 gpt-5.4", false); + final android.widget.EditText apiKeyInput = BossUi.buildInput(this, "API Key", false); + final android.widget.EditText loginStatusInput = BossUi.buildInput(this, "登录状态备注", true); + final Spinner roleSpinner = new Spinner(this); + roleSpinner.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, ROLE_LABELS)); + final Spinner providerSpinner = new Spinner(this); + providerSpinner.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, PROVIDER_LABELS)); + final SwitchCompat enabledSwitch = new SwitchCompat(this); + enabledSwitch.setText("启用"); + enabledSwitch.setChecked(existing == null || existing.optBoolean("enabled", true)); + final SwitchCompat setActiveSwitch = new SwitchCompat(this); + setActiveSwitch.setText("保存后设为当前主控"); + setActiveSwitch.setChecked(existing != null ? existing.optBoolean("isActive") : false); + + if (existing != null) { + labelInput.setText(existing.optString("label", "")); + displayNameInput.setText(existing.optString("displayName", "")); + accountIdentifierInput.setText(existing.optString("accountIdentifier", "")); + nodeIdInput.setText(existing.optString("nodeId", "")); + nodeLabelInput.setText(existing.optString("nodeLabel", "")); + modelInput.setText(existing.optString("model", "")); + loginStatusInput.setText(existing.optString("loginStatusNote", "")); + roleSpinner.setSelection(indexOf(ROLE_VALUES, existing.optString("role", "primary"))); + providerSpinner.setSelection(indexOf(PROVIDER_VALUES, existing.optString("provider", "master_codex_node"))); + } + if (apiKeyHint != null && !apiKeyHint.isEmpty()) { + apiKeyInput.setText(apiKeyHint); + } + + LinearLayout form = new LinearLayout(this); + form.setOrientation(LinearLayout.VERTICAL); + form.addView(labelInput); + form.addView(displayNameInput); + form.addView(accountIdentifierInput); + form.addView(nodeIdInput); + form.addView(nodeLabelInput); + form.addView(modelInput); + form.addView(apiKeyInput); + form.addView(loginStatusInput); + form.addView(roleSpinner); + form.addView(providerSpinner); + form.addView(enabledSwitch); + form.addView(setActiveSwitch); + + new AlertDialog.Builder(this) + .setTitle(existing == null ? "新增 AI 账号" : "编辑 AI 账号") + .setView(form) + .setNegativeButton("取消", null) + .setPositiveButton("保存", (dialog, which) -> saveAccount( + existing, + labelInput.getText().toString().trim(), + displayNameInput.getText().toString().trim(), + accountIdentifierInput.getText().toString().trim(), + nodeIdInput.getText().toString().trim(), + nodeLabelInput.getText().toString().trim(), + modelInput.getText().toString().trim(), + apiKeyInput.getText().toString().trim(), + loginStatusInput.getText().toString().trim(), + enabledSwitch.isChecked(), + setActiveSwitch.isChecked(), + ROLE_VALUES[roleSpinner.getSelectedItemPosition()], + PROVIDER_VALUES[providerSpinner.getSelectedItemPosition()] + )) + .show(); + } + + private void saveAccount( + @Nullable JSONObject existing, + String label, + String displayName, + String accountIdentifier, + String nodeId, + String nodeLabel, + String model, + String apiKey, + String loginStatusNote, + boolean enabled, + boolean setActive, + String role, + String provider + ) { + if (label.isEmpty() || displayName.isEmpty()) { + showMessage("标签和显示名称不能为空"); + return; + } + setRefreshing(true); + executor.execute(() -> { + try { + JSONObject payload = new JSONObject(); + payload.put("label", label); + payload.put("displayName", displayName); + payload.put("accountIdentifier", accountIdentifier); + payload.put("nodeId", nodeId); + payload.put("nodeLabel", nodeLabel); + payload.put("model", model); + payload.put("apiKey", apiKey); + payload.put("loginStatusNote", loginStatusNote); + payload.put("enabled", enabled); + payload.put("setActive", setActive); + payload.put("role", role); + payload.put("provider", provider); + + BossApiClient.ApiResponse response = existing == null + ? apiClient.createAccount(payload) + : apiClient.updateAccount(existing.optString("accountId"), payload); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + showMessage(existing == null ? "AI 账号已新增" : "AI 账号已更新"); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("保存失败:" + error.getMessage()); + }); + } + }); + } + + private int indexOf(String[] values, String target) { + for (int i = 0; i < values.length; i++) { + if (values[i].equals(target)) { + return i; + } + } + return 0; + } + + private void activateAccount(JSONObject account) { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.activateAccount(account.optString("accountId"), "原生页面手动切换"); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + showMessage("已切换当前主控"); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("切换失败:" + error.getMessage()); + }); + } + }); + } + + private void validateAccount(JSONObject account) { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.validateAccount(account.optString("accountId")); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + showMessage("账号校验成功"); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("校验失败:" + error.getMessage()); + }); + } + }); + } + + private void confirmDeleteAccount(JSONObject account) { + new AlertDialog.Builder(this) + .setTitle("删除 AI 账号") + .setMessage("确认删除 " + account.optString("label", "该账号") + " 吗?") + .setNegativeButton("取消", null) + .setPositiveButton("删除", (dialog, which) -> deleteAccount(account)) + .show(); + } + + private void deleteAccount(JSONObject account) { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.deleteAccount(account.optString("accountId")); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + showMessage("AI 账号已删除"); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("删除失败:" + error.getMessage()); + }); + } + }); + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java new file mode 100644 index 0000000..fec7ca9 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -0,0 +1,375 @@ +package com.hyzq.boss; + +import android.content.Context; +import android.content.SharedPreferences; +import android.net.Uri; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +public class BossApiClient { + private static final String PREFS_NAME = "boss_native_client"; + private static final String KEY_SESSION_COOKIE = "session_cookie"; + private static final String KEY_RESTORE_TOKEN = "restore_token"; + private static final String KEY_ACCOUNT = "account"; + private static final String KEY_DISPLAY_NAME = "display_name"; + + private final SharedPreferences prefs; + private final String baseUrl; + + public BossApiClient(Context context) { + this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + this.baseUrl = BuildConfig.BOSS_API_BASE_URL; + } + + public boolean hasSessionHints() { + return !getSessionCookie().isEmpty() || !getRestoreToken().isEmpty(); + } + + public ApiResponse autoLogin() throws IOException, JSONException { + ApiResponse response = request("POST", "/api/auth/login", new JSONObject(), false); + if (response.ok()) { + rememberIdentity(response.json); + } + return response; + } + + public ApiResponse restoreSession() throws IOException, JSONException { + if (getRestoreToken().isEmpty()) { + return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_RESTORE_TOKEN")); + } + JSONObject body = new JSONObject(); + body.put("restoreToken", getRestoreToken()); + ApiResponse response = request("POST", "/api/auth/restore", body, false); + if (response.ok()) { + rememberIdentity(response.json); + } + return response; + } + + public ApiResponse getSession() throws IOException, JSONException { + return request("GET", "/api/auth/session", null, true); + } + + public ApiResponse getConversations() throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/conversations", null); + } + + public ApiResponse getProjectDetail(String projectId) throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null); + } + + public ApiResponse sendProjectMessage(String projectId, String body, String kind) throws IOException, JSONException { + JSONObject payload = new JSONObject(); + payload.put("body", body); + payload.put("kind", kind); + return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/messages", payload); + } + + public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, String note) throws IOException, JSONException { + JSONObject payload = new JSONObject(); + payload.put("targetProjectId", targetProjectId); + payload.put("note", note); + return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/forwards", payload); + } + + public ApiResponse getThreadDetail(String threadId) throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/threads/" + encode(threadId) + "/context-budget", null); + } + + public ApiResponse toggleGoal(String projectId, String goalId) throws IOException, JSONException { + return requestWithRestore("POST", "/api/projects/" + encode(projectId) + "/goals/" + encode(goalId) + "/toggle", new JSONObject()); + } + + public ApiResponse createGoal(String projectId, String text) throws IOException, JSONException { + JSONObject payload = new JSONObject(); + payload.put("action", "create"); + payload.put("text", text); + return requestWithRestore("POST", "/api/projects/" + encode(projectId) + "/goals/update", payload); + } + + public ApiResponse updateGoal(String projectId, String goalId, String text) throws IOException, JSONException { + JSONObject payload = new JSONObject(); + payload.put("action", "update"); + payload.put("goalId", goalId); + payload.put("text", text); + return requestWithRestore("POST", "/api/projects/" + encode(projectId) + "/goals/update", payload); + } + + public ApiResponse getDevices() throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/devices", null); + } + + public ApiResponse getDeviceDetail(String deviceId) throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/devices?device=" + encode(deviceId), null); + } + + public ApiResponse updateDevice(String deviceId, JSONObject payload) throws IOException, JSONException { + return requestWithRestore("PATCH", "/api/v1/devices/" + encode(deviceId), payload); + } + + public ApiResponse getDeviceSkills(String deviceId) throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/devices/" + encode(deviceId) + "/skills", null); + } + + public ApiResponse getDeviceEnrollments() throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/devices/enrollments", null); + } + + public ApiResponse createDeviceEnrollment(JSONObject payload) throws IOException, JSONException { + return requestWithRestore("POST", "/api/v1/devices/enrollments", payload); + } + + public ApiResponse getAccounts() throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/accounts", null); + } + + public ApiResponse createAccount(JSONObject payload) throws IOException, JSONException { + return requestWithRestore("POST", "/api/v1/accounts", payload); + } + + public ApiResponse updateAccount(String accountId, JSONObject payload) throws IOException, JSONException { + return requestWithRestore("PATCH", "/api/v1/accounts/" + encode(accountId), payload); + } + + public ApiResponse deleteAccount(String accountId) throws IOException, JSONException { + return requestWithRestore("DELETE", "/api/v1/accounts/" + encode(accountId), null); + } + + public ApiResponse activateAccount(String accountId, String reason) throws IOException, JSONException { + JSONObject payload = new JSONObject(); + payload.put("reason", reason); + return requestWithRestore("POST", "/api/v1/accounts/" + encode(accountId) + "/activate", payload); + } + + public ApiResponse validateAccount(String accountId) throws IOException, JSONException { + return requestWithRestore("POST", "/api/v1/accounts/" + encode(accountId) + "/validate", new JSONObject()); + } + + public ApiResponse getOpsSummary() throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/ops/summary", null); + } + + public ApiResponse approveRepairTicket(String ticketId) throws IOException, JSONException { + return requestWithRestore("POST", "/api/v1/ops/repair-tickets/" + encode(ticketId) + "/approve", new JSONObject()); + } + + public ApiResponse verifyRepairTicket(String ticketId) throws IOException, JSONException { + return requestWithRestore("POST", "/api/v1/ops/repair-tickets/" + encode(ticketId) + "/verify", new JSONObject()); + } + + public ApiResponse getAuditSummary() throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/audits/summary", null); + } + + public ApiResponse getSettings() throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/settings", null); + } + + public ApiResponse updateSettings(JSONObject payload) throws IOException, JSONException { + return requestWithRestore("POST", "/api/v1/settings", payload); + } + + public ApiResponse getOtaStatus() throws IOException, JSONException { + return requestWithRestore("GET", "/api/v1/user/ota", null); + } + + public ApiResponse checkOta() throws IOException, JSONException { + JSONObject payload = new JSONObject(); + payload.put("action", "check"); + return requestWithRestore("POST", "/api/v1/user/ota", payload); + } + + public ApiResponse applyOta() throws IOException, JSONException { + JSONObject payload = new JSONObject(); + payload.put("action", "apply"); + return requestWithRestore("POST", "/api/v1/user/ota", payload); + } + + public ApiResponse logout() throws IOException, JSONException { + ApiResponse response = request("POST", "/api/auth/logout", new JSONObject(), false); + clearSession(); + return response; + } + + public String getAccountLabel() { + return prefs.getString(KEY_ACCOUNT, "17600003315"); + } + + public String getDisplayName() { + return prefs.getString(KEY_DISPLAY_NAME, "Boss 超级管理员"); + } + + public String getRestoreToken() { + return prefs.getString(KEY_RESTORE_TOKEN, ""); + } + + public String getSessionCookie() { + return prefs.getString(KEY_SESSION_COOKIE, ""); + } + + public String getBaseUrl() { + return baseUrl; + } + + public String getProtectedOtaPackageUrl() { + return baseUrl + "/api/v1/user/ota/package"; + } + + private ApiResponse requestWithRestore(String method, String path, JSONObject body) throws IOException, JSONException { + ApiResponse response = request(method, path, body, true); + if (response.statusCode == 401 && !getRestoreToken().isEmpty()) { + ApiResponse restored = restoreSession(); + if (restored.ok()) { + return request(method, path, body, true); + } + } + return response; + } + + private ApiResponse request(String method, String path, JSONObject body, boolean expectProtected) throws IOException, JSONException { + HttpURLConnection connection = (HttpURLConnection) new URL(baseUrl + path).openConnection(); + connection.setRequestMethod(method); + connection.setConnectTimeout(12000); + connection.setReadTimeout(12000); + connection.setUseCaches(false); + connection.setDoInput(true); + connection.setRequestProperty("Accept", "application/json"); + connection.setRequestProperty("x-boss-native-app", "1"); + + String cookie = getSessionCookie(); + if (!cookie.isEmpty()) { + connection.setRequestProperty("Cookie", cookie); + } + + if (body != null) { + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/json"); + try (OutputStream outputStream = connection.getOutputStream(); + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) { + writer.write(body.toString()); + } + } + + int statusCode = connection.getResponseCode(); + captureSessionCookie(connection.getHeaderFields()); + JSONObject json = readJson(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream()); + + if (statusCode == 401 && !expectProtected) { + clearSession(); + } + if (json != null) { + rememberIdentity(json); + } + + return new ApiResponse(statusCode, json == null ? new JSONObject() : json); + } + + private JSONObject readJson(InputStream stream) throws IOException, JSONException { + if (stream == null) { + return new JSONObject(); + } + StringBuilder builder = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + builder.append(line); + } + } + String raw = builder.toString().trim(); + if (raw.isEmpty()) { + return new JSONObject(); + } + return new JSONObject(raw); + } + + private void captureSessionCookie(Map> headers) { + if (headers == null) return; + List setCookieHeaders = headers.get("Set-Cookie"); + if (setCookieHeaders == null) { + setCookieHeaders = headers.get("set-cookie"); + } + if (setCookieHeaders == null) return; + + for (String header : setCookieHeaders) { + if (header == null || !header.startsWith("boss_session=")) continue; + String cookiePair = header.split(";", 2)[0]; + if (header.contains("Max-Age=0")) { + clearSession(); + return; + } + prefs.edit().putString(KEY_SESSION_COOKIE, cookiePair).apply(); + return; + } + } + + private void rememberIdentity(JSONObject json) { + if (json == null) return; + JSONObject session = json.optJSONObject("session"); + JSONObject source = session != null ? session : json; + SharedPreferences.Editor editor = prefs.edit(); + + String restoreToken = source.optString("restoreToken", ""); + if (!restoreToken.isEmpty()) { + editor.putString(KEY_RESTORE_TOKEN, restoreToken); + } + + String account = source.optString("account", ""); + if (!account.isEmpty()) { + editor.putString(KEY_ACCOUNT, account); + } + + String displayName = source.optString("displayName", ""); + if (!displayName.isEmpty()) { + editor.putString(KEY_DISPLAY_NAME, displayName); + } + + editor.apply(); + } + + private void clearSession() { + prefs.edit() + .remove(KEY_SESSION_COOKIE) + .remove(KEY_RESTORE_TOKEN) + .apply(); + } + + private String encode(String value) { + return Uri.encode(value); + } + + public static class ApiResponse { + public final int statusCode; + public final JSONObject json; + + public ApiResponse(int statusCode, JSONObject json) { + this.statusCode = statusCode; + this.json = json; + } + + public boolean ok() { + return statusCode >= 200 && statusCode < 300 && json.optBoolean("ok", false); + } + + public String message() { + return json.optString("message", "UNKNOWN"); + } + + public static ApiResponse error(int statusCode, JSONObject json) { + return new ApiResponse(statusCode, json); + } + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/BossScreenActivity.java b/android/app/src/main/java/com/hyzq/boss/BossScreenActivity.java new file mode 100644 index 0000000..bd5c5a8 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/BossScreenActivity.java @@ -0,0 +1,91 @@ +package com.hyzq.boss; + +import android.os.Bundle; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public abstract class BossScreenActivity extends AppCompatActivity { + protected final ExecutorService executor = Executors.newSingleThreadExecutor(); + + protected BossApiClient apiClient; + protected Button backButton; + protected Button refreshButton; + protected Button headerActionButton; + protected TextView titleView; + protected TextView subtitleView; + protected SwipeRefreshLayout refreshLayout; + protected LinearLayout contentLayout; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_screen); + apiClient = new BossApiClient(this); + + backButton = findViewById(R.id.screen_back_button); + refreshButton = findViewById(R.id.screen_refresh_button); + headerActionButton = findViewById(R.id.screen_header_action); + titleView = findViewById(R.id.screen_title); + subtitleView = findViewById(R.id.screen_subtitle); + refreshLayout = findViewById(R.id.screen_refresh_layout); + contentLayout = findViewById(R.id.screen_content); + + backButton.setOnClickListener(v -> finish()); + refreshButton.setOnClickListener(v -> reload()); + refreshLayout.setOnRefreshListener(this::reload); + } + + @Override + protected void onDestroy() { + executor.shutdownNow(); + super.onDestroy(); + } + + protected void configureScreen(String title, String subtitle) { + titleView.setText(title); + subtitleView.setText(subtitle == null || subtitle.isEmpty() ? "原生页面" : subtitle); + } + + protected void setHeaderAction(String label, android.view.View.OnClickListener listener) { + headerActionButton.setVisibility(android.view.View.VISIBLE); + headerActionButton.setText(label); + headerActionButton.setOnClickListener(listener); + } + + protected void hideHeaderAction() { + headerActionButton.setVisibility(android.view.View.GONE); + headerActionButton.setOnClickListener(null); + } + + protected void setRefreshing(boolean refreshing) { + refreshLayout.setRefreshing(refreshing); + refreshButton.setEnabled(!refreshing); + refreshButton.setText(refreshing ? "同步中" : "刷新"); + } + + protected void replaceContent(android.view.View... views) { + contentLayout.removeAllViews(); + for (android.view.View view : views) { + contentLayout.addView(view); + } + } + + protected void appendContent(android.view.View view) { + contentLayout.addView(view); + } + + protected void showMessage(String text) { + Toast.makeText(this, text, Toast.LENGTH_SHORT).show(); + } + + protected abstract void reload(); +} diff --git a/android/app/src/main/java/com/hyzq/boss/BossUi.java b/android/app/src/main/java/com/hyzq/boss/BossUi.java new file mode 100644 index 0000000..2b70ca4 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/BossUi.java @@ -0,0 +1,177 @@ +package com.hyzq.boss; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.graphics.Typeface; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.Nullable; + +public final class BossUi { + private BossUi() {} + + public static LinearLayout buildCard(Context context, String title, String body, String meta) { + return buildCard(context, title, body, meta, null); + } + + public static LinearLayout buildCard( + Context context, + String title, + String body, + String meta, + @Nullable View.OnClickListener listener + ) { + LinearLayout card = new LinearLayout(context); + card.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.bottomMargin = dp(context, 12); + card.setLayoutParams(params); + card.setPadding(dp(context, 18), dp(context, 18), dp(context, 18), dp(context, 18)); + card.setBackgroundResource(R.drawable.bg_card); + if (listener != null) { + card.setClickable(true); + card.setFocusable(true); + card.setOnClickListener(listener); + } + + TextView titleView = new TextView(context); + titleView.setText(title); + titleView.setTextSize(18); + titleView.setTypeface(Typeface.DEFAULT_BOLD); + titleView.setTextColor(context.getColor(R.color.boss_text_primary)); + + TextView bodyView = new TextView(context); + bodyView.setText(body); + bodyView.setTextSize(14); + bodyView.setTextColor(context.getColor(R.color.boss_text_primary)); + bodyView.setPadding(0, dp(context, 8), 0, 0); + + TextView metaView = new TextView(context); + metaView.setText(meta); + metaView.setTextSize(12); + metaView.setTextColor(context.getColor(R.color.boss_text_muted)); + metaView.setPadding(0, dp(context, 10), 0, 0); + + card.addView(titleView); + card.addView(bodyView); + card.addView(metaView); + return card; + } + + public static LinearLayout buildMenuRow( + Context context, + String title, + String description, + @Nullable String badge, + View.OnClickListener listener + ) { + LinearLayout row = buildCard(context, title, description, badge == null ? "点击进入" : badge, listener); + row.setOrientation(LinearLayout.HORIZONTAL); + row.removeAllViews(); + + LinearLayout textWrap = new LinearLayout(context); + textWrap.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1f + ); + textWrap.setLayoutParams(textParams); + + TextView titleView = new TextView(context); + titleView.setText(title); + titleView.setTextSize(17); + titleView.setTypeface(Typeface.DEFAULT_BOLD); + titleView.setTextColor(context.getColor(R.color.boss_text_primary)); + + TextView descView = new TextView(context); + descView.setText(description); + descView.setTextSize(13); + descView.setTextColor(context.getColor(R.color.boss_text_muted)); + descView.setPadding(0, dp(context, 6), 0, 0); + + textWrap.addView(titleView); + textWrap.addView(descView); + + TextView accessory = new TextView(context); + accessory.setText(badge == null ? "›" : badge + " ›"); + accessory.setTextSize(13); + accessory.setTextColor(context.getColor(R.color.boss_green)); + + row.addView(textWrap); + row.addView(accessory); + return row; + } + + public static LinearLayout buildEmptyCard(Context context, String text) { + return buildCard(context, "暂无内容", text, "下拉或点击顶部刷新按钮重试。"); + } + + public static Button buildPrimaryButton(Context context, String label) { + Button button = new Button(context); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.topMargin = dp(context, 12); + button.setLayoutParams(params); + button.setText(label); + button.setTextColor(context.getColor(R.color.boss_surface)); + button.setBackgroundResource(R.drawable.bg_primary_button); + button.setPadding(dp(context, 14), dp(context, 12), dp(context, 14), dp(context, 12)); + button.setAllCaps(false); + return button; + } + + public static Button buildSecondaryButton(Context context, String label) { + Button button = new Button(context); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.topMargin = dp(context, 10); + button.setLayoutParams(params); + button.setText(label); + button.setTextColor(context.getColor(R.color.boss_green)); + button.setBackgroundResource(R.drawable.bg_secondary_button); + button.setPadding(dp(context, 14), dp(context, 12), dp(context, 14), dp(context, 12)); + button.setAllCaps(false); + return button; + } + + public static EditText buildInput(Context context, String hint, boolean multiline) { + EditText input = new EditText(context); + input.setHint(hint); + input.setTextColor(context.getColor(R.color.boss_text_primary)); + input.setHintTextColor(context.getColor(R.color.boss_text_muted)); + input.setBackgroundResource(R.drawable.bg_secondary_button); + input.setPadding(dp(context, 14), dp(context, 12), dp(context, 14), dp(context, 12)); + input.setSingleLine(!multiline); + if (multiline) { + input.setMinLines(3); + input.setMaxLines(6); + } + return input; + } + + public static void copyText(Context context, String label, String text) { + ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboard != null) { + clipboard.setPrimaryClip(ClipData.newPlainText(label, text)); + Toast.makeText(context, label + " 已复制", Toast.LENGTH_SHORT).show(); + } + } + + public static int dp(Context context, int value) { + return Math.round(value * context.getResources().getDisplayMetrics().density); + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java new file mode 100644 index 0000000..27da0f8 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java @@ -0,0 +1,211 @@ +package com.hyzq.boss; + +import android.content.Intent; +import android.os.Bundle; +import android.widget.Button; +import android.widget.LinearLayout; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import org.json.JSONArray; +import org.json.JSONObject; + +public class DeviceDetailActivity extends BossScreenActivity { + public static final String EXTRA_DEVICE_ID = "device_id"; + public static final String EXTRA_DEVICE_NAME = "device_name"; + + private String deviceId; + private String deviceName; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + deviceId = getIntent().getStringExtra(EXTRA_DEVICE_ID); + deviceName = getIntent().getStringExtra(EXTRA_DEVICE_NAME); + configureScreen(deviceName == null ? "设备详情" : deviceName, "原生设备详情"); + setHeaderAction("编辑", v -> openEditDialog()); + reload(); + } + + @Override + protected void reload() { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.getDeviceDetail(deviceId); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> renderDevice(response.json)); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + replaceContent(BossUi.buildEmptyCard(this, "设备详情加载失败:" + error.getMessage())); + }); + } + }); + } + + private void renderDevice(JSONObject payload) { + JSONObject workspace = payload.optJSONObject("workspace"); + JSONObject device = workspace == null ? null : workspace.optJSONObject("selectedDevice"); + JSONArray relatedThreads = workspace == null ? null : workspace.optJSONArray("relatedThreads"); + JSONObject enrollment = workspace == null ? null : workspace.optJSONObject("activeEnrollment"); + + replaceContent(); + if (device == null) { + appendContent(BossUi.buildEmptyCard(this, "设备不存在。")); + setRefreshing(false); + return; + } + + deviceName = device.optString("name", deviceId); + configureScreen(deviceName, device.optString("endpoint", "设备详情")); + appendContent(BossUi.buildCard( + this, + device.optString("name", "设备"), + device.optString("note", "暂无备注"), + "状态 " + device.optString("status", "unknown") + + " · 账号 " + device.optString("account", "-") + + " · 5h " + device.optInt("quota5h", 0) + + " · 7d " + device.optInt("quota7d", 0) + )); + + Button skillsButton = BossUi.buildPrimaryButton(this, "查看技能"); + skillsButton.setOnClickListener(v -> openSkills()); + appendContent(skillsButton); + + if (relatedThreads != null && relatedThreads.length() > 0) { + for (int i = 0; i < relatedThreads.length(); i++) { + JSONObject thread = relatedThreads.optJSONObject(i); + if (thread == null) continue; + appendContent(BossUi.buildCard( + this, + thread.optString("title", "线程"), + thread.optString("summary", ""), + thread.optString("workerId", "-") + + " · " + thread.optInt("contextBudgetRemainingPct", 0) + "%" + + " · " + thread.optString("contextBudgetLevel", "safe"), + v -> openThread(thread.optString("threadId")) + )); + } + } + + if (enrollment != null) { + appendContent(BossUi.buildCard( + this, + "当前绑定草稿", + "pairingCode " + enrollment.optString("pairingCode", "-") + + "\ntoken " + enrollment.optString("token", "-"), + enrollment.optString("status", "ready") + + " · 到期 " + enrollment.optString("expiresAt", "-") + )); + } + setRefreshing(false); + } + + private void openThread(String threadId) { + Intent intent = new Intent(this, ThreadDetailActivity.class); + intent.putExtra(ThreadDetailActivity.EXTRA_THREAD_ID, threadId); + startActivity(intent); + } + + private void openSkills() { + Intent intent = new Intent(this, SkillInventoryActivity.class); + intent.putExtra(SkillInventoryActivity.EXTRA_DEVICE_ID, deviceId); + intent.putExtra(SkillInventoryActivity.EXTRA_DEVICE_NAME, deviceName); + startActivity(intent); + } + + private void openEditDialog() { + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.getDeviceDetail(deviceId); + if (!response.ok()) throw new IllegalStateException(response.message()); + JSONObject workspace = response.json.optJSONObject("workspace"); + JSONObject device = workspace == null ? null : workspace.optJSONObject("selectedDevice"); + if (device == null) throw new IllegalStateException("DEVICE_NOT_FOUND"); + runOnUiThread(() -> showEditForm(device)); + } catch (Exception error) { + runOnUiThread(() -> showMessage("读取设备失败:" + error.getMessage())); + } + }); + } + + private void showEditForm(JSONObject device) { + LinearLayout form = new LinearLayout(this); + form.setOrientation(LinearLayout.VERTICAL); + form.setPadding(0, 0, 0, 0); + + final android.widget.EditText nameInput = BossUi.buildInput(this, "设备名", false); + nameInput.setText(device.optString("name", "")); + final android.widget.EditText avatarInput = BossUi.buildInput(this, "头像字符", false); + avatarInput.setText(device.optString("avatar", "")); + final android.widget.EditText endpointInput = BossUi.buildInput(this, "endpoint", false); + endpointInput.setText(device.optString("endpoint", "")); + final android.widget.EditText noteInput = BossUi.buildInput(this, "备注", true); + noteInput.setText(device.optString("note", "")); + final android.widget.EditText projectsInput = BossUi.buildInput(this, "项目列表,逗号分隔", true); + projectsInput.setText(joinArray(device.optJSONArray("projects"))); + + form.addView(nameInput); + form.addView(avatarInput); + form.addView(endpointInput); + form.addView(noteInput); + form.addView(projectsInput); + + new AlertDialog.Builder(this) + .setTitle("编辑设备") + .setView(form) + .setNegativeButton("取消", null) + .setPositiveButton("保存", (dialog, which) -> saveDevice( + nameInput.getText().toString().trim(), + avatarInput.getText().toString().trim(), + endpointInput.getText().toString().trim(), + noteInput.getText().toString().trim(), + projectsInput.getText().toString().trim() + )) + .show(); + } + + private void saveDevice(String name, String avatar, String endpoint, String note, String projectsText) { + setRefreshing(true); + executor.execute(() -> { + try { + JSONObject payload = new JSONObject(); + payload.put("name", name); + payload.put("avatar", avatar); + payload.put("endpoint", endpoint); + payload.put("note", note); + JSONArray projects = new JSONArray(); + for (String item : projectsText.split(",")) { + String trimmed = item.trim(); + if (!trimmed.isEmpty()) projects.put(trimmed); + } + payload.put("projects", projects); + BossApiClient.ApiResponse response = apiClient.updateDevice(deviceId, payload); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + showMessage("设备已更新"); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("保存失败:" + error.getMessage()); + }); + } + }); + } + + private String joinArray(@Nullable JSONArray values) { + if (values == null || values.length() == 0) return ""; + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < values.length(); i++) { + String value = values.optString(i); + if (value == null || value.isEmpty()) continue; + if (builder.length() > 0) builder.append(", "); + builder.append(value); + } + return builder.toString(); + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/DeviceEnrollmentActivity.java b/android/app/src/main/java/com/hyzq/boss/DeviceEnrollmentActivity.java new file mode 100644 index 0000000..3613655 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/DeviceEnrollmentActivity.java @@ -0,0 +1,103 @@ +package com.hyzq.boss; + +import android.os.Bundle; +import android.widget.EditText; + +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONObject; + +public class DeviceEnrollmentActivity extends BossScreenActivity { + private EditText nameInput; + private EditText avatarInput; + private EditText accountInput; + private EditText endpointInput; + private EditText noteInput; + private EditText projectsInput; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + configureScreen("添加设备", "通过 pairing code 或 token 把新设备接入"); + hideHeaderAction(); + buildForm(); + } + + @Override + protected void reload() { + // static form + } + + private void buildForm() { + nameInput = BossUi.buildInput(this, "设备名,例如 Mac Studio", false); + avatarInput = BossUi.buildInput(this, "头像字符,例如 M", false); + accountInput = BossUi.buildInput(this, "账号", false); + accountInput.setText(apiClient.getAccountLabel()); + endpointInput = BossUi.buildInput(this, "endpoint,例如 mac://kris.local", false); + noteInput = BossUi.buildInput(this, "备注", true); + projectsInput = BossUi.buildInput(this, "项目列表,逗号分隔", true); + + replaceContent( + BossUi.buildCard( + this, + "绑定新设备", + "支持通过 pairing code、临时 token 或登录引导把 Mac、Windows、云端节点接入。", + "当前原生页会直接调用 /api/v1/devices/enrollments" + ), + nameInput, + avatarInput, + accountInput, + endpointInput, + noteInput, + projectsInput, + BossUi.buildPrimaryButton(this, "生成绑定草稿") + ); + ((android.widget.Button) contentLayout.getChildAt(contentLayout.getChildCount() - 1)) + .setOnClickListener(v -> submitEnrollment()); + } + + private void submitEnrollment() { + setRefreshing(true); + executor.execute(() -> { + try { + JSONObject payload = new JSONObject(); + payload.put("name", nameInput.getText().toString().trim()); + payload.put("avatar", avatarInput.getText().toString().trim()); + payload.put("account", accountInput.getText().toString().trim()); + payload.put("endpoint", endpointInput.getText().toString().trim()); + payload.put("note", noteInput.getText().toString().trim()); + JSONArray projects = new JSONArray(); + for (String item : projectsInput.getText().toString().split(",")) { + String trimmed = item.trim(); + if (!trimmed.isEmpty()) projects.put(trimmed); + } + payload.put("projects", projects); + + BossApiClient.ApiResponse response = apiClient.createDeviceEnrollment(payload); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + JSONObject enrollment = response.json.optJSONObject("enrollment"); + JSONObject device = response.json.optJSONObject("device"); + replaceContent( + BossUi.buildCard( + this, + "绑定草稿已生成", + "设备 " + (device == null ? "-" : device.optString("name", "-")) + + "\npairingCode " + (enrollment == null ? "-" : enrollment.optString("pairingCode", "-")) + + "\ntoken " + (enrollment == null ? "-" : enrollment.optString("token", "-")), + enrollment == null ? "ready" : enrollment.optString("status", "ready") + + " · 到期 " + enrollment.optString("expiresAt", "-") + ) + ); + setRefreshing(false); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("创建失败:" + error.getMessage()); + }); + } + }); + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/MainActivity.java b/android/app/src/main/java/com/hyzq/boss/MainActivity.java new file mode 100644 index 0000000..30be0a8 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java @@ -0,0 +1,468 @@ +package com.hyzq.boss; + +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.IOException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class MainActivity extends AppCompatActivity { + public static final String EXTRA_INITIAL_TAB = "initial_tab"; + + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + + private BossApiClient apiClient; + + private View loginPanel; + private View contentPanel; + private TextView loginHint; + private Button loginButton; + private ProgressBar loginProgress; + + private Button backButton; + private TextView topTitle; + private TextView topSubtitle; + private Button refreshButton; + private Button tabConversations; + private Button tabDevices; + private Button tabMe; + private SwipeRefreshLayout screenRefresh; + private LinearLayout screenContent; + + private String activeTab = "conversations"; + private @Nullable JSONObject sessionData; + private @Nullable JSONObject otaData; + private @Nullable JSONArray conversationsData; + private @Nullable JSONArray devicesData; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + apiClient = new BossApiClient(this); + bindViews(); + bindActions(); + applyInitialTab(getIntent()); + bootstrapSession(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + applyInitialTab(intent); + if (contentPanel.getVisibility() == View.VISIBLE) { + renderCurrentTab(); + } + } + + @Override + protected void onDestroy() { + executor.shutdownNow(); + super.onDestroy(); + } + + private void bindViews() { + loginPanel = findViewById(R.id.login_panel); + contentPanel = findViewById(R.id.content_panel); + loginHint = findViewById(R.id.login_hint); + loginButton = findViewById(R.id.login_button); + loginProgress = findViewById(R.id.login_progress); + backButton = findViewById(R.id.back_button); + topTitle = findViewById(R.id.top_title); + topSubtitle = findViewById(R.id.top_subtitle); + refreshButton = findViewById(R.id.refresh_button); + tabConversations = findViewById(R.id.tab_conversations); + tabDevices = findViewById(R.id.tab_devices); + tabMe = findViewById(R.id.tab_me); + screenRefresh = findViewById(R.id.screen_refresh); + screenContent = findViewById(R.id.screen_content); + } + + private void bindActions() { + loginButton.setOnClickListener(v -> performAutoLogin()); + backButton.setVisibility(View.GONE); + refreshButton.setOnClickListener(v -> refreshCurrentTab()); + tabConversations.setOnClickListener(v -> switchTab("conversations")); + tabDevices.setOnClickListener(v -> switchTab("devices")); + tabMe.setOnClickListener(v -> switchTab("me")); + screenRefresh.setOnRefreshListener(this::refreshCurrentTab); + } + + private void applyInitialTab(@Nullable Intent intent) { + String requested = intent == null ? null : intent.getStringExtra(EXTRA_INITIAL_TAB); + if ("devices".equals(requested) || "me".equals(requested) || "conversations".equals(requested)) { + activeTab = requested; + } + } + + private void bootstrapSession() { + showLogin("原生 Android 客户端已启用。点击下方按钮直接进入系统。"); + if (!apiClient.hasSessionHints()) { + return; + } + + setLoginLoading(true, "正在恢复上次登录状态..."); + executor.execute(() -> { + try { + BossApiClient.ApiResponse sessionResponse = apiClient.getSession(); + if (!sessionResponse.ok()) { + sessionResponse = apiClient.restoreSession(); + } + if (sessionResponse.ok()) { + JSONObject session = sessionResponse.json.optJSONObject("session"); + runOnUiThread(() -> { + showContent(); + refreshAllData(session); + }); + return; + } + } catch (Exception ignored) { + // Fall back to login panel. + } + runOnUiThread(() -> setLoginLoading(false, "点击登录后会直接进入系统。")); + }); + } + + private void performAutoLogin() { + setLoginLoading(true, "正在创建会话..."); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.autoLogin(); + if (response.ok()) { + JSONObject session = response.json.optJSONObject("session"); + runOnUiThread(() -> { + showContent(); + refreshAllData(session); + }); + return; + } + runOnUiThread(() -> setLoginLoading(false, "登录失败:" + response.message())); + } catch (Exception error) { + runOnUiThread(() -> setLoginLoading(false, "登录链路异常:" + error.getMessage())); + } + }); + } + + private void refreshCurrentTab() { + refreshAllData(sessionData); + } + + private void refreshAllData(@Nullable JSONObject initialSession) { + startRefreshing(true); + topSubtitle.setText("正在同步最新数据..."); + executor.execute(() -> { + try { + JSONObject session = initialSession; + if (session == null) { + BossApiClient.ApiResponse sessionResponse = apiClient.getSession(); + if (!sessionResponse.ok()) { + sessionResponse = apiClient.restoreSession(); + } + if (!sessionResponse.ok()) { + throw new IOException("SESSION_UNAVAILABLE"); + } + session = sessionResponse.json.optJSONObject("session"); + } + + BossApiClient.ApiResponse conversations = apiClient.getConversations(); + BossApiClient.ApiResponse devices = apiClient.getDevices(); + BossApiClient.ApiResponse ota = apiClient.getOtaStatus(); + if (!conversations.ok() || !devices.ok() || !ota.ok()) { + throw new IOException("API_REFRESH_FAILED"); + } + + JSONObject finalSession = session; + runOnUiThread(() -> { + sessionData = finalSession; + conversationsData = conversations.json.optJSONArray("conversations"); + devicesData = devices.json.optJSONArray("devices"); + otaData = ota.json; + renderCurrentTab(); + startRefreshing(false); + }); + } catch (Exception error) { + runOnUiThread(() -> { + startRefreshing(false); + sessionData = null; + conversationsData = null; + devicesData = null; + otaData = null; + showLogin("当前登录已失效或同步失败,请重新点击登录。"); + }); + } + }); + } + + private void showLogin(String hint) { + loginPanel.setVisibility(View.VISIBLE); + contentPanel.setVisibility(View.GONE); + setLoginLoading(false, hint); + } + + private void showContent() { + loginPanel.setVisibility(View.GONE); + contentPanel.setVisibility(View.VISIBLE); + switchTab(activeTab); + } + + private void setLoginLoading(boolean loading, String hint) { + loginProgress.setVisibility(loading ? View.VISIBLE : View.GONE); + loginButton.setEnabled(!loading); + loginButton.setText(loading ? "处理中..." : "登录"); + loginHint.setText(hint); + } + + private void switchTab(String tab) { + activeTab = tab; + updateTabStyles(); + renderCurrentTab(); + } + + private void renderCurrentTab() { + if (contentPanel.getVisibility() != View.VISIBLE) { + return; + } + + switch (activeTab) { + case "devices": + updateHeader("设备", "只展示当前正式接入生产链路的设备。"); + renderDevicesRoot(); + break; + case "me": + updateHeader("我的", "账号、安全、技能、运维、OTA 都从这里进入。"); + renderMeRoot(); + break; + case "conversations": + default: + updateHeader("会话", "原生会话列表直接消费 /api/v1/conversations。"); + renderConversationsRoot(); + break; + } + } + + private void updateHeader(String title, String subtitle) { + topTitle.setText(title); + topSubtitle.setText(subtitle); + } + + private void updateTabStyles() { + styleTab(tabConversations, "conversations".equals(activeTab)); + styleTab(tabDevices, "devices".equals(activeTab)); + styleTab(tabMe, "me".equals(activeTab)); + } + + private void styleTab(Button button, boolean active) { + button.setBackgroundResource(active ? R.drawable.bg_primary_button : R.drawable.bg_secondary_button); + button.setTextColor(getColor(active ? R.color.boss_surface : R.color.boss_green)); + } + + private void renderConversationsRoot() { + screenContent.removeAllViews(); + screenContent.addView(BossUi.buildCard( + this, + "会话首页", + "当前原生首页会直接进入项目详情、目标、版本、转发与线程预算详情。", + conversationsData == null ? "正在等待数据" : "会话数 " + conversationsData.length() + )); + + if (conversationsData == null || conversationsData.length() == 0) { + screenContent.addView(BossUi.buildEmptyCard(this, "当前没有会话数据。")); + return; + } + + for (int i = 0; i < conversationsData.length(); i++) { + JSONObject item = conversationsData.optJSONObject(i); + if (item == null) continue; + String projectId = item.optString("projectId", ""); + String title = item.optString("projectTitle", "未命名会话"); + StringBuilder body = new StringBuilder(item.optString("preview", "暂无预览")); + if (item.optInt("activeDeviceCount", 0) > 0) { + body.append("\n设备 ").append(item.optString("deviceNamesPreview", "未标注")); + } + JSONObject budget = item.optJSONObject("contextBudgetIndicator"); + String meta = "风险 " + item.optString("riskLevel", "unknown") + + " · 未读 " + item.optInt("unreadCount", 0) + + " · " + item.optString("latestReplyLabel", "-"); + if (budget != null && budget.optBoolean("visible", false)) { + meta = meta + " · 预算 " + budget.optInt("percent", 0) + "%"; + } + screenContent.addView(BossUi.buildCard(this, title, body.toString(), meta, v -> { + if (projectId.isEmpty()) { + showMessage("缺少 projectId"); + return; + } + openProject(projectId, title); + })); + } + } + + private void renderDevicesRoot() { + screenContent.removeAllViews(); + screenContent.addView(BossUi.buildCard( + this, + "设备首页", + "设备详情、技能清单和配对草稿都改为原生页。", + devicesData == null ? "正在等待数据" : "设备数 " + devicesData.length() + )); + + Button addDeviceButton = BossUi.buildPrimaryButton(this, "添加设备"); + addDeviceButton.setOnClickListener(v -> startActivity(new Intent(this, DeviceEnrollmentActivity.class))); + screenContent.addView(addDeviceButton); + + if (devicesData == null || devicesData.length() == 0) { + screenContent.addView(BossUi.buildEmptyCard(this, "当前没有接入设备。")); + return; + } + + for (int i = 0; i < devicesData.length(); i++) { + JSONObject item = devicesData.optJSONObject(i); + if (item == null) continue; + String deviceId = item.optString("id", ""); + String title = item.optString("name", "未命名设备"); + String body = item.optString("note", item.optString("endpoint", "暂无设备说明")); + String meta = "状态 " + item.optString("status", "unknown") + + " · 账号 " + item.optString("account", "-") + + " · 5h " + item.optInt("quota5h", 0) + + " · 7d " + item.optInt("quota7d", 0); + screenContent.addView(BossUi.buildCard(this, title, body, meta, v -> { + if (deviceId.isEmpty()) { + showMessage("缺少 deviceId"); + return; + } + openDevice(deviceId, title); + })); + } + } + + private void renderMeRoot() { + screenContent.removeAllViews(); + String displayName = sessionData == null + ? apiClient.getDisplayName() + : sessionData.optString("displayName", apiClient.getDisplayName()); + String account = sessionData == null + ? apiClient.getAccountLabel() + : sessionData.optString("account", apiClient.getAccountLabel()); + String expiresAt = sessionData == null ? "-" : sessionData.optString("expiresAt", "-"); + screenContent.addView(BossUi.buildCard( + this, + displayName, + "账号 " + account + "\n当前原生客户端已覆盖会话 / 设备 / 我的一级导航。", + "会话到期 " + expiresAt + )); + + screenContent.addView(BossUi.buildMenuRow( + this, + "账号与安全", + "查看当前会话、登录模式和退出登录。", + null, + v -> startActivity(new Intent(this, SecurityActivity.class)) + )); + screenContent.addView(BossUi.buildMenuRow( + this, + "设置", + "实时刷新、风险徽标和默认首页。", + null, + v -> startActivity(new Intent(this, SettingsActivity.class)) + )); + screenContent.addView(BossUi.buildMenuRow( + this, + "运维与修复", + "查看故障、repair ticket、审计请求和能力注册表。", + null, + v -> startActivity(new Intent(this, OpsCenterActivity.class)) + )); + screenContent.addView(BossUi.buildMenuRow( + this, + "AI 账号", + "管理主 GPT、备用 GPT、Master Codex Node 与 API 容灾。", + null, + v -> startActivity(new Intent(this, AiAccountsActivity.class)) + )); + screenContent.addView(BossUi.buildMenuRow( + this, + "技能", + "按绑定设备查看 Skill,并一键复制调用语句。", + null, + v -> startActivity(new Intent(this, SkillInventoryActivity.class)) + )); + screenContent.addView(BossUi.buildMenuRow( + this, + "关于", + "查看版本、OTA 状态和当前绑定节点。", + otaData == null ? null : otaData.optBoolean("hasOta", false) ? "OTA" : null, + v -> startActivity(new Intent(this, AboutActivity.class)) + )); + + if (otaData != null) { + JSONObject availableRelease = otaData.optJSONObject("availableRelease"); + String body = "当前版本 " + otaData.optString("currentVersion", "-"); + String meta = availableRelease == null + ? "当前没有待安装版本" + : "可用版本 " + availableRelease.optString("version", "-") + + " · 文件 " + availableRelease.optString("packageFileName", "-"); + screenContent.addView(BossUi.buildCard(this, "OTA 状态", body, meta)); + } + + Button logoutButton = BossUi.buildSecondaryButton(this, "退出登录"); + logoutButton.setOnClickListener(v -> logout()); + screenContent.addView(logoutButton); + } + + private void logout() { + startRefreshing(true); + executor.execute(() -> { + try { + apiClient.logout(); + } catch (Exception ignored) { + // Ignore transport errors and still clear UI. + } + runOnUiThread(() -> { + startRefreshing(false); + sessionData = null; + conversationsData = null; + devicesData = null; + otaData = null; + showLogin("已退出登录。点击登录可重新进入系统。"); + }); + }); + } + + private void openProject(String projectId, String projectName) { + Intent intent = new Intent(this, ProjectDetailActivity.class); + intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, projectId); + intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, projectName); + startActivity(intent); + } + + private void openDevice(String deviceId, String deviceName) { + Intent intent = new Intent(this, DeviceDetailActivity.class); + intent.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, deviceId); + intent.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, deviceName); + startActivity(intent); + } + + private void startRefreshing(boolean refreshing) { + screenRefresh.setRefreshing(refreshing); + refreshButton.setEnabled(!refreshing); + refreshButton.setText(refreshing ? "同步中" : "刷新"); + } + + private void showMessage(String text) { + Toast.makeText(this, text, Toast.LENGTH_SHORT).show(); + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java b/android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java new file mode 100644 index 0000000..0491f3d --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java @@ -0,0 +1,326 @@ +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 OpsCenterActivity extends BossScreenActivity { + private enum Tab { + OPS, + AUDIT + } + + private Tab activeTab = Tab.OPS; + private LinearLayout contentRoot; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + configureScreen("运维中心", "运维对话 / 审计对话"); + setHeaderAction("刷新", v -> reload()); + contentRoot = new LinearLayout(this); + contentRoot.setOrientation(LinearLayout.VERTICAL); + replaceContent(contentRoot); + reload(); + } + + @Override + protected void reload() { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse ops = apiClient.getOpsSummary(); + BossApiClient.ApiResponse audit = apiClient.getAuditSummary(); + if (!ops.ok() || !audit.ok()) { + throw new IllegalStateException("OPS_OR_AUDIT_LOAD_FAILED"); + } + runOnUiThread(() -> render(ops.json, audit.json)); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + replaceContent(BossUi.buildEmptyCard(this, "运维中心加载失败:" + error.getMessage())); + }); + } + }); + } + + private void render(JSONObject ops, JSONObject audit) { + contentRoot.removeAllViews(); + contentRoot.addView(buildTabBar()); + if (activeTab == Tab.OPS) { + renderOpsTab(ops); + } else { + renderAuditTab(audit); + } + setRefreshing(false); + } + + private LinearLayout buildTabBar() { + LinearLayout bar = new LinearLayout(this); + bar.setOrientation(LinearLayout.HORIZONTAL); + bar.addView(buildTabButton("运维对话", activeTab == Tab.OPS, v -> { + activeTab = Tab.OPS; + reload(); + })); + bar.addView(buildTabButton("审计对话", activeTab == Tab.AUDIT, v -> { + activeTab = Tab.AUDIT; + reload(); + })); + return bar; + } + + private Button buildTabButton(String label, boolean active, android.view.View.OnClickListener listener) { + Button button = BossUi.buildPrimaryButton(this, label); + button.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)); + button.setBackgroundResource(active ? R.drawable.bg_primary_button : R.drawable.bg_secondary_button); + button.setTextColor(getColor(active ? R.color.boss_surface : R.color.boss_green)); + button.setOnClickListener(listener); + return button; + } + + private void renderOpsTab(JSONObject ops) { + contentRoot.addView(BossUi.buildCard( + this, + "当前巡检模式", + ops.optString("mode", "idle").equals("active") + ? "active:当前存在风险线程或未关闭运维工单。" + : "idle:当前没有高风险工单,保持低频巡检。", + "来源:/api/v1/ops/summary" + )); + + JSONArray faults = ops.optJSONArray("faults"); + if (faults == null || faults.length() == 0) { + contentRoot.addView(BossUi.buildEmptyCard(this, "当前没有运维故障。")); + } else { + for (int i = 0; i < faults.length(); i++) { + JSONObject fault = faults.optJSONObject(i); + if (fault == null) continue; + contentRoot.addView(buildFaultCard(fault, ops.optJSONArray("tickets"))); + } + } + } + + private LinearLayout buildFaultCard(JSONObject fault, @Nullable JSONArray tickets) { + LinearLayout card = BossUi.buildCard( + this, + fault.optString("faultKey", "故障"), + fault.optString("summary", "暂无摘要"), + fault.optString("severity", "-") + + " · " + fault.optString("status", "-") + + " · " + fault.optString("nodeId", "-") + + " · " + fault.optString("serviceName", "-") + ); + + card.addView(BossUi.buildCard( + this, + "建议动作", + fault.optString("suggestedNextAction", "暂无"), + "trace " + fault.optString("traceId", "-") + )); + + if (tickets != null) { + for (int i = 0; i < tickets.length(); i++) { + JSONObject ticket = tickets.optJSONObject(i); + if (ticket == null) continue; + if (!fault.optString("faultId").equals(ticket.optString("faultId"))) continue; + card.addView(buildTicketCard(ticket)); + } + } + return card; + } + + private LinearLayout buildTicketCard(JSONObject ticket) { + LinearLayout card = BossUi.buildCard( + this, + ticket.optString("title", "修复工单"), + ticket.optString("actionSummary", "暂无动作摘要"), + ticket.optString("approvalStatus", "-") + + " · " + ticket.optString("executionStatus", "-") + + " · " + ticket.optString("targetNodeId", "-") + ); + + if (ticket.optJSONObject("verification") != null) { + JSONObject verification = ticket.optJSONObject("verification"); + card.addView(BossUi.buildCard( + this, + "验证结果", + verification.optString("summary", "暂无"), + verification.optString("status", "-") + + " · " + verification.optString("verifiedAt", "-") + )); + } + + Button approve = BossUi.buildPrimaryButton(this, "批准修复"); + approve.setOnClickListener(v -> approveTicket(ticket.optString("ticketId"))); + card.addView(approve); + + Button verify = BossUi.buildSecondaryButton(this, "验证修复"); + verify.setOnClickListener(v -> verifyTicket(ticket.optString("ticketId"))); + card.addView(verify); + return card; + } + + private void renderAuditTab(JSONObject audit) { + contentRoot.addView(BossUi.buildCard( + this, + "审计概要", + "待处理请求 " + (audit.optJSONArray("pendingRequests") == null ? 0 : audit.optJSONArray("pendingRequests").length()) + + "\n最新结果 " + (audit.optJSONArray("latestResults") == null ? 0 : audit.optJSONArray("latestResults").length()), + "来源:/api/v1/audits/summary" + )); + + JSONArray pendingRequests = audit.optJSONArray("pendingRequests"); + if (pendingRequests == null || pendingRequests.length() == 0) { + contentRoot.addView(BossUi.buildEmptyCard(this, "当前没有待处理的审计请求。")); + } else { + for (int i = 0; i < pendingRequests.length(); i++) { + JSONObject request = pendingRequests.optJSONObject(i); + if (request == null) continue; + contentRoot.addView(buildAuditRequestCard(request)); + } + } + + JSONArray latestResults = audit.optJSONArray("latestResults"); + if (latestResults != null && latestResults.length() > 0) { + contentRoot.addView(BossUi.buildCard(this, "审计结果", "最近完成的审计会展示在这里。", "可回看 decision / findings")); + for (int i = 0; i < latestResults.length(); i++) { + JSONObject result = latestResults.optJSONObject(i); + if (result == null) continue; + contentRoot.addView(buildAuditResultCard(result)); + } + } + + JSONArray capabilities = audit.optJSONArray("capabilities"); + if (capabilities != null && capabilities.length() > 0) { + contentRoot.addView(BossUi.buildCard(this, "能力注册表", "展示当前设备上的可用能力。", "与审计请求的 capabilityRequirements 对应")); + for (int i = 0; i < capabilities.length(); i++) { + JSONObject capability = capabilities.optJSONObject(i); + if (capability == null) continue; + contentRoot.addView(BossUi.buildCard( + this, + capability.optString("displayName", "能力"), + capability.optString("capabilityType", "-") + + "\n提供者:" + capability.optString("providerId", "-") + + "\n模式:" + capability.optString("leaseMode", "-") + + "\n动作:" + joinArray(capability.optJSONArray("supportedActions")), + capability.optString("status", "-") + + " · " + capability.optString("healthStatus", "-") + + " · " + capability.optString("nodeId", "-") + )); + } + } + } + + private LinearLayout buildAuditRequestCard(JSONObject request) { + LinearLayout card = BossUi.buildCard( + this, + request.optString("projectName", "审计请求"), + request.optString("objective", "暂无目标"), + request.optString("auditType", "-") + + " · priority " + request.optInt("priority", 0) + + " · " + request.optString("trigger", "-") + ); + card.addView(BossUi.buildCard( + this, + "审计条件", + "要求:" + joinStringArray(request.optJSONArray("acceptanceCriteria")) + + "\n风险:" + joinStringArray(request.optJSONArray("riskFocus")) + + "\n证据:" + joinStringArray(request.optJSONArray("evidenceRefs")), + "时限 " + request.optInt("timeBudgetSeconds", 0) + " 秒" + )); + return card; + } + + private LinearLayout buildAuditResultCard(JSONObject result) { + LinearLayout card = BossUi.buildCard( + this, + result.optString("decision", "result"), + result.optString("summary", "暂无摘要"), + result.optString("status", "-") + + " · confidence " + result.optDouble("confidence", 0.0) + + " · " + result.optString("completedAt", "-") + ); + card.addView(BossUi.buildCard( + this, + "审计发现", + joinStringArray(result.optJSONArray("findings")), + "需要动作:" + joinStringArray(result.optJSONArray("requiredActions")) + )); + return card; + } + + private void approveTicket(String ticketId) { + if (ticketId == null || ticketId.isEmpty()) { + showMessage("缺少 ticketId"); + return; + } + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.approveRepairTicket(ticketId); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + showMessage("修复工单已批准"); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("批准失败:" + error.getMessage()); + }); + } + }); + } + + private void verifyTicket(String ticketId) { + if (ticketId == null || ticketId.isEmpty()) { + showMessage("缺少 ticketId"); + return; + } + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.verifyRepairTicket(ticketId); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + showMessage("修复结果已验证"); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("验证失败:" + error.getMessage()); + }); + } + }); + } + + private String joinArray(@Nullable JSONArray values) { + if (values == null || values.length() == 0) return "-"; + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < values.length(); i++) { + String value = values.optString(i); + if (value == null || value.isEmpty()) continue; + if (builder.length() > 0) builder.append(" · "); + builder.append(value); + } + return builder.length() == 0 ? "-" : builder.toString(); + } + + private String joinStringArray(@Nullable JSONArray values) { + if (values == null || values.length() == 0) return "-"; + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < values.length(); i++) { + String value = values.optString(i); + if (value == null || value.isEmpty()) continue; + if (builder.length() > 0) builder.append(";"); + builder.append(value); + } + return builder.length() == 0 ? "-" : builder.toString(); + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java new file mode 100644 index 0000000..fb69a8a --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -0,0 +1,285 @@ +package com.hyzq.boss; + +import android.content.Intent; +import android.os.Bundle; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.LinearLayout; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +public class ProjectDetailActivity extends BossScreenActivity { + public static final String EXTRA_PROJECT_ID = "project_id"; + public static final String EXTRA_PROJECT_NAME = "project_name"; + + private String projectId; + private String initialProjectName; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID); + initialProjectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME); + configureScreen(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情..."); + setHeaderAction("发消息", v -> chooseMessageKindAndSend()); + reload(); + } + + @Override + protected void reload() { + if (projectId == null || projectId.isEmpty()) { + showMessage("缺少 projectId"); + finish(); + return; + } + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.getProjectDetail(projectId); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } + runOnUiThread(() -> renderProject(response.json)); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + replaceContent(BossUi.buildEmptyCard(this, "项目详情加载失败:" + error.getMessage())); + }); + } + }); + } + + private void renderProject(JSONObject payload) { + JSONObject project = payload.optJSONObject("project"); + JSONArray devices = payload.optJSONArray("devices"); + JSONArray threadContexts = payload.optJSONArray("activeThreadContexts"); + JSONArray recentLogs = payload.optJSONArray("recentAppLogs"); + + String title = project != null ? project.optString("name", "项目详情") : "项目详情"; + String subtitle = "设备:" + joinDeviceNames(devices); + configureScreen(title, subtitle); + + replaceContent(); + appendContent(buildActionGrid()); + + JSONObject masterIdentity = payload.optJSONObject("masterIdentity"); + if (masterIdentity != null) { + String body = masterIdentity.optString("roleLabel", "主控") + + " · " + masterIdentity.optString("displayName", "-") + + (masterIdentity.optString("nodeLabel").isEmpty() ? "" : " · " + masterIdentity.optString("nodeLabel")) + + (masterIdentity.optString("model").isEmpty() ? "" : "\n模型 " + masterIdentity.optString("model")); + String meta = masterIdentity.optString("statusLabel", "") + + (masterIdentity.optString("lastSwitchedAt").isEmpty() ? "" : " · 最近切换 " + masterIdentity.optString("lastSwitchedAt")); + appendContent(BossUi.buildCard(this, "当前主控身份", body, meta)); + } + + appendContent(BossUi.buildCard( + this, + "主 Agent 调度结论", + payload.optString("masterContextStrategySummary", "暂无调度摘要。"), + "原生项目详情已接入 /api/v1/projects/{projectId}" + )); + + if (threadContexts != null && threadContexts.length() > 0) { + for (int i = 0; i < threadContexts.length(); i++) { + JSONObject thread = threadContexts.optJSONObject(i); + if (thread == null) continue; + JSONObject snapshot = thread.optJSONObject("snapshot"); + if (snapshot == null) continue; + String threadId = snapshot.optString("threadId"); + String body = snapshot.optString("summary", "暂无摘要"); + String meta = snapshot.optString("workerId", "-") + + " · " + snapshot.optString("nodeId", "-") + + " · " + snapshot.optInt("contextBudgetRemainingPct", 0) + "%" + + " · " + snapshot.optString("contextBudgetLevel", "safe"); + appendContent(BossUi.buildCard( + this, + snapshot.optString("title", "线程详情"), + body, + meta, + v -> openThread(threadId) + )); + } + } else { + appendContent(BossUi.buildEmptyCard(this, "当前项目还没有线程预算数据。")); + } + + if (recentLogs != null && recentLogs.length() > 0) { + for (int i = 0; i < recentLogs.length(); i++) { + JSONObject log = recentLogs.optJSONObject(i); + if (log == null) continue; + String body = log.optString("message", "无消息体"); + if (!log.optString("detail").isEmpty()) { + body = body + "\n" + log.optString("detail"); + } + String meta = log.optString("deviceId", "-") + + " · " + log.optString("category", "-") + + " · " + log.optString("createdAt", "-"); + appendContent(BossUi.buildCard(this, "实时 APP 日志", body, meta)); + } + } + + JSONArray messages = project == null ? null : project.optJSONArray("messages"); + if (messages != null && messages.length() > 0) { + for (int i = 0; i < messages.length(); i++) { + JSONObject message = messages.optJSONObject(i); + if (message == null) continue; + String meta = message.optString("sentAt", "-") + + (message.optString("kind").isEmpty() ? "" : " · " + message.optString("kind")); + appendContent(BossUi.buildCard( + this, + message.optString("senderLabel", "消息"), + message.optString("body", ""), + meta + )); + } + } + + appendContent(BossUi.buildCard( + this, + "媒体与转发说明", + "语音、图片、视频与转发现在都通过原生入口触发,并写回现有 Boss 消息账本。", + "对象存储与真实媒体文件仍保持 MVP 占位。" + )); + setRefreshing(false); + } + + private LinearLayout buildActionGrid() { + LinearLayout wrapper = new LinearLayout(this); + wrapper.setOrientation(LinearLayout.VERTICAL); + wrapper.setLayoutParams(new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + )); + + wrapper.addView(buildActionRow( + buildActionButton("发送消息", v -> chooseMessageKindAndSend()), + buildActionButton("项目目标", v -> openGoals()) + )); + wrapper.addView(buildActionRow( + buildActionButton("版本记录", v -> openVersions()), + buildActionButton("消息转发", v -> openForward()) + )); + return wrapper; + } + + private LinearLayout buildActionRow(Button left, Button right) { + LinearLayout row = new LinearLayout(this); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.bottomMargin = BossUi.dp(this, 12); + row.setLayoutParams(params); + row.setOrientation(LinearLayout.HORIZONTAL); + + LinearLayout.LayoutParams childParams = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f); + childParams.rightMargin = BossUi.dp(this, 6); + left.setLayoutParams(childParams); + + LinearLayout.LayoutParams rightParams = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f); + rightParams.leftMargin = BossUi.dp(this, 6); + right.setLayoutParams(rightParams); + + row.addView(left); + row.addView(right); + return row; + } + + private Button buildActionButton(String label, android.view.View.OnClickListener listener) { + Button button = BossUi.buildPrimaryButton(this, label); + button.setOnClickListener(listener); + return button; + } + + private void chooseMessageKindAndSend() { + final String[] labels = {"文本消息", "语音意图", "图片意图", "视频意图"}; + final String[] kinds = {"text", "voice_intent", "image_intent", "video_intent"}; + new AlertDialog.Builder(this) + .setTitle("选择消息类型") + .setItems(labels, (dialog, which) -> showSendDialog(kinds[which], labels[which])) + .setNegativeButton("取消", null) + .show(); + } + + private void showSendDialog(String kind, String label) { + final android.widget.EditText input = BossUi.buildInput(this, "请输入要发送给项目的内容", true); + new AlertDialog.Builder(this) + .setTitle("发送" + label) + .setView(input) + .setNegativeButton("取消", null) + .setPositiveButton("发送", (dialog, which) -> sendProjectMessage(kind, input.getText().toString().trim())) + .show(); + } + + private void sendProjectMessage(String kind, String body) { + if (body.isEmpty()) { + showMessage("请输入消息内容"); + return; + } + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.sendProjectMessage(projectId, body, kind); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + setRefreshing(false); + showMessage("消息已发送"); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("发送失败:" + error.getMessage()); + }); + } + }); + } + + private void openGoals() { + Intent intent = new Intent(this, ProjectGoalsActivity.class); + intent.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, projectId); + intent.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, initialProjectName); + startActivity(intent); + } + + private void openVersions() { + Intent intent = new Intent(this, ProjectVersionsActivity.class); + intent.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, projectId); + intent.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, initialProjectName); + startActivity(intent); + } + + private void openForward() { + Intent intent = new Intent(this, ProjectForwardActivity.class); + intent.putExtra(ProjectForwardActivity.EXTRA_PROJECT_ID, projectId); + intent.putExtra(ProjectForwardActivity.EXTRA_PROJECT_NAME, initialProjectName); + startActivity(intent); + } + + private void openThread(String threadId) { + Intent intent = new Intent(this, ThreadDetailActivity.class); + intent.putExtra(ThreadDetailActivity.EXTRA_THREAD_ID, threadId); + intent.putExtra(ThreadDetailActivity.EXTRA_PROJECT_ID, projectId); + startActivity(intent); + } + + private String joinDeviceNames(@Nullable JSONArray devices) { + if (devices == null || devices.length() == 0) { + return "未绑定设备"; + } + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < devices.length(); i++) { + JSONObject device = devices.optJSONObject(i); + if (device == null) continue; + if (builder.length() > 0) builder.append(" / "); + builder.append(device.optString("name", device.optString("id", "设备"))); + } + return builder.length() == 0 ? "未绑定设备" : builder.toString(); + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectForwardActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectForwardActivity.java new file mode 100644 index 0000000..ab05f50 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/ProjectForwardActivity.java @@ -0,0 +1,106 @@ +package com.hyzq.boss; + +import android.os.Bundle; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import org.json.JSONArray; +import org.json.JSONObject; + +public class ProjectForwardActivity extends BossScreenActivity { + public static final String EXTRA_PROJECT_ID = "project_id"; + public static final String EXTRA_PROJECT_NAME = "project_name"; + + private String projectId; + private String projectName; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID); + projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME); + configureScreen("消息转发", projectName == null ? "选择目标项目并写备注" : "源项目:" + projectName); + reload(); + } + + @Override + protected void reload() { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.getConversations(); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> renderTargets(response.json.optJSONArray("conversations"))); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + replaceContent(BossUi.buildEmptyCard(this, "转发目标加载失败:" + error.getMessage())); + }); + } + }); + } + + private void renderTargets(@Nullable JSONArray conversations) { + replaceContent(BossUi.buildCard( + this, + "原生转发入口", + "选择一个目标项目,填写备注后会走现有 `/api/v1/projects/{projectId}/forwards`。", + "源项目:" + (projectName == null ? projectId : projectName) + )); + if (conversations == null || conversations.length() == 0) { + appendContent(BossUi.buildEmptyCard(this, "当前没有可转发的目标项目。")); + setRefreshing(false); + return; + } + for (int i = 0; i < conversations.length(); i++) { + JSONObject item = conversations.optJSONObject(i); + if (item == null) continue; + String targetProjectId = item.optString("projectId"); + if (projectId.equals(targetProjectId)) continue; + appendContent(BossUi.buildCard( + this, + item.optString("projectTitle", "未命名项目"), + item.optString("preview", ""), + item.optString("latestReplyLabel", "最近更新"), + v -> openForwardDialog(targetProjectId, item.optString("projectTitle", targetProjectId)) + )); + } + setRefreshing(false); + } + + private void openForwardDialog(String targetProjectId, String targetTitle) { + final android.widget.EditText input = BossUi.buildInput(this, "请输入要附带的转发说明", true); + input.setText("请同步关注 " + targetTitle + " 的当前进展。"); + new AlertDialog.Builder(this) + .setTitle("转发到 " + targetTitle) + .setView(input) + .setNegativeButton("取消", null) + .setPositiveButton("转发", (dialog, which) -> forwardMessage(targetProjectId, input.getText().toString().trim())) + .show(); + } + + private void forwardMessage(String targetProjectId, String note) { + if (note.isEmpty()) { + showMessage("请先填写转发说明"); + return; + } + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.forwardProjectMessage(projectId, targetProjectId, note); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + setRefreshing(false); + showMessage("转发成功"); + finish(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("转发失败:" + error.getMessage()); + }); + } + }); + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectGoalsActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectGoalsActivity.java new file mode 100644 index 0000000..53cef4c --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/ProjectGoalsActivity.java @@ -0,0 +1,167 @@ +package com.hyzq.boss; + +import android.os.Bundle; +import android.widget.Button; +import android.widget.LinearLayout; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import org.json.JSONArray; +import org.json.JSONObject; + +public class ProjectGoalsActivity extends BossScreenActivity { + public static final String EXTRA_PROJECT_ID = "project_id"; + public static final String EXTRA_PROJECT_NAME = "project_name"; + + private String projectId; + private String projectName; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID); + projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME); + configureScreen("项目目标", projectName == null ? "原生目标清单" : projectName); + setHeaderAction("新增", v -> openGoalEditor(null, "")); + reload(); + } + + @Override + protected void reload() { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.getProjectDetail(projectId); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> renderGoals(response.json.optJSONObject("project"))); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + replaceContent(BossUi.buildEmptyCard(this, "目标清单加载失败:" + error.getMessage())); + }); + } + }); + } + + private void renderGoals(@Nullable JSONObject project) { + replaceContent(); + if (project == null) { + appendContent(BossUi.buildEmptyCard(this, "项目不存在。")); + setRefreshing(false); + return; + } + + JSONArray goals = project.optJSONArray("goals"); + int completedCount = 0; + if (goals != null) { + for (int i = 0; i < goals.length(); i++) { + JSONObject goal = goals.optJSONObject(i); + if (goal != null && "completed".equals(goal.optString("state"))) { + completedCount++; + } + } + } + + appendContent(BossUi.buildCard( + this, + "主 Agent 已整理项目目标", + "已完成 " + completedCount + "/" + (goals == null ? 0 : goals.length()), + "用户可编辑,点按钮即可标记完成或修改正文。" + )); + + if (goals == null || goals.length() == 0) { + appendContent(BossUi.buildEmptyCard(this, "当前项目还没有目标。点击右上角新增即可。")); + } else { + for (int i = 0; i < goals.length(); i++) { + JSONObject goal = goals.optJSONObject(i); + if (goal == null) continue; + appendContent(buildGoalCard(goal)); + } + } + + appendContent(BossUi.buildCard( + this, + "当前约束", + "• 只能使用已绑定设备\n• 审计证据必须可回放\n• 版本记录仅主 Agent 可发布", + "原生目标页已覆盖 Web 目标清单" + )); + setRefreshing(false); + } + + private LinearLayout buildGoalCard(JSONObject goal) { + LinearLayout card = BossUi.buildCard( + this, + goal.optString("text", "未命名目标"), + goal.optString("note", "暂无备注"), + "状态 " + goal.optString("state", "pending") + ); + + Button toggle = BossUi.buildPrimaryButton( + this, + "completed".equals(goal.optString("state")) ? "标记未完成" : "标记完成" + ); + toggle.setOnClickListener(v -> toggleGoal(goal.optString("id"))); + card.addView(toggle); + + Button edit = BossUi.buildSecondaryButton(this, "编辑目标"); + edit.setOnClickListener(v -> openGoalEditor(goal.optString("id"), goal.optString("text"))); + card.addView(edit); + return card; + } + + private void toggleGoal(String goalId) { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.toggleGoal(projectId, goalId); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + showMessage("目标状态已更新"); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("更新失败:" + error.getMessage()); + }); + } + }); + } + + private void openGoalEditor(@Nullable String goalId, String currentText) { + final android.widget.EditText input = BossUi.buildInput(this, "请输入目标正文", true); + input.setText(currentText); + new AlertDialog.Builder(this) + .setTitle(goalId == null ? "新增目标" : "编辑目标") + .setView(input) + .setNegativeButton("取消", null) + .setPositiveButton("保存", (dialog, which) -> saveGoal(goalId, input.getText().toString().trim())) + .show(); + } + + private void saveGoal(@Nullable String goalId, String text) { + if (text.isEmpty()) { + showMessage("目标正文不能为空"); + return; + } + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = goalId == null + ? apiClient.createGoal(projectId, text) + : apiClient.updateGoal(projectId, goalId, text); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + showMessage(goalId == null ? "目标已新增" : "目标已更新"); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("保存失败:" + error.getMessage()); + }); + } + }); + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectVersionsActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectVersionsActivity.java new file mode 100644 index 0000000..ec6d654 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/ProjectVersionsActivity.java @@ -0,0 +1,71 @@ +package com.hyzq.boss; + +import android.os.Bundle; + +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONObject; + +public class ProjectVersionsActivity extends BossScreenActivity { + public static final String EXTRA_PROJECT_ID = "project_id"; + public static final String EXTRA_PROJECT_NAME = "project_name"; + + private String projectId; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID); + configureScreen("版本迭代记录", getIntent().getStringExtra(EXTRA_PROJECT_NAME)); + reload(); + } + + @Override + protected void reload() { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.getProjectDetail(projectId); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> renderVersions(response.json.optJSONObject("project"))); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + replaceContent(BossUi.buildEmptyCard(this, "版本记录加载失败:" + error.getMessage())); + }); + } + }); + } + + private void renderVersions(@Nullable JSONObject project) { + replaceContent(BossUi.buildCard( + this, + "版本记录只读", + "版本记录由主 Agent 监督各线程提交,并在复核后自动发布。", + "原生版本页仅展示,不允许手工篡改正文。" + )); + if (project == null) { + appendContent(BossUi.buildEmptyCard(this, "项目不存在。")); + setRefreshing(false); + return; + } + JSONArray versions = project.optJSONArray("versions"); + if (versions == null || versions.length() == 0) { + appendContent(BossUi.buildEmptyCard(this, "当前项目还没有版本记录。")); + setRefreshing(false); + return; + } + for (int i = 0; i < versions.length(); i++) { + JSONObject item = versions.optJSONObject(i); + if (item == null) continue; + appendContent(BossUi.buildCard( + this, + item.optString("version", "未命名版本"), + item.optString("summary", ""), + item.optString("createdAt", "-") + )); + } + setRefreshing(false); + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/SecurityActivity.java b/android/app/src/main/java/com/hyzq/boss/SecurityActivity.java new file mode 100644 index 0000000..73b0c3a --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/SecurityActivity.java @@ -0,0 +1,87 @@ +package com.hyzq.boss; + +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.Nullable; + +import org.json.JSONObject; + +public class SecurityActivity extends BossScreenActivity { + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + configureScreen("账号与安全", "原生会话与设备安全"); + reload(); + } + + @Override + protected void reload() { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.getSession(); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> renderSecurity(response.json.optJSONObject("session"))); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + replaceContent(BossUi.buildEmptyCard(this, "安全信息加载失败:" + error.getMessage())); + }); + } + }); + } + + private void renderSecurity(@Nullable JSONObject session) { + replaceContent( + BossUi.buildCard( + this, + "当前登录模式", + "当前登录页已临时切到免验证模式,点击登录会直接创建最高管理员会话。", + "后续如收口认证,再切回账号密码 / 验证码登录。" + ) + ); + if (session != null) { + appendContent(BossUi.buildCard( + this, + "当前会话", + "账号 " + session.optString("account", "-") + + "\n角色 " + session.optString("role", "-") + + "\n登录方式 " + session.optString("loginMethod", "-"), + "到期 " + session.optString("expiresAt", "-") + )); + } + + android.widget.Button devicesButton = BossUi.buildPrimaryButton(this, "打开设备页"); + devicesButton.setOnClickListener(v -> { + Intent intent = new Intent(this, MainActivity.class); + intent.putExtra(MainActivity.EXTRA_INITIAL_TAB, "devices"); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + startActivity(intent); + }); + appendContent(devicesButton); + + android.widget.Button logoutButton = BossUi.buildSecondaryButton(this, "退出登录"); + logoutButton.setOnClickListener(v -> logout()); + appendContent(logoutButton); + setRefreshing(false); + } + + private void logout() { + setRefreshing(true); + executor.execute(() -> { + try { + apiClient.logout(); + } catch (Exception ignored) { + // ignore + } + runOnUiThread(() -> { + setRefreshing(false); + Intent intent = new Intent(this, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + finish(); + }); + }); + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/SettingsActivity.java b/android/app/src/main/java/com/hyzq/boss/SettingsActivity.java new file mode 100644 index 0000000..62936cf --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/SettingsActivity.java @@ -0,0 +1,120 @@ +package com.hyzq.boss; + +import android.os.Bundle; +import android.widget.ArrayAdapter; +import android.widget.LinearLayout; +import android.widget.Spinner; + +import androidx.annotation.Nullable; +import androidx.appcompat.widget.SwitchCompat; + +import org.json.JSONObject; + +public class SettingsActivity extends BossScreenActivity { + private SwitchCompat liveUpdatesSwitch; + private SwitchCompat riskBadgesSwitch; + private SwitchCompat confirmActionsSwitch; + private Spinner preferredEntrySpinner; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + configureScreen("设置", "原生偏好配置"); + setHeaderAction("保存", v -> saveSettings()); + buildForm(); + reload(); + } + + @Override + protected void reload() { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.getSettings(); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> populate(response.json.optJSONObject("settings"))); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + replaceContent(BossUi.buildEmptyCard(this, "设置加载失败:" + error.getMessage())); + }); + } + }); + } + + private void buildForm() { + replaceContent( + BossUi.buildCard( + this, + "设置说明", + "当前设置会持久化到 data/boss-state.json,下一线程接手不会丢失。", + "原生设置页直接走 /api/v1/settings" + ) + ); + + liveUpdatesSwitch = new SwitchCompat(this); + liveUpdatesSwitch.setText("启用实时刷新"); + + riskBadgesSwitch = new SwitchCompat(this); + riskBadgesSwitch.setText("显示风险徽标"); + + confirmActionsSwitch = new SwitchCompat(this); + confirmActionsSwitch.setText("危险操作前确认"); + + preferredEntrySpinner = new Spinner(this); + ArrayAdapter adapter = new ArrayAdapter<>( + this, + android.R.layout.simple_spinner_dropdown_item, + new String[]{"conversations", "devices", "me"} + ); + preferredEntrySpinner.setAdapter(adapter); + + LinearLayout card = BossUi.buildCard(this, "交互偏好", "可切换默认首页与提醒行为。", "保存后立即生效"); + card.addView(liveUpdatesSwitch); + card.addView(riskBadgesSwitch); + card.addView(confirmActionsSwitch); + card.addView(preferredEntrySpinner); + appendContent(card); + } + + private void populate(@Nullable JSONObject settings) { + if (settings != null) { + liveUpdatesSwitch.setChecked(settings.optBoolean("liveUpdates", true)); + riskBadgesSwitch.setChecked(settings.optBoolean("showRiskBadges", true)); + confirmActionsSwitch.setChecked(settings.optBoolean("confirmDangerousActions", true)); + String preferredEntry = settings.optString("preferredEntryPoint", "conversations"); + if ("devices".equals(preferredEntry)) { + preferredEntrySpinner.setSelection(1); + } else if ("me".equals(preferredEntry)) { + preferredEntrySpinner.setSelection(2); + } else { + preferredEntrySpinner.setSelection(0); + } + } + setRefreshing(false); + } + + private void saveSettings() { + setRefreshing(true); + executor.execute(() -> { + try { + JSONObject payload = new JSONObject(); + payload.put("liveUpdates", liveUpdatesSwitch.isChecked()); + payload.put("showRiskBadges", riskBadgesSwitch.isChecked()); + payload.put("confirmDangerousActions", confirmActionsSwitch.isChecked()); + payload.put("preferredEntryPoint", preferredEntrySpinner.getSelectedItem().toString()); + BossApiClient.ApiResponse response = apiClient.updateSettings(payload); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + setRefreshing(false); + showMessage("设置已保存"); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("设置保存失败:" + error.getMessage()); + }); + } + }); + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java b/android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java new file mode 100644 index 0000000..24f1120 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/SkillInventoryActivity.java @@ -0,0 +1,101 @@ +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 SkillInventoryActivity extends BossScreenActivity { + public static final String EXTRA_DEVICE_ID = "device_id"; + public static final String EXTRA_DEVICE_NAME = "device_name"; + + private String deviceId; + private String deviceName; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + deviceId = getIntent().getStringExtra(EXTRA_DEVICE_ID); + deviceName = getIntent().getStringExtra(EXTRA_DEVICE_NAME); + configureScreen("技能", deviceName == null ? "当前设备 Skill 清单" : deviceName); + reload(); + } + + @Override + protected void reload() { + setRefreshing(true); + executor.execute(() -> { + try { + String targetDeviceId = resolveTargetDeviceId(); + BossApiClient.ApiResponse response = apiClient.getDeviceSkills(targetDeviceId); + if (!response.ok()) throw new IllegalStateException(response.message()); + deviceId = targetDeviceId; + runOnUiThread(() -> renderSkills(response.json)); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + replaceContent(BossUi.buildEmptyCard(this, "技能列表加载失败:" + error.getMessage())); + }); + } + }); + } + + private String resolveTargetDeviceId() throws Exception { + if (deviceId != null && !deviceId.isEmpty()) { + return deviceId; + } + BossApiClient.ApiResponse response = apiClient.getDevices(); + if (!response.ok()) throw new IllegalStateException(response.message()); + JSONArray devices = response.json.optJSONArray("devices"); + if (devices == null || devices.length() == 0) { + throw new IllegalStateException("NO_DEVICE"); + } + return devices.optJSONObject(0).optString("id"); + } + + private void renderSkills(JSONObject payload) { + replaceContent(); + JSONObject device = payload.optJSONObject("device"); + JSONArray skills = payload.optJSONArray("skills"); + + if (device != null) { + deviceName = device.optString("name", deviceId); + configureScreen("技能", deviceName); + appendContent(BossUi.buildCard( + this, + deviceName, + "当前页按设备查看 Skill 清单。", + "Skill 由 local-agent 从本机 ~/.codex/skills 扫描并同步。" + )); + } + + if (skills == null || skills.length() == 0) { + appendContent(BossUi.buildEmptyCard(this, "当前设备还没有同步 Skill。")); + setRefreshing(false); + return; + } + for (int i = 0; i < skills.length(); i++) { + JSONObject skill = skills.optJSONObject(i); + if (skill == null) continue; + LinearLayout card = BossUi.buildCard( + this, + skill.optString("name", "未命名 Skill"), + skill.optString("description", "未提供说明"), + skill.optString("category", "-") + + " · " + skill.optString("updatedAt", "-") + ); + Button copyInvocation = BossUi.buildPrimaryButton(this, "复制调用语句"); + copyInvocation.setOnClickListener(v -> BossUi.copyText(this, "Skill 调用", skill.optString("invocation", ""))); + card.addView(copyInvocation); + Button copyPath = BossUi.buildSecondaryButton(this, "复制路径"); + copyPath.setOnClickListener(v -> BossUi.copyText(this, "Skill 路径", skill.optString("path", ""))); + card.addView(copyPath); + appendContent(card); + } + setRefreshing(false); + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/ThreadDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ThreadDetailActivity.java new file mode 100644 index 0000000..0cdad39 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/ThreadDetailActivity.java @@ -0,0 +1,114 @@ +package com.hyzq.boss; + +import android.os.Bundle; + +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONObject; + +public class ThreadDetailActivity extends BossScreenActivity { + public static final String EXTRA_THREAD_ID = "thread_id"; + public static final String EXTRA_PROJECT_ID = "project_id"; + + private String threadId; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + threadId = getIntent().getStringExtra(EXTRA_THREAD_ID); + configureScreen("线程详情", threadId == null ? "原生线程预算视图" : threadId); + reload(); + } + + @Override + protected void reload() { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.getThreadDetail(threadId); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> renderThread(response.json)); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + replaceContent(BossUi.buildEmptyCard(this, "线程详情加载失败:" + error.getMessage())); + }); + } + }); + } + + private void renderThread(JSONObject payload) { + replaceContent(); + JSONObject snapshot = payload.optJSONObject("snapshot"); + if (snapshot == null) { + appendContent(BossUi.buildEmptyCard(this, "线程不存在。")); + setRefreshing(false); + return; + } + + configureScreen("线程详情", snapshot.optString("title", threadId)); + appendContent(BossUi.buildCard( + this, + snapshot.optString("title", "线程"), + snapshot.optString("summary", "暂无摘要"), + snapshot.optString("workerId", "-") + + " · " + snapshot.optString("nodeId", "-") + + " · " + snapshot.optInt("contextBudgetRemainingPct", 0) + "%" + + " · " + snapshot.optString("contextBudgetLevel", "safe") + )); + + appendContent(BossUi.buildCard( + this, + "压缩前收尾清单", + joinBulletLines(payload.optJSONArray("currentChecklist")), + "这些步骤需要在上下文压缩前固化。" + )); + + appendContent(BossUi.buildCard( + this, + "主 Agent 动作", + joinBulletLines(payload.optJSONArray("masterActions")), + "若为空,说明当前无需额外动作。" + )); + + JSONObject handoffPackage = payload.optJSONObject("handoffPackage"); + if (handoffPackage != null) { + appendContent(BossUi.buildCard( + this, + "handoff package", + handoffPackage.optString("summaryText", "暂无摘要"), + handoffPackage.optString("packageStatus", "draft") + )); + } + + JSONArray alerts = payload.optJSONArray("alerts"); + if (alerts != null) { + for (int i = 0; i < alerts.length(); i++) { + JSONObject alert = alerts.optJSONObject(i); + if (alert == null) continue; + appendContent(BossUi.buildCard( + this, + "上下文告警", + alert.optString("summary", "无摘要"), + alert.optString("status", "opened") + )); + } + } + setRefreshing(false); + } + + private String joinBulletLines(@Nullable JSONArray items) { + if (items == null || items.length() == 0) { + return "当前没有内容。"; + } + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < items.length(); i++) { + String value = items.optString(i); + if (value == null || value.isEmpty()) continue; + if (builder.length() > 0) builder.append('\n'); + builder.append("• ").append(value); + } + return builder.length() == 0 ? "当前没有内容。" : builder.toString(); + } +} diff --git a/android/app/src/main/res/drawable-land-hdpi/splash.png b/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000..e31573b Binary files /dev/null and b/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-mdpi/splash.png b/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xhdpi/splash.png b/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000..8077255 Binary files /dev/null and b/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000..14c6c8f Binary files /dev/null and b/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000..244ca25 Binary files /dev/null and b/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-hdpi/splash.png b/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 0000000..74faaa5 Binary files /dev/null and b/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-mdpi/splash.png b/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 0000000..e944f4a Binary files /dev/null and b/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xhdpi/splash.png b/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 0000000..564a82f Binary files /dev/null and b/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000..bfabe68 Binary files /dev/null and b/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000..6929071 Binary files /dev/null and b/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7bd21d --- /dev/null +++ b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_app_gradient.xml b/android/app/src/main/res/drawable/bg_app_gradient.xml new file mode 100644 index 0000000..a487e8b --- /dev/null +++ b/android/app/src/main/res/drawable/bg_app_gradient.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/res/drawable/bg_card.xml b/android/app/src/main/res/drawable/bg_card.xml new file mode 100644 index 0000000..d5d2c26 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_card.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/bg_primary_button.xml b/android/app/src/main/res/drawable/bg_primary_button.xml new file mode 100644 index 0000000..8b82519 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_primary_button.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_secondary_button.xml b/android/app/src/main/res/drawable/bg_secondary_button.xml new file mode 100644 index 0000000..8e75236 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_secondary_button.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d5fccc5 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/splash.png b/android/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/android/app/src/main/res/drawable/splash.png differ diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..f366d37 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,253 @@ + + + + + + + + + + + + + + + + + + + + + + +