android: add attachment composer flow
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import java.io.OutputStreamWriter;
|
|||||||
import java.net.HttpURLConnection;
|
import java.net.HttpURLConnection;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -100,6 +101,44 @@ public class BossApiClient {
|
|||||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/messages", payload);
|
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 {
|
public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, JSONObject payload) throws IOException, JSONException {
|
||||||
String requestBody = ForwardPayloads.toRequestBody(targetProjectId, payload);
|
String requestBody = ForwardPayloads.toRequestBody(targetProjectId, payload);
|
||||||
return requestWithRestoreRaw("POST", "/api/v1/projects/" + encode(projectId) + "/forwards", requestBody);
|
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 {
|
private ApiResponse requestRaw(String method, String path, @Nullable String body, boolean expectProtected) throws IOException, JSONException {
|
||||||
HttpURLConnection connection = openConnection(path);
|
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.setRequestMethod(method);
|
||||||
connection.setConnectTimeout(12000);
|
connection.setConnectTimeout(12000);
|
||||||
connection.setReadTimeout(12000);
|
connection.setReadTimeout(12000);
|
||||||
@@ -281,16 +339,9 @@ public class BossApiClient {
|
|||||||
if (!cookie.isEmpty()) {
|
if (!cookie.isEmpty()) {
|
||||||
connection.setRequestProperty("Cookie", cookie);
|
connection.setRequestProperty("Cookie", cookie);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (body != null) {
|
private ApiResponse executeConnection(HttpURLConnection connection, boolean expectProtected) throws IOException, JSONException {
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int statusCode = connection.getResponseCode();
|
int statusCode = connection.getResponseCode();
|
||||||
captureSessionCookie(connection.getHeaderFields());
|
captureSessionCookie(connection.getHeaderFields());
|
||||||
JSONObject json = readJson(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
|
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);
|
return new ApiResponse(statusCode, json == null ? new JSONObject() : json);
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpURLConnection openConnection(String path) throws IOException {
|
private void writeMultipartPart(
|
||||||
return (HttpURLConnection) new URL(baseUrl + path).openConnection();
|
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 {
|
private JSONObject readJson(InputStream stream) throws IOException, JSONException {
|
||||||
|
|||||||
@@ -673,26 +673,7 @@ public final class BossUi {
|
|||||||
boolean outgoing,
|
boolean outgoing,
|
||||||
@Nullable String kindLabel
|
@Nullable String kindLabel
|
||||||
) {
|
) {
|
||||||
LinearLayout wrapper = new LinearLayout(context);
|
LinearLayout wrapper = buildMessageWrapper(context, senderLabel, meta, outgoing);
|
||||||
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 bubble = new LinearLayout(context);
|
LinearLayout bubble = new LinearLayout(context);
|
||||||
bubble.setOrientation(LinearLayout.VERTICAL);
|
bubble.setOrientation(LinearLayout.VERTICAL);
|
||||||
@@ -723,6 +704,93 @@ public final class BossUi {
|
|||||||
return wrapper;
|
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(
|
public static LinearLayout buildForwardSingleBubble(
|
||||||
Context context,
|
Context context,
|
||||||
String senderLabel,
|
String senderLabel,
|
||||||
@@ -894,6 +962,65 @@ public final class BossUi {
|
|||||||
return button;
|
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) {
|
public static EditText buildInput(Context context, String hint, boolean multiline) {
|
||||||
EditText input = new EditText(context);
|
EditText input = new EditText(context);
|
||||||
input.setHint(hint);
|
input.setHint(hint);
|
||||||
|
|||||||
@@ -162,6 +162,51 @@ public final class ProjectChatUiState {
|
|||||||
return truncate(lastBody, 28);
|
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) {
|
private static boolean isBlank(@Nullable String value) {
|
||||||
return value == null || value.trim().isEmpty();
|
return value == null || value.trim().isEmpty();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ package com.hyzq.boss;
|
|||||||
import android.content.ClipData;
|
import android.content.ClipData;
|
||||||
import android.content.ClipboardManager;
|
import android.content.ClipboardManager;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.net.Uri;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.provider.OpenableColumns;
|
||||||
import android.text.Editable;
|
import android.text.Editable;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import android.text.TextWatcher;
|
import android.text.TextWatcher;
|
||||||
|
import android.view.Gravity;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.widget.Button;
|
import android.widget.Button;
|
||||||
import android.widget.EditText;
|
import android.widget.EditText;
|
||||||
@@ -21,6 +25,8 @@ import androidx.appcompat.app.AlertDialog;
|
|||||||
import org.json.JSONArray;
|
import org.json.JSONArray;
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -35,6 +41,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
private LinearLayout quickActionsLayout;
|
private LinearLayout quickActionsLayout;
|
||||||
private LinearLayout composerRow;
|
private LinearLayout composerRow;
|
||||||
private LinearLayout multiSelectActionsLayout;
|
private LinearLayout multiSelectActionsLayout;
|
||||||
|
private Button composerAttachmentButton;
|
||||||
private EditText composerInput;
|
private EditText composerInput;
|
||||||
private Button composerSendButton;
|
private Button composerSendButton;
|
||||||
private Button multiSelectForwardButton;
|
private Button multiSelectForwardButton;
|
||||||
@@ -49,6 +56,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
private ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.emptySelection();
|
private ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.emptySelection();
|
||||||
private ActivityResultLauncher<Intent> conversationInfoLauncher;
|
private ActivityResultLauncher<Intent> conversationInfoLauncher;
|
||||||
private ActivityResultLauncher<Intent> forwardTargetLauncher;
|
private ActivityResultLauncher<Intent> forwardTargetLauncher;
|
||||||
|
private ActivityResultLauncher<String> imagePickerLauncher;
|
||||||
|
private ActivityResultLauncher<String> videoPickerLauncher;
|
||||||
|
private ActivityResultLauncher<String> filePickerLauncher;
|
||||||
|
|
||||||
static final class ChromeBindings {
|
static final class ChromeBindings {
|
||||||
final boolean multiSelecting;
|
final boolean multiSelecting;
|
||||||
@@ -100,6 +110,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
quickActionsLayout = findViewById(R.id.project_chat_quick_actions);
|
quickActionsLayout = findViewById(R.id.project_chat_quick_actions);
|
||||||
composerRow = findViewById(R.id.project_chat_composer_row);
|
composerRow = findViewById(R.id.project_chat_composer_row);
|
||||||
multiSelectActionsLayout = findViewById(R.id.project_chat_multi_select_actions);
|
multiSelectActionsLayout = findViewById(R.id.project_chat_multi_select_actions);
|
||||||
|
composerAttachmentButton = findViewById(R.id.project_chat_attach);
|
||||||
composerInput = findViewById(R.id.project_chat_input);
|
composerInput = findViewById(R.id.project_chat_input);
|
||||||
composerSendButton = findViewById(R.id.project_chat_send);
|
composerSendButton = findViewById(R.id.project_chat_send);
|
||||||
multiSelectForwardButton = findViewById(R.id.project_chat_multi_forward);
|
multiSelectForwardButton = findViewById(R.id.project_chat_multi_forward);
|
||||||
@@ -131,8 +142,23 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
reload(true);
|
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, "正在同步项目详情...");
|
updateProjectHeader(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情...");
|
||||||
|
if (composerAttachmentButton != null) {
|
||||||
|
composerAttachmentButton.setOnClickListener(v -> showAttachmentEntrySheet());
|
||||||
|
}
|
||||||
composerSendButton.setOnClickListener(v -> sendTextMessageFromComposer());
|
composerSendButton.setOnClickListener(v -> sendTextMessageFromComposer());
|
||||||
multiSelectForwardButton.setOnClickListener(v -> {
|
multiSelectForwardButton.setOnClickListener(v -> {
|
||||||
if (!ProjectChatUiState.canForwardSelection(selectionState)) {
|
if (!ProjectChatUiState.canForwardSelection(selectionState)) {
|
||||||
@@ -203,6 +229,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
@Override
|
@Override
|
||||||
protected void setRefreshing(boolean refreshing) {
|
protected void setRefreshing(boolean refreshing) {
|
||||||
super.setRefreshing(refreshing);
|
super.setRefreshing(refreshing);
|
||||||
|
if (composerAttachmentButton != null) {
|
||||||
|
composerAttachmentButton.setEnabled(!refreshing && !composerSending);
|
||||||
|
}
|
||||||
if (composerInput != null) {
|
if (composerInput != null) {
|
||||||
composerInput.setEnabled(!refreshing);
|
composerInput.setEnabled(!refreshing);
|
||||||
}
|
}
|
||||||
@@ -297,6 +326,62 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
sendProjectMessage("text", body);
|
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) {
|
private void sendProjectMessage(String kind, String body) {
|
||||||
composerSending = true;
|
composerSending = true;
|
||||||
updateComposerSendButtonState();
|
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() {
|
private void openGoals() {
|
||||||
Intent intent = new Intent(this, ProjectGoalsActivity.class);
|
Intent intent = new Intent(this, ProjectGoalsActivity.class);
|
||||||
intent.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, projectId);
|
intent.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, projectId);
|
||||||
@@ -362,6 +502,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
|
|
||||||
View messageView;
|
View messageView;
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
|
case "attachment":
|
||||||
|
messageView = buildAttachmentMessageView(message, senderLabel, meta, outgoing);
|
||||||
|
break;
|
||||||
case "forward_single":
|
case "forward_single":
|
||||||
messageView = BossUi.buildForwardSingleBubble(
|
messageView = BossUi.buildForwardSingleBubble(
|
||||||
this,
|
this,
|
||||||
@@ -397,6 +540,42 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
return messageView;
|
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) {
|
private void bindMessageInteractions(View messageView, String messageId, String body) {
|
||||||
if (messageView == null || TextUtils.isEmpty(messageId)) {
|
if (messageView == null || TextUtils.isEmpty(messageId)) {
|
||||||
return;
|
return;
|
||||||
@@ -611,6 +790,31 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
appendContent(pendingOutgoingBubble);
|
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() {
|
private void removePendingOutgoingBubble() {
|
||||||
if (pendingOutgoingBubble != null && pendingOutgoingBubble.getParent() != null && contentLayout != null) {
|
if (pendingOutgoingBubble != null && pendingOutgoingBubble.getParent() != null && contentLayout != null) {
|
||||||
contentLayout.removeView(pendingOutgoingBubble);
|
contentLayout.removeView(pendingOutgoingBubble);
|
||||||
@@ -707,6 +911,106 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
return message.optString("body", "转发的聊天记录");
|
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(
|
static ChromeBindings buildChromeBindings(
|
||||||
ProjectChatUiState.ChromeState chromeState,
|
ProjectChatUiState.ChromeState chromeState,
|
||||||
boolean composerBusy
|
boolean composerBusy
|
||||||
|
|||||||
@@ -135,6 +135,19 @@
|
|||||||
android:paddingRight="12dp"
|
android:paddingRight="12dp"
|
||||||
android:paddingBottom="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
|
<EditText
|
||||||
android:id="@+id/project_chat_input"
|
android:id="@+id/project_chat_input"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user