913 lines
37 KiB
Java
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);
|
|
}
|
|
}
|
|
}
|