diff --git a/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java b/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java index f4bf530..1c0a642 100644 --- a/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ConversationFolderActivity.java @@ -9,6 +9,10 @@ import org.json.JSONArray; import org.json.JSONObject; import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; public class ConversationFolderActivity extends BossScreenActivity { public static final String EXTRA_FOLDER_KEY = "folder_key"; @@ -16,12 +20,16 @@ public class ConversationFolderActivity extends BossScreenActivity { public static final String EXTRA_TARGET_PROJECT_ID = "target_project_id"; public static final String EXTRA_TARGET_PROJECT_IDS = "target_project_ids"; public static final String EXTRA_TARGET_PROJECT_LABEL = "target_project_label"; + private static final long REALTIME_RELOAD_THROTTLE_MS = 900L; private String folderKey; private String folderName; private String targetProjectId; private ArrayList targetProjectIds; private String targetProjectLabel; + private @Nullable BossRealtimeClient realtimeClient; + private final Map recentRealtimeEventTimestamps = new LinkedHashMap<>(); + private final Set trackedProjectIds = new LinkedHashSet<>(); @Override protected void onCreate(@Nullable Bundle savedInstanceState) { @@ -45,9 +53,28 @@ public class ConversationFolderActivity extends BossScreenActivity { configureScreen(folderName == null || folderName.isEmpty() ? "项目线程" : folderName, "0 个线程"); refreshButton.setVisibility(android.view.View.GONE); setHeaderAction("...", v -> showMoreMenu()); + realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent); reload(); } + @Override + protected void onResume() { + super.onResume(); + updateRealtimeSubscription(); + } + + @Override + protected void onPause() { + stopRealtimeUpdates(); + super.onPause(); + } + + @Override + protected void onDestroy() { + stopRealtimeUpdates(); + super.onDestroy(); + } + @Override protected void reload() { if (folderKey == null || folderKey.isEmpty()) { @@ -72,9 +99,76 @@ public class ConversationFolderActivity extends BossScreenActivity { }); } + private void updateRealtimeSubscription() { + if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) { + realtimeClient.start(); + return; + } + stopRealtimeUpdates(); + } + + private void stopRealtimeUpdates() { + if (realtimeClient != null) { + realtimeClient.stop(); + } + } + + void handleRealtimeEvent(BossRealtimeEvent event) { + if (event == null || event.eventName.isEmpty()) { + return; + } + if (!shouldReloadForRealtimeEvent(event)) { + return; + } + String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event); + if (eventFingerprint.isEmpty()) { + return; + } + long now = System.currentTimeMillis(); + if (isDuplicateRealtimeEvent(eventFingerprint, now)) { + return; + } + runOnUiThread(this::reload); + } + + private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) { + if (!"conversation.updated".equals(event.eventName) + && !"project.messages.updated".equals(event.eventName)) { + return false; + } + String payloadProjectId = event.payload.optString("projectId", "").trim(); + if (payloadProjectId.isEmpty()) { + return false; + } + return trackedProjectIds.contains(payloadProjectId) + || (!targetProjectIds.isEmpty() && targetProjectIds.contains(payloadProjectId)) + || (targetProjectId != null && targetProjectId.equals(payloadProjectId)); + } + + private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) { + pruneRecentRealtimeEvents(now); + Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint); + if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) { + return true; + } + recentRealtimeEventTimestamps.put(eventFingerprint, now); + return false; + } + + private void pruneRecentRealtimeEvents(long now) { + java.util.Iterator> iterator = recentRealtimeEventTimestamps.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) { + iterator.remove(); + } + } + } + private void renderFolder(@Nullable JSONObject folder) { replaceContent(); if (folder == null) { + trackedProjectIds.clear(); appendContent(BossUi.buildEmptyCard(this, "未找到项目线程。")); setRefreshing(false); return; @@ -91,6 +185,7 @@ public class ConversationFolderActivity extends BossScreenActivity { )); JSONArray threads = folder.optJSONArray("threads"); + updateTrackedProjectIds(threads); if (threads == null || threads.length() == 0) { appendContent(BossUi.buildEmptyCard(this, "当前项目下没有线程。")); setRefreshing(false); @@ -127,6 +222,23 @@ public class ConversationFolderActivity extends BossScreenActivity { setRefreshing(false); } + private void updateTrackedProjectIds(@Nullable JSONArray threads) { + trackedProjectIds.clear(); + if (threads == null) { + return; + } + for (int i = 0; i < threads.length(); i++) { + JSONObject item = threads.optJSONObject(i); + if (item == null) { + continue; + } + String projectId = item.optString("projectId", "").trim(); + if (!projectId.isEmpty()) { + trackedProjectIds.add(projectId); + } + } + } + private void renderThreadAtIndex(JSONArray threads, int index, boolean highlighted) { JSONObject item = threads.optJSONObject(index); if (item == null) return; diff --git a/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java b/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java index 0780df5..b8efb73 100644 --- a/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java @@ -12,9 +12,13 @@ import androidx.appcompat.widget.SwitchCompat; import org.json.JSONArray; import org.json.JSONObject; +import java.util.LinkedHashMap; +import java.util.Map; + public class ConversationInfoActivity extends BossScreenActivity { public static final String EXTRA_PROJECT_ID = "project_id"; public static final String EXTRA_PROJECT_NAME = "project_name"; + private static final long REALTIME_RELOAD_THROTTLE_MS = 900L; private String projectId; private String projectName; @@ -22,6 +26,8 @@ public class ConversationInfoActivity extends BossScreenActivity { private int participantCount; private boolean takeoverEnabled; private boolean takeoverInheritedFromGlobal; + private @Nullable BossRealtimeClient realtimeClient; + private final Map recentRealtimeEventTimestamps = new LinkedHashMap<>(); @Override protected int getLayoutResId() { @@ -36,9 +42,28 @@ public class ConversationInfoActivity extends BossScreenActivity { configureScreen("会话信息", projectName == null ? "单线程会话" : projectName); refreshButton.setVisibility(android.view.View.GONE); setHeaderAction("...", v -> showMoreMenu()); + realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent); reload(); } + @Override + protected void onResume() { + super.onResume(); + updateRealtimeSubscription(); + } + + @Override + protected void onPause() { + stopRealtimeUpdates(); + super.onPause(); + } + + @Override + protected void onDestroy() { + stopRealtimeUpdates(); + super.onDestroy(); + } + @Override protected void reload() { if (projectId == null || projectId.isEmpty()) { @@ -72,6 +97,67 @@ public class ConversationInfoActivity extends BossScreenActivity { }); } + private void updateRealtimeSubscription() { + if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) { + realtimeClient.start(); + return; + } + stopRealtimeUpdates(); + } + + private void stopRealtimeUpdates() { + if (realtimeClient != null) { + realtimeClient.stop(); + } + } + + void handleRealtimeEvent(BossRealtimeEvent event) { + if (event == null || event.eventName.isEmpty() || projectId == null || projectId.isEmpty()) { + return; + } + if (!shouldReloadForRealtimeEvent(event)) { + return; + } + String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event); + if (eventFingerprint.isEmpty()) { + return; + } + long now = System.currentTimeMillis(); + if (isDuplicateRealtimeEvent(eventFingerprint, now)) { + return; + } + runOnUiThread(this::reload); + } + + private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) { + String payloadProjectId = event.payload.optString("projectId", "").trim(); + if (payloadProjectId.isEmpty() || !payloadProjectId.equals(projectId)) { + return false; + } + return "conversation.updated".equals(event.eventName) + || "project.messages.updated".equals(event.eventName); + } + + private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) { + pruneRecentRealtimeEvents(now); + Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint); + if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) { + return true; + } + recentRealtimeEventTimestamps.put(eventFingerprint, now); + return false; + } + + private void pruneRecentRealtimeEvents(long now) { + java.util.Iterator> iterator = recentRealtimeEventTimestamps.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) { + iterator.remove(); + } + } + } + private void renderConversation(JSONObject detail, JSONObject participantsPayload, @Nullable JSONObject threadStatusPayload) { replaceContent(); JSONObject project = detail.optJSONObject("project"); diff --git a/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java b/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java index eac025b..dbaf6e8 100644 --- a/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java @@ -12,12 +12,18 @@ import androidx.appcompat.app.AlertDialog; import org.json.JSONArray; import org.json.JSONObject; +import java.util.LinkedHashMap; +import java.util.Map; + public class GroupInfoActivity extends BossScreenActivity { public static final String EXTRA_PROJECT_ID = "project_id"; public static final String EXTRA_PROJECT_NAME = "project_name"; + private static final long REALTIME_RELOAD_THROTTLE_MS = 900L; private String projectId; private String projectName; + private @Nullable BossRealtimeClient realtimeClient; + private final Map recentRealtimeEventTimestamps = new LinkedHashMap<>(); @Override protected int getLayoutResId() { @@ -32,9 +38,28 @@ public class GroupInfoActivity extends BossScreenActivity { configureScreen("群资料", projectName == null ? "协作群聊" : projectName); refreshButton.setVisibility(android.view.View.GONE); setHeaderAction("...", v -> showMoreMenu()); + realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent); reload(); } + @Override + protected void onResume() { + super.onResume(); + updateRealtimeSubscription(); + } + + @Override + protected void onPause() { + stopRealtimeUpdates(); + super.onPause(); + } + + @Override + protected void onDestroy() { + stopRealtimeUpdates(); + super.onDestroy(); + } + @Override protected void reload() { if (projectId == null || projectId.isEmpty()) { @@ -63,6 +88,67 @@ public class GroupInfoActivity extends BossScreenActivity { }); } + private void updateRealtimeSubscription() { + if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) { + realtimeClient.start(); + return; + } + stopRealtimeUpdates(); + } + + private void stopRealtimeUpdates() { + if (realtimeClient != null) { + realtimeClient.stop(); + } + } + + void handleRealtimeEvent(BossRealtimeEvent event) { + if (event == null || event.eventName.isEmpty() || projectId == null || projectId.isEmpty()) { + return; + } + if (!shouldReloadForRealtimeEvent(event)) { + return; + } + String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event); + if (eventFingerprint.isEmpty()) { + return; + } + long now = System.currentTimeMillis(); + if (isDuplicateRealtimeEvent(eventFingerprint, now)) { + return; + } + runOnUiThread(this::reload); + } + + private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) { + String payloadProjectId = event.payload.optString("projectId", "").trim(); + if (payloadProjectId.isEmpty() || !payloadProjectId.equals(projectId)) { + return false; + } + return "conversation.updated".equals(event.eventName) + || "project.messages.updated".equals(event.eventName); + } + + private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) { + pruneRecentRealtimeEvents(now); + Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint); + if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) { + return true; + } + recentRealtimeEventTimestamps.put(eventFingerprint, now); + return false; + } + + private void pruneRecentRealtimeEvents(long now) { + java.util.Iterator> iterator = recentRealtimeEventTimestamps.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) { + iterator.remove(); + } + } + } + private void renderGroup(JSONObject detail, JSONObject participantsPayload) { renderGroup(detail, participantsPayload, null); } diff --git a/android/app/src/test/java/com/hyzq/boss/ConversationFolderActivityTest.java b/android/app/src/test/java/com/hyzq/boss/ConversationFolderActivityTest.java index fc980da..8ed4de1 100644 --- a/android/app/src/test/java/com/hyzq/boss/ConversationFolderActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ConversationFolderActivityTest.java @@ -19,6 +19,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; +import org.robolectric.Shadows; import org.robolectric.annotation.Config; import org.robolectric.shadows.ShadowDialog; import org.robolectric.util.ReflectionHelpers; @@ -117,6 +118,70 @@ public class ConversationFolderActivityTest { assertEquals(1, countTextOccurrences(content, "目标线程")); } + @Test + public void matchingProjectMessagesUpdatedEventTriggersReload() throws Exception { + Intent intent = new Intent() + .putExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY, "talking") + .putExtra(ConversationFolderActivity.EXTRA_FOLDER_NAME, "Talking"); + TestConversationFolderActivity activity = Robolectric + .buildActivity(TestConversationFolderActivity.class, intent) + .setup() + .resume() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderFolder", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildFolderPayload()) + ); + activity.reloadEnabled = true; + activity.reloadCount = 0; + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2")) + ) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(1, activity.reloadCount); + } + + @Test + public void unrelatedConversationEventDoesNotTriggerReload() throws Exception { + Intent intent = new Intent() + .putExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY, "talking") + .putExtra(ConversationFolderActivity.EXTRA_FOLDER_NAME, "Talking"); + TestConversationFolderActivity activity = Robolectric + .buildActivity(TestConversationFolderActivity.class, intent) + .setup() + .resume() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderFolder", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildFolderPayload()) + ); + activity.reloadEnabled = true; + activity.reloadCount = 0; + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-9")) + ) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(0, activity.reloadCount); + } + private static JSONObject buildFolderPayload() throws Exception { JSONArray threads = new JSONArray() .put(new JSONObject() @@ -182,9 +247,17 @@ public class ConversationFolderActivityTest { } public static class TestConversationFolderActivity extends ConversationFolderActivity { + private boolean reloadEnabled; + private int reloadCount; + @Override protected void reload() { - // Tests render the folder state directly. + if (!reloadEnabled) { + return; + } + reloadCount += 1; + replaceContent(BossUi.buildEmptyCard(this, "test reload")); + setRefreshing(false); } } } diff --git a/android/app/src/test/java/com/hyzq/boss/ConversationInfoActivityTest.java b/android/app/src/test/java/com/hyzq/boss/ConversationInfoActivityTest.java index def9e53..3821f69 100644 --- a/android/app/src/test/java/com/hyzq/boss/ConversationInfoActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ConversationInfoActivityTest.java @@ -187,6 +187,7 @@ public class ConversationInfoActivityTest { ); ReflectionHelpers.setField(activity, "apiClient", apiClient); ReflectionHelpers.setField(activity, "reloadEnabled", true); + ReflectionHelpers.setField(activity, "delegateReloadToSuper", true); ReflectionHelpers.setField(activity, "executor", new DirectExecutorService()); activity.reload(); @@ -217,6 +218,7 @@ public class ConversationInfoActivityTest { apiClient.failFirstSave = true; ReflectionHelpers.setField(activity, "apiClient", apiClient); ReflectionHelpers.setField(activity, "reloadEnabled", true); + ReflectionHelpers.setField(activity, "delegateReloadToSuper", true); ReflectionHelpers.setField(activity, "executor", new DirectExecutorService()); activity.reload(); @@ -233,6 +235,58 @@ public class ConversationInfoActivityTest { assertEquals(1, apiClient.autoLoginCalls); } + @Test + public void matchingProjectMessagesUpdatedEventTriggersReload() throws Exception { + Intent intent = new Intent() + .putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归"); + TestConversationInfoActivity activity = Robolectric + .buildActivity(TestConversationInfoActivity.class, intent) + .setup() + .resume() + .get(); + activity.reloadEnabled = true; + activity.reloadCount = 0; + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1")) + ) + ); + ShadowLooper.shadowMainLooper().idle(); + + assertEquals(1, activity.reloadCount); + } + + @Test + public void unrelatedConversationEventDoesNotTriggerReload() throws Exception { + Intent intent = new Intent() + .putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归"); + TestConversationInfoActivity activity = Robolectric + .buildActivity(TestConversationInfoActivity.class, intent) + .setup() + .resume() + .get(); + activity.reloadEnabled = true; + activity.reloadCount = 0; + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-2")) + ) + ); + ShadowLooper.shadowMainLooper().idle(); + + assertEquals(0, activity.reloadCount); + } + private static JSONObject buildDetailPayload() throws Exception { JSONObject threadMeta = new JSONObject() .put("threadId", "thread-7") @@ -341,13 +395,21 @@ public class ConversationInfoActivityTest { public static class TestConversationInfoActivity extends ConversationInfoActivity { private boolean reloadEnabled; + private boolean delegateReloadToSuper; + private int reloadCount; @Override protected void reload() { if (!reloadEnabled) { return; } - super.reload(); + reloadCount += 1; + if (delegateReloadToSuper) { + super.reload(); + return; + } + replaceContent(BossUi.buildEmptyCard(this, "test reload")); + setRefreshing(false); } } diff --git a/android/app/src/test/java/com/hyzq/boss/GroupInfoActivityTest.java b/android/app/src/test/java/com/hyzq/boss/GroupInfoActivityTest.java index 26405e1..8cf9d49 100644 --- a/android/app/src/test/java/com/hyzq/boss/GroupInfoActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/GroupInfoActivityTest.java @@ -286,6 +286,58 @@ public class GroupInfoActivityTest { assertEquals("{\"lightDispatchReminderEnabled\":true}", connection.requestBody()); } + @Test + public void matchingConversationUpdatedEventTriggersReload() throws Exception { + Intent intent = new Intent() + .putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1") + .putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群"); + TestGroupInfoActivity activity = Robolectric + .buildActivity(TestGroupInfoActivity.class, intent) + .setup() + .resume() + .get(); + activity.reloadEnabled = true; + activity.reloadCount = 0; + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "group-1")) + ) + ); + Shadows.shadowOf(Looper.getMainLooper()).idle(); + + assertEquals(1, activity.reloadCount); + } + + @Test + public void unrelatedProjectMessagesEventDoesNotTriggerReload() throws Exception { + Intent intent = new Intent() + .putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1") + .putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群"); + TestGroupInfoActivity activity = Robolectric + .buildActivity(TestGroupInfoActivity.class, intent) + .setup() + .resume() + .get(); + activity.reloadEnabled = true; + activity.reloadCount = 0; + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2")) + ) + ); + Shadows.shadowOf(Looper.getMainLooper()).idle(); + + assertEquals(0, activity.reloadCount); + } + private static JSONObject buildDetailPayload() throws Exception { return buildDetailPayload(false); } @@ -425,9 +477,17 @@ public class GroupInfoActivityTest { } public static class TestGroupInfoActivity extends GroupInfoActivity { + private boolean reloadEnabled; + private int reloadCount; + @Override protected void reload() { - // Tests render the lightweight info state directly. + if (!reloadEnabled) { + return; + } + reloadCount += 1; + replaceContent(BossUi.buildEmptyCard(this, "test reload")); + setRefreshing(false); } } diff --git a/public/downloads/boss-android-latest.apk b/public/downloads/boss-android-latest.apk index 82bf5ec..6140d6d 100644 Binary files a/public/downloads/boss-android-latest.apk and b/public/downloads/boss-android-latest.apk differ diff --git a/public/downloads/boss-android-latest.json b/public/downloads/boss-android-latest.json index 1c84058..b83033b 100644 --- a/public/downloads/boss-android-latest.json +++ b/public/downloads/boss-android-latest.json @@ -1,9 +1,9 @@ { "fileName": "boss-android-v2.5.11-release.apk", "urlPath": "/api/v1/user/ota/package", - "sizeBytes": 3353553, - "updatedAt": "2026-04-07T07:18:49Z", - "sha256": "19e0ff6da6a1b4c0239ab6283c0becf1107a4637f5322d3268954ce2910de5cd", + "sizeBytes": 3354971, + "updatedAt": "2026-04-07T07:47:51Z", + "sha256": "09649f9fa11f5dec4192088e5fc3f6be025e42cd1519c647e80e10505e99a0b3", "versionName": "2.5.11", "versionCode": 24, "buildFlavor": "release" diff --git a/public/downloads/boss-android-v2.5.11-release.apk b/public/downloads/boss-android-v2.5.11-release.apk index 82bf5ec..6140d6d 100644 Binary files a/public/downloads/boss-android-v2.5.11-release.apk and b/public/downloads/boss-android-v2.5.11-release.apk differ