feat: add realtime sync and import project understanding

This commit is contained in:
kris
2026-04-04 07:53:27 +08:00
parent 9c53e583ba
commit 908ad8858b
12 changed files with 809 additions and 12 deletions

View 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);
}
}

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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