fix: recover thread takeover session on android

This commit is contained in:
kris
2026-04-05 12:15:05 +08:00
parent e00f7a55ea
commit f046adc393
2 changed files with 275 additions and 7 deletions

View File

@@ -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;
}
}
}

View File

@@ -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<Runnable> 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();
}
}
}