feat: add realtime sync and import project understanding
This commit is contained in:
157
android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java
Normal file
157
android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java
Normal file
@@ -0,0 +1,157 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
final class BossRealtimeClient {
|
||||
interface Listener {
|
||||
void onRealtimeEvent(BossRealtimeEvent event);
|
||||
}
|
||||
|
||||
private final BossApiClient apiClient;
|
||||
private final Listener listener;
|
||||
private volatile boolean running;
|
||||
private @Nullable Thread workerThread;
|
||||
private @Nullable HttpURLConnection activeConnection;
|
||||
|
||||
BossRealtimeClient(BossApiClient apiClient, Listener listener) {
|
||||
this.apiClient = apiClient;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
synchronized void start() {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
workerThread = new Thread(this::runLoop, "boss-realtime");
|
||||
workerThread.start();
|
||||
}
|
||||
|
||||
synchronized void stop() {
|
||||
running = false;
|
||||
if (activeConnection != null) {
|
||||
activeConnection.disconnect();
|
||||
activeConnection = null;
|
||||
}
|
||||
if (workerThread != null) {
|
||||
workerThread.interrupt();
|
||||
workerThread = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void runLoop() {
|
||||
long backoffMs = 800L;
|
||||
while (running) {
|
||||
try {
|
||||
openAndConsumeStream();
|
||||
backoffMs = 800L;
|
||||
} catch (Exception ignored) {
|
||||
if (!running) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(backoffMs);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
backoffMs = Math.min(backoffMs + 1200L, 5000L);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void openAndConsumeStream() throws IOException {
|
||||
HttpURLConnection connection = apiClient.openConnection("/api/v1/events");
|
||||
activeConnection = connection;
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setConnectTimeout(12_000);
|
||||
connection.setReadTimeout(60_000);
|
||||
connection.setUseCaches(false);
|
||||
connection.setDoInput(true);
|
||||
connection.setRequestProperty("Accept", "text/event-stream");
|
||||
connection.setRequestProperty("Cache-Control", "no-cache");
|
||||
connection.setRequestProperty("x-boss-native-app", "1");
|
||||
String cookie = apiClient.getSessionCookie();
|
||||
if (!cookie.isEmpty()) {
|
||||
connection.setRequestProperty("Cookie", cookie);
|
||||
}
|
||||
|
||||
int statusCode = connection.getResponseCode();
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
throw new IOException("REALTIME_STREAM_HTTP_" + statusCode);
|
||||
}
|
||||
|
||||
try (InputStream inputStream = connection.getInputStream();
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
|
||||
StringBuilder block = new StringBuilder();
|
||||
String line;
|
||||
while (running && (line = reader.readLine()) != null) {
|
||||
if (line.isEmpty()) {
|
||||
dispatchEventBlock(block.toString());
|
||||
block.setLength(0);
|
||||
continue;
|
||||
}
|
||||
block.append(line).append('\n');
|
||||
}
|
||||
if (block.length() > 0) {
|
||||
dispatchEventBlock(block.toString());
|
||||
}
|
||||
} finally {
|
||||
activeConnection = null;
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private void dispatchEventBlock(String rawBlock) {
|
||||
BossRealtimeEvent event = parseEventBlock(rawBlock);
|
||||
if (event == null || event.eventName.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
listener.onRealtimeEvent(event);
|
||||
}
|
||||
|
||||
static @Nullable BossRealtimeEvent parseEventBlock(String rawBlock) {
|
||||
if (rawBlock == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = rawBlock.trim();
|
||||
if (trimmed.isEmpty() || trimmed.startsWith(":")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String eventName = "";
|
||||
StringBuilder dataBuilder = new StringBuilder();
|
||||
for (String line : rawBlock.split("\n")) {
|
||||
if (line.startsWith("event:")) {
|
||||
eventName = line.substring("event:".length()).trim();
|
||||
} else if (line.startsWith("data:")) {
|
||||
if (dataBuilder.length() > 0) {
|
||||
dataBuilder.append('\n');
|
||||
}
|
||||
dataBuilder.append(line.substring("data:".length()).trim());
|
||||
}
|
||||
}
|
||||
if (eventName.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
JSONObject payload = new JSONObject();
|
||||
if (dataBuilder.length() > 0) {
|
||||
try {
|
||||
payload = new JSONObject(dataBuilder.toString());
|
||||
} catch (JSONException ignored) {
|
||||
payload = new JSONObject();
|
||||
}
|
||||
}
|
||||
return new BossRealtimeEvent(eventName, payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
public final class BossRealtimeEvent {
|
||||
public final String eventName;
|
||||
public final JSONObject payload;
|
||||
|
||||
public BossRealtimeEvent(String eventName, @Nullable JSONObject payload) {
|
||||
this.eventName = eventName == null ? "" : eventName.trim();
|
||||
this.payload = payload == null ? new JSONObject() : payload;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
private @Nullable JSONObject currentDraft;
|
||||
private @Nullable JSONObject currentResolution;
|
||||
private @Nullable JSONObject currentReviewTask;
|
||||
private @Nullable JSONArray currentUnderstandingTasks;
|
||||
private @Nullable JSONArray currentProjectUnderstandings;
|
||||
private final LinkedHashSet<String> selectedCandidateIds = new LinkedHashSet<>();
|
||||
private final Runnable reviewPollRunnable = this::reload;
|
||||
|
||||
@@ -53,7 +55,9 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
runOnUiThread(() -> applyPayload(
|
||||
response.json.optJSONObject("draft"),
|
||||
response.json.optJSONObject("resolution"),
|
||||
response.json.optJSONObject("reviewTask")
|
||||
response.json.optJSONObject("reviewTask"),
|
||||
response.json.optJSONArray("understandingTasks"),
|
||||
response.json.optJSONArray("projectUnderstandings")
|
||||
));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
@@ -64,10 +68,18 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void applyPayload(@Nullable JSONObject draft, @Nullable JSONObject resolution, @Nullable JSONObject reviewTask) {
|
||||
private void applyPayload(
|
||||
@Nullable JSONObject draft,
|
||||
@Nullable JSONObject resolution,
|
||||
@Nullable JSONObject reviewTask,
|
||||
@Nullable JSONArray understandingTasks,
|
||||
@Nullable JSONArray projectUnderstandings
|
||||
) {
|
||||
currentDraft = draft;
|
||||
currentResolution = resolution;
|
||||
currentReviewTask = reviewTask;
|
||||
currentUnderstandingTasks = understandingTasks;
|
||||
currentProjectUnderstandings = projectUnderstandings;
|
||||
selectedCandidateIds.clear();
|
||||
JSONArray selected = draft == null ? null : draft.optJSONArray("selectedCandidateIds");
|
||||
if (selected != null) {
|
||||
@@ -105,6 +117,8 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
JSONObject draft = currentDraft;
|
||||
JSONObject resolution = currentResolution;
|
||||
JSONObject reviewTask = currentReviewTask;
|
||||
JSONArray understandingTasks = currentUnderstandingTasks;
|
||||
JSONArray projectUnderstandings = currentProjectUnderstandings;
|
||||
contentLayout.removeCallbacks(reviewPollRunnable);
|
||||
replaceContent();
|
||||
appendContent(BossUi.buildSoftPanel(
|
||||
@@ -223,6 +237,40 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
));
|
||||
}
|
||||
|
||||
if (understandingTasks != null && understandingTasks.length() > 0) {
|
||||
int completedCount = 0;
|
||||
for (int i = 0; i < understandingTasks.length(); i++) {
|
||||
JSONObject task = understandingTasks.optJSONObject(i);
|
||||
if (task != null && "completed".equals(task.optString("status", ""))) {
|
||||
completedCount += 1;
|
||||
}
|
||||
}
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"项目理解",
|
||||
completedCount == understandingTasks.length()
|
||||
? "主 Agent 已经拿到活跃线程的项目目标、进度和技术架构。"
|
||||
: "主 Agent 正在向活跃线程追问项目目标、进度和技术架构。",
|
||||
"已完成 " + completedCount + " / " + understandingTasks.length()
|
||||
));
|
||||
}
|
||||
|
||||
if (projectUnderstandings != null) {
|
||||
for (int i = 0; i < projectUnderstandings.length(); i++) {
|
||||
JSONObject understanding = projectUnderstandings.optJSONObject(i);
|
||||
if (understanding == null) continue;
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
understanding.optString("threadDisplayName", "项目理解"),
|
||||
"目标:" + understanding.optString("projectGoal", "未提供")
|
||||
+ "\n进度:" + understanding.optString("currentProgress", "未提供")
|
||||
+ "\n架构:" + understanding.optString("technicalArchitecture", "未提供"),
|
||||
"阻塞:" + understanding.optString("currentBlockers", "无")
|
||||
+ " · 下一步:" + understanding.optString("recommendedNextStep", "继续联调")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
JSONArray appliedProjectNames = draft.optJSONArray("appliedProjectNames");
|
||||
if (appliedProjectNames != null && appliedProjectNames.length() > 0) {
|
||||
appendContent(BossUi.buildCard(
|
||||
@@ -369,14 +417,16 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
reviewResponse.json.optJSONObject("resolution"),
|
||||
reviewResponse.json.optJSONObject("reviewTask") != null
|
||||
? reviewResponse.json.optJSONObject("reviewTask")
|
||||
: reviewResponse.json.optJSONObject("task")
|
||||
: reviewResponse.json.optJSONObject("task"),
|
||||
reviewResponse.json.optJSONArray("understandingTasks"),
|
||||
reviewResponse.json.optJSONArray("projectUnderstandings")
|
||||
);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
final JSONObject fallbackDraft = selectedDraft;
|
||||
runOnUiThread(() -> {
|
||||
if (fallbackDraft != null) {
|
||||
applyPayload(fallbackDraft, null, null);
|
||||
applyPayload(fallbackDraft, null, null, null, null);
|
||||
} else {
|
||||
setRefreshing(false);
|
||||
}
|
||||
@@ -400,7 +450,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showMessage("已清空当前勾选");
|
||||
applyPayload(response.json.optJSONObject("draft"), null, null);
|
||||
applyPayload(response.json.optJSONObject("draft"), null, null, null, null);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
@@ -425,7 +475,13 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showMessage("已应用导入");
|
||||
applyPayload(response.json.optJSONObject("draft"), response.json.optJSONObject("resolution"), null);
|
||||
applyPayload(
|
||||
response.json.optJSONObject("draft"),
|
||||
response.json.optJSONObject("resolution"),
|
||||
null,
|
||||
null,
|
||||
response.json.optJSONArray("projectUnderstandings")
|
||||
);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
|
||||
@@ -45,11 +45,13 @@ 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_THROTTLE_MS = 900L;
|
||||
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
private final Handler uiHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
private BossApiClient apiClient;
|
||||
private BossRealtimeClient realtimeClient;
|
||||
|
||||
private View loginPanel;
|
||||
private View contentPanel;
|
||||
@@ -100,6 +102,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
private boolean conversationQuickActionsVisible = false;
|
||||
private boolean conversationAutoRefreshArmed = false;
|
||||
private boolean conversationAutoRefreshEnabled = false;
|
||||
private long lastRealtimeRefreshAt = 0L;
|
||||
private final Set<String> selectedConversationProjectIds = new LinkedHashSet<>();
|
||||
private @Nullable RootPagerAdapter rootPagerAdapter;
|
||||
private boolean syncingRootPagerSelection = false;
|
||||
@@ -122,6 +125,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_main);
|
||||
apiClient = new BossApiClient(this);
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
bindViews();
|
||||
bindActions();
|
||||
applyInitialTab(getIntent());
|
||||
@@ -170,6 +174,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
cancelConversationAutoRefresh();
|
||||
stopRealtimeUpdates();
|
||||
executor.shutdownNow();
|
||||
super.onDestroy();
|
||||
}
|
||||
@@ -179,12 +184,14 @@ public class MainActivity extends AppCompatActivity {
|
||||
super.onResume();
|
||||
conversationAutoRefreshEnabled = true;
|
||||
updateConversationAutoRefresh();
|
||||
updateRealtimeSubscription();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
conversationAutoRefreshEnabled = false;
|
||||
cancelConversationAutoRefresh();
|
||||
stopRealtimeUpdates();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@@ -352,10 +359,43 @@ public class MainActivity extends AppCompatActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void refreshCurrentTab() {
|
||||
void refreshCurrentTab() {
|
||||
refreshAllData(sessionData);
|
||||
}
|
||||
|
||||
void handleRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (event == null || event.eventName.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (contentPanel == null || contentPanel.getVisibility() != View.VISIBLE) {
|
||||
return;
|
||||
}
|
||||
boolean shouldRefresh = false;
|
||||
if ("conversations".equals(activeTab)) {
|
||||
shouldRefresh =
|
||||
"conversation.updated".equals(event.eventName) ||
|
||||
"project.messages.updated".equals(event.eventName) ||
|
||||
"master_agent.task.updated".equals(event.eventName) ||
|
||||
"conversation.context_indicator.updated".equals(event.eventName);
|
||||
} else if ("devices".equals(activeTab)) {
|
||||
shouldRefresh =
|
||||
"devices.updated".equals(event.eventName) ||
|
||||
"devices.skills.updated".equals(event.eventName) ||
|
||||
"conversation.updated".equals(event.eventName);
|
||||
} else if ("me".equals(activeTab)) {
|
||||
shouldRefresh = "ota.updated".equals(event.eventName) || "app.logs.updated".equals(event.eventName);
|
||||
}
|
||||
if (!shouldRefresh) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastRealtimeRefreshAt < REALTIME_REFRESH_THROTTLE_MS) {
|
||||
return;
|
||||
}
|
||||
lastRealtimeRefreshAt = now;
|
||||
runOnUiThread(this::refreshCurrentTab);
|
||||
}
|
||||
|
||||
private void refreshAllData(@Nullable JSONObject initialSession) {
|
||||
startRefreshing(true);
|
||||
topSubtitle.setText("");
|
||||
@@ -490,6 +530,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
loginPanel.setVisibility(View.VISIBLE);
|
||||
contentPanel.setVisibility(View.GONE);
|
||||
setLoginLoading(false, hint);
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
|
||||
private void showContent() {
|
||||
@@ -497,6 +538,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
contentPanel.setVisibility(View.VISIBLE);
|
||||
setActiveTab(activeTab, false);
|
||||
updateConversationAutoRefresh();
|
||||
updateRealtimeSubscription();
|
||||
}
|
||||
|
||||
private void setLoginLoading(boolean loading, String hint) {
|
||||
@@ -1212,6 +1254,23 @@ public class MainActivity extends AppCompatActivity {
|
||||
Toast.makeText(this, text, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
private void updateRealtimeSubscription() {
|
||||
if (contentPanel != null
|
||||
&& contentPanel.getVisibility() == View.VISIBLE
|
||||
&& apiClient != null
|
||||
&& apiClient.hasSessionHints()) {
|
||||
realtimeClient.start();
|
||||
return;
|
||||
}
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
|
||||
private void stopRealtimeUpdates() {
|
||||
if (realtimeClient != null) {
|
||||
realtimeClient.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private void openMeEntry(String key) {
|
||||
Intent intent;
|
||||
switch (key) {
|
||||
|
||||
@@ -43,6 +43,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
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;
|
||||
|
||||
private String projectId;
|
||||
private String initialProjectName;
|
||||
@@ -82,6 +83,8 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
private ActivityResultLauncher<String> videoPickerLauncher;
|
||||
private ActivityResultLauncher<String> filePickerLauncher;
|
||||
private final ExecutorService replyWaitExecutor = Executors.newSingleThreadExecutor();
|
||||
private @Nullable BossRealtimeClient realtimeClient;
|
||||
private long lastRealtimeReloadAt;
|
||||
|
||||
static final class ChromeBindings {
|
||||
final boolean multiSelecting;
|
||||
@@ -205,6 +208,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
new ActivityResultContracts.GetContent(),
|
||||
uri -> onAttachmentPicked(uri, "file")
|
||||
);
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
|
||||
BossWindowInsets.applyKeyboardAvoidingInset(composerRow);
|
||||
BossWindowInsets.applyKeyboardAvoidingInset(multiSelectActionsLayout);
|
||||
@@ -242,10 +246,23 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
stopRealtimeUpdates();
|
||||
replyWaitExecutor.shutdownNow();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
updateRealtimeSubscription();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
stopRealtimeUpdates();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
boolean shouldLoadOnCreate() {
|
||||
return true;
|
||||
}
|
||||
@@ -255,6 +272,32 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
reload(false);
|
||||
}
|
||||
|
||||
void handleRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (event == null || event.eventName.isEmpty() || projectId == null || projectId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
boolean shouldReload = false;
|
||||
String payloadProjectId = event.payload.optString("projectId", "");
|
||||
if ("project.messages.updated".equals(event.eventName) || "conversation.updated".equals(event.eventName)) {
|
||||
shouldReload = projectId.equals(payloadProjectId);
|
||||
} else if ("master_agent.task.updated".equals(event.eventName)) {
|
||||
shouldReload = "master-agent".equals(projectId);
|
||||
}
|
||||
if (!shouldReload) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastRealtimeReloadAt < REALTIME_RELOAD_THROTTLE_MS) {
|
||||
return;
|
||||
}
|
||||
lastRealtimeReloadAt = now;
|
||||
runOnUiThread(this::triggerRealtimeReload);
|
||||
}
|
||||
|
||||
void triggerRealtimeReload() {
|
||||
reload();
|
||||
}
|
||||
|
||||
private void reload(boolean forcedScrollToBottom) {
|
||||
if (projectId == null || projectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
@@ -368,6 +411,20 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
}
|
||||
}
|
||||
|
||||
private void updateRealtimeSubscription() {
|
||||
if (apiClient != null && apiClient.hasSessionHints()) {
|
||||
realtimeClient.start();
|
||||
return;
|
||||
}
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
|
||||
private void stopRealtimeUpdates() {
|
||||
if (realtimeClient != null) {
|
||||
realtimeClient.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private void renderQuickActions() {
|
||||
if (quickActionsLayout == null) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class BossRealtimeClientTest {
|
||||
@Test
|
||||
public void parseEventBlockExtractsEventNameAndJsonPayload() {
|
||||
BossRealtimeEvent event = BossRealtimeClient.parseEventBlock(
|
||||
"event: project.messages.updated\n" +
|
||||
"data: {\"projectId\":\"project-1\",\"status\":\"completed\"}\n\n"
|
||||
);
|
||||
|
||||
assertEquals("project.messages.updated", event.eventName);
|
||||
assertEquals("project-1", event.payload.optString("projectId"));
|
||||
assertEquals("completed", event.payload.optString("status"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseEventBlockReturnsNullForKeepaliveComment() {
|
||||
assertNull(BossRealtimeClient.parseEventBlock(": keepalive\n\n"));
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,9 @@ public class DeviceImportDraftActivityTest {
|
||||
"applyPayload",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildPendingDraft()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(JSONArray.class, null)
|
||||
);
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
@@ -64,7 +66,9 @@ public class DeviceImportDraftActivityTest {
|
||||
"applyPayload",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAppliedDraft()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAppliedResolution()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(JSONArray.class, null)
|
||||
);
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
@@ -92,13 +96,18 @@ public class DeviceImportDraftActivityTest {
|
||||
"applyPayload",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildPendingResolutionDraft()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildQueuedReviewTask())
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildQueuedReviewTask()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONArray.class, buildUnderstandingTasks()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONArray.class, buildProjectUnderstandings())
|
||||
);
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "主 Agent 审核中"));
|
||||
assertTrue(viewTreeContainsText(content, "审核任务"));
|
||||
assertTrue(viewTreeContainsText(content, "状态:queued"));
|
||||
assertTrue(viewTreeContainsText(content, "项目理解"));
|
||||
assertTrue(viewTreeContainsText(content, "北区试产线回归"));
|
||||
assertTrue(viewTreeContainsText(content, "树莓派二代接入与联调"));
|
||||
}
|
||||
|
||||
private static JSONObject buildPendingDraft() throws Exception {
|
||||
@@ -200,6 +209,27 @@ public class DeviceImportDraftActivityTest {
|
||||
.put("status", "queued");
|
||||
}
|
||||
|
||||
private static JSONArray buildUnderstandingTasks() throws Exception {
|
||||
return new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("taskId", "mastertask-understanding-1")
|
||||
.put("candidateId", "candidate-1")
|
||||
.put("threadDisplayName", "北区试产线回归")
|
||||
.put("status", "completed"));
|
||||
}
|
||||
|
||||
private static JSONArray buildProjectUnderstandings() throws Exception {
|
||||
return new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("candidateId", "candidate-1")
|
||||
.put("threadDisplayName", "北区试产线回归")
|
||||
.put("projectGoal", "完成树莓派二代接入与联调")
|
||||
.put("currentProgress", "正在核对接线和控制链路")
|
||||
.put("technicalArchitecture", "Next.js 控制台 + local-agent + Codex 线程")
|
||||
.put("currentBlockers", "串口稳定性待验证")
|
||||
.put("recommendedNextStep", "先确认串口日志"));
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class MainActivityRealtimeTest {
|
||||
@Test
|
||||
public void conversationRealtimeEventRefreshesVisibleConversationTab() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
ReflectionHelpers.ClassParameter.from(
|
||||
BossRealtimeEvent.class,
|
||||
new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-1"))
|
||||
)
|
||||
);
|
||||
|
||||
assertEquals(1, activity.refreshCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void devicesRealtimeEventDoesNotRefreshConversationTab() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
ReflectionHelpers.ClassParameter.from(
|
||||
BossRealtimeEvent.class,
|
||||
new BossRealtimeEvent("devices.updated", new JSONObject().put("deviceId", "mac-studio"))
|
||||
)
|
||||
);
|
||||
|
||||
assertEquals(0, activity.refreshCount);
|
||||
}
|
||||
|
||||
public static class TestMainActivity extends MainActivity {
|
||||
int refreshCount;
|
||||
|
||||
@Override
|
||||
void refreshCurrentTab() {
|
||||
refreshCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
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.annotation.Config;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class ProjectDetailActivityRealtimeTest {
|
||||
@Test
|
||||
public void matchingProjectMessageEventTriggersReload() 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();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
ReflectionHelpers.ClassParameter.from(
|
||||
BossRealtimeEvent.class,
|
||||
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
|
||||
)
|
||||
);
|
||||
|
||||
assertEquals(1, activity.reloadCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unrelatedProjectMessageEventDoesNotTriggerReload() 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();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
ReflectionHelpers.ClassParameter.from(
|
||||
BossRealtimeEvent.class,
|
||||
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
|
||||
)
|
||||
);
|
||||
|
||||
assertEquals(0, activity.reloadCount);
|
||||
}
|
||||
|
||||
public static class TestRealtimeProjectDetailActivity extends ProjectDetailActivity {
|
||||
int reloadCount;
|
||||
|
||||
@Override
|
||||
boolean shouldLoadOnCreate() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
reloadCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user