chore: polish native wechat ui release v2.5.1

This commit is contained in:
kris
2026-03-29 20:00:00 +08:00
parent fe186ad8d5
commit 062b46bd41
17 changed files with 314 additions and 67 deletions

View File

@@ -90,7 +90,7 @@ Android APK
- 已生成 Android debug APK`android/app/build/outputs/apk/debug/app-debug.apk`
- 已生成 Android signed release APK`android/app/build/outputs/apk/release/app-release.apk`
- `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
- 当前最新 release 构建版本:`2.5.0``versionCode=13`
- 当前最新 release 构建版本:`2.5.1``versionCode=14`
- 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局`
- 当前原生活动页已经覆盖会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于
- 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab会话首页是简单聊天列表`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口
@@ -110,6 +110,7 @@ Android APK
- `2.3.0` 已把会话模型切到“线程 = 聊天窗口”,补上文件夹名副信息、后台活跃数量动态图标、微信式会话信息页、线程改名、独立群聊创建、群资料页,以及 `主 Agent / 审计对话` 普通置顶会话化
- `2.4.0` 已把消息转发切到微信式原生链路:聊天页支持长按消息操作、多选合并转发、统一目标会话选择页;单条消息转发显示为普通转发消息,多条消息转发显示为“聊天记录”卡片
- `2.5.0` 已补齐聊天附件主链:原生聊天框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;默认走服务器文件存储,`我的 > 附件与存储` 可切到阿里 OSS 私有桶;附件消息已支持下载 / 打开、手动分析、自动分析状态,以及带 task token 的主 Agent 附件分析链接
- `2.5.1` 继续收口微信式原生 UI聊天页普通态顶部已隐藏刷新按钮只保留右上角“信息”发起群聊页顶部说明和选择区已压成更轻的会话式密度候选线程继续复用微信式会话卡片
## 本地启动

View File

@@ -36,8 +36,8 @@ android {
applicationId "com.hyzq.boss"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 13
versionName "2.5.0"
versionCode 14
versionName "2.5.1"
buildConfigField "String", "BOSS_API_BASE_URL", "\"https://boss.hyzq.net\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -84,27 +84,15 @@ public class GroupCreateActivity extends BossScreenActivity {
sourceProjectName = threadMeta == null
? sourceProjectName
: threadMeta.optString("threadDisplayName", sourceProjectName == null ? "当前会话" : sourceProjectName);
appendContent(BossUi.buildCard(
this,
"新建独立群聊",
"群聊不是升级原会话,而是以当前会话为源,新建一个独立线程。",
buildSourceMeta(threadMeta, participants)
));
appendContent(BossUi.buildCard(
this,
sourceProjectName,
buildSourceBody(threadMeta, participants),
sourceProjectId + (sourceFolderName.isEmpty() ? "" : " · " + sourceFolderName)
));
} else {
appendContent(BossUi.buildCard(
this,
"从会话首页发起群聊",
"你可以直接把任意线程拉进一个新的独立群聊,原来的单线程会话会保留不变。",
"至少选择 2 个线程"
));
}
for (SummaryCardSpec cardSpec : buildHeaderCardSpecs(
hasSourceProject(),
sourceProjectId,
sourceProjectName,
threadMeta,
participants
)) {
appendContent(BossUi.buildCard(this, cardSpec.title, cardSpec.body, cardSpec.meta));
}
if (rebuildCandidates) {
@@ -133,15 +121,16 @@ public class GroupCreateActivity extends BossScreenActivity {
lastCandidateProjectIds.addAll(nextCandidateProjectIds);
}
SummaryCardSpec selectionCard = buildSelectionCardSpec(
candidates.size(),
selectedProjectIds.size(),
hasSourceProject()
);
appendContent(BossUi.buildCard(
this,
"选择其他线程",
candidates.isEmpty()
? "当前没有可加入的其他线程。"
: selectedProjectIds.isEmpty()
? "你已取消全部勾选,可继续手动选择。"
: "已保留你当前的勾选状态。",
"已选 " + selectedProjectIds.size() + " 个线程"
selectionCard.title,
selectionCard.body,
selectionCard.meta
));
candidateListLayout = new LinearLayout(this);
@@ -203,6 +192,59 @@ public class GroupCreateActivity extends BossScreenActivity {
);
}
static List<SummaryCardSpec> buildHeaderCardSpecs(
boolean hasSourceProject,
@Nullable String sourceProjectId,
@Nullable String sourceProjectName,
@Nullable JSONObject threadMeta,
@Nullable JSONArray participants
) {
List<SummaryCardSpec> cards = new ArrayList<>(1);
if (hasSourceProject) {
String resolvedSourceProjectName = sourceProjectName == null || sourceProjectName.isEmpty()
? "当前会话"
: sourceProjectName;
cards.add(new SummaryCardSpec(
resolvedSourceProjectName,
buildSourceBody(sourceProjectId, threadMeta, participants),
"新群会单独创建,原会话保留"
));
return cards;
}
cards.add(new SummaryCardSpec(
"从会话中发起群聊",
"至少选择 2 个线程,新群会单独创建。",
"原有单线程会话会保留"
));
return cards;
}
static SummaryCardSpec buildSelectionCardSpec(
int candidateCount,
int selectedCount,
boolean hasSourceProject
) {
if (candidateCount <= 0) {
return new SummaryCardSpec(
"选择其他线程",
"当前没有可加入的其他线程",
"候选 0 个 · 已选 0 个"
);
}
if (selectedCount <= 0) {
return new SummaryCardSpec(
"选择其他线程",
hasSourceProject ? "至少选择 1 个其他线程" : "至少选择 2 个线程",
"候选 " + candidateCount + " 个 · 已选 0 个"
);
}
return new SummaryCardSpec(
"选择其他线程",
"继续点选可调整成员",
"候选 " + candidateCount + " 个 · 已选 " + selectedCount + ""
);
}
private LinearLayout buildCandidateRow(CandidateConversation candidate) {
return BossUi.buildConversationRow(
this,
@@ -348,25 +390,33 @@ public class GroupCreateActivity extends BossScreenActivity {
return sourceProjectId != null && !sourceProjectId.isEmpty();
}
private String buildSourceMeta(@Nullable JSONObject threadMeta, @Nullable JSONArray participants) {
String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
int count = participants == null ? 0 : participants.length();
String memberLabel = count <= 0 ? "暂无参与线程" : count + " 个参与线程";
if (folderName.isEmpty()) {
return memberLabel;
}
return folderName + " · " + memberLabel;
}
private String buildSourceBody(@Nullable JSONObject threadMeta, @Nullable JSONArray participants) {
private static String buildSourceBody(
@Nullable String sourceProjectId,
@Nullable JSONObject threadMeta,
@Nullable JSONArray participants
) {
String threadId = threadMeta == null ? sourceProjectId : threadMeta.optString("threadId", sourceProjectId);
String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
StringBuilder builder = new StringBuilder();
builder.append("来源线程").append(threadId);
builder.append("\n文件夹").append(folderName.isEmpty() ? "未命名文件夹" : folderName);
builder.append("\n参与线程").append(participants == null ? 0 : participants.length());
builder.append("\n默认规则会自动勾选当前会话之外的其他线程");
return builder.toString();
String resolvedFolderName = folderName.isEmpty() ? "未命名文件夹" : folderName;
String resolvedThreadId = threadId == null || threadId.isEmpty() ? "未命名线程" : threadId;
return "来源线程 " + resolvedThreadId
+ " · "
+ resolvedFolderName
+ " · "
+ (participants == null ? 0 : participants.length())
+ " 个参与线程";
}
static final class SummaryCardSpec {
final String title;
final String body;
final String meta;
SummaryCardSpec(String title, String body, String meta) {
this.title = title;
this.body = body;
this.meta = meta;
}
}
private static final class CandidateConversation {

View File

@@ -136,7 +136,7 @@ public final class ProjectChatUiState {
false,
true,
false,
true,
!conversationInfoReady,
conversationInfoReady,
false,
"返回",

View File

@@ -4,6 +4,7 @@ import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import java.util.List;
import java.util.LinkedHashSet;
import java.util.Set;
@@ -12,6 +13,41 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class GroupCreateActivityTest {
@Test
public void buildHeaderCardSpecs_collapsesSourceFlowIntoSingleCompactCard() {
JSONObject threadMeta = new StubJSONObject()
.withString("threadDisplayName", "北区试产线回归")
.withString("threadId", "thread-7")
.withString("folderName", "Mac Studio");
JSONArray participants = new StubJSONArray(
new StubJSONObject(),
new StubJSONObject(),
new StubJSONObject()
);
List<GroupCreateActivity.SummaryCardSpec> cards = GroupCreateActivity.buildHeaderCardSpecs(
true,
"source-1",
"北区试产线回归",
threadMeta,
participants
);
assertEquals(1, cards.size());
assertEquals("北区试产线回归", cards.get(0).title);
assertEquals("来源线程 thread-7 · Mac Studio · 3 个参与线程", cards.get(0).body);
assertEquals("新群会单独创建,原会话保留", cards.get(0).meta);
}
@Test
public void buildSelectionCardSpec_usesCompactWechatStyleHint() {
GroupCreateActivity.SummaryCardSpec card = GroupCreateActivity.buildSelectionCardSpec(5, 0, true);
assertEquals("选择其他线程", card.title);
assertEquals("至少选择 1 个其他线程", card.body);
assertEquals("候选 5 个 · 已选 0 个", card.meta);
}
@Test
public void collectSelectableConversationItems_filtersOutExistingGroupChats() {
JSONObject threadConversation = new StubJSONObject()

View File

@@ -0,0 +1,142 @@
package com.hyzq.boss;
import static org.junit.Assert.assertTrue;
import android.content.Intent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class GroupCreateActivityUiTest {
@Test
public void renderCreatePageUsesCompactSummaryCardsForSourceFlow() throws Exception {
Intent intent = new Intent()
.putExtra(GroupCreateActivity.EXTRA_SOURCE_PROJECT_ID, "source-1")
.putExtra(GroupCreateActivity.EXTRA_SOURCE_PROJECT_NAME, "北区试产线回归");
TestGroupCreateActivity activity = Robolectric
.buildActivity(TestGroupCreateActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderCreatePage",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildConversationsPayload()),
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content.getChildAt(0), "北区试产线回归"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "来源线程 thread-7 · Mac Studio · 3 个参与线程"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "新群会单独创建,原会话保留"));
assertTrue(viewTreeContainsText(content.getChildAt(1), "选择其他线程"));
assertTrue(viewTreeContainsText(content.getChildAt(1), "继续点选可调整成员"));
assertTrue(viewTreeContainsText(content.getChildAt(1), "候选 2 个 · 已选 2 个"));
}
@Test
public void toggleSelectionRefreshesRenderedSelectionSummary() throws Exception {
Intent intent = new Intent()
.putExtra(GroupCreateActivity.EXTRA_SOURCE_PROJECT_ID, "source-1")
.putExtra(GroupCreateActivity.EXTRA_SOURCE_PROJECT_NAME, "北区试产线回归");
TestGroupCreateActivity activity = Robolectric
.buildActivity(TestGroupCreateActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderCreatePage",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildConversationsPayload()),
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
ReflectionHelpers.callInstanceMethod(
activity,
"toggleSelection",
ReflectionHelpers.ClassParameter.from(String.class, "thread-2")
);
ReflectionHelpers.callInstanceMethod(
activity,
"toggleSelection",
ReflectionHelpers.ClassParameter.from(String.class, "thread-3")
);
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content.getChildAt(1), "至少选择 1 个其他线程"));
assertTrue(viewTreeContainsText(content.getChildAt(1), "候选 2 个 · 已选 0 个"));
}
private static JSONObject buildParticipantsPayload() throws Exception {
JSONObject threadMeta = new JSONObject()
.put("threadDisplayName", "北区试产线回归")
.put("threadId", "thread-7")
.put("folderName", "Mac Studio");
JSONArray participants = new JSONArray()
.put(new JSONObject().put("projectId", "source-1"))
.put(new JSONObject().put("projectId", "thread-2"))
.put(new JSONObject().put("projectId", "thread-3"));
return new JSONObject()
.put("threadMeta", threadMeta)
.put("participants", participants);
}
private static JSONObject buildConversationsPayload() throws Exception {
JSONArray conversations = new JSONArray()
.put(new JSONObject()
.put("projectId", "thread-2")
.put("projectTitle", "硬件审计协作")
.put("folderLabel", "Mac Studio")
.put("lastMessagePreview", "检查摄像头供电链路")
.put("latestReplyLabel", "09:28")
.put("isGroup", false))
.put(new JSONObject()
.put("projectId", "thread-3")
.put("projectTitle", "Boss 移动控制台")
.put("folderLabel", "Boss")
.put("lastMessagePreview", "统一顶部按钮样式")
.put("latestReplyLabel", "09:31")
.put("isGroup", false));
return new JSONObject().put("conversations", conversations);
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (expectedText.contentEquals(text)) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
public static class TestGroupCreateActivity extends GroupCreateActivity {
@Override
protected void reload() {
// Tests drive renderCreatePage manually to avoid network work.
}
}
}

View File

@@ -111,7 +111,7 @@ public class ProjectChatUiStateTest {
assertFalse(chromeState.multiSelecting);
assertTrue(chromeState.showComposer);
assertFalse(chromeState.showMultiSelectBar);
assertTrue(chromeState.showRefresh);
assertFalse(chromeState.showRefresh);
assertTrue(chromeState.showHeaderAction);
assertFalse(chromeState.forwardEnabled);
assertEquals("返回", chromeState.backLabel);
@@ -119,6 +119,22 @@ public class ProjectChatUiStateTest {
assertEquals("归档确认", chromeState.subtitle);
}
@Test
public void chromeStateRestoresRefreshBeforeConversationInfoIsReady() {
ProjectChatUiState.ChromeState chromeState =
ProjectChatUiState.resolveChromeState(ProjectChatUiState.emptySelection(), false, "北区试产线回归", "归档确认");
assertFalse(chromeState.multiSelecting);
assertTrue(chromeState.showComposer);
assertFalse(chromeState.showMultiSelectBar);
assertTrue(chromeState.showRefresh);
assertFalse(chromeState.showHeaderAction);
assertFalse(chromeState.forwardEnabled);
assertEquals("返回", chromeState.backLabel);
assertEquals("北区试产线回归", chromeState.title);
assertEquals("归档确认", chromeState.subtitle);
}
@Test
public void reconcileSelectionDropsMessagesMissingFromRenderSet() {
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");

View File

@@ -46,7 +46,7 @@ public class ProjectDetailActivityChromeBindingsTest {
assertFalse(bindings.multiSelecting);
assertTrue(bindings.showComposer);
assertFalse(bindings.showMultiSelectBar);
assertTrue(bindings.showRefresh);
assertFalse(bindings.showRefresh);
assertTrue(bindings.showHeaderAction);
assertFalse(bindings.enableForwardButton);
assertTrue(bindings.enablePullRefresh);

View File

@@ -75,7 +75,7 @@ public class ProjectDetailActivityUiTest {
assertEquals(View.VISIBLE, composerRow.getVisibility());
assertEquals(View.GONE, multiSelectActions.getVisibility());
assertEquals("返回", backButton.getText().toString());
assertEquals(View.VISIBLE, refreshButton.getVisibility());
assertEquals(View.GONE, refreshButton.getVisibility());
}
@Test

View File

@@ -153,7 +153,7 @@
- 邮件:`Postfix + Dovecot`
- Android`AppCompatActivity + 原生 XML 布局 + HttpURLConnection`
- 原生登录恢复:`SharedPreferences + restore token`
- 当前最新原生 APK`2.5.0``versionCode=13`
- 当前最新原生 APK`2.5.1``versionCode=14`
当前不要误判成已经用了:

View File

@@ -116,7 +116,7 @@ cd /Users/kris/code/boss
- 当前已生成 Android debug APK`android/app/build/outputs/apk/debug/app-debug.apk`
- 当前已生成 Android signed release APK`android/app/build/outputs/apk/release/app-release.apk`
- 当前 release 构建还会额外生成带版本号的 APK`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
- 当前最新 release 构建版本:`2.5.0``versionCode=13`
- 当前最新 release 构建版本:`2.5.1``versionCode=14`
- 当前 release keystore 位于本机 `android/keystores/boss-release.keystore`,签名参数位于 `android/signing/release-signing.properties`
- `2.0.1` 已在本机连接的华为真机上复核通过,修复了 `Theme.SplashScreen` 导致的 `AppCompatActivity` 启动闪退
- `2.1.0` 已把 Web 一级页和主要二级页全部补成原生活动页:`MainActivity / ProjectDetailActivity / ProjectGoalsActivity / ProjectVersionsActivity / ProjectForwardActivity / ThreadDetailActivity / DeviceDetailActivity / DeviceEnrollmentActivity / SkillInventoryActivity / SecurityActivity / SettingsActivity / AiAccountsActivity / OpsCenterActivity / AboutActivity`
@@ -128,6 +128,8 @@ cd /Users/kris/code/boss
- `2.4.0` 已把原生消息转发切到微信式链路:单条消息支持长按直接转发,多选消息支持合并转发成聊天记录卡片,统一使用原生会话选择页替换旧的备注转发页
- `2.5.0` 已补齐聊天附件主链:原生聊天框左侧 `+` 已改成底部抽屉,支持图片 / 视频 / 文件发送;图片 / PDF / 文本会自动排队给主 Agent 分析,视频 / Office / 大文件改成手动触发
- `2.5.0` 已上线 `我的 > 附件与存储`:默认使用服务器文件存储,用户可切到阿里 OSS 私有桶并填写最小配置;下载链会使用附件上传时固化的 OSS 快照,避免后续改配置后旧附件失效
- `2.5.1` 已继续回退聊天详情页顶部交互:普通聊天态不再显示“刷新”,只保留微信式右上角“信息”入口;多选态的“取消 / 转发”保持不变
- `2.5.1` 已压缩“发起群聊”页首信息密度:来源会话场景只保留一张紧凑摘要卡,选择区改成更短的微信式提示,同时保留会话卡片式候选列表
- 当前附件分析任务已带受控 `task token` 下载链接和文本摘录:本地开发环境会跟随请求 origin 生成链接,生产环境默认走 `https://boss.hyzq.net`
- `2.5.x` 当前已补上会话首页独立建群入口:可以不从单线程聊天内部出发,直接在会话首页右上角 `+` 建立新群聊;同时已把多个原生自定义 top bar 页面统一纳入状态栏安全区处理

View File

@@ -1,11 +1,11 @@
{
"artifactType": "aab",
"fileName": "boss-android-v2.5.0-release.aab",
"urlPath": "/downloads/boss-android-v2.5.0-release.aab",
"sizeBytes": 2906325,
"updatedAt": "2026-03-29T09:19:37Z",
"sha256": "e230a1e0eae8e1e6d264f11feb3125ff40661dc2e049e18d8f683c3571e3a568",
"versionName": "2.5.0",
"versionCode": 13,
"fileName": "boss-android-v2.5.1-release.aab",
"urlPath": "/downloads/boss-android-v2.5.1-release.aab",
"sizeBytes": 2910555,
"updatedAt": "2026-03-29T11:58:09Z",
"sha256": "a1db186f0bd8ac9439f9cd7b9116d273ddaa2d169eb46613a29b848b1824458b",
"versionName": "2.5.1",
"versionCode": 14,
"buildFlavor": "release"
}

View File

@@ -1,10 +1,10 @@
{
"fileName": "boss-android-v2.5.0-release.apk",
"fileName": "boss-android-v2.5.1-release.apk",
"urlPath": "/api/v1/user/ota/package",
"sizeBytes": 3083087,
"updatedAt": "2026-03-29T09:20:05Z",
"sha256": "92183a0ebed80da5e363ffcd8e41a4acfd650aac76072ac5a4e432af5902f59f",
"versionName": "2.5.0",
"versionCode": 13,
"sizeBytes": 3087319,
"updatedAt": "2026-03-29T11:58:01Z",
"sha256": "c97c17d2989066027a68f291c830d007696403c9f484fc4278310d8303cb8d99",
"versionName": "2.5.1",
"versionCode": 14,
"buildFlavor": "release"
}

Binary file not shown.

Binary file not shown.