From d28afb2df1bf40aa2adef473552d7924afd0631a Mon Sep 17 00:00:00 2001 From: kris Date: Mon, 6 Apr 2026 06:55:06 +0800 Subject: [PATCH] fix: group fallback conversation feed into folder archives on android --- .../main/java/com/hyzq/boss/MainActivity.java | 14 +- .../com/hyzq/boss/WechatSurfaceMapper.java | 308 ++++++++++++++++++ .../hyzq/boss/MainActivityRealtimeTest.java | 130 ++++++-- 3 files changed, 414 insertions(+), 38 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 1c4e826..7d0be6b 100644 --- a/android/app/src/main/java/com/hyzq/boss/MainActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java @@ -409,9 +409,14 @@ public class MainActivity extends AppCompatActivity { final boolean finalConversationsOk = conversationsOk; runOnUiThread(() -> { sessionData = session; + JSONArray refreshedConversations = finalConversations == null + ? null + : WechatSurfaceMapper.normalizeConversationHomeFeed( + finalConversations.json.optJSONArray("conversations") + ); conversationsData = WechatSurfaceMapper.resolveRefreshValue( conversationsData, - finalConversations == null ? null : finalConversations.json.optJSONArray("conversations"), + refreshedConversations, finalConversationsOk ); maybeApplyPreferredEntry(); @@ -699,9 +704,14 @@ public class MainActivity extends AppCompatActivity { final boolean finalSettingsOk = settingsOk; runOnUiThread(() -> { sessionData = finalSession; + JSONArray refreshedConversations = finalConversations == null + ? null + : WechatSurfaceMapper.normalizeConversationHomeFeed( + finalConversations.json.optJSONArray("conversations") + ); conversationsData = WechatSurfaceMapper.resolveRefreshValue( conversationsData, - finalConversations == null ? null : finalConversations.json.optJSONArray("conversations"), + refreshedConversations, finalConversationsOk ); devicesData = WechatSurfaceMapper.resolveRefreshValue( 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 cddd898..5a9421e 100644 --- a/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java +++ b/android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java @@ -5,7 +5,11 @@ import org.json.JSONArray; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; public final class WechatSurfaceMapper { private static final List ROOT_TAB_LABELS = Arrays.asList( @@ -255,6 +259,310 @@ public final class WechatSurfaceMapper { return cachedValue; } + public static JSONArray normalizeConversationHomeFeed(JSONArray source) { + if (source == null) { + return null; + } + JSONArray passthrough = new JSONArray(); + Map> grouped = new LinkedHashMap<>(); + for (int index = 0; index < source.length(); index += 1) { + JSONObject item = source.optJSONObject(index); + if (item == null) { + continue; + } + if (!"single_device".equals(item.optString("conversationType", ""))) { + passthrough.put(copyJson(item)); + continue; + } + String folderKey = item.optString("folderKey", "").trim(); + if (folderKey.isEmpty()) { + passthrough.put(copyJson(item)); + continue; + } + List items = grouped.get(folderKey); + if (items == null) { + items = new ArrayList<>(); + grouped.put(folderKey, items); + } + items.add(copyJson(item)); + } + + for (Map.Entry> entry : grouped.entrySet()) { + List items = entry.getValue(); + if (items == null || items.isEmpty()) { + continue; + } + if (items.size() == 1) { + passthrough.put(items.get(0)); + continue; + } + passthrough.put(buildFolderArchiveItem(entry.getKey(), items)); + } + return sortConversationItems(passthrough); + } + + private static JSONObject buildFolderArchiveItem(String folderKey, List items) { + List sortedByLatest = new ArrayList<>(items); + sortedByLatest.sort((left, right) -> compareConversationFreshness(right, left)); + JSONObject latest = sortedByLatest.get(0); + JSONObject topContext = selectTopContextItem(items); + JSONArray searchAliases = new JSONArray(); + JSONArray searchTargetProjectIds = new JSONArray(); + for (JSONObject item : sortedByLatest) { + String alias = firstNonBlank(item.optString("threadTitle", ""), item.optString("projectTitle", "")); + String projectId = item.optString("projectId", "").trim(); + if (!alias.isEmpty() && !projectId.isEmpty()) { + searchAliases.put(alias); + searchTargetProjectIds.put(projectId); + } + } + + JSONObject folder = new JSONObject(); + putIfNotEmpty(folder, "conversationId", "folder-" + folderKey); + putIfNotEmpty(folder, "conversationType", "folder_archive"); + putIfNotEmpty(folder, "projectId", folderKey); + String projectTitle = firstNonBlank( + latest.optString("folderLabel", ""), + latest.optString("projectTitle", ""), + latest.optString("threadTitle", "") + ); + putIfNotEmpty(folder, "projectTitle", projectTitle); + putIfNotEmpty(folder, "threadTitle", projectTitle); + String recentThread = latest.optString("threadTitle", "").trim(); + putIfNotEmpty( + folder, + "folderLabel", + recentThread.isEmpty() + ? items.size() + " 个线程" + : items.size() + " 个线程 · 最近:" + recentThread + ); + putIfNotEmpty(folder, "folderKey", folderKey); + safePut(folder, "threadCount", items.size()); + if (searchAliases.length() > 0) { + safePut(folder, "searchAliases", searchAliases); + safePut(folder, "searchTargetProjectIds", searchTargetProjectIds); + } + putIfNotEmpty(folder, "preview", firstNonBlank(latest.optString("preview", ""), latest.optString("lastMessagePreview", ""))); + putIfNotEmpty( + folder, + "lastMessagePreview", + firstNonBlank(latest.optString("lastMessagePreview", ""), latest.optString("preview", "")) + ); + safePut(folder, "activityIconCount", Math.max(0, Math.min(4, sumInt(items, "activityIconCount")))); + if (hasPinnedLabel(items)) { + safePut(folder, "topPinnedLabel", "置顶"); + } + safePut(folder, "manualPinned", hasManualPinned(items)); + putIfNotEmpty(folder, "latestReplyAt", latest.optString("latestReplyAt", "")); + putIfNotEmpty(folder, "latestReplyLabel", latest.optString("latestReplyLabel", "")); + safePut(folder, "unreadCount", sumInt(items, "unreadCount")); + putIfNotEmpty(folder, "riskLevel", resolveHighestRisk(items)); + safePut(folder, "activeDeviceCount", Math.max(1, latest.optInt("activeDeviceCount", 1))); + JSONArray deviceNamesPreview = latest.optJSONArray("deviceNamesPreview"); + if (deviceNamesPreview != null) { + safePut(folder, "deviceNamesPreview", copyArray(deviceNamesPreview)); + } + JSONObject avatar = latest.optJSONObject("avatar"); + safePut(folder, "avatar", avatar == null ? new JSONObject() : copyJson(avatar)); + JSONObject indicator = topContext == null + ? buildContextIndicator(100, "safe") + : copyJson(topContext.optJSONObject("contextBudgetIndicator")); + if (indicator == null) { + indicator = buildContextIndicator(100, "safe"); + } + safePut(indicator, "visible", true); + safePut(indicator, "style", "ring_percent"); + safePut(folder, "contextBudgetIndicator", indicator); + putIfNotEmpty(folder, "contextBudgetSourceNodeId", topContext == null ? "" : topContext.optString("contextBudgetSourceNodeId", "")); + putIfNotEmpty(folder, "contextBudgetUpdatedAt", topContext == null ? "" : topContext.optString("contextBudgetUpdatedAt", "")); + safePut(folder, "mustFinishBeforeCompaction", topContext != null && topContext.optBoolean("mustFinishBeforeCompaction", false)); + return folder; + } + + private static JSONArray sortConversationItems(JSONArray source) { + List items = new ArrayList<>(); + for (int index = 0; index < source.length(); index += 1) { + JSONObject item = source.optJSONObject(index); + if (item != null) { + items.add(item); + } + } + items.sort((left, right) -> { + boolean leftPinned = "置顶".equals(left.optString("topPinnedLabel", "")) || left.optBoolean("manualPinned", false); + boolean rightPinned = "置顶".equals(right.optString("topPinnedLabel", "")) || right.optBoolean("manualPinned", false); + if (leftPinned != rightPinned) { + return leftPinned ? -1 : 1; + } + return compareConversationFreshness(right, left); + }); + JSONArray sorted = new JSONArray(); + for (JSONObject item : items) { + sorted.put(item); + } + return sorted; + } + + private static int compareConversationFreshness(JSONObject left, JSONObject right) { + String leftAt = left.optString("latestReplyAt", ""); + String rightAt = right.optString("latestReplyAt", ""); + if (!leftAt.equals(rightAt)) { + return leftAt.compareTo(rightAt); + } + return left.optString("projectId", "").compareTo(right.optString("projectId", "")); + } + + private static JSONObject selectTopContextItem(List items) { + List visible = new ArrayList<>(); + for (JSONObject item : items) { + JSONObject indicator = item.optJSONObject("contextBudgetIndicator"); + if (indicator != null && indicator.optBoolean("visible", false)) { + visible.add(item); + } + } + if (visible.isEmpty()) { + return null; + } + Collections.sort(visible, new Comparator() { + @Override + public int compare(JSONObject left, JSONObject right) { + boolean leftMustFinish = left.optBoolean("mustFinishBeforeCompaction", false); + boolean rightMustFinish = right.optBoolean("mustFinishBeforeCompaction", false); + if (leftMustFinish != rightMustFinish) { + return leftMustFinish ? -1 : 1; + } + String leftLevel = contextLevel(left); + String rightLevel = contextLevel(right); + if (contextLevelPriority(leftLevel) != contextLevelPriority(rightLevel)) { + return Integer.compare(contextLevelPriority(leftLevel), contextLevelPriority(rightLevel)); + } + return compareConversationFreshness(right, left); + } + }); + return visible.get(0); + } + + private static String resolveHighestRisk(List items) { + boolean hasMedium = false; + for (JSONObject item : items) { + String risk = item.optString("riskLevel", "low"); + if ("high".equals(risk)) { + return "high"; + } + if ("medium".equals(risk)) { + hasMedium = true; + } + } + return hasMedium ? "medium" : "low"; + } + + private static boolean hasPinnedLabel(List items) { + for (JSONObject item : items) { + if ("置顶".equals(item.optString("topPinnedLabel", ""))) { + return true; + } + } + return false; + } + + private static boolean hasManualPinned(List items) { + for (JSONObject item : items) { + if (item.optBoolean("manualPinned", false)) { + return true; + } + } + return false; + } + + private static int sumInt(List items, String key) { + int total = 0; + for (JSONObject item : items) { + total += item.optInt(key, 0); + } + return total; + } + + private static String contextLevel(JSONObject item) { + JSONObject indicator = item.optJSONObject("contextBudgetIndicator"); + if (indicator == null) { + return "safe"; + } + return indicator.optString("level", "safe"); + } + + private static int contextLevelPriority(String level) { + switch (level) { + case "critical": + return 0; + case "urgent": + return 1; + case "watch": + return 2; + default: + return 3; + } + } + + private static JSONObject buildContextIndicator(int percent, String level) { + JSONObject indicator = new JSONObject(); + safePut(indicator, "visible", true); + safePut(indicator, "style", "ring_percent"); + safePut(indicator, "percent", percent); + safePut(indicator, "level", level); + return indicator; + } + + private static void putIfNotEmpty(JSONObject target, String key, String value) { + if (target == null || value == null || value.trim().isEmpty()) { + return; + } + safePut(target, key, value); + } + + private static JSONObject copyJson(JSONObject source) { + if (source == null) { + return null; + } + try { + return new JSONObject(source.toString()); + } catch (Exception error) { + return new JSONObject(); + } + } + + private static JSONArray copyArray(JSONArray source) { + if (source == null) { + return null; + } + try { + return new JSONArray(source.toString()); + } catch (Exception error) { + return new JSONArray(); + } + } + + private static void safePut(JSONObject target, String key, Object value) { + if (target == null || key == null) { + return; + } + try { + target.put(key, value); + } catch (Exception ignored) { + // Best effort normalization for fallback feed. + } + } + + private static String firstNonBlank(String... values) { + if (values == null) { + return ""; + } + for (String value : values) { + if (value != null && !value.trim().isEmpty()) { + return value.trim(); + } + } + return ""; + } + public static final class RootTopAction { public final String label; public final boolean primaryStyle; diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java index 65f0e08..4a576c7 100644 --- a/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java +++ b/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java @@ -241,7 +241,7 @@ public class MainActivityRealtimeTest { } @Test - public void refreshConversationsData_fallsBackToFlatConversationsFeedWhenHomeFeedFails() throws Exception { + public void refreshConversationsData_groupsFlatFallbackFeedWhenHomeFeedFails() throws Exception { MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get(); Shadows.shadowOf(activity.getMainLooper()).idle(); @@ -253,16 +253,18 @@ public class MainActivityRealtimeTest { Shadows.shadowOf(activity.getMainLooper()).idle(); activity.refreshConversationsData(); - waitFor(() -> apiClient.homeCalls > 0 && apiClient.conversationsCalls > 0); + waitFor(() -> apiClient.homeCalls > 0 && apiClient.conversationsCalls > 0 && hasConversationData(activity)); assertEquals(1, apiClient.homeCalls); assertEquals(1, apiClient.conversationsCalls); JSONArray conversationsData = ReflectionHelpers.getField(activity, "conversationsData"); - assertEquals("flat-thread", conversationsData.optJSONObject(0).optString("projectId", "")); + assertEquals("folder_archive", conversationsData.optJSONObject(0).optString("conversationType", "")); + assertEquals("mac-studio:boss", conversationsData.optJSONObject(0).optString("projectId", "")); + assertEquals(2, conversationsData.optJSONObject(0).optInt("threadCount", 0)); } @Test - public void refreshConversationsData_fallsBackToFlatConversationsFeedWhenHomeFeedThrowsIOException() throws Exception { + public void refreshConversationsData_groupsFlatFallbackFeedWhenHomeFeedThrowsIOException() throws Exception { MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get(); Shadows.shadowOf(activity.getMainLooper()).idle(); @@ -274,12 +276,14 @@ public class MainActivityRealtimeTest { Shadows.shadowOf(activity.getMainLooper()).idle(); activity.refreshConversationsData(); - waitFor(() -> apiClient.homeCalls > 0 && apiClient.conversationsCalls > 0); + waitFor(() -> apiClient.homeCalls > 0 && apiClient.conversationsCalls > 0 && hasConversationData(activity)); assertEquals(1, apiClient.homeCalls); assertEquals(1, apiClient.conversationsCalls); JSONArray conversationsData = ReflectionHelpers.getField(activity, "conversationsData"); - assertEquals("flat-thread", conversationsData.optJSONObject(0).optString("projectId", "")); + assertEquals("folder_archive", conversationsData.optJSONObject(0).optString("conversationType", "")); + assertEquals("mac-studio:boss", conversationsData.optJSONObject(0).optString("projectId", "")); + assertEquals(2, conversationsData.optJSONObject(0).optInt("threadCount", 0)); } @Test @@ -306,7 +310,7 @@ public class MainActivityRealtimeTest { } @Test - public void refreshAllData_fallsBackToFlatConversationsFeedWhenHomeFeedFails() throws Exception { + public void refreshAllData_groupsFlatFallbackFeedWhenHomeFeedFails() throws Exception { MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get(); Shadows.shadowOf(activity.getMainLooper()).idle(); @@ -322,16 +326,18 @@ public class MainActivityRealtimeTest { "refreshAllData", ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject()) ); - waitFor(() -> apiClient.homeCalls > 0 && apiClient.conversationsCalls > 0); + waitFor(() -> apiClient.homeCalls > 0 && apiClient.conversationsCalls > 0 && hasConversationData(activity)); assertEquals(1, apiClient.homeCalls); assertEquals(1, apiClient.conversationsCalls); JSONArray conversationsData = ReflectionHelpers.getField(activity, "conversationsData"); - assertEquals("flat-thread", conversationsData.optJSONObject(0).optString("projectId", "")); + assertEquals("folder_archive", conversationsData.optJSONObject(0).optString("conversationType", "")); + assertEquals("mac-studio:boss", conversationsData.optJSONObject(0).optString("projectId", "")); + assertEquals(2, conversationsData.optJSONObject(0).optInt("threadCount", 0)); } @Test - public void refreshAllData_fallsBackToFlatConversationsFeedWhenHomeFeedThrowsIOException() throws Exception { + public void refreshAllData_groupsFlatFallbackFeedWhenHomeFeedThrowsIOException() throws Exception { MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get(); Shadows.shadowOf(activity.getMainLooper()).idle(); @@ -347,12 +353,14 @@ public class MainActivityRealtimeTest { "refreshAllData", ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject()) ); - waitFor(() -> apiClient.homeCalls > 0 && apiClient.conversationsCalls > 0); + waitFor(() -> apiClient.homeCalls > 0 && apiClient.conversationsCalls > 0 && hasConversationData(activity)); assertEquals(1, apiClient.homeCalls); assertEquals(1, apiClient.conversationsCalls); JSONArray conversationsData = ReflectionHelpers.getField(activity, "conversationsData"); - assertEquals("flat-thread", conversationsData.optJSONObject(0).optString("projectId", "")); + assertEquals("folder_archive", conversationsData.optJSONObject(0).optString("conversationType", "")); + assertEquals("mac-studio:boss", conversationsData.optJSONObject(0).optString("projectId", "")); + assertEquals(2, conversationsData.optJSONObject(0).optInt("threadCount", 0)); } private static void waitFor(BooleanSupplier condition) throws Exception { @@ -367,6 +375,11 @@ public class MainActivityRealtimeTest { throw new AssertionError("condition not met before timeout"); } + private static boolean hasConversationData(MainActivity activity) { + JSONArray conversationsData = ReflectionHelpers.getField(activity, "conversationsData"); + return conversationsData != null && conversationsData.length() > 0; + } + public static class TestMainActivity extends MainActivity { int conversationRefreshCount; int deviceRefreshCount; @@ -455,14 +468,29 @@ public class MainActivityRealtimeTest { } private static JSONArray buildFlatConversations() throws org.json.JSONException { - return new JSONArray().put(new JSONObject() - .put("projectId", "flat-thread") - .put("conversationType", "single_device") - .put("projectTitle", "发布回滚") - .put("threadTitle", "发布回滚") - .put("folderLabel", "Boss") - .put("lastMessagePreview", "最近:发布回滚") - .put("latestReplyLabel", "11:00")); + return new JSONArray() + .put(new JSONObject() + .put("projectId", "thread-revert") + .put("conversationType", "single_device") + .put("projectTitle", "发布回滚") + .put("threadTitle", "发布回滚") + .put("folderLabel", "Boss") + .put("folderKey", "mac-studio:boss") + .put("lastMessagePreview", "最近:发布回滚") + .put("latestReplyAt", "2026-04-06T10:00:00.000Z") + .put("latestReplyLabel", "11:00") + .put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 80).put("level", "watch"))) + .put(new JSONObject() + .put("projectId", "thread-ui") + .put("conversationType", "single_device") + .put("projectTitle", "Android UI 收尾") + .put("threadTitle", "Android UI 收尾") + .put("folderLabel", "Boss") + .put("folderKey", "mac-studio:boss") + .put("lastMessagePreview", "最近:Android UI 收尾") + .put("latestReplyAt", "2026-04-06T09:59:00.000Z") + .put("latestReplyLabel", "10:59") + .put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 95).put("level", "safe"))); } } @@ -543,14 +571,29 @@ public class MainActivityRealtimeTest { } private static JSONArray buildFlatConversations() throws org.json.JSONException { - return new JSONArray().put(new JSONObject() - .put("projectId", "flat-thread") - .put("conversationType", "single_device") - .put("projectTitle", "发布回滚") - .put("threadTitle", "发布回滚") - .put("folderLabel", "Boss") - .put("lastMessagePreview", "最近:发布回滚") - .put("latestReplyLabel", "11:00")); + return new JSONArray() + .put(new JSONObject() + .put("projectId", "thread-revert") + .put("conversationType", "single_device") + .put("projectTitle", "发布回滚") + .put("threadTitle", "发布回滚") + .put("folderLabel", "Boss") + .put("folderKey", "mac-studio:boss") + .put("lastMessagePreview", "最近:发布回滚") + .put("latestReplyAt", "2026-04-06T10:00:00.000Z") + .put("latestReplyLabel", "11:00") + .put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 80).put("level", "watch"))) + .put(new JSONObject() + .put("projectId", "thread-ui") + .put("conversationType", "single_device") + .put("projectTitle", "Android UI 收尾") + .put("threadTitle", "Android UI 收尾") + .put("folderLabel", "Boss") + .put("folderKey", "mac-studio:boss") + .put("lastMessagePreview", "最近:Android UI 收尾") + .put("latestReplyAt", "2026-04-06T09:59:00.000Z") + .put("latestReplyLabel", "10:59") + .put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 95).put("level", "safe"))); } } @@ -616,14 +659,29 @@ public class MainActivityRealtimeTest { } private static JSONArray buildFlatConversations() throws org.json.JSONException { - return new JSONArray().put(new JSONObject() - .put("projectId", "flat-thread") - .put("conversationType", "single_device") - .put("projectTitle", "发布回滚") - .put("threadTitle", "发布回滚") - .put("folderLabel", "Boss") - .put("lastMessagePreview", "最近:发布回滚") - .put("latestReplyLabel", "11:00")); + return new JSONArray() + .put(new JSONObject() + .put("projectId", "thread-revert") + .put("conversationType", "single_device") + .put("projectTitle", "发布回滚") + .put("threadTitle", "发布回滚") + .put("folderLabel", "Boss") + .put("folderKey", "mac-studio:boss") + .put("lastMessagePreview", "最近:发布回滚") + .put("latestReplyAt", "2026-04-06T10:00:00.000Z") + .put("latestReplyLabel", "11:00") + .put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 80).put("level", "watch"))) + .put(new JSONObject() + .put("projectId", "thread-ui") + .put("conversationType", "single_device") + .put("projectTitle", "Android UI 收尾") + .put("threadTitle", "Android UI 收尾") + .put("folderLabel", "Boss") + .put("folderKey", "mac-studio:boss") + .put("lastMessagePreview", "最近:Android UI 收尾") + .put("latestReplyAt", "2026-04-06T09:59:00.000Z") + .put("latestReplyLabel", "10:59") + .put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 95).put("level", "safe"))); } } }