diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java index 2d0fc15..d598c20 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -85,6 +85,9 @@ public class ProjectDetailActivity extends BossScreenActivity { private final ExecutorService replyWaitExecutor = Executors.newSingleThreadExecutor(); private @Nullable BossRealtimeClient realtimeClient; private final java.util.HashMap recentRealtimeEventTimestamps = new java.util.HashMap<>(); + private boolean reloadInFlight; + private boolean pendingReload; + private boolean pendingReloadForcedScrollToBottom; static final class ChromeBindings { final boolean multiSelecting; @@ -123,7 +126,7 @@ public class ProjectDetailActivity extends BossScreenActivity { } } - private static final class ProjectSnapshot { + static final class ProjectSnapshot { final JSONObject payload; final @Nullable JSONArray dispatchPlans; final @Nullable JSONObject participantsPayload; @@ -331,28 +334,61 @@ public class ProjectDetailActivity extends BossScreenActivity { finish(); return; } + if (reloadInFlight) { + pendingReload = true; + pendingReloadForcedScrollToBottom = pendingReloadForcedScrollToBottom || forcedScrollToBottom; + return; + } renderNearBottom = isChatNearBottom(); renderForcedScrollToBottom = forcedScrollToBottom; + reloadInFlight = true; setRefreshing(true); executor.execute(() -> { try { - ProjectSnapshot snapshot = fetchProjectSnapshot(); - runOnUiThread(() -> renderProject(snapshot.payload, snapshot.dispatchPlans, snapshot.participantsPayload)); + ProjectSnapshot snapshot = loadProjectSnapshotForRefresh(); + runOnUiThread(() -> { + renderLoadedProjectSnapshot(snapshot); + finishReloadCycle(); + }); } catch (Exception error) { runOnUiThread(() -> { - setRefreshing(false); - composerSending = false; - updateComposerSendButtonState(); - if (pendingOutgoingBubble == null && !masterAgentReplyWaiting) { - replaceContent(BossUi.buildEmptyCard(this, "项目详情加载失败:" + error.getMessage())); - } else { - showMessage("项目详情刷新失败:" + error.getMessage()); - } + handleProjectReloadFailure(error); + finishReloadCycle(); }); } }); } + ProjectSnapshot loadProjectSnapshotForRefresh() throws Exception { + return fetchProjectSnapshot(); + } + + void renderLoadedProjectSnapshot(ProjectSnapshot snapshot) { + renderProject(snapshot.payload, snapshot.dispatchPlans, snapshot.participantsPayload); + } + + void handleProjectReloadFailure(Exception error) { + setRefreshing(false); + composerSending = false; + updateComposerSendButtonState(); + if (pendingOutgoingBubble == null && !masterAgentReplyWaiting) { + replaceContent(BossUi.buildEmptyCard(this, "项目详情加载失败:" + error.getMessage())); + } else { + showMessage("项目详情刷新失败:" + error.getMessage()); + } + } + + private void finishReloadCycle() { + reloadInFlight = false; + if (!pendingReload) { + return; + } + boolean forcedScrollToBottom = pendingReloadForcedScrollToBottom; + pendingReload = false; + pendingReloadForcedScrollToBottom = false; + reload(forcedScrollToBottom); + } + @Override protected void setRefreshing(boolean refreshing) { super.setRefreshing(refreshing); diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java index 981e252..764063c 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java @@ -1,9 +1,13 @@ package com.hyzq.boss; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; +import static org.junit.Assert.assertTrue; import android.content.Intent; +import android.os.Looper; +import org.json.JSONArray; import org.json.JSONObject; import org.junit.Test; import org.junit.runner.RunWith; @@ -13,6 +17,10 @@ import org.robolectric.Shadows; import org.robolectric.annotation.Config; import org.robolectric.util.ReflectionHelpers; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + @RunWith(RobolectricTestRunner.class) @Config(sdk = 34) public class ProjectDetailActivityRealtimeTest { @@ -164,17 +172,126 @@ public class ProjectDetailActivityRealtimeTest { assertEquals(1, activity.reloadCount); } + @Test + public void burstRealtimeEventsWhileReloadingCoalesceIntoSingleFollowUpReload() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线"); + TestRealtimeProjectDetailActivity activity = Robolectric + .buildActivity(TestRealtimeProjectDetailActivity.class, intent) + .setup() + .resume() + .get(); + + activity.blockFirstReload(); + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1")) + ) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + assertTrue(activity.awaitFirstLoadStarted()); + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-1")) + ) + ); + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-1")) + ) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(1, activity.loadCallCount); + assertEquals(0, activity.renderCount); + + activity.releaseFirstLoad(); + waitFor(() -> activity.renderCount == 2 && activity.loadCallCount == 2); + + assertEquals(2, activity.loadCallCount); + assertEquals(2, activity.renderCount); + } + + private static void waitFor(BooleanSupplier condition) throws Exception { + long deadlineAt = System.currentTimeMillis() + 2_000L; + while (System.currentTimeMillis() < deadlineAt) { + Shadows.shadowOf(Looper.getMainLooper()).idle(); + if (condition.getAsBoolean()) { + return; + } + Thread.sleep(20L); + } + fail("condition not met before timeout"); + } + public static class TestRealtimeProjectDetailActivity extends ProjectDetailActivity { int reloadCount; + volatile int loadCallCount; + volatile int renderCount; + private CountDownLatch firstLoadStarted; + private CountDownLatch releaseFirstLoad; @Override boolean shouldLoadOnCreate() { return false; } + void blockFirstReload() { + firstLoadStarted = new CountDownLatch(1); + releaseFirstLoad = new CountDownLatch(1); + } + + boolean awaitFirstLoadStarted() throws InterruptedException { + return firstLoadStarted != null && firstLoadStarted.await(2, TimeUnit.SECONDS); + } + + void releaseFirstLoad() { + if (releaseFirstLoad != null) { + releaseFirstLoad.countDown(); + } + } + @Override protected void reload() { reloadCount += 1; + super.reload(); + } + + @Override + ProjectSnapshot loadProjectSnapshotForRefresh() throws Exception { + loadCallCount += 1; + if (loadCallCount == 1 && firstLoadStarted != null && releaseFirstLoad != null) { + firstLoadStarted.countDown(); + releaseFirstLoad.await(2, TimeUnit.SECONDS); + } + return new ProjectSnapshot( + new JSONObject().put( + "project", + new JSONObject() + .put("name", "北区试产线") + .put("messages", new JSONArray()) + ), + null, + null + ); + } + + @Override + void renderLoadedProjectSnapshot(ProjectSnapshot snapshot) { + renderCount += 1; + setRefreshing(false); } } }