diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectGoalsActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectGoalsActivity.java index 21a0496..ca5d756 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectGoalsActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectGoalsActivity.java @@ -12,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 recentRealtimeEventTimestamps = new LinkedHashMap<>(); @Override protected void onCreate(@Nullable Bundle savedInstanceState) { @@ -26,11 +33,35 @@ public class ProjectGoalsActivity extends BossScreenActivity { projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME); configureScreen("项目目标", projectName == null ? "原生目标清单" : projectName); 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 { @@ -46,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> iterator = recentRealtimeEventTimestamps.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) { + iterator.remove(); + } + } + } + private void renderGoals(@Nullable JSONObject project) { replaceContent(); if (project == null) { diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectVersionsActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectVersionsActivity.java index f95670e..3dc538e 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectVersionsActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectVersionsActivity.java @@ -7,11 +7,18 @@ 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 recentRealtimeEventTimestamps = new LinkedHashMap<>(); @Override protected void onCreate(@Nullable Bundle savedInstanceState) { @@ -19,11 +26,35 @@ public class ProjectVersionsActivity extends BossScreenActivity { 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 { @@ -39,6 +70,70 @@ 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> iterator = recentRealtimeEventTimestamps.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) { + iterator.remove(); + } + } + } + private void renderVersions(@Nullable JSONObject project) { replaceContent(BossUi.buildCard( this, diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectGoalsActivityTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectGoalsActivityTest.java new file mode 100644 index 0000000..43d72a9 --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/ProjectGoalsActivityTest.java @@ -0,0 +1,106 @@ +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 ProjectGoalsActivityTest { + @Test + public void matchingConversationUpdatedEventTriggersReload() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入"); + TestProjectGoalsActivity activity = Robolectric + .buildActivity(TestProjectGoalsActivity.class, 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", "project-1") + .put("note", "project_goals.updated") + ) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(1, activity.reloadCount); + } + + @Test + public void sameProjectNonGoalEventDoesNotTriggerReload() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入"); + TestProjectGoalsActivity activity = Robolectric + .buildActivity(TestProjectGoalsActivity.class, 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", "project-1") + .put("note", "dispatch_execution.updated") + ) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(0, activity.reloadCount); + } + + private static Method findHandleRealtimeEvent() { + for (Method method : ProjectGoalsActivity.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 TestProjectGoalsActivity extends ProjectGoalsActivity { + private boolean reloadEnabled; + private int reloadCount; + + @Override + protected void reload() { + if (!reloadEnabled) { + return; + } + reloadCount += 1; + setRefreshing(false); + } + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectVersionsActivityTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectVersionsActivityTest.java new file mode 100644 index 0000000..1503b5b --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/ProjectVersionsActivityTest.java @@ -0,0 +1,106 @@ +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 ProjectVersionsActivityTest { + @Test + public void matchingGoalRefreshMarkerTriggersReload() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入"); + TestProjectVersionsActivity activity = Robolectric + .buildActivity(TestProjectVersionsActivity.class, 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", "project-1") + .put("note", "project_goals.updated") + ) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(1, activity.reloadCount); + } + + @Test + public void sameProjectNonGoalEventDoesNotTriggerReload() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入"); + TestProjectVersionsActivity activity = Robolectric + .buildActivity(TestProjectVersionsActivity.class, 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", "project-1") + .put("note", "dispatch_execution.updated") + ) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(0, activity.reloadCount); + } + + private static Method findHandleRealtimeEvent() { + for (Method method : ProjectVersionsActivity.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 TestProjectVersionsActivity extends ProjectVersionsActivity { + private boolean reloadEnabled; + private int reloadCount; + + @Override + protected void reload() { + if (!reloadEnabled) { + return; + } + reloadCount += 1; + setRefreshing(false); + } + } +} diff --git a/public/downloads/boss-android-latest.apk b/public/downloads/boss-android-latest.apk index 3e6e455..3d054b7 100644 Binary files a/public/downloads/boss-android-latest.apk and b/public/downloads/boss-android-latest.apk differ diff --git a/public/downloads/boss-android-latest.json b/public/downloads/boss-android-latest.json index 35614b7..bc5f1ac 100644 --- a/public/downloads/boss-android-latest.json +++ b/public/downloads/boss-android-latest.json @@ -1,9 +1,9 @@ { "fileName": "boss-android-v2.5.11-release.apk", "urlPath": "/api/v1/user/ota/package", - "sizeBytes": 3355587, - "updatedAt": "2026-04-07T08:13:31Z", - "sha256": "0a94ce924344c02914f78414ad6bb5276126124e8eae123508a52e2c33aa86d7", + "sizeBytes": 3356442, + "updatedAt": "2026-04-07T08:39:26Z", + "sha256": "292abbd4f6e7953b3e8d8f195ee1c463d16e973a2d81e7a9760a71951167d47a", "versionName": "2.5.11", "versionCode": 24, "buildFlavor": "release" diff --git a/public/downloads/boss-android-v2.5.11-release.apk b/public/downloads/boss-android-v2.5.11-release.apk index 3e6e455..3d054b7 100644 Binary files a/public/downloads/boss-android-v2.5.11-release.apk and b/public/downloads/boss-android-v2.5.11-release.apk differ diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index b02425b..7155b16 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -5040,7 +5040,7 @@ export async function getPreferredDeviceIdForAccount( } export async function toggleGoal(projectId: string, goalId: string) { - return mutateState((state) => { + const goal = await mutateState((state) => { const project = state.projects.find((item) => item.id === projectId); if (!project) throw new Error("PROJECT_NOT_FOUND"); @@ -5062,10 +5062,12 @@ export async function toggleGoal(projectId: string, goalId: string) { project.lastMessageAt = nowIso(); return goal; }); + publishBossEvent("conversation.updated", { projectId, note: "project_goals.updated" }); + return goal; } export async function updateGoalText(projectId: string, goalId: string, text: string) { - return mutateState((state) => { + const goal = await mutateState((state) => { const project = state.projects.find((item) => item.id === projectId); if (!project) throw new Error("PROJECT_NOT_FOUND"); @@ -5078,10 +5080,12 @@ export async function updateGoalText(projectId: string, goalId: string, text: st project.lastMessageAt = nowIso(); return goal; }); + publishBossEvent("conversation.updated", { projectId, note: "project_goals.updated" }); + return goal; } export async function createGoal(projectId: string, text: string) { - return mutateState((state) => { + const goal = await mutateState((state) => { const project = state.projects.find((item) => item.id === projectId); if (!project) throw new Error("PROJECT_NOT_FOUND"); if (!text.trim()) throw new Error("GOAL_TEXT_REQUIRED"); @@ -5097,6 +5101,8 @@ export async function createGoal(projectId: string, text: string) { project.lastMessageAt = nowIso(); return goal; }); + publishBossEvent("conversation.updated", { projectId, note: "project_goals.updated" }); + return goal; } export async function issueVerificationCode( diff --git a/tests/project-goal-events.test.ts b/tests/project-goal-events.test.ts new file mode 100644 index 0000000..fe56d7f --- /dev/null +++ b/tests/project-goal-events.test.ts @@ -0,0 +1,113 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; + +let runtimeRoot = ""; +let readState: (typeof import("../src/lib/boss-data"))["readState"]; +let writeState: (typeof import("../src/lib/boss-data"))["writeState"]; +let toggleGoal: (typeof import("../src/lib/boss-data"))["toggleGoal"]; +let updateGoalText: (typeof import("../src/lib/boss-data"))["updateGoalText"]; +let createGoal: (typeof import("../src/lib/boss-data"))["createGoal"]; +let subscribeBossEvents: (typeof import("../src/lib/boss-events"))["subscribeBossEvents"]; + +async function setup() { + if (runtimeRoot) return; + + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-goal-events-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + + const [data, events] = await Promise.all([ + import("../src/lib/boss-data.ts"), + import("../src/lib/boss-events.ts"), + ]); + readState = data.readState; + writeState = data.writeState; + toggleGoal = data.toggleGoal; + updateGoalText = data.updateGoalText; + createGoal = data.createGoal; + subscribeBossEvents = events.subscribeBossEvents; +} + +async function resetGoalState() { + const state = await readState(); + const existingProject = state.projects.find((project) => project.id !== "master-agent") ?? state.projects[0]; + const project = structuredClone(existingProject); + project.id = "project-goal-events"; + project.name = "项目目标事件测试"; + project.goals = [ + { + id: "goal-1", + text: "完成接入验证", + state: "pending", + note: "等待执行", + }, + ]; + project.versions = []; + project.messages = []; + project.lastMessageAt = "2026-04-07T10:00:00.000Z"; + state.projects = state.projects.filter((item) => item.id !== "project-goal-events"); + state.projects.unshift(project); + await writeState(state); +} + +test.beforeEach(async () => { + await setup(); + await resetGoalState(); +}); + +test.after(async () => { + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +test("toggleGoal publishes project goal refresh marker for the project", async () => { + const events: Array<{ event: string; payload: { projectId?: string; note?: string } }> = []; + const unsubscribe = subscribeBossEvents((event, payload) => { + events.push({ event, payload }); + }); + + await toggleGoal("project-goal-events", "goal-1"); + unsubscribe(); + + const latest = events.at(-1); + assert.ok(latest); + assert.equal(latest.event, "conversation.updated"); + assert.equal(latest.payload.projectId, "project-goal-events"); + assert.equal(latest.payload.note, "project_goals.updated"); +}); + +test("updateGoalText publishes project goal refresh marker for the project", async () => { + const events: Array<{ event: string; payload: { projectId?: string; note?: string } }> = []; + const unsubscribe = subscribeBossEvents((event, payload) => { + events.push({ event, payload }); + }); + + await updateGoalText("project-goal-events", "goal-1", "完成接入验证并复盘"); + unsubscribe(); + + const latest = events.at(-1); + assert.ok(latest); + assert.equal(latest.event, "conversation.updated"); + assert.equal(latest.payload.projectId, "project-goal-events"); + assert.equal(latest.payload.note, "project_goals.updated"); +}); + +test("createGoal publishes project goal refresh marker for the project", async () => { + const events: Array<{ event: string; payload: { projectId?: string; note?: string } }> = []; + const unsubscribe = subscribeBossEvents((event, payload) => { + events.push({ event, payload }); + }); + + await createGoal("project-goal-events", "补一条回归目标"); + unsubscribe(); + + const latest = events.at(-1); + assert.ok(latest); + assert.equal(latest.event, "conversation.updated"); + assert.equal(latest.payload.projectId, "project-goal-events"); + assert.equal(latest.payload.note, "project_goals.updated"); +});