diff --git a/android/app/src/main/java/com/hyzq/boss/DeviceImportDraftActivity.java b/android/app/src/main/java/com/hyzq/boss/DeviceImportDraftActivity.java index 1c776d2..ec0d22a 100644 --- a/android/app/src/main/java/com/hyzq/boss/DeviceImportDraftActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/DeviceImportDraftActivity.java @@ -18,6 +18,7 @@ import java.util.Map; public class DeviceImportDraftActivity 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; @@ -26,6 +27,8 @@ public class DeviceImportDraftActivity extends BossScreenActivity { private @Nullable JSONObject currentReviewTask; private final LinkedHashSet selectedCandidateIds = new LinkedHashSet<>(); private final Runnable reviewPollRunnable = this::reload; + private @Nullable BossRealtimeClient realtimeClient; + private final Map recentRealtimeEventTimestamps = new LinkedHashMap<>(); @Override protected void onCreate(@Nullable Bundle savedInstanceState) { @@ -33,9 +36,22 @@ public class DeviceImportDraftActivity extends BossScreenActivity { deviceId = getIntent().getStringExtra(EXTRA_DEVICE_ID); deviceName = getIntent().getStringExtra(EXTRA_DEVICE_NAME); configureScreen("导入项目", deviceName == null ? "选择要导入的 Codex 项目与线程" : deviceName); + realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent); reload(); } + @Override + protected void onResume() { + super.onResume(); + updateRealtimeSubscription(); + } + + @Override + protected void onPause() { + stopRealtimeUpdates(); + super.onPause(); + } + @Override protected void reload() { if (deviceId == null || deviceId.isEmpty()) { @@ -64,6 +80,72 @@ public class DeviceImportDraftActivity 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 (!"devices.updated".equals(event.eventName)) { + return false; + } + String payloadDeviceId = event.payload.optString("deviceId", "").trim(); + if (payloadDeviceId.isEmpty()) { + return true; + } + if (deviceId == null || deviceId.isEmpty()) { + return true; + } + return payloadDeviceId.equals(deviceId); + } + + 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 applyPayload( @Nullable JSONObject draft, @Nullable JSONObject resolution, @@ -88,6 +170,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity { @Override protected void onDestroy() { contentLayout.removeCallbacks(reviewPollRunnable); + stopRealtimeUpdates(); super.onDestroy(); } diff --git a/android/app/src/test/java/com/hyzq/boss/DeviceImportDraftActivityTest.java b/android/app/src/test/java/com/hyzq/boss/DeviceImportDraftActivityTest.java index 971eba0..2452ad1 100644 --- a/android/app/src/test/java/com/hyzq/boss/DeviceImportDraftActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/DeviceImportDraftActivityTest.java @@ -14,6 +14,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.util.ReflectionHelpers; @@ -103,6 +104,62 @@ public class DeviceImportDraftActivityTest { assertFalse(viewTreeContainsText(content, "树莓派二代接入与联调")); } + @Test + public void matchingDeviceUpdatedEventTriggersReload() throws Exception { + TestDeviceImportDraftActivity activity = Robolectric + .buildActivity( + TestDeviceImportDraftActivity.class, + new Intent() + .putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_ID, "device-1") + .putExtra(DeviceImportDraftActivity.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(); + + assertTrue(activity.reloadCount == 1); + } + + @Test + public void unrelatedDeviceEventDoesNotTriggerReload() throws Exception { + TestDeviceImportDraftActivity activity = Robolectric + .buildActivity( + TestDeviceImportDraftActivity.class, + new Intent() + .putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_ID, "device-1") + .putExtra(DeviceImportDraftActivity.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(); + + assertTrue(activity.reloadCount == 0); + } + private static JSONObject buildPendingDraft() throws Exception { return new JSONObject() .put("draftId", "draft-1") @@ -222,9 +279,16 @@ public class DeviceImportDraftActivityTest { } public static class TestDeviceImportDraftActivity extends DeviceImportDraftActivity { + private boolean reloadEnabled; + private int reloadCount; + @Override protected void reload() { - // Tests render synthetic payloads directly. + if (!reloadEnabled) { + return; + } + reloadCount += 1; + setRefreshing(false); } } } diff --git a/public/downloads/boss-android-latest.apk b/public/downloads/boss-android-latest.apk index 9daae4b..3e6e455 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 63ef977..35614b7 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": 3354908, - "updatedAt": "2026-04-07T08:07:13Z", - "sha256": "519c26f094aa0babb102ff557e8ed44f2f97290133765300f2f3ba1766923290", + "sizeBytes": 3355587, + "updatedAt": "2026-04-07T08:13:31Z", + "sha256": "0a94ce924344c02914f78414ad6bb5276126124e8eae123508a52e2c33aa86d7", "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 9daae4b..3e6e455 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