diff --git a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java index 4a26633..34da070 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -29,6 +29,7 @@ import java.util.Map; public class BossApiClient { private static final int DEFAULT_CONNECT_TIMEOUT_MS = 12000; private static final int DEFAULT_READ_TIMEOUT_MS = 12000; + private static final int CONVERSATIONS_READ_TIMEOUT_MS = 30000; private static final int CHAT_FLOW_READ_TIMEOUT_MS = 65000; private static final int CHAT_SEND_READ_TIMEOUT_MS = 20000; private static final String PREFS_NAME = "boss_native_client"; @@ -79,7 +80,13 @@ public class BossApiClient { } public ApiResponse getConversations() throws IOException, JSONException { - return requestWithRestore("GET", "/api/v1/conversations", null); + return requestWithRestoreRaw( + "GET", + "/api/v1/conversations", + null, + DEFAULT_CONNECT_TIMEOUT_MS, + CONVERSATIONS_READ_TIMEOUT_MS + ); } public ApiResponse getConversationHome() throws IOException, JSONException { diff --git a/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java b/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java index 43bfc5c..0841256 100644 --- a/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java @@ -80,8 +80,8 @@ public class GroupCreateActivity extends BossScreenActivity { cachedConversationsPayload = conversationsPayload; replaceContent(); - JSONObject threadMeta = participantsPayload.optJSONObject("threadMeta"); - JSONArray participants = participantsPayload.optJSONArray("participants"); + JSONObject threadMeta = participantsPayload == null ? null : participantsPayload.optJSONObject("threadMeta"); + JSONArray participants = participantsPayload == null ? null : participantsPayload.optJSONArray("participants"); sourceFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", ""); if (hasSourceProject()) { sourceProjectName = threadMeta == null 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 d8ec940..c4ff538 100644 --- a/android/app/src/main/java/com/hyzq/boss/MainActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java @@ -2,8 +2,11 @@ package com.hyzq.boss; import android.content.Intent; import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; import android.view.View; import android.widget.Button; +import android.widget.EditText; import android.widget.LinearLayout; import android.widget.ProgressBar; import android.widget.TextView; @@ -60,6 +63,7 @@ public class MainActivity extends AppCompatActivity { private @Nullable JSONArray devicesData; private @Nullable String boundDeviceId; private @Nullable String boundDeviceName; + private String conversationSearchQuery = ""; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { @@ -256,6 +260,17 @@ public class MainActivity extends AppCompatActivity { } catch (Exception ignored) { conversationsOk = false; } + if (!conversationsOk) { + try { + BossApiClient.ApiResponse fallbackConversations = apiClient.getConversationHome(); + if (fallbackConversations.ok()) { + conversations = fallbackConversations; + conversationsOk = true; + } + } catch (Exception ignored) { + conversationsOk = false; + } + } try { devices = apiClient.getDevices(); devicesOk = devices.ok(); @@ -442,14 +457,15 @@ public class MainActivity extends AppCompatActivity { private void renderConversationsRoot() { screenContent.removeAllViews(); - screenContent.addView(BossUi.buildHintPill(this, WechatSurfaceMapper.conversationsHintPillText())); - if (conversationsData == null || conversationsData.length() == 0) { + screenContent.addView(buildConversationSearchInput()); + JSONArray filteredConversations = filterConversationItems(conversationsData, conversationSearchQuery); + if (filteredConversations == null || filteredConversations.length() == 0) { screenContent.addView(BossUi.buildEmptyCard(this, "当前没有会话数据。")); return; } - for (int i = 0; i < conversationsData.length(); i++) { - JSONObject item = conversationsData.optJSONObject(i); + for (int i = 0; i < filteredConversations.length(); i++) { + JSONObject item = filteredConversations.optJSONObject(i); if (item == null) continue; String projectId = item.optString("projectId", ""); String conversationType = item.optString("conversationType", ""); @@ -477,6 +493,81 @@ public class MainActivity extends AppCompatActivity { } } + private EditText buildConversationSearchInput() { + EditText input = BossUi.buildInput(this, "搜索项目或线程", false); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.leftMargin = BossUi.dp(this, 16); + params.rightMargin = BossUi.dp(this, 16); + params.bottomMargin = BossUi.dp(this, 10); + input.setLayoutParams(params); + input.setText(conversationSearchQuery); + input.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + + @Override + public void afterTextChanged(Editable editable) { + String nextQuery = editable == null ? "" : editable.toString(); + if (nextQuery.equals(conversationSearchQuery)) { + return; + } + conversationSearchQuery = nextQuery; + renderConversationsRoot(); + } + }); + return input; + } + + static JSONArray filterConversationItems(@Nullable JSONArray source, @Nullable String rawQuery) { + if (source == null) { + return null; + } + String query = rawQuery == null ? "" : rawQuery.trim().toLowerCase(); + if (query.isEmpty()) { + return source; + } + JSONArray filtered = new JSONArray(); + for (int i = 0; i < source.length(); i++) { + JSONObject item = source.optJSONObject(i); + if (item == null) { + continue; + } + if (matchesConversationQuery(item, query)) { + filtered.put(item); + } + } + return filtered; + } + + static boolean matchesConversationQuery(JSONObject item, String rawQuery) { + if (item == null) { + return false; + } + String query = rawQuery == null ? "" : rawQuery.trim().toLowerCase(); + if (query.isEmpty()) { + return true; + } + String[] fields = new String[] { + item.optString("projectTitle", ""), + item.optString("threadTitle", ""), + item.optString("folderLabel", ""), + item.optString("lastMessagePreview", ""), + item.optString("preview", "") + }; + for (String field : fields) { + if (field != null && field.toLowerCase().contains(query)) { + return true; + } + } + return false; + } + private void renderDevicesRoot() { screenContent.removeAllViews(); if (devicesData == null || devicesData.length() == 0) { diff --git a/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java b/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java index e2c10f9..e687608 100644 --- a/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java +++ b/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java @@ -39,6 +39,20 @@ public class BossApiClientDispatchPlansTest { assertEquals("GET", connection.requestMethodValue); } + @Test + public void getConversationsUsesExtendedReadTimeoutForFullThreadList() throws Exception { + RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/conversations")); + RecordingBossApiClient apiClient = new RecordingBossApiClient(connection); + + BossApiClient.ApiResponse response = apiClient.getConversations(); + + assertEquals(200, response.statusCode); + assertEquals("/api/v1/conversations", apiClient.lastPath); + assertEquals("GET", connection.requestMethodValue); + assertEquals(12000, connection.connectTimeoutValue); + assertEquals(30000, connection.readTimeoutValue); + } + @Test public void confirmDispatchPlanWritesApprovedTargetProjectIds() throws Exception { RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/confirm")); diff --git a/android/app/src/test/java/com/hyzq/boss/GroupCreateActivityUiTest.java b/android/app/src/test/java/com/hyzq/boss/GroupCreateActivityUiTest.java index 8c4340e..e3c5903 100644 --- a/android/app/src/test/java/com/hyzq/boss/GroupCreateActivityUiTest.java +++ b/android/app/src/test/java/com/hyzq/boss/GroupCreateActivityUiTest.java @@ -104,6 +104,28 @@ public class GroupCreateActivityUiTest { assertTrue(viewTreeContainsText(lastChild, "创建群聊")); } + @Test + public void renderCreatePageSupportsRootCreateFlowWithoutParticipantsPayload() throws Exception { + Intent intent = new Intent(); + TestGroupCreateActivity activity = Robolectric + .buildActivity(TestGroupCreateActivity.class, intent) + .setup() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderCreatePage", + ReflectionHelpers.ClassParameter.from(JSONObject.class, null), + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildConversationsPayload()), + ReflectionHelpers.ClassParameter.from(boolean.class, true) + ); + + LinearLayout content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content.getChildAt(0), "发起新群聊")); + assertTrue(viewTreeContainsText(content.getChildAt(0), "从会话列表直接建群")); + assertTrue(viewTreeContainsText(content.getChildAt(1), "选择其他线程")); + } + private static JSONObject buildParticipantsPayload() throws Exception { JSONObject threadMeta = new JSONObject() .put("threadDisplayName", "北区试产线回归") diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java new file mode 100644 index 0000000..a80b1b7 --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java @@ -0,0 +1,63 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import android.widget.EditText; +import android.widget.LinearLayout; + +import org.json.JSONArray; +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 MainActivityConversationSearchTest { + @Test + public void filterConversationItemsMatchesProjectTitleAndFolder() throws Exception { + JSONArray source = new JSONArray() + .put(new JSONObject() + .put("projectId", "p1") + .put("projectTitle", "500Gcode") + .put("folderLabel", "Mac Studio") + .put("lastMessagePreview", "线程链路正常")) + .put(new JSONObject() + .put("projectId", "p2") + .put("projectTitle", "Figma 联调") + .put("folderLabel", "设计") + .put("lastMessagePreview", "等待审阅")); + + JSONArray filteredByProject = MainActivity.filterConversationItems(source, "500g"); + JSONArray filteredByFolder = MainActivity.filterConversationItems(source, "设计"); + + assertEquals(1, filteredByProject.length()); + assertEquals("p1", filteredByProject.optJSONObject(0).optString("projectId", "")); + assertEquals(1, filteredByFolder.length()); + assertEquals("p2", filteredByFolder.optJSONObject(0).optString("projectId", "")); + } + + @Test + public void renderConversationsRootShowsSearchInputInsteadOfHintPill() throws Exception { + MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get(); + ReflectionHelpers.setField(activity, "conversationsData", new JSONArray() + .put(new JSONObject() + .put("projectId", "p1") + .put("projectTitle", "500Gcode") + .put("folderLabel", "Mac Studio") + .put("lastMessagePreview", "线程链路正常") + .put("latestReplyLabel", "09:40"))); + + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + ReflectionHelpers.callInstanceMethod(activity, "renderConversationsRoot"); + + LinearLayout content = activity.findViewById(R.id.screen_content); + assertTrue(content.getChildAt(0) instanceof EditText); + EditText input = (EditText) content.getChildAt(0); + assertEquals("搜索项目或线程", String.valueOf(input.getHint())); + } +}