feat: ship enterprise control and desktop governance

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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