Refresh goals and versions on precise goal updates

This commit is contained in:
kris
2026-04-07 16:41:09 +08:00
parent 9268f64506
commit b5d6495017
9 changed files with 527 additions and 6 deletions

View File

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

View File

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

View File

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

View File

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