Wire device execution mode controls into UI

This commit is contained in:
kris
2026-04-06 11:10:51 +08:00
parent 27ab594921
commit 43c733069c
11 changed files with 1033 additions and 19 deletions

View File

@@ -401,6 +401,31 @@ public class BossApiClient {
return requestWithRestore("PATCH", "/api/v1/devices/" + encode(deviceId), payload);
}
public ApiResponse updateDevicePreferredExecutionMode(
String deviceId,
@Nullable String preferredExecutionMode
) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put(
"preferredExecutionMode",
preferredExecutionMode == null ? JSONObject.NULL : preferredExecutionMode
);
return updateDevice(deviceId, payload);
}
public ApiResponse updateProjectConflictDecision(
String deviceId,
String projectId,
@Nullable String folderKey,
String decision
) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("projectId", projectId);
payload.put("folderKey", folderKey == null ? JSONObject.NULL : folderKey);
payload.put("conflictDecision", decision);
return updateDevice(deviceId, payload);
}
public ApiResponse getDeviceSkills(String deviceId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/devices/" + encode(deviceId) + "/skills", null);
}

View File

@@ -22,7 +22,7 @@ public class DeviceDetailActivity extends BossScreenActivity {
super.onCreate(savedInstanceState);
deviceId = getIntent().getStringExtra(EXTRA_DEVICE_ID);
deviceName = getIntent().getStringExtra(EXTRA_DEVICE_NAME);
configureScreen(deviceName == null ? "设备详情" : deviceName, "设备状态与绑定项目");
configureScreen(deviceName == null ? "设备详情" : deviceName, "设备状态、GUI/CLI 能力与默认执行模式");
setHeaderAction("编辑", v -> openEditDialog());
reload();
}
@@ -47,6 +47,7 @@ public class DeviceDetailActivity extends BossScreenActivity {
private void renderDevice(JSONObject payload) {
JSONObject workspace = payload.optJSONObject("workspace");
JSONObject device = workspace == null ? null : workspace.optJSONObject("selectedDevice");
JSONObject primaryPolicy = resolvePrimaryProjectExecutionPolicy(workspace);
replaceContent();
if (device == null) {
@@ -56,7 +57,7 @@ public class DeviceDetailActivity extends BossScreenActivity {
}
deviceName = device.optString("name", deviceId);
configureScreen(deviceName, "设备状态与绑定项目");
configureScreen(deviceName, "设备状态、GUI/CLI 能力与默认执行模式");
WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(device);
appendContent(BossUi.buildDeviceCard(
this,
@@ -74,6 +75,64 @@ public class DeviceDetailActivity extends BossScreenActivity {
null
));
}
appendContent(BossUi.buildWechatMenuRow(
this,
WechatSurfaceMapper.deviceCapabilityTitle("gui"),
WechatSurfaceMapper.deviceCapabilityStatusLabel(device, "gui"),
WechatSurfaceMapper.deviceCapabilityDetailLabel(device, "gui"),
null,
null
));
appendContent(BossUi.buildWechatMenuRow(
this,
WechatSurfaceMapper.deviceCapabilityTitle("cli"),
WechatSurfaceMapper.deviceCapabilityStatusLabel(device, "cli"),
WechatSurfaceMapper.deviceCapabilityDetailLabel(device, "cli"),
null,
null
));
appendContent(BossUi.buildWechatMenuRow(
this,
"默认执行模式",
WechatSurfaceMapper.devicePreferredExecutionModeSummary(device),
"切换",
null,
v -> showPreferredExecutionModeDialog(device)
));
if (primaryPolicy != null) {
appendContent(BossUi.buildWechatMenuRow(
this,
"异常项目 / 文件夹冲突",
primaryPolicy.optString("projectId", "未知项目"),
primaryPolicy.optString("folderKey", ""),
null,
null
));
appendContent(BossUi.buildWechatMenuRow(
this,
"当前冲突态",
WechatSurfaceMapper.projectConflictStateLabel(primaryPolicy.optString("conflictState", "")),
null,
null,
null
));
appendContent(BossUi.buildWechatMenuRow(
this,
"当前策略",
WechatSurfaceMapper.projectConflictAllowPolicyLabel(primaryPolicy.optString("allowPolicy", "")),
"仅作用于当前异常项目 / 文件夹",
null,
null
));
appendContent(BossUi.buildWechatMenuRow(
this,
"冲突策略",
"禁止 / 允许本次 / 永久放行",
"切换",
null,
v -> showConflictDecisionDialog(payload)
));
}
appendContent(BossUi.buildMenuRow(this, "导入项目", "勾选这台设备上要暴露到会话首页的项目和线程", null, v -> openImportDraft()));
appendContent(BossUi.buildMenuRow(this, "查看技能", "查看当前设备同步的 Skill 清单", null, v -> openSkills()));
setRefreshing(false);
@@ -93,6 +152,50 @@ public class DeviceDetailActivity extends BossScreenActivity {
startActivity(intent);
}
private void showPreferredExecutionModeDialog(JSONObject device) {
String currentMode = device == null ? "cli" : device.optString("preferredExecutionMode", "cli");
String[] modeLabels = new String[] {
WechatSurfaceMapper.deviceExecutionModeChoiceLabel("gui"),
WechatSurfaceMapper.deviceExecutionModeChoiceLabel("cli")
};
String[] modeValues = new String[] {"gui", "cli"};
int checkedIndex = "gui".equals(currentMode) ? 0 : 1;
new AlertDialog.Builder(this)
.setTitle("默认执行模式")
.setSingleChoiceItems(modeLabels, checkedIndex, (dialog, which) -> {
dialog.dismiss();
savePreferredExecutionMode(modeValues[which]);
})
.setNegativeButton("取消", null)
.show();
}
private void showConflictDecisionDialog(JSONObject payload) {
JSONObject workspace = payload == null ? null : payload.optJSONObject("workspace");
JSONObject primaryPolicy = resolvePrimaryProjectExecutionPolicy(workspace);
if (primaryPolicy == null) {
showMessage("当前没有可处理的异常项目 / 文件夹。");
return;
}
String[] labels = new String[] {"禁止", "允许本次", "永久放行"};
String[] values = new String[] {"forbid", "allow_once", "allow_always"};
int checkedIndex = resolveConflictDecisionCheckedIndex(primaryPolicy.optString("allowPolicy", ""));
new AlertDialog.Builder(this)
.setTitle("冲突策略")
.setSingleChoiceItems(labels, checkedIndex, (dialog, which) -> {
dialog.dismiss();
saveConflictDecision(
primaryPolicy.optString("projectId", ""),
primaryPolicy.optString("folderKey", ""),
values[which]
);
})
.setNegativeButton("取消", null)
.show();
}
private void openEditDialog() {
executor.execute(() -> {
try {
@@ -108,6 +211,52 @@ public class DeviceDetailActivity extends BossScreenActivity {
});
}
private void savePreferredExecutionMode(String preferredExecutionMode) {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.updateDevicePreferredExecutionMode(
deviceId,
preferredExecutionMode
);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
showMessage("默认执行模式已更新");
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("保存失败:" + error.getMessage());
});
}
});
}
private void saveConflictDecision(String projectId, @Nullable String folderKey, String decision) {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.updateProjectConflictDecision(
deviceId,
projectId,
folderKey,
decision
);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
showMessage("冲突策略已更新");
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("保存失败:" + error.getMessage());
});
}
});
}
private void showEditForm(JSONObject device) {
LinearLayout form = new LinearLayout(this);
form.setOrientation(LinearLayout.VERTICAL);
@@ -185,4 +334,21 @@ public class DeviceDetailActivity extends BossScreenActivity {
}
return builder.toString();
}
private @Nullable JSONObject resolvePrimaryProjectExecutionPolicy(@Nullable JSONObject workspace) {
if (workspace == null) return null;
JSONArray policies = workspace.optJSONArray("projectExecutionPolicies");
if (policies == null || policies.length() == 0) return null;
return policies.optJSONObject(0);
}
private int resolveConflictDecisionCheckedIndex(String allowPolicy) {
if ("allow_once".equals(allowPolicy)) {
return 1;
}
if ("allow_always".equals(allowPolicy)) {
return 2;
}
return 0;
}
}

View File

@@ -109,6 +109,71 @@ public final class WechatSurfaceMapper {
);
}
public static String deviceCapabilityTitle(String capabilityKey) {
return "gui".equals(capabilityKey) ? "GUI 能力" : "CLI 能力";
}
public static String deviceCapabilityStatusLabel(JSONObject device, String capabilityKey) {
JSONObject capability = resolveDeviceCapability(device, capabilityKey);
boolean connected = capability != null && capability.optBoolean("connected", false);
return connected ? "已连接" : "未连接";
}
public static String deviceCapabilityDetailLabel(JSONObject device, String capabilityKey) {
JSONObject capability = resolveDeviceCapability(device, capabilityKey);
if (capability == null) {
return "暂无最近上报";
}
StringBuilder builder = new StringBuilder();
String lastSeenAt = capability.optString("lastSeenAt", "").trim();
String lastActiveProjectId = capability.optString("lastActiveProjectId", "").trim();
if (!lastSeenAt.isEmpty()) {
builder.append("最近上报 ").append(lastSeenAt);
}
if (!lastActiveProjectId.isEmpty()) {
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append("最近项目 ").append(lastActiveProjectId);
}
if (builder.length() == 0) {
return "暂无最近上报";
}
return builder.toString();
}
public static String devicePreferredExecutionModeLabel(JSONObject device) {
return "gui".equals(device == null ? "" : device.optString("preferredExecutionMode", "")) ? "GUI" : "CLI";
}
public static String devicePreferredExecutionModeSummary(JSONObject device) {
return "当前默认:" + devicePreferredExecutionModeLabel(device);
}
public static String deviceExecutionModeChoiceLabel(String mode) {
return "gui".equals(mode) ? "GUI" : "CLI";
}
public static String projectConflictAllowPolicyLabel(String allowPolicy) {
if ("allow_once".equals(allowPolicy)) {
return "允许本次";
}
if ("allow_always".equals(allowPolicy)) {
return "永久放行";
}
return "禁止";
}
public static String projectConflictStateLabel(String conflictState) {
if ("warning".equals(conflictState)) {
return "存在并行风险";
}
if ("blocked".equals(conflictState)) {
return "默认阻断";
}
return "暂无冲突";
}
public static String[] rootTabLabels() {
return ROOT_TAB_LABELS.toArray(new String[0]);
}
@@ -235,6 +300,17 @@ public final class WechatSurfaceMapper {
return true;
}
private static JSONObject resolveDeviceCapability(JSONObject device, String capabilityKey) {
if (device == null) {
return null;
}
JSONObject capabilities = device.optJSONObject("capabilities");
if (capabilities == null) {
return null;
}
return capabilities.optJSONObject(capabilityKey);
}
public static RootTopAction rootTopAction(String activeTab, boolean refreshing) {
return rootTopAction(activeTab, refreshing, false);
}

View File

@@ -0,0 +1,247 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import android.content.SharedPreferences;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.ProtocolException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossApiClientDeviceModeTest {
@Test
public void updateDevicePreferredExecutionModeWritesModeToPatchBody() throws Exception {
RecordingConnection connection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/devices/device-1")
);
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
apiClient.updateDevicePreferredExecutionMode("device-1", "gui");
assertEquals("/api/v1/devices/device-1", apiClient.lastPath);
assertEquals("PATCH", connection.requestMethodValue);
assertEquals("{\"preferredExecutionMode\":\"gui\"}", connection.requestBody());
}
@Test
public void updateProjectConflictDecisionWritesProjectScopedPatchBody() throws Exception {
RecordingConnection connection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/devices/device-1")
);
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
apiClient.updateProjectConflictDecision("device-1", "thread-ui", "mac-studio:boss", "allow_always");
assertEquals("/api/v1/devices/device-1", apiClient.lastPath);
assertEquals("PATCH", connection.requestMethodValue);
assertEquals(
"{\"projectId\":\"thread-ui\",\"folderKey\":\"mac-studio:boss\",\"conflictDecision\":\"allow_always\"}",
connection.requestBody()
);
}
private static final class RecordingBossApiClient extends BossApiClient {
private final RecordingConnection connection;
private String lastPath = "";
RecordingBossApiClient(RecordingConnection connection) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
this.connection = connection;
}
@Override
HttpURLConnection openConnection(String path) {
lastPath = path;
return connection;
}
@Override
String encode(String value) {
return value;
}
@Override
void rememberIdentity(JSONObject json) {
// JVM 单测只关心 patch body。
}
}
private static final class RecordingConnection extends HttpURLConnection {
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
private final Map<String, String> requestHeaders = new HashMap<>();
private String requestMethodValue = "GET";
RecordingConnection(URL url) {
super(url);
}
@Override
public void disconnect() {}
@Override
public boolean usingProxy() {
return false;
}
@Override
public void connect() {}
@Override
public void setRequestMethod(String method) throws ProtocolException {
requestMethodValue = method;
}
@Override
public void setRequestProperty(String key, String value) {
requestHeaders.put(key, value);
}
@Override
public String getRequestProperty(String key) {
return requestHeaders.get(key);
}
@Override
public OutputStream getOutputStream() {
return requestBody;
}
@Override
public int getResponseCode() {
return 200;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
}
String requestBody() {
return requestBody.toString(StandardCharsets.UTF_8);
}
}
private static final class InMemorySharedPreferences implements SharedPreferences {
private final Map<String, String> values = new HashMap<>();
@Override
public Map<String, ?> getAll() {
return Collections.unmodifiableMap(values);
}
@Override
public String getString(String key, String defValue) {
return values.getOrDefault(key, defValue);
}
@Override
public Set<String> getStringSet(String key, Set<String> defValues) {
throw new UnsupportedOperationException();
}
@Override
public int getInt(String key, int defValue) {
throw new UnsupportedOperationException();
}
@Override
public long getLong(String key, long defValue) {
throw new UnsupportedOperationException();
}
@Override
public float getFloat(String key, float defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean getBoolean(String key, boolean defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean contains(String key) {
return values.containsKey(key);
}
@Override
public Editor edit() {
return new Editor() {
@Override
public Editor putString(String key, String value) {
values.put(key, value);
return this;
}
@Override
public Editor remove(String key) {
values.remove(key);
return this;
}
@Override
public Editor clear() {
values.clear();
return this;
}
@Override
public void apply() {}
@Override
public boolean commit() {
return true;
}
@Override
public Editor putStringSet(String key, Set<String> values) {
throw new UnsupportedOperationException();
}
@Override
public Editor putInt(String key, int value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putLong(String key, long value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putFloat(String key, float value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putBoolean(String key, boolean value) {
throw new UnsupportedOperationException();
}
};
}
@Override
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
@Override
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
}
}

View File

@@ -1,6 +1,9 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.content.Intent;
@@ -8,6 +11,8 @@ import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
@@ -15,8 +20,14 @@ import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowDialog;
import org.robolectric.util.ReflectionHelpers;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.AbstractExecutorService;
import java.util.concurrent.TimeUnit;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class DeviceDetailActivityTest {
@@ -37,6 +48,144 @@ public class DeviceDetailActivityTest {
assertFalse(viewTreeContainsText(content, "让主 Agent 主动询问这台设备上的活跃项目目标、进度和架构"));
}
@Test
public void renderDeviceShowsGuiCliCapabilitiesAndPreferredExecutionMode() 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()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderDevice",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDevicePayload())
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "GUI 能力"));
assertTrue(viewTreeContainsText(content, "CLI 能力"));
assertTrue(viewTreeContainsText(content, "默认执行模式"));
assertTrue(viewTreeContainsText(content, "当前默认GUI"));
assertTrue(viewTreeContainsText(content, "已连接"));
assertTrue(viewTreeContainsText(content, "未连接"));
}
@Test
public void renderDeviceShowsProjectScopedConflictCardAndActions() 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()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderDevice",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildConflictPayload())
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "异常项目 / 文件夹冲突"));
assertTrue(viewTreeContainsText(content, "thread-ui"));
assertTrue(viewTreeContainsText(content, "允许本次"));
assertTrue(viewTreeContainsText(content, "存在并行风险"));
assertTrue(viewTreeContainsText(content, "仅作用于当前异常项目 / 文件夹"));
assertTrue(viewTreeContainsText(content, "冲突策略"));
}
@Test
public void preferredExecutionModeDialogPersistsSelectedMode() 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()
.get();
RecordingBossApiClient apiClient = new RecordingBossApiClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE),
"https://boss.hyzq.net"
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
ReflectionHelpers.callInstanceMethod(
activity,
"showPreferredExecutionModeDialog",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDevicePayload())
);
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
assertTrue(latestDialog instanceof AlertDialog);
AlertDialog dialog = (AlertDialog) latestDialog;
assertNotNull(dialog.getListView());
dialog.getListView().performItemClick(
dialog.getListView().getAdapter().getView(0, null, dialog.getListView()),
0,
dialog.getListView().getAdapter().getItemId(0)
);
assertEquals(1, apiClient.updateDeviceCalls);
assertEquals("device-1", apiClient.lastDeviceId);
assertNotNull(apiClient.lastPayload);
assertEquals("gui", apiClient.lastPayload.optString("preferredExecutionMode"));
}
@Test
public void conflictDecisionDialogPersistsScopedDecision() 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()
.get();
RecordingBossApiClient apiClient = new RecordingBossApiClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE),
"https://boss.hyzq.net"
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
ReflectionHelpers.callInstanceMethod(
activity,
"showConflictDecisionDialog",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildConflictPayload())
);
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
assertTrue(latestDialog instanceof AlertDialog);
AlertDialog dialog = (AlertDialog) latestDialog;
assertNotNull(dialog.getListView());
dialog.getListView().performItemClick(
dialog.getListView().getAdapter().getView(2, null, dialog.getListView()),
2,
dialog.getListView().getAdapter().getItemId(2)
);
assertEquals(1, apiClient.updateDeviceCalls);
assertEquals("device-1", apiClient.lastDeviceId);
assertNotNull(apiClient.lastPayload);
assertEquals("thread-ui", apiClient.lastPayload.optString("projectId"));
assertEquals("mac-studio:boss", apiClient.lastPayload.optString("folderKey"));
assertEquals("allow_always", apiClient.lastPayload.optString("conflictDecision"));
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
@@ -56,6 +205,49 @@ public class DeviceDetailActivityTest {
return false;
}
private static JSONObject buildDevicePayload() throws Exception {
return new JSONObject()
.put("workspace", new JSONObject()
.put("selectedDevice", new JSONObject()
.put("id", "device-1")
.put("name", "Mac Studio")
.put("avatar", "M")
.put("account", "17600003315")
.put("status", "online")
.put("quota5h", 75)
.put("quota7d", 88)
.put("capabilities", new JSONObject()
.put("gui", new JSONObject()
.put("connected", true)
.put("lastSeenAt", "2026-04-06T08:50:00+08:00")
.put("lastActiveProjectId", "master-agent"))
.put("cli", new JSONObject()
.put("connected", false)
.put("lastSeenAt", "2026-04-06T08:40:00+08:00")
.put("lastActiveProjectId", "")))
.put("preferredExecutionMode", "gui")
.put("projects", new JSONArray().put("Boss"))
.put("endpoint", "mac://studio.local")
.put("note", "测试设备")));
}
private static JSONObject buildConflictPayload() throws Exception {
JSONObject payload = buildDevicePayload();
payload.getJSONObject("workspace").put(
"projectExecutionPolicies",
new JSONArray().put(
new JSONObject()
.put("deviceId", "device-1")
.put("folderKey", "mac-studio:boss")
.put("projectId", "thread-ui")
.put("allowPolicy", "allow_once")
.put("conflictState", "warning")
.put("updatedAt", "2026-04-06T12:00:00.000Z")
)
);
return payload;
}
public static class TestDeviceDetailActivity extends DeviceDetailActivity {
@Override
protected void reload() {
@@ -70,21 +262,62 @@ public class DeviceDetailActivityTest {
throw new RuntimeException(error);
}
}
}
private static JSONObject buildDevicePayload() throws Exception {
return new JSONObject()
.put("workspace", new JSONObject()
.put("selectedDevice", new JSONObject()
.put("id", "device-1")
.put("name", "Mac Studio")
.put("avatar", "M")
.put("account", "17600003315")
.put("status", "online")
.put("quota5h", 75)
.put("quota7d", 88)
.put("projects", new JSONArray().put("Boss"))
.put("endpoint", "mac://studio.local")
.put("note", "测试设备")));
private static final class RecordingBossApiClient extends BossApiClient {
private int updateDeviceCalls;
private String lastDeviceId;
private JSONObject lastPayload;
RecordingBossApiClient(android.content.SharedPreferences prefs, String baseUrl) {
super(prefs, baseUrl);
}
@Override
public ApiResponse updateDevice(String deviceId, JSONObject payload) {
updateDeviceCalls += 1;
lastDeviceId = deviceId;
lastPayload = payload;
try {
return new ApiResponse(200, new JSONObject().put("ok", true));
} catch (Exception error) {
throw new RuntimeException(error);
}
}
}
private static final class DirectExecutorService extends AbstractExecutorService {
private boolean shutdown;
@Override
public void shutdown() {
shutdown = true;
}
@Override
public List<Runnable> shutdownNow() {
shutdown = true;
return Collections.emptyList();
}
@Override
public boolean isShutdown() {
return shutdown;
}
@Override
public boolean isTerminated() {
return shutdown;
}
@Override
public boolean awaitTermination(long timeout, TimeUnit unit) {
return true;
}
@Override
public void execute(Runnable command) {
command.run();
}
}
}