diff --git a/android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java b/android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java index 9d802d0..f77b5f7 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java @@ -34,6 +34,7 @@ final class BossRealtimeClient { private final BossApiClient apiClient; private final Listener listener; private volatile boolean running; + private volatile boolean connected; private @Nullable Thread workerThread; private @Nullable HttpURLConnection activeConnection; @@ -53,6 +54,7 @@ final class BossRealtimeClient { synchronized void stop() { running = false; + connected = false; if (activeConnection != null) { activeConnection.disconnect(); activeConnection = null; @@ -63,6 +65,10 @@ final class BossRealtimeClient { } } + boolean isConnected() { + return connected; + } + private void runLoop() { long backoffMs = INITIAL_BACKOFF_MS; while (running) { @@ -123,6 +129,7 @@ final class BossRealtimeClient { if (statusCode < 200 || statusCode >= 300) { throw new IOException("REALTIME_STREAM_HTTP_" + statusCode); } + connected = true; Log.i(TAG, "Realtime stream connected"); try (InputStream inputStream = connection.getInputStream(); @@ -145,6 +152,7 @@ final class BossRealtimeClient { } } } finally { + connected = false; activeConnection = null; connection.disconnect(); } 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 4ebd811..9b3a5bc 100644 --- a/android/app/src/main/java/com/hyzq/boss/MainActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java @@ -114,10 +114,10 @@ public class MainActivity extends AppCompatActivity { @Override public void run() { conversationAutoRefreshArmed = false; - if (!shouldAutoRefreshConversations()) { + if (!shouldMaintainConversationAutoRefresh()) { return; } - if (!screenRefresh.isRefreshing()) { + if (!screenRefresh.isRefreshing() && shouldAutoRefreshConversations()) { refreshCurrentTab(); } armConversationAutoRefresh(); @@ -1688,14 +1688,24 @@ public class MainActivity extends AppCompatActivity { } private boolean shouldAutoRefreshConversations() { + return shouldMaintainConversationAutoRefresh() && !isRealtimeConnected(); + } + + private boolean shouldMaintainConversationAutoRefresh() { return conversationAutoRefreshEnabled && contentPanel != null && contentPanel.getVisibility() == View.VISIBLE - && "conversations".equals(activeTab); + && "conversations".equals(activeTab) + && apiClient != null + && apiClient.hasSessionHints(); + } + + private boolean isRealtimeConnected() { + return realtimeClient != null && realtimeClient.isConnected(); } private void updateConversationAutoRefresh() { - if (shouldAutoRefreshConversations()) { + if (shouldMaintainConversationAutoRefresh()) { armConversationAutoRefresh(); } else { cancelConversationAutoRefresh(); 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 12a12e1..ac1556b 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -8,6 +8,8 @@ import android.content.Intent; import android.database.Cursor; import android.net.Uri; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; import android.provider.OpenableColumns; import android.text.Editable; import android.text.TextUtils; @@ -41,6 +43,7 @@ import java.util.concurrent.Executors; 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 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; @@ -86,9 +89,25 @@ 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 final Handler uiHandler = new Handler(Looper.getMainLooper()); + private boolean conversationAutoRefreshArmed; + private boolean conversationAutoRefreshEnabled; private boolean reloadInFlight; private boolean pendingReload; private boolean pendingReloadForcedScrollToBottom; + private final Runnable conversationAutoRefreshRunnable = new Runnable() { + @Override + public void run() { + conversationAutoRefreshArmed = false; + if (!shouldMaintainConversationAutoRefresh()) { + return; + } + if (!reloadInFlight && !refreshLayout.isRefreshing() && shouldAutoRefreshConversation()) { + reload(false); + } + armConversationAutoRefresh(); + } + }; static final class ChromeBindings { final boolean multiSelecting; @@ -258,6 +277,7 @@ public class ProjectDetailActivity extends BossScreenActivity { @Override protected void onDestroy() { + cancelConversationAutoRefresh(); stopRealtimeUpdates(); replyWaitExecutor.shutdownNow(); super.onDestroy(); @@ -266,11 +286,15 @@ public class ProjectDetailActivity extends BossScreenActivity { @Override protected void onResume() { super.onResume(); + conversationAutoRefreshEnabled = true; + updateConversationAutoRefresh(); updateRealtimeSubscription(); } @Override protected void onPause() { + conversationAutoRefreshEnabled = false; + cancelConversationAutoRefresh(); stopRealtimeUpdates(); super.onPause(); } @@ -410,6 +434,9 @@ public class ProjectDetailActivity extends BossScreenActivity { } updateComposerSendButtonState(); updateSelectionUi(); + if (!refreshing) { + updateConversationAutoRefresh(); + } } private void renderProject( @@ -498,6 +525,42 @@ public class ProjectDetailActivity extends BossScreenActivity { } } + private boolean shouldMaintainConversationAutoRefresh() { + return conversationAutoRefreshEnabled + && apiClient != null + && apiClient.hasSessionHints() + && !TextUtils.isEmpty(projectId); + } + + private boolean shouldAutoRefreshConversation() { + return shouldMaintainConversationAutoRefresh() && !isRealtimeConnected(); + } + + private boolean isRealtimeConnected() { + return realtimeClient != null && realtimeClient.isConnected(); + } + + private void updateConversationAutoRefresh() { + if (shouldMaintainConversationAutoRefresh()) { + armConversationAutoRefresh(); + } else { + cancelConversationAutoRefresh(); + } + } + + private void armConversationAutoRefresh() { + if (conversationAutoRefreshArmed) { + return; + } + uiHandler.postDelayed(conversationAutoRefreshRunnable, CONVERSATION_AUTO_REFRESH_MS); + conversationAutoRefreshArmed = true; + } + + private void cancelConversationAutoRefresh() { + uiHandler.removeCallbacks(conversationAutoRefreshRunnable); + conversationAutoRefreshArmed = false; + } + private void renderQuickActions() { if (quickActionsLayout == null) { return; diff --git a/tests/android-realtime-client-regression.test.ts b/tests/android-realtime-client-regression.test.ts index dee1d0f..1de4baa 100644 --- a/tests/android-realtime-client-regression.test.ts +++ b/tests/android-realtime-client-regression.test.ts @@ -62,7 +62,7 @@ test("android realtime client always clears the active connection after stream s assert.match( source, - /try\s*\{[\s\S]{0,1200}int statusCode = connection\.getResponseCode\(\);[\s\S]{0,220}throw new IOException\("REALTIME_STREAM_HTTP_" \+ statusCode\);[\s\S]{0,2000}finally\s*\{\s*activeConnection = null;\s*connection\.disconnect\(\);\s*\}/, + /try\s*\{[\s\S]{0,1200}int statusCode = connection\.getResponseCode\(\);[\s\S]{0,220}throw new IOException\("REALTIME_STREAM_HTTP_" \+ statusCode\);[\s\S]{0,2000}finally\s*\{[\s\S]{0,160}activeConnection = null;\s*connection\.disconnect\(\);\s*\}/, "expected non-2xx SSE setup failures to still flow through the connection cleanup finally block", ); }); diff --git a/tests/android-realtime-fallback-polling.test.ts b/tests/android-realtime-fallback-polling.test.ts new file mode 100644 index 0000000..9dad298 --- /dev/null +++ b/tests/android-realtime-fallback-polling.test.ts @@ -0,0 +1,51 @@ +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("BossRealtimeClient tracks whether the SSE stream is currently connected", async () => { + const source = await readSource("../android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java"); + + assert.match(source, /private volatile boolean connected;/, "expected realtime client to cache connection state"); + assert.match(source, /boolean isConnected\(\)\s*\{\s*return connected;\s*\}/, "expected realtime client to expose connection state"); + assert.match(source, /connected = true;/, "expected realtime client to flip connected once the SSE stream is ready"); + assert.match(source, /connected = false;/, "expected realtime client to clear connected when the stream stops"); +}); + +test("MainActivity only performs conversation polling when realtime is unavailable", async () => { + const source = await readSource("../android/app/src/main/java/com/hyzq/boss/MainActivity.java"); + + assert.match( + source, + /private boolean shouldAutoRefreshConversations\(\)\s*\{[\s\S]{0,240}!isRealtimeConnected\(\)/, + "expected root conversation polling to back off while realtime is healthy", + ); + assert.match( + source, + /private boolean shouldMaintainConversationAutoRefresh\(\)/, + "expected root page to keep a lightweight fallback scheduler even when polling is paused", + ); +}); + +test("ProjectDetailActivity keeps a fallback poller for chat pages when realtime disconnects", async () => { + const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java"); + + assert.match( + source, + /private static final long CONVERSATION_AUTO_REFRESH_MS = [\d_]+L;/, + "expected chat detail page to define a fallback polling cadence", + ); + assert.match( + source, + /private boolean shouldAutoRefreshConversation\(\)\s*\{[\s\S]{0,260}!isRealtimeConnected\(\)/, + "expected chat detail fallback polling to only run when realtime is disconnected", + ); + assert.match( + source, + /conversationAutoRefreshRunnable/, + "expected chat detail page to own an auto-refresh runnable for fallback polling", + ); +});