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.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -100,6 +101,44 @@ public class BossApiClient {
|
||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/messages", payload);
|
||||
}
|
||||
|
||||
public ApiResponse uploadAttachment(
|
||||
String projectId,
|
||||
String fileName,
|
||||
String mimeType,
|
||||
byte[] bytes,
|
||||
String sourceType
|
||||
) throws IOException, JSONException {
|
||||
HttpURLConnection connection = openConnection("/api/v1/projects/" + encode(projectId) + "/attachments");
|
||||
prepareConnection(connection, "POST");
|
||||
connection.setDoOutput(true);
|
||||
|
||||
String boundary = "BossBoundary" + System.currentTimeMillis();
|
||||
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
|
||||
|
||||
try (OutputStream outputStream = connection.getOutputStream()) {
|
||||
writeMultipartPart(outputStream, boundary, "sourceType", sourceType, null);
|
||||
writeMultipartPart(
|
||||
outputStream,
|
||||
boundary,
|
||||
"file",
|
||||
bytes == null ? new byte[0] : bytes,
|
||||
fileName,
|
||||
mimeType
|
||||
);
|
||||
outputStream.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
return executeConnection(connection, true);
|
||||
}
|
||||
|
||||
public ApiResponse analyzeAttachment(String projectId, String attachmentId) throws IOException, JSONException {
|
||||
return requestWithRestoreRaw(
|
||||
"POST",
|
||||
"/api/v1/projects/" + encode(projectId) + "/attachments/" + encode(attachmentId) + "/analyze",
|
||||
"{}"
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, JSONObject payload) throws IOException, JSONException {
|
||||
String requestBody = ForwardPayloads.toRequestBody(targetProjectId, payload);
|
||||
return requestWithRestoreRaw("POST", "/api/v1/projects/" + encode(projectId) + "/forwards", requestBody);
|
||||
@@ -269,6 +308,25 @@ public class BossApiClient {
|
||||
|
||||
private ApiResponse requestRaw(String method, String path, @Nullable String body, boolean expectProtected) throws IOException, JSONException {
|
||||
HttpURLConnection connection = openConnection(path);
|
||||
prepareConnection(connection, method);
|
||||
|
||||
if (body != null) {
|
||||
connection.setDoOutput(true);
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
try (OutputStream outputStream = connection.getOutputStream();
|
||||
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) {
|
||||
writer.write(body);
|
||||
}
|
||||
}
|
||||
|
||||
return executeConnection(connection, expectProtected);
|
||||
}
|
||||
|
||||
HttpURLConnection openConnection(String path) throws IOException {
|
||||
return (HttpURLConnection) new URL(baseUrl + path).openConnection();
|
||||
}
|
||||
|
||||
private void prepareConnection(HttpURLConnection connection, String method) throws IOException {
|
||||
connection.setRequestMethod(method);
|
||||
connection.setConnectTimeout(12000);
|
||||
connection.setReadTimeout(12000);
|
||||
@@ -281,16 +339,9 @@ public class BossApiClient {
|
||||
if (!cookie.isEmpty()) {
|
||||
connection.setRequestProperty("Cookie", cookie);
|
||||
}
|
||||
}
|
||||
|
||||
if (body != null) {
|
||||
connection.setDoOutput(true);
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
try (OutputStream outputStream = connection.getOutputStream();
|
||||
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) {
|
||||
writer.write(body);
|
||||
}
|
||||
}
|
||||
|
||||
private ApiResponse executeConnection(HttpURLConnection connection, boolean expectProtected) throws IOException, JSONException {
|
||||
int statusCode = connection.getResponseCode();
|
||||
captureSessionCookie(connection.getHeaderFields());
|
||||
JSONObject json = readJson(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
|
||||
@@ -305,8 +356,57 @@ public class BossApiClient {
|
||||
return new ApiResponse(statusCode, json == null ? new JSONObject() : json);
|
||||
}
|
||||
|
||||
HttpURLConnection openConnection(String path) throws IOException {
|
||||
return (HttpURLConnection) new URL(baseUrl + path).openConnection();
|
||||
private void writeMultipartPart(
|
||||
OutputStream outputStream,
|
||||
String boundary,
|
||||
String fieldName,
|
||||
String value,
|
||||
@Nullable String contentType
|
||||
) throws IOException {
|
||||
outputStream.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
outputStream.write(
|
||||
("Content-Disposition: form-data; name=\"" + fieldName + "\"\r\n")
|
||||
.getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
if (contentType != null && !contentType.isEmpty()) {
|
||||
outputStream.write(("Content-Type: " + contentType + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
outputStream.write("\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
outputStream.write((value == null ? "" : value).getBytes(StandardCharsets.UTF_8));
|
||||
outputStream.write("\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private void writeMultipartPart(
|
||||
OutputStream outputStream,
|
||||
String boundary,
|
||||
String fieldName,
|
||||
byte[] bytes,
|
||||
String fileName,
|
||||
String contentType
|
||||
) throws IOException {
|
||||
outputStream.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
outputStream.write(
|
||||
String.format(
|
||||
Locale.US,
|
||||
"Content-Disposition: form-data; name=\"%s\"; filename=\"%s\"\r\n",
|
||||
fieldName,
|
||||
escapeMultipartValue(fileName)
|
||||
).getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
outputStream.write(
|
||||
("Content-Type: " + (contentType == null || contentType.isEmpty()
|
||||
? "application/octet-stream"
|
||||
: contentType) + "\r\n\r\n").getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
outputStream.write(bytes == null ? new byte[0] : bytes);
|
||||
outputStream.write("\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private String escapeMultipartValue(String value) {
|
||||
if (value == null) {
|
||||
return "attachment";
|
||||
}
|
||||
return value.replace("\"", "%22");
|
||||
}
|
||||
|
||||
private JSONObject readJson(InputStream stream) throws IOException, JSONException {
|
||||
|
||||
@@ -673,26 +673,7 @@ public final class BossUi {
|
||||
boolean outgoing,
|
||||
@Nullable String kindLabel
|
||||
) {
|
||||
LinearLayout wrapper = new LinearLayout(context);
|
||||
wrapper.setOrientation(LinearLayout.VERTICAL);
|
||||
wrapper.setGravity(outgoing ? Gravity.END : Gravity.START);
|
||||
LinearLayout.LayoutParams wrapperParams = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
wrapperParams.bottomMargin = dp(context, 12);
|
||||
wrapper.setLayoutParams(wrapperParams);
|
||||
|
||||
TextView metaView = new TextView(context);
|
||||
String metaText = senderLabel;
|
||||
if (!TextUtils.isEmpty(meta)) {
|
||||
metaText = metaText + " · " + meta;
|
||||
}
|
||||
metaView.setText(metaText);
|
||||
metaView.setTextSize(11);
|
||||
metaView.setTextColor(context.getColor(R.color.boss_text_soft));
|
||||
metaView.setPadding(dp(context, 6), 0, dp(context, 6), dp(context, 4));
|
||||
wrapper.addView(metaView);
|
||||
LinearLayout wrapper = buildMessageWrapper(context, senderLabel, meta, outgoing);
|
||||
|
||||
LinearLayout bubble = new LinearLayout(context);
|
||||
bubble.setOrientation(LinearLayout.VERTICAL);
|
||||
@@ -723,6 +704,93 @@ public final class BossUi {
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
public static LinearLayout buildAttachmentMessageCard(
|
||||
Context context,
|
||||
String senderLabel,
|
||||
String sourceType,
|
||||
String fileName,
|
||||
@Nullable String detail,
|
||||
@Nullable String status,
|
||||
@Nullable String meta,
|
||||
boolean outgoing
|
||||
) {
|
||||
LinearLayout wrapper = buildMessageWrapper(context, senderLabel, meta, outgoing);
|
||||
|
||||
LinearLayout card = new LinearLayout(context);
|
||||
card.setOrientation(LinearLayout.VERTICAL);
|
||||
card.setMinimumWidth(dp(context, 180));
|
||||
card.setPadding(dp(context, 14), dp(context, 12), dp(context, 14), dp(context, 12));
|
||||
card.setBackground(createRoundedBackground(
|
||||
outgoing ? Color.parseColor("#CFF0D8") : Color.WHITE,
|
||||
dp(context, 18)
|
||||
));
|
||||
card.setElevation(dp(context, 1));
|
||||
|
||||
if ("image".equals(sourceType) || "video".equals(sourceType)) {
|
||||
TextView preview = new TextView(context);
|
||||
LinearLayout.LayoutParams previewParams = new LinearLayout.LayoutParams(
|
||||
Math.round(context.getResources().getDisplayMetrics().widthPixels * 0.56f),
|
||||
dp(context, 118)
|
||||
);
|
||||
preview.setLayoutParams(previewParams);
|
||||
preview.setGravity(Gravity.CENTER);
|
||||
preview.setText("image".equals(sourceType) ? "图片预览" : "视频封面");
|
||||
preview.setTextSize(13);
|
||||
preview.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
preview.setTextColor(context.getColor(R.color.boss_text_muted));
|
||||
preview.setBackground(createRoundedBackground(Color.parseColor("#EEF2EE"), dp(context, 14)));
|
||||
card.addView(preview);
|
||||
|
||||
TextView nameView = buildAttachmentPrimaryText(context, fileName);
|
||||
nameView.setPadding(0, dp(context, 10), 0, 0);
|
||||
card.addView(nameView);
|
||||
|
||||
TextView statusView = buildAttachmentSecondaryText(
|
||||
context,
|
||||
TextUtils.isEmpty(status) ? "已发送" : status
|
||||
);
|
||||
statusView.setPadding(0, dp(context, 6), 0, 0);
|
||||
card.addView(statusView);
|
||||
} else {
|
||||
LinearLayout row = new LinearLayout(context);
|
||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||
row.setGravity(Gravity.CENTER_VERTICAL);
|
||||
|
||||
TextView icon = new TextView(context);
|
||||
LinearLayout.LayoutParams iconParams = new LinearLayout.LayoutParams(dp(context, 42), dp(context, 42));
|
||||
icon.setLayoutParams(iconParams);
|
||||
icon.setGravity(Gravity.CENTER);
|
||||
icon.setText("文");
|
||||
icon.setTextSize(15);
|
||||
icon.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
icon.setTextColor(context.getColor(R.color.boss_green));
|
||||
icon.setBackground(createRoundedBackground(Color.parseColor("#E8F6ED"), dp(context, 12)));
|
||||
row.addView(icon);
|
||||
|
||||
LinearLayout texts = new LinearLayout(context);
|
||||
texts.setOrientation(LinearLayout.VERTICAL);
|
||||
LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams(
|
||||
0,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
1f
|
||||
);
|
||||
textParams.leftMargin = dp(context, 12);
|
||||
texts.setLayoutParams(textParams);
|
||||
|
||||
texts.addView(buildAttachmentPrimaryText(context, fileName));
|
||||
texts.addView(buildAttachmentSecondaryText(
|
||||
context,
|
||||
joinAttachmentDetail(detail, status)
|
||||
));
|
||||
row.addView(texts);
|
||||
|
||||
card.addView(row);
|
||||
}
|
||||
|
||||
wrapper.addView(card);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
public static LinearLayout buildForwardSingleBubble(
|
||||
Context context,
|
||||
String senderLabel,
|
||||
@@ -894,6 +962,65 @@ public final class BossUi {
|
||||
return button;
|
||||
}
|
||||
|
||||
private static LinearLayout buildMessageWrapper(
|
||||
Context context,
|
||||
String senderLabel,
|
||||
@Nullable String meta,
|
||||
boolean outgoing
|
||||
) {
|
||||
LinearLayout wrapper = new LinearLayout(context);
|
||||
wrapper.setOrientation(LinearLayout.VERTICAL);
|
||||
wrapper.setGravity(outgoing ? Gravity.END : Gravity.START);
|
||||
LinearLayout.LayoutParams wrapperParams = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
wrapperParams.bottomMargin = dp(context, 12);
|
||||
wrapper.setLayoutParams(wrapperParams);
|
||||
|
||||
TextView metaView = new TextView(context);
|
||||
String metaText = senderLabel;
|
||||
if (!TextUtils.isEmpty(meta)) {
|
||||
metaText = metaText + " · " + meta;
|
||||
}
|
||||
metaView.setText(metaText);
|
||||
metaView.setTextSize(11);
|
||||
metaView.setTextColor(context.getColor(R.color.boss_text_soft));
|
||||
metaView.setPadding(dp(context, 6), 0, dp(context, 6), dp(context, 4));
|
||||
wrapper.addView(metaView);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
private static TextView buildAttachmentPrimaryText(Context context, String text) {
|
||||
TextView primary = new TextView(context);
|
||||
primary.setText(TextUtils.isEmpty(text) ? "未命名附件" : text);
|
||||
primary.setTextSize(15);
|
||||
primary.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
primary.setTextColor(context.getColor(R.color.boss_text_primary));
|
||||
primary.setMaxLines(2);
|
||||
primary.setEllipsize(TextUtils.TruncateAt.END);
|
||||
return primary;
|
||||
}
|
||||
|
||||
private static TextView buildAttachmentSecondaryText(Context context, String text) {
|
||||
TextView secondary = new TextView(context);
|
||||
secondary.setText(TextUtils.isEmpty(text) ? "已发送" : text);
|
||||
secondary.setTextSize(13);
|
||||
secondary.setTextColor(context.getColor(R.color.boss_text_muted));
|
||||
secondary.setPadding(0, dp(context, 6), 0, 0);
|
||||
return secondary;
|
||||
}
|
||||
|
||||
private static String joinAttachmentDetail(@Nullable String detail, @Nullable String status) {
|
||||
if (TextUtils.isEmpty(detail)) {
|
||||
return TextUtils.isEmpty(status) ? "已发送" : status;
|
||||
}
|
||||
if (TextUtils.isEmpty(status)) {
|
||||
return detail;
|
||||
}
|
||||
return detail + " · " + status;
|
||||
}
|
||||
|
||||
public static EditText buildInput(Context context, String hint, boolean multiline) {
|
||||
EditText input = new EditText(context);
|
||||
input.setHint(hint);
|
||||
|
||||
@@ -162,6 +162,51 @@ public final class ProjectChatUiState {
|
||||
return truncate(lastBody, 28);
|
||||
}
|
||||
|
||||
public static String labelForAttachmentAnalysisState(@Nullable String analysisState) {
|
||||
if ("queued_auto".equals(analysisState) || "ready_manual".equals(analysisState)) {
|
||||
return "待分析";
|
||||
}
|
||||
if ("processing".equals(analysisState)) {
|
||||
return "分析中";
|
||||
}
|
||||
if ("completed".equals(analysisState)) {
|
||||
return "已分析";
|
||||
}
|
||||
if ("failed".equals(analysisState)) {
|
||||
return "分析失败";
|
||||
}
|
||||
return "已发送";
|
||||
}
|
||||
|
||||
public static String labelForAttachmentKind(@Nullable String attachmentKind) {
|
||||
if ("image".equals(attachmentKind)) {
|
||||
return "图片";
|
||||
}
|
||||
if ("video".equals(attachmentKind)) {
|
||||
return "视频";
|
||||
}
|
||||
if ("pdf".equals(attachmentKind)) {
|
||||
return "PDF";
|
||||
}
|
||||
if ("office".equals(attachmentKind)) {
|
||||
return "文档";
|
||||
}
|
||||
if ("text".equals(attachmentKind)) {
|
||||
return "文本";
|
||||
}
|
||||
return "文件";
|
||||
}
|
||||
|
||||
public static String formatAttachmentSize(long fileSizeBytes) {
|
||||
if (fileSizeBytes >= 1024L * 1024L) {
|
||||
return String.format(java.util.Locale.US, "%.1f MB", fileSizeBytes / (1024f * 1024f));
|
||||
}
|
||||
if (fileSizeBytes >= 1024L) {
|
||||
return Math.max(1, Math.round(fileSizeBytes / 1024f)) + " KB";
|
||||
}
|
||||
return Math.max(fileSizeBytes, 0L) + " B";
|
||||
}
|
||||
|
||||
private static boolean isBlank(@Nullable String value) {
|
||||
return value == null || value.trim().isEmpty();
|
||||
}
|
||||
|
||||
@@ -3,10 +3,14 @@ package com.hyzq.boss;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.text.Editable;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
@@ -21,6 +25,8 @@ import androidx.appcompat.app.AlertDialog;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -35,6 +41,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
private LinearLayout quickActionsLayout;
|
||||
private LinearLayout composerRow;
|
||||
private LinearLayout multiSelectActionsLayout;
|
||||
private Button composerAttachmentButton;
|
||||
private EditText composerInput;
|
||||
private Button composerSendButton;
|
||||
private Button multiSelectForwardButton;
|
||||
@@ -49,6 +56,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
private ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.emptySelection();
|
||||
private ActivityResultLauncher<Intent> conversationInfoLauncher;
|
||||
private ActivityResultLauncher<Intent> forwardTargetLauncher;
|
||||
private ActivityResultLauncher<String> imagePickerLauncher;
|
||||
private ActivityResultLauncher<String> videoPickerLauncher;
|
||||
private ActivityResultLauncher<String> filePickerLauncher;
|
||||
|
||||
static final class ChromeBindings {
|
||||
final boolean multiSelecting;
|
||||
@@ -100,6 +110,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
quickActionsLayout = findViewById(R.id.project_chat_quick_actions);
|
||||
composerRow = findViewById(R.id.project_chat_composer_row);
|
||||
multiSelectActionsLayout = findViewById(R.id.project_chat_multi_select_actions);
|
||||
composerAttachmentButton = findViewById(R.id.project_chat_attach);
|
||||
composerInput = findViewById(R.id.project_chat_input);
|
||||
composerSendButton = findViewById(R.id.project_chat_send);
|
||||
multiSelectForwardButton = findViewById(R.id.project_chat_multi_forward);
|
||||
@@ -131,8 +142,23 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
reload(true);
|
||||
}
|
||||
);
|
||||
imagePickerLauncher = registerForActivityResult(
|
||||
new ActivityResultContracts.GetContent(),
|
||||
uri -> onAttachmentPicked(uri, "image")
|
||||
);
|
||||
videoPickerLauncher = registerForActivityResult(
|
||||
new ActivityResultContracts.GetContent(),
|
||||
uri -> onAttachmentPicked(uri, "video")
|
||||
);
|
||||
filePickerLauncher = registerForActivityResult(
|
||||
new ActivityResultContracts.GetContent(),
|
||||
uri -> onAttachmentPicked(uri, "file")
|
||||
);
|
||||
|
||||
updateProjectHeader(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情...");
|
||||
if (composerAttachmentButton != null) {
|
||||
composerAttachmentButton.setOnClickListener(v -> showAttachmentEntrySheet());
|
||||
}
|
||||
composerSendButton.setOnClickListener(v -> sendTextMessageFromComposer());
|
||||
multiSelectForwardButton.setOnClickListener(v -> {
|
||||
if (!ProjectChatUiState.canForwardSelection(selectionState)) {
|
||||
@@ -203,6 +229,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
@Override
|
||||
protected void setRefreshing(boolean refreshing) {
|
||||
super.setRefreshing(refreshing);
|
||||
if (composerAttachmentButton != null) {
|
||||
composerAttachmentButton.setEnabled(!refreshing && !composerSending);
|
||||
}
|
||||
if (composerInput != null) {
|
||||
composerInput.setEnabled(!refreshing);
|
||||
}
|
||||
@@ -297,6 +326,62 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
sendProjectMessage("text", body);
|
||||
}
|
||||
|
||||
private void showAttachmentEntrySheet() {
|
||||
if (isComposerBusy()) {
|
||||
return;
|
||||
}
|
||||
AlertDialog dialog = new AlertDialog.Builder(this)
|
||||
.setItems(new CharSequence[]{"图片", "视频", "文件"}, (sheet, which) -> {
|
||||
if (which == 0) {
|
||||
imagePickerLauncher.launch("image/*");
|
||||
} else if (which == 1) {
|
||||
videoPickerLauncher.launch("video/*");
|
||||
} else {
|
||||
filePickerLauncher.launch("*/*");
|
||||
}
|
||||
})
|
||||
.create();
|
||||
dialog.show();
|
||||
if (dialog.getWindow() != null) {
|
||||
dialog.getWindow().setGravity(Gravity.BOTTOM);
|
||||
}
|
||||
}
|
||||
|
||||
private void onAttachmentPicked(@Nullable Uri uri, String sourceType) {
|
||||
if (uri == null) {
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
AttachmentComposerState.PendingAttachment attachment = readPendingAttachment(uri, sourceType);
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
if (attachment.requiresConfirmation()) {
|
||||
showAttachmentConfirmDialog(attachment);
|
||||
} else {
|
||||
uploadAttachment(attachment);
|
||||
}
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("读取附件失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void showAttachmentConfirmDialog(AttachmentComposerState.PendingAttachment attachment) {
|
||||
String actionLabel = "image".equals(attachment.sourceType) ? "图片" : "视频";
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("发送" + actionLabel)
|
||||
.setMessage(attachment.fileName + " · " + ProjectChatUiState.formatAttachmentSize(attachment.fileSizeBytes))
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("发送", (dialog, which) -> uploadAttachment(attachment))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void sendProjectMessage(String kind, String body) {
|
||||
composerSending = true;
|
||||
updateComposerSendButtonState();
|
||||
@@ -327,6 +412,61 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void uploadAttachment(AttachmentComposerState.PendingAttachment attachment) {
|
||||
composerSending = true;
|
||||
updateComposerSendButtonState();
|
||||
setRefreshing(true);
|
||||
appendPendingOutgoingAttachment(attachment);
|
||||
scrollChatToBottom();
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse uploadResponse = apiClient.uploadAttachment(
|
||||
projectId,
|
||||
attachment.fileName,
|
||||
attachment.mimeType,
|
||||
attachment.bytes,
|
||||
attachment.sourceType
|
||||
);
|
||||
if (!uploadResponse.ok()) {
|
||||
throw new IllegalStateException(uploadResponse.message());
|
||||
}
|
||||
|
||||
String successMessage = "附件已发送";
|
||||
JSONObject uploadedAttachment = uploadResponse.json.optJSONObject("attachment");
|
||||
if (uploadedAttachment != null && attachment.requiresConfirmation()) {
|
||||
String attachmentId = uploadedAttachment.optString("attachmentId", "");
|
||||
String analysisState = uploadedAttachment.optString("analysisState", "");
|
||||
if (!TextUtils.isEmpty(attachmentId)
|
||||
&& ("ready_manual".equals(analysisState) || "failed".equals(analysisState))) {
|
||||
try {
|
||||
BossApiClient.ApiResponse analyzeResponse = apiClient.analyzeAttachment(projectId, attachmentId);
|
||||
successMessage = analyzeResponse.ok()
|
||||
? "附件已发送,分析已发起"
|
||||
: "附件已发送,分析稍后可用";
|
||||
} catch (Exception ignored) {
|
||||
successMessage = "附件已发送,分析稍后可用";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String finalSuccessMessage = successMessage;
|
||||
runOnUiThread(() -> {
|
||||
composerSending = false;
|
||||
showMessage(finalSuccessMessage);
|
||||
reload(true);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
composerSending = false;
|
||||
setRefreshing(false);
|
||||
removePendingOutgoingBubble();
|
||||
showMessage("附件发送失败:" + error.getMessage());
|
||||
updateComposerSendButtonState();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void openGoals() {
|
||||
Intent intent = new Intent(this, ProjectGoalsActivity.class);
|
||||
intent.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, projectId);
|
||||
@@ -362,6 +502,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
|
||||
View messageView;
|
||||
switch (kind) {
|
||||
case "attachment":
|
||||
messageView = buildAttachmentMessageView(message, senderLabel, meta, outgoing);
|
||||
break;
|
||||
case "forward_single":
|
||||
messageView = BossUi.buildForwardSingleBubble(
|
||||
this,
|
||||
@@ -397,6 +540,42 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
return messageView;
|
||||
}
|
||||
|
||||
private View buildAttachmentMessageView(
|
||||
JSONObject message,
|
||||
String senderLabel,
|
||||
String meta,
|
||||
boolean outgoing
|
||||
) {
|
||||
JSONObject attachment = firstAttachment(message);
|
||||
if (attachment == null) {
|
||||
return BossUi.buildMessageBubble(
|
||||
this,
|
||||
senderLabel,
|
||||
message.optString("body", "已发送附件"),
|
||||
meta,
|
||||
outgoing,
|
||||
"附件"
|
||||
);
|
||||
}
|
||||
String sourceType = resolveAttachmentSourceType(
|
||||
attachment.optString("attachmentKind", ""),
|
||||
attachment.optString("mimeType", "")
|
||||
);
|
||||
String detail = ("image".equals(sourceType) || "video".equals(sourceType))
|
||||
? null
|
||||
: ProjectChatUiState.formatAttachmentSize(attachment.optLong("fileSizeBytes", 0L));
|
||||
return BossUi.buildAttachmentMessageCard(
|
||||
this,
|
||||
senderLabel,
|
||||
sourceType,
|
||||
attachment.optString("fileName", "attachment"),
|
||||
detail,
|
||||
ProjectChatUiState.labelForAttachmentAnalysisState(attachment.optString("analysisState", "")),
|
||||
meta,
|
||||
outgoing
|
||||
);
|
||||
}
|
||||
|
||||
private void bindMessageInteractions(View messageView, String messageId, String body) {
|
||||
if (messageView == null || TextUtils.isEmpty(messageId)) {
|
||||
return;
|
||||
@@ -611,6 +790,31 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
appendContent(pendingOutgoingBubble);
|
||||
}
|
||||
|
||||
private void appendPendingOutgoingAttachment(AttachmentComposerState.PendingAttachment attachment) {
|
||||
if (contentLayout == null) {
|
||||
return;
|
||||
}
|
||||
removePendingOutgoingBubble();
|
||||
if (contentLayout.getChildCount() == 1 && contentLayout.getChildAt(0) instanceof android.widget.TextView) {
|
||||
contentLayout.removeAllViews();
|
||||
}
|
||||
String senderLabel = TextUtils.isEmpty(apiClient.getDisplayName()) ? "你" : apiClient.getDisplayName();
|
||||
String detail = attachment.requiresConfirmation()
|
||||
? null
|
||||
: ProjectChatUiState.formatAttachmentSize(attachment.fileSizeBytes);
|
||||
pendingOutgoingBubble = BossUi.buildAttachmentMessageCard(
|
||||
this,
|
||||
senderLabel,
|
||||
attachment.sourceType,
|
||||
attachment.fileName,
|
||||
detail,
|
||||
"发送中",
|
||||
null,
|
||||
true
|
||||
);
|
||||
appendContent(pendingOutgoingBubble);
|
||||
}
|
||||
|
||||
private void removePendingOutgoingBubble() {
|
||||
if (pendingOutgoingBubble != null && pendingOutgoingBubble.getParent() != null && contentLayout != null) {
|
||||
contentLayout.removeView(pendingOutgoingBubble);
|
||||
@@ -707,6 +911,106 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
return message.optString("body", "转发的聊天记录");
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private JSONObject firstAttachment(JSONObject message) {
|
||||
JSONArray attachments = message.optJSONArray("attachments");
|
||||
if (attachments == null || attachments.length() == 0) {
|
||||
return null;
|
||||
}
|
||||
return attachments.optJSONObject(0);
|
||||
}
|
||||
|
||||
private String resolveAttachmentSourceType(String attachmentKind, String mimeType) {
|
||||
if ("image".equals(attachmentKind) || (!TextUtils.isEmpty(mimeType) && mimeType.startsWith("image/"))) {
|
||||
return "image";
|
||||
}
|
||||
if ("video".equals(attachmentKind) || (!TextUtils.isEmpty(mimeType) && mimeType.startsWith("video/"))) {
|
||||
return "video";
|
||||
}
|
||||
return "file";
|
||||
}
|
||||
|
||||
private AttachmentComposerState.PendingAttachment readPendingAttachment(Uri uri, String sourceType) throws Exception {
|
||||
String fileName = resolveAttachmentFileName(uri);
|
||||
String mimeType = resolveAttachmentMimeType(uri, sourceType);
|
||||
byte[] bytes;
|
||||
try (InputStream inputStream = getContentResolver().openInputStream(uri)) {
|
||||
if (inputStream == null) {
|
||||
throw new IllegalStateException("无法打开附件输入流");
|
||||
}
|
||||
bytes = readAllBytes(inputStream);
|
||||
}
|
||||
long fileSize = resolveAttachmentFileSize(uri, bytes.length);
|
||||
return new AttachmentComposerState.PendingAttachment(sourceType, fileName, mimeType, fileSize, bytes);
|
||||
}
|
||||
|
||||
private String resolveAttachmentFileName(Uri uri) {
|
||||
String fallback = uri.getLastPathSegment();
|
||||
try (Cursor cursor = getContentResolver().query(
|
||||
uri,
|
||||
new String[]{OpenableColumns.DISPLAY_NAME},
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
int columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||
if (columnIndex >= 0) {
|
||||
String displayName = cursor.getString(columnIndex);
|
||||
if (!TextUtils.isEmpty(displayName)) {
|
||||
return displayName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return TextUtils.isEmpty(fallback) ? "attachment" : fallback;
|
||||
}
|
||||
|
||||
private long resolveAttachmentFileSize(Uri uri, long fallback) {
|
||||
try (Cursor cursor = getContentResolver().query(
|
||||
uri,
|
||||
new String[]{OpenableColumns.SIZE},
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
int columnIndex = cursor.getColumnIndex(OpenableColumns.SIZE);
|
||||
if (columnIndex >= 0) {
|
||||
long value = cursor.getLong(columnIndex);
|
||||
if (value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private String resolveAttachmentMimeType(Uri uri, String sourceType) {
|
||||
String mimeType = getContentResolver().getType(uri);
|
||||
if (!TextUtils.isEmpty(mimeType)) {
|
||||
return mimeType;
|
||||
}
|
||||
if ("image".equals(sourceType)) {
|
||||
return "image/*";
|
||||
}
|
||||
if ("video".equals(sourceType)) {
|
||||
return "video/*";
|
||||
}
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
private byte[] readAllBytes(InputStream inputStream) throws Exception {
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, read);
|
||||
}
|
||||
return outputStream.toByteArray();
|
||||
}
|
||||
|
||||
static ChromeBindings buildChromeBindings(
|
||||
ProjectChatUiState.ChromeState chromeState,
|
||||
boolean composerBusy
|
||||
|
||||
@@ -135,6 +135,19 @@
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/project_chat_attach"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:text="+"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/project_chat_input"
|
||||
android:layout_width="0dp"
|
||||
|
||||
@@ -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