diff --git a/android/app/src/main/java/com/hyzq/boss/AboutActivity.java b/android/app/src/main/java/com/hyzq/boss/AboutActivity.java index 46e0d2d..40dbeb9 100644 --- a/android/app/src/main/java/com/hyzq/boss/AboutActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/AboutActivity.java @@ -19,8 +19,12 @@ import androidx.annotation.Nullable; import org.json.JSONArray; import org.json.JSONObject; +import java.util.LinkedHashMap; +import java.util.Map; + public class AboutActivity extends BossScreenActivity { private static final long OTA_PROGRESS_POLL_INTERVAL_MS = 1_000L; + private static final long REALTIME_RELOAD_THROTTLE_MS = 900L; private static final String OTA_UI_PREFS = "boss_native_client"; private static final String KEY_ACTIVE_DOWNLOAD_ID = "ota_active_download_id"; private static final String KEY_COMPLETED_DOWNLOAD_ID = "ota_completed_download_id"; @@ -38,6 +42,8 @@ public class AboutActivity extends BossScreenActivity { private int lastDownloadStatus = -1; private long lastDownloadedBytes = 0L; private long lastTotalBytes = -1L; + private @Nullable BossRealtimeClient realtimeClient; + private final Map recentRealtimeEventTimestamps = new LinkedHashMap<>(); private final Handler otaProgressHandler = new Handler(Looper.getMainLooper()); private final Runnable otaProgressPoller = new Runnable() { @Override @@ -68,6 +74,7 @@ public class AboutActivity extends BossScreenActivity { super.onCreate(savedInstanceState); configureScreen("关于", "版本与 OTA 更新"); restoreDownloadUiState(); + realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent); IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { registerReceiver(otaDownloadReceiver, filter, Context.RECEIVER_NOT_EXPORTED); @@ -77,8 +84,21 @@ public class AboutActivity extends BossScreenActivity { reload(); } + @Override + protected void onResume() { + super.onResume(); + updateRealtimeSubscription(); + } + + @Override + protected void onPause() { + stopRealtimeUpdates(); + super.onPause(); + } + @Override protected void onDestroy() { + stopRealtimeUpdates(); otaProgressHandler.removeCallbacks(otaProgressPoller); try { unregisterReceiver(otaDownloadReceiver); @@ -111,6 +131,58 @@ public class AboutActivity 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 (!"ota.updated".equals(event.eventName)) { + return; + } + String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event); + if (eventFingerprint.isEmpty()) { + return; + } + long now = System.currentTimeMillis(); + if (isDuplicateRealtimeEvent(eventFingerprint, now)) { + return; + } + runOnUiThread(this::reload); + } + + 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 renderAbout(@Nullable JSONObject user, JSONObject ota) { replaceContent(); otaPayload = ota; diff --git a/android/app/src/test/java/com/hyzq/boss/AboutActivityTest.java b/android/app/src/test/java/com/hyzq/boss/AboutActivityTest.java new file mode 100644 index 0000000..a6533ea --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/AboutActivityTest.java @@ -0,0 +1,90 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import android.content.Intent; + +import org.json.JSONObject; +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 java.lang.reflect.Method; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class AboutActivityTest { + @Test + public void otaUpdatedEventTriggersReload() throws Exception { + TestAboutActivity activity = Robolectric + .buildActivity(TestAboutActivity.class, new Intent()) + .setup() + .resume() + .get(); + activity.reloadEnabled = true; + activity.reloadCount = 0; + + Method handleRealtimeEvent = findHandleRealtimeEvent(); + assertNotNull(handleRealtimeEvent); + + handleRealtimeEvent.invoke( + activity, + new BossRealtimeEvent("ota.updated", new JSONObject().put("deviceId", "mac-studio")) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(1, activity.reloadCount); + } + + @Test + public void unrelatedConversationEventDoesNotTriggerReload() throws Exception { + TestAboutActivity activity = Robolectric + .buildActivity(TestAboutActivity.class, new Intent()) + .setup() + .resume() + .get(); + activity.reloadEnabled = true; + activity.reloadCount = 0; + + Method handleRealtimeEvent = findHandleRealtimeEvent(); + assertNotNull(handleRealtimeEvent); + + handleRealtimeEvent.invoke( + activity, + new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "master-agent")) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(0, activity.reloadCount); + } + + private static Method findHandleRealtimeEvent() { + for (Method method : AboutActivity.class.getDeclaredMethods()) { + if ("handleRealtimeEvent".equals(method.getName()) + && method.getParameterTypes().length == 1 + && method.getParameterTypes()[0] == BossRealtimeEvent.class) { + method.setAccessible(true); + return method; + } + } + return null; + } + + public static class TestAboutActivity extends AboutActivity { + private boolean reloadEnabled; + private int reloadCount; + + @Override + protected void reload() { + if (!reloadEnabled) { + return; + } + reloadCount += 1; + setRefreshing(false); + } + } +} diff --git a/public/downloads/boss-android-latest.apk b/public/downloads/boss-android-latest.apk index 3d054b7..5618d0c 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 bc5f1ac..f40e344 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": 3356442, - "updatedAt": "2026-04-07T08:39:26Z", - "sha256": "292abbd4f6e7953b3e8d8f195ee1c463d16e973a2d81e7a9760a71951167d47a", + "sizeBytes": 3357009, + "updatedAt": "2026-04-07T08:49:27Z", + "sha256": "7fe774cacef70758f227273dc4774e065e9eebba48e90a88d9c814b8f315a00d", "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 3d054b7..5618d0c 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