Refresh thread status screen in realtime

This commit is contained in:
kris
2026-04-07 14:48:16 +08:00
parent 17ecd56b57
commit e0e8d4f687
5 changed files with 180 additions and 3 deletions

View File

@@ -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<String, Long> 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<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");

View File

@@ -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();
}
}

View File

@@ -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"