fix: stabilize attachment upload and storage flows
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public final class AttachmentComposerState {
|
||||
private AttachmentComposerState() {}
|
||||
|
||||
public static boolean requiresConfirmation(@Nullable String sourceType) {
|
||||
return "image".equals(sourceType) || "video".equals(sourceType);
|
||||
return ProjectChatUiState.requiresAttachmentConfirmation(sourceType);
|
||||
}
|
||||
|
||||
public static final class PendingAttachment {
|
||||
@@ -14,22 +16,22 @@ public final class AttachmentComposerState {
|
||||
public final String fileName;
|
||||
public final String mimeType;
|
||||
public final long fileSizeBytes;
|
||||
public final byte[] bytes;
|
||||
@Nullable public final Uri uri;
|
||||
|
||||
public PendingAttachment(
|
||||
String sourceType,
|
||||
String fileName,
|
||||
String mimeType,
|
||||
long fileSizeBytes,
|
||||
byte[] bytes
|
||||
@Nullable Uri uri
|
||||
) {
|
||||
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();
|
||||
this.fileSizeBytes = Math.max(fileSizeBytes, 0L);
|
||||
this.uri = uri;
|
||||
}
|
||||
|
||||
public boolean requiresConfirmation() {
|
||||
|
||||
@@ -11,6 +11,8 @@ import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
@@ -107,6 +109,22 @@ public class BossApiClient {
|
||||
String mimeType,
|
||||
byte[] bytes,
|
||||
String sourceType
|
||||
) throws IOException, JSONException {
|
||||
return uploadAttachment(
|
||||
projectId,
|
||||
fileName,
|
||||
mimeType,
|
||||
new ByteArrayInputStream(bytes == null ? new byte[0] : bytes),
|
||||
sourceType
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse uploadAttachment(
|
||||
String projectId,
|
||||
String fileName,
|
||||
String mimeType,
|
||||
InputStream inputStream,
|
||||
String sourceType
|
||||
) throws IOException, JSONException {
|
||||
HttpURLConnection connection = openConnection("/api/v1/projects/" + encode(projectId) + "/attachments");
|
||||
prepareConnection(connection, "POST");
|
||||
@@ -121,7 +139,7 @@ public class BossApiClient {
|
||||
outputStream,
|
||||
boundary,
|
||||
"file",
|
||||
bytes == null ? new byte[0] : bytes,
|
||||
inputStream,
|
||||
fileName,
|
||||
mimeType
|
||||
);
|
||||
@@ -139,6 +157,25 @@ public class BossApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
public DownloadedAttachment downloadAttachment(
|
||||
String attachmentId,
|
||||
String fallbackFileName,
|
||||
String fallbackMimeType
|
||||
) throws IOException {
|
||||
DownloadedAttachment attachment = downloadAttachmentRaw(attachmentId, fallbackFileName, fallbackMimeType, true);
|
||||
if (attachment.statusCode == 401 && !getRestoreToken().isEmpty()) {
|
||||
try {
|
||||
ApiResponse restored = restoreSession();
|
||||
if (restored.ok()) {
|
||||
return downloadAttachmentRaw(attachmentId, fallbackFileName, fallbackMimeType, true);
|
||||
}
|
||||
} catch (JSONException exception) {
|
||||
throw new IOException("SESSION_RESTORE_FAILED", exception);
|
||||
}
|
||||
}
|
||||
return attachment;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -380,7 +417,7 @@ public class BossApiClient {
|
||||
OutputStream outputStream,
|
||||
String boundary,
|
||||
String fieldName,
|
||||
byte[] bytes,
|
||||
InputStream inputStream,
|
||||
String fileName,
|
||||
String contentType
|
||||
) throws IOException {
|
||||
@@ -398,10 +435,57 @@ public class BossApiClient {
|
||||
? "application/octet-stream"
|
||||
: contentType) + "\r\n\r\n").getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
outputStream.write(bytes == null ? new byte[0] : bytes);
|
||||
if (inputStream != null) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
outputStream.write("\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private DownloadedAttachment downloadAttachmentRaw(
|
||||
String attachmentId,
|
||||
String fallbackFileName,
|
||||
String fallbackMimeType,
|
||||
boolean expectProtected
|
||||
) throws IOException {
|
||||
HttpURLConnection connection = openConnection("/api/v1/attachments/" + encode(attachmentId) + "/download");
|
||||
prepareConnection(connection, "GET");
|
||||
int statusCode = connection.getResponseCode();
|
||||
captureSessionCookie(connection.getHeaderFields());
|
||||
|
||||
if (statusCode >= 400) {
|
||||
String errorBody = readText(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
|
||||
if (statusCode == 401 && !expectProtected) {
|
||||
clearSession();
|
||||
}
|
||||
return DownloadedAttachment.error(statusCode, errorBody);
|
||||
}
|
||||
|
||||
byte[] bytes;
|
||||
try (InputStream inputStream = connection.getInputStream();
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while (inputStream != null && (read = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, read);
|
||||
}
|
||||
bytes = outputStream.toByteArray();
|
||||
}
|
||||
|
||||
String contentType = connection.getHeaderField("Content-Type");
|
||||
String fileName = parseDownloadFileName(connection.getHeaderField("Content-Disposition"));
|
||||
return new DownloadedAttachment(
|
||||
statusCode,
|
||||
fileName == null || fileName.isEmpty() ? fallbackFileName : fileName,
|
||||
contentType == null || contentType.isEmpty() ? fallbackMimeType : contentType,
|
||||
bytes,
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
private String escapeMultipartValue(String value) {
|
||||
if (value == null) {
|
||||
return "attachment";
|
||||
@@ -427,6 +511,35 @@ public class BossApiClient {
|
||||
return new JSONObject(raw);
|
||||
}
|
||||
|
||||
private String readText(InputStream stream) throws IOException {
|
||||
if (stream == null) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
builder.append(line);
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private String parseDownloadFileName(@Nullable String contentDisposition) {
|
||||
if (contentDisposition == null || contentDisposition.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String[] parts = contentDisposition.split(";");
|
||||
for (String part : parts) {
|
||||
String trimmed = part.trim();
|
||||
if (trimmed.startsWith("filename=")) {
|
||||
return trimmed.substring("filename=".length()).replace("\"", "");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void captureSessionCookie(Map<String, List<String>> headers) {
|
||||
if (headers == null) return;
|
||||
List<String> setCookieHeaders = headers.get("Set-Cookie");
|
||||
@@ -503,4 +616,34 @@ public class BossApiClient {
|
||||
return new ApiResponse(statusCode, json);
|
||||
}
|
||||
}
|
||||
|
||||
public static class DownloadedAttachment {
|
||||
public final int statusCode;
|
||||
public final String fileName;
|
||||
public final String mimeType;
|
||||
public final byte[] bytes;
|
||||
public final String errorBody;
|
||||
|
||||
public DownloadedAttachment(
|
||||
int statusCode,
|
||||
String fileName,
|
||||
String mimeType,
|
||||
byte[] bytes,
|
||||
String errorBody
|
||||
) {
|
||||
this.statusCode = statusCode;
|
||||
this.fileName = fileName;
|
||||
this.mimeType = mimeType;
|
||||
this.bytes = bytes == null ? new byte[0] : bytes;
|
||||
this.errorBody = errorBody == null ? "" : errorBody;
|
||||
}
|
||||
|
||||
public boolean ok() {
|
||||
return statusCode >= 200 && statusCode < 300;
|
||||
}
|
||||
|
||||
public static DownloadedAttachment error(int statusCode, String errorBody) {
|
||||
return new DownloadedAttachment(statusCode, "", "application/octet-stream", new byte[0], errorBody);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,8 +711,12 @@ public final class BossUi {
|
||||
String fileName,
|
||||
@Nullable String detail,
|
||||
@Nullable String status,
|
||||
@Nullable String summary,
|
||||
@Nullable String actionLabel,
|
||||
@Nullable View.OnClickListener actionListener,
|
||||
@Nullable String meta,
|
||||
boolean outgoing
|
||||
boolean outgoing,
|
||||
@Nullable View.OnClickListener cardClickListener
|
||||
) {
|
||||
LinearLayout wrapper = buildMessageWrapper(context, senderLabel, meta, outgoing);
|
||||
|
||||
@@ -725,6 +729,11 @@ public final class BossUi {
|
||||
dp(context, 18)
|
||||
));
|
||||
card.setElevation(dp(context, 1));
|
||||
if (cardClickListener != null) {
|
||||
card.setClickable(true);
|
||||
card.setFocusable(true);
|
||||
card.setOnClickListener(cardClickListener);
|
||||
}
|
||||
|
||||
if ("image".equals(sourceType) || "video".equals(sourceType)) {
|
||||
TextView preview = new TextView(context);
|
||||
@@ -787,6 +796,32 @@ public final class BossUi {
|
||||
card.addView(row);
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(summary)) {
|
||||
TextView summaryView = buildAttachmentSecondaryText(context, summary);
|
||||
summaryView.setPadding(0, dp(context, 10), 0, 0);
|
||||
summaryView.setTextColor(context.getColor(R.color.boss_text_primary));
|
||||
card.addView(summaryView);
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(actionLabel) && actionListener != null) {
|
||||
Button actionButton = new Button(context);
|
||||
LinearLayout.LayoutParams actionParams = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
dp(context, 34)
|
||||
);
|
||||
actionParams.topMargin = dp(context, 12);
|
||||
actionButton.setLayoutParams(actionParams);
|
||||
actionButton.setMinWidth(dp(context, 88));
|
||||
actionButton.setText(actionLabel);
|
||||
actionButton.setAllCaps(false);
|
||||
actionButton.setTextSize(13);
|
||||
actionButton.setPadding(dp(context, 14), 0, dp(context, 14), 0);
|
||||
actionButton.setBackgroundResource(R.drawable.bg_secondary_button);
|
||||
actionButton.setTextColor(context.getColor(R.color.boss_text_primary));
|
||||
actionButton.setOnClickListener(actionListener);
|
||||
card.addView(actionButton);
|
||||
}
|
||||
|
||||
wrapper.addView(card);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ public final class ProjectChatUiState {
|
||||
return !sending && text != null && !text.trim().isEmpty();
|
||||
}
|
||||
|
||||
public static boolean requiresAttachmentConfirmation(@Nullable String sourceType) {
|
||||
return "image".equals(sourceType) || "video".equals(sourceType);
|
||||
}
|
||||
|
||||
public static boolean shouldAutoScroll(boolean nearBottom, boolean forced) {
|
||||
return nearBottom || forced;
|
||||
}
|
||||
@@ -163,11 +167,14 @@ public final class ProjectChatUiState {
|
||||
}
|
||||
|
||||
public static String labelForAttachmentAnalysisState(@Nullable String analysisState) {
|
||||
if ("queued_auto".equals(analysisState) || "ready_manual".equals(analysisState)) {
|
||||
if ("queued_auto".equals(analysisState)) {
|
||||
return "自动分析排队中";
|
||||
}
|
||||
if ("ready_manual".equals(analysisState)) {
|
||||
return "待分析";
|
||||
}
|
||||
if ("processing".equals(analysisState)) {
|
||||
return "分析中";
|
||||
return "AI 分析中";
|
||||
}
|
||||
if ("completed".equals(analysisState)) {
|
||||
return "已分析";
|
||||
@@ -178,6 +185,17 @@ public final class ProjectChatUiState {
|
||||
return "已发送";
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String actionLabelForAttachmentAnalysisState(@Nullable String analysisState) {
|
||||
if ("ready_manual".equals(analysisState)) {
|
||||
return "让 AI 分析";
|
||||
}
|
||||
if ("failed".equals(analysisState)) {
|
||||
return "重试分析";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String labelForAttachmentKind(@Nullable String attachmentKind) {
|
||||
if ("image".equals(attachmentKind)) {
|
||||
return "图片";
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
@@ -16,16 +18,19 @@ import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.content.FileProvider;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -330,23 +335,60 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
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 dialog = new Dialog(this);
|
||||
LinearLayout sheet = new LinearLayout(this);
|
||||
sheet.setOrientation(LinearLayout.VERTICAL);
|
||||
sheet.setPadding(BossUi.dp(this, 18), BossUi.dp(this, 12), BossUi.dp(this, 18), BossUi.dp(this, 18));
|
||||
|
||||
TextView title = new TextView(this);
|
||||
title.setText("发送附件");
|
||||
title.setTextSize(16);
|
||||
title.setTypeface(android.graphics.Typeface.DEFAULT_BOLD);
|
||||
title.setTextColor(getColor(R.color.boss_text_primary));
|
||||
title.setPadding(0, 0, 0, BossUi.dp(this, 12));
|
||||
sheet.addView(title);
|
||||
|
||||
sheet.addView(buildAttachmentSheetAction("图片", () -> imagePickerLauncher.launch("image/*"), dialog));
|
||||
sheet.addView(buildAttachmentSheetAction("视频", () -> videoPickerLauncher.launch("video/*"), dialog));
|
||||
sheet.addView(buildAttachmentSheetAction("文件", () -> filePickerLauncher.launch("*/*"), dialog));
|
||||
|
||||
dialog.setContentView(sheet);
|
||||
dialog.show();
|
||||
if (dialog.getWindow() != null) {
|
||||
dialog.getWindow().setGravity(Gravity.BOTTOM);
|
||||
dialog.getWindow().setLayout(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
dialog.getWindow().setGravity(Gravity.BOTTOM);
|
||||
}
|
||||
}
|
||||
|
||||
private View buildAttachmentSheetAction(
|
||||
String label,
|
||||
Runnable action,
|
||||
Dialog dialog
|
||||
) {
|
||||
Button button = new Button(this);
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
BossUi.dp(this, 48)
|
||||
);
|
||||
params.bottomMargin = BossUi.dp(this, 8);
|
||||
button.setLayoutParams(params);
|
||||
button.setBackgroundResource(R.drawable.bg_secondary_button);
|
||||
button.setAllCaps(false);
|
||||
button.setGravity(Gravity.CENTER_VERTICAL | Gravity.START);
|
||||
button.setPadding(BossUi.dp(this, 16), 0, BossUi.dp(this, 16), 0);
|
||||
button.setText(label);
|
||||
button.setTextSize(15);
|
||||
button.setTextColor(getColor(R.color.boss_text_primary));
|
||||
button.setOnClickListener(v -> {
|
||||
dialog.dismiss();
|
||||
action.run();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
private void onAttachmentPicked(@Nullable Uri uri, String sourceType) {
|
||||
if (uri == null) {
|
||||
return;
|
||||
@@ -419,12 +461,12 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
appendPendingOutgoingAttachment(attachment);
|
||||
scrollChatToBottom();
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
try (InputStream inputStream = openAttachmentInputStream(attachment)) {
|
||||
BossApiClient.ApiResponse uploadResponse = apiClient.uploadAttachment(
|
||||
projectId,
|
||||
attachment.fileName,
|
||||
attachment.mimeType,
|
||||
attachment.bytes,
|
||||
inputStream,
|
||||
attachment.sourceType
|
||||
);
|
||||
if (!uploadResponse.ok()) {
|
||||
@@ -433,19 +475,14 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
|
||||
String successMessage = "附件已发送";
|
||||
JSONObject uploadedAttachment = uploadResponse.json.optJSONObject("attachment");
|
||||
if (uploadedAttachment != null && attachment.requiresConfirmation()) {
|
||||
String attachmentId = uploadedAttachment.optString("attachmentId", "");
|
||||
if (uploadedAttachment != null) {
|
||||
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 = "附件已发送,分析稍后可用";
|
||||
}
|
||||
if ("queued_auto".equals(analysisState)) {
|
||||
successMessage = "附件已发送,自动分析排队中";
|
||||
} else if ("ready_manual".equals(analysisState)) {
|
||||
successMessage = "附件已发送,可手动发起分析";
|
||||
} else if ("failed".equals(analysisState)) {
|
||||
successMessage = "附件已发送,分析稍后可重试";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,9 +538,17 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
boolean outgoing = isOutgoingMessage(senderLabel);
|
||||
|
||||
View messageView;
|
||||
View.OnClickListener messagePrimaryClick = null;
|
||||
switch (kind) {
|
||||
case "attachment":
|
||||
messageView = buildAttachmentMessageView(message, senderLabel, meta, outgoing);
|
||||
JSONObject attachment = firstAttachment(message);
|
||||
if (attachment != null) {
|
||||
String attachmentId = attachment.optString("attachmentId", "");
|
||||
if (!TextUtils.isEmpty(attachmentId)) {
|
||||
messagePrimaryClick = v -> openAttachment(attachment);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "forward_single":
|
||||
messageView = BossUi.buildForwardSingleBubble(
|
||||
@@ -536,7 +581,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
);
|
||||
break;
|
||||
}
|
||||
bindMessageInteractions(messageView, messageId, body);
|
||||
bindMessageInteractions(messageView, messageId, body, messagePrimaryClick);
|
||||
return messageView;
|
||||
}
|
||||
|
||||
@@ -564,19 +609,34 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
String detail = ("image".equals(sourceType) || "video".equals(sourceType))
|
||||
? null
|
||||
: ProjectChatUiState.formatAttachmentSize(attachment.optLong("fileSizeBytes", 0L));
|
||||
String analysisState = attachment.optString("analysisState", "");
|
||||
String attachmentId = attachment.optString("attachmentId", "");
|
||||
String actionLabel = ProjectChatUiState.actionLabelForAttachmentAnalysisState(analysisState);
|
||||
View.OnClickListener actionListener = TextUtils.isEmpty(actionLabel) || TextUtils.isEmpty(attachmentId)
|
||||
? null
|
||||
: v -> requestAttachmentAnalysis(attachmentId, attachment.optString("fileName", "附件"));
|
||||
return BossUi.buildAttachmentMessageCard(
|
||||
this,
|
||||
senderLabel,
|
||||
sourceType,
|
||||
attachment.optString("fileName", "attachment"),
|
||||
detail,
|
||||
ProjectChatUiState.labelForAttachmentAnalysisState(attachment.optString("analysisState", "")),
|
||||
ProjectChatUiState.labelForAttachmentAnalysisState(analysisState),
|
||||
attachment.optString("analysisSummary", ""),
|
||||
actionLabel,
|
||||
actionListener,
|
||||
meta,
|
||||
outgoing
|
||||
outgoing,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private void bindMessageInteractions(View messageView, String messageId, String body) {
|
||||
private void bindMessageInteractions(
|
||||
View messageView,
|
||||
String messageId,
|
||||
String body,
|
||||
@Nullable View.OnClickListener defaultClickListener
|
||||
) {
|
||||
if (messageView == null || TextUtils.isEmpty(messageId)) {
|
||||
return;
|
||||
}
|
||||
@@ -585,6 +645,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
messageView.setLongClickable(true);
|
||||
messageView.setOnClickListener(v -> {
|
||||
if (!selectionState.multiSelecting) {
|
||||
if (defaultClickListener != null) {
|
||||
defaultClickListener.onClick(v);
|
||||
}
|
||||
return;
|
||||
}
|
||||
toggleMultiSelectMessage(messageId);
|
||||
@@ -810,7 +873,11 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
detail,
|
||||
"发送中",
|
||||
null,
|
||||
true
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
null
|
||||
);
|
||||
appendContent(pendingOutgoingBubble);
|
||||
}
|
||||
@@ -933,15 +1000,8 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
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);
|
||||
long fileSize = resolveAttachmentFileSize(uri, 0L);
|
||||
return new AttachmentComposerState.PendingAttachment(sourceType, fileName, mimeType, fileSize, uri);
|
||||
}
|
||||
|
||||
private String resolveAttachmentFileName(Uri uri) {
|
||||
@@ -1001,14 +1061,104 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
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);
|
||||
private InputStream openAttachmentInputStream(AttachmentComposerState.PendingAttachment attachment) throws Exception {
|
||||
if (attachment.uri == null) {
|
||||
throw new IllegalStateException("附件来源不存在");
|
||||
}
|
||||
InputStream inputStream = getContentResolver().openInputStream(attachment.uri);
|
||||
if (inputStream == null) {
|
||||
throw new IllegalStateException("无法打开附件输入流");
|
||||
}
|
||||
return inputStream;
|
||||
}
|
||||
|
||||
private void requestAttachmentAnalysis(String attachmentId, String fileName) {
|
||||
if (TextUtils.isEmpty(attachmentId)) {
|
||||
showMessage("缺少附件标识");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.analyzeAttachment(projectId, attachmentId);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showMessage("已发起分析:" + fileName);
|
||||
reload(false);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("发起分析失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void openAttachment(JSONObject attachment) {
|
||||
String attachmentId = attachment.optString("attachmentId", "");
|
||||
if (TextUtils.isEmpty(attachmentId)) {
|
||||
showMessage("缺少附件标识");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.DownloadedAttachment downloaded = apiClient.downloadAttachment(
|
||||
attachmentId,
|
||||
attachment.optString("fileName", "attachment"),
|
||||
attachment.optString("mimeType", "application/octet-stream")
|
||||
);
|
||||
if (!downloaded.ok()) {
|
||||
throw new IllegalStateException(
|
||||
TextUtils.isEmpty(downloaded.errorBody)
|
||||
? "DOWNLOAD_FAILED"
|
||||
: downloaded.errorBody
|
||||
);
|
||||
}
|
||||
File file = cacheDownloadedAttachment(downloaded.fileName, downloaded.bytes);
|
||||
runOnUiThread(() -> openDownloadedAttachment(file, downloaded.mimeType));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("打开附件失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private File cacheDownloadedAttachment(String fileName, byte[] bytes) throws Exception {
|
||||
File downloadsDir = new File(getCacheDir(), "attachment-downloads");
|
||||
if (!downloadsDir.exists() && !downloadsDir.mkdirs()) {
|
||||
throw new IllegalStateException("无法创建附件缓存目录");
|
||||
}
|
||||
File file = new File(downloadsDir, sanitizeAttachmentFileName(fileName));
|
||||
try (FileOutputStream outputStream = new FileOutputStream(file, false)) {
|
||||
outputStream.write(bytes);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private String sanitizeAttachmentFileName(String fileName) {
|
||||
String trimmed = TextUtils.isEmpty(fileName) ? "attachment" : fileName.trim();
|
||||
return trimmed.replace('/', '_').replace('\\', '_');
|
||||
}
|
||||
|
||||
private void openDownloadedAttachment(File file, String mimeType) {
|
||||
Uri uri = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID + ".fileprovider", file);
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW);
|
||||
intent.setDataAndType(uri, TextUtils.isEmpty(mimeType) ? "application/octet-stream" : mimeType);
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
try {
|
||||
startActivity(Intent.createChooser(intent, "打开附件"));
|
||||
} catch (ActivityNotFoundException error) {
|
||||
showMessage("系统中没有可打开该附件的应用");
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
return outputStream.toByteArray();
|
||||
}
|
||||
|
||||
static ChromeBindings buildChromeBindings(
|
||||
|
||||
@@ -14,9 +14,10 @@ public class AttachmentComposerStateTest {
|
||||
"现场照片.png",
|
||||
"image/png",
|
||||
4096L,
|
||||
new byte[] {1, 2, 3}
|
||||
null
|
||||
);
|
||||
|
||||
assertTrue(ProjectChatUiState.requiresAttachmentConfirmation(attachment.sourceType));
|
||||
assertTrue(attachment.requiresConfirmation());
|
||||
}
|
||||
|
||||
@@ -28,9 +29,10 @@ public class AttachmentComposerStateTest {
|
||||
"巡检录屏.mp4",
|
||||
"video/mp4",
|
||||
8192L,
|
||||
new byte[] {4, 5, 6}
|
||||
null
|
||||
);
|
||||
|
||||
assertTrue(ProjectChatUiState.requiresAttachmentConfirmation(attachment.sourceType));
|
||||
assertTrue(attachment.requiresConfirmation());
|
||||
}
|
||||
|
||||
@@ -42,9 +44,10 @@ public class AttachmentComposerStateTest {
|
||||
"日报.xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
16384L,
|
||||
new byte[] {7, 8, 9}
|
||||
null
|
||||
);
|
||||
|
||||
assertFalse(ProjectChatUiState.requiresAttachmentConfirmation(attachment.sourceType));
|
||||
assertFalse(attachment.requiresConfirmation());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@ import static org.junit.Assert.assertTrue;
|
||||
import android.content.Intent;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ListView;
|
||||
import android.widget.TextView;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
@@ -75,6 +78,42 @@ public class ProjectDetailActivityUiTest {
|
||||
assertEquals(View.VISIBLE, refreshButton.getVisibility());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void manualAnalysisAttachmentShowsActionChip() 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-1")
|
||||
.put("fileName", "巡检录像.mp4")
|
||||
.put("mimeType", "video/mp4")
|
||||
.put("attachmentKind", "video")
|
||||
.put("analysisState", "ready_manual")
|
||||
.put("fileSizeBytes", 2048);
|
||||
JSONObject message = new JSONObject()
|
||||
.put("id", "msg-1")
|
||||
.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, "待分析"));
|
||||
}
|
||||
|
||||
private static View buildBoundMessageView(TestProjectDetailActivity activity, String messageId, String body) {
|
||||
TextView messageView = new TextView(activity);
|
||||
messageView.setText(body);
|
||||
@@ -84,11 +123,31 @@ public class ProjectDetailActivityUiTest {
|
||||
"bindMessageInteractions",
|
||||
ReflectionHelpers.ClassParameter.from(View.class, messageView),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, messageId),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, body)
|
||||
ReflectionHelpers.ClassParameter.from(String.class, body),
|
||||
ReflectionHelpers.ClassParameter.from(View.OnClickListener.class, null)
|
||||
);
|
||||
return messageView;
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
if (expectedText.contentEquals(text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return false;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static class TestProjectDetailActivity extends ProjectDetailActivity {
|
||||
@Override
|
||||
boolean shouldLoadOnCreate() {
|
||||
|
||||
Reference in New Issue
Block a user