diff --git a/android/app/src/main/java/com/hyzq/boss/ThreadStatusActivity.java b/android/app/src/main/java/com/hyzq/boss/ThreadStatusActivity.java index 9c44ae1..313efdb 100644 --- a/android/app/src/main/java/com/hyzq/boss/ThreadStatusActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ThreadStatusActivity.java @@ -8,12 +8,18 @@ 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 recentRealtimeEventTimestamps = new LinkedHashMap<>(); @Override protected void onCreate(@Nullable Bundle savedInstanceState) { @@ -22,9 +28,28 @@ public class ThreadStatusActivity extends BossScreenActivity { 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()) { @@ -47,6 +72,69 @@ public class ThreadStatusActivity 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) { + 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> iterator = recentRealtimeEventTimestamps.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) { + iterator.remove(); + } + } + } + private void renderThreadStatus(JSONObject payload) { replaceContent(); JSONObject document = payload.optJSONObject("threadStatusDocument"); diff --git a/android/app/src/test/java/com/hyzq/boss/ThreadStatusActivityTest.java b/android/app/src/test/java/com/hyzq/boss/ThreadStatusActivityTest.java index 88e33ae..54fda2e 100644 --- a/android/app/src/test/java/com/hyzq/boss/ThreadStatusActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ThreadStatusActivityTest.java @@ -7,6 +7,7 @@ import static org.junit.Assert.assertFalse; import android.content.Context; import android.content.Intent; +import android.os.Looper; import android.view.View; import android.view.ViewGroup; import android.widget.LinearLayout; @@ -18,6 +19,7 @@ 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; @@ -69,14 +71,101 @@ public class ThreadStatusActivityTest { assertFalse(viewTreeContainsText(content, "只读状态文档")); } + @Test + public void matchingConversationEventTriggersReload() throws Exception { + Intent intent = new Intent() + .putExtra(ThreadStatusActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ThreadStatusActivity.EXTRA_PROJECT_NAME, "北区试产线回归"); + TestThreadStatusActivity activity = Robolectric + .buildActivity(TestThreadStatusActivity.class, intent) + .setup() + .resume() + .get(); + configureReloadDependencies(activity); + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-1")) + ) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(1, activity.reloadCount); + } + + @Test + public void matchingProjectContextRiskEventTriggersReload() throws Exception { + Intent intent = new Intent() + .putExtra(ThreadStatusActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ThreadStatusActivity.EXTRA_PROJECT_NAME, "北区试产线回归"); + TestThreadStatusActivity activity = Robolectric + .buildActivity(TestThreadStatusActivity.class, intent) + .setup() + .resume() + .get(); + configureReloadDependencies(activity); + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent("project.context_risk.updated", new JSONObject().put("projectId", "project-1")) + ) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(1, activity.reloadCount); + } + + @Test + public void unrelatedProjectEventDoesNotTriggerReload() throws Exception { + Intent intent = new Intent() + .putExtra(ThreadStatusActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ThreadStatusActivity.EXTRA_PROJECT_NAME, "北区试产线回归"); + TestThreadStatusActivity activity = Robolectric + .buildActivity(TestThreadStatusActivity.class, intent) + .setup() + .resume() + .get(); + configureReloadDependencies(activity); + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-2")) + ) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(0, activity.reloadCount); + } + + private static void configureReloadDependencies(TestThreadStatusActivity activity) { + RecordingBossApiClient apiClient = new RecordingBossApiClient( + activity.getSharedPreferences("thread-status-realtime-test", Context.MODE_PRIVATE), + "https://boss.hyzq.net" + ); + ReflectionHelpers.setField(activity, "apiClient", apiClient); + ReflectionHelpers.setField(activity, "executor", new DirectExecutorService()); + ReflectionHelpers.setField(activity, "reloadEnabled", true); + } + private static final class TestThreadStatusActivity extends ThreadStatusActivity { private boolean reloadEnabled; + private int reloadCount; @Override protected void reload() { if (!reloadEnabled) { return; } + reloadCount += 1; super.reload(); } } diff --git a/public/downloads/boss-android-latest.apk b/public/downloads/boss-android-latest.apk index be26947..ca277f4 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 6f78676..2e7d6bc 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": 3351842, - "updatedAt": "2026-04-07T06:31:08Z", - "sha256": "a8abbacc477ecb635ae5c0f1eabb762b0e2a68f5184edae0f4d7f83083365bea", + "sizeBytes": 3352262, + "updatedAt": "2026-04-07T06:48:08Z", + "sha256": "c1f21212dacef6c6d72d1fb87bd02e4b1c16b63869ff4fbdff8bc7a977a2bd0d", "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 be26947..ca277f4 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