Refresh conversation info surfaces in realtime

This commit is contained in:
kris
2026-04-07 15:49:18 +08:00
parent 45329159f5
commit a42e5b75dc
9 changed files with 485 additions and 6 deletions

View File

@@ -9,6 +9,10 @@ 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";
@@ -16,12 +20,16 @@ public class ConversationFolderActivity extends BossScreenActivity {
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_RELOAD_THROTTLE_MS = 900L;
private String folderKey;
private String folderName;
private String targetProjectId;
private ArrayList<String> targetProjectIds;
private String targetProjectLabel;
private @Nullable BossRealtimeClient realtimeClient;
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
private final Set<String> trackedProjectIds = new LinkedHashSet<>();
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
@@ -45,9 +53,28 @@ public class ConversationFolderActivity extends BossScreenActivity {
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() {
stopRealtimeUpdates();
super.onPause();
}
@Override
protected void onDestroy() {
stopRealtimeUpdates();
super.onDestroy();
}
@Override
protected void reload() {
if (folderKey == null || folderKey.isEmpty()) {
@@ -72,9 +99,76 @@ public class ConversationFolderActivity 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 (!"conversation.updated".equals(event.eventName)
&& !"project.messages.updated".equals(event.eventName)) {
return false;
}
String payloadProjectId = event.payload.optString("projectId", "").trim();
if (payloadProjectId.isEmpty()) {
return false;
}
return trackedProjectIds.contains(payloadProjectId)
|| (!targetProjectIds.isEmpty() && targetProjectIds.contains(payloadProjectId))
|| (targetProjectId != null && targetProjectId.equals(payloadProjectId));
}
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) {
trackedProjectIds.clear();
appendContent(BossUi.buildEmptyCard(this, "未找到项目线程。"));
setRefreshing(false);
return;
@@ -91,6 +185,7 @@ public class ConversationFolderActivity extends BossScreenActivity {
));
JSONArray threads = folder.optJSONArray("threads");
updateTrackedProjectIds(threads);
if (threads == null || threads.length() == 0) {
appendContent(BossUi.buildEmptyCard(this, "当前项目下没有线程。"));
setRefreshing(false);
@@ -127,6 +222,23 @@ public class ConversationFolderActivity extends BossScreenActivity {
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 void renderThreadAtIndex(JSONArray threads, int index, boolean highlighted) {
JSONObject item = threads.optJSONObject(index);
if (item == null) return;

View File

@@ -12,9 +12,13 @@ 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;
@@ -22,6 +26,8 @@ public class ConversationInfoActivity extends BossScreenActivity {
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() {
@@ -36,9 +42,28 @@ public class ConversationInfoActivity extends BossScreenActivity {
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()) {
@@ -72,6 +97,67 @@ public class ConversationInfoActivity 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);
}
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");

View File

@@ -12,12 +12,18 @@ 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 @Nullable BossRealtimeClient realtimeClient;
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
@Override
protected int getLayoutResId() {
@@ -32,9 +38,28 @@ public class GroupInfoActivity extends BossScreenActivity {
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()) {
@@ -63,6 +88,67 @@ public class GroupInfoActivity 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);
}
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 renderGroup(JSONObject detail, JSONObject participantsPayload) {
renderGroup(detail, participantsPayload, null);
}

View File

@@ -19,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.shadows.ShadowDialog;
import org.robolectric.util.ReflectionHelpers;
@@ -117,6 +118,70 @@ public class ConversationFolderActivityTest {
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()).idle();
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()
@@ -182,9 +247,17 @@ public class ConversationFolderActivityTest {
}
public static class TestConversationFolderActivity extends ConversationFolderActivity {
private boolean reloadEnabled;
private int reloadCount;
@Override
protected void reload() {
// Tests render the folder state directly.
if (!reloadEnabled) {
return;
}
reloadCount += 1;
replaceContent(BossUi.buildEmptyCard(this, "test reload"));
setRefreshing(false);
}
}
}

View File

@@ -187,6 +187,7 @@ public class ConversationInfoActivityTest {
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.setField(activity, "reloadEnabled", true);
ReflectionHelpers.setField(activity, "delegateReloadToSuper", true);
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
activity.reload();
@@ -217,6 +218,7 @@ public class ConversationInfoActivityTest {
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();
@@ -233,6 +235,58 @@ public class ConversationInfoActivityTest {
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")
@@ -341,13 +395,21 @@ public class ConversationInfoActivityTest {
public static class TestConversationInfoActivity extends ConversationInfoActivity {
private boolean reloadEnabled;
private boolean delegateReloadToSuper;
private int reloadCount;
@Override
protected void reload() {
if (!reloadEnabled) {
return;
}
super.reload();
reloadCount += 1;
if (delegateReloadToSuper) {
super.reload();
return;
}
replaceContent(BossUi.buildEmptyCard(this, "test reload"));
setRefreshing(false);
}
}

View File

@@ -286,6 +286,58 @@ public class GroupInfoActivityTest {
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);
}
@@ -425,9 +477,17 @@ public class GroupInfoActivityTest {
}
public static class TestGroupInfoActivity extends GroupInfoActivity {
private boolean reloadEnabled;
private int reloadCount;
@Override
protected void reload() {
// Tests render the lightweight info state directly.
if (!reloadEnabled) {
return;
}
reloadCount += 1;
replaceContent(BossUi.buildEmptyCard(this, "test reload"));
setRefreshing(false);
}
}