feat: ship enterprise control and desktop governance

This commit is contained in:
AI Bot
2026-05-11 14:59:26 +08:00
parent 0757d07521
commit a311280238
285 changed files with 48574 additions and 2428 deletions

View File

@@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
@@ -14,6 +17,11 @@
android:theme="@style/AppTheme"
android:forceDarkAllowed="false">
<service
android:name=".BossBackgroundRealtimeService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
@@ -50,8 +58,11 @@
<activity android:name=".DeviceImportDraftActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".SkillInventoryActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".SecurityActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".AccessManagementActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".SettingsActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".StorageSettingsActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".AiAccountsActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".TelegramIntegrationActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".OpenAiOnboardingActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".MasterAgentPromptActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".MasterAgentTakeoverActivity" android:exported="false" android:screenOrientation="portrait" />

View File

@@ -15,6 +15,7 @@ import android.provider.Settings;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -76,11 +77,7 @@ public class AboutActivity extends BossScreenActivity {
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);
} else {
registerReceiver(otaDownloadReceiver, filter);
}
ContextCompat.registerReceiver(this, otaDownloadReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED);
reload();
}
@@ -491,11 +488,9 @@ public class AboutActivity extends BossScreenActivity {
persistDownloadUiState();
refreshDownloadStateSection();
if (!getPackageManager().canRequestPackageInstalls()) {
if (!canInstallDownloadedPackages()) {
showMessage("请先允许 Boss 安装未知来源应用,然后重新打开安装包。");
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
openUnknownAppSourcesSettings();
return;
}
@@ -566,7 +561,7 @@ public class AboutActivity extends BossScreenActivity {
return OtaDownloadStateMapper.failed(fileName);
}
if (downloadedApkUri != null) {
if (!getPackageManager().canRequestPackageInstalls()) {
if (!canInstallDownloadedPackages()) {
return OtaDownloadStateMapper.waitingInstallPermission(fileName);
}
return OtaDownloadStateMapper.readyToInstall(fileName);
@@ -580,9 +575,7 @@ public class AboutActivity extends BossScreenActivity {
downloadLatestApk();
break;
case OPEN_INSTALL_PERMISSION:
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
openUnknownAppSourcesSettings();
break;
case INSTALL_APK:
installDownloadedApk();
@@ -598,11 +591,9 @@ public class AboutActivity extends BossScreenActivity {
showMessage("当前没有可安装的更新包");
return;
}
if (!getPackageManager().canRequestPackageInstalls()) {
if (!canInstallDownloadedPackages()) {
showMessage("请先开启安装未知来源应用权限");
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
openUnknownAppSourcesSettings();
return;
}
Intent installIntent = new Intent(Intent.ACTION_VIEW);
@@ -622,6 +613,19 @@ public class AboutActivity extends BossScreenActivity {
return "boss-android-latest.apk";
}
private boolean canInstallDownloadedPackages() {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.O
|| getPackageManager().canRequestPackageInstalls();
}
private void openUnknownAppSourcesSettings() {
Intent intent = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
? new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()))
: new Intent(Settings.ACTION_SECURITY_SETTINGS);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
}
private void restoreDownloadUiState() {
android.content.SharedPreferences prefs = getSharedPreferences(OTA_UI_PREFS, Context.MODE_PRIVATE);
activeDownloadId = prefs.getLong(KEY_ACTIVE_DOWNLOAD_ID, -1L);

View File

@@ -0,0 +1,598 @@
package com.hyzq.boss;
import android.app.AlertDialog;
import android.os.Bundle;
import android.text.InputType;
import android.text.TextUtils;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.Spinner;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class AccessManagementActivity extends BossScreenActivity {
@Nullable private JSONObject accessPayload;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("用户与权限", "子账号、设备、项目与 Skill");
setHeaderAction("新增", v -> showAccountDialog());
reload();
}
@Override
protected void reload() {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getAdminAccess();
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> renderAccess(response.json));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "权限配置加载失败:" + error.getMessage()));
});
}
});
}
private void renderAccess(JSONObject payload) {
accessPayload = payload;
JSONArray accounts = payload.optJSONArray("accounts");
JSONArray devices = payload.optJSONArray("devices");
JSONArray projects = payload.optJSONArray("projects");
JSONArray skills = payload.optJSONArray("skills");
JSONArray skillCatalog = payload.optJSONArray("skillCatalog");
JSONArray permissionTemplates = payload.optJSONArray("permissionTemplates");
replaceContent(BossUi.buildWechatMenuRow(
this,
"权限总览",
"子账号 " + lengthOf(accounts) + " 个 · 设备 " + lengthOf(devices) + " 台 · 项目 " + lengthOf(projects) + "",
"Skill 类目 " + lengthOf(skillCatalog) + " 类 · 设备 Skill 实例 " + lengthOf(skills) + "",
null,
null
));
Button accountButton = BossUi.buildMiniActionButton(this, "创建子账号", true);
Button deviceButton = BossUi.buildMiniActionButton(this, "授权设备", false);
Button projectButton = BossUi.buildMiniActionButton(this, "授权项目", false);
Button skillButton = BossUi.buildMiniActionButton(this, "分配 Skill", false);
Button templateButton = BossUi.buildMiniActionButton(this, "套用模板", true);
accountButton.setOnClickListener(v -> showAccountDialog());
deviceButton.setOnClickListener(v -> showDeviceGrantDialog());
projectButton.setOnClickListener(v -> showProjectGrantDialog());
skillButton.setOnClickListener(v -> showSkillGrantDialog());
templateButton.setOnClickListener(v -> showTemplateGrantDialog());
appendContent(buildActionRow(accountButton, deviceButton));
appendContent(buildActionRow(projectButton, skillButton));
if (!isEmpty(permissionTemplates)) {
Button refreshAccessButton = BossUi.buildMiniActionButton(this, "刷新权限", false);
refreshAccessButton.setOnClickListener(v -> reload());
appendContent(buildActionRow(templateButton, refreshAccessButton));
templateButton.setOnClickListener(v -> showTemplateGrantDialog());
}
if (!isEmpty(permissionTemplates)) {
appendContent(BossUi.buildWechatMenuRow(
this,
"权限模板",
lengthOf(permissionTemplates) + " 个模板可用",
"一次性给账号分配设备、项目和 Skill 权限",
null,
v -> showTemplateGrantDialog()
));
} else {
appendContent(BossUi.buildWechatMenuRow(
this,
"暂无权限模板",
"模板列表为空,仍可使用单项授权。",
"等待服务端同步只读观察员、项目开发者、设备操作者等模板。",
null,
null
));
}
appendUnavailableTargetHints(devices, projects, skills);
appendContent(BossUi.buildWechatMenuRow(
this,
"已配置账号",
summarizeAccounts(accounts),
"点击右上角新增,可创建或更新子账号",
null,
null
));
JSONArray deviceGrants = grantsArray(payload, "devices");
JSONArray projectGrants = grantsArray(payload, "projects");
JSONArray skillGrants = grantsArray(payload, "skills");
appendContent(BossUi.buildWechatMenuRow(
this,
"当前授权",
"设备 " + lengthOf(deviceGrants) + " 条 · 项目 " + lengthOf(projectGrants) + " 条 · Skill " + lengthOf(skillGrants) + "",
"点击授权记录可撤销当前这条授权",
null,
null
));
appendGrantRows(deviceGrants, "设备");
appendGrantRows(projectGrants, "项目");
appendGrantRows(skillGrants, "Skill");
setRefreshing(false);
}
private void appendUnavailableTargetHints(JSONArray devices, JSONArray projects, JSONArray skills) {
if (isEmpty(devices)) {
appendContent(BossUi.buildWechatMenuRow(
this,
"暂无可授权设备",
"设备列表为空,无法分配 device.view 或 computer.control。",
"请先完成设备绑定或等待授权范围刷新。",
null,
null
));
}
if (isEmpty(projects)) {
appendContent(BossUi.buildWechatMenuRow(
this,
"暂无可授权项目",
"项目列表为空,无法分配 project.view、thread.chat 或主 Agent 协同。",
"请先导入项目或等待设备线程同步。",
null,
null
));
}
if (isEmpty(skills)) {
appendContent(BossUi.buildWechatMenuRow(
this,
"暂无可分配 Skill",
"Skill 实例为空,无法分配 skill.use。",
"请确认 local-agent 已同步 ~/.codex/skills。",
null,
null
));
}
}
private LinearLayout buildActionRow(Button left, Button right) {
LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setPadding(BossUi.dp(this, 12), 0, BossUi.dp(this, 12), BossUi.dp(this, 10));
LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
row.setLayoutParams(rowParams);
LinearLayout.LayoutParams leftParams = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f);
LinearLayout.LayoutParams rightParams = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f);
rightParams.leftMargin = BossUi.dp(this, 8);
row.addView(left, leftParams);
row.addView(right, rightParams);
return row;
}
private void appendGrantRows(JSONArray grants, String scopeLabel) {
if (grants == null || grants.length() == 0) {
return;
}
int max = Math.min(8, grants.length());
for (int index = 0; index < max; index += 1) {
JSONObject grant = grants.optJSONObject(index);
if (grant == null) {
continue;
}
String grantId = grant.optString("grantId", "");
appendContent(BossUi.buildWechatMenuRow(
this,
scopeLabel + "授权 · " + grant.optString("account", ""),
grantTargetSummary(grant),
joinJsonArray(grant.optJSONArray("permissions")),
null,
TextUtils.isEmpty(grantId) ? null : v -> confirmRevoke(grantId)
));
}
if (grants.length() > max) {
appendContent(BossUi.buildHintPill(this, "还有 " + (grants.length() - max) + " 条授权未展开,可在 Web 端查看完整审计。"));
}
}
private void showAccountDialog() {
LinearLayout form = buildDialogForm();
EditText accountInput = BossUi.buildInput(this, "子账号,例如 worker@example.com", false);
EditText displayInput = BossUi.buildInput(this, "显示名", false);
EditText passwordInput = BossUi.buildInput(this, "初始密码 / 新密码", false);
passwordInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
Spinner roleSpinner = spinnerWith(new String[]{"成员", "管理员"});
form.addView(BossUi.buildFormCell(this, "账号", null, accountInput));
form.addView(BossUi.buildFormCell(this, "显示名", null, displayInput));
form.addView(BossUi.buildFormCell(this, "角色", "最高管理员不在手机端创建,避免误提权。", roleSpinner));
form.addView(BossUi.buildFormCell(this, "密码", "创建账号时必填;更新账号时留空表示不改密码。", passwordInput));
new AlertDialog.Builder(this)
.setTitle("创建 / 更新子账号")
.setView(form)
.setNegativeButton("取消", null)
.setPositiveButton("保存", (dialog, which) -> {
try {
JSONObject payload = new JSONObject();
payload.put("action", "upsert_account");
payload.put("account", accountInput.getText().toString().trim());
payload.put("displayName", displayInput.getText().toString().trim());
payload.put("password", passwordInput.getText().toString());
payload.put("role", roleSpinner.getSelectedItemPosition() == 1 ? "admin" : "member");
runAdminAction(payload);
} catch (JSONException error) {
showMessage("保存失败:" + error.getMessage());
}
})
.show();
}
private void showDeviceGrantDialog() {
JSONObject payload = requireAccessPayload();
if (payload == null) return;
JSONArray accounts = payload.optJSONArray("accounts");
JSONArray devices = payload.optJSONArray("devices");
if (isEmpty(accounts) || isEmpty(devices)) {
showMessage("需要先有账号和设备。");
return;
}
LinearLayout form = buildDialogForm();
Spinner accountSpinner = spinnerWith(labelsFor(accounts, "account", "displayName"));
Spinner deviceSpinner = spinnerWith(labelsFor(devices, "id", "name"));
Spinner permissionSpinner = spinnerWith(new String[]{"只读查看", "管理设备", "允许电脑控制"});
form.addView(BossUi.buildFormCell(this, "账号", null, accountSpinner));
form.addView(BossUi.buildFormCell(this, "设备", null, deviceSpinner));
form.addView(BossUi.buildFormCell(this, "权限模板", null, permissionSpinner));
confirmGrant("授权设备", form, () -> {
JSONObject body = new JSONObject();
body.put("action", "grant_device");
body.put("account", valueAt(accounts, accountSpinner.getSelectedItemPosition(), "account"));
body.put("deviceId", valueAt(devices, deviceSpinner.getSelectedItemPosition(), "id"));
body.put("permissions", new JSONArray(permissionSpinner.getSelectedItemPosition() == 1
? Arrays.asList("device.view", "device.manage")
: permissionSpinner.getSelectedItemPosition() == 2
? Arrays.asList("device.view", "computer.control")
: Arrays.asList("device.view")));
return body;
});
}
private void showProjectGrantDialog() {
JSONObject payload = requireAccessPayload();
if (payload == null) return;
JSONArray accounts = payload.optJSONArray("accounts");
JSONArray projects = payload.optJSONArray("projects");
if (isEmpty(accounts) || isEmpty(projects)) {
showMessage("需要先有账号和项目。");
return;
}
LinearLayout form = buildDialogForm();
Spinner accountSpinner = spinnerWith(labelsFor(accounts, "account", "displayName"));
Spinner projectSpinner = spinnerWith(labelsFor(projects, "id", "name"));
Spinner permissionSpinner = spinnerWith(new String[]{"只读查看", "允许聊天", "主 Agent 协同", "电脑控制"});
form.addView(BossUi.buildFormCell(this, "账号", null, accountSpinner));
form.addView(BossUi.buildFormCell(this, "项目", null, projectSpinner));
form.addView(BossUi.buildFormCell(this, "权限模板", null, permissionSpinner));
confirmGrant("授权项目", form, () -> {
JSONObject body = new JSONObject();
body.put("action", "grant_project");
body.put("account", valueAt(accounts, accountSpinner.getSelectedItemPosition(), "account"));
body.put("projectId", valueAt(projects, projectSpinner.getSelectedItemPosition(), "id"));
body.put("permissions", projectPermissionsFor(permissionSpinner.getSelectedItemPosition()));
return body;
});
}
private void showSkillGrantDialog() {
JSONObject payload = requireAccessPayload();
if (payload == null) return;
JSONArray accounts = payload.optJSONArray("accounts");
JSONArray skills = payload.optJSONArray("skills");
if (isEmpty(accounts) || isEmpty(skills)) {
showMessage("需要先有账号和已同步 Skill。");
return;
}
LinearLayout form = buildDialogForm();
Spinner accountSpinner = spinnerWith(labelsFor(accounts, "account", "displayName"));
Spinner skillSpinner = spinnerWith(labelsFor(skills, "skillId", "name"));
Spinner permissionSpinner = spinnerWith(new String[]{"可调用", "可管理"});
form.addView(BossUi.buildFormCell(this, "账号", null, accountSpinner));
form.addView(BossUi.buildFormCell(this, "Skill", null, skillSpinner));
form.addView(BossUi.buildFormCell(this, "权限模板", null, permissionSpinner));
confirmGrant("分配 Skill", form, () -> {
JSONObject skill = skills.optJSONObject(skillSpinner.getSelectedItemPosition());
JSONObject body = new JSONObject();
body.put("action", "grant_skill");
body.put("account", valueAt(accounts, accountSpinner.getSelectedItemPosition(), "account"));
body.put("skillId", skill == null ? "" : skill.optString("skillId", ""));
body.put("deviceId", skill == null ? "" : skill.optString("deviceId", ""));
body.put("permissions", new JSONArray(permissionSpinner.getSelectedItemPosition() == 1
? Arrays.asList("skill.view", "skill.use", "skill.manage")
: Arrays.asList("skill.view", "skill.use")));
return body;
});
}
private void showTemplateGrantDialog() {
JSONObject payload = requireAccessPayload();
if (payload == null) return;
JSONArray accounts = payload.optJSONArray("accounts");
JSONArray templates = payload.optJSONArray("permissionTemplates");
JSONArray devices = payload.optJSONArray("devices");
JSONArray projects = payload.optJSONArray("projects");
JSONArray skills = payload.optJSONArray("skills");
if (isEmpty(accounts) || isEmpty(templates)) {
showMessage("需要先有账号和权限模板。");
return;
}
if (isEmpty(devices) && isEmpty(projects) && isEmpty(skills)) {
showMessage("需要至少有设备、项目或 Skill。");
return;
}
LinearLayout form = buildDialogForm();
Spinner accountSpinner = spinnerWith(labelsFor(accounts, "account", "displayName"));
Spinner templateSpinner = spinnerWith(labelsFor(templates, "templateId", "name"));
Spinner deviceSpinner = spinnerWith(optionalLabelsFor(devices, "id", "name", "不授权设备"));
Spinner projectSpinner = spinnerWith(optionalLabelsFor(projects, "id", "name", "不授权项目"));
Spinner skillSpinner = spinnerWith(optionalLabelsFor(skills, "skillId", "name", "不分配 Skill"));
if (!isEmpty(devices)) {
deviceSpinner.setSelection(1);
}
if (!isEmpty(projects)) {
projectSpinner.setSelection(1);
}
if (!isEmpty(skills)) {
skillSpinner.setSelection(1);
}
form.addView(BossUi.buildFormCell(this, "账号", null, accountSpinner));
form.addView(BossUi.buildFormCell(this, "模板", "模板只作用于本次选择的账号和目标,不会全局放行。", templateSpinner));
form.addView(BossUi.buildFormCell(this, "设备", null, deviceSpinner));
form.addView(BossUi.buildFormCell(this, "项目", null, projectSpinner));
form.addView(BossUi.buildFormCell(this, "Skill", null, skillSpinner));
confirmGrant("套用权限模板", form, () -> buildTemplateApplyPayload(
valueAt(accounts, accountSpinner.getSelectedItemPosition(), "account"),
objectAt(templates, templateSpinner.getSelectedItemPosition()),
optionalObjectAt(devices, deviceSpinner.getSelectedItemPosition()),
optionalObjectAt(projects, projectSpinner.getSelectedItemPosition()),
optionalObjectAt(skills, skillSpinner.getSelectedItemPosition())
));
}
static JSONObject buildTemplateApplyPayload(
String account,
JSONObject template,
@Nullable JSONObject device,
@Nullable JSONObject project,
@Nullable JSONObject skill
) throws JSONException {
JSONObject body = new JSONObject();
body.put("action", "apply_template");
body.put("account", account == null ? "" : account.trim());
body.put("templateId", template == null ? "" : template.optString("templateId", ""));
JSONArray deviceIds = new JSONArray();
if (device != null && !TextUtils.isEmpty(device.optString("id", ""))) {
deviceIds.put(device.optString("id", ""));
}
JSONArray projectIds = new JSONArray();
if (project != null && !TextUtils.isEmpty(project.optString("id", ""))) {
projectIds.put(project.optString("id", ""));
}
JSONArray skillIds = new JSONArray();
if (skill != null && !TextUtils.isEmpty(skill.optString("skillId", ""))) {
skillIds.put(skill.optString("skillId", ""));
}
body.put("deviceIds", deviceIds);
body.put("projectIds", projectIds);
body.put("skillIds", skillIds);
return body;
}
private void confirmGrant(String title, LinearLayout form, PayloadFactory factory) {
new AlertDialog.Builder(this)
.setTitle(title)
.setView(form)
.setNegativeButton("取消", null)
.setPositiveButton("保存", (dialog, which) -> {
try {
runAdminAction(factory.create());
} catch (JSONException error) {
showMessage("保存失败:" + error.getMessage());
}
})
.show();
}
private void confirmRevoke(String grantId) {
new AlertDialog.Builder(this)
.setTitle("撤销授权")
.setMessage("只撤销当前这条授权,不影响其他设备、项目或 Skill。")
.setNegativeButton("取消", null)
.setPositiveButton("撤销", (dialog, which) -> {
try {
JSONObject payload = new JSONObject();
payload.put("action", "revoke_grant");
payload.put("grantId", grantId);
runAdminAction(payload);
} catch (JSONException error) {
showMessage("撤销失败:" + error.getMessage());
}
})
.show();
}
private void runAdminAction(JSONObject payload) {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.updateAdminAccess(payload);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
showMessage("已保存");
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("操作失败:" + error.getMessage());
});
}
});
}
@Nullable
private JSONObject requireAccessPayload() {
if (accessPayload == null) {
showMessage("权限数据还没加载完成。");
}
return accessPayload;
}
private LinearLayout buildDialogForm() {
LinearLayout form = new LinearLayout(this);
form.setOrientation(LinearLayout.VERTICAL);
return form;
}
private Spinner spinnerWith(String[] values) {
Spinner spinner = new Spinner(this);
spinner.setAdapter(new ArrayAdapter<>(
this,
android.R.layout.simple_spinner_dropdown_item,
values
));
return spinner;
}
private JSONArray projectPermissionsFor(int position) {
if (position == 3) {
return new JSONArray(Arrays.asList("project.view", "thread.chat", "master_agent.ask", "master_agent.takeover", "computer.control"));
}
if (position == 2) {
return new JSONArray(Arrays.asList("project.view", "thread.chat", "master_agent.ask", "master_agent.takeover"));
}
if (position == 1) {
return new JSONArray(Arrays.asList("project.view", "thread.chat"));
}
return new JSONArray(Arrays.asList("project.view"));
}
private String[] labelsFor(JSONArray array, String idKey, String nameKey) {
List<String> labels = new ArrayList<>();
if (array == null) {
return new String[0];
}
for (int index = 0; index < array.length(); index += 1) {
JSONObject item = array.optJSONObject(index);
if (item == null) {
continue;
}
String id = item.optString(idKey, "");
String name = item.optString(nameKey, "");
labels.add(TextUtils.isEmpty(name) || name.equals(id) ? id : name + " · " + id);
}
return labels.toArray(new String[0]);
}
private String[] optionalLabelsFor(JSONArray array, String idKey, String nameKey, String emptyLabel) {
List<String> labels = new ArrayList<>();
labels.add(emptyLabel);
if (array != null) {
labels.addAll(Arrays.asList(labelsFor(array, idKey, nameKey)));
}
return labels.toArray(new String[0]);
}
private String valueAt(JSONArray array, int position, String key) {
JSONObject item = array == null ? null : array.optJSONObject(position);
return item == null ? "" : item.optString(key, "");
}
@Nullable
private JSONObject objectAt(JSONArray array, int position) {
return array == null ? null : array.optJSONObject(position);
}
@Nullable
private JSONObject optionalObjectAt(JSONArray array, int position) {
if (position <= 0 || array == null) {
return null;
}
return array.optJSONObject(position - 1);
}
private JSONArray grantsArray(JSONObject payload, String key) {
JSONObject grants = payload.optJSONObject("grants");
return grants == null ? new JSONArray() : grants.optJSONArray(key);
}
private String summarizeAccounts(JSONArray accounts) {
if (accounts == null || accounts.length() == 0) {
return "暂无子账号";
}
List<String> parts = new ArrayList<>();
int max = Math.min(4, accounts.length());
for (int index = 0; index < max; index += 1) {
JSONObject account = accounts.optJSONObject(index);
if (account == null) continue;
parts.add(account.optString("displayName", account.optString("account", "")) + " · " + BossUi.formatRoleLabel(account.optString("role", "")));
}
if (accounts.length() > max) {
parts.add("+" + (accounts.length() - max));
}
return TextUtils.join("\n", parts);
}
private String grantTargetSummary(JSONObject grant) {
if (!TextUtils.isEmpty(grant.optString("skillId", ""))) {
return "Skill" + grant.optString("skillId", "");
}
if (!TextUtils.isEmpty(grant.optString("projectId", ""))) {
return "项目:" + grant.optString("projectId", "");
}
return "设备:" + grant.optString("deviceId", "");
}
private String joinJsonArray(JSONArray values) {
if (values == null || values.length() == 0) {
return "未设置权限";
}
List<String> parts = new ArrayList<>();
for (int index = 0; index < values.length(); index += 1) {
parts.add(values.optString(index, ""));
}
return TextUtils.join(" / ", parts);
}
private int lengthOf(@Nullable JSONArray array) {
return array == null ? 0 : array.length();
}
private boolean isEmpty(@Nullable JSONArray array) {
return array == null || array.length() == 0;
}
private interface PayloadFactory {
JSONObject create() throws JSONException;
}
}

View File

@@ -133,9 +133,7 @@ public class AiAccountsActivity extends BossScreenActivity {
} else {
appendContent(buildApiSection(
isPrimaryRole(currentRole) ? "主要API配置" : "备用API配置",
isPrimaryRole(currentRole)
? "主链路只在这里配置 OAuth 登录或 API 接入。"
: "主链路异常时自动切到这里,不抢占当前主控。",
isPrimaryRole(currentRole) ? "主链路" : "备用链路",
accounts,
currentRole
));
@@ -170,7 +168,7 @@ public class AiAccountsActivity extends BossScreenActivity {
if (currentRole == null) {
return "主要API与备用API";
}
return "OAuth 登录与 API 接入";
return "OAuth / API";
}
private LinearLayout buildOverviewSection(@Nullable JSONArray accounts) {
@@ -198,9 +196,9 @@ public class AiAccountsActivity extends BossScreenActivity {
private String overviewSummaryForRole(@Nullable JSONArray accounts, String targetRole) {
int count = countAccountsForRole(accounts, targetRole);
if (count <= 0) {
return "暂未配置,点进去添加。";
return "暂未配置";
}
return "已配置 " + count + ",点进去查看和编辑。";
return "已配置 " + count + "";
}
private int countAccountsForRole(@Nullable JSONArray accounts, String targetRole) {
@@ -282,7 +280,7 @@ public class AiAccountsActivity extends BossScreenActivity {
currentFastModelOverride,
currentDeepModelOverride
),
"切换后会和主Agent对话框保持同步",
"主Agent对话同步",
"切换",
v -> showMasterAgentModePicker()
));
@@ -290,7 +288,7 @@ public class AiAccountsActivity extends BossScreenActivity {
this,
"快速反应模型",
"当前:" + MasterAgentModePresets.resolveFastModel(currentFastModelOverride),
"快速问答默认使用低推理强度",
"低推理强度",
"配置",
v -> showMasterAgentModeModelPicker(true)
));
@@ -298,7 +296,7 @@ public class AiAccountsActivity extends BossScreenActivity {
this,
"深度思考模型",
"当前:" + MasterAgentModePresets.resolveDeepModel(currentDeepModelOverride),
"复杂任务默认使用高推理强度",
"高推理强度",
"配置",
v -> showMasterAgentModeModelPicker(false)
));
@@ -306,7 +304,7 @@ public class AiAccountsActivity extends BossScreenActivity {
section.addView(BossUi.buildWechatMenuRow(
this,
"OAuth 登录",
isPrimaryRole(targetRole) ? "设置主要 OAuth 登录。" : "设置备用 OAuth 登录。",
isPrimaryRole(targetRole) ? "主要 OAuth" : "备用 OAuth",
configuredMethodAccountsSummary(accounts, targetRole, true),
null,
v -> openRoleProviderChooser(targetRole, true)
@@ -314,7 +312,7 @@ public class AiAccountsActivity extends BossScreenActivity {
section.addView(BossUi.buildWechatMenuRow(
this,
"API 接入",
isPrimaryRole(targetRole) ? "设置主要 API 接入。" : "设置备用 API 接入。",
isPrimaryRole(targetRole) ? "主要 API" : "备用 API",
configuredMethodAccountsSummary(accounts, targetRole, false),
null,
v -> openRoleProviderChooser(targetRole, false)

View File

@@ -66,6 +66,53 @@ public class BossApiClient {
return response;
}
public ApiResponse loginWithPassword(String account, String password) throws IOException, JSONException {
JSONObject body = new JSONObject();
body.put("account", account);
body.put("password", password);
body.put("method", "password");
ApiResponse response = request("POST", "/api/auth/login", body, false);
if (response.ok()) {
rememberIdentity(response.json);
}
return response;
}
public ApiResponse sendVerificationCode(String account, String purpose) throws IOException, JSONException {
JSONObject body = new JSONObject();
body.put("account", account);
body.put("purpose", purpose);
return request("POST", "/api/auth/send-code", body, false);
}
public ApiResponse registerAccount(
String account,
String password,
String confirmPassword,
String code
) throws IOException, JSONException {
JSONObject body = new JSONObject();
body.put("account", account);
body.put("password", password);
body.put("confirmPassword", confirmPassword);
body.put("code", code);
return request("POST", "/api/auth/register", body, false);
}
public ApiResponse resetPassword(
String account,
String password,
String confirmPassword,
String code
) throws IOException, JSONException {
JSONObject body = new JSONObject();
body.put("account", account);
body.put("password", password);
body.put("confirmPassword", confirmPassword);
body.put("code", code);
return request("POST", "/api/auth/forgot-password", body, false);
}
public ApiResponse restoreSession() throws IOException, JSONException {
if (getRestoreToken().isEmpty()) {
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_RESTORE_TOKEN"));
@@ -83,6 +130,17 @@ public class BossApiClient {
return request("GET", "/api/auth/session", null, true);
}
public ApiResponse getAuthSessions() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/auth/sessions", null);
}
public ApiResponse revokeAuthSession(String sessionId) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("action", "revoke_session");
payload.put("sessionId", sessionId);
return requestWithRestore("POST", "/api/v1/auth/sessions", payload);
}
public ApiResponse getConversations() throws IOException, JSONException {
return requestWithRestoreRaw(
"GET",
@@ -107,6 +165,12 @@ public class BossApiClient {
return requestWithRestore("GET", "/api/v1/conversation-folders/" + encode(folderKey), null);
}
public ApiResponse markConversationRead(String projectId) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("action", "mark_read");
return requestWithRestore("POST", "/api/v1/conversations/" + encode(projectId) + "/actions", payload);
}
public ApiResponse getProjectDetail(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null);
}
@@ -254,6 +318,16 @@ public class BossApiClient {
);
}
public ApiResponse decideDialogGuardIntervention(String interventionId, String decision) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("decision", decision);
return requestWithRestore(
"POST",
"/api/v1/dialog-guard/interventions/" + encode(interventionId) + "/decision",
payload
);
}
public ApiResponse retryDispatchPlan(String projectId, String planId) throws IOException, JSONException {
return requestWithRestoreRaw(
"POST",
@@ -299,6 +373,18 @@ public class BossApiClient {
return requestWithRestore("PATCH", "/api/v1/projects/" + encode(projectId) + "/dispatch-reminder", payload);
}
public ApiResponse getAttachmentStorageConfig() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/storage/config", null);
}
public ApiResponse saveAttachmentStorageConfig(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("PATCH", "/api/v1/storage/config", payload);
}
public ApiResponse validateAttachmentStorageConfig(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/storage/config/validate", payload);
}
public ApiResponse sendProjectMessage(String projectId, String body, String kind) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("body", body);
@@ -312,6 +398,14 @@ public class BossApiClient {
);
}
public ApiResponse deleteProjectMessage(String projectId, String messageId) throws IOException, JSONException {
return requestWithRestore(
"DELETE",
"/api/v1/projects/" + encode(projectId) + "/messages?messageId=" + encode(messageId),
null
);
}
public ApiResponse uploadAttachment(
String projectId,
String fileName,
@@ -484,6 +578,14 @@ public class BossApiClient {
return requestWithRestore("GET", "/api/v1/accounts", null);
}
public ApiResponse getAdminAccess() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/admin/access", null);
}
public ApiResponse updateAdminAccess(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/admin/access", payload);
}
public ApiResponse createAccount(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/accounts", payload);
}
@@ -546,6 +648,14 @@ public class BossApiClient {
return requestWithRestore("POST", "/api/v1/settings", payload);
}
public ApiResponse getTelegramIntegration() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/integrations/telegram", null);
}
public ApiResponse updateTelegramIntegration(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/integrations/telegram", payload);
}
public ApiResponse getOtaStatus() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/user/ota", null);
}
@@ -569,7 +679,7 @@ public class BossApiClient {
}
public String getAccountLabel() {
return prefs.getString(KEY_ACCOUNT, "17600003315");
return prefs.getString(KEY_ACCOUNT, "krisolo");
}
public String getDisplayName() {
@@ -614,9 +724,9 @@ public class BossApiClient {
int readTimeoutMs
) throws IOException, JSONException {
ApiResponse response = requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
if (response.statusCode == 401 && !getRestoreToken().isEmpty()) {
ApiResponse restored = restoreSession();
if (restored.ok()) {
if (response.statusCode == 401) {
ApiResponse recovered = !getRestoreToken().isEmpty() ? restoreSession() : autoLogin();
if (recovered.ok()) {
return requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
}
}
@@ -709,7 +819,16 @@ public class BossApiClient {
private ApiResponse executeConnection(HttpURLConnection connection, boolean expectProtected) throws IOException, JSONException {
int statusCode = connection.getResponseCode();
captureSessionCookie(connection.getHeaderFields());
JSONObject json = readJson(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
JsonBody jsonBody = readJsonBody(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
JSONObject json = jsonBody.json;
if (!jsonBody.validJson) {
int normalizedStatusCode = expectProtected && statusCode < 400 ? 401 : statusCode;
json = new JSONObject()
.put("ok", false)
.put("message", "NON_JSON_RESPONSE")
.put("statusCode", statusCode);
statusCode = normalizedStatusCode;
}
if (statusCode == 401 && !expectProtected) {
clearSession();
@@ -822,8 +941,12 @@ public class BossApiClient {
}
private JSONObject readJson(InputStream stream) throws IOException, JSONException {
return readJsonBody(stream).json;
}
private JsonBody readJsonBody(InputStream stream) throws IOException, JSONException {
if (stream == null) {
return new JSONObject();
return new JsonBody(new JSONObject(), true);
}
StringBuilder builder = new StringBuilder();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
@@ -834,9 +957,13 @@ public class BossApiClient {
}
String raw = builder.toString().trim();
if (raw.isEmpty()) {
return new JSONObject();
return new JsonBody(new JSONObject(), true);
}
try {
return new JsonBody(new JSONObject(raw), true);
} catch (JSONException error) {
return new JsonBody(new JSONObject(), false);
}
return new JSONObject(raw);
}
private String readText(InputStream stream) throws IOException {
@@ -870,9 +997,13 @@ public class BossApiClient {
private void captureSessionCookie(Map<String, List<String>> headers) {
if (headers == null) return;
List<String> setCookieHeaders = headers.get("Set-Cookie");
if (setCookieHeaders == null) {
setCookieHeaders = headers.get("set-cookie");
List<String> setCookieHeaders = null;
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
String headerName = entry.getKey();
if (headerName != null && "set-cookie".equalsIgnoreCase(headerName)) {
setCookieHeaders = entry.getValue();
break;
}
}
if (setCookieHeaders == null) return;
@@ -965,6 +1096,16 @@ public class BossApiClient {
}
}
private static class JsonBody {
final JSONObject json;
final boolean validJson;
JsonBody(JSONObject json, boolean validJson) {
this.json = json;
this.validJson = validJson;
}
}
public static class DownloadedAttachment {
public final int statusCode;
public final String fileName;

View File

@@ -0,0 +1,48 @@
package com.hyzq.boss;
import androidx.annotation.Nullable;
final class BossAppVisibilityTracker {
private volatile boolean appInForeground;
private volatile @Nullable String visibleProjectId;
void onAppForegrounded() {
appInForeground = true;
}
void onAppBackgrounded() {
appInForeground = false;
}
boolean isAppInForeground() {
return appInForeground;
}
void setVisibleProjectId(@Nullable String projectId) {
if (projectId == null) {
visibleProjectId = null;
return;
}
String normalized = projectId.trim();
visibleProjectId = normalized.isEmpty() ? null : normalized;
}
void clearVisibleProjectId(@Nullable String projectId) {
if (visibleProjectId == null) {
return;
}
if (projectId == null) {
visibleProjectId = null;
return;
}
String normalized = projectId.trim();
if (normalized.isEmpty() || visibleProjectId.equals(normalized)) {
visibleProjectId = null;
}
}
@Nullable
String getVisibleProjectId() {
return visibleProjectId;
}
}

View File

@@ -1,13 +1,68 @@
package com.hyzq.boss;
import android.app.Activity;
import android.app.Application;
import android.os.Bundle;
import androidx.appcompat.app.AppCompatDelegate;
public final class BossApplication extends Application {
private final BossAppVisibilityTracker visibilityTracker = new BossAppVisibilityTracker();
private BossNotificationRouter notificationRouter;
private int startedActivityCount;
@Override
public void onCreate() {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
super.onCreate();
notificationRouter = new BossNotificationRouter(this, visibilityTracker);
registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {}
@Override
public void onActivityStarted(Activity activity) {
startedActivityCount += 1;
if (startedActivityCount == 1) {
visibilityTracker.onAppForegrounded();
BossBackgroundRealtimeService.stop(BossApplication.this);
}
}
@Override
public void onActivityResumed(Activity activity) {}
@Override
public void onActivityPaused(Activity activity) {}
@Override
public void onActivityStopped(Activity activity) {
startedActivityCount = Math.max(0, startedActivityCount - 1);
if (startedActivityCount == 0) {
visibilityTracker.onAppBackgrounded();
BossBackgroundRealtimeService.start(BossApplication.this);
}
}
@Override
public void onActivitySaveInstanceState(Activity activity, Bundle outState) {}
@Override
public void onActivityDestroyed(Activity activity) {}
});
}
@Override
public void onTerminate() {
BossBackgroundRealtimeService.stop(this);
super.onTerminate();
}
BossAppVisibilityTracker visibilityTracker() {
return visibilityTracker;
}
BossNotificationRouter notificationRouter() {
return notificationRouter;
}
}

View File

@@ -0,0 +1,156 @@
package com.hyzq.boss;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.IBinder;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
public class BossBackgroundRealtimeService extends Service {
static final String ACTION_START = "com.hyzq.boss.action.START_BACKGROUND_REALTIME";
static final String ACTION_STOP = "com.hyzq.boss.action.STOP_BACKGROUND_REALTIME";
static final String SERVICE_CHANNEL_ID = "boss_background_sync";
static final int SERVICE_NOTIFICATION_ID = 2002;
interface BossRealtimeRuntime {
void start();
void stop();
}
private @Nullable BossApiClient apiClient;
private @Nullable BossRealtimeRuntime realtimeRuntime;
private boolean realtimeStarted;
static void start(Context context) {
Intent intent = new Intent(context, BossBackgroundRealtimeService.class).setAction(ACTION_START);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent);
return;
}
context.startService(intent);
}
static void stop(Context context) {
Intent intent = new Intent(context, BossBackgroundRealtimeService.class).setAction(ACTION_STOP);
context.startService(intent);
}
@Override
public void onCreate() {
super.onCreate();
apiClient = createApiClient();
BossNotificationRouter notificationRouter = createNotificationRouter();
realtimeRuntime = createRealtimeRuntime(apiClient, notificationRouter);
}
BossApiClient createApiClient() {
return new BossApiClient(this);
}
BossNotificationRouter createNotificationRouter() {
BossAppVisibilityTracker tracker = getApplication() instanceof BossApplication
? ((BossApplication) getApplication()).visibilityTracker()
: new BossAppVisibilityTracker();
return new BossNotificationRouter(this, tracker);
}
BossRealtimeRuntime createRealtimeRuntime(BossApiClient apiClient, BossNotificationRouter router) {
BossRealtimeClient realtimeClient = new BossRealtimeClient(apiClient, router::maybeNotifyForRealtimeEvent);
return new BossRealtimeRuntime() {
@Override
public void start() {
realtimeClient.start();
}
@Override
public void stop() {
realtimeClient.stop();
}
};
}
@Override
public int onStartCommand(@Nullable Intent intent, int flags, int startId) {
String action = intent == null ? ACTION_START : intent.getAction();
if (ACTION_STOP.equals(action)) {
stopSelf();
return START_NOT_STICKY;
}
if (apiClient == null || realtimeRuntime == null || !apiClient.hasSessionHints()) {
stopSelf();
return START_NOT_STICKY;
}
startForeground(SERVICE_NOTIFICATION_ID, buildForegroundNotification());
if (!realtimeStarted) {
realtimeRuntime.start();
realtimeStarted = true;
}
return START_STICKY;
}
@Override
public void onDestroy() {
if (realtimeRuntime != null && realtimeStarted) {
realtimeRuntime.stop();
realtimeStarted = false;
}
stopForeground(STOP_FOREGROUND_REMOVE);
super.onDestroy();
}
@Override
public @Nullable IBinder onBind(Intent intent) {
return null;
}
private Notification buildForegroundNotification() {
ensureChannel();
return new NotificationCompat.Builder(this, SERVICE_CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("Boss 后台同步中")
.setContentText("主 Agent 新回复会通过系统通知提醒")
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentIntent(buildContentIntent())
.build();
}
private PendingIntent buildContentIntent() {
Intent intent = new Intent(this, MainActivity.class)
.putExtra(MainActivity.EXTRA_INITIAL_TAB, "conversations")
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
return PendingIntent.getActivity(
this,
902,
intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
}
private void ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationManager notificationManager = getSystemService(NotificationManager.class);
if (notificationManager == null || notificationManager.getNotificationChannel(SERVICE_CHANNEL_ID) != null) {
return;
}
NotificationChannel channel = new NotificationChannel(
SERVICE_CHANNEL_ID,
"Boss 后台同步",
NotificationManager.IMPORTANCE_LOW
);
channel.setDescription("保持主 Agent 后台同步与消息提醒");
notificationManager.createNotificationChannel(channel);
}
}

View File

@@ -4,6 +4,7 @@ import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Color;
import android.graphics.Typeface;
import android.os.Build;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.SpannedString;
@@ -155,8 +156,10 @@ public final class BossMarkdown {
ensureBlockSeparation(builder, false);
int start = builder.length();
appendInlineStyled(builder, TextUtils.isEmpty(text) ? "引用" : text, palette);
builder.setSpan(new QuoteSpan(palette.quoteColor, BossUi.dp(palette.context, 3), BossUi.dp(palette.context, 8)),
start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
QuoteSpan quoteSpan = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
? new QuoteSpan(palette.quoteColor, BossUi.dp(palette.context, 3), BossUi.dp(palette.context, 8))
: new QuoteSpan(palette.quoteColor);
builder.setSpan(quoteSpan, start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.append('\n');
}

View File

@@ -0,0 +1,161 @@
package com.hyzq.boss;
import android.Manifest;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat;
import org.json.JSONArray;
import org.json.JSONObject;
final class BossNotificationRouter {
static final String CHANNEL_ID = "boss_master_agent_messages";
static final int MASTER_AGENT_NOTIFICATION_ID = 2001;
private final Context appContext;
private final BossAppVisibilityTracker visibilityTracker;
private @Nullable String lastNotifiedMessageId;
BossNotificationRouter(Context context, BossAppVisibilityTracker visibilityTracker) {
this.appContext = context.getApplicationContext();
this.visibilityTracker = visibilityTracker;
}
boolean maybeNotifyForRealtimeEvent(@Nullable BossRealtimeEvent event) {
NotificationCandidate candidate = latestMasterAgentMessage(event);
if (candidate == null) {
return false;
}
if (candidate.messageId.isEmpty() || TextUtils.equals(candidate.messageId, lastNotifiedMessageId)) {
return false;
}
if (visibilityTracker.isAppInForeground()) {
return false;
}
if (!canPostNotifications()) {
return false;
}
ensureChannel();
try {
NotificationManagerCompat.from(appContext).notify(
MASTER_AGENT_NOTIFICATION_ID,
new NotificationCompat.Builder(appContext, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(candidate.title)
.setContentText(candidate.body)
.setStyle(new NotificationCompat.BigTextStyle().bigText(
candidate.body
))
.setAutoCancel(true)
.setContentIntent(buildContentIntent(candidate))
.build()
);
} catch (SecurityException ignored) {
return false;
}
lastNotifiedMessageId = candidate.messageId;
return true;
}
void resetLastNotifiedMessageId() {
lastNotifiedMessageId = null;
}
void clearMasterAgentNotification() {
NotificationManagerCompat.from(appContext).cancel(MASTER_AGENT_NOTIFICATION_ID);
}
private boolean canPostNotifications() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
&& ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
return false;
}
return NotificationManagerCompat.from(appContext).areNotificationsEnabled();
}
private @Nullable NotificationCandidate latestMasterAgentMessage(@Nullable BossRealtimeEvent event) {
if (event == null || !"project.messages.updated".equals(event.eventName)) {
return null;
}
String projectId = event.payload.optString("projectId", "").trim();
JSONObject projectMessagesPayload = event.payload.optJSONObject("projectMessagesPayload");
JSONObject project = projectMessagesPayload == null ? null : projectMessagesPayload.optJSONObject("project");
JSONArray messages = project == null ? null : project.optJSONArray("messages");
if (messages == null || messages.length() <= 0) {
return null;
}
JSONObject latestMessage = messages.optJSONObject(messages.length() - 1);
if (latestMessage == null) {
return null;
}
String sender = latestMessage.optString("sender", "");
String senderLabel = latestMessage.optString("senderLabel", "");
if (!"master".equals(sender) && !senderLabel.contains("主 Agent")) {
return null;
}
String messageId = latestMessage.optString("id", "").trim();
String projectName = project == null ? "" : project.optString("name", "").trim();
String title = "master-agent".equals(projectId) || projectName.isEmpty()
? "主 Agent"
: "主 Agent · " + projectName;
String body = latestMessage.optString("body", "你有一条新的主 Agent 回复");
return new NotificationCandidate(projectId, projectName, messageId, title, TextUtils.isEmpty(body) ? "你有一条新的主 Agent 回复" : body);
}
private PendingIntent buildContentIntent(NotificationCandidate candidate) {
Intent intent = new Intent(appContext, ProjectDetailActivity.class)
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, candidate.projectId)
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, candidate.projectName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
return PendingIntent.getActivity(
appContext,
901 + Math.abs(candidate.projectId.hashCode() % 97),
intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
}
private void ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationManager notificationManager = appContext.getSystemService(NotificationManager.class);
if (notificationManager == null || notificationManager.getNotificationChannel(CHANNEL_ID) != null) {
return;
}
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
"主 Agent 消息",
NotificationManager.IMPORTANCE_DEFAULT
);
channel.setDescription("Boss 主 Agent 后台消息提醒");
notificationManager.createNotificationChannel(channel);
}
private static final class NotificationCandidate {
final String projectId;
final String projectName;
final String messageId;
final String title;
final String body;
NotificationCandidate(String projectId, String projectName, String messageId, String title, String body) {
this.projectId = projectId == null || projectId.trim().isEmpty() ? "master-agent" : projectId.trim();
this.projectName = projectName == null || projectName.trim().isEmpty() ? "主 Agent" : projectName.trim();
this.messageId = messageId == null ? "" : messageId.trim();
this.title = title == null || title.trim().isEmpty() ? "主 Agent" : title.trim();
this.body = body == null || body.trim().isEmpty() ? "你有一条新的主 Agent 回复" : body.trim();
}
}
}

View File

@@ -1,5 +1,6 @@
package com.hyzq.boss;
import android.annotation.SuppressLint;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
@@ -25,9 +26,15 @@ import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.SwitchCompat;
import androidx.core.widget.ImageViewCompat;
import android.content.res.ColorStateList;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.Locale;
public final class BossUi {
private static final int[] AVATAR_BG_COLORS = {
Color.parseColor("#1EC76F"),
@@ -371,7 +378,64 @@ public final class BossUi {
LinearLayout row = buildListRow(context, title, subtitle, meta, badge, listener);
row.setBackgroundColor(Color.WHITE);
row.setElevation(0f);
row.setPadding(dp(context, 16), dp(context, 13), dp(context, 16), dp(context, 13));
return row;
}
public static LinearLayout buildWechatSwitchRow(
Context context,
String title,
@Nullable String subtitle,
SwitchCompat switchView
) {
LinearLayout row = new LinearLayout(context);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
row.setLayoutParams(params);
row.setBackgroundColor(Color.WHITE);
row.setPadding(dp(context, 18), dp(context, 15), dp(context, 18), dp(context, 15));
LinearLayout textWrap = new LinearLayout(context);
textWrap.setOrientation(LinearLayout.VERTICAL);
textWrap.setLayoutParams(new LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
));
TextView titleView = new TextView(context);
titleView.setText(title);
titleView.setTextSize(17);
titleView.setTypeface(Typeface.DEFAULT_BOLD);
titleView.setTextColor(context.getColor(R.color.boss_text_primary));
textWrap.addView(titleView);
if (!TextUtils.isEmpty(subtitle)) {
TextView subtitleView = new TextView(context);
subtitleView.setText(subtitle);
subtitleView.setTextSize(14);
subtitleView.setTextColor(context.getColor(R.color.boss_text_muted));
subtitleView.setPadding(0, dp(context, 4), 0, 0);
textWrap.addView(subtitleView);
}
row.addView(textWrap);
ViewParent currentParent = switchView.getParent();
if (currentParent instanceof ViewGroup) {
((ViewGroup) currentParent).removeView(switchView);
}
LinearLayout.LayoutParams switchParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
switchParams.leftMargin = dp(context, 12);
switchView.setLayoutParams(switchParams);
row.addView(switchView);
return row;
}
@@ -501,11 +565,11 @@ public final class BossUi {
);
params.leftMargin = dp(context, 12);
params.rightMargin = dp(context, 12);
params.bottomMargin = dp(context, 12);
params.bottomMargin = dp(context, 1);
card.setLayoutParams(params);
card.setPadding(dp(context, 14), dp(context, 14), dp(context, 14), dp(context, 14));
card.setBackground(createRoundedBackground(Color.WHITE, dp(context, 18)));
card.setElevation(dp(context, 1));
card.setPadding(dp(context, 14), dp(context, 13), dp(context, 14), dp(context, 13));
card.setBackgroundColor(Color.WHITE);
card.setElevation(0f);
if (listener != null) {
card.setClickable(true);
card.setFocusable(true);
@@ -944,7 +1008,34 @@ public final class BossUi {
}
public static LinearLayout buildEmptyCard(Context context, String text) {
return buildCard(context, "暂无内容", text, "下拉或点击顶部刷新按钮重试。");
LinearLayout card = new LinearLayout(context);
card.setOrientation(LinearLayout.VERTICAL);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
params.leftMargin = dp(context, 16);
params.rightMargin = dp(context, 16);
params.topMargin = dp(context, 28);
card.setLayoutParams(params);
card.setGravity(Gravity.CENTER_HORIZONTAL);
card.setPadding(dp(context, 16), dp(context, 18), dp(context, 16), dp(context, 18));
TextView titleView = new TextView(context);
titleView.setText("暂无内容");
titleView.setTextSize(16);
titleView.setTypeface(Typeface.DEFAULT_BOLD);
titleView.setTextColor(context.getColor(R.color.boss_text_primary));
card.addView(titleView);
TextView bodyView = new TextView(context);
bodyView.setText(text);
bodyView.setTextSize(13);
bodyView.setGravity(Gravity.CENTER);
bodyView.setTextColor(context.getColor(R.color.boss_text_muted));
bodyView.setPadding(0, dp(context, 8), 0, 0);
card.addView(bodyView);
return card;
}
public static TextView buildHintPill(Context context, String text) {
@@ -965,6 +1056,7 @@ public final class BossUi {
return pill;
}
@SuppressLint("WrongConstant")
public static LinearLayout buildMessageBubble(
Context context,
String senderLabel,
@@ -1006,6 +1098,410 @@ public final class BossUi {
return wrapper;
}
public static LinearLayout buildMasterAgentMessageBubble(
Context context,
String senderLabel,
String body,
@Nullable String meta,
@Nullable String kindLabel
) {
LinearLayout wrapper = buildMessageBubble(context, senderLabel, body, meta, false, kindLabel);
View bubble = findMessageBodyContainer(wrapper);
if (bubble != null) {
GradientDrawable background = createRoundedBackground(Color.parseColor("#EAF5FF"), dp(context, 18));
background.setStroke(dp(context, 1), Color.parseColor("#D1E8FF"));
bubble.setBackground(background);
}
return wrapper;
}
public static LinearLayout buildThreadProcessFoldCard(
Context context,
int itemCount,
@Nullable String preview,
@Nullable String detail
) {
LinearLayout card = new LinearLayout(context);
card.setOrientation(LinearLayout.VERTICAL);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
params.leftMargin = dp(context, 12);
params.rightMargin = dp(context, 12);
params.bottomMargin = dp(context, 10);
card.setLayoutParams(params);
card.setPadding(dp(context, 14), dp(context, 12), dp(context, 14), dp(context, 12));
GradientDrawable background = createRoundedBackground(Color.parseColor("#F7F8F7"), dp(context, 16));
background.setStroke(dp(context, 1), Color.parseColor("#E4E9E5"));
card.setBackground(background);
card.setClickable(true);
card.setFocusable(true);
LinearLayout header = new LinearLayout(context);
header.setOrientation(LinearLayout.HORIZONTAL);
header.setGravity(Gravity.CENTER_VERTICAL);
header.setLayoutParams(new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
TextView titleView = new TextView(context);
titleView.setTextSize(14);
titleView.setTypeface(Typeface.DEFAULT_BOLD);
titleView.setTextColor(context.getColor(R.color.boss_green));
titleView.setLayoutParams(new LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
));
header.addView(titleView);
TextView arrowView = new TextView(context);
arrowView.setTextSize(16);
arrowView.setTypeface(Typeface.DEFAULT_BOLD);
arrowView.setTextColor(context.getColor(R.color.boss_text_muted));
arrowView.setPadding(dp(context, 8), 0, 0, 0);
header.addView(arrowView);
card.addView(header);
TextView previewView = new TextView(context);
previewView.setText(TextUtils.isEmpty(preview) ? "线程正在输出过程内容" : preview);
previewView.setTextSize(13);
previewView.setLineSpacing(0f, 1.24f);
previewView.setTextColor(context.getColor(R.color.boss_text_muted));
previewView.setPadding(0, dp(context, 6), 0, 0);
previewView.setMaxLines(2);
previewView.setEllipsize(TextUtils.TruncateAt.END);
card.addView(previewView);
TextView detailView = new TextView(context);
detailView.setText(detail);
detailView.setTextSize(13);
detailView.setLineSpacing(0f, 1.28f);
detailView.setTextColor(context.getColor(R.color.boss_text_primary));
detailView.setPadding(0, dp(context, 10), 0, 0);
detailView.setVisibility(View.GONE);
card.addView(detailView);
final boolean[] expanded = new boolean[] {false};
View.OnClickListener toggleListener = ignored -> {
expanded[0] = !expanded[0];
titleView.setText((expanded[0] ? "收起本轮工作过程" : "查看本轮工作过程") + "" + itemCount + " 条)");
arrowView.setText(expanded[0] ? "" : "");
detailView.setVisibility(expanded[0] ? View.VISIBLE : View.GONE);
previewView.setVisibility(expanded[0] ? View.GONE : View.VISIBLE);
card.setContentDescription((expanded[0] ? "已展开" : "已折叠") + "本轮工作过程");
};
card.setOnClickListener(toggleListener);
titleView.setText("查看本轮工作过程(" + itemCount + " 条)");
arrowView.setText("");
card.setContentDescription("已折叠本轮工作过程");
return card;
}
public static LinearLayout buildControlSummaryCard(
Context context,
String title,
String body,
@Nullable String meta,
@Nullable String badge
) {
LinearLayout card = buildCard(context, title, body, meta, null);
card.setPadding(dp(context, 16), dp(context, 14), dp(context, 16), dp(context, 14));
card.setBackground(createRoundedBackground(Color.parseColor("#F7FAF7"), dp(context, 18)));
GradientDrawable background = (GradientDrawable) card.getBackground();
background.setStroke(dp(context, 1), Color.parseColor("#DCEBDD"));
if (!TextUtils.isEmpty(badge)) {
View badgeView = buildHintPill(context, badge);
if (badgeView.getParent() != null) {
((ViewGroup) badgeView.getParent()).removeView(badgeView);
}
card.addView(badgeView, 0);
}
return card;
}
public static LinearLayout buildExecutionProgressCard(
Context context,
@Nullable JSONObject progress,
@Nullable String meta
) {
LinearLayout card = new LinearLayout(context);
card.setOrientation(LinearLayout.VERTICAL);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
params.leftMargin = dp(context, 12);
params.rightMargin = dp(context, 12);
params.bottomMargin = dp(context, 12);
card.setLayoutParams(params);
card.setPadding(dp(context, 18), dp(context, 16), dp(context, 18), dp(context, 16));
GradientDrawable background = createRoundedBackground(Color.WHITE, dp(context, 22));
background.setStroke(dp(context, 1), Color.parseColor("#E5E9E7"));
card.setBackground(background);
card.setElevation(dp(context, 1));
LinearLayout titleRow = new LinearLayout(context);
titleRow.setOrientation(LinearLayout.HORIZONTAL);
titleRow.setGravity(Gravity.CENTER_VERTICAL);
titleRow.setLayoutParams(new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
TextView title = sectionTitle(context, "进度");
title.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
titleRow.addView(title);
TextView pin = new TextView(context);
pin.setText("");
pin.setTextSize(18);
pin.setTextColor(Color.parseColor("#9AA09D"));
titleRow.addView(pin);
card.addView(titleRow);
JSONArray steps = progress == null ? null : progress.optJSONArray("steps");
if (steps == null || steps.length() == 0) {
card.addView(progressLine(context, "等待执行进度回写", "running"));
} else {
for (int i = 0; i < steps.length(); i += 1) {
JSONObject step = steps.optJSONObject(i);
if (step == null) {
continue;
}
String text = step.optString("text", "").trim();
if (TextUtils.isEmpty(text)) {
continue;
}
card.addView(progressLine(context, text, step.optString("status", "pending")));
}
}
card.addView(divider(context));
card.addView(sectionTitle(context, "分支详情"));
JSONObject branch = progress == null ? null : progress.optJSONObject("branch");
if (branch != null) {
String changeText = formatChangeText(branch);
if (!TextUtils.isEmpty(changeText)) {
card.addView(changeRow(context, branch));
}
String gitStatus = branch.optString("gitStatus", "").trim();
card.addView(detailRow(context, "", TextUtils.isEmpty(gitStatus) ? "Git 操作" : gitStatus, "", false));
String ghStatus = branch.optString("githubCliStatus", "").trim();
if ("unavailable".equals(ghStatus)) {
card.addView(detailRow(context, "", "GitHub CLI 不可用", "", false, true));
} else if ("available".equals(ghStatus)) {
card.addView(detailRow(context, "", "GitHub CLI 可用", "", false));
}
} else {
card.addView(detailRow(context, "", "Git 操作", "等待执行器回写", false));
}
JSONArray artifacts = progress == null ? null : progress.optJSONArray("artifacts");
if (artifacts != null && artifacts.length() > 0) {
card.addView(divider(context));
card.addView(sectionTitle(context, "生成结果"));
int shown = 0;
for (int i = 0; i < artifacts.length(); i += 1) {
JSONObject artifact = artifacts.optJSONObject(i);
String label = artifact == null ? "" : artifact.optString("label", "").trim();
if (TextUtils.isEmpty(label)) {
continue;
}
String icon = "image".equals(artifact.optString("kind", "")) ? "" : "";
card.addView(detailRow(context, icon, label, "", false));
shown += 1;
if (shown >= 6) {
break;
}
}
if (artifacts.length() > shown) {
card.addView(detailRow(context, "", "再显示 " + (artifacts.length() - shown) + "", "", false, true));
}
}
JSONArray agents = progress == null ? null : progress.optJSONArray("agents");
if (agents != null && agents.length() > 0) {
card.addView(divider(context));
card.addView(sectionTitle(context, "后台智能体"));
for (int i = 0; i < agents.length(); i += 1) {
JSONObject agent = agents.optJSONObject(i);
String name = agent == null ? "" : agent.optString("name", "").trim();
if (TextUtils.isEmpty(name)) {
continue;
}
String role = agent.optString("role", "").trim();
card.addView(detailRow(
context,
"",
TextUtils.isEmpty(role) ? name : name + "" + role + "",
"",
false
));
}
}
if (!TextUtils.isEmpty(meta)) {
TextView metaView = secondaryText(context, meta);
metaView.setPadding(0, dp(context, 10), 0, 0);
card.addView(metaView);
}
return card;
}
private static View progressLine(Context context, String text, String status) {
LinearLayout row = new LinearLayout(context);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.TOP);
row.setPadding(0, dp(context, 10), 0, 0);
TextView check = new TextView(context);
LinearLayout.LayoutParams checkParams = new LinearLayout.LayoutParams(dp(context, 28), dp(context, 28));
check.setLayoutParams(checkParams);
check.setGravity(Gravity.CENTER);
check.setText("");
check.setTextSize(15);
check.setTypeface(Typeface.DEFAULT_BOLD);
int color = "failed".equals(status) ? Color.parseColor("#E44B4B") :
"running".equals(status) ? Color.parseColor("#1EC76F") : Color.parseColor("#9AA09D");
check.setTextColor(Color.WHITE);
check.setBackground(createRoundedBackground(color, dp(context, 14)));
row.addView(check);
TextView body = new TextView(context);
LinearLayout.LayoutParams bodyParams = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f);
bodyParams.leftMargin = dp(context, 12);
body.setLayoutParams(bodyParams);
body.setText(text);
body.setTextSize(18);
body.setLineSpacing(0f, 1.22f);
body.setTextColor(context.getColor(R.color.boss_text_primary));
row.addView(body);
return row;
}
private static View divider(Context context) {
View divider = new View(context);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
dp(context, 1)
);
params.topMargin = dp(context, 16);
params.bottomMargin = dp(context, 14);
divider.setLayoutParams(params);
divider.setBackgroundColor(Color.parseColor("#ECEFEE"));
return divider;
}
private static TextView sectionTitle(Context context, String text) {
TextView view = new TextView(context);
view.setText(text);
view.setTextSize(17);
view.setTypeface(Typeface.DEFAULT_BOLD);
view.setTextColor(Color.parseColor("#8B918F"));
return view;
}
private static TextView secondaryText(Context context, String text) {
TextView view = new TextView(context);
view.setText(text);
view.setTextSize(12);
view.setTextColor(context.getColor(R.color.boss_text_muted));
return view;
}
private static View detailRow(Context context, String iconText, String label, String value, boolean valueIsChange) {
return detailRow(context, iconText, label, value, valueIsChange, false);
}
private static View detailRow(Context context, String iconText, String label, String value, boolean valueIsChange, boolean muted) {
LinearLayout row = new LinearLayout(context);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
row.setPadding(0, dp(context, 8), 0, 0);
TextView icon = new TextView(context);
icon.setText(iconText);
icon.setGravity(Gravity.CENTER);
icon.setTextSize(18);
icon.setTextColor(muted ? Color.parseColor("#9AA09D") : context.getColor(R.color.boss_text_primary));
row.addView(icon, new LinearLayout.LayoutParams(dp(context, 34), LinearLayout.LayoutParams.WRAP_CONTENT));
TextView labelView = new TextView(context);
labelView.setText(label);
labelView.setTextSize(17);
labelView.setTextColor(muted ? Color.parseColor("#8B918F") : context.getColor(R.color.boss_text_primary));
row.addView(labelView, new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
if (!TextUtils.isEmpty(value)) {
TextView valueView = new TextView(context);
valueView.setText(value);
valueView.setTextSize(16);
valueView.setTextColor(valueIsChange ? Color.parseColor("#00A94F") : context.getColor(R.color.boss_text_muted));
row.addView(valueView);
}
return row;
}
private static View changeRow(Context context, JSONObject branch) {
LinearLayout row = new LinearLayout(context);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
row.setPadding(0, dp(context, 8), 0, 0);
TextView icon = new TextView(context);
icon.setText("");
icon.setGravity(Gravity.CENTER);
icon.setTextSize(18);
icon.setTextColor(context.getColor(R.color.boss_text_primary));
row.addView(icon, new LinearLayout.LayoutParams(dp(context, 34), LinearLayout.LayoutParams.WRAP_CONTENT));
TextView labelView = new TextView(context);
labelView.setText("变更");
labelView.setTextSize(17);
labelView.setTextColor(context.getColor(R.color.boss_text_primary));
row.addView(labelView, new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
int additions = branch.optInt("additions", 0);
if (additions > 0) {
TextView additionsView = new TextView(context);
additionsView.setText("+" + String.format(Locale.US, "%,d", additions));
additionsView.setTextSize(16);
additionsView.setTextColor(Color.parseColor("#00A94F"));
row.addView(additionsView);
}
int deletions = branch.optInt("deletions", 0);
if (deletions > 0) {
TextView deletionsView = new TextView(context);
deletionsView.setText("-" + String.format(Locale.US, "%,d", deletions));
deletionsView.setTextSize(16);
deletionsView.setTextColor(Color.parseColor("#C52828"));
LinearLayout.LayoutParams deletionParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
deletionParams.leftMargin = dp(context, 8);
row.addView(deletionsView, deletionParams);
}
return row;
}
private static String formatChangeText(JSONObject branch) {
int additions = branch.optInt("additions", 0);
int deletions = branch.optInt("deletions", 0);
StringBuilder builder = new StringBuilder();
if (additions > 0) {
builder.append("+").append(String.format(Locale.US, "%,d", additions));
}
if (deletions > 0) {
if (builder.length() > 0) {
builder.append(" ");
}
builder.append("-").append(String.format(Locale.US, "%,d", deletions));
}
return builder.toString();
}
public static LinearLayout buildAttachmentMessageCard(
Context context,
String senderLabel,
@@ -1160,6 +1656,23 @@ public final class BossUi {
return wrapper;
}
public static LinearLayout buildMasterAgentForwardSingleBubble(
Context context,
String senderLabel,
String body,
@Nullable String meta,
@Nullable String sourceLabel
) {
LinearLayout wrapper = buildForwardSingleBubble(context, senderLabel, body, meta, sourceLabel, false);
View bubble = findMessageBodyContainer(wrapper);
if (bubble != null) {
GradientDrawable background = createRoundedBackground(Color.parseColor("#EAF5FF"), dp(context, 18));
background.setStroke(dp(context, 1), Color.parseColor("#D1E8FF"));
bubble.setBackground(background);
}
return wrapper;
}
public static LinearLayout buildForwardBundleCard(
Context context,
String senderLabel,
@@ -1235,6 +1748,14 @@ public final class BossUi {
return buildMessageBubble(context, effectiveSender, body, "发送中", true, null);
}
@Nullable
private static View findMessageBodyContainer(LinearLayout wrapper) {
if (wrapper == null || wrapper.getChildCount() < 2) {
return null;
}
return wrapper.getChildAt(1);
}
public static void applyMessageSelectionState(Context context, View messageView, boolean selected) {
if (messageView == null) {
return;
@@ -1248,6 +1769,61 @@ public final class BossUi {
}
}
public static View wrapIncomingMessageWithSourceAvatar(
Context context,
View messageView,
@Nullable String avatarLabel,
@Nullable String sourceName
) {
if (messageView == null || TextUtils.isEmpty(avatarLabel)) {
return messageView;
}
ViewParent parent = messageView.getParent();
if (parent instanceof ViewGroup) {
((ViewGroup) parent).removeView(messageView);
}
LinearLayout row = new LinearLayout(context);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.TOP);
LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
row.setLayoutParams(rowParams);
TextView avatar = buildAvatarCircle(
context,
avatarLabel,
Color.parseColor("#E5F6EC"),
context.getColor(R.color.boss_green),
36
);
LinearLayout.LayoutParams avatarParams = new LinearLayout.LayoutParams(dp(context, 36), dp(context, 36));
avatarParams.leftMargin = dp(context, 8);
avatarParams.rightMargin = dp(context, 8);
avatarParams.topMargin = dp(context, 16);
avatar.setLayoutParams(avatarParams);
if (!TextUtils.isEmpty(sourceName)) {
avatar.setContentDescription("来自 " + sourceName);
}
row.addView(avatar);
LinearLayout.LayoutParams contentParams = new LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
);
if (messageView.getLayoutParams() instanceof LinearLayout.LayoutParams) {
LinearLayout.LayoutParams previous = (LinearLayout.LayoutParams) messageView.getLayoutParams();
contentParams.topMargin = previous.topMargin;
contentParams.bottomMargin = previous.bottomMargin;
}
messageView.setLayoutParams(contentParams);
row.addView(messageView);
return row;
}
public static TextView buildMessagePlaceholder(Context context, String text) {
TextView placeholder = new TextView(context);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
@@ -1327,9 +1903,6 @@ public final class BossUi {
@Nullable String meta,
boolean outgoing
) {
if (outgoing && !TextUtils.isEmpty(meta)) {
return meta;
}
if (TextUtils.isEmpty(meta)) {
return senderLabel;
}

View File

@@ -18,6 +18,7 @@ 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";
public static final String EXTRA_TAKEOVER_ENABLED = "takeover_enabled";
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private String projectId;
@@ -178,17 +179,9 @@ public class ConversationInfoActivity extends BossScreenActivity {
takeoverInheritedFromGlobal = agentControls != null && agentControls.optBoolean("takeoverInheritedFromGlobal", false);
configureScreen("会话信息", buildSubtitle(threadMeta, participantCount));
appendContent(BossUi.buildSimpleProfileHeader(
this,
projectName,
"单线程会话",
buildHeaderDetail(project, threadMeta, participantCount)
));
appendThreadStatusSummary(threadStatusPayload);
appendTakeoverControl();
appendContent(BossUi.buildWechatMenuRow(
appendConversationInfoItem(BossUi.buildWechatMenuRow(
this,
"发起群聊",
"选择其他线程加入新群",
@@ -197,7 +190,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
v -> openGroupCreate()
));
appendContent(BossUi.buildWechatMenuRow(
appendConversationInfoItem(BossUi.buildWechatMenuRow(
this,
"线程详情",
"查看当前线程聊天与项目",
@@ -206,7 +199,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
v -> openProject(projectId, projectName)
));
appendContent(BossUi.buildWechatMenuRow(
appendConversationInfoItem(BossUi.buildWechatMenuRow(
this,
"线程状态",
"状态文档和最近进展事件",
@@ -215,7 +208,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
v -> openThreadStatus()
));
appendContent(BossUi.buildWechatMenuRow(
appendConversationInfoItem(BossUi.buildWechatMenuRow(
this,
"参与线程",
participantCount <= 0 ? "暂无参与线程" : "" + participantCount + "",
@@ -225,7 +218,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
));
if (participants == null || participants.length() == 0) {
appendContent(BossUi.buildWechatMenuRow(
appendConversationInfoItem(BossUi.buildWechatMenuRow(
this,
"暂无参与线程",
"下拉刷新后重试",
@@ -237,7 +230,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
for (int i = 0; i < participants.length(); i++) {
JSONObject participant = participants.optJSONObject(i);
if (participant == null) continue;
appendContent(buildParticipantRow(participant));
appendConversationInfoItem(buildParticipantRow(participant));
}
}
@@ -246,86 +239,34 @@ public class ConversationInfoActivity extends BossScreenActivity {
private void appendTakeoverControl() {
SwitchCompat takeoverSwitch = new SwitchCompat(this);
takeoverSwitch.setText("开启");
takeoverSwitch.setShowText(false);
takeoverSwitch.setText(null);
takeoverSwitch.setChecked(takeoverEnabled);
takeoverSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> saveTakeoverSetting(isChecked));
appendContent(BossUi.buildFormCell(
appendConversationInfoItem(BossUi.buildWechatSwitchRow(
this,
"主 Agent 协同接管",
takeoverInheritedFromGlobal
? "当前跟随全局默认开启。主 Agent 会协同推进,但不会抢走你直接控制线程开发的能力。"
: "这个线程单独开启主 Agent 协同推进。不会抢走你直接控制线程开发的能力。",
? "跟随全局默认开启"
: "线程单独开启",
takeoverSwitch
));
}
private void appendThreadStatusSummary(@Nullable JSONObject threadStatusPayload) {
if (threadStatusPayload == null) {
return;
private void appendConversationInfoItem(android.view.View view) {
android.view.ViewGroup.LayoutParams currentParams = view.getLayoutParams();
LinearLayout.LayoutParams params;
if (currentParams instanceof LinearLayout.LayoutParams) {
params = (LinearLayout.LayoutParams) currentParams;
} else {
params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
}
JSONObject document = threadStatusPayload.optJSONObject("threadStatusDocument");
if (document == null) {
return;
}
JSONArray recentProgressEvents = threadStatusPayload.optJSONArray("recentProgressEvents");
int eventCount = recentProgressEvents == null ? 0 : recentProgressEvents.length();
String body = buildThreadStatusSummaryBody(document, eventCount);
String meta = buildThreadStatusSummaryMeta(document, eventCount);
appendContent(BossUi.buildCard(this, "线程状态摘要", body, meta));
}
private String buildThreadStatusSummaryBody(JSONObject document, int eventCount) {
return joinNonEmptyLines(
formatSummaryLine("当前目标", document.optString("projectGoal", "")),
formatSummaryLine("当前进度", document.optString("currentProgress", "")),
formatSummaryLine("当前阻塞", document.optString("currentBlockers", "")),
formatSummaryLine("建议下一步", document.optString("recommendedNextStep", "")),
eventCount > 0 ? "最近进展:" + eventCount + "" : ""
);
}
private String buildThreadStatusSummaryMeta(JSONObject document, int eventCount) {
return joinNonEmptyParts(
projectFolderName,
eventCount > 0 ? "最近 " + eventCount + " 条进展" : "暂无进展",
document.optString("updatedAt", "").isEmpty() ? "" : "更新于 " + document.optString("updatedAt", "")
);
}
private String formatSummaryLine(String label, String value) {
String trimmed = value == null ? "" : value.trim();
if (trimmed.isEmpty()) {
return "";
}
return label + "" + trimmed;
}
private String joinNonEmptyLines(String... values) {
StringBuilder builder = new StringBuilder();
for (String value : values) {
if (value == null || value.trim().isEmpty()) {
continue;
}
if (builder.length() > 0) {
builder.append('\n');
}
builder.append(value.trim());
}
return builder.toString();
}
private String joinNonEmptyParts(String... values) {
StringBuilder builder = new StringBuilder();
for (String value : values) {
if (value == null || value.trim().isEmpty()) {
continue;
}
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(value.trim());
}
return builder.toString();
params.bottomMargin = BossUi.dp(this, 8);
view.setLayoutParams(params);
appendContent(view);
}
private LinearLayout buildParticipantRow(JSONObject participant) {
@@ -445,6 +386,10 @@ public class ConversationInfoActivity extends BossScreenActivity {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
Intent result = new Intent();
result.putExtra(EXTRA_PROJECT_NAME, projectName);
result.putExtra(EXTRA_TAKEOVER_ENABLED, enabled);
setResult(RESULT_OK, result);
showMessage(enabled ? "已开启主 Agent 协同接管" : "已关闭主 Agent 协同接管");
reload();
});
@@ -517,25 +462,6 @@ public class ConversationInfoActivity extends BossScreenActivity {
return folder + " · " + suffix;
}
private String buildHeaderDetail(JSONObject project, @Nullable JSONObject threadMeta, int count) {
StringBuilder builder = new StringBuilder();
String threadId = resolveThreadId(project, threadMeta);
if (!threadId.isEmpty()) {
builder.append(threadId);
}
if (!projectFolderName.isEmpty()) {
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(projectFolderName);
}
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(count <= 0 ? "暂无参与线程" : count + " 个参与线程");
return builder.toString();
}
private String resolveThreadId(JSONObject project, @Nullable JSONObject threadMeta) {
if (threadMeta != null) {
String threadId = threadMeta.optString("threadId", "");

View File

@@ -1,11 +1,17 @@
package com.hyzq.boss;
import android.Manifest;
import android.content.res.ColorStateList;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.LayoutInflater;
import android.view.View;
@@ -23,6 +29,7 @@ import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.DiffUtil;
@@ -45,14 +52,17 @@ import java.util.function.Supplier;
public class MainActivity extends AppCompatActivity {
public static final String EXTRA_INITIAL_TAB = "initial_tab";
private static final int REQUEST_POST_NOTIFICATIONS = 2101;
private static final String UI_PREFS = "boss_native_client";
private static final String KEY_LAST_ROOT_TAB = "last_root_tab";
private static final String KEY_NOTIFICATION_PERMISSION_REQUESTED = "notification_permission_requested";
private static final long ROOT_BACK_EXIT_WINDOW_MS = 1_500L;
private static final long CONVERSATION_AUTO_REFRESH_MS = 12_000L;
private static final long REALTIME_REFRESH_DEBOUNCE_MS = 350L;
private static final long REALTIME_REFRESH_THROTTLE_MS = 900L;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final ExecutorService sessionExecutor = Executors.newSingleThreadExecutor();
private final Handler uiHandler = new Handler(Looper.getMainLooper());
private BossApiClient apiClient;
@@ -64,7 +74,16 @@ public class MainActivity extends AppCompatActivity {
private View mainTopBar;
private TextView loginTitle;
private TextView loginHint;
private EditText loginAccountInput;
private EditText loginPasswordInput;
private EditText loginConfirmPasswordInput;
private EditText loginCodeInput;
private View loginCodeRow;
private Button loginSendCodeButton;
private Button loginButton;
private Button loginModeButton;
private Button registerModeButton;
private Button forgotModeButton;
private ProgressBar loginProgress;
private ImageButton backButton;
@@ -92,6 +111,7 @@ public class MainActivity extends AppCompatActivity {
private String activeTab = "conversations";
private String preferredEntryTab = "conversations";
private @Nullable String requestedInitialTab;
private String authMode = "login";
private boolean userSelectedTab = false;
private long lastRootBackPressedAt = 0L;
private @Nullable JSONObject sessionData;
@@ -107,9 +127,11 @@ public class MainActivity extends AppCompatActivity {
private boolean conversationQuickActionsVisible = false;
private boolean conversationAutoRefreshArmed = false;
private boolean conversationAutoRefreshEnabled = false;
private boolean conversationRootUsesGroupedHomeFeed = false;
private boolean rootTabRefreshInFlight = false;
private boolean pendingRootTabRefresh = false;
private boolean realtimeRefreshScheduled = false;
private boolean notificationPermissionRequestScheduled = false;
private final java.util.HashMap<String, Long> recentRealtimeEventTimestamps = new java.util.HashMap<>();
private final Set<String> selectedConversationProjectIds = new LinkedHashSet<>();
private @Nullable RootPagerAdapter rootPagerAdapter;
@@ -142,8 +164,21 @@ public class MainActivity extends AppCompatActivity {
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
apiClient = new BossApiClient(this);
realtimeClient = new BossRealtimeClient(apiClient, new BossRealtimeClient.Listener() {
apiClient = createApiClient();
realtimeClient = createRealtimeClient(apiClient);
bindViews();
bindActions();
configureBackNavigation();
applyInitialTab(getIntent());
bootstrapSession();
}
BossApiClient createApiClient() {
return new BossApiClient(this);
}
BossRealtimeClient createRealtimeClient(BossApiClient client) {
return new BossRealtimeClient(client, new BossRealtimeClient.Listener() {
@Override
public void onRealtimeEvent(BossRealtimeEvent event) {
handleRealtimeEvent(event);
@@ -154,10 +189,6 @@ public class MainActivity extends AppCompatActivity {
runOnUiThread(() -> handleRealtimeConnectionChanged(connected));
}
});
bindViews();
bindActions();
applyInitialTab(getIntent());
bootstrapSession();
}
@Override
@@ -171,32 +202,44 @@ public class MainActivity extends AppCompatActivity {
}
}
@Override
public void onBackPressed() {
private void configureBackNavigation() {
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
if (handleRootBackPressed()) {
return;
}
setEnabled(false);
getOnBackPressedDispatcher().onBackPressed();
}
});
}
private boolean handleRootBackPressed() {
if (contentPanel.getVisibility() == View.VISIBLE && conversationSearchMode) {
exitConversationSearchMode(true);
return;
return true;
}
if (contentPanel.getVisibility() == View.VISIBLE && conversationQuickActionsVisible) {
hideConversationQuickActions(true);
return;
return true;
}
if (contentPanel.getVisibility() == View.VISIBLE && !"conversations".equals(activeTab)) {
setActiveTab("conversations", false);
persistLastRootTab("conversations");
return;
return true;
}
if (contentPanel.getVisibility() == View.VISIBLE) {
long now = System.currentTimeMillis();
if (now - lastRootBackPressedAt < ROOT_BACK_EXIT_WINDOW_MS) {
moveTaskToBack(true);
return;
return true;
}
lastRootBackPressedAt = now;
showMessage("再按一次返回,应用进入后台");
return;
return true;
}
super.onBackPressed();
return false;
}
@Override
@@ -204,6 +247,7 @@ public class MainActivity extends AppCompatActivity {
cancelConversationAutoRefresh();
cancelRealtimeRefreshSchedule();
stopRealtimeUpdates();
sessionExecutor.shutdownNow();
executor.shutdownNow();
super.onDestroy();
}
@@ -214,6 +258,17 @@ public class MainActivity extends AppCompatActivity {
conversationAutoRefreshEnabled = true;
updateConversationAutoRefresh();
updateRealtimeSubscription();
maybeRequestNotificationPermission();
if (
contentPanel != null &&
contentPanel.getVisibility() == View.VISIBLE &&
"conversations".equals(activeTab) &&
apiClient != null &&
apiClient.hasSessionHints() &&
!rootTabRefreshInFlight
) {
refreshConversationsData();
}
}
@Override
@@ -232,7 +287,16 @@ public class MainActivity extends AppCompatActivity {
mainTopBar = findViewById(R.id.main_top_bar);
loginTitle = findViewById(R.id.login_title);
loginHint = findViewById(R.id.login_hint);
loginAccountInput = findViewById(R.id.login_account_input);
loginPasswordInput = findViewById(R.id.login_password_input);
loginConfirmPasswordInput = findViewById(R.id.login_confirm_password_input);
loginCodeInput = findViewById(R.id.login_code_input);
loginCodeRow = findViewById(R.id.login_code_row);
loginSendCodeButton = findViewById(R.id.login_send_code_button);
loginButton = findViewById(R.id.login_button);
loginModeButton = findViewById(R.id.login_mode_button);
registerModeButton = findViewById(R.id.register_mode_button);
forgotModeButton = findViewById(R.id.forgot_mode_button);
loginProgress = findViewById(R.id.login_progress);
backButton = findViewById(R.id.back_button);
topTitle = findViewById(R.id.top_title);
@@ -259,12 +323,17 @@ public class MainActivity extends AppCompatActivity {
loginTitle.setText(WechatSurfaceMapper.loginTitle());
loginHint.setText(WechatSurfaceMapper.loginHintText());
loginButton.setText(WechatSurfaceMapper.loginButtonLabel());
setAuthMode("login", WechatSurfaceMapper.loginHintText());
BossWindowInsets.applyStatusBarInset(loginShell);
BossWindowInsets.applyStatusBarInset(mainTopBar);
}
private void bindActions() {
loginButton.setOnClickListener(v -> performAutoLogin());
loginButton.setOnClickListener(v -> performPrimaryAuthAction());
loginSendCodeButton.setOnClickListener(v -> sendAuthVerificationCode());
loginModeButton.setOnClickListener(v -> setAuthMode("login", "请输入账号和密码登录。"));
registerModeButton.setOnClickListener(v -> setAuthMode("register", "注册后会自动登录并进入会话。"));
forgotModeButton.setOnClickListener(v -> setAuthMode("forgot", "通过验证码重置密码后再登录。"));
backButton.setVisibility(View.GONE);
backButton.setOnClickListener(v -> {
if (conversationSearchMode) {
@@ -348,7 +417,7 @@ public class MainActivity extends AppCompatActivity {
}
setLoginLoading(true, "正在恢复上次登录状态...");
executor.execute(() -> {
sessionExecutor.execute(() -> {
try {
BossApiClient.ApiResponse sessionResponse = apiClient.getSession();
if (!sessionResponse.ok()) {
@@ -365,15 +434,52 @@ public class MainActivity extends AppCompatActivity {
} catch (Exception ignored) {
// Fall back to login panel.
}
runOnUiThread(() -> setLoginLoading(false, WechatSurfaceMapper.loginHintText()));
runOnUiThread(() -> setLoginLoading(false, "登录已过期,请重新输入账号密码。"));
});
}
private void performAutoLogin() {
setLoginLoading(true, "正在创建会话...");
executor.execute(() -> {
private void performPrimaryAuthAction() {
String account = inputText(loginAccountInput);
String password = inputText(loginPasswordInput);
String confirmPassword = inputText(loginConfirmPasswordInput);
String code = inputText(loginCodeInput);
if (account.isEmpty()) {
setLoginLoading(false, "请先填写账号。");
return;
}
if (password.isEmpty()) {
setLoginLoading(false, "请先填写密码。");
return;
}
if (!"login".equals(authMode) && confirmPassword.isEmpty()) {
setLoginLoading(false, "请再次确认密码。");
return;
}
if (!"login".equals(authMode) && !password.equals(confirmPassword)) {
setLoginLoading(false, "两次输入的密码不一致。");
return;
}
if (!"login".equals(authMode) && code.isEmpty()) {
setLoginLoading(false, "请先填写验证码。");
return;
}
if ("register".equals(authMode)) {
performRegisterAndLogin(account, password, confirmPassword, code);
return;
}
if ("forgot".equals(authMode)) {
performPasswordReset(account, password, confirmPassword, code);
return;
}
performPasswordLogin(account, password);
}
private void performPasswordLogin(String account, String password) {
setLoginLoading(true, "正在登录...");
sessionExecutor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.autoLogin();
BossApiClient.ApiResponse response = apiClient.loginWithPassword(account, password);
if (response.ok()) {
JSONObject session = response.json.optJSONObject("session");
runOnUiThread(() -> {
@@ -389,6 +495,78 @@ public class MainActivity extends AppCompatActivity {
});
}
private void performRegisterAndLogin(String account, String password, String confirmPassword, String code) {
setLoginLoading(true, "正在注册...");
sessionExecutor.execute(() -> {
try {
BossApiClient.ApiResponse registerResponse = apiClient.registerAccount(
account,
password,
confirmPassword,
code
);
if (!registerResponse.ok()) {
runOnUiThread(() -> setLoginLoading(false, "注册失败:" + registerResponse.message()));
return;
}
BossApiClient.ApiResponse loginResponse = apiClient.loginWithPassword(account, password);
if (loginResponse.ok()) {
JSONObject session = loginResponse.json.optJSONObject("session");
runOnUiThread(() -> {
showContent();
refreshAllData(session);
});
return;
}
runOnUiThread(() -> {
setAuthMode("login", "注册成功,请用刚才的账号密码登录。");
setLoginLoading(false, "注册成功,请用刚才的账号密码登录。");
});
} catch (Exception error) {
runOnUiThread(() -> setLoginLoading(false, "注册链路异常:" + error.getMessage()));
}
});
}
private void performPasswordReset(String account, String password, String confirmPassword, String code) {
setLoginLoading(true, "正在重置密码...");
sessionExecutor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.resetPassword(account, password, confirmPassword, code);
if (response.ok()) {
runOnUiThread(() -> {
clearSecretInputs();
setAuthMode("login", "密码已重置,请使用新密码登录。");
});
return;
}
runOnUiThread(() -> setLoginLoading(false, "重置失败:" + response.message()));
} catch (Exception error) {
runOnUiThread(() -> setLoginLoading(false, "重置链路异常:" + error.getMessage()));
}
});
}
private void sendAuthVerificationCode() {
String account = inputText(loginAccountInput);
if (account.isEmpty()) {
setLoginLoading(false, "请先填写账号。");
return;
}
String purpose = "forgot".equals(authMode) ? "forgot-password" : "register";
setLoginLoading(true, "正在发送验证码...");
sessionExecutor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.sendVerificationCode(account, purpose);
runOnUiThread(() -> setLoginLoading(false, response.ok()
? "验证码已发送,请查看对应邮箱或短信。"
: "验证码发送失败:" + response.message()));
} catch (Exception error) {
runOnUiThread(() -> setLoginLoading(false, "验证码链路异常:" + error.getMessage()));
}
});
}
void refreshCurrentTab() {
if (rootTabRefreshInFlight) {
pendingRootTabRefresh = true;
@@ -413,9 +591,11 @@ public class MainActivity extends AppCompatActivity {
JSONObject session = ensureActiveSession();
BossApiClient.ApiResponse conversations = null;
boolean conversationsOk = false;
boolean usedGroupedHomeFeed = false;
try {
conversations = apiClient.getConversationHome();
conversationsOk = conversations.ok();
usedGroupedHomeFeed = conversationsOk;
} catch (Exception ignored) {
conversationsOk = false;
}
@@ -425,6 +605,7 @@ public class MainActivity extends AppCompatActivity {
if (fallbackConversations.ok()) {
conversations = fallbackConversations;
conversationsOk = true;
usedGroupedHomeFeed = false;
}
} catch (Exception ignored) {
conversationsOk = false;
@@ -433,18 +614,24 @@ public class MainActivity extends AppCompatActivity {
BossApiClient.ApiResponse finalConversations = conversations;
final boolean finalConversationsOk = conversationsOk;
final boolean finalUsedGroupedHomeFeed = usedGroupedHomeFeed;
runOnUiThread(() -> {
sessionData = session;
JSONArray refreshedConversations = finalConversations == null
? null
: WechatSurfaceMapper.normalizeConversationHomeFeed(
finalConversations.json.optJSONArray("conversations")
);
: finalUsedGroupedHomeFeed
? finalConversations.json.optJSONArray("conversations")
: WechatSurfaceMapper.normalizeConversationHomeFeed(
finalConversations.json.optJSONArray("conversations")
);
conversationsData = WechatSurfaceMapper.resolveRefreshValue(
conversationsData,
refreshedConversations,
finalConversationsOk
);
if (finalConversationsOk) {
conversationRootUsesGroupedHomeFeed = finalUsedGroupedHomeFeed;
}
maybeApplyPreferredEntry();
renderCurrentTab();
startRefreshing(false);
@@ -612,7 +799,11 @@ public class MainActivity extends AppCompatActivity {
return false;
}
JSONObject conversationItem = event.payload.optJSONObject("conversationItem");
if (conversationItem == null) {
JSONObject threadConversationItem = event.payload.optJSONObject("threadConversationItem");
JSONObject patchItem = conversationRootUsesGroupedHomeFeed
? (conversationItem != null ? conversationItem : threadConversationItem)
: (threadConversationItem != null ? threadConversationItem : conversationItem);
if (patchItem == null) {
return false;
}
runOnUiThread(() -> {
@@ -622,7 +813,7 @@ public class MainActivity extends AppCompatActivity {
}
conversationsData = WechatSurfaceMapper.mergeConversationHomeItem(
conversationsData,
conversationItem,
patchItem,
affectedProjectId
);
renderCurrentTab();
@@ -631,8 +822,11 @@ public class MainActivity extends AppCompatActivity {
}
private void scheduleRealtimeRefresh() {
realtimeRefreshScheduled = false;
refreshCurrentTab();
if (realtimeRefreshScheduled) {
return;
}
realtimeRefreshScheduled = true;
uiHandler.postDelayed(realtimeRefreshRunnable, REALTIME_REFRESH_DEBOUNCE_MS);
}
private void cancelRealtimeRefreshSchedule() {
@@ -729,6 +923,7 @@ public class MainActivity extends AppCompatActivity {
BossApiClient.ApiResponse ota = null;
BossApiClient.ApiResponse settings = null;
boolean conversationsOk = false;
boolean usedGroupedHomeFeed = false;
boolean devicesOk = false;
boolean otaOk = false;
boolean settingsOk = false;
@@ -736,6 +931,7 @@ public class MainActivity extends AppCompatActivity {
try {
conversations = apiClient.getConversationHome();
conversationsOk = conversations.ok();
usedGroupedHomeFeed = conversationsOk;
} catch (Exception ignored) {
conversationsOk = false;
}
@@ -745,6 +941,7 @@ public class MainActivity extends AppCompatActivity {
if (fallbackConversations.ok()) {
conversations = fallbackConversations;
conversationsOk = true;
usedGroupedHomeFeed = false;
}
} catch (Exception ignored) {
conversationsOk = false;
@@ -775,6 +972,7 @@ public class MainActivity extends AppCompatActivity {
BossApiClient.ApiResponse finalOta = ota;
BossApiClient.ApiResponse finalSettings = settings;
final boolean finalConversationsOk = conversationsOk;
final boolean finalUsedGroupedHomeFeed = usedGroupedHomeFeed;
final boolean finalDevicesOk = devicesOk;
final boolean finalOtaOk = otaOk;
final boolean finalSettingsOk = settingsOk;
@@ -782,14 +980,19 @@ public class MainActivity extends AppCompatActivity {
sessionData = finalSession;
JSONArray refreshedConversations = finalConversations == null
? null
: WechatSurfaceMapper.normalizeConversationHomeFeed(
finalConversations.json.optJSONArray("conversations")
);
: finalUsedGroupedHomeFeed
? finalConversations.json.optJSONArray("conversations")
: WechatSurfaceMapper.normalizeConversationHomeFeed(
finalConversations.json.optJSONArray("conversations")
);
conversationsData = WechatSurfaceMapper.resolveRefreshValue(
conversationsData,
refreshedConversations,
finalConversationsOk
);
if (finalConversationsOk) {
conversationRootUsesGroupedHomeFeed = finalUsedGroupedHomeFeed;
}
devicesData = WechatSurfaceMapper.resolveRefreshValue(
devicesData,
finalDevices == null ? null : finalDevices.json.optJSONArray("devices"),
@@ -865,6 +1068,7 @@ public class MainActivity extends AppCompatActivity {
private void showLogin(String hint) {
loginPanel.setVisibility(View.VISIBLE);
contentPanel.setVisibility(View.GONE);
setAuthMode("login", hint);
setLoginLoading(false, hint);
stopRealtimeUpdates();
}
@@ -875,15 +1079,76 @@ public class MainActivity extends AppCompatActivity {
setActiveTab(activeTab, false);
updateConversationAutoRefresh();
updateRealtimeSubscription();
scheduleNotificationPermissionRequest();
}
private void setLoginLoading(boolean loading, String hint) {
loginProgress.setVisibility(loading ? View.VISIBLE : View.GONE);
loginButton.setEnabled(!loading);
loginButton.setText(loading ? "处理中..." : WechatSurfaceMapper.loginButtonLabel());
loginSendCodeButton.setEnabled(!loading);
loginModeButton.setEnabled(!loading);
registerModeButton.setEnabled(!loading);
forgotModeButton.setEnabled(!loading);
loginButton.setText(loading ? "处理中..." : primaryAuthButtonLabel());
loginHint.setText(hint);
}
private void setAuthMode(String mode, String hint) {
authMode = ("register".equals(mode) || "forgot".equals(mode)) ? mode : "login";
boolean codeMode = !"login".equals(authMode);
loginTitle.setText(authTitle());
loginButton.setText(primaryAuthButtonLabel());
loginPasswordInput.setHint("forgot".equals(authMode) ? "新密码" : "密码");
loginConfirmPasswordInput.setVisibility(codeMode ? View.VISIBLE : View.GONE);
loginCodeRow.setVisibility(codeMode ? View.VISIBLE : View.GONE);
loginHint.setText(hint);
tintAuthModeButtons();
}
private String authTitle() {
if ("register".equals(authMode)) {
return "注册账号";
}
if ("forgot".equals(authMode)) {
return "找回密码";
}
return "登录 Boss";
}
private String primaryAuthButtonLabel() {
if ("register".equals(authMode)) {
return "注册并登录";
}
if ("forgot".equals(authMode)) {
return "重置密码";
}
return "登录";
}
private void tintAuthModeButtons() {
int selectedColor = getColor(R.color.boss_green);
int mutedColor = getColor(R.color.boss_text_muted);
loginModeButton.setTextColor("login".equals(authMode) ? selectedColor : mutedColor);
registerModeButton.setTextColor("register".equals(authMode) ? selectedColor : mutedColor);
forgotModeButton.setTextColor("forgot".equals(authMode) ? selectedColor : mutedColor);
}
private String inputText(EditText input) {
return input == null || input.getText() == null ? "" : input.getText().toString().trim();
}
private void clearSecretInputs() {
if (loginPasswordInput != null) {
loginPasswordInput.setText("");
}
if (loginConfirmPasswordInput != null) {
loginConfirmPasswordInput.setText("");
}
if (loginCodeInput != null) {
loginCodeInput.setText("");
}
}
private void setActiveTab(String tab, boolean fromUser) {
if (!"conversations".equals(tab)) {
exitConversationSelectionMode();
@@ -942,14 +1207,22 @@ public class MainActivity extends AppCompatActivity {
}
private void updateTabStyles() {
styleTab(tabConversations, "conversations".equals(activeTab));
styleTab(tabDevices, "devices".equals(activeTab));
styleTab(tabMe, "me".equals(activeTab));
styleTab(tabConversations, "conversations".equals(activeTab), R.drawable.ic_boss_tab_chat);
styleTab(tabDevices, "devices".equals(activeTab), R.drawable.ic_boss_tab_devices);
styleTab(tabMe, "me".equals(activeTab), R.drawable.ic_boss_tab_me);
}
private void styleTab(Button button, boolean active) {
button.setBackgroundResource(active ? R.drawable.bg_tab_active : R.drawable.bg_tab_inactive);
button.setTextColor(getColor(active ? R.color.boss_green : R.color.boss_text_muted));
private void styleTab(Button button, boolean active, int iconRes) {
int color = getColor(active ? R.color.boss_green : R.color.boss_text_muted);
button.setBackgroundColor(Color.TRANSPARENT);
button.setTextColor(color);
button.setTextSize(12);
button.setAllCaps(false);
button.setGravity(android.view.Gravity.CENTER);
button.setCompoundDrawablesWithIntrinsicBounds(0, iconRes, 0, 0);
button.setCompoundDrawablePadding(BossUi.dp(this, 3));
button.setCompoundDrawableTintList(ColorStateList.valueOf(color));
button.setPadding(0, BossUi.dp(this, 5), 0, BossUi.dp(this, 3));
}
private void configureTopAction(WechatSurfaceMapper.RootTopAction action) {
@@ -970,7 +1243,7 @@ public class MainActivity extends AppCompatActivity {
topSearchInput.setVisibility(View.GONE);
backButton.setVisibility(View.GONE);
searchButton.setVisibility(View.GONE);
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, false);
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, false, false, currentSessionRole());
refreshButton.setVisibility(View.VISIBLE);
configureTopAction(action);
}
@@ -1005,7 +1278,7 @@ public class MainActivity extends AppCompatActivity {
refreshButton.setEnabled(true);
return;
}
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing, conversationSelectionMode);
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing, conversationSelectionMode, currentSessionRole());
configureTopAction(action);
refreshButton.setEnabled(!"refresh".equals(action.actionKey) || !refreshing);
refreshButton.setAlpha(refreshing && "refresh".equals(action.actionKey) ? 0.45f : 1f);
@@ -1036,7 +1309,7 @@ public class MainActivity extends AppCompatActivity {
toggleConversationQuickActions();
return;
}
String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode).actionKey;
String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode, currentSessionRole()).actionKey;
if ("add_device".equals(actionKey)) {
startActivity(new Intent(this, DeviceEnrollmentActivity.class));
return;
@@ -1181,11 +1454,19 @@ public class MainActivity extends AppCompatActivity {
String matchedProjectId = item.optString("searchMatchProjectId", "").trim();
String matchedProjectLabel = item.optString("searchMatchLabel", "").trim();
if (!matchedProjectId.isEmpty() && !matchedProjectLabel.isEmpty()) {
exitConversationSearchMode(true);
openProject(matchedProjectId, matchedProjectLabel);
exitConversationSearchMode(true);
return;
}
openConversationFolder(
folderKey,
resolveConversationFolderName(item, row),
item.optString("searchMatchProjectId", ""),
item.optJSONArray("searchMatchProjectIds"),
item.optString("searchMatchLabel", "")
);
exitConversationSearchMode(true);
return;
}
openConversationFolder(
folderKey,
@@ -1543,6 +1824,7 @@ public class MainActivity extends AppCompatActivity {
}
private void prepareConversationQuickActionMenu() {
quickActionAddDevice.setVisibility("highest_admin".equals(currentSessionRole()) ? View.VISIBLE : View.GONE);
conversationQuickActionsMenu.setVisibility(View.VISIBLE);
conversationQuickActionsMenu.setAlpha(0f);
conversationQuickActionsMenu.setTranslationY(-BossUi.dp(this, 6));
@@ -1553,6 +1835,7 @@ public class MainActivity extends AppCompatActivity {
conversationQuickActionsMenu.setAlpha(0f);
conversationQuickActionsMenu.setTranslationY(-BossUi.dp(this, 6));
conversationQuickActionsMenu.setVisibility(View.GONE);
quickActionAddDevice.setVisibility(View.VISIBLE);
}
static boolean matchesConversationQuery(JSONObject item, String rawQuery) {
@@ -1706,7 +1989,7 @@ public class MainActivity extends AppCompatActivity {
(roleLabel.isEmpty() ? "主控账号已启用安全保护" : roleLabel + " · 主控账号已启用安全保护")
));
for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItems()) {
for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItemsForRole(currentSessionRole())) {
screenContent.addView(BossUi.buildWechatMenuRow(
this,
item.title,
@@ -1856,6 +2139,33 @@ public class MainActivity extends AppCompatActivity {
}
}
private void maybeRequestNotificationPermission() {
notificationPermissionRequestScheduled = false;
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
return;
}
if (contentPanel == null || contentPanel.getVisibility() != View.VISIBLE) {
return;
}
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
return;
}
android.content.SharedPreferences prefs = getSharedPreferences(UI_PREFS, Context.MODE_PRIVATE);
if (prefs.getBoolean(KEY_NOTIFICATION_PERMISSION_REQUESTED, false)) {
return;
}
prefs.edit().putBoolean(KEY_NOTIFICATION_PERMISSION_REQUESTED, true).apply();
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, REQUEST_POST_NOTIFICATIONS);
}
private void scheduleNotificationPermissionRequest() {
if (notificationPermissionRequestScheduled) {
return;
}
notificationPermissionRequestScheduled = true;
uiHandler.postDelayed(this::maybeRequestNotificationPermission, 450L);
}
void handleRealtimeConnectionChanged(boolean connected) {
if (!connected
&& shouldMaintainConversationAutoRefresh()
@@ -1868,14 +2178,27 @@ public class MainActivity extends AppCompatActivity {
}
private void openMeEntry(String key) {
if (!WechatSurfaceMapper.canOpenMeEntryForRole(key, currentSessionRole())) {
showMessage("当前账号没有权限打开这个入口。");
return;
}
Intent intent;
switch (key) {
case "security":
intent = new Intent(this, SecurityActivity.class);
break;
case "access":
intent = new Intent(this, AccessManagementActivity.class);
break;
case "ai_accounts":
intent = new Intent(this, AiAccountsActivity.class);
break;
case "storage":
intent = new Intent(this, StorageSettingsActivity.class);
break;
case "telegram":
intent = new Intent(this, TelegramIntegrationActivity.class);
break;
case "settings":
intent = new Intent(this, SettingsActivity.class);
break;
@@ -1895,6 +2218,13 @@ public class MainActivity extends AppCompatActivity {
startActivity(intent);
}
private String currentSessionRole() {
if (sessionData == null) {
return "member";
}
return sessionData.optString("role", "member");
}
private void openSkillInventoryFromMe() {
String targetDeviceId = resolveSkillTargetDeviceId();
if (targetDeviceId == null || targetDeviceId.isEmpty()) {

View File

@@ -14,6 +14,30 @@ import java.util.Set;
public final class ProjectChatUiState {
private ProjectChatUiState() {}
public static final class MessageDisplayItem {
public static final String TYPE_MESSAGE = "message";
public static final String TYPE_PROCESS_GROUP = "process_group";
public final String type;
@Nullable
public final JSONObject message;
public final List<JSONObject> processMessages;
private MessageDisplayItem(String type, @Nullable JSONObject message, List<JSONObject> processMessages) {
this.type = type;
this.message = message;
this.processMessages = Collections.unmodifiableList(new ArrayList<>(processMessages));
}
private static MessageDisplayItem message(JSONObject message) {
return new MessageDisplayItem(TYPE_MESSAGE, message, Collections.emptyList());
}
private static MessageDisplayItem processGroup(List<JSONObject> processMessages) {
return new MessageDisplayItem(TYPE_PROCESS_GROUP, null, processMessages);
}
}
public static final class SelectionState {
public final boolean multiSelecting;
public final Set<String> selectedMessageIds;
@@ -31,6 +55,7 @@ public final class ProjectChatUiState {
public final boolean showMultiSelectBar;
public final boolean showRefresh;
public final boolean showHeaderAction;
public final boolean copyEnabled;
public final boolean forwardEnabled;
public final String backLabel;
public final String title;
@@ -42,6 +67,7 @@ public final class ProjectChatUiState {
boolean showMultiSelectBar,
boolean showRefresh,
boolean showHeaderAction,
boolean copyEnabled,
boolean forwardEnabled,
String backLabel,
String title,
@@ -52,6 +78,7 @@ public final class ProjectChatUiState {
this.showMultiSelectBar = showMultiSelectBar;
this.showRefresh = showRefresh;
this.showHeaderAction = showHeaderAction;
this.copyEnabled = copyEnabled;
this.forwardEnabled = forwardEnabled;
this.backLabel = backLabel;
this.title = title;
@@ -81,6 +108,77 @@ public final class ProjectChatUiState {
return nearBottom || forced;
}
public static List<MessageDisplayItem> buildMessageDisplayItems(@Nullable JSONArray messages) {
ArrayList<MessageDisplayItem> items = new ArrayList<>();
if (messages == null || messages.length() == 0) {
return items;
}
ArrayList<JSONObject> pendingProcessMessages = new ArrayList<>();
for (int i = 0; i < messages.length(); i++) {
JSONObject message = messages.optJSONObject(i);
if (message == null) {
continue;
}
if (isThreadProcessMessage(message)) {
pendingProcessMessages.add(message);
continue;
}
flushProcessGroup(items, pendingProcessMessages);
items.add(MessageDisplayItem.message(message));
}
flushProcessGroup(items, pendingProcessMessages);
return items;
}
public static boolean hasThreadProcessFoldCandidates(@Nullable JSONArray messages, int startIndex) {
if (messages == null || messages.length() == 0) {
return false;
}
int firstIndex = Math.max(0, startIndex);
for (int i = firstIndex; i < messages.length(); i++) {
JSONObject message = messages.optJSONObject(i);
if (message != null && isThreadProcessMessage(message)) {
return true;
}
}
return false;
}
public static String processGroupPreview(@Nullable MessageDisplayItem item) {
if (item == null || item.processMessages.isEmpty()) {
return "";
}
JSONObject latestMessage = item.processMessages.get(item.processMessages.size() - 1);
return truncate(latestMessage.optString("body", ""), 52);
}
public static String processGroupDetail(@Nullable MessageDisplayItem item) {
if (item == null || item.processMessages.isEmpty()) {
return "";
}
StringBuilder builder = new StringBuilder();
for (int i = 0; i < item.processMessages.size(); i++) {
JSONObject message = item.processMessages.get(i);
String body = compactBody(message.optString("body", ""));
if (body.isEmpty()) {
continue;
}
if (builder.length() > 0) {
builder.append("\n\n");
}
builder.append(i + 1).append(". ").append(body);
}
return builder.toString();
}
private static void flushProcessGroup(List<MessageDisplayItem> items, List<JSONObject> pendingProcessMessages) {
if (pendingProcessMessages.isEmpty()) {
return;
}
items.add(MessageDisplayItem.processGroup(pendingProcessMessages));
pendingProcessMessages.clear();
}
public static String threadExecutionConflictTitle(@Nullable JSONObject conflict) {
if (conflict == null) {
return "当前线程命中冲突保护";
@@ -149,6 +247,10 @@ public final class ProjectChatUiState {
return state != null && state.multiSelecting && state.selectedMessageIds.size() >= 2;
}
public static boolean canCopySelection(@Nullable SelectionState state) {
return state != null && state.multiSelecting && !state.selectedMessageIds.isEmpty();
}
public static SelectionState reconcileSelection(
@Nullable SelectionState current,
@Nullable List<String> availableMessageIds
@@ -181,6 +283,7 @@ public final class ProjectChatUiState {
true,
false,
false,
canCopySelection(selectionState),
canForwardSelection(selectionState),
"取消",
"已选 " + selectedCount + "",
@@ -194,6 +297,7 @@ public final class ProjectChatUiState {
!conversationInfoReady,
conversationInfoReady,
false,
false,
"返回",
isBlank(defaultTitle) ? "项目详情" : defaultTitle,
isBlank(defaultSubtitle) ? "原生页面" : defaultSubtitle
@@ -420,6 +524,13 @@ public final class ProjectChatUiState {
if ("completed".equals(taskStatus) || "failed".equals(taskStatus)) {
return new ReplyWaitSpec(false, null);
}
JSONObject replyMessage = response.optJSONObject("replyMessage");
if (replyMessage != null) {
String replyMessageId = replyMessage.optString("id", "").trim();
if (!replyMessageId.isEmpty()) {
return new ReplyWaitSpec(true, replyMessageId);
}
}
JSONObject message = response.optJSONObject("message");
return new ReplyWaitSpec(true, message == null ? null : message.optString("id", ""));
}
@@ -444,6 +555,14 @@ public final class ProjectChatUiState {
return !isBlank(latestMessageId) && !baselineMessageId.trim().equals(latestMessageId);
}
public static boolean shouldAutoRefreshConversation(
boolean shouldMaintainAutoRefresh,
boolean realtimeConnected,
boolean trackedMasterReplyTimedOut
) {
return shouldMaintainAutoRefresh && (!realtimeConnected || trackedMasterReplyTimedOut);
}
@Nullable
public static String latestMessageId(@Nullable JSONArray messages) {
if (messages == null || messages.length() == 0) {
@@ -457,10 +576,110 @@ public final class ProjectChatUiState {
return messageId.isEmpty() ? null : messageId;
}
private static boolean isThreadProcessMessage(@Nullable JSONObject message) {
if (message == null) {
return false;
}
String kind = message.optString("kind", "").trim();
if ("thread_process".equals(kind)) {
return true;
}
if (!isBlank(kind)
&& !"text".equals(kind)
&& !"conversation_reply".equals(kind)
&& !"thread_reply".equals(kind)) {
return false;
}
String sender = message.optString("sender", "").trim().toLowerCase(java.util.Locale.ROOT);
String senderLabel = message.optString("senderLabel", "").trim();
if ("user".equals(sender)
|| "master".equals(sender)
|| "ops".equals(sender)
|| "audit".equals(sender)
|| senderLabel.contains("主 Agent")
|| senderLabel.contains("审计")
|| senderLabel.contains("")) {
return false;
}
String body = compactBody(message.optString("body", ""));
if (body.isEmpty()) {
return false;
}
if (isStructuredNumberedProcessBody(body)) {
return true;
}
if (containsAny(body, FOLD_BLOCK_MARKERS)) {
return false;
}
return hasProcessProgressMarker(body);
}
private static boolean isBlank(@Nullable String value) {
return value == null || value.trim().isEmpty();
}
private static String compactBody(@Nullable String value) {
if (value == null) {
return "";
}
return value
.replace("\r\n", "\n")
.replace('\r', '\n')
.replaceAll("\\n{2,}", "\n")
.trim();
}
private static boolean containsAny(String body, String[] markers) {
String normalizedBody = body.toLowerCase(java.util.Locale.ROOT);
for (String marker : markers) {
if (normalizedBody.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
return true;
}
}
return false;
}
private static boolean isStructuredNumberedProcessBody(String body) {
String[] rawLines = body
.replace("\r\n", "\n")
.replace('\r', '\n')
.split("\n");
ArrayList<String> numberedLines = new ArrayList<>();
for (String rawLine : rawLines) {
String normalizedLine = compactBody(rawLine);
if (normalizedLine.isEmpty()) {
continue;
}
if (normalizedLine.matches("^\\d+[.)、]\\s*.+$")) {
numberedLines.add(normalizedLine);
}
}
if (numberedLines.size() < 2) {
return false;
}
String merged = android.text.TextUtils.join(" ", numberedLines)
.toLowerCase(java.util.Locale.ROOT);
return containsAny(merged, PROCESS_PROGRESS_NUMBERED_HINTS);
}
private static boolean hasProcessProgressMarker(String body) {
String normalizedBody = body.trim().toLowerCase(java.util.Locale.ROOT);
if (isStructuredNumberedProcessBody(body)) {
return true;
}
for (String marker : PROCESS_PROGRESS_PREFIXES) {
if (normalizedBody.startsWith(marker.toLowerCase(java.util.Locale.ROOT))) {
return true;
}
}
for (String marker : PROCESS_PROGRESS_CONTAINS) {
if (normalizedBody.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
return true;
}
}
return false;
}
private static String truncate(@Nullable String value, int maxLength) {
String normalized = value == null ? "" : value.trim();
if (normalized.length() <= maxLength) {
@@ -468,4 +687,83 @@ public final class ProjectChatUiState {
}
return normalized.substring(0, maxLength) + "";
}
private static final String[] PROCESS_PROGRESS_PREFIXES = new String[] {
"我先",
"我现在",
"我会先",
"我发现",
"我准备",
"接下来",
"正在",
"先看",
"先读",
"我把",
"我再",
"目前在",
"现在在",
"补一组",
"处理一下",
"先确认",
"准备",
"同步一下",
"我这边已经"
};
private static final String[] PROCESS_PROGRESS_CONTAINS = new String[] {
"我继续",
"我已经在",
"正在跑",
"正在检查",
"正在处理",
"正在同步",
"我会直接",
"我先把",
"先补",
"再接"
};
private static final String[] PROCESS_PROGRESS_NUMBERED_HINTS = new String[] {
"",
"",
"接下来",
"然后",
"检查",
"确认",
"处理",
"同步",
"",
"排查",
"推进",
"回你",
"回传",
"会把",
"我会"
};
private static final String[] FOLD_BLOCK_MARKERS = new String[] {
"失败",
"报错",
"错误",
"阻塞",
"不能",
"无法",
"崩溃",
"超时",
"exception",
"error",
"fatal",
"结论",
"最终",
"总结",
"已完成",
"已经完成",
"验证通过",
"测试通过",
"已修复",
"修好了",
"已部署",
"已安装",
"可以直接"
};
}

View File

@@ -5,6 +5,7 @@ import android.os.Bundle;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
public class SecurityActivity extends BossScreenActivity {
@@ -22,7 +23,11 @@ public class SecurityActivity extends BossScreenActivity {
try {
BossApiClient.ApiResponse response = apiClient.getSession();
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> renderSecurity(response.json.optJSONObject("session")));
BossApiClient.ApiResponse sessionsResponse = apiClient.getAuthSessions();
JSONArray sessions = sessionsResponse.ok()
? sessionsResponse.json.optJSONArray("sessions")
: new JSONArray();
runOnUiThread(() -> renderSecurity(response.json.optJSONObject("session"), sessions));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
@@ -32,7 +37,7 @@ public class SecurityActivity extends BossScreenActivity {
});
}
private void renderSecurity(@Nullable JSONObject session) {
private void renderSecurity(@Nullable JSONObject session, @Nullable JSONArray sessions) {
replaceContent();
appendContent(BossUi.buildWechatMenuRow(
this,
@@ -55,6 +60,33 @@ public class SecurityActivity extends BossScreenActivity {
));
}
appendContent(BossUi.buildWechatMenuRow(
this,
"登录会话",
"当前可管理 " + (sessions == null ? 0 : sessions.length()) + " 个登录端",
"点击非当前会话可撤销;撤销当前会话会回到登录页。",
null,
null
));
if (sessions != null) {
for (int index = 0; index < sessions.length(); index += 1) {
JSONObject item = sessions.optJSONObject(index);
if (item == null) {
continue;
}
appendContent(BossUi.buildWechatMenuRow(
this,
buildSessionTitle(item),
item.optString("account", "-")
+ " · " + BossUi.formatRoleLabel(item.optString("role", "-")),
"最近 " + item.optString("lastSeenAt", "-")
+ " · 到期 " + item.optString("expiresAt", "-"),
item.optBoolean("current", false) ? "当前" : null,
v -> confirmRevokeSession(item.optString("sessionId", ""), item.optBoolean("current", false))
));
}
}
appendContent(BossUi.buildMenuRow(this, "打开设备页", "查看已绑定设备与状态", null, v -> {
Intent intent = new Intent(this, MainActivity.class);
intent.putExtra(MainActivity.EXTRA_INITIAL_TAB, "devices");
@@ -68,6 +100,56 @@ public class SecurityActivity extends BossScreenActivity {
setRefreshing(false);
}
private String buildSessionTitle(JSONObject session) {
String method = "code".equals(session.optString("loginMethod", "password")) ? "验证码登录" : "账号密码登录";
String name = session.optString("displayName", session.optString("account", "登录端"));
return name + " · " + method;
}
private void confirmRevokeSession(String sessionId, boolean current) {
if (sessionId == null || sessionId.isEmpty()) {
showMessage("会话 ID 缺失,无法撤销。");
return;
}
new androidx.appcompat.app.AlertDialog.Builder(this)
.setTitle(current ? "退出当前会话" : "撤销登录会话")
.setMessage(current ? "撤销当前会话后需要重新登录。" : "只撤销这一端的登录态,不影响其他会话。")
.setNegativeButton("取消", null)
.setPositiveButton(current ? "退出" : "撤销", (dialog, which) -> revokeSession(sessionId, current))
.show();
}
private void revokeSession(String sessionId, boolean current) {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.revokeAuthSession(sessionId);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
if (current) {
apiClient.logout();
}
runOnUiThread(() -> {
showMessage("会话已撤销");
if (current) {
Intent intent = new Intent(this, MainActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
finish();
} else {
reload();
}
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("撤销失败:" + error.getMessage());
});
}
});
}
private void logout() {
setRefreshing(true);
executor.execute(() -> {

View File

@@ -0,0 +1,207 @@
package com.hyzq.boss;
import android.os.Bundle;
import android.text.InputType;
import android.text.TextUtils;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import org.json.JSONObject;
public class StorageSettingsActivity extends BossScreenActivity {
private String storageMode = "server_file";
private boolean configLoaded;
private LinearLayout ossForm;
private Button serverModeButton;
private Button ossModeButton;
private EditText accessKeyIdField;
private EditText accessKeySecretField;
private EditText bucketField;
private EditText endpointField;
private EditText regionField;
private EditText prefixField;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("附件与存储", "附件上传位置与 OSS 配置");
setHeaderAction("保存", v -> saveConfig(false));
buildFormContent();
updateSaveAvailability();
reload();
}
@Override
protected void reload() {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getAttachmentStorageConfig();
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> populate(response.json.optJSONObject("config")));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
configLoaded = false;
updateSaveAvailability();
replaceContent(BossUi.buildEmptyCard(this, "附件与存储加载失败:" + error.getMessage()));
});
}
});
}
private void buildFormContent() {
serverModeButton = BossUi.buildMiniActionButton(this, "服务器文件存储", true);
ossModeButton = BossUi.buildMiniActionButton(this, "阿里 OSS", false);
serverModeButton.setOnClickListener(v -> switchMode("server_file"));
ossModeButton.setOnClickListener(v -> switchMode("oss"));
accessKeyIdField = buildTextField("AccessKey ID");
accessKeySecretField = buildTextField("AccessKey Secret");
accessKeySecretField.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
bucketField = buildTextField("Bucket");
endpointField = buildTextField("Endpoint例如 oss-cn-hangzhou.aliyuncs.com");
regionField = buildTextField("Region例如 oss-cn-hangzhou");
prefixField = buildTextField("Prefix例如 boss/");
ossForm = new LinearLayout(this);
ossForm.setOrientation(LinearLayout.VERTICAL);
ossForm.addView(BossUi.buildFormCell(this, "AccessKey ID", "阿里 OSS AccessKey ID", accessKeyIdField));
ossForm.addView(BossUi.buildFormCell(this, "AccessKey Secret", "不会回显;留空表示沿用已保存密钥", accessKeySecretField));
ossForm.addView(BossUi.buildFormCell(this, "Bucket", "附件所在 Bucket", bucketField));
ossForm.addView(BossUi.buildFormCell(this, "Endpoint", "OSS Endpoint不需要填写 https://", endpointField));
ossForm.addView(BossUi.buildFormCell(this, "Region", "Bucket 所在地域", regionField));
ossForm.addView(BossUi.buildFormCell(this, "Prefix", "可选,默认 boss/", prefixField));
Button validateButton = BossUi.buildMiniActionButton(this, "测试并保存", true);
validateButton.setOnClickListener(v -> saveConfig(true));
replaceContent(
BossUi.buildWechatMenuRow(
this,
"当前使用方式",
"服务器文件存储适合内测OSS 适合长期附件归档。",
"切换后点击保存生效",
null,
null
),
BossUi.buildInlineActionRow(this, serverModeButton, ossModeButton),
ossForm,
BossUi.buildInlineActionRow(this, validateButton)
);
updateModeUi();
}
private EditText buildTextField(String hint) {
EditText field = new EditText(this);
field.setSingleLine(true);
field.setHint(hint);
field.setTextSize(14);
field.setInputType(InputType.TYPE_CLASS_TEXT);
return field;
}
private void populate(@Nullable JSONObject config) {
buildFormContent();
if (config != null) {
storageMode = config.optString("mode", "server_file");
JSONObject aliyunOss = config.optJSONObject("aliyunOss");
if (aliyunOss != null) {
accessKeyIdField.setText(aliyunOss.optString("accessKeyId", ""));
bucketField.setText(aliyunOss.optString("bucket", ""));
endpointField.setText(aliyunOss.optString("endpoint", ""));
regionField.setText(aliyunOss.optString("region", ""));
prefixField.setText(aliyunOss.optString("prefix", "boss/"));
}
}
configLoaded = config != null;
updateModeUi();
updateSaveAvailability();
setRefreshing(false);
}
private void switchMode(String mode) {
storageMode = mode;
updateModeUi();
}
private void updateModeUi() {
boolean oss = "oss".equals(storageMode);
if (serverModeButton != null) {
serverModeButton.setText(oss ? "服务器文件存储" : "已选 服务器文件存储");
}
if (ossModeButton != null) {
ossModeButton.setText(oss ? "已选 阿里 OSS" : "阿里 OSS");
}
if (ossForm != null) {
ossForm.setVisibility(oss ? android.view.View.VISIBLE : android.view.View.GONE);
}
}
private void saveConfig(boolean validateFirst) {
if (!configLoaded) {
showMessage("配置尚未加载完成,请先刷新成功后再保存。");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
JSONObject payload = buildPayload();
BossApiClient.ApiResponse response = validateFirst && "oss".equals(storageMode)
? apiClient.validateAttachmentStorageConfig(payload)
: apiClient.saveAttachmentStorageConfig(payload);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
setRefreshing(false);
showMessage(validateFirst && "oss".equals(storageMode) ? "测试通过,配置已保存" : "附件存储配置已保存");
populate(response.json.optJSONObject("config"));
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("保存失败:" + error.getMessage());
});
}
});
}
private JSONObject buildPayload() throws org.json.JSONException {
JSONObject payload = new JSONObject();
payload.put("mode", storageMode);
if (!"oss".equals(storageMode)) {
return payload;
}
JSONObject aliyunOss = new JSONObject();
aliyunOss.put("accessKeyId", textOf(accessKeyIdField));
aliyunOss.put("bucket", textOf(bucketField));
aliyunOss.put("endpoint", textOf(endpointField));
aliyunOss.put("region", textOf(regionField));
aliyunOss.put("prefix", textOf(prefixField));
String secret = textOf(accessKeySecretField);
if (!TextUtils.isEmpty(secret)) {
aliyunOss.put("accessKeySecret", secret);
}
payload.put("ossProvider", "aliyun_oss");
payload.put("aliyunOss", aliyunOss);
return payload;
}
private String textOf(EditText field) {
return field == null || field.getText() == null ? "" : field.getText().toString().trim();
}
private void updateSaveAvailability() {
if (headerActionButton != null) {
headerActionButton.setEnabled(configLoaded);
headerActionButton.setAlpha(configLoaded ? 1f : 0.45f);
}
}
}

View File

@@ -0,0 +1,388 @@
package com.hyzq.boss;
import android.os.Bundle;
import android.text.InputType;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.Spinner;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.SwitchCompat;
import org.json.JSONObject;
public class TelegramIntegrationActivity extends BossScreenActivity {
private SwitchCompat enabledSwitch;
private Spinner modeSpinner;
private Spinner dmPolicySpinner;
private Spinner groupPolicySpinner;
private SwitchCompat requireMentionSwitch;
private EditText botTokenInput;
private EditText webhookSecretInput;
private EditText webhookUrlInput;
private EditText defaultProjectIdInput;
private EditText allowFromInput;
private EditText groupsInput;
private EditText groupProjectRoutesInput;
@Nullable private JSONObject currentTelegram;
private boolean telegramLoaded = false;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("Telegram 接入", "Bot 网关与白名单");
setHeaderAction("保存", v -> saveTelegram(false));
buildFormContent();
updateActionAvailability();
reload();
}
@Override
protected void reload() {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getTelegramIntegration();
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> populate(response.json.optJSONObject("telegram")));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
telegramLoaded = false;
updateActionAvailability();
replaceContent(BossUi.buildEmptyCard(this, "Telegram 配置加载失败:" + error.getMessage()));
});
}
});
}
private void buildFormContent() {
if (enabledSwitch == null) {
enabledSwitch = new SwitchCompat(this);
enabledSwitch.setText("开启 Telegram 接入");
}
if (requireMentionSwitch == null) {
requireMentionSwitch = new SwitchCompat(this);
requireMentionSwitch.setText("群聊要求 @Bot 或回复 Bot");
}
if (modeSpinner == null) {
modeSpinner = new Spinner(this);
modeSpinner.setAdapter(new ArrayAdapter<>(
this,
android.R.layout.simple_spinner_dropdown_item,
new String[]{"webhook", "polling"}
));
}
if (dmPolicySpinner == null) {
dmPolicySpinner = new Spinner(this);
dmPolicySpinner.setAdapter(new ArrayAdapter<>(
this,
android.R.layout.simple_spinner_dropdown_item,
new String[]{"allowlist", "open", "disabled"}
));
}
if (groupPolicySpinner == null) {
groupPolicySpinner = new Spinner(this);
groupPolicySpinner.setAdapter(new ArrayAdapter<>(
this,
android.R.layout.simple_spinner_dropdown_item,
new String[]{"allowlist", "open", "disabled"}
));
}
if (botTokenInput == null) {
botTokenInput = BossUi.buildInput(this, "输入 Telegram Bot Token", false);
botTokenInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
}
if (webhookSecretInput == null) {
webhookSecretInput = BossUi.buildInput(this, "留空则沿用当前 secret", false);
webhookSecretInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
}
if (webhookUrlInput == null) {
webhookUrlInput = BossUi.buildInput(this, "例如 https://boss.hyzq.net/api/v1/integrations/telegram/webhook", false);
}
if (defaultProjectIdInput == null) {
defaultProjectIdInput = BossUi.buildInput(this, "默认 master-agent", false);
}
if (allowFromInput == null) {
allowFromInput = BossUi.buildInput(this, "每行一个 Telegram 用户 ID", true);
}
if (groupsInput == null) {
groupsInput = BossUi.buildInput(this, "每行一个 Telegram 群 chat id", true);
}
if (groupProjectRoutesInput == null) {
groupProjectRoutesInput = BossUi.buildInput(this, "chatId[#topicId] projectId 可选备注", true);
}
replaceContent(buildStatusRow(currentTelegram));
appendContent(BossUi.buildWechatMenuRow(
this,
"Telegram Bot 网关",
"主 Agent 可通过 Telegram 私聊或受控群聊接收消息。",
"保存 webhook 模式后会自动同步 Telegram Webhook",
null,
null
));
appendContent(BossUi.buildWechatSwitchRow(this, "开启接入", "关闭后 Boss 不再接收 Telegram 消息", enabledSwitch));
appendContent(BossUi.buildFormCell(this, "接入模式", "Webhook 推荐用于正式运行Polling 仅作兜底。", modeSpinner));
appendContent(BossUi.buildFormCell(this, "Bot Token", "留空表示沿用当前已保存 token不会主动清空。", botTokenInput));
appendContent(BossUi.buildFormCell(this, "Webhook Secret", "Telegram webhook secret建议启用。", webhookSecretInput));
appendContent(BossUi.buildFormCell(this, "Webhook URL", "Webhook 模式下使用的公开地址。", webhookUrlInput));
appendContent(BossUi.buildFormCell(this, "默认项目", "当前默认路由到 master-agent。", defaultProjectIdInput));
appendContent(BossUi.buildFormCell(this, "私聊策略", "allowlist 更安全。", dmPolicySpinner));
appendContent(BossUi.buildFormCell(this, "允许私聊用户 ID", "每行一个 Telegram 用户 ID。", allowFromInput));
appendContent(BossUi.buildFormCell(this, "群聊策略", "群白名单建议配合 requireMention 使用。", groupPolicySpinner));
appendContent(BossUi.buildFormCell(this, "允许群聊 chat id", "每行一个 Telegram 群 chat id。", groupsInput));
appendContent(BossUi.buildFormCell(this, "群 / Topic 路由", "每行格式chatId[#topicId] projectId 可选备注;未命中时回到默认项目。", groupProjectRoutesInput));
appendContent(BossUi.buildWechatSwitchRow(this, "群聊要求 @Bot", "开启后只有 @bot_username 或回复当前 Bot 的消息才会进入主 Agent。", requireMentionSwitch));
android.widget.Button testButton = BossUi.buildSecondaryButton(this, "测试连接");
testButton.setOnClickListener(v -> saveTelegram(true));
appendContent(testButton);
TextView noteView = BossUi.buildHintPill(this, "提示:保存为 webhook 模式时会自动 setWebhook切回 polling/关闭时会自动 deleteWebhook。");
appendContent(noteView);
}
private void populate(@Nullable JSONObject telegram) {
currentTelegram = telegram;
buildFormContent();
if (telegram != null) {
enabledSwitch.setChecked(telegram.optBoolean("enabled", false));
String mode = telegram.optString("mode", "webhook");
modeSpinner.setSelection("polling".equals(mode) ? 1 : 0);
String dmPolicy = telegram.optString("dmPolicy", "allowlist");
dmPolicySpinner.setSelection(policySelection(dmPolicy));
String groupPolicy = telegram.optString("groupPolicy", "allowlist");
groupPolicySpinner.setSelection(policySelection(groupPolicy));
requireMentionSwitch.setChecked(telegram.optBoolean("requireMentionInGroups", true));
webhookUrlInput.setText(telegram.optString("webhookUrl", ""));
defaultProjectIdInput.setText(telegram.optString("defaultProjectId", "master-agent"));
allowFromInput.setText(joinLines(telegram.optJSONArray("allowFrom")));
groupsInput.setText(joinLines(telegram.optJSONArray("groups")));
groupProjectRoutesInput.setText(formatGroupProjectRoutes(telegram.optJSONArray("groupProjectRoutes")));
}
telegramLoaded = telegram != null;
updateActionAvailability();
setRefreshing(false);
}
private LinearLayout buildStatusRow(@Nullable JSONObject telegram) {
return BossUi.buildWechatMenuRow(
this,
"当前状态",
buildStatusSummary(telegram),
buildStatusMeta(telegram),
null,
null
);
}
private String buildStatusSummary(@Nullable JSONObject telegram) {
if (telegram == null) {
return "接入:加载中\n模式未加载\nBot未识别";
}
String botUsername = telegram.optString("botUsername", "").trim();
StringBuilder builder = new StringBuilder();
builder.append("接入:").append(telegram.optBoolean("enabled", false) ? "已开启" : "已关闭");
builder.append("\n模式").append("polling".equals(telegram.optString("mode", "webhook")) ? "Polling" : "Webhook");
builder.append("\nBot").append(botUsername.isEmpty() ? "未识别" : "@" + botUsername);
builder.append("\nToken").append(telegram.optBoolean("botTokenConfigured", false) ? "已配置" : "未配置");
builder.append("\nWebhook Secret").append(telegram.optBoolean("webhookSecretConfigured", false) ? "已配置" : "未配置");
builder.append("\n默认项目").append(telegram.optString("defaultProjectId", "master-agent"));
builder.append("\n已处理 update").append(telegram.optInt("processedUpdateCount", 0));
return builder.toString();
}
private String buildStatusMeta(@Nullable JSONObject telegram) {
if (telegram == null) {
return "加载完成后可测试连接或保存配置。";
}
String lastError = telegram.optString("lastError", "").trim();
if (!lastError.isEmpty()) {
return "最近错误:" + lastError;
}
return "状态正常时Telegram 消息会进入主 Agent。";
}
private int policySelection(String policy) {
switch (policy) {
case "open":
return 1;
case "disabled":
return 2;
case "allowlist":
default:
return 0;
}
}
private String joinLines(@Nullable org.json.JSONArray array) {
if (array == null || array.length() == 0) {
return "";
}
StringBuilder builder = new StringBuilder();
for (int index = 0; index < array.length(); index += 1) {
String value = array.optString(index, "").trim();
if (value.isEmpty()) {
continue;
}
if (builder.length() > 0) {
builder.append("\n");
}
builder.append(value);
}
return builder.toString();
}
private org.json.JSONArray parseLines(EditText input) {
org.json.JSONArray array = new org.json.JSONArray();
String[] lines = input.getText().toString().split("\\r?\\n");
for (String line : lines) {
String trimmed = line == null ? "" : line.trim();
if (!trimmed.isEmpty()) {
array.put(trimmed);
}
}
return array;
}
private String formatGroupProjectRoutes(@Nullable org.json.JSONArray routes) {
if (routes == null || routes.length() == 0) {
return "";
}
StringBuilder builder = new StringBuilder();
for (int index = 0; index < routes.length(); index += 1) {
JSONObject route = routes.optJSONObject(index);
if (route == null) {
continue;
}
String chatId = route.optString("chatId", "").trim();
String projectId = route.optString("projectId", "").trim();
if (chatId.isEmpty() || projectId.isEmpty()) {
continue;
}
if (builder.length() > 0) {
builder.append("\n");
}
builder.append(chatId);
if (route.has("threadId")) {
builder.append("#").append(route.optInt("threadId"));
}
builder.append(" ").append(projectId);
String label = route.optString("label", "").trim();
if (!label.isEmpty()) {
builder.append(" ").append(label);
}
}
return builder.toString();
}
private org.json.JSONArray parseGroupProjectRoutes(EditText input) throws org.json.JSONException {
org.json.JSONArray array = new org.json.JSONArray();
String[] lines = input.getText().toString().split("\\r?\\n");
for (String line : lines) {
String trimmed = line == null ? "" : line.trim();
if (trimmed.isEmpty()) {
continue;
}
String[] parts = trimmed.split("\\s+", 3);
if (parts.length < 2) {
continue;
}
String[] chatParts = parts[0].split("#", 2);
String chatId = chatParts[0].trim();
String projectId = parts[1].trim();
if (chatId.isEmpty() || projectId.isEmpty()) {
continue;
}
JSONObject route = new JSONObject();
route.put("chatId", chatId);
if (chatParts.length > 1) {
try {
route.put("threadId", Integer.parseInt(chatParts[1].trim()));
} catch (NumberFormatException ignored) {
// Invalid topic id is ignored so the chat-level route can still be saved.
}
}
route.put("projectId", projectId);
if (parts.length > 2 && !parts[2].trim().isEmpty()) {
route.put("label", parts[2].trim());
}
array.put(route);
}
return array;
}
private void saveTelegram(boolean testConnection) {
if (!telegramLoaded) {
showMessage("配置尚未加载完成,请先刷新成功后再保存。");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
JSONObject payload = new JSONObject();
payload.put("enabled", enabledSwitch.isChecked());
payload.put("mode", String.valueOf(modeSpinner.getSelectedItem()));
payload.put("botToken", emptyToNull(botTokenInput.getText().toString()));
payload.put("webhookSecret", emptyToNull(webhookSecretInput.getText().toString()));
payload.put("webhookUrl", emptyToNull(webhookUrlInput.getText().toString()));
payload.put("defaultProjectId", emptyToNull(defaultProjectIdInput.getText().toString()));
payload.put("dmPolicy", String.valueOf(dmPolicySpinner.getSelectedItem()));
payload.put("allowFrom", parseLines(allowFromInput));
payload.put("groupPolicy", String.valueOf(groupPolicySpinner.getSelectedItem()));
payload.put("groups", parseLines(groupsInput));
payload.put("groupProjectRoutes", parseGroupProjectRoutes(groupProjectRoutesInput));
payload.put("requireMentionInGroups", requireMentionSwitch.isChecked());
payload.put("testConnection", testConnection);
BossApiClient.ApiResponse response = apiClient.updateTelegramIntegration(payload);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
JSONObject telegram = response.json.optJSONObject("telegram");
populate(telegram);
String probeUsername = "";
JSONObject probe = response.json.optJSONObject("probe");
if (probe != null) {
probeUsername = probe.optString("username", "");
}
showMessage(testConnection
? (probeUsername.isEmpty() ? "Telegram 连接测试通过" : "连接测试通过:@" + probeUsername)
: "Telegram 配置已保存");
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("Telegram 配置失败:" + error.getMessage());
});
}
});
}
@Nullable
private Object emptyToNull(String value) {
String trimmed = value == null ? "" : value.trim();
return trimmed.isEmpty() ? JSONObject.NULL : trimmed;
}
private void updateActionAvailability() {
if (headerActionButton != null) {
headerActionButton.setEnabled(telegramLoaded);
headerActionButton.setAlpha(telegramLoaded ? 1f : 0.45f);
}
}
}

View File

@@ -12,6 +12,101 @@ import java.util.List;
import java.util.Map;
public final class WechatSurfaceMapper {
private static final String[] PROCESS_PREVIEW_PREFIXES = new String[] {
"我先",
"我现在",
"我会先",
"我发现",
"我准备",
"接下来",
"正在",
"先看",
"先读",
"我把",
"我再",
"目前在",
"现在在",
"补一组",
"处理一下",
"先确认",
"准备",
"同步一下",
"我这边已经"
};
private static final String[] PROCESS_PREVIEW_CONTAINS = new String[] {
"我继续",
"我已经在",
"正在跑",
"正在检查",
"正在处理",
"正在同步",
"我会直接",
"我先把",
"先补",
"再接"
};
private static final String[] PROCESS_PREVIEW_NUMBERED_HINTS = new String[] {
"",
"",
"接下来",
"然后",
"检查",
"确认",
"处理",
"同步",
"",
"排查",
"推进",
"回你",
"回传",
"会把",
"我会"
};
private static final String[] PROCESS_PREVIEW_BLOCK_MARKERS = new String[] {
"失败",
"报错",
"错误",
"阻塞",
"不能",
"无法",
"崩溃",
"超时",
"exception",
"error",
"fatal",
"结论",
"最终",
"总结",
"已完成",
"已经完成",
"验证通过"
};
private static final String[] LEAKED_TITLE_PREFIXES = new String[] {
"你当前接手的项目根目录是",
"你现在接手的项目根目录是",
"你现在以目标线程身份直接回复用户",
"你正在向主 Agent 同步当前项目状态",
"只回复对用户真正有用的内容",
"只输出 JSON"
};
private static final String[] LEAKED_TITLE_CONTAINS = new String[] {
"不要发送内部字段",
"不要自称主 Agent",
"不要解释系统如何分发",
"不要输出 JSON",
"项目名称:",
"线程名称:",
"文件夹:",
"同步原因:",
"当前消息:",
"用户当前消息:"
};
private static final List<String> ROOT_TAB_LABELS = Arrays.asList(
"会话",
"设备",
@@ -21,8 +116,11 @@ public final class WechatSurfaceMapper {
private static final List<MeMenuItem> ROOT_ME_MENU_ITEMS = Arrays.asList(
new MeMenuItem("security", "账号与安全", "修改登录密码、设备安全与身份校验"),
new MeMenuItem("settings", "设置", "默认首页、提醒方式与危险操作确认"),
new MeMenuItem("access", "用户与权限", "分配子账号、设备、项目与 Skill 权限"),
new MeMenuItem("ops", "运维与修复", "查看运维会话、修复回放与 standby 切换"),
new MeMenuItem("ai_accounts", "AI 账号", "管理主 GPT、备用 GPT 与 API 容灾"),
new MeMenuItem("storage", "附件与存储", "配置附件上传位置、服务器文件与阿里 OSS"),
new MeMenuItem("telegram", "Telegram 接入", "配置 Telegram Bot、Webhook 与白名单"),
new MeMenuItem("skills", "技能", "按设备查看 Skill 清单"),
new MeMenuItem("about", "关于", "当前版本、OTA 状态与更新内容")
);
@@ -59,14 +157,20 @@ public final class WechatSurfaceMapper {
JSONObject avatar = source.optJSONObject("avatar");
boolean isGroup = source.optBoolean("isGroup", groupAvatarMembers.size() > 1);
String conversationType = source.optString("conversationType", "");
String threadTitle = trimLocalWorkspacePrefix(
source.optString("threadTitle", source.optString("title", source.optString("projectTitle", "")))
String folderLabel = normalizeConversationTitle(source.optString("folderLabel", ""));
String threadTitle = sanitizeConversationTitle(
source.optString("threadTitle", source.optString("title", source.optString("projectTitle", ""))),
folderLabel,
source.optString("projectTitle", "")
);
String projectId = source.optString("projectId", "").trim();
if ("folder_archive".equals(conversationType)) {
threadTitle = source.optString(
"projectTitle",
source.optString("threadTitle", source.optString("title", source.optString("folderLabel", "")))
);
} else if (isPinnedSystemProject(projectId)) {
threadTitle = source.optString("projectTitle", threadTitle);
}
String pinnedLabel = source.optString("topPinnedLabel", "");
return new ConversationRow(
@@ -188,10 +292,36 @@ public final class WechatSurfaceMapper {
return titles;
}
public static String[] rootMeMenuTitlesForRole(String role) {
List<MeMenuItem> items = rootMeMenuItemsForRoleList(role);
String[] titles = new String[items.size()];
for (int i = 0; i < items.size(); i++) {
titles[i] = items.get(i).title;
}
return titles;
}
public static MeMenuItem[] rootMeMenuItems() {
return ROOT_ME_MENU_ITEMS.toArray(new MeMenuItem[0]);
}
public static MeMenuItem[] rootMeMenuItemsForRole(String role) {
List<MeMenuItem> items = rootMeMenuItemsForRoleList(role);
return items.toArray(new MeMenuItem[0]);
}
public static boolean canOpenMeEntryForRole(String key, String role) {
if (key == null || key.trim().isEmpty()) {
return false;
}
for (MeMenuItem item : rootMeMenuItemsForRoleList(role)) {
if (item.key.equals(key)) {
return true;
}
}
return false;
}
public static MeMenuItem findMeMenuItem(String key) {
for (MeMenuItem item : ROOT_ME_MENU_ITEMS) {
if (item.key.equals(key)) {
@@ -201,6 +331,34 @@ public final class WechatSurfaceMapper {
return null;
}
private static List<MeMenuItem> rootMeMenuItemsForRoleList(String role) {
if ("highest_admin".equals(role)) {
return ROOT_ME_MENU_ITEMS;
}
List<MeMenuItem> visible = new ArrayList<>();
for (MeMenuItem item : ROOT_ME_MENU_ITEMS) {
if (!isHighestAdminOnlyMeEntry(item.key) && (isAdministratorRole(role) || !isAdministratorOnlyMeEntry(item.key))) {
visible.add(item);
}
}
return visible;
}
private static boolean isAdministratorRole(String role) {
return "highest_admin".equals(role) || "admin".equals(role);
}
private static boolean isAdministratorOnlyMeEntry(String key) {
return "ops".equals(key)
|| "ai_accounts".equals(key)
|| "storage".equals(key)
|| "telegram".equals(key);
}
private static boolean isHighestAdminOnlyMeEntry(String key) {
return "access".equals(key);
}
public static String[] projectQuickActions() {
return PROJECT_QUICK_ACTIONS.toArray(new String[0]);
}
@@ -249,6 +407,10 @@ public final class WechatSurfaceMapper {
return "cancel_on_detach";
}
private static boolean isPinnedSystemProject(String projectId) {
return "master-agent".equals(projectId) || "audit-collab".equals(projectId);
}
private static String buildContextStatusLabel(JSONObject source) {
if (source.optBoolean("mustFinishBeforeCompaction", false)) {
return "必须收尾";
@@ -322,7 +484,14 @@ public final class WechatSurfaceMapper {
}
public static RootTopAction rootTopAction(String activeTab, boolean refreshing, boolean selectionMode) {
return rootTopAction(activeTab, refreshing, selectionMode, "highest_admin");
}
public static RootTopAction rootTopAction(String activeTab, boolean refreshing, boolean selectionMode, String role) {
if ("devices".equals(activeTab)) {
if (!"highest_admin".equals(role)) {
return new RootTopAction("刷新", false, true, "refresh", "refresh");
}
return new RootTopAction("添加设备", false, true, "add", "add_device");
}
if ("conversations".equals(activeTab)) {
@@ -714,9 +883,156 @@ public final class WechatSurfaceMapper {
if (preview.matches("^已从设备.+导入线程《.+》[。.]?$")) {
return "已导入线程";
}
if (isLikelyProcessPreview(preview)) {
return "";
}
return preview;
}
private static boolean isLikelyProcessPreview(String value) {
String preview = value == null ? "" : value
.replace("\r\n", "\n")
.replace('\r', '\n')
.replaceAll("\\n{2,}", "\n")
.trim();
if (preview.isEmpty()) {
return false;
}
if (containsMarker(preview, PROCESS_PREVIEW_BLOCK_MARKERS)) {
return false;
}
if (isStructuredNumberedProcessPreview(preview)) {
return true;
}
String normalized = preview.toLowerCase(java.util.Locale.ROOT);
for (String marker : PROCESS_PREVIEW_PREFIXES) {
if (normalized.startsWith(marker.toLowerCase(java.util.Locale.ROOT))) {
return true;
}
}
for (String marker : PROCESS_PREVIEW_CONTAINS) {
if (normalized.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
return true;
}
}
return false;
}
private static boolean isStructuredNumberedProcessPreview(String value) {
String[] rawLines = value
.replace("\r\n", "\n")
.replace('\r', '\n')
.split("\n");
ArrayList<String> numberedLines = new ArrayList<>();
for (String rawLine : rawLines) {
String normalizedLine = rawLine == null ? "" : rawLine.trim();
if (normalizedLine.isEmpty()) {
continue;
}
if (normalizedLine.matches("^\\d+[.)、]\\s*.+$")) {
numberedLines.add(normalizedLine);
}
}
if (numberedLines.size() < 2) {
return false;
}
String merged = android.text.TextUtils.join(" ", numberedLines)
.toLowerCase(java.util.Locale.ROOT);
return containsMarker(merged, PROCESS_PREVIEW_NUMBERED_HINTS);
}
private static boolean containsMarker(String value, String[] markers) {
String normalized = value == null ? "" : value.toLowerCase(java.util.Locale.ROOT);
for (String marker : markers) {
if (normalized.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
return true;
}
}
return false;
}
private static String normalizeConversationTitle(String value) {
String source = value == null ? "" : value.replace("\u0000", "");
String[] lines = source.split("\\r?\\n");
for (String line : lines) {
if (line == null) {
continue;
}
String trimmed = line.trim();
if (!trimmed.isEmpty()) {
return trimmed.replaceAll("\\s+", " ");
}
}
return "";
}
private static String stripTrailingConversationTitleNoise(String value) {
return value == null ? "" : value.replaceAll("['\"}\\]]{2,}$", "").trim();
}
private static boolean looksLikeLeakedConversationTitle(String value) {
String normalized = normalizeConversationTitle(value);
if (normalized.isEmpty()) {
return false;
}
for (String marker : LEAKED_TITLE_PREFIXES) {
if (normalized.startsWith(marker)) {
return true;
}
}
for (String marker : LEAKED_TITLE_CONTAINS) {
if (normalized.contains(marker)) {
return true;
}
}
return false;
}
private static String extractWorkspaceProjectName(String value) {
String normalized = normalizeConversationTitle(value).replace('\\', '/');
if (normalized.isEmpty()) {
return "";
}
String[] patterns = new String[] {
".*/Users/[^/]+/code/([^/\\s\"'`,。;!?]+).*",
".*/home/[^/]+/code/([^/\\s\"'`,。;!?]+).*",
".*[A-Za-z]:/Users/[^/]+/code/([^/\\s\"'`,。;!?]+).*"
};
for (String pattern : patterns) {
if (normalized.matches(pattern)) {
return normalized.replaceFirst(pattern, "$1").split("/")[0].trim();
}
}
return "";
}
private static String sanitizeConversationTitle(String value, String... fallbackCandidates) {
String normalized = normalizeConversationTitle(value);
String trimmed = stripTrailingConversationTitleNoise(trimLocalWorkspacePrefix(normalized));
if (!trimmed.isEmpty() && !looksLikeLeakedConversationTitle(normalized) && !looksLikeLeakedConversationTitle(trimmed)) {
return trimmed;
}
String extractedProject = extractWorkspaceProjectName(normalized);
if (!extractedProject.isEmpty() && !looksLikeLeakedConversationTitle(extractedProject)) {
return extractedProject;
}
for (String fallbackCandidate : fallbackCandidates) {
String extractedFallback = extractWorkspaceProjectName(fallbackCandidate);
if (!extractedFallback.isEmpty() && !looksLikeLeakedConversationTitle(extractedFallback)) {
return extractedFallback;
}
String normalizedFallback = stripTrailingConversationTitleNoise(
trimLocalWorkspacePrefix(normalizeConversationTitle(fallbackCandidate))
);
if (!normalizedFallback.isEmpty() && !looksLikeLeakedConversationTitle(normalizedFallback)) {
return normalizedFallback;
}
}
return trimmed;
}
private static String trimLocalWorkspacePrefix(String value) {
String label = value == null ? "" : value.trim();
if (label.isEmpty()) {

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/boss_surface" />
<corners android:radius="24dp" />
<stroke
android:width="1dp"
android:color="@color/boss_card_stroke" />
</shape>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF111111"
android:pathData="M7.41,8.59L6,10l6,6 6,-6 -1.41,-1.41L12,13.17 7.41,8.59Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/boss_text_muted"
android:pathData="M5,6.5C5,4.57 6.57,3 8.5,3H15.5C17.43,3 19,4.57 19,6.5V11.2C19,13.13 17.43,14.7 15.5,14.7H11.25L7.92,18.03C7.55,18.4 6.92,18.14 6.92,17.62V14.54C5.8,14.04 5,12.91 5,11.6V6.5ZM8.4,8.1C7.82,8.1 7.35,8.57 7.35,9.15C7.35,9.73 7.82,10.2 8.4,10.2C8.98,10.2 9.45,9.73 9.45,9.15C9.45,8.57 8.98,8.1 8.4,8.1ZM12,8.1C11.42,8.1 10.95,8.57 10.95,9.15C10.95,9.73 11.42,10.2 12,10.2C12.58,10.2 13.05,9.73 13.05,9.15C13.05,8.57 12.58,8.1 12,8.1ZM15.6,8.1C15.02,8.1 14.55,8.57 14.55,9.15C14.55,9.73 15.02,10.2 15.6,10.2C16.18,10.2 16.65,9.73 16.65,9.15C16.65,8.57 16.18,8.1 15.6,8.1Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/boss_text_muted"
android:pathData="M12,2.8L20,7.1V16.9L12,21.2L4,16.9V7.1L12,2.8ZM6.2,8.42V15.58L10.9,18.11V10.95L6.2,8.42ZM12,9.05L16.78,6.48L12,3.91L7.22,6.48L12,9.05ZM13.1,10.95V18.11L17.8,15.58V8.42L13.1,10.95Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/boss_text_muted"
android:pathData="M12,12.1C9.65,12.1 7.75,10.2 7.75,7.85C7.75,5.5 9.65,3.6 12,3.6C14.35,3.6 16.25,5.5 16.25,7.85C16.25,10.2 14.35,12.1 12,12.1ZM4.8,19.5C5.44,16.13 8.39,13.58 12,13.58C15.61,13.58 18.56,16.13 19.2,19.5C19.31,20.09 18.85,20.63 18.25,20.63H5.75C5.15,20.63 4.69,20.09 4.8,19.5Z" />
</vector>

View File

@@ -52,11 +52,96 @@
android:textColor="@color/boss_text_muted"
android:textSize="14sp" />
<EditText
android:id="@+id/login_account_input"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="28dp"
android:background="@drawable/bg_secondary_button"
android:hint="账号"
android:imeOptions="actionNext"
android:inputType="text"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:singleLine="true"
android:textColor="@color/boss_text_primary"
android:textColorHint="@color/boss_text_muted"
android:textSize="16sp" />
<EditText
android:id="@+id/login_password_input"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="12dp"
android:background="@drawable/bg_secondary_button"
android:hint="密码"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:singleLine="true"
android:textColor="@color/boss_text_primary"
android:textColorHint="@color/boss_text_muted"
android:textSize="16sp" />
<EditText
android:id="@+id/login_confirm_password_input"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="12dp"
android:background="@drawable/bg_secondary_button"
android:hint="确认密码"
android:imeOptions="actionNext"
android:inputType="textPassword"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:singleLine="true"
android:textColor="@color/boss_text_primary"
android:textColorHint="@color/boss_text_muted"
android:textSize="16sp"
android:visibility="gone" />
<LinearLayout
android:id="@+id/login_code_row"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="12dp"
android:orientation="horizontal"
android:visibility="gone">
<EditText
android:id="@+id/login_code_input"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/bg_secondary_button"
android:hint="验证码"
android:imeOptions="actionDone"
android:inputType="number"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:singleLine="true"
android:textColor="@color/boss_text_primary"
android:textColorHint="@color/boss_text_muted"
android:textSize="16sp" />
<Button
android:id="@+id/login_send_code_button"
android:layout_width="104dp"
android:layout_height="match_parent"
android:layout_marginLeft="10dp"
android:background="@drawable/bg_secondary_button"
android:text="获取验证码"
android:textAllCaps="false"
android:textColor="@color/boss_text_primary"
android:textSize="14sp" />
</LinearLayout>
<ProgressBar
android:id="@+id/login_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:layout_marginTop="18dp"
android:visibility="gone" />
<Button
@@ -72,6 +157,46 @@
android:textColor="@color/boss_surface"
android:textSize="18sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:gravity="center"
android:orientation="horizontal">
<Button
android:id="@+id/login_mode_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:background="@android:color/transparent"
android:text="账号登录"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textSize="14sp" />
<Button
android:id="@+id/register_mode_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginLeft="8dp"
android:background="@android:color/transparent"
android:text="注册账号"
android:textAllCaps="false"
android:textColor="@color/boss_text_muted"
android:textSize="14sp" />
<Button
android:id="@+id/forgot_mode_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginLeft="8dp"
android:background="@android:color/transparent"
android:text="忘记密码"
android:textAllCaps="false"
android:textColor="@color/boss_text_muted"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>
</ScrollView>
@@ -188,51 +313,50 @@
<LinearLayout
android:layout_width="match_parent"
android:layout_height="72dp"
android:layout_height="64dp"
android:background="@color/boss_surface"
android:elevation="10dp"
android:gravity="center"
android:orientation="horizontal"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:paddingLeft="12dp"
android:paddingRight="12dp">
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:paddingLeft="10dp"
android:paddingRight="10dp">
<Button
android:id="@+id/tab_conversations"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginRight="6dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/bg_tab_active"
android:background="@android:color/transparent"
android:text="会话"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textSize="12sp"
android:textStyle="bold" />
<Button
android:id="@+id/tab_devices"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginLeft="6dp"
android:layout_marginRight="6dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/bg_tab_inactive"
android:background="@android:color/transparent"
android:text="设备"
android:textAllCaps="false"
android:textColor="@color/boss_text_muted"
android:textSize="12sp"
android:textStyle="bold" />
<Button
android:id="@+id/tab_me"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginLeft="6dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/bg_tab_inactive"
android:background="@android:color/transparent"
android:text="我的"
android:textAllCaps="false"
android:textColor="@color/boss_text_muted"
android:textSize="12sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>

View File

@@ -80,54 +80,90 @@
android:tint="@color/boss_text_primary" />
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/screen_refresh_layout"
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:layout_weight="1"
android:clipChildren="false"
android:clipToPadding="false">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/screen_refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/project_chat_quick_actions_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_bg_app"
android:orientation="vertical"
android:paddingLeft="12dp"
android:paddingTop="10dp"
android:paddingRight="12dp"
android:paddingBottom="12dp">
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:id="@+id/project_chat_quick_actions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal" />
</LinearLayout>
<ScrollView
android:id="@+id/project_chat_scroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls">
<LinearLayout
android:id="@+id/screen_content"
android:id="@+id/project_chat_quick_actions_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_bg_app"
android:orientation="vertical"
android:paddingLeft="12dp"
android:paddingTop="0dp"
android:paddingTop="10dp"
android:paddingRight="12dp"
android:paddingBottom="20dp" />
</ScrollView>
</LinearLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
android:paddingBottom="12dp">
<LinearLayout
android:id="@+id/project_chat_quick_actions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal" />
</LinearLayout>
<ScrollView
android:id="@+id/project_chat_scroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls">
<LinearLayout
android:id="@+id/screen_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingLeft="12dp"
android:paddingTop="0dp"
android:paddingRight="12dp"
android:paddingBottom="20dp" />
</ScrollView>
</LinearLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/project_chat_scroll_bottom"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="bottom|left"
android:layout_marginLeft="12dp"
android:layout_marginBottom="12dp"
android:background="@drawable/bg_chat_scroll_bottom_button"
android:contentDescription="回到底部"
android:elevation="8dp"
android:padding="12dp"
android:scaleType="center"
android:src="@drawable/ic_boss_arrow_down"
android:tint="@color/boss_text_primary"
android:visibility="gone" />
</FrameLayout>
<LinearLayout
android:id="@+id/project_chat_mention_panel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:elevation="8dp"
android:orientation="vertical"
android:paddingLeft="12dp"
android:paddingTop="8dp"
android:paddingRight="12dp"
android:paddingBottom="8dp"
android:visibility="gone" />
<LinearLayout
android:id="@+id/project_chat_composer_row"
@@ -197,9 +233,22 @@
android:visibility="gone">
<Button
android:id="@+id/project_chat_multi_forward"
android:layout_width="match_parent"
android:id="@+id/project_chat_multi_copy"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_marginRight="8dp"
android:layout_weight="1"
android:background="@drawable/bg_secondary_button"
android:text="复制"
android:textAllCaps="false"
android:textColor="@color/boss_text_primary"
android:textStyle="bold" />
<Button
android:id="@+id/project_chat_multi_forward"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_weight="1"
android:background="@drawable/bg_primary_button"
android:text="转发"
android:textAllCaps="false"

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.Light.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:windowBackground">@color/boss_bg_app</item>
<item name="android:forceDarkAllowed">false</item>
</style>
</resources>

View File

@@ -13,7 +13,6 @@
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:windowBackground">@color/boss_bg_app</item>
<item name="android:forceDarkAllowed">false</item>
</style>

View File

@@ -0,0 +1,145 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.content.Intent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class AccessManagementActivityTest {
@Test
public void renderAccessShowsTemplateApplyEntryWhenTemplatesAreAvailable() throws Exception {
TestAccessManagementActivity activity = Robolectric
.buildActivity(TestAccessManagementActivity.class, new Intent())
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccess",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAccessPayload())
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "套用模板"));
assertTrue(viewTreeContainsText(content, "一次性给账号分配设备、项目和 Skill 权限"));
}
@Test
public void renderAccessExplainsUnavailableTargetsInsteadOfBlankState() throws Exception {
TestAccessManagementActivity activity = Robolectric
.buildActivity(TestAccessManagementActivity.class, new Intent())
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccess",
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject()
.put("accounts", new JSONArray())
.put("devices", new JSONArray())
.put("projects", new JSONArray())
.put("skills", new JSONArray())
.put("skillCatalog", new JSONArray())
.put("permissionTemplates", new JSONArray())
.put("grants", new JSONObject()
.put("devices", new JSONArray())
.put("projects", new JSONArray())
.put("skills", new JSONArray())))
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "暂无权限模板"));
assertTrue(viewTreeContainsText(content, "暂无可授权设备"));
assertTrue(viewTreeContainsText(content, "暂无可授权项目"));
assertTrue(viewTreeContainsText(content, "暂无可分配 Skill"));
}
@Test
public void buildTemplateApplyPayloadWritesServerTemplateContract() throws Exception {
JSONObject payload = AccessManagementActivity.buildTemplateApplyPayload(
"developer@example.com",
new JSONObject().put("templateId", "developer"),
new JSONObject().put("id", "mac-studio"),
new JSONObject().put("id", "master-agent"),
new JSONObject().put("skillId", "mac-studio:boss-server-debug")
);
assertEquals("apply_template", payload.optString("action"));
assertEquals("developer@example.com", payload.optString("account"));
assertEquals("developer", payload.optString("templateId"));
assertEquals("mac-studio", payload.optJSONArray("deviceIds").optString(0));
assertEquals("master-agent", payload.optJSONArray("projectIds").optString(0));
assertEquals("mac-studio:boss-server-debug", payload.optJSONArray("skillIds").optString(0));
}
private static JSONObject buildAccessPayload() throws Exception {
return new JSONObject()
.put("accounts", new JSONArray()
.put(new JSONObject()
.put("account", "developer@example.com")
.put("displayName", "Developer")
.put("role", "member")))
.put("devices", new JSONArray()
.put(new JSONObject()
.put("id", "mac-studio")
.put("name", "Mac Studio")))
.put("projects", new JSONArray()
.put(new JSONObject()
.put("id", "master-agent")
.put("name", "主 Agent")))
.put("skills", new JSONArray()
.put(new JSONObject()
.put("skillId", "mac-studio:boss-server-debug")
.put("deviceId", "mac-studio")
.put("name", "boss-server-debug")))
.put("skillCatalog", new JSONArray())
.put("permissionTemplates", new JSONArray()
.put(new JSONObject()
.put("templateId", "developer")
.put("name", "项目开发者")
.put("description", "允许聊天和 Skill 调用")))
.put("grants", new JSONObject()
.put("devices", new JSONArray())
.put("projects", new JSONArray())
.put("skills", new JSONArray()));
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (text != null && text.toString().contains(expectedText)) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
private static final class TestAccessManagementActivity extends AccessManagementActivity {
@Override
protected void reload() {
}
}
}

View File

@@ -81,6 +81,22 @@ public class BossApiClientDispatchPlansTest {
assertEquals("no-cache", connection.getRequestProperty("Pragma"));
}
@Test
public void protectedHtmlResponseReturnsJsonErrorInsteadOfThrowing() throws Exception {
RecordingConnection connection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/auth/session"),
200,
"<!DOCTYPE html><html><body>login</body></html>",
""
);
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.getSession();
assertEquals(401, response.statusCode);
assertEquals("NON_JSON_RESPONSE", response.message());
}
@Test
public void confirmDispatchPlanWritesApprovedTargetProjectIds() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/confirm"));
@@ -114,6 +130,19 @@ public class BossApiClientDispatchPlansTest {
assertEquals("{}", connection.requestBody());
}
@Test
public void decideDialogGuardInterventionUsesContractEndpointAndDecisionBody() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/dialog-guard/interventions/intervention-1/decision"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.decideDialogGuardIntervention("intervention-1", "allow_once");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/dialog-guard/interventions/intervention-1/decision", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals("{\"decision\":\"allow_once\"}", connection.requestBody());
}
@Test
public void retryDispatchPlanUsesProjectScopedRetryEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/retry"));
@@ -282,6 +311,153 @@ public class BossApiClientDispatchPlansTest {
assertEquals(20000, connection.readTimeoutValue);
}
@Test
public void deleteProjectMessageUsesProjectScopedDeleteEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/thread-1/messages"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.deleteProjectMessage("thread-1", "msg-1");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/thread-1/messages?messageId=msg-1", apiClient.lastPath);
assertEquals("DELETE", connection.requestMethodValue);
}
@Test
public void storageConfigMethodsUseDedicatedStorageEndpoints() throws Exception {
RecordingConnection getConnection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/storage/config"));
RecordingBossApiClient getClient = new RecordingBossApiClient(getConnection);
getClient.getAttachmentStorageConfig();
assertEquals("/api/v1/storage/config", getClient.lastPath);
assertEquals("GET", getConnection.requestMethodValue);
RecordingConnection saveConnection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/storage/config"));
RecordingBossApiClient saveClient = new RecordingBossApiClient(saveConnection);
saveClient.saveAttachmentStorageConfig(new JSONObject().put("mode", "server_file"));
assertEquals("/api/v1/storage/config", saveClient.lastPath);
assertEquals("PATCH", saveConnection.requestMethodValue);
assertEquals("{\"mode\":\"server_file\"}", saveConnection.requestBody());
RecordingConnection validateConnection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/storage/config/validate"));
RecordingBossApiClient validateClient = new RecordingBossApiClient(validateConnection);
validateClient.validateAttachmentStorageConfig(new JSONObject().put("mode", "oss"));
assertEquals("/api/v1/storage/config/validate", validateClient.lastPath);
assertEquals("POST", validateConnection.requestMethodValue);
assertEquals("{\"mode\":\"oss\"}", validateConnection.requestBody());
}
@Test
public void protectedRequestFallsBackToAutoLoginWhenNoRestoreTokenExists() throws Exception {
SequencedBossApiClient apiClient = new SequencedBossApiClient(
new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/projects/project-1"),
401,
"{\"ok\":false,\"message\":\"UNAUTHORIZED\"}",
"{\"ok\":false,\"message\":\"UNAUTHORIZED\"}"
),
new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/projects/project-1"),
200,
"{\"ok\":true,\"project\":{\"id\":\"project-1\",\"name\":\"北区试产线\"}}",
"{\"ok\":false}"
)
);
BossApiClient.ApiResponse response = apiClient.getProjectDetail("project-1");
assertEquals(1, apiClient.autoLoginCalls);
assertEquals(2, apiClient.protectedRequestCount);
assertEquals(200, response.statusCode);
assertEquals("北区试产线", response.json.optJSONObject("project").optString("name"));
}
@Test
public void autoLoginCapturesSessionCookieFromMixedCaseHeaderNames() throws Exception {
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/auth/login"));
connection.responseHeaders.put(
"Set-cookie",
Collections.singletonList("boss_session=session-from-mixed-case; Path=/; HttpOnly")
);
IdentityCapturingBossApiClient apiClient = new IdentityCapturingBossApiClient(connection, prefs);
BossApiClient.ApiResponse response = apiClient.autoLogin();
assertEquals(200, response.statusCode);
assertEquals("boss_session=session-from-mixed-case", prefs.getString("session_cookie", ""));
assertEquals("krisolo", prefs.getString("account", ""));
assertEquals("Boss 超级管理员", prefs.getString("display_name", ""));
}
@Test
public void loginWithPasswordPostsCredentialsAndCapturesNativeRestoreToken() throws Exception {
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
RecordingConnection connection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/auth/login"),
200,
"{\"ok\":true,\"account\":\"krisolo\",\"displayName\":\"Boss 超级管理员\",\"restoreToken\":\"restore-login\"}",
"{\"ok\":false}"
);
connection.responseHeaders.put(
"Set-cookie",
Collections.singletonList("boss_session=session-from-login; Path=/; HttpOnly")
);
IdentityCapturingBossApiClient apiClient = new IdentityCapturingBossApiClient(connection, prefs);
BossApiClient.ApiResponse response = apiClient.loginWithPassword("krisolo", "Admin_yqs_asd.");
assertEquals(200, response.statusCode);
assertEquals("/api/auth/login", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(
"{\"account\":\"krisolo\",\"password\":\"Admin_yqs_asd.\",\"method\":\"password\"}",
connection.requestBody()
);
assertEquals("boss_session=session-from-login", prefs.getString("session_cookie", ""));
assertEquals("restore-login", prefs.getString("restore_token", ""));
assertEquals("krisolo", prefs.getString("account", ""));
}
@Test
public void authRegistrationAndPasswordResetUseDedicatedNativeRoutes() throws Exception {
ScriptedBossApiClient apiClient = new ScriptedBossApiClient(
new RecordingConnection(new URL("https://boss.hyzq.net/api/auth/send-code")),
new RecordingConnection(new URL("https://boss.hyzq.net/api/auth/register")),
new RecordingConnection(new URL("https://boss.hyzq.net/api/auth/forgot-password"))
);
BossApiClient.ApiResponse codeResponse = apiClient.sendVerificationCode("new-user", "register");
assertEquals(200, codeResponse.statusCode);
assertEquals("/api/auth/send-code", apiClient.lastPath);
assertEquals("{\"account\":\"new-user\",\"purpose\":\"register\"}", apiClient.lastConnection.requestBody());
BossApiClient.ApiResponse registerResponse = apiClient.registerAccount(
"new-user",
"New_password_123",
"New_password_123",
"123456"
);
assertEquals(200, registerResponse.statusCode);
assertEquals("/api/auth/register", apiClient.lastPath);
assertEquals(
"{\"account\":\"new-user\",\"password\":\"New_password_123\",\"confirmPassword\":\"New_password_123\",\"code\":\"123456\"}",
apiClient.lastConnection.requestBody()
);
BossApiClient.ApiResponse resetResponse = apiClient.resetPassword(
"new-user",
"Reset_password_123",
"Reset_password_123",
"654321"
);
assertEquals(200, resetResponse.statusCode);
assertEquals("/api/auth/forgot-password", apiClient.lastPath);
assertEquals(
"{\"account\":\"new-user\",\"password\":\"Reset_password_123\",\"confirmPassword\":\"Reset_password_123\",\"code\":\"654321\"}",
apiClient.lastConnection.requestBody()
);
}
@Test
public void onboardOpenAiApiAccountUsesDedicatedRouteAndSetsActive() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"));
@@ -308,7 +484,7 @@ public class BossApiClientDispatchPlansTest {
public void rememberIdentityDoesNotOverwriteSessionIdentityFromAiAccountOnboardingResponse() throws Exception {
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
prefs.edit()
.putString("account", "17600003315")
.putString("account", "krisolo")
.putString("display_name", "Boss 超级管理员")
.apply();
BossApiClient apiClient = new BossApiClient(prefs, "https://boss.hyzq.net");
@@ -321,7 +497,7 @@ public class BossApiClientDispatchPlansTest {
apiClient.rememberIdentity(onboardingResponse);
assertEquals("17600003315", apiClient.getAccountLabel());
assertEquals("krisolo", apiClient.getAccountLabel());
assertEquals("Boss 超级管理员", apiClient.getDisplayName());
}
@@ -359,7 +535,11 @@ public class BossApiClientDispatchPlansTest {
private String lastPath = "";
RecordingBossApiClient(RecordingConnection connection) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
this(connection, new InMemorySharedPreferences());
}
RecordingBossApiClient(RecordingConnection connection, SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
this.connection = connection;
}
@@ -383,6 +563,7 @@ public class BossApiClientDispatchPlansTest {
private static final class ScriptedBossApiClient extends BossApiClient {
private final Map<String, RecordingConnection> connections;
private String lastPath = "";
private RecordingConnection lastConnection;
ScriptedBossApiClient(RecordingConnection... connections) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
@@ -399,6 +580,7 @@ public class BossApiClientDispatchPlansTest {
if (connection == null) {
throw new IllegalStateException("Missing scripted connection for " + path);
}
lastConnection = connection;
return connection;
}
@@ -413,6 +595,65 @@ public class BossApiClientDispatchPlansTest {
}
}
private static final class SequencedBossApiClient extends BossApiClient {
private final java.util.ArrayDeque<RecordingConnection> protectedConnections = new java.util.ArrayDeque<>();
private int autoLoginCalls;
private int protectedRequestCount;
SequencedBossApiClient(RecordingConnection... protectedConnections) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
Collections.addAll(this.protectedConnections, protectedConnections);
}
@Override
public ApiResponse autoLogin() throws org.json.JSONException {
autoLoginCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("account", "krisolo")
.put("displayName", "Boss 超级管理员"));
}
@Override
HttpURLConnection openConnection(String path) {
if (!"/api/v1/projects/project-1".equals(path)) {
throw new IllegalStateException("Unexpected path " + path);
}
protectedRequestCount += 1;
RecordingConnection connection = protectedConnections.pollFirst();
if (connection == null) {
throw new IllegalStateException("No more scripted protected responses");
}
return connection;
}
@Override
String encode(String value) {
return value;
}
@Override
void rememberIdentity(JSONObject json) {
// no-op for JVM unit test
}
}
private static final class IdentityCapturingBossApiClient extends BossApiClient {
private final RecordingConnection connection;
private String lastPath = "";
IdentityCapturingBossApiClient(RecordingConnection connection, SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
this.connection = connection;
}
@Override
HttpURLConnection openConnection(String path) {
lastPath = path;
return connection;
}
}
private static final class RecordingConnection extends HttpURLConnection {
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
private final Map<String, String> requestHeaders = new HashMap<>();
@@ -422,9 +663,15 @@ public class BossApiClientDispatchPlansTest {
private final int responseCodeValue;
private final String responseBody;
private final String errorBody;
private final Map<String, java.util.List<String>> responseHeaders = new HashMap<>();
RecordingConnection(URL url) {
this(url, 200, "{\"ok\":true}", "{\"ok\":false}");
this(
url,
200,
"{\"ok\":true,\"account\":\"krisolo\",\"displayName\":\"Boss 超级管理员\"}",
"{\"ok\":false}"
);
}
RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) {
@@ -493,6 +740,11 @@ public class BossApiClientDispatchPlansTest {
return new ByteArrayInputStream(errorBody.getBytes(StandardCharsets.UTF_8));
}
@Override
public Map<String, java.util.List<String>> getHeaderFields() {
return responseHeaders;
}
String requestBody() {
return requestBody.toString(StandardCharsets.UTF_8);
}

View File

@@ -0,0 +1,116 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import android.app.NotificationManager;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.SharedPreferences;
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.shadows.ShadowNotificationManager;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossBackgroundRealtimeServiceTest {
@After
public void tearDown() {
TestBossBackgroundRealtimeService.runtimeOverride = null;
}
@Test
public void manifestDeclaresForegroundDataSyncPermission() throws Exception {
Context context = RuntimeEnvironment.getApplication();
PackageManager packageManager = context.getPackageManager();
PackageInfo packageInfo = packageManager.getPackageInfo(
context.getPackageName(),
PackageManager.GET_PERMISSIONS
);
assertNotNull(packageInfo.requestedPermissions);
org.junit.Assert.assertTrue(
java.util.Arrays.asList(packageInfo.requestedPermissions)
.contains("android.permission.FOREGROUND_SERVICE_DATA_SYNC")
);
}
@Test
public void startCommandStartsForegroundSyncAndRealtimeWhenSessionExists() {
Context context = RuntimeEnvironment.getApplication();
SharedPreferences prefs = context.getSharedPreferences("boss-background-service", Context.MODE_PRIVATE);
prefs.edit()
.putString("session_cookie", "boss_session=test")
.putString("restore_token", "restore-test")
.apply();
RecordingRealtimeRuntime runtime = new RecordingRealtimeRuntime();
TestBossBackgroundRealtimeService.runtimeOverride = runtime;
TestBossBackgroundRealtimeService service = Robolectric
.buildService(TestBossBackgroundRealtimeService.class)
.create()
.startCommand(0, 1)
.get();
ShadowNotificationManager notificationManager = Shadows.shadowOf(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
);
assertEquals(1, runtime.startCount);
assertEquals(
1,
notificationManager.size()
);
assertEquals(
"Boss 后台同步中",
String.valueOf(
notificationManager
.getNotification(BossBackgroundRealtimeService.SERVICE_NOTIFICATION_ID)
.extras
.getCharSequence(android.app.Notification.EXTRA_TITLE)
)
);
service.onDestroy();
assertEquals(1, runtime.stopCount);
}
public static class TestBossBackgroundRealtimeService extends BossBackgroundRealtimeService {
static RecordingRealtimeRuntime runtimeOverride;
@Override
BossRealtimeRuntime createRealtimeRuntime(BossApiClient apiClient, BossNotificationRouter router) {
return runtimeOverride == null ? super.createRealtimeRuntime(apiClient, router) : runtimeOverride;
}
@Override
BossApiClient createApiClient() {
Context context = RuntimeEnvironment.getApplication();
SharedPreferences prefs = context.getSharedPreferences("boss-background-service", Context.MODE_PRIVATE);
return new BossApiClient(prefs, "https://boss.hyzq.net");
}
}
static final class RecordingRealtimeRuntime implements BossBackgroundRealtimeService.BossRealtimeRuntime {
int startCount;
int stopCount;
@Override
public void start() {
startCount += 1;
}
@Override
public void stop() {
stopCount += 1;
}
}
}

View File

@@ -0,0 +1,133 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import android.app.Notification;
import android.app.NotificationManager;
import android.content.Context;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.shadows.ShadowNotificationManager;
import org.robolectric.shadows.ShadowApplication;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossNotificationRouterTest {
@Test
public void visibilityTrackerMarksForegroundAndVisibleProject() {
BossAppVisibilityTracker tracker = new BossAppVisibilityTracker();
tracker.onAppForegrounded();
tracker.setVisibleProjectId("master-agent");
assertTrue(tracker.isAppInForeground());
assertEquals("master-agent", tracker.getVisibleProjectId());
tracker.clearVisibleProjectId("master-agent");
tracker.onAppBackgrounded();
assertFalse(tracker.isAppInForeground());
assertNull(tracker.getVisibleProjectId());
}
@Test
public void routerNotifiesOnlyForNewMasterAgentRepliesWhileBackgrounded() throws Exception {
Context context = RuntimeEnvironment.getApplication();
BossAppVisibilityTracker tracker = new BossAppVisibilityTracker();
tracker.onAppBackgrounded();
BossNotificationRouter router = new BossNotificationRouter(context, tracker);
ShadowNotificationManager notificationManager = Shadows.shadowOf(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
);
ShadowApplication.getInstance().grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS);
JSONObject message = new JSONObject()
.put("id", "m-2")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "主 Agent 已完成同步。")
.put("sentAt", "2026-04-21T10:00:00.000Z");
JSONObject payload = new JSONObject()
.put("projectId", "master-agent")
.put("projectMessagesPayload", new JSONObject().put(
"project",
new JSONObject().put("messages", new JSONArray().put(message))
));
assertTrue(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload)));
assertFalse(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload)));
assertEquals(1, notificationManager.size());
Notification posted = notificationManager.getNotification(BossNotificationRouter.MASTER_AGENT_NOTIFICATION_ID);
assertEquals("主 Agent", String.valueOf(posted.extras.getCharSequence(Notification.EXTRA_TITLE)));
assertEquals("主 Agent 已完成同步。", String.valueOf(posted.extras.getCharSequence(Notification.EXTRA_TEXT)));
}
@Test
public void routerNotifiesForMasterAgentRepliesInsideThreadConversationsWhileBackgrounded() throws Exception {
Context context = RuntimeEnvironment.getApplication();
BossAppVisibilityTracker tracker = new BossAppVisibilityTracker();
tracker.onAppBackgrounded();
BossNotificationRouter router = new BossNotificationRouter(context, tracker);
ShadowNotificationManager notificationManager = Shadows.shadowOf(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
);
ShadowApplication.getInstance().grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS);
JSONObject message = new JSONObject()
.put("id", "thread-master-reply-1")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "我已接管这个线程,下一步先核对当前目标。");
JSONObject payload = new JSONObject()
.put("projectId", "aiyanjing-thread")
.put("projectMessagesPayload", new JSONObject().put(
"project",
new JSONObject()
.put("name", "AI 眼镜线程")
.put("messages", new JSONArray().put(message))
));
assertTrue(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload)));
Notification posted = notificationManager.getNotification(BossNotificationRouter.MASTER_AGENT_NOTIFICATION_ID);
assertEquals("主 Agent · AI 眼镜线程", String.valueOf(posted.extras.getCharSequence(Notification.EXTRA_TITLE)));
assertEquals("我已接管这个线程,下一步先核对当前目标。", String.valueOf(posted.extras.getCharSequence(Notification.EXTRA_TEXT)));
}
@Test
public void routerSuppressesNotificationWhileAppIsForeground() throws Exception {
Context context = RuntimeEnvironment.getApplication();
BossAppVisibilityTracker tracker = new BossAppVisibilityTracker();
tracker.onAppForegrounded();
BossNotificationRouter router = new BossNotificationRouter(context, tracker);
ShadowNotificationManager notificationManager = Shadows.shadowOf(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
);
ShadowApplication.getInstance().grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS);
JSONObject message = new JSONObject()
.put("id", "m-3")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "这条前台不该弹通知。");
JSONObject payload = new JSONObject()
.put("projectId", "master-agent")
.put("projectMessagesPayload", new JSONObject().put(
"project",
new JSONObject().put("messages", new JSONArray().put(message))
));
assertFalse(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload)));
assertEquals(0, notificationManager.size());
}
}

View File

@@ -0,0 +1,42 @@
package com.hyzq.boss;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossRbacVisibilityTest {
@Test
public void memberMeMenuHidesAdministratorControlEntries() {
assertArrayEquals(
new String[]{"账号与安全", "设置", "技能", "关于"},
WechatSurfaceMapper.rootMeMenuTitlesForRole("member")
);
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("access", "member"));
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("ai_accounts", "member"));
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("ops", "member"));
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("storage", "member"));
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("telegram", "member"));
assertTrue(WechatSurfaceMapper.canOpenMeEntryForRole("skills", "member"));
}
@Test
public void administratorMeMenuKeepsControlEntries() {
assertArrayEquals(
new String[]{"账号与安全", "设置", "用户与权限", "运维与修复", "AI 账号", "附件与存储", "Telegram 接入", "技能", "关于"},
WechatSurfaceMapper.rootMeMenuTitlesForRole("highest_admin")
);
assertTrue(WechatSurfaceMapper.canOpenMeEntryForRole("access", "highest_admin"));
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("access", "admin"));
assertTrue(WechatSurfaceMapper.canOpenMeEntryForRole("ai_accounts", "highest_admin"));
assertTrue(WechatSurfaceMapper.canOpenMeEntryForRole("ops", "admin"));
}
}

View File

@@ -1,10 +1,13 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import android.content.Intent;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.Button;
import android.widget.TextView;
import org.json.JSONObject;
@@ -13,6 +16,7 @@ import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@@ -21,31 +25,41 @@ public class BossUiRootSurfaceTest {
@Test
public void renderMeRoot_usesWechatProfileHeaderAndFlatMenuRows() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
activity,
"setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "me"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
ReflectionHelpers.setField(
activity,
"sessionData",
new JSONObject()
.put("displayName", "Kris")
.put("account", "17600003315")
.put("account", "krisolo")
.put("role", "highest_admin")
);
ReflectionHelpers.callInstanceMethod(activity, "renderMeRoot");
LinearLayout content = activity.findViewById(R.id.screen_content);
assertEquals("我的页应是资料头 + 6 条菜单", 7, content.getChildCount());
LinearLayout content = ReflectionHelpers.getField(activity, "screenContent");
assertEquals("我的页应是资料头 + 9 条菜单", 10, content.getChildCount());
View header = content.getChildAt(0);
assertEquals("资料头不应保留浮层卡片感", 0f, header.getElevation(), 0.01f);
assertTrue(viewTreeContainsText(header, "Kris"));
assertTrue(viewTreeContainsText(header, "17600003315"));
assertTrue(viewTreeContainsText(header, "krisolo"));
assertTrue(viewTreeContainsText(header, "最高管理员"));
assertTrue(viewTreeContainsText(header, "主控账号已启用安全保护"));
assertTrue(viewTreeContainsText(content, "账号与安全"));
assertTrue(viewTreeContainsText(content, "设置"));
assertTrue(viewTreeContainsText(content, "用户与权限"));
assertTrue(viewTreeContainsText(content, "运维与修复"));
assertTrue(viewTreeContainsText(content, "AI 账号"));
assertTrue(viewTreeContainsText(content, "附件与存储"));
assertTrue(viewTreeContainsText(content, "Telegram 接入"));
assertTrue(viewTreeContainsText(content, "技能"));
assertTrue(viewTreeContainsText(content, "关于"));
@@ -55,6 +69,44 @@ public class BossUiRootSurfaceTest {
}
}
@Test
public void openMeEntry_storageStartsAttachmentStorageSettings() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(
activity,
"sessionData",
new JSONObject().put("role", "highest_admin")
);
ReflectionHelpers.callInstanceMethod(
activity,
"openMeEntry",
ReflectionHelpers.ClassParameter.from(String.class, "storage")
);
Intent started = Shadows.shadowOf(activity).getNextStartedActivity();
assertNotNull(started);
assertEquals(StorageSettingsActivity.class.getName(), started.getComponent().getClassName());
}
@Test
public void rootTabs_useWechatIconLabelNavigation() {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Button conversations = activity.findViewById(R.id.tab_conversations);
Button devices = activity.findViewById(R.id.tab_devices);
Button me = activity.findViewById(R.id.tab_me);
assertEquals("会话", conversations.getText().toString());
assertEquals("设备", devices.getText().toString());
assertEquals("我的", me.getText().toString());
assertNotNull("会话 tab 应显示顶部图标", conversations.getCompoundDrawables()[1]);
assertNotNull("设备 tab 应显示顶部图标", devices.getCompoundDrawables()[1]);
assertNotNull("我的 tab 应显示顶部图标", me.getCompoundDrawables()[1]);
assertEquals("底栏文字应压成微信式小字号", 12f, conversations.getTextSize() / activity.getResources().getDisplayMetrics().scaledDensity, 0.5f);
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();

View File

@@ -22,6 +22,7 @@ import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowDialog;
import java.time.Duration;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@@ -145,7 +146,7 @@ public class ConversationFolderActivityTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.reloadCount);
}

View File

@@ -7,6 +7,8 @@ import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.content.Intent;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
@@ -15,6 +17,7 @@ import android.widget.ListView;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.SwitchCompat;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -37,7 +40,7 @@ import java.util.concurrent.TimeUnit;
@Config(sdk = 34)
public class ConversationInfoActivityTest {
@Test
public void renderConversationUsesLightweightHeaderMenuAndThreadList() throws Exception {
public void renderConversationOmitsProfileHeaderAndStartsWithUsefulSettings() throws Exception {
Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
@@ -55,22 +58,81 @@ public class ConversationInfoActivityTest {
);
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content.getChildAt(0), "北区试产线回归"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "单线程会话"));
assertTrue(viewTreeContainsText(content.getChildAt(1), "线程状态摘要"));
assertTrue(viewTreeContainsTextFragment(content.getChildAt(1), "当前进度:已经记录最近 2 条进展"));
assertTrue(viewTreeContainsTextFragment(content.getChildAt(1), "建议下一步:继续同步 Android 只读页"));
assertTrue(viewTreeContainsText(content.getChildAt(2), "主 Agent 协同接管"));
assertTrue(viewTreeContainsText(content.getChildAt(3), "发起群聊"));
assertTrue(viewTreeContainsText(content.getChildAt(3), "选择其他线程加入新群"));
assertTrue(viewTreeContainsText(content.getChildAt(4), "线程详情"));
assertTrue(viewTreeContainsText(content.getChildAt(4), "查看当前线程聊天与项目"));
assertFalse(viewTreeContainsText(content, "线程状态摘要"));
assertFalse(viewTreeContainsTextFragment(content, "当前进度:已经记录最近 2 条进展"));
assertFalse(viewTreeContainsTextFragment(content, "建议下一步:继续同步 Android 只读页"));
assertFalse(viewTreeContainsText(content, "单线程会话"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "主 Agent 协同接管"));
assertTrue(viewTreeContainsText(content.getChildAt(1), "发起群聊"));
assertTrue(viewTreeContainsText(content.getChildAt(1), "选择其他线程加入新群"));
assertTrue(viewTreeContainsText(content.getChildAt(2), "线程详情"));
assertTrue(viewTreeContainsText(content.getChildAt(2), "查看当前线程聊天与项目"));
assertTrue(viewTreeContainsText(content, "参与线程"));
assertTrue(viewTreeContainsText(content, "硬件审计协作"));
assertFalse(viewTreeContainsText(content, "从当前会话选择其他线程,创建新的独立群聊"));
assertFalse(viewTreeContainsText(content, "以下线程参与当前会话,点击可查看对应项目详情。"));
}
@Test
public void takeoverControlUsesWechatRowVisualSystem() 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()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderConversation",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildThreadStatusPayload())
);
LinearLayout content = activity.findViewById(R.id.screen_content);
LinearLayout takeoverRow = (LinearLayout) content.getChildAt(0);
SwitchCompat takeoverSwitch = findFirstSwitch(takeoverRow);
assertEquals(LinearLayout.HORIZONTAL, takeoverRow.getOrientation());
assertEquals(BossUi.dp(activity, 18), takeoverRow.getPaddingLeft());
assertEquals(BossUi.dp(activity, 18), takeoverRow.getPaddingRight());
assertNotNull(takeoverSwitch);
assertEquals("", String.valueOf(takeoverSwitch.getText()));
}
@Test
public void conversationInfoRowsUseConsistentSpacingAndTakeoverHasNoDividerLines() 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()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderConversation",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildThreadStatusPayload())
);
LinearLayout content = activity.findViewById(R.id.screen_content);
int expectedBottomMargin = BossUi.dp(activity, 8);
for (int index = 0; index < Math.min(content.getChildCount(), 6); index += 1) {
View child = content.getChildAt(index);
assertTrue(child.getLayoutParams() instanceof LinearLayout.LayoutParams);
assertEquals(expectedBottomMargin, ((LinearLayout.LayoutParams) child.getLayoutParams()).bottomMargin);
}
View takeoverRow = content.getChildAt(0);
assertTrue(takeoverRow.getBackground() instanceof ColorDrawable);
assertEquals(Color.WHITE, ((ColorDrawable) takeoverRow.getBackground()).getColor());
}
@Test
public void threadDetailMenuRowStillOpensProjectDetail() throws Exception {
Intent intent = new Intent()
@@ -235,6 +297,42 @@ public class ConversationInfoActivityTest {
assertEquals(1, apiClient.autoLoginCalls);
}
@Test
public void saveTakeoverSettingReturnsUpdatedResultState() {
Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestConversationInfoActivity activity = Robolectric
.buildActivity(TestConversationInfoActivity.class, intent)
.setup()
.get();
RecordingBossApiClient apiClient = new RecordingBossApiClient(
activity.getSharedPreferences("conversation-info-save-result-test", Context.MODE_PRIVATE),
"https://boss.hyzq.net"
);
apiClient.failFirstLoad = false;
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.setField(activity, "reloadEnabled", true);
ReflectionHelpers.setField(activity, "delegateReloadToSuper", true);
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
activity.reload();
ShadowLooper.shadowMainLooper().idle();
ReflectionHelpers.callInstanceMethod(
activity,
"saveTakeoverSetting",
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
ShadowLooper.shadowMainLooper().idle();
assertEquals(android.app.Activity.RESULT_OK, Shadows.shadowOf(activity).getResultCode());
Intent resultIntent = Shadows.shadowOf(activity).getResultIntent();
assertNotNull(resultIntent);
assertTrue(resultIntent.getBooleanExtra(ConversationInfoActivity.EXTRA_TAKEOVER_ENABLED, false));
assertEquals("北区试产线回归", resultIntent.getStringExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME));
}
@Test
public void matchingProjectMessagesUpdatedEventTriggersReload() throws Exception {
Intent intent = new Intent()
@@ -393,6 +491,23 @@ public class ConversationInfoActivityTest {
return null;
}
private static SwitchCompat findFirstSwitch(View root) {
if (root instanceof SwitchCompat) {
return (SwitchCompat) root;
}
if (!(root instanceof ViewGroup)) {
return null;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
SwitchCompat match = findFirstSwitch(group.getChildAt(index));
if (match != null) {
return match;
}
}
return null;
}
public static class TestConversationInfoActivity extends ConversationInfoActivity {
private boolean reloadEnabled;
private boolean delegateReloadToSuper;
@@ -474,7 +589,7 @@ public class ConversationInfoActivityTest {
200,
new JSONObject()
.put("ok", true)
.put("session", new JSONObject().put("account", "17600003315"))
.put("session", new JSONObject().put("account", "krisolo"))
);
}

View File

@@ -297,7 +297,7 @@ public class DeviceDetailActivityTest {
.put("id", "device-1")
.put("name", "Mac Studio")
.put("avatar", "M")
.put("account", "17600003315")
.put("account", "krisolo")
.put("status", "online")
.put("quota5h", 75)
.put("quota7d", 88)

View File

@@ -0,0 +1,279 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import android.content.Context;
import android.content.SharedPreferences;
import android.view.View;
import org.json.JSONArray;
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 org.robolectric.util.ReflectionHelpers;
import java.time.Duration;
import java.util.function.BooleanSupplier;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class MainActivityBootstrapSessionTest {
@Test
public void bootstrapSession_withoutSessionHints_showsLoginFormAndDoesNotAutoLogin() throws Exception {
TestBootstrapSessionMainActivity activity =
Robolectric.buildActivity(TestBootstrapSessionMainActivity.class).setup().get();
SharedPreferences prefs = activity.getSharedPreferences("test-bootstrap-session", Context.MODE_PRIVATE);
prefs.edit().clear().apply();
Shadows.shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(200));
View loginPanel = activity.findViewById(R.id.login_panel);
View contentPanel = activity.findViewById(R.id.content_panel);
android.widget.EditText accountInput = activity.findViewById(R.id.login_account_input);
android.widget.EditText passwordInput = activity.findViewById(R.id.login_password_input);
assertEquals(0, activity.apiClient.autoLoginCalls);
assertEquals(View.VISIBLE, loginPanel.getVisibility());
assertEquals(View.GONE, contentPanel.getVisibility());
assertNotNull(accountInput);
assertNotNull(passwordInput);
assertFalse(accountInput.getHint().toString().isEmpty());
assertFalse(passwordInput.getHint().toString().isEmpty());
}
@Test
public void bootstrapSession_withSessionHints_prefersRestoreAndDoesNotAutoLogin() throws Exception {
TestRestoreBootstrapSessionMainActivity activity =
Robolectric.buildActivity(TestRestoreBootstrapSessionMainActivity.class).setup().get();
waitFor(() -> activity.apiClient.restoreCalls > 0 && activity.apiClient.homeCalls > 0);
View loginPanel = activity.findViewById(R.id.login_panel);
View contentPanel = activity.findViewById(R.id.content_panel);
JSONObject sessionData = ReflectionHelpers.getField(activity, "sessionData");
assertEquals(0, activity.apiClient.autoLoginCalls);
assertEquals(1, activity.apiClient.getSessionCalls);
assertEquals(1, activity.apiClient.restoreCalls);
assertEquals(View.GONE, loginPanel.getVisibility());
assertEquals(View.VISIBLE, contentPanel.getVisibility());
assertNotNull(sessionData);
assertEquals("krisolo", sessionData.optString("account", ""));
}
private static void waitFor(BooleanSupplier condition) {
long deadline = System.currentTimeMillis() + 5_000L;
while (System.currentTimeMillis() < deadline) {
Shadows.shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(50));
if (condition.getAsBoolean()) {
return;
}
}
throw new AssertionError("Condition not met before timeout");
}
public static class TestBootstrapSessionMainActivity extends MainActivity {
RecordingBootstrapApiClient apiClient;
@Override
BossApiClient createApiClient() {
apiClient = new RecordingBootstrapApiClient(
getSharedPreferences("test-bootstrap-session", Context.MODE_PRIVATE)
);
return apiClient;
}
@Override
BossRealtimeClient createRealtimeClient(BossApiClient client) {
return new BossRealtimeClient(client, new BossRealtimeClient.Listener() {
@Override
public void onRealtimeEvent(BossRealtimeEvent event) {}
});
}
}
public static class TestRestoreBootstrapSessionMainActivity extends MainActivity {
RecordingRestoreBootstrapApiClient apiClient;
@Override
BossApiClient createApiClient() {
apiClient = new RecordingRestoreBootstrapApiClient(
getSharedPreferences("test-bootstrap-session-restore", Context.MODE_PRIVATE)
);
return apiClient;
}
@Override
BossRealtimeClient createRealtimeClient(BossApiClient client) {
return new BossRealtimeClient(client, new BossRealtimeClient.Listener() {
@Override
public void onRealtimeEvent(BossRealtimeEvent event) {}
});
}
}
private static final class RecordingBootstrapApiClient extends BossApiClient {
int autoLoginCalls;
int homeCalls;
int devicesCalls;
int otaCalls;
int settingsCalls;
RecordingBootstrapApiClient(SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
}
@Override
public boolean hasSessionHints() {
return false;
}
@Override
public ApiResponse autoLogin() throws java.io.IOException, org.json.JSONException {
autoLoginCalls += 1;
JSONObject session = new JSONObject()
.put("account", "krisolo")
.put("displayName", "Boss 超级管理员")
.put("restoreToken", "restore-auto");
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("session", session));
}
@Override
public ApiResponse restoreSession() throws java.io.IOException, org.json.JSONException {
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_RESTORE_TOKEN"));
}
@Override
public ApiResponse getSession() throws java.io.IOException, org.json.JSONException {
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_SESSION"));
}
@Override
public ApiResponse getConversationHome() throws java.io.IOException, org.json.JSONException {
homeCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("conversations", new JSONArray().put(new JSONObject()
.put("projectId", "master-agent")
.put("conversationType", "master_agent")
.put("projectTitle", "主 Agent")
.put("threadTitle", "主 Agent 汇总")
.put("lastMessagePreview", "最近会话已恢复")
.put("latestReplyLabel", "刚刚"))));
}
@Override
public ApiResponse getDevices() throws java.io.IOException, org.json.JSONException {
devicesCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("devices", new JSONArray()));
}
@Override
public ApiResponse getOtaStatus() throws java.io.IOException, org.json.JSONException {
otaCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("hasOta", false));
}
@Override
public ApiResponse getSettings() throws java.io.IOException, org.json.JSONException {
settingsCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("settings", new JSONObject().put("preferredEntryPoint", "conversations"))
.put("user", new JSONObject()));
}
}
private static final class RecordingRestoreBootstrapApiClient extends BossApiClient {
int autoLoginCalls;
int getSessionCalls;
int restoreCalls;
int homeCalls;
int devicesCalls;
int otaCalls;
int settingsCalls;
RecordingRestoreBootstrapApiClient(SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
prefs.edit()
.putString("session_cookie", "boss_session=test")
.putString("restore_token", "restore-test")
.apply();
}
@Override
public ApiResponse autoLogin() throws java.io.IOException, org.json.JSONException {
autoLoginCalls += 1;
return ApiResponse.error(500, new JSONObject().put("ok", false).put("message", "AUTO_LOGIN_SHOULD_NOT_RUN"));
}
@Override
public ApiResponse getSession() throws java.io.IOException, org.json.JSONException {
getSessionCalls += 1;
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "SESSION_EXPIRED"));
}
@Override
public ApiResponse restoreSession() throws java.io.IOException, org.json.JSONException {
restoreCalls += 1;
JSONObject session = new JSONObject()
.put("account", "krisolo")
.put("displayName", "Boss 超级管理员")
.put("restoreToken", "restore-test");
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("session", session));
}
@Override
public ApiResponse getConversationHome() throws java.io.IOException, org.json.JSONException {
homeCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("conversations", new JSONArray().put(new JSONObject()
.put("projectId", "master-agent")
.put("conversationType", "master_agent")
.put("projectTitle", "主 Agent")
.put("threadTitle", "主 Agent 汇总")
.put("lastMessagePreview", "最近会话已恢复")
.put("latestReplyLabel", "刚刚"))));
}
@Override
public ApiResponse getDevices() throws java.io.IOException, org.json.JSONException {
devicesCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("devices", new JSONArray()));
}
@Override
public ApiResponse getOtaStatus() throws java.io.IOException, org.json.JSONException {
otaCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("hasOta", false));
}
@Override
public ApiResponse getSettings() throws java.io.IOException, org.json.JSONException {
settingsCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("settings", new JSONObject().put("preferredEntryPoint", "conversations"))
.put("user", new JSONObject()));
}
}
}

View File

@@ -1,13 +1,21 @@
package com.hyzq.boss;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import android.Manifest;
import android.content.Context;
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.ShadowApplication;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@@ -18,6 +26,10 @@ public class MainActivityConversationAutoRefreshTest {
org.robolectric.android.controller.ActivityController<MainActivity> controller =
Robolectric.buildActivity(MainActivity.class).setup().resume();
MainActivity activity = controller.get();
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
.edit()
.putString("session_cookie", "boss_session=test")
.apply();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
assertTrue(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
@@ -35,4 +47,53 @@ public class MainActivityConversationAutoRefreshTest {
controller.pause();
assertFalse(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
}
@Test
public void returningToVisibleConversationRootRefreshesImmediatelyOnResume() {
org.robolectric.android.controller.ActivityController<TestResumeRefreshMainActivity> controller =
Robolectric.buildActivity(TestResumeRefreshMainActivity.class).setup().resume();
TestResumeRefreshMainActivity activity = controller.get();
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
.edit()
.putString("session_cookie", "boss_session=test")
.apply();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
activity.conversationRefreshCount = 0;
controller.pause();
controller.resume();
assertEquals(1, activity.conversationRefreshCount);
}
@Test
public void showContent_doesNotRequestNotificationPermissionInSameTapFrame() {
ShadowApplication.getInstance().denyPermissions(Manifest.permission.POST_NOTIFICATIONS);
org.robolectric.android.controller.ActivityController<MainActivity> controller =
Robolectric.buildActivity(MainActivity.class).setup();
MainActivity activity = controller.get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
assertNull(Shadows.shadowOf(activity).getLastRequestedPermission());
Shadows.shadowOf(activity.getMainLooper()).idleFor(java.time.Duration.ofMillis(500));
assertNotNull(Shadows.shadowOf(activity).getLastRequestedPermission());
assertEquals(1, Shadows.shadowOf(activity).getLastRequestedPermission().requestedPermissions.length);
assertEquals(
Manifest.permission.POST_NOTIFICATIONS,
Shadows.shadowOf(activity).getLastRequestedPermission().requestedPermissions[0]
);
}
public static class TestResumeRefreshMainActivity extends MainActivity {
int conversationRefreshCount;
@Override
void refreshConversationsData() {
conversationRefreshCount += 1;
completeRealtimeTabRefresh();
}
}
}

View File

@@ -7,6 +7,7 @@ import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.content.Intent;
import android.Manifest;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
@@ -25,6 +26,7 @@ import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.shadow.api.Shadow;
import org.robolectric.shadows.ShadowInputMethodManager;
import org.robolectric.shadows.ShadowApplication;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@@ -148,6 +150,7 @@ public class MainActivityConversationSearchTest {
@Test
public void searchHitOnSingleThread_exitsSearchModeAndOpensProjectDetail() throws Exception {
ShadowApplication.getInstance().grantPermissions(Manifest.permission.POST_NOTIFICATIONS);
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.callInstanceMethod(activity, "showContent");
@@ -180,6 +183,7 @@ public class MainActivityConversationSearchTest {
@Test
public void searchHitInsideArchivedProject_opensMatchedThreadDetailAndClearsSearchState() throws Exception {
ShadowApplication.getInstance().grantPermissions(Manifest.permission.POST_NOTIFICATIONS);
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", new JSONArray()
.put(new JSONObject()
@@ -221,6 +225,7 @@ public class MainActivityConversationSearchTest {
@Test
public void archivedProjectSearchByFolderName_stillOpensFolderPage() throws Exception {
ShadowApplication.getInstance().grantPermissions(Manifest.permission.POST_NOTIFICATIONS);
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", new JSONArray()
.put(new JSONObject()

View File

@@ -90,6 +90,7 @@ public class MainActivityConversationSelectionTest {
public void topPlusAction_opensWechatStyleDropdownMenu() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.setField(activity, "sessionData", new JSONObject().put("role", "highest_admin"));
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
@@ -106,6 +107,27 @@ public class MainActivityConversationSelectionTest {
assertTrue(viewTreeContainsText(menu, "发起群聊"));
}
@Test
public void topPlusAction_hidesAddDeviceForSubAccount() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.setField(activity, "sessionData", new JSONObject().put("role", "member"));
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ImageButton actionButton = activity.findViewById(R.id.refresh_button);
actionButton.performClick();
Shadows.shadowOf(activity.getMainLooper()).idle();
View overlay = activity.findViewById(R.id.conversation_quick_actions_overlay);
View menu = activity.findViewById(R.id.conversation_quick_actions_menu);
assertEquals(View.VISIBLE, overlay.getVisibility());
assertEquals(View.VISIBLE, menu.getVisibility());
assertFalse(viewTreeContainsVisibleText(menu, "添加设备"));
assertTrue(viewTreeContainsVisibleText(menu, "扫一扫"));
assertTrue(viewTreeContainsVisibleText(menu, "发起群聊"));
}
private static View getRecyclerChild(RecyclerView recyclerView, int position) {
RecyclerView.Adapter adapter = recyclerView.getAdapter();
int viewType = adapter.getItemViewType(position);
@@ -188,6 +210,28 @@ public class MainActivityConversationSelectionTest {
return false;
}
private static boolean viewTreeContainsVisibleText(View root, String expectedText) {
if (root.getVisibility() != View.VISIBLE) {
return false;
}
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (expectedText.contentEquals(text)) {
return true;
}
}
if (!(root instanceof LinearLayout)) {
return false;
}
LinearLayout group = (LinearLayout) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsVisibleText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
private static boolean viewTreeContainsContentDescription(View root, String expectedText) {
CharSequence description = root.getContentDescription();
if (expectedText.contentEquals(description)) {

View File

@@ -31,7 +31,7 @@ public class MainActivityDevicesRootTest {
.put("name", "Mac Studio")
.put("status", "online")
.put("platform", "macOS")
.put("account", "17600003315")));
.put("account", "krisolo")));
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",

View File

@@ -3,6 +3,7 @@ package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Looper;
import org.json.JSONObject;
import org.json.JSONArray;
@@ -15,6 +16,7 @@ import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
import java.io.IOException;
import java.time.Duration;
import java.util.function.BooleanSupplier;
@RunWith(RobolectricTestRunner.class)
@@ -24,6 +26,15 @@ public class MainActivityRealtimeTest {
public void conversationRealtimeEventRefreshesVisibleConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
activity,
"setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
activity.conversationRefreshCount = 0;
activity.deviceRefreshCount = 0;
activity.meRefreshCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -33,6 +44,8 @@ public class MainActivityRealtimeTest {
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
@@ -78,6 +91,15 @@ public class MainActivityRealtimeTest {
public void deviceScopedConversationEventRefreshesVisibleConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
activity,
"setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
activity.conversationRefreshCount = 0;
activity.deviceRefreshCount = 0;
activity.meRefreshCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -87,6 +109,8 @@ public class MainActivityRealtimeTest {
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
@@ -108,6 +132,8 @@ public class MainActivityRealtimeTest {
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
@@ -129,13 +155,15 @@ public class MainActivityRealtimeTest {
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
}
@Test
public void distinctConversationEventsBackToBackBothRefreshVisibleConversationTab() throws Exception {
public void distinctConversationEventsBackToBackCoalesceIntoSingleVisibleConversationRefresh() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
@@ -161,8 +189,10 @@ public class MainActivityRealtimeTest {
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(2, activity.conversationRefreshCount);
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
}
@@ -176,6 +206,9 @@ public class MainActivityRealtimeTest {
ReflectionHelpers.ClassParameter.from(String.class, "devices"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
activity.conversationRefreshCount = 0;
activity.deviceRefreshCount = 0;
activity.meRefreshCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -187,6 +220,8 @@ public class MainActivityRealtimeTest {
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.deviceRefreshCount);
assertEquals(0, activity.meRefreshCount);
}
@@ -201,6 +236,9 @@ public class MainActivityRealtimeTest {
ReflectionHelpers.ClassParameter.from(String.class, "me"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
activity.conversationRefreshCount = 0;
activity.deviceRefreshCount = 0;
activity.meRefreshCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -213,6 +251,8 @@ public class MainActivityRealtimeTest {
assertEquals(0, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
assertEquals(0, activity.meRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.meRefreshCount);
}
@@ -220,6 +260,15 @@ public class MainActivityRealtimeTest {
public void burstConversationRealtimeEventsCoalesceIntoSingleFollowUpRefresh() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
activity,
"setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
activity.conversationRefreshCount = 0;
activity.deviceRefreshCount = 0;
activity.meRefreshCount = 0;
ReflectionHelpers.setField(activity, "rootTabRefreshInFlight", true);
ReflectionHelpers.callInstanceMethod(
@@ -253,6 +302,7 @@ public class MainActivityRealtimeTest {
assertEquals(0, activity.conversationRefreshCount);
activity.completeRealtimeTabRefresh();
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
waitFor(() -> activity.conversationRefreshCount == 1);
assertEquals(1, activity.conversationRefreshCount);
@@ -282,7 +332,7 @@ public class MainActivityRealtimeTest {
}
@Test
public void refreshConversationsData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception {
public void refreshConversationsData_prefersGroupedHomeFeedOverFlatConversationFeed() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
@@ -294,18 +344,47 @@ public class MainActivityRealtimeTest {
Shadows.shadowOf(activity.getMainLooper()).idle();
activity.refreshConversationsData();
waitFor(() -> apiClient.homeCalls > 0 || apiClient.conversationsCalls > 0);
waitFor(() -> apiClient.homeCalls > 0);
assertEquals(1, apiClient.homeCalls);
assertEquals(0, apiClient.conversationsCalls);
}
@Test
public void refreshConversationsData_prefersGroupedHomeFeedForRootList() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
SharedPreferences prefs = activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE);
prefs.edit()
.putString("session_cookie", "boss_session=test")
.putString("restore_token", "restore-test")
.apply();
RecordingConversationSourceClient apiClient = new RecordingConversationSourceClient(
prefs
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
activity.refreshConversationsData();
waitFor(() -> apiClient.homeCalls > 0);
assertEquals(1, apiClient.homeCalls);
assertEquals(0, apiClient.conversationsCalls);
JSONArray conversationsData = ReflectionHelpers.getField(activity, "conversationsData");
assertEquals(1, conversationsData.length());
assertEquals("folder_archive", conversationsData.optJSONObject(0).optString("conversationType", ""));
assertEquals("mac-studio:boss", conversationsData.optJSONObject(0).optString("projectId", ""));
assertEquals(2, conversationsData.optJSONObject(0).optInt("threadCount", 0));
}
@Test
public void refreshConversationsData_groupsFlatFallbackFeedWhenHomeFeedFails() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
RecordingRejectedConversationSourceClient apiClient = new RecordingRejectedConversationSourceClient(
RecordingRejectedHomeConversationSourceClient apiClient = new RecordingRejectedHomeConversationSourceClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
@@ -328,7 +407,7 @@ public class MainActivityRealtimeTest {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
RecordingIOExceptionConversationSourceClient apiClient = new RecordingIOExceptionConversationSourceClient(
RecordingIOExceptionHomeConversationSourceClient apiClient = new RecordingIOExceptionHomeConversationSourceClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
@@ -347,7 +426,7 @@ public class MainActivityRealtimeTest {
}
@Test
public void refreshAllData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception {
public void refreshAllData_prefersGroupedHomeFeedOverFlatConversationFeed() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
@@ -363,18 +442,51 @@ public class MainActivityRealtimeTest {
"refreshAllData",
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject())
);
waitFor(() -> apiClient.homeCalls > 0 || apiClient.conversationsCalls > 0);
waitFor(() -> apiClient.homeCalls > 0);
assertEquals(1, apiClient.homeCalls);
assertEquals(0, apiClient.conversationsCalls);
}
@Test
public void refreshAllData_prefersGroupedHomeFeedForRootList() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
SharedPreferences prefs = activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE);
prefs.edit()
.putString("session_cookie", "boss_session=test")
.putString("restore_token", "restore-test")
.apply();
RecordingConversationSourceClient apiClient = new RecordingConversationSourceClient(
prefs
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"refreshAllData",
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject())
);
waitFor(() -> apiClient.homeCalls > 0);
assertEquals(1, apiClient.homeCalls);
assertEquals(0, apiClient.conversationsCalls);
JSONArray conversationsData = ReflectionHelpers.getField(activity, "conversationsData");
assertEquals(1, conversationsData.length());
assertEquals("folder_archive", conversationsData.optJSONObject(0).optString("conversationType", ""));
assertEquals("mac-studio:boss", conversationsData.optJSONObject(0).optString("projectId", ""));
assertEquals(2, conversationsData.optJSONObject(0).optInt("threadCount", 0));
}
@Test
public void refreshAllData_groupsFlatFallbackFeedWhenHomeFeedFails() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
RecordingRejectedConversationSourceClient apiClient = new RecordingRejectedConversationSourceClient(
RecordingRejectedHomeConversationSourceClient apiClient = new RecordingRejectedHomeConversationSourceClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
@@ -401,7 +513,7 @@ public class MainActivityRealtimeTest {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
RecordingIOExceptionConversationSourceClient apiClient = new RecordingIOExceptionConversationSourceClient(
RecordingIOExceptionHomeConversationSourceClient apiClient = new RecordingIOExceptionHomeConversationSourceClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
@@ -445,6 +557,13 @@ public class MainActivityRealtimeTest {
int deviceRefreshCount;
int meRefreshCount;
@Override
BossApiClient createApiClient() {
SharedPreferences prefs = getSharedPreferences("boss_native_client", Context.MODE_PRIVATE);
prefs.edit().clear().apply();
return new InertBootstrapApiClient(prefs);
}
@Override
void refreshConversationsData() {
conversationRefreshCount += 1;
@@ -464,7 +583,28 @@ public class MainActivityRealtimeTest {
}
}
private static final class RecordingRejectedConversationSourceClient extends BossApiClient {
private static final class InertBootstrapApiClient extends BossApiClient {
InertBootstrapApiClient(SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
}
@Override
public ApiResponse autoLogin() throws IOException, org.json.JSONException {
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "TEST_BOOTSTRAP_DISABLED"));
}
@Override
public ApiResponse restoreSession() throws IOException, org.json.JSONException {
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "TEST_BOOTSTRAP_DISABLED"));
}
@Override
public ApiResponse getSession() throws IOException, org.json.JSONException {
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "TEST_BOOTSTRAP_DISABLED"));
}
}
private static final class RecordingRejectedHomeConversationSourceClient extends BossApiClient {
int homeCalls;
int conversationsCalls;
int sessionCalls;
@@ -472,7 +612,7 @@ public class MainActivityRealtimeTest {
int settingsCalls;
int otaCalls;
RecordingRejectedConversationSourceClient(android.content.SharedPreferences prefs) {
RecordingRejectedHomeConversationSourceClient(android.content.SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
}
@@ -489,7 +629,7 @@ public class MainActivityRealtimeTest {
conversationsCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("conversations", buildFlatConversations()));
.put("conversations", RecordingConversationSourceClient.buildFlatConversations()));
}
@Override
@@ -498,7 +638,7 @@ public class MainActivityRealtimeTest {
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("session", new JSONObject()
.put("account", "17600003315")
.put("account", "krisolo")
.put("displayName", "Boss 超级管理员")));
}
@@ -526,32 +666,6 @@ public class MainActivityRealtimeTest {
.put("ok", true)
.put("hasOta", false));
}
private static JSONArray buildFlatConversations() throws org.json.JSONException {
return new JSONArray()
.put(new JSONObject()
.put("projectId", "thread-revert")
.put("conversationType", "single_device")
.put("projectTitle", "发布回滚")
.put("threadTitle", "发布回滚")
.put("folderLabel", "Boss")
.put("folderKey", "mac-studio:boss")
.put("lastMessagePreview", "最近:发布回滚")
.put("latestReplyAt", "2026-04-06T10:00:00.000Z")
.put("latestReplyLabel", "11:00")
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 80).put("level", "watch")))
.put(new JSONObject()
.put("projectId", "thread-ui")
.put("conversationType", "single_device")
.put("projectTitle", "Android UI 收尾")
.put("threadTitle", "Android UI 收尾")
.put("folderLabel", "Boss")
.put("folderKey", "mac-studio:boss")
.put("lastMessagePreview", "最近Android UI 收尾")
.put("latestReplyAt", "2026-04-06T09:59:00.000Z")
.put("latestReplyLabel", "10:59")
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 95).put("level", "safe")));
}
}
private static final class RecordingConversationSourceClient extends BossApiClient {
@@ -588,7 +702,7 @@ public class MainActivityRealtimeTest {
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("session", new JSONObject()
.put("account", "17600003315")
.put("account", "krisolo")
.put("displayName", "Boss 超级管理员")));
}
@@ -619,13 +733,15 @@ public class MainActivityRealtimeTest {
private static JSONArray buildHomeConversations() throws org.json.JSONException {
return new JSONArray().put(new JSONObject()
.put("projectId", "folder-boss")
.put("projectId", "mac-studio:boss")
.put("conversationType", "folder_archive")
.put("folderKey", "mac-studio:boss")
.put("projectTitle", "Boss")
.put("threadTitle", "Boss")
.put("threadCount", 2)
.put("folderLabel", "2 个线程 · 最近:发布回滚")
.put("searchAliases", new JSONArray().put("发布回滚").put("Android UI 收尾"))
.put("searchTargetProjectIds", new JSONArray().put("thread-revert").put("thread-ui"))
.put("lastMessagePreview", "最近:发布回滚")
.put("latestReplyLabel", "11:00"));
}
@@ -657,7 +773,7 @@ public class MainActivityRealtimeTest {
}
}
private static final class RecordingIOExceptionConversationSourceClient extends BossApiClient {
private static final class RecordingIOExceptionHomeConversationSourceClient extends BossApiClient {
int homeCalls;
int conversationsCalls;
int sessionCalls;
@@ -665,7 +781,7 @@ public class MainActivityRealtimeTest {
int settingsCalls;
int otaCalls;
RecordingIOExceptionConversationSourceClient(android.content.SharedPreferences prefs) {
RecordingIOExceptionHomeConversationSourceClient(android.content.SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
}
@@ -680,7 +796,7 @@ public class MainActivityRealtimeTest {
conversationsCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("conversations", buildFlatConversations()));
.put("conversations", RecordingConversationSourceClient.buildFlatConversations()));
}
@Override
@@ -689,7 +805,7 @@ public class MainActivityRealtimeTest {
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("session", new JSONObject()
.put("account", "17600003315")
.put("account", "krisolo")
.put("displayName", "Boss 超级管理员")));
}
@@ -717,31 +833,5 @@ public class MainActivityRealtimeTest {
.put("ok", true)
.put("hasOta", false));
}
private static JSONArray buildFlatConversations() throws org.json.JSONException {
return new JSONArray()
.put(new JSONObject()
.put("projectId", "thread-revert")
.put("conversationType", "single_device")
.put("projectTitle", "发布回滚")
.put("threadTitle", "发布回滚")
.put("folderLabel", "Boss")
.put("folderKey", "mac-studio:boss")
.put("lastMessagePreview", "最近:发布回滚")
.put("latestReplyAt", "2026-04-06T10:00:00.000Z")
.put("latestReplyLabel", "11:00")
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 80).put("level", "watch")))
.put(new JSONObject()
.put("projectId", "thread-ui")
.put("conversationType", "single_device")
.put("projectTitle", "Android UI 收尾")
.put("threadTitle", "Android UI 收尾")
.put("folderLabel", "Boss")
.put("folderKey", "mac-studio:boss")
.put("lastMessagePreview", "最近Android UI 收尾")
.put("latestReplyAt", "2026-04-06T09:59:00.000Z")
.put("latestReplyLabel", "10:59")
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 95).put("level", "safe")));
}
}
}

View File

@@ -176,7 +176,7 @@ public class MasterAgentTakeoverActivityTest {
200,
new JSONObject()
.put("ok", true)
.put("session", new JSONObject().put("account", "17600003315"))
.put("session", new JSONObject().put("account", "krisolo"))
);
}

View File

@@ -59,6 +59,14 @@ public class ProjectChatUiStateTest {
assertTrue(ProjectChatUiState.canForwardSelection(next));
}
@Test
public void copySelectionRequiresAtLeastOneMessage() {
assertFalse(ProjectChatUiState.canCopySelection(ProjectChatUiState.emptySelection()));
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
assertTrue(ProjectChatUiState.canCopySelection(state));
}
@Test
public void selectionPreservesInsertionOrder() {
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m2");
@@ -104,6 +112,7 @@ public class ProjectChatUiStateTest {
assertTrue(chromeState.showMultiSelectBar);
assertFalse(chromeState.showRefresh);
assertFalse(chromeState.showHeaderAction);
assertTrue(chromeState.copyEnabled);
assertTrue(chromeState.forwardEnabled);
assertEquals("取消", chromeState.backLabel);
assertEquals("已选 2 条", chromeState.title);
@@ -120,6 +129,7 @@ public class ProjectChatUiStateTest {
assertFalse(chromeState.showMultiSelectBar);
assertFalse(chromeState.showRefresh);
assertTrue(chromeState.showHeaderAction);
assertFalse(chromeState.copyEnabled);
assertFalse(chromeState.forwardEnabled);
assertEquals("返回", chromeState.backLabel);
assertEquals("北区试产线回归", chromeState.title);
@@ -136,6 +146,7 @@ public class ProjectChatUiStateTest {
assertFalse(chromeState.showMultiSelectBar);
assertTrue(chromeState.showRefresh);
assertFalse(chromeState.showHeaderAction);
assertFalse(chromeState.copyEnabled);
assertFalse(chromeState.forwardEnabled);
assertEquals("返回", chromeState.backLabel);
assertEquals("北区试产线回归", chromeState.title);
@@ -196,9 +207,10 @@ public class ProjectChatUiStateTest {
}
@Test
public void queuedReplyTaskStartsReplyWaitFromRequestMessageId() throws Exception {
public void queuedReplyTaskStartsReplyWaitFromImmediateReplyWhenPresent() throws Exception {
JSONObject response = new JSONObject()
.put("message", new JSONObject().put("id", "msg-user-1"))
.put("replyMessage", new JSONObject().put("id", "msg-master-ack-1"))
.put("task", new JSONObject()
.put("taskId", "task-1")
.put("taskType", "conversation_reply")
@@ -207,7 +219,7 @@ public class ProjectChatUiStateTest {
ProjectChatUiState.ReplyWaitSpec waitSpec = ProjectChatUiState.resolveReplyWaitAfterSend(response);
assertTrue(waitSpec.shouldWait);
assertEquals("msg-user-1", waitSpec.baselineMessageId);
assertEquals("msg-master-ack-1", waitSpec.baselineMessageId);
}
@Test
@@ -250,6 +262,318 @@ public class ProjectChatUiStateTest {
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, ""));
}
@Test
public void replyWaitIgnoresDuplicateBaselineMessages() throws Exception {
JSONObject project = new JSONObject()
.put("messages", new JSONArray()
.put(new JSONObject().put("id", "msg-user-1"))
.put(new JSONObject().put("id", "msg-user-1")));
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, "msg-user-1"));
}
@Test
public void timedOutMasterRelayKeepsConversationPollingEvenWhenRealtimeConnected() {
assertTrue(ProjectChatUiState.shouldAutoRefreshConversation(true, true, true));
assertTrue(ProjectChatUiState.shouldAutoRefreshConversation(true, false, false));
assertFalse(ProjectChatUiState.shouldAutoRefreshConversation(true, true, false));
assertFalse(ProjectChatUiState.shouldAutoRefreshConversation(false, true, true));
}
@Test
public void threadProcessMessagesAreCollapsedBeforeFinalResult() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "u1")
.put("sender", "user")
.put("body", "继续"))
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("body", "我先看一下当前聊天渲染链路和消息结构。"))
.put(new JSONObject()
.put("id", "p2")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("body", "接下来我会补一组单元测试,再把折叠 UI 接上。"))
.put(new JSONObject()
.put("id", "r1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("body", "这轮已经接好过程折叠,最终结果现在直接显示在主消息流里。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(3, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
assertEquals("u1", items.get(0).message.optString("id"));
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
assertEquals(2, items.get(1).processMessages.size());
assertEquals("p1", items.get(1).processMessages.get(0).optString("id"));
assertEquals("p2", items.get(1).processMessages.get(1).optString("id"));
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
assertEquals("r1", items.get(2).message.optString("id"));
}
@Test
public void errorMessagesStayVisibleInsteadOfBeingCollapsed() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "e1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("body", "当前执行失败,构建报错,需要先补依赖。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(1, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
assertEquals("e1", items.get(0).message.optString("id"));
}
@Test
public void processGroupPreviewUsesLatestProgressLine() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("body", "我先检查项目结构。"))
.put(new JSONObject()
.put("id", "p2")
.put("sender", "device")
.put("body", "接下来开始补聊天折叠按钮。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(1, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
assertEquals("接下来开始补聊天折叠按钮。", ProjectChatUiState.processGroupPreview(items.get(0)));
}
@Test
public void explicitThreadProcessKindIsCollapsedEvenWhenCopyLooksLikeACompletionUpdate() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "thread_process")
.put("body", "工程骨架已经建好了,我现在开始写核心代码。"))
.put(new JSONObject()
.put("id", "p2")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "thread_process")
.put("body", "编译错误已定位到导入问题,我已修复并正在重新构建确认。"))
.put(new JSONObject()
.put("id", "r1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "text")
.put("body", "已完成折叠修复,过程消息会收进按钮里,未读只增加一次。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(2, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
assertEquals(2, items.get(0).processMessages.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(1).type);
assertEquals("r1", items.get(1).message.optString("id"));
}
@Test
public void executionProgressCardsStayVisibleBetweenProcessGroups() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "thread_process")
.put("body", "我先检查当前执行链路。"))
.put(new JSONObject()
.put("id", "progress-1")
.put("sender", "master")
.put("senderLabel", "主 Agent")
.put("kind", "execution_progress")
.put("body", "执行进度")
.put("executionProgress", new JSONObject()
.put("status", "running")
.put("steps", new JSONArray()
.put(new JSONObject().put("text", "接收对话任务").put("status", "done"))
.put(new JSONObject().put("text", "等待目标线程回复").put("status", "running")))))
.put(new JSONObject()
.put("id", "p2")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "thread_process")
.put("body", "我继续执行验证。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(3, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(1).type);
assertEquals("progress-1", items.get(1).message.optString("id"));
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(2).type);
}
@Test
public void processGroupKeepsFinalResultVisibleWhenProcessMessagesCarryThreadProcessKind() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "u1")
.put("sender", "user")
.put("body", "继续推进"))
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "thread_process")
.put("body", "我先检查聊天折叠链路,确认过程消息不会直接展开。"))
.put(new JSONObject()
.put("id", "r1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "text")
.put("body", "这轮已经完成折叠修复,未读现在只会算最终结果。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(3, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
assertEquals(1, items.get(1).processMessages.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
assertEquals("r1", items.get(2).message.optString("id"));
}
@Test
public void numberedProgressUpdatesAreCollapsedWhenMarkedAsThreadProcess() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "u1")
.put("sender", "user")
.put("body", "继续处理"))
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "thread_process")
.put("body", "1. 先检查当前消息折叠链路。\\n2. 再确认 Android 端只把最终结果记成未读。\\n3. 处理完成后我会回你最终结果。"))
.put(new JSONObject()
.put("id", "r1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "text")
.put("body", "这轮已经处理完成,最终结果已回写。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(3, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
assertEquals(1, items.get(1).processMessages.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
assertEquals("r1", items.get(2).message.optString("id"));
}
@Test
public void numberedProgressUpdatesWithoutKindStillCollapseBeforeFinalResult() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "u1")
.put("sender", "user")
.put("body", "继续处理"))
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("body", "1. 先检查当前消息折叠链路。\n2. 再确认 Android 端只把最终结果记成未读。\n3. 处理完成后我会回你最终结果。"))
.put(new JSONObject()
.put("id", "r1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("body", "这轮已经处理完成,最终结果已回写。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(3, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
assertEquals(1, items.get(1).processMessages.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
assertEquals("r1", items.get(2).message.optString("id"));
}
@Test
public void progressUpdatesStartingWithWoZheBianYiJingStillCollapseIntoProcessGroup() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "u1")
.put("sender", "user")
.put("body", "继续"))
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("body", "我这边已经查了adb 现在还只看到一台 USB 连着的 PHZ110PLB110 的无线目标还没有被发现出来。"))
.put(new JSONObject()
.put("id", "r1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "text")
.put("body", "无线调试已经接通,最新 debug 包也装好了。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(3, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
assertEquals(1, items.get(1).processMessages.size());
assertEquals("p1", items.get(1).processMessages.get(0).optString("id"));
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
assertEquals("r1", items.get(2).message.optString("id"));
}
@Test
public void realThreadPlanningCopyIsCollapsedButSavedResultStaysVisible() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Andorid")
.put("body", "我发现当前这个仓库快照里没有 ios/ 目录,所以这份报告会明确分成两层。"))
.put(new JSONObject()
.put("id", "p2")
.put("sender", "device")
.put("senderLabel", "Andorid")
.put("body", "我准备新增一份 doc/iOS实时转写开发交接报告_20260419.md。"))
.put(new JSONObject()
.put("id", "r1")
.put("sender", "device")
.put("senderLabel", "Andorid")
.put("body", "报告已经落盘了。我再快速过一遍这份文档的结构和措辞。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(2, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
assertEquals(2, items.get(0).processMessages.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(1).type);
assertEquals("r1", items.get(1).message.optString("id"));
}
@Test
public void threadExecutionConflictCopyExplainsPreferredGuiModeAsProjectScoped() throws Exception {
JSONObject conflict = new JSONObject()

View File

@@ -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;
@@ -113,7 +114,7 @@ public class ProjectDetailActivityMasterAgentMenuTest {
}
@Test
public void normalConversationMoreMenuShowsInfoAndRefresh() {
public void normalConversationHeaderActionOpensConversationInfoDirectly() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss 移动控制台");
@@ -122,15 +123,11 @@ public class ProjectDetailActivityMasterAgentMenuTest {
.setup()
.get();
ReflectionHelpers.callInstanceMethod(activity, "showConversationMoreMenu");
ReflectionHelpers.callInstanceMethod(activity, "openConversationInfo");
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
assertTrue(latestDialog instanceof AlertDialog);
AlertDialog actionDialog = (AlertDialog) latestDialog;
ListView listView = actionDialog.getListView();
assertMenuItem(listView, 0, "会话信息");
assertMenuItem(listView, 1, "刷新");
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
assertNotNull(nextIntent);
assertEquals(ConversationInfoActivity.class.getName(), nextIntent.getComponent().getClassName());
}
@Test

View File

@@ -1,11 +1,19 @@
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.fail;
import static org.junit.Assert.assertTrue;
import android.app.Dialog;
import android.app.NotificationManager;
import android.content.Context;
import android.content.Intent;
import android.os.Looper;
import android.view.View;
import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -13,8 +21,12 @@ import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowApplication;
import org.robolectric.shadows.ShadowDialog;
import org.robolectric.shadows.ShadowNotificationManager;
import org.robolectric.util.ReflectionHelpers;
import java.util.concurrent.CountDownLatch;
@@ -43,7 +55,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(0, activity.reloadCount);
assertEquals(1, activity.messageReloadCount);
@@ -68,7 +80,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(0, activity.reloadCount);
}
@@ -92,7 +104,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-2"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(0, activity.reloadCount);
}
@@ -130,10 +142,10 @@ public class ProjectDetailActivityRealtimeTest {
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(1, activity.reloadCount);
assertEquals(1, activity.messageReloadCount);
assertEquals(0, activity.messageReloadCount);
}
@Test
@@ -158,7 +170,7 @@ public class ProjectDetailActivityRealtimeTest {
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(1, activity.reloadCount);
assertEquals(0, activity.messageReloadCount);
@@ -197,12 +209,162 @@ public class ProjectDetailActivityRealtimeTest {
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(0, activity.reloadCount);
assertEquals(1, activity.messageReloadCount);
}
@Test
public void dialogGuardInterventionRequiredShowsBlockedSafeActionDialog() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
TestRealtimeProjectDetailActivity activity = Robolectric
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
.setup()
.resume()
.get();
RecordingDialogGuardApiClient apiClient = new RecordingDialogGuardApiClient();
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent(
"desktop.dialog_guard.intervention_required",
new JSONObject()
.put("interventionId", "intervention-1")
.put("dialogId", "dialog-1")
.put("requestId", "request-1")
.put("taskId", "task-1")
.put("deviceId", "mac-studio")
.put("projectId", "project-1")
.put("appName", "微信")
.put("platform", "macos")
.put("risk", "blocked")
.put("summary", "微信正在请求读取敏感通讯录权限")
.put("recommendedAction", "handled_on_device")
.put("availableActions", new JSONArray()
.put("allow_once")
.put("allow_for_device_dialog")
.put("deny")
.put("handled_on_device")
.put("cancel_task"))
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
Dialog latestDialog = ShadowDialog.getLatestDialog();
assertTrue(latestDialog instanceof AlertDialog);
AlertDialog dialog = (AlertDialog) latestDialog;
assertTrue(dialog.isShowing());
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "微信"));
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "微信正在请求读取敏感通讯录权限"));
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "我已在电脑上处理"));
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "取消任务"));
assertFalse(viewTreeContainsText(dialog.getWindow().getDecorView(), "允许本次"));
assertFalse(viewTreeContainsText(dialog.getWindow().getDecorView(), "当前设备此弹窗允许"));
View handledButton = findClickableViewContainingText(dialog.getWindow().getDecorView(), "我已在电脑上处理");
assertNotNull(handledButton);
handledButton.performClick();
waitFor(() -> apiClient.decisionCallCount == 1);
assertEquals("intervention-1", apiClient.lastInterventionId);
assertEquals("handled_on_device", apiClient.lastDecision);
}
@Test
public void dialogGuardResolvedEventClosesMatchingDialog() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
TestRealtimeProjectDetailActivity activity = Robolectric
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
.setup()
.resume()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent(
"desktop.dialog_guard.intervention_required",
new JSONObject()
.put("interventionId", "intervention-2")
.put("projectId", "project-1")
.put("appName", "访达")
.put("risk", "safe")
.put("summary", "确认打开下载文件")
.put("availableActions", new JSONArray().put("allow_once").put("deny"))
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertTrue(dialog.isShowing());
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent(
"desktop.dialog_guard.intervention_resolved",
new JSONObject()
.put("interventionId", "intervention-2")
.put("projectId", "project-1")
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertFalse(dialog.isShowing());
}
@Test
public void openingMasterAgentConversationClearsPendingMasterAgentNotification() throws Exception {
Context context = RuntimeEnvironment.getApplication();
BossApplication application = (BossApplication) context.getApplicationContext();
ShadowApplication.getInstance().grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS);
ShadowNotificationManager notificationManager = Shadows.shadowOf(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
);
application.visibilityTracker().onAppBackgrounded();
JSONObject message = new JSONObject()
.put("id", "master-msg-1")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "主 Agent 后台回复");
JSONObject payload = new JSONObject()
.put("projectId", "master-agent")
.put("projectMessagesPayload", new JSONObject().put(
"project",
new JSONObject().put("messages", new JSONArray().put(message))
));
assertTrue(application.notificationRouter().maybeNotifyForRealtimeEvent(
new BossRealtimeEvent("project.messages.updated", payload)
));
assertEquals(1, notificationManager.size());
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
Robolectric.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
.setup()
.resume()
.get();
assertEquals(0, notificationManager.size());
}
@Test
public void burstRealtimeEventsWhileReloadingCoalesceIntoSingleFollowUpReload() throws Exception {
Intent intent = new Intent()
@@ -224,7 +386,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertTrue(activity.awaitFirstLoadStarted());
ReflectionHelpers.callInstanceMethod(
@@ -243,7 +405,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(0, activity.loadCallCount);
assertEquals(1, activity.messageLoadCallCount);
@@ -277,7 +439,7 @@ public class ProjectDetailActivityRealtimeTest {
"handleRealtimeConnectionChanged",
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(1, activity.reloadCount);
}
@@ -317,6 +479,49 @@ public class ProjectDetailActivityRealtimeTest {
fail("condition not met before timeout");
}
private static void drainRealtimeDebounce(TestRealtimeProjectDetailActivity activity) {
Shadows.shadowOf(activity.getMainLooper()).idleFor(350, TimeUnit.MILLISECONDS);
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof android.widget.TextView) {
CharSequence text = ((android.widget.TextView) root).getText();
if (expectedText.contentEquals(text)) {
return true;
}
}
if (!(root instanceof android.view.ViewGroup)) {
return false;
}
android.view.ViewGroup group = (android.view.ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
private static View findClickableViewContainingText(View root, String expectedText) {
if (root == null) {
return null;
}
if (viewTreeContainsText(root, expectedText) && root.isClickable()) {
return root;
}
if (!(root instanceof android.view.ViewGroup)) {
return null;
}
android.view.ViewGroup group = (android.view.ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
View match = findClickableViewContainingText(group.getChildAt(index), expectedText);
if (match != null) {
return match;
}
}
return null;
}
public static class TestRealtimeProjectDetailActivity extends ProjectDetailActivity {
int reloadCount;
int messageReloadCount;
@@ -397,4 +602,22 @@ public class ProjectDetailActivityRealtimeTest {
setRefreshing(false);
}
}
private static final class RecordingDialogGuardApiClient extends BossApiClient {
int decisionCallCount;
String lastInterventionId;
String lastDecision;
RecordingDialogGuardApiClient() {
super(RuntimeEnvironment.getApplication().getSharedPreferences("dialog_guard_test", Context.MODE_PRIVATE), "https://boss.hyzq.net");
}
@Override
public ApiResponse decideDialogGuardIntervention(String interventionId, String decision) throws org.json.JSONException {
decisionCallCount += 1;
lastInterventionId = interventionId;
lastDecision = decision;
return new ApiResponse(200, new JSONObject().put("ok", true));
}
}
}

View File

@@ -3,15 +3,21 @@ 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.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.drawable.ColorDrawable;
import android.content.res.ColorStateList;
import android.graphics.drawable.GradientDrawable;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.ListView;
@@ -26,6 +32,7 @@ import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.android.controller.ActivityController;
import org.robolectric.Shadows;
import org.robolectric.shadows.ShadowDialog;
import org.robolectric.util.ReflectionHelpers;
@@ -34,11 +41,112 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.function.BooleanSupplier;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class ProjectDetailActivityUiTest {
@Test
public void typingAtInComposerShowsAgentMentionSuggestions() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
EditText input = activity.findViewById(R.id.project_chat_input);
input.requestFocus();
input.setText("@");
input.setSelection(1);
Shadows.shadowOf(activity.getMainLooper()).idle();
View panel = activity.findViewById(R.id.project_chat_mention_panel);
assertEquals(View.VISIBLE, panel.getVisibility());
assertTrue(viewTreeContainsText(panel, "主Agent"));
assertTrue(viewTreeContainsText(panel, "审计Agent"));
}
@Test
public void tappingMentionSuggestionInsertsAgentMentionAndClosesPanel() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
EditText input = activity.findViewById(R.id.project_chat_input);
input.requestFocus();
input.setText("@");
input.setSelection(1);
Shadows.shadowOf(activity.getMainLooper()).idle();
View panel = activity.findViewById(R.id.project_chat_mention_panel);
View masterAgentRow = findClickableViewContainingText(panel, "主Agent");
assertNotNull(masterAgentRow);
masterAgentRow.performClick();
assertEquals("@主Agent ", input.getText().toString());
assertEquals(input.getText().length(), input.getSelectionStart());
assertEquals(View.GONE, panel.getVisibility());
}
@Test
public void tappingAuditMentionSuggestionInsertsAuditAgentMention() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
EditText input = activity.findViewById(R.id.project_chat_input);
input.requestFocus();
input.setText("请看 @审");
input.setSelection(input.getText().length());
Shadows.shadowOf(activity.getMainLooper()).idle();
View panel = activity.findViewById(R.id.project_chat_mention_panel);
View auditAgentRow = findClickableViewContainingText(panel, "审计Agent");
assertNotNull(auditAgentRow);
auditAgentRow.performClick();
assertEquals("请看 @审计Agent ", input.getText().toString());
assertEquals(View.GONE, panel.getVisibility());
}
@Test
public void formatMessageTimeConvertsUtcTimestampIntoLocalTimezoneClock() {
TimeZone original = TimeZone.getDefault();
try {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
String label = ReflectionHelpers.callInstanceMethod(
activity,
"formatMessageTime",
ReflectionHelpers.ClassParameter.from(String.class, "2026-04-20T09:01:00.000Z")
);
assertEquals("17:01", label);
} finally {
TimeZone.setDefault(original);
}
}
@Test
public void multiSelectModeUpdatesRealChatChrome() {
Intent intent = new Intent()
@@ -73,12 +181,14 @@ public class ProjectDetailActivityUiTest {
LinearLayout multiSelectActions = activity.findViewById(R.id.project_chat_multi_select_actions);
ImageButton backButton = activity.findViewById(R.id.screen_back_button);
ImageButton refreshButton = activity.findViewById(R.id.screen_refresh_button);
Button copyButton = activity.findViewById(R.id.project_chat_multi_copy);
Button forwardButton = activity.findViewById(R.id.project_chat_multi_forward);
assertEquals(View.GONE, composerRow.getVisibility());
assertEquals(View.VISIBLE, multiSelectActions.getVisibility());
assertEquals("取消", String.valueOf(backButton.getContentDescription()));
assertEquals(View.GONE, refreshButton.getVisibility());
assertTrue(copyButton.isEnabled());
assertEquals(false, forwardButton.isEnabled());
secondMessage.performClick();
@@ -92,6 +202,101 @@ public class ProjectDetailActivityUiTest {
assertEquals(View.GONE, refreshButton.getVisibility());
}
@Test
public void systemBackInMultiSelectModeExitsSelectionInsteadOfClosingConversation() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
ReflectionHelpers.setField(activity, "currentScreenTitle", "北区试产线回归");
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "归档确认");
ReflectionHelpers.callInstanceMethod(
activity,
"enterMultiSelectFromMessage",
ReflectionHelpers.ClassParameter.from(String.class, "m1")
);
assertEquals(View.GONE, activity.findViewById(R.id.project_chat_composer_row).getVisibility());
assertEquals(View.VISIBLE, activity.findViewById(R.id.project_chat_multi_select_actions).getVisibility());
activity.getOnBackPressedDispatcher().onBackPressed();
assertEquals(0, activity.finishCallCount);
assertEquals(View.VISIBLE, activity.findViewById(R.id.project_chat_composer_row).getVisibility());
assertEquals(View.GONE, activity.findViewById(R.id.project_chat_multi_select_actions).getVisibility());
assertEquals("返回", String.valueOf(((ImageButton) activity.findViewById(R.id.screen_back_button)).getContentDescription()));
}
@Test
public void multiSelectModeShowsCheckmarksBeforeMessagesAndCopiesTranscript() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
JSONObject payload = new JSONObject()
.put("project", new JSONObject()
.put("id", "project-1")
.put("name", "北区试产线回归")
.put("messages", new JSONArray()
.put(new JSONObject()
.put("id", "msg-user")
.put("sender", "user")
.put("senderLabel", "Boss 超级管理员")
.put("body", "请同步项目目标")
.put("kind", "text")
.put("sentAt", "2026-04-20T09:01:00+08:00"))
.put(new JSONObject()
.put("id", "msg-master")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "我会先核对目标,再更新版本记录。")
.put("kind", "text")
.put("sentAt", "2026-04-20T09:02:00+08:00"))));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
ReflectionHelpers.callInstanceMethod(
activity,
"enterMultiSelectFromMessage",
ReflectionHelpers.ClassParameter.from(String.class, "msg-user")
);
ReflectionHelpers.callInstanceMethod(
activity,
"toggleMultiSelectMessage",
ReflectionHelpers.ClassParameter.from(String.class, "msg-master")
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, ""));
assertTrue(viewTreeContainsText(content, "你 · 09:01"));
assertTrue(viewTreeContainsText(content, "主Agent · 09:02"));
Button copyButton = activity.findViewById(R.id.project_chat_multi_copy);
copyButton.performClick();
android.content.ClipData clipData = activity
.getSystemService(android.content.ClipboardManager.class)
.getPrimaryClip();
assertNotNull(clipData);
String copied = String.valueOf(clipData.getItemAt(0).coerceToText(activity));
assertTrue(copied.contains("09:01 你:请同步项目目标"));
assertTrue(copied.contains("09:02 主Agent我会先核对目标再更新版本记录。"));
}
@Test
public void composerFocus_scrollsChatToBottomToKeepLatestMessageVisible() {
Intent intent = new Intent()
@@ -148,6 +353,56 @@ public class ProjectDetailActivityUiTest {
assertNotNull(childScrollCallback);
}
@Test
public void renderProjectWithUnread_marksConversationReadOncePerVisibleSession() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
ActivityController<TestProjectDetailActivity> controller = Robolectric.buildActivity(TestProjectDetailActivity.class, intent);
TestProjectDetailActivity activity = controller.setup().get();
RecordingConversationActionApiClient apiClient = new RecordingConversationActionApiClient();
ReflectionHelpers.setField(activity, "apiClient", apiClient);
JSONObject payload = new JSONObject()
.put("project", new JSONObject()
.put("id", "project-1")
.put("name", "北区试产线回归")
.put("unreadCount", 3)
.put("messages", new JSONArray()));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
waitForUiCondition(activity, () -> apiClient.markConversationReadCount == 1);
assertEquals("project-1", apiClient.lastMarkedProjectId);
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
Thread.sleep(80L);
assertEquals(1, apiClient.markConversationReadCount);
controller.pause();
controller.resume();
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
waitForUiCondition(activity, () -> apiClient.markConversationReadCount == 2);
}
@Test
public void composerRowLayoutChangeWithFocusedInput_scrollsChatToBottomAgain() {
Intent intent = new Intent()
@@ -235,6 +490,110 @@ public class ProjectDetailActivityUiTest {
assertTrue(params.height >= BossUi.dp(activity, 46));
}
@Test
public void scrollBottomShortcutIsFloatingIconAboveComposerAndTriggersBottomScroll() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
int shortcutId = activity.getResources().getIdentifier(
"project_chat_scroll_bottom",
"id",
activity.getPackageName()
);
assertTrue("project_chat_scroll_bottom id should exist", shortcutId != 0);
View shortcutView = activity.findViewById(shortcutId);
assertNotNull(shortcutView);
assertTrue(shortcutView instanceof ImageButton);
assertEquals(View.GONE, shortcutView.getVisibility());
assertTrue(shortcutView.getLayoutParams() instanceof FrameLayout.LayoutParams);
FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) shortcutView.getLayoutParams();
assertTrue((params.gravity & Gravity.BOTTOM) == Gravity.BOTTOM);
assertTrue((params.gravity & Gravity.LEFT) == Gravity.LEFT || (params.gravity & Gravity.START) == Gravity.START);
assertEquals(BossUi.dp(activity, 12), params.leftMargin);
assertTrue(params.bottomMargin >= BossUi.dp(activity, 12));
assertEquals(BossUi.dp(activity, 48), params.width);
assertEquals(BossUi.dp(activity, 48), params.height);
int baselineScrollCount = activity.scrollChatToBottomCount;
shortcutView.performClick();
assertTrue(activity.scrollChatToBottomCount > baselineScrollCount);
}
@Test
public void scrollBottomShortcutVisibilityLogicMatchesObservedSwipeDirection() {
Boolean farFromBottom = ReflectionHelpers.callStaticMethod(
ProjectDetailActivity.class,
"shouldShowScrollBottomShortcut",
ReflectionHelpers.ClassParameter.from(int.class, 140),
ReflectionHelpers.ClassParameter.from(int.class, 96),
ReflectionHelpers.ClassParameter.from(int.class, 460),
ReflectionHelpers.ClassParameter.from(int.class, 400),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
Boolean oppositeDirection = ReflectionHelpers.callStaticMethod(
ProjectDetailActivity.class,
"shouldShowScrollBottomShortcut",
ReflectionHelpers.ClassParameter.from(int.class, 140),
ReflectionHelpers.ClassParameter.from(int.class, 96),
ReflectionHelpers.ClassParameter.from(int.class, 320),
ReflectionHelpers.ClassParameter.from(int.class, 400),
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
Boolean keepVisibleWhileStopped = ReflectionHelpers.callStaticMethod(
ProjectDetailActivity.class,
"shouldShowScrollBottomShortcut",
ReflectionHelpers.ClassParameter.from(int.class, 140),
ReflectionHelpers.ClassParameter.from(int.class, 96),
ReflectionHelpers.ClassParameter.from(int.class, 400),
ReflectionHelpers.ClassParameter.from(int.class, 400),
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
Boolean alreadyNearBottom = ReflectionHelpers.callStaticMethod(
ProjectDetailActivity.class,
"shouldShowScrollBottomShortcut",
ReflectionHelpers.ClassParameter.from(int.class, 80),
ReflectionHelpers.ClassParameter.from(int.class, 96),
ReflectionHelpers.ClassParameter.from(int.class, 320),
ReflectionHelpers.ClassParameter.from(int.class, 400),
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
assertTrue(farFromBottom);
assertFalse(oppositeDirection);
assertTrue(keepVisibleWhileStopped);
assertFalse(alreadyNearBottom);
}
@Test
public void normalConversationHeaderActionOpensConversationInfoDirectlyWithoutDialog() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss 移动控制台");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
ReflectionHelpers.setField(activity, "currentScreenTitle", "Boss 移动控制台");
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "归档确认");
ReflectionHelpers.callInstanceMethod(activity, "updateSelectionUi");
ImageButton headerAction = activity.findViewById(R.id.screen_header_action);
ShadowDialog.reset();
headerAction.performClick();
assertNull(ShadowDialog.getLatestDialog());
}
@Test
public void manualAnalysisAttachmentShowsActionChip() throws Exception {
Intent intent = new Intent()
@@ -358,7 +717,7 @@ public class ProjectDetailActivityUiTest {
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
prefs.edit()
.putString("account", "17600003315")
.putString("account", "krisolo")
.putString("display_name", "OpenAI 平台账号")
.apply();
ReflectionHelpers.setField(activity, "apiClient", new BossApiClient(prefs, "https://boss.hyzq.net"));
@@ -369,7 +728,7 @@ public class ProjectDetailActivityUiTest {
.put("senderLabel", "Boss 超级管理员")
.put("body", "请只回复一句:聊天链路自检正常。")
.put("kind", "text")
.put("sentAt", "2026-03-31T10:26:00.000Z");
.put("sentAt", "2026-03-31T10:26:00+08:00");
View messageView = ReflectionHelpers.callInstanceMethod(
activity,
@@ -377,10 +736,174 @@ public class ProjectDetailActivityUiTest {
ReflectionHelpers.ClassParameter.from(JSONObject.class, message)
);
assertTrue(viewTreeContainsText(messageView, "10:26"));
assertTrue(viewTreeContainsText(messageView, "你 · 10:26"));
assertFalse(viewTreeContainsText(messageView, "Boss 超级管理员 · 10:26"));
}
@Test
public void masterAgentMessageUsesStableSpeakerLabelAndLightBlueBubble() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
JSONObject message = new JSONObject()
.put("id", "msg-master-1")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "我会先核对目标,再同步到顶部入口。")
.put("kind", "text")
.put("sentAt", "2026-04-20T09:16:00+08:00");
View messageView = ReflectionHelpers.callInstanceMethod(
activity,
"buildMessageView",
ReflectionHelpers.ClassParameter.from(JSONObject.class, message)
);
assertTrue(viewTreeContainsText(messageView, "主Agent · 09:16"));
assertTrue(viewTreeHasGradientColor(messageView, 0xFFEAF5FF));
}
@Test
public void renderThreadMessageUsesBoundCodexDeviceAvatar() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "thread-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss开发主线程");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
JSONObject payload = new JSONObject()
.put("project", new JSONObject()
.put("id", "thread-1")
.put("name", "Boss开发主线程")
.put("deviceIds", new JSONArray().put("mac-studio"))
.put("messages", new JSONArray()
.put(new JSONObject()
.put("id", "msg-device-1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程 · Mac Studio")
.put("body", "已完成构建检查。")
.put("kind", "text")
.put("sentAt", "2026-05-09T09:10:00+08:00"))))
.put("devices", new JSONArray()
.put(new JSONObject()
.put("id", "mac-studio")
.put("name", "Mac Studio")
.put("avatar", "M")));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "M"));
assertTrue(viewTreeContainsContentDescription(content, "来自 Mac Studio"));
}
@Test
public void renderGroupThreadMessageMatchesAvatarByCodexDeviceName() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "group-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "协作群");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
JSONObject payload = new JSONObject()
.put("project", new JSONObject()
.put("id", "group-1")
.put("name", "协作群")
.put("isGroup", true)
.put("deviceIds", new JSONArray().put("mac-studio").put("windows-gpu"))
.put("messages", new JSONArray()
.put(new JSONObject()
.put("id", "msg-device-2")
.put("sender", "device")
.put("senderLabel", "购物车修复 · Windows GPU")
.put("body", "Windows 线程已回写结果。")
.put("kind", "text")
.put("sentAt", "2026-05-09T09:16:00+08:00"))))
.put("devices", new JSONArray()
.put(new JSONObject()
.put("id", "mac-studio")
.put("name", "Mac Studio")
.put("avatar", "M"))
.put(new JSONObject()
.put("id", "windows-gpu")
.put("name", "Windows GPU")
.put("avatar", "W")));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "W"));
assertTrue(viewTreeContainsContentDescription(content, "来自 Windows GPU"));
}
@Test
public void executionProgressMessageRendersAsStructuredCard() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "thread-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss开发主线程");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
JSONObject message = new JSONObject()
.put("id", "progress-1")
.put("sender", "master")
.put("senderLabel", "主 Agent")
.put("body", "执行进度")
.put("kind", "execution_progress")
.put("sentAt", "2026-05-08T10:16:00+08:00")
.put("executionProgress", new JSONObject()
.put("status", "completed")
.put("steps", new JSONArray()
.put(new JSONObject().put("text", "回读计划和 H5 商品支付链现状").put("status", "done"))
.put(new JSONObject().put("text", "运行 targeted/full test、typecheck 和 diff 检查").put("status", "done")))
.put("branch", new JSONObject()
.put("additions", 181500)
.put("deletions", 52)
.put("githubCliStatus", "unavailable"))
.put("artifacts", new JSONArray()
.put(new JSONObject().put("label", "development_version_log_20260508.md").put("kind", "file"))
.put(new JSONObject().put("label", "已生成图像 1").put("kind", "image")))
.put("agents", new JSONArray()
.put(new JSONObject().put("name", "Mendel").put("role", "explorer"))));
View messageView = ReflectionHelpers.callInstanceMethod(
activity,
"buildMessageView",
ReflectionHelpers.ClassParameter.from(JSONObject.class, message)
);
assertTrue(viewTreeContainsText(messageView, "进度"));
assertTrue(viewTreeContainsText(messageView, "回读计划和 H5 商品支付链现状"));
assertTrue(viewTreeContainsText(messageView, "+181,500"));
assertTrue(viewTreeContainsText(messageView, "-52"));
assertTrue(viewTreeContainsText(messageView, "GitHub CLI 不可用"));
assertTrue(viewTreeContainsText(messageView, "development_version_log_20260508.md"));
assertTrue(viewTreeContainsText(messageView, "Mendelexplorer"));
}
@Test
public void completedReplyResponseRendersImmediatelyWithoutReloadingProjectDetail() throws Exception {
Intent intent = new Intent()
@@ -473,6 +996,170 @@ public class ProjectDetailActivityUiTest {
assertEquals("更多", String.valueOf(headerAction.getContentDescription()));
}
@Test
public void completedBrowserControlResponseShowsControlSummaryInConversation() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
ReflectionHelpers.setField(activity, "currentScreenTitle", "主 Agent");
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "单聊会话");
JSONObject initialPayload = new JSONObject()
.put("project", new JSONObject()
.put("id", "master-agent")
.put("name", "主 Agent")
.put("messages", new JSONArray()));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, initialPayload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
JSONObject userMessage = new JSONObject()
.put("id", "msg-user-browser")
.put("sender", "user")
.put("senderLabel", "Boss 超级管理员")
.put("body", "打开 https://example.com 看一下首页")
.put("kind", "text")
.put("sentAt", "2026-04-17T10:00:00.000Z");
JSONObject replyMessage = new JSONObject()
.put("id", "msg-master-browser")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "浏览器控制已完成:打开 https://example.com 看一下首页")
.put("kind", "text")
.put("sentAt", "2026-04-17T10:00:01.000Z");
JSONObject sendResponse = new JSONObject()
.put("ok", true)
.put("message", userMessage)
.put("replyMessage", replyMessage)
.put("masterReplyState", "completed")
.put("replyPresenter", "master")
.put("executionMode", "browser")
.put("riskLevel", "medium")
.put("requiresConfirmation", true)
.put("targetUrl", "https://example.com")
.put("task", JSONObject.NULL)
.put("dispatchPlan", JSONObject.NULL)
.put("collaborationGate", new JSONObject()
.put("isGroup", false)
.put("collaborationMode", "development")
.put("approvalState", "not_required"));
CompletedReplyApiClient fakeApiClient = new CompletedReplyApiClient(sendResponse);
ReflectionHelpers.setField(activity, "apiClient", fakeApiClient);
ReflectionHelpers.callInstanceMethod(
activity,
"sendProjectMessage",
ReflectionHelpers.ClassParameter.from(String.class, "text"),
ReflectionHelpers.ClassParameter.from(String.class, "打开 https://example.com 看一下首页")
);
Shadows.shadowOf(activity.getMainLooper()).idle();
JSONObject controlSummary = ReflectionHelpers.callInstanceMethod(
activity,
"buildControlSummaryMessageIfNeeded",
ReflectionHelpers.ClassParameter.from(JSONObject.class, sendResponse)
);
assertNotNull(controlSummary);
assertEquals("control_summary", controlSummary.optString("kind"));
assertEquals("https://example.com", controlSummary.optString("controlTarget"));
assertEquals("浏览器控制已完成:打开 https://example.com 看一下首页", controlSummary.optString("body"));
assertEquals(0, fakeApiClient.projectDetailCallCount);
}
@Test
public void completedDesktopControlResponseShowsControlSummaryInConversation() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
ReflectionHelpers.setField(activity, "currentScreenTitle", "主 Agent");
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "单聊会话");
JSONObject initialPayload = new JSONObject()
.put("project", new JSONObject()
.put("id", "master-agent")
.put("name", "主 Agent")
.put("messages", new JSONArray()));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, initialPayload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
JSONObject userMessage = new JSONObject()
.put("id", "msg-user-desktop")
.put("sender", "user")
.put("senderLabel", "Boss 超级管理员")
.put("body", "打开微信并准备切到聊天窗口")
.put("kind", "text")
.put("sentAt", "2026-04-17T10:05:00.000Z");
JSONObject replyMessage = new JSONObject()
.put("id", "msg-master-desktop")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "桌面控制已完成:打开微信并准备切到聊天窗口")
.put("kind", "text")
.put("sentAt", "2026-04-17T10:05:01.000Z");
JSONObject sendResponse = new JSONObject()
.put("ok", true)
.put("message", userMessage)
.put("replyMessage", replyMessage)
.put("masterReplyState", "completed")
.put("replyPresenter", "master")
.put("executionMode", "desktop")
.put("riskLevel", "medium")
.put("requiresConfirmation", true)
.put("targetApp", "微信")
.put("task", JSONObject.NULL)
.put("dispatchPlan", JSONObject.NULL)
.put("collaborationGate", new JSONObject()
.put("isGroup", false)
.put("collaborationMode", "development")
.put("approvalState", "not_required"));
CompletedReplyApiClient fakeApiClient = new CompletedReplyApiClient(sendResponse);
ReflectionHelpers.setField(activity, "apiClient", fakeApiClient);
ReflectionHelpers.callInstanceMethod(
activity,
"sendProjectMessage",
ReflectionHelpers.ClassParameter.from(String.class, "text"),
ReflectionHelpers.ClassParameter.from(String.class, "打开微信并准备切到聊天窗口")
);
Shadows.shadowOf(activity.getMainLooper()).idle();
JSONObject controlSummary = ReflectionHelpers.callInstanceMethod(
activity,
"buildControlSummaryMessageIfNeeded",
ReflectionHelpers.ClassParameter.from(JSONObject.class, sendResponse)
);
assertNotNull(controlSummary);
assertEquals("control_summary", controlSummary.optString("kind"));
assertEquals("微信", controlSummary.optString("controlTarget"));
assertEquals("桌面控制已完成:打开微信并准备切到聊天窗口", controlSummary.optString("body"));
assertEquals(0, fakeApiClient.projectDetailCallCount);
}
@Test
public void normalConversationHeaderUsesWechatMoreMenuLabel() {
Intent intent = new Intent()
@@ -635,6 +1322,80 @@ public class ProjectDetailActivityUiTest {
assertEquals(null, ReflectionHelpers.getField(activity, "masterAgentReplyBaselineMessageId"));
}
@Test
public void startReplyWaitTracksMasterRelayInThreadConversation() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "thread-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "AI 眼镜线程");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "pendingReplyPresenter", "master");
JSONObject sendResponse = new JSONObject()
.put("message", new JSONObject().put("id", "msg-user-1"))
.put("task", new JSONObject()
.put("taskId", "task-1")
.put("taskType", "conversation_reply")
.put("status", "queued"));
ProjectChatUiState.ReplyWaitSpec waitSpec =
ProjectChatUiState.resolveReplyWaitAfterSend(sendResponse);
ReflectionHelpers.callInstanceMethod(
activity,
"startReplyWait",
ReflectionHelpers.ClassParameter.from(ProjectChatUiState.ReplyWaitSpec.class, waitSpec),
ReflectionHelpers.ClassParameter.from(boolean.class, false),
ReflectionHelpers.ClassParameter.from(String.class, "消息已发送,主 Agent 正在转述")
);
assertTrue(ReflectionHelpers.<Boolean>getField(activity, "masterAgentReplyWaiting"));
assertFalse(ReflectionHelpers.<Boolean>getField(activity, "masterAgentReplyTimedOut"));
assertEquals("msg-user-1", ReflectionHelpers.getField(activity, "masterAgentReplyBaselineMessageId"));
assertEquals(1, activity.replyWaitPollCount);
}
@Test
public void renderThreadProjectClearsMasterRelayWaitStateAfterNewReplyArrives() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "thread-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "AI 眼镜线程");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
ReflectionHelpers.setField(activity, "currentScreenTitle", "AI 眼镜线程");
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "单聊会话");
ReflectionHelpers.setField(activity, "pendingReplyPresenter", "master");
ReflectionHelpers.setField(activity, "masterAgentReplyWaiting", false);
ReflectionHelpers.setField(activity, "masterAgentReplyTimedOut", true);
ReflectionHelpers.setField(activity, "masterAgentReplyBaselineMessageId", "msg-user-1");
JSONObject project = new JSONObject()
.put("project", new JSONObject()
.put("id", "thread-1")
.put("name", "AI 眼镜线程")
.put("messages", new JSONArray()
.put(new JSONObject().put("id", "msg-user-1").put("sender", "user"))
.put(new JSONObject().put("id", "msg-master-1").put("sender", "master"))));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, project),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
assertFalse(ReflectionHelpers.<Boolean>getField(activity, "masterAgentReplyWaiting"));
assertFalse(ReflectionHelpers.<Boolean>getField(activity, "masterAgentReplyTimedOut"));
assertEquals(null, ReflectionHelpers.getField(activity, "masterAgentReplyBaselineMessageId"));
assertEquals(null, ReflectionHelpers.getField(activity, "pendingReplyPresenter"));
}
@Test
public void outgoingAttachmentMetaPrefersTimeOnly() throws Exception {
Intent intent = new Intent()
@@ -667,8 +1428,7 @@ public class ProjectDetailActivityUiTest {
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
assertTrue(viewTreeContainsText(attachmentView, "09:26"));
assertFalse(viewTreeContainsText(attachmentView, "你 · 09:26"));
assertTrue(viewTreeContainsText(attachmentView, "你 · 09:26"));
}
@Test
@@ -917,6 +1677,67 @@ public class ProjectDetailActivityUiTest {
return false;
}
private static boolean viewTreeContainsContentDescription(View root, String expectedText) {
if (root == null) {
return false;
}
CharSequence description = root.getContentDescription();
if (description != null && expectedText.contentEquals(description)) {
return true;
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsContentDescription(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
private static boolean viewTreeHasBackgroundColor(View root, int expectedColor) {
if (root.getBackground() instanceof ColorDrawable) {
return ((ColorDrawable) root.getBackground()).getColor() == expectedColor;
}
if (root.getBackground() instanceof GradientDrawable) {
ColorStateList color = ((GradientDrawable) root.getBackground()).getColor();
if (color != null && color.getDefaultColor() == expectedColor) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeHasBackgroundColor(group.getChildAt(index), expectedColor)) {
return true;
}
}
return false;
}
private static boolean viewTreeHasGradientColor(View root, int expectedColor) {
if (root.getBackground() instanceof GradientDrawable) {
ColorStateList color = ((GradientDrawable) root.getBackground()).getColor();
if (color != null && color.getDefaultColor() == expectedColor) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeHasGradientColor(group.getChildAt(index), expectedColor)) {
return true;
}
}
return false;
}
private static View findClickableViewContainingText(View root, String expectedText) {
if (root == null) {
return null;
@@ -954,6 +1775,7 @@ public class ProjectDetailActivityUiTest {
String lastReplyWaitBaselineMessageId;
boolean lastReplyWaitIncludeDispatchPlans;
int scrollChatToBottomCount;
int finishCallCount;
@Override
boolean shouldLoadOnCreate() {
@@ -971,6 +1793,12 @@ public class ProjectDetailActivityUiTest {
void scrollChatToBottom() {
scrollChatToBottomCount += 1;
}
@Override
public void finish() {
finishCallCount += 1;
super.finish();
}
}
private static final class CompletedReplyApiClient extends BossApiClient {
@@ -1002,6 +1830,22 @@ public class ProjectDetailActivityUiTest {
}
}
private static final class RecordingConversationActionApiClient extends BossApiClient {
int markConversationReadCount;
String lastMarkedProjectId;
RecordingConversationActionApiClient() {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
}
@Override
public ApiResponse markConversationRead(String projectId) throws org.json.JSONException {
markConversationReadCount += 1;
lastMarkedProjectId = projectId;
return new ApiResponse(200, new JSONObject().put("ok", true));
}
}
private static final class InMemorySharedPreferences implements SharedPreferences {
private final Map<String, Object> values = new HashMap<>();

View File

@@ -0,0 +1,96 @@
package com.hyzq.boss;
import static org.junit.Assert.assertTrue;
import android.content.Intent;
import android.view.View;
import android.view.ViewGroup;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class TelegramIntegrationActivityTest {
@Test
public void populateShowsCurrentTelegramStatusBeforeEditableForm() throws Exception {
TestTelegramIntegrationActivity activity = Robolectric
.buildActivity(TestTelegramIntegrationActivity.class, new Intent())
.setup()
.get();
JSONObject telegram = new JSONObject()
.put("enabled", true)
.put("mode", "webhook")
.put("botTokenConfigured", true)
.put("webhookSecretConfigured", true)
.put("botUsername", "boss_demo_bot")
.put("defaultProjectId", "master-agent")
.put("processedUpdateCount", 3)
.put("lastError", "上次 webhook 同步失败")
.put("allowFrom", new JSONArray().put("123456"))
.put("groups", new JSONArray().put("-10001"))
.put(
"groupProjectRoutes",
new JSONArray().put(
new JSONObject()
.put("chatId", "-10001")
.put("threadId", 12)
.put("projectId", "audit-collab")
.put("label", "审计 Topic")
)
)
.put("dmPolicy", "allowlist")
.put("groupPolicy", "allowlist")
.put("requireMentionInGroups", true);
ReflectionHelpers.callInstanceMethod(
activity,
"populate",
ReflectionHelpers.ClassParameter.from(JSONObject.class, telegram)
);
ViewGroup content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content.getChildAt(0), "当前状态"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "接入:已开启"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "模式Webhook"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "Bot@boss_demo_bot"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "Token已配置"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "Webhook Secret已配置"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "已处理 update3"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "最近错误:上次 webhook 同步失败"));
assertTrue(viewTreeContainsText(content, "群 / Topic 路由"));
assertTrue(viewTreeContainsText(content, "-10001#12 audit-collab 审计 Topic"));
}
private static boolean viewTreeContainsText(View view, String text) {
if (view instanceof android.widget.TextView) {
CharSequence value = ((android.widget.TextView) view).getText();
if (value != null && value.toString().contains(text)) {
return true;
}
}
if (view instanceof ViewGroup) {
ViewGroup group = (ViewGroup) view;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), text)) {
return true;
}
}
}
return false;
}
public static class TestTelegramIntegrationActivity extends TelegramIntegrationActivity {
@Override
protected void reload() {
// Tests drive rendering directly through populate().
}
}
}

View File

@@ -137,13 +137,96 @@ public class WechatSurfaceMapperTest {
assertEquals("已导入线程", row.lastMessagePreview);
}
@Test
public void toConversationRow_sanitizesLeakedPromptTitleToFolderFallback() throws Exception {
JSONObject item = new JSONObject()
.put("conversationType", "single_device")
.put("projectTitle", "你当前接手的项目根目录是:")
.put("threadTitle", "你当前接手的项目根目录是:")
.put("folderLabel", "boss")
.put("latestReplyLabel", "17:35");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("boss", row.threadTitle);
}
@Test
public void toConversationRow_extractsWorkspaceFolderFromPromptLeakTitle() throws Exception {
JSONObject item = new JSONObject()
.put("conversationType", "single_device")
.put("projectTitle", "你现在接手的项目根目录是 /Users/kris/code/yuandi。")
.put("threadTitle", "你现在接手的项目根目录是 /Users/kris/code/yuandi。")
.put("latestReplyLabel", "17:36");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("yuandi", row.threadTitle);
}
@Test
public void toConversationRow_prefersStableMasterAgentProjectTitleOverOperationalThreadTitle() throws Exception {
JSONObject item = new JSONObject()
.put("projectId", "master-agent")
.put("projectTitle", "主 Agent")
.put("threadTitle", "主 Agent 汇总")
.put("lastMessagePreview", "同步已完成")
.put("latestReplyLabel", "10:18");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("主 Agent", row.threadTitle);
assertEquals("同步已完成", row.lastMessagePreview);
}
@Test
public void toConversationRow_prefersStableAuditProjectTitleOverOperationalThreadTitle() throws Exception {
JSONObject item = new JSONObject()
.put("projectId", "audit-collab")
.put("projectTitle", "硬件审计协作")
.put("threadTitle", "审计对话")
.put("lastMessagePreview", "审计结果已回写")
.put("latestReplyLabel", "10:20");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("硬件审计协作", row.threadTitle);
assertEquals("审计结果已回写", row.lastMessagePreview);
}
@Test
public void toConversationRow_hidesProcessLikePreviewFallback() throws Exception {
JSONObject item = new JSONObject()
.put("projectTitle", "Boss")
.put("threadTitle", "Boss开发主线程")
.put("lastMessagePreview", "我继续往下收,这一轮先检查折叠链路,再确认未读逻辑,随后回你结果。")
.put("latestReplyLabel", "10:20");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("", row.lastMessagePreview);
}
@Test
public void toConversationRow_keepsFinalSummaryPreviewVisible() throws Exception {
JSONObject item = new JSONObject()
.put("projectTitle", "Boss")
.put("threadTitle", "Boss开发主线程")
.put("lastMessagePreview", "折叠修复已部署,未读数现在只按最终结果计数。")
.put("latestReplyLabel", "10:22");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("折叠修复已部署,未读数现在只按最终结果计数。", row.lastMessagePreview);
}
@Test
public void toDeviceRow_mapsLegacyWechatThreeLineSummary() throws Exception {
JSONObject item = new StubJSONObject()
.withString("name", "Mac Studio")
.withString("avatar", "M")
.withString("status", "online")
.withString("account", "17600003315")
.withString("account", "krisolo")
.withStringArray("projects", "北区试产线回归", "容灾切换验证")
.withInt("quota5h", 8)
.withInt("quota7d", 22);
@@ -151,7 +234,7 @@ public class WechatSurfaceMapperTest {
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
assertEquals("Mac Studio", row.title);
assertEquals("账号: 17600003315 · 项目: 北区试产线回归 / 容灾切换验证", row.subtitle);
assertEquals("账号: krisolo · 项目: 北区试产线回归 / 容灾切换验证", row.subtitle);
assertEquals("额度: 5h 8% · 7d 22%", row.meta);
assertEquals("M", row.avatarLabel);
assertEquals("online", row.statusKey);
@@ -162,12 +245,12 @@ public class WechatSurfaceMapperTest {
JSONObject item = new StubJSONObject()
.withString("name", "Mac Studio")
.withString("status", "abnormal")
.withString("account", "17600003315");
.withString("account", "krisolo");
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
assertEquals("Mac Studio", row.title);
assertEquals("账号: 17600003315", row.subtitle);
assertEquals("账号: krisolo", row.subtitle);
assertEquals("额度: 暂无 · 状态异常", row.meta);
assertEquals("abnormal", row.statusKey);
}
@@ -177,7 +260,7 @@ public class WechatSurfaceMapperTest {
JSONObject item = new StubJSONObject()
.withString("name", "Mac Studio")
.withString("status", "online")
.withString("account", "17600003315")
.withString("account", "krisolo")
.withString("note", "书房主机")
.withString("endpoint", "https://boss.hyzq.net/device/mac-studio")
.withStringArray("projects", "master-agent", "android-app");
@@ -185,14 +268,14 @@ public class WechatSurfaceMapperTest {
WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(item);
assertEquals("Mac Studio", summary.title);
assertEquals("账号: 17600003315 · 项目: master-agent / android-app", summary.subtitle);
assertEquals("账号: krisolo · 项目: master-agent / android-app", summary.subtitle);
assertEquals("额度: 暂无 · 书房主机 · https://boss.hyzq.net/device/mac-studio · 项目 master-agent, android-app", summary.meta);
}
@Test
public void rootMeMenuTitles_matchLegacyWechatMenuWithOpsEntry() throws Exception {
assertArrayEquals(
new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
new String[]{"账号与安全", "设置", "用户与权限", "运维与修复", "AI 账号", "附件与存储", "Telegram 接入", "技能", "关于"},
WechatSurfaceMapper.rootMeMenuTitles()
);
}
@@ -208,7 +291,7 @@ public class WechatSurfaceMapperTest {
@Test
public void mainPage_keepsOpsEntryInStableWechatMenuOrder() throws Exception {
assertArrayEquals(
new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
new String[]{"账号与安全", "设置", "用户与权限", "运维与修复", "AI 账号", "附件与存储", "Telegram 接入", "技能", "关于"},
WechatSurfaceMapper.rootMeMenuTitles()
);
}
@@ -292,7 +375,7 @@ public class WechatSurfaceMapperTest {
JSONArray devices = new StubObjectArray(
new StubJSONObject()
.withString("id", "device-b")
.withString("account", "17600003315"),
.withString("account", "krisolo"),
new StubJSONObject()
.withString("id", "device-c")
.withString("account", "other-account")
@@ -311,7 +394,7 @@ public class WechatSurfaceMapperTest {
null,
"stale-device-id",
"missing-bound-device",
"17600003315",
"krisolo",
devices
);
@@ -380,15 +463,20 @@ public class WechatSurfaceMapperTest {
public void meMenuItems_useStableKeysInsteadOfDisplayTitlesForRouting() throws Exception {
WechatSurfaceMapper.MeMenuItem[] items = WechatSurfaceMapper.rootMeMenuItems();
assertEquals(6, items.length);
assertEquals(9, items.length);
assertEquals("security", items[0].key);
assertEquals("账号与安全", items[0].title);
assertEquals("settings", items[1].key);
assertEquals("ops", items[2].key);
assertEquals("运维与修复", items[2].title);
assertEquals("ai_accounts", items[3].key);
assertEquals("skills", items[4].key);
assertEquals("about", items[5].key);
assertEquals("access", items[2].key);
assertEquals("用户与权限", items[2].title);
assertEquals("ops", items[3].key);
assertEquals("运维与修复", items[3].title);
assertEquals("ai_accounts", items[4].key);
assertEquals("storage", items[5].key);
assertEquals("附件与存储", items[5].title);
assertEquals("telegram", items[6].key);
assertEquals("skills", items[7].key);
assertEquals("about", items[8].key);
}
@Test

View File

@@ -40,6 +40,17 @@ public class WechatSurfaceMapperTopActionTest {
assertEquals("add_device", action.actionKey);
}
@Test
public void rootTopAction_hidesAddDeviceForSubAccounts() {
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("devices", false, false, "member");
assertEquals("刷新", action.label);
assertEquals("refresh", action.iconKey);
assertFalse(action.primaryStyle);
assertTrue(action.compactStyle);
assertEquals("refresh", action.actionKey);
}
@Test
public void rootTopAction_keepsRefreshOnMeTab() {
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("me", true);