fix: group fallback conversation feed into folder archives on android

This commit is contained in:
kris
2026-04-06 06:55:06 +08:00
parent b7492e4789
commit d28afb2df1
3 changed files with 414 additions and 38 deletions

View File

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

View File

@@ -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<String> 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<String, List<JSONObject>> 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<JSONObject> items = grouped.get(folderKey);
if (items == null) {
items = new ArrayList<>();
grouped.put(folderKey, items);
}
items.add(copyJson(item));
}
for (Map.Entry<String, List<JSONObject>> entry : grouped.entrySet()) {
List<JSONObject> 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<JSONObject> items) {
List<JSONObject> 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<JSONObject> 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<JSONObject> items) {
List<JSONObject> 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<JSONObject>() {
@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<JSONObject> 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<JSONObject> items) {
for (JSONObject item : items) {
if ("置顶".equals(item.optString("topPinnedLabel", ""))) {
return true;
}
}
return false;
}
private static boolean hasManualPinned(List<JSONObject> items) {
for (JSONObject item : items) {
if (item.optBoolean("manualPinned", false)) {
return true;
}
}
return false;
}
private static int sumInt(List<JSONObject> 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;

View File

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