diff --git a/android/app/src/main/java/com/hyzq/boss/AttachmentComposerState.java b/android/app/src/main/java/com/hyzq/boss/AttachmentComposerState.java new file mode 100644 index 0000000..1a0da95 --- /dev/null +++ b/android/app/src/main/java/com/hyzq/boss/AttachmentComposerState.java @@ -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); + } + } +} diff --git a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java index b2baf38..faabd28 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -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 { diff --git a/android/app/src/main/java/com/hyzq/boss/BossUi.java b/android/app/src/main/java/com/hyzq/boss/BossUi.java index 67a28fc..ee053d0 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossUi.java +++ b/android/app/src/main/java/com/hyzq/boss/BossUi.java @@ -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); diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java index e182136..2fa84fc 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java @@ -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(); } 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 5ece433..397181b 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -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 conversationInfoLauncher; private ActivityResultLauncher forwardTargetLauncher; + private ActivityResultLauncher imagePickerLauncher; + private ActivityResultLauncher videoPickerLauncher; + private ActivityResultLauncher 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 diff --git a/android/app/src/main/res/layout/activity_project_chat.xml b/android/app/src/main/res/layout/activity_project_chat.xml index f98a56f..b3536bf 100644 --- a/android/app/src/main/res/layout/activity_project_chat.xml +++ b/android/app/src/main/res/layout/activity_project_chat.xml @@ -135,6 +135,19 @@ android:paddingRight="12dp" android:paddingBottom="12dp"> +