feat: 添加设备激活功能并重构网络配置

- 引入设备激活流程,支持通过激活码和服务端配置激活设备
- 重构网络配置,将IP地址配置改为base_url配置
- 新增激活状态UI显示和激活提示横幅
- 添加ConnectionCoordinator统一管理MQTT和LiveKit连接
- 新增RobotEventHandler处理机器人状态和位置标准化
- 新增UiState类集中管理UI状态更新
- 在设置页面添加关于对话框显示设备信息
- 更新网络安全配置,限制明文流量仅允许本地地址
This commit is contained in:
2026-04-20 18:57:18 +08:00
parent 8698dfacf2
commit aa446a6046
15 changed files with 1038 additions and 183 deletions

View File

@@ -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">
<meta-data
android:name="com.robotemi.sdk.metadata.SKILL"

View File

@@ -0,0 +1,146 @@
package com.example.lzwcai_terminal_temi
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.robotemi.sdk.Robot
import java.net.URL
class ConnectionCoordinator(
private val context: Context,
private val prefs: SharedPreferences,
private val robot: Robot,
private val navController: NavController,
private val liveKitManagerProvider: () -> 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<String>, 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() }
}
}

View File

@@ -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<String, String>? = 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<String, String>()
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<String, String>? = 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()
}
}
}

View File

@@ -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,15 +266,22 @@ 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()
}
}
override fun onStop() {
super.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<String, Any>()
)
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<String, Any>()
)
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<String, Any>()
)
}
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() {

View File

@@ -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<String>, 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 -> {}

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,6 +51,27 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/statusIndicator" />
<TextView
android:id="@+id/tvActivationRequired"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="24dp"
android:background="@android:color/holo_orange_dark"
android:gravity="center"
android:paddingStart="12dp"
android:paddingTop="8dp"
android:paddingEnd="12dp"
android:paddingBottom="8dp"
android:text="@string/msg_activation_required"
android:textColor="@android:color/white"
android:textSize="18sp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvNetworkError" />
<com.example.lzwcai_terminal_temi.AnimatedEmojiView
android:id="@+id/animatedEmojiView"
android:layout_width="0dp"

View File

@@ -96,8 +96,7 @@
android:id="@+id/etIpAddress"
android:layout_width="match_parent"
android:layout_height="80dp"
android:inputType="number|numberDecimal"
android:digits="0123456789."
android:inputType="textUri"
android:textSize="20sp"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
@@ -118,6 +117,38 @@
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.App.TextInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/hint_login_username">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etLoginUsername"
android:layout_width="match_parent"
android:layout_height="72dp"
android:inputType="text"
android:textSize="18sp"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.App.TextInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/hint_login_password">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etLoginPassword"
android:layout_width="match_parent"
android:layout_height="72dp"
android:inputType="textPassword"
android:textSize="18sp"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/btnSave"
style="@style/Widget.App.Button"
@@ -127,6 +158,72 @@
android:textSize="20sp"
android:text="@string/btn_save" />
<TextView
android:id="@+id/tvActivationStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textColor="@color/text_secondary"
android:textSize="16sp"
tools:text="激活状态:未激活" />
<LinearLayout
android:id="@+id/activationContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:text="@string/label_activation_config"
android:textColor="@color/text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.App.TextInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/hint_activation_code">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etActivationCode"
android:layout_width="match_parent"
android:layout_height="72dp"
android:inputType="text"
android:textSize="18sp"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.App.TextInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/hint_device_name">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etDeviceName"
android:layout_width="match_parent"
android:layout_height="72dp"
android:inputType="text"
android:textSize="18sp"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/btnActivate"
style="@style/Widget.App.Button"
android:layout_width="match_parent"
android:layout_height="72dp"
android:layout_marginTop="12dp"
android:text="@string/btn_activate"
android:textSize="20sp" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
@@ -310,7 +407,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="特殊状态"
android:text="@string/label_special_state"
android:textColor="@color/text_primary"
android:textSize="22sp"
android:textStyle="bold" />
@@ -319,7 +416,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="启用特定场景下的行为逻辑"
android:text="@string/desc_special_state"
android:textColor="@color/text_secondary"
android:textSize="16sp" />
</LinearLayout>
@@ -387,6 +484,15 @@
android:textSize="20sp"
android:text="@string/btn_clear_task" />
<Button
android:id="@+id/btnAbout"
style="@style/Widget.App.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="72dp"
android:layout_marginTop="16dp"
android:text="@string/btn_about"
android:textSize="20sp" />
<TextView
android:id="@+id/tvVersion"
android:layout_width="wrap_content"

View File

@@ -51,6 +51,27 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/statusIndicator" />
<TextView
android:id="@+id/tvActivationRequired"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="24dp"
android:background="@android:color/holo_orange_dark"
android:gravity="center"
android:paddingStart="12dp"
android:paddingTop="8dp"
android:paddingEnd="12dp"
android:paddingBottom="8dp"
android:text="@string/msg_activation_required"
android:textColor="@android:color/white"
android:textSize="18sp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvNetworkError" />
<com.example.lzwcai_terminal_temi.AnimatedEmojiView
android:id="@+id/animatedEmojiView"
android:layout_width="0dp"

View File

@@ -87,8 +87,7 @@
android:id="@+id/etIpAddress"
android:layout_width="match_parent"
android:layout_height="80dp"
android:inputType="number|numberDecimal"
android:digits="0123456789."
android:inputType="textUri"
android:textSize="20sp"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
@@ -109,6 +108,38 @@
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.App.TextInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/hint_login_username">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etLoginUsername"
android:layout_width="match_parent"
android:layout_height="72dp"
android:inputType="text"
android:textSize="18sp"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.App.TextInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/hint_login_password">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etLoginPassword"
android:layout_width="match_parent"
android:layout_height="72dp"
android:inputType="textPassword"
android:textSize="18sp"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/btnSave"
style="@style/Widget.App.Button"
@@ -118,6 +149,72 @@
android:textSize="20sp"
android:text="@string/btn_save" />
<TextView
android:id="@+id/tvActivationStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textColor="@color/text_secondary"
android:textSize="16sp"
tools:text="激活状态:未激活" />
<LinearLayout
android:id="@+id/activationContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:text="@string/label_activation_config"
android:textColor="@color/text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.App.TextInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/hint_activation_code">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etActivationCode"
android:layout_width="match_parent"
android:layout_height="72dp"
android:inputType="text"
android:textSize="18sp"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.App.TextInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/hint_device_name">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etDeviceName"
android:layout_width="match_parent"
android:layout_height="72dp"
android:inputType="text"
android:textSize="18sp"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/btnActivate"
style="@style/Widget.App.Button"
android:layout_width="match_parent"
android:layout_height="72dp"
android:layout_marginTop="12dp"
android:text="@string/btn_activate"
android:textSize="20sp" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
@@ -293,7 +390,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="特殊状态"
android:text="@string/label_special_state"
android:textColor="@color/text_primary"
android:textSize="22sp"
android:textStyle="bold" />
@@ -302,7 +399,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="启用特定场景下的行为逻辑"
android:text="@string/desc_special_state"
android:textColor="@color/text_secondary"
android:textSize="16sp" />
</LinearLayout>
@@ -371,6 +468,15 @@
android:textSize="20sp"
android:text="@string/btn_clear_task" />
<Button
android:id="@+id/btnAbout"
style="@style/Widget.App.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="72dp"
android:layout_marginTop="16dp"
android:text="@string/btn_about"
android:textSize="20sp" />
<TextView
android:id="@+id/tvVersion"
android:layout_width="wrap_content"

View File

@@ -4,9 +4,9 @@
<string name="title_main">主界面</string>
<string name="btn_settings">设置</string>
<string name="title_settings">设置</string>
<string name="label_ip_config">网络 IP 配置</string>
<string name="hint_ip_address">请输入 IP 地址 (例如 192.168.1.100)</string>
<string name="btn_save">保存</string>
<string name="label_ip_config">服务配置</string>
<string name="hint_ip_address">请输入 base_url (例如 http://192.168.11.24:8088)</string>
<string name="btn_save">确认生效</string>
<string name="btn_back">返回主界面</string>
<string name="msg_ip_saved">IP 已保存: %1$s</string>
<string name="msg_invalid_ip">请输入有效的 IP 地址</string>
@@ -37,4 +37,30 @@
<string name="livekit_status_permission">需要麦克风/摄像头权限</string>
<string name="btn_clear_task">清除当前任务</string>
<string name="msg_task_cleared">当前任务已清除</string>
<string name="hint_login_username">请输入登录用户名</string>
<string name="hint_login_password">请输入登录密码</string>
<string name="label_activation_config">设备激活</string>
<string name="hint_activation_code">请输入激活码</string>
<string name="hint_device_name">请输入机器名称</string>
<string name="btn_activate">激活设备</string>
<string name="label_activation_status">激活状态:%1$s</string>
<string name="status_activated">已激活</string>
<string name="status_not_activated">未激活</string>
<string name="msg_base_url_saved">base_url 已生效: %1$s</string>
<string name="msg_base_url_changed_reset">base_url 已变更,激活信息已重置</string>
<string name="msg_activate_success">激活成功MQTT 配置已更新</string>
<string name="msg_activate_failed">激活失败,请检查账号、网络或激活码</string>
<string name="msg_fetch_mqtt_failed">激活成功,但拉取运行配置失败</string>
<string name="msg_activation_required">请先激活应用</string>
<string name="btn_about">关于</string>
<string name="title_about">关于</string>
<string name="about_base_url">base_url: %1$s</string>
<string name="about_device_id">deviceId: %1$s</string>
<string name="about_activation_code">activationCode: %1$s</string>
<string name="about_device_name">deviceName: %1$s</string>
<string name="about_activated">激活状态: %1$s</string>
<string name="about_version">版本: %1$s</string>
<string name="msg_input_required">请完整填写必填项</string>
<string name="label_special_state">特殊状态</string>
<string name="desc_special_state">启用特定场景下的行为逻辑</string>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">192.168.11.24</domain>
</domain-config>
</network-security-config>