262 Commits

Author SHA1 Message Date
kris
36a2cd8dfd feat: finish app-side master agent control surfaces 2026-04-16 06:18:27 +08:00
kris
e8304faebc feat: improve evolution screen feedback states 2026-04-16 06:06:03 +08:00
kris
4bedf75dc2 test: cover master agent evolution entry wiring 2026-04-16 06:02:27 +08:00
kris
363f732e29 feat: add native master agent evolution center 2026-04-16 05:59:12 +08:00
kris
f0490de180 feat: expose master agent evolution dashboard 2026-04-16 05:46:45 +08:00
kris
504d112218 feat: default master agent evolution to autonomous 2026-04-16 05:23:16 +08:00
kris
de6257a819 feat: add master agent evolution engine core 2026-04-16 05:21:50 +08:00
kris
f496838ced Add second batch master agent fast paths 2026-04-16 05:01:38 +08:00
kris
514971bef8 Fix master agent model switch availability copy 2026-04-16 04:45:41 +08:00
kris
39be49630f Integrate master agent runtime orchestration updates 2026-04-16 04:41:46 +08:00
kris
e0c0ea1814 Strip pin metadata from web conversations 2026-04-11 03:05:37 +08:00
kris
5bf745f45f Hide pinned controls on Web conversations 2026-04-11 02:44:01 +08:00
kris
164a7568a7 Skip duplicate chat payloads and batch layouts 2026-04-10 22:45:19 +08:00
kris
05dc9d8788 Cache markdown and append realtime messages 2026-04-10 22:40:49 +08:00
kris
1b0f126d4f Patch local chat realtime and align Caddy 2026-04-10 22:12:58 +08:00
kris
a084688e35 Patch folder realtime threads locally 2026-04-10 22:01:21 +08:00
kris
0781a56aad Patch conversation home from realtime events 2026-04-10 19:21:36 +08:00
kris
7131ee9eb1 Lighten Android chat realtime refreshes 2026-04-10 17:15:39 +08:00
kris
68da424eb8 Debounce Android realtime refresh bursts 2026-04-10 16:54:21 +08:00
kris
7593cc9cea Gate Android polling behind realtime health 2026-04-10 14:07:17 +08:00
kris
b1a0516717 Harden Android realtime recovery 2026-04-10 13:38:21 +08:00
kris
c4dbfc7398 Reduce Android realtime heartbeat noise 2026-04-10 13:25:05 +08:00
kris
0cba837ed3 Scope folder realtime refreshes by device 2026-04-10 12:55:43 +08:00
kris
d1e5a1ac5e Refresh settings page in realtime 2026-04-07 18:43:26 +08:00
kris
07ecce3d0d Refresh config pages in realtime 2026-04-07 18:26:17 +08:00
kris
0c01627d67 Refresh status pages in realtime 2026-04-07 18:00:14 +08:00
kris
4c31dd7e98 Throttle realtime refresh bursts 2026-04-07 17:50:30 +08:00
kris
4093c41949 Refresh folder pages for scoped thread updates 2026-04-07 17:33:37 +08:00
kris
b06b084438 Filter goal refreshes by conversation note 2026-04-07 17:29:42 +08:00
kris
8fc94f1849 Scope project refreshes and harden deploy script 2026-04-07 17:20:53 +08:00
kris
1de9ae0492 Refresh project chat page on conversation updates 2026-04-07 17:09:58 +08:00
kris
4f59d59014 Refresh OTA screen in realtime 2026-04-07 16:53:22 +08:00
kris
b5d6495017 Refresh goals and versions on precise goal updates 2026-04-07 16:41:09 +08:00
kris
9268f64506 Refresh device import draft in realtime 2026-04-07 16:16:37 +08:00
kris
ef3bf35463 Refresh skill inventory in realtime 2026-04-07 16:08:29 +08:00
kris
0b0bc5152f Refresh ops center in realtime 2026-04-07 16:04:18 +08:00
kris
a42e5b75dc Refresh conversation info surfaces in realtime 2026-04-07 15:49:18 +08:00
kris
45329159f5 Refresh device detail screen in realtime 2026-04-07 15:19:39 +08:00
kris
e0e8d4f687 Refresh thread status screen in realtime 2026-04-07 14:48:16 +08:00
kris
17ecd56b57 Publish Android 2.5.11 release artifacts 2026-04-07 14:31:36 +08:00
kris
aaaf1926b4 Refresh project detail on context risk events 2026-04-07 14:29:54 +08:00
kris
f83ab50d6b Improve conversation realtime refresh and heartbeat defaults 2026-04-07 14:17:30 +08:00
kris
233f61a649 Disable cache storage on special route error responses 2026-04-07 14:08:27 +08:00
kris
b1fa3c9b26 Disable cache storage on device import draft 2026-04-07 14:01:56 +08:00
kris
4052822595 Disable cache storage on live config routes 2026-04-07 13:57:28 +08:00
kris
67d37c2c21 Disable cache storage on live detail routes 2026-04-07 13:54:00 +08:00
kris
cc1afe8173 Disable cache storage on live JSON routes 2026-04-07 13:42:46 +08:00
kris
6153e94000 Fix stale conversation sync labels on mobile 2026-04-07 13:35:52 +08:00
kris
a43bb92f3c Compact imported single-thread conversation copy 2026-04-07 12:49:53 +08:00
kris
c5223c7c16 Add remote build fallback to deploy script 2026-04-07 09:30:32 +08:00
kris
992f8dbba4 Compact imported folder conversation previews 2026-04-06 16:45:35 +08:00
kris
6d90123092 Hide context rings without live snapshots 2026-04-06 14:58:30 +08:00
kris
5782804df3 Refresh stale device capability timestamps 2026-04-06 14:38:18 +08:00
kris
5789707072 Polish web device conflict copy 2026-04-06 14:09:46 +08:00
kris
3564aeaf2e Harden read-only thread handling and refresh Android releases 2026-04-06 13:26:48 +08:00
kris
9d7d2f4d17 Add thread execution conflict guards to chat flows 2026-04-06 12:01:06 +08:00
kris
2c47df702e Harden deploy script SSH session flow 2026-04-06 11:19:51 +08:00
kris
43c733069c Wire device execution mode controls into UI 2026-04-06 11:10:51 +08:00
kris
27ab594921 Add gui/cli capability conflict guards 2026-04-06 10:22:07 +08:00
kris
d04eca4703 docs: add gui cli capability implementation plan 2026-04-06 09:04:19 +08:00
kris
9de4fb7d40 docs: scope conflict actions to the active project folder 2026-04-06 09:00:20 +08:00
kris
6f2206a438 docs: define gui cli device capability and conflict guard 2026-04-06 08:58:39 +08:00
kris
d28afb2df1 fix: group fallback conversation feed into folder archives on android 2026-04-06 06:55:06 +08:00
kris
b7492e4789 docs: describe folder archive homepage behavior 2026-04-06 05:37:20 +08:00
kris
6956d1ac78 fix: complete folder archive action handling 2026-04-06 05:35:42 +08:00
kris
a46f11cf6c Fix conversation folder search targeting 2026-04-05 14:51:01 +08:00
kris
272698234d Fix conversation home reachability and Android timeout 2026-04-05 14:16:18 +08:00
kris
08a746c3bf fix: use conversation home feed on android 2026-04-05 14:03:47 +08:00
kris
0fcbf2d0a0 fix: support archived thread search on android homepage 2026-04-05 13:58:08 +08:00
kris
7206be05b6 feat: align android conversation folders with drawer design 2026-04-05 13:49:16 +08:00
kris
c8156d5f40 fix: use folder project title in web rows 2026-04-05 13:38:09 +08:00
kris
447e9e0b62 feat: align web conversation folders with drawer design 2026-04-05 13:26:50 +08:00
kris
a17c702edf test: assert folder archive removal on downgrade 2026-04-05 13:23:36 +08:00
kris
f9a0d205df test: cover folder archive context tie-breakers 2026-04-05 13:17:28 +08:00
kris
20b296ce4f feat: tighten conversation folder archive projections 2026-04-05 13:14:15 +08:00
kris
ef17947635 docs: add conversation folder drawer implementation plan 2026-04-05 13:05:28 +08:00
kris
b60da88e37 docs: add conversation folder drawer design 2026-04-05 12:40:16 +08:00
kris
f046adc393 fix: recover thread takeover session on android 2026-04-05 12:15:05 +08:00
kris
e00f7a55ea fix: recover master-agent takeover session on android 2026-04-05 11:56:20 +08:00
kris
35913f9d1d feat: add standalone web master-agent takeover page 2026-04-05 09:15:43 +08:00
kris
7cc33d391b feat: move global takeover into master-agent menu 2026-04-05 08:52:21 +08:00
kris
2a5962f767 feat: add master-agent takeover controls 2026-04-05 08:45:07 +08:00
kris
52f7d08b9e perf: coalesce root tab realtime refresh bursts 2026-04-05 08:26:39 +08:00
kris
71aa1a7143 perf: diff root list updates by stable item keys 2026-04-05 08:18:15 +08:00
kris
6083079be9 perf: coalesce project chat realtime reload bursts 2026-04-05 08:07:09 +08:00
kris
0bae3a78ec perf: refresh only active root tab data on realtime events 2026-04-05 08:02:14 +08:00
kris
28f692706b refactor: remove stale import understanding surfaces 2026-04-05 07:53:03 +08:00
kris
50d5327afd refactor: disable automatic main-agent thread sync pushes 2026-04-05 07:39:59 +08:00
kris
93c4574130 docs: add adb wireless keeper plan 2026-04-05 06:06:14 +08:00
kris
e649e2f9ac docs: add adb wireless keeper design 2026-04-05 06:00:06 +08:00
kris
af0cc3fead refactor: clarify thread execution failure notices 2026-04-05 05:19:18 +08:00
kris
276beb3486 refactor: keep thread sync in collaborative mode 2026-04-05 04:47:32 +08:00
kris
2e4d6f693d refactor: remove readonly wording from thread status surfaces 2026-04-05 04:02:34 +08:00
kris
2a34c19cc9 feat: surface thread status summary in conversation info 2026-04-05 03:32:12 +08:00
kris
5a53b60f13 feat: narrow thread sync context and dedupe realtime refresh 2026-04-05 03:23:11 +08:00
kris
da78e82a90 fix: fail closed on invalid codex resume bindings 2026-04-04 13:08:08 +08:00
kris
40b78c5cae fix: fail closed on invalid codex resume bindings 2026-04-04 13:04:12 +08:00
kris
4ab0414d43 docs: describe thread status sync runtime 2026-04-04 12:22:23 +08:00
kris
4d9b8e2976 feat: add thread status documents and safe thread reply handling 2026-04-04 11:50:46 +08:00
kris
010d8eda2d feat: add thread status read views 2026-04-04 11:39:06 +08:00
kris
7d578aa12f feat: prioritize thread status in master agent prompt 2026-04-04 11:37:41 +08:00
kris
f69eebd82d feat: sync thread status events 2026-04-04 11:06:00 +08:00
kris
8e12fa1e8c docs: add thread status sync implementation plan 2026-04-04 10:47:47 +08:00
kris
f180a5e4a8 docs: add thread status sync design 2026-04-04 10:43:39 +08:00
kris
48a45f04c6 fix: migrate legacy boss console state 2026-04-04 10:16:57 +08:00
kris
99acf26b1b refactor: remove seeded boss console conversation 2026-04-04 10:06:57 +08:00
kris
4e78fb0a34 fix: prefer fresh codex activity in conversation timestamps 2026-04-04 09:25:28 +08:00
kris
22406ed587 feat: suggest master-agent takeover for synced projects 2026-04-04 08:59:40 +08:00
kris
c4ce8d4b0a feat: surface auto project sync guidance in master chat 2026-04-04 08:53:28 +08:00
kris
6bad739ab9 feat: mirror project understanding sync into master chat 2026-04-04 08:49:08 +08:00
kris
cf57b5058f refactor: keep imported project understanding sync automatic 2026-04-04 08:43:53 +08:00
kris
432cf97541 feat: sync project understanding for imported devices 2026-04-04 08:29:17 +08:00
kris
01f438e3af feat: keep project understanding in sync after import 2026-04-04 08:09:47 +08:00
kris
908ad8858b feat: add realtime sync and import project understanding 2026-04-04 07:53:27 +08:00
kris
9c53e583ba style: align native top bar icons with wechat chrome 2026-04-04 04:13:37 +08:00
kris
2f741c327f refactor: slim thread execution prompts 2026-04-04 03:43:21 +08:00
kris
829005ba66 feat: improve chat readability with markdown 2026-04-04 03:10:16 +08:00
kris
5ebb37cbfc feat: streamline group dispatch reminders 2026-04-04 03:00:34 +08:00
kris
425d8992ef style: tune quick actions menu position and theming 2026-04-04 02:36:22 +08:00
kris
d126c46479 style: switch conversation quick actions to dropdown menu 2026-04-04 02:30:54 +08:00
kris
9d19163b0d feat: add conversation quick actions fan menu 2026-04-04 02:19:27 +08:00
kris
17dca04b6f feat: move conversation search into header mode 2026-04-04 01:50:06 +08:00
kris
062cab8e9a fix: lock android app to portrait 2026-04-04 01:41:13 +08:00
kris
bf4b27b062 perf: move root surfaces to recycler-backed pages 2026-04-04 01:31:02 +08:00
kris
35bcf92d72 perf: reduce high-refresh ui churn 2026-04-04 01:16:48 +08:00
kris
31004c512a feat: use pager-based root tab navigation 2026-04-04 01:09:50 +08:00
kris
05b9bee9e8 fix: mirror dispatch replies back to thread chats 2026-04-04 00:57:40 +08:00
kris
1a64fd9f29 fix: enable swipe tabs and clamp group titles 2026-04-04 00:53:53 +08:00
kris
5157a0ac07 feat: add conversation selection mode and swipe tabs 2026-04-04 00:32:57 +08:00
kris
c30b0a66ae feat: auto refresh conversation tab 2026-04-03 14:31:40 +08:00
kris
da7d4e0a7d style: tighten pinned header and context ring 2026-04-03 11:14:45 +08:00
kris
4d2d567bf9 feat: polish pinned conversations and context ring 2026-04-03 10:48:34 +08:00
kris
da55071a99 fix: show conversation context status 2026-04-03 10:09:19 +08:00
kris
459b301939 style: unify project detail top menu 2026-04-03 09:51:21 +08:00
kris
95f164e552 fix: stabilize conversation refresh and group create 2026-04-03 09:43:03 +08:00
kris
354c8b1f0b feat: let master-agent dispatch real threads 2026-04-03 05:29:38 +08:00
kris
ad7dd94d95 fix: keep imported thread candidates distinct 2026-04-03 05:02:18 +08:00
kris
42cf489450 style: unify native overflow menus 2026-04-03 04:48:41 +08:00
kris
24241d1f64 feat: execute omx dispatches via local-agent 2026-04-03 03:51:16 +08:00
kris
ec45bed59f feat: add omx orchestration backend selection 2026-04-03 03:17:12 +08:00
kris
60f5e2d7d6 feat: add omx team adapter skeleton 2026-04-03 02:22:02 +08:00
kris
8e2350e89d feat: gate claw runtime selection by availability 2026-04-03 02:11:41 +08:00
kris
6c999fb951 chore: add claw smoke runtime sample 2026-04-03 01:41:29 +08:00
kris
39b576cc42 feat: add claw backend adapter 2026-04-03 01:36:29 +08:00
kris
8daaea01fd docs: add claw backend adapter plan 2026-04-03 00:48:51 +08:00
kris
bfb7c43447 docs: add claw backend adapter design 2026-04-03 00:46:44 +08:00
kris
519ecb56eb docs: record execution foundation rollout 2026-04-03 00:26:44 +08:00
kris
70e8a13368 refactor: add remote runtime adapter 2026-04-03 00:24:11 +08:00
kris
8a62e72fd5 refactor: add execution backend selection 2026-04-03 00:21:19 +08:00
kris
a3a4f3e980 refactor: extract execution permission policy 2026-04-02 22:46:41 +08:00
kris
384dd570de refactor: extract execution prompt assembly 2026-04-02 22:32:19 +08:00
kris
e348d6cc5d feat: add execution foundation contracts 2026-04-02 21:37:10 +08:00
kris
5e23e1d408 docs: add execution foundation implementation plan 2026-04-02 20:43:59 +08:00
kris
3a45e41b1b docs: add execution foundation design 2026-04-02 20:12:26 +08:00
kris
22442979fe docs: add external runtime fusion strategy 2026-04-02 19:13:43 +08:00
kris
3a03ec4cbd docs: add claw-code integration strategy 2026-04-02 19:09:41 +08:00
kris
a4655439dd feat: add preset aliyun qwen model switching 2026-04-01 08:04:31 +08:00
kris
e52932e8ef feat: polish web master-agent controls and dispatch recovery 2026-04-01 07:50:20 +08:00
kris
87093677b8 feat: add web master-agent chat menu 2026-04-01 07:42:17 +08:00
kris
a27c7da7d4 refactor: harden master-agent prompt assembly 2026-04-01 06:53:38 +08:00
kris
1c45a88205 refactor: tighten master-agent memory ingestion 2026-04-01 06:42:41 +08:00
kris
60d69eb222 feat: add aliyun qwen backup provider 2026-04-01 05:33:35 +08:00
kris
ba01ae5393 feat: finish master-agent prompt and memory runtime 2026-04-01 04:56:07 +08:00
kris
d316f0490e feat: add master-agent prompts and memory management 2026-04-01 04:10:11 +08:00
kris
9000a9f185 docs: add master-agent prompts and memory plan 2026-04-01 03:36:09 +08:00
kris
4312b248a7 docs: add master-agent prompts and memory design 2026-04-01 03:32:27 +08:00
kris
811d011178 fix: shorten android chat send timeout 2026-03-31 23:40:07 +08:00
kris
d518878faa chore: publish android release v2.5.8 2026-03-31 23:36:17 +08:00
kris
ee2fab7ceb feat: apply per-chat master-agent execution config 2026-03-31 23:36:12 +08:00
kris
c3ee76909d fix: persist master-agent wait state in chat 2026-03-31 22:58:13 +08:00
kris
5c69eaa26d docs: sync device import review runtime 2026-03-31 22:43:23 +08:00
kris
87ffe19f78 feat: queue device import review tasks 2026-03-31 22:38:57 +08:00
kris
dcbff3cc7d feat: add dispatch retry and import recovery flows 2026-03-31 22:10:03 +08:00
kris
be31503d22 fix: serialize local-agent heartbeats 2026-03-31 21:49:46 +08:00
kris
02fcc56332 fix: harden production chat runtime 2026-03-31 20:20:07 +08:00
kris
ec7081f6cc docs: sync master-agent async chat flow 2026-03-31 20:03:07 +08:00
kris
013d9566be feat: queue master-agent chat replies 2026-03-31 19:59:08 +08:00
kris
e741952295 feat: add master agent chat controls 2026-03-31 19:30:26 +08:00
kris
bc464905a5 docs: add master-agent chat controls plan 2026-03-31 17:29:39 +08:00
kris
e9ae37028e docs: add master-agent chat controls spec 2026-03-31 17:26:34 +08:00
kris
71d0979292 fix: preserve user chat messages after ai onboarding 2026-03-31 16:30:21 +08:00
kris
70494fc15b fix: keep chat composer above ime on android 2026-03-31 16:12:31 +08:00
kris
f417fe1955 feat: harden agent onboarding and device import flows 2026-03-31 05:18:58 +08:00
kris
4aed93e90c feat: add master-agent jump after openai onboarding 2026-03-31 04:43:16 +08:00
kris
9d7f38412a feat: add browser-assisted openai onboarding flow 2026-03-31 04:31:58 +08:00
kris
0cb2171dd3 feat: harden ai onboarding and approval chat flows 2026-03-31 04:18:57 +08:00
kris
4336dc22a7 feat: add group repair and dispatch rejection flows 2026-03-31 03:56:28 +08:00
kris
9c02ebb574 feat: complete chat routing and openai onboarding 2026-03-31 03:31:22 +08:00
kris
5b590f7cc1 feat: add ai account onboarding entry points 2026-03-30 21:08:08 +08:00
kris
7c6101f22b fix: restore master agent relay guidance 2026-03-30 17:42:21 +08:00
kris
5eb1246f02 feat: restore dispatch confirmation flows 2026-03-30 17:11:07 +08:00
kris
40861c63da fix: filter codex subthreads during auto import 2026-03-30 14:25:25 +08:00
kris
03ac40f427 feat: group imported threads into project archives 2026-03-30 13:50:26 +08:00
kris
98dd0e3cd5 feat: auto-sync bound codex threads into conversations 2026-03-30 13:01:37 +08:00
kris
9c15c30a41 style: align native me surfaces with wechat ui 2026-03-30 12:26:14 +08:00
kris
038c2bd088 fix: harden dispatch and device import flows 2026-03-30 12:03:43 +08:00
kris
745b47e812 feat: wire dispatch execution and device import flows 2026-03-30 11:08:43 +08:00
kris
3b2bf59b65 feat: add dispatch plan confirmation flow 2026-03-30 10:41:52 +08:00
kris
11724e9834 feat: create dispatch plans from group messages 2026-03-30 01:49:07 +08:00
kris
74ea7151ad fix: reject dispatch plan retry mismatches 2026-03-30 01:40:52 +08:00
kris
1cd3a15a5d fix: tighten dispatch retry validation 2026-03-30 01:33:06 +08:00
kris
81f4245763 fix: harden dispatch confirmation flow 2026-03-30 01:24:45 +08:00
kris
40d93c05d1 feat: add orchestration dispatch state 2026-03-30 01:13:45 +08:00
kris
949dcf7845 docs: plan orchestration and device import 2026-03-30 01:09:05 +08:00
kris
1b55072d9b test: lock wechat root surface expectations 2026-03-29 23:52:19 +08:00
kris
6402096639 docs: add wechat ui polish plan 2026-03-29 23:47:59 +08:00
kris
ffefc62b35 docs: add wechat ui polish spec 2026-03-29 23:40:57 +08:00
kris
3724b3b444 chore: publish native ui polish release v2.5.3 2026-03-29 20:43:16 +08:00
kris
ef630f3572 chore: publish native ui polish release v2.5.2 2026-03-29 20:30:23 +08:00
kris
062b46bd41 chore: polish native wechat ui release v2.5.1 2026-03-29 20:00:00 +08:00
kris
fe186ad8d5 style: align group creation list with conversations 2026-03-29 19:20:37 +08:00
kris
e94e91a0f7 style: unify native top action buttons 2026-03-29 19:10:20 +08:00
kris
32960f8ecc fix: restyle conversations plus action 2026-03-29 19:02:13 +08:00
kris
c6e8d19ee5 fix: restore safe top actions and home group chat entry 2026-03-29 18:44:53 +08:00
kris
e9ab62e94d fix: restore native conversation row text layout 2026-03-29 17:54:34 +08:00
kris
e051a49f7a chore: publish attachment storage release v2.5.0 2026-03-29 17:22:07 +08:00
kris
5fb75b50b4 fix: harden attachment analysis delivery 2026-03-29 17:10:58 +08:00
kris
88ab2d011a feat: add attachment analysis access links 2026-03-29 17:06:54 +08:00
kris
18dc7c6120 fix: stabilize attachment upload and storage flows 2026-03-29 16:59:10 +08:00
kris
1e476a2097 android: add attachment composer flow 2026-03-29 16:27:04 +08:00
kris
9e4b64ba9e Implement attachment analysis task flow 2026-03-29 16:21:05 +08:00
kris
8273340f7f feat(web): add me storage page 2026-03-29 16:20:25 +08:00
kris
3307f79162 feat: add aliyun oss storage config 2026-03-29 16:05:25 +08:00
kris
de23a6e921 fix: harden attachment access and file paths 2026-03-29 15:47:07 +08:00
kris
aa75506364 feat: add server file attachment pipeline 2026-03-29 15:26:06 +08:00
kris
c3900a11ec fix: align attachment storage model 2026-03-29 15:18:27 +08:00
kris
4262c8fb5c feat: add attachment storage config model 2026-03-29 15:11:17 +08:00
kris
e4ff24a18f docs: add attachment storage implementation plan 2026-03-29 15:06:16 +08:00
kris
3cb4405b14 docs: add attachment storage and ai processing spec 2026-03-29 15:03:02 +08:00
kris
a5e8ba2b7e chore: publish wechat forwarding release v2.4.0 2026-03-28 09:00:53 +08:00
kris
cc08ca28aa fix: exercise native chat actions via ui callbacks 2026-03-28 08:54:44 +08:00
kris
a3a7f43626 test: verify native chat mode transitions 2026-03-28 08:50:57 +08:00
kris
64ad401d0c test: cover native chat chrome transitions 2026-03-28 08:47:55 +08:00
kris
d2291af32c fix: stabilize native chat selection chrome 2026-03-28 08:45:20 +08:00
kris
7109f1d3db feat: add wechat style native message forwarding 2026-03-28 08:39:08 +08:00
kris
200fc18210 test: cover native forward request boundary 2026-03-28 08:33:08 +08:00
kris
13c67425ab refactor: isolate forward payload serialization 2026-03-28 08:29:05 +08:00
kris
0783f4da14 fix: serialize forward target payloads 2026-03-28 08:21:37 +08:00
kris
42063db78f fix: tighten chat selection state invariants 2026-03-28 08:18:22 +08:00
kris
c90dea4b7c feat: add native forward target picker 2026-03-28 07:22:48 +08:00
kris
9613c3c154 feat: add structured message forwarding payloads 2026-03-28 07:20:49 +08:00
kris
227d270505 feat: add native chat forward selection state 2026-03-28 07:18:58 +08:00
kris
b606af66f6 docs: add wechat forwarding implementation plan 2026-03-28 07:12:15 +08:00
kris
a9e8bb9ddd docs: add wechat message forwarding spec 2026-03-28 07:08:05 +08:00
kris
f0735b31e5 feat: restore wechat thread ui and group chat 2026-03-28 05:21:44 +08:00
kris
afa7e79ad2 docs: add wechat ui restore implementation plan 2026-03-28 02:09:29 +08:00
kris
e27ea1e071 docs: add thread and group chat ui restore spec 2026-03-28 02:04:10 +08:00
kris
0a3390b132 chore: publish native ui polish release v2.2.1 2026-03-27 15:58:50 +08:00
kris
4dbf4ac1de feat: polish native root tab memory 2026-03-27 15:41:26 +08:00
kris
6559ad5bce feat: add native ota progress feedback 2026-03-27 15:39:19 +08:00
kris
ae571a76ff feat: polish chat composer feedback 2026-03-27 14:26:57 +08:00
kris
63ceef9871 docs: add native ui phase 2 spec and plan 2026-03-27 14:22:58 +08:00
kris
8da592bddf chore: publish wechat native rollback release v2.2.0 2026-03-27 13:41:23 +08:00
kris
9e0b5b223f android: preserve device detail summary context 2026-03-27 13:28:15 +08:00
kris
ff56617fdb android: simplify wechat device and me surfaces 2026-03-27 13:20:46 +08:00
kris
05e26afbf1 Restore chat-first project detail surface 2026-03-27 02:14:30 +08:00
kris
b794ba05fa fix: preserve abnormal device status in root list 2026-03-27 02:07:05 +08:00
kris
ce8dcad41c feat: restore wechat-style root shell 2026-03-27 01:58:34 +08:00
kris
17300c49ea fix: tighten wechat surface mapper contract 2026-03-27 01:52:29 +08:00
kris
efcefd8a62 test: freeze wechat surface contract 2026-03-27 01:49:01 +08:00
kris
785db90a7a docs: add wechat native ui rollback plan 2026-03-27 01:47:28 +08:00
kris
8439428479 docs: add wechat native ui rollback spec 2026-03-27 01:42:05 +08:00
450 changed files with 95598 additions and 2123 deletions

27
.env.server.example Normal file
View File

@@ -0,0 +1,27 @@
BOSS_AUTH_VERIFICATION_MODE=fixed
BOSS_AUTH_FIXED_CODE=000000
BOSS_STATE_FILE=/opt/boss/data/boss-state.json
# 切到真实邮件验证码时,改成:
# BOSS_AUTH_VERIFICATION_MODE=email
# BOSS_AUTH_FIXED_CODE=
BOSS_MAIL_DOMAIN=boss.hyzq.net
BOSS_MAIL_FROM_ADDRESS=verify@boss.hyzq.net
BOSS_MAIL_FROM_NAME=Boss Verify
BOSS_SENDMAIL_PATH=/usr/sbin/sendmail
# 可选:启用 ClawBackendAdapter默认关闭
# BOSS_CLAW_ENABLED=true
# BOSS_CLAW_COMMAND=node
# BOSS_CLAW_ARGS=scripts/claw-runtime-smoke.mjs
# BOSS_CLAW_WORKDIR=/opt/boss
# BOSS_CLAW_TIMEOUT_MS=45000
# BOSS_CLAW_DEFAULT_MODEL=gpt-5.4
# 可选:启用 OmxTeamBackendAdapter默认关闭
# BOSS_OMX_ENABLED=true
# BOSS_OMX_COMMAND=node
# BOSS_OMX_ARGS=scripts/omx-team-smoke.mjs
# BOSS_OMX_WORKDIR=/opt/boss
# BOSS_OMX_TIMEOUT_MS=45000

116
README.md
View File

@@ -18,6 +18,7 @@
- `src/app`:当前 Web 页面和 API 路由
- `src/components`:共享 UI 和交互组件
- `src/lib`:文件型状态模型和聚合投影视图
- `src/lib/execution`:当前已落地的执行底座抽象层,包含 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend` 默认实现
- `local-agent`:本地设备端心跳 + thread-context 上报服务
- `deployment``Caddy``systemd``launchd` 配置
- `scripts`:本地启动、安装、远端部署脚本
@@ -33,7 +34,7 @@
- `src/boss_control`:空占位目录,不参与当前运行
- `src/boss_device_agent`:空占位目录,不参与当前运行
## 当前运行状态2026-03-26
## 当前运行状态2026-04-03
本地:
@@ -43,14 +44,24 @@
- `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 容灾账号摘要
- `POST http://127.0.0.1:3000/api/v1/accounts/master-codex-primary/validate` 正常,已验证会明确提示“主 GPT 不在手机里直接登录”,并校验绑定设备在线状态
- `POST http://127.0.0.1:3102/api/v1/accounts/onboard/master-node` 正常,已验证会保存 Master Codex Node 绑定信息并返回显式登录指引
- `POST http://127.0.0.1:3102/api/v1/accounts/onboard/openai-api` 正常,已验证会对 API Key 做真实 OpenAI 探针校验;无效 Key 会返回真实错误
- `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/v1/projects/[projectId]/messages` 正常,普通单线程会话当前会返回 `conversation_reply` 任务,并等待绑定设备上的真实 Codex 线程回写
- `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` 正常
- 当前这台开发机的 `launchd` 常驻 `local-agent` 已恢复:`GET http://127.0.0.1:4317/health` 现在可在数十毫秒内返回,且在手动 heartbeat 执行期间仍能正常回包
- 当前 Boss 已新增 `src/lib/execution/` 执行底座抽象层;当前生产主链仍然沿用 `local-agent -> codex exec resume`,只是执行责任已开始通过 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend` 默认实现收束
- 当前 `claw-code` 已以最小 `ClawBackendAdapter` 形式接入执行底座,但默认关闭;只有在显式配置 `BOSS_CLAW_*` 且可用性探测通过时,`master-agent` 当前对话里才会出现并允许选择 `claw-runtime`
- 如果历史上已经保存过 `backendOverride=claw-runtime`,但当前 `Claw Runtime` 不可用,运行时会自动回退到默认后端,并在前台显示明确原因
- 当前 `oh-my-codex` 已以最小 `OmxTeamBackendAdapter` 形式接入执行底座,但默认关闭;当前已经接到 Web 群聊详情页 / 原生群资料页的编排后端选择卡,可在 `Boss Native``OMX Team` 间切换OMX 不可用时会自动回退到默认后端并明确提示原因
- 当前仓库已自带一个本地 OMX smoke runtime`scripts/omx-team-smoke.mjs`。在还没有真实 `oh-my-codex` 可执行文件时,可以先用它验证 `OmxTeamBackendAdapter -> selector -> dispatch_execution -> 回写群聊账本` 这条链
- 当前仓库已自带一个本地 smoke runtime`scripts/claw-runtime-smoke.mjs`。在还没有真实 `claw-code` 可执行文件时,可以先用它验证 `ClawBackendAdapter -> backendOverride -> 异步回流` 整条链
- `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`
@@ -90,13 +101,65 @@ 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.1``versionCode=8`
- 当前最新 release 构建版本:`2.5.11``versionCode=24`
- 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局`
- 当前原生活动页已经覆盖会话首页、项目详情、项目目标、版本记录、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于
- Android 真机无线调试如果要尽量稳定,优先使用“同一局域网 + 初次 USB 启用后执行 `adb tcpip 5555` + `adb connect <phone-ip>:5555`”这条链路;它通常比只依赖系统“无线调试配对码”更稳
- Android 系统层面对“无线调试”没有真正的永久不掉线开关;重启手机、切 WiFi、切热点、ADB server 重启、USB 调试被重新切换后,都可能导致无线调试自动失效
- 真机调试时建议固定同一 SSID、避免代理/VPN 改路、开发者选项里开启“保持唤醒”,并在需要长时间稳定调试时优先保留 USB 兜底;如果必须完全避免自动断开,不要只依赖无线调试
- 当前原生活动页已经覆盖会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、主 Agent 提示词、主 Agent 记忆、全局接管、主 Agent 自动进化、技能、运维中心、关于
- 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab会话首页是简单聊天列表`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口
- 当前会话首页右上角已切回 `+` 入口:直接从首页发起独立群聊;设备页右上角仍是 `+添加`
- 当前会话首页已升级成“项目聚合 + 线程下钻”的结构:如果某个 Codex 文件夹只导入了 1 个线程,会话列表直接显示这个线程;如果同一文件夹导入了多个线程,会话首页只显示该文件夹归档项,点进去再看这个项目下的全部线程
- 当前会话首页的数据源已分成两层:`/api/v1/conversations` 继续保留平铺线程视图给群聊创建、转发等内部能力使用;首页和原生根页改走 `/api/v1/conversations/home`,文件夹归档详情走 `/api/v1/conversation-folders/[folderKey]`
- 当前会话搜索仍然保留线程可达性:如果命中单线程项目,会直接进入该线程;如果命中多线程项目里的某条线程,结果会显示 `项目 / 线程`,点击后先进入项目文件夹页并定位到对应线程,不会把首页重新打平成线程列表
- 当前首页的 `置顶 / 已读 / 时间 / 预览 / 上下文环` 都已经按项目会话聚合:单线程项目直接作用在线程,多线程项目则作用在文件夹归档项,文件夹时间和预览取内部最新线程,上下文环取内部最需要关注的线程
- 当前会话信息页已经支持按微信最新逻辑改线程名;群聊会作为独立新会话创建,默认自动命名,创建后可在群资料页改名
- 原生顶部安全区当前已补齐状态栏 inset 处理,并把首页 / 会话信息 / 群资料 / 发起群聊 / 转发目标等页面的顶部操作区域收回到可点击安全区内
- 当前消息转发已经切到微信式链路:长按消息可直接 `转发 / 多选 / 复制 / 删除`,多选后底部只保留 `转发`,统一进入原生会话选择页
- 当前单条消息转发会在目标会话里显示为普通转发消息;多条消息会合并成一张“聊天记录”卡片,不再走旧的备注转发页
- 当前群聊调度主链已补上第一轮业务闭环:群聊文字消息会先进入主 Agent 生成推荐下发方案,用户确认后创建真正的线程执行单,执行完成后会把线程原始结果回写到群聊,再追加一条主 Agent 汇总
- 当前 `approval_required` 群聊已补齐两条审批动作:可以确认主 Agent 推荐,也可以明确拒绝;拒绝后会把群审批状态写成 `rejected`,并在群里追加系统提示,不会继续下发到线程
- 当前原生聊天页已把待审批推荐前移到主消息流:`ProjectDetailActivity` 会直接显示 `确认下发 / 拒绝`,刷新后也能恢复最近一条待确认推荐
- 当前 `approval_required` 群聊在已有待确认推荐时,会拒绝继续生成新的推荐,并提示用户先确认或拒绝当前推荐,避免审批消息叠加
- 当前三条聊天主链都已接入真实等待链路:`主 Agent 单聊 / 普通线程单聊 / 群聊确认下发` 当前都会返回任务信息,原生 Android 会保持等待直到收到真实回写或明确超时提示
- 当前 `我的 > AI 账号` 已补 `登录 OpenAI 平台账号``接入阿里百炼备用账号``绑定 Master Codex Node` 三条显式入口OpenAI API 登录成功后会立即设为当前主控,阿里百炼账号会作为备用链路保存
- 当前 `登录 OpenAI 平台账号` 已升级成浏览器辅助登录流:会先进入原生引导页,再自动打开 `OpenAI Platform` 登录页;用户登录后可直接跳到 `API Keys` 页面,回 APP 粘贴 key 完成接入
- 当前 `AI 账号` 页顶部会显式展示“当前主控身份”,并提供 `校验主控 / 测试主 Agent 对话` 两个动作,切换主控后可直接验证聊天通路
- 当前阿里百炼备用链已完成一次真实线上闭环验证:手动切到 `aliyun-qwen-backup` 后,`POST /api/v1/projects/master-agent/messages` 会返回 `queued`,并已实际回流 `阿里备用链正常。``master-agent` 会话
- 当前 `我的 > AI 账号` 已把阿里百炼备用模型切成预设选择Web 和原生 Android 都支持直接切换 `qwen3.5-plus / qwen3.5-flash`,只有在预设不适用时才需要填自定义模型
- 当前 `我的` 根页已拆出 `主 Agent 提示词 / 主 Agent 记忆 / 全局接管 / 主 Agent 自动进化` 四个独立入口;其中提示词页支持管理员全局主提示词只读展示、用户主提示词、当前对话附加提示词与执行后端切换,记忆页支持用户通用记忆 / 跨项目项目记忆的新增、编辑与归档
- 当前 Web 端 `master-agent` 会话页右上角也已补齐微信式三点菜单,支持直接进入 `提示词 / 模型 / 推理强度 / 记忆 / 刷新`
- 当前 `approval_required` 群聊在 Web 端已统一用单一状态快照驱动:如果有新的待确认推荐,会自动折叠旧的拒绝态;如果上次推荐已拒绝,会明确展示“重新生成新的推荐”的恢复入口
- 当前 `OpenAiOnboardingActivity` 在登录成功后会直接给出 `测试主 Agent 对话` 入口,可一键跳到 `master-agent` 聊天页
- 当前主控若还是 `Master Codex Node`,但节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API / 阿里百炼 Qwen` 备用账号,避免聊天直接掉成失败日志
- 当前原生 Android 的聊天发送已改成更短的客户端等待窗口;`master-agent` 单聊依赖服务端快速入队和消息流里的“思考中 / 超时 / 重试等待”状态,不再要求客户端长时间同步阻塞
- 当前群资料页已经支持“修复群成员”:如果历史脏群里混入了 `master-agent` 或失效线程引用,前台会明确提示并允许重新选择真实线程成员,修复后会正式写回群成员账本
- 当前原生聊天页也会直接提示“修复群成员”:当群里存在失效线程或不可下发成员时,`ProjectDetailActivity` 会在消息流上方直接给出 `去修复` 入口,并跳到群资料页完成修复
- 当前 Web 群聊页也已补上待确认推荐的刷新恢复:群聊详情会在服务端读取最近一条 `pending_user_confirmation` 的 dispatch plan并在刷新或重新进入页面后继续显示“等待你确认主 Agent 推荐”
- 当前设备导入主链已补上真实审核闭环:设备 heartbeat 可上报真实项目候选,服务端会生成 `import draft`;用户提交勾选后会先排队 `device_import_resolution` 主 Agent 任务,前台进入“主 Agent 审核中”并自动刷新,任务完成后才写回正式导入建议,再把选中的线程真正落成聊天窗口
- 当前新设备导入前台已经接通Web `添加设备` 成功后会直接进入“导入项目”步骤;设备页详情里也可再次打开导入草稿。原生 Android 端同样已补 `DeviceImportDraftActivity`,可完成 `勾选 -> 预览决议 -> 应用导入`
- 当前设备导入前台文案与状态卡已收口:会明确显示 `等待候选线程 / 等待勾选 / 建议已生成 / 已导入`,并在导入后回显真正落到会话首页的线程名
- 当前已导入设备也支持自动同步项目理解:绑定设备 heartbeat 发现活跃线程有新活动、或线程本身刚回写新结果时,都会自动排隐藏的主 Agent 理解任务,把项目目标、当前进度和技术架构回写到项目理解和项目记忆
- 当前主 Agent 对活跃线程的同步已经升级成“线程状态文档 + 最近进展事件 + 关键时刻深拉”heartbeat / thread reply 平时优先追加轻量进展事件,只有在线程首次理解、状态变薄、长时间未全量刷新或主 Agent 真正接手时,才重新触发全量理解
- 当前 Web 和 Android 都已经提供线程状态只读入口Web 可直接打开 `/conversations/[projectId]/thread-status`Android 可在单线程 `会话信息` 中进入 `ThreadStatusActivity`,查看当前目标、阶段、进度、架构、阻塞、建议下一步,以及最近 5 条进展事件
- 当前 `dispatch_execution` 完成回写已补幂等:同一个执行单重复完成,不会再向群聊重复追加线程原始回复和主 Agent 汇总
- 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `import draft`,不再绕过勾选/应用阶段直接把旧项目目录导入为聊天窗口
- 当前设备导入 `review` 已补 owner/admin 鉴权,并改成真正的异步审核链:`review` 只负责排队 `device_import_resolution` 任务并返回 queued 状态,等 local-agent 完成回写后才把决议写回草稿和会话账本
- 当前原生 APP 会话页的“刷新失败”已按当前 tab 的主数据源独立判错:`会话` 只看会话请求本身,`设备` 只看设备请求,`我的` 只在 `settings + ota` 同时失败时才提示刷新失败
- 当前 `设备``我的` 根页已收口为简单列表;`运维与修复 / AI 账号 / 技能` 保留在一级 `我的``审计对话` 作为置顶会话保留在会话首页
- 原生客户端当前直接调用 `https://boss.hyzq.net` 的 Boss API不再打开 WebView
- `2.0.1` 已修复华为真机上因 `Theme.SplashScreen``AppCompatActivity` 不兼容导致的启动闪退
- `2.1.0` 已在本机连接的华为真机上完成签名包覆盖安装与启动复核,原生三栏入口和子活动页声明已全部接通
- `2.1.1` 已补上原生 OTA 下载链路:关于页会直接请求受保护的 `/api/v1/user/ota/package`,下载完成后可拉起系统安装器
- `2.2.0` 已把原生 UI 从控制台风格回退到微信式简单列表和聊天优先视图,并复核了设备页 / 我的页 / 深层高级入口
- `2.2.1` 已继续补齐原生交互细节:聊天页发送后会先出现本地“发送中”气泡,关于页会展示 OTA 下载进度 / 重试 / 安装授权提示,根 tab 会记住用户上次停留位置并改成“再按一次返回进入后台”
- `2.3.0` 已把会话模型切到“线程 = 聊天窗口”,补上文件夹名副信息、后台活跃数量动态图标、微信式会话信息页、线程改名、独立群聊创建、群资料页,以及 `主 Agent / 审计对话` 普通置顶会话化
- `2.4.0` 已把消息转发切到微信式原生链路:聊天页支持长按消息操作、多选合并转发、统一目标会话选择页;单条消息转发显示为普通转发消息,多条消息转发显示为“聊天记录”卡片
- `2.5.0` 已补齐聊天附件主链:原生聊天框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;默认走服务器文件存储,`我的 > 附件与存储` 可切到阿里 OSS 私有桶;附件消息已支持下载 / 打开、手动分析、自动分析状态,以及带 task token 的主 Agent 附件分析链接
- `2.5.1` 继续收口微信式原生 UI聊天页普通态顶部已隐藏刷新按钮只保留右上角“信息”发起群聊页顶部说明和选择区已压成更轻的会话式密度候选线程继续复用微信式会话卡片
- `2.5.2` 继续补齐深层原生页:`项目目标 / 版本迭代记录 / 会话信息 / 群资料` 已进一步向设计图收口;附件消息卡片的分析状态和动作文案也压成了更轻的微信式层级
- `2.5.4` 已把 `我的` 根页收口成微信式资料区 + 白底菜单列表,并同步把 `设置 / 账号与安全 / AI 账号 / 技能 / 运维与修复` 的顶部说明从重 `soft panel` 降成轻量列表说明
- `2.5.5` 已补上群资料页的“修复群成员”主链:历史脏群会明确提示失效成员,并允许重新选择真实线程成员写回群资料;`approval_required` 群聊也已补齐“确认 / 拒绝”两条审批动作
## 本地启动
@@ -157,6 +220,18 @@ device-agent 当前职责:
- 递归扫描本机 `~/.codex/skills`,并同步到云端 `/api/v1/devices/[deviceId]/skills`
- 轮询云端 `/api/v1/master-agent/tasks/claim`,并用当前电脑已登录的 `codex` 账号执行主 Agent 任务
- 将主 Agent 执行结果回写到云端 `/api/v1/master-agent/tasks/[taskId]/complete`
- 对普通单线程会话,认领到的 `conversation_reply` 任务会直接恢复到目标 Codex 线程,并把线程原始回复回写到对应聊天窗口
- 对群聊线程分发任务,认领到的 `dispatch_execution` 任务会把原始线程结果和主 Agent 汇总一起回写到群聊消息账本
- `local-agent``conversation_reply` 当前会优先使用 `codex exec resume <targetCodexThreadRef>`,只有缺失真实线程引用时才退回 `--ephemeral`
- `local-agent``dispatch_execution` 当前会按 `orchestrationBackendId` 分流:默认继续走 `codex exec resume`;当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议执行
- `local-agent` 当前的任务完成回写已通过 `RemoteRuntimeAdapter` 标准化,`conversation_reply / dispatch_execution` 的完成结果都会先归一到统一远程执行结果结构,再进入主 Agent 完成路由
- `RemoteRuntimeAdapter` 当前还会拦截固定模式的线程内部环境提示(如“当前会话环境只读 / cwd 我可以在命令里指向 …”),并改写成系统失败提示,不再把这类脏文本直接回写到单聊或群聊
- 当前设备模型已支持同一台 Mac / Windows 同时接入 Codex `GUI + CLI` 双能力;设备详情页会同时展示两种能力状态,并允许切换默认执行模式
- 当前同项目 `GUI / CLI` 并行写入风险已接入项目/文件夹级冲突控制:默认阻断,用户可仅对当前异常项目/文件夹选择 `禁止 / 允许本次 / 永久放行`
- `local-agent` 当前会先启动本地 `4317` 健康监听,再异步执行首次 heartbeat 和 task poll避免控制面短暂阻塞时本地健康检查一起挂死
- Codex 项目/线程扫描当前已搬到 worker 线程执行,避免 `.codex/logs_1.sqlite``state_5.sqlite` 的同步扫描阻塞主线程 HTTP 响应
- 如果某个历史群聊里已经没有真实线程成员,当前不会再表现成“发了没反应”,而是会在群里追加一条 `system_notice`,提示用户先重新整理群成员
- 设备导入审核当前已经升级成 `local-agent -> codex exec -> complete` 的真实任务链Web 和 Android 前台都会在 `pending_resolution` 阶段显示“主 Agent 审核中”并自动刷新,审核失败时保留当前勾选以便重新生成
- 提供本地 `/health``/api/v1/device``/api/v1/skills``/api/v1/heartbeat`
当前常驻默认值:
@@ -180,6 +255,8 @@ device-agent 当前职责:
- 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`
- Android 微信式 surface contract`android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
- Android 聊天页布局:`android/app/src/main/res/layout/activity_project_chat.xml`
- 服务器环境示例:`.env.server.example`
当前 `scripts/deploy-server.sh`
@@ -244,12 +321,28 @@ npm run aab:release
- Web 端根布局当前仍保留 `NativeAppBridge`,用于浏览器态与历史桥接兼容;当前正式 APK 已改为原生 Activity + 原生 API 客户端,不再依赖 WebView
- APP 日志桥已经改成会话感知:只会按当前登录账号解析绑定设备,不再在未登录页默认按全局管理员设备写日志
- APP 外壳已经从“桌面预览卡片”切回真机态:移动端不再渲染假的 `9:41 / 5G` 状态栏,底部 `会话 / 设备 / 我的` 导航固定在视口底部,背景改为全屏 cover不再出现圆角矩形外壳
- 原生 Android 当前也和这套产品口径对齐:根页采用微信式简单列表,项目聊天页改成消息流优先,`设备 / 我的` 页不再展示控制台式统计卡片
- 原生聊天页当前会即时渲染本地发送中消息,并且只有在用户接近底部或本次发送是主动触发时才自动滚到底
- 登录成功后的进入首页链路已做稳态处理:会先确认 `/api/auth/session` 可读,再执行 `replace(/conversations)`,并附带一次原生级兜底跳转,避免真机 WebView 偶发停留在“正在进入会话首页”
- `/api/v1/events` 已作为 SSE 出口使用,会话页、设备页、技能页和项目详情页会按事件自动刷新,不再只靠手动刷新
- 我的页新增 `技能` 入口,`/me/skills` 会按设备分组展示 Skill并支持一键复制调用语句
- 我的页已拆出 `主 Agent 提示词 / 主 Agent 记忆 / 全局接管 / 主 Agent 自动进化` 入口;`/me/master-agent` 继续展示管理员全局主提示词、用户主提示词、当前对话附加提示词、组合预览,以及当前用户的通用记忆和跨项目项目记忆
- 我的页新增 `AI 账号` 入口,`/me/ai-accounts` 会展示主 GPT / 备用 GPT / API 容灾,并明确主链路优先走已登录 `ChatGPT Plus / Codex``Master Codex Node`
- `AI 账号` 页面当前已补上显式 `登录指引`:手机端不会直接弹出 ChatGPT OAuth主 GPT 的登录动作必须在绑定电脑上的 Codex / ChatGPT Plus 会话里完成,再回手机端点“测试连接 / 校验连接”
- `AI 账号` 页面当前已升级成双入口:首页会显式展示 `登录 OpenAI 平台账号``绑定电脑上的 Codex 节点`
- `登录 OpenAI 平台账号` 当前通过填写 `OpenAI API Key` 完成;校验成功后会立即创建/更新 `openai_api` 主账号,并设为当前主控
- `绑定电脑上的 Codex 节点` 当前会创建/更新 `master_codex_node` 主账号,并可直接设为当前主控;同时会返回“登录发生在绑定设备上”的明确中文指引
- 当前公网服务器对 `api.openai.com` 仍存在出网阻塞;`OpenAI API Key` 登录入口已经实现,但在服务器恢复出网前,公网校验会返回明确的中文网络错误,建议先切回 `Master Codex Node`
- `POST /api/v1/accounts/[accountId]/validate` 当前不再只看 `nodeId`;对 `master_codex_node` 会同时校验绑定设备是否在线,并在设备离线时返回明确的降级说明
- API 容灾当前不走服务器预置 Key而是由用户在 APP 的 `我的 > AI 账号` 中自行配置 `OpenAI API` 账号
- 设备页当前只展示已接入生产链路的设备,历史演示脏数据已经从正式设备视图、运维视图和审计视图中剔除
- 本机 `local-agent` 现在会直接从 `~/.codex/state_5.sqlite / logs_1.sqlite / session_index.jsonl / .codex-global-state.json` 动态发现真实 Codex 线程,并在 heartbeat 里上报 `projectCandidates`
- 线程发现当前会优先保留每个 Codex 文件夹下的“主工作线程”;如果同一文件夹里同时存在 `worker / explorer` 这类子线程,会优先过滤掉这些子线程,避免会话首页被子代理线程冲成异常多条
- 如果某条历史线程在 Codex 本地状态库里是 `read-only``local-agent` 当前会在候选发现和 `codex exec resume` 前都直接拒绝这类线程,避免把只读线程误当成可开发线程继续复用
- 如果某个项目下已经存在历史 `worker / explorer` 子线程,即使数据库权限后来被改成可写,也不能默认把它们当成主开发线程复用;这类线程往往还带着“只读勘察 / 不改文件”的历史上下文,恢复开发时应优先回到该项目的主交接线程,或先显式补发“解除只读勘察限制”的新用户指令
- 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;因此会话页会自动出现这台设备当前真实运行的 Codex 线程窗口
- 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;如果某个项目下存在多个线程,会话首页会先显示项目归档项,而不是把所有线程平铺在首页
- 对已经绑定的生产设备,如果某些自动导入线程已经不再出现在最新 `projectCandidates[]` 中,服务端会在下一次 heartbeat 自动清理这些过时会话,避免旧线程长期滞留首页
- 登录页当前已临时切到免验证模式,点击“登录”会直接进入会话首页
- 认证现在已经有最小会话链路:登录后会写入 `boss_session` Cookie默认保持 30 天,`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 接口都要求有效会话
- 新增 `GET /api/auth/session``POST /api/auth/logout``POST /api/auth/restore`
@@ -259,17 +352,26 @@ npm run aab:release
- 当前登录页已临时放开成“一键进入”,账号密码和验证码输入暂时不作为拦截条件
- `POST /api/auth/send-code` 与固定验证码 `000000` 仍保留给注册 / 重置密码和后续认证收口,不作为当前登录页前置条件
- 新注册和重置密码现在使用 `scrypt` 哈希;历史 `sha256` 密码会在下一次密码登录时自动迁移
- 原生 Android 当前把 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等复杂能力下沉到二级或更深层入口,不再把线程预算 / 转发 / 运维说明堆在主聊天页和一级我的页
- 原生 OTA 当前除了整包下载和系统安装器拉起,还会在关于页保留本地下载状态;离开关于页再回来时,仍能看到进行中 / 失败 / 待授权 / 可安装状态
- Android 本地 Gradle 验证当前必须串行执行,避免并发 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug` 相互踩坏中间产物
- 当前默认最高管理员账号:`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 在任务完成后自动刷新出真实回复
- 主 Agent 对话当前真实执行链路是:`Boss Web -> 写入用户消息 -> 返回 queued/running -> master-agent task queue -> local-agent / OpenAI API -> complete task -> project ledger`
- `master-agent` 单聊当前已改成“快速入队 + 异步回流”:发送后会立即返回任务包和 `masterReplyState`,前台先显示“主 Agent 思考中”,真实回复稍后自动回写到账本
- 原生 Android 当前会把 `master-agent` 的等待态保留在消息流里:发送后常驻显示“主 Agent 思考中”,超时后改成“主 Agent 回复超时 + 重试等待”,收到新回复后会自动清掉,不再只靠 toast 提示
- `master-agent` 单聊当前已支持当前对话级别的 `模型 / 推理强度` 覆盖,服务端会优先把该会话的 `agentControls` 用到实际 OpenAI 回复和 Master Codex Node 执行 prompt 中
- 原生 Android 当前在 `master-agent` 聊天页右上角提供微信式 `...` 菜单,菜单项包含 `模型 / 推理强度 / 会话信息 / 刷新`
- 服务器已经部署 `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 防护
- 当前图片 / 视频入口会写入消息账本,但真实文件上传还没有接对象存储
- 聊天附件当前已支持真实上传、消息账本、受保护下载和原生打开;默认存储后端为服务器文件存储
- 当前用户已可在 `我的 > 附件与存储` 切到阿里 OSS 私有桶,下载链会按附件快照生成签名地址,避免用户后续修改配置后旧附件失效
- 图片 / PDF / 文本默认自动进入主 Agent 附件分析;视频 / Office / 大文件默认手动触发
- 当前采用“极轻云 + 本地设备端”的路线,云端只承载 Web、轻 API 和状态文件
- 服务器侧主 Agent 对话能否返回真实大模型回复,依赖被绑定设备的 `local-agent` 在线并能执行 `codex exec`;服务器本身不直接持有主 GPT 会话
- 原生 Android 当前不再依赖长时间同步等待 `master-agent` 完整回复;消息发送后会立即进入“主 Agent 思考中”状态,并通过后台轮询刷新真实回复

View File

@@ -13,6 +13,9 @@ android {
buildFeatures {
buildConfig true
}
testOptions {
unitTests.includeAndroidResources = true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
@@ -33,8 +36,8 @@ android {
applicationId "com.hyzq.boss"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 8
versionName "2.1.1"
versionCode 24
versionName "2.5.11"
buildConfigField "String", "BOSS_API_BASE_URL", "\"https://boss.hyzq.net\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -55,7 +58,9 @@ dependencies {
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation "androidx.viewpager2:viewpager2:1.1.0"
testImplementation "junit:junit:$junitVersion"
testImplementation "org.robolectric:robolectric:4.14.1"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
}

View File

@@ -18,6 +18,7 @@
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:screenOrientation="portrait"
android:exported="true">
<intent-filter>
@@ -27,19 +28,35 @@
</activity>
<activity android:name=".ProjectDetailActivity" android:exported="false" />
<activity android:name=".ProjectGoalsActivity" android:exported="false" />
<activity android:name=".ProjectVersionsActivity" android:exported="false" />
<activity android:name=".ProjectForwardActivity" android:exported="false" />
<activity android:name=".ThreadDetailActivity" android:exported="false" />
<activity android:name=".DeviceDetailActivity" android:exported="false" />
<activity android:name=".DeviceEnrollmentActivity" android:exported="false" />
<activity android:name=".SkillInventoryActivity" android:exported="false" />
<activity android:name=".SecurityActivity" android:exported="false" />
<activity android:name=".SettingsActivity" android:exported="false" />
<activity android:name=".AiAccountsActivity" android:exported="false" />
<activity android:name=".OpsCenterActivity" android:exported="false" />
<activity android:name=".AboutActivity" android:exported="false" />
<activity
android:name=".ProjectDetailActivity"
android:exported="false"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize" />
<activity android:name=".ConversationFolderActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".ProjectGoalsActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".ProjectVersionsActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".ProjectForwardActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".ForwardTargetActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".ThreadDetailActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".ConversationInfoActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".ThreadStatusActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".GroupInfoActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".GroupCreateActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".DeviceDetailActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".DeviceEnrollmentActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".DeviceImportDraftActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".SkillInventoryActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".SecurityActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".SettingsActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".AiAccountsActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".OpenAiOnboardingActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".MasterAgentPromptActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".MasterAgentTakeoverActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".MasterAgentMemoryActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".MasterAgentEvolutionActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".OpsCenterActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".AboutActivity" android:exported="false" android:screenOrientation="portrait" />
<provider
android:name="androidx.core.content.FileProvider"

View File

@@ -9,8 +9,9 @@ import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.provider.Settings;
import android.widget.Button;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
@@ -18,9 +19,41 @@ import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.LinkedHashMap;
import java.util.Map;
public class AboutActivity extends BossScreenActivity {
private static final long OTA_PROGRESS_POLL_INTERVAL_MS = 1_000L;
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private static final String OTA_UI_PREFS = "boss_native_client";
private static final String KEY_ACTIVE_DOWNLOAD_ID = "ota_active_download_id";
private static final String KEY_COMPLETED_DOWNLOAD_ID = "ota_completed_download_id";
private static final String KEY_LAST_DOWNLOAD_FILE_NAME = "ota_last_download_file_name";
private static final String KEY_LAST_DOWNLOAD_VERSION = "ota_last_download_version";
private static final String KEY_LAST_DOWNLOAD_STATUS = "ota_last_download_status";
private long activeDownloadId = -1L;
private long completedDownloadId = -1L;
private @Nullable JSONObject otaPayload;
private @Nullable LinearLayout otaDownloadStateSection;
private @Nullable Uri downloadedApkUri;
private @Nullable String lastDownloadFileName;
private @Nullable String lastDownloadVersion;
private int lastDownloadStatus = -1;
private long lastDownloadedBytes = 0L;
private long lastTotalBytes = -1L;
private @Nullable BossRealtimeClient realtimeClient;
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
private final Handler otaProgressHandler = new Handler(Looper.getMainLooper());
private final Runnable otaProgressPoller = new Runnable() {
@Override
public void run() {
refreshDownloadStateSection();
if (activeDownloadId > 0) {
otaProgressHandler.postDelayed(this, OTA_PROGRESS_POLL_INTERVAL_MS);
}
}
};
private final BroadcastReceiver otaDownloadReceiver = new BroadcastReceiver() {
@Override
@@ -32,7 +65,6 @@ public class AboutActivity extends BossScreenActivity {
if (downloadId <= 0 || downloadId != activeDownloadId) {
return;
}
activeDownloadId = -1L;
handleCompletedDownload(downloadId);
}
};
@@ -40,7 +72,9 @@ public class AboutActivity extends BossScreenActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("关于 / OTA", "原生版本中心");
configureScreen("关于", "版本与 OTA 更新");
restoreDownloadUiState();
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(otaDownloadReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
@@ -50,8 +84,22 @@ public class AboutActivity extends BossScreenActivity {
reload();
}
@Override
protected void onResume() {
super.onResume();
updateRealtimeSubscription();
}
@Override
protected void onPause() {
stopRealtimeUpdates();
super.onPause();
}
@Override
protected void onDestroy() {
stopRealtimeUpdates();
otaProgressHandler.removeCallbacks(otaProgressPoller);
try {
unregisterReceiver(otaDownloadReceiver);
} catch (IllegalArgumentException ignored) {
@@ -67,14 +115,12 @@ public class AboutActivity extends BossScreenActivity {
try {
BossApiClient.ApiResponse settings = apiClient.getSettings();
BossApiClient.ApiResponse ota = apiClient.getOtaStatus();
BossApiClient.ApiResponse session = apiClient.getSession();
if (!settings.ok() || !ota.ok() || !session.ok()) {
if (!settings.ok() || !ota.ok()) {
throw new IllegalStateException("PROFILE_OR_OTA_LOAD_FAILED");
}
runOnUiThread(() -> renderAbout(
settings.json.optJSONObject("user"),
ota.json,
session.json.optJSONObject("session")
ota.json
));
} catch (Exception error) {
runOnUiThread(() -> {
@@ -85,69 +131,233 @@ public class AboutActivity extends BossScreenActivity {
});
}
private void renderAbout(@Nullable JSONObject user, JSONObject ota, @Nullable JSONObject session) {
replaceContent();
otaPayload = ota;
if (user != null) {
appendContent(BossUi.buildCard(
this,
"当前版本",
user.optString("version", "-")
+ "\n当前账号" + user.optString("account", "-")
+ "\n绑定 Codex" + user.optString("boundCodexNodeLabel", "未绑定"),
session == null ? "-" : "会话到期 " + session.optString("expiresAt", "-")
));
private void updateRealtimeSubscription() {
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
realtimeClient.start();
return;
}
stopRealtimeUpdates();
}
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", "-")
));
private void stopRealtimeUpdates() {
if (realtimeClient != null) {
realtimeClient.stop();
}
}
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 -> downloadLatestApk());
actionCard.addView(download);
appendContent(actionCard);
void handleRealtimeEvent(BossRealtimeEvent event) {
if (event == null || event.eventName.isEmpty()) {
return;
}
if (!"ota.updated".equals(event.eventName)) {
return;
}
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
if (eventFingerprint.isEmpty()) {
return;
}
long now = System.currentTimeMillis();
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
return;
}
runOnUiThread(this::reload);
}
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", "-")
));
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
pruneRecentRealtimeEvents(now);
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
return true;
}
recentRealtimeEventTimestamps.put(eventFingerprint, now);
return false;
}
private void pruneRecentRealtimeEvents(long now) {
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Long> entry = iterator.next();
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
iterator.remove();
}
}
}
private void renderAbout(@Nullable JSONObject user, JSONObject ota) {
replaceContent();
otaPayload = ota;
invalidateStaleDownloadedApk(ota.optJSONObject("availableRelease"));
appendContent(BossUi.buildWechatMenuRow(
this,
"当前版本",
resolveInstalledVersionLabel(user, ota, BuildConfig.VERSION_NAME),
"已安装版本",
null,
null
));
JSONObject availableRelease = ota.optJSONObject("availableRelease");
appendContent(BossUi.buildWechatMenuRow(
this,
"OTA 状态",
buildOtaStatusSubtitle(ota),
buildOtaStatusMeta(ota),
availableRelease == null ? null : "OTA",
null
));
appendContent(BossUi.buildSoftPanel(
this,
"OTA 更新内容",
buildOtaContentBody(ota),
availableRelease == null ? "没有可下载的新版本时,可直接点按钮检查更新。" : "下载完成后会自动拉起系统安装器。"
));
android.widget.Button otaButton = BossUi.buildPrimaryButton(this, resolvePrimaryOtaActionLabel(availableRelease));
otaButton.setEnabled(activeDownloadId <= 0);
otaButton.setOnClickListener(v -> performPrimaryOtaAction(availableRelease));
appendContent(otaButton);
appendContent(BossUi.buildMenuRow(this, "重新检查更新", "拉取最新 OTA 状态", null, v -> performOtaAction("check")));
if (downloadedApkUri != null || completedDownloadId > 0) {
appendContent(BossUi.buildMenuRow(
this,
"同步已应用状态",
"安装完成后点这里,把服务端 OTA 状态更新为已应用",
null,
v -> performOtaAction("apply")
));
}
otaDownloadStateSection = new LinearLayout(this);
otaDownloadStateSection.setOrientation(LinearLayout.VERTICAL);
appendContent(otaDownloadStateSection);
refreshDownloadStateSection();
setRefreshing(false);
}
private static String buildOtaStatusSubtitle(JSONObject ota) {
JSONObject availableRelease = ota.optJSONObject("availableRelease");
if (availableRelease == null) {
return "当前已经是最新版本。";
}
return "发现新版本 " + availableRelease.optString("version", "未知版本");
}
private static String resolveInstalledVersionLabel(
@Nullable JSONObject user,
JSONObject ota,
@Nullable String packageVersionName
) {
if (packageVersionName != null && !packageVersionName.isEmpty()) {
return packageVersionName;
}
if (user != null) {
String userVersion = user.optString("version", "");
if (!userVersion.isEmpty()) {
return userVersion;
}
}
return ota.optString("currentVersion", "-");
}
private static String buildOtaStatusMeta(JSONObject ota) {
JSONObject availableRelease = ota.optJSONObject("availableRelease");
if (availableRelease == null) {
return "当前版本 " + ota.optString("currentVersion", "-");
}
String summaryLine = firstSummaryLine(availableRelease.optJSONArray("summary"));
return availableRelease.optString("packageFileName", "boss-android-latest.apk")
+ (summaryLine.isEmpty() ? "" : " · " + summaryLine);
}
private static String buildOtaContentBody(JSONObject ota) {
JSONObject availableRelease = ota.optJSONObject("availableRelease");
if (availableRelease != null) {
JSONArray lines = availableRelease.optJSONArray("summary");
if (lines != null && lines.length() > 0) {
StringBuilder builder = new StringBuilder("版本 ").append(availableRelease.optString("version", "-"));
for (int i = 0; i < lines.length(); i++) {
String line = lines.optString(i);
if (line == null || line.isEmpty()) {
continue;
}
builder.append("\n").append(i + 1).append(". ").append(line);
}
return builder.toString();
}
String note = availableRelease.optString("note", "");
if (!note.isEmpty()) {
return "版本 " + availableRelease.optString("version", "-") + "\n" + note;
}
}
JSONArray logs = ota.optJSONArray("logs");
if (logs != null && logs.length() > 0) {
JSONObject latest = logs.optJSONObject(0);
if (latest != null) {
String note = latest.optString("note", "");
if (!note.isEmpty()) {
return latest.optString("version", "当前版本") + "\n" + note;
}
}
}
return "当前没有待更新内容,点击下方按钮可重新检查更新。";
}
private static String firstSummaryLine(@Nullable JSONArray lines) {
if (lines == null || lines.length() == 0) {
return "";
}
for (int i = 0; i < lines.length(); i++) {
String line = lines.optString(i);
if (line != null && !line.isEmpty()) {
return line;
}
}
return "";
}
private String resolvePrimaryOtaActionLabel(@Nullable JSONObject availableRelease) {
if (activeDownloadId > 0) {
return "下载中…";
}
if (downloadedApkUri != null) {
return "安装更新";
}
if (availableRelease != null) {
return "立即 OTA";
}
return "检查更新";
}
private void performPrimaryOtaAction(@Nullable JSONObject availableRelease) {
if (activeDownloadId > 0) {
return;
}
if (downloadedApkUri != null) {
installDownloadedApk();
return;
}
if (availableRelease != null) {
downloadLatestApk();
return;
}
performOtaAction("check");
}
private void performOtaAction(String action) {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = "check".equals(action) ? apiClient.checkOta() : apiClient.applyOta();
BossApiClient.ApiResponse response = "apply".equals(action)
? apiClient.applyOta()
: apiClient.checkOta();
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
showMessage("check".equals(action) ? "已完成版本检查" : "已登记 OTA 应用");
if ("apply".equals(action)) {
clearLocalOtaDownloadState();
}
showMessage("apply".equals(action) ? "已同步 OTA 应用状态" : "已完成版本检查");
reload();
});
} catch (Exception error) {
@@ -159,6 +369,19 @@ public class AboutActivity extends BossScreenActivity {
});
}
private void clearLocalOtaDownloadState() {
activeDownloadId = -1L;
completedDownloadId = -1L;
downloadedApkUri = null;
lastDownloadFileName = null;
lastDownloadVersion = null;
lastDownloadStatus = -1;
lastDownloadedBytes = 0L;
lastTotalBytes = -1L;
otaProgressHandler.removeCallbacks(otaProgressPoller);
persistDownloadUiState();
}
private void downloadLatestApk() {
executor.execute(() -> {
try {
@@ -187,6 +410,9 @@ public class AboutActivity extends BossScreenActivity {
String fileName = availableRelease == null
? "boss-android-latest.apk"
: availableRelease.optString("packageFileName", "boss-android-latest.apk");
String releaseVersion = availableRelease == null
? null
: availableRelease.optString("version", null);
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(apiClient.getProtectedOtaPackageUrl()));
request.setTitle(fileName);
@@ -198,7 +424,18 @@ public class AboutActivity extends BossScreenActivity {
request.addRequestHeader("x-boss-native-app", "1");
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName);
downloadedApkUri = null;
lastDownloadFileName = fileName;
lastDownloadVersion = releaseVersion;
lastDownloadStatus = DownloadManager.STATUS_PENDING;
lastDownloadedBytes = 0L;
lastTotalBytes = -1L;
completedDownloadId = -1L;
activeDownloadId = manager.enqueue(request);
persistDownloadUiState();
otaProgressHandler.removeCallbacks(otaProgressPoller);
otaProgressHandler.post(otaProgressPoller);
refreshDownloadStateSection();
showMessage("已开始下载,完成后会自动拉起安装。");
}
@@ -213,10 +450,23 @@ public class AboutActivity extends BossScreenActivity {
try (android.database.Cursor cursor = manager.query(query)) {
if (cursor == null || !cursor.moveToFirst()) {
showMessage("下载完成,但无法读取文件信息");
activeDownloadId = -1L;
completedDownloadId = -1L;
lastDownloadStatus = DownloadManager.STATUS_FAILED;
persistDownloadUiState();
refreshDownloadStateSection();
return;
}
int status = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
if (status != DownloadManager.STATUS_SUCCESSFUL) {
lastDownloadStatus = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
lastDownloadedBytes = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
lastTotalBytes = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
if (lastDownloadStatus != DownloadManager.STATUS_SUCCESSFUL) {
activeDownloadId = -1L;
completedDownloadId = -1L;
downloadedApkUri = null;
otaProgressHandler.removeCallbacks(otaProgressPoller);
persistDownloadUiState();
refreshDownloadStateSection();
showMessage("下载未成功完成");
return;
}
@@ -224,10 +474,23 @@ public class AboutActivity extends BossScreenActivity {
Uri apkUri = manager.getUriForDownloadedFile(downloadId);
if (apkUri == null) {
activeDownloadId = -1L;
completedDownloadId = -1L;
lastDownloadStatus = DownloadManager.STATUS_FAILED;
otaProgressHandler.removeCallbacks(otaProgressPoller);
persistDownloadUiState();
refreshDownloadStateSection();
showMessage("下载完成,但找不到安装包");
return;
}
activeDownloadId = -1L;
completedDownloadId = downloadId;
downloadedApkUri = apkUri;
otaProgressHandler.removeCallbacks(otaProgressPoller);
persistDownloadUiState();
refreshDownloadStateSection();
if (!getPackageManager().canRequestPackageInstalls()) {
showMessage("请先允许 Boss 安装未知来源应用,然后重新打开安装包。");
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
@@ -241,4 +504,262 @@ public class AboutActivity extends BossScreenActivity {
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(installIntent);
}
private void refreshDownloadStateSection() {
if (otaDownloadStateSection == null) {
return;
}
otaDownloadStateSection.removeAllViews();
OtaDownloadStateMapper.UiState uiState = resolveDownloadUiState();
if (uiState == null) {
return;
}
otaDownloadStateSection.addView(BossUi.buildListRow(
this,
uiState.title,
uiState.subtitle,
uiState.meta,
uiState.badge,
null
));
if (uiState.actionKind != OtaDownloadStateMapper.ActionKind.NONE) {
otaDownloadStateSection.addView(BossUi.buildMenuRow(
this,
uiState.actionLabel,
uiState.subtitle,
null,
v -> performDownloadStateAction(uiState.actionKind)
));
}
}
@Nullable
private OtaDownloadStateMapper.UiState resolveDownloadUiState() {
JSONObject availableRelease = otaPayload == null ? null : otaPayload.optJSONObject("availableRelease");
invalidateStaleDownloadedApk(availableRelease);
String fileName = resolveDownloadFileName();
if (activeDownloadId > 0) {
DownloadProgressSnapshot snapshot = queryDownloadProgress(activeDownloadId);
if (snapshot != null) {
lastDownloadStatus = snapshot.status;
lastDownloadedBytes = snapshot.bytesDownloaded;
lastTotalBytes = snapshot.totalBytes;
boolean hasKnownTotal = snapshot.totalBytes > 0;
int percent = hasKnownTotal
? (int) Math.round((snapshot.bytesDownloaded * 100.0d) / snapshot.totalBytes)
: 0;
if (snapshot.status == DownloadManager.STATUS_RUNNING
|| snapshot.status == DownloadManager.STATUS_PENDING
|| snapshot.status == DownloadManager.STATUS_PAUSED) {
return OtaDownloadStateMapper.active(fileName, percent, hasKnownTotal, snapshot.bytesDownloaded, snapshot.totalBytes);
}
if (snapshot.status == DownloadManager.STATUS_FAILED) {
activeDownloadId = -1L;
completedDownloadId = -1L;
otaProgressHandler.removeCallbacks(otaProgressPoller);
persistDownloadUiState();
return OtaDownloadStateMapper.failed(fileName);
}
}
}
if (lastDownloadStatus == DownloadManager.STATUS_FAILED) {
return OtaDownloadStateMapper.failed(fileName);
}
if (downloadedApkUri != null) {
if (!getPackageManager().canRequestPackageInstalls()) {
return OtaDownloadStateMapper.waitingInstallPermission(fileName);
}
return OtaDownloadStateMapper.readyToInstall(fileName);
}
return null;
}
private void performDownloadStateAction(OtaDownloadStateMapper.ActionKind actionKind) {
switch (actionKind) {
case RETRY_DOWNLOAD:
downloadLatestApk();
break;
case OPEN_INSTALL_PERMISSION:
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
break;
case INSTALL_APK:
installDownloadedApk();
break;
case NONE:
default:
break;
}
}
private void installDownloadedApk() {
if (downloadedApkUri == null) {
showMessage("当前没有可安装的更新包");
return;
}
if (!getPackageManager().canRequestPackageInstalls()) {
showMessage("请先开启安装未知来源应用权限");
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
return;
}
Intent installIntent = new Intent(Intent.ACTION_VIEW);
installIntent.setDataAndType(downloadedApkUri, "application/vnd.android.package-archive");
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(installIntent);
}
private String resolveDownloadFileName() {
if (lastDownloadFileName != null && !lastDownloadFileName.isEmpty()) {
return lastDownloadFileName;
}
JSONObject availableRelease = otaPayload == null ? null : otaPayload.optJSONObject("availableRelease");
if (availableRelease != null) {
return availableRelease.optString("packageFileName", "boss-android-latest.apk");
}
return "boss-android-latest.apk";
}
private void restoreDownloadUiState() {
android.content.SharedPreferences prefs = getSharedPreferences(OTA_UI_PREFS, Context.MODE_PRIVATE);
activeDownloadId = prefs.getLong(KEY_ACTIVE_DOWNLOAD_ID, -1L);
completedDownloadId = prefs.getLong(KEY_COMPLETED_DOWNLOAD_ID, -1L);
lastDownloadFileName = prefs.getString(KEY_LAST_DOWNLOAD_FILE_NAME, null);
lastDownloadVersion = prefs.getString(KEY_LAST_DOWNLOAD_VERSION, null);
lastDownloadStatus = prefs.getInt(KEY_LAST_DOWNLOAD_STATUS, -1);
if (completedDownloadId > 0) {
DownloadManager manager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
if (manager != null) {
downloadedApkUri = manager.getUriForDownloadedFile(completedDownloadId);
}
}
if (activeDownloadId > 0) {
otaProgressHandler.removeCallbacks(otaProgressPoller);
otaProgressHandler.post(otaProgressPoller);
}
}
private void persistDownloadUiState() {
getSharedPreferences(OTA_UI_PREFS, Context.MODE_PRIVATE)
.edit()
.putLong(KEY_ACTIVE_DOWNLOAD_ID, activeDownloadId)
.putLong(KEY_COMPLETED_DOWNLOAD_ID, completedDownloadId)
.putString(KEY_LAST_DOWNLOAD_FILE_NAME, lastDownloadFileName)
.putString(KEY_LAST_DOWNLOAD_VERSION, lastDownloadVersion)
.putInt(KEY_LAST_DOWNLOAD_STATUS, lastDownloadStatus)
.apply();
}
private void invalidateStaleDownloadedApk(@Nullable JSONObject availableRelease) {
long[] downloadIds = collectStaleDownloadIdsForRemoval(
availableRelease,
lastDownloadFileName,
lastDownloadVersion,
downloadedApkUri != null || completedDownloadId > 0 || activeDownloadId > 0,
activeDownloadId,
completedDownloadId
);
if (downloadIds.length == 0) {
return;
}
removeStaleDownloadTasks(downloadIds);
clearLocalOtaDownloadState();
}
private void removeStaleDownloadTasks(long[] downloadIds) {
if (downloadIds.length == 0) {
return;
}
DownloadManager manager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
if (manager == null) {
return;
}
try {
manager.remove(downloadIds);
} catch (RuntimeException ignored) {
// Keep UI state recoverable even if DownloadManager cleanup fails.
}
}
static long[] collectStaleDownloadIdsForRemoval(
@Nullable JSONObject availableRelease,
@Nullable String downloadedFileName,
@Nullable String downloadedVersion,
boolean hasLocalDownload,
long activeId,
long completedId
) {
if (!hasLocalDownload) {
return new long[0];
}
if (isDownloadedReleaseCurrent(availableRelease, downloadedFileName, downloadedVersion)) {
return new long[0];
}
return collectDownloadIdsForRemoval(activeId, completedId);
}
private static long[] collectDownloadIdsForRemoval(long activeId, long completedId) {
if (activeId > 0 && completedId > 0) {
if (activeId == completedId) {
return new long[]{activeId};
}
return new long[]{activeId, completedId};
}
if (activeId > 0) {
return new long[]{activeId};
}
if (completedId > 0) {
return new long[]{completedId};
}
return new long[0];
}
private static boolean isDownloadedReleaseCurrent(
@Nullable JSONObject availableRelease,
@Nullable String downloadedFileName,
@Nullable String downloadedVersion
) {
if (availableRelease == null) {
return false;
}
String releaseFileName = availableRelease.optString("packageFileName", "");
String releaseVersion = availableRelease.optString("version", "");
if (releaseFileName.isEmpty() || releaseVersion.isEmpty()) {
return false;
}
return releaseFileName.equals(downloadedFileName) && releaseVersion.equals(downloadedVersion);
}
@Nullable
private DownloadProgressSnapshot queryDownloadProgress(long downloadId) {
DownloadManager manager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
if (manager == null) {
return null;
}
DownloadManager.Query query = new DownloadManager.Query().setFilterById(downloadId);
try (android.database.Cursor cursor = manager.query(query)) {
if (cursor == null || !cursor.moveToFirst()) {
return null;
}
return new DownloadProgressSnapshot(
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)),
cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)),
cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
);
}
}
private static final class DownloadProgressSnapshot {
private final int status;
private final long bytesDownloaded;
private final long totalBytes;
private DownloadProgressSnapshot(int status, long bytesDownloaded, long totalBytes) {
this.status = status;
this.bytesDownloaded = bytesDownloaded;
this.totalBytes = totalBytes;
}
}
}

View File

@@ -2,8 +2,12 @@ package com.hyzq.boss;
import android.os.Bundle;
import android.view.View;
import android.text.InputType;
import android.content.Intent;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.Spinner;
@@ -17,20 +21,31 @@ 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;
private static final String[] PROVIDER_VALUES = {"master_codex_node", "openai_api", "aliyun_qwen_api"};
private static final String[] PROVIDER_LABELS = {"Master Codex Node", "OpenAI API", "阿里百炼 Qwen"};
private static final String[] ALIYUN_QWEN_MODEL_VALUES = {"qwen3.5-plus", "qwen3.5-flash", "__custom__"};
private static final String[] ALIYUN_QWEN_MODEL_LABELS = {"qwen3.5-plus", "qwen3.5-flash", "自定义模型"};
private static final String ALIYUN_QWEN_PROVIDER = "aliyun_qwen_api";
private boolean refreshOnResume;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("AI 账号", "主 GPT / 备用 GPT / API 容灾");
configureScreen("AI 账号", "OpenAI API / Master Codex Node");
setHeaderAction("新增", v -> openAccountEditor(null, null));
replaceContent(buildIntroCard(), buildAccountListShell());
replaceContent();
reload();
}
@Override
protected void onResume() {
super.onResume();
if (refreshOnResume) {
refreshOnResume = false;
reload();
}
}
@Override
protected void reload() {
setRefreshing(true);
@@ -48,56 +63,94 @@ public class AiAccountsActivity extends BossScreenActivity {
});
}
private LinearLayout buildIntroCard() {
return BossUi.buildCard(
this,
"账号说明",
"当前页面管理 Boss 的主控 AI 账号。主链路优先使用已绑定电脑上的 Master Codex NodeAPI 容灾在同页可补充配置。",
"支持新增、编辑、激活、校验和删除"
);
}
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));
replaceContent();
appendContent(BossUi.buildWechatMenuRow(
this,
"AI 账号",
"这里统一管理主 GPT、备用 GPT 与 API 容灾账号。",
"OpenAI API 可以在手机直接登录Master Codex Node 仍然在绑定设备上完成登录。",
null,
null
));
appendContent(buildActiveIdentityCard(activeIdentity));
appendContent(buildOnboardingEntrySection());
appendContent(buildAccountsSection(accounts));
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);
if (activeIdentity == null) {
LinearLayout empty = new LinearLayout(this);
empty.setOrientation(LinearLayout.VERTICAL);
empty.addView(BossUi.buildWechatMenuRow(
this,
"当前主控身份",
"当前没有可用账号。",
"请先新增或启用一个账号",
null,
null
));
return empty;
}
String subtitle = activeIdentity.optString("label", "AI 账号")
+ " · " + activeIdentity.optString("displayName", "-");
String meta = activeIdentity.optString("roleLabel", "-")
+ " · " + activeIdentity.optString("providerLabel", "-")
+ " · " + activeIdentity.optString("statusLabel", "-");
String note = activeIdentity.optString("note", "");
String activeAccountId = activeIdentity.optString("accountId", "");
boolean canGenerate = activeIdentity.optBoolean("canGenerate", false);
LinearLayout card = new LinearLayout(this);
card.setOrientation(LinearLayout.VERTICAL);
card.addView(BossUi.buildWechatMenuRow(
this,
"当前主控身份",
subtitle,
meta,
activeIdentity.optBoolean("isEnvironmentFallback") ? "环境" : "当前",
null
));
if (!note.isEmpty()) {
card.addView(BossUi.buildWechatMenuRow(
this,
"主控状态",
note,
activeIdentity.optString("switchReason", ""),
null,
null
));
}
if (!activeAccountId.isEmpty()) {
Button validate = BossUi.buildMiniActionButton(this, "校验主控", false);
validate.setOnClickListener(v -> validateAccount(activeAccountId));
Button testMasterAgent = BossUi.buildMiniActionButton(this, "测试主 Agent 对话", canGenerate);
testMasterAgent.setEnabled(canGenerate);
testMasterAgent.setOnClickListener(v -> openMasterAgentConversation());
card.addView(BossUi.buildInlineActionRow(this, validate, testMasterAgent));
}
return card;
}
private LinearLayout buildAccountsSection(@Nullable JSONArray accounts) {
LinearLayout section = new LinearLayout(this);
section.setOrientation(LinearLayout.VERTICAL);
section.addView(BossUi.buildCard(
section.addView(BossUi.buildWechatMenuRow(
this,
"账号列表",
accounts == null || accounts.length() == 0 ? "当前还没有 AI 账号。" : "击卡片可编辑,按钮可激活 / 校验 / 删除。",
"当前 API/api/v1/accounts"
accounts == null || accounts.length() == 0 ? "当前还没有 AI 账号。" : "可编辑,按钮可激活、校验或删除。",
null,
null,
null
));
if (accounts == null || accounts.length() == 0) {
@@ -113,71 +166,371 @@ public class AiAccountsActivity extends BossScreenActivity {
return section;
}
private LinearLayout buildOnboardingEntrySection() {
LinearLayout section = new LinearLayout(this);
section.setOrientation(LinearLayout.VERTICAL);
section.addView(BossUi.buildWechatMenuRow(
this,
"登录 OpenAI 平台账号",
"先打开 OpenAI 登录页,再回 APP 完成接入。",
"成功后会立即设为当前主控。",
"推荐",
v -> openOpenAiOnboardingScreen()
));
section.addView(BossUi.buildWechatMenuRow(
this,
"接入阿里百炼备用账号",
"把阿里百炼 Qwen 兼容接口接成主 Agent 的备用链路。",
"建议模型qwen3.5-plus 或 qwen3.5-flash。",
null,
v -> openAliyunQwenOnboardingDialog()
));
section.addView(BossUi.buildWechatMenuRow(
this,
"绑定电脑上的 Codex 节点",
"把这台 Mac 上的 Codex / ChatGPT Plus 节点接回主 Agent。",
"登录发生在绑定设备上。",
null,
v -> openMasterNodeOnboardingDialog()
));
return section;
}
private void openOpenAiOnboardingScreen() {
refreshOnResume = true;
Intent intent = new Intent(this, OpenAiOnboardingActivity.class);
intent.putExtra(OpenAiOnboardingActivity.EXTRA_AUTO_OPEN_LOGIN, true);
startActivity(intent);
}
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"));
StringBuilder subtitle = new StringBuilder(account.optString("displayName", "-"));
if (!account.optString("accountIdentifier").isEmpty()) {
subtitle.append(" · ").append(account.optString("accountIdentifier", "-"));
}
if (!account.optString("nodeLabel").isEmpty()) {
subtitle.append(" · ").append(account.optString("nodeLabel", "-"));
}
LinearLayout card = BossUi.buildCard(
LinearLayout card = new LinearLayout(this);
card.setOrientation(LinearLayout.VERTICAL);
card.addView(BossUi.buildWechatMenuRow(
this,
account.optString("label", "未命名账号"),
body,
subtitle.toString(),
meta,
account.optBoolean("isActive") ? "当前" : null,
v -> openAccountEditor(account, null)
);
));
Button activate = BossUi.buildPrimaryButton(this, account.optBoolean("isActive") ? "已激活" : "设为当前主控");
Button activate = BossUi.buildMiniActionButton(this, account.optBoolean("isActive") ? "当前主控" : "设为当前", !account.optBoolean("isActive"));
activate.setEnabled(!account.optBoolean("isActive"));
activate.setOnClickListener(v -> activateAccount(account));
card.addView(activate);
Button validate = BossUi.buildSecondaryButton(this, "校验连接");
Button loginGuide = null;
if ("master_codex_node".equals(account.optString("provider"))) {
loginGuide = BossUi.buildMiniActionButton(this, "登录指引", false);
loginGuide.setOnClickListener(v -> showMasterNodeLoginGuide(account));
}
Button validate = BossUi.buildMiniActionButton(this, "校验连接", false);
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, "删除账号");
Button delete = BossUi.buildMiniActionButton(this, "删除账号", false);
delete.setOnClickListener(v -> confirmDeleteAccount(account));
card.addView(delete);
card.addView(loginGuide == null
? BossUi.buildInlineActionRow(this, activate, validate, delete)
: BossUi.buildInlineActionRow(this, activate, loginGuide, validate, 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;
private void showMasterNodeLoginGuide(JSONObject account) {
String nodeLabel = account.optString("nodeLabel");
if (nodeLabel == null || nodeLabel.trim().isEmpty()) {
nodeLabel = account.optString("nodeId");
}
if (nodeLabel == null || nodeLabel.trim().isEmpty()) {
nodeLabel = "绑定设备";
}
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));
String message = "主 GPT 不在手机里直接登录。\n\n"
+ "请到绑定设备 " + nodeLabel + " 上打开 Codex / ChatGPT Plus 会话完成登录。\n"
+ "登录完成后,回到这里点“校验连接”,确认主 Agent relay 已经接通。";
new AlertDialog.Builder(this)
.setTitle("主 GPT 登录指引")
.setMessage(message)
.setPositiveButton("知道了", null)
.show();
}
private void openOpenAiOnboardingDialog() {
final EditText labelInput = BossUi.buildInput(this, "标签,例如 主 GPT", false);
labelInput.setText("主 GPT");
final EditText displayNameInput = BossUi.buildInput(this, "显示名称", false);
displayNameInput.setText("OpenAI 平台账号");
final EditText accountIdentifierInput = BossUi.buildInput(this, "账号标识 / 备注", false);
final EditText modelInput = BossUi.buildInput(this, "模型,例如 gpt-5.4", false);
modelInput.setText("gpt-5.4");
final EditText apiKeyInput = BossUi.buildInput(this, "OpenAI API Key", false);
apiKeyInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
LinearLayout form = new LinearLayout(this);
form.setOrientation(LinearLayout.VERTICAL);
form.addView(BossUi.buildFormCell(this, "标签", "建议使用 主 GPT", labelInput));
form.addView(BossUi.buildFormCell(this, "显示名称", "会展示在账号列表中", displayNameInput));
form.addView(BossUi.buildFormCell(this, "账号标识", "可填邮箱、账号名或自定义备注", accountIdentifierInput));
form.addView(BossUi.buildFormCell(this, "模型", "例如 gpt-5.4", modelInput));
form.addView(BossUi.buildFormCell(this, "API Key", "填写后会直接登录并设为当前主控", apiKeyInput));
new AlertDialog.Builder(this)
.setTitle("登录 OpenAI 平台账号")
.setMessage("手机端直接输入 OpenAI API Key登录成功后立即设为当前主控。")
.setView(form)
.setNegativeButton("取消", null)
.setPositiveButton("登录", (dialog, which) -> submitOpenAiOnboarding(
labelInput.getText().toString().trim(),
displayNameInput.getText().toString().trim(),
accountIdentifierInput.getText().toString().trim(),
modelInput.getText().toString().trim(),
apiKeyInput.getText().toString().trim()
))
.show();
}
private void openMasterNodeOnboardingDialog() {
final EditText labelInput = BossUi.buildInput(this, "标签,例如 主 GPT", false);
labelInput.setText("主 GPT");
final EditText displayNameInput = BossUi.buildInput(this, "显示名称", false);
displayNameInput.setText("绑定电脑上的 Codex 节点");
final EditText accountIdentifierInput = BossUi.buildInput(this, "账号标识 / 备注", false);
final EditText nodeIdInput = BossUi.buildInput(this, "节点 ID", false);
final EditText nodeLabelInput = BossUi.buildInput(this, "节点名称", false);
final EditText modelInput = BossUi.buildInput(this, "模型,例如 gpt-5.4", false);
modelInput.setText("gpt-5.4");
LinearLayout form = new LinearLayout(this);
form.setOrientation(LinearLayout.VERTICAL);
form.addView(BossUi.buildFormCell(this, "标签", "建议使用 主 GPT", labelInput));
form.addView(BossUi.buildFormCell(this, "显示名称", "会展示在账号列表中", displayNameInput));
form.addView(BossUi.buildFormCell(this, "账号标识", "可填账号名或自定义备注", accountIdentifierInput));
form.addView(BossUi.buildFormCell(this, "节点 ID", "本机 Codex 节点的唯一标识", nodeIdInput));
form.addView(BossUi.buildFormCell(this, "节点名称", "例如 Mac Studio", nodeLabelInput));
form.addView(BossUi.buildFormCell(this, "模型", "例如 gpt-5.4", modelInput));
new AlertDialog.Builder(this)
.setTitle("绑定电脑上的 Codex 节点")
.setMessage("主 GPT 不在手机里直接登录,请在绑定设备上的 Codex / ChatGPT Plus 会话里登录后再回来校验。")
.setView(form)
.setNegativeButton("取消", null)
.setPositiveButton("绑定", (dialog, which) -> submitMasterNodeOnboarding(
labelInput.getText().toString().trim(),
displayNameInput.getText().toString().trim(),
accountIdentifierInput.getText().toString().trim(),
nodeIdInput.getText().toString().trim(),
nodeLabelInput.getText().toString().trim(),
modelInput.getText().toString().trim()
))
.show();
}
private void openAliyunQwenOnboardingDialog() {
final EditText labelInput = BossUi.buildInput(this, "标签,例如 备用 GPT", false);
labelInput.setText("备用 GPT");
final EditText displayNameInput = BossUi.buildInput(this, "显示名称", false);
displayNameInput.setText("阿里百炼备用账号");
final EditText accountIdentifierInput = BossUi.buildInput(this, "账号标识 / 备注", false);
final EditText apiKeyInput = BossUi.buildInput(this, "阿里百炼 API Key", false);
apiKeyInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
final AliyunModelSelection modelSelection = buildAliyunQwenModelSelection("qwen3.5-plus");
LinearLayout form = new LinearLayout(this);
form.setOrientation(LinearLayout.VERTICAL);
form.addView(BossUi.buildFormCell(this, "标签", "建议使用 备用 GPT", labelInput));
form.addView(BossUi.buildFormCell(this, "显示名称", "会展示在账号列表中", displayNameInput));
form.addView(BossUi.buildFormCell(this, "账号标识", "可填账号名或自定义备注", accountIdentifierInput));
form.addView(BossUi.buildFormCell(this, "模型", "预设 qwen3.5-plus / qwen3.5-flash不适用时切换自定义模型。", modelSelection.container));
form.addView(BossUi.buildFormCell(this, "API Key", "填写后会保存为备用链路,不会抢占当前主控", apiKeyInput));
new AlertDialog.Builder(this)
.setTitle("接入阿里百炼备用账号")
.setMessage("接入成功后,这个账号会作为主 Agent 的备用模型链路,在主节点离线或失败时自动接管。")
.setView(form)
.setNegativeButton("取消", null)
.setPositiveButton("接入", (dialog, which) -> submitAliyunQwenOnboarding(
labelInput.getText().toString().trim(),
displayNameInput.getText().toString().trim(),
accountIdentifierInput.getText().toString().trim(),
modelSelection.resolveModel(),
apiKeyInput.getText().toString().trim()
))
.show();
}
private void submitOpenAiOnboarding(
String label,
String displayName,
String accountIdentifier,
String model,
String apiKey
) {
if (label.isEmpty() || displayName.isEmpty() || apiKey.isEmpty()) {
showMessage("标签、显示名称和 API Key 不能为空");
return;
}
return section;
setRefreshing(true);
executor.execute(() -> {
try {
JSONObject payload = new JSONObject();
payload.put("label", label);
payload.put("displayName", displayName);
payload.put("accountIdentifier", accountIdentifier);
payload.put("model", model);
payload.put("apiKey", apiKey);
payload.put("enabled", true);
payload.put("setActive", true);
payload.put("provider", "openai_api");
payload.put("role", "primary");
BossApiClient.ApiResponse response = apiClient.onboardOpenAiApiAccount(payload);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
showMessage("OpenAI 平台账号已登录,并设为当前主控。");
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
String detail = error.getMessage();
showMessage(detail == null || detail.trim().isEmpty()
? "OpenAI 平台账号登录失败,请稍后重试。"
: "OpenAI 平台账号登录失败:" + detail);
});
}
});
}
private void submitMasterNodeOnboarding(
String label,
String displayName,
String accountIdentifier,
String nodeId,
String nodeLabel,
String model
) {
if (label.isEmpty() || displayName.isEmpty() || nodeId.isEmpty()) {
showMessage("标签、显示名称和节点 ID 不能为空");
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("enabled", true);
payload.put("setActive", true);
payload.put("provider", "master_codex_node");
payload.put("role", "primary");
BossApiClient.ApiResponse response = apiClient.onboardMasterNodeAccount(payload);
if (!response.ok()) throw new IllegalStateException(response.message());
String accountId = extractAccountId(response.json);
if (accountId.isEmpty()) {
runOnUiThread(() -> {
showMessage("Master Codex Node 已绑定,并设为当前主控。");
reload();
});
return;
}
BossApiClient.ApiResponse validation = apiClient.validateAccount(accountId);
runOnUiThread(() -> {
showMessage(validation.ok()
? validation.message()
: "绑定完成,但校验失败:" + validation.message());
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("绑定失败:" + error.getMessage());
});
}
});
}
private void submitAliyunQwenOnboarding(
String label,
String displayName,
String accountIdentifier,
String model,
String apiKey
) {
if (label.isEmpty() || displayName.isEmpty() || apiKey.isEmpty()) {
showMessage("标签、显示名称和 API Key 不能为空");
return;
}
if (model.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("model", model);
payload.put("apiKey", apiKey);
BossApiClient.ApiResponse response = apiClient.onboardAliyunQwenAccount(payload);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
showMessage("阿里百炼备用账号已接入。");
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
String detail = error.getMessage();
showMessage(detail == null || detail.trim().isEmpty()
? "阿里百炼备用账号接入失败,请稍后重试。"
: "阿里百炼备用账号接入失败:" + detail);
});
}
});
}
private String extractAccountId(JSONObject json) {
if (json == null) {
return "";
}
String accountId = json.optString("accountId", "");
if (!accountId.isEmpty()) {
return accountId;
}
JSONObject account = json.optJSONObject("account");
if (account != null) {
return account.optString("accountId", "");
}
return "";
}
private void openAccountEditor(@Nullable JSONObject existing, @Nullable String apiKeyHint) {
@@ -187,12 +540,17 @@ public class AiAccountsActivity extends BossScreenActivity {
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 AliyunModelSelection aliyunModelSelection = buildAliyunQwenModelSelection(
existing == null ? "qwen3.5-plus" : existing.optString("model", "")
);
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 LinearLayout modelFieldContainer = new LinearLayout(this);
modelFieldContainer.setOrientation(LinearLayout.VERTICAL);
final SwitchCompat enabledSwitch = new SwitchCompat(this);
enabledSwitch.setText("启用");
enabledSwitch.setChecked(existing == null || existing.optBoolean("enabled", true));
@@ -215,20 +573,41 @@ public class AiAccountsActivity extends BossScreenActivity {
apiKeyInput.setText(apiKeyHint);
}
final Runnable refreshModelField = () -> {
modelFieldContainer.removeAllViews();
if (ALIYUN_QWEN_PROVIDER.equals(PROVIDER_VALUES[providerSpinner.getSelectedItemPosition()])) {
modelFieldContainer.addView(aliyunModelSelection.container);
} else {
modelFieldContainer.addView(modelInput);
}
};
providerSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
refreshModelField.run();
}
@Override
public void onNothingSelected(AdapterView<?> parent) {
refreshModelField.run();
}
});
refreshModelField.run();
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);
form.addView(BossUi.buildFormCell(this, "标签", "例如 主 GPT", labelInput));
form.addView(BossUi.buildFormCell(this, "显示名称", "会展示在账号列表中", displayNameInput));
form.addView(BossUi.buildFormCell(this, "账号标识", "邮箱、登录名或备注信息", accountIdentifierInput));
form.addView(BossUi.buildFormCell(this, "节点 ID", "Master Codex Node 的唯一标识", nodeIdInput));
form.addView(BossUi.buildFormCell(this, "节点名称", "用于快速识别节点", nodeLabelInput));
form.addView(BossUi.buildFormCell(this, "模型", "OpenAI / Master Node 使用通用模型名,阿里百炼会自动切换到预设选择。", modelFieldContainer));
form.addView(BossUi.buildFormCell(this, "API Key", "OpenAI API / 阿里百炼 Qwen 模式需要", apiKeyInput));
form.addView(BossUi.buildFormCell(this, "登录状态备注", "可记录 Plus、有无风控等状态", loginStatusInput));
form.addView(BossUi.buildFormCell(this, "账号角色", null, roleSpinner));
form.addView(BossUi.buildFormCell(this, "提供方", null, providerSpinner));
form.addView(BossUi.buildFormCell(this, "启用状态", null, enabledSwitch));
form.addView(BossUi.buildFormCell(this, "保存后动作", null, setActiveSwitch));
new AlertDialog.Builder(this)
.setTitle(existing == null ? "新增 AI 账号" : "编辑 AI 账号")
@@ -241,7 +620,9 @@ public class AiAccountsActivity extends BossScreenActivity {
accountIdentifierInput.getText().toString().trim(),
nodeIdInput.getText().toString().trim(),
nodeLabelInput.getText().toString().trim(),
modelInput.getText().toString().trim(),
ALIYUN_QWEN_PROVIDER.equals(PROVIDER_VALUES[providerSpinner.getSelectedItemPosition()])
? aliyunModelSelection.resolveModel()
: modelInput.getText().toString().trim(),
apiKeyInput.getText().toString().trim(),
loginStatusInput.getText().toString().trim(),
enabledSwitch.isChecked(),
@@ -268,7 +649,11 @@ public class AiAccountsActivity extends BossScreenActivity {
String provider
) {
if (label.isEmpty() || displayName.isEmpty()) {
showMessage("标签和显示名称不能为空");
showMessage("标签和显示名称不能为空");
return;
}
if (model.isEmpty()) {
showMessage("模型不能为空");
return;
}
setRefreshing(true);
@@ -314,6 +699,15 @@ public class AiAccountsActivity extends BossScreenActivity {
return 0;
}
private int indexOfOrMinusOne(String[] values, String target) {
for (int i = 0; i < values.length; i++) {
if (values[i].equals(target)) {
return i;
}
}
return -1;
}
private void activateAccount(JSONObject account) {
setRefreshing(true);
executor.execute(() -> {
@@ -334,13 +728,21 @@ public class AiAccountsActivity extends BossScreenActivity {
}
private void validateAccount(JSONObject account) {
validateAccount(account.optString("accountId"));
}
private void validateAccount(String accountId) {
if (accountId == null || accountId.trim().isEmpty()) {
showMessage("当前账号没有可用的账号 ID暂时无法校验。");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.validateAccount(account.optString("accountId"));
BossApiClient.ApiResponse response = apiClient.validateAccount(accountId.trim());
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
showMessage("账号校验成功");
showMessage(response.message());
reload();
});
} catch (Exception error) {
@@ -352,6 +754,13 @@ public class AiAccountsActivity extends BossScreenActivity {
});
}
private void openMasterAgentConversation() {
Intent intent = new Intent(this, ProjectDetailActivity.class);
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent");
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
startActivity(intent);
}
private void confirmDeleteAccount(JSONObject account) {
new AlertDialog.Builder(this)
.setTitle("删除 AI 账号")
@@ -379,4 +788,59 @@ public class AiAccountsActivity extends BossScreenActivity {
}
});
}
private AliyunModelSelection buildAliyunQwenModelSelection(String initialModel) {
LinearLayout container = new LinearLayout(this);
container.setOrientation(LinearLayout.VERTICAL);
final Spinner presetSpinner = new Spinner(this);
presetSpinner.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, ALIYUN_QWEN_MODEL_LABELS));
final EditText customModelInput = BossUi.buildInput(this, "自定义模型名", false);
int presetIndex = indexOfOrMinusOne(ALIYUN_QWEN_MODEL_VALUES, initialModel);
if (presetIndex >= 0 && presetIndex < ALIYUN_QWEN_MODEL_VALUES.length - 1) {
presetSpinner.setSelection(presetIndex);
customModelInput.setVisibility(View.GONE);
} else {
presetSpinner.setSelection(ALIYUN_QWEN_MODEL_VALUES.length - 1);
customModelInput.setText(initialModel == null ? "" : initialModel);
customModelInput.setVisibility(View.VISIBLE);
}
presetSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
customModelInput.setVisibility(position == ALIYUN_QWEN_MODEL_VALUES.length - 1 ? View.VISIBLE : View.GONE);
}
@Override
public void onNothingSelected(AdapterView<?> parent) {
customModelInput.setVisibility(View.GONE);
}
});
container.addView(BossUi.buildFormCell(this, "预设模型", "建议选择 qwen3.5-plus 或 qwen3.5-flash。", presetSpinner));
container.addView(BossUi.buildFormCell(this, "自定义模型", "预设不适用时填写完整模型名。", customModelInput));
return new AliyunModelSelection(container, presetSpinner, customModelInput);
}
private static final class AliyunModelSelection {
private final LinearLayout container;
private final Spinner presetSpinner;
private final EditText customModelInput;
AliyunModelSelection(LinearLayout container, Spinner presetSpinner, EditText customModelInput) {
this.container = container;
this.presetSpinner = presetSpinner;
this.customModelInput = customModelInput;
}
String resolveModel() {
int selectedIndex = presetSpinner.getSelectedItemPosition();
if (selectedIndex >= 0 && selectedIndex < ALIYUN_QWEN_MODEL_VALUES.length - 1) {
return ALIYUN_QWEN_MODEL_VALUES[selectedIndex];
}
return customModelInput.getText().toString().trim();
}
}
}

View File

@@ -0,0 +1,41 @@
package com.hyzq.boss;
import android.net.Uri;
import androidx.annotation.Nullable;
public final class AttachmentComposerState {
private AttachmentComposerState() {}
public static boolean requiresConfirmation(@Nullable String sourceType) {
return ProjectChatUiState.requiresAttachmentConfirmation(sourceType);
}
public static final class PendingAttachment {
public final String sourceType;
public final String fileName;
public final String mimeType;
public final long fileSizeBytes;
@Nullable public final Uri uri;
public PendingAttachment(
String sourceType,
String fileName,
String mimeType,
long fileSizeBytes,
@Nullable Uri uri
) {
this.sourceType = sourceType == null ? "file" : sourceType;
this.fileName = fileName == null || fileName.trim().isEmpty() ? "attachment" : fileName;
this.mimeType = mimeType == null || mimeType.trim().isEmpty()
? "application/octet-stream"
: mimeType;
this.fileSizeBytes = Math.max(fileSizeBytes, 0L);
this.uri = uri;
}
public boolean requiresConfirmation() {
return AttachmentComposerState.requiresConfirmation(sourceType);
}
}
}

View File

@@ -4,11 +4,16 @@ import android.content.Context;
import android.content.SharedPreferences;
import android.net.Uri;
import androidx.annotation.Nullable;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONArray;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
@@ -17,10 +22,16 @@ import java.io.OutputStreamWriter;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.List;
import java.util.Map;
public class BossApiClient {
private static final int DEFAULT_CONNECT_TIMEOUT_MS = 12000;
private static final int DEFAULT_READ_TIMEOUT_MS = 12000;
private static final int CONVERSATIONS_READ_TIMEOUT_MS = 30000;
private static final int CHAT_FLOW_READ_TIMEOUT_MS = 65000;
private static final int CHAT_SEND_READ_TIMEOUT_MS = 20000;
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";
@@ -31,14 +42,22 @@ public class BossApiClient {
private final String baseUrl;
public BossApiClient(Context context) {
this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
this.baseUrl = BuildConfig.BOSS_API_BASE_URL;
this(context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE), BuildConfig.BOSS_API_BASE_URL);
}
BossApiClient(SharedPreferences prefs, String baseUrl) {
this.prefs = prefs;
this.baseUrl = baseUrl;
}
public boolean hasSessionHints() {
return !getSessionCookie().isEmpty() || !getRestoreToken().isEmpty();
}
boolean hasRestoreToken() {
return !getRestoreToken().isEmpty();
}
public ApiResponse autoLogin() throws IOException, JSONException {
ApiResponse response = request("POST", "/api/auth/login", new JSONObject(), false);
if (response.ok()) {
@@ -65,25 +84,390 @@ public class BossApiClient {
}
public ApiResponse getConversations() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/conversations", null);
return requestWithRestoreRaw(
"GET",
"/api/v1/conversations",
null,
DEFAULT_CONNECT_TIMEOUT_MS,
CONVERSATIONS_READ_TIMEOUT_MS
);
}
public ApiResponse getConversationHome() throws IOException, JSONException {
return requestWithRestoreRaw(
"GET",
"/api/v1/conversations/home",
null,
DEFAULT_CONNECT_TIMEOUT_MS,
CONVERSATIONS_READ_TIMEOUT_MS
);
}
public ApiResponse getConversationFolder(String folderKey) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/conversation-folders/" + encode(folderKey), null);
}
public ApiResponse getProjectDetail(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null);
}
public ApiResponse getProjectMessages(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/messages", null);
}
public ApiResponse getDispatchPlans(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/dispatch-plans", null);
}
public ApiResponse getConversationParticipants(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/participants", null);
}
public ApiResponse getProjectAgentControls(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/agent-controls", null);
}
public ApiResponse updateProjectAgentControls(
String projectId,
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride
) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("modelOverride", modelOverride == null ? JSONObject.NULL : modelOverride);
payload.put("reasoningEffortOverride", reasoningEffortOverride == null ? JSONObject.NULL : reasoningEffortOverride);
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
}
public ApiResponse updateProjectAgentControls(
String projectId,
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride,
@Nullable String fastModelOverride,
@Nullable String fastReasoningEffortOverride,
@Nullable String smartModelOverride,
@Nullable String smartReasoningEffortOverride
) throws IOException, JSONException {
JSONObject payload = buildProjectAgentControlsPayload(
modelOverride,
reasoningEffortOverride,
fastModelOverride,
fastReasoningEffortOverride,
smartModelOverride,
smartReasoningEffortOverride,
false
);
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
}
public ApiResponse updateProjectAgentControls(
String projectId,
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride,
@Nullable String fastModelOverride,
@Nullable String fastReasoningEffortOverride,
@Nullable String smartModelOverride,
@Nullable String smartReasoningEffortOverride,
boolean includeAdvancedOverrides
) throws IOException, JSONException {
JSONObject payload = buildProjectAgentControlsPayload(
modelOverride,
reasoningEffortOverride,
fastModelOverride,
fastReasoningEffortOverride,
smartModelOverride,
smartReasoningEffortOverride,
includeAdvancedOverrides
);
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
}
static JSONObject buildProjectAgentControlsPayload(
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride,
@Nullable String fastModelOverride,
@Nullable String fastReasoningEffortOverride,
@Nullable String smartModelOverride,
@Nullable String smartReasoningEffortOverride,
boolean includeAdvancedOverrides
) throws JSONException {
JSONObject payload = new JSONObject();
payload.put("modelOverride", modelOverride == null ? JSONObject.NULL : modelOverride);
payload.put("reasoningEffortOverride", reasoningEffortOverride == null ? JSONObject.NULL : reasoningEffortOverride);
boolean hasAdvancedOverrides =
fastModelOverride != null
|| fastReasoningEffortOverride != null
|| smartModelOverride != null
|| smartReasoningEffortOverride != null;
if (includeAdvancedOverrides || hasAdvancedOverrides) {
payload.put("fastModelOverride", fastModelOverride == null ? JSONObject.NULL : fastModelOverride);
payload.put("fastReasoningEffortOverride", fastReasoningEffortOverride == null ? JSONObject.NULL : fastReasoningEffortOverride);
payload.put("smartModelOverride", smartModelOverride == null ? JSONObject.NULL : smartModelOverride);
payload.put("smartReasoningEffortOverride", smartReasoningEffortOverride == null ? JSONObject.NULL : smartReasoningEffortOverride);
}
return payload;
}
public ApiResponse updateProjectAgentControls(
String projectId,
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride,
@Nullable String promptOverride
) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("modelOverride", modelOverride == null ? JSONObject.NULL : modelOverride);
payload.put("reasoningEffortOverride", reasoningEffortOverride == null ? JSONObject.NULL : reasoningEffortOverride);
payload.put("promptOverride", promptOverride == null ? JSONObject.NULL : promptOverride);
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
}
public ApiResponse updateProjectTakeoverSettings(
String projectId,
@Nullable Boolean takeoverEnabled,
@Nullable Boolean globalTakeoverEnabled
) throws IOException, JSONException {
JSONObject payload = new JSONObject();
if (!"master-agent".equals(projectId)) {
if (takeoverEnabled == null) {
payload.put("takeoverEnabled", JSONObject.NULL);
} else {
payload.put("takeoverEnabled", takeoverEnabled);
}
}
if (globalTakeoverEnabled != null || "master-agent".equals(projectId)) {
payload.put(
"globalTakeoverEnabled",
globalTakeoverEnabled == null ? JSONObject.NULL : globalTakeoverEnabled
);
}
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
}
public ApiResponse getProjectOrchestrationBackend(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/orchestration-backend", null);
}
public ApiResponse updateProjectOrchestrationBackend(String projectId, @Nullable String requestedBackendId) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("requestedBackendId", requestedBackendId == null ? JSONObject.NULL : requestedBackendId);
return requestWithRestore("PATCH", "/api/v1/projects/" + encode(projectId) + "/orchestration-backend", payload);
}
public ApiResponse getMasterAgentPromptProfile(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/prompt-profile", null);
}
public ApiResponse updateMasterAgentPromptProfile(String projectId, JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/prompt-profile", payload);
}
public ApiResponse getMasterAgentMemories(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/memories", null);
}
public ApiResponse getMasterAgentEvolution() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/master-agent/evolution", null);
}
public ApiResponse updateMasterAgentEvolutionMode(String mode) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("mode", mode);
return requestWithRestore("POST", "/api/v1/master-agent/evolution/config", payload);
}
public ApiResponse approveMasterAgentEvolutionProposal(String proposalId) throws IOException, JSONException {
return requestWithRestore(
"POST",
"/api/v1/master-agent/evolution/proposals/" + encode(proposalId) + "/approve",
new JSONObject()
);
}
public ApiResponse rejectMasterAgentEvolutionProposal(String proposalId) throws IOException, JSONException {
return requestWithRestore(
"POST",
"/api/v1/master-agent/evolution/proposals/" + encode(proposalId) + "/reject",
new JSONObject()
);
}
public ApiResponse createMasterAgentMemory(String projectId, JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/memories", payload);
}
public ApiResponse updateMasterAgentMemory(String projectId, String memoryId, JSONObject payload) throws IOException, JSONException {
return requestWithRestore("PATCH", "/api/v1/projects/" + encode(projectId) + "/memories/" + encode(memoryId), payload);
}
public ApiResponse deleteMasterAgentMemory(String projectId, String memoryId) throws IOException, JSONException {
return requestWithRestore("DELETE", "/api/v1/projects/" + encode(projectId) + "/memories/" + encode(memoryId), null);
}
public ApiResponse confirmDispatchPlan(
String projectId,
String planId,
JSONArray approvedTargetProjectIds,
boolean rememberLightReminder
) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put(
"approvedTargetProjectIds",
approvedTargetProjectIds == null ? new JSONArray() : approvedTargetProjectIds
);
payload.put("rememberLightReminder", rememberLightReminder);
return requestWithRestoreRaw(
"POST",
"/api/v1/projects/" + encode(projectId) + "/dispatch-plans/" + encode(planId) + "/confirm",
payload.toString(),
DEFAULT_CONNECT_TIMEOUT_MS,
CHAT_FLOW_READ_TIMEOUT_MS
);
}
public ApiResponse confirmDispatchPlan(String projectId, String planId, JSONArray approvedTargetProjectIds) throws IOException, JSONException {
return confirmDispatchPlan(projectId, planId, approvedTargetProjectIds, false);
}
public ApiResponse rejectDispatchPlan(String projectId, String planId) throws IOException, JSONException {
return requestWithRestoreRaw(
"POST",
"/api/v1/projects/" + encode(projectId) + "/dispatch-plans/" + encode(planId) + "/reject",
new JSONObject().toString(),
DEFAULT_CONNECT_TIMEOUT_MS,
CHAT_FLOW_READ_TIMEOUT_MS
);
}
public ApiResponse retryDispatchPlan(String projectId, String planId) throws IOException, JSONException {
return requestWithRestoreRaw(
"POST",
"/api/v1/projects/" + encode(projectId) + "/dispatch-plans/" + encode(planId) + "/retry",
new JSONObject().toString(),
DEFAULT_CONNECT_TIMEOUT_MS,
CHAT_FLOW_READ_TIMEOUT_MS
);
}
public ApiResponse renameConversation(String projectId, String name, boolean group) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("name", name);
payload.put("mode", group ? "group" : "thread");
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/rename", payload);
}
public ApiResponse createGroupChat(String projectId, JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/group-chat", payload == null ? new JSONObject() : payload);
}
public ApiResponse createStandaloneGroupChat(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/group-chats", payload == null ? new JSONObject() : payload);
}
public ApiResponse getThreadStatus(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/thread-status", null);
}
public ApiResponse replaceConversationParticipants(String projectId, JSONArray memberProjectIds) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("memberProjectIds", memberProjectIds == null ? new JSONArray() : memberProjectIds);
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/participants", payload);
}
public ApiResponse updateProjectDispatchReminder(String projectId, boolean lightDispatchReminderEnabled) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("lightDispatchReminderEnabled", lightDispatchReminderEnabled);
return requestWithRestore("PATCH", "/api/v1/projects/" + encode(projectId) + "/dispatch-reminder", payload);
}
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);
return requestWithRestoreRaw(
"POST",
"/api/v1/projects/" + encode(projectId) + "/messages",
payload.toString(),
DEFAULT_CONNECT_TIMEOUT_MS,
CHAT_SEND_READ_TIMEOUT_MS
);
}
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 uploadAttachment(
String projectId,
String fileName,
String mimeType,
byte[] bytes,
String sourceType
) throws IOException, JSONException {
return uploadAttachment(
projectId,
fileName,
mimeType,
new ByteArrayInputStream(bytes == null ? new byte[0] : bytes),
sourceType
);
}
public ApiResponse uploadAttachment(
String projectId,
String fileName,
String mimeType,
InputStream inputStream,
String sourceType
) throws IOException, JSONException {
HttpURLConnection connection = openConnection("/api/v1/projects/" + encode(projectId) + "/attachments");
prepareConnection(connection, "POST", DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS);
connection.setDoOutput(true);
String boundary = "BossBoundary" + System.currentTimeMillis();
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
try (OutputStream outputStream = connection.getOutputStream()) {
writeMultipartPart(outputStream, boundary, "sourceType", sourceType, null);
writeMultipartPart(
outputStream,
boundary,
"file",
inputStream,
fileName,
mimeType
);
outputStream.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
}
return executeConnection(connection, true);
}
public ApiResponse analyzeAttachment(String projectId, String attachmentId) throws IOException, JSONException {
return requestWithRestoreRaw(
"POST",
"/api/v1/projects/" + encode(projectId) + "/attachments/" + encode(attachmentId) + "/analyze",
"{}"
);
}
public DownloadedAttachment downloadAttachment(
String attachmentId,
String fallbackFileName,
String fallbackMimeType
) throws IOException {
DownloadedAttachment attachment = downloadAttachmentRaw(attachmentId, fallbackFileName, fallbackMimeType, true);
if (attachment.statusCode == 401 && !getRestoreToken().isEmpty()) {
try {
ApiResponse restored = restoreSession();
if (restored.ok()) {
return downloadAttachmentRaw(attachmentId, fallbackFileName, fallbackMimeType, true);
}
} catch (JSONException exception) {
throw new IOException("SESSION_RESTORE_FAILED", exception);
}
}
return attachment;
}
public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, JSONObject payload) throws IOException, JSONException {
String requestBody = ForwardPayloads.toRequestBody(targetProjectId, payload);
return requestWithRestoreRaw("POST", "/api/v1/projects/" + encode(projectId) + "/forwards", requestBody);
}
public ApiResponse getThreadDetail(String threadId) throws IOException, JSONException {
@@ -121,6 +505,31 @@ public class BossApiClient {
return requestWithRestore("PATCH", "/api/v1/devices/" + encode(deviceId), payload);
}
public ApiResponse updateDevicePreferredExecutionMode(
String deviceId,
@Nullable String preferredExecutionMode
) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put(
"preferredExecutionMode",
preferredExecutionMode == null ? JSONObject.NULL : preferredExecutionMode
);
return updateDevice(deviceId, payload);
}
public ApiResponse updateProjectConflictDecision(
String deviceId,
String projectId,
@Nullable String folderKey,
String decision
) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("projectId", projectId);
payload.put("folderKey", folderKey == null ? JSONObject.NULL : folderKey);
payload.put("conflictDecision", decision);
return updateDevice(deviceId, payload);
}
public ApiResponse getDeviceSkills(String deviceId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/devices/" + encode(deviceId) + "/skills", null);
}
@@ -133,6 +542,24 @@ public class BossApiClient {
return requestWithRestore("POST", "/api/v1/devices/enrollments", payload);
}
public ApiResponse getDeviceImportDraft(String deviceId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/devices/" + encode(deviceId) + "/import-draft", null);
}
public ApiResponse selectDeviceImportCandidates(String deviceId, JSONArray selectedCandidateIds) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("selectedCandidateIds", (Object) (selectedCandidateIds == null ? new JSONArray() : selectedCandidateIds));
return requestWithRestore("POST", "/api/v1/devices/" + encode(deviceId) + "/import-draft/select", payload);
}
public ApiResponse reviewDeviceImportDraft(String deviceId) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/devices/" + encode(deviceId) + "/import-draft/review", new JSONObject());
}
public ApiResponse applyDeviceImportDraft(String deviceId) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/devices/" + encode(deviceId) + "/import-draft/apply", new JSONObject());
}
public ApiResponse getAccounts() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/accounts", null);
}
@@ -159,6 +586,18 @@ public class BossApiClient {
return requestWithRestore("POST", "/api/v1/accounts/" + encode(accountId) + "/validate", new JSONObject());
}
public ApiResponse onboardOpenAiApiAccount(JSONObject payload) throws IOException, JSONException {
return onboardAccount("/api/v1/accounts/onboard/openai-api", payload);
}
public ApiResponse onboardAliyunQwenAccount(JSONObject payload) throws IOException, JSONException {
return onboardAccount("/api/v1/accounts/onboard/aliyun-qwen", payload);
}
public ApiResponse onboardMasterNodeAccount(JSONObject payload) throws IOException, JSONException {
return onboardAccount("/api/v1/accounts/onboard/master-node", payload);
}
public ApiResponse getOpsSummary() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/ops/summary", null);
}
@@ -230,40 +669,120 @@ public class BossApiClient {
}
private ApiResponse requestWithRestore(String method, String path, JSONObject body) throws IOException, JSONException {
ApiResponse response = request(method, path, body, true);
return requestWithRestoreRaw(
method,
path,
body == null ? null : body.toString(),
DEFAULT_CONNECT_TIMEOUT_MS,
DEFAULT_READ_TIMEOUT_MS
);
}
private ApiResponse requestWithRestoreRaw(String method, String path, @Nullable String body) throws IOException, JSONException {
return requestWithRestoreRaw(method, path, body, DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS);
}
private ApiResponse requestWithRestoreRaw(
String method,
String path,
@Nullable String body,
int connectTimeoutMs,
int readTimeoutMs
) throws IOException, JSONException {
ApiResponse response = requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
if (response.statusCode == 401 && !getRestoreToken().isEmpty()) {
ApiResponse restored = restoreSession();
if (restored.ok()) {
return request(method, path, body, true);
return requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
}
}
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);
private ApiResponse onboardAccount(String onboardPath, JSONObject payload) throws IOException, JSONException {
JSONObject normalized = payload == null ? new JSONObject() : new JSONObject(payload.toString());
normalized.put("setActive", true);
ApiResponse response = requestWithRestore("POST", onboardPath, normalized);
if (response.statusCode != 404) {
return response;
}
JSONObject fallbackPayload = new JSONObject(normalized.toString());
String accountId = fallbackPayload.optString("accountId", "");
if (!accountId.isEmpty()) {
return updateAccount(accountId, fallbackPayload);
}
fallbackPayload.remove("accountId");
return createAccount(fallbackPayload);
}
private ApiResponse request(String method, String path, JSONObject body, boolean expectProtected) throws IOException, JSONException {
return requestRaw(
method,
path,
body == null ? null : body.toString(),
expectProtected,
DEFAULT_CONNECT_TIMEOUT_MS,
DEFAULT_READ_TIMEOUT_MS
);
}
private ApiResponse requestRaw(String method, String path, @Nullable String body, boolean expectProtected) throws IOException, JSONException {
return requestRaw(method, path, body, expectProtected, DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS);
}
private ApiResponse requestRaw(
String method,
String path,
@Nullable String body,
boolean expectProtected,
int connectTimeoutMs,
int readTimeoutMs
) throws IOException, JSONException {
HttpURLConnection connection = openConnection(path);
prepareConnection(connection, method, connectTimeoutMs, readTimeoutMs);
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());
writer.write(body);
}
}
return executeConnection(connection, expectProtected);
}
HttpURLConnection openConnection(String path) throws IOException {
return (HttpURLConnection) new URL(baseUrl + path).openConnection();
}
private void prepareConnection(
HttpURLConnection connection,
String method,
int connectTimeoutMs,
int readTimeoutMs
) throws IOException {
connection.setRequestMethod(method);
connection.setConnectTimeout(connectTimeoutMs);
connection.setReadTimeout(readTimeoutMs);
connection.setUseCaches(false);
connection.setDoInput(true);
connection.setRequestProperty("Accept", "application/json");
connection.setRequestProperty("x-boss-native-app", "1");
if ("GET".equals(method)) {
connection.setRequestProperty("Cache-Control", "no-cache, no-store, max-age=0");
connection.setRequestProperty("Pragma", "no-cache");
}
String cookie = getSessionCookie();
if (!cookie.isEmpty()) {
connection.setRequestProperty("Cookie", cookie);
}
}
private ApiResponse executeConnection(HttpURLConnection connection, boolean expectProtected) throws IOException, JSONException {
int statusCode = connection.getResponseCode();
captureSessionCookie(connection.getHeaderFields());
JSONObject json = readJson(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
@@ -278,6 +797,106 @@ public class BossApiClient {
return new ApiResponse(statusCode, json == null ? new JSONObject() : json);
}
private void writeMultipartPart(
OutputStream outputStream,
String boundary,
String fieldName,
String value,
@Nullable String contentType
) throws IOException {
outputStream.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8));
outputStream.write(
("Content-Disposition: form-data; name=\"" + fieldName + "\"\r\n")
.getBytes(StandardCharsets.UTF_8)
);
if (contentType != null && !contentType.isEmpty()) {
outputStream.write(("Content-Type: " + contentType + "\r\n").getBytes(StandardCharsets.UTF_8));
}
outputStream.write("\r\n".getBytes(StandardCharsets.UTF_8));
outputStream.write((value == null ? "" : value).getBytes(StandardCharsets.UTF_8));
outputStream.write("\r\n".getBytes(StandardCharsets.UTF_8));
}
private void writeMultipartPart(
OutputStream outputStream,
String boundary,
String fieldName,
InputStream inputStream,
String fileName,
String contentType
) throws IOException {
outputStream.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8));
outputStream.write(
String.format(
Locale.US,
"Content-Disposition: form-data; name=\"%s\"; filename=\"%s\"\r\n",
fieldName,
escapeMultipartValue(fileName)
).getBytes(StandardCharsets.UTF_8)
);
outputStream.write(
("Content-Type: " + (contentType == null || contentType.isEmpty()
? "application/octet-stream"
: contentType) + "\r\n\r\n").getBytes(StandardCharsets.UTF_8)
);
if (inputStream != null) {
byte[] buffer = new byte[8192];
int read;
while ((read = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, read);
}
}
outputStream.write("\r\n".getBytes(StandardCharsets.UTF_8));
}
private DownloadedAttachment downloadAttachmentRaw(
String attachmentId,
String fallbackFileName,
String fallbackMimeType,
boolean expectProtected
) throws IOException {
HttpURLConnection connection = openConnection("/api/v1/attachments/" + encode(attachmentId) + "/download");
prepareConnection(connection, "GET", DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS);
int statusCode = connection.getResponseCode();
captureSessionCookie(connection.getHeaderFields());
if (statusCode >= 400) {
String errorBody = readText(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
if (statusCode == 401 && !expectProtected) {
clearSession();
}
return DownloadedAttachment.error(statusCode, errorBody);
}
byte[] bytes;
try (InputStream inputStream = connection.getInputStream();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
byte[] buffer = new byte[8192];
int read;
while (inputStream != null && (read = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, read);
}
bytes = outputStream.toByteArray();
}
String contentType = connection.getHeaderField("Content-Type");
String fileName = parseDownloadFileName(connection.getHeaderField("Content-Disposition"));
return new DownloadedAttachment(
statusCode,
fileName == null || fileName.isEmpty() ? fallbackFileName : fileName,
contentType == null || contentType.isEmpty() ? fallbackMimeType : contentType,
bytes,
""
);
}
private String escapeMultipartValue(String value) {
if (value == null) {
return "attachment";
}
return value.replace("\"", "%22");
}
private JSONObject readJson(InputStream stream) throws IOException, JSONException {
if (stream == null) {
return new JSONObject();
@@ -296,6 +915,35 @@ public class BossApiClient {
return new JSONObject(raw);
}
private String readText(InputStream stream) throws IOException {
if (stream == null) {
return "";
}
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);
}
}
return builder.toString();
}
@Nullable
private String parseDownloadFileName(@Nullable String contentDisposition) {
if (contentDisposition == null || contentDisposition.isEmpty()) {
return null;
}
String[] parts = contentDisposition.split(";");
for (String part : parts) {
String trimmed = part.trim();
if (trimmed.startsWith("filename=")) {
return trimmed.substring("filename=".length()).replace("\"", "");
}
}
return null;
}
private void captureSessionCookie(Map<String, List<String>> headers) {
if (headers == null) return;
List<String> setCookieHeaders = headers.get("Set-Cookie");
@@ -316,10 +964,12 @@ public class BossApiClient {
}
}
private void rememberIdentity(JSONObject json) {
void rememberIdentity(JSONObject json) {
if (json == null) return;
JSONObject session = json.optJSONObject("session");
JSONObject source = session != null ? session : json;
JSONObject source = resolveSessionIdentitySource(json);
if (source == null) {
return;
}
SharedPreferences.Editor editor = prefs.edit();
String restoreToken = source.optString("restoreToken", "");
@@ -340,6 +990,24 @@ public class BossApiClient {
editor.apply();
}
@Nullable
private JSONObject resolveSessionIdentitySource(JSONObject json) {
JSONObject session = json.optJSONObject("session");
if (session != null) {
return session;
}
if (
json.has("restoreToken")
|| json.has("account")
|| json.has("role")
|| json.has("expiresAt")
|| json.has("sessionCookie")
) {
return json;
}
return null;
}
private void clearSession() {
prefs.edit()
.remove(KEY_SESSION_COOKIE)
@@ -347,7 +1015,7 @@ public class BossApiClient {
.apply();
}
private String encode(String value) {
String encode(String value) {
return Uri.encode(value);
}
@@ -372,4 +1040,34 @@ public class BossApiClient {
return new ApiResponse(statusCode, json);
}
}
public static class DownloadedAttachment {
public final int statusCode;
public final String fileName;
public final String mimeType;
public final byte[] bytes;
public final String errorBody;
public DownloadedAttachment(
int statusCode,
String fileName,
String mimeType,
byte[] bytes,
String errorBody
) {
this.statusCode = statusCode;
this.fileName = fileName;
this.mimeType = mimeType;
this.bytes = bytes == null ? new byte[0] : bytes;
this.errorBody = errorBody == null ? "" : errorBody;
}
public boolean ok() {
return statusCode >= 200 && statusCode < 300;
}
public static DownloadedAttachment error(int statusCode, String errorBody) {
return new DownloadedAttachment(statusCode, "", "application/octet-stream", new byte[0], errorBody);
}
}
}

View File

@@ -0,0 +1,272 @@
package com.hyzq.boss;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Color;
import android.graphics.Typeface;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.SpannedString;
import android.text.TextUtils;
import android.text.style.BackgroundColorSpan;
import android.text.style.BulletSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.LeadingMarginSpan;
import android.text.style.QuoteSpan;
import android.text.style.RelativeSizeSpan;
import android.text.style.StyleSpan;
import android.text.style.TypefaceSpan;
import android.util.LruCache;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class BossMarkdown {
private static final Pattern HEADING_PATTERN = Pattern.compile("^(#{1,3})\\s+(.+)$");
private static final Pattern BULLET_PATTERN = Pattern.compile("^[-*]\\s+(.+)$");
private static final Pattern ORDERED_PATTERN = Pattern.compile("^(\\d+)\\.\\s+(.+)$");
private static final Pattern INLINE_TOKEN_PATTERN = Pattern.compile("(\\*\\*([^*]+)\\*\\*)|(`([^`]+)`)");;
private static final LruCache<String, CharSequence> RENDER_CACHE = new LruCache<>(180);
private BossMarkdown() {}
public static CharSequence render(Context context, String markdown, boolean outgoing) {
if (TextUtils.isEmpty(markdown) || TextUtils.isEmpty(markdown.trim())) {
return "(空消息)";
}
String cacheKey = buildCacheKey(context, markdown, outgoing);
CharSequence cached = RENDER_CACHE.get(cacheKey);
if (cached != null) {
return cached;
}
Palette palette = Palette.resolve(context, outgoing);
SpannableStringBuilder builder = new SpannableStringBuilder();
String normalized = markdown.replace("\r\n", "\n").replace('\r', '\n');
String[] lines = normalized.split("\n", -1);
boolean inCodeFence = false;
List<String> codeLines = new ArrayList<>();
for (String line : lines) {
String trimmed = line.trim();
if (trimmed.startsWith("```")) {
if (inCodeFence) {
appendCodeBlock(builder, joinCodeLines(codeLines), palette);
codeLines.clear();
}
inCodeFence = !inCodeFence;
continue;
}
if (inCodeFence) {
codeLines.add(line);
continue;
}
if (trimmed.isEmpty()) {
appendBlankLine(builder);
continue;
}
Matcher headingMatcher = HEADING_PATTERN.matcher(line);
if (headingMatcher.matches()) {
int level = headingMatcher.group(1).length();
appendHeading(builder, headingMatcher.group(2), level, palette);
continue;
}
Matcher bulletMatcher = BULLET_PATTERN.matcher(line);
if (bulletMatcher.matches()) {
appendBullet(builder, bulletMatcher.group(1), palette);
continue;
}
Matcher orderedMatcher = ORDERED_PATTERN.matcher(line);
if (orderedMatcher.matches()) {
appendOrdered(builder, orderedMatcher.group(1), orderedMatcher.group(2), palette);
continue;
}
if (trimmed.startsWith(">")) {
appendQuote(builder, trimmed.substring(1).trim(), palette);
continue;
}
appendParagraph(builder, line, palette);
}
if (inCodeFence && !codeLines.isEmpty()) {
appendCodeBlock(builder, joinCodeLines(codeLines), palette);
}
trimTrailingNewline(builder);
CharSequence rendered = SpannedString.valueOf(builder);
RENDER_CACHE.put(cacheKey, rendered);
return rendered;
}
private static String buildCacheKey(Context context, String markdown, boolean outgoing) {
int uiMode = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
return (outgoing ? "out" : "in") + "|" + uiMode + "|" + markdown;
}
private static void appendHeading(SpannableStringBuilder builder, String text, int level, Palette palette) {
ensureBlockSeparation(builder, true);
int start = builder.length();
appendInlineStyled(builder, text, palette);
builder.setSpan(new StyleSpan(Typeface.BOLD), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
float size = level == 1 ? 1.18f : level == 2 ? 1.1f : 1.04f;
builder.setSpan(new RelativeSizeSpan(size), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.append('\n');
}
private static void appendParagraph(SpannableStringBuilder builder, String text, Palette palette) {
ensureBlockSeparation(builder, false);
appendInlineStyled(builder, text, palette);
builder.append('\n');
}
private static void appendBullet(SpannableStringBuilder builder, String text, Palette palette) {
ensureBlockSeparation(builder, false);
int start = builder.length();
builder.append("");
appendInlineStyled(builder, text, palette);
builder.setSpan(new BulletSpan(BossUi.dp(palette.context, 6), palette.quoteColor), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.setSpan(new LeadingMarginSpan.Standard(BossUi.dp(palette.context, 14)), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.append('\n');
}
private static void appendOrdered(SpannableStringBuilder builder, String number, String text, Palette palette) {
ensureBlockSeparation(builder, false);
int start = builder.length();
builder.append(number).append(". ");
appendInlineStyled(builder, text, palette);
builder.setSpan(new LeadingMarginSpan.Standard(BossUi.dp(palette.context, 14)), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.append('\n');
}
private static void appendQuote(SpannableStringBuilder builder, String text, Palette palette) {
ensureBlockSeparation(builder, false);
int start = builder.length();
appendInlineStyled(builder, TextUtils.isEmpty(text) ? "引用" : text, palette);
builder.setSpan(new QuoteSpan(palette.quoteColor, BossUi.dp(palette.context, 3), BossUi.dp(palette.context, 8)),
start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.append('\n');
}
private static void appendCodeBlock(SpannableStringBuilder builder, String text, Palette palette) {
if (TextUtils.isEmpty(text)) {
return;
}
ensureBlockSeparation(builder, true);
int start = builder.length();
builder.append(text);
int end = builder.length();
builder.setSpan(new TypefaceSpan("monospace"), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.setSpan(new BackgroundColorSpan(palette.codeBackgroundColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.setSpan(new ForegroundColorSpan(palette.codeTextColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.setSpan(new LeadingMarginSpan.Standard(BossUi.dp(palette.context, 10), BossUi.dp(palette.context, 10)),
start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.append('\n');
}
private static void appendInlineStyled(SpannableStringBuilder builder, String text, Palette palette) {
Matcher matcher = INLINE_TOKEN_PATTERN.matcher(text);
int cursor = 0;
while (matcher.find()) {
if (matcher.start() > cursor) {
builder.append(text, cursor, matcher.start());
}
if (matcher.group(2) != null) {
int start = builder.length();
builder.append(matcher.group(2));
builder.setSpan(new StyleSpan(Typeface.BOLD), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
} else if (matcher.group(4) != null) {
int start = builder.length();
builder.append(matcher.group(4));
builder.setSpan(new TypefaceSpan("monospace"), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.setSpan(new BackgroundColorSpan(palette.codeBackgroundColor), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.setSpan(new ForegroundColorSpan(palette.codeTextColor), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
cursor = matcher.end();
}
if (cursor < text.length()) {
builder.append(text.substring(cursor));
}
}
private static void ensureBlockSeparation(SpannableStringBuilder builder, boolean relaxed) {
if (builder.length() == 0) {
return;
}
if (builder.charAt(builder.length() - 1) != '\n') {
builder.append('\n');
return;
}
if (!relaxed) {
return;
}
if (builder.length() < 2 || builder.charAt(builder.length() - 2) != '\n') {
builder.append('\n');
}
}
private static void appendBlankLine(SpannableStringBuilder builder) {
if (builder.length() == 0) {
return;
}
if (builder.charAt(builder.length() - 1) != '\n') {
builder.append('\n');
}
if (builder.length() < 2 || builder.charAt(builder.length() - 2) != '\n') {
builder.append('\n');
}
}
private static void trimTrailingNewline(SpannableStringBuilder builder) {
while (builder.length() > 0 && builder.charAt(builder.length() - 1) == '\n') {
builder.delete(builder.length() - 1, builder.length());
}
}
private static String joinCodeLines(List<String> codeLines) {
StringBuilder builder = new StringBuilder();
for (int index = 0; index < codeLines.size(); index += 1) {
if (index > 0) {
builder.append('\n');
}
builder.append(codeLines.get(index));
}
return builder.toString();
}
private static final class Palette {
final Context context;
final int quoteColor;
final int codeBackgroundColor;
final int codeTextColor;
private Palette(Context context, int quoteColor, int codeBackgroundColor, int codeTextColor) {
this.context = context;
this.quoteColor = quoteColor;
this.codeBackgroundColor = codeBackgroundColor;
this.codeTextColor = codeTextColor;
}
static Palette resolve(Context context, boolean outgoing) {
if (outgoing) {
return new Palette(
context,
Color.parseColor("#B7E6C6"),
Color.parseColor("#33FFFFFF"),
context.getColor(R.color.boss_surface)
);
}
return new Palette(
context,
Color.parseColor("#91A39A"),
Color.parseColor("#FFF0F1F3"),
context.getColor(R.color.boss_text_primary)
);
}
}
}

View File

@@ -0,0 +1,280 @@
package com.hyzq.boss;
import android.util.Log;
import androidx.annotation.Nullable;
import org.json.JSONException;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.nio.charset.StandardCharsets;
final class BossRealtimeClient {
private static final String TAG = "BossRealtime";
private static final String HEARTBEAT_EVENT_NAME = "heartbeat";
private static final String REALTIME_STREAM_HTTP_401 = "REALTIME_STREAM_HTTP_401";
private static final int STREAM_CONNECT_TIMEOUT_MS = 12_000;
private static final int STREAM_READ_TIMEOUT_MS = 30_000;
private static final long INITIAL_BACKOFF_MS = 800L;
private static final long MAX_BACKOFF_MS = 5000L;
interface Listener {
void onRealtimeEvent(BossRealtimeEvent event);
default void onRealtimeConnectionChanged(boolean connected) {}
}
private final BossApiClient apiClient;
private final Listener listener;
private volatile boolean running;
private volatile boolean connected;
private @Nullable Thread workerThread;
private @Nullable HttpURLConnection activeConnection;
BossRealtimeClient(BossApiClient apiClient, Listener listener) {
this.apiClient = apiClient;
this.listener = listener;
}
synchronized void start() {
if (running) {
return;
}
running = true;
workerThread = new Thread(this::runLoop, "boss-realtime");
workerThread.start();
}
synchronized void stop() {
running = false;
setConnected(false);
if (activeConnection != null) {
activeConnection.disconnect();
activeConnection = null;
}
if (workerThread != null) {
workerThread.interrupt();
workerThread = null;
}
}
boolean isConnected() {
return connected;
}
private void setConnected(boolean nextConnected) {
if (connected == nextConnected) {
return;
}
connected = nextConnected;
listener.onRealtimeConnectionChanged(nextConnected);
}
private void runLoop() {
long backoffMs = INITIAL_BACKOFF_MS;
while (running) {
try {
Log.i(TAG, "Realtime stream connecting");
openAndConsumeStream();
backoffMs = INITIAL_BACKOFF_MS;
} catch (Exception error) {
if (!running) {
return;
}
if (shouldAttemptSessionRestore(error)) {
try {
BossApiClient.ApiResponse restored = apiClient.restoreSession();
if (restored.ok()) {
Log.i(TAG, "Realtime stream session restored");
backoffMs = INITIAL_BACKOFF_MS;
continue;
}
Log.w(
TAG,
"Realtime stream restore failed: " + restored.statusCode + " " + restored.message()
);
} catch (Exception restoreError) {
Log.w(TAG, "Realtime stream restore threw", restoreError);
}
}
Log.w(TAG, "Realtime stream disconnected; retrying in " + backoffMs + "ms", error);
try {
Thread.sleep(backoffMs);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return;
}
backoffMs = Math.min(backoffMs + 1200L, MAX_BACKOFF_MS);
}
}
}
private void openAndConsumeStream() throws IOException {
HttpURLConnection connection = apiClient.openConnection("/api/v1/events");
activeConnection = connection;
try {
connection.setRequestMethod("GET");
connection.setConnectTimeout(STREAM_CONNECT_TIMEOUT_MS);
connection.setReadTimeout(STREAM_READ_TIMEOUT_MS);
connection.setUseCaches(false);
connection.setDoInput(true);
connection.setRequestProperty("Accept", "text/event-stream");
connection.setRequestProperty("Cache-Control", "no-cache");
connection.setRequestProperty("x-boss-native-app", "1");
String cookie = apiClient.getSessionCookie();
if (!cookie.isEmpty()) {
connection.setRequestProperty("Cookie", cookie);
}
int statusCode = connection.getResponseCode();
if (statusCode < 200 || statusCode >= 300) {
throw new IOException("REALTIME_STREAM_HTTP_" + statusCode);
}
setConnected(true);
Log.i(TAG, "Realtime stream connected");
try (InputStream inputStream = connection.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
StringBuilder block = new StringBuilder();
String line;
while (running && (line = reader.readLine()) != null) {
if (line.isEmpty()) {
dispatchEventBlock(block.toString());
block.setLength(0);
continue;
}
block.append(line).append('\n');
}
if (block.length() > 0) {
dispatchEventBlock(block.toString());
}
if (running) {
Log.w(TAG, "Realtime stream ended; reopening");
}
}
} finally {
setConnected(false);
activeConnection = null;
connection.disconnect();
}
}
private boolean shouldAttemptSessionRestore(Exception error) {
return error instanceof IOException
&& REALTIME_STREAM_HTTP_401.equals(error.getMessage())
&& apiClient.hasRestoreToken();
}
private void dispatchEventBlock(String rawBlock) {
BossRealtimeEvent event = parseEventBlock(rawBlock);
if (event == null || event.eventName.isEmpty()) {
return;
}
listener.onRealtimeEvent(event);
}
static @Nullable BossRealtimeEvent parseEventBlock(String rawBlock) {
if (rawBlock == null) {
return null;
}
String trimmed = rawBlock.trim();
if (trimmed.isEmpty() || trimmed.startsWith(":")) {
return null;
}
String eventName = "";
StringBuilder dataBuilder = new StringBuilder();
for (String line : rawBlock.split("\n")) {
if (line.startsWith("event:")) {
eventName = line.substring("event:".length()).trim();
} else if (line.startsWith("data:")) {
if (dataBuilder.length() > 0) {
dataBuilder.append('\n');
}
dataBuilder.append(line.substring("data:".length()).trim());
}
}
if (eventName.isEmpty()) {
return null;
}
if (HEARTBEAT_EVENT_NAME.equals(eventName)) {
return null;
}
JSONObject payload = new JSONObject();
if (dataBuilder.length() > 0) {
try {
payload = new JSONObject(dataBuilder.toString());
} catch (JSONException ignored) {
payload = new JSONObject();
}
} else {
return null;
}
return new BossRealtimeEvent(eventName, payload);
}
static String buildEventFingerprint(@Nullable BossRealtimeEvent event) {
if (event == null || event.eventName.isEmpty()) {
return "";
}
return event.eventName + "|" + canonicalizeJson(event.payload);
}
private static String canonicalizeJson(@Nullable Object value) {
if (value == null || value == JSONObject.NULL) {
return "null";
}
if (value instanceof JSONObject) {
JSONObject object = (JSONObject) value;
ArrayList<String> keys = new ArrayList<>();
Iterator<String> iterator = object.keys();
while (iterator.hasNext()) {
String key = iterator.next();
if (!"at".equals(key)) {
keys.add(key);
}
}
Collections.sort(keys);
StringBuilder builder = new StringBuilder("{");
for (int index = 0; index < keys.size(); index += 1) {
if (index > 0) {
builder.append(',');
}
String key = keys.get(index);
builder.append(JSONObject.quote(key));
builder.append(':');
builder.append(canonicalizeJson(object.opt(key)));
}
builder.append('}');
return builder.toString();
}
if (value instanceof JSONArray) {
JSONArray array = (JSONArray) value;
StringBuilder builder = new StringBuilder("[");
for (int index = 0; index < array.length(); index += 1) {
if (index > 0) {
builder.append(',');
}
builder.append(canonicalizeJson(array.opt(index)));
}
builder.append(']');
return builder.toString();
}
if (value instanceof String) {
return JSONObject.quote((String) value);
}
if (value instanceof Number || value instanceof Boolean) {
return String.valueOf(value);
}
return JSONObject.quote(String.valueOf(value));
}
}

View File

@@ -0,0 +1,15 @@
package com.hyzq.boss;
import androidx.annotation.Nullable;
import org.json.JSONObject;
public final class BossRealtimeEvent {
public final String eventName;
public final JSONObject payload;
public BossRealtimeEvent(String eventName, @Nullable JSONObject payload) {
this.eventName = eventName == null ? "" : eventName.trim();
this.payload = payload == null ? new JSONObject() : payload;
}
}

View File

@@ -1,7 +1,8 @@
package com.hyzq.boss;
import android.os.Bundle;
import android.widget.Button;
import android.view.View;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
@@ -17,9 +18,10 @@ 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 ImageButton backButton;
protected ImageButton refreshButton;
protected ImageButton headerActionButton;
protected View topBarView;
protected TextView titleView;
protected TextView subtitleView;
protected SwipeRefreshLayout refreshLayout;
@@ -28,22 +30,32 @@ public abstract class BossScreenActivity extends AppCompatActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_screen);
setContentView(getLayoutResId());
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);
topBarView = findViewById(R.id.screen_top_bar);
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);
BossWindowInsets.applyStatusBarInset(topBarView);
backButton.setOnClickListener(v -> finish());
BossUi.bindTopIconButton(this, backButton, BossUi.TopActionIcon.BACK, "返回");
BossUi.bindTopIconButton(this, refreshButton, BossUi.TopActionIcon.REFRESH, "刷新");
BossUi.bindTopIconButton(this, headerActionButton, BossUi.TopActionIcon.MORE, "更多");
refreshButton.setOnClickListener(v -> reload());
refreshLayout.setOnRefreshListener(this::reload);
}
protected int getLayoutResId() {
return R.layout.activity_screen;
}
@Override
protected void onDestroy() {
executor.shutdownNow();
@@ -57,7 +69,7 @@ public abstract class BossScreenActivity extends AppCompatActivity {
protected void setHeaderAction(String label, android.view.View.OnClickListener listener) {
headerActionButton.setVisibility(android.view.View.VISIBLE);
headerActionButton.setText(label);
BossUi.bindTopIconButton(this, headerActionButton, BossUi.topActionIconForLabel(label), label);
headerActionButton.setOnClickListener(listener);
}
@@ -69,7 +81,8 @@ public abstract class BossScreenActivity extends AppCompatActivity {
protected void setRefreshing(boolean refreshing) {
refreshLayout.setRefreshing(refreshing);
refreshButton.setEnabled(!refreshing);
refreshButton.setText(refreshing ? "同步中" : "刷新");
refreshButton.setAlpha(refreshing ? 0.45f : 1f);
refreshButton.setContentDescription(refreshing ? "同步中" : "刷新");
}
protected void replaceContent(android.view.View... views) {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
package com.hyzq.boss;
import android.view.View;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
public final class BossWindowInsets {
private BossWindowInsets() {}
public static void applyStatusBarInset(View view) {
if (view == null) {
return;
}
final int initialLeft = view.getPaddingLeft();
final int initialTop = view.getPaddingTop();
final int initialRight = view.getPaddingRight();
final int initialBottom = view.getPaddingBottom();
ViewCompat.setOnApplyWindowInsetsListener(view, (target, insets) -> {
Insets statusInsets = insets.getInsets(
WindowInsetsCompat.Type.statusBars() | WindowInsetsCompat.Type.displayCutout()
);
target.setPadding(
initialLeft,
initialTop + statusInsets.top,
initialRight,
initialBottom
);
return insets;
});
if (ViewCompat.isAttachedToWindow(view)) {
ViewCompat.requestApplyInsets(view);
return;
}
view.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
@Override
public void onViewAttachedToWindow(View v) {
v.removeOnAttachStateChangeListener(this);
ViewCompat.requestApplyInsets(v);
}
@Override
public void onViewDetachedFromWindow(View v) {
// no-op
}
});
}
public static void applyKeyboardAvoidingInset(View view) {
if (view == null) {
return;
}
final int initialLeft = view.getPaddingLeft();
final int initialTop = view.getPaddingTop();
final int initialRight = view.getPaddingRight();
final int initialBottom = view.getPaddingBottom();
ViewCompat.setOnApplyWindowInsetsListener(view, (target, insets) -> {
Insets navigationInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars());
Insets imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime());
int bottomInset = Math.max(navigationInsets.bottom, imeInsets.bottom);
target.setPadding(
initialLeft,
initialTop,
initialRight,
initialBottom + bottomInset
);
return insets;
});
if (ViewCompat.isAttachedToWindow(view)) {
ViewCompat.requestApplyInsets(view);
return;
}
view.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
@Override
public void onViewAttachedToWindow(View v) {
v.removeOnAttachStateChangeListener(this);
ViewCompat.requestApplyInsets(v);
}
@Override
public void onViewDetachedFromWindow(View v) {
// no-op
}
});
}
}

View File

@@ -0,0 +1,441 @@
package com.hyzq.boss;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
public class ConversationFolderActivity extends BossScreenActivity {
public static final String EXTRA_FOLDER_KEY = "folder_key";
public static final String EXTRA_FOLDER_NAME = "folder_name";
public static final String EXTRA_TARGET_PROJECT_ID = "target_project_id";
public static final String EXTRA_TARGET_PROJECT_IDS = "target_project_ids";
public static final String EXTRA_TARGET_PROJECT_LABEL = "target_project_label";
private static final long REALTIME_REFRESH_DEBOUNCE_MS = 300L;
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private String folderKey;
private String folderName;
private String folderDeviceId;
private String targetProjectId;
private ArrayList<String> targetProjectIds;
private String targetProjectLabel;
private @Nullable BossRealtimeClient realtimeClient;
private @Nullable JSONObject currentFolderPayload;
private final Handler uiHandler = new Handler(Looper.getMainLooper());
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
private final Set<String> trackedProjectIds = new LinkedHashSet<>();
private boolean realtimeReloadScheduled;
private final Runnable realtimeReloadRunnable = new Runnable() {
@Override
public void run() {
realtimeReloadScheduled = false;
reload();
}
};
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
folderKey = getIntent().getStringExtra(EXTRA_FOLDER_KEY);
folderName = getIntent().getStringExtra(EXTRA_FOLDER_NAME);
folderDeviceId = parseFolderDeviceId(folderKey);
targetProjectId = getIntent().getStringExtra(EXTRA_TARGET_PROJECT_ID);
targetProjectIds = new ArrayList<>();
String[] extraTargetProjectIds = getIntent().getStringArrayExtra(EXTRA_TARGET_PROJECT_IDS);
if (extraTargetProjectIds != null) {
for (String item : extraTargetProjectIds) {
if (item != null) {
String trimmed = item.trim();
if (!trimmed.isEmpty()) {
targetProjectIds.add(trimmed);
}
}
}
}
targetProjectLabel = getIntent().getStringExtra(EXTRA_TARGET_PROJECT_LABEL);
configureScreen(folderName == null || folderName.isEmpty() ? "项目线程" : folderName, "0 个线程");
refreshButton.setVisibility(android.view.View.GONE);
setHeaderAction("...", v -> showMoreMenu());
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
reload();
}
@Override
protected void onResume() {
super.onResume();
updateRealtimeSubscription();
}
@Override
protected void onPause() {
cancelRealtimeReloadSchedule();
stopRealtimeUpdates();
super.onPause();
}
@Override
protected void onDestroy() {
cancelRealtimeReloadSchedule();
stopRealtimeUpdates();
super.onDestroy();
}
@Override
protected void reload() {
if (folderKey == null || folderKey.isEmpty()) {
replaceContent(BossUi.buildEmptyCard(this, "缺少项目标识。"));
setRefreshing(false);
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getConversationFolder(folderKey);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> renderFolder(response.json.optJSONObject("folder")));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "项目线程加载失败:" + error.getMessage()));
});
}
});
}
private void updateRealtimeSubscription() {
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
realtimeClient.start();
return;
}
stopRealtimeUpdates();
}
private void stopRealtimeUpdates() {
if (realtimeClient != null) {
realtimeClient.stop();
}
}
void handleRealtimeEvent(BossRealtimeEvent event) {
if (event == null || event.eventName.isEmpty()) {
return;
}
if (!shouldReloadForRealtimeEvent(event)) {
return;
}
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
if (eventFingerprint.isEmpty()) {
return;
}
long now = System.currentTimeMillis();
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
return;
}
if (tryApplyFolderRealtimePatch(event)) {
return;
}
runOnUiThread(this::scheduleRealtimeReload);
}
private boolean tryApplyFolderRealtimePatch(BossRealtimeEvent event) {
if (event == null || currentFolderPayload == null) {
return false;
}
if (!"conversation.updated".equals(event.eventName)
&& !"project.messages.updated".equals(event.eventName)) {
return false;
}
String affectedProjectId = event.payload.optString("projectId", "").trim();
if (affectedProjectId.isEmpty() || !trackedProjectIds.contains(affectedProjectId)) {
return false;
}
JSONObject threadConversationItem = event.payload.optJSONObject("threadConversationItem");
if (threadConversationItem == null) {
return false;
}
String patchedFolderKey = threadConversationItem.optString("folderKey", "").trim();
if (folderKey == null || folderKey.isEmpty() || !folderKey.equals(patchedFolderKey)) {
return false;
}
runOnUiThread(() -> {
if (currentFolderPayload == null) {
scheduleRealtimeReload();
return;
}
JSONObject mergedFolder = replaceThreadConversationItem(
currentFolderPayload,
affectedProjectId,
threadConversationItem
);
currentFolderPayload = mergedFolder;
renderFolder(mergedFolder);
});
return true;
}
private void scheduleRealtimeReload() {
if (realtimeReloadScheduled) {
return;
}
realtimeReloadScheduled = true;
uiHandler.postDelayed(realtimeReloadRunnable, REALTIME_REFRESH_DEBOUNCE_MS);
}
private void cancelRealtimeReloadSchedule() {
uiHandler.removeCallbacks(realtimeReloadRunnable);
realtimeReloadScheduled = false;
}
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
if (!"conversation.updated".equals(event.eventName)
&& !"project.messages.updated".equals(event.eventName)) {
return false;
}
String payloadProjectId = event.payload.optString("projectId", "").trim();
if (!payloadProjectId.isEmpty()) {
return trackedProjectIds.contains(payloadProjectId)
|| (!targetProjectIds.isEmpty() && targetProjectIds.contains(payloadProjectId))
|| (targetProjectId != null && targetProjectId.equals(payloadProjectId));
}
String payloadDeviceId = event.payload.optString("deviceId", "").trim();
if (payloadDeviceId.isEmpty() || folderDeviceId == null || folderDeviceId.isEmpty()) {
return false;
}
return payloadDeviceId.equals(folderDeviceId);
}
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
pruneRecentRealtimeEvents(now);
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
return true;
}
recentRealtimeEventTimestamps.put(eventFingerprint, now);
return false;
}
private void pruneRecentRealtimeEvents(long now) {
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Long> entry = iterator.next();
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
iterator.remove();
}
}
}
private void renderFolder(@Nullable JSONObject folder) {
replaceContent();
if (folder == null) {
currentFolderPayload = null;
trackedProjectIds.clear();
appendContent(BossUi.buildEmptyCard(this, "未找到项目线程。"));
setRefreshing(false);
return;
}
currentFolderPayload = copyJson(folder);
String resolvedFolderName = folder.optString("folderLabel", folderName == null ? "项目线程" : folderName);
folderDeviceId = folder.optString("deviceId", folderDeviceId == null ? "" : folderDeviceId).trim();
int threadCount = folder.optInt("threadCount", 0);
configureScreen(resolvedFolderName, threadCount + " 个线程");
appendContent(BossUi.buildSoftPanel(
this,
"项目内部线程页",
resolvedFolderName,
"点击线程后进入具体聊天窗口。"
));
JSONArray threads = folder.optJSONArray("threads");
updateTrackedProjectIds(threads);
if (threads == null || threads.length() == 0) {
appendContent(BossUi.buildEmptyCard(this, "当前项目下没有线程。"));
setRefreshing(false);
return;
}
ArrayList<Integer> targetIndices = resolveTargetThreadIndices(threads);
if (!targetIndices.isEmpty()) {
String matchedLabel = targetProjectLabel;
if ((matchedLabel == null || matchedLabel.isEmpty())) {
JSONObject firstTarget = threads.optJSONObject(targetIndices.get(0));
if (firstTarget != null) {
matchedLabel = firstTarget.optString("threadTitle", "");
}
}
appendContent(BossUi.buildSoftPanel(
this,
"已定位到目标线程",
matchedLabel == null || matchedLabel.isEmpty()
? "文件夹页已打开,并将匹配线程置顶显示。"
: matchedLabel,
targetIndices.size() + " 个匹配项已置顶"
));
}
for (int i = 0; i < targetIndices.size(); i++) {
renderThreadAtIndex(threads, targetIndices.get(i), true);
}
for (int i = 0; i < threads.length(); i++) {
if (!targetIndices.contains(i)) {
renderThreadAtIndex(threads, i, false);
}
}
setRefreshing(false);
}
private void updateTrackedProjectIds(@Nullable JSONArray threads) {
trackedProjectIds.clear();
if (threads == null) {
return;
}
for (int i = 0; i < threads.length(); i++) {
JSONObject item = threads.optJSONObject(i);
if (item == null) {
continue;
}
String projectId = item.optString("projectId", "").trim();
if (!projectId.isEmpty()) {
trackedProjectIds.add(projectId);
}
}
}
private JSONObject replaceThreadConversationItem(JSONObject folder, String affectedProjectId, JSONObject threadConversationItem) {
JSONObject mergedFolder = copyJson(folder);
JSONArray existingThreads = mergedFolder.optJSONArray("threads");
JSONArray mergedThreads = new JSONArray();
boolean replaced = false;
if (existingThreads != null) {
for (int i = 0; i < existingThreads.length(); i++) {
JSONObject item = existingThreads.optJSONObject(i);
if (item == null) {
continue;
}
if (affectedProjectId.equals(item.optString("projectId", "").trim())) {
mergedThreads.put(copyJson(threadConversationItem));
replaced = true;
continue;
}
mergedThreads.put(copyJson(item));
}
}
if (!replaced) {
mergedThreads.put(copyJson(threadConversationItem));
}
try {
mergedFolder.put("threads", mergedThreads);
mergedFolder.put("threadCount", mergedThreads.length());
} catch (org.json.JSONException ignored) {
}
return mergedFolder;
}
private JSONObject copyJson(@Nullable JSONObject source) {
if (source == null) {
return new JSONObject();
}
try {
return new JSONObject(source.toString());
} catch (org.json.JSONException ignored) {
return new JSONObject();
}
}
private String parseFolderDeviceId(@Nullable String candidateFolderKey) {
if (candidateFolderKey == null) {
return "";
}
int separatorIndex = candidateFolderKey.indexOf(':');
if (separatorIndex <= 0) {
return "";
}
return candidateFolderKey.substring(0, separatorIndex).trim();
}
private void renderThreadAtIndex(JSONArray threads, int index, boolean highlighted) {
JSONObject item = threads.optJSONObject(index);
if (item == null) return;
String projectId = item.optString("projectId", "");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
appendContent(BossUi.buildConversationRow(
this,
row,
false,
false,
highlighted,
v -> {
if (projectId.isEmpty()) {
showMessage("缺少 projectId");
return;
}
openProject(projectId, row.threadTitle);
}
));
}
private ArrayList<Integer> resolveTargetThreadIndices(JSONArray threads) {
ArrayList<Integer> targetIndices = new ArrayList<>();
if (threads == null || threads.length() == 0) {
return targetIndices;
}
if (!targetProjectIds.isEmpty()) {
for (int i = 0; i < threads.length(); i++) {
JSONObject item = threads.optJSONObject(i);
if (item == null) continue;
String projectId = item.optString("projectId", "");
if (targetProjectIds.contains(projectId)) {
targetIndices.add(i);
}
}
}
if (targetIndices.isEmpty() && targetProjectId != null && !targetProjectId.isEmpty()) {
for (int i = 0; i < threads.length(); i++) {
JSONObject item = threads.optJSONObject(i);
if (item != null && targetProjectId.equals(item.optString("projectId", ""))) {
targetIndices.add(i);
break;
}
}
}
if (targetIndices.isEmpty() && targetProjectLabel != null && !targetProjectLabel.isEmpty()) {
for (int i = 0; i < threads.length(); i++) {
JSONObject item = threads.optJSONObject(i);
if (item != null && targetProjectLabel.equals(item.optString("threadTitle", ""))) {
targetIndices.add(i);
}
}
}
return targetIndices;
}
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 showMoreMenu() {
new androidx.appcompat.app.AlertDialog.Builder(this)
.setItems(new CharSequence[]{"刷新"}, (dialog, which) -> reload())
.show();
}
}

View File

@@ -0,0 +1,565 @@
package com.hyzq.boss;
import android.content.Intent;
import android.os.Bundle;
import android.widget.EditText;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.SwitchCompat;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.LinkedHashMap;
import java.util.Map;
public class ConversationInfoActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id";
public static final String EXTRA_PROJECT_NAME = "project_name";
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private String projectId;
private String projectName;
private String projectFolderName;
private int participantCount;
private boolean takeoverEnabled;
private boolean takeoverInheritedFromGlobal;
private @Nullable BossRealtimeClient realtimeClient;
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
@Override
protected int getLayoutResId() {
return R.layout.activity_conversation_info;
}
@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);
refreshButton.setVisibility(android.view.View.GONE);
setHeaderAction("...", v -> showMoreMenu());
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
reload();
}
@Override
protected void onResume() {
super.onResume();
updateRealtimeSubscription();
}
@Override
protected void onPause() {
stopRealtimeUpdates();
super.onPause();
}
@Override
protected void onDestroy() {
stopRealtimeUpdates();
super.onDestroy();
}
@Override
protected void reload() {
if (projectId == null || projectId.isEmpty()) {
showMessage("缺少 projectId");
finish();
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
LoadedConversation loadedConversation = loadConversation();
BossApiClient.ApiResponse detailResponse = loadedConversation.detailResponse;
JSONObject participantsPayload = loadedConversation.participantsPayload;
JSONObject threadStatusPayload = null;
try {
BossApiClient.ApiResponse threadStatusResponse = loadedConversation.threadStatusResponse;
if (threadStatusResponse.ok()) {
threadStatusPayload = threadStatusResponse.json;
}
} catch (Exception ignored) {
threadStatusPayload = null;
}
JSONObject finalThreadStatusPayload = threadStatusPayload;
runOnUiThread(() -> renderConversation(detailResponse.json, participantsPayload, finalThreadStatusPayload));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "会话信息加载失败:" + error.getMessage()));
});
}
});
}
private void updateRealtimeSubscription() {
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
realtimeClient.start();
return;
}
stopRealtimeUpdates();
}
private void stopRealtimeUpdates() {
if (realtimeClient != null) {
realtimeClient.stop();
}
}
void handleRealtimeEvent(BossRealtimeEvent event) {
if (event == null || event.eventName.isEmpty() || projectId == null || projectId.isEmpty()) {
return;
}
if (!shouldReloadForRealtimeEvent(event)) {
return;
}
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
if (eventFingerprint.isEmpty()) {
return;
}
long now = System.currentTimeMillis();
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
return;
}
runOnUiThread(this::reload);
}
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
String payloadProjectId = event.payload.optString("projectId", "").trim();
if (payloadProjectId.isEmpty() || !payloadProjectId.equals(projectId)) {
return false;
}
return "conversation.updated".equals(event.eventName)
|| "project.messages.updated".equals(event.eventName);
}
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
pruneRecentRealtimeEvents(now);
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
return true;
}
recentRealtimeEventTimestamps.put(eventFingerprint, now);
return false;
}
private void pruneRecentRealtimeEvents(long now) {
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Long> entry = iterator.next();
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
iterator.remove();
}
}
}
private void renderConversation(JSONObject detail, JSONObject participantsPayload, @Nullable JSONObject threadStatusPayload) {
replaceContent();
JSONObject project = detail.optJSONObject("project");
JSONArray participants = participantsPayload.optJSONArray("participants");
if (project == null) {
appendContent(BossUi.buildEmptyCard(this, "会话不存在。"));
setRefreshing(false);
return;
}
projectName = project.optString("name", projectName == null ? "会话信息" : projectName);
JSONObject agentControls = detail.optJSONObject("agentControls");
JSONObject threadMeta = project.optJSONObject("threadMeta");
projectFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
participantCount = participants == null ? 0 : participants.length();
takeoverEnabled = agentControls != null && agentControls.optBoolean("effectiveTakeoverEnabled", false);
takeoverInheritedFromGlobal = agentControls != null && agentControls.optBoolean("takeoverInheritedFromGlobal", false);
configureScreen("会话信息", buildSubtitle(threadMeta, participantCount));
appendContent(BossUi.buildSimpleProfileHeader(
this,
projectName,
"单线程会话",
buildHeaderDetail(project, threadMeta, participantCount)
));
appendThreadStatusSummary(threadStatusPayload);
appendTakeoverControl();
appendContent(BossUi.buildWechatMenuRow(
this,
"发起群聊",
"选择其他线程加入新群",
"原会话保留",
null,
v -> openGroupCreate()
));
appendContent(BossUi.buildWechatMenuRow(
this,
"线程详情",
"查看当前线程聊天与项目",
resolveThreadId(project, threadMeta),
null,
v -> openProject(projectId, projectName)
));
appendContent(BossUi.buildWechatMenuRow(
this,
"线程状态",
"状态文档和最近进展事件",
projectFolderName.isEmpty() ? null : projectFolderName,
null,
v -> openThreadStatus()
));
appendContent(BossUi.buildWechatMenuRow(
this,
"参与线程",
participantCount <= 0 ? "暂无参与线程" : "" + participantCount + "",
projectFolderName.isEmpty() ? null : projectFolderName,
null,
null
));
if (participants == null || participants.length() == 0) {
appendContent(BossUi.buildWechatMenuRow(
this,
"暂无参与线程",
"下拉刷新后重试",
null,
null,
null
));
} else {
for (int i = 0; i < participants.length(); i++) {
JSONObject participant = participants.optJSONObject(i);
if (participant == null) continue;
appendContent(buildParticipantRow(participant));
}
}
setRefreshing(false);
}
private void appendTakeoverControl() {
SwitchCompat takeoverSwitch = new SwitchCompat(this);
takeoverSwitch.setText("开启");
takeoverSwitch.setChecked(takeoverEnabled);
takeoverSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> saveTakeoverSetting(isChecked));
appendContent(BossUi.buildFormCell(
this,
"主 Agent 协同接管",
takeoverInheritedFromGlobal
? "当前跟随全局默认开启。主 Agent 会协同推进,但不会抢走你直接控制线程开发的能力。"
: "为这个线程单独开启主 Agent 协同推进。不会抢走你直接控制线程开发的能力。",
takeoverSwitch
));
}
private void appendThreadStatusSummary(@Nullable JSONObject threadStatusPayload) {
if (threadStatusPayload == null) {
return;
}
JSONObject document = threadStatusPayload.optJSONObject("threadStatusDocument");
if (document == null) {
return;
}
JSONArray recentProgressEvents = threadStatusPayload.optJSONArray("recentProgressEvents");
int eventCount = recentProgressEvents == null ? 0 : recentProgressEvents.length();
String body = buildThreadStatusSummaryBody(document, eventCount);
String meta = buildThreadStatusSummaryMeta(document, eventCount);
appendContent(BossUi.buildCard(this, "线程状态摘要", body, meta));
}
private String buildThreadStatusSummaryBody(JSONObject document, int eventCount) {
return joinNonEmptyLines(
formatSummaryLine("当前目标", document.optString("projectGoal", "")),
formatSummaryLine("当前进度", document.optString("currentProgress", "")),
formatSummaryLine("当前阻塞", document.optString("currentBlockers", "")),
formatSummaryLine("建议下一步", document.optString("recommendedNextStep", "")),
eventCount > 0 ? "最近进展:" + eventCount + "" : ""
);
}
private String buildThreadStatusSummaryMeta(JSONObject document, int eventCount) {
return joinNonEmptyParts(
projectFolderName,
eventCount > 0 ? "最近 " + eventCount + " 条进展" : "暂无进展",
document.optString("updatedAt", "").isEmpty() ? "" : "更新于 " + document.optString("updatedAt", "")
);
}
private String formatSummaryLine(String label, String value) {
String trimmed = value == null ? "" : value.trim();
if (trimmed.isEmpty()) {
return "";
}
return label + "" + trimmed;
}
private String joinNonEmptyLines(String... values) {
StringBuilder builder = new StringBuilder();
for (String value : values) {
if (value == null || value.trim().isEmpty()) {
continue;
}
if (builder.length() > 0) {
builder.append('\n');
}
builder.append(value.trim());
}
return builder.toString();
}
private String joinNonEmptyParts(String... values) {
StringBuilder builder = new StringBuilder();
for (String value : values) {
if (value == null || value.trim().isEmpty()) {
continue;
}
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(value.trim());
}
return builder.toString();
}
private LinearLayout buildParticipantRow(JSONObject participant) {
boolean sourceProject = participant.optBoolean("isSourceProject", false);
String participantProjectId = participant.optString("projectId", "");
String title = participant.optString("threadDisplayName", "未命名线程");
String subtitle = participant.optString("folderName", "");
String meta = participant.optString("deviceId", "");
if (!participant.optString("threadId", "").isEmpty()) {
meta = meta.isEmpty() ? participant.optString("threadId", "") : meta + " · " + participant.optString("threadId", "");
}
if (sourceProject) {
subtitle = subtitle.isEmpty() ? "来源线程" : "来源线程 · " + subtitle;
}
return BossUi.buildWechatMenuRow(
this,
title,
subtitle,
meta,
sourceProject ? "来源" : null,
v -> openProject(participantProjectId, title)
);
}
private void openProject(String targetProjectId, String targetProjectName) {
if (targetProjectId == null || targetProjectId.isEmpty()) {
showMessage("缺少 projectId");
return;
}
Intent intent = new Intent(this, ProjectDetailActivity.class);
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, targetProjectId);
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, targetProjectName);
startActivity(intent);
}
private void openGroupCreate() {
if (projectId == null || projectId.isEmpty()) {
showMessage("缺少 projectId");
return;
}
Intent intent = new Intent(this, GroupCreateActivity.class);
intent.putExtra(GroupCreateActivity.EXTRA_SOURCE_PROJECT_ID, projectId);
intent.putExtra(GroupCreateActivity.EXTRA_SOURCE_PROJECT_NAME, projectName);
startActivity(intent);
}
private void openThreadStatus() {
if (projectId == null || projectId.isEmpty()) {
showMessage("缺少 projectId");
return;
}
Intent intent = new Intent(this, ThreadStatusActivity.class);
intent.putExtra(ThreadStatusActivity.EXTRA_PROJECT_ID, projectId);
intent.putExtra(ThreadStatusActivity.EXTRA_PROJECT_NAME, projectName);
startActivity(intent);
}
private void openRenameDialog() {
final EditText input = BossUi.buildInput(this, "线程名", false);
input.setText(projectName == null ? "" : projectName);
new AlertDialog.Builder(this)
.setTitle("重命名会话")
.setView(input)
.setNegativeButton("取消", null)
.setPositiveButton("保存", (dialog, which) -> saveConversationName(input.getText().toString().trim()))
.show();
}
private void showMoreMenu() {
new AlertDialog.Builder(this)
.setItems(new CharSequence[]{"改名", "刷新"}, (dialog, which) -> {
if (which == 0) {
openRenameDialog();
return;
}
reload();
})
.show();
}
private void saveConversationName(String name) {
if (name.isEmpty()) {
showMessage("线程名不能为空");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.renameConversation(projectId, name, false);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
Intent result = new Intent();
result.putExtra(EXTRA_PROJECT_NAME, name);
setResult(RESULT_OK, result);
showMessage("线程名已更新");
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("保存失败:" + error.getMessage());
});
}
});
}
private void saveTakeoverSetting(boolean enabled) {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = saveTakeoverSettingsWithRetry(
projectId,
enabled,
null
);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
showMessage(enabled ? "已开启主 Agent 协同接管" : "已关闭主 Agent 协同接管");
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("保存失败:" + error.getMessage());
reload();
});
}
});
}
private LoadedConversation loadConversation() throws Exception {
BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(projectId);
if (isUnauthorized(detailResponse)) {
BossApiClient.ApiResponse loginResponse = apiClient.autoLogin();
if (!loginResponse.ok()) {
throw new IllegalStateException(loginResponse.message());
}
detailResponse = apiClient.getProjectDetail(projectId);
}
if (!detailResponse.ok()) {
throw new IllegalStateException(detailResponse.message());
}
JSONObject participantsPayload = extractParticipantsPayload(detailResponse.json);
BossApiClient.ApiResponse threadStatusResponse = apiClient.getThreadStatus(projectId);
return new LoadedConversation(detailResponse, participantsPayload, threadStatusResponse);
}
private JSONObject extractParticipantsPayload(JSONObject detailPayload) {
JSONObject participantsPayload = detailPayload == null ? null : detailPayload.optJSONObject("participantsPayload");
return participantsPayload == null ? new JSONObject() : participantsPayload;
}
private BossApiClient.ApiResponse saveTakeoverSettingsWithRetry(
String targetProjectId,
boolean takeoverEnabled,
@Nullable Boolean globalTakeoverEnabled
) throws Exception {
BossApiClient.ApiResponse response = apiClient.updateProjectTakeoverSettings(
targetProjectId,
takeoverEnabled,
globalTakeoverEnabled
);
if (!isUnauthorized(response)) {
return response;
}
BossApiClient.ApiResponse loginResponse = apiClient.autoLogin();
if (!loginResponse.ok()) {
return response;
}
return apiClient.updateProjectTakeoverSettings(
targetProjectId,
takeoverEnabled,
globalTakeoverEnabled
);
}
private boolean isUnauthorized(@Nullable BossApiClient.ApiResponse response) {
return response != null && response.statusCode == 401 && "UNAUTHORIZED".equals(response.message());
}
private String buildSubtitle(@Nullable JSONObject threadMeta, int count) {
String folder = threadMeta == null ? "" : threadMeta.optString("folderName", "");
String suffix = count <= 0 ? "暂无参与线程" : count + " 个参与线程";
if (folder.isEmpty()) {
return suffix;
}
return folder + " · " + suffix;
}
private String buildHeaderDetail(JSONObject project, @Nullable JSONObject threadMeta, int count) {
StringBuilder builder = new StringBuilder();
String threadId = resolveThreadId(project, threadMeta);
if (!threadId.isEmpty()) {
builder.append(threadId);
}
if (!projectFolderName.isEmpty()) {
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(projectFolderName);
}
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(count <= 0 ? "暂无参与线程" : count + " 个参与线程");
return builder.toString();
}
private String resolveThreadId(JSONObject project, @Nullable JSONObject threadMeta) {
if (threadMeta != null) {
String threadId = threadMeta.optString("threadId", "");
if (!threadId.isEmpty()) {
return threadId;
}
}
return project.optString("id", "");
}
private static final class LoadedConversation {
private final BossApiClient.ApiResponse detailResponse;
private final JSONObject participantsPayload;
private final BossApiClient.ApiResponse threadStatusResponse;
private LoadedConversation(
BossApiClient.ApiResponse detailResponse,
JSONObject participantsPayload,
BossApiClient.ApiResponse threadStatusResponse
) {
this.detailResponse = detailResponse;
this.participantsPayload = participantsPayload;
this.threadStatusResponse = threadStatusResponse;
}
}
}

View File

@@ -2,7 +2,6 @@ package com.hyzq.boss;
import android.content.Intent;
import android.os.Bundle;
import android.widget.Button;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
@@ -11,23 +10,48 @@ import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.LinkedHashMap;
import java.util.Map;
public class DeviceDetailActivity extends BossScreenActivity {
public static final String EXTRA_DEVICE_ID = "device_id";
public static final String EXTRA_DEVICE_NAME = "device_name";
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private String deviceId;
private String deviceName;
private @Nullable BossRealtimeClient realtimeClient;
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
@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, "原生设备详情");
configureScreen(deviceName == null ? "设备详情" : deviceName, "设备状态、GUI/CLI 能力与默认执行模式");
setHeaderAction("编辑", v -> openEditDialog());
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
reload();
}
@Override
protected void onResume() {
super.onResume();
updateRealtimeSubscription();
}
@Override
protected void onPause() {
stopRealtimeUpdates();
super.onPause();
}
@Override
protected void onDestroy() {
stopRealtimeUpdates();
super.onDestroy();
}
@Override
protected void reload() {
setRefreshing(true);
@@ -45,11 +69,72 @@ public class DeviceDetailActivity extends BossScreenActivity {
});
}
private void updateRealtimeSubscription() {
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
realtimeClient.start();
return;
}
stopRealtimeUpdates();
}
private void stopRealtimeUpdates() {
if (realtimeClient != null) {
realtimeClient.stop();
}
}
void handleRealtimeEvent(BossRealtimeEvent event) {
if (event == null || event.eventName.isEmpty() || deviceId == null || deviceId.isEmpty()) {
return;
}
if (!shouldReloadForRealtimeEvent(event)) {
return;
}
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
if (eventFingerprint.isEmpty()) {
return;
}
long now = System.currentTimeMillis();
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
return;
}
runOnUiThread(this::reload);
}
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
String payloadDeviceId = event.payload.optString("deviceId", "").trim();
if (payloadDeviceId.isEmpty() || !payloadDeviceId.equals(deviceId)) {
return false;
}
return "devices.updated".equals(event.eventName)
|| "devices.skills.updated".equals(event.eventName)
|| "project.context_risk.updated".equals(event.eventName);
}
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
pruneRecentRealtimeEvents(now);
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
return true;
}
recentRealtimeEventTimestamps.put(eventFingerprint, now);
return false;
}
private void pruneRecentRealtimeEvents(long now) {
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Long> entry = iterator.next();
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
iterator.remove();
}
}
}
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");
JSONObject primaryPolicy = resolvePrimaryProjectExecutionPolicy(workspace);
replaceContent();
if (device == null) {
@@ -59,53 +144,91 @@ public class DeviceDetailActivity extends BossScreenActivity {
}
deviceName = device.optString("name", deviceId);
configureScreen(deviceName, device.optString("endpoint", "设备详情"));
appendContent(BossUi.buildCard(
configureScreen(deviceName, "设备状态、GUI/CLI 能力与默认执行模式");
WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(device);
appendContent(BossUi.buildDeviceCard(
this,
device.optString("name", "设备"),
device.optString("note", "暂无备注"),
"状态 " + device.optString("status", "unknown")
+ " · 账号 " + device.optString("account", "-")
+ " · 5h " + device.optInt("quota5h", 0)
+ " · 7d " + device.optInt("quota7d", 0)
WechatSurfaceMapper.toDeviceRow(device),
null,
null
));
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(
if (summary.meta != null && !summary.meta.isEmpty()) {
appendContent(BossUi.buildWechatMenuRow(
this,
"当前绑定草稿",
"pairingCode " + enrollment.optString("pairingCode", "-")
+ "\ntoken " + enrollment.optString("token", "-"),
enrollment.optString("status", "ready")
+ " · 到期 " + enrollment.optString("expiresAt", "-")
"设备说明",
summary.meta,
null,
null,
null
));
}
appendContent(BossUi.buildWechatMenuRow(
this,
WechatSurfaceMapper.deviceCapabilityTitle("gui"),
WechatSurfaceMapper.deviceCapabilityStatusLabel(device, "gui"),
WechatSurfaceMapper.deviceCapabilityDetailLabel(device, "gui"),
null,
null
));
appendContent(BossUi.buildWechatMenuRow(
this,
WechatSurfaceMapper.deviceCapabilityTitle("cli"),
WechatSurfaceMapper.deviceCapabilityStatusLabel(device, "cli"),
WechatSurfaceMapper.deviceCapabilityDetailLabel(device, "cli"),
null,
null
));
appendContent(BossUi.buildWechatMenuRow(
this,
"默认执行模式",
WechatSurfaceMapper.devicePreferredExecutionModeSummary(device),
"切换",
null,
v -> showPreferredExecutionModeDialog(device)
));
if (primaryPolicy != null) {
appendContent(BossUi.buildWechatMenuRow(
this,
"异常项目 / 文件夹冲突",
primaryPolicy.optString("projectId", "未知项目"),
primaryPolicy.optString("folderKey", ""),
null,
null
));
appendContent(BossUi.buildWechatMenuRow(
this,
"当前冲突态",
WechatSurfaceMapper.projectConflictStateLabel(primaryPolicy.optString("conflictState", "")),
null,
null,
null
));
appendContent(BossUi.buildWechatMenuRow(
this,
"当前策略",
WechatSurfaceMapper.projectConflictAllowPolicyLabel(primaryPolicy.optString("allowPolicy", "")),
"仅作用于当前异常项目 / 文件夹",
null,
null
));
appendContent(BossUi.buildWechatMenuRow(
this,
"冲突策略",
"禁止 / 允许本次 / 永久放行",
"切换",
null,
v -> showConflictDecisionDialog(payload)
));
}
appendContent(BossUi.buildMenuRow(this, "导入项目", "勾选这台设备上要暴露到会话首页的项目和线程", null, v -> openImportDraft()));
appendContent(BossUi.buildMenuRow(this, "查看技能", "查看当前设备同步的 Skill 清单", null, v -> openSkills()));
setRefreshing(false);
}
private void openThread(String threadId) {
Intent intent = new Intent(this, ThreadDetailActivity.class);
intent.putExtra(ThreadDetailActivity.EXTRA_THREAD_ID, threadId);
private void openImportDraft() {
Intent intent = new Intent(this, DeviceImportDraftActivity.class);
intent.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_ID, deviceId);
intent.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_NAME, deviceName);
startActivity(intent);
}
@@ -116,6 +239,50 @@ public class DeviceDetailActivity extends BossScreenActivity {
startActivity(intent);
}
private void showPreferredExecutionModeDialog(JSONObject device) {
String currentMode = device == null ? "cli" : device.optString("preferredExecutionMode", "cli");
String[] modeLabels = new String[] {
WechatSurfaceMapper.deviceExecutionModeChoiceLabel("gui"),
WechatSurfaceMapper.deviceExecutionModeChoiceLabel("cli")
};
String[] modeValues = new String[] {"gui", "cli"};
int checkedIndex = "gui".equals(currentMode) ? 0 : 1;
new AlertDialog.Builder(this)
.setTitle("默认执行模式")
.setSingleChoiceItems(modeLabels, checkedIndex, (dialog, which) -> {
dialog.dismiss();
savePreferredExecutionMode(modeValues[which]);
})
.setNegativeButton("取消", null)
.show();
}
private void showConflictDecisionDialog(JSONObject payload) {
JSONObject workspace = payload == null ? null : payload.optJSONObject("workspace");
JSONObject primaryPolicy = resolvePrimaryProjectExecutionPolicy(workspace);
if (primaryPolicy == null) {
showMessage("当前没有可处理的异常项目 / 文件夹。");
return;
}
String[] labels = new String[] {"禁止", "允许本次", "永久放行"};
String[] values = new String[] {"forbid", "allow_once", "allow_always"};
int checkedIndex = resolveConflictDecisionCheckedIndex(primaryPolicy.optString("allowPolicy", ""));
new AlertDialog.Builder(this)
.setTitle("冲突策略")
.setSingleChoiceItems(labels, checkedIndex, (dialog, which) -> {
dialog.dismiss();
saveConflictDecision(
primaryPolicy.optString("projectId", ""),
primaryPolicy.optString("folderKey", ""),
values[which]
);
})
.setNegativeButton("取消", null)
.show();
}
private void openEditDialog() {
executor.execute(() -> {
try {
@@ -131,6 +298,52 @@ public class DeviceDetailActivity extends BossScreenActivity {
});
}
private void savePreferredExecutionMode(String preferredExecutionMode) {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.updateDevicePreferredExecutionMode(
deviceId,
preferredExecutionMode
);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
showMessage("默认执行模式已更新");
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("保存失败:" + error.getMessage());
});
}
});
}
private void saveConflictDecision(String projectId, @Nullable String folderKey, String decision) {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.updateProjectConflictDecision(
deviceId,
projectId,
folderKey,
decision
);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
showMessage("冲突策略已更新");
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("保存失败:" + error.getMessage());
});
}
});
}
private void showEditForm(JSONObject device) {
LinearLayout form = new LinearLayout(this);
form.setOrientation(LinearLayout.VERTICAL);
@@ -208,4 +421,21 @@ public class DeviceDetailActivity extends BossScreenActivity {
}
return builder.toString();
}
private @Nullable JSONObject resolvePrimaryProjectExecutionPolicy(@Nullable JSONObject workspace) {
if (workspace == null) return null;
JSONArray policies = workspace.optJSONArray("projectExecutionPolicies");
if (policies == null || policies.length() == 0) return null;
return policies.optJSONObject(0);
}
private int resolveConflictDecisionCheckedIndex(String allowPolicy) {
if ("allow_once".equals(allowPolicy)) {
return 1;
}
if ("allow_always".equals(allowPolicy)) {
return 2;
}
return 0;
}
}

View File

@@ -1,5 +1,6 @@
package com.hyzq.boss;
import android.content.Intent;
import android.os.Bundle;
import android.widget.EditText;
@@ -19,7 +20,7 @@ public class DeviceEnrollmentActivity extends BossScreenActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("添加设备", "通过 pairing code 或 token 把新设备接入");
configureScreen("添加设备", "填写设备信息后生成配对草稿");
hideHeaderAction();
buildForm();
}
@@ -38,23 +39,24 @@ public class DeviceEnrollmentActivity extends BossScreenActivity {
noteInput = BossUi.buildInput(this, "备注", true);
projectsInput = BossUi.buildInput(this, "项目列表,逗号分隔", true);
android.widget.Button submitButton = BossUi.buildPrimaryButton(this, "生成绑定草稿");
submitButton.setOnClickListener(v -> submitEnrollment());
replaceContent(
BossUi.buildCard(
BossUi.buildSoftPanel(
this,
"绑定新设备",
"支持通过 pairing code、临时 token 或登录引导把 Mac、Windows、云端节点接入",
"当前原生页会直接调用 /api/v1/devices/enrollments"
"接入新设备",
"支持通过 pairing code token 接入 Mac、Windows、云端节点。",
"生成后把配对码交给设备端即可完成绑定。"
),
nameInput,
avatarInput,
accountInput,
endpointInput,
noteInput,
projectsInput,
BossUi.buildPrimaryButton(this, "生成绑定草稿")
BossUi.buildFormCell(this, "设备名称", "例如 Mac Studio 或 Windows GPU", nameInput),
BossUi.buildFormCell(this, "头像字符", "会显示在设备卡片左侧", avatarInput),
BossUi.buildFormCell(this, "所属账号", "默认使用当前登录账号", accountInput),
BossUi.buildFormCell(this, "设备地址", "例如 mac://kris.local", endpointInput),
BossUi.buildFormCell(this, "设备备注", "可填写位置、用途或节点说明", noteInput),
BossUi.buildFormCell(this, "项目列表", "多个项目用逗号分隔", projectsInput),
submitButton
);
((android.widget.Button) contentLayout.getChildAt(contentLayout.getChildCount() - 1))
.setOnClickListener(v -> submitEnrollment());
}
private void submitEnrollment() {
@@ -79,16 +81,20 @@ public class DeviceEnrollmentActivity extends BossScreenActivity {
runOnUiThread(() -> {
JSONObject enrollment = response.json.optJSONObject("enrollment");
JSONObject device = response.json.optJSONObject("device");
android.widget.Button importButton = BossUi.buildSecondaryButton(this, "继续导入线程");
importButton.setOnClickListener(v -> openImportDraft(device));
replaceContent(
BossUi.buildCard(
BossUi.buildSoftPanel(
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", "-")
)
(enrollment == null ? "ready" : enrollment.optString("status", "ready"))
+ " · 到期 " + (enrollment == null ? "-" : enrollment.optString("expiresAt", "-"))
+ "\n下一步打开导入草稿勾选线程后生成导入建议。"
),
importButton
);
setRefreshing(false);
});
@@ -100,4 +106,20 @@ public class DeviceEnrollmentActivity extends BossScreenActivity {
}
});
}
private void openImportDraft(@Nullable JSONObject device) {
if (device == null) {
showMessage("设备草稿未生成完成");
return;
}
String deviceId = device.optString("id", "");
if (deviceId.isEmpty()) {
showMessage("缺少 deviceId");
return;
}
Intent intent = new Intent(this, DeviceImportDraftActivity.class);
intent.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_ID, deviceId);
intent.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_NAME, device.optString("name", nameInput.getText().toString().trim()));
startActivity(intent);
}
}

View File

@@ -0,0 +1,529 @@
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;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
public class DeviceImportDraftActivity extends BossScreenActivity {
public static final String EXTRA_DEVICE_ID = "device_id";
public static final String EXTRA_DEVICE_NAME = "device_name";
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private String deviceId;
private String deviceName;
private @Nullable JSONObject currentDraft;
private @Nullable JSONObject currentResolution;
private @Nullable JSONObject currentReviewTask;
private final LinkedHashSet<String> selectedCandidateIds = new LinkedHashSet<>();
private final Runnable reviewPollRunnable = this::reload;
private @Nullable BossRealtimeClient realtimeClient;
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
@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 ? "选择要导入的 Codex 项目与线程" : deviceName);
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
reload();
}
@Override
protected void onResume() {
super.onResume();
updateRealtimeSubscription();
}
@Override
protected void onPause() {
stopRealtimeUpdates();
super.onPause();
}
@Override
protected void reload() {
if (deviceId == null || deviceId.isEmpty()) {
replaceContent(BossUi.buildEmptyCard(this, "缺少 deviceId。"));
setRefreshing(false);
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getDeviceImportDraft(deviceId);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> applyPayload(
response.json.optJSONObject("draft"),
response.json.optJSONObject("resolution"),
response.json.optJSONObject("reviewTask")
));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "导入草稿加载失败:" + error.getMessage()));
});
}
});
}
private void updateRealtimeSubscription() {
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
realtimeClient.start();
return;
}
stopRealtimeUpdates();
}
private void stopRealtimeUpdates() {
if (realtimeClient != null) {
realtimeClient.stop();
}
}
void handleRealtimeEvent(BossRealtimeEvent event) {
if (event == null || event.eventName.isEmpty()) {
return;
}
if (!shouldReloadForRealtimeEvent(event)) {
return;
}
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
if (eventFingerprint.isEmpty()) {
return;
}
long now = System.currentTimeMillis();
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
return;
}
runOnUiThread(this::reload);
}
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
if (!"devices.updated".equals(event.eventName)) {
return false;
}
String payloadDeviceId = event.payload.optString("deviceId", "").trim();
if (payloadDeviceId.isEmpty()) {
return true;
}
if (deviceId == null || deviceId.isEmpty()) {
return true;
}
return payloadDeviceId.equals(deviceId);
}
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
pruneRecentRealtimeEvents(now);
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
return true;
}
recentRealtimeEventTimestamps.put(eventFingerprint, now);
return false;
}
private void pruneRecentRealtimeEvents(long now) {
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Long> entry = iterator.next();
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
iterator.remove();
}
}
}
private void applyPayload(
@Nullable JSONObject draft,
@Nullable JSONObject resolution,
@Nullable JSONObject reviewTask
) {
currentDraft = draft;
currentResolution = resolution;
currentReviewTask = reviewTask;
selectedCandidateIds.clear();
JSONArray selected = draft == null ? null : draft.optJSONArray("selectedCandidateIds");
if (selected != null) {
for (int i = 0; i < selected.length(); i++) {
String candidateId = selected.optString(i, "");
if (!candidateId.isEmpty()) {
selectedCandidateIds.add(candidateId);
}
}
}
renderCurrentState();
}
@Override
protected void onDestroy() {
contentLayout.removeCallbacks(reviewPollRunnable);
stopRealtimeUpdates();
super.onDestroy();
}
private boolean isReviewPending(@Nullable JSONObject draft, @Nullable JSONObject resolution, @Nullable JSONObject reviewTask) {
if (draft == null || resolution != null) {
return false;
}
if (!"pending_resolution".equals(draft.optString("status", ""))) {
return false;
}
if (reviewTask == null) {
return false;
}
String taskStatus = reviewTask.optString("status", "");
return "queued".equals(taskStatus) || "running".equals(taskStatus);
}
private void renderCurrentState() {
JSONObject draft = currentDraft;
JSONObject resolution = currentResolution;
JSONObject reviewTask = currentReviewTask;
contentLayout.removeCallbacks(reviewPollRunnable);
replaceContent();
appendContent(BossUi.buildSoftPanel(
this,
"导入 Codex 项目",
(deviceName == null ? "当前设备" : deviceName) + "\n先勾选线程再生成导入建议最后应用导入。",
draft == null
? "等待设备完成首次 heartbeat"
: "状态 " + resolveStatusTitle(draft)
));
if (draft == null) {
appendContent(BossUi.buildEmptyCard(this, "设备完成配对并上报项目候选后,这里会出现可导入项目。"));
setRefreshing(false);
return;
}
JSONArray candidates = draft.optJSONArray("candidates");
if (candidates == null || candidates.length() == 0) {
appendContent(BossUi.buildEmptyCard(this, "设备已在线,但当前还没有发现可导入线程。可以稍后刷新重试。"));
setRefreshing(false);
return;
}
int recommendedCount = 0;
for (int i = 0; i < candidates.length(); i++) {
JSONObject candidate = candidates.optJSONObject(i);
if (candidate != null && candidate.optBoolean("suggestedImport", false)) {
recommendedCount += 1;
}
}
appendContent(BossUi.buildCard(
this,
resolveStatusTitle(draft),
resolveStatusBody(draft, resolution, reviewTask),
"候选 " + candidates.length()
+ " · 已选 " + selectedCandidateIds.size()
+ " · 推荐 " + recommendedCount
));
Map<String, JSONArray> grouped = new LinkedHashMap<>();
for (int i = 0; i < candidates.length(); i++) {
JSONObject candidate = candidates.optJSONObject(i);
if (candidate == null) continue;
String groupKey = candidate.optString("codexFolderRef", candidate.optString("folderRef", candidate.optString("folderName", "未命名项目")));
JSONArray bucket = grouped.get(groupKey);
if (bucket == null) {
bucket = new JSONArray();
grouped.put(groupKey, bucket);
}
bucket.put(candidate);
}
for (JSONArray items : grouped.values()) {
JSONObject first = items.optJSONObject(0);
if (first == null) continue;
String folderName = first.optString("folderName", "未命名项目");
appendContent(BossUi.buildWechatMenuRow(
this,
folderName,
items.length() + " 个线程",
"勾选后会进入主 Agent 导入建议",
null,
null
));
for (int i = 0; i < items.length(); i++) {
JSONObject candidate = items.optJSONObject(i);
if (candidate == null) continue;
String candidateId = candidate.optString("candidateId", "");
boolean selectedState = selectedCandidateIds.contains(candidateId);
appendContent(BossUi.buildWechatMenuRow(
this,
candidate.optString("threadDisplayName", "未命名线程"),
"最近活跃:" + candidate.optString("lastActiveAt", "-"),
null,
selectedState
? (candidate.optBoolean("suggestedImport", false) ? "已选 · 推荐导入" : "已选")
: (candidate.optBoolean("suggestedImport", false) ? "推荐导入" : null),
v -> toggleSelection(candidateId)
));
}
}
if (resolution != null) {
appendContent(BossUi.buildCard(
this,
"导入建议",
resolution.optString("summary", "已生成导入建议。"),
"应用后会把选中的线程映射成正式聊天窗口。"
));
JSONArray items = resolution.optJSONArray("items");
if (items != null) {
for (int i = 0; i < items.length(); i++) {
JSONObject item = items.optJSONObject(i);
if (item == null) continue;
appendContent(BossUi.buildWechatMenuRow(
this,
item.optString("threadDisplayName", "未命名线程"),
item.optString("folderName", ""),
item.optString("action", "") + " · " + item.optString("reason", ""),
null,
null
));
}
}
}
if (reviewTask != null) {
appendContent(BossUi.buildCard(
this,
"审核任务",
"状态:" + reviewTask.optString("status", "unknown"),
isReviewPending(draft, resolution, reviewTask)
? "主 Agent 正在生成导入建议,页面会自动刷新。"
: "如果任务失败,可以直接重新生成导入建议。"
));
}
JSONArray appliedProjectNames = draft.optJSONArray("appliedProjectNames");
if (appliedProjectNames != null && appliedProjectNames.length() > 0) {
appendContent(BossUi.buildCard(
this,
"应用结果",
"已导入 " + appliedProjectNames.length() + " 个线程:" + joinNames(appliedProjectNames) + "",
"这些线程现在会出现在会话首页。"
));
}
Button reviewButton = BossUi.buildMiniActionButton(this, "生成导入建议", true);
reviewButton.setEnabled(!selectedCandidateIds.isEmpty() && !isReviewPending(draft, resolution, reviewTask));
if (isReviewPending(draft, resolution, reviewTask)) {
reviewButton.setText("主 Agent 审核中");
} else if (reviewTask != null && "failed".equals(reviewTask.optString("status", ""))) {
reviewButton.setText("重新生成导入建议");
} else if ("resolved".equals(draft.optString("status", "")) || "applied".equals(draft.optString("status", ""))) {
reviewButton.setText("重新生成导入建议");
}
reviewButton.setOnClickListener(v -> reviewSelection());
Button clearButton = BossUi.buildMiniActionButton(this, "清空勾选", false);
clearButton.setEnabled(!selectedCandidateIds.isEmpty());
clearButton.setOnClickListener(v -> clearSelection());
Button applyButton = BossUi.buildMiniActionButton(
this,
"applied".equals(draft.optString("status", "")) ? "已导入" : "应用导入",
false
);
applyButton.setEnabled(resolution != null && "resolved".equals(draft.optString("status", "")));
applyButton.setOnClickListener(v -> applyResolution());
appendContent(BossUi.buildInlineActionRow(this, reviewButton, clearButton, applyButton));
setRefreshing(false);
if (isReviewPending(draft, resolution, reviewTask)) {
contentLayout.postDelayed(reviewPollRunnable, 2000);
}
}
private String resolveStatusTitle(@Nullable JSONObject draft) {
if (draft == null) {
return "等待导入草稿";
}
String status = draft.optString("status", "");
if ("pending_candidates".equals(status)) {
return "等待候选线程";
}
if ("pending_selection".equals(status)) {
return "等待勾选";
}
if ("pending_resolution".equals(status)) {
return "建议生成中";
}
if ("resolved".equals(status)) {
return "建议已生成";
}
if ("applied".equals(status)) {
return "已导入";
}
return "导入草稿";
}
private String resolveStatusBody(@Nullable JSONObject draft, @Nullable JSONObject resolution, @Nullable JSONObject reviewTask) {
if (draft == null) {
return "先让设备完成首次 heartbeat 并上报候选线程,导入草稿就会出现在这里。";
}
String status = draft.optString("status", "");
if ("pending_candidates".equals(status)) {
return "设备已经就绪,等 heartbeat 带回线程候选后,就可以开始勾选。";
}
if ("pending_selection".equals(status)) {
return "先勾选想导入的线程,再生成导入建议。";
}
if ("pending_resolution".equals(status)) {
if (isReviewPending(draft, resolution, reviewTask)) {
return "勾选已保存,主 Agent 正在整理导入建议,页面会自动刷新。";
}
if (reviewTask != null && "failed".equals(reviewTask.optString("status", ""))) {
return "主 Agent 这次没能生成导入建议。可以稍后重新生成,当前勾选会保留。";
}
return "勾选已保存,接下来会生成导入建议。";
}
if ("resolved".equals(status)) {
return resolution == null ? "可以先看建议,再点应用导入。" : resolution.optString("summary", "可以先看建议,再点应用导入。");
}
if ("applied".equals(status)) {
JSONArray appliedProjectNames = draft.optJSONArray("appliedProjectNames");
if (appliedProjectNames != null && appliedProjectNames.length() > 0) {
return "已导入 " + appliedProjectNames.length() + " 个线程:" + joinNames(appliedProjectNames) + "";
}
return "导入已完成,线程已经落到会话首页。";
}
return "先勾选线程,再生成导入建议,最后应用导入。";
}
private String joinNames(JSONArray values) {
List<String> names = new ArrayList<>();
for (int i = 0; i < values.length(); i++) {
String value = values.optString(i, "");
if (!value.isEmpty()) {
names.add(value);
}
}
return String.join("", names);
}
private void toggleSelection(String candidateId) {
if (candidateId == null || candidateId.isEmpty()) {
return;
}
if (selectedCandidateIds.contains(candidateId)) {
selectedCandidateIds.remove(candidateId);
} else {
selectedCandidateIds.add(candidateId);
}
renderCurrentState();
}
private void reviewSelection() {
if (deviceId == null || deviceId.isEmpty()) {
showMessage("缺少 deviceId");
return;
}
setRefreshing(true);
executor.execute(() -> {
JSONObject selectedDraft = null;
try {
JSONArray selected = new JSONArray();
for (String candidateId : selectedCandidateIds) {
selected.put(candidateId);
}
BossApiClient.ApiResponse selectResponse = apiClient.selectDeviceImportCandidates(deviceId, selected);
if (!selectResponse.ok()) {
throw new IllegalStateException(selectResponse.message());
}
selectedDraft = selectResponse.json.optJSONObject("draft");
BossApiClient.ApiResponse reviewResponse = apiClient.reviewDeviceImportDraft(deviceId);
if (!reviewResponse.ok()) {
throw new IllegalStateException(reviewResponse.message());
}
runOnUiThread(() -> {
boolean hasResolution = reviewResponse.json.optJSONObject("resolution") != null;
showMessage(hasResolution ? "已生成导入建议" : "已提交给主 Agent 审核");
applyPayload(
reviewResponse.json.optJSONObject("draft"),
reviewResponse.json.optJSONObject("resolution"),
reviewResponse.json.optJSONObject("reviewTask") != null
? reviewResponse.json.optJSONObject("reviewTask")
: reviewResponse.json.optJSONObject("task")
);
});
} catch (Exception error) {
final JSONObject fallbackDraft = selectedDraft;
runOnUiThread(() -> {
if (fallbackDraft != null) {
applyPayload(fallbackDraft, null, null);
} else {
setRefreshing(false);
}
showMessage("导入建议生成失败:" + error.getMessage());
});
}
});
}
private void clearSelection() {
if (deviceId == null || deviceId.isEmpty()) {
showMessage("缺少 deviceId");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.selectDeviceImportCandidates(deviceId, new JSONArray());
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
showMessage("已清空当前勾选");
applyPayload(response.json.optJSONObject("draft"), null, null);
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("清空勾选失败:" + error.getMessage());
});
}
});
}
private void applyResolution() {
if (deviceId == null || deviceId.isEmpty()) {
showMessage("缺少 deviceId");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.applyDeviceImportDraft(deviceId);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
showMessage("已应用导入");
applyPayload(
response.json.optJSONObject("draft"),
response.json.optJSONObject("resolution"),
null
);
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("导入应用失败:" + error.getMessage());
});
}
});
}
}

View File

@@ -0,0 +1,248 @@
package com.hyzq.boss;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class ForwardPayloads {
private ForwardPayloads() {}
public static JSONObject build(
String mode,
@Nullable String sourceMessageId,
@Nullable List<String> sourceMessageIds
) throws JSONException {
MutableJsonObject payload = new MutableJsonObject();
String normalizedMode = isEmpty(mode) ? "single" : mode;
payload.put("mode", normalizedMode);
if (normalizedMode.startsWith("single")) {
String resolvedSourceMessageId = sourceMessageId;
if (isEmpty(resolvedSourceMessageId) && sourceMessageIds != null && sourceMessageIds.size() == 1) {
resolvedSourceMessageId = sourceMessageIds.get(0);
}
if (isEmpty(resolvedSourceMessageId)) {
throw new JSONException("sourceMessageId required");
}
payload.put("sourceMessageId", resolvedSourceMessageId);
return payload;
}
MutableJsonArray orderedIds = new MutableJsonArray();
if (sourceMessageIds != null) {
for (String messageId : sourceMessageIds) {
if (!isEmpty(messageId)) {
orderedIds.put(messageId);
}
}
}
if (orderedIds.length() == 0) {
throw new JSONException("sourceMessageIds required");
}
payload.put("sourceMessageIds", orderedIds);
return payload;
}
public static String toRequestBody(String targetProjectId, @Nullable JSONObject payload) throws JSONException {
MutableJsonObject requestPayload = new MutableJsonObject();
requestPayload.put("targetProjectId", targetProjectId);
if (payload == null) {
return requestPayload.toString();
}
String mode = payload.optString("mode", "");
if (!isEmpty(mode)) {
requestPayload.put("mode", mode);
}
String sourceMessageId = payload.optString("sourceMessageId", "");
if (!isEmpty(sourceMessageId)) {
requestPayload.put("sourceMessageId", sourceMessageId);
}
JSONArray sourceMessageIds = payload.optJSONArray("sourceMessageIds");
if (sourceMessageIds != null && sourceMessageIds.length() > 0) {
MutableJsonArray orderedIds = new MutableJsonArray();
for (int i = 0; i < sourceMessageIds.length(); i++) {
String messageId = sourceMessageIds.optString(i);
if (!isEmpty(messageId)) {
orderedIds.put(messageId);
}
}
if (orderedIds.length() > 0) {
requestPayload.put("sourceMessageIds", orderedIds);
}
}
return requestPayload.toString();
}
public static boolean isApprovalRequired(@Nullable JSONObject responseJson) {
return responseJson != null && responseJson.optBoolean("approvalRequired", false);
}
private static boolean isEmpty(@Nullable String value) {
return value == null || value.length() == 0;
}
private static final class MutableJsonObject extends JSONObject {
private final Map<String, Object> values = new LinkedHashMap<>();
@Override
public JSONObject put(String key, boolean value) {
values.put(key, value);
return this;
}
@Override
public JSONObject put(String key, int value) {
values.put(key, value);
return this;
}
@Override
public JSONObject put(String key, long value) {
values.put(key, value);
return this;
}
@Override
public JSONObject put(String key, Object value) {
values.put(key, value);
return this;
}
@Override
public String optString(String key) {
Object value = values.get(key);
return value instanceof String ? (String) value : "";
}
@Override
public String optString(String key, String fallback) {
String value = optString(key);
return value.isEmpty() ? fallback : value;
}
@Override
public JSONArray optJSONArray(String key) {
Object value = values.get(key);
return value instanceof JSONArray ? (JSONArray) value : null;
}
@Override
public boolean optBoolean(String key, boolean fallback) {
Object value = values.get(key);
return value instanceof Boolean ? (Boolean) value : fallback;
}
@Override
public String toString() {
StringBuilder builder = new StringBuilder("{");
boolean first = true;
for (Map.Entry<String, Object> entry : values.entrySet()) {
if (!first) {
builder.append(",");
}
first = false;
builder.append("\"").append(escape(entry.getKey())).append("\":");
builder.append(stringify(entry.getValue()));
}
builder.append("}");
return builder.toString();
}
}
private static final class MutableJsonArray extends JSONArray {
private final ArrayList<Object> values = new ArrayList<>();
@Override
public JSONArray put(boolean value) {
values.add(value);
return this;
}
@Override
public JSONArray put(int value) {
values.add(value);
return this;
}
@Override
public JSONArray put(long value) {
values.add(value);
return this;
}
@Override
public JSONArray put(Object value) {
values.add(value);
return this;
}
@Override
public int length() {
return values.size();
}
@Override
public JSONObject optJSONObject(int index) {
if (index < 0 || index >= values.size()) {
return null;
}
Object value = values.get(index);
return value instanceof JSONObject ? (JSONObject) value : null;
}
@Override
public String optString(int index) {
if (index < 0 || index >= values.size()) {
return "";
}
Object value = values.get(index);
return value instanceof String ? (String) value : "";
}
@Override
public String toString() {
StringBuilder builder = new StringBuilder("[");
for (int i = 0; i < values.size(); i++) {
if (i > 0) {
builder.append(",");
}
builder.append(stringify(values.get(i)));
}
builder.append("]");
return builder.toString();
}
}
private static String stringify(@Nullable Object value) {
if (value == null) {
return "null";
}
if (value instanceof String) {
return "\"" + escape((String) value) + "\"";
}
if (value instanceof Number || value instanceof Boolean) {
return String.valueOf(value);
}
return value.toString();
}
private static String escape(String value) {
return value
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
}
}

View File

@@ -0,0 +1,195 @@
package com.hyzq.boss;
import android.content.Intent;
import android.os.Bundle;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
public class ForwardTargetActivity extends BossScreenActivity {
public static final String EXTRA_SOURCE_PROJECT_ID = "source_project_id";
public static final String EXTRA_FORWARD_MODE = "forward_mode";
public static final String EXTRA_SOURCE_MESSAGE_ID = "source_message_id";
public static final String EXTRA_SOURCE_MESSAGE_IDS = "source_message_ids";
private String sourceProjectId;
private String forwardMode;
@Nullable
private String sourceMessageId;
private final ArrayList<String> sourceMessageIds = new ArrayList<>();
@Override
protected int getLayoutResId() {
return R.layout.activity_forward_target;
}
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent intent = getIntent();
sourceProjectId = intent.getStringExtra(EXTRA_SOURCE_PROJECT_ID);
forwardMode = intent.getStringExtra(EXTRA_FORWARD_MODE);
sourceMessageId = intent.getStringExtra(EXTRA_SOURCE_MESSAGE_ID);
String[] messageIds = intent.getStringArrayExtra(EXTRA_SOURCE_MESSAGE_IDS);
if (messageIds != null) {
for (String messageId : messageIds) {
if (!TextUtils.isEmpty(messageId)) {
sourceMessageIds.add(messageId);
}
}
}
configureScreen("选择转发目标", buildSourceMeta());
reload();
}
@Override
protected void reload() {
if (isEmpty(sourceProjectId)) {
showMessage("缺少源会话");
finish();
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getConversations();
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
JSONArray conversations = response.json.optJSONArray("conversations");
List<JSONObject> targets = collectSelectableTargets(conversations, sourceProjectId);
runOnUiThread(() -> renderTargets(targets));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "转发目标加载失败:" + error.getMessage()));
});
}
});
}
public static List<JSONObject> collectSelectableTargets(JSONArray conversations, String sourceProjectId) {
ArrayList<JSONObject> result = new ArrayList<>();
if (conversations == null) {
return result;
}
for (int i = 0; i < conversations.length(); i++) {
JSONObject item = conversations.optJSONObject(i);
if (item == null) {
continue;
}
if (!isEmpty(sourceProjectId) && sourceProjectId.equals(item.optString("projectId", ""))) {
continue;
}
result.add(item);
}
return result;
}
public static JSONObject buildForwardPayload(String mode, @Nullable String sourceMessageId, List<String> sourceMessageIds)
throws JSONException {
return ForwardPayloads.build(mode, sourceMessageId, sourceMessageIds);
}
static String resolveForwardResultMessage(JSONObject responseJson) {
return ForwardPayloads.isApprovalRequired(responseJson) ? "已提交主 Agent 审批" : "转发成功";
}
private void renderTargets(List<JSONObject> targets) {
replaceContent(
BossUi.buildCard(
this,
"正在选择转发目标",
buildSourceBody(),
buildSourceMeta()
)
);
if (targets.isEmpty()) {
appendContent(BossUi.buildEmptyCard(this, "当前没有可转发的目标会话。"));
setRefreshing(false);
return;
}
for (JSONObject target : targets) {
appendContent(BossUi.buildConversationRow(
this,
WechatSurfaceMapper.toConversationRow(target),
v -> forwardToTarget(target)
));
}
setRefreshing(false);
}
private String buildSourceBody() {
StringBuilder builder = new StringBuilder();
builder.append("源会话:").append(isEmpty(sourceProjectId) ? "-" : sourceProjectId);
builder.append("\n转发模式").append(isEmpty(forwardMode) ? "single" : forwardMode);
return builder.toString();
}
private String buildSourceMeta() {
int messageCount = sourceMessageIds.size();
if (!isEmpty(sourceMessageId)) {
return "source_message_id 已就绪";
}
if (messageCount > 0) {
return "source_message_ids " + messageCount + "";
}
return "等待聊天页入口补充消息选择";
}
private void forwardToTarget(JSONObject target) {
if (target == null) {
showMessage("目标会话无效");
return;
}
String targetProjectId = target.optString("projectId", "");
if (isEmpty(targetProjectId)) {
showMessage("目标会话无效");
return;
}
try {
JSONObject payload = buildForwardPayload(
forwardMode,
sourceMessageId,
sourceMessageIds
);
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.forwardProjectMessage(sourceProjectId, targetProjectId, payload);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
setRefreshing(false);
showMessage(resolveForwardResultMessage(response.json));
setResult(RESULT_OK);
finish();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("转发失败:" + error.getMessage());
});
}
});
} catch (JSONException error) {
showMessage("缺少源消息,暂无法转发");
}
}
private static boolean isEmpty(@Nullable String value) {
return value == null || value.length() == 0;
}
}

View File

@@ -0,0 +1,474 @@
package com.hyzq.boss;
import android.content.Intent;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
public class GroupCreateActivity extends BossScreenActivity {
public static final String EXTRA_SOURCE_PROJECT_ID = "source_project_id";
public static final String EXTRA_SOURCE_PROJECT_NAME = "source_project_name";
private final List<CandidateConversation> candidates = new ArrayList<>();
private final Set<String> selectedProjectIds = new LinkedHashSet<>();
private final Set<String> lastCandidateProjectIds = new LinkedHashSet<>();
private static final Set<String> AUTO_JOIN_GROUP_TITLES = new HashSet<>(Arrays.asList(
"主agent",
"硬件审计协作",
"boss移动控制台"
));
private String sourceProjectId;
private String sourceProjectName;
private String sourceFolderName;
private LinearLayout candidateListLayout;
private Button createButton;
private boolean creatingGroupChat;
private JSONObject cachedParticipantsPayload;
private JSONObject cachedConversationsPayload;
@Override
protected int getLayoutResId() {
return R.layout.activity_group_create;
}
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
sourceProjectId = getIntent().getStringExtra(EXTRA_SOURCE_PROJECT_ID);
sourceProjectName = getIntent().getStringExtra(EXTRA_SOURCE_PROJECT_NAME);
configureScreen(
"发起群聊",
hasSourceProject() ? (sourceProjectName == null ? "从当前会话出发" : sourceProjectName) : "从会话列表直接建群"
);
reload();
}
@Override
protected void reload() {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse conversationsResponse = apiClient.getConversations();
if (!conversationsResponse.ok()) throw new IllegalStateException(conversationsResponse.message());
if (!hasSourceProject()) {
runOnUiThread(() -> renderCreatePage(null, conversationsResponse.json, true));
return;
}
BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(sourceProjectId);
if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message());
JSONObject participantsPayload = extractParticipantsPayload(detailResponse.json, sourceProjectId);
runOnUiThread(() -> renderCreatePage(participantsPayload, conversationsResponse.json, true));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "群聊创建页加载失败:" + error.getMessage()));
});
}
});
}
private void renderCreatePage(JSONObject participantsPayload, JSONObject conversationsPayload, boolean rebuildCandidates) {
cachedParticipantsPayload = participantsPayload;
cachedConversationsPayload = conversationsPayload;
replaceContent();
JSONObject threadMeta = participantsPayload == null ? null : participantsPayload.optJSONObject("threadMeta");
JSONArray participants = participantsPayload == null ? null : participantsPayload.optJSONArray("participants");
sourceFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
if (hasSourceProject()) {
sourceProjectName = threadMeta == null
? sourceProjectName
: threadMeta.optString("threadDisplayName", sourceProjectName == null ? "当前会话" : sourceProjectName);
}
appendContent(buildHeaderView(
hasSourceProject(),
sourceProjectId,
sourceProjectName,
threadMeta,
participants
));
if (rebuildCandidates) {
List<JSONObject> selectableConversations = collectSelectableConversationItems(conversationsPayload, sourceProjectId);
List<CandidateConversation> nextCandidates = new ArrayList<>(selectableConversations.size());
Set<String> nextCandidateProjectIds = new LinkedHashSet<>();
for (JSONObject item : selectableConversations) {
CandidateConversation candidate = new CandidateConversation(
item.optString("projectId", ""),
item
);
nextCandidates.add(candidate);
nextCandidateProjectIds.add(candidate.projectId);
}
Set<String> currentSelectedProjectIds = new LinkedHashSet<>(selectedProjectIds);
candidates.clear();
candidates.addAll(nextCandidates);
selectedProjectIds.clear();
selectedProjectIds.addAll(reconcileSelectedProjectIds(
currentSelectedProjectIds,
lastCandidateProjectIds,
nextCandidateProjectIds,
hasSourceProject()
));
lastCandidateProjectIds.clear();
lastCandidateProjectIds.addAll(nextCandidateProjectIds);
}
appendContent(buildSectionLabel("选择其他线程"));
appendContent(BossUi.buildHintPill(
this,
buildSelectionHintText(candidates.size(), selectedProjectIds.size(), hasSourceProject())
));
candidateListLayout = new LinearLayout(this);
candidateListLayout.setOrientation(LinearLayout.VERTICAL);
for (CandidateConversation candidate : candidates) {
candidateListLayout.addView(buildCandidateRow(candidate));
}
if (candidates.isEmpty()) {
candidateListLayout.addView(BossUi.buildEmptyCard(this, "当前没有可选择的其他线程。"));
}
appendContent(candidateListLayout);
createButton = BossUi.buildPrimaryButton(this, "创建群聊");
createButton.setOnClickListener(v -> createGroupChat());
Button cancelButton = BossUi.buildSecondaryButton(this, "取消");
cancelButton.setOnClickListener(v -> finish());
appendContent(BossUi.buildInlineActionRow(this, cancelButton, createButton));
setRefreshing(false);
updateCreateButtonState();
}
private JSONObject extractParticipantsPayload(JSONObject detailPayload, String fallbackProjectId) {
JSONObject participantsPayload = detailPayload == null ? null : detailPayload.optJSONObject("participantsPayload");
if (participantsPayload != null) {
return participantsPayload;
}
JSONObject fallback = new JSONObject();
JSONObject project = detailPayload == null ? null : detailPayload.optJSONObject("project");
JSONObject threadMeta = project == null ? null : project.optJSONObject("threadMeta");
try {
fallback.put("projectId", fallbackProjectId == null ? "" : fallbackProjectId);
fallback.put("threadMeta", threadMeta == null ? new JSONObject() : threadMeta);
fallback.put("participants", new JSONArray());
} catch (JSONException ignored) {
}
return fallback;
}
private View buildHeaderView(
boolean hasSourceProject,
@Nullable String sourceProjectId,
@Nullable String sourceProjectName,
@Nullable JSONObject threadMeta,
@Nullable JSONArray participants
) {
if (hasSourceProject) {
return BossUi.buildSimpleProfileHeader(
this,
TextUtils.isEmpty(sourceProjectName) ? "当前会话" : sourceProjectName,
"从当前会话发起群聊",
buildSourceHeaderDetail(sourceProjectId, threadMeta, participants)
);
}
return BossUi.buildSimpleProfileHeader(
this,
"发起新群聊",
"从会话列表直接建群",
"至少选择 2 个线程后创建新群"
);
}
private TextView buildSectionLabel(String text) {
TextView label = new TextView(this);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
params.leftMargin = BossUi.dp(this, 16);
params.rightMargin = BossUi.dp(this, 16);
params.bottomMargin = BossUi.dp(this, 6);
label.setLayoutParams(params);
label.setText(text);
label.setTextSize(13);
label.setTextColor(getColor(R.color.boss_text_muted));
return label;
}
static List<JSONObject> collectSelectableConversationItems(@Nullable JSONObject conversationsPayload, String sourceProjectId) {
List<JSONObject> result = new ArrayList<>();
JSONArray conversations = conversationsPayload == null ? null : conversationsPayload.optJSONArray("conversations");
if (conversations == null) {
return result;
}
for (int i = 0; i < conversations.length(); i++) {
JSONObject item = conversations.optJSONObject(i);
if (item == null) continue;
if (isEligibleForManualGroupSelection(item, sourceProjectId)) {
result.add(item);
}
}
return result;
}
static boolean isEligibleForManualGroupSelection(@Nullable JSONObject item, @Nullable String sourceProjectId) {
if (item == null) {
return false;
}
String projectId = item.optString("projectId", "");
if (projectId.isEmpty()) {
return false;
}
if (sourceProjectId != null && !sourceProjectId.isEmpty() && sourceProjectId.equals(projectId)) {
return false;
}
if (item.optBoolean("isGroup", false)) {
return false;
}
if (!"single_device".equals(item.optString("conversationType", "single_device"))) {
return false;
}
return !AUTO_JOIN_GROUP_TITLES.contains(normalizeConversationTitle(item));
}
private static String normalizeConversationTitle(JSONObject item) {
String title = item.optString("projectTitle", item.optString("threadTitle", ""));
return title == null ? "" : title.replaceAll("\\s+", "").toLowerCase();
}
static WechatSurfaceMapper.ConversationRow toCandidateConversationRow(JSONObject item, boolean selected) {
return new WechatSurfaceMapper.ConversationRow(
item.optString("projectTitle", item.optString("threadTitle", "未命名会话")),
item.optString("folderLabel", ""),
item.optString("lastMessagePreview", item.optString("preview", "")),
item.optString("latestReplyLabel", ""),
0,
selected ? "已选" : "",
0,
false,
"",
"",
new WechatSurfaceMapper.GroupAvatarMember[0]
);
}
static String buildSourceHeaderDetail(
@Nullable String sourceProjectId,
@Nullable JSONObject threadMeta,
@Nullable JSONArray participants
) {
return buildSourceBody(sourceProjectId, threadMeta, participants);
}
static String buildSelectionHintText(
int candidateCount,
int selectedCount,
boolean hasSourceProject
) {
if (candidateCount <= 0) {
return "当前没有可加入的其他线程";
}
if (selectedCount <= 0) {
return hasSourceProject ? "至少选择 1 个其他线程" : "至少选择 2 个线程";
}
return "已选 " + selectedCount + " 个线程";
}
private LinearLayout buildCandidateRow(CandidateConversation candidate) {
LinearLayout row = BossUi.buildConversationRow(
this,
toCandidateConversationRow(candidate.sourceItem, selectedProjectIds.contains(candidate.projectId)),
v -> toggleSelection(candidate.projectId)
);
LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) row.getLayoutParams();
params.bottomMargin = BossUi.dp(this, 8);
row.setLayoutParams(params);
row.setPadding(BossUi.dp(this, 12), BossUi.dp(this, 12), BossUi.dp(this, 12), BossUi.dp(this, 12));
return row;
}
private void toggleSelection(String projectId) {
if (selectedProjectIds.contains(projectId)) {
selectedProjectIds.remove(projectId);
} else {
selectedProjectIds.add(projectId);
}
refreshCandidateRows();
updateCreateButtonState();
}
private void refreshCandidateRows() {
if (cachedParticipantsPayload == null || cachedConversationsPayload == null) {
return;
}
renderCreatePage(cachedParticipantsPayload, cachedConversationsPayload, false);
}
private void updateCreateButtonState() {
if (createButton != null) {
boolean refreshing = refreshLayout != null && refreshLayout.isRefreshing();
createButton.setEnabled(canCreateGroupChat(refreshing, creatingGroupChat, selectedProjectIds, hasSourceProject()));
createButton.setText(creatingGroupChat ? "创建中..." : "创建群聊");
}
}
private void createGroupChat() {
boolean refreshing = refreshLayout != null && refreshLayout.isRefreshing();
if (refreshing || creatingGroupChat) {
return;
}
if (selectedProjectIds.isEmpty()) {
showMessage("请至少选择一个其他线程");
return;
}
List<String> memberProjectIdsSnapshot = new ArrayList<>(selectedProjectIds);
creatingGroupChat = true;
setRefreshing(true);
updateCreateButtonState();
executor.execute(() -> {
try {
JSONObject payload = new JSONObject();
JSONArray memberProjectIds = new JSONArray();
for (String projectId : memberProjectIdsSnapshot) {
memberProjectIds.put(projectId);
}
payload.put("memberProjectIds", memberProjectIds);
BossApiClient.ApiResponse response = hasSourceProject()
? apiClient.createGroupChat(sourceProjectId, payload)
: apiClient.createStandaloneGroupChat(payload);
if (!response.ok()) throw new IllegalStateException(response.message());
JSONObject project = response.json.optJSONObject("project");
if (project == null) throw new IllegalStateException("GROUP_CHAT_PROJECT_MISSING");
String createdProjectId = project.optString("id", "");
if (createdProjectId.isEmpty()) {
throw new IllegalStateException("GROUP_CHAT_PROJECT_ID_MISSING");
}
String createdProjectName = project.optString("name", sourceProjectName == null ? "群聊" : sourceProjectName);
runOnUiThread(() -> {
setRefreshing(false);
creatingGroupChat = false;
updateCreateButtonState();
showMessage("群聊已创建");
Intent intent = new Intent(this, ProjectDetailActivity.class);
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, createdProjectId);
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, createdProjectName);
startActivity(intent);
finish();
});
} catch (Exception error) {
runOnUiThread(() -> {
creatingGroupChat = false;
setRefreshing(false);
showMessage("创建失败:" + error.getMessage());
updateCreateButtonState();
});
}
});
}
static boolean canCreateGroupChat(
boolean refreshing,
boolean creatingGroupChat,
@Nullable Set<String> selectedProjectIds,
boolean hasSourceProject
) {
return !refreshing
&& !creatingGroupChat
&& selectedProjectIds != null
&& selectedProjectIds.size() >= (hasSourceProject ? 1 : 2);
}
static Set<String> reconcileSelectedProjectIds(
@Nullable Set<String> currentSelectedProjectIds,
@Nullable Set<String> previousCandidateProjectIds,
@Nullable Set<String> nextCandidateProjectIds
) {
return reconcileSelectedProjectIds(
currentSelectedProjectIds,
previousCandidateProjectIds,
nextCandidateProjectIds,
true
);
}
static Set<String> reconcileSelectedProjectIds(
@Nullable Set<String> currentSelectedProjectIds,
@Nullable Set<String> previousCandidateProjectIds,
@Nullable Set<String> nextCandidateProjectIds,
boolean defaultSelectAll
) {
Set<String> reconciled = new LinkedHashSet<>();
if (nextCandidateProjectIds == null || nextCandidateProjectIds.isEmpty()) {
return reconciled;
}
if (previousCandidateProjectIds == null
|| previousCandidateProjectIds.isEmpty()
|| !previousCandidateProjectIds.equals(nextCandidateProjectIds)) {
if (defaultSelectAll) {
reconciled.addAll(nextCandidateProjectIds);
}
return reconciled;
}
if (currentSelectedProjectIds == null || currentSelectedProjectIds.isEmpty()) {
return reconciled;
}
for (String projectId : currentSelectedProjectIds) {
if (nextCandidateProjectIds.contains(projectId)) {
reconciled.add(projectId);
}
}
return reconciled;
}
private boolean hasSourceProject() {
return sourceProjectId != null && !sourceProjectId.isEmpty();
}
private static String buildSourceBody(
@Nullable String sourceProjectId,
@Nullable JSONObject threadMeta,
@Nullable JSONArray participants
) {
String threadId = threadMeta == null ? sourceProjectId : threadMeta.optString("threadId", sourceProjectId);
String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
String resolvedFolderName = folderName.isEmpty() ? "未命名文件夹" : folderName;
String resolvedThreadId = threadId == null || threadId.isEmpty() ? "未命名线程" : threadId;
return resolvedThreadId
+ " · "
+ resolvedFolderName
+ " · "
+ (participants == null ? 0 : participants.length())
+ " 个参与线程";
}
private static final class CandidateConversation {
private final String projectId;
private final JSONObject sourceItem;
private CandidateConversation(
String projectId,
JSONObject sourceItem
) {
this.projectId = projectId;
this.sourceItem = sourceItem;
}
}
}

View File

@@ -0,0 +1,687 @@
package com.hyzq.boss;
import android.content.Intent;
import android.os.Bundle;
import android.text.TextUtils;
import android.widget.EditText;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.LinkedHashMap;
import java.util.Map;
public class GroupInfoActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id";
public static final String EXTRA_PROJECT_NAME = "project_name";
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private String projectId;
private String projectName;
private boolean groupRepairJustApplied;
private @Nullable BossRealtimeClient realtimeClient;
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
@Override
protected int getLayoutResId() {
return R.layout.activity_group_info;
}
@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);
refreshButton.setVisibility(android.view.View.GONE);
setHeaderAction("...", v -> showMoreMenu());
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
reload();
}
@Override
protected void onResume() {
super.onResume();
updateRealtimeSubscription();
}
@Override
protected void onPause() {
stopRealtimeUpdates();
super.onPause();
}
@Override
protected void onDestroy() {
stopRealtimeUpdates();
super.onDestroy();
}
@Override
protected void reload() {
if (projectId == null || projectId.isEmpty()) {
showMessage("缺少 projectId");
finish();
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(projectId);
if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message());
JSONObject participantsPayload = extractParticipantsPayload(detailResponse.json);
BossApiClient.ApiResponse orchestrationResponse = apiClient.getProjectOrchestrationBackend(projectId);
JSONObject orchestrationBackend = orchestrationResponse.ok()
? orchestrationResponse.json
: buildFallbackOrchestrationBackendPayload(orchestrationResponse.message());
runOnUiThread(() -> renderGroup(detailResponse.json, participantsPayload, orchestrationBackend));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "群资料加载失败:" + error.getMessage()));
});
}
});
}
private void updateRealtimeSubscription() {
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
realtimeClient.start();
return;
}
stopRealtimeUpdates();
}
private void stopRealtimeUpdates() {
if (realtimeClient != null) {
realtimeClient.stop();
}
}
void handleRealtimeEvent(BossRealtimeEvent event) {
if (event == null || event.eventName.isEmpty() || projectId == null || projectId.isEmpty()) {
return;
}
if (!shouldReloadForRealtimeEvent(event)) {
return;
}
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
if (eventFingerprint.isEmpty()) {
return;
}
long now = System.currentTimeMillis();
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
return;
}
runOnUiThread(this::reload);
}
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
String payloadProjectId = event.payload.optString("projectId", "").trim();
if (payloadProjectId.isEmpty() || !payloadProjectId.equals(projectId)) {
return false;
}
return "conversation.updated".equals(event.eventName)
|| "project.messages.updated".equals(event.eventName);
}
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
pruneRecentRealtimeEvents(now);
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
return true;
}
recentRealtimeEventTimestamps.put(eventFingerprint, now);
return false;
}
private void pruneRecentRealtimeEvents(long now) {
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Long> entry = iterator.next();
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
iterator.remove();
}
}
}
private JSONObject extractParticipantsPayload(JSONObject detailPayload) {
JSONObject participantsPayload = detailPayload == null ? null : detailPayload.optJSONObject("participantsPayload");
return participantsPayload == null ? new JSONObject() : participantsPayload;
}
private void renderGroup(JSONObject detail, JSONObject participantsPayload) {
renderGroup(detail, participantsPayload, null);
}
private void renderGroup(JSONObject detail, JSONObject participantsPayload, @Nullable JSONObject orchestrationBackendPayload) {
replaceContent();
JSONObject project = detail.optJSONObject("project");
JSONArray participants = participantsPayload.optJSONArray("participants");
if (project == null) {
appendContent(BossUi.buildEmptyCard(this, "群聊不存在。"));
setRefreshing(false);
return;
}
projectName = project.optString("name", projectName == null ? "群聊" : projectName);
JSONObject threadMeta = project.optJSONObject("threadMeta");
String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
int participantCount = participants == null ? 0 : participants.length();
boolean repairRequired = participantsPayload.optBoolean("repairRequired", false);
String repairReason = participantsPayload.optString("repairReason", "");
int validParticipantCount = participantsPayload.optInt("validParticipantCount", participantCount);
int invalidParticipantCount = participantsPayload.optInt("invalidParticipantCount", 0);
configureScreen("群资料", buildSubtitle(folderName, participantCount));
if (groupRepairJustApplied) {
appendContent(BossUi.buildEmptyCard(this, "群成员已更新,当前群聊已经切换到新的真实线程成员。"));
groupRepairJustApplied = false;
}
appendContent(BossUi.buildSimpleProfileHeader(
this,
projectName,
"协作群聊",
buildHeaderDetail(project, threadMeta, folderName, participantCount)
));
appendContent(BossUi.buildWechatMenuRow(
this,
"线程详情",
"查看当前群聊对应项目",
resolveThreadId(project, threadMeta),
null,
v -> openProject(projectId, projectName)
));
if (orchestrationBackendPayload != null) {
appendContent(buildOrchestrationBackendRow(orchestrationBackendPayload));
}
appendContent(buildDispatchReminderRow(project));
if (repairRequired) {
String meta = invalidParticipantCount > 0
? "存在 " + invalidParticipantCount + " 个失效成员"
: "当前仅有 " + validParticipantCount + " 个真实线程成员";
appendContent(BossUi.buildWechatMenuRow(
this,
"修复群成员",
TextUtils.isEmpty(repairReason) ? "重新选择要加入群聊的真实线程" : repairReason,
meta,
"推荐",
v -> openRepairMembersDialog(participantsPayload)
));
}
appendContent(BossUi.buildWechatMenuRow(
this,
"群成员",
participantCount <= 0 ? "暂无成员" : "" + participantCount + "",
folderName.isEmpty() ? null : folderName,
null,
null
));
if (participants == null || participants.length() == 0) {
appendContent(BossUi.buildWechatMenuRow(
this,
"暂无群成员",
"下拉刷新后重试",
null,
null,
null
));
} else {
for (int i = 0; i < participants.length(); i++) {
JSONObject participant = participants.optJSONObject(i);
if (participant == null) continue;
appendContent(buildMemberRow(participant));
}
}
setRefreshing(false);
}
private LinearLayout buildDispatchReminderRow(JSONObject project) {
boolean enabled = project.optBoolean("lightDispatchReminderEnabled", false);
return BossUi.buildWechatMenuRow(
this,
"推荐下发默认轻提醒",
enabled ? "已开启" : "已关闭",
enabled ? "后续推荐会保留轻状态卡,不再弹重确认提醒。" : "当前仍会显式提醒你确认主 Agent 推荐。",
enabled ? "开启" : "关闭",
v -> openDispatchReminderDialog(enabled)
);
}
private LinearLayout buildMemberRow(JSONObject participant) {
boolean sourceProject = participant.optBoolean("isSourceProject", false);
boolean canOpenProject = participant.optBoolean("canOpenProject", true);
String status = participant.optString("status", "active");
String statusLabel = participant.optString("statusLabel", "");
String participantProjectId = participant.optString("projectId", "");
String title = participant.optString("threadDisplayName", "未命名线程");
String subtitle = participant.optString("folderName", "");
String meta = participant.optString("deviceId", "");
String threadId = participant.optString("threadId", "");
if (!threadId.isEmpty()) {
meta = meta.isEmpty() ? threadId : meta + " · " + threadId;
}
if (sourceProject) {
subtitle = subtitle.isEmpty() ? "当前群聊" : "当前群聊 · " + subtitle;
}
if (!statusLabel.isEmpty() && !"active".equals(status)) {
subtitle = subtitle.isEmpty() ? statusLabel : subtitle + " · " + statusLabel;
}
return BossUi.buildWechatMenuRow(
this,
title,
subtitle,
meta,
sourceProject ? "当前" : (!"active".equals(status) ? "失效" : null),
canOpenProject ? v -> openProject(participantProjectId, title) : null
);
}
private void openRepairMembersDialog(JSONObject participantsPayload) {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse conversationsResponse = apiClient.getConversations();
if (!conversationsResponse.ok()) throw new IllegalStateException(conversationsResponse.message());
runOnUiThread(() -> showRepairMembersPicker(participantsPayload, conversationsResponse.json));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("加载可选线程失败:" + error.getMessage());
});
}
});
}
private void showRepairMembersPicker(JSONObject participantsPayload, JSONObject conversationsPayload) {
JSONArray conversations = conversationsPayload.optJSONArray("conversations");
JSONArray participants = participantsPayload.optJSONArray("participants");
java.util.Set<String> selectedProjectIds = new java.util.LinkedHashSet<>();
if (participants != null) {
for (int i = 0; i < participants.length(); i++) {
JSONObject participant = participants.optJSONObject(i);
if (participant == null) continue;
if (!"active".equals(participant.optString("status", "active"))) continue;
String participantProjectId = participant.optString("projectId", "").trim();
if (!participantProjectId.isEmpty()) {
selectedProjectIds.add(participantProjectId);
}
}
}
java.util.ArrayList<String> labels = new java.util.ArrayList<>();
java.util.ArrayList<String> projectIds = new java.util.ArrayList<>();
java.util.ArrayList<Boolean> checkedValues = new java.util.ArrayList<>();
if (conversations != null) {
for (int i = 0; i < conversations.length(); i++) {
JSONObject conversation = conversations.optJSONObject(i);
if (conversation == null) continue;
if (!"single_device".equals(conversation.optString("conversationType", ""))) continue;
String candidateProjectId = conversation.optString("projectId", "").trim();
if (candidateProjectId.isEmpty() || candidateProjectId.equals(projectId)) continue;
String title = conversation.optString("threadTitle", conversation.optString("projectTitle", "未命名线程"));
String folderLabel = conversation.optString("folderLabel", "");
labels.add(TextUtils.isEmpty(folderLabel) ? title : title + " · " + folderLabel);
projectIds.add(candidateProjectId);
checkedValues.add(selectedProjectIds.contains(candidateProjectId));
}
}
if (labels.isEmpty()) {
setRefreshing(false);
showMessage("当前没有可加入群聊的真实线程");
return;
}
CharSequence[] items = labels.toArray(new CharSequence[0]);
boolean[] checked = new boolean[checkedValues.size()];
for (int i = 0; i < checked.length; i++) {
checked[i] = checkedValues.get(i);
}
setRefreshing(false);
new AlertDialog.Builder(this)
.setTitle("修复群成员")
.setMessage("请选择要加入这个群聊的真实线程。")
.setMultiChoiceItems(items, checked, (dialog, which, isChecked) -> checked[which] = isChecked)
.setNegativeButton("取消", null)
.setPositiveButton("应用", (dialog, which) -> applyGroupRepair(projectIds, checked))
.show();
}
private void applyGroupRepair(java.util.List<String> candidateProjectIds, boolean[] checked) {
JSONArray memberProjectIds = new JSONArray();
for (int i = 0; i < checked.length && i < candidateProjectIds.size(); i++) {
if (checked[i]) {
memberProjectIds.put(candidateProjectIds.get(i));
}
}
if (memberProjectIds.length() < 2) {
showMessage("群聊至少需要 2 个真实线程成员");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.replaceConversationParticipants(projectId, memberProjectIds);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
groupRepairJustApplied = true;
showMessage("群成员已更新");
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("修复失败:" + error.getMessage());
});
}
});
}
private void openProject(String targetProjectId, String targetProjectName) {
if (targetProjectId == null || targetProjectId.isEmpty()) {
showMessage("缺少 projectId");
return;
}
Intent intent = new Intent(this, ProjectDetailActivity.class);
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, targetProjectId);
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, targetProjectName);
startActivity(intent);
}
private void openRenameDialog() {
final EditText input = BossUi.buildInput(this, "群名", false);
input.setText(projectName == null ? "" : projectName);
new AlertDialog.Builder(this)
.setTitle("重命名群聊")
.setView(input)
.setNegativeButton("取消", null)
.setPositiveButton("保存", (dialog, which) -> saveGroupName(input.getText().toString().trim()))
.show();
}
private void saveGroupName(String name) {
if (name.isEmpty()) {
showMessage("群名不能为空");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.renameConversation(projectId, name, true);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
Intent result = new Intent();
result.putExtra(EXTRA_PROJECT_NAME, name);
setResult(RESULT_OK, result);
showMessage("群名已更新");
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("保存失败:" + error.getMessage());
});
}
});
}
private LinearLayout buildOrchestrationBackendRow(JSONObject backendPayload) {
String requestedBackendId = backendPayload.optString("requestedBackendId", "boss-native-orchestrator");
String currentBackendId = backendPayload.optString("currentBackendId", requestedBackendId);
JSONObject omxAvailability = backendPayload.optJSONObject("omxAvailability");
String currentLabel = resolveBackendLabel(backendPayload, currentBackendId);
String requestedLabel = resolveBackendLabel(backendPayload, requestedBackendId);
String subtitle = "当前:" + currentLabel;
if (!TextUtils.equals(currentBackendId, requestedBackendId)) {
subtitle += " · 请求:" + requestedLabel;
}
boolean omxSelectable = omxAvailability != null && omxAvailability.optBoolean("selectable", false);
boolean fallbackActive = !TextUtils.equals(currentBackendId, requestedBackendId);
if (omxAvailability != null) {
subtitle += omxSelectable ? " · OMX 可用" : " · OMX 受限";
}
String meta = omxAvailability == null
? "等待后端状态"
: buildOrchestrationBackendAvailabilitySummary(omxAvailability, fallbackActive);
String badge = fallbackActive ? "回退" : (omxSelectable ? "当前" : "受限");
return BossUi.buildWechatMenuRow(
this,
"编排后端",
subtitle,
meta,
badge,
v -> openOrchestrationBackendDialog(backendPayload)
);
}
private void openOrchestrationBackendDialog(JSONObject backendPayload) {
JSONArray availableChoices = backendPayload.optJSONArray("availableChoices");
if (availableChoices == null || availableChoices.length() == 0) {
showMessage("编排后端状态暂不可用");
return;
}
CharSequence[] items = new CharSequence[availableChoices.length()];
final String[] backendIds = new String[availableChoices.length()];
final boolean[] selectable = new boolean[availableChoices.length()];
final String omxReason = backendPayload.optJSONObject("omxAvailability") == null
? "OMX Team Runtime 当前不可用。"
: backendPayload.optJSONObject("omxAvailability").optString("reasonLabel", "OMX Team Runtime 当前不可用。");
final boolean omxSelectable = backendPayload.optJSONObject("omxAvailability") != null
&& backendPayload.optJSONObject("omxAvailability").optBoolean("selectable", false);
for (int i = 0; i < availableChoices.length(); i++) {
JSONObject choice = availableChoices.optJSONObject(i);
if (choice == null) {
items[i] = "未命名后端";
backendIds[i] = "";
selectable[i] = false;
continue;
}
backendIds[i] = choice.optString("backendId", "");
selectable[i] = choice.optBoolean("selectable", false);
String label = resolveBackendLabel(backendPayload, backendIds[i]);
items[i] = label + (selectable[i] ? "" : "(不可用)");
}
new AlertDialog.Builder(this)
.setTitle("选择编排后端")
.setMessage(omxSelectable
? "Boss Native Orchestrator 永远可用OMX Team Runtime 当前可直接切换。"
: "Boss Native Orchestrator 永远可用OMX Team Runtime 当前不可用,切换时会自动回退到 Boss Native Orchestrator。")
.setItems(items, (dialog, which) -> {
String selectedBackendId = backendIds[which];
if (TextUtils.isEmpty(selectedBackendId)) {
showMessage("编排后端选择无效");
return;
}
if (!selectable[which] && TextUtils.equals(selectedBackendId, "omx-team")) {
showMessage(omxReason);
return;
}
saveOrchestrationBackend(selectedBackendId);
})
.setNegativeButton("取消", null)
.show();
}
private void saveOrchestrationBackend(String requestedBackendId) {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.updateProjectOrchestrationBackend(projectId, requestedBackendId);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
showMessage("编排后端已更新为 " + resolveBackendLabelForId(requestedBackendId));
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("保存失败:" + error.getMessage());
});
}
});
}
private void openDispatchReminderDialog(boolean enabled) {
CharSequence[] items = enabled
? new CharSequence[]{"关闭默认轻提醒"}
: new CharSequence[]{"开启默认轻提醒"};
new AlertDialog.Builder(this)
.setTitle("推荐下发默认轻提醒")
.setMessage(enabled
? "关闭后,这个群会恢复成每次都显式确认的提醒方式。"
: "开启后,这个群后续仍会显示一张轻状态卡,但不再出现重提醒。")
.setItems(items, (dialog, which) -> saveDispatchReminderPreference(!enabled))
.setNegativeButton("取消", null)
.show();
}
private void saveDispatchReminderPreference(boolean enabled) {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.updateProjectDispatchReminder(projectId, enabled);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
showMessage(enabled ? "已开启默认轻提醒" : "已关闭默认轻提醒");
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("保存失败:" + error.getMessage());
});
}
});
}
private void showMoreMenu() {
new AlertDialog.Builder(this)
.setItems(new CharSequence[]{"改名", "刷新"}, (dialog, which) -> {
if (which == 0) {
openRenameDialog();
return;
}
reload();
})
.show();
}
private String resolveBackendLabel(JSONObject backendPayload, String backendId) {
JSONArray availableChoices = backendPayload.optJSONArray("availableChoices");
if (availableChoices != null) {
for (int i = 0; i < availableChoices.length(); i++) {
JSONObject choice = availableChoices.optJSONObject(i);
if (choice == null) continue;
if (TextUtils.equals(choice.optString("backendId", ""), backendId)) {
return choice.optString("label", resolveBackendLabelForId(backendId));
}
}
}
return resolveBackendLabelForId(backendId);
}
private String resolveBackendLabelForId(String backendId) {
if (TextUtils.equals(backendId, "omx-team")) {
return "OMX Team Runtime";
}
return "Boss Native Orchestrator";
}
private String normalizeOrchestrationReasonLabel(String value) {
String trimmed = value == null ? "" : value.trim();
if (trimmed.endsWith("") || trimmed.endsWith(".")) {
return trimmed.substring(0, trimmed.length() - 1);
}
return trimmed;
}
private String buildOrchestrationBackendAvailabilitySummary(JSONObject omxAvailability, boolean fallbackActive) {
if (omxAvailability.optBoolean("selectable", false)) {
return "OMX Team Runtime 当前可用,当前可切换到该后端。";
}
String reasonLabel = normalizeOrchestrationReasonLabel(
omxAvailability.optString("reasonLabel", "OMX Team Runtime 当前不可用。")
);
return fallbackActive
? reasonLabel + ",当前已自动回退到 Boss Native Orchestrator。"
: reasonLabel + ",切换后会自动回退到 Boss Native Orchestrator。";
}
private JSONObject buildFallbackOrchestrationBackendPayload(String reason) {
try {
JSONArray availableChoices = new JSONArray()
.put(new JSONObject()
.put("backendId", "boss-native-orchestrator")
.put("label", "Boss Native Orchestrator")
.put("selectable", true)
.put("current", true))
.put(new JSONObject()
.put("backendId", "omx-team")
.put("label", "OMX Team Runtime")
.put("selectable", false)
.put("current", false));
return new JSONObject()
.put("currentBackendId", "boss-native-orchestrator")
.put("requestedBackendId", "boss-native-orchestrator")
.put("availableChoices", availableChoices)
.put("omxAvailability", new JSONObject()
.put("selectable", false)
.put("reason", "disabled")
.put("reasonLabel", TextUtils.isEmpty(reason) ? "OMX Team Runtime 当前不可用。" : reason));
} catch (Exception error) {
return new JSONObject();
}
}
private String buildSubtitle(String folderName, int count) {
String memberLabel = count <= 0 ? "暂无成员" : count + " 个成员";
if (folderName.isEmpty()) {
return memberLabel;
}
return folderName + " · " + memberLabel;
}
private String buildHeaderDetail(JSONObject project, @Nullable JSONObject threadMeta, String folderName, int count) {
StringBuilder builder = new StringBuilder();
String threadId = resolveThreadId(project, threadMeta);
if (!threadId.isEmpty()) {
builder.append(threadId);
}
if (!folderName.isEmpty()) {
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(folderName);
}
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(count <= 0 ? "暂无成员" : count + " 个成员");
return builder.toString();
}
private String resolveThreadId(JSONObject project, @Nullable JSONObject threadMeta) {
if (threadMeta != null) {
String threadId = threadMeta.optString("threadId", "");
if (!threadId.isEmpty()) {
return threadId;
}
}
return project.optString("id", "");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,378 @@
package com.hyzq.boss;
import android.os.Bundle;
import android.widget.Button;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
public class MasterAgentEvolutionActivity extends BossScreenActivity {
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private LinearLayout contentRoot;
private @Nullable BossRealtimeClient realtimeClient;
private long lastRealtimeReloadAt;
private boolean contentLoaded;
private @Nullable String currentMode;
private @Nullable String statusMessage;
private boolean statusIsError;
private boolean canManageEvolution = true;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("主 Agent 自动进化", "信号、提案与生效规则");
setHeaderAction("刷新", v -> reload());
contentRoot = new LinearLayout(this);
contentRoot.setOrientation(LinearLayout.VERTICAL);
replaceContent(contentRoot);
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
reload();
}
@Override
protected void onResume() {
super.onResume();
updateRealtimeSubscription();
}
@Override
protected void onPause() {
stopRealtimeUpdates();
super.onPause();
}
@Override
protected void onDestroy() {
stopRealtimeUpdates();
super.onDestroy();
}
@Override
protected void reload() {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getMasterAgentEvolution();
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
clearStatusMessage();
renderDashboard(response.json);
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
contentLoaded = false;
showStatusMessage("自动进化加载失败:" + error.getMessage(), true);
renderLoadErrorState(error.getMessage());
});
}
});
}
private void updateRealtimeSubscription() {
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
realtimeClient.start();
return;
}
stopRealtimeUpdates();
}
private void stopRealtimeUpdates() {
if (realtimeClient != null) {
realtimeClient.stop();
}
}
void handleRealtimeEvent(BossRealtimeEvent event) {
if (event == null || !"master_agent.settings.updated".equals(event.eventName)) {
return;
}
long now = System.currentTimeMillis();
if (now - lastRealtimeReloadAt < REALTIME_RELOAD_THROTTLE_MS) {
return;
}
lastRealtimeReloadAt = now;
runOnUiThread(this::reload);
}
private void renderDashboard(JSONObject payload) {
JSONObject config = payload.optJSONObject("config");
JSONArray signals = payload.optJSONArray("signals");
JSONArray proposals = payload.optJSONArray("proposals");
JSONArray rules = payload.optJSONArray("rules");
canManageEvolution = payload.optBoolean("canManage", true);
currentMode = config == null ? "controlled" : config.optString("mode", "controlled");
boolean autoApplyLowRiskRules = config != null && config.optBoolean("autoApplyLowRiskRules", false);
replaceContent(contentRoot);
contentRoot.removeAllViews();
contentRoot.addView(BossUi.buildSimpleProfileHeader(
this,
"主 Agent 自动进化",
"最近在学什么、打算怎么改、已经生效了什么",
canManageEvolution
? "支持在这里切换 controlled / autonomous并直接审核待处理提案。"
: "当前是只读视角,可以查看主 Agent 正在学习和生效的规则。"
));
contentRoot.addView(BossUi.buildSoftPanel(
this,
"当前模式",
"autonomous".equals(currentMode) ? "完全自我进化" : "受控自动进化",
autoApplyLowRiskRules ? "低风险提案会自动采纳。" : "所有提案都需要人工确认。"
));
maybeRenderStatusBanner();
if (canManageEvolution) {
Button controlledButton = BossUi.buildMiniActionButton(this, "切到受控模式", false);
controlledButton.setEnabled(!"controlled".equals(currentMode));
controlledButton.setOnClickListener(v -> switchMode("controlled"));
Button autonomousButton = BossUi.buildMiniActionButton(this, "切到全自动模式", true);
autonomousButton.setEnabled(!"autonomous".equals(currentMode));
autonomousButton.setOnClickListener(v -> switchMode("autonomous"));
contentRoot.addView(BossUi.buildInlineActionRow(this, controlledButton, autonomousButton));
} else {
contentRoot.addView(BossUi.buildEmptyCard(this, "你当前没有管理权限,模式切换和提案审批仅管理员可操作。"));
}
contentRoot.addView(BossUi.buildWechatMenuRow(
this,
"待处理提案",
String.valueOf(countPendingProposals(proposals)) + "",
"待审核的策略变更会在这里集中展示。",
null,
null
));
renderPendingProposals(proposals);
contentRoot.addView(BossUi.buildWechatMenuRow(
this,
"最近信号",
signals == null ? "0 条" : signals.length() + "",
"主 Agent 最近捕获到的问题和自我修正线索。",
null,
null
));
renderSignals(signals);
contentRoot.addView(BossUi.buildWechatMenuRow(
this,
"已生效规则",
rules == null ? "0 条" : rules.length() + "",
"已经落进系统并开始影响主 Agent 行为的规则。",
null,
null
));
renderRules(rules);
contentLoaded = true;
setRefreshing(false);
}
private void renderPendingProposals(@Nullable JSONArray proposals) {
if (proposals == null || proposals.length() == 0) {
contentRoot.addView(BossUi.buildEmptyCard(this, "当前没有待审批提案。"));
return;
}
boolean rendered = false;
for (int i = 0; i < proposals.length(); i++) {
JSONObject proposal = proposals.optJSONObject(i);
if (proposal == null || !"pending_review".equals(proposal.optString("status", ""))) {
continue;
}
rendered = true;
String proposalId = proposal.optString("proposalId", "");
contentRoot.addView(BossUi.buildWechatMenuRow(
this,
proposal.optString("title", "待审批提案"),
proposal.optString("summary", "暂无摘要"),
proposal.optString("proposalType", "-")
+ " · " + proposal.optString("riskLevel", "-")
+ " · " + formatTime(proposal.optString("createdAt", "-")),
null,
null
));
if (canManageEvolution) {
Button rejectButton = BossUi.buildMiniActionButton(this, "拒绝", false);
rejectButton.setOnClickListener(v -> reviewProposal(proposalId, false));
Button approveButton = BossUi.buildMiniActionButton(this, "批准", true);
approveButton.setOnClickListener(v -> reviewProposal(proposalId, true));
contentRoot.addView(BossUi.buildInlineActionRow(this, rejectButton, approveButton));
}
}
if (!rendered) {
contentRoot.addView(BossUi.buildEmptyCard(this, "当前没有待审批提案。"));
}
}
private void renderSignals(@Nullable JSONArray signals) {
if (signals == null || signals.length() == 0) {
contentRoot.addView(BossUi.buildEmptyCard(this, "当前还没有进化信号。"));
return;
}
for (int i = 0; i < Math.min(signals.length(), 8); i++) {
JSONObject signal = signals.optJSONObject(i);
if (signal == null) continue;
contentRoot.addView(BossUi.buildWechatMenuRow(
this,
signal.optString("kind", "signal"),
signal.optString("requestText", ""),
formatTime(signal.optString("createdAt", "-")),
null,
null
));
}
}
private void renderRules(@Nullable JSONArray rules) {
if (rules == null || rules.length() == 0) {
contentRoot.addView(BossUi.buildEmptyCard(this, "当前还没有已生效规则。"));
return;
}
for (int i = 0; i < Math.min(rules.length(), 8); i++) {
JSONObject rule = rules.optJSONObject(i);
if (rule == null) continue;
contentRoot.addView(BossUi.buildWechatMenuRow(
this,
rule.optString("ruleType", "rule"),
rule.optString("sourceProposalId", "直接创建"),
formatTime(rule.optString("createdAt", "-")),
null,
null
));
}
}
private int countPendingProposals(@Nullable JSONArray proposals) {
if (proposals == null) {
return 0;
}
int count = 0;
for (int i = 0; i < proposals.length(); i++) {
JSONObject proposal = proposals.optJSONObject(i);
if (proposal != null && "pending_review".equals(proposal.optString("status", ""))) {
count += 1;
}
}
return count;
}
private void switchMode(String mode) {
if (!contentLoaded) {
showMessage("自动进化尚未加载完成。");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.updateMasterAgentEvolutionMode(mode);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
showStatusMessage("已切到 " + ("autonomous".equals(mode) ? "完全自我进化" : "受控自动进化"), false);
setResult(RESULT_OK);
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showStatusMessage("切换失败:" + error.getMessage(), true);
});
}
});
}
private void reviewProposal(String proposalId, boolean approve) {
if (proposalId == null || proposalId.isEmpty()) {
showMessage("缺少 proposalId");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = approve
? apiClient.approveMasterAgentEvolutionProposal(proposalId)
: apiClient.rejectMasterAgentEvolutionProposal(proposalId);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
showStatusMessage(approve ? "提案已批准" : "提案已拒绝", false);
setResult(RESULT_OK);
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showStatusMessage((approve ? "批准失败:" : "拒绝失败:") + error.getMessage(), true);
});
}
});
}
private void maybeRenderStatusBanner() {
if (statusMessage == null || statusMessage.isEmpty()) {
return;
}
contentRoot.addView(BossUi.buildSoftPanel(
this,
statusIsError ? "最近状态" : "最近操作",
statusMessage,
statusIsError ? "你可以直接点顶部刷新重试,或继续切换模式/审批提案。" : "本页已经同步到最新自动进化状态。"
));
}
private void renderLoadErrorState(String message) {
replaceContent(contentRoot);
contentRoot.removeAllViews();
contentRoot.addView(BossUi.buildSimpleProfileHeader(
this,
"主 Agent 自动进化",
"信号、提案与生效规则",
"当前加载失败,保留在这个页面直接重试即可。"
));
maybeRenderStatusBanner();
Button retryButton = BossUi.buildMiniActionButton(this, "重新加载", true);
retryButton.setOnClickListener(v -> reload());
contentRoot.addView(BossUi.buildInlineActionRow(this, retryButton));
contentRoot.addView(BossUi.buildEmptyCard(this, "自动进化中心暂时不可用:" + message));
}
private void showStatusMessage(String message, boolean isError) {
statusMessage = message;
statusIsError = isError;
showMessage(message);
}
private void clearStatusMessage() {
if (!statusIsError) {
statusMessage = null;
}
statusIsError = false;
}
private String formatTime(String value) {
if (value == null || value.isEmpty() || "-".equals(value)) {
return "-";
}
String normalized = value.replace('T', ' ');
int plusIndex = normalized.indexOf('+');
if (plusIndex > 0) {
return normalized.substring(0, plusIndex);
}
int zIndex = normalized.indexOf('Z');
if (zIndex > 0) {
return normalized.substring(0, zIndex);
}
return normalized;
}
}

View File

@@ -0,0 +1,409 @@
package com.hyzq.boss;
import android.app.Dialog;
import android.content.DialogInterface;
import android.os.Bundle;
import android.text.TextUtils;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.Spinner;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray;
import org.json.JSONObject;
public class MasterAgentMemoryActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id";
public static final String EXTRA_PROJECT_NAME = "project_name";
private static final String[] MEMORY_SCOPE_VALUES = {"global", "project"};
private static final String[] MEMORY_SCOPE_LABELS = {"我的通用记忆", "项目记忆"};
private static final String[] MEMORY_TYPE_VALUES = {
"user_preference",
"project_progress",
"decision",
"risk",
"blocking_issue",
"research_note",
"workflow_rule"
};
private static final String[] MEMORY_TYPE_LABELS = {
"用户偏好",
"项目进度",
"决策",
"风险",
"阻塞",
"调研结论",
"工作规则"
};
private String projectId;
private String projectName;
private boolean contentLoaded;
private @Nullable JSONObject globalMemoriesPayload;
private @Nullable JSONObject projectMemoriesPayload;
private @Nullable JSONArray globalMemoryItems;
private @Nullable JSONArray projectMemoryItems;
@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 ? "主 Agent 记忆" : projectName);
setHeaderAction("新增", v -> openMemoryEditor(null));
updateSaveAvailability();
reload();
}
@Override
protected void reload() {
if (projectId == null || projectId.isEmpty()) {
replaceContent(BossUi.buildEmptyCard(this, "缺少 projectId。"));
setRefreshing(false);
contentLoaded = false;
updateSaveAvailability();
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getMasterAgentMemories(projectId);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> renderMemories(response.json));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
contentLoaded = false;
updateSaveAvailability();
replaceContent(BossUi.buildEmptyCard(this, "记忆加载失败:" + error.getMessage()));
});
}
});
}
private void renderMemories(JSONObject payload) {
JSONObject memories = payload.optJSONObject("memories");
globalMemoriesPayload = memories == null ? null : memories.optJSONObject("global");
projectMemoriesPayload = memories == null ? null : memories.optJSONObject("project");
globalMemoryItems = extractMemoryItems(memories, "global");
projectMemoryItems = extractMemoryItems(memories, "project");
replaceContent();
appendContent(BossUi.buildSimpleProfileHeader(
this,
projectName == null ? "主 Agent" : projectName,
"自动沉淀 / 手动维护",
"项目记忆会绑定到真实项目,通用记忆属于当前用户。"
));
appendContent(BossUi.buildSoftPanel(
this,
"记忆说明",
"主 Agent 会自动沉淀长期有用的信息。你也可以在这里手动新增、编辑或归档。",
"底层是结构化存储,项目记忆会显示真实 projectId。"
));
renderSection(
"我的通用记忆",
globalMemoryItems,
"当前没有通用记忆。"
);
renderSection(
"项目记忆",
projectMemoryItems,
"当前还没有项目记忆。"
);
contentLoaded = true;
updateSaveAvailability();
setRefreshing(false);
}
private void renderSection(String title, @Nullable JSONArray items, String emptyText) {
int count = items == null ? 0 : items.length();
appendContent(BossUi.buildWechatMenuRow(
this,
title,
count <= 0 ? emptyText : "" + count + "",
null,
null,
null
));
if (items == null || items.length() == 0) {
appendContent(BossUi.buildEmptyCard(this, emptyText));
return;
}
for (int i = 0; i < items.length(); i++) {
JSONObject memory = items.optJSONObject(i);
if (memory == null) continue;
appendContent(buildMemoryRow(memory));
}
}
private @Nullable JSONArray extractMemoryItems(@Nullable JSONObject memories, String key) {
if (memories == null || !memories.has(key)) {
return null;
}
Object value = memories.opt(key);
if (value instanceof JSONArray) {
return (JSONArray) value;
}
if (value instanceof JSONObject) {
return ((JSONObject) value).optJSONArray("items");
}
return null;
}
private LinearLayout buildMemoryRow(JSONObject memory) {
String scope = memory.optString("scope", "global");
String type = memory.optString("memoryType", "user_preference");
String title = memory.optString("title", "未命名记忆");
String content = memory.optString("content", "");
String tags = joinTags(memory.optJSONArray("tags"));
String meta = memory.optString("updatedAt", memory.optString("createdAt", ""));
if (!TextUtils.isEmpty(tags)) {
meta = TextUtils.isEmpty(meta) ? tags : meta + " · " + tags;
}
String badge = "project".equals(scope) ? "项目" : "全局";
String subtitle = memoryTypeLabel(type) + (TextUtils.isEmpty(content) ? "" : " · " + content);
return BossUi.buildWechatMenuRow(
this,
title,
subtitle,
meta,
badge,
v -> openMemoryEditor(memory)
);
}
private void openMemoryEditor(@Nullable JSONObject memory) {
LinearLayout form = new LinearLayout(this);
form.setOrientation(LinearLayout.VERTICAL);
final Spinner scopeSpinner = new Spinner(this);
ArrayAdapter<String> scopeAdapter = new ArrayAdapter<>(
this,
android.R.layout.simple_spinner_dropdown_item,
MEMORY_SCOPE_LABELS
);
scopeSpinner.setAdapter(scopeAdapter);
final Spinner typeSpinner = new Spinner(this);
ArrayAdapter<String> typeAdapter = new ArrayAdapter<>(
this,
android.R.layout.simple_spinner_dropdown_item,
MEMORY_TYPE_LABELS
);
typeSpinner.setAdapter(typeAdapter);
final EditText titleInput = BossUi.buildInput(this, "记忆标题", false);
final EditText contentInput = BossUi.buildInput(this, "记忆内容", true);
final EditText projectIdInput = BossUi.buildInput(this, "例如wenshenapp", false);
final EditText tagsInput = BossUi.buildInput(this, "标签,逗号分隔", false);
contentInput.setMinLines(6);
if (memory != null) {
titleInput.setText(memory.optString("title", ""));
contentInput.setText(memory.optString("content", ""));
projectIdInput.setText(memory.optString("projectId", ""));
tagsInput.setText(joinTags(memory.optJSONArray("tags")));
scopeSpinner.setSelection("project".equals(memory.optString("scope", "global")) ? 1 : 0);
typeSpinner.setSelection(memoryTypeIndex(memory.optString("memoryType", "user_preference")));
} else {
scopeSpinner.setSelection(0);
typeSpinner.setSelection(0);
projectIdInput.setText(projectId == null || "master-agent".equals(projectId) ? "" : projectId);
}
form.addView(BossUi.buildFormCell(this, "作用域", "决定是用户通用记忆还是当前项目记忆。", scopeSpinner));
form.addView(BossUi.buildFormCell(this, "projectId", "项目记忆需要绑定到真实项目;通用记忆可以留空。", projectIdInput));
form.addView(BossUi.buildFormCell(this, "标题", "一句话说明这条记忆。", titleInput));
form.addView(BossUi.buildFormCell(this, "内容", "主 Agent 读取时会使用这段内容。", contentInput));
form.addView(BossUi.buildFormCell(this, "类型", "帮助主 Agent 决定优先级与使用场景。", typeSpinner));
form.addView(BossUi.buildFormCell(this, "标签", "以逗号分隔,便于后续检索和归档。", tagsInput));
AlertDialog.Builder builder = new AlertDialog.Builder(this)
.setTitle(memory == null ? "新增记忆" : "编辑记忆")
.setView(form)
.setNegativeButton("取消", null)
.setPositiveButton("保存", (dialog, which) -> saveMemory(
memory,
MEMORY_SCOPE_VALUES[scopeSpinner.getSelectedItemPosition()],
projectIdInput.getText() == null ? "" : projectIdInput.getText().toString(),
titleInput.getText() == null ? "" : titleInput.getText().toString(),
contentInput.getText() == null ? "" : contentInput.getText().toString(),
MEMORY_TYPE_VALUES[typeSpinner.getSelectedItemPosition()],
tagsInput.getText() == null ? "" : tagsInput.getText().toString()
));
if (memory != null) {
builder.setNeutralButton("归档", (dialog, which) -> confirmArchiveMemory(memory));
}
builder.show();
}
private void confirmArchiveMemory(JSONObject memory) {
final String memoryId = memory.optString("memoryId", "");
if (memoryId.isEmpty()) {
showMessage("缺少 memoryId");
return;
}
new AlertDialog.Builder(this)
.setTitle("归档记忆")
.setMessage("确定归档这条记忆吗?归档后会从当前列表移除,不是永久删除。")
.setNegativeButton("取消", null)
.setPositiveButton("归档", (dialog, which) -> archiveMemory(memoryId))
.show();
}
private void saveMemory(
@Nullable JSONObject existingMemory,
String scope,
String targetProjectId,
String title,
String content,
String memoryType,
String tagsText
) {
if (!contentLoaded && existingMemory == null) {
showMessage("记忆尚未加载完成,请先刷新成功后再保存。");
return;
}
final String normalizedTitle = title == null ? "" : title.trim();
final String normalizedContent = content == null ? "" : content.trim();
if (normalizedTitle.isEmpty()) {
showMessage("记忆标题不能为空");
return;
}
if (normalizedContent.isEmpty()) {
showMessage("记忆内容不能为空");
return;
}
final JSONArray tags = parseTags(tagsText);
final boolean projectScope = "project".equals(scope);
final String normalizedProjectId = targetProjectId == null ? "" : targetProjectId.trim();
if (projectScope && normalizedProjectId.isEmpty()) {
showMessage("项目记忆必须填写真实 projectId");
return;
}
final String memoryId = existingMemory == null ? "" : existingMemory.optString("memoryId", "");
setRefreshing(true);
executor.execute(() -> {
try {
JSONObject payload = new JSONObject();
payload.put("scope", scope);
if (projectScope) {
payload.put("projectId", normalizedProjectId);
}
payload.put("title", normalizedTitle);
payload.put("content", normalizedContent);
payload.put("memoryType", memoryType);
payload.put("tags", tags);
if (existingMemory != null && existingMemory.has("sourceMessageId")) {
payload.put("sourceMessageId", existingMemory.optString("sourceMessageId", ""));
}
BossApiClient.ApiResponse response = memoryId.isEmpty()
? apiClient.createMasterAgentMemory(projectId, payload)
: apiClient.updateMasterAgentMemory(projectId, memoryId, payload);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
showMessage("记忆已保存");
setResult(RESULT_OK);
finish();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("记忆保存失败:" + error.getMessage());
});
}
});
}
private void archiveMemory(String memoryId) {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.deleteMasterAgentMemory(projectId, memoryId);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
showMessage("记忆已归档");
setResult(RESULT_OK);
finish();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("记忆归档失败:" + error.getMessage());
});
}
});
}
private int memoryTypeIndex(String memoryType) {
for (int i = 0; i < MEMORY_TYPE_VALUES.length; i++) {
if (MEMORY_TYPE_VALUES[i].equals(memoryType)) {
return i;
}
}
return 0;
}
private JSONArray parseTags(String rawTags) {
JSONArray tags = new JSONArray();
if (rawTags == null) {
return tags;
}
String[] parts = rawTags.split("[,]");
for (String part : parts) {
String tag = part == null ? "" : part.trim();
if (!tag.isEmpty()) {
tags.put(tag);
}
}
return tags;
}
private String joinTags(@Nullable JSONArray tags) {
if (tags == null || tags.length() == 0) {
return "";
}
StringBuilder builder = new StringBuilder();
for (int i = 0; i < tags.length(); i++) {
String tag = tags.optString(i, "").trim();
if (tag.isEmpty()) continue;
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(tag);
}
return builder.toString();
}
private String memoryTypeLabel(String memoryType) {
for (int i = 0; i < MEMORY_TYPE_VALUES.length; i++) {
if (MEMORY_TYPE_VALUES[i].equals(memoryType)) {
return MEMORY_TYPE_LABELS[i];
}
}
return memoryType;
}
private void updateSaveAvailability() {
if (headerActionButton != null) {
headerActionButton.setEnabled(contentLoaded);
headerActionButton.setAlpha(contentLoaded ? 1f : 0.45f);
}
}
}

View File

@@ -0,0 +1,319 @@
package com.hyzq.boss;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.text.TextUtils;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.Spinner;
import android.widget.TextView;
import androidx.annotation.Nullable;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
public class MasterAgentPromptActivity 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;
private boolean contentLoaded;
private @Nullable JSONObject promptPolicy;
private @Nullable JSONObject userPrompt;
private @Nullable JSONObject projectControls;
private @Nullable String adminPromptText;
private @Nullable String userPromptText;
private @Nullable String projectPromptOverrideText;
private @Nullable String backendOverrideText;
private boolean clawSelectable;
private @Nullable String clawReasonLabel;
private boolean hermesSelectable;
private @Nullable String hermesReasonLabel;
private final List<String> backendOverrideValues = new ArrayList<>();
private EditText userPromptInput;
private EditText projectPromptInput;
private Spinner backendSpinner;
private TextView previewTextView;
@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 ? "主 Agent 提示词分层" : projectName);
setHeaderAction("保存", v -> savePromptProfile());
updateSaveAvailability();
reload();
}
@Override
protected void reload() {
if (projectId == null || projectId.isEmpty()) {
replaceContent(BossUi.buildEmptyCard(this, "缺少 projectId。"));
setRefreshing(false);
contentLoaded = false;
updateSaveAvailability();
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getMasterAgentPromptProfile(projectId);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> renderPromptProfile(response.json));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
contentLoaded = false;
updateSaveAvailability();
replaceContent(BossUi.buildEmptyCard(this, "提示词加载失败:" + error.getMessage()));
});
}
});
}
private void renderPromptProfile(JSONObject payload) {
promptPolicy = payload.optJSONObject("promptPolicy");
userPrompt = payload.optJSONObject("userPrompt");
projectControls = payload.optJSONObject("projectControls");
JSONObject clawAvailability = payload.optJSONObject("clawAvailability");
JSONObject hermesAvailability = payload.optJSONObject("hermesAvailability");
adminPromptText = promptPolicy == null ? null : promptPolicy.optString("globalPrompt", "");
userPromptText = userPrompt == null ? "" : userPrompt.optString("content", "");
projectPromptOverrideText = payload.optString(
"projectPromptOverride",
projectControls == null ? "" : projectControls.optString("promptOverride", "")
);
backendOverrideText = projectControls == null ? "" : projectControls.optString("backendOverride", "");
clawSelectable = clawAvailability != null && clawAvailability.optBoolean("selectable", false);
clawReasonLabel = clawAvailability == null ? "" : clawAvailability.optString("reasonLabel", "");
hermesSelectable = hermesAvailability != null && hermesAvailability.optBoolean("selectable", false);
hermesReasonLabel = hermesAvailability == null ? "" : hermesAvailability.optString("reasonLabel", "");
replaceContent();
appendContent(BossUi.buildSimpleProfileHeader(
this,
projectName == null ? "主 Agent" : projectName,
"管理员全局主提示词 + 用户私有主提示词 + 当前对话提示词",
"管理员提示词不可覆盖,用户可编辑自己的主提示词和当前对话覆盖。"
));
appendContent(BossUi.buildSoftPanel(
this,
"管理员全局主提示词",
TextUtils.isEmpty(adminPromptText) ? "暂无全局主提示词。" : adminPromptText,
"只读 · 由管理员 Web 后台配置 · 不可覆盖"
));
userPromptInput = BossUi.buildInput(this, "编辑当前用户的主 Agent 提示词", true);
userPromptInput.setText(TextUtils.isEmpty(userPromptText) ? "" : userPromptText);
userPromptInput.setMinLines(8);
userPromptInput.setText(userPromptText == null ? "" : userPromptText);
appendContent(BossUi.buildFormCell(
this,
"用户私有主提示词",
"仅影响当前登录用户的主 Agent 对话。",
userPromptInput
));
projectPromptInput = BossUi.buildInput(this, "编辑当前对话附加提示词", true);
projectPromptInput.setMinLines(8);
projectPromptInput.setText(projectPromptOverrideText == null ? "" : projectPromptOverrideText);
appendContent(BossUi.buildFormCell(
this,
"当前对话提示词",
"只对当前 master-agent 会话生效。",
projectPromptInput
));
backendOverrideValues.clear();
List<String> backendLabels = new ArrayList<>();
backendOverrideValues.add("");
backendLabels.add("默认");
if (clawSelectable) {
backendOverrideValues.add("claw-runtime");
backendLabels.add("Claw Runtime");
}
if (hermesSelectable) {
backendOverrideValues.add("hermes-runtime");
backendLabels.add("Hermes Runtime");
}
backendSpinner = new Spinner(this);
backendSpinner.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, backendLabels));
backendSpinner.setSelection(indexOfBackendOverride(backendOverrideText));
backendSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, android.view.View view, int position, long id) {
refreshPreview();
}
@Override
public void onNothingSelected(AdapterView<?> parent) {
refreshPreview();
}
});
appendContent(BossUi.buildFormCell(
this,
"执行后端",
"默认沿用 Boss 当前主链;需要时可显式切到 Claw Runtime。",
backendSpinner
));
if (!clawSelectable) {
appendContent(BossUi.buildSoftPanel(
this,
"Claw Runtime 当前不可用",
TextUtils.isEmpty(clawReasonLabel) ? "当前环境未满足 Claw Runtime 的启动条件。" : clawReasonLabel,
TextUtils.equals(backendOverrideText, "claw-runtime")
? "当前对话之前保存过 Claw Runtime运行时会自动回退到默认后端。"
: "恢复可用后,执行后端下拉框会重新出现 Claw Runtime。"
));
}
if (!hermesSelectable) {
appendContent(BossUi.buildSoftPanel(
this,
"Hermes Runtime 当前不可用",
TextUtils.isEmpty(hermesReasonLabel) ? "当前环境未满足 Hermes Runtime 的启动条件。" : hermesReasonLabel,
TextUtils.equals(backendOverrideText, "hermes-runtime")
? "当前对话之前保存过 Hermes Runtime运行时会自动回退到默认后端。"
: "恢复可用后,执行后端下拉框会重新出现 Hermes Runtime。"
));
}
previewTextView = new TextView(this);
previewTextView.setText(buildPreviewText());
previewTextView.setTextSize(14);
previewTextView.setLineSpacing(0f, 1.2f);
previewTextView.setTextColor(getColor(R.color.boss_text_primary));
previewTextView.setPadding(0, BossUi.dp(this, 8), 0, 0);
LinearLayout previewPanel = new LinearLayout(this);
previewPanel.setOrientation(LinearLayout.VERTICAL);
previewPanel.addView(previewTextView);
appendContent(BossUi.buildFormCell(
this,
"合成预览",
"主 Agent 实际执行时会先遵守管理员全局主提示词,再追加你的私有提示词和当前对话提示词。",
previewPanel
));
TextWatcher previewWatcher = new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
refreshPreview();
}
@Override
public void afterTextChanged(Editable s) {}
};
userPromptInput.addTextChangedListener(previewWatcher);
projectPromptInput.addTextChangedListener(previewWatcher);
refreshPreview();
contentLoaded = true;
updateSaveAvailability();
setRefreshing(false);
}
private void refreshPreview() {
if (previewTextView != null) {
previewTextView.setText(buildPreviewText());
}
}
private String buildPreviewText() {
StringBuilder builder = new StringBuilder();
if (!TextUtils.isEmpty(adminPromptText)) {
builder.append("【管理员全局主提示词】\n").append(adminPromptText).append("\n\n");
}
String userText = userPromptInput == null ? userPromptText : userPromptInput.getText().toString();
if (!TextUtils.isEmpty(userText)) {
builder.append("【用户私有主提示词】\n").append(userText).append("\n\n");
}
String projectText = projectPromptInput == null ? projectPromptOverrideText : projectPromptInput.getText().toString();
if (!TextUtils.isEmpty(projectText)) {
builder.append("【当前对话提示词】\n").append(projectText).append("\n\n");
}
String backendValue = backendSpinner == null
? (backendOverrideText == null ? "" : backendOverrideText)
: backendOverrideValues.get(backendSpinner.getSelectedItemPosition());
if (!TextUtils.isEmpty(backendValue)) {
builder.append("【执行后端】\n").append(backendValue).append("\n\n");
} else if (TextUtils.equals(backendOverrideText, "claw-runtime") && !clawSelectable) {
builder.append("【执行后端】\n默认Claw Runtime 当前不可用,运行时会自动回退)\n\n");
} else if (TextUtils.equals(backendOverrideText, "hermes-runtime") && !hermesSelectable) {
builder.append("【执行后端】\n默认Hermes Runtime 当前不可用,运行时会自动回退)\n\n");
}
if (builder.length() == 0) {
return "当前没有任何提示词内容。";
}
return builder.toString().trim();
}
private void savePromptProfile() {
if (!contentLoaded) {
showMessage("提示词尚未加载完成,请先刷新成功后再保存。");
return;
}
final String userContent = userPromptInput == null ? "" : userPromptInput.getText().toString();
final String promptOverride = projectPromptInput == null ? "" : projectPromptInput.getText().toString();
final String backendOverride = backendSpinner == null
? ""
: backendOverrideValues.get(backendSpinner.getSelectedItemPosition());
setRefreshing(true);
executor.execute(() -> {
try {
JSONObject payload = new JSONObject();
payload.put("userPromptContent", userContent);
payload.put("promptOverride", promptOverride);
payload.put("backendOverride", TextUtils.isEmpty(backendOverride) ? JSONObject.NULL : backendOverride);
BossApiClient.ApiResponse response = apiClient.updateMasterAgentPromptProfile(projectId, payload);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
showMessage("提示词已保存");
setResult(RESULT_OK);
finish();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("提示词保存失败:" + error.getMessage());
});
}
});
}
private void updateSaveAvailability() {
if (headerActionButton != null) {
headerActionButton.setEnabled(contentLoaded);
headerActionButton.setAlpha(contentLoaded ? 1f : 0.45f);
}
}
private int indexOfBackendOverride(@Nullable String value) {
if (TextUtils.isEmpty(value)) {
return 0;
}
for (int index = 0; index < backendOverrideValues.size(); index += 1) {
if (value.equals(backendOverrideValues.get(index))) {
return index;
}
}
return 0;
}
}

View File

@@ -0,0 +1,167 @@
package com.hyzq.boss;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.SwitchCompat;
import org.json.JSONObject;
public class MasterAgentTakeoverActivity 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;
private boolean contentLoaded;
private boolean globalTakeoverEnabled;
private SwitchCompat globalTakeoverSwitch;
@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 ? "主 Agent 协同推进" : projectName);
setHeaderAction("保存", v -> saveTakeoverSettings());
updateSaveAvailability();
reload();
}
@Override
protected void reload() {
if (projectId == null || projectId.isEmpty()) {
replaceContent(BossUi.buildEmptyCard(this, "缺少 projectId。"));
setRefreshing(false);
contentLoaded = false;
updateSaveAvailability();
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = loadTakeoverControls();
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> renderTakeoverSettings(response.json));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
contentLoaded = false;
updateSaveAvailability();
replaceContent(BossUi.buildEmptyCard(this, "全局接管加载失败:" + error.getMessage()));
});
}
});
}
private void renderTakeoverSettings(JSONObject payload) {
JSONObject controls = payload.optJSONObject("controls");
globalTakeoverEnabled = controls != null && controls.optBoolean("globalTakeoverEnabled", false);
replaceContent();
appendContent(BossUi.buildSimpleProfileHeader(
this,
projectName == null ? "主 Agent" : projectName,
"全局主 Agent 协同推进",
"为线程会话默认开启协同推进,不会抢走你继续直接控制线程开发的能力。"
));
globalTakeoverSwitch = new SwitchCompat(this);
globalTakeoverSwitch.setText("开启");
globalTakeoverSwitch.setChecked(globalTakeoverEnabled);
appendContent(BossUi.buildFormCell(
this,
"全局主 Agent 协同接管",
"开启后,线程会话默认跟随全局协同推进;线程会话仍可单独覆盖。",
globalTakeoverSwitch
));
appendContent(BossUi.buildSoftPanel(
this,
"说明",
"主 Agent 会理解项目状态、给建议、补调度方案,但不会因为介入就抢走你继续直接控制线程开发的能力。",
"线程级开关优先于这里的全局默认。"
));
contentLoaded = true;
updateSaveAvailability();
setRefreshing(false);
}
private void saveTakeoverSettings() {
if (!contentLoaded) {
showMessage("全局接管尚未加载完成,请先刷新成功后再保存。");
return;
}
final boolean enabled = globalTakeoverSwitch != null && globalTakeoverSwitch.isChecked();
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = saveTakeoverControls(
projectId,
null,
enabled
);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
showMessage(enabled ? "已开启全局主 Agent 协同接管" : "已关闭全局主 Agent 协同接管");
setResult(RESULT_OK);
finish();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("保存失败:" + error.getMessage());
});
}
});
}
private BossApiClient.ApiResponse loadTakeoverControls() throws Exception {
BossApiClient.ApiResponse response = apiClient.getProjectAgentControls(projectId);
if (response.ok() || !isUnauthorized(response)) {
return response;
}
BossApiClient.ApiResponse loginResponse = apiClient.autoLogin();
if (!loginResponse.ok()) {
return response;
}
return apiClient.getProjectAgentControls(projectId);
}
private BossApiClient.ApiResponse saveTakeoverControls(
String projectId,
@Nullable Boolean takeoverEnabled,
@Nullable Boolean globalTakeoverEnabled
) throws Exception {
BossApiClient.ApiResponse response = apiClient.updateProjectTakeoverSettings(
projectId,
takeoverEnabled,
globalTakeoverEnabled
);
if (response.ok() || !isUnauthorized(response)) {
return response;
}
BossApiClient.ApiResponse loginResponse = apiClient.autoLogin();
if (!loginResponse.ok()) {
return response;
}
return apiClient.updateProjectTakeoverSettings(projectId, takeoverEnabled, globalTakeoverEnabled);
}
private boolean isUnauthorized(BossApiClient.ApiResponse response) {
return response != null
&& response.statusCode == 401
&& "UNAUTHORIZED".equals(response.message());
}
private void updateSaveAvailability() {
if (headerActionButton != null) {
headerActionButton.setEnabled(contentLoaded);
headerActionButton.setAlpha(contentLoaded ? 1f : 0.45f);
}
}
}

View File

@@ -0,0 +1,240 @@
package com.hyzq.boss;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.text.InputType;
import android.view.View;
import android.widget.EditText;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import org.json.JSONObject;
public class OpenAiOnboardingActivity extends BossScreenActivity {
public static final String EXTRA_AUTO_OPEN_LOGIN = "extra_auto_open_login";
private static final String STATE_AUTO_OPENED = "state_auto_opened";
private static final String OPENAI_LOGIN_URL = "https://platform.openai.com/login";
private static final String OPENAI_KEYS_URL = "https://platform.openai.com/api-keys";
private EditText labelInput;
private EditText displayNameInput;
private EditText accountIdentifierInput;
private EditText modelInput;
private EditText apiKeyInput;
private boolean autoOpened;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("登录 OpenAI 平台账号", "先登录 OpenAI再回这里接入 API Key");
hideHeaderAction();
refreshButton.setVisibility(View.GONE);
refreshLayout.setEnabled(false);
if (savedInstanceState != null) {
autoOpened = savedInstanceState.getBoolean(STATE_AUTO_OPENED, false);
}
buildForm();
reload();
if (getIntent().getBooleanExtra(EXTRA_AUTO_OPEN_LOGIN, false) && !autoOpened) {
autoOpened = true;
openExternalUrl(OPENAI_LOGIN_URL, "已打开 OpenAI 登录页,登录后回到这里继续。");
}
}
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putBoolean(STATE_AUTO_OPENED, autoOpened);
}
@Override
protected void reload() {
replaceContent();
appendContent(BossUi.buildSimpleProfileHeader(
this,
"OpenAI 平台账号",
"像 Codex 一样,先去浏览器登录,再回 APP 完成接入。",
"OpenAI 目前不会把可直接调用 API 的凭据通过第三方 OAuth 直接交给 APP所以最后一步仍然需要 API Key。"
));
appendContent(BossUi.buildWechatMenuRow(
this,
"第一步:打开 OpenAI 登录页",
"先在浏览器完成 OpenAI Platform 登录。",
"返回 APP 后继续下一步。",
null,
v -> openExternalUrl(OPENAI_LOGIN_URL, "已打开 OpenAI 登录页。")
));
appendContent(BossUi.buildWechatMenuRow(
this,
"第二步:打开 API Keys 页面",
"登录后创建或复制新的 API Key。",
"建议创建专门给 Boss 使用的 Key。",
null,
v -> openExternalUrl(OPENAI_KEYS_URL, "已打开 API Keys 页面。")
));
appendContent(BossUi.buildFormCell(this, "标签", "建议使用 主 GPT", labelInput));
appendContent(BossUi.buildFormCell(this, "显示名称", "会展示在账号列表和当前主控里", displayNameInput));
appendContent(BossUi.buildFormCell(this, "账号标识", "可填邮箱、账号名或自定义备注", accountIdentifierInput));
appendContent(BossUi.buildFormCell(this, "模型", "例如 gpt-5.4", modelInput));
appendContent(BossUi.buildFormCell(this, "API Key", "从 OpenAI Platform 复制后粘贴到这里", apiKeyInput));
appendContent(BossUi.buildWechatMenuRow(
this,
"从剪贴板粘贴 API Key",
"如果你刚从浏览器复制了 key可以直接粘贴到输入框。",
null,
null,
v -> pasteApiKeyFromClipboard()
));
android.widget.Button guideButton = BossUi.buildSecondaryButton(this, "主 GPT 登录说明");
guideButton.setOnClickListener(v ->
new AlertDialog.Builder(this)
.setTitle("为什么还要 API Key")
.setMessage("浏览器登录解决的是账号身份校验,但主 Agent 真正调用 OpenAI 模型仍然需要 API Key。当前这条链会先帮你打开 OpenAI 登录和 API Keys 页面,再回 APP 完成接入。")
.setPositiveButton("知道了", null)
.show()
);
appendContent(guideButton);
android.widget.Button submitButton = BossUi.buildPrimaryButton(this, "验证并设为当前主控");
submitButton.setOnClickListener(v -> submit());
appendContent(submitButton);
setRefreshing(false);
}
private void buildForm() {
labelInput = BossUi.buildInput(this, "标签,例如 主 GPT", false);
labelInput.setText("主 GPT");
displayNameInput = BossUi.buildInput(this, "显示名称", false);
displayNameInput.setText("OpenAI 平台账号");
accountIdentifierInput = BossUi.buildInput(this, "账号标识 / 备注", false);
modelInput = BossUi.buildInput(this, "模型,例如 gpt-5.4", false);
modelInput.setText("gpt-5.4");
apiKeyInput = BossUi.buildInput(this, "OpenAI API Key", false);
apiKeyInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
}
private void pasteApiKeyFromClipboard() {
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboard == null || !clipboard.hasPrimaryClip()) {
showMessage("剪贴板里还没有可用的 API Key。");
return;
}
ClipData clip = clipboard.getPrimaryClip();
if (clip == null || clip.getItemCount() == 0) {
showMessage("剪贴板里还没有可用的 API Key。");
return;
}
CharSequence text = clip.getItemAt(0).coerceToText(this);
if (text == null || text.toString().trim().isEmpty()) {
showMessage("剪贴板里还没有可用的 API Key。");
return;
}
apiKeyInput.setText(text.toString().trim());
showMessage("已从剪贴板粘贴 API Key。");
}
private void openExternalUrl(String url, String successMessage) {
try {
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
startActivity(intent);
showMessage(successMessage);
} catch (Exception error) {
showMessage("打开浏览器失败:" + error.getMessage());
}
}
private void submit() {
String label = labelInput.getText().toString().trim();
String displayName = displayNameInput.getText().toString().trim();
String accountIdentifier = accountIdentifierInput.getText().toString().trim();
String model = modelInput.getText().toString().trim();
String apiKey = apiKeyInput.getText().toString().trim();
if (label.isEmpty() || displayName.isEmpty() || apiKey.isEmpty()) {
showMessage("标签、显示名称和 API Key 不能为空");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
JSONObject payload = new JSONObject();
payload.put("label", label);
payload.put("displayName", displayName);
payload.put("accountIdentifier", accountIdentifier);
payload.put("model", model);
payload.put("apiKey", apiKey);
payload.put("enabled", true);
payload.put("setActive", true);
payload.put("provider", "openai_api");
payload.put("role", "primary");
BossApiClient.ApiResponse response = apiClient.onboardOpenAiApiAccount(payload);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
setResult(RESULT_OK);
setRefreshing(false);
showPostLoginActions(response.json);
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
String detail = error.getMessage();
showMessage(detail == null || detail.trim().isEmpty()
? "OpenAI 平台账号登录失败,请稍后重试。"
: "OpenAI 平台账号登录失败:" + detail);
});
}
});
}
private void showPostLoginActions(JSONObject responseJson) {
JSONObject activeIdentity = responseJson == null ? null : responseJson.optJSONObject("activeIdentity");
StringBuilder message = new StringBuilder();
if (activeIdentity != null) {
String statusLabel = activeIdentity.optString("statusLabel", "");
String note = activeIdentity.optString("note", "");
message.append("当前主控:")
.append(activeIdentity.optString("label", "OpenAI 平台账号"))
.append(" · ")
.append(activeIdentity.optString("displayName", ""))
.append('\n')
.append("状态:")
.append(statusLabel.isEmpty() ? "可用" : statusLabel);
if (!note.isEmpty()) {
message.append('\n').append(note);
}
} else {
message.append("OpenAI 平台账号已登录,并设为当前主控。");
}
new AlertDialog.Builder(this)
.setTitle("OpenAI 平台账号已登录")
.setMessage(message.toString() + "\n\n你现在可以直接测试主 Agent 对话,确认当前主控链路是否可用。")
.setPositiveButton("测试主 Agent 对话", (dialog, which) -> {
openMasterAgentConversation();
finish();
})
.setNegativeButton("返回账号页", (dialog, which) -> finish())
.setOnDismissListener(dialog -> {
if (!isFinishing()) {
finish();
}
})
.show();
}
private void openMasterAgentConversation() {
Intent intent = new Intent(this, ProjectDetailActivity.class);
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent");
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
startActivity(intent);
}
}

View File

@@ -9,88 +9,139 @@ import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
public class OpsCenterActivity extends BossScreenActivity {
private enum Tab {
OPS,
AUDIT
}
import java.util.LinkedHashMap;
import java.util.Map;
public class OpsCenterActivity extends BossScreenActivity {
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private Tab activeTab = Tab.OPS;
private LinearLayout contentRoot;
private @Nullable BossRealtimeClient realtimeClient;
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("运维中心", "运维对话 / 审计对话");
configureScreen("运维与修复", "运维会话、修复回放与 standby 切换");
setHeaderAction("刷新", v -> reload());
contentRoot = new LinearLayout(this);
contentRoot.setOrientation(LinearLayout.VERTICAL);
replaceContent(contentRoot);
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
reload();
}
@Override
protected void onResume() {
super.onResume();
updateRealtimeSubscription();
}
@Override
protected void onPause() {
stopRealtimeUpdates();
super.onPause();
}
@Override
protected void onDestroy() {
stopRealtimeUpdates();
super.onDestroy();
}
@Override
protected void reload() {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse ops = apiClient.getOpsSummary();
BossApiClient.ApiResponse audit = apiClient.getAuditSummary();
if (!ops.ok() || !audit.ok()) {
throw new IllegalStateException("OPS_OR_AUDIT_LOAD_FAILED");
if (!ops.ok()) {
throw new IllegalStateException("OPS_LOAD_FAILED");
}
runOnUiThread(() -> render(ops.json, audit.json));
runOnUiThread(() -> render(ops.json));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "运维中心加载失败:" + error.getMessage()));
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);
private void updateRealtimeSubscription() {
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
realtimeClient.start();
return;
}
stopRealtimeUpdates();
}
private void stopRealtimeUpdates() {
if (realtimeClient != null) {
realtimeClient.stop();
}
}
void handleRealtimeEvent(BossRealtimeEvent event) {
if (event == null || event.eventName.isEmpty()) {
return;
}
if (!shouldReloadForRealtimeEvent(event)) {
return;
}
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
if (eventFingerprint.isEmpty()) {
return;
}
long now = System.currentTimeMillis();
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
return;
}
runOnUiThread(this::reload);
}
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
return "app.logs.updated".equals(event.eventName)
|| "project.context_risk.updated".equals(event.eventName);
}
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
pruneRecentRealtimeEvents(now);
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
return true;
}
recentRealtimeEventTimestamps.put(eventFingerprint, now);
return false;
}
private void pruneRecentRealtimeEvents(long now) {
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Long> entry = iterator.next();
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
iterator.remove();
}
}
}
private void render(JSONObject ops) {
replaceContent(contentRoot);
contentRoot.removeAllViews();
renderOpsTab(ops);
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(
contentRoot.addView(BossUi.buildWechatMenuRow(
this,
"当前巡检模式",
"巡检状态",
ops.optString("mode", "idle").equals("active")
? "active当前存在风险线程或未关闭运维工单。"
: "idle当前没有高风险工单保持低频巡检。",
"来源:/api/v1/ops/summary"
"这里只保留修复与验证的轻量入口。",
null,
null
));
JSONArray faults = ops.optJSONArray("faults");
@@ -106,7 +157,9 @@ public class OpsCenterActivity extends BossScreenActivity {
}
private LinearLayout buildFaultCard(JSONObject fault, @Nullable JSONArray tickets) {
LinearLayout card = BossUi.buildCard(
LinearLayout card = new LinearLayout(this);
card.setOrientation(LinearLayout.VERTICAL);
card.addView(BossUi.buildWechatMenuRow(
this,
fault.optString("faultKey", "故障"),
fault.optString("summary", "暂无摘要"),
@@ -114,13 +167,9 @@ public class OpsCenterActivity extends BossScreenActivity {
+ " · " + fault.optString("status", "-")
+ " · " + fault.optString("nodeId", "-")
+ " · " + fault.optString("serviceName", "-")
);
card.addView(BossUi.buildCard(
this,
"建议动作",
fault.optString("suggestedNextAction", "暂无"),
"trace " + fault.optString("traceId", "-")
+ " · 建议 " + fault.optString("suggestedNextAction", "暂无"),
null,
null
));
if (tickets != null) {
@@ -135,122 +184,38 @@ public class OpsCenterActivity extends BossScreenActivity {
}
private LinearLayout buildTicketCard(JSONObject ticket) {
LinearLayout card = BossUi.buildCard(
LinearLayout card = new LinearLayout(this);
card.setOrientation(LinearLayout.VERTICAL);
card.addView(BossUi.buildWechatMenuRow(
this,
ticket.optString("title", "修复工单"),
ticket.optString("actionSummary", "暂无动作摘要"),
ticket.optString("approvalStatus", "-")
+ " · " + ticket.optString("executionStatus", "-")
+ " · " + ticket.optString("targetNodeId", "-")
);
+ " · " + ticket.optString("updatedAt", "-"),
null,
null
));
if (ticket.optJSONObject("verification") != null) {
JSONObject verification = ticket.optJSONObject("verification");
card.addView(BossUi.buildCard(
card.addView(BossUi.buildWechatMenuRow(
this,
"验证结果",
verification.optString("summary", "暂无"),
verification.optString("status", "-")
+ " · " + verification.optString("verifiedAt", "-")
+ " · " + verification.optString("verifiedAt", "-"),
null,
null
));
}
Button approve = BossUi.buildPrimaryButton(this, "批准修复");
Button approve = BossUi.buildMiniActionButton(this, "批准修复", true);
approve.setOnClickListener(v -> approveTicket(ticket.optString("ticketId")));
card.addView(approve);
Button verify = BossUi.buildSecondaryButton(this, "验证修复");
Button verify = BossUi.buildMiniActionButton(this, "验证修复", false);
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"))
));
card.addView(BossUi.buildInlineActionRow(this, approve, verify));
return card;
}
@@ -311,16 +276,4 @@ public class OpsCenterActivity extends BossScreenActivity {
}
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();
}
}

View File

@@ -0,0 +1,114 @@
package com.hyzq.boss;
public final class OtaDownloadStateMapper {
public enum ActionKind {
NONE,
RETRY_DOWNLOAD,
OPEN_INSTALL_PERMISSION,
INSTALL_APK
}
public static final class UiState {
public final String title;
public final String subtitle;
public final String meta;
public final String badge;
public final String actionLabel;
public final ActionKind actionKind;
public UiState(
String title,
String subtitle,
String meta,
String badge,
String actionLabel,
ActionKind actionKind
) {
this.title = title;
this.subtitle = subtitle;
this.meta = meta;
this.badge = badge;
this.actionLabel = actionLabel;
this.actionKind = actionKind;
}
}
private OtaDownloadStateMapper() {}
public static String toProgressLabel(int percent, boolean hasKnownTotal) {
if (!hasKnownTotal) {
return "正在准备下载";
}
int safePercent = Math.max(0, Math.min(100, percent));
return "已下载 " + safePercent + "%";
}
public static UiState active(String fileName, int percent, boolean hasKnownTotal, long bytesDownloaded, long totalBytes) {
return new UiState(
"安装包下载中",
toProgressLabel(percent, hasKnownTotal),
buildMeta(fileName, bytesDownloaded, totalBytes),
"NOW",
null,
ActionKind.NONE
);
}
public static UiState failed(String fileName) {
return new UiState(
"安装包下载失败",
"下载未成功完成,可以直接重试",
fileName,
"FAIL",
"重试下载",
ActionKind.RETRY_DOWNLOAD
);
}
public static UiState waitingInstallPermission(String fileName) {
return new UiState(
"等待安装授权",
"请先允许 Boss 安装未知来源应用",
fileName,
"STEP",
"前往授权",
ActionKind.OPEN_INSTALL_PERMISSION
);
}
public static UiState readyToInstall(String fileName) {
return new UiState(
"安装包已就绪",
"下载完成,可继续拉起系统安装",
fileName,
"DONE",
"继续安装",
ActionKind.INSTALL_APK
);
}
private static String buildMeta(String fileName, long bytesDownloaded, long totalBytes) {
if (bytesDownloaded <= 0 && totalBytes <= 0) {
return fileName;
}
StringBuilder builder = new StringBuilder(fileName);
builder.append(" · ").append(formatBytes(bytesDownloaded));
if (totalBytes > 0) {
builder.append(" / ").append(formatBytes(totalBytes));
}
return builder.toString();
}
private static String formatBytes(long bytes) {
if (bytes <= 0) {
return "0 B";
}
if (bytes < 1024) {
return bytes + " B";
}
if (bytes < 1024L * 1024L) {
return String.format(java.util.Locale.US, "%.1f KB", bytes / 1024.0d);
}
return String.format(java.util.Locale.US, "%.1f MB", bytes / (1024.0d * 1024.0d));
}
}

View File

@@ -0,0 +1,556 @@
package com.hyzq.boss;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
public final class ProjectChatUiState {
private ProjectChatUiState() {}
public static final class SelectionState {
public final boolean multiSelecting;
public final Set<String> selectedMessageIds;
private SelectionState(Set<String> selectedMessageIds) {
LinkedHashSet<String> normalizedIds = new LinkedHashSet<>(selectedMessageIds);
this.multiSelecting = !normalizedIds.isEmpty();
this.selectedMessageIds = Collections.unmodifiableSet(normalizedIds);
}
}
public static final class ChromeState {
public final boolean multiSelecting;
public final boolean showComposer;
public final boolean showMultiSelectBar;
public final boolean showRefresh;
public final boolean showHeaderAction;
public final boolean forwardEnabled;
public final String backLabel;
public final String title;
public final String subtitle;
private ChromeState(
boolean multiSelecting,
boolean showComposer,
boolean showMultiSelectBar,
boolean showRefresh,
boolean showHeaderAction,
boolean forwardEnabled,
String backLabel,
String title,
String subtitle
) {
this.multiSelecting = multiSelecting;
this.showComposer = showComposer;
this.showMultiSelectBar = showMultiSelectBar;
this.showRefresh = showRefresh;
this.showHeaderAction = showHeaderAction;
this.forwardEnabled = forwardEnabled;
this.backLabel = backLabel;
this.title = title;
this.subtitle = subtitle;
}
}
public static final class ReplyWaitSpec {
public final boolean shouldWait;
public final String baselineMessageId;
public final List<String> executionIds;
private ReplyWaitSpec(
boolean shouldWait,
@Nullable String baselineMessageId,
@Nullable List<String> executionIds
) {
ArrayList<String> normalizedExecutionIds = new ArrayList<>();
if (executionIds != null) {
for (String executionId : executionIds) {
if (!isBlank(executionId)) {
normalizedExecutionIds.add(executionId.trim());
}
}
}
boolean hasBaseline = !isBlank(baselineMessageId);
boolean hasExecutionIds = !normalizedExecutionIds.isEmpty();
this.shouldWait = shouldWait && (hasBaseline || hasExecutionIds);
this.baselineMessageId = hasBaseline ? baselineMessageId.trim() : "";
this.executionIds = Collections.unmodifiableList(normalizedExecutionIds);
}
}
public static boolean canSend(String text, boolean sending) {
return !sending && text != null && !text.trim().isEmpty();
}
public static boolean requiresAttachmentConfirmation(@Nullable String sourceType) {
return "image".equals(sourceType) || "video".equals(sourceType);
}
public static boolean shouldAutoScroll(boolean nearBottom, boolean forced) {
return nearBottom || forced;
}
public static String threadExecutionConflictTitle(@Nullable JSONObject conflict) {
if (conflict == null) {
return "当前线程命中冲突保护";
}
if ("preferred_gui_mode".equals(conflict.optString("reason", ""))) {
return "当前项目默认先走 GUI";
}
return "当前项目已命中并发保护";
}
public static String threadExecutionConflictSummary(@Nullable JSONObject conflict) {
if (conflict == null) {
return "当前线程命中了 GUI / CLI 冲突保护,请先确认是否继续。";
}
String projectName = conflict.optString("projectName", "当前项目");
String deviceName = conflict.optString("deviceName", "当前设备");
if ("preferred_gui_mode".equals(conflict.optString("reason", ""))) {
return deviceName + " 现在默认优先 GUI。要让主 Agent 继续通过 CLI 推进 " + projectName + ",需要你先对这个项目放行;这个选择只对这个项目生效。";
}
return projectName + " 最近检测到 GUI / CLI 同时活动,当前先按禁止处理。这个提示只影响这个项目;你可以临时放行,或者把这个项目永久放行。";
}
public static String labelForThreadExecutionConflictDecision(@Nullable String decision) {
if ("allow_once".equals(decision)) {
return "允许本次";
}
if ("allow_always".equals(decision)) {
return "永久放行";
}
return "禁止";
}
public static String summarizeThreadExecutionConflictDecisionResult(@Nullable String decision) {
if ("allow_once".equals(decision)) {
return "已允许本次,继续发送中…";
}
if ("allow_always".equals(decision)) {
return "已对当前项目永久放行,继续发送中…";
}
return "已保持禁止,这次消息没有发出。";
}
public static SelectionState emptySelection() {
return new SelectionState(new LinkedHashSet<>());
}
public static SelectionState selectOnly(String messageId) {
return toggleSelection(emptySelection(), messageId);
}
public static SelectionState toggleSelection(@Nullable SelectionState current, String messageId) {
if (messageId == null || messageId.trim().isEmpty()) {
throw new IllegalArgumentException("messageId must not be blank");
}
SelectionState state = current == null ? emptySelection() : current;
LinkedHashSet<String> selectedMessageIds = new LinkedHashSet<>(state.selectedMessageIds);
if (selectedMessageIds.contains(messageId)) {
selectedMessageIds.remove(messageId);
return new SelectionState(selectedMessageIds);
}
selectedMessageIds.add(messageId);
return new SelectionState(selectedMessageIds);
}
public static boolean canForwardSelection(@Nullable SelectionState state) {
return state != null && state.multiSelecting && state.selectedMessageIds.size() >= 2;
}
public static SelectionState reconcileSelection(
@Nullable SelectionState current,
@Nullable List<String> availableMessageIds
) {
if (current == null || current.selectedMessageIds.isEmpty() || availableMessageIds == null || availableMessageIds.isEmpty()) {
return emptySelection();
}
LinkedHashSet<String> available = new LinkedHashSet<>(availableMessageIds);
LinkedHashSet<String> selected = new LinkedHashSet<>();
for (String selectedMessageId : current.selectedMessageIds) {
if (available.contains(selectedMessageId)) {
selected.add(selectedMessageId);
}
}
return new SelectionState(selected);
}
public static ChromeState resolveChromeState(
@Nullable SelectionState selectionState,
boolean conversationInfoReady,
@Nullable String defaultTitle,
@Nullable String defaultSubtitle
) {
boolean multiSelecting = selectionState != null && selectionState.multiSelecting;
if (multiSelecting) {
int selectedCount = selectionState.selectedMessageIds.size();
return new ChromeState(
true,
false,
true,
false,
false,
canForwardSelection(selectionState),
"取消",
"已选 " + selectedCount + "",
"选择要转发的消息"
);
}
return new ChromeState(
false,
true,
false,
!conversationInfoReady,
conversationInfoReady,
false,
"返回",
isBlank(defaultTitle) ? "项目详情" : defaultTitle,
isBlank(defaultSubtitle) ? "原生页面" : defaultSubtitle
);
}
@Nullable
public static String labelForForwardKind(@Nullable String kind) {
if ("forward_single".equals(kind)) {
return "转发";
}
if ("forward_bundle".equals(kind)) {
return "聊天记录";
}
return null;
}
public static String summarizeForwardBundle(@Nullable String lastBody, int itemCount) {
if (itemCount > 0 && !isBlank(lastBody)) {
return itemCount + " 条消息 · 最后一条:" + truncate(lastBody, 28);
}
if (itemCount > 0) {
return itemCount + " 条消息";
}
return truncate(lastBody, 28);
}
public static String labelForAttachmentAnalysisState(@Nullable String analysisState) {
if ("queued_auto".equals(analysisState)) {
return "自动分析排队中";
}
if ("ready_manual".equals(analysisState)) {
return "待分析";
}
if ("processing".equals(analysisState)) {
return "AI 分析中";
}
if ("completed".equals(analysisState)) {
return "已分析";
}
if ("failed".equals(analysisState)) {
return "分析失败";
}
return "已发送";
}
@Nullable
public static String actionLabelForAttachmentAnalysisState(@Nullable String analysisState) {
if ("ready_manual".equals(analysisState)) {
return "让 AI 分析";
}
if ("failed".equals(analysisState)) {
return "重试分析";
}
return null;
}
public static String labelForAttachmentKind(@Nullable String attachmentKind) {
if ("image".equals(attachmentKind)) {
return "图片";
}
if ("video".equals(attachmentKind)) {
return "视频";
}
if ("pdf".equals(attachmentKind)) {
return "PDF";
}
if ("office".equals(attachmentKind)) {
return "文档";
}
if ("text".equals(attachmentKind)) {
return "文本";
}
return "文件";
}
@Nullable
public static JSONObject latestPendingDispatchPlan(@Nullable JSONArray plans) {
if (plans == null || plans.length() == 0) {
return null;
}
for (int i = 0; i < plans.length(); i++) {
JSONObject plan = plans.optJSONObject(i);
if (plan == null) {
continue;
}
if ("pending_user_confirmation".equals(plan.optString("status", ""))) {
return plan;
}
}
return null;
}
@Nullable
public static JSONObject latestRejectedDispatchPlan(@Nullable JSONArray plans) {
if (plans == null || plans.length() == 0) {
return null;
}
for (int i = 0; i < plans.length(); i++) {
JSONObject plan = plans.optJSONObject(i);
if (plan == null) {
continue;
}
if ("rejected".equals(plan.optString("status", ""))) {
return plan;
}
}
return null;
}
public static List<String> dispatchPlanApprovedTargetIds(@Nullable JSONObject plan) {
ArrayList<String> approved = new ArrayList<>();
if (plan == null) {
return approved;
}
JSONArray targets = plan.optJSONArray("targets");
if (targets == null) {
return approved;
}
for (int i = 0; i < targets.length(); i++) {
JSONObject target = targets.optJSONObject(i);
if (target == null) {
continue;
}
String projectId = target.optString("projectId", "").trim();
if (!projectId.isEmpty()) {
approved.add(projectId);
}
}
return approved;
}
public static String summarizeDispatchPlan(@Nullable JSONObject plan) {
if (plan == null) {
return "主 Agent 暂未生成推荐线程。";
}
String summary = plan.optString("summary", "").trim();
List<String> targetTitles = new ArrayList<>();
JSONArray targets = plan.optJSONArray("targets");
if (targets != null) {
for (int i = 0; i < targets.length(); i++) {
JSONObject target = targets.optJSONObject(i);
if (target == null) {
continue;
}
String title = target.optString("threadDisplayName", "").trim();
if (!title.isEmpty()) {
targetTitles.add(title);
}
}
}
StringBuilder builder = new StringBuilder();
builder.append(isBlank(summary) ? "主 Agent 已生成推荐线程。" : summary);
if (!targetTitles.isEmpty()) {
builder.append("\n推荐线程");
builder.append(String.join("", targetTitles));
}
return builder.toString();
}
public static String summarizeDispatchPlanCompact(@Nullable JSONObject plan) {
if (plan == null) {
return "主 Agent 暂未生成推荐线程。";
}
List<String> targetTitles = dispatchPlanTargetTitles(plan);
String summary = plan.optString("summary", "").trim();
if (targetTitles.isEmpty()) {
return isBlank(summary) ? "主 Agent 已生成推荐线程。" : truncate(summary, 32);
}
if (isBlank(summary)) {
return "推荐给:" + String.join("", targetTitles);
}
return "推荐给:" + String.join("", targetTitles) + "\n" + truncate(summary, 32);
}
public static String summarizeDispatchPlanLight(@Nullable JSONObject plan) {
int targetCount = dispatchPlanTargetTitles(plan).size();
if (targetCount <= 0) {
return "主 Agent 已推荐线程";
}
return "主 Agent 已推荐 " + targetCount + " 个线程";
}
private static List<String> dispatchPlanTargetTitles(@Nullable JSONObject plan) {
List<String> targetTitles = new ArrayList<>();
if (plan == null) {
return targetTitles;
}
JSONArray targets = plan.optJSONArray("targets");
if (targets != null) {
for (int i = 0; i < targets.length(); i++) {
JSONObject target = targets.optJSONObject(i);
if (target == null) {
continue;
}
String title = target.optString("threadDisplayName", "").trim();
if (!title.isEmpty()) {
targetTitles.add(title);
}
}
}
return targetTitles;
}
public static String formatAttachmentSize(long fileSizeBytes) {
if (fileSizeBytes >= 1024L * 1024L) {
return String.format(java.util.Locale.US, "%.1f MB", fileSizeBytes / (1024f * 1024f));
}
if (fileSizeBytes >= 1024L) {
return Math.max(1, Math.round(fileSizeBytes / 1024f)) + " KB";
}
return Math.max(fileSizeBytes, 0L) + " B";
}
public static ReplyWaitSpec resolveReplyWaitAfterSend(@Nullable JSONObject response) {
if (response == null) {
return new ReplyWaitSpec(false, null, null);
}
JSONObject task = response.optJSONObject("task");
if (task == null) {
return new ReplyWaitSpec(false, null, null);
}
String taskStatus = task.optString("status", "");
if ("completed".equals(taskStatus) || "failed".equals(taskStatus)) {
return new ReplyWaitSpec(false, null, null);
}
JSONObject message = response.optJSONObject("message");
return new ReplyWaitSpec(true, message == null ? null : message.optString("id", ""), null);
}
public static ReplyWaitSpec resolveReplyWaitAfterDispatchConfirm(@Nullable JSONObject response) {
if (response == null) {
return new ReplyWaitSpec(false, null, null);
}
JSONArray executions = response.optJSONArray("executions");
if (executions == null || executions.length() == 0) {
return new ReplyWaitSpec(false, null, null);
}
JSONObject notice = response.optJSONObject("notice");
return new ReplyWaitSpec(
true,
notice == null ? null : notice.optString("id", ""),
collectExecutionIds(executions)
);
}
public static ReplyWaitSpec replyWaitFromBaseline(@Nullable String baselineMessageId) {
return new ReplyWaitSpec(true, baselineMessageId, null);
}
public static boolean hasTrackedDispatchExecutionReply(
@Nullable JSONArray dispatchPlans,
@Nullable List<String> executionIds
) {
if (dispatchPlans == null || executionIds == null || executionIds.isEmpty()) {
return false;
}
LinkedHashSet<String> trackedIds = new LinkedHashSet<>();
for (String executionId : executionIds) {
if (!isBlank(executionId)) {
trackedIds.add(executionId.trim());
}
}
if (trackedIds.isEmpty()) {
return false;
}
for (int i = 0; i < dispatchPlans.length(); i++) {
JSONObject plan = dispatchPlans.optJSONObject(i);
if (plan == null) {
continue;
}
JSONArray executions = plan.optJSONArray("executions");
if (executions == null) {
continue;
}
for (int j = 0; j < executions.length(); j++) {
JSONObject execution = executions.optJSONObject(j);
if (execution == null) {
continue;
}
String executionId = execution.optString("executionId", "").trim();
if (!trackedIds.contains(executionId)) {
continue;
}
String status = execution.optString("status", "").trim();
if ("completed".equals(status) || "failed".equals(status)) {
return true;
}
}
}
return false;
}
public static boolean hasReplyBeyondBaseline(@Nullable JSONObject project, @Nullable String baselineMessageId) {
if (project == null || isBlank(baselineMessageId)) {
return false;
}
String latestMessageId = latestMessageId(project.optJSONArray("messages"));
return !isBlank(latestMessageId) && !baselineMessageId.trim().equals(latestMessageId);
}
@Nullable
public static String latestMessageId(@Nullable JSONArray messages) {
if (messages == null || messages.length() == 0) {
return null;
}
JSONObject latestMessage = messages.optJSONObject(messages.length() - 1);
if (latestMessage == null) {
return null;
}
String messageId = latestMessage.optString("id", "").trim();
return messageId.isEmpty() ? null : messageId;
}
private static List<String> collectExecutionIds(@Nullable JSONArray executions) {
ArrayList<String> executionIds = new ArrayList<>();
if (executions == null) {
return executionIds;
}
for (int i = 0; i < executions.length(); i++) {
JSONObject execution = executions.optJSONObject(i);
if (execution == null) {
continue;
}
String executionId = execution.optString("executionId", "").trim();
if (!executionId.isEmpty()) {
executionIds.add(executionId);
}
}
return executionIds;
}
private static boolean isBlank(@Nullable String value) {
return value == null || value.trim().isEmpty();
}
private static String truncate(@Nullable String value, int maxLength) {
String normalized = value == null ? "" : value.trim();
if (normalized.length() <= maxLength) {
return normalized;
}
return normalized.substring(0, maxLength) + "";
}
}

View File

@@ -1,106 +1,30 @@
package com.hyzq.boss;
import android.content.Intent;
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();
configureScreen("消息转发", "正在切换到微信式转发");
Intent intent = new Intent(this, ForwardTargetActivity.class);
intent.putExtra(ForwardTargetActivity.EXTRA_SOURCE_PROJECT_ID, projectId);
intent.putExtra(ForwardTargetActivity.EXTRA_FORWARD_MODE, "single_legacy");
startActivity(intent);
finish();
}
@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());
});
}
});
// 兼容页只负责跳转,不再承载旧的备注转发链路。
}
}

View File

@@ -3,6 +3,8 @@ package com.hyzq.boss;
import android.os.Bundle;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.view.Gravity;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
@@ -10,12 +12,19 @@ import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.LinkedHashMap;
import java.util.Map;
public class ProjectGoalsActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id";
public static final String EXTRA_PROJECT_NAME = "project_name";
private static final String GOAL_REFRESH_NOTE = "project_goals.updated";
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private String projectId;
private String projectName;
private @Nullable BossRealtimeClient realtimeClient;
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
@@ -23,12 +32,36 @@ public class ProjectGoalsActivity extends BossScreenActivity {
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
configureScreen("项目目标", projectName == null ? "原生目标清单" : projectName);
setHeaderAction("新增", v -> openGoalEditor(null, ""));
setHeaderAction("编辑目标", v -> openGoalEditor(null, ""));
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
reload();
}
@Override
protected void onResume() {
super.onResume();
updateRealtimeSubscription();
}
@Override
protected void onPause() {
stopRealtimeUpdates();
super.onPause();
}
@Override
protected void onDestroy() {
stopRealtimeUpdates();
super.onDestroy();
}
@Override
protected void reload() {
if (projectId == null || projectId.isEmpty()) {
replaceContent(BossUi.buildEmptyCard(this, "缺少 projectId。"));
setRefreshing(false);
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
@@ -44,6 +77,70 @@ public class ProjectGoalsActivity extends BossScreenActivity {
});
}
private void updateRealtimeSubscription() {
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
realtimeClient.start();
return;
}
stopRealtimeUpdates();
}
private void stopRealtimeUpdates() {
if (realtimeClient != null) {
realtimeClient.stop();
}
}
void handleRealtimeEvent(BossRealtimeEvent event) {
if (event == null || event.eventName.isEmpty() || projectId == null || projectId.isEmpty()) {
return;
}
if (!shouldReloadForRealtimeEvent(event)) {
return;
}
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
if (eventFingerprint.isEmpty()) {
return;
}
long now = System.currentTimeMillis();
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
return;
}
runOnUiThread(this::reload);
}
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
if (!"conversation.updated".equals(event.eventName)) {
return false;
}
String payloadProjectId = event.payload.optString("projectId", "").trim();
if (payloadProjectId.isEmpty()) {
return false;
}
String payloadNote = event.payload.optString("note", "").trim();
return payloadProjectId.equals(projectId) && GOAL_REFRESH_NOTE.equals(payloadNote);
}
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
pruneRecentRealtimeEvents(now);
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
return true;
}
recentRealtimeEventTimestamps.put(eventFingerprint, now);
return false;
}
private void pruneRecentRealtimeEvents(long now) {
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Long> entry = iterator.next();
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
iterator.remove();
}
}
}
private void renderGoals(@Nullable JSONObject project) {
replaceContent();
if (project == null) {
@@ -63,11 +160,12 @@ public class ProjectGoalsActivity extends BossScreenActivity {
}
}
int goalCount = goals == null ? 0 : goals.length();
appendContent(BossUi.buildCard(
this,
"主 Agent 已整理项目目标",
"已完成 " + completedCount + "/" + (goals == null ? 0 : goals.length()),
"用户可编辑,点按钮即可标记完成或修改正文。"
"主 Agent 已整理项目目标 · 已完成 " + completedCount + "/" + goalCount,
"最近更新 09:18 · 用户可编辑,点选圆圈标记完成后自动划线",
""
));
if (goals == null || goals.length() == 0) {
@@ -76,7 +174,7 @@ public class ProjectGoalsActivity extends BossScreenActivity {
for (int i = 0; i < goals.length(); i++) {
JSONObject goal = goals.optJSONObject(i);
if (goal == null) continue;
appendContent(buildGoalCard(goal));
appendContent(buildGoalChecklistCard(goal));
}
}
@@ -84,29 +182,66 @@ public class ProjectGoalsActivity extends BossScreenActivity {
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")
);
private LinearLayout buildGoalChecklistCard(JSONObject goal) {
LinearLayout card = BossUi.buildCard(this, "", "", "");
card.removeAllViews();
card.setClickable(true);
card.setFocusable(true);
card.setOnClickListener(v -> toggleGoal(goal.optString("id")));
card.setOnLongClickListener(v -> {
openGoalEditor(goal.optString("id"), goal.optString("text"));
return true;
});
Button toggle = BossUi.buildPrimaryButton(
this,
"completed".equals(goal.optString("state")) ? "标记未完成" : "标记完成"
);
toggle.setOnClickListener(v -> toggleGoal(goal.optString("id")));
card.addView(toggle);
boolean completed = "completed".equals(goal.optString("state"));
Button edit = BossUi.buildSecondaryButton(this, "编辑目标");
edit.setOnClickListener(v -> openGoalEditor(goal.optString("id"), goal.optString("text")));
card.addView(edit);
LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.TOP);
TextView indicator = new TextView(this);
LinearLayout.LayoutParams indicatorParams = new LinearLayout.LayoutParams(
BossUi.dp(this, 28),
BossUi.dp(this, 28)
);
indicatorParams.rightMargin = BossUi.dp(this, 12);
indicator.setLayoutParams(indicatorParams);
indicator.setGravity(Gravity.CENTER);
indicator.setText(completed ? "" : "");
indicator.setTextSize(18);
indicator.setTextColor(getColor(completed ? R.color.boss_green : R.color.boss_text_muted));
row.addView(indicator);
LinearLayout texts = new LinearLayout(this);
texts.setOrientation(LinearLayout.VERTICAL);
LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
);
texts.setLayoutParams(textParams);
TextView title = new TextView(this);
title.setText(goal.optString("text", "未命名目标"));
title.setTextSize(16);
title.setTextColor(getColor(R.color.boss_text_primary));
title.setLineSpacing(0f, 1.2f);
texts.addView(title);
TextView note = new TextView(this);
note.setText(goal.optString("note", "暂无备注"));
note.setTextSize(13);
note.setTextColor(getColor(R.color.boss_text_muted));
note.setPadding(0, BossUi.dp(this, 8), 0, 0);
texts.addView(note);
row.addView(texts);
card.addView(row);
return card;
}

View File

@@ -7,22 +7,54 @@ import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.LinkedHashMap;
import java.util.Map;
public class ProjectVersionsActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id";
public static final String EXTRA_PROJECT_NAME = "project_name";
private static final String GOAL_REFRESH_NOTE = "project_goals.updated";
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private String projectId;
private @Nullable BossRealtimeClient realtimeClient;
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
configureScreen("版本迭代记录", getIntent().getStringExtra(EXTRA_PROJECT_NAME));
setHeaderAction("只读", v -> showMessage("版本记录只读"));
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
reload();
}
@Override
protected void onResume() {
super.onResume();
updateRealtimeSubscription();
}
@Override
protected void onPause() {
stopRealtimeUpdates();
super.onPause();
}
@Override
protected void onDestroy() {
stopRealtimeUpdates();
super.onDestroy();
}
@Override
protected void reload() {
if (projectId == null || projectId.isEmpty()) {
replaceContent(BossUi.buildEmptyCard(this, "缺少 projectId。"));
setRefreshing(false);
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
@@ -38,12 +70,76 @@ public class ProjectVersionsActivity extends BossScreenActivity {
});
}
private void updateRealtimeSubscription() {
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
realtimeClient.start();
return;
}
stopRealtimeUpdates();
}
private void stopRealtimeUpdates() {
if (realtimeClient != null) {
realtimeClient.stop();
}
}
void handleRealtimeEvent(BossRealtimeEvent event) {
if (event == null || event.eventName.isEmpty() || projectId == null || projectId.isEmpty()) {
return;
}
if (!shouldReloadForRealtimeEvent(event)) {
return;
}
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
if (eventFingerprint.isEmpty()) {
return;
}
long now = System.currentTimeMillis();
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
return;
}
runOnUiThread(this::reload);
}
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
if (!"conversation.updated".equals(event.eventName)) {
return false;
}
String payloadProjectId = event.payload.optString("projectId", "").trim();
if (payloadProjectId.isEmpty()) {
return false;
}
String payloadNote = event.payload.optString("note", "").trim();
return payloadProjectId.equals(projectId) && GOAL_REFRESH_NOTE.equals(payloadNote);
}
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
pruneRecentRealtimeEvents(now);
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
return true;
}
recentRealtimeEventTimestamps.put(eventFingerprint, now);
return false;
}
private void pruneRecentRealtimeEvents(long now) {
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Long> entry = iterator.next();
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
iterator.remove();
}
}
}
private void renderVersions(@Nullable JSONObject project) {
replaceContent(BossUi.buildCard(
this,
"版本记录只读",
"版本记录由主 Agent 监督各线程提交,并在复核后自动发布",
"原生版本页仅展示,不允许手工篡改正文。"
"仅主 Agent 可发布迭代记录",
"每条记录需备核线程提交内容、测试结论与版本号一致性",
""
));
if (project == null) {
appendContent(BossUi.buildEmptyCard(this, "项目不存在。"));
@@ -63,9 +159,18 @@ public class ProjectVersionsActivity extends BossScreenActivity {
this,
item.optString("version", "未命名版本"),
item.optString("summary", ""),
item.optString("createdAt", "-")
""
));
}
String reviewTime = versions.optJSONObject(0) == null
? "-"
: versions.optJSONObject(0).optString("createdAt", "-");
appendContent(BossUi.buildCard(
this,
"主 Agent 复核记录",
"最近一次复核 " + reviewTime + " · 对比线程提交摘要、测试结果和补丁说明后发布。",
""
));
setRefreshing(false);
}
}

View File

@@ -0,0 +1,21 @@
package com.hyzq.boss;
public final class RootRefreshPolicy {
private RootRefreshPolicy() {}
public static boolean shouldShowFailure(
String activeTab,
boolean conversationsOk,
boolean devicesOk,
boolean otaOk,
boolean settingsOk
) {
if ("devices".equals(activeTab)) {
return !devicesOk;
}
if ("me".equals(activeTab)) {
return !settingsOk && !otaOk;
}
return !conversationsOk;
}
}

View File

@@ -0,0 +1,28 @@
package com.hyzq.boss;
public final class RootTabMemory {
private RootTabMemory() {}
public static String resolveInitialTab(String explicitTab, String storedTab, String preferredTab) {
String explicit = normalize(explicitTab);
if (explicit != null) {
return explicit;
}
String stored = normalize(storedTab);
if (stored != null) {
return stored;
}
String preferred = normalize(preferredTab);
if (preferred != null) {
return preferred;
}
return "conversations";
}
private static String normalize(String tab) {
if ("conversations".equals(tab) || "devices".equals(tab) || "me".equals(tab)) {
return tab;
}
return null;
}
}

View File

@@ -11,7 +11,7 @@ public class SecurityActivity extends BossScreenActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("账号与安全", "原生会话与设备安全");
configureScreen("账号与安全", "登录会话与设备保护");
reload();
}
@@ -33,33 +33,34 @@ public class SecurityActivity extends BossScreenActivity {
}
private void renderSecurity(@Nullable JSONObject session) {
replaceContent(
BossUi.buildCard(
this,
"当前登录模式",
"当前登录页已临时切到免验证模式,点击登录会直接创建最高管理员会话",
"后续如收口认证,再切回账号密码 / 验证码登录。"
)
);
replaceContent();
appendContent(BossUi.buildWechatMenuRow(
this,
"当前登录模式",
"当前客户端仍使用快速进入模式",
"需要更严格认证,再切回账号密码验证码登录。",
null,
null
));
if (session != null) {
appendContent(BossUi.buildCard(
appendContent(BossUi.buildWechatMenuRow(
this,
"当前会话",
"账号 " + session.optString("account", "-")
+ "\n角色 " + session.optString("role", "-")
+ "\n登录方式 " + session.optString("loginMethod", "-"),
"到期 " + session.optString("expiresAt", "-")
+ " · " + BossUi.formatRoleLabel(session.optString("role", "-")),
"登录方式 " + session.optString("loginMethod", "-")
+ " · 到期 " + session.optString("expiresAt", "-"),
null,
null
));
}
android.widget.Button devicesButton = BossUi.buildPrimaryButton(this, "打开设备页");
devicesButton.setOnClickListener(v -> {
appendContent(BossUi.buildMenuRow(this, "打开设备页", "查看已绑定设备与状态", null, 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());

View File

@@ -15,13 +15,15 @@ public class SettingsActivity extends BossScreenActivity {
private SwitchCompat riskBadgesSwitch;
private SwitchCompat confirmActionsSwitch;
private Spinner preferredEntrySpinner;
private boolean settingsLoaded = false;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("设置", "原生偏好配置");
configureScreen("设置", "默认首页与提醒偏好");
setHeaderAction("保存", v -> saveSettings());
buildForm();
buildFormContent();
updateSaveAvailability();
reload();
}
@@ -36,48 +38,54 @@ public class SettingsActivity extends BossScreenActivity {
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
settingsLoaded = false;
updateSaveAvailability();
replaceContent(BossUi.buildEmptyCard(this, "设置加载失败:" + error.getMessage()));
});
}
});
}
private void buildForm() {
replaceContent(
BossUi.buildCard(
this,
"设置说明",
"当前设置会持久化到 data/boss-state.json下一线程接手不会丢失。",
"原生设置页直接走 /api/v1/settings"
)
);
private void buildFormContent() {
if (liveUpdatesSwitch == null) {
liveUpdatesSwitch = new SwitchCompat(this);
liveUpdatesSwitch.setText("启用实时刷新");
}
if (riskBadgesSwitch == null) {
riskBadgesSwitch = new SwitchCompat(this);
riskBadgesSwitch.setText("显示风险徽标");
}
if (confirmActionsSwitch == null) {
confirmActionsSwitch = new SwitchCompat(this);
confirmActionsSwitch.setText("危险操作前确认");
}
if (preferredEntrySpinner == null) {
preferredEntrySpinner = new Spinner(this);
ArrayAdapter<String> adapter = new ArrayAdapter<>(
this,
android.R.layout.simple_spinner_dropdown_item,
new String[]{"conversations", "devices", "me"}
);
preferredEntrySpinner.setAdapter(adapter);
}
liveUpdatesSwitch = new SwitchCompat(this);
liveUpdatesSwitch.setText("启用实时刷新");
riskBadgesSwitch = new SwitchCompat(this);
riskBadgesSwitch.setText("显示风险徽标");
confirmActionsSwitch = new SwitchCompat(this);
confirmActionsSwitch.setText("危险操作前确认");
preferredEntrySpinner = new Spinner(this);
ArrayAdapter<String> adapter = new ArrayAdapter<>(
replaceContent(BossUi.buildWechatMenuRow(
this,
android.R.layout.simple_spinner_dropdown_item,
new String[]{"conversations", "devices", "me"}
);
preferredEntrySpinner.setAdapter(adapter);
"偏好设置",
"调整默认首页和提醒行为。",
"保存后会直接写入当前账号设置",
null,
null
));
LinearLayout card = BossUi.buildCard(this, "交互偏好", "可切换默认首页与提醒行为。", "保存后立即生效");
card.addView(liveUpdatesSwitch);
card.addView(riskBadgesSwitch);
card.addView(confirmActionsSwitch);
card.addView(preferredEntrySpinner);
appendContent(card);
appendContent(BossUi.buildFormCell(this, "实时刷新", "会话、设备和 OTA 状态变化时自动更新", liveUpdatesSwitch));
appendContent(BossUi.buildFormCell(this, "风险徽标", "在列表中显示风险状态提示", riskBadgesSwitch));
appendContent(BossUi.buildFormCell(this, "危险操作确认", "执行修复或切换前再次确认", confirmActionsSwitch));
appendContent(BossUi.buildFormCell(this, "默认首页", "下次打开 App 优先进入这里", preferredEntrySpinner));
}
private void populate(@Nullable JSONObject settings) {
buildFormContent();
if (settings != null) {
liveUpdatesSwitch.setChecked(settings.optBoolean("liveUpdates", true));
riskBadgesSwitch.setChecked(settings.optBoolean("showRiskBadges", true));
@@ -91,10 +99,16 @@ public class SettingsActivity extends BossScreenActivity {
preferredEntrySpinner.setSelection(0);
}
}
settingsLoaded = settings != null;
updateSaveAvailability();
setRefreshing(false);
}
private void saveSettings() {
if (!settingsLoaded) {
showMessage("设置尚未加载完成,请先刷新成功后再保存。");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
@@ -117,4 +131,11 @@ public class SettingsActivity extends BossScreenActivity {
}
});
}
private void updateSaveAvailability() {
if (headerActionButton != null) {
headerActionButton.setEnabled(settingsLoaded);
headerActionButton.setAlpha(settingsLoaded ? 1f : 0.45f);
}
}
}

View File

@@ -9,12 +9,18 @@ import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.LinkedHashMap;
import java.util.Map;
public class SkillInventoryActivity extends BossScreenActivity {
public static final String EXTRA_DEVICE_ID = "device_id";
public static final String EXTRA_DEVICE_NAME = "device_name";
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private String deviceId;
private String deviceName;
private @Nullable BossRealtimeClient realtimeClient;
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
@@ -22,9 +28,28 @@ public class SkillInventoryActivity extends BossScreenActivity {
deviceId = getIntent().getStringExtra(EXTRA_DEVICE_ID);
deviceName = getIntent().getStringExtra(EXTRA_DEVICE_NAME);
configureScreen("技能", deviceName == null ? "当前设备 Skill 清单" : deviceName);
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
reload();
}
@Override
protected void onResume() {
super.onResume();
updateRealtimeSubscription();
}
@Override
protected void onPause() {
stopRealtimeUpdates();
super.onPause();
}
@Override
protected void onDestroy() {
stopRealtimeUpdates();
super.onDestroy();
}
@Override
protected void reload() {
setRefreshing(true);
@@ -44,9 +69,82 @@ public class SkillInventoryActivity extends BossScreenActivity {
});
}
private void updateRealtimeSubscription() {
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
realtimeClient.start();
return;
}
stopRealtimeUpdates();
}
private void stopRealtimeUpdates() {
if (realtimeClient != null) {
realtimeClient.stop();
}
}
void handleRealtimeEvent(BossRealtimeEvent event) {
if (event == null || event.eventName.isEmpty()) {
return;
}
if (!shouldReloadForRealtimeEvent(event)) {
return;
}
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
if (eventFingerprint.isEmpty()) {
return;
}
long now = System.currentTimeMillis();
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
return;
}
runOnUiThread(this::reload);
}
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
if (!"devices.skills.updated".equals(event.eventName) && !"devices.updated".equals(event.eventName)) {
return false;
}
String payloadDeviceId = event.payload.optString("deviceId", "").trim();
if (payloadDeviceId.isEmpty()) {
return true;
}
if (deviceId == null || deviceId.isEmpty()) {
return true;
}
return payloadDeviceId.equals(deviceId);
}
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
pruneRecentRealtimeEvents(now);
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
return true;
}
recentRealtimeEventTimestamps.put(eventFingerprint, now);
return false;
}
private void pruneRecentRealtimeEvents(long now) {
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Long> entry = iterator.next();
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
iterator.remove();
}
}
}
private String resolveTargetDeviceId() throws Exception {
if (deviceId != null && !deviceId.isEmpty()) {
return deviceId;
String explicitDeviceId = deviceId;
String boundDeviceId = null;
BossApiClient.ApiResponse settingsResponse = apiClient.getSettings();
if (settingsResponse.ok()) {
JSONObject user = settingsResponse.json.optJSONObject("user");
if (user != null) {
String candidate = user.optString("boundDeviceId", "");
boundDeviceId = candidate.isEmpty() ? null : candidate;
}
}
BossApiClient.ApiResponse response = apiClient.getDevices();
if (!response.ok()) throw new IllegalStateException(response.message());
@@ -54,7 +152,54 @@ public class SkillInventoryActivity extends BossScreenActivity {
if (devices == null || devices.length() == 0) {
throw new IllegalStateException("NO_DEVICE");
}
return devices.optJSONObject(0).optString("id");
return chooseTargetDeviceId(explicitDeviceId, boundDeviceId, apiClient.getAccountLabel(), devices);
}
private static String chooseTargetDeviceId(
@Nullable String explicitDeviceId,
@Nullable String boundDeviceId,
String account,
JSONArray devices
) {
String explicitMatch = findDeviceId(devices, explicitDeviceId);
if (explicitMatch != null) {
return explicitMatch;
}
String boundMatch = findDeviceId(devices, boundDeviceId);
if (boundMatch != null) {
return boundMatch;
}
for (int i = 0; i < devices.length(); i++) {
JSONObject device = devices.optJSONObject(i);
if (device == null) continue;
if (account.equals(device.optString("account", ""))) {
return device.optString("id", "");
}
}
if (devices.length() == 1) {
JSONObject onlyDevice = devices.optJSONObject(0);
if (onlyDevice != null) {
return onlyDevice.optString("id", "");
}
}
JSONObject fallback = devices.optJSONObject(0);
return fallback == null ? "" : fallback.optString("id", "");
}
private static @Nullable String findDeviceId(JSONArray devices, @Nullable String candidateDeviceId) {
if (candidateDeviceId == null || candidateDeviceId.isEmpty()) {
return null;
}
for (int i = 0; i < devices.length(); i++) {
JSONObject device = devices.optJSONObject(i);
if (device == null) continue;
if (candidateDeviceId.equals(device.optString("id", ""))) {
return candidateDeviceId;
}
}
return null;
}
private void renderSkills(JSONObject payload) {
@@ -65,11 +210,13 @@ public class SkillInventoryActivity extends BossScreenActivity {
if (device != null) {
deviceName = device.optString("name", deviceId);
configureScreen("技能", deviceName);
appendContent(BossUi.buildCard(
appendContent(BossUi.buildWechatMenuRow(
this,
deviceName,
"当前页按设备查看 Skill 清单。",
"Skill 由 local-agent 从本机 ~/.codex/skills 扫描并同步。"
"Skill 由 local-agent 从本机 ~/.codex/skills 扫描并同步。",
null,
null
));
}
@@ -81,19 +228,22 @@ public class SkillInventoryActivity extends BossScreenActivity {
for (int i = 0; i < skills.length(); i++) {
JSONObject skill = skills.optJSONObject(i);
if (skill == null) continue;
LinearLayout card = BossUi.buildCard(
LinearLayout card = new LinearLayout(this);
card.setOrientation(LinearLayout.VERTICAL);
card.addView(BossUi.buildWechatMenuRow(
this,
skill.optString("name", "未命名 Skill"),
skill.optString("description", "未提供说明"),
skill.optString("category", "-")
+ " · " + skill.optString("updatedAt", "-")
);
Button copyInvocation = BossUi.buildPrimaryButton(this, "复制调用语句");
+ " · " + skill.optString("updatedAt", "-"),
null,
null
));
Button copyInvocation = BossUi.buildMiniActionButton(this, "复制调用", true);
copyInvocation.setOnClickListener(v -> BossUi.copyText(this, "Skill 调用", skill.optString("invocation", "")));
card.addView(copyInvocation);
Button copyPath = BossUi.buildSecondaryButton(this, "复制路径");
Button copyPath = BossUi.buildMiniActionButton(this, "复制路径", false);
copyPath.setOnClickListener(v -> BossUi.copyText(this, "Skill 路径", skill.optString("path", "")));
card.addView(copyPath);
card.addView(BossUi.buildInlineActionRow(this, copyInvocation, copyPath));
appendContent(card);
}
setRefreshing(false);

View File

@@ -0,0 +1,255 @@
package com.hyzq.boss;
import android.os.Bundle;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.LinkedHashMap;
import java.util.Map;
public class ThreadStatusActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id";
public static final String EXTRA_PROJECT_NAME = "project_name";
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private String projectId;
private String projectName;
private @Nullable BossRealtimeClient realtimeClient;
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
@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);
hideHeaderAction();
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
reload();
}
@Override
protected void onResume() {
super.onResume();
updateRealtimeSubscription();
}
@Override
protected void onPause() {
stopRealtimeUpdates();
super.onPause();
}
@Override
protected void onDestroy() {
stopRealtimeUpdates();
super.onDestroy();
}
@Override
protected void reload() {
if (projectId == null || projectId.isEmpty()) {
replaceContent(BossUi.buildEmptyCard(this, "缺少 projectId。"));
setRefreshing(false);
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getThreadStatus(projectId);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> renderThreadStatus(response.json));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "线程状态加载失败:" + error.getMessage()));
});
}
});
}
private void updateRealtimeSubscription() {
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
realtimeClient.start();
return;
}
stopRealtimeUpdates();
}
private void stopRealtimeUpdates() {
if (realtimeClient != null) {
realtimeClient.stop();
}
}
void handleRealtimeEvent(BossRealtimeEvent event) {
if (event == null || event.eventName.isEmpty() || projectId == null || projectId.isEmpty()) {
return;
}
if (!shouldReloadForRealtimeEvent(event)) {
return;
}
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
if (eventFingerprint.isEmpty()) {
return;
}
long now = System.currentTimeMillis();
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
return;
}
runOnUiThread(this::reload);
}
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
String payloadProjectId = event.payload.optString("projectId", "").trim();
if (payloadProjectId.isEmpty() || !payloadProjectId.equals(projectId)) {
return false;
}
return "conversation.updated".equals(event.eventName)
|| "project.messages.updated".equals(event.eventName)
|| "project.context_risk.updated".equals(event.eventName)
|| "master_agent.task.updated".equals(event.eventName);
}
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
pruneRecentRealtimeEvents(now);
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
return true;
}
recentRealtimeEventTimestamps.put(eventFingerprint, now);
return false;
}
private void pruneRecentRealtimeEvents(long now) {
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Long> entry = iterator.next();
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
iterator.remove();
}
}
}
private void renderThreadStatus(JSONObject payload) {
replaceContent();
JSONObject document = payload.optJSONObject("threadStatusDocument");
JSONArray recentProgressEvents = payload.optJSONArray("recentProgressEvents");
if (document == null) {
appendContent(BossUi.buildEmptyCard(this, "当前还没有线程状态文档。"));
setRefreshing(false);
return;
}
String threadDisplayName = document.optString(
"threadDisplayName",
projectName == null ? "线程状态" : projectName
);
configureScreen("线程状态", buildSubtitle(document, recentProgressEvents));
appendContent(BossUi.buildSimpleProfileHeader(
this,
threadDisplayName,
"线程状态文档",
buildHeaderDetail(document, recentProgressEvents)
));
appendStatusCard("当前目标", document.optString("projectGoal", "暂无目标"));
appendStatusCard("当前阶段", document.optString("currentPhase", "暂无阶段"));
appendStatusCard("当前进度", document.optString("currentProgress", "暂无进度"));
appendStatusCard("技术架构", document.optString("technicalArchitecture", "暂无架构"));
appendStatusCard("当前阻塞", document.optString("currentBlockers", "暂无阻塞"));
appendStatusCard("建议下一步", document.optString("recommendedNextStep", "暂无建议"));
appendRecentEvents(recentProgressEvents);
setRefreshing(false);
}
private void appendStatusCard(String title, String body) {
appendContent(BossUi.buildCard(this, title, body, ""));
}
private void appendRecentEvents(@Nullable JSONArray recentProgressEvents) {
int count = recentProgressEvents == null ? 0 : recentProgressEvents.length();
appendContent(BossUi.buildWechatMenuRow(
this,
"最近进展事件",
count <= 0 ? "当前还没有进展事件。" : "" + count + "",
"自动同步 · 最近 5 条",
null,
null
));
if (recentProgressEvents == null || recentProgressEvents.length() == 0) {
appendContent(BossUi.buildEmptyCard(this, "当前还没有进展事件。"));
return;
}
for (int i = 0; i < recentProgressEvents.length(); i++) {
JSONObject event = recentProgressEvents.optJSONObject(i);
if (event == null) continue;
LinearLayout row = BossUi.buildWechatMenuRow(
this,
event.optString("summary", "线程状态更新"),
event.optString("phase", event.optString("eventType", "progress_updated")),
buildEventMeta(event),
null,
null
);
appendContent(row);
}
}
private String buildSubtitle(JSONObject document, @Nullable JSONArray recentProgressEvents) {
int count = recentProgressEvents == null ? 0 : recentProgressEvents.length();
String folderName = document.optString("folderName", "");
String suffix = count <= 0 ? "暂无进展事件" : "最近 " + count + " 条进展事件";
if (folderName.isEmpty()) {
return suffix;
}
return folderName + " · " + suffix;
}
private String buildHeaderDetail(JSONObject document, @Nullable JSONArray recentProgressEvents) {
int count = recentProgressEvents == null ? 0 : recentProgressEvents.length();
StringBuilder builder = new StringBuilder();
String threadId = document.optString("threadId", "");
if (!threadId.isEmpty()) {
builder.append(threadId);
}
String deviceId = document.optString("deviceId", "");
if (!deviceId.isEmpty()) {
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(deviceId);
}
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(count <= 0 ? "暂无进展事件" : count + " 条进展事件");
return builder.toString();
}
private String buildEventMeta(JSONObject event) {
StringBuilder builder = new StringBuilder();
String deviceId = event.optString("deviceId", "");
if (!deviceId.isEmpty()) {
builder.append(deviceId);
}
String createdAt = event.optString("createdAt", "");
if (!createdAt.isEmpty()) {
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(createdAt);
}
if (builder.length() == 0) {
return event.optString("eventType", "progress_updated");
}
return builder.toString();
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="@color/boss_bg_app" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@color/boss_surface" />
<stroke
android:width="1dp"
android:color="@color/boss_divider" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/boss_surface" />
<corners
android:topLeftRadius="8dp"
android:topRightRadius="18dp"
android:bottomLeftRadius="18dp"
android:bottomRightRadius="18dp" />
<stroke
android:width="1dp"
android:color="@color/boss_card_stroke" />
</shape>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF111111"
android:pathData="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF111111"
android:pathData="M15.41,7.41L14,6L8,12L14,18L15.41,16.59L10.83,12L15.41,7.41Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF111111"
android:pathData="M9,16.17L4.83,12L3.41,13.41L9,19L21,7L19.59,5.59L9,16.17Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF111111"
android:pathData="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF111111"
android:pathData="M3,17.25V21H6.75L17.81,9.94L14.06,6.19L3,17.25ZM20.71,7.04C21.1,6.65 21.1,6.02 20.71,5.63L18.37,3.29C17.98,2.9 17.35,2.9 16.96,3.29L15.13,5.12L18.88,8.87L20.71,7.04Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF111111"
android:pathData="M11,7H13V9H11V7ZM11,11H13V17H11V11ZM12,2C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22C17.52,22 22,17.52 22,12C22,6.48 17.52,2 12,2Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF111111"
android:pathData="M6,10A2,2 0 1,0 6,14A2,2 0 1,0 6,10ZM12,10A2,2 0 1,0 12,14A2,2 0 1,0 12,10ZM18,10A2,2 0 1,0 18,14A2,2 0 1,0 18,10Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF111111"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4C7.58,4 4,7.58 4,12C4,16.42 7.58,20 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18C8.69,18 6,15.31 6,12C6,8.69 8.69,6 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF111111"
android:pathData="M15.5,14H14.71L14.43,13.73C15.41,12.59 16,11.11 16,9.5C16,5.91 13.09,3 9.5,3C5.91,3 3,5.91 3,9.5C3,13.09 5.91,16 9.5,16C11.11,16 12.59,15.41 13.73,14.43L14,14.71V15.5L19,20.49L20.49,19L15.5,14ZM9.5,14C7.01,14 5,11.99 5,9.5C5,7.01 7.01,5 9.5,5C11.99,5 14,7.01 14,9.5C14,11.99 11.99,14 9.5,14Z" />
</vector>

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/boss_bg_app"
android:orientation="vertical">
<LinearLayout
android:id="@+id/screen_top_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingLeft="20dp"
android:paddingTop="14dp"
android:paddingRight="20dp"
android:paddingBottom="12dp">
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_back_button"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/bg_top_icon_button"
android:contentDescription="返回"
android:padding="8dp"
android:src="@drawable/ic_boss_back"
android:tint="@color/boss_text_primary" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="12dp"
android:layout_marginRight="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/screen_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="会话信息"
android:textColor="@color/boss_text_primary"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/screen_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:text="单线程会话信息页"
android:textColor="@color/boss_text_muted"
android:textSize="12sp" />
</LinearLayout>
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_header_action"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button"
android:contentDescription="更多"
android:padding="8dp"
android:src="@drawable/ic_boss_more"
android:tint="@color/boss_text_primary"
android:visibility="gone" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_refresh_button"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button"
android:contentDescription="刷新"
android:padding="8dp"
android:src="@drawable/ic_boss_refresh"
android:tint="@color/boss_text_primary" />
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/screen_refresh_layout"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:id="@+id/screen_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_panel"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="24dp" />
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/boss_bg_app"
android:orientation="vertical">
<LinearLayout
android:id="@+id/screen_top_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingLeft="20dp"
android:paddingTop="14dp"
android:paddingRight="20dp"
android:paddingBottom="12dp">
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_back_button"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/bg_top_icon_button"
android:contentDescription="返回"
android:padding="8dp"
android:src="@drawable/ic_boss_back"
android:tint="@color/boss_text_primary" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="12dp"
android:layout_marginRight="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/screen_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="标题"
android:textColor="@color/boss_text_primary"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/screen_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:text="副标题"
android:textColor="@color/boss_text_muted"
android:textSize="12sp" />
</LinearLayout>
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_header_action"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button"
android:contentDescription="更多"
android:padding="8dp"
android:src="@drawable/ic_boss_more"
android:tint="@color/boss_text_primary"
android:visibility="gone" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_refresh_button"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button"
android:contentDescription="刷新"
android:padding="8dp"
android:src="@drawable/ic_boss_refresh"
android:tint="@color/boss_text_primary" />
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/screen_refresh_layout"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:id="@+id/screen_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_panel"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="24dp" />
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/boss_bg_app"
android:orientation="vertical">
<LinearLayout
android:id="@+id/screen_top_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingLeft="20dp"
android:paddingTop="14dp"
android:paddingRight="20dp"
android:paddingBottom="12dp">
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_back_button"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/bg_top_icon_button"
android:contentDescription="返回"
android:padding="8dp"
android:src="@drawable/ic_boss_back"
android:tint="@color/boss_text_primary" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="12dp"
android:layout_marginRight="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/screen_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="发起群聊"
android:textColor="@color/boss_text_primary"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/screen_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:text="从当前会话选择其他线程"
android:textColor="@color/boss_text_muted"
android:textSize="12sp" />
</LinearLayout>
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_header_action"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button"
android:contentDescription="更多"
android:padding="8dp"
android:src="@drawable/ic_boss_more"
android:tint="@color/boss_text_primary"
android:visibility="gone" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_refresh_button"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button"
android:contentDescription="刷新"
android:padding="8dp"
android:src="@drawable/ic_boss_refresh"
android:tint="@color/boss_text_primary" />
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/screen_refresh_layout"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:id="@+id/screen_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_panel"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="24dp" />
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/boss_bg_app"
android:orientation="vertical">
<LinearLayout
android:id="@+id/screen_top_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingLeft="20dp"
android:paddingTop="14dp"
android:paddingRight="20dp"
android:paddingBottom="12dp">
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_back_button"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/bg_top_icon_button"
android:contentDescription="返回"
android:padding="8dp"
android:src="@drawable/ic_boss_back"
android:tint="@color/boss_text_primary" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="12dp"
android:layout_marginRight="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/screen_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="群资料"
android:textColor="@color/boss_text_primary"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/screen_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:text="群聊资料页"
android:textColor="@color/boss_text_muted"
android:textSize="12sp" />
</LinearLayout>
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_header_action"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button"
android:contentDescription="更多"
android:padding="8dp"
android:src="@drawable/ic_boss_more"
android:tint="@color/boss_text_primary"
android:visibility="gone" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_refresh_button"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button"
android:contentDescription="刷新"
android:padding="8dp"
android:src="@drawable/ic_boss_refresh"
android:tint="@color/boss_text_primary" />
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/screen_refresh_layout"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:id="@+id/screen_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_panel"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="24dp" />
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>

View File

@@ -2,7 +2,7 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_app_gradient">
android:background="@color/boss_bg_app">
<ScrollView
android:id="@+id/login_panel"
@@ -11,6 +11,7 @@
android:fillViewport="true">
<LinearLayout
android:id="@+id/login_shell"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
@@ -18,23 +19,24 @@
android:paddingLeft="24dp"
android:paddingTop="72dp"
android:paddingRight="24dp"
android:paddingBottom="32dp">
android:paddingBottom="40dp">
<TextView
android:layout_width="72dp"
android:layout_height="72dp"
android:background="@drawable/bg_primary_button"
android:background="@drawable/bg_secondary_button"
android:gravity="center"
android:text="B"
android:textColor="@color/boss_surface"
android:textSize="30sp"
android:textColor="@color/boss_green"
android:textSize="28sp"
android:textStyle="bold" />
<TextView
android:id="@+id/login_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Boss 原生控制台"
android:layout_marginTop="22dp"
android:text=""
android:textColor="@color/boss_text_primary"
android:textSize="30sp"
android:textStyle="bold" />
@@ -43,38 +45,12 @@
android:id="@+id/login_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginTop="12dp"
android:gravity="center"
android:lineSpacingExtra="4dp"
android:text="原生 Android 客户端已启用。点击下方按钮直接进入系统。"
android:text=""
android:textColor="@color/boss_text_muted"
android:textSize="15sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:background="@drawable/bg_card"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="当前临时模式"
android:textColor="@color/boss_text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:lineSpacingExtra="4dp"
android:text="1. 这是原生 Android Activity不再打开 WebView。\n2. 登录暂时不做验证,点击按钮会直接进入最高管理员会话。\n3. 会话 / 设备 / 我的三栏都直接调用现有 Boss API。"
android:textColor="@color/boss_text_primary"
android:textSize="14sp" />
</LinearLayout>
android:textSize="14sp" />
<ProgressBar
android:id="@+id/login_progress"
@@ -87,11 +63,11 @@
android:id="@+id/login_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginTop="22dp"
android:background="@drawable/bg_primary_button"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="登录"
android:text=""
android:textAllCaps="false"
android:textColor="@color/boss_surface"
android:textSize="18sp"
@@ -107,32 +83,31 @@
android:visibility="gone">
<LinearLayout
android:id="@+id/main_top_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingLeft="20dp"
android:paddingTop="18dp"
android:paddingTop="14dp"
android:paddingRight="20dp"
android:paddingBottom="16dp">
android:paddingBottom="12dp">
<Button
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/back_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginRight="12dp"
android:background="@drawable/bg_secondary_button"
android:paddingLeft="14dp"
android:paddingTop="10dp"
android:paddingRight="14dp"
android:paddingBottom="10dp"
android:text="返回"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginRight="8dp"
android:background="@drawable/bg_top_icon_button"
android:contentDescription="返回"
android:padding="8dp"
android:src="@drawable/ic_boss_back"
android:tint="@color/boss_text_primary"
android:visibility="gone" />
<LinearLayout
android:id="@+id/top_title_group"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
@@ -140,11 +115,13 @@
<TextView
android:id="@+id/top_title"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="会话"
android:textColor="@color/boss_text_primary"
android:textSize="24sp"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
@@ -152,24 +129,50 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="原生 Android 客户端,直接消费 Boss API。"
android:text=""
android:textColor="@color/boss_text_muted"
android:textSize="13sp" />
android:textSize="12sp"
android:visibility="gone" />
</LinearLayout>
<Button
android:id="@+id/refresh_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
<EditText
android:id="@+id/top_search_input"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_weight="1"
android:background="@drawable/bg_secondary_button"
android:paddingLeft="16dp"
android:paddingTop="10dp"
android:paddingRight="16dp"
android:paddingBottom="10dp"
android:text="刷新"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold" />
android:hint="搜索项目或线程"
android:imeOptions="actionSearch"
android:inputType="text"
android:paddingLeft="14dp"
android:paddingTop="8dp"
android:paddingRight="14dp"
android:paddingBottom="8dp"
android:textColor="@color/boss_text_primary"
android:textColorHint="@color/boss_text_muted"
android:textSize="15sp"
android:visibility="gone" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/search_button"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginRight="8dp"
android:background="@drawable/bg_top_icon_button"
android:contentDescription="搜索"
android:padding="8dp"
android:src="@drawable/ic_boss_search"
android:tint="@color/boss_text_primary" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/refresh_button"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/bg_top_icon_button"
android:contentDescription="快捷操作"
android:padding="8dp"
android:src="@drawable/ic_boss_add"
android:tint="@color/boss_text_primary" />
</LinearLayout>
<FrameLayout
@@ -177,37 +180,21 @@
android:layout_height="0dp"
android:layout_weight="1">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/screen_refresh"
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/root_pager"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:id="@+id/screen_scroll"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:id="@+id/screen_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingLeft="20dp"
android:paddingTop="8dp"
android:paddingRight="20dp"
android:paddingBottom="88dp" />
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
android:layout_height="match_parent" />
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="76dp"
android:layout_height="72dp"
android:background="@color/boss_surface"
android:elevation="10dp"
android:gravity="center"
android:orientation="horizontal"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:paddingLeft="12dp"
android:paddingRight="12dp">
@@ -217,10 +204,10 @@
android:layout_height="48dp"
android:layout_marginRight="6dp"
android:layout_weight="1"
android:background="@drawable/bg_primary_button"
android:background="@drawable/bg_tab_active"
android:text="会话"
android:textAllCaps="false"
android:textColor="@color/boss_surface"
android:textColor="@color/boss_green"
android:textStyle="bold" />
<Button
@@ -230,10 +217,10 @@
android:layout_marginLeft="6dp"
android:layout_marginRight="6dp"
android:layout_weight="1"
android:background="@drawable/bg_secondary_button"
android:background="@drawable/bg_tab_inactive"
android:text="设备"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textColor="@color/boss_text_muted"
android:textStyle="bold" />
<Button
@@ -242,12 +229,93 @@
android:layout_height="48dp"
android:layout_marginLeft="6dp"
android:layout_weight="1"
android:background="@drawable/bg_secondary_button"
android:background="@drawable/bg_tab_inactive"
android:text="我的"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textColor="@color/boss_text_muted"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<FrameLayout
android:id="@+id/conversation_quick_actions_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clickable="true"
android:focusable="true"
android:visibility="gone">
<View
android:id="@+id/conversation_quick_actions_scrim"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:alpha="0"
android:background="@color/boss_overlay_scrim" />
<FrameLayout
android:id="@+id/conversation_quick_actions_anchor"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="58dp"
android:paddingRight="20dp">
<LinearLayout
android:id="@+id/conversation_quick_actions_menu"
android:layout_width="196dp"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:alpha="0"
android:background="@drawable/bg_conversation_quick_actions_menu"
android:elevation="14dp"
android:orientation="vertical"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:translationY="-6dp"
android:visibility="gone">
<Button
android:id="@+id/quick_action_add_device"
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="@android:color/transparent"
android:gravity="center_vertical|start"
android:minWidth="0dp"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:text="添加设备"
android:textAllCaps="false"
android:textColor="@color/boss_quick_actions_menu_text"
android:textSize="15sp" />
<Button
android:id="@+id/quick_action_scan"
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="@android:color/transparent"
android:gravity="center_vertical|start"
android:minWidth="0dp"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:text="扫一扫"
android:textAllCaps="false"
android:textColor="@color/boss_quick_actions_menu_text"
android:textSize="15sp" />
<Button
android:id="@+id/quick_action_group_chat"
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="@android:color/transparent"
android:gravity="center_vertical|start"
android:minWidth="0dp"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:text="发起群聊"
android:textAllCaps="false"
android:textColor="@color/boss_quick_actions_menu_text"
android:textSize="15sp" />
</LinearLayout>
</FrameLayout>
</FrameLayout>
</FrameLayout>

View File

@@ -0,0 +1,199 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/boss_bg_app"
android:orientation="vertical">
<LinearLayout
android:id="@+id/screen_top_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingLeft="20dp"
android:paddingTop="14dp"
android:paddingRight="20dp"
android:paddingBottom="12dp">
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_back_button"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/bg_top_icon_button"
android:contentDescription="返回"
android:padding="8dp"
android:src="@drawable/ic_boss_back"
android:tint="@color/boss_text_primary" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="12dp"
android:layout_marginRight="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/screen_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="项目详情"
android:textColor="@color/boss_text_primary"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/screen_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:text="设备"
android:textColor="@color/boss_text_muted"
android:textSize="12sp" />
</LinearLayout>
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_header_action"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button"
android:contentDescription="更多"
android:padding="8dp"
android:src="@drawable/ic_boss_more"
android:tint="@color/boss_text_primary"
android:visibility="gone" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_refresh_button"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button"
android:contentDescription="刷新"
android:padding="8dp"
android:src="@drawable/ic_boss_refresh"
android:tint="@color/boss_text_primary" />
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/screen_refresh_layout"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<ScrollView
android:id="@+id/project_chat_scroll"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingLeft="12dp"
android:paddingTop="10dp"
android:paddingRight="12dp"
android:paddingBottom="20dp">
<LinearLayout
android:id="@+id/project_chat_quick_actions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:orientation="horizontal" />
<LinearLayout
android:id="@+id/screen_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
</LinearLayout>
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<LinearLayout
android:id="@+id/project_chat_composer_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:gravity="bottom"
android:orientation="horizontal"
android:paddingLeft="12dp"
android:paddingTop="10dp"
android:paddingRight="12dp"
android:paddingBottom="12dp">
<Button
android:id="@+id/project_chat_attach"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_marginRight="8dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:text="+"
android:textAllCaps="false"
android:textColor="@color/boss_text_primary"
android:textSize="20sp"
android:textStyle="bold" />
<EditText
android:id="@+id/project_chat_input"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/bg_secondary_button"
android:gravity="top|start"
android:hint="输入消息"
android:inputType="textCapSentences|textMultiLine"
android:maxLines="4"
android:minHeight="44dp"
android:paddingLeft="14dp"
android:paddingTop="10dp"
android:paddingRight="14dp"
android:paddingBottom="10dp"
android:textColor="@color/boss_text_primary"
android:textColorHint="@color/boss_text_muted" />
<Button
android:id="@+id/project_chat_send"
android:layout_width="72dp"
android:layout_height="44dp"
android:layout_marginLeft="8dp"
android:background="@drawable/bg_primary_button"
android:text="发送"
android:textAllCaps="false"
android:textColor="@color/boss_surface"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:id="@+id/project_chat_multi_select_actions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingLeft="12dp"
android:paddingTop="10dp"
android:paddingRight="12dp"
android:paddingBottom="12dp"
android:visibility="gone">
<Button
android:id="@+id/project_chat_multi_forward"
android:layout_width="match_parent"
android:layout_height="44dp"
android:background="@drawable/bg_primary_button"
android:text="转发"
android:textAllCaps="false"
android:textColor="@color/boss_surface"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>

View File

@@ -2,31 +2,30 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_app_gradient"
android:background="@color/boss_bg_app"
android:orientation="vertical">
<LinearLayout
android:id="@+id/screen_top_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingLeft="16dp"
android:paddingTop="16dp"
android:paddingRight="16dp"
android:paddingBottom="14dp">
android:paddingLeft="20dp"
android:paddingTop="14dp"
android:paddingRight="20dp"
android:paddingBottom="12dp">
<Button
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_back_button"
android:layout_width="wrap_content"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="返回"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold" />
android:background="@drawable/bg_top_icon_button"
android:contentDescription="返回"
android:padding="8dp"
android:src="@drawable/ic_boss_back"
android:tint="@color/boss_text_primary" />
<LinearLayout
android:layout_width="0dp"
@@ -55,33 +54,28 @@
android:textSize="12sp" />
</LinearLayout>
<Button
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_header_action"
android:layout_width="wrap_content"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginRight="8dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="操作"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textStyle="bold"
android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button"
android:contentDescription="更多"
android:padding="8dp"
android:src="@drawable/ic_boss_more"
android:tint="@color/boss_text_primary"
android:visibility="gone" />
<Button
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_refresh_button"
android:layout_width="wrap_content"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/bg_primary_button"
android:minWidth="0dp"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:text="刷新"
android:textAllCaps="false"
android:textColor="@color/boss_surface"
android:textStyle="bold" />
android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button"
android:contentDescription="刷新"
android:padding="8dp"
android:src="@drawable/ic_boss_refresh"
android:tint="@color/boss_text_primary" />
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@@ -99,10 +93,9 @@
android:id="@+id/screen_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_panel"
android:orientation="vertical"
android:paddingLeft="18dp"
android:paddingTop="6dp"
android:paddingRight="18dp"
android:paddingTop="8dp"
android:paddingBottom="24dp" />
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root_page_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/root_page_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/boss_bg_app"
android:clipToPadding="false"
android:paddingTop="12dp"
android:paddingBottom="88dp"
android:scrollbars="vertical" />
<ScrollView
android:id="@+id/root_page_scroll"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:visibility="gone">
<LinearLayout
android:id="@+id/root_page_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_bg_app"
android:orientation="vertical"
android:paddingTop="12dp"
android:paddingLeft="0dp"
android:paddingRight="0dp"
android:paddingBottom="88dp" />
</ScrollView>
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="boss_green">#07C160</color>
<color name="boss_green_dark">#04984B</color>
<color name="boss_surface">#FF151515</color>
<color name="boss_bg_start">#FF0E0E0E</color>
<color name="boss_bg_end">#FF0E0E0E</color>
<color name="boss_bg_app">#FF0E0E0E</color>
<color name="boss_panel">#FF151515</color>
<color name="boss_card_stroke">#22FFFFFF</color>
<color name="boss_divider">#1FFFFFFF</color>
<color name="boss_text_primary">#FFF5F5F5</color>
<color name="boss_text_muted">#FFB4B4B8</color>
<color name="boss_text_soft">#FF8E8E93</color>
<color name="boss_overlay_scrim">#66000000</color>
<color name="boss_quick_actions_menu_bg">#FF2B2B2E</color>
<color name="boss_quick_actions_menu_text">#FFF5F5F5</color>
<color name="colorPrimary">@color/boss_green</color>
<color name="colorPrimaryDark">@color/boss_green_dark</color>
<color name="colorAccent">@color/boss_green</color>
</resources>

View File

@@ -3,11 +3,18 @@
<color name="boss_green">#07C160</color>
<color name="boss_green_dark">#04984B</color>
<color name="boss_surface">#FFFFFFFF</color>
<color name="boss_bg_start">#FFF1F6EE</color>
<color name="boss_bg_end">#FFE3F0E3</color>
<color name="boss_card_stroke">#1A0F1B12</color>
<color name="boss_bg_start">#FFF7F7F7</color>
<color name="boss_bg_end">#FFF7F7F7</color>
<color name="boss_bg_app">#FFF7F7F7</color>
<color name="boss_panel">#FFFFFFFF</color>
<color name="boss_card_stroke">#14000000</color>
<color name="boss_divider">#FFEAEAEA</color>
<color name="boss_text_primary">#FF111111</color>
<color name="boss_text_muted">#FF5F6B63</color>
<color name="boss_text_soft">#FF8E8E93</color>
<color name="boss_overlay_scrim">#22000000</color>
<color name="boss_quick_actions_menu_bg">#FFF7F7F7</color>
<color name="boss_quick_actions_menu_text">#FF111111</color>
<color name="colorPrimary">@color/boss_green</color>
<color name="colorPrimaryDark">@color/boss_green_dark</color>
<color name="colorAccent">@color/boss_green</color>

View File

@@ -6,17 +6,17 @@
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="android:windowBackground">@drawable/bg_app_gradient</item>
<item name="android:windowBackground">@color/boss_bg_app</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:windowBackground">@drawable/bg_app_gradient</item>
<item name="android:windowBackground">@color/boss_bg_app</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
<item name="android:windowBackground">@drawable/bg_app_gradient</item>
<item name="android:windowBackground">@color/boss_bg_app</item>
</style>
</resources>

View File

@@ -0,0 +1,65 @@
package com.hyzq.boss;
import static org.junit.Assert.assertArrayEquals;
import org.json.JSONObject;
import org.junit.Test;
public class AboutActivityStaleDownloadCleanupTest {
@Test
public void collectStaleDownloadIdsForRemoval_returnsIdsWhenReleaseChanged() throws Exception {
JSONObject availableRelease = new StubJSONObject()
.withString("packageFileName", "boss-android-v1.2.9-release.apk")
.withString("version", "v1.2.9");
long[] ids = AboutActivity.collectStaleDownloadIdsForRemoval(
availableRelease,
"boss-android-v1.2.8-release.apk",
"v1.2.8",
true,
42L,
77L
);
assertArrayEquals(new long[]{42L, 77L}, ids);
}
@Test
public void collectStaleDownloadIdsForRemoval_returnsEmptyWhenReleaseMatchesLocalPackage() throws Exception {
JSONObject availableRelease = new StubJSONObject()
.withString("packageFileName", "boss-android-v1.2.9-release.apk")
.withString("version", "v1.2.9");
long[] ids = AboutActivity.collectStaleDownloadIdsForRemoval(
availableRelease,
"boss-android-v1.2.9-release.apk",
"v1.2.9",
true,
42L,
77L
);
assertArrayEquals(new long[0], ids);
}
private static final class StubJSONObject extends JSONObject {
private final java.util.Map<String, Object> values = new java.util.HashMap<>();
StubJSONObject withString(String key, String value) {
values.put(key, value);
return this;
}
@Override
public String optString(String key) {
Object value = values.get(key);
return value instanceof String ? (String) value : "";
}
@Override
public String optString(String key, String fallback) {
String value = optString(key);
return value.isEmpty() ? fallback : value;
}
}
}

View File

@@ -0,0 +1,90 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import android.content.Intent;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import java.lang.reflect.Method;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class AboutActivityTest {
@Test
public void otaUpdatedEventTriggersReload() throws Exception {
TestAboutActivity activity = Robolectric
.buildActivity(TestAboutActivity.class, new Intent())
.setup()
.resume()
.get();
activity.reloadEnabled = true;
activity.reloadCount = 0;
Method handleRealtimeEvent = findHandleRealtimeEvent();
assertNotNull(handleRealtimeEvent);
handleRealtimeEvent.invoke(
activity,
new BossRealtimeEvent("ota.updated", new JSONObject().put("deviceId", "mac-studio"))
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(1, activity.reloadCount);
}
@Test
public void unrelatedConversationEventDoesNotTriggerReload() throws Exception {
TestAboutActivity activity = Robolectric
.buildActivity(TestAboutActivity.class, new Intent())
.setup()
.resume()
.get();
activity.reloadEnabled = true;
activity.reloadCount = 0;
Method handleRealtimeEvent = findHandleRealtimeEvent();
assertNotNull(handleRealtimeEvent);
handleRealtimeEvent.invoke(
activity,
new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "master-agent"))
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.reloadCount);
}
private static Method findHandleRealtimeEvent() {
for (Method method : AboutActivity.class.getDeclaredMethods()) {
if ("handleRealtimeEvent".equals(method.getName())
&& method.getParameterTypes().length == 1
&& method.getParameterTypes()[0] == BossRealtimeEvent.class) {
method.setAccessible(true);
return method;
}
}
return null;
}
public static class TestAboutActivity extends AboutActivity {
private boolean reloadEnabled;
private int reloadCount;
@Override
protected void reload() {
if (!reloadEnabled) {
return;
}
reloadCount += 1;
setRefreshing(false);
}
}
}

View File

@@ -0,0 +1,532 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Looper;
import android.view.View;
import android.view.ViewGroup;
import android.widget.EditText;
import android.widget.Spinner;
import android.widget.SpinnerAdapter;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.shadows.ShadowActivity;
import org.robolectric.shadows.ShadowDialog;
import org.robolectric.shadows.ShadowToast;
import org.robolectric.util.ReflectionHelpers;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.ProtocolException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.AbstractExecutorService;
import java.util.concurrent.TimeUnit;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class AiAccountsActivityTest {
@Test
public void submitOpenAiOnboarding_reportsExplicitPrimaryControllerSuccessAndRefreshesSummary() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(
new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"),
200,
"{\"ok\":true,\"accountId\":\"acc-1\"}",
"{\"ok\":false,\"message\":\"ONBOARD_FAILED\"}"
),
new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts/acc-1/validate"),
200,
"{\"ok\":true,\"message\":\"校验通过\"}",
"{\"ok\":false,\"message\":\"VALIDATION_FAILED\"}"
)
));
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
int initialReloadCount = activity.reloadCount;
ReflectionHelpers.callInstanceMethod(
activity,
"submitOpenAiOnboarding",
ReflectionHelpers.ClassParameter.from(String.class, "主 GPT"),
ReflectionHelpers.ClassParameter.from(String.class, "OpenAI 平台账号"),
ReflectionHelpers.ClassParameter.from(String.class, "sk-test"),
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
ReflectionHelpers.ClassParameter.from(String.class, "sk-test-key")
);
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
assertEquals("OpenAI 平台账号已登录,并设为当前主控。", ShadowToast.getTextOfLatestToast());
assertEquals(initialReloadCount + 1, activity.reloadCount);
}
@Test
public void submitOpenAiOnboarding_showsClearChineseFailurePrefix() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(
new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"),
403,
"{\"ok\":false,\"message\":\"API Key 无效\"}",
"{\"ok\":false,\"message\":\"API Key 无效\"}"
)
));
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
ReflectionHelpers.callInstanceMethod(
activity,
"submitOpenAiOnboarding",
ReflectionHelpers.ClassParameter.from(String.class, "主 GPT"),
ReflectionHelpers.ClassParameter.from(String.class, "OpenAI 平台账号"),
ReflectionHelpers.ClassParameter.from(String.class, "sk-test"),
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
ReflectionHelpers.ClassParameter.from(String.class, "bad-key")
);
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
assertEquals("OpenAI 平台账号登录失败API Key 无效", ShadowToast.getTextOfLatestToast());
assertEquals(1, activity.reloadCount);
}
@Test
public void activeIdentityCardOffersMainAgentTestEntry() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
JSONObject activeIdentity = new JSONObject()
.put("accountId", "acc-1")
.put("label", "主 GPT")
.put("displayName", "OpenAI 平台账号")
.put("roleLabel", "主 GPT")
.put("providerLabel", "OpenAI API")
.put("statusLabel", "ready")
.put("note", "当前账号可直接生成主 Agent 回复。")
.put("canGenerate", true);
View card = ReflectionHelpers.callInstanceMethod(
activity,
"buildActiveIdentityCard",
ReflectionHelpers.ClassParameter.from(JSONObject.class, activeIdentity)
);
View testButton = findClickableViewContainingText(card, "测试主 Agent 对话");
assertNotNull(testButton);
testButton.performClick();
ShadowActivity shadowActivity = Shadows.shadowOf(activity);
Intent nextIntent = shadowActivity.getNextStartedActivity();
assertNotNull(nextIntent);
assertEquals(ProjectDetailActivity.class.getName(), nextIntent.getComponent().getClassName());
assertEquals("master-agent", nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_ID));
}
@Test
public void openAliyunQwenOnboardingDialogUsesPresetModelsWithCustomFallback() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(activity, "openAliyunQwenOnboardingDialog");
Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog);
View root = dialog.getWindow().getDecorView();
Spinner modelSpinner = findSpinnerContainingItem(root, "qwen3.5-plus");
assertNotNull(modelSpinner);
SpinnerAdapter adapter = modelSpinner.getAdapter();
assertNotNull(adapter);
assertEquals(3, adapter.getCount());
assertEquals("qwen3.5-plus", adapter.getItem(0).toString());
assertEquals("qwen3.5-flash", adapter.getItem(1).toString());
assertEquals("自定义模型", adapter.getItem(2).toString());
assertEquals("qwen3.5-plus", modelSpinner.getSelectedItem().toString());
EditText customModelInput = findEditTextWithHint(root, "自定义模型");
assertNotNull(customModelInput);
}
@Test
public void openAccountEditorShowsCustomFallbackForNonPresetAliyunModel() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
JSONObject existing = new JSONObject()
.put("accountId", "acc-1")
.put("label", "备用 GPT")
.put("displayName", "阿里百炼备用账号")
.put("provider", "aliyun_qwen_api")
.put("model", "qwen-custom-x");
ReflectionHelpers.callInstanceMethod(
activity,
"openAccountEditor",
ReflectionHelpers.ClassParameter.from(JSONObject.class, existing),
ReflectionHelpers.ClassParameter.from(String.class, null)
);
Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog);
View root = dialog.getWindow().getDecorView();
Spinner modelSpinner = findSpinnerContainingItem(root, "自定义模型");
assertNotNull(modelSpinner);
SpinnerAdapter adapter = modelSpinner.getAdapter();
assertNotNull(adapter);
assertEquals(3, adapter.getCount());
assertEquals("自定义模型", modelSpinner.getSelectedItem().toString());
EditText customModelInput = findEditTextWithHint(root, "自定义模型");
assertNotNull(customModelInput);
assertEquals("qwen-custom-x", customModelInput.getText().toString());
}
private static final class TestAiAccountsActivity extends AiAccountsActivity {
private int reloadCount = 0;
@Override
protected void reload() {
reloadCount += 1;
}
}
private static final class DirectExecutorService extends AbstractExecutorService {
private boolean shutdown;
@Override
public void shutdown() {
shutdown = true;
}
@Override
public List<Runnable> shutdownNow() {
shutdown = true;
return Collections.emptyList();
}
@Override
public boolean isShutdown() {
return shutdown;
}
@Override
public boolean isTerminated() {
return shutdown;
}
@Override
public boolean awaitTermination(long timeout, TimeUnit unit) {
return true;
}
@Override
public void execute(Runnable command) {
command.run();
}
}
private static final class ScriptedBossApiClient extends BossApiClient {
private final Map<String, RecordingConnection> connections;
ScriptedBossApiClient(RecordingConnection... connections) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
this.connections = new HashMap<>();
for (RecordingConnection connection : connections) {
this.connections.put(connection.getURL().getPath(), connection);
}
}
@Override
HttpURLConnection openConnection(String path) {
RecordingConnection connection = connections.get(path);
if (connection == null) {
throw new IllegalStateException("Missing scripted connection for " + path);
}
return connection;
}
@Override
String encode(String value) {
return value;
}
@Override
void rememberIdentity(JSONObject json) {
// JVM 单测不需要落 Android 侧身份缓存。
}
}
private static final class RecordingConnection extends HttpURLConnection {
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
private final Map<String, String> requestHeaders = new HashMap<>();
private final int responseCodeValue;
private final String responseBody;
private final String errorBody;
private String requestMethodValue = "GET";
private String contentTypeValue = "";
RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) {
super(url);
this.responseCodeValue = responseCodeValue;
this.responseBody = responseBody;
this.errorBody = errorBody;
}
@Override
public void disconnect() {}
@Override
public boolean usingProxy() {
return false;
}
@Override
public void connect() {}
@Override
public void setRequestMethod(String method) throws ProtocolException {
requestMethodValue = method;
}
@Override
public void setRequestProperty(String key, String value) {
requestHeaders.put(key, value);
if ("Content-Type".equalsIgnoreCase(key)) {
contentTypeValue = value;
}
}
@Override
public OutputStream getOutputStream() {
return requestBody;
}
@Override
public int getResponseCode() {
return responseCodeValue;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8));
}
@Override
public InputStream getErrorStream() {
return new ByteArrayInputStream(errorBody.getBytes(StandardCharsets.UTF_8));
}
@Override
public Map<String, List<String>> getHeaderFields() {
return Collections.emptyMap();
}
}
private static final class InMemorySharedPreferences implements SharedPreferences {
private final Map<String, String> values = new HashMap<>();
@Override
public Map<String, ?> getAll() {
return Collections.unmodifiableMap(values);
}
@Override
public String getString(String key, String defValue) {
return values.getOrDefault(key, defValue);
}
@Override
public Set<String> getStringSet(String key, Set<String> defValues) {
throw new UnsupportedOperationException();
}
@Override
public int getInt(String key, int defValue) {
throw new UnsupportedOperationException();
}
@Override
public long getLong(String key, long defValue) {
throw new UnsupportedOperationException();
}
@Override
public float getFloat(String key, float defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean getBoolean(String key, boolean defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean contains(String key) {
return values.containsKey(key);
}
@Override
public Editor edit() {
return new Editor() {
@Override
public Editor putString(String key, String value) {
values.put(key, value);
return this;
}
@Override
public Editor remove(String key) {
values.remove(key);
return this;
}
@Override
public Editor clear() {
values.clear();
return this;
}
@Override
public Editor putStringSet(String key, Set<String> values) {
throw new UnsupportedOperationException();
}
@Override
public Editor putInt(String key, int value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putLong(String key, long value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putFloat(String key, float value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putBoolean(String key, boolean value) {
throw new UnsupportedOperationException();
}
@Override
public boolean commit() {
return true;
}
@Override
public void apply() {}
};
}
@Override
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
@Override
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
}
private static View findClickableViewContainingText(View root, String expectedText) {
if (root == null) {
return null;
}
if (root.isClickable() && viewTreeContainsText(root, expectedText)) {
return root;
}
if (!(root instanceof ViewGroup)) {
return null;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
View match = findClickableViewContainingText(group.getChildAt(index), expectedText);
if (match != null) {
return match;
}
}
return null;
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (text != null && text.toString().contains(expectedText)) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
private static Spinner findSpinnerContainingItem(View root, String expectedText) {
if (root instanceof Spinner) {
Spinner spinner = (Spinner) root;
SpinnerAdapter adapter = spinner.getAdapter();
if (adapter != null) {
for (int index = 0; index < adapter.getCount(); index += 1) {
Object item = adapter.getItem(index);
if (item != null && item.toString().contains(expectedText)) {
return spinner;
}
}
}
}
if (!(root instanceof ViewGroup)) {
return null;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
Spinner match = findSpinnerContainingItem(group.getChildAt(index), expectedText);
if (match != null) {
return match;
}
}
return null;
}
private static EditText findEditTextWithHint(View root, String expectedText) {
if (root instanceof EditText) {
CharSequence hint = ((EditText) root).getHint();
if (hint != null && hint.toString().contains(expectedText)) {
return (EditText) root;
}
}
if (!(root instanceof ViewGroup)) {
return null;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
EditText match = findEditTextWithHint(group.getChildAt(index), expectedText);
if (match != null) {
return match;
}
}
return null;
}
}

View File

@@ -0,0 +1,53 @@
package com.hyzq.boss;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class AttachmentComposerStateTest {
@Test
public void imageAttachments_requireConfirmationBeforeSending() {
AttachmentComposerState.PendingAttachment attachment =
new AttachmentComposerState.PendingAttachment(
"image",
"现场照片.png",
"image/png",
4096L,
null
);
assertTrue(ProjectChatUiState.requiresAttachmentConfirmation(attachment.sourceType));
assertTrue(attachment.requiresConfirmation());
}
@Test
public void videoAttachments_requireConfirmationBeforeSending() {
AttachmentComposerState.PendingAttachment attachment =
new AttachmentComposerState.PendingAttachment(
"video",
"巡检录屏.mp4",
"video/mp4",
8192L,
null
);
assertTrue(ProjectChatUiState.requiresAttachmentConfirmation(attachment.sourceType));
assertTrue(attachment.requiresConfirmation());
}
@Test
public void fileAttachments_doNotRequireConfirmation() {
AttachmentComposerState.PendingAttachment attachment =
new AttachmentComposerState.PendingAttachment(
"file",
"日报.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
16384L,
null
);
assertFalse(ProjectChatUiState.requiresAttachmentConfirmation(attachment.sourceType));
assertFalse(attachment.requiresConfirmation());
}
}

View File

@@ -0,0 +1,261 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.content.SharedPreferences;
import org.json.JSONObject;
import org.junit.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.ProtocolException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class BossApiClientAttachmentTest {
@Test
public void uploadAttachment_postsMultipartBodyWithSourceType() throws Exception {
RecordingConnection connection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/projects/project-1/attachments")
);
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.uploadAttachment(
"project-1",
"现场照片.png",
"image/png",
new byte[] {1, 2, 3, 4},
"image"
);
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/project-1/attachments", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertTrue(connection.contentTypeValue.startsWith("multipart/form-data; boundary="));
assertTrue(connection.requestBody().contains("name=\"sourceType\""));
assertTrue(connection.requestBody().contains("\r\nimage\r\n"));
assertTrue(connection.requestBody().contains("name=\"file\"; filename=\"现场照片.png\""));
assertTrue(connection.requestBody().contains("Content-Type: image/png"));
}
@Test
public void analyzeAttachment_postsToAnalyzeEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/projects/project-1/attachments/att-1/analyze")
);
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.analyzeAttachment("project-1", "att-1");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/project-1/attachments/att-1/analyze", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals("{}", connection.requestBody());
}
private static final class RecordingBossApiClient extends BossApiClient {
private final RecordingConnection connection;
private String lastPath = "";
RecordingBossApiClient(RecordingConnection connection) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
this.connection = connection;
}
@Override
HttpURLConnection openConnection(String path) {
lastPath = path;
return connection;
}
@Override
String encode(String value) {
return value;
}
@Override
void rememberIdentity(JSONObject json) {
// JVM 单测不需要落 Android 侧身份缓存。
}
}
private static final class RecordingConnection extends HttpURLConnection {
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
private final Map<String, String> requestHeaders = new HashMap<>();
private String requestMethodValue = "GET";
private String contentTypeValue = "";
RecordingConnection(URL url) {
super(url);
}
@Override
public void disconnect() {}
@Override
public boolean usingProxy() {
return false;
}
@Override
public void connect() {}
@Override
public void setRequestMethod(String method) throws ProtocolException {
requestMethodValue = method;
}
@Override
public void setRequestProperty(String key, String value) {
requestHeaders.put(key, value);
if ("Content-Type".equalsIgnoreCase(key)) {
contentTypeValue = value;
}
}
@Override
public String getRequestProperty(String key) {
return requestHeaders.get(key);
}
@Override
public OutputStream getOutputStream() {
return requestBody;
}
@Override
public int getResponseCode() {
return 200;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
}
@Override
public Map<String, List<String>> getHeaderFields() {
return Collections.emptyMap();
}
String requestBody() {
return requestBody.toString(StandardCharsets.UTF_8);
}
}
private static final class InMemorySharedPreferences implements SharedPreferences {
private final Map<String, String> values = new HashMap<>();
@Override
public Map<String, ?> getAll() {
return Collections.unmodifiableMap(values);
}
@Override
public String getString(String key, String defValue) {
return values.getOrDefault(key, defValue);
}
@Override
public Set<String> getStringSet(String key, Set<String> defValues) {
throw new UnsupportedOperationException();
}
@Override
public int getInt(String key, int defValue) {
throw new UnsupportedOperationException();
}
@Override
public long getLong(String key, long defValue) {
throw new UnsupportedOperationException();
}
@Override
public float getFloat(String key, float defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean getBoolean(String key, boolean defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean contains(String key) {
return values.containsKey(key);
}
@Override
public Editor edit() {
return new Editor() {
@Override
public Editor putString(String key, String value) {
values.put(key, value);
return this;
}
@Override
public Editor remove(String key) {
values.remove(key);
return this;
}
@Override
public Editor clear() {
values.clear();
return this;
}
@Override
public void apply() {}
@Override
public boolean commit() {
return true;
}
@Override
public Editor putStringSet(String key, Set<String> values) {
throw new UnsupportedOperationException();
}
@Override
public Editor putInt(String key, int value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putLong(String key, long value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putFloat(String key, float value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putBoolean(String key, boolean value) {
throw new UnsupportedOperationException();
}
};
}
@Override
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
@Override
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
}
}

View File

@@ -0,0 +1,247 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import android.content.SharedPreferences;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.ProtocolException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossApiClientDeviceModeTest {
@Test
public void updateDevicePreferredExecutionModeWritesModeToPatchBody() throws Exception {
RecordingConnection connection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/devices/device-1")
);
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
apiClient.updateDevicePreferredExecutionMode("device-1", "gui");
assertEquals("/api/v1/devices/device-1", apiClient.lastPath);
assertEquals("PATCH", connection.requestMethodValue);
assertEquals("{\"preferredExecutionMode\":\"gui\"}", connection.requestBody());
}
@Test
public void updateProjectConflictDecisionWritesProjectScopedPatchBody() throws Exception {
RecordingConnection connection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/devices/device-1")
);
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
apiClient.updateProjectConflictDecision("device-1", "thread-ui", "mac-studio:boss", "allow_always");
assertEquals("/api/v1/devices/device-1", apiClient.lastPath);
assertEquals("PATCH", connection.requestMethodValue);
assertEquals(
"{\"projectId\":\"thread-ui\",\"folderKey\":\"mac-studio:boss\",\"conflictDecision\":\"allow_always\"}",
connection.requestBody()
);
}
private static final class RecordingBossApiClient extends BossApiClient {
private final RecordingConnection connection;
private String lastPath = "";
RecordingBossApiClient(RecordingConnection connection) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
this.connection = connection;
}
@Override
HttpURLConnection openConnection(String path) {
lastPath = path;
return connection;
}
@Override
String encode(String value) {
return value;
}
@Override
void rememberIdentity(JSONObject json) {
// JVM 单测只关心 patch body。
}
}
private static final class RecordingConnection extends HttpURLConnection {
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
private final Map<String, String> requestHeaders = new HashMap<>();
private String requestMethodValue = "GET";
RecordingConnection(URL url) {
super(url);
}
@Override
public void disconnect() {}
@Override
public boolean usingProxy() {
return false;
}
@Override
public void connect() {}
@Override
public void setRequestMethod(String method) throws ProtocolException {
requestMethodValue = method;
}
@Override
public void setRequestProperty(String key, String value) {
requestHeaders.put(key, value);
}
@Override
public String getRequestProperty(String key) {
return requestHeaders.get(key);
}
@Override
public OutputStream getOutputStream() {
return requestBody;
}
@Override
public int getResponseCode() {
return 200;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
}
String requestBody() {
return requestBody.toString(StandardCharsets.UTF_8);
}
}
private static final class InMemorySharedPreferences implements SharedPreferences {
private final Map<String, String> values = new HashMap<>();
@Override
public Map<String, ?> getAll() {
return Collections.unmodifiableMap(values);
}
@Override
public String getString(String key, String defValue) {
return values.getOrDefault(key, defValue);
}
@Override
public Set<String> getStringSet(String key, Set<String> defValues) {
throw new UnsupportedOperationException();
}
@Override
public int getInt(String key, int defValue) {
throw new UnsupportedOperationException();
}
@Override
public long getLong(String key, long defValue) {
throw new UnsupportedOperationException();
}
@Override
public float getFloat(String key, float defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean getBoolean(String key, boolean defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean contains(String key) {
return values.containsKey(key);
}
@Override
public Editor edit() {
return new Editor() {
@Override
public Editor putString(String key, String value) {
values.put(key, value);
return this;
}
@Override
public Editor remove(String key) {
values.remove(key);
return this;
}
@Override
public Editor clear() {
values.clear();
return this;
}
@Override
public void apply() {}
@Override
public boolean commit() {
return true;
}
@Override
public Editor putStringSet(String key, Set<String> values) {
throw new UnsupportedOperationException();
}
@Override
public Editor putInt(String key, int value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putLong(String key, long value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putFloat(String key, float value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putBoolean(String key, boolean value) {
throw new UnsupportedOperationException();
}
};
}
@Override
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
@Override
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
}
}

View File

@@ -0,0 +1,627 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import android.content.SharedPreferences;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.ProtocolException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossApiClientDispatchPlansTest {
@Test
public void getDispatchPlansUsesProjectScopedEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.getDispatchPlans("p1");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/p1/dispatch-plans", apiClient.lastPath);
assertEquals("GET", connection.requestMethodValue);
}
@Test
public void getConversationsUsesExtendedReadTimeoutForFullThreadList() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/conversations"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.getConversations();
assertEquals(200, response.statusCode);
assertEquals("/api/v1/conversations", apiClient.lastPath);
assertEquals("GET", connection.requestMethodValue);
assertEquals(12000, connection.connectTimeoutValue);
assertEquals(30000, connection.readTimeoutValue);
assertEquals("no-cache, no-store, max-age=0", connection.getRequestProperty("Cache-Control"));
assertEquals("no-cache", connection.getRequestProperty("Pragma"));
}
@Test
public void getConversationHomeUsesExtendedReadTimeoutForSlowHomeFeed() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/conversations/home"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.getConversationHome();
assertEquals(200, response.statusCode);
assertEquals("/api/v1/conversations/home", apiClient.lastPath);
assertEquals("GET", connection.requestMethodValue);
assertEquals(12000, connection.connectTimeoutValue);
assertEquals(30000, connection.readTimeoutValue);
}
@Test
public void getConversationHomeSendsNoCacheHeadersToAvoidStaleMobileFeed() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/conversations/home"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.getConversationHome();
assertEquals(200, response.statusCode);
assertEquals("no-cache, no-store, max-age=0", connection.getRequestProperty("Cache-Control"));
assertEquals("no-cache", connection.getRequestProperty("Pragma"));
}
@Test
public void confirmDispatchPlanWritesApprovedTargetProjectIds() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/confirm"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.confirmDispatchPlan("p1", "plan-1", new JSONArray().put("target-1").put("target-2"));
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/p1/dispatch-plans/plan-1/confirm", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(12000, connection.connectTimeoutValue);
assertEquals(65000, connection.readTimeoutValue);
assertEquals(
"{\"approvedTargetProjectIds\":[\"target-1\",\"target-2\"],\"rememberLightReminder\":false}",
connection.requestBody()
);
}
@Test
public void rejectDispatchPlanUsesProjectScopedRejectEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/reject"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.rejectDispatchPlan("p1", "plan-1");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/p1/dispatch-plans/plan-1/reject", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(12000, connection.connectTimeoutValue);
assertEquals(65000, connection.readTimeoutValue);
assertEquals("{}", connection.requestBody());
}
@Test
public void retryDispatchPlanUsesProjectScopedRetryEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/retry"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.retryDispatchPlan("p1", "plan-1");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/p1/dispatch-plans/plan-1/retry", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(12000, connection.connectTimeoutValue);
assertEquals(65000, connection.readTimeoutValue);
assertEquals("{}", connection.requestBody());
}
@Test
public void getProjectAgentControlsUsesScopedEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/agent-controls"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.getProjectAgentControls("master-agent");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/master-agent/agent-controls", apiClient.lastPath);
assertEquals("GET", connection.requestMethodValue);
}
@Test
public void updateProjectAgentControlsWritesModelAndReasoningOverrides() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/agent-controls"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.updateProjectAgentControls("master-agent", "gpt-5.4", "high");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/master-agent/agent-controls", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals("{\"modelOverride\":\"gpt-5.4\",\"reasoningEffortOverride\":\"high\"}", connection.requestBody());
}
@Test
public void updateProjectAgentControlsWritesPromptOverrideWhenProvided() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/agent-controls"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.updateProjectAgentControls("master-agent", "gpt-5.4", "high", "当前对话提示词");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/master-agent/agent-controls", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(
"{\"modelOverride\":\"gpt-5.4\",\"reasoningEffortOverride\":\"high\",\"promptOverride\":\"当前对话提示词\"}",
connection.requestBody()
);
}
@Test
public void getMasterAgentPromptProfileUsesScopedEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/prompt-profile"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.getMasterAgentPromptProfile("master-agent");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/master-agent/prompt-profile", apiClient.lastPath);
assertEquals("GET", connection.requestMethodValue);
}
@Test
public void updateMasterAgentPromptProfileWritesUserPromptAndOverride() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/prompt-profile"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
JSONObject payload = new JSONObject()
.put("userPromptContent", "用户私有主提示词")
.put("promptOverride", "当前对话提示词");
BossApiClient.ApiResponse response = apiClient.updateMasterAgentPromptProfile("master-agent", payload);
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/master-agent/prompt-profile", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals("{\"userPromptContent\":\"用户私有主提示词\",\"promptOverride\":\"当前对话提示词\"}", connection.requestBody());
}
@Test
public void getMasterAgentMemoriesUsesScopedEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/memories"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.getMasterAgentMemories("master-agent");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/master-agent/memories", apiClient.lastPath);
assertEquals("GET", connection.requestMethodValue);
}
@Test
public void getMasterAgentEvolutionUsesDashboardEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/master-agent/evolution"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.getMasterAgentEvolution();
assertEquals(200, response.statusCode);
assertEquals("/api/v1/master-agent/evolution", apiClient.lastPath);
assertEquals("GET", connection.requestMethodValue);
}
@Test
public void updateMasterAgentEvolutionModeWritesModePayload() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/master-agent/evolution/config"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.updateMasterAgentEvolutionMode("controlled");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/master-agent/evolution/config", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals("{\"mode\":\"controlled\"}", connection.requestBody());
}
@Test
public void approveMasterAgentEvolutionProposalUsesProposalEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/master-agent/evolution/proposals/proposal-1/approve"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.approveMasterAgentEvolutionProposal("proposal-1");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/master-agent/evolution/proposals/proposal-1/approve", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals("{}", connection.requestBody());
}
@Test
public void createMasterAgentMemoryWritesStructuredPayload() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/memories"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
JSONObject payload = new JSONObject()
.put("scope", "project")
.put("projectId", "boss-console")
.put("title", "项目目标")
.put("content", "把会话页收成微信式列表")
.put("memoryType", "project_progress")
.put("tags", new JSONArray().put("ui").put("progress"));
BossApiClient.ApiResponse response = apiClient.createMasterAgentMemory("master-agent", payload);
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/master-agent/memories", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(
"{\"scope\":\"project\",\"projectId\":\"boss-console\",\"title\":\"项目目标\",\"content\":\"把会话页收成微信式列表\",\"memoryType\":\"project_progress\",\"tags\":[\"ui\",\"progress\"]}",
connection.requestBody()
);
}
@Test
public void sendProjectMessageUsesQueueFriendlyReadTimeoutForMasterAgent() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/messages"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.sendProjectMessage("master-agent", "你好", "text");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/master-agent/messages", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(12000, connection.connectTimeoutValue);
assertEquals(20000, connection.readTimeoutValue);
}
@Test
public void sendProjectMessageUsesQueueFriendlyReadTimeoutForNormalThread() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/thread-1/messages"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.sendProjectMessage("thread-1", "你好", "text");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/thread-1/messages", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(12000, connection.connectTimeoutValue);
assertEquals(20000, connection.readTimeoutValue);
}
@Test
public void onboardOpenAiApiAccountUsesDedicatedRouteAndSetsActive() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
JSONObject payload = new JSONObject()
.put("label", "主 GPT")
.put("displayName", "OpenAI 平台账号")
.put("accountIdentifier", "sk-test")
.put("model", "gpt-5.4")
.put("apiKey", "sk-test-key");
BossApiClient.ApiResponse response = apiClient.onboardOpenAiApiAccount(payload);
assertEquals(200, response.statusCode);
assertEquals("/api/v1/accounts/onboard/openai-api", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(
"{\"label\":\"主 GPT\",\"displayName\":\"OpenAI 平台账号\",\"accountIdentifier\":\"sk-test\",\"model\":\"gpt-5.4\",\"apiKey\":\"sk-test-key\",\"setActive\":true}",
connection.requestBody()
);
}
@Test
public void rememberIdentityDoesNotOverwriteSessionIdentityFromAiAccountOnboardingResponse() throws Exception {
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
prefs.edit()
.putString("account", "17600003315")
.putString("display_name", "Boss 超级管理员")
.apply();
BossApiClient apiClient = new BossApiClient(prefs, "https://boss.hyzq.net");
JSONObject onboardingResponse = new JSONObject()
.put("ok", true)
.put("accountId", "openai-api-primary")
.put("displayName", "OpenAI 平台账号")
.put("message", "OpenAI 平台账号已登录,并设为当前主控。");
apiClient.rememberIdentity(onboardingResponse);
assertEquals("17600003315", apiClient.getAccountLabel());
assertEquals("Boss 超级管理员", apiClient.getDisplayName());
}
@Test
public void onboardMasterNodeFallsBackToGenericAccountCreationWhenDedicatedRouteMissing() throws Exception {
RecordingConnection dedicated = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts/onboard/master-node"),
404,
"{\"ok\":false,\"message\":\"NOT_FOUND\"}",
"{\"ok\":false,\"message\":\"NOT_FOUND\"}"
);
RecordingConnection fallback = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/accounts"));
ScriptedBossApiClient apiClient = new ScriptedBossApiClient(dedicated, fallback);
JSONObject payload = new JSONObject()
.put("label", "主 GPT")
.put("displayName", "Mac Studio")
.put("accountIdentifier", "mac-studio")
.put("nodeId", "mac-studio")
.put("nodeLabel", "Mac Studio")
.put("model", "gpt-5.4");
BossApiClient.ApiResponse response = apiClient.onboardMasterNodeAccount(payload);
assertEquals(200, response.statusCode);
assertEquals("/api/v1/accounts", apiClient.lastPath);
assertEquals("POST", fallback.requestMethodValue);
assertEquals(
"{\"label\":\"主 GPT\",\"displayName\":\"Mac Studio\",\"accountIdentifier\":\"mac-studio\",\"nodeId\":\"mac-studio\",\"nodeLabel\":\"Mac Studio\",\"model\":\"gpt-5.4\",\"setActive\":true}",
fallback.requestBody()
);
}
private static final class RecordingBossApiClient extends BossApiClient {
private final RecordingConnection connection;
private String lastPath = "";
RecordingBossApiClient(RecordingConnection connection) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
this.connection = connection;
}
@Override
HttpURLConnection openConnection(String path) {
lastPath = path;
return connection;
}
@Override
String encode(String value) {
return value;
}
@Override
void rememberIdentity(JSONObject json) {
// no-op for JVM unit test
}
}
private static final class ScriptedBossApiClient extends BossApiClient {
private final Map<String, RecordingConnection> connections;
private String lastPath = "";
ScriptedBossApiClient(RecordingConnection... connections) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
this.connections = new HashMap<>();
for (RecordingConnection connection : connections) {
this.connections.put(connection.url().getPath(), connection);
}
}
@Override
HttpURLConnection openConnection(String path) {
lastPath = path;
RecordingConnection connection = connections.get(path);
if (connection == null) {
throw new IllegalStateException("Missing scripted connection for " + path);
}
return connection;
}
@Override
String encode(String value) {
return value;
}
@Override
void rememberIdentity(JSONObject json) {
// no-op for JVM unit test
}
}
private static final class RecordingConnection extends HttpURLConnection {
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
private final Map<String, String> requestHeaders = new HashMap<>();
private String requestMethodValue = "GET";
private int connectTimeoutValue = 0;
private int readTimeoutValue = 0;
private final int responseCodeValue;
private final String responseBody;
private final String errorBody;
RecordingConnection(URL url) {
this(url, 200, "{\"ok\":true}", "{\"ok\":false}");
}
RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) {
super(url);
this.responseCodeValue = responseCodeValue;
this.responseBody = responseBody;
this.errorBody = errorBody;
}
@Override
public void disconnect() {}
@Override
public boolean usingProxy() {
return false;
}
@Override
public void connect() {}
@Override
public void setRequestMethod(String method) throws ProtocolException {
requestMethodValue = method;
}
@Override
public void setRequestProperty(String key, String value) {
requestHeaders.put(key, value);
}
@Override
public void setConnectTimeout(int timeout) {
connectTimeoutValue = timeout;
}
@Override
public void setReadTimeout(int timeout) {
readTimeoutValue = timeout;
}
@Override
public String getRequestProperty(String key) {
return requestHeaders.get(key);
}
@Override
public OutputStream getOutputStream() {
return requestBody;
}
@Override
public int getResponseCode() {
return responseCodeValue;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8));
}
@Override
public InputStream getErrorStream() {
if (responseCodeValue < 400) {
return null;
}
return new ByteArrayInputStream(errorBody.getBytes(StandardCharsets.UTF_8));
}
String requestBody() {
return requestBody.toString(StandardCharsets.UTF_8);
}
URL url() {
return getURL();
}
}
private static final class InMemorySharedPreferences implements SharedPreferences {
private final Map<String, String> values = new HashMap<>();
@Override
public Map<String, ?> getAll() {
return Collections.unmodifiableMap(values);
}
@Override
public String getString(String key, String defValue) {
return values.getOrDefault(key, defValue);
}
@Override
public Set<String> getStringSet(String key, Set<String> defValues) {
throw new UnsupportedOperationException();
}
@Override
public int getInt(String key, int defValue) {
throw new UnsupportedOperationException();
}
@Override
public long getLong(String key, long defValue) {
throw new UnsupportedOperationException();
}
@Override
public float getFloat(String key, float defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean getBoolean(String key, boolean defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean contains(String key) {
return values.containsKey(key);
}
@Override
public Editor edit() {
return new Editor() {
@Override
public Editor putString(String key, String value) {
values.put(key, value);
return this;
}
@Override
public Editor remove(String key) {
values.remove(key);
return this;
}
@Override
public Editor clear() {
values.clear();
return this;
}
@Override
public void apply() {}
@Override
public boolean commit() {
return true;
}
@Override
public Editor putStringSet(String key, Set<String> values) {
throw new UnsupportedOperationException();
}
@Override
public Editor putInt(String key, int value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putLong(String key, long value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putFloat(String key, float value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putBoolean(String key, boolean value) {
throw new UnsupportedOperationException();
}
};
}
@Override
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
@Override
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
}
}

View File

@@ -0,0 +1,227 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import android.content.SharedPreferences;
import org.json.JSONObject;
import org.junit.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.ProtocolException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
public class BossApiClientForwardingTest {
@Test
public void forwardProjectMessageWritesStructuredJsonBody() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/source/forwards"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
JSONObject payload = ForwardPayloads.build("single", "m1", java.util.List.of());
BossApiClient.ApiResponse response = apiClient.forwardProjectMessage("source", "target", payload);
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/source/forwards", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(
"{\"targetProjectId\":\"target\",\"mode\":\"single\",\"sourceMessageId\":\"m1\"}",
connection.requestBody()
);
}
private static final class RecordingBossApiClient extends BossApiClient {
private final RecordingConnection connection;
private String lastPath = "";
RecordingBossApiClient(RecordingConnection connection) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
this.connection = connection;
}
@Override
HttpURLConnection openConnection(String path) {
lastPath = path;
return connection;
}
@Override
String encode(String value) {
return value;
}
@Override
void rememberIdentity(JSONObject json) {
// JVM 单测只关心 request body不需要走 Android org.json 的身份恢复副作用。
}
}
private static final class RecordingConnection extends HttpURLConnection {
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
private final Map<String, String> requestHeaders = new HashMap<>();
private String requestMethodValue = "GET";
RecordingConnection(URL url) {
super(url);
}
@Override
public void disconnect() {}
@Override
public boolean usingProxy() {
return false;
}
@Override
public void connect() {}
@Override
public void setRequestMethod(String method) throws ProtocolException {
requestMethodValue = method;
}
@Override
public void setRequestProperty(String key, String value) {
requestHeaders.put(key, value);
}
@Override
public String getRequestProperty(String key) {
return requestHeaders.get(key);
}
@Override
public OutputStream getOutputStream() {
return requestBody;
}
@Override
public int getResponseCode() {
return 200;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
}
String requestBody() {
return requestBody.toString(StandardCharsets.UTF_8);
}
}
private static final class InMemorySharedPreferences implements SharedPreferences {
private final Map<String, String> values = new HashMap<>();
@Override
public Map<String, ?> getAll() {
return Collections.unmodifiableMap(values);
}
@Override
public String getString(String key, String defValue) {
return values.getOrDefault(key, defValue);
}
@Override
public Set<String> getStringSet(String key, Set<String> defValues) {
throw new UnsupportedOperationException();
}
@Override
public int getInt(String key, int defValue) {
throw new UnsupportedOperationException();
}
@Override
public long getLong(String key, long defValue) {
throw new UnsupportedOperationException();
}
@Override
public float getFloat(String key, float defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean getBoolean(String key, boolean defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean contains(String key) {
return values.containsKey(key);
}
@Override
public Editor edit() {
return new Editor() {
@Override
public Editor putString(String key, String value) {
values.put(key, value);
return this;
}
@Override
public Editor remove(String key) {
values.remove(key);
return this;
}
@Override
public Editor clear() {
values.clear();
return this;
}
@Override
public void apply() {}
@Override
public boolean commit() {
return true;
}
@Override
public Editor putStringSet(String key, Set<String> values) {
throw new UnsupportedOperationException();
}
@Override
public Editor putInt(String key, int value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putLong(String key, long value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putFloat(String key, float value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putBoolean(String key, boolean value) {
throw new UnsupportedOperationException();
}
};
}
@Override
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
@Override
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
}
}

View File

@@ -0,0 +1,244 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import android.content.SharedPreferences;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.ProtocolException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossApiClientOrchestrationBackendTest {
@Test
public void getProjectOrchestrationBackendUsesScopedEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/projects/audit-collab/orchestration-backend")
);
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.getProjectOrchestrationBackend("audit-collab");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/audit-collab/orchestration-backend", apiClient.lastPath);
assertEquals("GET", connection.requestMethodValue);
}
@Test
public void updateProjectOrchestrationBackendWritesRequestedBackendId() throws Exception {
RecordingConnection connection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/projects/audit-collab/orchestration-backend")
);
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.updateProjectOrchestrationBackend("audit-collab", "omx-team");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/audit-collab/orchestration-backend", apiClient.lastPath);
assertEquals("PATCH", connection.requestMethodValue);
assertEquals("{\"requestedBackendId\":\"omx-team\"}", connection.requestBody());
}
private static final class RecordingBossApiClient extends BossApiClient {
private final RecordingConnection connection;
private String lastPath = "";
RecordingBossApiClient(RecordingConnection connection) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
this.connection = connection;
}
@Override
HttpURLConnection openConnection(String path) {
lastPath = path;
return connection;
}
@Override
String encode(String value) {
return value;
}
@Override
void rememberIdentity(JSONObject json) {
// No-op for JVM tests.
}
}
private static final class RecordingConnection extends HttpURLConnection {
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
private final Map<String, String> requestHeaders = new HashMap<>();
private String requestMethodValue = "GET";
RecordingConnection(URL url) {
super(url);
}
@Override
public void disconnect() {}
@Override
public boolean usingProxy() {
return false;
}
@Override
public void connect() {}
@Override
public void setRequestMethod(String method) throws ProtocolException {
requestMethodValue = method;
}
@Override
public void setRequestProperty(String key, String value) {
requestHeaders.put(key, value);
}
@Override
public String getRequestProperty(String key) {
return requestHeaders.get(key);
}
@Override
public OutputStream getOutputStream() {
return requestBody;
}
@Override
public int getResponseCode() {
return 200;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
}
String requestBody() {
return requestBody.toString(StandardCharsets.UTF_8);
}
}
private static final class InMemorySharedPreferences implements SharedPreferences {
private final Map<String, String> values = new HashMap<>();
@Override
public Map<String, ?> getAll() {
return Collections.unmodifiableMap(values);
}
@Override
public String getString(String key, String defValue) {
return values.getOrDefault(key, defValue);
}
@Override
public Set<String> getStringSet(String key, Set<String> defValues) {
throw new UnsupportedOperationException();
}
@Override
public int getInt(String key, int defValue) {
throw new UnsupportedOperationException();
}
@Override
public long getLong(String key, long defValue) {
throw new UnsupportedOperationException();
}
@Override
public float getFloat(String key, float defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean getBoolean(String key, boolean defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean contains(String key) {
return values.containsKey(key);
}
@Override
public Editor edit() {
return new Editor() {
@Override
public Editor putString(String key, String value) {
values.put(key, value);
return this;
}
@Override
public Editor remove(String key) {
values.remove(key);
return this;
}
@Override
public Editor clear() {
values.clear();
return this;
}
@Override
public void apply() {}
@Override
public boolean commit() {
return true;
}
@Override
public Editor putStringSet(String key, Set<String> values) {
throw new UnsupportedOperationException();
}
@Override
public Editor putInt(String key, int value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putLong(String key, long value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putFloat(String key, float value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putBoolean(String key, boolean value) {
throw new UnsupportedOperationException();
}
};
}
@Override
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
@Override
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
}
}

View File

@@ -0,0 +1,77 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossApiClientTest {
@Test
public void buildProjectAgentControlsPayload_omitsAdvancedOverridesWhenAllAreNull() throws Exception {
JSONObject payload = BossApiClient.buildProjectAgentControlsPayload(
"gpt-5.4-mini",
null,
null,
null,
null,
null,
false
);
assertEquals("gpt-5.4-mini", payload.getString("modelOverride"));
assertTrue(payload.has("reasoningEffortOverride"));
assertFalse(payload.has("fastModelOverride"));
assertFalse(payload.has("fastReasoningEffortOverride"));
assertFalse(payload.has("smartModelOverride"));
assertFalse(payload.has("smartReasoningEffortOverride"));
}
@Test
public void buildProjectAgentControlsPayload_includesAdvancedOverridesWhenPresent() throws Exception {
JSONObject payload = BossApiClient.buildProjectAgentControlsPayload(
null,
null,
"gpt-5.4-mini",
null,
"gpt-5.4",
"high",
false
);
assertTrue(payload.has("fastModelOverride"));
assertEquals("gpt-5.4-mini", payload.getString("fastModelOverride"));
assertTrue(payload.has("smartModelOverride"));
assertEquals("gpt-5.4", payload.getString("smartModelOverride"));
assertTrue(payload.has("smartReasoningEffortOverride"));
assertEquals("high", payload.getString("smartReasoningEffortOverride"));
}
@Test
public void buildProjectAgentControlsPayload_includesAdvancedNullsWhenExplicitlyRequested() throws Exception {
JSONObject payload = BossApiClient.buildProjectAgentControlsPayload(
"gpt-5.4-mini",
null,
null,
null,
null,
null,
true
);
assertTrue(payload.has("fastModelOverride"));
assertTrue(payload.isNull("fastModelOverride"));
assertTrue(payload.has("fastReasoningEffortOverride"));
assertTrue(payload.isNull("fastReasoningEffortOverride"));
assertTrue(payload.has("smartModelOverride"));
assertTrue(payload.isNull("smartModelOverride"));
assertTrue(payload.has("smartReasoningEffortOverride"));
assertTrue(payload.isNull("smartReasoningEffortOverride"));
}
}

View File

@@ -0,0 +1,63 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.text.Spanned;
import android.text.style.BackgroundColorSpan;
import android.text.style.BulletSpan;
import android.text.style.QuoteSpan;
import android.text.style.StyleSpan;
import android.text.style.TypefaceSpan;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossMarkdownTest {
@Test
public void render_formatsCommonMarkdownPatternsForChatReading() {
Context context = RuntimeEnvironment.getApplication();
CharSequence rendered = BossMarkdown.render(
context,
"# 标题\n\n" +
"- 第一项\n" +
"1. 第二项\n\n" +
"> 引用内容\n\n" +
"普通段落里有 **重点** 和 `代码`\n\n" +
"```js\nconst ok = true;\n```",
false
);
assertTrue(rendered instanceof Spanned);
Spanned spanned = (Spanned) rendered;
assertTrue(spanned.toString().contains("标题"));
assertTrue(spanned.toString().contains("• 第一项"));
assertTrue(spanned.toString().contains("1. 第二项"));
assertTrue(spanned.toString().contains("引用内容"));
assertTrue(spanned.toString().contains("重点"));
assertTrue(spanned.toString().contains("代码"));
assertTrue(spanned.toString().contains("const ok = true;"));
assertTrue(spanned.getSpans(0, spanned.length(), StyleSpan.class).length > 0);
assertTrue(spanned.getSpans(0, spanned.length(), BulletSpan.class).length > 0);
assertTrue(spanned.getSpans(0, spanned.length(), QuoteSpan.class).length > 0);
assertTrue(spanned.getSpans(0, spanned.length(), TypefaceSpan.class).length > 0);
assertTrue(spanned.getSpans(0, spanned.length(), BackgroundColorSpan.class).length > 0);
}
@Test
public void render_returnsReadablePlaceholderForEmptyBody() {
Context context = RuntimeEnvironment.getApplication();
CharSequence rendered = BossMarkdown.render(context, "", false);
assertEquals("(空消息)", rendered.toString());
}
}

View File

@@ -0,0 +1,40 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossRealtimeClientTest {
@Test
public void parseEventBlockExtractsEventNameAndJsonPayload() {
BossRealtimeEvent event = BossRealtimeClient.parseEventBlock(
"event: project.messages.updated\n" +
"data: {\"projectId\":\"project-1\",\"status\":\"completed\"}\n\n"
);
assertEquals("project.messages.updated", event.eventName);
assertEquals("project-1", event.payload.optString("projectId"));
assertEquals("completed", event.payload.optString("status"));
}
@Test
public void parseEventBlockReturnsNullForKeepaliveComment() {
assertNull(BossRealtimeClient.parseEventBlock(": keepalive\n\n"));
}
@Test
public void parseEventBlockIgnoresHeartbeatControlEvents() {
assertNull(BossRealtimeClient.parseEventBlock("event: heartbeat\n\n"));
}
@Test
public void parseEventBlockReturnsNullForEmptyEventPayloads() {
assertNull(BossRealtimeClient.parseEventBlock("event: conversation.updated\n\n"));
}
}

View File

@@ -0,0 +1,199 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.graphics.drawable.ColorDrawable;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.RuntimeEnvironment;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossUiConversationRowTest {
@Test
public void buildConversationRow_usesWechatListSpacingInsteadOfCardChrome() {
Context context = RuntimeEnvironment.getApplication();
WechatSurfaceMapper.ConversationRow row = new WechatSurfaceMapper.ConversationRow(
"北区试产线回归",
"归档确认",
"现场摄像头关键帧",
"09:26",
2,
"置顶",
2,
false,
"M",
"W",
new WechatSurfaceMapper.GroupAvatarMember[0]
);
LinearLayout rowView = BossUi.buildConversationRow(context, row, null);
int width = View.MeasureSpec.makeMeasureSpec(BossUi.dp(context, 320), View.MeasureSpec.EXACTLY);
int height = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
rowView.measure(width, height);
rowView.layout(0, 0, rowView.getMeasuredWidth(), rowView.getMeasuredHeight());
LinearLayout centerColumn = (LinearLayout) rowView.getChildAt(1);
LinearLayout trailingColumn = (LinearLayout) rowView.getChildAt(2);
TextView titleView = (TextView) centerColumn.getChildAt(0);
TextView previewView = (TextView) centerColumn.getChildAt(centerColumn.getChildCount() - 1);
String metrics = String.format(
"row=%d center=%d trailing=%d title=%d preview=%d",
rowView.getMeasuredWidth(),
centerColumn.getMeasuredWidth(),
trailingColumn.getMeasuredWidth(),
titleView.getMeasuredWidth(),
previewView.getMeasuredWidth()
);
assertEquals("列表项应使用微信式扁平 padding: " + metrics, BossUi.dp(context, 16), rowView.getPaddingLeft());
assertEquals("列表项应使用微信式扁平 padding: " + metrics, BossUi.dp(context, 12), rowView.getPaddingTop());
assertEquals("列表项应使用微信式扁平 padding: " + metrics, BossUi.dp(context, 16), rowView.getPaddingRight());
assertEquals("列表项应使用微信式扁平 padding: " + metrics, BossUi.dp(context, 12), rowView.getPaddingBottom());
assertEquals("列表项不应保持卡片式浮层感: " + metrics, 0f, rowView.getElevation(), 0.01f);
assertTrue("中间文字列不应被挤成 0 宽: " + metrics, centerColumn.getMeasuredWidth() > 0);
assertTrue("标题需要保留可见宽度: " + metrics, titleView.getMeasuredWidth() > 0);
assertTrue("预览需要保留可见宽度: " + metrics, previewView.getMeasuredWidth() > 0);
assertTrue("右侧信息列不应吞掉中间内容: " + metrics, trailingColumn.getMeasuredWidth() < rowView.getMeasuredWidth() / 2);
}
@Test
public void buildConversationRow_showsContextStatusWithoutIdleActivityDots() {
Context context = RuntimeEnvironment.getApplication();
WechatSurfaceMapper.ConversationRow row = new WechatSurfaceMapper.ConversationRow(
"北区试产线回归",
"归档确认",
"线程链路已稳定",
"09:26",
0,
"",
0,
false,
"M",
"W",
new WechatSurfaceMapper.GroupAvatarMember[0],
false,
"上下文紧张 34%",
"urgent",
66,
true,
false
);
LinearLayout rowView = BossUi.buildConversationRow(context, row, null);
LinearLayout trailingColumn = (LinearLayout) rowView.getChildAt(2);
assertEquals("空闲会话不应再渲染活动点", 2, trailingColumn.getChildCount());
assertFalse("右下角应改成环形上下文状态,而不是文字", viewTreeContainsText(trailingColumn, "上下文紧张 34%"));
FrameLayout ringWrap = (FrameLayout) trailingColumn.getChildAt(1);
assertEquals("上下文环外框应更克制,接近微信右侧小图标尺寸", BossUi.dp(context, 24), ringWrap.getLayoutParams().width);
assertEquals("上下文环外框应更克制,接近微信右侧小图标尺寸", BossUi.dp(context, 24), ringWrap.getLayoutParams().height);
assertEquals("上下文环本体应更细更轻", BossUi.dp(context, 16), ringWrap.getChildAt(0).getLayoutParams().width);
assertEquals("上下文环本体应更细更轻", BossUi.dp(context, 16), ringWrap.getChildAt(0).getLayoutParams().height);
}
@Test
public void buildConversationRow_usesStaticActivityDotsForHighRefreshSmoothness() {
Context context = RuntimeEnvironment.getApplication();
WechatSurfaceMapper.ConversationRow row = new WechatSurfaceMapper.ConversationRow(
"硬件审计协作",
"Mac Studio",
"检查摄像头供电链路",
"09:42",
0,
"",
2,
false,
"M",
"W",
new WechatSurfaceMapper.GroupAvatarMember[0],
false,
null,
null,
-1,
false,
false
);
LinearLayout rowView = BossUi.buildConversationRow(context, row, null);
LinearLayout trailingColumn = (LinearLayout) rowView.getChildAt(2);
LinearLayout activityWrap = (LinearLayout) trailingColumn.getChildAt(1);
View firstDot = activityWrap.getChildAt(0);
assertEquals("高刷设备上活动点应保持静态,不再从低 alpha 开始做无限动画", 0.92f, firstDot.getAlpha(), 0.01f);
assertEquals("高刷设备上活动点不应缩放动画起步", 1f, firstDot.getScaleX(), 0.01f);
assertEquals("高刷设备上活动点不应缩放动画起步", 1f, firstDot.getScaleY(), 0.01f);
}
@Test
public void buildConversationRow_usesSubtlePinnedBackgroundWithoutPinnedBadge() {
Context context = RuntimeEnvironment.getApplication();
WechatSurfaceMapper.ConversationRow row = new WechatSurfaceMapper.ConversationRow(
"主 Agent",
"主控线程",
"正在观察多个任务",
"09:26",
0,
"置顶",
0,
false,
"M",
"A",
new WechatSurfaceMapper.GroupAvatarMember[0],
true,
null,
null,
-1,
false,
false
);
LinearLayout rowView = BossUi.buildConversationRow(context, row, null);
assertTrue("置顶会话背景应略深于普通白底", rowView.getBackground() instanceof ColorDrawable);
assertEquals(0xFFF7F7F7, ((ColorDrawable) rowView.getBackground()).getColor());
assertFalse("置顶会话不应再显示右侧“置顶”文字", viewTreeContainsText(rowView, "置顶"));
}
@Test
public void buildConversationSectionHeader_usesTighterWechatSpacing() {
Context context = RuntimeEnvironment.getApplication();
LinearLayout header = BossUi.buildConversationSectionHeader(context, "置顶会话", "收起", null);
LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) header.getLayoutParams();
assertEquals("分组标题和列表之间应再收紧一点", BossUi.dp(context, 6), params.bottomMargin);
assertEquals("分组头顶部留白应更轻", BossUi.dp(context, 4), header.getPaddingTop());
assertEquals("分组头底部留白应更轻", BossUi.dp(context, 2), header.getPaddingBottom());
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (expectedText.contentEquals(text)) {
return true;
}
}
if (!(root instanceof LinearLayout)) {
return false;
}
LinearLayout group = (LinearLayout) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,41 @@
package com.hyzq.boss;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.text.Spanned;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossUiMessageBubbleTest {
@Test
public void buildMessageBubble_rendersMarkdownInsteadOfRawPlainText() {
Context context = RuntimeEnvironment.getApplication();
LinearLayout wrapper = BossUi.buildMessageBubble(
context,
"主 Agent",
"# 标题\n- 条目\n\n带 **重点** 和 `代码`",
"10:26",
false,
null
);
LinearLayout bubble = (LinearLayout) wrapper.getChildAt(1);
TextView bodyView = (TextView) bubble.getChildAt(0);
assertTrue(bodyView.getText() instanceof Spanned);
assertTrue(bodyView.getText().toString().contains("标题"));
assertTrue(bodyView.getText().toString().contains("• 条目"));
assertTrue(bodyView.getText().toString().contains("重点"));
assertTrue(bodyView.getText().toString().contains("代码"));
}
}

View File

@@ -0,0 +1,87 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossUiRootSurfaceTest {
@Test
public void renderMeRoot_usesWechatProfileHeaderAndFlatMenuRows() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
activity,
"setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "me"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
ReflectionHelpers.setField(
activity,
"sessionData",
new JSONObject()
.put("displayName", "Kris")
.put("account", "17600003315")
.put("role", "highest_admin")
);
ReflectionHelpers.callInstanceMethod(activity, "renderMeRoot");
LinearLayout content = ReflectionHelpers.getField(activity, "screenContent");
assertEquals("我的页应是资料头 + 10 条菜单", 11, content.getChildCount());
View header = content.getChildAt(0);
assertEquals("资料头不应保留浮层卡片感", 0f, header.getElevation(), 0.01f);
assertTrue(viewTreeContainsText(header, "Kris"));
assertTrue(viewTreeContainsText(header, "17600003315"));
assertTrue(viewTreeContainsText(header, "最高管理员"));
assertTrue(viewTreeContainsText(header, "主控账号已启用安全保护"));
assertTrue(viewTreeContainsText(content, "主 Agent 提示词"));
assertTrue(viewTreeContainsText(content, "主 Agent 记忆"));
assertTrue(viewTreeContainsText(content, "全局接管"));
assertTrue(viewTreeContainsText(content, "主 Agent 自动进化"));
assertTrue(viewTreeContainsText(content, "账号与安全"));
assertTrue(viewTreeContainsText(content, "设置"));
assertTrue(viewTreeContainsText(content, "运维与修复"));
assertTrue(viewTreeContainsText(content, "AI 账号"));
assertTrue(viewTreeContainsText(content, "技能"));
assertTrue(viewTreeContainsText(content, "关于"));
for (int i = 1; i < content.getChildCount(); i += 1) {
View row = content.getChildAt(i);
assertTrue("我的页菜单应整行可点", row.isClickable());
}
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (text != null && text.toString().contains(expectedText)) {
return true;
}
}
if (!(root instanceof LinearLayout)) {
return false;
}
LinearLayout group = (LinearLayout) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,29 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import android.content.Context;
import android.widget.ImageButton;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.RuntimeEnvironment;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossUiTopActionStyleTest {
@Test
public void applyCompactTopActionStyle_usesWechatLikeIconSizing() {
Context context = RuntimeEnvironment.getApplication();
ImageButton button = new ImageButton(context);
BossUi.applyTopIconButtonStyle(context, button);
assertEquals(BossUi.dp(context, 40), button.getMinimumWidth());
assertEquals(BossUi.dp(context, 40), button.getMinimumHeight());
assertEquals(BossUi.dp(context, 8), button.getPaddingLeft());
assertEquals(BossUi.dp(context, 8), button.getPaddingTop());
}
}

View File

@@ -0,0 +1,59 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import android.view.View;
import androidx.core.graphics.Insets;
import androidx.core.view.WindowInsetsCompat;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.RuntimeEnvironment;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossWindowInsetsTest {
@Test
public void applyStatusBarInset_addsInsetOnTopOfInitialPadding() {
View view = new View(RuntimeEnvironment.getApplication());
view.setPadding(12, 16, 18, 20);
BossWindowInsets.applyStatusBarInset(view);
WindowInsetsCompat insets = new WindowInsetsCompat.Builder()
.setInsets(WindowInsetsCompat.Type.statusBars(), Insets.of(0, 30, 0, 0))
.build();
WindowInsetsCompat applied = androidx.core.view.ViewCompat.dispatchApplyWindowInsets(view, insets);
assertEquals(12, view.getPaddingLeft());
assertEquals(46, view.getPaddingTop());
assertEquals(18, view.getPaddingRight());
assertEquals(20, view.getPaddingBottom());
assertEquals(insets, applied);
}
@Test
public void applyKeyboardAvoidingInset_addsImeInsetToBottomPadding() {
View view = new View(RuntimeEnvironment.getApplication());
view.setPadding(10, 12, 14, 16);
BossWindowInsets.applyKeyboardAvoidingInset(view);
WindowInsetsCompat insets = new WindowInsetsCompat.Builder()
.setInsets(WindowInsetsCompat.Type.navigationBars(), Insets.of(0, 0, 0, 24))
.setInsets(WindowInsetsCompat.Type.ime(), Insets.of(0, 0, 0, 180))
.build();
WindowInsetsCompat applied = androidx.core.view.ViewCompat.dispatchApplyWindowInsets(view, insets);
assertEquals(10, view.getPaddingLeft());
assertEquals(12, view.getPaddingTop());
assertEquals(14, view.getPaddingRight());
assertEquals(196, view.getPaddingBottom());
assertEquals(insets, applied);
}
}

View File

@@ -0,0 +1,265 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.content.Intent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowDialog;
import org.robolectric.util.ReflectionHelpers;
import java.util.concurrent.TimeUnit;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class ConversationFolderActivityTest {
@Test
public void conversationFolderUsesOverflowMenuInTopBar() throws Exception {
Intent intent = new Intent()
.putExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY, "talking")
.putExtra(ConversationFolderActivity.EXTRA_FOLDER_NAME, "Talking");
TestConversationFolderActivity activity = Robolectric
.buildActivity(TestConversationFolderActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderFolder",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildFolderPayload())
);
ImageButton headerAction = activity.findViewById(R.id.screen_header_action);
ImageButton refreshButton = activity.findViewById(R.id.screen_refresh_button);
LinearLayout content = activity.findViewById(R.id.screen_content);
TextView titleView = activity.findViewById(R.id.screen_title);
TextView subtitleView = activity.findViewById(R.id.screen_subtitle);
assertEquals("更多", String.valueOf(headerAction.getContentDescription()));
assertEquals(View.GONE, refreshButton.getVisibility());
assertEquals("Talking", String.valueOf(titleView.getText()));
assertEquals("3 个线程", String.valueOf(subtitleView.getText()));
assertTrue(viewTreeContainsText(content, "Talking"));
assertTrue(viewTreeContainsText(content, "项目内部线程页"));
ReflectionHelpers.callInstanceMethod(activity, "showMoreMenu");
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
assertTrue(latestDialog instanceof AlertDialog);
ListView listView = ((AlertDialog) latestDialog).getListView();
assertTrue(viewTreeContainsText(listView.getAdapter().getView(0, null, listView), "刷新"));
}
@Test
public void conversationFolderHighlightsTargetThreadWhenSearchTargetIsProvided() throws Exception {
Intent intent = new Intent()
.putExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY, "talking")
.putExtra(ConversationFolderActivity.EXTRA_FOLDER_NAME, "Talking")
.putExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_ID, "project-1")
.putExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_IDS, new String[]{"project-1", "project-2"})
.putExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_LABEL, "发布回滚");
TestConversationFolderActivity activity = Robolectric
.buildActivity(TestConversationFolderActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderFolder",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildFolderPayload())
);
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "已定位到目标线程"));
assertTrue(viewTreeContainsText(content, "目标线程"));
assertTrue(viewTreeContainsText(content, "发布回滚"));
assertEquals(2, countTextOccurrences(content, "目标线程"));
assertTrue(countTextOccurrences(content, "发布回滚") >= 3);
assertEquals(0, countTextOccurrences(content, "project-1"));
}
@Test
public void conversationFolderFallsBackFromMissingSearchTargetsToProjectIdThenLabel() throws Exception {
Intent intent = new Intent()
.putExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY, "talking")
.putExtra(ConversationFolderActivity.EXTRA_FOLDER_NAME, "Talking")
.putExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_ID, "project-3")
.putExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_IDS, new String[]{"project-99", "project-100"})
.putExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_LABEL, "日志收口");
TestConversationFolderActivity activity = Robolectric
.buildActivity(TestConversationFolderActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderFolder",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildFolderPayload())
);
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "已定位到目标线程"));
assertTrue(viewTreeContainsText(content, "日志收口"));
assertEquals(0, countTextOccurrences(content, "project-99"));
assertEquals(1, countTextOccurrences(content, "目标线程"));
}
@Test
public void matchingProjectMessagesUpdatedEventTriggersReload() throws Exception {
Intent intent = new Intent()
.putExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY, "talking")
.putExtra(ConversationFolderActivity.EXTRA_FOLDER_NAME, "Talking");
TestConversationFolderActivity activity = Robolectric
.buildActivity(TestConversationFolderActivity.class, intent)
.setup()
.resume()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderFolder",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildFolderPayload())
);
activity.reloadEnabled = true;
activity.reloadCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idleFor(400, TimeUnit.MILLISECONDS);
assertEquals(1, activity.reloadCount);
}
@Test
public void unrelatedConversationEventDoesNotTriggerReload() throws Exception {
Intent intent = new Intent()
.putExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY, "talking")
.putExtra(ConversationFolderActivity.EXTRA_FOLDER_NAME, "Talking");
TestConversationFolderActivity activity = Robolectric
.buildActivity(TestConversationFolderActivity.class, intent)
.setup()
.resume()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderFolder",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildFolderPayload())
);
activity.reloadEnabled = true;
activity.reloadCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-9"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.reloadCount);
}
private static JSONObject buildFolderPayload() throws Exception {
JSONArray threads = new JSONArray()
.put(new JSONObject()
.put("projectId", "project-1")
.put("threadTitle", "发布回滚")
.put("folderLabel", "Talking")
.put("lastMessagePreview", "已从设备导入线程")
.put("timeLabel", "02:28"))
.put(new JSONObject()
.put("projectId", "project-2")
.put("threadTitle", "发布回滚")
.put("folderLabel", "Talking")
.put("lastMessagePreview", "已从设备导入线程")
.put("timeLabel", "02:12"))
.put(new JSONObject()
.put("projectId", "project-3")
.put("threadTitle", "日志收口")
.put("folderLabel", "Talking")
.put("lastMessagePreview", "已从设备导入线程")
.put("timeLabel", "02:05"));
return new JSONObject()
.put("folderLabel", "Talking")
.put("deviceName", "Mac Studio")
.put("threadCount", 3)
.put("threads", threads);
}
private static int countTextOccurrences(View root, String expectedText) {
int count = 0;
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (expectedText.contentEquals(text)) {
count += 1;
}
}
if (!(root instanceof ViewGroup)) {
return count;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
count += countTextOccurrences(group.getChildAt(index), expectedText);
}
return count;
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (expectedText.contentEquals(text)) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
public static class TestConversationFolderActivity extends ConversationFolderActivity {
private boolean reloadEnabled;
private int reloadCount;
@Override
protected void reload() {
if (!reloadEnabled) {
return;
}
reloadCount += 1;
replaceContent(BossUi.buildEmptyCard(this, "test reload"));
setRefreshing(false);
}
}
}

View File

@@ -0,0 +1,544 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.content.Intent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowDialog;
import org.robolectric.util.ReflectionHelpers;
import org.robolectric.shadows.ShadowLooper;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.AbstractExecutorService;
import java.util.concurrent.TimeUnit;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class ConversationInfoActivityTest {
@Test
public void renderConversationUsesLightweightHeaderMenuAndThreadList() throws Exception {
Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestConversationInfoActivity activity = Robolectric
.buildActivity(TestConversationInfoActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderConversation",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildThreadStatusPayload())
);
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content.getChildAt(0), "北区试产线回归"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "单线程会话"));
assertTrue(viewTreeContainsText(content.getChildAt(1), "线程状态摘要"));
assertTrue(viewTreeContainsTextFragment(content.getChildAt(1), "当前进度:已经记录最近 2 条进展"));
assertTrue(viewTreeContainsTextFragment(content.getChildAt(1), "建议下一步:继续同步 Android 只读页"));
assertTrue(viewTreeContainsText(content.getChildAt(2), "主 Agent 协同接管"));
assertTrue(viewTreeContainsText(content.getChildAt(3), "发起群聊"));
assertTrue(viewTreeContainsText(content.getChildAt(3), "选择其他线程加入新群"));
assertTrue(viewTreeContainsText(content.getChildAt(4), "线程详情"));
assertTrue(viewTreeContainsText(content.getChildAt(4), "查看当前线程聊天与项目"));
assertTrue(viewTreeContainsText(content, "参与线程"));
assertTrue(viewTreeContainsText(content, "硬件审计协作"));
assertFalse(viewTreeContainsText(content, "从当前会话选择其他线程,创建新的独立群聊"));
assertFalse(viewTreeContainsText(content, "以下线程参与当前会话,点击可查看对应项目详情。"));
}
@Test
public void threadDetailMenuRowStillOpensProjectDetail() throws Exception {
Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestConversationInfoActivity activity = Robolectric
.buildActivity(TestConversationInfoActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderConversation",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildThreadStatusPayload())
);
View threadDetailRow = findClickableViewContainingText(
activity.findViewById(R.id.screen_content),
"线程详情"
);
assertNotNull(threadDetailRow);
threadDetailRow.performClick();
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
assertNotNull(nextIntent);
assertEquals(
ProjectDetailActivity.class.getName(),
nextIntent.getComponent().getClassName()
);
assertEquals(
"project-1",
nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_ID)
);
assertEquals(
"北区试产线回归",
nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME)
);
}
@Test
public void conversationInfoShowsThreadStatusEntryForThreadConversation() throws Exception {
Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestConversationInfoActivity activity = Robolectric
.buildActivity(TestConversationInfoActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderConversation",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildThreadStatusPayload())
);
View threadStatusRow = findClickableViewContainingText(
activity.findViewById(R.id.screen_content),
"线程状态"
);
assertNotNull(threadStatusRow);
threadStatusRow.performClick();
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
assertNotNull(nextIntent);
assertEquals(ThreadStatusActivity.class.getName(), nextIntent.getComponent().getClassName());
assertEquals("project-1", nextIntent.getStringExtra(ThreadStatusActivity.EXTRA_PROJECT_ID));
assertEquals("北区试产线回归", nextIntent.getStringExtra(ThreadStatusActivity.EXTRA_PROJECT_NAME));
}
@Test
public void conversationInfoUsesOverflowMenuInTopBar() {
Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestConversationInfoActivity activity = Robolectric
.buildActivity(TestConversationInfoActivity.class, intent)
.setup()
.get();
ImageButton headerAction = activity.findViewById(R.id.screen_header_action);
ImageButton refreshButton = activity.findViewById(R.id.screen_refresh_button);
assertEquals("更多", String.valueOf(headerAction.getContentDescription()));
assertEquals(View.GONE, refreshButton.getVisibility());
ReflectionHelpers.callInstanceMethod(activity, "showMoreMenu");
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
assertTrue(latestDialog instanceof AlertDialog);
ListView listView = ((AlertDialog) latestDialog).getListView();
assertTrue(viewTreeContainsText(listView.getAdapter().getView(0, null, listView), "改名"));
assertTrue(viewTreeContainsText(listView.getAdapter().getView(1, null, listView), "刷新"));
}
@Test
public void reloadAutoRecoversUnauthorizedAndRendersConversationInfo() {
Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestConversationInfoActivity activity = Robolectric
.buildActivity(TestConversationInfoActivity.class, intent)
.setup()
.get();
RecordingBossApiClient apiClient = new RecordingBossApiClient(
activity.getSharedPreferences("conversation-info-auth-test", Context.MODE_PRIVATE),
"https://boss.hyzq.net"
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.setField(activity, "reloadEnabled", true);
ReflectionHelpers.setField(activity, "delegateReloadToSuper", true);
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
activity.reload();
ShadowLooper.shadowMainLooper().idle();
assertEquals(2, apiClient.detailCalls);
assertEquals(1, apiClient.autoLoginCalls);
LinearLayout content = activity.findViewById(R.id.screen_content);
assertFalse(viewTreeContainsTextFragment(content, "会话信息加载失败"));
assertTrue(content.getChildCount() > 0);
}
@Test
public void saveTakeoverAutoRecoversUnauthorizedAndPersistsSetting() {
Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestConversationInfoActivity activity = Robolectric
.buildActivity(TestConversationInfoActivity.class, intent)
.setup()
.get();
RecordingBossApiClient apiClient = new RecordingBossApiClient(
activity.getSharedPreferences("conversation-info-save-auth-test", Context.MODE_PRIVATE),
"https://boss.hyzq.net"
);
apiClient.failFirstLoad = false;
apiClient.failFirstSave = true;
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.setField(activity, "reloadEnabled", true);
ReflectionHelpers.setField(activity, "delegateReloadToSuper", true);
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
activity.reload();
ShadowLooper.shadowMainLooper().idle();
ReflectionHelpers.callInstanceMethod(
activity,
"saveTakeoverSetting",
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
ShadowLooper.shadowMainLooper().idle();
assertEquals(1, apiClient.updateTakeoverCalls);
assertEquals(1, apiClient.retryUpdateTakeoverCalls);
assertEquals(1, apiClient.autoLoginCalls);
}
@Test
public void matchingProjectMessagesUpdatedEventTriggersReload() throws Exception {
Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestConversationInfoActivity activity = Robolectric
.buildActivity(TestConversationInfoActivity.class, intent)
.setup()
.resume()
.get();
activity.reloadEnabled = true;
activity.reloadCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
)
);
ShadowLooper.shadowMainLooper().idle();
assertEquals(1, activity.reloadCount);
}
@Test
public void unrelatedConversationEventDoesNotTriggerReload() throws Exception {
Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestConversationInfoActivity activity = Robolectric
.buildActivity(TestConversationInfoActivity.class, intent)
.setup()
.resume()
.get();
activity.reloadEnabled = true;
activity.reloadCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-2"))
)
);
ShadowLooper.shadowMainLooper().idle();
assertEquals(0, activity.reloadCount);
}
private static JSONObject buildDetailPayload() throws Exception {
JSONObject threadMeta = new JSONObject()
.put("threadId", "thread-7")
.put("folderName", "Boss");
JSONObject project = new JSONObject()
.put("id", "project-1")
.put("name", "北区试产线回归")
.put("isGroup", false)
.put("deviceIds", new JSONArray().put("mac-studio").put("macbook"))
.put("threadMeta", threadMeta);
return new JSONObject()
.put("project", project)
.put("agentControls", new JSONObject()
.put("effectiveTakeoverEnabled", true)
.put("takeoverInheritedFromGlobal", true));
}
private static JSONObject buildParticipantsPayload() throws Exception {
JSONArray participants = new JSONArray()
.put(new JSONObject()
.put("projectId", "project-1")
.put("threadDisplayName", "北区试产线回归")
.put("folderName", "Boss")
.put("deviceId", "Mac Studio")
.put("threadId", "thread-7")
.put("isSourceProject", true))
.put(new JSONObject()
.put("projectId", "project-2")
.put("threadDisplayName", "硬件审计协作")
.put("folderName", "Boss")
.put("deviceId", "Mac Studio")
.put("threadId", "thread-8"));
return new JSONObject().put("participants", participants);
}
private static JSONObject buildThreadStatusPayload() throws Exception {
return new JSONObject()
.put("threadStatusDocument", new JSONObject()
.put("projectGoal", "完成线程状态回归")
.put("currentProgress", "已经记录最近 2 条进展")
.put("currentBlockers", "暂无阻塞")
.put("recommendedNextStep", "继续同步 Android 只读页")
.put("updatedAt", "2026-04-04T18:00:00+08:00"))
.put("recentProgressEvents", new JSONArray()
.put(new JSONObject().put("summary", "事件 2"))
.put(new JSONObject().put("summary", "事件 1")));
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (expectedText.contentEquals(text)) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
private static boolean viewTreeContainsTextFragment(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (text != null && text.toString().contains(expectedText)) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsTextFragment(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
private static View findClickableViewContainingText(View root, String expectedText) {
if (root == null) {
return null;
}
if (viewTreeContainsText(root, expectedText) && root.isClickable()) {
return root;
}
if (!(root instanceof ViewGroup)) {
return null;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
View match = findClickableViewContainingText(group.getChildAt(index), expectedText);
if (match != null) {
return match;
}
}
return null;
}
public static class TestConversationInfoActivity extends ConversationInfoActivity {
private boolean reloadEnabled;
private boolean delegateReloadToSuper;
private int reloadCount;
@Override
protected void reload() {
if (!reloadEnabled) {
return;
}
reloadCount += 1;
if (delegateReloadToSuper) {
super.reload();
return;
}
replaceContent(BossUi.buildEmptyCard(this, "test reload"));
setRefreshing(false);
}
}
private static final class RecordingBossApiClient extends BossApiClient {
private int detailCalls;
private int autoLoginCalls;
private int updateTakeoverCalls;
private int retryUpdateTakeoverCalls;
private boolean failFirstLoad = true;
private boolean failFirstSave;
RecordingBossApiClient(android.content.SharedPreferences prefs, String baseUrl) {
super(prefs, baseUrl);
}
@Override
public ApiResponse getProjectDetail(String projectId) throws java.io.IOException, org.json.JSONException {
detailCalls += 1;
if (failFirstLoad && detailCalls == 1) {
return ApiResponse.error(
401,
new JSONObject()
.put("ok", false)
.put("message", "UNAUTHORIZED")
);
}
try {
JSONObject payload = buildDetailPayload();
payload.put("ok", true);
return new ApiResponse(200, payload);
} catch (Exception error) {
throw new java.io.IOException("BUILD_DETAIL_PAYLOAD_FAILED", error);
}
}
@Override
public ApiResponse getConversationParticipants(String projectId) throws java.io.IOException, org.json.JSONException {
try {
JSONObject payload = buildParticipantsPayload();
payload.put("ok", true);
return new ApiResponse(200, payload);
} catch (Exception error) {
throw new java.io.IOException("BUILD_PARTICIPANTS_PAYLOAD_FAILED", error);
}
}
@Override
public ApiResponse getThreadStatus(String projectId) throws java.io.IOException, org.json.JSONException {
try {
JSONObject payload = buildThreadStatusPayload();
payload.put("ok", true);
return new ApiResponse(200, payload);
} catch (Exception error) {
throw new java.io.IOException("BUILD_THREAD_STATUS_PAYLOAD_FAILED", error);
}
}
@Override
public ApiResponse autoLogin() throws java.io.IOException, org.json.JSONException {
autoLoginCalls += 1;
return new ApiResponse(
200,
new JSONObject()
.put("ok", true)
.put("session", new JSONObject().put("account", "17600003315"))
);
}
@Override
public ApiResponse updateProjectTakeoverSettings(
String projectId,
Boolean takeoverEnabled,
Boolean globalTakeoverEnabled
) throws java.io.IOException, org.json.JSONException {
if (failFirstSave && updateTakeoverCalls == 0) {
updateTakeoverCalls += 1;
return ApiResponse.error(
401,
new JSONObject()
.put("ok", false)
.put("message", "UNAUTHORIZED")
);
}
if (failFirstSave) {
retryUpdateTakeoverCalls += 1;
} else {
updateTakeoverCalls += 1;
}
return new ApiResponse(
200,
new JSONObject()
.put("ok", true)
.put("controls", new JSONObject().put("takeoverEnabled", takeoverEnabled))
);
}
}
private static final class DirectExecutorService extends AbstractExecutorService {
private boolean shutdown;
@Override
public void shutdown() {
shutdown = true;
}
@Override
public List<Runnable> shutdownNow() {
shutdown = true;
return Collections.emptyList();
}
@Override
public boolean isShutdown() {
return shutdown;
}
@Override
public boolean isTerminated() {
return shutdown;
}
@Override
public boolean awaitTermination(long timeout, TimeUnit unit) {
return true;
}
@Override
public void execute(Runnable command) {
command.run();
}
}
}

View File

@@ -0,0 +1,415 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.content.Intent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowDialog;
import org.robolectric.util.ReflectionHelpers;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.AbstractExecutorService;
import java.util.concurrent.TimeUnit;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class DeviceDetailActivityTest {
@Test
public void renderDeviceDoesNotShowManualProjectUnderstandingEntry() {
TestDeviceDetailActivity activity = Robolectric
.buildActivity(
TestDeviceDetailActivity.class,
new Intent()
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")
)
.setup()
.get();
View content = activity.findViewById(R.id.screen_content);
assertFalse(viewTreeContainsText(content, "同步项目理解"));
assertFalse(viewTreeContainsText(content, "让主 Agent 主动询问这台设备上的活跃项目目标、进度和架构"));
}
@Test
public void renderDeviceShowsGuiCliCapabilitiesAndPreferredExecutionMode() throws Exception {
TestDeviceDetailActivity activity = Robolectric
.buildActivity(
TestDeviceDetailActivity.class,
new Intent()
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")
)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderDevice",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDevicePayload())
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "GUI 能力"));
assertTrue(viewTreeContainsText(content, "CLI 能力"));
assertTrue(viewTreeContainsText(content, "默认执行模式"));
assertTrue(viewTreeContainsText(content, "当前默认GUI"));
assertTrue(viewTreeContainsText(content, "已连接"));
assertTrue(viewTreeContainsText(content, "未连接"));
}
@Test
public void renderDeviceShowsProjectScopedConflictCardAndActions() throws Exception {
TestDeviceDetailActivity activity = Robolectric
.buildActivity(
TestDeviceDetailActivity.class,
new Intent()
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")
)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderDevice",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildConflictPayload())
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "异常项目 / 文件夹冲突"));
assertTrue(viewTreeContainsText(content, "thread-ui"));
assertTrue(viewTreeContainsText(content, "允许本次"));
assertTrue(viewTreeContainsText(content, "存在并行风险"));
assertTrue(viewTreeContainsText(content, "仅作用于当前异常项目 / 文件夹"));
assertTrue(viewTreeContainsText(content, "冲突策略"));
}
@Test
public void preferredExecutionModeDialogPersistsSelectedMode() throws Exception {
TestDeviceDetailActivity activity = Robolectric
.buildActivity(
TestDeviceDetailActivity.class,
new Intent()
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")
)
.setup()
.get();
RecordingBossApiClient apiClient = new RecordingBossApiClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE),
"https://boss.hyzq.net"
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
ReflectionHelpers.callInstanceMethod(
activity,
"showPreferredExecutionModeDialog",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDevicePayload())
);
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
assertTrue(latestDialog instanceof AlertDialog);
AlertDialog dialog = (AlertDialog) latestDialog;
assertNotNull(dialog.getListView());
dialog.getListView().performItemClick(
dialog.getListView().getAdapter().getView(0, null, dialog.getListView()),
0,
dialog.getListView().getAdapter().getItemId(0)
);
assertEquals(1, apiClient.updateDeviceCalls);
assertEquals("device-1", apiClient.lastDeviceId);
assertNotNull(apiClient.lastPayload);
assertEquals("gui", apiClient.lastPayload.optString("preferredExecutionMode"));
}
@Test
public void conflictDecisionDialogPersistsScopedDecision() throws Exception {
TestDeviceDetailActivity activity = Robolectric
.buildActivity(
TestDeviceDetailActivity.class,
new Intent()
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")
)
.setup()
.get();
RecordingBossApiClient apiClient = new RecordingBossApiClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE),
"https://boss.hyzq.net"
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
ReflectionHelpers.callInstanceMethod(
activity,
"showConflictDecisionDialog",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildConflictPayload())
);
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
assertTrue(latestDialog instanceof AlertDialog);
AlertDialog dialog = (AlertDialog) latestDialog;
assertNotNull(dialog.getListView());
dialog.getListView().performItemClick(
dialog.getListView().getAdapter().getView(2, null, dialog.getListView()),
2,
dialog.getListView().getAdapter().getItemId(2)
);
assertEquals(1, apiClient.updateDeviceCalls);
assertEquals("device-1", apiClient.lastDeviceId);
assertNotNull(apiClient.lastPayload);
assertEquals("thread-ui", apiClient.lastPayload.optString("projectId"));
assertEquals("mac-studio:boss", apiClient.lastPayload.optString("folderKey"));
assertEquals("allow_always", apiClient.lastPayload.optString("conflictDecision"));
}
@Test
public void matchingDevicesUpdatedEventTriggersReload() throws Exception {
TestDeviceDetailActivity activity = Robolectric
.buildActivity(
TestDeviceDetailActivity.class,
new Intent()
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")
)
.setup()
.resume()
.get();
activity.reloadEnabled = true;
activity.reloadCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent("devices.updated", new JSONObject().put("deviceId", "device-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(1, activity.reloadCount);
}
@Test
public void matchingProjectContextRiskEventTriggersReload() throws Exception {
TestDeviceDetailActivity activity = Robolectric
.buildActivity(
TestDeviceDetailActivity.class,
new Intent()
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")
)
.setup()
.resume()
.get();
activity.reloadEnabled = true;
activity.reloadCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent("project.context_risk.updated", new JSONObject().put("deviceId", "device-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(1, activity.reloadCount);
}
@Test
public void unrelatedDeviceEventDoesNotTriggerReload() throws Exception {
TestDeviceDetailActivity activity = Robolectric
.buildActivity(
TestDeviceDetailActivity.class,
new Intent()
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")
)
.setup()
.resume()
.get();
activity.reloadEnabled = true;
activity.reloadCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent("devices.updated", new JSONObject().put("deviceId", "device-2"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.reloadCount);
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (text != null && text.toString().contains(expectedText)) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
private static JSONObject buildDevicePayload() throws Exception {
return new JSONObject()
.put("workspace", new JSONObject()
.put("selectedDevice", new JSONObject()
.put("id", "device-1")
.put("name", "Mac Studio")
.put("avatar", "M")
.put("account", "17600003315")
.put("status", "online")
.put("quota5h", 75)
.put("quota7d", 88)
.put("capabilities", new JSONObject()
.put("gui", new JSONObject()
.put("connected", true)
.put("lastSeenAt", "2026-04-06T08:50:00+08:00")
.put("lastActiveProjectId", "master-agent"))
.put("cli", new JSONObject()
.put("connected", false)
.put("lastSeenAt", "2026-04-06T08:40:00+08:00")
.put("lastActiveProjectId", "")))
.put("preferredExecutionMode", "gui")
.put("projects", new JSONArray().put("Boss"))
.put("endpoint", "mac://studio.local")
.put("note", "测试设备")));
}
private static JSONObject buildConflictPayload() throws Exception {
JSONObject payload = buildDevicePayload();
payload.getJSONObject("workspace").put(
"projectExecutionPolicies",
new JSONArray().put(
new JSONObject()
.put("deviceId", "device-1")
.put("folderKey", "mac-studio:boss")
.put("projectId", "thread-ui")
.put("allowPolicy", "allow_once")
.put("conflictState", "warning")
.put("updatedAt", "2026-04-06T12:00:00.000Z")
)
);
return payload;
}
public static class TestDeviceDetailActivity extends DeviceDetailActivity {
boolean reloadEnabled = true;
int reloadCount;
@Override
protected void reload() {
if (!reloadEnabled) {
return;
}
reloadCount += 1;
this.apiClient = new BossApiClient(getSharedPreferences("test-boss-api", Context.MODE_PRIVATE), "https://boss.hyzq.net");
try {
ReflectionHelpers.callInstanceMethod(
this,
"renderDevice",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDevicePayload())
);
} catch (Exception error) {
throw new RuntimeException(error);
}
}
}
private static final class RecordingBossApiClient extends BossApiClient {
private int updateDeviceCalls;
private String lastDeviceId;
private JSONObject lastPayload;
RecordingBossApiClient(android.content.SharedPreferences prefs, String baseUrl) {
super(prefs, baseUrl);
}
@Override
public ApiResponse updateDevice(String deviceId, JSONObject payload) {
updateDeviceCalls += 1;
lastDeviceId = deviceId;
lastPayload = payload;
try {
return new ApiResponse(200, new JSONObject().put("ok", true));
} catch (Exception error) {
throw new RuntimeException(error);
}
}
}
private static final class DirectExecutorService extends AbstractExecutorService {
private boolean shutdown;
@Override
public void shutdown() {
shutdown = true;
}
@Override
public List<Runnable> shutdownNow() {
shutdown = true;
return Collections.emptyList();
}
@Override
public boolean isShutdown() {
return shutdown;
}
@Override
public boolean isTerminated() {
return shutdown;
}
@Override
public boolean awaitTermination(long timeout, TimeUnit unit) {
return true;
}
@Override
public void execute(Runnable command) {
command.run();
}
}
}

View File

@@ -0,0 +1,294 @@
package com.hyzq.boss;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import android.content.Intent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class DeviceImportDraftActivityTest {
@Test
public void renderCurrentStateShowsSelectionAndRecommendationCopy() throws Exception {
TestDeviceImportDraftActivity activity = Robolectric
.buildActivity(
TestDeviceImportDraftActivity.class,
new Intent()
.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_NAME, "Mac Studio")
)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"applyPayload",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildPendingDraft()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "等待勾选"));
assertTrue(viewTreeContainsText(content, "推荐导入"));
assertTrue(viewTreeContainsText(content, "生成导入建议"));
assertFalse(viewTreeContainsText(content, "应用结果"));
}
@Test
public void renderCurrentStateShowsAppliedResultAndImportedNames() throws Exception {
TestDeviceImportDraftActivity activity = Robolectric
.buildActivity(
TestDeviceImportDraftActivity.class,
new Intent()
.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_NAME, "Mac Studio")
)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"applyPayload",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAppliedDraft()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAppliedResolution()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "已导入"));
assertTrue(viewTreeContainsText(content, "应用结果"));
assertTrue(viewTreeContainsText(content, "北区试产线回归"));
assertTrue(viewTreeContainsText(content, "北区试产线审计"));
assertTrue(viewTreeContainsText(content, "已导入"));
}
@Test
public void renderCurrentStateShowsQueuedReviewTaskCopyWithoutProjectUnderstandingSection() throws Exception {
TestDeviceImportDraftActivity activity = Robolectric
.buildActivity(
TestDeviceImportDraftActivity.class,
new Intent()
.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_NAME, "Mac Studio")
)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"applyPayload",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildPendingResolutionDraft()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildQueuedReviewTask())
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "主 Agent 审核中"));
assertTrue(viewTreeContainsText(content, "审核任务"));
assertTrue(viewTreeContainsText(content, "状态queued"));
assertFalse(viewTreeContainsText(content, "项目理解"));
assertFalse(viewTreeContainsText(content, "树莓派二代接入与联调"));
}
@Test
public void matchingDeviceUpdatedEventTriggersReload() throws Exception {
TestDeviceImportDraftActivity activity = Robolectric
.buildActivity(
TestDeviceImportDraftActivity.class,
new Intent()
.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_NAME, "Mac Studio")
)
.setup()
.resume()
.get();
activity.reloadEnabled = true;
activity.reloadCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent("devices.updated", new JSONObject().put("deviceId", "device-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertTrue(activity.reloadCount == 1);
}
@Test
public void unrelatedDeviceEventDoesNotTriggerReload() throws Exception {
TestDeviceImportDraftActivity activity = Robolectric
.buildActivity(
TestDeviceImportDraftActivity.class,
new Intent()
.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_NAME, "Mac Studio")
)
.setup()
.resume()
.get();
activity.reloadEnabled = true;
activity.reloadCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent("devices.updated", new JSONObject().put("deviceId", "device-2"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertTrue(activity.reloadCount == 0);
}
private static JSONObject buildPendingDraft() throws Exception {
return new JSONObject()
.put("draftId", "draft-1")
.put("deviceId", "device-1")
.put("status", "pending_selection")
.put("selectedCandidateIds", new JSONArray().put("candidate-1"))
.put("appliedProjectNames", new JSONArray())
.put("candidates", new JSONArray()
.put(new JSONObject()
.put("candidateId", "candidate-1")
.put("deviceId", "device-1")
.put("folderName", "北区试产线")
.put("threadId", "thread-1")
.put("threadDisplayName", "北区试产线回归")
.put("lastActiveAt", "2026-03-30T10:18:00+08:00")
.put("suggestedImport", true))
.put(new JSONObject()
.put("candidateId", "candidate-2")
.put("deviceId", "device-1")
.put("folderName", "北区试产线")
.put("threadId", "thread-2")
.put("threadDisplayName", "北区试产线审计")
.put("lastActiveAt", "2026-03-30T10:20:00+08:00")
.put("suggestedImport", false)));
}
private static JSONObject buildAppliedDraft() throws Exception {
return new JSONObject()
.put("draftId", "draft-1")
.put("deviceId", "device-1")
.put("status", "applied")
.put("selectedCandidateIds", new JSONArray().put("candidate-1").put("candidate-2"))
.put("appliedProjectNames", new JSONArray().put("北区试产线回归").put("北区试产线审计"))
.put("candidates", new JSONArray()
.put(new JSONObject()
.put("candidateId", "candidate-1")
.put("deviceId", "device-1")
.put("folderName", "北区试产线")
.put("threadId", "thread-1")
.put("threadDisplayName", "北区试产线回归")
.put("lastActiveAt", "2026-03-30T10:18:00+08:00")
.put("suggestedImport", true))
.put(new JSONObject()
.put("candidateId", "candidate-2")
.put("deviceId", "device-1")
.put("folderName", "北区试产线")
.put("threadId", "thread-2")
.put("threadDisplayName", "北区试产线审计")
.put("lastActiveAt", "2026-03-30T10:20:00+08:00")
.put("suggestedImport", true)));
}
private static JSONObject buildPendingResolutionDraft() throws Exception {
return new JSONObject()
.put("draftId", "draft-1")
.put("deviceId", "device-1")
.put("status", "pending_resolution")
.put("selectedCandidateIds", new JSONArray().put("candidate-1"))
.put("appliedProjectNames", new JSONArray())
.put("candidates", new JSONArray()
.put(new JSONObject()
.put("candidateId", "candidate-1")
.put("deviceId", "device-1")
.put("folderName", "北区试产线")
.put("threadId", "thread-1")
.put("threadDisplayName", "北区试产线回归")
.put("lastActiveAt", "2026-03-30T10:18:00+08:00")
.put("suggestedImport", true)));
}
private static JSONObject buildAppliedResolution() throws Exception {
return new JSONObject()
.put("resolutionId", "resolution-1")
.put("draftId", "draft-1")
.put("deviceId", "device-1")
.put("status", "applied")
.put("summary", "Mac Studio 导入建议:新建 2 个会话。")
.put("items", new JSONArray()
.put(new JSONObject()
.put("candidateId", "candidate-1")
.put("action", "create_thread_conversation")
.put("threadDisplayName", "北区试产线回归")
.put("folderName", "北区试产线")
.put("reason", "作为独立聊天窗口导入。"))
.put(new JSONObject()
.put("candidateId", "candidate-2")
.put("action", "create_thread_conversation")
.put("threadDisplayName", "北区试产线审计")
.put("folderName", "北区试产线")
.put("reason", "作为独立聊天窗口导入。")));
}
private static JSONObject buildQueuedReviewTask() throws Exception {
return new JSONObject()
.put("taskId", "mastertask-1")
.put("taskType", "device_import_resolution")
.put("status", "queued");
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (text != null && text.toString().contains(expectedText)) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
public static class TestDeviceImportDraftActivity extends DeviceImportDraftActivity {
private boolean reloadEnabled;
private int reloadCount;
@Override
protected void reload() {
if (!reloadEnabled) {
return;
}
reloadCount += 1;
setRefreshing(false);
}
}
}

View File

@@ -0,0 +1,109 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import java.util.List;
public class ForwardTargetActivityTest {
@Test
public void filtersOutSourceConversationFromTargets() {
JSONArray conversations = new StubJSONArray(
new StubJSONObject().withString("projectId", "source").withString("projectTitle", "源会话"),
new StubJSONObject().withString("projectId", "target").withString("projectTitle", "目标会话")
);
List<JSONObject> result = ForwardTargetActivity.collectSelectableTargets(conversations, "source");
assertEquals(1, result.size());
assertEquals("target", result.get(0).optString("projectId"));
}
@Test
public void singleModeRequiresOneMessageId() throws Exception {
JSONObject payload = ForwardTargetActivity.buildForwardPayload("single", "m1", java.util.List.of());
assertEquals("single", payload.optString("mode"));
assertEquals("m1", payload.optString("sourceMessageId"));
assertEquals(
"{\"targetProjectId\":\"target\",\"mode\":\"single\",\"sourceMessageId\":\"m1\"}",
ForwardPayloads.toRequestBody("target", payload)
);
}
@Test
public void bundleModeUsesOrderedMessageIds() throws Exception {
JSONObject payload = ForwardTargetActivity.buildForwardPayload("bundle", null, java.util.List.of("m1", "m2"));
assertEquals("bundle", payload.optString("mode"));
assertEquals(2, payload.optJSONArray("sourceMessageIds").length());
assertEquals("m1", payload.optJSONArray("sourceMessageIds").optString(0));
assertEquals("m2", payload.optJSONArray("sourceMessageIds").optString(1));
assertEquals(
"{\"targetProjectId\":\"target\",\"mode\":\"bundle\",\"sourceMessageIds\":[\"m1\",\"m2\"]}",
ForwardPayloads.toRequestBody("target", payload)
);
}
@Test
public void approvalRequiredResponseUsesApprovalMessage() {
StubJSONObject response = new StubJSONObject().withBoolean("approvalRequired", true);
assertEquals("已提交主 Agent 审批", ForwardTargetActivity.resolveForwardResultMessage(response));
}
private static final class StubJSONObject extends JSONObject {
private final java.util.Map<String, Object> values = new java.util.HashMap<>();
StubJSONObject withString(String key, String value) {
values.put(key, value);
return this;
}
StubJSONObject withBoolean(String key, boolean value) {
values.put(key, value);
return this;
}
@Override
public String optString(String key) {
Object value = values.get(key);
return value instanceof String ? (String) value : "";
}
@Override
public String optString(String key, String fallback) {
Object value = values.get(key);
return value instanceof String ? (String) value : fallback;
}
@Override
public boolean optBoolean(String key, boolean fallback) {
Object value = values.get(key);
return value instanceof Boolean ? (Boolean) value : fallback;
}
}
private static final class StubJSONArray extends JSONArray {
private final JSONObject[] values;
StubJSONArray(JSONObject... values) {
this.values = values == null ? new JSONObject[0] : values;
}
@Override
public int length() {
return values.length;
}
@Override
public JSONObject optJSONObject(int index) {
if (index < 0 || index >= values.length) {
return null;
}
return values[index];
}
}
}

View File

@@ -0,0 +1,308 @@
package com.hyzq.boss;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import java.util.List;
import java.util.LinkedHashSet;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class GroupCreateActivityTest {
@Test
public void buildSourceHeaderDetail_usesCompactWechatSummary() {
JSONObject threadMeta = new StubJSONObject()
.withString("threadDisplayName", "北区试产线回归")
.withString("threadId", "thread-7")
.withString("folderName", "Mac Studio");
JSONArray participants = new StubJSONArray(
new StubJSONObject(),
new StubJSONObject(),
new StubJSONObject()
);
String detail = GroupCreateActivity.buildSourceHeaderDetail(
"source-1",
threadMeta,
participants
);
assertEquals("thread-7 · Mac Studio · 3 个参与线程", detail);
}
@Test
public void buildSelectionHintText_usesCompactWechatStyleHint() {
assertEquals("至少选择 1 个其他线程", GroupCreateActivity.buildSelectionHintText(5, 0, true));
assertEquals("已选 2 个线程", GroupCreateActivity.buildSelectionHintText(5, 2, true));
assertEquals("当前没有可加入的其他线程", GroupCreateActivity.buildSelectionHintText(0, 0, true));
}
@Test
public void collectSelectableConversationItems_filtersOutExistingGroupChats() {
JSONObject threadConversation = new StubJSONObject()
.withString("projectId", "thread-1")
.withString("projectTitle", "线程一")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject groupConversation = new StubJSONObject()
.withString("projectId", "group-1")
.withString("projectTitle", "已有群聊")
.withString("conversationType", "group_chat")
.withBoolean("isGroup", true);
JSONObject sourceConversation = new StubJSONObject()
.withString("projectId", "source-1")
.withString("projectTitle", "来源线程")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject agentConversation = new StubJSONObject()
.withString("projectId", "master-agent")
.withString("projectTitle", "主Agent")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject consoleConversation = new StubJSONObject()
.withString("projectId", "console-1")
.withString("projectTitle", "BOSS移动控制台")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject conversationsPayload = new StubJSONObject()
.withObjectArray("conversations", threadConversation, groupConversation, sourceConversation, agentConversation, consoleConversation);
java.util.List<JSONObject> filtered = GroupCreateActivity.collectSelectableConversationItems(conversationsPayload, "source-1");
assertEquals(1, filtered.size());
assertEquals("thread-1", filtered.get(0).optString("projectId", ""));
}
@Test
public void collectSelectableConversationItems_keepsAllThreadsWhenSourceConversationIsMissing() {
JSONObject threadConversation = new StubJSONObject()
.withString("projectId", "thread-1")
.withString("projectTitle", "线程一")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject secondThreadConversation = new StubJSONObject()
.withString("projectId", "thread-2")
.withString("projectTitle", "线程二")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject groupConversation = new StubJSONObject()
.withString("projectId", "group-1")
.withString("projectTitle", "已有群聊")
.withString("conversationType", "group_chat")
.withBoolean("isGroup", true);
JSONObject auditConversation = new StubJSONObject()
.withString("projectId", "audit-1")
.withString("projectTitle", "硬件审计协作")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject conversationsPayload = new StubJSONObject()
.withObjectArray("conversations", threadConversation, secondThreadConversation, groupConversation, auditConversation);
java.util.List<JSONObject> filtered = GroupCreateActivity.collectSelectableConversationItems(conversationsPayload, null);
assertEquals(2, filtered.size());
assertEquals("thread-1", filtered.get(0).optString("projectId", ""));
assertEquals("thread-2", filtered.get(1).optString("projectId", ""));
}
@Test
public void collectSelectableConversationItems_filtersOutSystemManagedConversations() {
JSONObject agentConversation = new StubJSONObject()
.withString("projectId", "master-agent")
.withString("projectTitle", "主 Agent")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject auditConversation = new StubJSONObject()
.withString("projectId", "audit-1")
.withString("projectTitle", "硬件审计协作")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject consoleConversation = new StubJSONObject()
.withString("projectId", "console-1")
.withString("projectTitle", "BOSS移动控制台")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject workerConversation = new StubJSONObject()
.withString("projectId", "thread-7")
.withString("projectTitle", "树莓派查询线程")
.withString("conversationType", "single_device")
.withBoolean("isGroup", false);
JSONObject conversationsPayload = new StubJSONObject()
.withObjectArray("conversations", agentConversation, auditConversation, consoleConversation, workerConversation);
List<JSONObject> filtered = GroupCreateActivity.collectSelectableConversationItems(conversationsPayload, null);
assertEquals(1, filtered.size());
assertEquals("thread-7", filtered.get(0).optString("projectId", ""));
}
@Test
public void collectSelectableConversationItems_filtersOutSystemAutoJoinConversations() {
JSONObject allowedThreadConversation = new StubJSONObject()
.withString("projectId", "thread-1")
.withString("projectTitle", "查询树莓派二代")
.withBoolean("isGroup", false);
JSONObject masterAgentConversation = new StubJSONObject()
.withString("projectId", "thread-2")
.withString("projectTitle", "主Agent")
.withBoolean("isGroup", false);
JSONObject auditConversation = new StubJSONObject()
.withString("projectId", "thread-3")
.withString("projectTitle", "硬件审计协作")
.withBoolean("isGroup", false);
JSONObject consoleConversation = new StubJSONObject()
.withString("projectId", "thread-4")
.withString("projectTitle", "BOSS移动控制台")
.withBoolean("isGroup", false);
JSONObject conversationsPayload = new StubJSONObject()
.withObjectArray(
"conversations",
allowedThreadConversation,
masterAgentConversation,
auditConversation,
consoleConversation
);
List<JSONObject> filtered = GroupCreateActivity.collectSelectableConversationItems(conversationsPayload, null);
assertEquals(1, filtered.size());
assertEquals("thread-1", filtered.get(0).optString("projectId", ""));
}
@Test
public void reconcileSelectedProjectIds_keepsManualDeselectionWhenCandidatesStayTheSame() {
Set<String> previousCandidateIds = linkedSet("thread-1", "thread-2", "thread-3");
Set<String> currentSelectedIds = linkedSet("thread-1", "thread-3");
Set<String> nextCandidateIds = linkedSet("thread-1", "thread-2", "thread-3");
Set<String> reconciled = GroupCreateActivity.reconcileSelectedProjectIds(
currentSelectedIds,
previousCandidateIds,
nextCandidateIds
);
assertEquals(2, reconciled.size());
assertTrue(reconciled.contains("thread-1"));
assertTrue(reconciled.contains("thread-3"));
assertFalse(reconciled.contains("thread-2"));
}
@Test
public void canCreateGroupChat_blocksWhileRefreshingOrCreating() {
Set<String> selectedProjectIds = linkedSet("thread-1");
assertFalse(GroupCreateActivity.canCreateGroupChat(true, false, selectedProjectIds, true));
assertFalse(GroupCreateActivity.canCreateGroupChat(false, true, selectedProjectIds, true));
assertTrue(GroupCreateActivity.canCreateGroupChat(false, false, selectedProjectIds, true));
assertFalse(GroupCreateActivity.canCreateGroupChat(false, false, linkedSet(), true));
}
@Test
public void canCreateGroupChat_requiresTwoSelectionsWhenCreatedFromConversationList() {
assertFalse(GroupCreateActivity.canCreateGroupChat(false, false, linkedSet("thread-1"), false));
assertTrue(GroupCreateActivity.canCreateGroupChat(false, false, linkedSet("thread-1", "thread-2"), false));
}
@Test
public void toCandidateConversationRow_usesConversationCardChromeAndSelectedBadge() {
JSONObject item = new StubJSONObject()
.withString("projectId", "thread-1")
.withString("projectTitle", "北区试产线回归")
.withString("folderLabel", "Mac Studio")
.withString("lastMessagePreview", "认证链路交接线程")
.withString("latestReplyLabel", "09:26")
.withInt("unreadCount", 3)
.withInt("activityIconCount", 2);
WechatSurfaceMapper.ConversationRow row = GroupCreateActivity.toCandidateConversationRow(item, true);
assertEquals("北区试产线回归", row.threadTitle);
assertEquals("Mac Studio", row.folderLabel);
assertEquals("认证链路交接线程", row.lastMessagePreview);
assertEquals("09:26", row.timeLabel);
assertEquals("已选", row.topPinnedLabel);
assertEquals(0, row.unreadCount);
assertEquals(0, row.activityIconCount);
}
private static Set<String> linkedSet(String... values) {
Set<String> result = new LinkedHashSet<>();
for (String value : values) {
result.add(value);
}
return result;
}
private static final class StubJSONObject extends JSONObject {
private final java.util.Map<String, Object> values = new java.util.HashMap<>();
StubJSONObject withString(String key, String value) {
values.put(key, value);
return this;
}
StubJSONObject withBoolean(String key, boolean value) {
values.put(key, value);
return this;
}
StubJSONObject withInt(String key, int value) {
values.put(key, value);
return this;
}
StubJSONObject withObjectArray(String key, JSONObject... entries) {
values.put(key, new StubJSONArray(entries));
return this;
}
@Override
public String optString(String key, String defaultValue) {
Object value = values.get(key);
return value instanceof String ? (String) value : defaultValue;
}
@Override
public boolean optBoolean(String key, boolean defaultValue) {
Object value = values.get(key);
return value instanceof Boolean ? (Boolean) value : defaultValue;
}
@Override
public int optInt(String key, int defaultValue) {
Object value = values.get(key);
return value instanceof Integer ? (Integer) value : defaultValue;
}
@Override
public JSONArray optJSONArray(String key) {
Object value = values.get(key);
return value instanceof JSONArray ? (JSONArray) value : null;
}
}
private static final class StubJSONArray extends JSONArray {
private final JSONObject[] entries;
StubJSONArray(JSONObject... entries) {
this.entries = entries == null ? new JSONObject[0] : entries;
}
@Override
public int length() {
return entries.length;
}
@Override
public JSONObject optJSONObject(int index) {
if (index < 0 || index >= entries.length) {
return null;
}
return entries[index];
}
}
}

View File

@@ -0,0 +1,197 @@
package com.hyzq.boss;
import static org.junit.Assert.assertTrue;
import android.content.Intent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class GroupCreateActivityUiTest {
@Test
public void renderCreatePageUsesCompactSummaryCardsForSourceFlow() throws Exception {
Intent intent = new Intent()
.putExtra(GroupCreateActivity.EXTRA_SOURCE_PROJECT_ID, "source-1")
.putExtra(GroupCreateActivity.EXTRA_SOURCE_PROJECT_NAME, "北区试产线回归");
TestGroupCreateActivity activity = Robolectric
.buildActivity(TestGroupCreateActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderCreatePage",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildConversationsPayload()),
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content.getChildAt(0), "北区试产线回归"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "从当前会话发起群聊"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "thread-7 · Mac Studio · 3 个参与线程"));
assertTrue(viewTreeContainsText(content.getChildAt(1), "选择其他线程"));
assertTrue(viewTreeContainsText(content.getChildAt(2), "已选 2 个线程"));
}
@Test
public void toggleSelectionRefreshesRenderedSelectionSummary() throws Exception {
Intent intent = new Intent()
.putExtra(GroupCreateActivity.EXTRA_SOURCE_PROJECT_ID, "source-1")
.putExtra(GroupCreateActivity.EXTRA_SOURCE_PROJECT_NAME, "北区试产线回归");
TestGroupCreateActivity activity = Robolectric
.buildActivity(TestGroupCreateActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderCreatePage",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildConversationsPayload()),
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
ReflectionHelpers.callInstanceMethod(
activity,
"toggleSelection",
ReflectionHelpers.ClassParameter.from(String.class, "thread-2")
);
ReflectionHelpers.callInstanceMethod(
activity,
"toggleSelection",
ReflectionHelpers.ClassParameter.from(String.class, "thread-3")
);
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content.getChildAt(2), "至少选择 1 个其他线程"));
}
@Test
public void renderCreatePageUsesInlineActionRowInsteadOfStackedButtons() throws Exception {
Intent intent = new Intent()
.putExtra(GroupCreateActivity.EXTRA_SOURCE_PROJECT_ID, "source-1")
.putExtra(GroupCreateActivity.EXTRA_SOURCE_PROJECT_NAME, "北区试产线回归");
TestGroupCreateActivity activity = Robolectric
.buildActivity(TestGroupCreateActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderCreatePage",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildConversationsPayload()),
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
LinearLayout content = activity.findViewById(R.id.screen_content);
View lastChild = content.getChildAt(content.getChildCount() - 1);
assertTrue(lastChild instanceof LinearLayout);
assertTrue(viewTreeContainsText(lastChild, "取消"));
assertTrue(viewTreeContainsText(lastChild, "创建群聊"));
}
@Test
public void renderCreatePageSupportsRootCreateFlowWithoutParticipantsPayload() throws Exception {
Intent intent = new Intent();
TestGroupCreateActivity activity = Robolectric
.buildActivity(TestGroupCreateActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderCreatePage",
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildConversationsPayload()),
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content.getChildAt(0), "发起新群聊"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "从会话列表直接建群"));
assertTrue(viewTreeContainsText(content.getChildAt(1), "选择其他线程"));
}
private static JSONObject buildParticipantsPayload() throws Exception {
JSONObject threadMeta = new JSONObject()
.put("threadDisplayName", "北区试产线回归")
.put("threadId", "thread-7")
.put("folderName", "Mac Studio");
JSONArray participants = new JSONArray()
.put(new JSONObject().put("projectId", "source-1"))
.put(new JSONObject().put("projectId", "thread-2"))
.put(new JSONObject().put("projectId", "thread-3"));
return new JSONObject()
.put("threadMeta", threadMeta)
.put("participants", participants);
}
private static JSONObject buildConversationsPayload() throws Exception {
JSONArray conversations = new JSONArray()
.put(new JSONObject()
.put("projectId", "thread-2")
.put("projectTitle", "查询树莓派二代")
.put("folderLabel", "Mac Studio")
.put("lastMessagePreview", "检查树莓派二代供电链路")
.put("latestReplyLabel", "09:28")
.put("conversationType", "single_device")
.put("isGroup", false))
.put(new JSONObject()
.put("projectId", "thread-3")
.put("projectTitle", "Boss 线程修复")
.put("folderLabel", "Boss")
.put("lastMessagePreview", "统一顶部按钮样式")
.put("latestReplyLabel", "09:31")
.put("conversationType", "single_device")
.put("isGroup", false))
.put(new JSONObject()
.put("projectId", "thread-4")
.put("projectTitle", "主Agent")
.put("folderLabel", "Boss")
.put("lastMessagePreview", "系统自动加入")
.put("latestReplyLabel", "09:32")
.put("conversationType", "single_device")
.put("isGroup", false));
return new JSONObject().put("conversations", conversations);
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (expectedText.contentEquals(text)) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
public static class TestGroupCreateActivity extends GroupCreateActivity {
@Override
protected void reload() {
// Tests drive renderCreatePage manually to avoid network work.
}
}
}

View File

@@ -0,0 +1,710 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import android.content.Intent;
import android.os.Looper;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowDialog;
import org.robolectric.util.ReflectionHelpers;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.ProtocolException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.List;
import java.util.concurrent.AbstractExecutorService;
import java.util.concurrent.TimeUnit;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class GroupInfoActivityTest {
@Test
public void renderGroupUsesLightweightHeaderMenuAndMemberList() throws Exception {
Intent intent = new Intent()
.putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1")
.putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群");
TestGroupInfoActivity activity = Robolectric
.buildActivity(TestGroupInfoActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderGroup",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload())
);
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content.getChildAt(0), "巡检协作群"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "协作群聊"));
assertTrue(viewTreeContainsText(content.getChildAt(1), "线程详情"));
assertTrue(viewTreeContainsText(content.getChildAt(1), "查看当前群聊对应项目"));
assertTrue(viewTreeContainsText(content, "群成员"));
assertTrue(viewTreeContainsText(content, "Boss 移动控制台"));
assertFalse(viewTreeContainsText(content, "群聊成员可点击查看对应项目详情。"));
}
@Test
public void threadDetailMenuRowStillOpensProjectDetail() throws Exception {
Intent intent = new Intent()
.putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1")
.putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群");
TestGroupInfoActivity activity = Robolectric
.buildActivity(TestGroupInfoActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderGroup",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload())
);
View threadDetailRow = findClickableViewContainingText(
activity.findViewById(R.id.screen_content),
"线程详情"
);
assertNotNull(threadDetailRow);
threadDetailRow.performClick();
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
assertNotNull(nextIntent);
assertEquals(
ProjectDetailActivity.class.getName(),
nextIntent.getComponent().getClassName()
);
assertEquals(
"group-1",
nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_ID)
);
assertEquals(
"巡检协作群",
nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME)
);
}
@Test
public void renderGroupShowsRepairEntryForDirtyMembers() throws Exception {
Intent intent = new Intent()
.putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1")
.putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群");
TestGroupInfoActivity activity = Robolectric
.buildActivity(TestGroupInfoActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderGroup",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildRepairParticipantsPayload())
);
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "修复群成员"));
assertTrue(viewTreeContainsText(content, "当前群聊里有失效或不可下发的线程引用,请重新整理群成员。"));
assertTrue(viewTreeContainsText(content, "失效"));
}
@Test
public void renderGroupShowsOrchestrationBackendStateAndFallbackReason() throws Exception {
Intent intent = new Intent()
.putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1")
.putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群");
TestGroupInfoActivity activity = Robolectric
.buildActivity(TestGroupInfoActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderGroup",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildOrchestrationBackendPayload())
);
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "编排后端"));
assertTrue(viewTreeContainsText(content, "当前Boss Native Orchestrator · OMX 可用"));
assertTrue(viewTreeContainsText(content, "OMX Team Runtime 当前可用,当前可切换到该后端。"));
}
@Test
public void renderGroupShowsLightReminderPreferenceRow() throws Exception {
Intent intent = new Intent()
.putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1")
.putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群");
TestGroupInfoActivity activity = Robolectric
.buildActivity(TestGroupInfoActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderGroup",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload(true)),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildOrchestrationBackendPayload())
);
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "推荐下发默认轻提醒"));
assertTrue(viewTreeContainsText(content, "已开启"));
}
@Test
public void renderGroupShowsOmxFallbackHintWhenOmxRuntimeIsUnavailable() throws Exception {
Intent intent = new Intent()
.putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1")
.putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群");
TestGroupInfoActivity activity = Robolectric
.buildActivity(TestGroupInfoActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderGroup",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildUnavailableOrchestrationBackendPayload())
);
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "当前Boss Native Orchestrator · 请求OMX Team Runtime · OMX 受限"));
assertTrue(viewTreeContainsText(content, "OMX Team Runtime 当前不可用,当前已自动回退到 Boss Native Orchestrator。"));
}
@Test
public void groupInfoUsesOverflowMenuInTopBar() {
Intent intent = new Intent()
.putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1")
.putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群");
TestGroupInfoActivity activity = Robolectric
.buildActivity(TestGroupInfoActivity.class, intent)
.setup()
.get();
ImageButton headerAction = activity.findViewById(R.id.screen_header_action);
ImageButton refreshButton = activity.findViewById(R.id.screen_refresh_button);
assertEquals("更多", String.valueOf(headerAction.getContentDescription()));
assertEquals(View.GONE, refreshButton.getVisibility());
ReflectionHelpers.callInstanceMethod(activity, "showMoreMenu");
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
assertTrue(latestDialog instanceof AlertDialog);
ListView listView = ((AlertDialog) latestDialog).getListView();
assertTrue(viewTreeContainsText(listView.getAdapter().getView(0, null, listView), "改名"));
assertTrue(viewTreeContainsText(listView.getAdapter().getView(1, null, listView), "刷新"));
}
@Test
public void saveOrchestrationBackendUsesScopedEndpoint() throws Exception {
Intent intent = new Intent()
.putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1")
.putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群");
TestGroupInfoActivity activity = Robolectric
.buildActivity(TestGroupInfoActivity.class, intent)
.setup()
.get();
RecordingConnection connection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/projects/group-1/orchestration-backend")
);
ReflectionHelpers.setField(activity, "apiClient", new RecordingBossApiClient(connection));
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
ReflectionHelpers.callInstanceMethod(
activity,
"saveOrchestrationBackend",
ReflectionHelpers.ClassParameter.from(String.class, "omx-team")
);
Shadows.shadowOf(Looper.getMainLooper()).idle();
assertEquals("/api/v1/projects/group-1/orchestration-backend", connection.lastPath);
assertEquals("PATCH", connection.requestMethodValue);
assertEquals("{\"requestedBackendId\":\"omx-team\"}", connection.requestBody());
}
@Test
public void saveDispatchReminderPreferenceUsesScopedEndpoint() throws Exception {
Intent intent = new Intent()
.putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1")
.putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群");
TestGroupInfoActivity activity = Robolectric
.buildActivity(TestGroupInfoActivity.class, intent)
.setup()
.get();
RecordingConnection connection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/projects/group-1/dispatch-reminder")
);
ReflectionHelpers.setField(activity, "apiClient", new RecordingBossApiClient(connection));
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
ReflectionHelpers.callInstanceMethod(
activity,
"saveDispatchReminderPreference",
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
Shadows.shadowOf(Looper.getMainLooper()).idle();
assertEquals("/api/v1/projects/group-1/dispatch-reminder", connection.lastPath);
assertEquals("PATCH", connection.requestMethodValue);
assertEquals("{\"lightDispatchReminderEnabled\":true}", connection.requestBody());
}
@Test
public void matchingConversationUpdatedEventTriggersReload() throws Exception {
Intent intent = new Intent()
.putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1")
.putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群");
TestGroupInfoActivity activity = Robolectric
.buildActivity(TestGroupInfoActivity.class, intent)
.setup()
.resume()
.get();
activity.reloadEnabled = true;
activity.reloadCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "group-1"))
)
);
Shadows.shadowOf(Looper.getMainLooper()).idle();
assertEquals(1, activity.reloadCount);
}
@Test
public void unrelatedProjectMessagesEventDoesNotTriggerReload() throws Exception {
Intent intent = new Intent()
.putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1")
.putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群");
TestGroupInfoActivity activity = Robolectric
.buildActivity(TestGroupInfoActivity.class, intent)
.setup()
.resume()
.get();
activity.reloadEnabled = true;
activity.reloadCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
)
);
Shadows.shadowOf(Looper.getMainLooper()).idle();
assertEquals(0, activity.reloadCount);
}
private static JSONObject buildDetailPayload() throws Exception {
return buildDetailPayload(false);
}
private static JSONObject buildDetailPayload(boolean lightReminderEnabled) throws Exception {
JSONObject threadMeta = new JSONObject()
.put("threadId", "group-thread-3")
.put("folderName", "Boss");
JSONObject project = new JSONObject()
.put("id", "group-1")
.put("name", "巡检协作群")
.put("isGroup", true)
.put("collaborationMode", "development")
.put("lightDispatchReminderEnabled", lightReminderEnabled)
.put("threadMeta", threadMeta);
return new JSONObject().put("project", project);
}
private static JSONObject buildParticipantsPayload() throws Exception {
JSONArray participants = new JSONArray()
.put(new JSONObject()
.put("projectId", "group-1")
.put("threadDisplayName", "巡检协作群")
.put("folderName", "Boss")
.put("deviceId", "Mac Studio")
.put("threadId", "group-thread-3")
.put("isSourceProject", true))
.put(new JSONObject()
.put("projectId", "project-2")
.put("threadDisplayName", "Boss 移动控制台")
.put("folderName", "Boss")
.put("deviceId", "MacBook Pro")
.put("threadId", "thread-8"));
return new JSONObject().put("participants", participants);
}
private static JSONObject buildRepairParticipantsPayload() throws Exception {
JSONArray participants = new JSONArray()
.put(new JSONObject()
.put("projectId", "master-agent")
.put("threadDisplayName", "主 Agent 汇总")
.put("folderName", "主控线程")
.put("deviceId", "Mac Studio")
.put("threadId", "master-agent-thread")
.put("status", "invalid_target")
.put("statusLabel", "不是可下发线程")
.put("canOpenProject", true));
return new JSONObject()
.put("participants", participants)
.put("repairRequired", true)
.put("repairReason", "当前群聊里有失效或不可下发的线程引用,请重新整理群成员。")
.put("validParticipantCount", 0)
.put("invalidParticipantCount", 1);
}
private static JSONObject buildOrchestrationBackendPayload() throws Exception {
JSONArray availableChoices = new JSONArray()
.put(new JSONObject()
.put("backendId", "boss-native-orchestrator")
.put("label", "Boss Native Orchestrator")
.put("selectable", true)
.put("current", true))
.put(new JSONObject()
.put("backendId", "omx-team")
.put("label", "OMX Team Runtime")
.put("selectable", true)
.put("current", false));
return new JSONObject()
.put("currentBackendId", "boss-native-orchestrator")
.put("requestedBackendId", "boss-native-orchestrator")
.put("availableChoices", availableChoices)
.put("omxAvailability", new JSONObject()
.put("selectable", true)
.put("reason", "ready")
.put("reasonLabel", "OMX Team Runtime 可用。"));
}
private static JSONObject buildUnavailableOrchestrationBackendPayload() throws Exception {
JSONArray availableChoices = new JSONArray()
.put(new JSONObject()
.put("backendId", "boss-native-orchestrator")
.put("label", "Boss Native Orchestrator")
.put("selectable", true)
.put("current", true))
.put(new JSONObject()
.put("backendId", "omx-team")
.put("label", "OMX Team Runtime")
.put("selectable", false)
.put("current", false));
return new JSONObject()
.put("currentBackendId", "boss-native-orchestrator")
.put("requestedBackendId", "omx-team")
.put("availableChoices", availableChoices)
.put("omxAvailability", new JSONObject()
.put("selectable", false)
.put("reason", "script_not_found")
.put("reasonLabel", "OMX Team Runtime 当前不可用。"));
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (expectedText.contentEquals(text)) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
private static View findClickableViewContainingText(View root, String expectedText) {
if (root == null) {
return null;
}
if (viewTreeContainsText(root, expectedText) && root.isClickable()) {
return root;
}
if (!(root instanceof ViewGroup)) {
return null;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
View match = findClickableViewContainingText(group.getChildAt(index), expectedText);
if (match != null) {
return match;
}
}
return null;
}
public static class TestGroupInfoActivity extends GroupInfoActivity {
private boolean reloadEnabled;
private int reloadCount;
@Override
protected void reload() {
if (!reloadEnabled) {
return;
}
reloadCount += 1;
replaceContent(BossUi.buildEmptyCard(this, "test reload"));
setRefreshing(false);
}
}
private static final class RecordingBossApiClient extends BossApiClient {
private final RecordingConnection connection;
RecordingBossApiClient(RecordingConnection connection) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
this.connection = connection;
}
@Override
HttpURLConnection openConnection(String path) {
connection.lastPath = path;
return connection;
}
@Override
String encode(String value) {
return value;
}
}
private static final class RecordingConnection extends HttpURLConnection {
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
private final Map<String, String> requestHeaders = new HashMap<>();
private String requestMethodValue = "GET";
private String lastPath = "";
RecordingConnection(URL url) {
super(url);
}
@Override
public void disconnect() {}
@Override
public boolean usingProxy() {
return false;
}
@Override
public void connect() {}
@Override
public void setRequestMethod(String method) throws ProtocolException {
requestMethodValue = method;
}
@Override
public void setRequestProperty(String key, String value) {
requestHeaders.put(key, value);
}
@Override
public String getRequestProperty(String key) {
return requestHeaders.get(key);
}
@Override
public OutputStream getOutputStream() {
return requestBody;
}
@Override
public int getResponseCode() {
return 200;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
}
String requestBody() {
return requestBody.toString(StandardCharsets.UTF_8);
}
}
private static final class InMemorySharedPreferences implements android.content.SharedPreferences {
private final Map<String, String> values = new HashMap<>();
@Override
public Map<String, ?> getAll() {
return Collections.unmodifiableMap(values);
}
@Override
public String getString(String key, String defValue) {
return values.getOrDefault(key, defValue);
}
@Override
public Set<String> getStringSet(String key, Set<String> defValues) {
throw new UnsupportedOperationException();
}
@Override
public int getInt(String key, int defValue) {
throw new UnsupportedOperationException();
}
@Override
public long getLong(String key, long defValue) {
throw new UnsupportedOperationException();
}
@Override
public float getFloat(String key, float defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean getBoolean(String key, boolean defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean contains(String key) {
return values.containsKey(key);
}
@Override
public Editor edit() {
return new Editor() {
@Override
public Editor putString(String key, String value) {
values.put(key, value);
return this;
}
@Override
public Editor remove(String key) {
values.remove(key);
return this;
}
@Override
public Editor clear() {
values.clear();
return this;
}
@Override
public void apply() {}
@Override
public boolean commit() {
return true;
}
@Override
public Editor putStringSet(String key, Set<String> values) {
throw new UnsupportedOperationException();
}
@Override
public Editor putInt(String key, int value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putLong(String key, long value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putFloat(String key, float value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putBoolean(String key, boolean value) {
throw new UnsupportedOperationException();
}
};
}
@Override
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
@Override
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
}
private static final class DirectExecutorService extends AbstractExecutorService {
private boolean shutdown;
@Override
public void shutdown() {
shutdown = true;
}
@Override
public List<Runnable> shutdownNow() {
shutdown = true;
return java.util.Collections.emptyList();
}
@Override
public boolean isShutdown() {
return shutdown;
}
@Override
public boolean isTerminated() {
return shutdown;
}
@Override
public boolean awaitTermination(long timeout, TimeUnit unit) {
return true;
}
@Override
public void execute(Runnable command) {
command.run();
}
}
}

View File

@@ -0,0 +1,50 @@
package com.hyzq.boss;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class MainActivityConversationAutoRefreshTest {
@Test
public void conversationAutoRefresh_onlyArmsOnVisibleConversationTab() {
org.robolectric.android.controller.ActivityController<MainActivity> controller =
Robolectric.buildActivity(MainActivity.class).setup().resume();
MainActivity activity = controller.get();
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
.edit()
.putString("restore_token", "test-restore-token")
.apply();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
ReflectionHelpers.ClassParameter.from(boolean.class, false));
assertTrue(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "devices"),
ReflectionHelpers.ClassParameter.from(boolean.class, false));
Shadows.shadowOf(activity.getMainLooper()).idle();
assertFalse(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
ReflectionHelpers.ClassParameter.from(boolean.class, false));
Shadows.shadowOf(activity.getMainLooper()).idle();
assertTrue(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
controller.pause();
assertFalse(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
}
}

View File

@@ -0,0 +1,206 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import android.content.Intent;
import android.view.View;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import androidx.recyclerview.widget.RecyclerView;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class MainActivityConversationSearchTest {
@Test
public void filterConversationItemsMatchesProjectTitleAndFolder() throws Exception {
JSONArray source = new JSONArray()
.put(new JSONObject()
.put("projectId", "p1")
.put("projectTitle", "500Gcode")
.put("folderLabel", "Mac Studio")
.put("lastMessagePreview", "线程链路正常"))
.put(new JSONObject()
.put("projectId", "p2")
.put("projectTitle", "Figma 联调")
.put("folderLabel", "设计")
.put("lastMessagePreview", "等待审阅"));
JSONArray filteredByProject = MainActivity.filterConversationItems(source, "500g");
JSONArray filteredByFolder = MainActivity.filterConversationItems(source, "设计");
assertEquals(1, filteredByProject.length());
assertEquals("p1", filteredByProject.optJSONObject(0).optString("projectId", ""));
assertEquals(1, filteredByFolder.length());
assertEquals("p2", filteredByFolder.optJSONObject(0).optString("projectId", ""));
}
@Test
public void filterConversationItemsMatchesFolderArchiveSearchAliases() throws Exception {
JSONArray source = new JSONArray()
.put(new JSONObject()
.put("projectId", "folder-boss")
.put("conversationType", "folder_archive")
.put("folderKey", "mac-studio:boss")
.put("projectTitle", "Boss")
.put("threadTitle", "Boss")
.put("folderLabel", "2 个线程 · 最近:发布回滚")
.put("searchAliases", new JSONArray().put("发布回滚").put("发布回滚").put("Android UI 收尾"))
.put("searchTargetProjectIds", new JSONArray().put("thread-revert-1").put("thread-revert-2").put("thread-ui"))
.put("lastMessagePreview", "最近:发布回滚")
.put("latestReplyLabel", "11:00"));
JSONArray filtered = MainActivity.filterConversationItems(source, "发布回滚");
assertEquals(1, filtered.length());
assertEquals("folder-boss", filtered.optJSONObject(0).optString("projectId", ""));
assertEquals("发布回滚", filtered.optJSONObject(0).optString("searchMatchLabel", ""));
assertEquals("thread-revert-1", filtered.optJSONObject(0).optString("searchMatchProjectId", ""));
assertEquals(2, filtered.optJSONObject(0).optJSONArray("searchMatchProjectIds").length());
assertEquals("thread-revert-2", filtered.optJSONObject(0).optJSONArray("searchMatchProjectIds").optString(1, ""));
}
@Test
public void conversationsHeader_usesSearchIconAndPlusButton() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(activity, "renderCurrentTab");
ImageButton searchButton = activity.findViewById(R.id.search_button);
ImageButton actionButton = activity.findViewById(R.id.refresh_button);
LinearLayout titleGroup = activity.findViewById(R.id.top_title_group);
EditText searchInput = activity.findViewById(R.id.top_search_input);
assertEquals("搜索", String.valueOf(searchButton.getContentDescription()));
assertEquals("快捷操作", String.valueOf(actionButton.getContentDescription()));
assertEquals(LinearLayout.VISIBLE, titleGroup.getVisibility());
assertEquals(EditText.GONE, searchInput.getVisibility());
}
@Test
public void searchMode_keepsSameInputViewAndRetainsFocusWhileFiltering() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ImageButton searchButton = activity.findViewById(R.id.search_button);
searchButton.performClick();
Shadows.shadowOf(activity.getMainLooper()).idle();
EditText searchInput = activity.findViewById(R.id.top_search_input);
assertTrue(ReflectionHelpers.getField(activity, "conversationSearchMode"));
assertTrue(searchInput.isShown());
searchInput.requestFocus();
assertTrue(searchInput.isFocused());
searchInput.setText("树莓派");
Shadows.shadowOf(activity.getMainLooper()).idle();
EditText searchInputAfter = activity.findViewById(R.id.top_search_input);
assertSame(searchInput, searchInputAfter);
assertEquals("树莓派", searchInputAfter.getText().toString());
assertTrue(searchInputAfter.isFocused());
ImageButton actionButton = activity.findViewById(R.id.refresh_button);
assertEquals(ImageButton.GONE, actionButton.getVisibility());
assertFalse(activity.findViewById(R.id.search_button).isShown());
}
@Test
public void searchHitInsideArchivedProject_keepsProjectContextAndOpensFolderPage() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", new JSONArray()
.put(new JSONObject()
.put("projectId", "folder-boss")
.put("conversationType", "folder_archive")
.put("folderKey", "mac-studio:boss")
.put("folderLabel", "Boss")
.put("projectTitle", "Boss")
.put("threadTitle", "Boss")
.put("lastMessagePreview", "最近:发布回滚")
.put("latestReplyLabel", "11:00")
.put("searchAliases", new JSONArray().put("发布回滚").put("发布回滚").put("Android UI 收尾"))
.put("searchTargetProjectIds", new JSONArray().put("thread-revert-1").put("thread-revert-2").put("thread-ui"))));
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(activity, "enterConversationSearchMode");
Shadows.shadowOf(activity.getMainLooper()).idle();
EditText searchInput = activity.findViewById(R.id.top_search_input);
searchInput.setText("发布回滚");
Shadows.shadowOf(activity.getMainLooper()).idle();
RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
View row = getRecyclerChild(list, 0);
assertTrue(viewTreeContainsText(row, "Boss / 发布回滚"));
row.performClick();
Shadows.shadowOf(activity.getMainLooper()).idle();
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
assertEquals(ConversationFolderActivity.class.getName(), nextIntent.getComponent().getClassName());
assertEquals("mac-studio:boss", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY));
assertEquals("Boss", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_FOLDER_NAME));
assertEquals("thread-revert-1", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_ID));
assertEquals(2, nextIntent.getStringArrayExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_IDS).length);
assertEquals("thread-revert-2", nextIntent.getStringArrayExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_IDS)[1]);
assertEquals("发布回滚", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_LABEL));
}
private static JSONArray buildConversations() throws Exception {
return new JSONArray()
.put(new JSONObject()
.put("projectId", "p1")
.put("projectTitle", "查询树莓派二代")
.put("threadTitle", "查询树莓派二代")
.put("folderLabel", "Mac Studio")
.put("lastMessagePreview", "线程链路正常")
.put("latestReplyLabel", "09:40")
.put("conversationType", "single_device"));
}
private static View getRecyclerChild(RecyclerView recyclerView, int position) {
RecyclerView.Adapter adapter = recyclerView.getAdapter();
int viewType = adapter.getItemViewType(position);
RecyclerView.ViewHolder holder = adapter.createViewHolder(recyclerView, viewType);
adapter.bindViewHolder(holder, position);
return ((android.widget.FrameLayout) holder.itemView).getChildAt(0);
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof android.widget.TextView) {
CharSequence text = ((android.widget.TextView) root).getText();
if (expectedText.contentEquals(text)) {
return true;
}
}
if (!(root instanceof LinearLayout)) {
return false;
}
LinearLayout group = (LinearLayout) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,207 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.recyclerview.widget.RecyclerView;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class MainActivityConversationSelectionTest {
@Test
public void conversationsSelectionMode_requiresAtLeastTwoSelectionsForGroupChat() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ImageButton actionButton = activity.findViewById(R.id.refresh_button);
actionButton.performClick();
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(View.VISIBLE, activity.findViewById(R.id.conversation_quick_actions_overlay).getVisibility());
activity.findViewById(R.id.quick_action_group_chat).performClick();
Shadows.shadowOf(activity.getMainLooper()).idle();
assertTrue(ReflectionHelpers.getField(activity, "conversationSelectionMode"));
RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
assertTrue(recyclerContainsText(list, "发起群聊"));
assertTrue(recyclerContainsText(list, "至少选择 2 个线程"));
ReflectionHelpers.callInstanceMethod(activity, "toggleConversationSelection",
ReflectionHelpers.ClassParameter.from(String.class, "thread-1"));
assertTrue(recyclerContainsText(list, "已选 1 个线程"));
assertTrue(recyclerContainsText(list, "至少选择 2 个线程"));
ReflectionHelpers.callInstanceMethod(activity, "toggleConversationSelection",
ReflectionHelpers.ClassParameter.from(String.class, "thread-2"));
assertTrue(recyclerContainsText(list, "已选 2 个线程"));
assertFalse(recyclerContainsText(list, "至少选择 2 个线程"));
}
@Test
public void selectionModeRowsRenderSelectorInTrailingArea() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(activity, "enterConversationSelectionMode");
RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
View row = getRecyclerChild(list, 2);
assertTrue("多选模式应显示单选圆点", viewTreeContainsContentDescription(row, "未选中会话"));
}
@Test
public void selectionMode_hidesSystemManagedConversations() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(activity, "enterConversationSelectionMode");
RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
assertFalse(recyclerContainsText(list, "主Agent"));
assertFalse(recyclerContainsText(list, "硬件审计协作"));
assertFalse(recyclerContainsText(list, "BOSS移动控制台"));
assertTrue(recyclerContainsText(list, "查询树莓派二代"));
assertTrue(recyclerContainsText(list, "Boss 线程修复"));
}
@Test
public void topPlusAction_opensWechatStyleDropdownMenu() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ImageButton actionButton = activity.findViewById(R.id.refresh_button);
actionButton.performClick();
Shadows.shadowOf(activity.getMainLooper()).idle();
View overlay = activity.findViewById(R.id.conversation_quick_actions_overlay);
View menu = activity.findViewById(R.id.conversation_quick_actions_menu);
assertEquals(View.VISIBLE, overlay.getVisibility());
assertEquals(View.VISIBLE, menu.getVisibility());
assertTrue(viewTreeContainsText(menu, "添加设备"));
assertTrue(viewTreeContainsText(menu, "扫一扫"));
assertTrue(viewTreeContainsText(menu, "发起群聊"));
}
private static View getRecyclerChild(RecyclerView recyclerView, int position) {
RecyclerView.Adapter adapter = recyclerView.getAdapter();
int viewType = adapter.getItemViewType(position);
RecyclerView.ViewHolder holder = adapter.createViewHolder(recyclerView, viewType);
adapter.bindViewHolder(holder, position);
FrameLayout container = (FrameLayout) holder.itemView;
return container.getChildAt(0);
}
private static boolean recyclerContainsText(RecyclerView recyclerView, String expectedText) {
RecyclerView.Adapter adapter = recyclerView.getAdapter();
for (int index = 0; index < adapter.getItemCount(); index += 1) {
if (viewTreeContainsText(getRecyclerChild(recyclerView, index), expectedText)) {
return true;
}
}
return false;
}
private static JSONArray buildConversations() throws Exception {
return new JSONArray()
.put(new JSONObject()
.put("projectId", "master-agent")
.put("projectTitle", "主Agent")
.put("threadTitle", "主Agent")
.put("folderLabel", "Boss")
.put("lastMessagePreview", "线程链路正常")
.put("latestReplyLabel", "09:41")
.put("conversationType", "single_device"))
.put(new JSONObject()
.put("projectId", "audit-1")
.put("projectTitle", "硬件审计协作")
.put("threadTitle", "硬件审计协作")
.put("folderLabel", "Mac Studio")
.put("lastMessagePreview", "检查摄像头供电链路")
.put("latestReplyLabel", "09:42")
.put("conversationType", "single_device"))
.put(new JSONObject()
.put("projectId", "console-1")
.put("projectTitle", "BOSS移动控制台")
.put("threadTitle", "BOSS移动控制台")
.put("folderLabel", "Boss")
.put("lastMessagePreview", "统一顶部按钮样式")
.put("latestReplyLabel", "09:43")
.put("conversationType", "single_device"))
.put(new JSONObject()
.put("projectId", "thread-1")
.put("projectTitle", "查询树莓派二代")
.put("threadTitle", "查询树莓派二代")
.put("folderLabel", "Mac Studio")
.put("lastMessagePreview", "树莓派二代参数查询")
.put("latestReplyLabel", "09:44")
.put("conversationType", "single_device"))
.put(new JSONObject()
.put("projectId", "thread-2")
.put("projectTitle", "Boss 线程修复")
.put("threadTitle", "Boss 线程修复")
.put("folderLabel", "Boss")
.put("lastMessagePreview", "修复群聊回流")
.put("latestReplyLabel", "09:45")
.put("conversationType", "single_device"));
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (expectedText.contentEquals(text)) {
return true;
}
}
if (!(root instanceof LinearLayout)) {
return false;
}
LinearLayout group = (LinearLayout) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
private static boolean viewTreeContainsContentDescription(View root, String expectedText) {
CharSequence description = root.getContentDescription();
if (expectedText.contentEquals(description)) {
return true;
}
if (!(root instanceof LinearLayout)) {
return false;
}
LinearLayout group = (LinearLayout) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsContentDescription(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,57 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.view.View;
import android.view.View.MeasureSpec;
import android.widget.TextView;
import androidx.recyclerview.widget.RecyclerView;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class MainActivityDevicesRootTest {
@Test
public void devicesTab_hidesLegacySubtitleAndUsesRecyclerList() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "devicesData", new JSONArray()
.put(new JSONObject()
.put("id", "mac-studio")
.put("name", "Mac Studio")
.put("status", "online")
.put("platform", "macOS")
.put("account", "17600003315")));
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "devices"),
ReflectionHelpers.ClassParameter.from(boolean.class, false));
Shadows.shadowOf(activity.getMainLooper()).idle();
TextView subtitle = activity.findViewById(R.id.top_subtitle);
assertEquals(View.GONE, subtitle.getVisibility());
RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
layoutRecyclerView(list);
assertTrue(list.getVisibility() == View.VISIBLE);
assertTrue(list.getAdapter().getItemCount() > 0);
}
private static void layoutRecyclerView(RecyclerView recyclerView) {
int widthSpec = MeasureSpec.makeMeasureSpec(1080, MeasureSpec.EXACTLY);
int heightSpec = MeasureSpec.makeMeasureSpec(2400, MeasureSpec.EXACTLY);
recyclerView.measure(widthSpec, heightSpec);
recyclerView.layout(0, 0, 1080, 2400);
}
}

View File

@@ -0,0 +1,79 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import android.content.Intent;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class MainActivityMeEntryNavigationTest {
@Test
public void masterAgentPromptMeEntryOpensPromptActivityForMasterAgent() {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(
activity,
"openMeEntry",
ReflectionHelpers.ClassParameter.from(String.class, "master_agent_prompt")
);
Intent started = Shadows.shadowOf(activity).getNextStartedActivity();
assertEquals(MasterAgentPromptActivity.class.getName(), started.getComponent().getClassName());
assertEquals("master-agent", started.getStringExtra(MasterAgentPromptActivity.EXTRA_PROJECT_ID));
assertEquals("主 Agent", started.getStringExtra(MasterAgentPromptActivity.EXTRA_PROJECT_NAME));
}
@Test
public void masterAgentMemoryMeEntryOpensMemoryActivityForMasterAgent() {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(
activity,
"openMeEntry",
ReflectionHelpers.ClassParameter.from(String.class, "master_agent_memory")
);
Intent started = Shadows.shadowOf(activity).getNextStartedActivity();
assertEquals(MasterAgentMemoryActivity.class.getName(), started.getComponent().getClassName());
assertEquals("master-agent", started.getStringExtra(MasterAgentMemoryActivity.EXTRA_PROJECT_ID));
assertEquals("主 Agent", started.getStringExtra(MasterAgentMemoryActivity.EXTRA_PROJECT_NAME));
}
@Test
public void masterAgentTakeoverMeEntryOpensTakeoverActivityForMasterAgent() {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(
activity,
"openMeEntry",
ReflectionHelpers.ClassParameter.from(String.class, "master_agent_takeover")
);
Intent started = Shadows.shadowOf(activity).getNextStartedActivity();
assertEquals(MasterAgentTakeoverActivity.class.getName(), started.getComponent().getClassName());
assertEquals("master-agent", started.getStringExtra(MasterAgentTakeoverActivity.EXTRA_PROJECT_ID));
assertEquals("主 Agent", started.getStringExtra(MasterAgentTakeoverActivity.EXTRA_PROJECT_NAME));
}
@Test
public void masterAgentEvolutionMeEntryOpensEvolutionActivity() {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(
activity,
"openMeEntry",
ReflectionHelpers.ClassParameter.from(String.class, "master_agent_evolution")
);
Intent started = Shadows.shadowOf(activity).getNextStartedActivity();
assertEquals(MasterAgentEvolutionActivity.class.getName(), started.getComponent().getClassName());
}
}

Some files were not shown because too many files have changed in this diff Show More