diff --git a/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java b/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java index 107832e..e7a52d8 100644 --- a/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java @@ -2,6 +2,8 @@ package com.hyzq.boss; import android.content.Intent; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; import androidx.annotation.Nullable; @@ -20,6 +22,7 @@ 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_REFRESH_DEBOUNCE_MS = 300L; private static final long REALTIME_RELOAD_THROTTLE_MS = 900L; private String folderKey; @@ -29,8 +32,17 @@ public class ConversationFolderActivity extends BossScreenActivity { private ArrayList targetProjectIds; private String targetProjectLabel; private @Nullable BossRealtimeClient realtimeClient; + private final Handler uiHandler = new Handler(Looper.getMainLooper()); private final Map recentRealtimeEventTimestamps = new LinkedHashMap<>(); private final Set trackedProjectIds = new LinkedHashSet<>(); + private boolean realtimeReloadScheduled; + private final Runnable realtimeReloadRunnable = new Runnable() { + @Override + public void run() { + realtimeReloadScheduled = false; + reload(); + } + }; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { @@ -67,12 +79,14 @@ public class ConversationFolderActivity extends BossScreenActivity { @Override protected void onPause() { + cancelRealtimeReloadSchedule(); stopRealtimeUpdates(); super.onPause(); } @Override protected void onDestroy() { + cancelRealtimeReloadSchedule(); stopRealtimeUpdates(); super.onDestroy(); } @@ -130,7 +144,20 @@ public class ConversationFolderActivity extends BossScreenActivity { if (isDuplicateRealtimeEvent(eventFingerprint, now)) { return; } - runOnUiThread(this::reload); + runOnUiThread(this::scheduleRealtimeReload); + } + + private void scheduleRealtimeReload() { + if (realtimeReloadScheduled) { + return; + } + realtimeReloadScheduled = true; + uiHandler.postDelayed(realtimeReloadRunnable, REALTIME_REFRESH_DEBOUNCE_MS); + } + + private void cancelRealtimeReloadSchedule() { + uiHandler.removeCallbacks(realtimeReloadRunnable); + realtimeReloadScheduled = false; } private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) { diff --git a/android/app/src/main/java/com/hyzq/boss/MainActivity.java b/android/app/src/main/java/com/hyzq/boss/MainActivity.java index 9b3a5bc..9fc348d 100644 --- a/android/app/src/main/java/com/hyzq/boss/MainActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java @@ -47,6 +47,7 @@ public class MainActivity extends AppCompatActivity { private static final String KEY_LAST_ROOT_TAB = "last_root_tab"; private static final long ROOT_BACK_EXIT_WINDOW_MS = 1_500L; private static final long CONVERSATION_AUTO_REFRESH_MS = 12_000L; + private static final long REALTIME_REFRESH_DEBOUNCE_MS = 350L; private static final long REALTIME_REFRESH_THROTTLE_MS = 900L; private final ExecutorService executor = Executors.newSingleThreadExecutor(); @@ -106,6 +107,7 @@ public class MainActivity extends AppCompatActivity { private boolean conversationAutoRefreshEnabled = false; private boolean rootTabRefreshInFlight = false; private boolean pendingRootTabRefresh = false; + private boolean realtimeRefreshScheduled = false; private final java.util.HashMap recentRealtimeEventTimestamps = new java.util.HashMap<>(); private final Set selectedConversationProjectIds = new LinkedHashSet<>(); private @Nullable RootPagerAdapter rootPagerAdapter; @@ -123,6 +125,16 @@ public class MainActivity extends AppCompatActivity { armConversationAutoRefresh(); } }; + private final Runnable realtimeRefreshRunnable = new Runnable() { + @Override + public void run() { + realtimeRefreshScheduled = false; + if (contentPanel == null || contentPanel.getVisibility() != View.VISIBLE) { + return; + } + refreshCurrentTab(); + } + }; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { @@ -178,6 +190,7 @@ public class MainActivity extends AppCompatActivity { @Override protected void onDestroy() { cancelConversationAutoRefresh(); + cancelRealtimeRefreshSchedule(); stopRealtimeUpdates(); executor.shutdownNow(); super.onDestroy(); @@ -195,6 +208,7 @@ public class MainActivity extends AppCompatActivity { protected void onPause() { conversationAutoRefreshEnabled = false; cancelConversationAutoRefresh(); + cancelRealtimeRefreshSchedule(); stopRealtimeUpdates(); super.onPause(); } @@ -567,7 +581,20 @@ public class MainActivity extends AppCompatActivity { if (isDuplicateRealtimeEvent(eventFingerprint, now)) { return; } - runOnUiThread(this::refreshCurrentTab); + runOnUiThread(this::scheduleRealtimeRefresh); + } + + private void scheduleRealtimeRefresh() { + if (realtimeRefreshScheduled) { + return; + } + realtimeRefreshScheduled = true; + uiHandler.postDelayed(realtimeRefreshRunnable, REALTIME_REFRESH_DEBOUNCE_MS); + } + + private void cancelRealtimeRefreshSchedule() { + uiHandler.removeCallbacks(realtimeRefreshRunnable); + realtimeRefreshScheduled = false; } private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) { 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 ac1556b..06ab698 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -44,6 +44,7 @@ public class ProjectDetailActivity extends BossScreenActivity { public static final String EXTRA_PROJECT_ID = "project_id"; public static final String EXTRA_PROJECT_NAME = "project_name"; private static final long CONVERSATION_AUTO_REFRESH_MS = 8_000L; + private static final long REALTIME_REFRESH_DEBOUNCE_MS = 300L; private static final long REPLY_WAIT_TIMEOUT_MS = 55_000L; private static final long REPLY_WAIT_POLL_INTERVAL_MS = 1_500L; private static final long REALTIME_RELOAD_THROTTLE_MS = 900L; @@ -92,6 +93,7 @@ public class ProjectDetailActivity extends BossScreenActivity { private final Handler uiHandler = new Handler(Looper.getMainLooper()); private boolean conversationAutoRefreshArmed; private boolean conversationAutoRefreshEnabled; + private boolean realtimeReloadScheduled; private boolean reloadInFlight; private boolean pendingReload; private boolean pendingReloadForcedScrollToBottom; @@ -108,6 +110,13 @@ public class ProjectDetailActivity extends BossScreenActivity { armConversationAutoRefresh(); } }; + private final Runnable realtimeReloadRunnable = new Runnable() { + @Override + public void run() { + realtimeReloadScheduled = false; + triggerRealtimeReload(); + } + }; static final class ChromeBindings { final boolean multiSelecting; @@ -278,6 +287,7 @@ public class ProjectDetailActivity extends BossScreenActivity { @Override protected void onDestroy() { cancelConversationAutoRefresh(); + cancelRealtimeReloadSchedule(); stopRealtimeUpdates(); replyWaitExecutor.shutdownNow(); super.onDestroy(); @@ -295,6 +305,7 @@ public class ProjectDetailActivity extends BossScreenActivity { protected void onPause() { conversationAutoRefreshEnabled = false; cancelConversationAutoRefresh(); + cancelRealtimeReloadSchedule(); stopRealtimeUpdates(); super.onPause(); } @@ -324,7 +335,7 @@ public class ProjectDetailActivity extends BossScreenActivity { if (isDuplicateRealtimeEvent(eventFingerprint, now)) { return; } - runOnUiThread(this::triggerRealtimeReload); + runOnUiThread(this::scheduleRealtimeReload); } private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) { @@ -362,6 +373,19 @@ public class ProjectDetailActivity extends BossScreenActivity { reload(); } + private void scheduleRealtimeReload() { + if (realtimeReloadScheduled) { + return; + } + realtimeReloadScheduled = true; + uiHandler.postDelayed(realtimeReloadRunnable, REALTIME_REFRESH_DEBOUNCE_MS); + } + + private void cancelRealtimeReloadSchedule() { + uiHandler.removeCallbacks(realtimeReloadRunnable); + realtimeReloadScheduled = false; + } + private void reload(boolean forcedScrollToBottom) { if (projectId == null || projectId.isEmpty()) { showMessage("缺少 projectId"); diff --git a/tests/android-realtime-refresh-debounce.test.ts b/tests/android-realtime-refresh-debounce.test.ts new file mode 100644 index 0000000..587404b --- /dev/null +++ b/tests/android-realtime-refresh-debounce.test.ts @@ -0,0 +1,49 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +async function readSource(path: string) { + return readFile(new URL(path, import.meta.url), "utf8"); +} + +test("MainActivity debounces root realtime refresh bursts", async () => { + const source = await readSource("../android/app/src/main/java/com/hyzq/boss/MainActivity.java"); + + assert.match(source, /private static final long REALTIME_REFRESH_DEBOUNCE_MS = [\d_]+L;/); + assert.match(source, /private boolean realtimeRefreshScheduled(?: = false)?;/); + assert.match(source, /private final Runnable realtimeRefreshRunnable = new Runnable\(\)/); + assert.match(source, /scheduleRealtimeRefresh\(\)/); + assert.doesNotMatch( + source, + /runOnUiThread\(this::refreshCurrentTab\)/, + "root page should coalesce bursts instead of refreshing immediately for each event", + ); +}); + +test("ProjectDetailActivity debounces realtime chat reload bursts", async () => { + const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java"); + + assert.match(source, /private static final long REALTIME_REFRESH_DEBOUNCE_MS = [\d_]+L;/); + assert.match(source, /private boolean realtimeReloadScheduled(?: = false)?;/); + assert.match(source, /private final Runnable realtimeReloadRunnable = new Runnable\(\)/); + assert.match(source, /scheduleRealtimeReload\(\)/); + assert.doesNotMatch( + source, + /runOnUiThread\(this::triggerRealtimeReload\)/, + "chat page should debounce repeated realtime updates before reloading", + ); +}); + +test("ConversationFolderActivity debounces realtime folder reload bursts", async () => { + const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java"); + + assert.match(source, /private static final long REALTIME_REFRESH_DEBOUNCE_MS = [\d_]+L;/); + assert.match(source, /private boolean realtimeReloadScheduled(?: = false)?;/); + assert.match(source, /private final Runnable realtimeReloadRunnable = new Runnable\(\)/); + assert.match(source, /scheduleRealtimeReload\(\)/); + assert.doesNotMatch( + source, + /runOnUiThread\(this::reload\)/, + "folder page should debounce repeated realtime updates before reloading", + ); +});