Gate Android polling behind realtime health

This commit is contained in:
kris
2026-04-10 14:07:17 +08:00
parent b1a0516717
commit 7593cc9cea
5 changed files with 137 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View File

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