From aa446a60461e0ec8a875577ce991afb795de6197 Mon Sep 17 00:00:00 2001 From: tanjianbin <632190820@qq.com> Date: Mon, 20 Apr 2026 18:57:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E6=BF=80=E6=B4=BB=E5=8A=9F=E8=83=BD=E5=B9=B6=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E7=BD=91=E7=BB=9C=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入设备激活流程,支持通过激活码和服务端配置激活设备 - 重构网络配置,将IP地址配置改为base_url配置 - 新增激活状态UI显示和激活提示横幅 - 添加ConnectionCoordinator统一管理MQTT和LiveKit连接 - 新增RobotEventHandler处理机器人状态和位置标准化 - 新增UiState类集中管理UI状态更新 - 在设置页面添加关于对话框显示设备信息 - 更新网络安全配置,限制明文流量仅允许本地地址 --- app/src/main/AndroidManifest.xml | 3 +- .../ConnectionCoordinator.kt | 146 +++++++++++ .../lzwcai_terminal_temi/HttpManager.kt | 182 +++++++++++++- .../lzwcai_terminal_temi/MainActivity.kt | 235 ++++++++---------- .../lzwcai_terminal_temi/MqttManager.kt | 54 ++-- .../lzwcai_terminal_temi/NavController.kt | 6 +- .../lzwcai_terminal_temi/RobotEventHandler.kt | 33 +++ .../lzwcai_terminal_temi/SettingsActivity.kt | 219 +++++++++++++++- .../example/lzwcai_terminal_temi/UiState.kt | 31 +++ .../main/res/layout-land/activity_main.xml | 21 ++ .../res/layout-land/activity_settings.xml | 114 ++++++++- app/src/main/res/layout/activity_main.xml | 21 ++ app/src/main/res/layout/activity_settings.xml | 114 ++++++++- app/src/main/res/values/strings.xml | 32 ++- .../main/res/xml/network_security_config.xml | 10 + 15 files changed, 1038 insertions(+), 183 deletions(-) create mode 100644 app/src/main/java/com/example/lzwcai_terminal_temi/ConnectionCoordinator.kt create mode 100644 app/src/main/java/com/example/lzwcai_terminal_temi/RobotEventHandler.kt create mode 100644 app/src/main/java/com/example/lzwcai_terminal_temi/UiState.kt create mode 100644 app/src/main/res/xml/network_security_config.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0e1ccc7..43b8e91 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,10 +15,11 @@ android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:networkSecurityConfig="@xml/network_security_config" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Lzwcaiterminaltemi" - android:usesCleartextTraffic="true" + android:usesCleartextTraffic="false" tools:targetApi="31"> LiveKitManager?, + private val mqttStatusListener: (Boolean) -> Unit, + private val liveKitStatusListener: (Boolean) -> Unit, + private val hasAudioPermission: () -> Boolean, + private val hasCameraPermission: () -> Boolean, + private val requestMediaPermissions: () -> Unit, + private val buildLiveKitToken: (room: String, savedToken: String) -> String, + private val onSetCurrentTask: (String) -> Unit, + private val onMarkSpeechTaskActive: () -> Unit, + private val onClearSpeechTaskIfActive: () -> Unit, + private val onStartNotificationMode: (location: String, text: String) -> Unit, + private val onStartPatrolMode: (route: List, times: Int, waiting: Int, nonStop: Boolean) -> Unit, + private val onStartReceptionMode: (location: String, text: String, destination: String) -> Unit, + private val onPublishStatusSnapshot: (reason: String, force: Boolean) -> Unit +) { + private var mqttManager: MqttManager? = null + + fun handleTtsStatusChange(ttsRequest: com.robotemi.sdk.TtsRequest) { + mqttManager?.handleTtsStatusChange(ttsRequest) + } + + fun publish(topic: String, payload: String) { + mqttManager?.publish(topic, payload) + } + + fun connectMqttIfNeeded() { + mqttManager?.connect() + } + + fun disconnectMqtt() { + mqttManager?.disconnect() + } + + fun disconnectLiveKit() { + liveKitManagerProvider()?.disconnect() + } + + fun release() { + mqttManager?.disconnect() + liveKitManagerProvider()?.release() + } + + fun updateMqttConnection(isActivated: Boolean) { + mqttManager?.shutdown() + if (!isActivated) { + mqttManager = null + mqttStatusListener(false) + return + } + val host = resolveBrokerHostFromBaseUrl() + if (host.isNullOrEmpty()) { + mqttManager = null + mqttStatusListener(false) + Log.w("ConnectionCoordinator", "MQTT disabled: base_url is invalid or not set.") + return + } + mqttManager = MqttManager( + context = context, + serverIp = host, + robot = robot, + navController = navController, + statusListener = mqttStatusListener, + onSetCurrentTask = onSetCurrentTask, + onMarkSpeechTaskActive = onMarkSpeechTaskActive, + onClearSpeechTaskIfActive = onClearSpeechTaskIfActive, + onStartNotificationMode = onStartNotificationMode, + onStartPatrolMode = onStartPatrolMode, + onStartReceptionMode = onStartReceptionMode, + onPublishStatusSnapshot = onPublishStatusSnapshot + ) + mqttManager?.connect() + Log.i("ConnectionCoordinator", "MQTT updated with host=$host") + } + + fun updateLiveKitConnection(isActivated: Boolean) { + if (!isActivated) { + liveKitManagerProvider()?.disconnect() + liveKitStatusListener(false) + return + } + val enabled = prefs.getBoolean(LiveKitManager.PREF_KEY_ENABLED, true) + val url = resolveLiveKitUrl() + val room = prefs.getString(LiveKitManager.PREF_KEY_ROOM, LiveKitManager.DEFAULT_ROOM).orEmpty() + val savedToken = prefs.getString(LiveKitManager.PREF_KEY_TOKEN, "").orEmpty() + val token = buildLiveKitToken(room, savedToken) + if (!enabled || url.isBlank() || room.isBlank()) { + liveKitManagerProvider()?.disconnect() + liveKitStatusListener(false) + return + } + if (!hasAudioPermission() || !hasCameraPermission()) { + liveKitStatusListener(false) + requestMediaPermissions() + return + } + liveKitStatusListener(false) + liveKitManagerProvider()?.connect(url, token, true, true) + } + + fun updateLiveKitStatusSnapshot(isActivated: Boolean) { + if (!isActivated) { + liveKitStatusListener(false) + return + } + val enabled = prefs.getBoolean(LiveKitManager.PREF_KEY_ENABLED, true) + val url = resolveLiveKitUrl() + val room = prefs.getString(LiveKitManager.PREF_KEY_ROOM, LiveKitManager.DEFAULT_ROOM).orEmpty() + if (!enabled || url.isBlank() || room.isBlank() || !hasAudioPermission() || !hasCameraPermission()) { + liveKitStatusListener(false) + return + } + liveKitStatusListener(false) + } + + private fun resolveLiveKitUrl(): String { + val savedUrl = prefs.getString(LiveKitManager.PREF_KEY_URL, "").orEmpty().trim() + if (savedUrl.isNotEmpty()) { + return savedUrl + } + return LiveKitManager.DEFAULT_URL + } + + private fun resolveBrokerHostFromBaseUrl(): String? { + val baseUrl = prefs.getString(HttpManager.PREF_KEY_BASE_URL, "").orEmpty().trim() + if (baseUrl.isEmpty()) { + return null + } + return runCatching { URL(baseUrl).host } + .getOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + } +} diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/HttpManager.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/HttpManager.kt index ee2f6e3..995e2e7 100644 --- a/app/src/main/java/com/example/lzwcai_terminal_temi/HttpManager.kt +++ b/app/src/main/java/com/example/lzwcai_terminal_temi/HttpManager.kt @@ -12,23 +12,137 @@ import java.net.URL object HttpManager { private const val TAG = "HttpManager" + const val PREF_KEY_BASE_URL = "base_url" + const val PREF_KEY_LOGIN_USERNAME = "login_username" + const val PREF_KEY_LOGIN_PASSWORD = "login_password" + const val PREF_KEY_DEVICE_ID = "device_id" + const val PREF_KEY_ACTIVATION_CODE = "activation_code" + const val PREF_KEY_DEVICE_NAME = "device_name" + const val PREF_KEY_ACTIVATED = "is_activated" + const val PREF_KEY_MQTT_USERNAME = "mqtt_username" + const val PREF_KEY_MQTT_PASSWORD = "mqtt_password" + const val PREF_KEY_OD_WFID = "od_wfid" + const val PREF_KEY_OD_WF_KEY = "od_wf_key" + const val PREF_KEY_CD_WFID = "cd_wfid" + const val PREF_KEY_CD_WF_KEY = "cd_wf_key" + + fun getBaseUrl(context: Context): String { + val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) + val saved = prefs.getString(PREF_KEY_BASE_URL, "").orEmpty().trim() + return saved.trimEnd('/') + } + + suspend fun login(context: Context): String? = withContext(Dispatchers.IO) { + val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) + val username = prefs.getString(PREF_KEY_LOGIN_USERNAME, "").orEmpty().trim() + val password = prefs.getString(PREF_KEY_LOGIN_PASSWORD, "").orEmpty() + if (username.isEmpty() || password.isEmpty()) { + Log.w(TAG, "Login skipped: username or password is empty.") + return@withContext null + } + val body = JSONObject() + .put("username", username) + .put("password", password) + .put("loginType", "user") + val response = postJson(context, "/login", body, token = null) + if (response == null) { + return@withContext null + } + val code = response.optInt("code", -1) + if (code != 200) { + Log.e(TAG, "Login failed: code=$code, msg=${response.optString("msg")}") + return@withContext null + } + return@withContext response.optString("token", "").trim().ifEmpty { null } + } + + suspend fun activateDevice( + context: Context, + activationCode: String, + deviceName: String, + deviceId: String + ): Boolean = withContext(Dispatchers.IO) { + val token = login(context) ?: return@withContext false + val body = JSONObject() + .put("activationCode", activationCode) + .put("deviceName", deviceName) + .put("deviceTypeName", "轮足机器人") + .put("deviceId", deviceId) + val response = postJson(context, "/system/serverConfig/deviceActivate", body, token) + if (response == null) { + return@withContext false + } + val code = response.optInt("code", -1) + if (code != 200) { + Log.e(TAG, "Activation failed: code=$code, msg=${response.optString("msg")}") + return@withContext false + } + return@withContext true + } + + suspend fun fetchRuntimeConfigs(context: Context): Map? = withContext(Dispatchers.IO) { + val token = login(context) ?: return@withContext null + val body = JSONArray() + .put(PREF_KEY_MQTT_USERNAME) + .put(PREF_KEY_OD_WFID) + .put(PREF_KEY_OD_WF_KEY) + .put(PREF_KEY_CD_WFID) + .put(PREF_KEY_CD_WF_KEY) + .put(PREF_KEY_MQTT_PASSWORD) + val response = postJsonArray(context, "/system/config/getConfig", body, token) + if (response == null) { + return@withContext null + } + val code = response.optInt("code", -1) + if (code != 200) { + Log.e(TAG, "Get runtime config failed: code=$code, msg=${response.optString("msg")}") + return@withContext null + } + val data = response.optJSONArray("data") ?: return@withContext null + val result = mutableMapOf() + for (i in 0 until data.length()) { + val item = data.optJSONObject(i) ?: continue + val key = item.optString("configKey", "").trim() + if (key.isEmpty()) { + continue + } + result[key] = item.optString("configValue", "") + } + return@withContext result + } + + suspend fun fetchMqttCredentials(context: Context): Pair? = withContext(Dispatchers.IO) { + val configs = fetchRuntimeConfigs(context) ?: return@withContext null + val username = configs[PREF_KEY_MQTT_USERNAME].orEmpty() + val password = configs[PREF_KEY_MQTT_PASSWORD].orEmpty() + if (username.isBlank() || password.isBlank()) { + Log.e(TAG, "Get MQTT config failed: username/password empty.") + return@withContext null + } + return@withContext username to password + } suspend fun workflow_execute(context: Context, apiKey: String, workflowId: String, inputs: Any): String? = withContext(Dispatchers.IO) { var result: String? = null try { - val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) - val ip = prefs.getString("network_ip", "") ?: "" - if (ip.isEmpty()) { - Log.e(TAG, "No IP address configured") + val baseUrl = getBaseUrl(context) + if (baseUrl.isEmpty()) { + Log.e(TAG, "No base_url configured") return@withContext null } - val workflowUrl = "http://$ip:8088/open/workflow/execute" + val token = login(context) + if (token.isNullOrBlank()) { + Log.e(TAG, "Workflow execute skipped: login token missing.") + return@withContext null + } + val workflowUrl = "$baseUrl/open/workflow/execute" val url = URL(workflowUrl) val connection = url.openConnection() as HttpURLConnection connection.requestMethod = "POST" connection.setRequestProperty("Content-Type", "application/json") connection.setRequestProperty("X-API-Key", apiKey) + connection.setRequestProperty("token", token) connection.doOutput = true connection.doInput = true connection.connectTimeout = 10000 @@ -65,4 +179,62 @@ object HttpManager { } return@withContext result } + + private fun postJson(context: Context, path: String, body: JSONObject, token: String?): JSONObject? { + val baseUrl = getBaseUrl(context) + if (baseUrl.isEmpty()) { + Log.e(TAG, "Request skipped: base_url is empty.") + return null + } + val finalUrl = "$baseUrl$path" + return request(finalUrl, body.toString(), token) + } + + private fun postJsonArray(context: Context, path: String, body: JSONArray, token: String?): JSONObject? { + val baseUrl = getBaseUrl(context) + if (baseUrl.isEmpty()) { + Log.e(TAG, "Request skipped: base_url is empty.") + return null + } + val finalUrl = "$baseUrl$path" + return request(finalUrl, body.toString(), token) + } + + private fun request(urlText: String, bodyText: String, token: String?): JSONObject? { + var connection: HttpURLConnection? = null + return try { + val connectionUrl = URL(urlText) + connection = connectionUrl.openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + if (!token.isNullOrBlank()) { + connection.setRequestProperty("token", token) + } + connection.doOutput = true + connection.doInput = true + connection.connectTimeout = 10000 + connection.readTimeout = 10000 + + val writer = OutputStreamWriter(connection.outputStream) + writer.write(bodyText) + writer.flush() + writer.close() + + val responseText = if (connection.responseCode in 200..299) { + connection.inputStream.bufferedReader().use { it.readText() } + } else { + connection.errorStream?.bufferedReader()?.use { it.readText() }.orEmpty() + } + if (responseText.isBlank()) { + null + } else { + JSONObject(responseText) + } + } catch (e: Exception) { + Log.e(TAG, "Request failed: $urlText", e) + null + } finally { + connection?.disconnect() + } + } } diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/MainActivity.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/MainActivity.kt index 51af001..8fe638b 100644 --- a/app/src/main/java/com/example/lzwcai_terminal_temi/MainActivity.kt +++ b/app/src/main/java/com/example/lzwcai_terminal_temi/MainActivity.kt @@ -12,7 +12,6 @@ import android.util.Log import android.view.WindowManager import android.view.View import android.util.Base64 -import android.graphics.drawable.GradientDrawable import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.activity.result.ActivityResultLauncher @@ -56,7 +55,8 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG private lateinit var robot: Robot private lateinit var binding: ActivityMainBinding - private var mqttManager: MqttManager? = null + private lateinit var uiState: UiState + private lateinit var connectionCoordinator: ConnectionCoordinator private val mainScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private lateinit var prefs: SharedPreferences private val specialStateKey = "special_state" @@ -101,12 +101,14 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG private var receptionAnchorYaw: Float? = null private lateinit var telemetryManager: TelemetryManager private lateinit var taskController: TaskController + private val robotEventHandler = RobotEventHandler() @SuppressLint("SetTextI18n") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) + uiState = UiState(this, binding) LogManager.configureLogcat( tags = listOf( @@ -162,6 +164,38 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG clearTaskLauncher.launch(Intent(this, SettingsActivity::class.java)) } + connectionCoordinator = ConnectionCoordinator( + context = this, + prefs = prefs, + robot = robot, + navController = navCon, + liveKitManagerProvider = { liveKitManager }, + mqttStatusListener = { connected -> setMqttConnectionStatus(connected) }, + liveKitStatusListener = { connected -> setLiveKitStatus(connected) }, + hasAudioPermission = { hasAudioPermission() }, + hasCameraPermission = { hasCameraPermission() }, + requestMediaPermissions = { requestMediaPermissions() }, + buildLiveKitToken = { room, savedToken -> + if (savedToken.isBlank()) { + buildLiveKitToken( + apiKey = LiveKitManager.DEFAULT_API_KEY, + apiSecret = LiveKitManager.DEFAULT_API_SECRET, + room = room, + identity = buildLiveKitIdentity() + ) + } else { + savedToken + } + }, + onSetCurrentTask = { task -> setCurrentTask(task) }, + onMarkSpeechTaskActive = { markSpeechTaskActive() }, + onClearSpeechTaskIfActive = { clearSpeechTaskIfActive() }, + onStartNotificationMode = { location, text -> startNotificationMode(location, text) }, + onStartPatrolMode = { route, times, waiting, nonStop -> startPatrolMode(route, times, waiting, nonStop) }, + onStartReceptionMode = { location, text, destination -> startReceptionMode(location, text, destination) }, + onPublishStatusSnapshot = { reason, force -> if (::telemetryManager.isInitialized) telemetryManager.publishStatusSnapshot(reason, force) } + ) + taskController = TaskController( scope = mainScope, navController = navCon, @@ -208,13 +242,14 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG locationProvider = { lastArrivalLocation }, mqttConnectedProvider = { isMqttConnected }, liveKitConnectedProvider = { isLiveKitConnected }, - publish = { topic, payload -> mqttManager?.publish(topic, payload) }, + publish = { topic, payload -> connectionCoordinator.publish(topic, payload) }, onLowBattery = { _ -> val ttsRequest = TtsRequest.create("电量低,请及时充电。", false, language = TtsRequest.Language.ZH_CN) robot.speak(ttsRequest) } ) + updateActivationBanner() updateMqttConnection() updateLiveKitStatusSnapshot() } @@ -231,14 +266,21 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG robot.addOnCurrentPositionChangedListener(this) robot.addOnRequestPermissionResultListener(this) robot.constraintBeWith() - if (mqttManager == null) { - updateMqttConnection() + updateActivationBanner() + if (!isActivated()) { + connectionCoordinator.disconnectMqtt() + connectionCoordinator.disconnectLiveKit() + setMqttConnectionStatus(false) + setLiveKitStatus(false) + stopBlinking() + telemetryManager.stop() + Log.w("MainActivity", "Application is not activated yet.") } else { - mqttManager?.connect() + updateMqttConnection() + updateLiveKitConnection() + startBlinking() + telemetryManager.start() } - updateLiveKitConnection() - startBlinking() - telemetryManager.start() } override fun onStop() { @@ -252,8 +294,8 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG robot.removeOnMovementStatusChangedListener(this) robot.removeOnCurrentPositionChangedListener(this) robot.removeOnRequestPermissionResultListener(this) - // mqttManager?.disconnect() // Keep MQTT alive in background/settings - liveKitManager?.disconnect() + // Keep MQTT alive in background/settings + connectionCoordinator.disconnectLiveKit() stopBlinking() telemetryManager.stop() } @@ -261,8 +303,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG override fun onDestroy() { super.onDestroy() prefs.unregisterOnSharedPreferenceChangeListener(this) - mqttManager?.disconnect() - liveKitManager?.release() + connectionCoordinator.release() LogManager.stopLogcatListener() mainScope.cancel() Log.i("MainActivity", "All resources released on destroy.") @@ -290,7 +331,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG } override fun onTtsStatusChanged(ttsRequest: TtsRequest) { - mqttManager?.handleTtsStatusChange(ttsRequest) + connectionCoordinator.handleTtsStatusChange(ttsRequest) when (ttsRequest.status) { TtsRequest.Status.STARTED -> { Log.i("MainActivity", "TTS started") @@ -336,8 +377,8 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG override fun onGoToLocationStatusChanged(location: String, status: String, descriptionId: Int, description: String) { val normalized = status.lowercase() - val isAbort = normalized in setOf("abort", "aborted", "canceled", "cancelled", "stopped", "stop", "failed", "error") - val isMoving = normalized in setOf("start", "starting", "going", "moving", "navigating", "calculating", "recalculating") + val isAbort = robotEventHandler.isAbortStatus(status) + val isMoving = robotEventHandler.isMovingStatus(status) if (isMoving) { cancelAutoRecharge("movement_started:$location/$status") taskController.cancelTaskWaitTimeout() @@ -360,7 +401,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG lastArrivalLocation = location lastArrivalAt = now prefs.edit().putString("current_location", location).apply() - if (normalizeLocation(location) == "homebase") { + if (robotEventHandler.normalizeLocation(location) == "homebase") { navCon.tiltAngle(20) } if (taskController.currentTask == "patrol") { @@ -434,12 +475,12 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG } } if (taskController.handleDetectionStateChanged(state)) { - Log.i("MainActivity", "what the f**k") + Log.i("MainActivity", "Detection event handled by task controller.") return } val isSpecialState = isSpecialStateEnabled() val isIdleTask = taskController.currentTask.isEmpty() || taskController.currentTask == "speech" - val atHomeBase = normalizeLocation(lastArrivalLocation) == "homebase" + val atHomeBase = robotEventHandler.normalizeLocation(lastArrivalLocation) == "homebase" val canHandleDoor = isIdleTask && atHomeBase && !taskController.isLeavingHomeBase && !isSpecialState if (canHandleDoor) { when (state) { @@ -449,12 +490,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG val ttsRequest = TtsRequest.create("正在为你开门,请稍等。", false, language = TtsRequest.Language.ZH_CN) robot.speak(ttsRequest) mainScope.launch { - val result = HttpManager.workflow_execute( - context = this@MainActivity, - apiKey = "wf_865e80f5fc1a4a319474a21d47470863", - workflowId = "2031297462423851009", - inputs = emptyMap() - ) + val result = executeDoorWorkflow(openDoor = true) if (result == null) { showNetworkErrorBanner() } @@ -465,12 +501,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG val ttsRequest = TtsRequest.create("正准备关门,请小心被夹。", false, language = TtsRequest.Language.ZH_CN) robot.speak(ttsRequest) mainScope.launch { - val result = HttpManager.workflow_execute( - context = this@MainActivity, - apiKey = "wf_c02aa853371345dbb29572641d083c24", - workflowId = "2031634633458520065", - inputs = emptyMap() - ) + val result = executeDoorWorkflow(openDoor = false) if (result == null) { showNetworkErrorBanner() } @@ -492,15 +523,6 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG } } - private fun normalizeLocation(value: String?): String { - return value.orEmpty() - .trim() - .lowercase() - .replace(" ", "") - .replace("_", "") - .replace("-", "") - } - fun startReceptionMode(location: String, text: String, destination: String) { taskController.startReceptionMode(location, text, destination) } @@ -532,8 +554,16 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - if (key == "network_ip") { - Log.i("MainActivity", "IP address changed, re-initializing MQTT connection.") + if (key == HttpManager.PREF_KEY_BASE_URL || + key == HttpManager.PREF_KEY_MQTT_USERNAME || + key == HttpManager.PREF_KEY_MQTT_PASSWORD + ) { + Log.i("MainActivity", "Base URL or MQTT config changed, re-initializing MQTT connection.") + updateMqttConnection() + updateLiveKitConnection() + } + if (key == HttpManager.PREF_KEY_ACTIVATED) { + updateActivationBanner() updateMqttConnection() updateLiveKitConnection() } @@ -562,53 +592,11 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG } private fun updateMqttConnection() { - mqttManager?.shutdown() - val ip = prefs.getString("network_ip", null) - if (!ip.isNullOrEmpty()) { - mqttManager = MqttManager(this, ip, robot, navCon) { connected -> - setMqttConnectionStatus(connected) - } - mqttManager?.connect() - Log.i("MainActivity", "MQTT Manager updated with new IP: $ip") - } else { - mqttManager = null - setMqttConnectionStatus(false) - Log.w("MainActivity", "MQTT Manager disabled: IP address is not set.") - } + connectionCoordinator.updateMqttConnection(isActivated()) } private fun updateLiveKitConnection() { - val enabled = prefs.getBoolean(LiveKitManager.PREF_KEY_ENABLED, true) - val url = resolveLiveKitUrl() - val room = prefs.getString(LiveKitManager.PREF_KEY_ROOM, LiveKitManager.DEFAULT_ROOM).orEmpty() - val savedToken = prefs.getString(LiveKitManager.PREF_KEY_TOKEN, "").orEmpty() - val token = if (savedToken.isBlank()) { - buildLiveKitToken( - apiKey = LiveKitManager.DEFAULT_API_KEY, - apiSecret = LiveKitManager.DEFAULT_API_SECRET, - room = room, - identity = buildLiveKitIdentity() - ) - } else { - savedToken - } - if (!enabled) { - liveKitManager?.disconnect() - setLiveKitStatus(false) - return - } - if (url.isBlank() || room.isBlank()) { - liveKitManager?.disconnect() - setLiveKitStatus(false) - return - } - if (!hasAudioPermission() || !hasCameraPermission()) { - setLiveKitStatus(false) - requestMediaPermissions() - return - } - setLiveKitStatus(false) - liveKitManager?.connect(url, token, true, true) + connectionCoordinator.updateLiveKitConnection(isActivated()) } private fun hasAudioPermission(): Boolean { @@ -691,32 +679,17 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG } private fun updateLiveKitStatusSnapshot() { - val enabled = prefs.getBoolean(LiveKitManager.PREF_KEY_ENABLED, true) - val url = resolveLiveKitUrl() - val room = prefs.getString(LiveKitManager.PREF_KEY_ROOM, LiveKitManager.DEFAULT_ROOM).orEmpty() - if (!enabled) { - setLiveKitStatus(false) - return - } - if (url.isBlank() || room.isBlank()) { - setLiveKitStatus(false) - return - } - if (!hasAudioPermission() || !hasCameraPermission()) { - setLiveKitStatus(false) - return - } - setLiveKitStatus(false) + connectionCoordinator.updateLiveKitStatusSnapshot(isActivated()) } private fun setLiveKitStatus(connected: Boolean) { isLiveKitConnected = connected - updateConnectionIndicator() + uiState.updateConnectionIndicator(isLiveKitConnected, isMqttConnected) } private fun setMqttConnectionStatus(connected: Boolean) { isMqttConnected = connected - updateConnectionIndicator() + uiState.updateConnectionIndicator(isLiveKitConnected, isMqttConnected) if (connected) { telemetryManager.publishStatusSnapshot("mqtt_connected", true) } @@ -733,7 +706,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG .put("topic", topicLabel) .put("participant", participantLabel) .put("ts", System.currentTimeMillis()) - mqttManager?.publish("robot/asr", data.toString()) + connectionCoordinator.publish("robot/asr", data.toString()) } private fun extractAsrText(payload: String): String? { @@ -756,10 +729,10 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG private fun showNetworkErrorBanner() { networkErrorJob?.cancel() - binding.tvNetworkError.visibility = View.VISIBLE + uiState.setNetworkErrorVisible(true) networkErrorJob = mainScope.launch { delay(5000L) - binding.tvNetworkError.visibility = View.GONE + uiState.setNetworkErrorVisible(false) } } @@ -767,7 +740,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG if (taskController.currentTask.isNotEmpty()) { return } - if (normalizeLocation(lastArrivalLocation) == "homebase") { + if (robotEventHandler.normalizeLocation(lastArrivalLocation) == "homebase") { return } autoRechargeJob?.cancel() @@ -776,7 +749,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG if (taskController.currentTask.isNotEmpty()) { return@launch } - if (normalizeLocation(lastArrivalLocation) == "homebase") { + if (robotEventHandler.normalizeLocation(lastArrivalLocation) == "homebase") { return@launch } Log.i("MainActivity", "Auto recharge triggered after idle arrival timeout.") @@ -811,7 +784,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG } val anchorYaw = receptionAnchorYaw ?: return val currentYaw = latestYaw ?: return - val delta = normalizeAngle(anchorYaw - currentYaw) + val delta = robotEventHandler.normalizeAngle(anchorYaw - currentYaw) // Ignore tiny drift to avoid jitter. if (kotlin.math.abs(delta) < 8f) { return @@ -824,33 +797,33 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG Log.i("MainActivity", "Reception facing recovered by $turn degrees (delta=$delta).") } - private fun normalizeAngle(angle: Float): Float { - var normalized = angle % 360f - if (normalized > 180f) { - normalized -= 360f - } else if (normalized < -180f) { - normalized += 360f + private suspend fun executeDoorWorkflow(openDoor: Boolean): String? { + val workflowIdKey = if (openDoor) HttpManager.PREF_KEY_OD_WFID else HttpManager.PREF_KEY_CD_WFID + val workflowApiKey = if (openDoor) HttpManager.PREF_KEY_OD_WF_KEY else HttpManager.PREF_KEY_CD_WF_KEY + val workflowId = prefs.getString(workflowIdKey, "").orEmpty().trim() + val apiKey = prefs.getString(workflowApiKey, "").orEmpty().trim() + if (workflowId.isEmpty() || apiKey.isEmpty()) { + Log.w("MainActivity", "Door workflow config missing: openDoor=$openDoor") + return null } - return normalized + return HttpManager.workflow_execute( + context = this@MainActivity, + apiKey = apiKey, + workflowId = workflowId, + inputs = emptyMap() + ) } - private fun updateConnectionIndicator() { - val colorRes = when { - !isLiveKitConnected && !isMqttConnected -> android.R.color.holo_red_dark - !isLiveKitConnected && isMqttConnected -> android.R.color.holo_blue_light - isLiveKitConnected && !isMqttConnected -> android.R.color.holo_orange_light - else -> android.R.color.holo_green_light + private fun isActivated(): Boolean { + if (!::prefs.isInitialized) { + return false } - val indicatorDrawable = binding.statusIndicator.background as GradientDrawable - indicatorDrawable.setColor(ContextCompat.getColor(this, colorRes)) + return prefs.getBoolean(HttpManager.PREF_KEY_ACTIVATED, false) } - private fun resolveLiveKitUrl(): String { - val savedUrl = prefs.getString(LiveKitManager.PREF_KEY_URL, "").orEmpty().trim() - if (savedUrl.isNotEmpty()) { - return savedUrl - } - return LiveKitManager.DEFAULT_URL + private fun updateActivationBanner() { + val activated = isActivated() + uiState.setActivationRequired(!activated) } private fun startBlinking() { diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/MqttManager.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/MqttManager.kt index 1b2d63d..92f06d2 100644 --- a/app/src/main/java/com/example/lzwcai_terminal_temi/MqttManager.kt +++ b/app/src/main/java/com/example/lzwcai_terminal_temi/MqttManager.kt @@ -14,13 +14,21 @@ import java.util.LinkedList import java.util.Queue import java.nio.charset.StandardCharsets class MqttManager( - private val context: Context, + context: Context, private val serverIp: String, private val robot: Robot, private val navController: NavController, - private val statusListener: (Boolean) -> Unit + private val statusListener: (Boolean) -> Unit, + private val onSetCurrentTask: (String) -> Unit, + private val onMarkSpeechTaskActive: () -> Unit, + private val onClearSpeechTaskIfActive: () -> Unit, + private val onStartNotificationMode: (location: String, text: String) -> Unit, + private val onStartPatrolMode: (route: List, times: Int, waiting: Int, nonStop: Boolean) -> Unit, + private val onStartReceptionMode: (location: String, text: String, destination: String) -> Unit, + private val onPublishStatusSnapshot: (reason: String, force: Boolean) -> Unit ) { + private val appContext = context.applicationContext private var mqttClient: MqttClient? = null private val TAG = "MqttManager" private val brokerUri = "tcp://$serverIp:1883" @@ -28,7 +36,7 @@ class MqttManager( private val job = SupervisorJob() private val scope = CoroutineScope(Dispatchers.IO + job) private var reconnectJob: Job? = null - private val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) + private val prefs = appContext.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) private val agentDempIdKey = "agent_demp_id" // Streaming text buffer @@ -88,6 +96,13 @@ class MqttManager( updateConnectionStatus(true) return@launch } + val username = prefs.getString(HttpManager.PREF_KEY_MQTT_USERNAME, "").orEmpty().trim() + val password = prefs.getString(HttpManager.PREF_KEY_MQTT_PASSWORD, "").orEmpty() + if (username.isEmpty() || password.isEmpty()) { + Log.w(TAG, "MQTT connect skipped: username/password not configured.") + updateConnectionStatus(false) + return@launch + } Log.i(TAG, "Attempting to connect to MQTT broker at $brokerUri") try { val options = MqttConnectOptions().apply { @@ -95,8 +110,8 @@ class MqttManager( isCleanSession = true connectionTimeout = 10 keepAliveInterval = 60 - userName = "lzwc" - password = "Lzwc@4187.".toCharArray() + userName = username + this.password = password.toCharArray() } mqttClient?.connect(options) } catch (e: MqttException) { @@ -224,7 +239,7 @@ class MqttManager( val isFinal = obj.optBoolean("is_final", false) val lang = obj.optString("lang", "").trim() scope.launch(Dispatchers.Main) { - (context as? MainActivity)?.markSpeechTaskActive() + onMarkSpeechTaskActive() } processStreamText(text, lang) if (isFinal) { @@ -268,11 +283,13 @@ class MqttManager( } private fun handleJsonCommand(obj: JSONObject) { - // 收到任何 MQTT 指令时清空当前任务,确保开门等基础行为不受影响 - scope.launch(Dispatchers.Main) { - (context as? MainActivity)?.setCurrentTask("") - } val action = obj.optString("action", obj.optString("cmd", obj.optString("type", ""))).lowercase() + val actionsResetTask = setOf("recharge", "goto", "notification", "reception", "patrol", "repose", "turn", "tilt", "terminate") + if (action in actionsResetTask) { + scope.launch(Dispatchers.Main) { + onSetCurrentTask("") + } + } when (action) { "recharge" -> { speak("前往充电桩", "zh") @@ -291,7 +308,7 @@ class MqttManager( val text = obj.optString("text", obj.optString("content", "")) val lang = obj.optString("lang", "") scope.launch(Dispatchers.Main) { - (context as? MainActivity)?.markSpeechTaskActive() + onMarkSpeechTaskActive() } processStreamText(text, lang) } @@ -299,7 +316,7 @@ class MqttManager( val location = obj.optString("location", obj.optString("target", "")) val text = obj.optString("text", obj.optString("content", "")) scope.launch(Dispatchers.Main) { - (context as? MainActivity)?.startNotificationMode(location, text) + onStartNotificationMode(location, text) } } "repose" -> { @@ -331,7 +348,7 @@ class MqttManager( } "terminate" -> { scope.launch(Dispatchers.Main) { - (context as? MainActivity)?.setCurrentTask("") + onSetCurrentTask("") } navController.stop() stopTts() @@ -341,7 +358,7 @@ class MqttManager( } "status" -> { scope.launch(Dispatchers.Main) { - (context as? MainActivity)?.publishStatusSnapshot("command", true) + onPublishStatusSnapshot("command", true) } } "patrol" -> { @@ -369,11 +386,10 @@ class MqttManager( } } scope.launch(Dispatchers.Main) { - val activity = context as? MainActivity if (patrolLocations.isNotEmpty()) { - activity?.startPatrolMode(patrolLocations, times, waiting, nonStop) + onStartPatrolMode(patrolLocations, times, waiting, nonStop) } else { - activity?.setCurrentTask("") + onSetCurrentTask("") } } } @@ -383,7 +399,7 @@ class MqttManager( val text = obj.optString("text", "你是我要接待的贵宾吗?") val destination = obj.optString("destination", "会议室") scope.launch(Dispatchers.Main) { - (context as? MainActivity)?.startReceptionMode(location, text, destination) + onStartReceptionMode(location, text, destination) } } else -> Log.w(TAG, "Unknown command action: $action") @@ -582,7 +598,7 @@ class MqttManager( processNextTts() } if (!isTtsPaused && !isTtsBusy && ttsQueue.isEmpty() && speechBuffer.isEmpty()) { - (context as? MainActivity)?.clearSpeechTaskIfActive() + onClearSpeechTaskIfActive() } } else -> {} diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/NavController.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/NavController.kt index 32fb431..189fbb2 100644 --- a/app/src/main/java/com/example/lzwcai_terminal_temi/NavController.kt +++ b/app/src/main/java/com/example/lzwcai_terminal_temi/NavController.kt @@ -5,11 +5,9 @@ import com.robotemi.sdk.Robot class NavController(private val robot: Robot) { private val TAG = "NavController" - private var playmode = false - fun recharge(): Boolean { - playmode = !playmode - robot.goTo("home base", playmode) + fun recharge(backwards: Boolean = True): Boolean { + robot.goTo("home base", backwards) return true } diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/RobotEventHandler.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/RobotEventHandler.kt new file mode 100644 index 0000000..52a46a9 --- /dev/null +++ b/app/src/main/java/com/example/lzwcai_terminal_temi/RobotEventHandler.kt @@ -0,0 +1,33 @@ +package com.example.lzwcai_terminal_temi + +class RobotEventHandler { + private val abortStatuses = setOf("abort", "aborted", "canceled", "cancelled", "stopped", "stop", "failed", "error") + private val movingStatuses = setOf("start", "starting", "going", "moving", "navigating", "calculating", "recalculating") + + fun isAbortStatus(status: String): Boolean { + return status.lowercase() in abortStatuses + } + + fun isMovingStatus(status: String): Boolean { + return status.lowercase() in movingStatuses + } + + fun normalizeLocation(value: String?): String { + return value.orEmpty() + .trim() + .lowercase() + .replace(" ", "") + .replace("_", "") + .replace("-", "") + } + + fun normalizeAngle(angle: Float): Float { + var normalized = angle % 360f + if (normalized > 180f) { + normalized -= 360f + } else if (normalized < -180f) { + normalized += 360f + } + return normalized + } +} diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/SettingsActivity.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/SettingsActivity.kt index 8a11c45..bc87602 100644 --- a/app/src/main/java/com/example/lzwcai_terminal_temi/SettingsActivity.kt +++ b/app/src/main/java/com/example/lzwcai_terminal_temi/SettingsActivity.kt @@ -14,12 +14,19 @@ import android.view.WindowManager import android.view.inputmethod.InputMethodManager import android.widget.ArrayAdapter import android.widget.Toast +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import com.example.lzwcai_terminal_temi.databinding.ActivitySettingsBinding import kotlin.system.exitProcess import com.robotemi.sdk.Robot import android.graphics.drawable.GradientDrawable import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.net.URL class SettingsActivity : AppCompatActivity() { @@ -31,6 +38,7 @@ class SettingsActivity : AppCompatActivity() { private val specialStateKey = "special_state" private lateinit var prefs: SharedPreferences private val agentDempIdKey = "agent_demp_id" + private val uiScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -41,10 +49,16 @@ class SettingsActivity : AppCompatActivity() { robot = Robot.getInstance() prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE) - val savedIp = prefs.getString("network_ip", "") - binding.etIpAddress.setText(savedIp) + val savedBaseUrl = HttpManager.getBaseUrl(this) + binding.etIpAddress.setText(savedBaseUrl) val savedDempId = prefs.getString(agentDempIdKey, "") binding.etAgentDempId.setText(savedDempId) + val savedLoginUsername = prefs.getString(HttpManager.PREF_KEY_LOGIN_USERNAME, "") + val savedLoginPassword = prefs.getString(HttpManager.PREF_KEY_LOGIN_PASSWORD, "") + binding.etLoginUsername.setText(savedLoginUsername) + binding.etLoginPassword.setText(savedLoginPassword) + binding.etActivationCode.setText(prefs.getString(HttpManager.PREF_KEY_ACTIVATION_CODE, "")) + binding.etDeviceName.setText(prefs.getString(HttpManager.PREF_KEY_DEVICE_NAME, "")) val savedLiveKitUrl = prefs.getString(LiveKitManager.PREF_KEY_URL, resolveLiveKitUrl()) val savedLiveKitRoom = prefs.getString(LiveKitManager.PREF_KEY_ROOM, LiveKitManager.DEFAULT_ROOM) val savedLiveKitToken = prefs.getString(LiveKitManager.PREF_KEY_TOKEN, "") @@ -55,26 +69,112 @@ class SettingsActivity : AppCompatActivity() { binding.switchLiveKitAuto.setOnCheckedChangeListener(null) binding.switchLiveKitAuto.isChecked = isLiveKitEnabled - // Set Version Name - val versionName = "2603131822" + val versionName = getAppVersionName() binding.tvVersion.text = getString(R.string.version_prefix, versionName) + updateActivationUi(isActivated()) binding.root.setOnClickListener { hideKeyboard() } binding.btnSave.setOnClickListener { hideKeyboard() - val ip = binding.etIpAddress.text.toString().trim() + val baseUrl = normalizeBaseUrl(binding.etIpAddress.text.toString()) val dempId = binding.etAgentDempId.text?.toString()?.trim().orEmpty() - if (ip.isNotEmpty()) { - prefs.edit() - .putString("network_ip", ip) + val loginUsername = binding.etLoginUsername.text?.toString()?.trim().orEmpty() + val loginPassword = binding.etLoginPassword.text?.toString().orEmpty() + if (baseUrl.isNotEmpty()) { + val oldBaseUrl = HttpManager.getBaseUrl(this) + val changed = oldBaseUrl != baseUrl + val editor = prefs.edit() + .putString(HttpManager.PREF_KEY_BASE_URL, baseUrl) .putString(agentDempIdKey, dempId) - .apply() - Toast.makeText(this, getString(R.string.msg_ip_saved, ip), Toast.LENGTH_SHORT).show() - finish() + .putString(HttpManager.PREF_KEY_LOGIN_USERNAME, loginUsername) + .putString(HttpManager.PREF_KEY_LOGIN_PASSWORD, loginPassword) + val host = parseHostFromBaseUrl(baseUrl) + if (!host.isNullOrEmpty()) { + editor.putString("network_ip", host) + } + if (changed) { + editor.remove(HttpManager.PREF_KEY_ACTIVATION_CODE) + .remove(HttpManager.PREF_KEY_DEVICE_NAME) + .putBoolean(HttpManager.PREF_KEY_ACTIVATED, false) + .remove(HttpManager.PREF_KEY_MQTT_USERNAME) + .remove(HttpManager.PREF_KEY_MQTT_PASSWORD) + .remove(HttpManager.PREF_KEY_OD_WFID) + .remove(HttpManager.PREF_KEY_OD_WF_KEY) + .remove(HttpManager.PREF_KEY_CD_WFID) + .remove(HttpManager.PREF_KEY_CD_WF_KEY) + binding.etActivationCode.setText("") + binding.etDeviceName.setText("") + updateActivationUi(false) + Toast.makeText(this, getString(R.string.msg_base_url_changed_reset), Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this, getString(R.string.msg_base_url_saved, baseUrl), Toast.LENGTH_SHORT).show() + } + editor.apply() } else { Toast.makeText(this, getString(R.string.msg_invalid_ip), Toast.LENGTH_SHORT).show() - Log.w("SettingsActivity", "Invalid IP attempt") + Log.w("SettingsActivity", "Invalid base_url attempt") + } + } + + binding.btnActivate.setOnClickListener { + hideKeyboard() + val activationCode = binding.etActivationCode.text?.toString()?.trim().orEmpty() + val deviceName = binding.etDeviceName.text?.toString()?.trim().orEmpty() + val baseUrl = normalizeBaseUrl(binding.etIpAddress.text?.toString().orEmpty()) + val loginUsername = binding.etLoginUsername.text?.toString()?.trim().orEmpty() + val loginPassword = binding.etLoginPassword.text?.toString().orEmpty() + if (activationCode.isBlank() || deviceName.isBlank() || baseUrl.isBlank() || loginUsername.isBlank() || loginPassword.isBlank()) { + Toast.makeText(this, getString(R.string.msg_input_required), Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + prefs.edit() + .putString(HttpManager.PREF_KEY_BASE_URL, baseUrl) + .putString(HttpManager.PREF_KEY_LOGIN_USERNAME, loginUsername) + .putString(HttpManager.PREF_KEY_LOGIN_PASSWORD, loginPassword) + .apply() + val deviceId = getOrCreateDeviceId() + binding.btnActivate.isEnabled = false + uiScope.launch { + val activateSuccess = HttpManager.activateDevice( + context = this@SettingsActivity, + activationCode = activationCode, + deviceName = deviceName, + deviceId = deviceId + ) + if (!activateSuccess) { + binding.btnActivate.isEnabled = true + Toast.makeText(this@SettingsActivity, getString(R.string.msg_activate_failed), Toast.LENGTH_SHORT).show() + return@launch + } + val runtimeConfigs = HttpManager.fetchRuntimeConfigs(this@SettingsActivity) + val mqttUser = runtimeConfigs?.get(HttpManager.PREF_KEY_MQTT_USERNAME).orEmpty() + val mqttPass = runtimeConfigs?.get(HttpManager.PREF_KEY_MQTT_PASSWORD).orEmpty() + val odWfid = runtimeConfigs?.get(HttpManager.PREF_KEY_OD_WFID).orEmpty() + val odWfKey = runtimeConfigs?.get(HttpManager.PREF_KEY_OD_WF_KEY).orEmpty() + val cdWfid = runtimeConfigs?.get(HttpManager.PREF_KEY_CD_WFID).orEmpty() + val cdWfKey = runtimeConfigs?.get(HttpManager.PREF_KEY_CD_WF_KEY).orEmpty() + val mqttReady = mqttUser.isNotBlank() && mqttPass.isNotBlank() + val workflowReady = odWfid.isNotBlank() && odWfKey.isNotBlank() && cdWfid.isNotBlank() && cdWfKey.isNotBlank() + val configReady = mqttReady && workflowReady + val editor = prefs.edit() + .putString(HttpManager.PREF_KEY_ACTIVATION_CODE, activationCode) + .putString(HttpManager.PREF_KEY_DEVICE_NAME, deviceName) + .putString(HttpManager.PREF_KEY_DEVICE_ID, deviceId) + .putBoolean(HttpManager.PREF_KEY_ACTIVATED, true) + .putString(HttpManager.PREF_KEY_MQTT_USERNAME, mqttUser) + .putString(HttpManager.PREF_KEY_MQTT_PASSWORD, mqttPass) + .putString(HttpManager.PREF_KEY_OD_WFID, odWfid) + .putString(HttpManager.PREF_KEY_OD_WF_KEY, odWfKey) + .putString(HttpManager.PREF_KEY_CD_WFID, cdWfid) + .putString(HttpManager.PREF_KEY_CD_WF_KEY, cdWfKey) + editor.apply() + updateActivationUi(true) + if (!configReady) { + Toast.makeText(this@SettingsActivity, getString(R.string.msg_fetch_mqtt_failed), Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this@SettingsActivity, getString(R.string.msg_activate_success), Toast.LENGTH_SHORT).show() + } } } @@ -107,6 +207,10 @@ class SettingsActivity : AppCompatActivity() { finish() } + binding.btnAbout.setOnClickListener { + showAboutDialog() + } + setupRestartButton() setupSpecialStateSwitch() setupLocationSelector() @@ -116,6 +220,17 @@ class SettingsActivity : AppCompatActivity() { override fun onResume() { super.onResume() refreshLocationList() + updateActivationUi(isActivated()) + } + + override fun onPause() { + super.onPause() + persistDraftInputs() + } + + override fun onDestroy() { + super.onDestroy() + uiScope.cancel() } private fun setupSpecialStateSwitch() { @@ -259,4 +374,84 @@ class SettingsActivity : AppCompatActivity() { } return LiveKitManager.DEFAULT_URL } + + private fun normalizeBaseUrl(input: String): String { + return input.trim().trimEnd('/') + } + + private fun parseHostFromBaseUrl(baseUrl: String): String? { + return runCatching { URL(baseUrl).host } + .getOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + } + + private fun isActivated(): Boolean { + return prefs.getBoolean(HttpManager.PREF_KEY_ACTIVATED, false) + } + + private fun updateActivationUi(activated: Boolean) { + val status = if (activated) getString(R.string.status_activated) else getString(R.string.status_not_activated) + binding.tvActivationStatus.text = getString(R.string.label_activation_status, status) + binding.activationContainer.visibility = if (activated) View.GONE else View.VISIBLE + binding.btnActivate.isEnabled = !activated + } + + private fun getOrCreateDeviceId(): String { + val saved = prefs.getString(HttpManager.PREF_KEY_DEVICE_ID, "").orEmpty().trim() + if (saved.isNotEmpty()) { + return saved + } + val serial = runCatching { robot.serialNumber }.getOrDefault("").trim() + val deviceId = if (serial.isNotEmpty()) serial else "unknown-device" + prefs.edit().putString(HttpManager.PREF_KEY_DEVICE_ID, deviceId).apply() + return deviceId + } + + private fun persistDraftInputs() { + prefs.edit() + .putString(HttpManager.PREF_KEY_LOGIN_USERNAME, binding.etLoginUsername.text?.toString()?.trim().orEmpty()) + .putString(HttpManager.PREF_KEY_LOGIN_PASSWORD, binding.etLoginPassword.text?.toString().orEmpty()) + .putString(agentDempIdKey, binding.etAgentDempId.text?.toString()?.trim().orEmpty()) + .apply() + } + + private fun maskActivationCode(code: String): String { + val value = code.trim() + if (value.length <= 2) { + return value + } + val stars = "*".repeat((value.length - 2).coerceAtLeast(1)) + return "${value.first()}$stars${value.last()}" + } + + private fun showAboutDialog() { + val baseUrl = HttpManager.getBaseUrl(this).ifEmpty { "-" } + val deviceId = prefs.getString(HttpManager.PREF_KEY_DEVICE_ID, "").orEmpty().ifEmpty { "-" } + val activationCode = prefs.getString(HttpManager.PREF_KEY_ACTIVATION_CODE, "").orEmpty() + val maskedCode = if (activationCode.isBlank()) "-" else maskActivationCode(activationCode) + val deviceName = prefs.getString(HttpManager.PREF_KEY_DEVICE_NAME, "").orEmpty().ifEmpty { "-" } + val activated = if (isActivated()) getString(R.string.status_activated) else getString(R.string.status_not_activated) + val version = getAppVersionName() + val message = listOf( + getString(R.string.about_base_url, baseUrl), + getString(R.string.about_device_id, deviceId), + getString(R.string.about_activation_code, maskedCode), + getString(R.string.about_device_name, deviceName), + getString(R.string.about_activated, activated), + getString(R.string.about_version, version) + ).joinToString("\n") + AlertDialog.Builder(this) + .setTitle(getString(R.string.title_about)) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + private fun getAppVersionName(): String { + return runCatching { + val packageInfo = packageManager.getPackageInfo(packageName, 0) + packageInfo.versionName ?: "--" + }.getOrDefault("--") + } } diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/UiState.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/UiState.kt new file mode 100644 index 0000000..135881c --- /dev/null +++ b/app/src/main/java/com/example/lzwcai_terminal_temi/UiState.kt @@ -0,0 +1,31 @@ +package com.example.lzwcai_terminal_temi + +import android.content.Context +import android.graphics.drawable.GradientDrawable +import android.view.View +import androidx.core.content.ContextCompat +import com.example.lzwcai_terminal_temi.databinding.ActivityMainBinding + +class UiState( + private val context: Context, + private val binding: ActivityMainBinding +) { + fun setActivationRequired(required: Boolean) { + binding.tvActivationRequired.visibility = if (required) View.VISIBLE else View.GONE + } + + fun setNetworkErrorVisible(visible: Boolean) { + binding.tvNetworkError.visibility = if (visible) View.VISIBLE else View.GONE + } + + fun updateConnectionIndicator(liveKitConnected: Boolean, mqttConnected: Boolean) { + val colorRes = when { + !liveKitConnected && !mqttConnected -> android.R.color.holo_red_dark + !liveKitConnected && mqttConnected -> android.R.color.holo_blue_light + liveKitConnected && !mqttConnected -> android.R.color.holo_orange_light + else -> android.R.color.holo_green_light + } + val indicatorDrawable = binding.statusIndicator.background as GradientDrawable + indicatorDrawable.setColor(ContextCompat.getColor(context, colorRes)) + } +} diff --git a/app/src/main/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml index 1810cab..b54fb72 100644 --- a/app/src/main/res/layout-land/activity_main.xml +++ b/app/src/main/res/layout-land/activity_main.xml @@ -51,6 +51,27 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/statusIndicator" /> + + @@ -118,6 +117,38 @@ android:textColor="@color/text_primary" /> + + + + + + + + + +