From 7206be05b6f138eae4481cb6daf47ea27560a7af Mon Sep 17 00:00:00 2001 From: kris Date: Sun, 5 Apr 2026 13:49:16 +0800 Subject: [PATCH] feat: align android conversation folders with drawer design --- .../main/java/com/hyzq/boss/MainActivity.java | 77 ++++++++++++++++--- .../com/hyzq/boss/WechatSurfaceMapper.java | 10 ++- .../MainActivityConversationSearchTest.java | 68 ++++++++++++++++ .../MainActivityPinnedConversationsTest.java | 28 ++++++- 4 files changed, 171 insertions(+), 12 deletions(-) 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 3d8de3b..e52c419 100644 --- a/android/app/src/main/java/com/hyzq/boss/MainActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java @@ -1028,16 +1028,40 @@ public class MainActivity extends AppCompatActivity { String conversationType = item.optString("conversationType", ""); String folderKey = item.optString("folderKey", ""); WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item); + String displayTitle = conversationSearchMode ? buildConversationSearchLabel(item, row) : row.threadTitle; + WechatSurfaceMapper.ConversationRow displayRow = row; + if (!displayTitle.equals(row.threadTitle)) { + displayRow = new WechatSurfaceMapper.ConversationRow( + displayTitle, + row.folderLabel, + row.lastMessagePreview, + row.timeLabel, + row.unreadCount, + row.topPinnedLabel, + row.activityIconCount, + row.isGroup, + row.avatarPrimary, + row.avatarSecondary, + row.groupAvatarMembers, + row.pinnedConversation, + row.contextStatusLabel, + row.contextStatusLevel, + row.contextUsagePercent, + row.contextIndicatorVisible, + row.contextMustFinish + ); + } + final WechatSurfaceMapper.ConversationRow finalDisplayRow = displayRow; boolean selected = selectedConversationProjectIds.contains(projectId); String stableKey = !"".equals(projectId) ? "conversation-project:" + projectId : "conversation-folder:" + folderKey; - String signature = row.threadTitle + "|" - + row.lastMessagePreview + "|" - + row.timeLabel + "|" - + row.unreadCount + "|" - + row.contextStatusLabel + "|" - + row.activityIconCount + "|" + String signature = finalDisplayRow.threadTitle + "|" + + finalDisplayRow.lastMessagePreview + "|" + + finalDisplayRow.timeLabel + "|" + + finalDisplayRow.unreadCount + "|" + + finalDisplayRow.contextStatusLabel + "|" + + finalDisplayRow.activityIconCount + "|" + selected + "|" + conversationSelectionMode; rootItems.add(rootListItem( @@ -1045,7 +1069,7 @@ public class MainActivity extends AppCompatActivity { signature, () -> BossUi.buildConversationRow( this, - row, + finalDisplayRow, conversationSelectionMode, selected, v -> { @@ -1061,25 +1085,58 @@ public class MainActivity extends AppCompatActivity { toggleConversationSelection(projectId); return; } - if ("folder_archive".equals(conversationType)) { + if ("folder_archive".equals(conversationType) + || (conversationSearchMode && isArchivedProjectThread(item))) { if (folderKey.isEmpty()) { showMessage("缺少 folderKey"); return; } - openConversationFolder(folderKey, row.threadTitle); + openConversationFolder(folderKey, resolveConversationFolderName(item, finalDisplayRow)); return; } if (projectId.isEmpty()) { showMessage("缺少 projectId"); return; } - String projectName = row.threadTitle.isEmpty() ? "未命名会话" : row.threadTitle; + String projectName = finalDisplayRow.threadTitle.isEmpty() ? "未命名会话" : finalDisplayRow.threadTitle; openProject(projectId, projectName); }) )); } } + private static boolean isArchivedProjectThread(JSONObject item) { + String folderKey = item.optString("folderKey", "").trim(); + if (folderKey.isEmpty()) { + return false; + } + return item.optInt("threadCount", 0) > 1; + } + + private static String buildConversationSearchLabel(JSONObject item, WechatSurfaceMapper.ConversationRow row) { + if (row == null) { + return ""; + } + if (isArchivedProjectThread(item)) { + String folderLabel = row.folderLabel == null ? "" : row.folderLabel.trim(); + if (!folderLabel.isEmpty()) { + return folderLabel + " / " + row.threadTitle; + } + } + return row.threadTitle; + } + + private static String resolveConversationFolderName(JSONObject item, WechatSurfaceMapper.ConversationRow row) { + if ("folder_archive".equals(item.optString("conversationType", ""))) { + return row.threadTitle; + } + String folderLabel = row.folderLabel == null ? "" : row.folderLabel.trim(); + if (!folderLabel.isEmpty()) { + return folderLabel; + } + return row.threadTitle; + } + private void appendConversationSelectionControls(List items) { items.add(rootListItem( "conversation-selection-summary", diff --git a/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java b/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java index 80aa89e..cddd898 100644 --- a/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java +++ b/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java @@ -54,9 +54,17 @@ public final class WechatSurfaceMapper { } JSONObject avatar = source.optJSONObject("avatar"); boolean isGroup = source.optBoolean("isGroup", groupAvatarMembers.size() > 1); + String conversationType = source.optString("conversationType", ""); + String threadTitle = source.optString("threadTitle", source.optString("title", source.optString("projectTitle", ""))); + if ("folder_archive".equals(conversationType)) { + threadTitle = source.optString( + "projectTitle", + source.optString("threadTitle", source.optString("title", source.optString("folderLabel", ""))) + ); + } String pinnedLabel = source.optString("topPinnedLabel", ""); return new ConversationRow( - source.optString("threadTitle", source.optString("title", source.optString("projectTitle", ""))), + threadTitle, source.optString("folderLabel", ""), source.optString("lastMessagePreview", source.optString("preview", "")), source.optString("timeLabel", source.optString("latestReplyLabel", "")), diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java index 663f605..966e935 100644 --- a/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java +++ b/android/app/src/test/java/com/hyzq/boss/MainActivityConversationSearchTest.java @@ -5,10 +5,14 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import android.content.Intent; +import android.view.View; import android.widget.EditText; import android.widget.ImageButton; import android.widget.LinearLayout; +import androidx.recyclerview.widget.RecyclerView; + import org.json.JSONArray; import org.json.JSONObject; import org.junit.Test; @@ -94,6 +98,43 @@ public class MainActivityConversationSearchTest { assertFalse(activity.findViewById(R.id.search_button).isShown()); } + @Test + public void searchHitInsideArchivedProject_keepsProjectContextAndOpensFolderPage() throws Exception { + MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get(); + ReflectionHelpers.setField(activity, "conversationsData", new JSONArray() + .put(new JSONObject() + .put("projectId", "boss-thread-1") + .put("conversationType", "single_device") + .put("folderKey", "mac-studio:boss") + .put("folderLabel", "Boss") + .put("projectTitle", "Boss") + .put("threadTitle", "发布回滚") + .put("lastMessagePreview", "最近:发布回滚") + .put("latestReplyLabel", "11:00") + .put("threadCount", 2))); + + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + Shadows.shadowOf(activity.getMainLooper()).idle(); + ReflectionHelpers.callInstanceMethod(activity, "enterConversationSearchMode"); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + EditText searchInput = activity.findViewById(R.id.top_search_input); + searchInput.setText("发布回滚"); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + RecyclerView list = ReflectionHelpers.getField(activity, "screenList"); + View row = getRecyclerChild(list, 0); + assertTrue(viewTreeContainsText(row, "Boss / 发布回滚")); + + row.performClick(); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity(); + assertEquals(ConversationFolderActivity.class.getName(), nextIntent.getComponent().getClassName()); + assertEquals("mac-studio:boss", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY)); + assertEquals("Boss", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_FOLDER_NAME)); + } + private static JSONArray buildConversations() throws Exception { return new JSONArray() .put(new JSONObject() @@ -105,4 +146,31 @@ public class MainActivityConversationSearchTest { .put("latestReplyLabel", "09:40") .put("conversationType", "single_device")); } + + private static View getRecyclerChild(RecyclerView recyclerView, int position) { + RecyclerView.Adapter adapter = recyclerView.getAdapter(); + int viewType = adapter.getItemViewType(position); + RecyclerView.ViewHolder holder = adapter.createViewHolder(recyclerView, viewType); + adapter.bindViewHolder(holder, position); + return ((android.widget.FrameLayout) holder.itemView).getChildAt(0); + } + + private static boolean viewTreeContainsText(View root, String expectedText) { + if (root instanceof android.widget.TextView) { + CharSequence text = ((android.widget.TextView) root).getText(); + if (expectedText.contentEquals(text)) { + return true; + } + } + if (!(root instanceof LinearLayout)) { + return false; + } + LinearLayout group = (LinearLayout) root; + for (int index = 0; index < group.getChildCount(); index += 1) { + if (viewTreeContainsText(group.getChildAt(index), expectedText)) { + return true; + } + } + return false; + } } diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivityPinnedConversationsTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivityPinnedConversationsTest.java index bb6aa9c..5377d3c 100644 --- a/android/app/src/test/java/com/hyzq/boss/MainActivityPinnedConversationsTest.java +++ b/android/app/src/test/java/com/hyzq/boss/MainActivityPinnedConversationsTest.java @@ -54,7 +54,7 @@ public class MainActivityPinnedConversationsTest { assertTrue(recyclerContainsText(list, "主 Agent")); assertTrue(recyclerContainsText(list, "Boss 移动控制台")); - View pinnedHeader = getRecyclerChild(list, 1); + View pinnedHeader = getRecyclerChild(list, 0); pinnedHeader.performClick(); assertEquals(true, ReflectionHelpers.getField(activity, "pinnedConversationsCollapsed")); @@ -62,6 +62,32 @@ public class MainActivityPinnedConversationsTest { assertTrue("收起后普通会话仍应保留", recyclerContainsText(list, "Boss 移动控制台")); } + @Test + public void folderArchiveRow_usesProjectTitleAndCompactSubtitleOnHomepage() throws Exception { + MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get(); + ReflectionHelpers.setField(activity, "conversationsData", new JSONArray() + .put(new JSONObject() + .put("projectId", "folder-boss") + .put("conversationType", "folder_archive") + .put("folderKey", "mac-studio:boss") + .put("projectTitle", "Boss") + .put("threadTitle", "发布回滚") + .put("folderLabel", "2 个线程 · 最近:发布回滚") + .put("lastMessagePreview", "最近:发布回滚") + .put("latestReplyLabel", "11:00"))); + + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + Shadows.shadowOf(activity.getMainLooper()).idle(); + ReflectionHelpers.callInstanceMethod(activity, "renderConversationsRoot"); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + RecyclerView list = ReflectionHelpers.getField(activity, "screenList"); + View row = getRecyclerChild(list, 0); + + assertTrue(viewTreeContainsText(row, "Boss")); + assertTrue(viewTreeContainsText(row, "2 个线程 · 最近:发布回滚")); + } + private static View getRecyclerChild(RecyclerView recyclerView, int position) { RecyclerView.Adapter adapter = recyclerView.getAdapter(); int viewType = adapter.getItemViewType(position);