android: add attachment composer flow

This commit is contained in:
kris
2026-03-29 16:27:04 +08:00
parent 9e4b64ba9e
commit 1e476a2097
8 changed files with 970 additions and 31 deletions

View File

@@ -0,0 +1,39 @@
package com.hyzq.boss;
import androidx.annotation.Nullable;
public final class AttachmentComposerState {
private AttachmentComposerState() {}
public static boolean requiresConfirmation(@Nullable String sourceType) {
return "image".equals(sourceType) || "video".equals(sourceType);
}
public static final class PendingAttachment {
public final String sourceType;
public final String fileName;
public final String mimeType;
public final long fileSizeBytes;
public final byte[] bytes;
public PendingAttachment(
String sourceType,
String fileName,
String mimeType,
long fileSizeBytes,
byte[] bytes
) {
this.sourceType = sourceType == null ? "file" : sourceType;
this.fileName = fileName == null || fileName.trim().isEmpty() ? "attachment" : fileName;
this.mimeType = mimeType == null || mimeType.trim().isEmpty()
? "application/octet-stream"
: mimeType;
this.fileSizeBytes = Math.max(fileSizeBytes, bytes == null ? 0 : bytes.length);
this.bytes = bytes == null ? new byte[0] : bytes.clone();
}
public boolean requiresConfirmation() {
return AttachmentComposerState.requiresConfirmation(sourceType);
}
}
}

View File

@@ -19,6 +19,7 @@ import java.io.OutputStreamWriter;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.List;
import java.util.Map;
@@ -100,6 +101,44 @@ public class BossApiClient {
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/messages", payload);
}
public ApiResponse uploadAttachment(
String projectId,
String fileName,
String mimeType,
byte[] bytes,
String sourceType
) throws IOException, JSONException {
HttpURLConnection connection = openConnection("/api/v1/projects/" + encode(projectId) + "/attachments");
prepareConnection(connection, "POST");
connection.setDoOutput(true);
String boundary = "BossBoundary" + System.currentTimeMillis();
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
try (OutputStream outputStream = connection.getOutputStream()) {
writeMultipartPart(outputStream, boundary, "sourceType", sourceType, null);
writeMultipartPart(
outputStream,
boundary,
"file",
bytes == null ? new byte[0] : bytes,
fileName,
mimeType
);
outputStream.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
}
return executeConnection(connection, true);
}
public ApiResponse analyzeAttachment(String projectId, String attachmentId) throws IOException, JSONException {
return requestWithRestoreRaw(
"POST",
"/api/v1/projects/" + encode(projectId) + "/attachments/" + encode(attachmentId) + "/analyze",
"{}"
);
}
public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, JSONObject payload) throws IOException, JSONException {
String requestBody = ForwardPayloads.toRequestBody(targetProjectId, payload);
return requestWithRestoreRaw("POST", "/api/v1/projects/" + encode(projectId) + "/forwards", requestBody);
@@ -269,6 +308,25 @@ public class BossApiClient {
private ApiResponse requestRaw(String method, String path, @Nullable String body, boolean expectProtected) throws IOException, JSONException {
HttpURLConnection connection = openConnection(path);
prepareConnection(connection, method);
if (body != null) {
connection.setDoOutput(true);
connection.setRequestProperty("Content-Type", "application/json");
try (OutputStream outputStream = connection.getOutputStream();
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) {
writer.write(body);
}
}
return executeConnection(connection, expectProtected);
}
HttpURLConnection openConnection(String path) throws IOException {
return (HttpURLConnection) new URL(baseUrl + path).openConnection();
}
private void prepareConnection(HttpURLConnection connection, String method) throws IOException {
connection.setRequestMethod(method);
connection.setConnectTimeout(12000);
connection.setReadTimeout(12000);
@@ -281,16 +339,9 @@ public class BossApiClient {
if (!cookie.isEmpty()) {
connection.setRequestProperty("Cookie", cookie);
}
}
if (body != null) {
connection.setDoOutput(true);
connection.setRequestProperty("Content-Type", "application/json");
try (OutputStream outputStream = connection.getOutputStream();
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) {
writer.write(body);
}
}
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());
@@ -305,8 +356,57 @@ public class BossApiClient {
return new ApiResponse(statusCode, json == null ? new JSONObject() : json);
}
HttpURLConnection openConnection(String path) throws IOException {
return (HttpURLConnection) new URL(baseUrl + path).openConnection();
private void writeMultipartPart(
OutputStream outputStream,
String boundary,
String fieldName,
String value,
@Nullable String contentType
) throws IOException {
outputStream.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8));
outputStream.write(
("Content-Disposition: form-data; name=\"" + fieldName + "\"\r\n")
.getBytes(StandardCharsets.UTF_8)
);
if (contentType != null && !contentType.isEmpty()) {
outputStream.write(("Content-Type: " + contentType + "\r\n").getBytes(StandardCharsets.UTF_8));
}
outputStream.write("\r\n".getBytes(StandardCharsets.UTF_8));
outputStream.write((value == null ? "" : value).getBytes(StandardCharsets.UTF_8));
outputStream.write("\r\n".getBytes(StandardCharsets.UTF_8));
}
private void writeMultipartPart(
OutputStream outputStream,
String boundary,
String fieldName,
byte[] bytes,
String fileName,
String contentType
) throws IOException {
outputStream.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8));
outputStream.write(
String.format(
Locale.US,
"Content-Disposition: form-data; name=\"%s\"; filename=\"%s\"\r\n",
fieldName,
escapeMultipartValue(fileName)
).getBytes(StandardCharsets.UTF_8)
);
outputStream.write(
("Content-Type: " + (contentType == null || contentType.isEmpty()
? "application/octet-stream"
: contentType) + "\r\n\r\n").getBytes(StandardCharsets.UTF_8)
);
outputStream.write(bytes == null ? new byte[0] : bytes);
outputStream.write("\r\n".getBytes(StandardCharsets.UTF_8));
}
private String escapeMultipartValue(String value) {
if (value == null) {
return "attachment";
}
return value.replace("\"", "%22");
}
private JSONObject readJson(InputStream stream) throws IOException, JSONException {

View File

@@ -673,26 +673,7 @@ public final class BossUi {
boolean outgoing,
@Nullable String kindLabel
) {
LinearLayout wrapper = new LinearLayout(context);
wrapper.setOrientation(LinearLayout.VERTICAL);
wrapper.setGravity(outgoing ? Gravity.END : Gravity.START);
LinearLayout.LayoutParams wrapperParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
wrapperParams.bottomMargin = dp(context, 12);
wrapper.setLayoutParams(wrapperParams);
TextView metaView = new TextView(context);
String metaText = senderLabel;
if (!TextUtils.isEmpty(meta)) {
metaText = metaText + " · " + meta;
}
metaView.setText(metaText);
metaView.setTextSize(11);
metaView.setTextColor(context.getColor(R.color.boss_text_soft));
metaView.setPadding(dp(context, 6), 0, dp(context, 6), dp(context, 4));
wrapper.addView(metaView);
LinearLayout wrapper = buildMessageWrapper(context, senderLabel, meta, outgoing);
LinearLayout bubble = new LinearLayout(context);
bubble.setOrientation(LinearLayout.VERTICAL);
@@ -723,6 +704,93 @@ public final class BossUi {
return wrapper;
}
public static LinearLayout buildAttachmentMessageCard(
Context context,
String senderLabel,
String sourceType,
String fileName,
@Nullable String detail,
@Nullable String status,
@Nullable String meta,
boolean outgoing
) {
LinearLayout wrapper = buildMessageWrapper(context, senderLabel, meta, outgoing);
LinearLayout card = new LinearLayout(context);
card.setOrientation(LinearLayout.VERTICAL);
card.setMinimumWidth(dp(context, 180));
card.setPadding(dp(context, 14), dp(context, 12), dp(context, 14), dp(context, 12));
card.setBackground(createRoundedBackground(
outgoing ? Color.parseColor("#CFF0D8") : Color.WHITE,
dp(context, 18)
));
card.setElevation(dp(context, 1));
if ("image".equals(sourceType) || "video".equals(sourceType)) {
TextView preview = new TextView(context);
LinearLayout.LayoutParams previewParams = new LinearLayout.LayoutParams(
Math.round(context.getResources().getDisplayMetrics().widthPixels * 0.56f),
dp(context, 118)
);
preview.setLayoutParams(previewParams);
preview.setGravity(Gravity.CENTER);
preview.setText("image".equals(sourceType) ? "图片预览" : "视频封面");
preview.setTextSize(13);
preview.setTypeface(Typeface.DEFAULT_BOLD);
preview.setTextColor(context.getColor(R.color.boss_text_muted));
preview.setBackground(createRoundedBackground(Color.parseColor("#EEF2EE"), dp(context, 14)));
card.addView(preview);
TextView nameView = buildAttachmentPrimaryText(context, fileName);
nameView.setPadding(0, dp(context, 10), 0, 0);
card.addView(nameView);
TextView statusView = buildAttachmentSecondaryText(
context,
TextUtils.isEmpty(status) ? "已发送" : status
);
statusView.setPadding(0, dp(context, 6), 0, 0);
card.addView(statusView);
} else {
LinearLayout row = new LinearLayout(context);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
TextView icon = new TextView(context);
LinearLayout.LayoutParams iconParams = new LinearLayout.LayoutParams(dp(context, 42), dp(context, 42));
icon.setLayoutParams(iconParams);
icon.setGravity(Gravity.CENTER);
icon.setText("");
icon.setTextSize(15);
icon.setTypeface(Typeface.DEFAULT_BOLD);
icon.setTextColor(context.getColor(R.color.boss_green));
icon.setBackground(createRoundedBackground(Color.parseColor("#E8F6ED"), dp(context, 12)));
row.addView(icon);
LinearLayout texts = new LinearLayout(context);
texts.setOrientation(LinearLayout.VERTICAL);
LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
);
textParams.leftMargin = dp(context, 12);
texts.setLayoutParams(textParams);
texts.addView(buildAttachmentPrimaryText(context, fileName));
texts.addView(buildAttachmentSecondaryText(
context,
joinAttachmentDetail(detail, status)
));
row.addView(texts);
card.addView(row);
}
wrapper.addView(card);
return wrapper;
}
public static LinearLayout buildForwardSingleBubble(
Context context,
String senderLabel,
@@ -894,6 +962,65 @@ public final class BossUi {
return button;
}
private static LinearLayout buildMessageWrapper(
Context context,
String senderLabel,
@Nullable String meta,
boolean outgoing
) {
LinearLayout wrapper = new LinearLayout(context);
wrapper.setOrientation(LinearLayout.VERTICAL);
wrapper.setGravity(outgoing ? Gravity.END : Gravity.START);
LinearLayout.LayoutParams wrapperParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
wrapperParams.bottomMargin = dp(context, 12);
wrapper.setLayoutParams(wrapperParams);
TextView metaView = new TextView(context);
String metaText = senderLabel;
if (!TextUtils.isEmpty(meta)) {
metaText = metaText + " · " + meta;
}
metaView.setText(metaText);
metaView.setTextSize(11);
metaView.setTextColor(context.getColor(R.color.boss_text_soft));
metaView.setPadding(dp(context, 6), 0, dp(context, 6), dp(context, 4));
wrapper.addView(metaView);
return wrapper;
}
private static TextView buildAttachmentPrimaryText(Context context, String text) {
TextView primary = new TextView(context);
primary.setText(TextUtils.isEmpty(text) ? "未命名附件" : text);
primary.setTextSize(15);
primary.setTypeface(Typeface.DEFAULT_BOLD);
primary.setTextColor(context.getColor(R.color.boss_text_primary));
primary.setMaxLines(2);
primary.setEllipsize(TextUtils.TruncateAt.END);
return primary;
}
private static TextView buildAttachmentSecondaryText(Context context, String text) {
TextView secondary = new TextView(context);
secondary.setText(TextUtils.isEmpty(text) ? "已发送" : text);
secondary.setTextSize(13);
secondary.setTextColor(context.getColor(R.color.boss_text_muted));
secondary.setPadding(0, dp(context, 6), 0, 0);
return secondary;
}
private static String joinAttachmentDetail(@Nullable String detail, @Nullable String status) {
if (TextUtils.isEmpty(detail)) {
return TextUtils.isEmpty(status) ? "已发送" : status;
}
if (TextUtils.isEmpty(status)) {
return detail;
}
return detail + " · " + status;
}
public static EditText buildInput(Context context, String hint, boolean multiline) {
EditText input = new EditText(context);
input.setHint(hint);

View File

@@ -162,6 +162,51 @@ public final class ProjectChatUiState {
return truncate(lastBody, 28);
}
public static String labelForAttachmentAnalysisState(@Nullable String analysisState) {
if ("queued_auto".equals(analysisState) || "ready_manual".equals(analysisState)) {
return "待分析";
}
if ("processing".equals(analysisState)) {
return "分析中";
}
if ("completed".equals(analysisState)) {
return "已分析";
}
if ("failed".equals(analysisState)) {
return "分析失败";
}
return "已发送";
}
public static String labelForAttachmentKind(@Nullable String attachmentKind) {
if ("image".equals(attachmentKind)) {
return "图片";
}
if ("video".equals(attachmentKind)) {
return "视频";
}
if ("pdf".equals(attachmentKind)) {
return "PDF";
}
if ("office".equals(attachmentKind)) {
return "文档";
}
if ("text".equals(attachmentKind)) {
return "文本";
}
return "文件";
}
public static String formatAttachmentSize(long fileSizeBytes) {
if (fileSizeBytes >= 1024L * 1024L) {
return String.format(java.util.Locale.US, "%.1f MB", fileSizeBytes / (1024f * 1024f));
}
if (fileSizeBytes >= 1024L) {
return Math.max(1, Math.round(fileSizeBytes / 1024f)) + " KB";
}
return Math.max(fileSizeBytes, 0L) + " B";
}
private static boolean isBlank(@Nullable String value) {
return value == null || value.trim().isEmpty();
}

View File

@@ -3,10 +3,14 @@ package com.hyzq.boss;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.provider.OpenableColumns;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.Gravity;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
@@ -21,6 +25,8 @@ import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
@@ -35,6 +41,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
private LinearLayout quickActionsLayout;
private LinearLayout composerRow;
private LinearLayout multiSelectActionsLayout;
private Button composerAttachmentButton;
private EditText composerInput;
private Button composerSendButton;
private Button multiSelectForwardButton;
@@ -49,6 +56,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
private ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.emptySelection();
private ActivityResultLauncher<Intent> conversationInfoLauncher;
private ActivityResultLauncher<Intent> forwardTargetLauncher;
private ActivityResultLauncher<String> imagePickerLauncher;
private ActivityResultLauncher<String> videoPickerLauncher;
private ActivityResultLauncher<String> filePickerLauncher;
static final class ChromeBindings {
final boolean multiSelecting;
@@ -100,6 +110,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
quickActionsLayout = findViewById(R.id.project_chat_quick_actions);
composerRow = findViewById(R.id.project_chat_composer_row);
multiSelectActionsLayout = findViewById(R.id.project_chat_multi_select_actions);
composerAttachmentButton = findViewById(R.id.project_chat_attach);
composerInput = findViewById(R.id.project_chat_input);
composerSendButton = findViewById(R.id.project_chat_send);
multiSelectForwardButton = findViewById(R.id.project_chat_multi_forward);
@@ -131,8 +142,23 @@ public class ProjectDetailActivity extends BossScreenActivity {
reload(true);
}
);
imagePickerLauncher = registerForActivityResult(
new ActivityResultContracts.GetContent(),
uri -> onAttachmentPicked(uri, "image")
);
videoPickerLauncher = registerForActivityResult(
new ActivityResultContracts.GetContent(),
uri -> onAttachmentPicked(uri, "video")
);
filePickerLauncher = registerForActivityResult(
new ActivityResultContracts.GetContent(),
uri -> onAttachmentPicked(uri, "file")
);
updateProjectHeader(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情...");
if (composerAttachmentButton != null) {
composerAttachmentButton.setOnClickListener(v -> showAttachmentEntrySheet());
}
composerSendButton.setOnClickListener(v -> sendTextMessageFromComposer());
multiSelectForwardButton.setOnClickListener(v -> {
if (!ProjectChatUiState.canForwardSelection(selectionState)) {
@@ -203,6 +229,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
@Override
protected void setRefreshing(boolean refreshing) {
super.setRefreshing(refreshing);
if (composerAttachmentButton != null) {
composerAttachmentButton.setEnabled(!refreshing && !composerSending);
}
if (composerInput != null) {
composerInput.setEnabled(!refreshing);
}
@@ -297,6 +326,62 @@ public class ProjectDetailActivity extends BossScreenActivity {
sendProjectMessage("text", body);
}
private void showAttachmentEntrySheet() {
if (isComposerBusy()) {
return;
}
AlertDialog dialog = new AlertDialog.Builder(this)
.setItems(new CharSequence[]{"图片", "视频", "文件"}, (sheet, which) -> {
if (which == 0) {
imagePickerLauncher.launch("image/*");
} else if (which == 1) {
videoPickerLauncher.launch("video/*");
} else {
filePickerLauncher.launch("*/*");
}
})
.create();
dialog.show();
if (dialog.getWindow() != null) {
dialog.getWindow().setGravity(Gravity.BOTTOM);
}
}
private void onAttachmentPicked(@Nullable Uri uri, String sourceType) {
if (uri == null) {
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
AttachmentComposerState.PendingAttachment attachment = readPendingAttachment(uri, sourceType);
runOnUiThread(() -> {
setRefreshing(false);
if (attachment.requiresConfirmation()) {
showAttachmentConfirmDialog(attachment);
} else {
uploadAttachment(attachment);
}
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("读取附件失败:" + error.getMessage());
});
}
});
}
private void showAttachmentConfirmDialog(AttachmentComposerState.PendingAttachment attachment) {
String actionLabel = "image".equals(attachment.sourceType) ? "图片" : "视频";
new AlertDialog.Builder(this)
.setTitle("发送" + actionLabel)
.setMessage(attachment.fileName + " · " + ProjectChatUiState.formatAttachmentSize(attachment.fileSizeBytes))
.setNegativeButton("取消", null)
.setPositiveButton("发送", (dialog, which) -> uploadAttachment(attachment))
.show();
}
private void sendProjectMessage(String kind, String body) {
composerSending = true;
updateComposerSendButtonState();
@@ -327,6 +412,61 @@ public class ProjectDetailActivity extends BossScreenActivity {
});
}
private void uploadAttachment(AttachmentComposerState.PendingAttachment attachment) {
composerSending = true;
updateComposerSendButtonState();
setRefreshing(true);
appendPendingOutgoingAttachment(attachment);
scrollChatToBottom();
executor.execute(() -> {
try {
BossApiClient.ApiResponse uploadResponse = apiClient.uploadAttachment(
projectId,
attachment.fileName,
attachment.mimeType,
attachment.bytes,
attachment.sourceType
);
if (!uploadResponse.ok()) {
throw new IllegalStateException(uploadResponse.message());
}
String successMessage = "附件已发送";
JSONObject uploadedAttachment = uploadResponse.json.optJSONObject("attachment");
if (uploadedAttachment != null && attachment.requiresConfirmation()) {
String attachmentId = uploadedAttachment.optString("attachmentId", "");
String analysisState = uploadedAttachment.optString("analysisState", "");
if (!TextUtils.isEmpty(attachmentId)
&& ("ready_manual".equals(analysisState) || "failed".equals(analysisState))) {
try {
BossApiClient.ApiResponse analyzeResponse = apiClient.analyzeAttachment(projectId, attachmentId);
successMessage = analyzeResponse.ok()
? "附件已发送,分析已发起"
: "附件已发送,分析稍后可用";
} catch (Exception ignored) {
successMessage = "附件已发送,分析稍后可用";
}
}
}
String finalSuccessMessage = successMessage;
runOnUiThread(() -> {
composerSending = false;
showMessage(finalSuccessMessage);
reload(true);
});
} catch (Exception error) {
runOnUiThread(() -> {
composerSending = false;
setRefreshing(false);
removePendingOutgoingBubble();
showMessage("附件发送失败:" + error.getMessage());
updateComposerSendButtonState();
});
}
});
}
private void openGoals() {
Intent intent = new Intent(this, ProjectGoalsActivity.class);
intent.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, projectId);
@@ -362,6 +502,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
View messageView;
switch (kind) {
case "attachment":
messageView = buildAttachmentMessageView(message, senderLabel, meta, outgoing);
break;
case "forward_single":
messageView = BossUi.buildForwardSingleBubble(
this,
@@ -397,6 +540,42 @@ public class ProjectDetailActivity extends BossScreenActivity {
return messageView;
}
private View buildAttachmentMessageView(
JSONObject message,
String senderLabel,
String meta,
boolean outgoing
) {
JSONObject attachment = firstAttachment(message);
if (attachment == null) {
return BossUi.buildMessageBubble(
this,
senderLabel,
message.optString("body", "已发送附件"),
meta,
outgoing,
"附件"
);
}
String sourceType = resolveAttachmentSourceType(
attachment.optString("attachmentKind", ""),
attachment.optString("mimeType", "")
);
String detail = ("image".equals(sourceType) || "video".equals(sourceType))
? null
: ProjectChatUiState.formatAttachmentSize(attachment.optLong("fileSizeBytes", 0L));
return BossUi.buildAttachmentMessageCard(
this,
senderLabel,
sourceType,
attachment.optString("fileName", "attachment"),
detail,
ProjectChatUiState.labelForAttachmentAnalysisState(attachment.optString("analysisState", "")),
meta,
outgoing
);
}
private void bindMessageInteractions(View messageView, String messageId, String body) {
if (messageView == null || TextUtils.isEmpty(messageId)) {
return;
@@ -611,6 +790,31 @@ public class ProjectDetailActivity extends BossScreenActivity {
appendContent(pendingOutgoingBubble);
}
private void appendPendingOutgoingAttachment(AttachmentComposerState.PendingAttachment attachment) {
if (contentLayout == null) {
return;
}
removePendingOutgoingBubble();
if (contentLayout.getChildCount() == 1 && contentLayout.getChildAt(0) instanceof android.widget.TextView) {
contentLayout.removeAllViews();
}
String senderLabel = TextUtils.isEmpty(apiClient.getDisplayName()) ? "" : apiClient.getDisplayName();
String detail = attachment.requiresConfirmation()
? null
: ProjectChatUiState.formatAttachmentSize(attachment.fileSizeBytes);
pendingOutgoingBubble = BossUi.buildAttachmentMessageCard(
this,
senderLabel,
attachment.sourceType,
attachment.fileName,
detail,
"发送中",
null,
true
);
appendContent(pendingOutgoingBubble);
}
private void removePendingOutgoingBubble() {
if (pendingOutgoingBubble != null && pendingOutgoingBubble.getParent() != null && contentLayout != null) {
contentLayout.removeView(pendingOutgoingBubble);
@@ -707,6 +911,106 @@ public class ProjectDetailActivity extends BossScreenActivity {
return message.optString("body", "转发的聊天记录");
}
@Nullable
private JSONObject firstAttachment(JSONObject message) {
JSONArray attachments = message.optJSONArray("attachments");
if (attachments == null || attachments.length() == 0) {
return null;
}
return attachments.optJSONObject(0);
}
private String resolveAttachmentSourceType(String attachmentKind, String mimeType) {
if ("image".equals(attachmentKind) || (!TextUtils.isEmpty(mimeType) && mimeType.startsWith("image/"))) {
return "image";
}
if ("video".equals(attachmentKind) || (!TextUtils.isEmpty(mimeType) && mimeType.startsWith("video/"))) {
return "video";
}
return "file";
}
private AttachmentComposerState.PendingAttachment readPendingAttachment(Uri uri, String sourceType) throws Exception {
String fileName = resolveAttachmentFileName(uri);
String mimeType = resolveAttachmentMimeType(uri, sourceType);
byte[] bytes;
try (InputStream inputStream = getContentResolver().openInputStream(uri)) {
if (inputStream == null) {
throw new IllegalStateException("无法打开附件输入流");
}
bytes = readAllBytes(inputStream);
}
long fileSize = resolveAttachmentFileSize(uri, bytes.length);
return new AttachmentComposerState.PendingAttachment(sourceType, fileName, mimeType, fileSize, bytes);
}
private String resolveAttachmentFileName(Uri uri) {
String fallback = uri.getLastPathSegment();
try (Cursor cursor = getContentResolver().query(
uri,
new String[]{OpenableColumns.DISPLAY_NAME},
null,
null,
null
)) {
if (cursor != null && cursor.moveToFirst()) {
int columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
if (columnIndex >= 0) {
String displayName = cursor.getString(columnIndex);
if (!TextUtils.isEmpty(displayName)) {
return displayName;
}
}
}
}
return TextUtils.isEmpty(fallback) ? "attachment" : fallback;
}
private long resolveAttachmentFileSize(Uri uri, long fallback) {
try (Cursor cursor = getContentResolver().query(
uri,
new String[]{OpenableColumns.SIZE},
null,
null,
null
)) {
if (cursor != null && cursor.moveToFirst()) {
int columnIndex = cursor.getColumnIndex(OpenableColumns.SIZE);
if (columnIndex >= 0) {
long value = cursor.getLong(columnIndex);
if (value > 0) {
return value;
}
}
}
}
return fallback;
}
private String resolveAttachmentMimeType(Uri uri, String sourceType) {
String mimeType = getContentResolver().getType(uri);
if (!TextUtils.isEmpty(mimeType)) {
return mimeType;
}
if ("image".equals(sourceType)) {
return "image/*";
}
if ("video".equals(sourceType)) {
return "video/*";
}
return "application/octet-stream";
}
private byte[] readAllBytes(InputStream inputStream) throws Exception {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
byte[] buffer = new byte[8192];
int read;
while ((read = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, read);
}
return outputStream.toByteArray();
}
static ChromeBindings buildChromeBindings(
ProjectChatUiState.ChromeState chromeState,
boolean composerBusy

View File

@@ -135,6 +135,19 @@
android:paddingRight="12dp"
android:paddingBottom="12dp">
<Button
android:id="@+id/project_chat_attach"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_marginRight="8dp"
android:background="@drawable/bg_secondary_button"
android:minWidth="0dp"
android:text="+"
android:textAllCaps="false"
android:textColor="@color/boss_text_primary"
android:textSize="20sp"
android:textStyle="bold" />
<EditText
android:id="@+id/project_chat_input"
android:layout_width="0dp"

View File

@@ -0,0 +1,50 @@
package com.hyzq.boss;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class AttachmentComposerStateTest {
@Test
public void imageAttachments_requireConfirmationBeforeSending() {
AttachmentComposerState.PendingAttachment attachment =
new AttachmentComposerState.PendingAttachment(
"image",
"现场照片.png",
"image/png",
4096L,
new byte[] {1, 2, 3}
);
assertTrue(attachment.requiresConfirmation());
}
@Test
public void videoAttachments_requireConfirmationBeforeSending() {
AttachmentComposerState.PendingAttachment attachment =
new AttachmentComposerState.PendingAttachment(
"video",
"巡检录屏.mp4",
"video/mp4",
8192L,
new byte[] {4, 5, 6}
);
assertTrue(attachment.requiresConfirmation());
}
@Test
public void fileAttachments_doNotRequireConfirmation() {
AttachmentComposerState.PendingAttachment attachment =
new AttachmentComposerState.PendingAttachment(
"file",
"日报.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
16384L,
new byte[] {7, 8, 9}
);
assertFalse(attachment.requiresConfirmation());
}
}

View File

@@ -0,0 +1,261 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.content.SharedPreferences;
import org.json.JSONObject;
import org.junit.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.ProtocolException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class BossApiClientAttachmentTest {
@Test
public void uploadAttachment_postsMultipartBodyWithSourceType() throws Exception {
RecordingConnection connection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/projects/project-1/attachments")
);
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.uploadAttachment(
"project-1",
"现场照片.png",
"image/png",
new byte[] {1, 2, 3, 4},
"image"
);
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/project-1/attachments", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertTrue(connection.contentTypeValue.startsWith("multipart/form-data; boundary="));
assertTrue(connection.requestBody().contains("name=\"sourceType\""));
assertTrue(connection.requestBody().contains("\r\nimage\r\n"));
assertTrue(connection.requestBody().contains("name=\"file\"; filename=\"现场照片.png\""));
assertTrue(connection.requestBody().contains("Content-Type: image/png"));
}
@Test
public void analyzeAttachment_postsToAnalyzeEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/projects/project-1/attachments/att-1/analyze")
);
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.analyzeAttachment("project-1", "att-1");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/project-1/attachments/att-1/analyze", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals("{}", connection.requestBody());
}
private static final class RecordingBossApiClient extends BossApiClient {
private final RecordingConnection connection;
private String lastPath = "";
RecordingBossApiClient(RecordingConnection connection) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
this.connection = connection;
}
@Override
HttpURLConnection openConnection(String path) {
lastPath = path;
return connection;
}
@Override
String encode(String value) {
return value;
}
@Override
void rememberIdentity(JSONObject json) {
// JVM 单测不需要落 Android 侧身份缓存。
}
}
private static final class RecordingConnection extends HttpURLConnection {
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
private final Map<String, String> requestHeaders = new HashMap<>();
private String requestMethodValue = "GET";
private String contentTypeValue = "";
RecordingConnection(URL url) {
super(url);
}
@Override
public void disconnect() {}
@Override
public boolean usingProxy() {
return false;
}
@Override
public void connect() {}
@Override
public void setRequestMethod(String method) throws ProtocolException {
requestMethodValue = method;
}
@Override
public void setRequestProperty(String key, String value) {
requestHeaders.put(key, value);
if ("Content-Type".equalsIgnoreCase(key)) {
contentTypeValue = value;
}
}
@Override
public String getRequestProperty(String key) {
return requestHeaders.get(key);
}
@Override
public OutputStream getOutputStream() {
return requestBody;
}
@Override
public int getResponseCode() {
return 200;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
}
@Override
public Map<String, List<String>> getHeaderFields() {
return Collections.emptyMap();
}
String requestBody() {
return requestBody.toString(StandardCharsets.UTF_8);
}
}
private static final class InMemorySharedPreferences implements SharedPreferences {
private final Map<String, String> values = new HashMap<>();
@Override
public Map<String, ?> getAll() {
return Collections.unmodifiableMap(values);
}
@Override
public String getString(String key, String defValue) {
return values.getOrDefault(key, defValue);
}
@Override
public Set<String> getStringSet(String key, Set<String> defValues) {
throw new UnsupportedOperationException();
}
@Override
public int getInt(String key, int defValue) {
throw new UnsupportedOperationException();
}
@Override
public long getLong(String key, long defValue) {
throw new UnsupportedOperationException();
}
@Override
public float getFloat(String key, float defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean getBoolean(String key, boolean defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean contains(String key) {
return values.containsKey(key);
}
@Override
public Editor edit() {
return new Editor() {
@Override
public Editor putString(String key, String value) {
values.put(key, value);
return this;
}
@Override
public Editor remove(String key) {
values.remove(key);
return this;
}
@Override
public Editor clear() {
values.clear();
return this;
}
@Override
public void apply() {}
@Override
public boolean commit() {
return true;
}
@Override
public Editor putStringSet(String key, Set<String> values) {
throw new UnsupportedOperationException();
}
@Override
public Editor putInt(String key, int value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putLong(String key, long value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putFloat(String key, float value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putBoolean(String key, boolean value) {
throw new UnsupportedOperationException();
}
};
}
@Override
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
@Override
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
}
}