feat: refine mobile master agent sync and chat rendering
This commit is contained in:
@@ -5,12 +5,14 @@
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:name=".BossApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
android:theme="@style/AppTheme"
|
||||
android:forceDarkAllowed="false">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -147,6 +147,22 @@ public class BossApiClient {
|
||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
|
||||
}
|
||||
|
||||
public ApiResponse updateMasterAgentModeModels(
|
||||
@Nullable String fastModelOverride,
|
||||
@Nullable String deepModelOverride,
|
||||
@Nullable String modelOverride,
|
||||
@Nullable String reasoningEffortOverride
|
||||
) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("fastModelOverride", fastModelOverride == null ? JSONObject.NULL : fastModelOverride);
|
||||
payload.put("deepModelOverride", deepModelOverride == null ? JSONObject.NULL : deepModelOverride);
|
||||
if (modelOverride != null || reasoningEffortOverride != null) {
|
||||
payload.put("modelOverride", modelOverride == null ? JSONObject.NULL : modelOverride);
|
||||
payload.put("reasoningEffortOverride", reasoningEffortOverride == null ? JSONObject.NULL : reasoningEffortOverride);
|
||||
}
|
||||
return requestWithRestore("POST", "/api/v1/projects/master-agent/agent-controls", payload);
|
||||
}
|
||||
|
||||
public ApiResponse updateProjectTakeoverSettings(
|
||||
String projectId,
|
||||
@Nullable Boolean takeoverEnabled,
|
||||
@@ -490,6 +506,10 @@ public class BossApiClient {
|
||||
return requestWithRestore("POST", "/api/v1/accounts/" + encode(accountId) + "/validate", new JSONObject());
|
||||
}
|
||||
|
||||
public ApiResponse validateDraftAccount(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/accounts/validate-draft", payload);
|
||||
}
|
||||
|
||||
public ApiResponse onboardOpenAiApiAccount(JSONObject payload) throws IOException, JSONException {
|
||||
return onboardAccount("/api/v1/accounts/onboard/openai-api", payload);
|
||||
}
|
||||
|
||||
13
android/app/src/main/java/com/hyzq/boss/BossApplication.java
Normal file
13
android/app/src/main/java/com/hyzq/boss/BossApplication.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.app.Application;
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
|
||||
public final class BossApplication extends Application {
|
||||
@Override
|
||||
public void onCreate() {
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
|
||||
super.onCreate();
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ public final class BossMarkdown {
|
||||
private static final Pattern HEADING_PATTERN = Pattern.compile("^(#{1,3})\\s+(.+)$");
|
||||
private static final Pattern BULLET_PATTERN = Pattern.compile("^[-*]\\s+(.+)$");
|
||||
private static final Pattern ORDERED_PATTERN = Pattern.compile("^(\\d+)\\.\\s+(.+)$");
|
||||
private static final Pattern LABEL_SECTION_PATTERN = Pattern.compile("^([^::\\n]{1,24})[::]\\s*(.+)$");
|
||||
private static final Pattern INLINE_TOKEN_PATTERN = Pattern.compile("(\\*\\*([^*]+)\\*\\*)|(`([^`]+)`)");;
|
||||
private static final LruCache<String, CharSequence> RENDER_CACHE = new LruCache<>(180);
|
||||
|
||||
@@ -86,6 +87,12 @@ public final class BossMarkdown {
|
||||
continue;
|
||||
}
|
||||
|
||||
Matcher labelMatcher = LABEL_SECTION_PATTERN.matcher(trimmed);
|
||||
if (labelMatcher.matches()) {
|
||||
appendLabelSection(builder, labelMatcher.group(1), labelMatcher.group(2), palette);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith(">")) {
|
||||
appendQuote(builder, trimmed.substring(1).trim(), palette);
|
||||
continue;
|
||||
@@ -153,6 +160,22 @@ public final class BossMarkdown {
|
||||
builder.append('\n');
|
||||
}
|
||||
|
||||
private static void appendLabelSection(
|
||||
SpannableStringBuilder builder,
|
||||
String label,
|
||||
String content,
|
||||
Palette palette
|
||||
) {
|
||||
ensureBlockSeparation(builder, true);
|
||||
int labelStart = builder.length();
|
||||
builder.append(label.trim());
|
||||
builder.setSpan(new StyleSpan(Typeface.BOLD), labelStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.setSpan(new RelativeSizeSpan(1.03f), labelStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.append('\n');
|
||||
appendInlineStyled(builder, content.trim(), palette);
|
||||
builder.append('\n');
|
||||
}
|
||||
|
||||
private static void appendCodeBlock(SpannableStringBuilder builder, String text, Palette palette) {
|
||||
if (TextUtils.isEmpty(text)) {
|
||||
return;
|
||||
|
||||
@@ -13,6 +13,7 @@ import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Iterator;
|
||||
@@ -90,6 +91,11 @@ final class BossRealtimeClient {
|
||||
if (!running) {
|
||||
return;
|
||||
}
|
||||
if (shouldReconnectImmediately(error)) {
|
||||
Log.i(TAG, "Realtime stream timed out while idle; reconnecting immediately");
|
||||
backoffMs = INITIAL_BACKOFF_MS;
|
||||
continue;
|
||||
}
|
||||
if (shouldAttemptSessionRestore(error)) {
|
||||
try {
|
||||
BossApiClient.ApiResponse restored = apiClient.restoreSession();
|
||||
@@ -174,6 +180,10 @@ final class BossRealtimeClient {
|
||||
&& apiClient.hasRestoreToken();
|
||||
}
|
||||
|
||||
static boolean shouldReconnectImmediately(@Nullable Exception error) {
|
||||
return error instanceof SocketTimeoutException;
|
||||
}
|
||||
|
||||
private void dispatchEventBlock(String rawBlock) {
|
||||
BossRealtimeEvent event = parseEventBlock(rawBlock);
|
||||
if (event == null || event.eventName.isEmpty()) {
|
||||
|
||||
@@ -15,6 +15,8 @@ import android.text.TextUtils;
|
||||
import android.util.TypedValue;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewParent;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.FrameLayout;
|
||||
@@ -475,6 +477,10 @@ public final class BossUi {
|
||||
cell.addView(helperView);
|
||||
}
|
||||
|
||||
ViewParent currentParent = field.getParent();
|
||||
if (currentParent instanceof ViewGroup) {
|
||||
((ViewGroup) currentParent).removeView(field);
|
||||
}
|
||||
field.setPadding(field.getPaddingLeft(), field.getPaddingTop(), field.getPaddingRight(), field.getPaddingBottom());
|
||||
cell.addView(field);
|
||||
return cell;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
@@ -11,6 +12,7 @@ import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewParent;
|
||||
import android.view.animation.AccelerateDecelerateInterpolator;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.FrameLayout;
|
||||
@@ -141,7 +143,17 @@ public class MainActivity extends AppCompatActivity {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_main);
|
||||
apiClient = new BossApiClient(this);
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
realtimeClient = new BossRealtimeClient(apiClient, new BossRealtimeClient.Listener() {
|
||||
@Override
|
||||
public void onRealtimeEvent(BossRealtimeEvent event) {
|
||||
handleRealtimeEvent(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRealtimeConnectionChanged(boolean connected) {
|
||||
runOnUiThread(() -> handleRealtimeConnectionChanged(connected));
|
||||
}
|
||||
});
|
||||
bindViews();
|
||||
bindActions();
|
||||
applyInitialTab(getIntent());
|
||||
@@ -619,11 +631,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
private void scheduleRealtimeRefresh() {
|
||||
if (realtimeRefreshScheduled) {
|
||||
return;
|
||||
}
|
||||
realtimeRefreshScheduled = true;
|
||||
uiHandler.postDelayed(realtimeRefreshRunnable, REALTIME_REFRESH_DEBOUNCE_MS);
|
||||
realtimeRefreshScheduled = false;
|
||||
refreshCurrentTab();
|
||||
}
|
||||
|
||||
private void cancelRealtimeRefreshSchedule() {
|
||||
@@ -653,7 +662,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private boolean shouldRefreshConversationsTab(BossRealtimeEvent event) {
|
||||
if ("conversation.context_indicator.updated".equals(event.eventName)) {
|
||||
return false;
|
||||
return hasProjectId(event)
|
||||
|| hasDeviceId(event)
|
||||
|| event.payload.optJSONArray("conversations") != null;
|
||||
}
|
||||
if ("conversation.updated".equals(event.eventName)) {
|
||||
return hasProjectId(event) || hasDeviceId(event);
|
||||
@@ -1166,6 +1177,16 @@ public class MainActivity extends AppCompatActivity {
|
||||
showMessage("缺少 folderKey");
|
||||
return;
|
||||
}
|
||||
if (conversationSearchMode) {
|
||||
String matchedProjectId = item.optString("searchMatchProjectId", "").trim();
|
||||
String matchedProjectLabel = item.optString("searchMatchLabel", "").trim();
|
||||
if (!matchedProjectId.isEmpty() && !matchedProjectLabel.isEmpty()) {
|
||||
exitConversationSearchMode(true);
|
||||
openProject(matchedProjectId, matchedProjectLabel);
|
||||
return;
|
||||
}
|
||||
exitConversationSearchMode(true);
|
||||
}
|
||||
openConversationFolder(
|
||||
folderKey,
|
||||
resolveConversationFolderName(item, row),
|
||||
@@ -1180,6 +1201,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
return;
|
||||
}
|
||||
String projectName = finalDisplayRow.threadTitle.isEmpty() ? "未命名会话" : finalDisplayRow.threadTitle;
|
||||
if (conversationSearchMode) {
|
||||
exitConversationSearchMode(true);
|
||||
}
|
||||
openProject(projectId, projectName);
|
||||
})
|
||||
));
|
||||
@@ -1292,10 +1316,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
hideConversationQuickActions(false);
|
||||
conversationSearchMode = true;
|
||||
syncTopActionVisualState(screenRefresh.isRefreshing());
|
||||
topSearchInput.post(() -> {
|
||||
topSearchInput.requestFocus();
|
||||
topSearchInput.setSelection(topSearchInput.getText().length());
|
||||
});
|
||||
showConversationSearchKeyboard();
|
||||
}
|
||||
|
||||
private void exitConversationSearchMode(boolean clearQuery) {
|
||||
@@ -1308,12 +1329,40 @@ public class MainActivity extends AppCompatActivity {
|
||||
conversationSearchQuery = "";
|
||||
topSearchInput.setText("");
|
||||
}
|
||||
hideConversationSearchKeyboard();
|
||||
syncTopActionVisualState(screenRefresh != null && screenRefresh.isRefreshing());
|
||||
if (queryChanged && "conversations".equals(activeTab) && contentPanel.getVisibility() == View.VISIBLE) {
|
||||
renderConversationsRoot();
|
||||
}
|
||||
}
|
||||
|
||||
private void showConversationSearchKeyboard() {
|
||||
if (topSearchInput == null) {
|
||||
return;
|
||||
}
|
||||
topSearchInput.post(() -> {
|
||||
topSearchInput.requestFocus();
|
||||
topSearchInput.setSelection(topSearchInput.getText().length());
|
||||
InputMethodManager inputMethodManager =
|
||||
(InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
if (inputMethodManager != null) {
|
||||
inputMethodManager.showSoftInput(topSearchInput, InputMethodManager.SHOW_IMPLICIT);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void hideConversationSearchKeyboard() {
|
||||
if (topSearchInput == null) {
|
||||
return;
|
||||
}
|
||||
InputMethodManager inputMethodManager =
|
||||
(InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
if (inputMethodManager != null) {
|
||||
inputMethodManager.hideSoftInputFromWindow(topSearchInput.getWindowToken(), 0);
|
||||
}
|
||||
topSearchInput.clearFocus();
|
||||
}
|
||||
|
||||
private void toggleConversationSelection(String projectId) {
|
||||
if (selectedConversationProjectIds.contains(projectId)) {
|
||||
selectedConversationProjectIds.remove(projectId);
|
||||
@@ -1807,6 +1856,17 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
|
||||
void handleRealtimeConnectionChanged(boolean connected) {
|
||||
if (!connected
|
||||
&& shouldMaintainConversationAutoRefresh()
|
||||
&& !rootTabRefreshInFlight
|
||||
&& screenRefresh != null
|
||||
&& !screenRefresh.isRefreshing()) {
|
||||
refreshCurrentTab();
|
||||
}
|
||||
updateConversationAutoRefresh();
|
||||
}
|
||||
|
||||
private void openMeEntry(String key) {
|
||||
Intent intent;
|
||||
switch (key) {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
final class MasterAgentModePresets {
|
||||
static final class ModePreset {
|
||||
final String key;
|
||||
final String label;
|
||||
@Nullable final String modelOverride;
|
||||
@Nullable final String reasoningEffortOverride;
|
||||
|
||||
ModePreset(
|
||||
String key,
|
||||
String label,
|
||||
@Nullable String modelOverride,
|
||||
@Nullable String reasoningEffortOverride
|
||||
) {
|
||||
this.key = key;
|
||||
this.label = label;
|
||||
this.modelOverride = modelOverride;
|
||||
this.reasoningEffortOverride = reasoningEffortOverride;
|
||||
}
|
||||
}
|
||||
|
||||
static final ModePreset DEFAULT = new ModePreset("default", "沿用默认", null, null);
|
||||
private static final String DEFAULT_FAST_MODEL = "gpt-5.4-mini";
|
||||
private static final String DEFAULT_DEEP_MODEL = "gpt-5.4";
|
||||
|
||||
private MasterAgentModePresets() {}
|
||||
|
||||
static ModePreset[] primaryChoices(@Nullable String fastModelOverride, @Nullable String deepModelOverride) {
|
||||
return new ModePreset[]{
|
||||
DEFAULT,
|
||||
new ModePreset("fast", "快速反应", resolveFastModel(fastModelOverride), "low"),
|
||||
new ModePreset("deep", "深度思考", resolveDeepModel(deepModelOverride), "high")
|
||||
};
|
||||
}
|
||||
|
||||
static String[] primaryChoiceLabels(@Nullable String fastModelOverride, @Nullable String deepModelOverride) {
|
||||
return new String[]{
|
||||
"沿用默认",
|
||||
"快速反应(" + resolveFastModel(fastModelOverride) + ")",
|
||||
"深度思考(" + resolveDeepModel(deepModelOverride) + ")",
|
||||
"更多模型..."
|
||||
};
|
||||
}
|
||||
|
||||
static int findPrimaryChoiceIndex(
|
||||
@Nullable String modelOverride,
|
||||
@Nullable String reasoningEffortOverride,
|
||||
@Nullable String fastModelOverride,
|
||||
@Nullable String deepModelOverride
|
||||
) {
|
||||
ModePreset preset = matchPreset(modelOverride, reasoningEffortOverride, fastModelOverride, deepModelOverride);
|
||||
if (preset == null) {
|
||||
return primaryChoiceLabels(fastModelOverride, deepModelOverride).length - 1;
|
||||
}
|
||||
ModePreset[] choices = primaryChoices(fastModelOverride, deepModelOverride);
|
||||
for (int index = 0; index < choices.length; index += 1) {
|
||||
if (choices[index].key.equals(preset.key)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
static ModePreset matchPreset(
|
||||
@Nullable String modelOverride,
|
||||
@Nullable String reasoningEffortOverride,
|
||||
@Nullable String fastModelOverride,
|
||||
@Nullable String deepModelOverride
|
||||
) {
|
||||
String model = normalize(modelOverride);
|
||||
String reasoning = normalize(reasoningEffortOverride);
|
||||
if (TextUtils.isEmpty(model) && TextUtils.isEmpty(reasoning)) {
|
||||
return DEFAULT;
|
||||
}
|
||||
for (ModePreset preset : primaryChoices(fastModelOverride, deepModelOverride)) {
|
||||
if (TextUtils.equals(normalize(preset.modelOverride), model)
|
||||
&& TextUtils.equals(normalize(preset.reasoningEffortOverride), reasoning)) {
|
||||
return preset;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String describeCurrentMode(
|
||||
@Nullable String modelOverride,
|
||||
@Nullable String reasoningEffortOverride,
|
||||
@Nullable String fastModelOverride,
|
||||
@Nullable String deepModelOverride
|
||||
) {
|
||||
ModePreset preset = matchPreset(modelOverride, reasoningEffortOverride, fastModelOverride, deepModelOverride);
|
||||
return preset == null ? "自定义" : preset.label;
|
||||
}
|
||||
|
||||
static String resolveFastModel(@Nullable String fastModelOverride) {
|
||||
String resolved = normalize(fastModelOverride);
|
||||
return TextUtils.isEmpty(resolved) ? DEFAULT_FAST_MODEL : resolved;
|
||||
}
|
||||
|
||||
static String resolveDeepModel(@Nullable String deepModelOverride) {
|
||||
String resolved = normalize(deepModelOverride);
|
||||
return TextUtils.isEmpty(resolved) ? DEFAULT_DEEP_MODEL : resolved;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String normalize(@Nullable String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
if (trimmed.isEmpty() || "null".equalsIgnoreCase(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
|
||||
public class ProjectDetailActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
@@ -55,6 +56,8 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
private String projectFolderName;
|
||||
private @Nullable String currentAgentModelOverride;
|
||||
private @Nullable String currentReasoningEffortOverride;
|
||||
private @Nullable String currentFastModelOverride;
|
||||
private @Nullable String currentDeepModelOverride;
|
||||
private LinearLayout quickActionsLayout;
|
||||
private LinearLayout composerRow;
|
||||
private LinearLayout multiSelectActionsLayout;
|
||||
@@ -65,6 +68,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
private ScrollView chatScrollView;
|
||||
private View pendingOutgoingBubble;
|
||||
private boolean composerSending;
|
||||
private @Nullable String pendingReplyPresenter;
|
||||
private boolean renderNearBottom;
|
||||
private boolean renderForcedScrollToBottom;
|
||||
private boolean conversationInfoReady;
|
||||
@@ -102,6 +106,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
private boolean reloadInFlight;
|
||||
private boolean pendingReload;
|
||||
private boolean pendingReloadForcedScrollToBottom;
|
||||
private volatile boolean activityDestroyed;
|
||||
private final Runnable conversationAutoRefreshRunnable = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
@@ -124,6 +129,12 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
triggerRealtimeReload(requireFullSnapshot);
|
||||
}
|
||||
};
|
||||
private final Runnable composerViewportSyncRunnable = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
syncChatViewportForComposer();
|
||||
}
|
||||
};
|
||||
|
||||
static final class ChromeBindings {
|
||||
final boolean multiSelecting;
|
||||
@@ -263,10 +274,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
|
||||
@Override
|
||||
public void onRealtimeConnectionChanged(boolean connected) {
|
||||
runOnUiThread(() -> {
|
||||
lastKnownRealtimeConnected = connected;
|
||||
syncRealtimeStatusIndicator();
|
||||
});
|
||||
runOnUiThread(() -> handleRealtimeConnectionChanged(connected));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -297,6 +305,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {}
|
||||
});
|
||||
bindComposerViewportSync();
|
||||
updateComposerSendButtonState();
|
||||
updateSelectionUi();
|
||||
if (shouldLoadOnCreate()) {
|
||||
@@ -306,9 +315,11 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
activityDestroyed = true;
|
||||
cancelConversationAutoRefresh();
|
||||
cancelRealtimeReloadSchedule();
|
||||
stopRealtimeUpdates();
|
||||
uiHandler.removeCallbacksAndMessages(null);
|
||||
replyWaitExecutor.shutdownNow();
|
||||
super.onDestroy();
|
||||
}
|
||||
@@ -360,7 +371,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
if (tryApplyRealtimeMessagesPatch(event)) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(() -> scheduleRealtimeReload(!"project.messages.updated".equals(event.eventName)));
|
||||
runOnUiThread(() -> scheduleRealtimeReload(true));
|
||||
}
|
||||
|
||||
private boolean tryApplyRealtimeMessagesPatch(BossRealtimeEvent event) {
|
||||
@@ -523,14 +534,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
}
|
||||
|
||||
private void scheduleRealtimeReload(boolean requireFullSnapshot) {
|
||||
if (requireFullSnapshot) {
|
||||
realtimeReloadRequiresFullSnapshot = true;
|
||||
}
|
||||
if (realtimeReloadScheduled) {
|
||||
return;
|
||||
}
|
||||
realtimeReloadScheduled = true;
|
||||
uiHandler.postDelayed(realtimeReloadRunnable, REALTIME_REFRESH_DEBOUNCE_MS);
|
||||
realtimeReloadRequiresFullSnapshot = false;
|
||||
realtimeReloadScheduled = false;
|
||||
triggerRealtimeReload(requireFullSnapshot);
|
||||
}
|
||||
|
||||
private void cancelRealtimeReloadSchedule() {
|
||||
@@ -547,6 +553,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
}
|
||||
|
||||
private void reloadSnapshot(boolean forcedScrollToBottom, boolean messagesOnly) {
|
||||
if (shouldSkipAsyncUiWork()) {
|
||||
return;
|
||||
}
|
||||
if (projectId == null || projectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
finish();
|
||||
@@ -561,27 +570,45 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
renderForcedScrollToBottom = forcedScrollToBottom;
|
||||
reloadInFlight = true;
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
ProjectSnapshot snapshot = messagesOnly
|
||||
? loadProjectMessagesSnapshotForRefresh()
|
||||
: loadProjectSnapshotForRefresh();
|
||||
runOnUiThread(() -> {
|
||||
renderLoadedProjectSnapshot(snapshot);
|
||||
finishReloadCycle();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
if (messagesOnly) {
|
||||
reloadInFlight = false;
|
||||
setRefreshing(false);
|
||||
reload(forcedScrollToBottom);
|
||||
return;
|
||||
}
|
||||
handleProjectReloadFailure(error);
|
||||
finishReloadCycle();
|
||||
});
|
||||
try {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
ProjectSnapshot snapshot = messagesOnly
|
||||
? loadProjectMessagesSnapshotForRefresh()
|
||||
: loadProjectSnapshotForRefresh();
|
||||
runOnUiThreadIfActive(() -> {
|
||||
renderLoadedProjectSnapshot(snapshot);
|
||||
finishReloadCycle();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThreadIfActive(() -> {
|
||||
if (messagesOnly) {
|
||||
reloadInFlight = false;
|
||||
setRefreshing(false);
|
||||
reload(forcedScrollToBottom);
|
||||
return;
|
||||
}
|
||||
handleProjectReloadFailure(error);
|
||||
finishReloadCycle();
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (RejectedExecutionException ignored) {
|
||||
reloadInFlight = false;
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean shouldSkipAsyncUiWork() {
|
||||
return activityDestroyed || isFinishing() || isDestroyed();
|
||||
}
|
||||
|
||||
private void runOnUiThreadIfActive(Runnable action) {
|
||||
runOnUiThread(() -> {
|
||||
if (shouldSkipAsyncUiWork()) {
|
||||
return;
|
||||
}
|
||||
action.run();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -654,6 +681,8 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
JSONObject agentControls = project == null ? null : project.optJSONObject("agentControls");
|
||||
currentAgentModelOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("modelOverride", null));
|
||||
currentReasoningEffortOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("reasoningEffortOverride", null));
|
||||
currentFastModelOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("fastModelOverride", null));
|
||||
currentDeepModelOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("deepModelOverride", null));
|
||||
if (dispatchPlans != null) {
|
||||
currentPendingDispatchPlan = ProjectChatUiState.latestPendingDispatchPlan(dispatchPlans);
|
||||
currentRejectedDispatchPlan = currentPendingDispatchPlan == null
|
||||
@@ -752,6 +781,19 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
syncRealtimeStatusIndicator();
|
||||
}
|
||||
|
||||
void handleRealtimeConnectionChanged(boolean connected) {
|
||||
lastKnownRealtimeConnected = connected;
|
||||
syncRealtimeStatusIndicator();
|
||||
if (!connected
|
||||
&& shouldMaintainConversationAutoRefresh()
|
||||
&& !reloadInFlight
|
||||
&& refreshLayout != null
|
||||
&& !refreshLayout.isRefreshing()) {
|
||||
reload();
|
||||
}
|
||||
updateConversationAutoRefresh();
|
||||
}
|
||||
|
||||
private boolean shouldMaintainConversationAutoRefresh() {
|
||||
return conversationAutoRefreshEnabled
|
||||
&& apiClient != null
|
||||
@@ -838,6 +880,47 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
sendProjectMessage("text", body);
|
||||
}
|
||||
|
||||
private void bindComposerViewportSync() {
|
||||
if (composerInput != null) {
|
||||
composerInput.setOnFocusChangeListener((v, hasFocus) -> {
|
||||
if (hasFocus) {
|
||||
scheduleComposerViewportSync();
|
||||
}
|
||||
});
|
||||
composerInput.setOnClickListener(v -> scheduleComposerViewportSync());
|
||||
}
|
||||
if (composerRow != null) {
|
||||
composerRow.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
|
||||
if ((bottom - top) == (oldBottom - oldTop) && bottom == oldBottom) {
|
||||
return;
|
||||
}
|
||||
if (shouldKeepLatestMessageVisibleForInput()) {
|
||||
scheduleComposerViewportSync();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleComposerViewportSync() {
|
||||
uiHandler.removeCallbacks(composerViewportSyncRunnable);
|
||||
uiHandler.post(composerViewportSyncRunnable);
|
||||
uiHandler.postDelayed(composerViewportSyncRunnable, 96L);
|
||||
}
|
||||
|
||||
private void syncChatViewportForComposer() {
|
||||
if (!shouldKeepLatestMessageVisibleForInput()) {
|
||||
return;
|
||||
}
|
||||
scrollChatToBottom();
|
||||
}
|
||||
|
||||
private boolean shouldKeepLatestMessageVisibleForInput() {
|
||||
return composerInput != null
|
||||
&& composerInput.isFocused()
|
||||
&& composerRow != null
|
||||
&& composerRow.getVisibility() == View.VISIBLE;
|
||||
}
|
||||
|
||||
private void showAttachmentEntrySheet() {
|
||||
if (isComposerBusy()) {
|
||||
return;
|
||||
@@ -958,6 +1041,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
}
|
||||
JSONObject dispatchPlan = response.json.optJSONObject("dispatchPlan");
|
||||
JSONObject collaborationGate = response.json.optJSONObject("collaborationGate");
|
||||
String replyPresenter = response.json.optString("replyPresenter", "").trim();
|
||||
ProjectChatUiState.ReplyWaitSpec waitSpec =
|
||||
ProjectChatUiState.resolveReplyWaitAfterSend(response.json);
|
||||
runOnUiThread(() -> {
|
||||
@@ -970,8 +1054,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
}
|
||||
currentPendingDispatchPlan = dispatchPlan;
|
||||
currentRejectedDispatchPlan = null;
|
||||
pendingReplyPresenter = replyPresenter.isEmpty() ? null : replyPresenter;
|
||||
if (dispatchPlan != null) {
|
||||
composerSending = false;
|
||||
pendingReplyPresenter = null;
|
||||
updateComposerSendButtonState();
|
||||
showMessage(
|
||||
"approval_required".equals(projectCollaborationMode)
|
||||
@@ -982,21 +1068,22 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
return;
|
||||
}
|
||||
if (waitSpec.shouldWait) {
|
||||
if (isMasterAgentConversation()) {
|
||||
startMasterAgentReplyWait(waitSpec, false, "消息已发送,主 Agent 思考中");
|
||||
} else {
|
||||
startReplyWait(waitSpec, false, "消息已发送,正在等待回复…");
|
||||
}
|
||||
startReplyWait(waitSpec, false, buildReplyWaitWaitingMessage());
|
||||
return;
|
||||
}
|
||||
composerSending = false;
|
||||
pendingReplyPresenter = null;
|
||||
updateComposerSendButtonState();
|
||||
if (tryApplyCompletedSendResponse(response.json)) {
|
||||
return;
|
||||
}
|
||||
showMessage("消息已发送");
|
||||
reload(true);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
composerSending = false;
|
||||
pendingReplyPresenter = null;
|
||||
setRefreshing(false);
|
||||
removePendingOutgoingBubble();
|
||||
showMessage("发送失败:" + error.getMessage());
|
||||
@@ -1006,6 +1093,51 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private boolean tryApplyCompletedSendResponse(@Nullable JSONObject response) {
|
||||
if (response == null || currentRenderedProjectPayload == null) {
|
||||
return false;
|
||||
}
|
||||
JSONObject sentMessage = response.optJSONObject("message");
|
||||
JSONObject replyMessage = response.optJSONObject("replyMessage");
|
||||
if (sentMessage == null || replyMessage == null) {
|
||||
return false;
|
||||
}
|
||||
JSONObject payload = copyJson(currentRenderedProjectPayload);
|
||||
JSONObject project = payload.optJSONObject("project");
|
||||
if (project == null || !TextUtils.equals(project.optString("id", "").trim(), projectId)) {
|
||||
return false;
|
||||
}
|
||||
JSONArray messages = project.optJSONArray("messages");
|
||||
if (messages == null) {
|
||||
return false;
|
||||
}
|
||||
appendMessageIfMissing(messages, sentMessage);
|
||||
appendMessageIfMissing(messages, replyMessage);
|
||||
removePendingOutgoingBubble();
|
||||
clearMasterAgentReplyState();
|
||||
renderNearBottom = true;
|
||||
renderForcedScrollToBottom = true;
|
||||
renderLoadedProjectSnapshot(new ProjectSnapshot(payload, null, currentParticipantsPayload));
|
||||
return true;
|
||||
}
|
||||
|
||||
private void appendMessageIfMissing(JSONArray messages, JSONObject message) {
|
||||
if (messages == null || message == null) {
|
||||
return;
|
||||
}
|
||||
String messageId = message.optString("id", "").trim();
|
||||
if (messageId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (int index = 0; index < messages.length(); index += 1) {
|
||||
JSONObject existing = messages.optJSONObject(index);
|
||||
if (existing != null && TextUtils.equals(existing.optString("id", "").trim(), messageId)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
messages.put(copyJson(message));
|
||||
}
|
||||
|
||||
private void showThreadExecutionConflictDialog(JSONObject executionConflict, String body, String kind) {
|
||||
String preferredMode = "gui".equals(executionConflict.optString("preferredExecutionMode", "cli"))
|
||||
? "GUI"
|
||||
@@ -1251,10 +1383,45 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
if (!isMasterAgentConversation()) {
|
||||
return;
|
||||
}
|
||||
final MasterAgentModePresets.ModePreset[] presets = MasterAgentModePresets.primaryChoices(
|
||||
currentFastModelOverride,
|
||||
currentDeepModelOverride
|
||||
);
|
||||
final String[] options = MasterAgentModePresets.primaryChoiceLabels(
|
||||
currentFastModelOverride,
|
||||
currentDeepModelOverride
|
||||
);
|
||||
int checkedIndex = MasterAgentModePresets.findPrimaryChoiceIndex(
|
||||
currentAgentModelOverride,
|
||||
currentReasoningEffortOverride,
|
||||
currentFastModelOverride,
|
||||
currentDeepModelOverride
|
||||
);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("模型")
|
||||
.setSingleChoiceItems(options, checkedIndex, (dialog, which) -> {
|
||||
if (which == options.length - 1) {
|
||||
dialog.dismiss();
|
||||
showAdvancedMasterAgentModelPicker();
|
||||
return;
|
||||
}
|
||||
MasterAgentModePresets.ModePreset preset = presets[which];
|
||||
dialog.dismiss();
|
||||
updateMasterAgentControls(
|
||||
preset.modelOverride,
|
||||
preset.reasoningEffortOverride,
|
||||
"主Agent模式已切换为 " + preset.label
|
||||
);
|
||||
})
|
||||
.setNegativeButton("取消", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void showAdvancedMasterAgentModelPicker() {
|
||||
final String[] options = buildMasterAgentModelOptions();
|
||||
int checkedIndex = findCheckedIndex(options, currentAgentModelOverride);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("模型")
|
||||
.setTitle("更多模型")
|
||||
.setSingleChoiceItems(options, checkedIndex, (dialog, which) -> {
|
||||
if (which == 0) {
|
||||
dialog.dismiss();
|
||||
@@ -1275,7 +1442,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
|
||||
private void showCustomMasterAgentModelDialog() {
|
||||
final EditText input = BossUi.buildInput(this, "模型,例如 gpt-5.4", false);
|
||||
input.setText(TextUtils.isEmpty(currentAgentModelOverride) ? "gpt-5.4" : currentAgentModelOverride);
|
||||
input.setText(TextUtils.isEmpty(currentAgentModelOverride) ? "gpt-5.4-mini" : currentAgentModelOverride);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("自定义模型")
|
||||
.setView(input)
|
||||
@@ -1351,14 +1518,11 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
if (!TextUtils.isEmpty(currentAgentModelOverride)) {
|
||||
options.add(currentAgentModelOverride);
|
||||
}
|
||||
if (!options.contains("gpt-5.4")) {
|
||||
options.add("gpt-5.4");
|
||||
}
|
||||
if (!options.contains("gpt-5.1")) {
|
||||
options.add("gpt-5.1");
|
||||
}
|
||||
if (!options.contains("gpt-4.1")) {
|
||||
options.add("gpt-4.1");
|
||||
String[] preferredModels = new String[]{"gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1"};
|
||||
for (String model : preferredModels) {
|
||||
if (!options.contains(model)) {
|
||||
options.add(model);
|
||||
}
|
||||
}
|
||||
options.add("自定义...");
|
||||
return options.toArray(new String[0]);
|
||||
@@ -2134,9 +2298,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
masterAgentReplyWaiting = false;
|
||||
masterAgentReplyTimedOut = false;
|
||||
masterAgentReplyBaselineMessageId = null;
|
||||
pendingReplyPresenter = null;
|
||||
}
|
||||
|
||||
private void scrollChatToBottom() {
|
||||
void scrollChatToBottom() {
|
||||
if (chatScrollView == null) {
|
||||
return;
|
||||
}
|
||||
@@ -2555,7 +2720,45 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
}
|
||||
|
||||
protected void enqueueReplyWaitPoll(@Nullable String baselineMessageId, boolean includeDispatchPlans) {
|
||||
replyWaitExecutor.execute(() -> pollUntilReply(baselineMessageId, includeDispatchPlans));
|
||||
if (shouldSkipAsyncUiWork() || replyWaitExecutor.isShutdown() || replyWaitExecutor.isTerminated()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
replyWaitExecutor.execute(() -> pollUntilReply(baselineMessageId, includeDispatchPlans));
|
||||
} catch (RejectedExecutionException ignored) {
|
||||
// Activity is already finishing; ignore late polling requests.
|
||||
}
|
||||
}
|
||||
|
||||
private String buildReplyWaitWaitingMessage() {
|
||||
if ("master".equals(pendingReplyPresenter) && !isMasterAgentConversation()) {
|
||||
return "消息已发送,主 Agent 正在转述";
|
||||
}
|
||||
return isMasterAgentConversation()
|
||||
? "消息已发送,主 Agent 思考中"
|
||||
: "消息已发送,线程正在处理中";
|
||||
}
|
||||
|
||||
private String buildReplyWaitTimeoutMessage() {
|
||||
if ("master".equals(pendingReplyPresenter) && !isMasterAgentConversation()) {
|
||||
return "主 Agent 转述暂未回流,可继续等待或稍后刷新查看。";
|
||||
}
|
||||
return isMasterAgentConversation()
|
||||
? "主 Agent 回复超时,可重试等待最新回复。"
|
||||
: "当前线程暂未回流,继续后台等待或稍后刷新查看。";
|
||||
}
|
||||
|
||||
private String buildReplyWaitFailureMessage(Exception error) {
|
||||
String detail = error.getMessage();
|
||||
if (detail == null || detail.trim().isEmpty()) {
|
||||
detail = "未知错误";
|
||||
}
|
||||
if ("master".equals(pendingReplyPresenter) && !isMasterAgentConversation()) {
|
||||
return "等待主 Agent 转述失败:" + detail;
|
||||
}
|
||||
return isMasterAgentConversation()
|
||||
? "等待主 Agent 回复失败:" + detail
|
||||
: "等待线程回复失败:" + detail;
|
||||
}
|
||||
|
||||
private void pollUntilReply(
|
||||
@@ -2565,13 +2768,15 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
long deadlineAt = System.currentTimeMillis() + REPLY_WAIT_TIMEOUT_MS;
|
||||
boolean renderedInitialSnapshot = false;
|
||||
try {
|
||||
while (!Thread.currentThread().isInterrupted() && System.currentTimeMillis() < deadlineAt) {
|
||||
while (!shouldSkipAsyncUiWork()
|
||||
&& !Thread.currentThread().isInterrupted()
|
||||
&& System.currentTimeMillis() < deadlineAt) {
|
||||
ProjectSnapshot snapshot = fetchProjectSnapshot(includeDispatchPlans);
|
||||
JSONObject project = snapshot.payload.optJSONObject("project");
|
||||
boolean hasReply = ProjectChatUiState.hasReplyBeyondBaseline(project, baselineMessageId);
|
||||
|
||||
if (!renderedInitialSnapshot || hasReply) {
|
||||
runOnUiThread(() -> {
|
||||
runOnUiThreadIfActive(() -> {
|
||||
renderProject(snapshot.payload, snapshot.dispatchPlans, snapshot.participantsPayload);
|
||||
if (!hasReply && !isMasterAgentConversation()) {
|
||||
composerSending = true;
|
||||
@@ -2583,7 +2788,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
}
|
||||
|
||||
if (hasReply) {
|
||||
runOnUiThread(() -> {
|
||||
runOnUiThreadIfActive(() -> {
|
||||
clearMasterAgentReplyState();
|
||||
composerSending = false;
|
||||
updateComposerSendButtonState();
|
||||
@@ -2596,7 +2801,11 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
Thread.sleep(REPLY_WAIT_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
runOnUiThread(() -> {
|
||||
if (shouldSkipAsyncUiWork() || Thread.currentThread().isInterrupted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
runOnUiThreadIfActive(() -> {
|
||||
if (isMasterAgentConversation()) {
|
||||
masterAgentReplyWaiting = false;
|
||||
masterAgentReplyTimedOut = true;
|
||||
@@ -2604,11 +2813,16 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
composerSending = false;
|
||||
updateComposerSendButtonState();
|
||||
setRefreshing(false);
|
||||
showMessage("主 Agent 回复超时,可重试等待最新回复。");
|
||||
showMessage(buildReplyWaitTimeoutMessage());
|
||||
reload(false);
|
||||
});
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
if (shouldSkipAsyncUiWork() || Thread.currentThread().isInterrupted()) {
|
||||
return;
|
||||
}
|
||||
runOnUiThreadIfActive(() -> {
|
||||
if (isMasterAgentConversation()) {
|
||||
masterAgentReplyWaiting = false;
|
||||
masterAgentReplyTimedOut = true;
|
||||
@@ -2616,7 +2830,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
composerSending = false;
|
||||
updateComposerSendButtonState();
|
||||
setRefreshing(false);
|
||||
showMessage("等待回复失败:" + error.getMessage());
|
||||
showMessage(buildReplyWaitFailureMessage(error));
|
||||
reload(false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -168,6 +168,25 @@ public class ProjectGoalsActivity extends BossScreenActivity {
|
||||
""
|
||||
));
|
||||
|
||||
JSONObject understanding = project.optJSONObject("projectUnderstanding");
|
||||
if (understanding != null) {
|
||||
String projectGoal = understanding.optString("projectGoal").trim();
|
||||
String currentProgress = understanding.optString("currentProgress").trim();
|
||||
String recommendedNextStep = understanding.optString("recommendedNextStep").trim();
|
||||
if (!projectGoal.isEmpty() || !currentProgress.isEmpty() || !recommendedNextStep.isEmpty()) {
|
||||
StringBuilder summary = new StringBuilder();
|
||||
appendSummaryLine(summary, "项目目标", projectGoal);
|
||||
appendSummaryLine(summary, "当前进度", currentProgress);
|
||||
appendSummaryLine(summary, "建议下一步", recommendedNextStep);
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"同步项目摘要",
|
||||
summary.toString().trim(),
|
||||
understanding.optString("updatedAt", "")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (goals == null || goals.length() == 0) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前项目还没有目标。点击右上角新增即可。"));
|
||||
} else {
|
||||
@@ -187,6 +206,16 @@ public class ProjectGoalsActivity extends BossScreenActivity {
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void appendSummaryLine(StringBuilder builder, String label, String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append('\n');
|
||||
}
|
||||
builder.append(label).append(":").append(value.trim());
|
||||
}
|
||||
|
||||
private LinearLayout buildGoalChecklistCard(JSONObject goal) {
|
||||
LinearLayout card = BossUi.buildCard(this, "", "", "");
|
||||
card.removeAllViews();
|
||||
|
||||
@@ -13,7 +13,7 @@ import java.util.Map;
|
||||
public class ProjectVersionsActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
private static final String GOAL_REFRESH_NOTE = "project_goals.updated";
|
||||
private static final String VERSION_REFRESH_NOTE = "project_versions.updated";
|
||||
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
|
||||
|
||||
private String projectId;
|
||||
@@ -24,7 +24,7 @@ public class ProjectVersionsActivity extends BossScreenActivity {
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
|
||||
configureScreen("版本迭代记录", getIntent().getStringExtra(EXTRA_PROJECT_NAME));
|
||||
configureScreen("版本记录", getIntent().getStringExtra(EXTRA_PROJECT_NAME));
|
||||
setHeaderAction("只读", v -> showMessage("版本记录只读"));
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
reload();
|
||||
@@ -111,7 +111,7 @@ public class ProjectVersionsActivity extends BossScreenActivity {
|
||||
return false;
|
||||
}
|
||||
String payloadNote = event.payload.optString("note", "").trim();
|
||||
return payloadProjectId.equals(projectId) && GOAL_REFRESH_NOTE.equals(payloadNote);
|
||||
return payloadProjectId.equals(projectId) && VERSION_REFRESH_NOTE.equals(payloadNote);
|
||||
}
|
||||
|
||||
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
|
||||
|
||||
@@ -85,37 +85,48 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/project_chat_scroll"
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="ifContentScrolls">
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_quick_actions_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_bg_app"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="20dp">
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_quick_actions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:orientation="horizontal" />
|
||||
</LinearLayout>
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/project_chat_scroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="ifContentScrolls">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="0dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="20dp" />
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
<LinearLayout
|
||||
|
||||
@@ -37,8 +37,10 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="标题"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="22sp"
|
||||
@@ -46,9 +48,11 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="副标题"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="12sp" />
|
||||
@@ -95,6 +99,8 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_panel"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="24dp" />
|
||||
</ScrollView>
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
<item name="android:windowBackground">@color/boss_bg_app</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:windowBackground">@color/boss_bg_app</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -10,7 +12,6 @@ import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.EditText;
|
||||
import android.widget.Spinner;
|
||||
import android.widget.SpinnerAdapter;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
@@ -35,6 +36,7 @@ import java.net.HttpURLConnection;
|
||||
import java.net.ProtocolException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -46,78 +48,15 @@ import java.util.concurrent.TimeUnit;
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class AiAccountsActivityTest {
|
||||
@Test
|
||||
public void submitOpenAiOnboarding_reportsExplicitPrimaryControllerSuccessAndRefreshesSummary() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(
|
||||
new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"),
|
||||
200,
|
||||
"{\"ok\":true,\"accountId\":\"acc-1\"}",
|
||||
"{\"ok\":false,\"message\":\"ONBOARD_FAILED\"}"
|
||||
),
|
||||
new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/accounts/acc-1/validate"),
|
||||
200,
|
||||
"{\"ok\":true,\"message\":\"校验通过\"}",
|
||||
"{\"ok\":false,\"message\":\"VALIDATION_FAILED\"}"
|
||||
)
|
||||
));
|
||||
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
|
||||
int initialReloadCount = activity.reloadCount;
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"submitOpenAiOnboarding",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "主 GPT"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "OpenAI 平台账号"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "sk-test"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "sk-test-key")
|
||||
);
|
||||
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
assertEquals("OpenAI 平台账号已登录,并设为当前主控。", ShadowToast.getTextOfLatestToast());
|
||||
assertEquals(initialReloadCount + 1, activity.reloadCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void submitOpenAiOnboarding_showsClearChineseFailurePrefix() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(
|
||||
new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"),
|
||||
403,
|
||||
"{\"ok\":false,\"message\":\"API Key 无效\"}",
|
||||
"{\"ok\":false,\"message\":\"API Key 无效\"}"
|
||||
)
|
||||
));
|
||||
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"submitOpenAiOnboarding",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "主 GPT"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "OpenAI 平台账号"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "sk-test"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "bad-key")
|
||||
);
|
||||
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
assertEquals("OpenAI 平台账号登录失败:API Key 无效", ShadowToast.getTextOfLatestToast());
|
||||
assertEquals(1, activity.reloadCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void activeIdentityCardOffersMainAgentTestEntry() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
JSONObject activeIdentity = new JSONObject()
|
||||
.put("accountId", "acc-1")
|
||||
.put("label", "主 GPT")
|
||||
.put("displayName", "OpenAI 平台账号")
|
||||
.put("roleLabel", "主 GPT")
|
||||
.put("providerLabel", "OpenAI API")
|
||||
.put("label", "主Agent")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("statusLabel", "ready")
|
||||
.put("note", "当前账号可直接生成主 Agent 回复。")
|
||||
.put("canGenerate", true);
|
||||
@@ -140,62 +79,547 @@ public class AiAccountsActivityTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openAliyunQwenOnboardingDialogUsesPresetModelsWithCustomFallback() throws Exception {
|
||||
public void renderAccountsShowsStructuredSectionsAndExpandedEntries() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("activeIdentity", new JSONObject()
|
||||
.put("accountId", "chatgpt-primary")
|
||||
.put("label", "主Agent")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("statusLabel", "ready")
|
||||
.put("canGenerate", true))
|
||||
.put("accounts", new org.json.JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "chatgpt-primary")
|
||||
.put("label", "主Agent")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("provider", "chatgpt_oauth")
|
||||
.put("role", "primary")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", true))
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "hyzq-backup")
|
||||
.put("label", "备用API")
|
||||
.put("displayName", "环宇智擎 备用账号")
|
||||
.put("roleLabel", "备用链路")
|
||||
.put("providerLabel", "环宇智擎")
|
||||
.put("provider", "hyzq_api")
|
||||
.put("role", "backup")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", false)
|
||||
.put("apiKeyConfigured", true)
|
||||
.put("apiBaseUrl", "https://api.hyzq2046.com/v1"))
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "master-node")
|
||||
.put("label", "主Agent")
|
||||
.put("displayName", "绑定电脑上的 Codex 节点")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "主Agent 节点")
|
||||
.put("provider", "master_codex_node")
|
||||
.put("role", "primary")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", false)));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
assertNotNull(root);
|
||||
assertTrue(viewTreeContainsText(root, "主要API配置"));
|
||||
assertTrue(viewTreeContainsText(root, "备用API配置"));
|
||||
assertFalse(viewTreeContainsText(root, "OAuth 登录"));
|
||||
assertFalse(viewTreeContainsText(root, "API 接入"));
|
||||
assertFalse(viewTreeContainsText(root, "谷歌登录"));
|
||||
assertFalse(viewTreeContainsText(root, "ChatGPT登录"));
|
||||
assertFalse(viewTreeContainsText(root, "阿里"));
|
||||
assertFalse(viewTreeContainsText(root, "Minimax"));
|
||||
assertFalse(viewTreeContainsText(root, "GLM"));
|
||||
assertFalse(viewTreeContainsText(root, "环宇智擎"));
|
||||
assertFalse(viewTreeContainsText(root, "自定义"));
|
||||
assertFalse(viewTreeContainsText(root, "绑定设备节点"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tappingPrimaryConfigEntryOpensPrimaryDetailPage() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "openAliyunQwenOnboardingDialog");
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray()))
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
View entry = findClickableViewContainingText(root, "主要API配置");
|
||||
assertNotNull(entry);
|
||||
entry.performClick();
|
||||
|
||||
ShadowActivity shadowActivity = Shadows.shadowOf(activity);
|
||||
Intent nextIntent = shadowActivity.getNextStartedActivity();
|
||||
assertNotNull(nextIntent);
|
||||
assertEquals(AiAccountsActivity.class.getName(), nextIntent.getComponent().getClassName());
|
||||
assertEquals("primary", nextIntent.getStringExtra("ai_accounts_role"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void detailPageShowsOnlySelectedRoleConfiguration() throws Exception {
|
||||
Intent intent = new Intent(
|
||||
org.robolectric.RuntimeEnvironment.getApplication(),
|
||||
TestAiAccountsActivity.class
|
||||
);
|
||||
intent.putExtra("ai_accounts_role", "primary");
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("accounts", new org.json.JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "chatgpt-primary")
|
||||
.put("label", "主要API")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("provider", "chatgpt_oauth")
|
||||
.put("role", "primary")
|
||||
.put("model", "gpt-5.4-mini")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", true))
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "hyzq-primary")
|
||||
.put("label", "主要API")
|
||||
.put("displayName", "环宇智擎 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "环宇智擎")
|
||||
.put("provider", "hyzq_api")
|
||||
.put("role", "primary")
|
||||
.put("model", "gpt-5.4")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", false)
|
||||
.put("apiKeyConfigured", true)
|
||||
.put("apiBaseUrl", "https://api.hyzq2046.com/v1")));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
assertNotNull(root);
|
||||
assertTrue(viewTreeContainsText(root, "当前使用方式"));
|
||||
assertTrue(viewTreeContainsText(root, "主Agent模式"));
|
||||
assertTrue(viewTreeContainsText(root, "快速反应模型"));
|
||||
assertTrue(viewTreeContainsText(root, "深度思考模型"));
|
||||
assertTrue(viewTreeContainsText(root, "ChatGPT登录"));
|
||||
assertTrue(viewTreeContainsText(root, "OAuth 登录"));
|
||||
assertTrue(viewTreeContainsText(root, "当前模型:gpt-5.4-mini"));
|
||||
assertTrue(viewTreeContainsText(root, "当前:沿用默认"));
|
||||
assertTrue(viewTreeContainsText(root, "当前:gpt-5.4-mini"));
|
||||
assertTrue(viewTreeContainsText(root, "当前:gpt-5.4"));
|
||||
assertTrue(viewTreeContainsText(root, "API 接入"));
|
||||
assertTrue(viewTreeContainsText(root, "已配置:ChatGPT登录"));
|
||||
assertTrue(viewTreeContainsText(root, "已配置:环宇智擎"));
|
||||
assertFalse(viewTreeContainsText(root, "谷歌登录"));
|
||||
assertFalse(viewTreeContainsText(root, "阿里"));
|
||||
assertFalse(viewTreeContainsText(root, "Minimax"));
|
||||
assertFalse(viewTreeContainsText(root, "GLM"));
|
||||
assertFalse(viewTreeContainsText(root, "自定义"));
|
||||
assertFalse(viewTreeContainsText(root, "可编辑配置"));
|
||||
assertFalse(viewTreeContainsText(root, "当前已保存"));
|
||||
assertFalse(viewTreeContainsText(root, "只读状态"));
|
||||
assertFalse(viewTreeContainsText(root, "备用API配置"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void currentMethodEntryOpensCurrentAccountEditor() throws Exception {
|
||||
Intent intent = new Intent(
|
||||
org.robolectric.RuntimeEnvironment.getApplication(),
|
||||
TestAiAccountsActivity.class
|
||||
);
|
||||
intent.putExtra("ai_accounts_role", "primary");
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
|
||||
ReflectionHelpers.setField(activity, "currentMasterAgentModelOverride", "gpt-5.4-mini");
|
||||
ReflectionHelpers.setField(activity, "currentMasterAgentReasoningEffortOverride", "low");
|
||||
ReflectionHelpers.setField(activity, "currentFastModelOverride", "gpt-5.4-mini");
|
||||
ReflectionHelpers.setField(activity, "currentDeepModelOverride", "gpt-5.4");
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("accounts", new org.json.JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "chatgpt-primary")
|
||||
.put("label", "主要API")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("provider", "chatgpt_oauth")
|
||||
.put("role", "primary")
|
||||
.put("model", "gpt-5.4-mini")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", true)));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
View entry = findClickableViewContainingText(root, "当前使用方式");
|
||||
assertNotNull(entry);
|
||||
entry.performClick();
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
|
||||
View root = dialog.getWindow().getDecorView();
|
||||
Spinner modelSpinner = findSpinnerContainingItem(root, "qwen3.5-plus");
|
||||
assertNotNull(modelSpinner);
|
||||
SpinnerAdapter adapter = modelSpinner.getAdapter();
|
||||
assertNotNull(adapter);
|
||||
assertEquals(3, adapter.getCount());
|
||||
assertEquals("qwen3.5-plus", adapter.getItem(0).toString());
|
||||
assertEquals("qwen3.5-flash", adapter.getItem(1).toString());
|
||||
assertEquals("自定义模型", adapter.getItem(2).toString());
|
||||
assertEquals("qwen3.5-plus", modelSpinner.getSelectedItem().toString());
|
||||
|
||||
EditText customModelInput = findEditTextWithHint(root, "自定义模型");
|
||||
assertNotNull(customModelInput);
|
||||
View dialogRoot = dialog.getWindow().getDecorView();
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "账号快捷登录"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "选择模型"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openAccountEditorShowsCustomFallbackForNonPresetAliyunModel() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
JSONObject existing = new JSONObject()
|
||||
.put("accountId", "acc-1")
|
||||
.put("label", "备用 GPT")
|
||||
.put("displayName", "阿里百炼备用账号")
|
||||
.put("provider", "aliyun_qwen_api")
|
||||
.put("model", "qwen-custom-x");
|
||||
public void fastModeEntryOpensDedicatedModelPicker() throws Exception {
|
||||
Intent intent = new Intent(
|
||||
org.robolectric.RuntimeEnvironment.getApplication(),
|
||||
TestAiAccountsActivity.class
|
||||
);
|
||||
intent.putExtra("ai_accounts_role", "primary");
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
|
||||
ReflectionHelpers.setField(activity, "currentFastModelOverride", "gpt-4.1");
|
||||
ReflectionHelpers.setField(activity, "currentDeepModelOverride", "gpt-5.4");
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"openAccountEditor",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, existing),
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject()
|
||||
.put("accounts", new org.json.JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "chatgpt-primary")
|
||||
.put("label", "主要API")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("provider", "chatgpt_oauth")
|
||||
.put("role", "primary")
|
||||
.put("model", "gpt-5.4-mini")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", true))))
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
View entry = findClickableViewContainingText(root, "快速反应模型");
|
||||
assertNotNull(entry);
|
||||
entry.performClick();
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
View dialogRoot = dialog.getWindow().getDecorView();
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "快速反应模型"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "gpt-4.1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tappingOauthEntryShowsOauthProviderChooser() throws Exception {
|
||||
Intent intent = new Intent(
|
||||
org.robolectric.RuntimeEnvironment.getApplication(),
|
||||
TestAiAccountsActivity.class
|
||||
);
|
||||
intent.putExtra("ai_accounts_role", "primary");
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray()))
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
View entry = findClickableViewContainingText(root, "OAuth 登录");
|
||||
assertNotNull(entry);
|
||||
entry.performClick();
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
View dialogRoot = dialog.getWindow().getDecorView();
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "谷歌登录"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "ChatGPT登录"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tappingApiEntryShowsApiProviderChooser() throws Exception {
|
||||
Intent intent = new Intent(
|
||||
org.robolectric.RuntimeEnvironment.getApplication(),
|
||||
TestAiAccountsActivity.class
|
||||
);
|
||||
intent.putExtra("ai_accounts_role", "primary");
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray()))
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
View entry = findClickableViewContainingText(root, "API 接入");
|
||||
assertNotNull(entry);
|
||||
entry.performClick();
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
View dialogRoot = dialog.getWindow().getDecorView();
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "阿里"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "Minimax"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "GLM"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "环宇智擎"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "自定义"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void defaultApiBaseUrlForProviderSupportsExpandedApiProviders() {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
|
||||
String openai = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"defaultApiBaseUrlForProvider",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "openai_api")
|
||||
);
|
||||
String aliyun = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"defaultApiBaseUrlForProvider",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "aliyun_qwen_api")
|
||||
);
|
||||
String minimax = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"defaultApiBaseUrlForProvider",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "minimax_api")
|
||||
);
|
||||
String glm = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"defaultApiBaseUrlForProvider",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "glm_api")
|
||||
);
|
||||
String hyzq = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"defaultApiBaseUrlForProvider",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "hyzq_api")
|
||||
);
|
||||
String custom = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"defaultApiBaseUrlForProvider",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "custom_api")
|
||||
);
|
||||
|
||||
assertEquals("https://api.openai.com/v1", openai);
|
||||
assertEquals("https://dashscope.aliyuncs.com/compatible-mode/v1", aliyun);
|
||||
assertEquals("https://api.minimaxi.com/v1", minimax);
|
||||
assertEquals("https://open.bigmodel.cn/api/paas/v4", glm);
|
||||
assertEquals("https://api.hyzq2046.com/v1", hyzq);
|
||||
assertEquals("", custom);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openOauthAccountDialogShowsLoginAction() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"openOauthAccountDialog",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "primary"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "google_oauth"),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
|
||||
);
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
View root = dialog.getWindow().getDecorView();
|
||||
assertTrue(viewTreeContainsText(root, "账号快捷登录"));
|
||||
assertTrue(viewTreeContainsText(root, "谷歌登录"));
|
||||
Spinner modelSpinner = findSpinner(root);
|
||||
assertNotNull(modelSpinner);
|
||||
assertFalse(modelSpinner.isEnabled());
|
||||
assertFalse(modelSpinner.isClickable());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openOauthAccountDialogEnablesModelSelectionWhenAccountIsReady() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
JSONObject existing = new JSONObject()
|
||||
.put("label", "主要API")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("accountIdentifier", "kris@example.com")
|
||||
.put("model", "gpt-5.4")
|
||||
.put("loginStatusNote", "已登录")
|
||||
.put("enabled", true)
|
||||
.put("isActive", true)
|
||||
.put("status", "ready")
|
||||
.put("statusLabel", "ready");
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"openOauthAccountDialog",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "primary"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "chatgpt_oauth"),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, existing)
|
||||
);
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
View root = dialog.getWindow().getDecorView();
|
||||
Spinner modelSpinner = findSpinner(root);
|
||||
assertNotNull(modelSpinner);
|
||||
assertTrue(modelSpinner.isEnabled());
|
||||
assertTrue(modelSpinner.isClickable());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openApiAccountDialogLocksModelSelectionBeforeValidation() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"openApiAccountDialog",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "backup"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "hyzq_api"),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, null)
|
||||
);
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
|
||||
View root = dialog.getWindow().getDecorView();
|
||||
Spinner modelSpinner = findSpinnerContainingItem(root, "自定义模型");
|
||||
assertNotNull(findEditTextWithHint(root, "账号标识 / 备注"));
|
||||
assertNotNull(findEditTextWithHint(root, "API Key"));
|
||||
Spinner modelSpinner = findSpinner(root);
|
||||
assertNotNull(modelSpinner);
|
||||
SpinnerAdapter adapter = modelSpinner.getAdapter();
|
||||
assertNotNull(adapter);
|
||||
assertEquals(3, adapter.getCount());
|
||||
assertEquals("自定义模型", modelSpinner.getSelectedItem().toString());
|
||||
assertFalse(modelSpinner.isEnabled());
|
||||
assertEquals(0, ((android.widget.ArrayAdapter<?>) modelSpinner.getAdapter()).getCount());
|
||||
}
|
||||
|
||||
EditText customModelInput = findEditTextWithHint(root, "自定义模型");
|
||||
assertNotNull(customModelInput);
|
||||
assertEquals("qwen-custom-x", customModelInput.getText().toString());
|
||||
@Test
|
||||
public void applyDraftValidatedModelsEnablesModelSelection() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
Spinner spinner = new Spinner(activity);
|
||||
android.widget.ArrayAdapter<String> adapter = new android.widget.ArrayAdapter<>(
|
||||
activity,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
new ArrayList<>()
|
||||
);
|
||||
spinner.setAdapter(adapter);
|
||||
spinner.setEnabled(false);
|
||||
org.json.JSONArray models = new org.json.JSONArray().put("gpt-5.4-mini").put("gpt-5.4");
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"applyValidatedApiModels",
|
||||
ReflectionHelpers.ClassParameter.from(Spinner.class, spinner),
|
||||
ReflectionHelpers.ClassParameter.from(android.widget.ArrayAdapter.class, adapter),
|
||||
ReflectionHelpers.ClassParameter.from(org.json.JSONArray.class, models),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4")
|
||||
);
|
||||
|
||||
assertTrue(spinner.isEnabled());
|
||||
assertEquals(2, adapter.getCount());
|
||||
assertEquals("gpt-5.4", spinner.getSelectedItem());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void saveExpandedApiProviderUsesGenericCreateFlowAndAutoFillsBaseUrl() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
RecordingConnection createConnection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/accounts"),
|
||||
200,
|
||||
"{\"ok\":true,\"accountId\":\"acc-1\"}",
|
||||
"{\"ok\":false,\"message\":\"SAVE_FAILED\"}"
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(createConnection));
|
||||
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
|
||||
int initialReloadCount = activity.reloadCount;
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"saveAccount",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "备用API"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "环宇智擎备用账号"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "fallback@example.com"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, ""),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, ""),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, ""),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "hyzq-secret"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "待校验"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, true),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "backup"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "hyzq_api")
|
||||
);
|
||||
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
assertEquals("AI 账号已新增", ShadowToast.getTextOfLatestToast());
|
||||
assertEquals(initialReloadCount + 1, activity.reloadCount);
|
||||
|
||||
JSONObject requestJson = new JSONObject(createConnection.getCapturedRequestBody());
|
||||
assertEquals("hyzq_api", requestJson.getString("provider"));
|
||||
assertEquals("backup", requestJson.getString("role"));
|
||||
assertEquals("https://api.hyzq2046.com/v1", requestJson.getString("apiBaseUrl"));
|
||||
assertEquals("hyzq-secret", requestJson.getString("apiKey"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void saveOauthAccountUsesGenericCreateFlow() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
RecordingConnection createConnection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/accounts"),
|
||||
200,
|
||||
"{\"ok\":true,\"accountId\":\"acc-2\"}",
|
||||
"{\"ok\":false,\"message\":\"SAVE_FAILED\"}"
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(createConnection));
|
||||
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"saveAccount",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "主Agent"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "ChatGPT OAuth 主链路账号"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "kris@example.com"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, ""),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, ""),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, ""),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, ""),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "待网页登录"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, true),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, true),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "primary"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "chatgpt_oauth")
|
||||
);
|
||||
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
assertEquals("AI 账号已新增", ShadowToast.getTextOfLatestToast());
|
||||
JSONObject requestJson = new JSONObject(createConnection.getCapturedRequestBody());
|
||||
assertEquals("chatgpt_oauth", requestJson.getString("provider"));
|
||||
assertEquals("primary", requestJson.getString("role"));
|
||||
assertEquals("待网页登录", requestJson.getString("loginStatusNote"));
|
||||
assertEquals("", requestJson.getString("apiBaseUrl"));
|
||||
}
|
||||
|
||||
private static final class TestAiAccountsActivity extends AiAccountsActivity {
|
||||
@@ -279,8 +703,6 @@ public class AiAccountsActivityTest {
|
||||
private final int responseCodeValue;
|
||||
private final String responseBody;
|
||||
private final String errorBody;
|
||||
private String requestMethodValue = "GET";
|
||||
private String contentTypeValue = "";
|
||||
|
||||
RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) {
|
||||
super(url);
|
||||
@@ -301,16 +723,11 @@ public class AiAccountsActivityTest {
|
||||
public void connect() {}
|
||||
|
||||
@Override
|
||||
public void setRequestMethod(String method) throws ProtocolException {
|
||||
requestMethodValue = method;
|
||||
}
|
||||
public void setRequestMethod(String method) throws ProtocolException {}
|
||||
|
||||
@Override
|
||||
public void setRequestProperty(String key, String value) {
|
||||
requestHeaders.put(key, value);
|
||||
if ("Content-Type".equalsIgnoreCase(key)) {
|
||||
contentTypeValue = value;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -337,6 +754,10 @@ public class AiAccountsActivityTest {
|
||||
public Map<String, List<String>> getHeaderFields() {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
String getCapturedRequestBody() {
|
||||
return requestBody.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class InMemorySharedPreferences implements SharedPreferences {
|
||||
@@ -484,32 +905,6 @@ public class AiAccountsActivityTest {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Spinner findSpinnerContainingItem(View root, String expectedText) {
|
||||
if (root instanceof Spinner) {
|
||||
Spinner spinner = (Spinner) root;
|
||||
SpinnerAdapter adapter = spinner.getAdapter();
|
||||
if (adapter != null) {
|
||||
for (int index = 0; index < adapter.getCount(); index += 1) {
|
||||
Object item = adapter.getItem(index);
|
||||
if (item != null && item.toString().contains(expectedText)) {
|
||||
return spinner;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return null;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
Spinner match = findSpinnerContainingItem(group.getChildAt(index), expectedText);
|
||||
if (match != null) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static EditText findEditTextWithHint(View root, String expectedText) {
|
||||
if (root instanceof EditText) {
|
||||
CharSequence hint = ((EditText) root).getHint();
|
||||
@@ -529,4 +924,41 @@ public class AiAccountsActivityTest {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static EditText findEditTextWithText(View root, String expectedText) {
|
||||
if (root instanceof EditText) {
|
||||
CharSequence text = ((EditText) root).getText();
|
||||
if (text != null && text.toString().contains(expectedText)) {
|
||||
return (EditText) root;
|
||||
}
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return null;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
EditText match = findEditTextWithText(group.getChildAt(index), expectedText);
|
||||
if (match != null) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Spinner findSpinner(View root) {
|
||||
if (root instanceof Spinner) {
|
||||
return (Spinner) root;
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return null;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
Spinner match = findSpinner(group.getChildAt(index));
|
||||
if (match != null) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,27 @@ public class BossApiClientDispatchPlansTest {
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updateMasterAgentModeModelsWritesFastAndDeepModelMappings() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/agent-controls"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.updateMasterAgentModeModels(
|
||||
"gpt-4.1",
|
||||
"gpt-5.1",
|
||||
"gpt-4.1",
|
||||
"low"
|
||||
);
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/master-agent/agent-controls", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals(
|
||||
"{\"fastModelOverride\":\"gpt-4.1\",\"deepModelOverride\":\"gpt-5.1\",\"modelOverride\":\"gpt-4.1\",\"reasoningEffortOverride\":\"low\"}",
|
||||
connection.requestBody()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getMasterAgentPromptProfileUsesScopedEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/prompt-profile"));
|
||||
|
||||
@@ -60,4 +60,32 @@ public class BossMarkdownTest {
|
||||
|
||||
assertEquals("(空消息)", rendered.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void render_normalizesColonSectionsIntoReadableBlocks() {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
|
||||
CharSequence rendered = BossMarkdown.render(
|
||||
context,
|
||||
"项目目标:完成 Boss 真机回归\n" +
|
||||
"当前进度:已完成 UI 调整\n" +
|
||||
"下一步:推送到 Gitea",
|
||||
false
|
||||
);
|
||||
|
||||
assertTrue(rendered instanceof Spanned);
|
||||
Spanned spanned = (Spanned) rendered;
|
||||
String text = spanned.toString();
|
||||
|
||||
assertTrue(text.contains("项目目标"));
|
||||
assertTrue(text.contains("完成 Boss 真机回归"));
|
||||
assertTrue(text.indexOf("项目目标") < text.indexOf("完成 Boss 真机回归"));
|
||||
assertTrue(text.contains("当前进度"));
|
||||
assertTrue(text.contains("已完成 UI 调整"));
|
||||
assertTrue(text.indexOf("当前进度") < text.indexOf("已完成 UI 调整"));
|
||||
assertTrue(text.contains("下一步"));
|
||||
assertTrue(text.contains("推送到 Gitea"));
|
||||
assertTrue(text.indexOf("下一步") < text.indexOf("推送到 Gitea"));
|
||||
assertTrue(text.contains("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.io.IOException;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class BossRealtimeClientTest {
|
||||
@@ -37,4 +42,10 @@ public class BossRealtimeClientTest {
|
||||
public void parseEventBlockReturnsNullForEmptyEventPayloads() {
|
||||
assertNull(BossRealtimeClient.parseEventBlock("event: conversation.updated\n\n"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void socketTimeoutReconnectsImmediately() {
|
||||
assertTrue(BossRealtimeClient.shouldReconnectImmediately(new SocketTimeoutException("timeout")));
|
||||
assertFalse(BossRealtimeClient.shouldReconnectImmediately(new IOException("boom")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertSame;
|
||||
|
||||
import android.content.Context;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.RuntimeEnvironment;
|
||||
import org.robolectric.annotation.Config;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class BossUiFormCellTest {
|
||||
@Test
|
||||
public void buildFormCell_detachesFieldFromPreviousParentBeforeReusingIt() {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
EditText field = new EditText(context);
|
||||
|
||||
BossUi.buildFormCell(context, "模型", "第一次渲染", field);
|
||||
LinearLayout secondCell = BossUi.buildFormCell(context, "模型", "刷新后重建", field);
|
||||
|
||||
assertSame(secondCell, field.getParent());
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.view.View;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.EditText;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.LinearLayout;
|
||||
@@ -21,6 +23,8 @@ import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.shadow.api.Shadow;
|
||||
import org.robolectric.shadows.ShadowInputMethodManager;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@@ -124,7 +128,58 @@ public class MainActivityConversationSearchTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void searchHitInsideArchivedProject_keepsProjectContextAndOpensFolderPage() throws Exception {
|
||||
public void searchMode_showsSoftKeyboardWhenActivated() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
ImageButton searchButton = activity.findViewById(R.id.search_button);
|
||||
searchButton.performClick();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
EditText searchInput = activity.findViewById(R.id.top_search_input);
|
||||
InputMethodManager inputMethodManager = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
ShadowInputMethodManager shadowInputMethodManager = Shadow.extract(inputMethodManager);
|
||||
|
||||
assertTrue(searchInput.isFocused());
|
||||
assertTrue(shadowInputMethodManager.isSoftInputVisible());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void searchHitOnSingleThread_exitsSearchModeAndOpensProjectDetail() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
ImageButton searchButton = activity.findViewById(R.id.search_button);
|
||||
searchButton.performClick();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
EditText searchInput = activity.findViewById(R.id.top_search_input);
|
||||
searchInput.setText("树莓派");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
|
||||
View row = getRecyclerChild(list, 0);
|
||||
row.performClick();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
|
||||
InputMethodManager inputMethodManager = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
ShadowInputMethodManager shadowInputMethodManager = Shadow.extract(inputMethodManager);
|
||||
|
||||
assertEquals(ProjectDetailActivity.class.getName(), nextIntent.getComponent().getClassName());
|
||||
assertEquals("p1", nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_ID));
|
||||
assertFalse(ReflectionHelpers.getField(activity, "conversationSearchMode"));
|
||||
assertEquals("", searchInput.getText().toString());
|
||||
assertFalse(shadowInputMethodManager.isSoftInputVisible());
|
||||
assertFalse(activity.isFinishing());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void searchHitInsideArchivedProject_opensMatchedThreadDetailAndClearsSearchState() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "conversationsData", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
@@ -155,14 +210,50 @@ public class MainActivityConversationSearchTest {
|
||||
row.performClick();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
|
||||
assertEquals(ProjectDetailActivity.class.getName(), nextIntent.getComponent().getClassName());
|
||||
assertEquals("thread-revert-1", nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_ID));
|
||||
assertEquals("发布回滚", nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME));
|
||||
assertFalse(ReflectionHelpers.getField(activity, "conversationSearchMode"));
|
||||
assertEquals("", searchInput.getText().toString());
|
||||
assertFalse(activity.isFinishing());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void archivedProjectSearchByFolderName_stillOpensFolderPage() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "conversationsData", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("projectId", "folder-boss")
|
||||
.put("conversationType", "folder_archive")
|
||||
.put("folderKey", "mac-studio:boss")
|
||||
.put("folderLabel", "Boss")
|
||||
.put("projectTitle", "Boss")
|
||||
.put("threadTitle", "Boss")
|
||||
.put("lastMessagePreview", "最近:发布回滚")
|
||||
.put("latestReplyLabel", "11:00")
|
||||
.put("searchAliases", new JSONArray().put("发布回滚"))
|
||||
.put("searchTargetProjectIds", new JSONArray().put("thread-revert-1"))));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "enterConversationSearchMode");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
EditText searchInput = activity.findViewById(R.id.top_search_input);
|
||||
searchInput.setText("Boss");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
|
||||
View row = getRecyclerChild(list, 0);
|
||||
row.performClick();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
|
||||
assertEquals(ConversationFolderActivity.class.getName(), nextIntent.getComponent().getClassName());
|
||||
assertEquals("mac-studio:boss", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY));
|
||||
assertEquals("Boss", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_FOLDER_NAME));
|
||||
assertEquals("thread-revert-1", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_ID));
|
||||
assertEquals(2, nextIntent.getStringArrayExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_IDS).length);
|
||||
assertEquals("thread-revert-2", nextIntent.getStringArrayExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_IDS)[1]);
|
||||
assertEquals("发布回滚", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_LABEL));
|
||||
assertFalse(ReflectionHelpers.getField(activity, "conversationSearchMode"));
|
||||
assertEquals("", searchInput.getText().toString());
|
||||
}
|
||||
|
||||
private static JSONArray buildConversations() throws Exception {
|
||||
|
||||
@@ -260,6 +260,27 @@ public class MainActivityRealtimeTest {
|
||||
assertEquals(0, activity.meRefreshCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void realtimeDisconnectTriggersImmediateConversationFallbackRefresh() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.apply();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeConnectionChanged",
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
assertEquals(0, activity.meRefreshCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshConversationsData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
@@ -51,6 +52,66 @@ public class ProjectDetailActivityMasterAgentMenuTest {
|
||||
assertMenuItem(listView, 6, "刷新");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masterAgentModelOptionsIncludeFastAndDeepChoices() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
String[] options = ReflectionHelpers.callInstanceMethod(activity, "buildMasterAgentModelOptions");
|
||||
|
||||
assertArrayEquals(
|
||||
new String[]{"沿用默认", "gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1", "自定义..."},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masterAgentModelOptionsKeepCurrentCustomChoice() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
ReflectionHelpers.setField(activity, "currentAgentModelOverride", "gpt-4.1-mini");
|
||||
|
||||
String[] options = ReflectionHelpers.callInstanceMethod(activity, "buildMasterAgentModelOptions");
|
||||
|
||||
assertArrayEquals(
|
||||
new String[]{"沿用默认", "gpt-4.1-mini", "gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1", "自定义..."},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masterAgentModelPickerShowsFastAndDeepModes() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showMasterAgentModelPicker");
|
||||
|
||||
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
|
||||
assertTrue(latestDialog instanceof AlertDialog);
|
||||
AlertDialog actionDialog = (AlertDialog) latestDialog;
|
||||
ListView listView = actionDialog.getListView();
|
||||
|
||||
assertMenuItem(listView, 0, "沿用默认");
|
||||
assertMenuItem(listView, 1, "快速反应(gpt-5.4-mini)");
|
||||
assertMenuItem(listView, 2, "深度思考(gpt-5.4)");
|
||||
assertMenuItem(listView, 3, "更多模型...");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void normalConversationMoreMenuShowsInfoAndRefresh() {
|
||||
Intent intent = new Intent()
|
||||
|
||||
@@ -251,6 +251,54 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
assertEquals(2, activity.renderCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void realtimeDisconnectTriggersImmediateConversationFallbackReload() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
|
||||
TestRealtimeProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.resume()
|
||||
.get();
|
||||
activity.getSharedPreferences("boss_native_client", android.content.Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.apply();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeConnectionChanged",
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
assertEquals(1, activity.reloadCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reloadSnapshotAfterDestroyDoesNotCrashWhenExecutorsAreShutdown() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
|
||||
TestRealtimeProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.resume()
|
||||
.pause()
|
||||
.destroy()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"reloadSnapshot",
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
|
||||
assertEquals(0, activity.loadCallCount);
|
||||
}
|
||||
|
||||
private static void waitFor(BooleanSupplier condition) throws Exception {
|
||||
long deadlineAt = System.currentTimeMillis() + 2_000L;
|
||||
while (System.currentTimeMillis() < deadlineAt) {
|
||||
|
||||
@@ -2,8 +2,9 @@ package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -14,6 +15,7 @@ import android.widget.Button;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ListView;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import org.json.JSONArray;
|
||||
@@ -23,6 +25,7 @@ import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.shadows.ShadowDialog;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@@ -30,6 +33,7 @@ import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
@@ -87,6 +91,91 @@ public class ProjectDetailActivityUiTest {
|
||||
assertEquals(View.GONE, refreshButton.getVisibility());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void composerFocus_scrollsChatToBottomToKeepLatestMessageVisible() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
View composerInput = activity.findViewById(R.id.project_chat_input);
|
||||
composerInput.requestFocus();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
assertTrue(composerInput.isFocused());
|
||||
assertTrue(activity.scrollChatToBottomCount > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void quickActionsStayOutsideScrollableMessageContainer() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
LinearLayout quickActions = activity.findViewById(R.id.project_chat_quick_actions);
|
||||
ScrollView chatScrollView = activity.findViewById(R.id.project_chat_scroll);
|
||||
LinearLayout contentLayout = activity.findViewById(R.id.screen_content);
|
||||
|
||||
assertNotNull(quickActions);
|
||||
assertNotNull(chatScrollView);
|
||||
assertNotNull(contentLayout);
|
||||
assertEquals(R.id.project_chat_quick_actions_container, ((View) quickActions.getParent()).getId());
|
||||
assertEquals(View.NO_ID, ((View) chatScrollView.getParent()).getId());
|
||||
assertEquals(R.id.project_chat_scroll, ((View) contentLayout.getParent()).getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void composerRowLayoutChangeWithFocusedInput_scrollsChatToBottomAgain() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
View composerInput = activity.findViewById(R.id.project_chat_input);
|
||||
View composerRow = activity.findViewById(R.id.project_chat_composer_row);
|
||||
|
||||
composerRow.layout(0, 0, 1080, 120);
|
||||
composerInput.requestFocus();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
int baselineScrollCount = activity.scrollChatToBottomCount;
|
||||
|
||||
composerRow.layout(0, 0, 1080, 220);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
assertTrue(activity.scrollChatToBottomCount > baselineScrollCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void composerRowLayoutChangeWithoutFocusedInput_doesNotScrollChatToBottom() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
View composerRow = activity.findViewById(R.id.project_chat_composer_row);
|
||||
composerRow.layout(0, 0, 1080, 120);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
int baselineScrollCount = activity.scrollChatToBottomCount;
|
||||
|
||||
composerRow.layout(0, 0, 1080, 220);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
assertEquals(baselineScrollCount, activity.scrollChatToBottomCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void manualAnalysisAttachmentShowsActionChip() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
@@ -233,6 +322,77 @@ public class ProjectDetailActivityUiTest {
|
||||
assertFalse(viewTreeContainsText(messageView, "Boss 超级管理员 · 10:26"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void completedReplyResponseRendersImmediatelyWithoutReloadingProjectDetail() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
JSONObject initialPayload = new JSONObject()
|
||||
.put("project", new JSONObject()
|
||||
.put("id", "master-agent")
|
||||
.put("name", "主 Agent")
|
||||
.put("messages", new JSONArray()));
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderProject",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, initialPayload),
|
||||
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
|
||||
);
|
||||
|
||||
JSONObject userMessage = new JSONObject()
|
||||
.put("id", "msg-user-fast")
|
||||
.put("sender", "user")
|
||||
.put("senderLabel", "Boss 超级管理员")
|
||||
.put("body", "你现在是什么模型")
|
||||
.put("kind", "text")
|
||||
.put("sentAt", "2026-04-17T10:00:00.000Z");
|
||||
JSONObject replyMessage = new JSONObject()
|
||||
.put("id", "msg-master-fast")
|
||||
.put("sender", "master")
|
||||
.put("senderLabel", "主 Agent · gpt-5.4-mini")
|
||||
.put("body", "当前主 Agent 是 gpt-5.4-mini。")
|
||||
.put("kind", "text")
|
||||
.put("sentAt", "2026-04-17T10:00:01.000Z");
|
||||
JSONObject sendResponse = new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("message", userMessage)
|
||||
.put("replyMessage", replyMessage)
|
||||
.put("masterReplyState", "completed")
|
||||
.put("replyPresenter", "master")
|
||||
.put("task", JSONObject.NULL)
|
||||
.put("dispatchPlan", JSONObject.NULL)
|
||||
.put("collaborationGate", new JSONObject()
|
||||
.put("isGroup", false)
|
||||
.put("collaborationMode", "development")
|
||||
.put("approvalState", "not_required"));
|
||||
CompletedReplyApiClient fakeApiClient = new CompletedReplyApiClient(sendResponse);
|
||||
ReflectionHelpers.setField(activity, "apiClient", fakeApiClient);
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"sendProjectMessage",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "text"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "你现在是什么模型")
|
||||
);
|
||||
|
||||
waitForUiCondition(
|
||||
activity,
|
||||
() -> viewTreeContainsText(activity.findViewById(R.id.screen_content), "当前主 Agent 是 gpt-5.4-mini。")
|
||||
|| fakeApiClient.projectDetailCallCount > 0
|
||||
);
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "你现在是什么模型"));
|
||||
assertTrue(viewTreeContainsText(content, "当前主 Agent 是 gpt-5.4-mini。"));
|
||||
assertEquals(0, fakeApiClient.projectDetailCallCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masterAgentHeaderUsesWechatMoreMenuLabel() {
|
||||
Intent intent = new Intent()
|
||||
@@ -275,6 +435,21 @@ public class ProjectDetailActivityUiTest {
|
||||
assertEquals("更多", String.valueOf(headerAction.getContentDescription()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void normalConversationUsesThreadSpecificReplyWaitTimeoutCopy() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss 移动控制台");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
String timeoutMessage = ReflectionHelpers.callInstanceMethod(activity, "buildReplyWaitTimeoutMessage");
|
||||
|
||||
assertEquals("当前线程暂未回流,继续后台等待或稍后刷新查看。", timeoutMessage);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderProjectKeepsMasterAgentWaitingStateVisibleInMessageFlow() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
@@ -703,10 +878,23 @@ public class ProjectDetailActivityUiTest {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void waitForUiCondition(TestProjectDetailActivity activity, BooleanSupplier condition) throws Exception {
|
||||
long deadline = System.currentTimeMillis() + 2_000L;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
if (condition.getAsBoolean()) {
|
||||
return;
|
||||
}
|
||||
Thread.sleep(10L);
|
||||
}
|
||||
fail("condition was not met before timeout");
|
||||
}
|
||||
|
||||
public static class TestProjectDetailActivity extends ProjectDetailActivity {
|
||||
int replyWaitPollCount;
|
||||
String lastReplyWaitBaselineMessageId;
|
||||
boolean lastReplyWaitIncludeDispatchPlans;
|
||||
int scrollChatToBottomCount;
|
||||
|
||||
@Override
|
||||
boolean shouldLoadOnCreate() {
|
||||
@@ -719,6 +907,40 @@ public class ProjectDetailActivityUiTest {
|
||||
lastReplyWaitBaselineMessageId = baselineMessageId;
|
||||
lastReplyWaitIncludeDispatchPlans = includeDispatchPlans;
|
||||
}
|
||||
|
||||
@Override
|
||||
void scrollChatToBottom() {
|
||||
scrollChatToBottomCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CompletedReplyApiClient extends BossApiClient {
|
||||
private final JSONObject sendResponse;
|
||||
int projectDetailCallCount;
|
||||
|
||||
CompletedReplyApiClient(JSONObject sendResponse) {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
this.sendResponse = sendResponse;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse sendProjectMessage(String projectId, String body, String kind) {
|
||||
return new ApiResponse(200, sendResponse);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getProjectDetail(String projectId) throws org.json.JSONException {
|
||||
projectDetailCallCount += 1;
|
||||
return new ApiResponse(
|
||||
200,
|
||||
new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("project", new JSONObject()
|
||||
.put("id", projectId)
|
||||
.put("name", "主 Agent")
|
||||
.put("messages", new JSONArray()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class InMemorySharedPreferences implements SharedPreferences {
|
||||
|
||||
@@ -28,7 +28,7 @@ public class ProjectGoalsActivityUiTest {
|
||||
TestProjectGoalsActivity activity = Robolectric
|
||||
.buildActivity(TestProjectGoalsActivity.class, new Intent()
|
||||
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, "北区试产线回归"))
|
||||
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, "北区试产线回归需要只展示一行避免堆叠"))
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
@@ -38,16 +38,48 @@ public class ProjectGoalsActivityUiTest {
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildProject())
|
||||
);
|
||||
|
||||
activity.configureScreen("项目目标", "北区试产线回归需要只展示一行避免堆叠");
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
TextView subtitle = activity.findViewById(R.id.screen_subtitle);
|
||||
assertTrue(viewTreeContainsText(content, "主 Agent 已整理项目目标 · 已完成 1/3"));
|
||||
assertTrue(viewTreeContainsSubstring(content, "完成北区试产线全链路回归"));
|
||||
assertTrue(viewTreeContainsSubstring(content, "已完成 · 09:12 由主 Agent 复核"));
|
||||
assertTrue(viewTreeContainsText(content, "当前约束"));
|
||||
assertTrue(hasHorizontalContentPadding(content, BossUi.dp(activity, 12)));
|
||||
assertTrue(subtitle.getMaxLines() <= 1);
|
||||
assertTrue(String.valueOf(subtitle.getEllipsize()).contains("END"));
|
||||
assertFalse(viewTreeContainsText(content, "标记完成"));
|
||||
assertFalse(viewTreeContainsText(content, "编辑目标"));
|
||||
assertFalse(((SwipeRefreshLayout) activity.findViewById(R.id.screen_refresh_layout)).isRefreshing());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderGoalsShowsSyncedProjectUnderstandingSummary() throws Exception {
|
||||
TestProjectGoalsActivity activity = Robolectric
|
||||
.buildActivity(TestProjectGoalsActivity.class, new Intent()
|
||||
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, "北区试产线回归需要只展示一行避免堆叠"))
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderGoals",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildProject()
|
||||
.put("projectUnderstanding", new JSONObject()
|
||||
.put("projectGoal", "完成北区试产线与主 Agent 接管回归")
|
||||
.put("currentProgress", "已把最新核对结果同步到项目目标页顶部")
|
||||
.put("recommendedNextStep", "继续完成 Gitea 推送和真机回归")
|
||||
.put("updatedAt", "2026-04-18T10:28:00.000Z")))
|
||||
);
|
||||
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "同步项目摘要"));
|
||||
assertTrue(viewTreeContainsSubstring(content, "完成北区试产线与主 Agent 接管回归"));
|
||||
assertTrue(viewTreeContainsSubstring(content, "已把最新核对结果同步到项目目标页顶部"));
|
||||
assertTrue(viewTreeContainsSubstring(content, "继续完成 Gitea 推送和真机回归"));
|
||||
}
|
||||
|
||||
private static JSONObject buildProject() throws Exception {
|
||||
JSONArray goals = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
@@ -106,6 +138,10 @@ public class ProjectGoalsActivityUiTest {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean hasHorizontalContentPadding(LinearLayout content, int minPaddingPx) {
|
||||
return content.getPaddingLeft() >= minPaddingPx && content.getPaddingRight() >= minPaddingPx;
|
||||
}
|
||||
|
||||
public static class TestProjectGoalsActivity extends ProjectGoalsActivity {
|
||||
@Override
|
||||
protected void reload() {
|
||||
|
||||
@@ -19,7 +19,7 @@ import java.lang.reflect.Method;
|
||||
@Config(sdk = 34)
|
||||
public class ProjectVersionsActivityTest {
|
||||
@Test
|
||||
public void matchingGoalRefreshMarkerTriggersReload() throws Exception {
|
||||
public void matchingVersionRefreshMarkerTriggersReload() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入");
|
||||
@@ -40,7 +40,7 @@ public class ProjectVersionsActivityTest {
|
||||
"conversation.updated",
|
||||
new JSONObject()
|
||||
.put("projectId", "project-1")
|
||||
.put("note", "project_goals.updated")
|
||||
.put("note", "project_versions.updated")
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
@@ -49,7 +49,7 @@ public class ProjectVersionsActivityTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sameProjectNonGoalEventDoesNotTriggerReload() throws Exception {
|
||||
public void sameProjectNonVersionEventDoesNotTriggerReload() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Intent;
|
||||
@@ -28,7 +29,7 @@ public class ProjectVersionsActivityUiTest {
|
||||
TestProjectVersionsActivity activity = Robolectric
|
||||
.buildActivity(TestProjectVersionsActivity.class, new Intent()
|
||||
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "北区试产线回归"))
|
||||
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "北区试产线回归需要只展示一行避免堆叠"))
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
@@ -38,11 +39,18 @@ public class ProjectVersionsActivityUiTest {
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildProject())
|
||||
);
|
||||
|
||||
activity.configureScreen("版本记录", "北区试产线回归需要只展示一行避免堆叠");
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
TextView title = activity.findViewById(R.id.screen_title);
|
||||
TextView subtitle = activity.findViewById(R.id.screen_subtitle);
|
||||
assertEquals("版本记录", String.valueOf(title.getText()));
|
||||
assertTrue(viewTreeContainsText(content, "仅主 Agent 可发布迭代记录"));
|
||||
assertTrue(viewTreeContainsText(content, "v1.2.8 已发布"));
|
||||
assertTrue(viewTreeContainsSubstring(content, "• 优化 OTA 实时提示"));
|
||||
assertTrue(viewTreeContainsText(content, "主 Agent 复核记录"));
|
||||
assertTrue(hasHorizontalContentPadding(content, BossUi.dp(activity, 12)));
|
||||
assertTrue(subtitle.getMaxLines() <= 1);
|
||||
assertTrue(String.valueOf(subtitle.getEllipsize()).contains("END"));
|
||||
assertFalse(viewTreeContainsText(content, "版本记录只读"));
|
||||
assertFalse(((SwipeRefreshLayout) activity.findViewById(R.id.screen_refresh_layout)).isRefreshing());
|
||||
}
|
||||
@@ -98,6 +106,10 @@ public class ProjectVersionsActivityUiTest {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean hasHorizontalContentPadding(LinearLayout content, int minPaddingPx) {
|
||||
return content.getPaddingLeft() >= minPaddingPx && content.getPaddingRight() >= minPaddingPx;
|
||||
}
|
||||
|
||||
public static class TestProjectVersionsActivity extends ProjectVersionsActivity {
|
||||
@Override
|
||||
protected void reload() {
|
||||
|
||||
Reference in New Issue
Block a user