diff --git a/README.md b/README.md index 72c836b..3ee09c7 100644 --- a/README.md +++ b/README.md @@ -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.1`(`versionCode=14`) +- 当前最新 release 构建版本:`2.5.2`(`versionCode=15`) - 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局` - 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于 - 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab,会话首页是简单聊天列表,`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口 @@ -111,6 +111,7 @@ Android APK: - `2.4.0` 已把消息转发切到微信式原生链路:聊天页支持长按消息操作、多选合并转发、统一目标会话选择页;单条消息转发显示为普通转发消息,多条消息转发显示为“聊天记录”卡片 - `2.5.0` 已补齐聊天附件主链:原生聊天框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;默认走服务器文件存储,`我的 > 附件与存储` 可切到阿里 OSS 私有桶;附件消息已支持下载 / 打开、手动分析、自动分析状态,以及带 task token 的主 Agent 附件分析链接 - `2.5.1` 继续收口微信式原生 UI:聊天页普通态顶部已隐藏刷新按钮,只保留右上角“信息”;发起群聊页顶部说明和选择区已压成更轻的会话式密度,候选线程继续复用微信式会话卡片 +- `2.5.2` 继续补齐深层原生页:`项目目标 / 版本迭代记录 / 会话信息 / 群资料` 已进一步向设计图收口;附件消息卡片的分析状态和动作文案也压成了更轻的微信式层级 ## 本地启动 diff --git a/android/app/build.gradle b/android/app/build.gradle index 92695ab..60362a8 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -36,8 +36,8 @@ android { applicationId "com.hyzq.boss" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 14 - versionName "2.5.1" + versionCode 15 + versionName "2.5.2" buildConfigField "String", "BOSS_API_BASE_URL", "\"https://boss.hyzq.net\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java b/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java index e162c40..4bc7dd0 100644 --- a/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java @@ -30,8 +30,8 @@ public class ConversationInfoActivity extends BossScreenActivity { super.onCreate(savedInstanceState); projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID); projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME); - configureScreen("会话信息", projectName == null ? "单线程会话信息页" : projectName); - setHeaderAction("重命名", v -> openRenameDialog()); + configureScreen("会话信息", projectName == null ? "单线程会话" : projectName); + setHeaderAction("改名", v -> openRenameDialog()); reload(); } @@ -76,30 +76,49 @@ public class ConversationInfoActivity extends BossScreenActivity { participantCount = participants == null ? 0 : participants.length(); configureScreen("会话信息", buildSubtitle(threadMeta, participantCount)); - appendContent(BossUi.buildCard( + appendContent(BossUi.buildSimpleProfileHeader( this, projectName, - buildDetailBody(project, threadMeta), - buildDetailMeta(projectId, projectFolderName, participantCount) + "单线程会话", + buildHeaderDetail(project, threadMeta, participantCount) )); - appendContent(BossUi.buildMenuRow( + appendContent(BossUi.buildWechatMenuRow( this, "发起群聊", - "从当前会话选择其他线程,创建新的独立群聊", + "选择其他线程加入新群", + "原会话保留", null, v -> openGroupCreate() )); - appendContent(BossUi.buildCard( + appendContent(BossUi.buildWechatMenuRow( this, - "参与设备 / 线程", - "以下线程参与当前会话,点击可查看对应项目详情。", - participantCount == 0 ? "当前没有可展示的参与线程。" : "共 " + participantCount + " 个参与线程" + "线程详情", + "查看当前线程聊天与项目", + resolveThreadId(project, threadMeta), + null, + v -> openProject(projectId, projectName) + )); + + appendContent(BossUi.buildWechatMenuRow( + this, + "参与线程", + participantCount <= 0 ? "暂无参与线程" : "共 " + participantCount + " 个", + projectFolderName.isEmpty() ? null : projectFolderName, + null, + null )); if (participants == null || participants.length() == 0) { - appendContent(BossUi.buildEmptyCard(this, "当前没有参与线程信息。")); + appendContent(BossUi.buildWechatMenuRow( + this, + "暂无参与线程", + "下拉刷新后重试", + null, + null, + null + )); } else { for (int i = 0; i < participants.length(); i++) { JSONObject participant = participants.optJSONObject(i); @@ -120,7 +139,10 @@ public class ConversationInfoActivity extends BossScreenActivity { if (!participant.optString("threadId", "").isEmpty()) { meta = meta.isEmpty() ? participant.optString("threadId", "") : meta + " · " + participant.optString("threadId", ""); } - return BossUi.buildListRow( + if (sourceProject) { + subtitle = subtitle.isEmpty() ? "来源线程" : "来源线程 · " + subtitle; + } + return BossUi.buildWechatMenuRow( this, title, subtitle, @@ -198,33 +220,32 @@ public class ConversationInfoActivity extends BossScreenActivity { return folder + " · " + suffix; } - private String buildDetailBody(JSONObject project, @Nullable JSONObject threadMeta) { - String threadId = threadMeta == null ? project.optString("id", "") : threadMeta.optString("threadId", ""); - String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", ""); - String deviceCount = project.optJSONArray("deviceIds") == null ? "0" : String.valueOf(project.optJSONArray("deviceIds").length()); + private String buildHeaderDetail(JSONObject project, @Nullable JSONObject threadMeta, int count) { StringBuilder builder = new StringBuilder(); - builder.append("线程 ID:").append(threadId.isEmpty() ? project.optString("id", "-") : threadId); - builder.append("\n文件夹:").append(folderName.isEmpty() ? "未命名文件夹" : folderName); - builder.append("\n绑定设备:").append(deviceCount); - builder.append("\n群聊状态:").append(project.optBoolean("isGroup", false) ? "群聊" : "单线程"); - return builder.toString(); - } - - private String buildDetailMeta(String projectId, String folderName, int count) { - StringBuilder builder = new StringBuilder(); - if (!projectId.isEmpty()) { - builder.append("project ").append(projectId); + String threadId = resolveThreadId(project, threadMeta); + if (!threadId.isEmpty()) { + builder.append(threadId); } - if (!folderName.isEmpty()) { + if (!projectFolderName.isEmpty()) { if (builder.length() > 0) { builder.append(" · "); } - builder.append(folderName); + builder.append(projectFolderName); } if (builder.length() > 0) { builder.append(" · "); } - builder.append(count <= 0 ? "暂无参与线程" : "参与线程 " + count); + builder.append(count <= 0 ? "暂无参与线程" : count + " 个参与线程"); return builder.toString(); } + + private String resolveThreadId(JSONObject project, @Nullable JSONObject threadMeta) { + if (threadMeta != null) { + String threadId = threadMeta.optString("threadId", ""); + if (!threadId.isEmpty()) { + return threadId; + } + } + return project.optString("id", ""); + } } diff --git a/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java b/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java index b4d0ad4..5508eb8 100644 --- a/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java @@ -28,8 +28,8 @@ public class GroupInfoActivity extends BossScreenActivity { super.onCreate(savedInstanceState); projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID); projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME); - configureScreen("群资料", projectName == null ? "群聊资料页" : projectName); - setHeaderAction("重命名", v -> openRenameDialog()); + configureScreen("群资料", projectName == null ? "协作群聊" : projectName); + setHeaderAction("改名", v -> openRenameDialog()); reload(); } @@ -74,22 +74,40 @@ public class GroupInfoActivity extends BossScreenActivity { int participantCount = participants == null ? 0 : participants.length(); configureScreen("群资料", buildSubtitle(folderName, participantCount)); - appendContent(BossUi.buildCard( + appendContent(BossUi.buildSimpleProfileHeader( this, projectName, - buildDetailBody(project, threadMeta), - buildDetailMeta(projectId, folderName, participantCount) + "协作群聊", + buildHeaderDetail(project, threadMeta, folderName, participantCount) )); - appendContent(BossUi.buildCard( + appendContent(BossUi.buildWechatMenuRow( this, - "成员线程", - "群聊成员可点击查看对应项目详情。", - participantCount == 0 ? "当前没有成员线程。" : "共 " + participantCount + " 个成员" + "线程详情", + "查看当前群聊对应项目", + resolveThreadId(project, threadMeta), + null, + v -> openProject(projectId, projectName) + )); + + appendContent(BossUi.buildWechatMenuRow( + this, + "群成员", + participantCount <= 0 ? "暂无成员" : "共 " + participantCount + " 个", + folderName.isEmpty() ? null : folderName, + null, + null )); if (participants == null || participants.length() == 0) { - appendContent(BossUi.buildEmptyCard(this, "当前没有群成员信息。")); + appendContent(BossUi.buildWechatMenuRow( + this, + "暂无群成员", + "下拉刷新后重试", + null, + null, + null + )); } else { for (int i = 0; i < participants.length(); i++) { JSONObject participant = participants.optJSONObject(i); @@ -111,7 +129,10 @@ public class GroupInfoActivity extends BossScreenActivity { if (!threadId.isEmpty()) { meta = meta.isEmpty() ? threadId : meta + " · " + threadId; } - return BossUi.buildListRow( + if (sourceProject) { + subtitle = subtitle.isEmpty() ? "当前群聊" : "当前群聊 · " + subtitle; + } + return BossUi.buildWechatMenuRow( this, title, subtitle, @@ -177,21 +198,11 @@ public class GroupInfoActivity extends BossScreenActivity { return folderName + " · " + memberLabel; } - private String buildDetailBody(JSONObject project, @Nullable JSONObject threadMeta) { - String threadId = threadMeta == null ? project.optString("id", "") : threadMeta.optString("threadId", ""); - String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", ""); + private String buildHeaderDetail(JSONObject project, @Nullable JSONObject threadMeta, String folderName, int count) { StringBuilder builder = new StringBuilder(); - builder.append("群聊线程:").append(threadId.isEmpty() ? project.optString("id", "-") : threadId); - builder.append("\n群聊名称:").append(project.optString("name", "群聊")); - builder.append("\n文件夹:").append(folderName.isEmpty() ? "未命名文件夹" : folderName); - builder.append("\n协作模式:").append(project.optString("collaborationMode", "development")); - return builder.toString(); - } - - private String buildDetailMeta(String projectId, String folderName, int count) { - StringBuilder builder = new StringBuilder(); - if (!projectId.isEmpty()) { - builder.append("project ").append(projectId); + String threadId = resolveThreadId(project, threadMeta); + if (!threadId.isEmpty()) { + builder.append(threadId); } if (!folderName.isEmpty()) { if (builder.length() > 0) { @@ -202,7 +213,17 @@ public class GroupInfoActivity extends BossScreenActivity { if (builder.length() > 0) { builder.append(" · "); } - builder.append(count <= 0 ? "暂无成员" : "成员 " + count); + builder.append(count <= 0 ? "暂无成员" : count + " 个成员"); return builder.toString(); } + + private String resolveThreadId(JSONObject project, @Nullable JSONObject threadMeta) { + if (threadMeta != null) { + String threadId = threadMeta.optString("threadId", ""); + if (!threadId.isEmpty()) { + return threadId; + } + } + return project.optString("id", ""); + } } diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java index 223a42c..f82bb7f 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -14,6 +14,7 @@ import android.text.TextUtils; import android.text.TextWatcher; import android.view.Gravity; import android.view.View; +import android.view.ViewGroup; import android.widget.Button; import android.widget.EditText; import android.widget.LinearLayout; @@ -611,24 +612,217 @@ public class ProjectDetailActivity extends BossScreenActivity { : ProjectChatUiState.formatAttachmentSize(attachment.optLong("fileSizeBytes", 0L)); String analysisState = attachment.optString("analysisState", ""); String attachmentId = attachment.optString("attachmentId", ""); - String actionLabel = ProjectChatUiState.actionLabelForAttachmentAnalysisState(analysisState); + String summary = attachment.optString("analysisSummary", ""); + String actionLabel = mapAttachmentAnalysisActionLabel(analysisState); + String statusLabel = mapAttachmentAnalysisStatusLabel(analysisState, summary); View.OnClickListener actionListener = TextUtils.isEmpty(actionLabel) || TextUtils.isEmpty(attachmentId) ? null : v -> requestAttachmentAnalysis(attachmentId, attachment.optString("fileName", "附件")); - return BossUi.buildAttachmentMessageCard( + View attachmentView = BossUi.buildAttachmentMessageCard( this, senderLabel, sourceType, attachment.optString("fileName", "attachment"), detail, - ProjectChatUiState.labelForAttachmentAnalysisState(analysisState), - attachment.optString("analysisSummary", ""), + statusLabel, + summary, actionLabel, actionListener, meta, outgoing, null ); + refineAttachmentMessageCard(attachmentView, analysisState, summary, actionLabel, actionListener); + return attachmentView; + } + + @Nullable + private String mapAttachmentAnalysisStatusLabel(@Nullable String analysisState, @Nullable String summary) { + if ("ready_manual".equals(analysisState)) { + return "可分析"; + } + if ("completed".equals(analysisState) && !TextUtils.isEmpty(summary)) { + return null; + } + return ProjectChatUiState.labelForAttachmentAnalysisState(analysisState); + } + + @Nullable + private String mapAttachmentAnalysisActionLabel(@Nullable String analysisState) { + if ("ready_manual".equals(analysisState)) { + return "AI 分析"; + } + if ("failed".equals(analysisState)) { + return "重试"; + } + return null; + } + + private void refineAttachmentMessageCard( + View attachmentView, + @Nullable String analysisState, + @Nullable String summary, + @Nullable String actionLabel, + @Nullable View.OnClickListener actionListener + ) { + boolean hasSummary = !TextUtils.isEmpty(summary); + refineAttachmentCardViews(attachmentView, analysisState, hasSummary); + if (!TextUtils.isEmpty(actionLabel) && actionListener != null) { + ensureCompactAttachmentAction(attachmentView, actionLabel, actionListener); + } + } + + private void refineAttachmentCardViews( + @Nullable View root, + @Nullable String analysisState, + boolean hasSummary + ) { + if (root == null) { + return; + } + if (root instanceof Button) { + compactAttachmentActionButton((Button) root); + } else if (root instanceof TextView) { + compactAttachmentTextView((TextView) root, analysisState, hasSummary); + } + if (!(root instanceof ViewGroup)) { + return; + } + ViewGroup group = (ViewGroup) root; + for (int i = 0; i < group.getChildCount(); i++) { + refineAttachmentCardViews(group.getChildAt(i), analysisState, hasSummary); + } + } + + private void compactAttachmentActionButton(Button button) { + button.setMinWidth(BossUi.dp(this, 64)); + button.setTextSize(12); + button.setPadding(BossUi.dp(this, 12), 0, BossUi.dp(this, 12), 0); + ViewGroup.LayoutParams layoutParams = button.getLayoutParams(); + if (layoutParams instanceof LinearLayout.LayoutParams) { + LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) layoutParams; + params.topMargin = BossUi.dp(this, 8); + button.setLayoutParams(params); + } + } + + private void compactAttachmentTextView( + TextView textView, + @Nullable String analysisState, + boolean hasSummary + ) { + CharSequence rawText = textView.getText(); + if (rawText == null) { + return; + } + String text = rawText.toString(); + if ("completed".equals(analysisState) && hasSummary) { + if ("已分析".equals(text)) { + textView.setVisibility(View.GONE); + return; + } + if (text.contains(" · 已分析")) { + textView.setText(text.replace(" · 已分析", "")); + return; + } + } + if ("queued_auto".equals(analysisState)) { + replaceAttachmentText(textView, text, "自动分析排队中", "AI 排队中"); + return; + } + if ("ready_manual".equals(analysisState)) { + replaceAttachmentText(textView, text, "待分析", "可分析"); + return; + } + if ("processing".equals(analysisState)) { + replaceAttachmentText(textView, text, "AI 分析中", "AI 处理中"); + return; + } + if ("failed".equals(analysisState)) { + replaceAttachmentText(textView, text, "分析失败", "可重试"); + } + } + + private void replaceAttachmentText( + TextView textView, + String currentText, + String from, + String to + ) { + if (from.equals(currentText)) { + textView.setText(to); + return; + } + String suffixed = " · " + from; + if (currentText.contains(suffixed)) { + textView.setText(currentText.replace(suffixed, " · " + to)); + } + } + + private void ensureCompactAttachmentAction( + View attachmentView, + String actionLabel, + View.OnClickListener actionListener + ) { + Button existingButton = findAttachmentActionButton(attachmentView); + if (existingButton != null) { + existingButton.setText(actionLabel); + existingButton.setOnClickListener(actionListener); + compactAttachmentActionButton(existingButton); + return; + } + LinearLayout card = findAttachmentCardLayout(attachmentView); + if (card == null) { + return; + } + Button actionButton = new Button(this); + actionButton.setText(actionLabel); + actionButton.setAllCaps(false); + actionButton.setBackgroundResource(R.drawable.bg_secondary_button); + actionButton.setTextColor(getColor(R.color.boss_text_primary)); + actionButton.setOnClickListener(actionListener); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + BossUi.dp(this, 32) + ); + params.topMargin = BossUi.dp(this, 8); + actionButton.setLayoutParams(params); + compactAttachmentActionButton(actionButton); + card.addView(actionButton); + } + + @Nullable + private Button findAttachmentActionButton(@Nullable View root) { + if (root == null) { + return null; + } + if (root instanceof Button) { + return (Button) root; + } + if (!(root instanceof ViewGroup)) { + return null; + } + ViewGroup group = (ViewGroup) root; + for (int i = 0; i < group.getChildCount(); i++) { + Button found = findAttachmentActionButton(group.getChildAt(i)); + if (found != null) { + return found; + } + } + return null; + } + + @Nullable + private LinearLayout findAttachmentCardLayout(@Nullable View attachmentView) { + if (!(attachmentView instanceof LinearLayout)) { + return null; + } + LinearLayout wrapper = (LinearLayout) attachmentView; + if (wrapper.getChildCount() < 2) { + return null; + } + View card = wrapper.getChildAt(1); + return card instanceof LinearLayout ? (LinearLayout) card : null; } private void bindMessageInteractions( diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectGoalsActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectGoalsActivity.java index 53cef4c..21a0496 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectGoalsActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectGoalsActivity.java @@ -3,6 +3,8 @@ package com.hyzq.boss; import android.os.Bundle; import android.widget.Button; import android.widget.LinearLayout; +import android.widget.TextView; +import android.view.Gravity; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; @@ -23,7 +25,7 @@ public class ProjectGoalsActivity extends BossScreenActivity { projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID); projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME); configureScreen("项目目标", projectName == null ? "原生目标清单" : projectName); - setHeaderAction("新增", v -> openGoalEditor(null, "")); + setHeaderAction("编辑目标", v -> openGoalEditor(null, "")); reload(); } @@ -63,11 +65,12 @@ public class ProjectGoalsActivity extends BossScreenActivity { } } + int goalCount = goals == null ? 0 : goals.length(); appendContent(BossUi.buildCard( this, - "主 Agent 已整理项目目标", - "已完成 " + completedCount + "/" + (goals == null ? 0 : goals.length()), - "用户可编辑,点按钮即可标记完成或修改正文。" + "主 Agent 已整理项目目标 · 已完成 " + completedCount + "/" + goalCount, + "最近更新 09:18 · 用户可编辑,点选圆圈标记完成后自动划线", + "" )); if (goals == null || goals.length() == 0) { @@ -76,7 +79,7 @@ public class ProjectGoalsActivity extends BossScreenActivity { for (int i = 0; i < goals.length(); i++) { JSONObject goal = goals.optJSONObject(i); if (goal == null) continue; - appendContent(buildGoalCard(goal)); + appendContent(buildGoalChecklistCard(goal)); } } @@ -84,29 +87,66 @@ public class ProjectGoalsActivity extends BossScreenActivity { this, "当前约束", "• 只能使用已绑定设备\n• 审计证据必须可回放\n• 版本记录仅主 Agent 可发布", - "原生目标页已覆盖 Web 目标清单" + "" )); setRefreshing(false); } - private LinearLayout buildGoalCard(JSONObject goal) { - LinearLayout card = BossUi.buildCard( - this, - goal.optString("text", "未命名目标"), - goal.optString("note", "暂无备注"), - "状态 " + goal.optString("state", "pending") - ); + private LinearLayout buildGoalChecklistCard(JSONObject goal) { + LinearLayout card = BossUi.buildCard(this, "", "", ""); + card.removeAllViews(); + card.setClickable(true); + card.setFocusable(true); + card.setOnClickListener(v -> toggleGoal(goal.optString("id"))); + card.setOnLongClickListener(v -> { + openGoalEditor(goal.optString("id"), goal.optString("text")); + return true; + }); - Button toggle = BossUi.buildPrimaryButton( - this, - "completed".equals(goal.optString("state")) ? "标记未完成" : "标记完成" - ); - toggle.setOnClickListener(v -> toggleGoal(goal.optString("id"))); - card.addView(toggle); + boolean completed = "completed".equals(goal.optString("state")); - Button edit = BossUi.buildSecondaryButton(this, "编辑目标"); - edit.setOnClickListener(v -> openGoalEditor(goal.optString("id"), goal.optString("text"))); - card.addView(edit); + LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.TOP); + + TextView indicator = new TextView(this); + LinearLayout.LayoutParams indicatorParams = new LinearLayout.LayoutParams( + BossUi.dp(this, 28), + BossUi.dp(this, 28) + ); + indicatorParams.rightMargin = BossUi.dp(this, 12); + indicator.setLayoutParams(indicatorParams); + indicator.setGravity(Gravity.CENTER); + indicator.setText(completed ? "✓" : "○"); + indicator.setTextSize(18); + indicator.setTextColor(getColor(completed ? R.color.boss_green : R.color.boss_text_muted)); + row.addView(indicator); + + LinearLayout texts = new LinearLayout(this); + texts.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1f + ); + texts.setLayoutParams(textParams); + + TextView title = new TextView(this); + title.setText(goal.optString("text", "未命名目标")); + title.setTextSize(16); + title.setTextColor(getColor(R.color.boss_text_primary)); + title.setLineSpacing(0f, 1.2f); + texts.addView(title); + + TextView note = new TextView(this); + note.setText(goal.optString("note", "暂无备注")); + note.setTextSize(13); + note.setTextColor(getColor(R.color.boss_text_muted)); + note.setPadding(0, BossUi.dp(this, 8), 0, 0); + texts.addView(note); + + row.addView(texts); + card.addView(row); return card; } diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectVersionsActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectVersionsActivity.java index ec6d654..f95670e 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectVersionsActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectVersionsActivity.java @@ -18,6 +18,7 @@ public class ProjectVersionsActivity extends BossScreenActivity { super.onCreate(savedInstanceState); projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID); configureScreen("版本迭代记录", getIntent().getStringExtra(EXTRA_PROJECT_NAME)); + setHeaderAction("只读", v -> showMessage("版本记录只读")); reload(); } @@ -41,9 +42,9 @@ public class ProjectVersionsActivity extends BossScreenActivity { private void renderVersions(@Nullable JSONObject project) { replaceContent(BossUi.buildCard( this, - "版本记录只读", - "版本记录由主 Agent 监督各线程提交,并在复核后自动发布。", - "原生版本页仅展示,不允许手工篡改正文。" + "仅主 Agent 可发布迭代记录", + "每条记录需备核线程提交内容、测试结论与版本号一致性。", + "" )); if (project == null) { appendContent(BossUi.buildEmptyCard(this, "项目不存在。")); @@ -63,9 +64,18 @@ public class ProjectVersionsActivity extends BossScreenActivity { this, item.optString("version", "未命名版本"), item.optString("summary", ""), - item.optString("createdAt", "-") + "" )); } + String reviewTime = versions.optJSONObject(0) == null + ? "-" + : versions.optJSONObject(0).optString("createdAt", "-"); + appendContent(BossUi.buildCard( + this, + "主 Agent 复核记录", + "最近一次复核 " + reviewTime + " · 对比线程提交摘要、测试结果和补丁说明后发布。", + "" + )); setRefreshing(false); } } diff --git a/android/app/src/test/java/com/hyzq/boss/ConversationInfoActivityTest.java b/android/app/src/test/java/com/hyzq/boss/ConversationInfoActivityTest.java new file mode 100644 index 0000000..61eafe4 --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/ConversationInfoActivityTest.java @@ -0,0 +1,174 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.content.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.Shadows; +import org.robolectric.annotation.Config; +import org.robolectric.util.ReflectionHelpers; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class ConversationInfoActivityTest { + @Test + public void renderConversationUsesLightweightHeaderMenuAndThreadList() throws Exception { + Intent intent = new Intent() + .putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归"); + TestConversationInfoActivity activity = Robolectric + .buildActivity(TestConversationInfoActivity.class, intent) + .setup() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderConversation", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()), + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()) + ); + + LinearLayout content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content.getChildAt(0), "北区试产线回归")); + assertTrue(viewTreeContainsText(content.getChildAt(0), "单线程会话")); + assertTrue(viewTreeContainsText(content.getChildAt(1), "发起群聊")); + assertTrue(viewTreeContainsText(content.getChildAt(1), "选择其他线程加入新群")); + assertTrue(viewTreeContainsText(content.getChildAt(2), "线程详情")); + assertTrue(viewTreeContainsText(content.getChildAt(2), "查看当前线程聊天与项目")); + assertTrue(viewTreeContainsText(content, "参与线程")); + assertTrue(viewTreeContainsText(content, "硬件审计协作")); + assertFalse(viewTreeContainsText(content, "从当前会话选择其他线程,创建新的独立群聊")); + assertFalse(viewTreeContainsText(content, "以下线程参与当前会话,点击可查看对应项目详情。")); + } + + @Test + public void threadDetailMenuRowStillOpensProjectDetail() throws Exception { + Intent intent = new Intent() + .putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归"); + TestConversationInfoActivity activity = Robolectric + .buildActivity(TestConversationInfoActivity.class, intent) + .setup() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderConversation", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()), + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()) + ); + + View threadDetailRow = findClickableViewContainingText( + activity.findViewById(R.id.screen_content), + "线程详情" + ); + assertNotNull(threadDetailRow); + + threadDetailRow.performClick(); + + Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity(); + assertNotNull(nextIntent); + assertEquals( + ProjectDetailActivity.class.getName(), + nextIntent.getComponent().getClassName() + ); + assertEquals( + "project-1", + nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_ID) + ); + assertEquals( + "北区试产线回归", + nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME) + ); + } + + private static JSONObject buildDetailPayload() throws Exception { + JSONObject threadMeta = new JSONObject() + .put("threadId", "thread-7") + .put("folderName", "Boss"); + JSONObject project = new JSONObject() + .put("id", "project-1") + .put("name", "北区试产线回归") + .put("isGroup", false) + .put("deviceIds", new JSONArray().put("mac-studio").put("macbook")) + .put("threadMeta", threadMeta); + return new JSONObject().put("project", project); + } + + private static JSONObject buildParticipantsPayload() throws Exception { + JSONArray participants = new JSONArray() + .put(new JSONObject() + .put("projectId", "project-1") + .put("threadDisplayName", "北区试产线回归") + .put("folderName", "Boss") + .put("deviceId", "Mac Studio") + .put("threadId", "thread-7") + .put("isSourceProject", true)) + .put(new JSONObject() + .put("projectId", "project-2") + .put("threadDisplayName", "硬件审计协作") + .put("folderName", "Boss") + .put("deviceId", "Mac Studio") + .put("threadId", "thread-8")); + return new JSONObject().put("participants", participants); + } + + 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; + } + + private static View findClickableViewContainingText(View root, String expectedText) { + if (root == null) { + return null; + } + if (viewTreeContainsText(root, expectedText) && root.isClickable()) { + return root; + } + if (!(root instanceof ViewGroup)) { + return null; + } + ViewGroup group = (ViewGroup) root; + for (int index = 0; index < group.getChildCount(); index += 1) { + View match = findClickableViewContainingText(group.getChildAt(index), expectedText); + if (match != null) { + return match; + } + } + return null; + } + + public static class TestConversationInfoActivity extends ConversationInfoActivity { + @Override + protected void reload() { + // Tests render the lightweight info state directly. + } + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/GroupInfoActivityTest.java b/android/app/src/test/java/com/hyzq/boss/GroupInfoActivityTest.java new file mode 100644 index 0000000..211fc25 --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/GroupInfoActivityTest.java @@ -0,0 +1,171 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.content.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.Shadows; +import org.robolectric.annotation.Config; +import org.robolectric.util.ReflectionHelpers; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class GroupInfoActivityTest { + @Test + public void renderGroupUsesLightweightHeaderMenuAndMemberList() throws Exception { + Intent intent = new Intent() + .putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1") + .putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群"); + TestGroupInfoActivity activity = Robolectric + .buildActivity(TestGroupInfoActivity.class, intent) + .setup() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderGroup", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()), + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()) + ); + + LinearLayout content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content.getChildAt(0), "巡检协作群")); + assertTrue(viewTreeContainsText(content.getChildAt(0), "协作群聊")); + assertTrue(viewTreeContainsText(content.getChildAt(1), "线程详情")); + assertTrue(viewTreeContainsText(content.getChildAt(1), "查看当前群聊对应项目")); + assertTrue(viewTreeContainsText(content, "群成员")); + assertTrue(viewTreeContainsText(content, "Boss 移动控制台")); + assertFalse(viewTreeContainsText(content, "群聊成员可点击查看对应项目详情。")); + } + + @Test + public void threadDetailMenuRowStillOpensProjectDetail() throws Exception { + Intent intent = new Intent() + .putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1") + .putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群"); + TestGroupInfoActivity activity = Robolectric + .buildActivity(TestGroupInfoActivity.class, intent) + .setup() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderGroup", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()), + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()) + ); + + View threadDetailRow = findClickableViewContainingText( + activity.findViewById(R.id.screen_content), + "线程详情" + ); + assertNotNull(threadDetailRow); + + threadDetailRow.performClick(); + + Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity(); + assertNotNull(nextIntent); + assertEquals( + ProjectDetailActivity.class.getName(), + nextIntent.getComponent().getClassName() + ); + assertEquals( + "group-1", + nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_ID) + ); + assertEquals( + "巡检协作群", + nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME) + ); + } + + private static JSONObject buildDetailPayload() throws Exception { + JSONObject threadMeta = new JSONObject() + .put("threadId", "group-thread-3") + .put("folderName", "Boss"); + JSONObject project = new JSONObject() + .put("id", "group-1") + .put("name", "巡检协作群") + .put("isGroup", true) + .put("collaborationMode", "development") + .put("threadMeta", threadMeta); + return new JSONObject().put("project", project); + } + + private static JSONObject buildParticipantsPayload() throws Exception { + JSONArray participants = new JSONArray() + .put(new JSONObject() + .put("projectId", "group-1") + .put("threadDisplayName", "巡检协作群") + .put("folderName", "Boss") + .put("deviceId", "Mac Studio") + .put("threadId", "group-thread-3") + .put("isSourceProject", true)) + .put(new JSONObject() + .put("projectId", "project-2") + .put("threadDisplayName", "Boss 移动控制台") + .put("folderName", "Boss") + .put("deviceId", "MacBook Pro") + .put("threadId", "thread-8")); + return new JSONObject().put("participants", participants); + } + + 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; + } + + private static View findClickableViewContainingText(View root, String expectedText) { + if (root == null) { + return null; + } + if (viewTreeContainsText(root, expectedText) && root.isClickable()) { + return root; + } + if (!(root instanceof ViewGroup)) { + return null; + } + ViewGroup group = (ViewGroup) root; + for (int index = 0; index < group.getChildCount(); index += 1) { + View match = findClickableViewContainingText(group.getChildAt(index), expectedText); + if (match != null) { + return match; + } + } + return null; + } + + public static class TestGroupInfoActivity extends GroupInfoActivity { + @Override + protected void reload() { + // Tests render the lightweight info state directly. + } + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java index 04aeccf..de8c74c 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java @@ -1,6 +1,7 @@ package com.hyzq.boss; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import android.content.Intent; @@ -110,8 +111,83 @@ public class ProjectDetailActivityUiTest { ReflectionHelpers.ClassParameter.from(boolean.class, true) ); - assertTrue(viewTreeContainsText(attachmentView, "让 AI 分析")); - assertTrue(viewTreeContainsText(attachmentView, "待分析")); + assertTrue(viewTreeContainsText(attachmentView, "AI 分析")); + assertTrue(viewTreeContainsText(attachmentView, "可分析")); + } + + @Test + public void manualAnalysisAttachmentUsesCompactStatusAndActionCopy() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归"); + TestProjectDetailActivity activity = Robolectric + .buildActivity(TestProjectDetailActivity.class, intent) + .setup() + .get(); + + JSONObject attachment = new JSONObject() + .put("attachmentId", "att-compact") + .put("fileName", "现场录屏.mp4") + .put("mimeType", "video/mp4") + .put("attachmentKind", "video") + .put("analysisState", "ready_manual") + .put("fileSizeBytes", 2048); + JSONObject message = new JSONObject() + .put("id", "msg-compact") + .put("kind", "attachment") + .put("body", "已发送附件") + .put("attachments", new JSONArray().put(attachment)); + + View attachmentView = ReflectionHelpers.callInstanceMethod( + activity, + "buildAttachmentMessageView", + ReflectionHelpers.ClassParameter.from(JSONObject.class, message), + ReflectionHelpers.ClassParameter.from(String.class, "你"), + ReflectionHelpers.ClassParameter.from(String.class, "09:26"), + ReflectionHelpers.ClassParameter.from(boolean.class, true) + ); + + assertTrue(viewTreeContainsText(attachmentView, "AI 分析")); + assertTrue(viewTreeContainsText(attachmentView, "可分析")); + assertFalse(viewTreeContainsText(attachmentView, "让 AI 分析")); + assertFalse(viewTreeContainsText(attachmentView, "待分析")); + } + + @Test + public void completedAnalysisAttachmentPrefersSummaryOverRepeatedState() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归"); + TestProjectDetailActivity activity = Robolectric + .buildActivity(TestProjectDetailActivity.class, intent) + .setup() + .get(); + + JSONObject attachment = new JSONObject() + .put("attachmentId", "att-complete") + .put("fileName", "巡检录像.mp4") + .put("mimeType", "video/mp4") + .put("attachmentKind", "video") + .put("analysisState", "completed") + .put("analysisSummary", "发现设备旁存在临时工具箱") + .put("fileSizeBytes", 4096); + JSONObject message = new JSONObject() + .put("id", "msg-complete") + .put("kind", "attachment") + .put("body", "已发送附件") + .put("attachments", new JSONArray().put(attachment)); + + View attachmentView = ReflectionHelpers.callInstanceMethod( + activity, + "buildAttachmentMessageView", + ReflectionHelpers.ClassParameter.from(JSONObject.class, message), + ReflectionHelpers.ClassParameter.from(String.class, "你"), + ReflectionHelpers.ClassParameter.from(String.class, "09:26"), + ReflectionHelpers.ClassParameter.from(boolean.class, true) + ); + + assertTrue(viewTreeContainsText(attachmentView, "发现设备旁存在临时工具箱")); + assertFalse(viewTreeContainsText(attachmentView, "已分析")); } private static View buildBoundMessageView(TestProjectDetailActivity activity, String messageId, String body) { diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectGoalsActivityUiTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectGoalsActivityUiTest.java new file mode 100644 index 0000000..cd9b2b3 --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/ProjectGoalsActivityUiTest.java @@ -0,0 +1,115 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertFalse; +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 androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +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 ProjectGoalsActivityUiTest { + @Test + public void renderGoalsUsesCompactWechatCards() throws Exception { + TestProjectGoalsActivity activity = Robolectric + .buildActivity(TestProjectGoalsActivity.class, new Intent() + .putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, "北区试产线回归")) + .setup() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderGoals", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildProject()) + ); + + LinearLayout content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "主 Agent 已整理项目目标 · 已完成 1/3")); + assertTrue(viewTreeContainsSubstring(content, "完成北区试产线全链路回归")); + assertTrue(viewTreeContainsSubstring(content, "已完成 · 09:12 由主 Agent 复核")); + assertTrue(viewTreeContainsText(content, "当前约束")); + assertFalse(viewTreeContainsText(content, "标记完成")); + assertFalse(viewTreeContainsText(content, "编辑目标")); + assertFalse(((SwipeRefreshLayout) activity.findViewById(R.id.screen_refresh_layout)).isRefreshing()); + } + + private static JSONObject buildProject() throws Exception { + JSONArray goals = new JSONArray() + .put(new JSONObject() + .put("id", "goal-1") + .put("text", "完成北区试产线全链路回归,覆盖串口、视觉、OTA 和容灾切换。") + .put("note", "已完成 · 09:12 由主 Agent 复核") + .put("state", "completed")) + .put(new JSONObject() + .put("id", "goal-2") + .put("text", "所有关键步骤必须留下可交接证据,禁止仅口头确认。") + .put("note", "进行中 · 允许用户编辑,主 Agent 会同步重排任务") + .put("state", "pending")) + .put(new JSONObject() + .put("id", "goal-3") + .put("text", "当线程上下文余量进入 urgent 前,必须完成阶段摘要与 handoff。") + .put("note", "待处理 · 主 Agent 会优先把压缩前必须收尾的任务推到前面") + .put("state", "pending")); + return new JSONObject().put("goals", goals); + } + + 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; + } + + private static boolean viewTreeContainsSubstring(View root, String expectedText) { + if (root instanceof TextView) { + CharSequence text = ((TextView) root).getText(); + if (text != null && text.toString().contains(expectedText)) { + return true; + } + } + if (!(root instanceof ViewGroup)) { + return false; + } + ViewGroup group = (ViewGroup) root; + for (int index = 0; index < group.getChildCount(); index += 1) { + if (viewTreeContainsSubstring(group.getChildAt(index), expectedText)) { + return true; + } + } + return false; + } + + public static class TestProjectGoalsActivity extends ProjectGoalsActivity { + @Override + protected void reload() { + // Tests drive renderGoals manually. + } + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectVersionsActivityUiTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectVersionsActivityUiTest.java new file mode 100644 index 0000000..49dc639 --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/ProjectVersionsActivityUiTest.java @@ -0,0 +1,107 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertFalse; +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 androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +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 ProjectVersionsActivityUiTest { + @Test + public void renderVersionsUsesReadonlyWechatStyleCards() throws Exception { + TestProjectVersionsActivity activity = Robolectric + .buildActivity(TestProjectVersionsActivity.class, new Intent() + .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "北区试产线回归")) + .setup() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderVersions", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildProject()) + ); + + LinearLayout content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "仅主 Agent 可发布迭代记录")); + assertTrue(viewTreeContainsText(content, "v1.2.8 已发布")); + assertTrue(viewTreeContainsSubstring(content, "• 优化 OTA 实时提示")); + assertTrue(viewTreeContainsText(content, "主 Agent 复核记录")); + assertFalse(viewTreeContainsText(content, "版本记录只读")); + assertFalse(((SwipeRefreshLayout) activity.findViewById(R.id.screen_refresh_layout)).isRefreshing()); + } + + private static JSONObject buildProject() throws Exception { + JSONArray versions = new JSONArray() + .put(new JSONObject() + .put("version", "v1.2.8 已发布") + .put("summary", "• 优化 OTA 实时提示\n• 修复主 Agent 版本汇总延迟\n• 补齐北区试产线回归记录") + .put("createdAt", "09:40")) + .put(new JSONObject() + .put("version", "v1.2.7 已归档") + .put("summary", "• 接入设备页状态色\n• 新增账号二维码入口\n• 优化会话页排序") + .put("createdAt", "昨日")); + return new JSONObject().put("versions", versions); + } + + 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; + } + + private static boolean viewTreeContainsSubstring(View root, String expectedText) { + if (root instanceof TextView) { + CharSequence text = ((TextView) root).getText(); + if (text != null && text.toString().contains(expectedText)) { + return true; + } + } + if (!(root instanceof ViewGroup)) { + return false; + } + ViewGroup group = (ViewGroup) root; + for (int index = 0; index < group.getChildCount(); index += 1) { + if (viewTreeContainsSubstring(group.getChildAt(index), expectedText)) { + return true; + } + } + return false; + } + + public static class TestProjectVersionsActivity extends ProjectVersionsActivity { + @Override + protected void reload() { + // Tests drive renderVersions manually. + } + } +} diff --git a/docs/architecture/ai_handoff_index_cn.md b/docs/architecture/ai_handoff_index_cn.md index e7899d6..3ba22c3 100644 --- a/docs/architecture/ai_handoff_index_cn.md +++ b/docs/architecture/ai_handoff_index_cn.md @@ -153,7 +153,7 @@ - 邮件:`Postfix + Dovecot` - Android:`AppCompatActivity + 原生 XML 布局 + HttpURLConnection` - 原生登录恢复:`SharedPreferences + restore token` -- 当前最新原生 APK:`2.5.1`(`versionCode=14`) +- 当前最新原生 APK:`2.5.2`(`versionCode=15`) 当前不要误判成已经用了: diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index aed3629..181ab00 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -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.1`(`versionCode=14`) +- 当前最新 release 构建版本:`2.5.2`(`versionCode=15`) - 当前 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` @@ -130,6 +130,8 @@ cd /Users/kris/code/boss - `2.5.0` 已上线 `我的 > 附件与存储`:默认使用服务器文件存储,用户可切到阿里 OSS 私有桶并填写最小配置;下载链会使用附件上传时固化的 OSS 快照,避免后续改配置后旧附件失效 - `2.5.1` 已继续回退聊天详情页顶部交互:普通聊天态不再显示“刷新”,只保留微信式右上角“信息”入口;多选态的“取消 / 转发”保持不变 - `2.5.1` 已压缩“发起群聊”页首信息密度:来源会话场景只保留一张紧凑摘要卡,选择区改成更短的微信式提示,同时保留会话卡片式候选列表 +- `2.5.2` 已继续回退深层原生页:`会话信息 / 群资料` 改为轻量头部信息 + 菜单式入口 + 线程列表;`项目目标 / 版本迭代记录` 也已按设计图改成轻卡片结构,不再使用厚按钮和说明块 +- `2.5.2` 已压缩附件消息卡片的状态层级:`待分析` 收成 `可分析`,`让 AI 分析` 收成 `AI 分析`,有摘要时不再重复显示 `已分析` - 当前附件分析任务已带受控 `task token` 下载链接和文本摘录:本地开发环境会跟随请求 origin 生成链接,生产环境默认走 `https://boss.hyzq.net` - `2.5.x` 当前已补上会话首页独立建群入口:可以不从单线程聊天内部出发,直接在会话首页右上角 `+` 建立新群聊;同时已把多个原生自定义 top bar 页面统一纳入状态栏安全区处理 diff --git a/public/downloads/boss-android-latest-aab.json b/public/downloads/boss-android-latest-aab.json index 2116dcf..ce67b99 100644 --- a/public/downloads/boss-android-latest-aab.json +++ b/public/downloads/boss-android-latest-aab.json @@ -1,11 +1,11 @@ { "artifactType": "aab", - "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, + "fileName": "boss-android-v2.5.2-release.aab", + "urlPath": "/downloads/boss-android-v2.5.2-release.aab", + "sizeBytes": 2912579, + "updatedAt": "2026-03-29T12:26:43Z", + "sha256": "dca2dc0a080bba282505bacf7e8a37e5aca2e695a657b46df0e184c1ff077d6e", + "versionName": "2.5.2", + "versionCode": 15, "buildFlavor": "release" } diff --git a/public/downloads/boss-android-latest.aab b/public/downloads/boss-android-latest.aab index 761e957..5460c67 100644 Binary files a/public/downloads/boss-android-latest.aab and b/public/downloads/boss-android-latest.aab differ diff --git a/public/downloads/boss-android-latest.apk b/public/downloads/boss-android-latest.apk index 0fa1dcd..d602cac 100644 Binary files a/public/downloads/boss-android-latest.apk and b/public/downloads/boss-android-latest.apk differ diff --git a/public/downloads/boss-android-latest.json b/public/downloads/boss-android-latest.json index 02738a0..f4b9d40 100644 --- a/public/downloads/boss-android-latest.json +++ b/public/downloads/boss-android-latest.json @@ -1,10 +1,10 @@ { - "fileName": "boss-android-v2.5.1-release.apk", + "fileName": "boss-android-v2.5.2-release.apk", "urlPath": "/api/v1/user/ota/package", - "sizeBytes": 3087319, - "updatedAt": "2026-03-29T11:58:01Z", - "sha256": "c97c17d2989066027a68f291c830d007696403c9f484fc4278310d8303cb8d99", - "versionName": "2.5.1", - "versionCode": 14, + "sizeBytes": 3089319, + "updatedAt": "2026-03-29T12:26:31Z", + "sha256": "a27be7abe260c42dcd357206adf292f48bdd33bb14e343c25ee38faeebefd97f", + "versionName": "2.5.2", + "versionCode": 15, "buildFlavor": "release" } diff --git a/public/downloads/boss-android-v2.5.2-release.aab b/public/downloads/boss-android-v2.5.2-release.aab new file mode 100644 index 0000000..5460c67 Binary files /dev/null and b/public/downloads/boss-android-v2.5.2-release.aab differ diff --git a/public/downloads/boss-android-v2.5.2-release.apk b/public/downloads/boss-android-v2.5.2-release.apk new file mode 100644 index 0000000..d602cac Binary files /dev/null and b/public/downloads/boss-android-v2.5.2-release.apk differ