Files
boss/android/app/src/main/java/com/hyzq/boss/BossApiClient.java
2026-04-04 11:39:06 +08:00

913 lines
37 KiB
Java

package com.hyzq.boss;
import android.content.Context;
import android.content.SharedPreferences;
import android.net.Uri;
import androidx.annotation.Nullable;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONArray;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
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;
public class BossApiClient {
private static final int DEFAULT_CONNECT_TIMEOUT_MS = 12000;
private static final int DEFAULT_READ_TIMEOUT_MS = 12000;
private static final int CONVERSATIONS_READ_TIMEOUT_MS = 30000;
private static final int CHAT_FLOW_READ_TIMEOUT_MS = 65000;
private static final int CHAT_SEND_READ_TIMEOUT_MS = 20000;
private static final String PREFS_NAME = "boss_native_client";
private static final String KEY_SESSION_COOKIE = "session_cookie";
private static final String KEY_RESTORE_TOKEN = "restore_token";
private static final String KEY_ACCOUNT = "account";
private static final String KEY_DISPLAY_NAME = "display_name";
private final SharedPreferences prefs;
private final String baseUrl;
public BossApiClient(Context context) {
this(context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE), BuildConfig.BOSS_API_BASE_URL);
}
BossApiClient(SharedPreferences prefs, String baseUrl) {
this.prefs = prefs;
this.baseUrl = baseUrl;
}
public boolean hasSessionHints() {
return !getSessionCookie().isEmpty() || !getRestoreToken().isEmpty();
}
public ApiResponse autoLogin() throws IOException, JSONException {
ApiResponse response = request("POST", "/api/auth/login", new JSONObject(), false);
if (response.ok()) {
rememberIdentity(response.json);
}
return response;
}
public ApiResponse restoreSession() throws IOException, JSONException {
if (getRestoreToken().isEmpty()) {
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_RESTORE_TOKEN"));
}
JSONObject body = new JSONObject();
body.put("restoreToken", getRestoreToken());
ApiResponse response = request("POST", "/api/auth/restore", body, false);
if (response.ok()) {
rememberIdentity(response.json);
}
return response;
}
public ApiResponse getSession() throws IOException, JSONException {
return request("GET", "/api/auth/session", null, true);
}
public ApiResponse getConversations() throws IOException, JSONException {
return requestWithRestoreRaw(
"GET",
"/api/v1/conversations",
null,
DEFAULT_CONNECT_TIMEOUT_MS,
CONVERSATIONS_READ_TIMEOUT_MS
);
}
public ApiResponse getConversationHome() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/conversations/home", null);
}
public ApiResponse getConversationFolder(String folderKey) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/conversation-folders/" + encode(folderKey), null);
}
public ApiResponse getProjectDetail(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null);
}
public ApiResponse getDispatchPlans(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/dispatch-plans", null);
}
public ApiResponse getProjectAgentControls(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/agent-controls", null);
}
public ApiResponse updateProjectAgentControls(
String projectId,
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride
) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("modelOverride", modelOverride == null ? JSONObject.NULL : modelOverride);
payload.put("reasoningEffortOverride", reasoningEffortOverride == null ? JSONObject.NULL : reasoningEffortOverride);
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
}
public ApiResponse updateProjectAgentControls(
String projectId,
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride,
@Nullable String promptOverride
) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("modelOverride", modelOverride == null ? JSONObject.NULL : modelOverride);
payload.put("reasoningEffortOverride", reasoningEffortOverride == null ? JSONObject.NULL : reasoningEffortOverride);
payload.put("promptOverride", promptOverride == null ? JSONObject.NULL : promptOverride);
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
}
public ApiResponse getProjectOrchestrationBackend(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/orchestration-backend", null);
}
public ApiResponse updateProjectOrchestrationBackend(String projectId, @Nullable String requestedBackendId) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("requestedBackendId", requestedBackendId == null ? JSONObject.NULL : requestedBackendId);
return requestWithRestore("PATCH", "/api/v1/projects/" + encode(projectId) + "/orchestration-backend", payload);
}
public ApiResponse getMasterAgentPromptProfile(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/prompt-profile", null);
}
public ApiResponse updateMasterAgentPromptProfile(String projectId, JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/prompt-profile", payload);
}
public ApiResponse getMasterAgentMemories(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/memories", null);
}
public ApiResponse createMasterAgentMemory(String projectId, JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/memories", payload);
}
public ApiResponse updateMasterAgentMemory(String projectId, String memoryId, JSONObject payload) throws IOException, JSONException {
return requestWithRestore("PATCH", "/api/v1/projects/" + encode(projectId) + "/memories/" + encode(memoryId), payload);
}
public ApiResponse deleteMasterAgentMemory(String projectId, String memoryId) throws IOException, JSONException {
return requestWithRestore("DELETE", "/api/v1/projects/" + encode(projectId) + "/memories/" + encode(memoryId), null);
}
public ApiResponse confirmDispatchPlan(
String projectId,
String planId,
JSONArray approvedTargetProjectIds,
boolean rememberLightReminder
) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put(
"approvedTargetProjectIds",
approvedTargetProjectIds == null ? new JSONArray() : approvedTargetProjectIds
);
payload.put("rememberLightReminder", rememberLightReminder);
return requestWithRestoreRaw(
"POST",
"/api/v1/projects/" + encode(projectId) + "/dispatch-plans/" + encode(planId) + "/confirm",
payload.toString(),
DEFAULT_CONNECT_TIMEOUT_MS,
CHAT_FLOW_READ_TIMEOUT_MS
);
}
public ApiResponse confirmDispatchPlan(String projectId, String planId, JSONArray approvedTargetProjectIds) throws IOException, JSONException {
return confirmDispatchPlan(projectId, planId, approvedTargetProjectIds, false);
}
public ApiResponse rejectDispatchPlan(String projectId, String planId) throws IOException, JSONException {
return requestWithRestoreRaw(
"POST",
"/api/v1/projects/" + encode(projectId) + "/dispatch-plans/" + encode(planId) + "/reject",
new JSONObject().toString(),
DEFAULT_CONNECT_TIMEOUT_MS,
CHAT_FLOW_READ_TIMEOUT_MS
);
}
public ApiResponse retryDispatchPlan(String projectId, String planId) throws IOException, JSONException {
return requestWithRestoreRaw(
"POST",
"/api/v1/projects/" + encode(projectId) + "/dispatch-plans/" + encode(planId) + "/retry",
new JSONObject().toString(),
DEFAULT_CONNECT_TIMEOUT_MS,
CHAT_FLOW_READ_TIMEOUT_MS
);
}
public ApiResponse renameConversation(String projectId, String name, boolean group) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("name", name);
payload.put("mode", group ? "group" : "thread");
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/rename", payload);
}
public ApiResponse createGroupChat(String projectId, JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/group-chat", payload == null ? new JSONObject() : payload);
}
public ApiResponse createStandaloneGroupChat(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/group-chats", payload == null ? new JSONObject() : payload);
}
public ApiResponse getConversationParticipants(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/participants", null);
}
public ApiResponse getThreadStatus(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/thread-status", null);
}
public ApiResponse replaceConversationParticipants(String projectId, JSONArray memberProjectIds) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("memberProjectIds", memberProjectIds == null ? new JSONArray() : memberProjectIds);
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/participants", payload);
}
public ApiResponse updateProjectDispatchReminder(String projectId, boolean lightDispatchReminderEnabled) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("lightDispatchReminderEnabled", lightDispatchReminderEnabled);
return requestWithRestore("PATCH", "/api/v1/projects/" + encode(projectId) + "/dispatch-reminder", payload);
}
public ApiResponse sendProjectMessage(String projectId, String body, String kind) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("body", body);
payload.put("kind", kind);
return requestWithRestoreRaw(
"POST",
"/api/v1/projects/" + encode(projectId) + "/messages",
payload.toString(),
DEFAULT_CONNECT_TIMEOUT_MS,
CHAT_SEND_READ_TIMEOUT_MS
);
}
public ApiResponse uploadAttachment(
String projectId,
String fileName,
String mimeType,
byte[] bytes,
String sourceType
) throws IOException, JSONException {
return uploadAttachment(
projectId,
fileName,
mimeType,
new ByteArrayInputStream(bytes == null ? new byte[0] : bytes),
sourceType
);
}
public ApiResponse uploadAttachment(
String projectId,
String fileName,
String mimeType,
InputStream inputStream,
String sourceType
) throws IOException, JSONException {
HttpURLConnection connection = openConnection("/api/v1/projects/" + encode(projectId) + "/attachments");
prepareConnection(connection, "POST", DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS);
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",
inputStream,
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 DownloadedAttachment downloadAttachment(
String attachmentId,
String fallbackFileName,
String fallbackMimeType
) throws IOException {
DownloadedAttachment attachment = downloadAttachmentRaw(attachmentId, fallbackFileName, fallbackMimeType, true);
if (attachment.statusCode == 401 && !getRestoreToken().isEmpty()) {
try {
ApiResponse restored = restoreSession();
if (restored.ok()) {
return downloadAttachmentRaw(attachmentId, fallbackFileName, fallbackMimeType, true);
}
} catch (JSONException exception) {
throw new IOException("SESSION_RESTORE_FAILED", exception);
}
}
return attachment;
}
public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, JSONObject payload) throws IOException, JSONException {
String requestBody = ForwardPayloads.toRequestBody(targetProjectId, payload);
return requestWithRestoreRaw("POST", "/api/v1/projects/" + encode(projectId) + "/forwards", requestBody);
}
public ApiResponse getThreadDetail(String threadId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/threads/" + encode(threadId) + "/context-budget", null);
}
public ApiResponse toggleGoal(String projectId, String goalId) throws IOException, JSONException {
return requestWithRestore("POST", "/api/projects/" + encode(projectId) + "/goals/" + encode(goalId) + "/toggle", new JSONObject());
}
public ApiResponse createGoal(String projectId, String text) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("action", "create");
payload.put("text", text);
return requestWithRestore("POST", "/api/projects/" + encode(projectId) + "/goals/update", payload);
}
public ApiResponse updateGoal(String projectId, String goalId, String text) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("action", "update");
payload.put("goalId", goalId);
payload.put("text", text);
return requestWithRestore("POST", "/api/projects/" + encode(projectId) + "/goals/update", payload);
}
public ApiResponse getDevices() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/devices", null);
}
public ApiResponse getDeviceDetail(String deviceId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/devices?device=" + encode(deviceId), null);
}
public ApiResponse updateDevice(String deviceId, JSONObject payload) throws IOException, JSONException {
return requestWithRestore("PATCH", "/api/v1/devices/" + encode(deviceId), payload);
}
public ApiResponse getDeviceSkills(String deviceId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/devices/" + encode(deviceId) + "/skills", null);
}
public ApiResponse getDeviceEnrollments() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/devices/enrollments", null);
}
public ApiResponse createDeviceEnrollment(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/devices/enrollments", payload);
}
public ApiResponse getDeviceImportDraft(String deviceId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/devices/" + encode(deviceId) + "/import-draft", null);
}
public ApiResponse selectDeviceImportCandidates(String deviceId, JSONArray selectedCandidateIds) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("selectedCandidateIds", (Object) (selectedCandidateIds == null ? new JSONArray() : selectedCandidateIds));
return requestWithRestore("POST", "/api/v1/devices/" + encode(deviceId) + "/import-draft/select", payload);
}
public ApiResponse reviewDeviceImportDraft(String deviceId) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/devices/" + encode(deviceId) + "/import-draft/review", new JSONObject());
}
public ApiResponse applyDeviceImportDraft(String deviceId) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/devices/" + encode(deviceId) + "/import-draft/apply", new JSONObject());
}
public ApiResponse getAccounts() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/accounts", null);
}
public ApiResponse createAccount(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/accounts", payload);
}
public ApiResponse updateAccount(String accountId, JSONObject payload) throws IOException, JSONException {
return requestWithRestore("PATCH", "/api/v1/accounts/" + encode(accountId), payload);
}
public ApiResponse deleteAccount(String accountId) throws IOException, JSONException {
return requestWithRestore("DELETE", "/api/v1/accounts/" + encode(accountId), null);
}
public ApiResponse activateAccount(String accountId, String reason) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("reason", reason);
return requestWithRestore("POST", "/api/v1/accounts/" + encode(accountId) + "/activate", payload);
}
public ApiResponse validateAccount(String accountId) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/accounts/" + encode(accountId) + "/validate", new JSONObject());
}
public ApiResponse onboardOpenAiApiAccount(JSONObject payload) throws IOException, JSONException {
return onboardAccount("/api/v1/accounts/onboard/openai-api", payload);
}
public ApiResponse onboardAliyunQwenAccount(JSONObject payload) throws IOException, JSONException {
return onboardAccount("/api/v1/accounts/onboard/aliyun-qwen", payload);
}
public ApiResponse onboardMasterNodeAccount(JSONObject payload) throws IOException, JSONException {
return onboardAccount("/api/v1/accounts/onboard/master-node", payload);
}
public ApiResponse getOpsSummary() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/ops/summary", null);
}
public ApiResponse approveRepairTicket(String ticketId) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/ops/repair-tickets/" + encode(ticketId) + "/approve", new JSONObject());
}
public ApiResponse verifyRepairTicket(String ticketId) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/ops/repair-tickets/" + encode(ticketId) + "/verify", new JSONObject());
}
public ApiResponse getAuditSummary() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/audits/summary", null);
}
public ApiResponse getSettings() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/settings", null);
}
public ApiResponse updateSettings(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/settings", payload);
}
public ApiResponse getOtaStatus() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/user/ota", null);
}
public ApiResponse checkOta() throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("action", "check");
return requestWithRestore("POST", "/api/v1/user/ota", payload);
}
public ApiResponse applyOta() throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("action", "apply");
return requestWithRestore("POST", "/api/v1/user/ota", payload);
}
public ApiResponse logout() throws IOException, JSONException {
ApiResponse response = request("POST", "/api/auth/logout", new JSONObject(), false);
clearSession();
return response;
}
public String getAccountLabel() {
return prefs.getString(KEY_ACCOUNT, "17600003315");
}
public String getDisplayName() {
return prefs.getString(KEY_DISPLAY_NAME, "Boss 超级管理员");
}
public String getRestoreToken() {
return prefs.getString(KEY_RESTORE_TOKEN, "");
}
public String getSessionCookie() {
return prefs.getString(KEY_SESSION_COOKIE, "");
}
public String getBaseUrl() {
return baseUrl;
}
public String getProtectedOtaPackageUrl() {
return baseUrl + "/api/v1/user/ota/package";
}
private ApiResponse requestWithRestore(String method, String path, JSONObject body) throws IOException, JSONException {
return requestWithRestoreRaw(
method,
path,
body == null ? null : body.toString(),
DEFAULT_CONNECT_TIMEOUT_MS,
DEFAULT_READ_TIMEOUT_MS
);
}
private ApiResponse requestWithRestoreRaw(String method, String path, @Nullable String body) throws IOException, JSONException {
return requestWithRestoreRaw(method, path, body, DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS);
}
private ApiResponse requestWithRestoreRaw(
String method,
String path,
@Nullable String body,
int connectTimeoutMs,
int readTimeoutMs
) throws IOException, JSONException {
ApiResponse response = requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
if (response.statusCode == 401 && !getRestoreToken().isEmpty()) {
ApiResponse restored = restoreSession();
if (restored.ok()) {
return requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
}
}
return response;
}
private ApiResponse onboardAccount(String onboardPath, JSONObject payload) throws IOException, JSONException {
JSONObject normalized = payload == null ? new JSONObject() : new JSONObject(payload.toString());
normalized.put("setActive", true);
ApiResponse response = requestWithRestore("POST", onboardPath, normalized);
if (response.statusCode != 404) {
return response;
}
JSONObject fallbackPayload = new JSONObject(normalized.toString());
String accountId = fallbackPayload.optString("accountId", "");
if (!accountId.isEmpty()) {
return updateAccount(accountId, fallbackPayload);
}
fallbackPayload.remove("accountId");
return createAccount(fallbackPayload);
}
private ApiResponse request(String method, String path, JSONObject body, boolean expectProtected) throws IOException, JSONException {
return requestRaw(
method,
path,
body == null ? null : body.toString(),
expectProtected,
DEFAULT_CONNECT_TIMEOUT_MS,
DEFAULT_READ_TIMEOUT_MS
);
}
private ApiResponse requestRaw(String method, String path, @Nullable String body, boolean expectProtected) throws IOException, JSONException {
return requestRaw(method, path, body, expectProtected, DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS);
}
private ApiResponse requestRaw(
String method,
String path,
@Nullable String body,
boolean expectProtected,
int connectTimeoutMs,
int readTimeoutMs
) throws IOException, JSONException {
HttpURLConnection connection = openConnection(path);
prepareConnection(connection, method, connectTimeoutMs, readTimeoutMs);
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,
int connectTimeoutMs,
int readTimeoutMs
) throws IOException {
connection.setRequestMethod(method);
connection.setConnectTimeout(connectTimeoutMs);
connection.setReadTimeout(readTimeoutMs);
connection.setUseCaches(false);
connection.setDoInput(true);
connection.setRequestProperty("Accept", "application/json");
connection.setRequestProperty("x-boss-native-app", "1");
String cookie = getSessionCookie();
if (!cookie.isEmpty()) {
connection.setRequestProperty("Cookie", cookie);
}
}
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());
if (statusCode == 401 && !expectProtected) {
clearSession();
}
if (json != null) {
rememberIdentity(json);
}
return new ApiResponse(statusCode, json == null ? new JSONObject() : json);
}
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,
InputStream inputStream,
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)
);
if (inputStream != null) {
byte[] buffer = new byte[8192];
int read;
while ((read = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, read);
}
}
outputStream.write("\r\n".getBytes(StandardCharsets.UTF_8));
}
private DownloadedAttachment downloadAttachmentRaw(
String attachmentId,
String fallbackFileName,
String fallbackMimeType,
boolean expectProtected
) throws IOException {
HttpURLConnection connection = openConnection("/api/v1/attachments/" + encode(attachmentId) + "/download");
prepareConnection(connection, "GET", DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READ_TIMEOUT_MS);
int statusCode = connection.getResponseCode();
captureSessionCookie(connection.getHeaderFields());
if (statusCode >= 400) {
String errorBody = readText(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
if (statusCode == 401 && !expectProtected) {
clearSession();
}
return DownloadedAttachment.error(statusCode, errorBody);
}
byte[] bytes;
try (InputStream inputStream = connection.getInputStream();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
byte[] buffer = new byte[8192];
int read;
while (inputStream != null && (read = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, read);
}
bytes = outputStream.toByteArray();
}
String contentType = connection.getHeaderField("Content-Type");
String fileName = parseDownloadFileName(connection.getHeaderField("Content-Disposition"));
return new DownloadedAttachment(
statusCode,
fileName == null || fileName.isEmpty() ? fallbackFileName : fileName,
contentType == null || contentType.isEmpty() ? fallbackMimeType : contentType,
bytes,
""
);
}
private String escapeMultipartValue(String value) {
if (value == null) {
return "attachment";
}
return value.replace("\"", "%22");
}
private JSONObject readJson(InputStream stream) throws IOException, JSONException {
if (stream == null) {
return new JSONObject();
}
StringBuilder builder = new StringBuilder();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
builder.append(line);
}
}
String raw = builder.toString().trim();
if (raw.isEmpty()) {
return new JSONObject();
}
return new JSONObject(raw);
}
private String readText(InputStream stream) throws IOException {
if (stream == null) {
return "";
}
StringBuilder builder = new StringBuilder();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
builder.append(line);
}
}
return builder.toString();
}
@Nullable
private String parseDownloadFileName(@Nullable String contentDisposition) {
if (contentDisposition == null || contentDisposition.isEmpty()) {
return null;
}
String[] parts = contentDisposition.split(";");
for (String part : parts) {
String trimmed = part.trim();
if (trimmed.startsWith("filename=")) {
return trimmed.substring("filename=".length()).replace("\"", "");
}
}
return null;
}
private void captureSessionCookie(Map<String, List<String>> headers) {
if (headers == null) return;
List<String> setCookieHeaders = headers.get("Set-Cookie");
if (setCookieHeaders == null) {
setCookieHeaders = headers.get("set-cookie");
}
if (setCookieHeaders == null) return;
for (String header : setCookieHeaders) {
if (header == null || !header.startsWith("boss_session=")) continue;
String cookiePair = header.split(";", 2)[0];
if (header.contains("Max-Age=0")) {
clearSession();
return;
}
prefs.edit().putString(KEY_SESSION_COOKIE, cookiePair).apply();
return;
}
}
void rememberIdentity(JSONObject json) {
if (json == null) return;
JSONObject source = resolveSessionIdentitySource(json);
if (source == null) {
return;
}
SharedPreferences.Editor editor = prefs.edit();
String restoreToken = source.optString("restoreToken", "");
if (!restoreToken.isEmpty()) {
editor.putString(KEY_RESTORE_TOKEN, restoreToken);
}
String account = source.optString("account", "");
if (!account.isEmpty()) {
editor.putString(KEY_ACCOUNT, account);
}
String displayName = source.optString("displayName", "");
if (!displayName.isEmpty()) {
editor.putString(KEY_DISPLAY_NAME, displayName);
}
editor.apply();
}
@Nullable
private JSONObject resolveSessionIdentitySource(JSONObject json) {
JSONObject session = json.optJSONObject("session");
if (session != null) {
return session;
}
if (
json.has("restoreToken")
|| json.has("account")
|| json.has("role")
|| json.has("expiresAt")
|| json.has("sessionCookie")
) {
return json;
}
return null;
}
private void clearSession() {
prefs.edit()
.remove(KEY_SESSION_COOKIE)
.remove(KEY_RESTORE_TOKEN)
.apply();
}
String encode(String value) {
return Uri.encode(value);
}
public static class ApiResponse {
public final int statusCode;
public final JSONObject json;
public ApiResponse(int statusCode, JSONObject json) {
this.statusCode = statusCode;
this.json = json;
}
public boolean ok() {
return statusCode >= 200 && statusCode < 300 && json.optBoolean("ok", false);
}
public String message() {
return json.optString("message", "UNKNOWN");
}
public static ApiResponse error(int statusCode, JSONObject json) {
return new ApiResponse(statusCode, json);
}
}
public static class DownloadedAttachment {
public final int statusCode;
public final String fileName;
public final String mimeType;
public final byte[] bytes;
public final String errorBody;
public DownloadedAttachment(
int statusCode,
String fileName,
String mimeType,
byte[] bytes,
String errorBody
) {
this.statusCode = statusCode;
this.fileName = fileName;
this.mimeType = mimeType;
this.bytes = bytes == null ? new byte[0] : bytes;
this.errorBody = errorBody == null ? "" : errorBody;
}
public boolean ok() {
return statusCode >= 200 && statusCode < 300;
}
public static DownloadedAttachment error(int statusCode, String errorBody) {
return new DownloadedAttachment(statusCode, "", "application/octet-stream", new byte[0], errorBody);
}
}
}