feat: add realtime sync and import project understanding
This commit is contained in:
157
android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java
Normal file
157
android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java
Normal file
@@ -0,0 +1,157 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
final class BossRealtimeClient {
|
||||
interface Listener {
|
||||
void onRealtimeEvent(BossRealtimeEvent event);
|
||||
}
|
||||
|
||||
private final BossApiClient apiClient;
|
||||
private final Listener listener;
|
||||
private volatile boolean running;
|
||||
private @Nullable Thread workerThread;
|
||||
private @Nullable HttpURLConnection activeConnection;
|
||||
|
||||
BossRealtimeClient(BossApiClient apiClient, Listener listener) {
|
||||
this.apiClient = apiClient;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
synchronized void start() {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
workerThread = new Thread(this::runLoop, "boss-realtime");
|
||||
workerThread.start();
|
||||
}
|
||||
|
||||
synchronized void stop() {
|
||||
running = false;
|
||||
if (activeConnection != null) {
|
||||
activeConnection.disconnect();
|
||||
activeConnection = null;
|
||||
}
|
||||
if (workerThread != null) {
|
||||
workerThread.interrupt();
|
||||
workerThread = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void runLoop() {
|
||||
long backoffMs = 800L;
|
||||
while (running) {
|
||||
try {
|
||||
openAndConsumeStream();
|
||||
backoffMs = 800L;
|
||||
} catch (Exception ignored) {
|
||||
if (!running) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(backoffMs);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
backoffMs = Math.min(backoffMs + 1200L, 5000L);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void openAndConsumeStream() throws IOException {
|
||||
HttpURLConnection connection = apiClient.openConnection("/api/v1/events");
|
||||
activeConnection = connection;
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setConnectTimeout(12_000);
|
||||
connection.setReadTimeout(60_000);
|
||||
connection.setUseCaches(false);
|
||||
connection.setDoInput(true);
|
||||
connection.setRequestProperty("Accept", "text/event-stream");
|
||||
connection.setRequestProperty("Cache-Control", "no-cache");
|
||||
connection.setRequestProperty("x-boss-native-app", "1");
|
||||
String cookie = apiClient.getSessionCookie();
|
||||
if (!cookie.isEmpty()) {
|
||||
connection.setRequestProperty("Cookie", cookie);
|
||||
}
|
||||
|
||||
int statusCode = connection.getResponseCode();
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
throw new IOException("REALTIME_STREAM_HTTP_" + statusCode);
|
||||
}
|
||||
|
||||
try (InputStream inputStream = connection.getInputStream();
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
|
||||
StringBuilder block = new StringBuilder();
|
||||
String line;
|
||||
while (running && (line = reader.readLine()) != null) {
|
||||
if (line.isEmpty()) {
|
||||
dispatchEventBlock(block.toString());
|
||||
block.setLength(0);
|
||||
continue;
|
||||
}
|
||||
block.append(line).append('\n');
|
||||
}
|
||||
if (block.length() > 0) {
|
||||
dispatchEventBlock(block.toString());
|
||||
}
|
||||
} finally {
|
||||
activeConnection = null;
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private void dispatchEventBlock(String rawBlock) {
|
||||
BossRealtimeEvent event = parseEventBlock(rawBlock);
|
||||
if (event == null || event.eventName.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
listener.onRealtimeEvent(event);
|
||||
}
|
||||
|
||||
static @Nullable BossRealtimeEvent parseEventBlock(String rawBlock) {
|
||||
if (rawBlock == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = rawBlock.trim();
|
||||
if (trimmed.isEmpty() || trimmed.startsWith(":")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String eventName = "";
|
||||
StringBuilder dataBuilder = new StringBuilder();
|
||||
for (String line : rawBlock.split("\n")) {
|
||||
if (line.startsWith("event:")) {
|
||||
eventName = line.substring("event:".length()).trim();
|
||||
} else if (line.startsWith("data:")) {
|
||||
if (dataBuilder.length() > 0) {
|
||||
dataBuilder.append('\n');
|
||||
}
|
||||
dataBuilder.append(line.substring("data:".length()).trim());
|
||||
}
|
||||
}
|
||||
if (eventName.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
JSONObject payload = new JSONObject();
|
||||
if (dataBuilder.length() > 0) {
|
||||
try {
|
||||
payload = new JSONObject(dataBuilder.toString());
|
||||
} catch (JSONException ignored) {
|
||||
payload = new JSONObject();
|
||||
}
|
||||
}
|
||||
return new BossRealtimeEvent(eventName, payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
public final class BossRealtimeEvent {
|
||||
public final String eventName;
|
||||
public final JSONObject payload;
|
||||
|
||||
public BossRealtimeEvent(String eventName, @Nullable JSONObject payload) {
|
||||
this.eventName = eventName == null ? "" : eventName.trim();
|
||||
this.payload = payload == null ? new JSONObject() : payload;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
private @Nullable JSONObject currentDraft;
|
||||
private @Nullable JSONObject currentResolution;
|
||||
private @Nullable JSONObject currentReviewTask;
|
||||
private @Nullable JSONArray currentUnderstandingTasks;
|
||||
private @Nullable JSONArray currentProjectUnderstandings;
|
||||
private final LinkedHashSet<String> selectedCandidateIds = new LinkedHashSet<>();
|
||||
private final Runnable reviewPollRunnable = this::reload;
|
||||
|
||||
@@ -53,7 +55,9 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
runOnUiThread(() -> applyPayload(
|
||||
response.json.optJSONObject("draft"),
|
||||
response.json.optJSONObject("resolution"),
|
||||
response.json.optJSONObject("reviewTask")
|
||||
response.json.optJSONObject("reviewTask"),
|
||||
response.json.optJSONArray("understandingTasks"),
|
||||
response.json.optJSONArray("projectUnderstandings")
|
||||
));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
@@ -64,10 +68,18 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void applyPayload(@Nullable JSONObject draft, @Nullable JSONObject resolution, @Nullable JSONObject reviewTask) {
|
||||
private void applyPayload(
|
||||
@Nullable JSONObject draft,
|
||||
@Nullable JSONObject resolution,
|
||||
@Nullable JSONObject reviewTask,
|
||||
@Nullable JSONArray understandingTasks,
|
||||
@Nullable JSONArray projectUnderstandings
|
||||
) {
|
||||
currentDraft = draft;
|
||||
currentResolution = resolution;
|
||||
currentReviewTask = reviewTask;
|
||||
currentUnderstandingTasks = understandingTasks;
|
||||
currentProjectUnderstandings = projectUnderstandings;
|
||||
selectedCandidateIds.clear();
|
||||
JSONArray selected = draft == null ? null : draft.optJSONArray("selectedCandidateIds");
|
||||
if (selected != null) {
|
||||
@@ -105,6 +117,8 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
JSONObject draft = currentDraft;
|
||||
JSONObject resolution = currentResolution;
|
||||
JSONObject reviewTask = currentReviewTask;
|
||||
JSONArray understandingTasks = currentUnderstandingTasks;
|
||||
JSONArray projectUnderstandings = currentProjectUnderstandings;
|
||||
contentLayout.removeCallbacks(reviewPollRunnable);
|
||||
replaceContent();
|
||||
appendContent(BossUi.buildSoftPanel(
|
||||
@@ -223,6 +237,40 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
));
|
||||
}
|
||||
|
||||
if (understandingTasks != null && understandingTasks.length() > 0) {
|
||||
int completedCount = 0;
|
||||
for (int i = 0; i < understandingTasks.length(); i++) {
|
||||
JSONObject task = understandingTasks.optJSONObject(i);
|
||||
if (task != null && "completed".equals(task.optString("status", ""))) {
|
||||
completedCount += 1;
|
||||
}
|
||||
}
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"项目理解",
|
||||
completedCount == understandingTasks.length()
|
||||
? "主 Agent 已经拿到活跃线程的项目目标、进度和技术架构。"
|
||||
: "主 Agent 正在向活跃线程追问项目目标、进度和技术架构。",
|
||||
"已完成 " + completedCount + " / " + understandingTasks.length()
|
||||
));
|
||||
}
|
||||
|
||||
if (projectUnderstandings != null) {
|
||||
for (int i = 0; i < projectUnderstandings.length(); i++) {
|
||||
JSONObject understanding = projectUnderstandings.optJSONObject(i);
|
||||
if (understanding == null) continue;
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
understanding.optString("threadDisplayName", "项目理解"),
|
||||
"目标:" + understanding.optString("projectGoal", "未提供")
|
||||
+ "\n进度:" + understanding.optString("currentProgress", "未提供")
|
||||
+ "\n架构:" + understanding.optString("technicalArchitecture", "未提供"),
|
||||
"阻塞:" + understanding.optString("currentBlockers", "无")
|
||||
+ " · 下一步:" + understanding.optString("recommendedNextStep", "继续联调")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
JSONArray appliedProjectNames = draft.optJSONArray("appliedProjectNames");
|
||||
if (appliedProjectNames != null && appliedProjectNames.length() > 0) {
|
||||
appendContent(BossUi.buildCard(
|
||||
@@ -369,14 +417,16 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
reviewResponse.json.optJSONObject("resolution"),
|
||||
reviewResponse.json.optJSONObject("reviewTask") != null
|
||||
? reviewResponse.json.optJSONObject("reviewTask")
|
||||
: reviewResponse.json.optJSONObject("task")
|
||||
: reviewResponse.json.optJSONObject("task"),
|
||||
reviewResponse.json.optJSONArray("understandingTasks"),
|
||||
reviewResponse.json.optJSONArray("projectUnderstandings")
|
||||
);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
final JSONObject fallbackDraft = selectedDraft;
|
||||
runOnUiThread(() -> {
|
||||
if (fallbackDraft != null) {
|
||||
applyPayload(fallbackDraft, null, null);
|
||||
applyPayload(fallbackDraft, null, null, null, null);
|
||||
} else {
|
||||
setRefreshing(false);
|
||||
}
|
||||
@@ -400,7 +450,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showMessage("已清空当前勾选");
|
||||
applyPayload(response.json.optJSONObject("draft"), null, null);
|
||||
applyPayload(response.json.optJSONObject("draft"), null, null, null, null);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
@@ -425,7 +475,13 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showMessage("已应用导入");
|
||||
applyPayload(response.json.optJSONObject("draft"), response.json.optJSONObject("resolution"), null);
|
||||
applyPayload(
|
||||
response.json.optJSONObject("draft"),
|
||||
response.json.optJSONObject("resolution"),
|
||||
null,
|
||||
null,
|
||||
response.json.optJSONArray("projectUnderstandings")
|
||||
);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
|
||||
@@ -45,11 +45,13 @@ public class MainActivity extends AppCompatActivity {
|
||||
private static final String KEY_LAST_ROOT_TAB = "last_root_tab";
|
||||
private static final long ROOT_BACK_EXIT_WINDOW_MS = 1_500L;
|
||||
private static final long CONVERSATION_AUTO_REFRESH_MS = 12_000L;
|
||||
private static final long REALTIME_REFRESH_THROTTLE_MS = 900L;
|
||||
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
private final Handler uiHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
private BossApiClient apiClient;
|
||||
private BossRealtimeClient realtimeClient;
|
||||
|
||||
private View loginPanel;
|
||||
private View contentPanel;
|
||||
@@ -100,6 +102,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
private boolean conversationQuickActionsVisible = false;
|
||||
private boolean conversationAutoRefreshArmed = false;
|
||||
private boolean conversationAutoRefreshEnabled = false;
|
||||
private long lastRealtimeRefreshAt = 0L;
|
||||
private final Set<String> selectedConversationProjectIds = new LinkedHashSet<>();
|
||||
private @Nullable RootPagerAdapter rootPagerAdapter;
|
||||
private boolean syncingRootPagerSelection = false;
|
||||
@@ -122,6 +125,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_main);
|
||||
apiClient = new BossApiClient(this);
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
bindViews();
|
||||
bindActions();
|
||||
applyInitialTab(getIntent());
|
||||
@@ -170,6 +174,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
cancelConversationAutoRefresh();
|
||||
stopRealtimeUpdates();
|
||||
executor.shutdownNow();
|
||||
super.onDestroy();
|
||||
}
|
||||
@@ -179,12 +184,14 @@ public class MainActivity extends AppCompatActivity {
|
||||
super.onResume();
|
||||
conversationAutoRefreshEnabled = true;
|
||||
updateConversationAutoRefresh();
|
||||
updateRealtimeSubscription();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
conversationAutoRefreshEnabled = false;
|
||||
cancelConversationAutoRefresh();
|
||||
stopRealtimeUpdates();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@@ -352,10 +359,43 @@ public class MainActivity extends AppCompatActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void refreshCurrentTab() {
|
||||
void refreshCurrentTab() {
|
||||
refreshAllData(sessionData);
|
||||
}
|
||||
|
||||
void handleRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (event == null || event.eventName.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (contentPanel == null || contentPanel.getVisibility() != View.VISIBLE) {
|
||||
return;
|
||||
}
|
||||
boolean shouldRefresh = false;
|
||||
if ("conversations".equals(activeTab)) {
|
||||
shouldRefresh =
|
||||
"conversation.updated".equals(event.eventName) ||
|
||||
"project.messages.updated".equals(event.eventName) ||
|
||||
"master_agent.task.updated".equals(event.eventName) ||
|
||||
"conversation.context_indicator.updated".equals(event.eventName);
|
||||
} else if ("devices".equals(activeTab)) {
|
||||
shouldRefresh =
|
||||
"devices.updated".equals(event.eventName) ||
|
||||
"devices.skills.updated".equals(event.eventName) ||
|
||||
"conversation.updated".equals(event.eventName);
|
||||
} else if ("me".equals(activeTab)) {
|
||||
shouldRefresh = "ota.updated".equals(event.eventName) || "app.logs.updated".equals(event.eventName);
|
||||
}
|
||||
if (!shouldRefresh) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastRealtimeRefreshAt < REALTIME_REFRESH_THROTTLE_MS) {
|
||||
return;
|
||||
}
|
||||
lastRealtimeRefreshAt = now;
|
||||
runOnUiThread(this::refreshCurrentTab);
|
||||
}
|
||||
|
||||
private void refreshAllData(@Nullable JSONObject initialSession) {
|
||||
startRefreshing(true);
|
||||
topSubtitle.setText("");
|
||||
@@ -490,6 +530,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
loginPanel.setVisibility(View.VISIBLE);
|
||||
contentPanel.setVisibility(View.GONE);
|
||||
setLoginLoading(false, hint);
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
|
||||
private void showContent() {
|
||||
@@ -497,6 +538,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
contentPanel.setVisibility(View.VISIBLE);
|
||||
setActiveTab(activeTab, false);
|
||||
updateConversationAutoRefresh();
|
||||
updateRealtimeSubscription();
|
||||
}
|
||||
|
||||
private void setLoginLoading(boolean loading, String hint) {
|
||||
@@ -1212,6 +1254,23 @@ public class MainActivity extends AppCompatActivity {
|
||||
Toast.makeText(this, text, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
private void updateRealtimeSubscription() {
|
||||
if (contentPanel != null
|
||||
&& contentPanel.getVisibility() == View.VISIBLE
|
||||
&& apiClient != null
|
||||
&& apiClient.hasSessionHints()) {
|
||||
realtimeClient.start();
|
||||
return;
|
||||
}
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
|
||||
private void stopRealtimeUpdates() {
|
||||
if (realtimeClient != null) {
|
||||
realtimeClient.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private void openMeEntry(String key) {
|
||||
Intent intent;
|
||||
switch (key) {
|
||||
|
||||
@@ -43,6 +43,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
private static final long REPLY_WAIT_TIMEOUT_MS = 55_000L;
|
||||
private static final long REPLY_WAIT_POLL_INTERVAL_MS = 1_500L;
|
||||
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
|
||||
|
||||
private String projectId;
|
||||
private String initialProjectName;
|
||||
@@ -82,6 +83,8 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
private ActivityResultLauncher<String> videoPickerLauncher;
|
||||
private ActivityResultLauncher<String> filePickerLauncher;
|
||||
private final ExecutorService replyWaitExecutor = Executors.newSingleThreadExecutor();
|
||||
private @Nullable BossRealtimeClient realtimeClient;
|
||||
private long lastRealtimeReloadAt;
|
||||
|
||||
static final class ChromeBindings {
|
||||
final boolean multiSelecting;
|
||||
@@ -205,6 +208,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
new ActivityResultContracts.GetContent(),
|
||||
uri -> onAttachmentPicked(uri, "file")
|
||||
);
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
|
||||
BossWindowInsets.applyKeyboardAvoidingInset(composerRow);
|
||||
BossWindowInsets.applyKeyboardAvoidingInset(multiSelectActionsLayout);
|
||||
@@ -242,10 +246,23 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
stopRealtimeUpdates();
|
||||
replyWaitExecutor.shutdownNow();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
updateRealtimeSubscription();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
stopRealtimeUpdates();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
boolean shouldLoadOnCreate() {
|
||||
return true;
|
||||
}
|
||||
@@ -255,6 +272,32 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
reload(false);
|
||||
}
|
||||
|
||||
void handleRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (event == null || event.eventName.isEmpty() || projectId == null || projectId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
boolean shouldReload = false;
|
||||
String payloadProjectId = event.payload.optString("projectId", "");
|
||||
if ("project.messages.updated".equals(event.eventName) || "conversation.updated".equals(event.eventName)) {
|
||||
shouldReload = projectId.equals(payloadProjectId);
|
||||
} else if ("master_agent.task.updated".equals(event.eventName)) {
|
||||
shouldReload = "master-agent".equals(projectId);
|
||||
}
|
||||
if (!shouldReload) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastRealtimeReloadAt < REALTIME_RELOAD_THROTTLE_MS) {
|
||||
return;
|
||||
}
|
||||
lastRealtimeReloadAt = now;
|
||||
runOnUiThread(this::triggerRealtimeReload);
|
||||
}
|
||||
|
||||
void triggerRealtimeReload() {
|
||||
reload();
|
||||
}
|
||||
|
||||
private void reload(boolean forcedScrollToBottom) {
|
||||
if (projectId == null || projectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
@@ -368,6 +411,20 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
}
|
||||
}
|
||||
|
||||
private void updateRealtimeSubscription() {
|
||||
if (apiClient != null && apiClient.hasSessionHints()) {
|
||||
realtimeClient.start();
|
||||
return;
|
||||
}
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
|
||||
private void stopRealtimeUpdates() {
|
||||
if (realtimeClient != null) {
|
||||
realtimeClient.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private void renderQuickActions() {
|
||||
if (quickActionsLayout == null) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user