diff --git a/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java index eeb834a..426934d 100644 --- a/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java @@ -10,12 +10,18 @@ import androidx.appcompat.app.AlertDialog; import org.json.JSONArray; import org.json.JSONObject; +import java.util.LinkedHashMap; +import java.util.Map; + public class DeviceDetailActivity extends BossScreenActivity { public static final String EXTRA_DEVICE_ID = "device_id"; public static final String EXTRA_DEVICE_NAME = "device_name"; + private static final long REALTIME_RELOAD_THROTTLE_MS = 900L; private String deviceId; private String deviceName; + private @Nullable BossRealtimeClient realtimeClient; + private final Map recentRealtimeEventTimestamps = new LinkedHashMap<>(); @Override protected void onCreate(@Nullable Bundle savedInstanceState) { @@ -24,9 +30,28 @@ public class DeviceDetailActivity extends BossScreenActivity { deviceName = getIntent().getStringExtra(EXTRA_DEVICE_NAME); configureScreen(deviceName == null ? "设备详情" : deviceName, "设备状态、GUI/CLI 能力与默认执行模式"); setHeaderAction("编辑", v -> openEditDialog()); + 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() { setRefreshing(true); @@ -44,6 +69,68 @@ public class DeviceDetailActivity 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() || deviceId == null || deviceId.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 payloadDeviceId = event.payload.optString("deviceId", "").trim(); + if (payloadDeviceId.isEmpty() || !payloadDeviceId.equals(deviceId)) { + return false; + } + return "devices.updated".equals(event.eventName) + || "devices.skills.updated".equals(event.eventName) + || "project.context_risk.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 renderDevice(JSONObject payload) { JSONObject workspace = payload.optJSONObject("workspace"); JSONObject device = workspace == null ? null : workspace.optJSONObject("selectedDevice"); diff --git a/android/app/src/test/java/com/hyzq/boss/DeviceDetailActivityTest.java b/android/app/src/test/java/com/hyzq/boss/DeviceDetailActivityTest.java index 717263f..02e4168 100644 --- a/android/app/src/test/java/com/hyzq/boss/DeviceDetailActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/DeviceDetailActivityTest.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; @@ -186,6 +187,90 @@ public class DeviceDetailActivityTest { assertEquals("allow_always", apiClient.lastPayload.optString("conflictDecision")); } + @Test + public void matchingDevicesUpdatedEventTriggersReload() throws Exception { + TestDeviceDetailActivity activity = Robolectric + .buildActivity( + TestDeviceDetailActivity.class, + new Intent() + .putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1") + .putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio") + ) + .setup() + .resume() + .get(); + activity.reloadEnabled = true; + activity.reloadCount = 0; + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent("devices.updated", new JSONObject().put("deviceId", "device-1")) + ) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(1, activity.reloadCount); + } + + @Test + public void matchingProjectContextRiskEventTriggersReload() throws Exception { + TestDeviceDetailActivity activity = Robolectric + .buildActivity( + TestDeviceDetailActivity.class, + new Intent() + .putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1") + .putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio") + ) + .setup() + .resume() + .get(); + activity.reloadEnabled = true; + activity.reloadCount = 0; + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent("project.context_risk.updated", new JSONObject().put("deviceId", "device-1")) + ) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(1, activity.reloadCount); + } + + @Test + public void unrelatedDeviceEventDoesNotTriggerReload() throws Exception { + TestDeviceDetailActivity activity = Robolectric + .buildActivity( + TestDeviceDetailActivity.class, + new Intent() + .putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1") + .putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio") + ) + .setup() + .resume() + .get(); + activity.reloadEnabled = true; + activity.reloadCount = 0; + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent("devices.updated", new JSONObject().put("deviceId", "device-2")) + ) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(0, activity.reloadCount); + } + private static boolean viewTreeContainsText(View root, String expectedText) { if (root instanceof TextView) { CharSequence text = ((TextView) root).getText(); @@ -249,8 +334,15 @@ public class DeviceDetailActivityTest { } public static class TestDeviceDetailActivity extends DeviceDetailActivity { + boolean reloadEnabled = true; + int reloadCount; + @Override protected void reload() { + if (!reloadEnabled) { + return; + } + reloadCount += 1; this.apiClient = new BossApiClient(getSharedPreferences("test-boss-api", Context.MODE_PRIVATE), "https://boss.hyzq.net"); try { ReflectionHelpers.callInstanceMethod( diff --git a/public/downloads/boss-android-latest.apk b/public/downloads/boss-android-latest.apk index ca277f4..82bf5ec 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 2e7d6bc..1c84058 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": 3352262, - "updatedAt": "2026-04-07T06:48:08Z", - "sha256": "c1f21212dacef6c6d72d1fb87bd02e4b1c16b63869ff4fbdff8bc7a977a2bd0d", + "sizeBytes": 3353553, + "updatedAt": "2026-04-07T07:18:49Z", + "sha256": "19e0ff6da6a1b4c0239ab6283c0becf1107a4637f5322d3268954ce2910de5cd", "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 ca277f4..82bf5ec 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