fix: stabilize attachment upload and storage flows

This commit is contained in:
kris
2026-03-29 16:59:10 +08:00
parent 1e476a2097
commit 18dc7c6120
13 changed files with 733 additions and 183 deletions

View File

@@ -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() {

View File

@@ -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);
}
}
}

View File

@@ -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;
}

View File

@@ -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 "图片";

View File

@@ -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(

View File

@@ -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());
}
}

View File

@@ -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() {