diff --git a/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java b/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java index cebc751..0780df5 100644 --- a/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java @@ -49,13 +49,12 @@ public class ConversationInfoActivity extends BossScreenActivity { setRefreshing(true); executor.execute(() -> { try { - BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(projectId); - if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message()); - BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId); - if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message()); + LoadedConversation loadedConversation = loadConversation(); + BossApiClient.ApiResponse detailResponse = loadedConversation.detailResponse; + BossApiClient.ApiResponse participantsResponse = loadedConversation.participantsResponse; JSONObject threadStatusPayload = null; try { - BossApiClient.ApiResponse threadStatusResponse = apiClient.getThreadStatus(projectId); + BossApiClient.ApiResponse threadStatusResponse = loadedConversation.threadStatusResponse; if (threadStatusResponse.ok()) { threadStatusPayload = threadStatusResponse.json; } @@ -351,7 +350,7 @@ public class ConversationInfoActivity extends BossScreenActivity { setRefreshing(true); executor.execute(() -> { try { - BossApiClient.ApiResponse response = apiClient.updateProjectTakeoverSettings( + BossApiClient.ApiResponse response = saveTakeoverSettingsWithRetry( projectId, enabled, null @@ -373,6 +372,56 @@ public class ConversationInfoActivity extends BossScreenActivity { }); } + private LoadedConversation loadConversation() throws Exception { + BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(projectId); + if (isUnauthorized(detailResponse)) { + BossApiClient.ApiResponse loginResponse = apiClient.autoLogin(); + if (!loginResponse.ok()) { + throw new IllegalStateException(loginResponse.message()); + } + detailResponse = apiClient.getProjectDetail(projectId); + } + if (!detailResponse.ok()) { + throw new IllegalStateException(detailResponse.message()); + } + + BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId); + if (!participantsResponse.ok()) { + throw new IllegalStateException(participantsResponse.message()); + } + + BossApiClient.ApiResponse threadStatusResponse = apiClient.getThreadStatus(projectId); + return new LoadedConversation(detailResponse, participantsResponse, threadStatusResponse); + } + + private BossApiClient.ApiResponse saveTakeoverSettingsWithRetry( + String targetProjectId, + boolean takeoverEnabled, + @Nullable Boolean globalTakeoverEnabled + ) throws Exception { + BossApiClient.ApiResponse response = apiClient.updateProjectTakeoverSettings( + targetProjectId, + takeoverEnabled, + globalTakeoverEnabled + ); + if (!isUnauthorized(response)) { + return response; + } + BossApiClient.ApiResponse loginResponse = apiClient.autoLogin(); + if (!loginResponse.ok()) { + return response; + } + return apiClient.updateProjectTakeoverSettings( + targetProjectId, + takeoverEnabled, + globalTakeoverEnabled + ); + } + + private boolean isUnauthorized(@Nullable BossApiClient.ApiResponse response) { + return response != null && response.statusCode == 401 && "UNAUTHORIZED".equals(response.message()); + } + private String buildSubtitle(@Nullable JSONObject threadMeta, int count) { String folder = threadMeta == null ? "" : threadMeta.optString("folderName", ""); String suffix = count <= 0 ? "暂无参与线程" : count + " 个参与线程"; @@ -410,4 +459,20 @@ public class ConversationInfoActivity extends BossScreenActivity { } return project.optString("id", ""); } + + private static final class LoadedConversation { + private final BossApiClient.ApiResponse detailResponse; + private final BossApiClient.ApiResponse participantsResponse; + private final BossApiClient.ApiResponse threadStatusResponse; + + private LoadedConversation( + BossApiClient.ApiResponse detailResponse, + BossApiClient.ApiResponse participantsResponse, + BossApiClient.ApiResponse threadStatusResponse + ) { + this.detailResponse = detailResponse; + this.participantsResponse = participantsResponse; + this.threadStatusResponse = threadStatusResponse; + } + } } diff --git a/android/app/src/test/java/com/hyzq/boss/ConversationInfoActivityTest.java b/android/app/src/test/java/com/hyzq/boss/ConversationInfoActivityTest.java index a1c0b0d..def9e53 100644 --- a/android/app/src/test/java/com/hyzq/boss/ConversationInfoActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ConversationInfoActivityTest.java @@ -5,6 +5,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; +import android.content.Context; import android.content.Intent; import android.view.View; import android.view.ViewGroup; @@ -25,6 +26,12 @@ import org.robolectric.Shadows; import org.robolectric.annotation.Config; import org.robolectric.shadows.ShadowDialog; import org.robolectric.util.ReflectionHelpers; +import org.robolectric.shadows.ShadowLooper; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.AbstractExecutorService; +import java.util.concurrent.TimeUnit; @RunWith(RobolectricTestRunner.class) @Config(sdk = 34) @@ -164,6 +171,68 @@ public class ConversationInfoActivityTest { assertTrue(viewTreeContainsText(listView.getAdapter().getView(1, null, listView), "刷新")); } + @Test + public void reloadAutoRecoversUnauthorizedAndRendersConversationInfo() { + Intent intent = new Intent() + .putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归"); + TestConversationInfoActivity activity = Robolectric + .buildActivity(TestConversationInfoActivity.class, intent) + .setup() + .get(); + + RecordingBossApiClient apiClient = new RecordingBossApiClient( + activity.getSharedPreferences("conversation-info-auth-test", Context.MODE_PRIVATE), + "https://boss.hyzq.net" + ); + ReflectionHelpers.setField(activity, "apiClient", apiClient); + ReflectionHelpers.setField(activity, "reloadEnabled", true); + ReflectionHelpers.setField(activity, "executor", new DirectExecutorService()); + + activity.reload(); + ShadowLooper.shadowMainLooper().idle(); + + assertEquals(2, apiClient.detailCalls); + assertEquals(1, apiClient.autoLoginCalls); + LinearLayout content = activity.findViewById(R.id.screen_content); + assertFalse(viewTreeContainsTextFragment(content, "会话信息加载失败")); + assertTrue(content.getChildCount() > 0); + } + + @Test + public void saveTakeoverAutoRecoversUnauthorizedAndPersistsSetting() { + Intent intent = new Intent() + .putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归"); + TestConversationInfoActivity activity = Robolectric + .buildActivity(TestConversationInfoActivity.class, intent) + .setup() + .get(); + + RecordingBossApiClient apiClient = new RecordingBossApiClient( + activity.getSharedPreferences("conversation-info-save-auth-test", Context.MODE_PRIVATE), + "https://boss.hyzq.net" + ); + apiClient.failFirstLoad = false; + apiClient.failFirstSave = true; + ReflectionHelpers.setField(activity, "apiClient", apiClient); + ReflectionHelpers.setField(activity, "reloadEnabled", true); + ReflectionHelpers.setField(activity, "executor", new DirectExecutorService()); + + activity.reload(); + ShadowLooper.shadowMainLooper().idle(); + ReflectionHelpers.callInstanceMethod( + activity, + "saveTakeoverSetting", + ReflectionHelpers.ClassParameter.from(boolean.class, false) + ); + ShadowLooper.shadowMainLooper().idle(); + + assertEquals(1, apiClient.updateTakeoverCalls); + assertEquals(1, apiClient.retryUpdateTakeoverCalls); + assertEquals(1, apiClient.autoLoginCalls); + } + private static JSONObject buildDetailPayload() throws Exception { JSONObject threadMeta = new JSONObject() .put("threadId", "thread-7") @@ -271,9 +340,143 @@ public class ConversationInfoActivityTest { } public static class TestConversationInfoActivity extends ConversationInfoActivity { + private boolean reloadEnabled; + @Override protected void reload() { - // Tests render the lightweight info state directly. + if (!reloadEnabled) { + return; + } + super.reload(); + } + } + + private static final class RecordingBossApiClient extends BossApiClient { + private int detailCalls; + private int autoLoginCalls; + private int updateTakeoverCalls; + private int retryUpdateTakeoverCalls; + private boolean failFirstLoad = true; + private boolean failFirstSave; + + RecordingBossApiClient(android.content.SharedPreferences prefs, String baseUrl) { + super(prefs, baseUrl); + } + + @Override + public ApiResponse getProjectDetail(String projectId) throws java.io.IOException, org.json.JSONException { + detailCalls += 1; + if (failFirstLoad && detailCalls == 1) { + return ApiResponse.error( + 401, + new JSONObject() + .put("ok", false) + .put("message", "UNAUTHORIZED") + ); + } + try { + JSONObject payload = buildDetailPayload(); + payload.put("ok", true); + return new ApiResponse(200, payload); + } catch (Exception error) { + throw new java.io.IOException("BUILD_DETAIL_PAYLOAD_FAILED", error); + } + } + + @Override + public ApiResponse getConversationParticipants(String projectId) throws java.io.IOException, org.json.JSONException { + try { + JSONObject payload = buildParticipantsPayload(); + payload.put("ok", true); + return new ApiResponse(200, payload); + } catch (Exception error) { + throw new java.io.IOException("BUILD_PARTICIPANTS_PAYLOAD_FAILED", error); + } + } + + @Override + public ApiResponse getThreadStatus(String projectId) throws java.io.IOException, org.json.JSONException { + try { + JSONObject payload = buildThreadStatusPayload(); + payload.put("ok", true); + return new ApiResponse(200, payload); + } catch (Exception error) { + throw new java.io.IOException("BUILD_THREAD_STATUS_PAYLOAD_FAILED", error); + } + } + + @Override + public ApiResponse autoLogin() throws java.io.IOException, org.json.JSONException { + autoLoginCalls += 1; + return new ApiResponse( + 200, + new JSONObject() + .put("ok", true) + .put("session", new JSONObject().put("account", "17600003315")) + ); + } + + @Override + public ApiResponse updateProjectTakeoverSettings( + String projectId, + Boolean takeoverEnabled, + Boolean globalTakeoverEnabled + ) throws java.io.IOException, org.json.JSONException { + if (failFirstSave && updateTakeoverCalls == 0) { + updateTakeoverCalls += 1; + return ApiResponse.error( + 401, + new JSONObject() + .put("ok", false) + .put("message", "UNAUTHORIZED") + ); + } + if (failFirstSave) { + retryUpdateTakeoverCalls += 1; + } else { + updateTakeoverCalls += 1; + } + return new ApiResponse( + 200, + new JSONObject() + .put("ok", true) + .put("controls", new JSONObject().put("takeoverEnabled", takeoverEnabled)) + ); + } + } + + private static final class DirectExecutorService extends AbstractExecutorService { + private boolean shutdown; + + @Override + public void shutdown() { + shutdown = true; + } + + @Override + public List shutdownNow() { + shutdown = true; + return Collections.emptyList(); + } + + @Override + public boolean isShutdown() { + return shutdown; + } + + @Override + public boolean isTerminated() { + return shutdown; + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) { + return true; + } + + @Override + public void execute(Runnable command) { + command.run(); } } }