feat: ship enterprise control and desktop governance
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", "");
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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",
|
||||
"结论",
|
||||
"最终",
|
||||
"总结",
|
||||
"已完成",
|
||||
"已经完成",
|
||||
"验证通过",
|
||||
"测试通过",
|
||||
"已修复",
|
||||
"修好了",
|
||||
"已部署",
|
||||
"已安装",
|
||||
"可以直接"
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(() -> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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>
|
||||
10
android/app/src/main/res/drawable/ic_boss_arrow_down.xml
Normal file
10
android/app/src/main/res/drawable/ic_boss_arrow_down.xml
Normal 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>
|
||||
10
android/app/src/main/res/drawable/ic_boss_tab_chat.xml
Normal file
10
android/app/src/main/res/drawable/ic_boss_tab_chat.xml
Normal 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>
|
||||
10
android/app/src/main/res/drawable/ic_boss_tab_devices.xml
Normal file
10
android/app/src/main/res/drawable/ic_boss_tab_devices.xml
Normal 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>
|
||||
10
android/app/src/main/res/drawable/ic_boss_tab_me.xml
Normal file
10
android/app/src/main/res/drawable/ic_boss_tab_me.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
9
android/app/src/main/res/values-v29/styles.xml
Normal file
9
android/app/src/main/res/values-v29/styles.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 连着的 PHZ110,PLB110 的无线目标还没有被发现出来。"))
|
||||
.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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "Mendel(explorer)"));
|
||||
}
|
||||
|
||||
@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<>();
|
||||
|
||||
|
||||
@@ -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), "已处理 update:3"));
|
||||
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().
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user