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:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Lzwcaiterminaltemi" android:theme="@style/Theme.Lzwcaiterminaltemi"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="false"
tools:targetApi="31"> tools:targetApi="31">
<meta-data <meta-data
android:name="com.robotemi.sdk.metadata.SKILL" 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 { object HttpManager {
private const val TAG = "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) { suspend fun workflow_execute(context: Context, apiKey: String, workflowId: String, inputs: Any): String? = withContext(Dispatchers.IO) {
var result: String? = null var result: String? = null
try { try {
val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) val baseUrl = getBaseUrl(context)
val ip = prefs.getString("network_ip", "") ?: "" if (baseUrl.isEmpty()) {
if (ip.isEmpty()) { Log.e(TAG, "No base_url configured")
Log.e(TAG, "No IP address configured")
return@withContext null 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 url = URL(workflowUrl)
val connection = url.openConnection() as HttpURLConnection val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "POST" connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "application/json") connection.setRequestProperty("Content-Type", "application/json")
connection.setRequestProperty("X-API-Key", apiKey) connection.setRequestProperty("X-API-Key", apiKey)
connection.setRequestProperty("token", token)
connection.doOutput = true connection.doOutput = true
connection.doInput = true connection.doInput = true
connection.connectTimeout = 10000 connection.connectTimeout = 10000
@@ -65,4 +179,62 @@ object HttpManager {
} }
return@withContext result 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.WindowManager
import android.view.View import android.view.View
import android.util.Base64 import android.util.Base64
import android.graphics.drawable.GradientDrawable
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
@@ -56,7 +55,8 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
private lateinit var robot: Robot private lateinit var robot: Robot
private lateinit var binding: ActivityMainBinding 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 val mainScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private lateinit var prefs: SharedPreferences private lateinit var prefs: SharedPreferences
private val specialStateKey = "special_state" private val specialStateKey = "special_state"
@@ -101,12 +101,14 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
private var receptionAnchorYaw: Float? = null private var receptionAnchorYaw: Float? = null
private lateinit var telemetryManager: TelemetryManager private lateinit var telemetryManager: TelemetryManager
private lateinit var taskController: TaskController private lateinit var taskController: TaskController
private val robotEventHandler = RobotEventHandler()
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater) binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
uiState = UiState(this, binding)
LogManager.configureLogcat( LogManager.configureLogcat(
tags = listOf( tags = listOf(
@@ -162,6 +164,38 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
clearTaskLauncher.launch(Intent(this, SettingsActivity::class.java)) 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( taskController = TaskController(
scope = mainScope, scope = mainScope,
navController = navCon, navController = navCon,
@@ -208,13 +242,14 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
locationProvider = { lastArrivalLocation }, locationProvider = { lastArrivalLocation },
mqttConnectedProvider = { isMqttConnected }, mqttConnectedProvider = { isMqttConnected },
liveKitConnectedProvider = { isLiveKitConnected }, liveKitConnectedProvider = { isLiveKitConnected },
publish = { topic, payload -> mqttManager?.publish(topic, payload) }, publish = { topic, payload -> connectionCoordinator.publish(topic, payload) },
onLowBattery = { _ -> onLowBattery = { _ ->
val ttsRequest = TtsRequest.create("电量低,请及时充电。", false, language = TtsRequest.Language.ZH_CN) val ttsRequest = TtsRequest.create("电量低,请及时充电。", false, language = TtsRequest.Language.ZH_CN)
robot.speak(ttsRequest) robot.speak(ttsRequest)
} }
) )
updateActivationBanner()
updateMqttConnection() updateMqttConnection()
updateLiveKitStatusSnapshot() updateLiveKitStatusSnapshot()
} }
@@ -231,14 +266,21 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
robot.addOnCurrentPositionChangedListener(this) robot.addOnCurrentPositionChangedListener(this)
robot.addOnRequestPermissionResultListener(this) robot.addOnRequestPermissionResultListener(this)
robot.constraintBeWith() robot.constraintBeWith()
if (mqttManager == null) { updateActivationBanner()
updateMqttConnection() if (!isActivated()) {
connectionCoordinator.disconnectMqtt()
connectionCoordinator.disconnectLiveKit()
setMqttConnectionStatus(false)
setLiveKitStatus(false)
stopBlinking()
telemetryManager.stop()
Log.w("MainActivity", "Application is not activated yet.")
} else { } else {
mqttManager?.connect() updateMqttConnection()
updateLiveKitConnection()
startBlinking()
telemetryManager.start()
} }
updateLiveKitConnection()
startBlinking()
telemetryManager.start()
} }
override fun onStop() { override fun onStop() {
@@ -252,8 +294,8 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
robot.removeOnMovementStatusChangedListener(this) robot.removeOnMovementStatusChangedListener(this)
robot.removeOnCurrentPositionChangedListener(this) robot.removeOnCurrentPositionChangedListener(this)
robot.removeOnRequestPermissionResultListener(this) robot.removeOnRequestPermissionResultListener(this)
// mqttManager?.disconnect() // Keep MQTT alive in background/settings // Keep MQTT alive in background/settings
liveKitManager?.disconnect() connectionCoordinator.disconnectLiveKit()
stopBlinking() stopBlinking()
telemetryManager.stop() telemetryManager.stop()
} }
@@ -261,8 +303,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
prefs.unregisterOnSharedPreferenceChangeListener(this) prefs.unregisterOnSharedPreferenceChangeListener(this)
mqttManager?.disconnect() connectionCoordinator.release()
liveKitManager?.release()
LogManager.stopLogcatListener() LogManager.stopLogcatListener()
mainScope.cancel() mainScope.cancel()
Log.i("MainActivity", "All resources released on destroy.") Log.i("MainActivity", "All resources released on destroy.")
@@ -290,7 +331,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
} }
override fun onTtsStatusChanged(ttsRequest: TtsRequest) { override fun onTtsStatusChanged(ttsRequest: TtsRequest) {
mqttManager?.handleTtsStatusChange(ttsRequest) connectionCoordinator.handleTtsStatusChange(ttsRequest)
when (ttsRequest.status) { when (ttsRequest.status) {
TtsRequest.Status.STARTED -> { TtsRequest.Status.STARTED -> {
Log.i("MainActivity", "TTS 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) { override fun onGoToLocationStatusChanged(location: String, status: String, descriptionId: Int, description: String) {
val normalized = status.lowercase() val normalized = status.lowercase()
val isAbort = normalized in setOf("abort", "aborted", "canceled", "cancelled", "stopped", "stop", "failed", "error") val isAbort = robotEventHandler.isAbortStatus(status)
val isMoving = normalized in setOf("start", "starting", "going", "moving", "navigating", "calculating", "recalculating") val isMoving = robotEventHandler.isMovingStatus(status)
if (isMoving) { if (isMoving) {
cancelAutoRecharge("movement_started:$location/$status") cancelAutoRecharge("movement_started:$location/$status")
taskController.cancelTaskWaitTimeout() taskController.cancelTaskWaitTimeout()
@@ -360,7 +401,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
lastArrivalLocation = location lastArrivalLocation = location
lastArrivalAt = now lastArrivalAt = now
prefs.edit().putString("current_location", location).apply() prefs.edit().putString("current_location", location).apply()
if (normalizeLocation(location) == "homebase") { if (robotEventHandler.normalizeLocation(location) == "homebase") {
navCon.tiltAngle(20) navCon.tiltAngle(20)
} }
if (taskController.currentTask == "patrol") { if (taskController.currentTask == "patrol") {
@@ -434,12 +475,12 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
} }
} }
if (taskController.handleDetectionStateChanged(state)) { if (taskController.handleDetectionStateChanged(state)) {
Log.i("MainActivity", "what the f**k") Log.i("MainActivity", "Detection event handled by task controller.")
return return
} }
val isSpecialState = isSpecialStateEnabled() val isSpecialState = isSpecialStateEnabled()
val isIdleTask = taskController.currentTask.isEmpty() || taskController.currentTask == "speech" 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 val canHandleDoor = isIdleTask && atHomeBase && !taskController.isLeavingHomeBase && !isSpecialState
if (canHandleDoor) { if (canHandleDoor) {
when (state) { when (state) {
@@ -449,12 +490,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
val ttsRequest = TtsRequest.create("正在为你开门,请稍等。", false, language = TtsRequest.Language.ZH_CN) val ttsRequest = TtsRequest.create("正在为你开门,请稍等。", false, language = TtsRequest.Language.ZH_CN)
robot.speak(ttsRequest) robot.speak(ttsRequest)
mainScope.launch { mainScope.launch {
val result = HttpManager.workflow_execute( val result = executeDoorWorkflow(openDoor = true)
context = this@MainActivity,
apiKey = "wf_865e80f5fc1a4a319474a21d47470863",
workflowId = "2031297462423851009",
inputs = emptyMap<String, Any>()
)
if (result == null) { if (result == null) {
showNetworkErrorBanner() showNetworkErrorBanner()
} }
@@ -465,12 +501,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
val ttsRequest = TtsRequest.create("正准备关门,请小心被夹。", false, language = TtsRequest.Language.ZH_CN) val ttsRequest = TtsRequest.create("正准备关门,请小心被夹。", false, language = TtsRequest.Language.ZH_CN)
robot.speak(ttsRequest) robot.speak(ttsRequest)
mainScope.launch { mainScope.launch {
val result = HttpManager.workflow_execute( val result = executeDoorWorkflow(openDoor = false)
context = this@MainActivity,
apiKey = "wf_c02aa853371345dbb29572641d083c24",
workflowId = "2031634633458520065",
inputs = emptyMap<String, Any>()
)
if (result == null) { if (result == null) {
showNetworkErrorBanner() 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) { fun startReceptionMode(location: String, text: String, destination: String) {
taskController.startReceptionMode(location, text, destination) taskController.startReceptionMode(location, text, destination)
} }
@@ -532,8 +554,16 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
} }
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (key == "network_ip") { if (key == HttpManager.PREF_KEY_BASE_URL ||
Log.i("MainActivity", "IP address changed, re-initializing MQTT connection.") 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() updateMqttConnection()
updateLiveKitConnection() updateLiveKitConnection()
} }
@@ -562,53 +592,11 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
} }
private fun updateMqttConnection() { private fun updateMqttConnection() {
mqttManager?.shutdown() connectionCoordinator.updateMqttConnection(isActivated())
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.")
}
} }
private fun updateLiveKitConnection() { private fun updateLiveKitConnection() {
val enabled = prefs.getBoolean(LiveKitManager.PREF_KEY_ENABLED, true) connectionCoordinator.updateLiveKitConnection(isActivated())
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)
} }
private fun hasAudioPermission(): Boolean { private fun hasAudioPermission(): Boolean {
@@ -691,32 +679,17 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
} }
private fun updateLiveKitStatusSnapshot() { private fun updateLiveKitStatusSnapshot() {
val enabled = prefs.getBoolean(LiveKitManager.PREF_KEY_ENABLED, true) connectionCoordinator.updateLiveKitStatusSnapshot(isActivated())
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)
} }
private fun setLiveKitStatus(connected: Boolean) { private fun setLiveKitStatus(connected: Boolean) {
isLiveKitConnected = connected isLiveKitConnected = connected
updateConnectionIndicator() uiState.updateConnectionIndicator(isLiveKitConnected, isMqttConnected)
} }
private fun setMqttConnectionStatus(connected: Boolean) { private fun setMqttConnectionStatus(connected: Boolean) {
isMqttConnected = connected isMqttConnected = connected
updateConnectionIndicator() uiState.updateConnectionIndicator(isLiveKitConnected, isMqttConnected)
if (connected) { if (connected) {
telemetryManager.publishStatusSnapshot("mqtt_connected", true) telemetryManager.publishStatusSnapshot("mqtt_connected", true)
} }
@@ -733,7 +706,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
.put("topic", topicLabel) .put("topic", topicLabel)
.put("participant", participantLabel) .put("participant", participantLabel)
.put("ts", System.currentTimeMillis()) .put("ts", System.currentTimeMillis())
mqttManager?.publish("robot/asr", data.toString()) connectionCoordinator.publish("robot/asr", data.toString())
} }
private fun extractAsrText(payload: String): String? { private fun extractAsrText(payload: String): String? {
@@ -756,10 +729,10 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
private fun showNetworkErrorBanner() { private fun showNetworkErrorBanner() {
networkErrorJob?.cancel() networkErrorJob?.cancel()
binding.tvNetworkError.visibility = View.VISIBLE uiState.setNetworkErrorVisible(true)
networkErrorJob = mainScope.launch { networkErrorJob = mainScope.launch {
delay(5000L) delay(5000L)
binding.tvNetworkError.visibility = View.GONE uiState.setNetworkErrorVisible(false)
} }
} }
@@ -767,7 +740,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
if (taskController.currentTask.isNotEmpty()) { if (taskController.currentTask.isNotEmpty()) {
return return
} }
if (normalizeLocation(lastArrivalLocation) == "homebase") { if (robotEventHandler.normalizeLocation(lastArrivalLocation) == "homebase") {
return return
} }
autoRechargeJob?.cancel() autoRechargeJob?.cancel()
@@ -776,7 +749,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
if (taskController.currentTask.isNotEmpty()) { if (taskController.currentTask.isNotEmpty()) {
return@launch return@launch
} }
if (normalizeLocation(lastArrivalLocation) == "homebase") { if (robotEventHandler.normalizeLocation(lastArrivalLocation) == "homebase") {
return@launch return@launch
} }
Log.i("MainActivity", "Auto recharge triggered after idle arrival timeout.") 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 anchorYaw = receptionAnchorYaw ?: return
val currentYaw = latestYaw ?: return val currentYaw = latestYaw ?: return
val delta = normalizeAngle(anchorYaw - currentYaw) val delta = robotEventHandler.normalizeAngle(anchorYaw - currentYaw)
// Ignore tiny drift to avoid jitter. // Ignore tiny drift to avoid jitter.
if (kotlin.math.abs(delta) < 8f) { if (kotlin.math.abs(delta) < 8f) {
return return
@@ -824,33 +797,33 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG
Log.i("MainActivity", "Reception facing recovered by $turn degrees (delta=$delta).") Log.i("MainActivity", "Reception facing recovered by $turn degrees (delta=$delta).")
} }
private fun normalizeAngle(angle: Float): Float { private suspend fun executeDoorWorkflow(openDoor: Boolean): String? {
var normalized = angle % 360f val workflowIdKey = if (openDoor) HttpManager.PREF_KEY_OD_WFID else HttpManager.PREF_KEY_CD_WFID
if (normalized > 180f) { val workflowApiKey = if (openDoor) HttpManager.PREF_KEY_OD_WF_KEY else HttpManager.PREF_KEY_CD_WF_KEY
normalized -= 360f val workflowId = prefs.getString(workflowIdKey, "").orEmpty().trim()
} else if (normalized < -180f) { val apiKey = prefs.getString(workflowApiKey, "").orEmpty().trim()
normalized += 360f 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() { private fun isActivated(): Boolean {
val colorRes = when { if (!::prefs.isInitialized) {
!isLiveKitConnected && !isMqttConnected -> android.R.color.holo_red_dark return false
!isLiveKitConnected && isMqttConnected -> android.R.color.holo_blue_light
isLiveKitConnected && !isMqttConnected -> android.R.color.holo_orange_light
else -> android.R.color.holo_green_light
} }
val indicatorDrawable = binding.statusIndicator.background as GradientDrawable return prefs.getBoolean(HttpManager.PREF_KEY_ACTIVATED, false)
indicatorDrawable.setColor(ContextCompat.getColor(this, colorRes))
} }
private fun resolveLiveKitUrl(): String { private fun updateActivationBanner() {
val savedUrl = prefs.getString(LiveKitManager.PREF_KEY_URL, "").orEmpty().trim() val activated = isActivated()
if (savedUrl.isNotEmpty()) { uiState.setActivationRequired(!activated)
return savedUrl
}
return LiveKitManager.DEFAULT_URL
} }
private fun startBlinking() { private fun startBlinking() {

View File

@@ -14,13 +14,21 @@ import java.util.LinkedList
import java.util.Queue import java.util.Queue
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
class MqttManager( class MqttManager(
private val context: Context, context: Context,
private val serverIp: String, private val serverIp: String,
private val robot: Robot, private val robot: Robot,
private val navController: NavController, 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 var mqttClient: MqttClient? = null
private val TAG = "MqttManager" private val TAG = "MqttManager"
private val brokerUri = "tcp://$serverIp:1883" private val brokerUri = "tcp://$serverIp:1883"
@@ -28,7 +36,7 @@ class MqttManager(
private val job = SupervisorJob() private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO + job) private val scope = CoroutineScope(Dispatchers.IO + job)
private var reconnectJob: Job? = null 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" private val agentDempIdKey = "agent_demp_id"
// Streaming text buffer // Streaming text buffer
@@ -88,6 +96,13 @@ class MqttManager(
updateConnectionStatus(true) updateConnectionStatus(true)
return@launch 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") Log.i(TAG, "Attempting to connect to MQTT broker at $brokerUri")
try { try {
val options = MqttConnectOptions().apply { val options = MqttConnectOptions().apply {
@@ -95,8 +110,8 @@ class MqttManager(
isCleanSession = true isCleanSession = true
connectionTimeout = 10 connectionTimeout = 10
keepAliveInterval = 60 keepAliveInterval = 60
userName = "lzwc" userName = username
password = "Lzwc@4187.".toCharArray() this.password = password.toCharArray()
} }
mqttClient?.connect(options) mqttClient?.connect(options)
} catch (e: MqttException) { } catch (e: MqttException) {
@@ -224,7 +239,7 @@ class MqttManager(
val isFinal = obj.optBoolean("is_final", false) val isFinal = obj.optBoolean("is_final", false)
val lang = obj.optString("lang", "").trim() val lang = obj.optString("lang", "").trim()
scope.launch(Dispatchers.Main) { scope.launch(Dispatchers.Main) {
(context as? MainActivity)?.markSpeechTaskActive() onMarkSpeechTaskActive()
} }
processStreamText(text, lang) processStreamText(text, lang)
if (isFinal) { if (isFinal) {
@@ -268,11 +283,13 @@ class MqttManager(
} }
private fun handleJsonCommand(obj: JSONObject) { 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 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) { when (action) {
"recharge" -> { "recharge" -> {
speak("前往充电桩", "zh") speak("前往充电桩", "zh")
@@ -291,7 +308,7 @@ class MqttManager(
val text = obj.optString("text", obj.optString("content", "")) val text = obj.optString("text", obj.optString("content", ""))
val lang = obj.optString("lang", "") val lang = obj.optString("lang", "")
scope.launch(Dispatchers.Main) { scope.launch(Dispatchers.Main) {
(context as? MainActivity)?.markSpeechTaskActive() onMarkSpeechTaskActive()
} }
processStreamText(text, lang) processStreamText(text, lang)
} }
@@ -299,7 +316,7 @@ class MqttManager(
val location = obj.optString("location", obj.optString("target", "")) val location = obj.optString("location", obj.optString("target", ""))
val text = obj.optString("text", obj.optString("content", "")) val text = obj.optString("text", obj.optString("content", ""))
scope.launch(Dispatchers.Main) { scope.launch(Dispatchers.Main) {
(context as? MainActivity)?.startNotificationMode(location, text) onStartNotificationMode(location, text)
} }
} }
"repose" -> { "repose" -> {
@@ -331,7 +348,7 @@ class MqttManager(
} }
"terminate" -> { "terminate" -> {
scope.launch(Dispatchers.Main) { scope.launch(Dispatchers.Main) {
(context as? MainActivity)?.setCurrentTask("") onSetCurrentTask("")
} }
navController.stop() navController.stop()
stopTts() stopTts()
@@ -341,7 +358,7 @@ class MqttManager(
} }
"status" -> { "status" -> {
scope.launch(Dispatchers.Main) { scope.launch(Dispatchers.Main) {
(context as? MainActivity)?.publishStatusSnapshot("command", true) onPublishStatusSnapshot("command", true)
} }
} }
"patrol" -> { "patrol" -> {
@@ -369,11 +386,10 @@ class MqttManager(
} }
} }
scope.launch(Dispatchers.Main) { scope.launch(Dispatchers.Main) {
val activity = context as? MainActivity
if (patrolLocations.isNotEmpty()) { if (patrolLocations.isNotEmpty()) {
activity?.startPatrolMode(patrolLocations, times, waiting, nonStop) onStartPatrolMode(patrolLocations, times, waiting, nonStop)
} else { } else {
activity?.setCurrentTask("") onSetCurrentTask("")
} }
} }
} }
@@ -383,7 +399,7 @@ class MqttManager(
val text = obj.optString("text", "你是我要接待的贵宾吗?") val text = obj.optString("text", "你是我要接待的贵宾吗?")
val destination = obj.optString("destination", "会议室") val destination = obj.optString("destination", "会议室")
scope.launch(Dispatchers.Main) { scope.launch(Dispatchers.Main) {
(context as? MainActivity)?.startReceptionMode(location, text, destination) onStartReceptionMode(location, text, destination)
} }
} }
else -> Log.w(TAG, "Unknown command action: $action") else -> Log.w(TAG, "Unknown command action: $action")
@@ -582,7 +598,7 @@ class MqttManager(
processNextTts() processNextTts()
} }
if (!isTtsPaused && !isTtsBusy && ttsQueue.isEmpty() && speechBuffer.isEmpty()) { if (!isTtsPaused && !isTtsBusy && ttsQueue.isEmpty() && speechBuffer.isEmpty()) {
(context as? MainActivity)?.clearSpeechTaskIfActive() onClearSpeechTaskIfActive()
} }
} }
else -> {} else -> {}

View File

@@ -5,11 +5,9 @@ import com.robotemi.sdk.Robot
class NavController(private val robot: Robot) { class NavController(private val robot: Robot) {
private val TAG = "NavController" private val TAG = "NavController"
private var playmode = false
fun recharge(): Boolean { fun recharge(backwards: Boolean = True): Boolean {
playmode = !playmode robot.goTo("home base", backwards)
robot.goTo("home base", playmode)
return true 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.view.inputmethod.InputMethodManager
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.example.lzwcai_terminal_temi.databinding.ActivitySettingsBinding import com.example.lzwcai_terminal_temi.databinding.ActivitySettingsBinding
import kotlin.system.exitProcess import kotlin.system.exitProcess
import com.robotemi.sdk.Robot import com.robotemi.sdk.Robot
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import androidx.core.content.ContextCompat 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() { class SettingsActivity : AppCompatActivity() {
@@ -31,6 +38,7 @@ class SettingsActivity : AppCompatActivity() {
private val specialStateKey = "special_state" private val specialStateKey = "special_state"
private lateinit var prefs: SharedPreferences private lateinit var prefs: SharedPreferences
private val agentDempIdKey = "agent_demp_id" private val agentDempIdKey = "agent_demp_id"
private val uiScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -41,10 +49,16 @@ class SettingsActivity : AppCompatActivity() {
robot = Robot.getInstance() robot = Robot.getInstance()
prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE) prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
val savedIp = prefs.getString("network_ip", "") val savedBaseUrl = HttpManager.getBaseUrl(this)
binding.etIpAddress.setText(savedIp) binding.etIpAddress.setText(savedBaseUrl)
val savedDempId = prefs.getString(agentDempIdKey, "") val savedDempId = prefs.getString(agentDempIdKey, "")
binding.etAgentDempId.setText(savedDempId) 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 savedLiveKitUrl = prefs.getString(LiveKitManager.PREF_KEY_URL, resolveLiveKitUrl())
val savedLiveKitRoom = prefs.getString(LiveKitManager.PREF_KEY_ROOM, LiveKitManager.DEFAULT_ROOM) val savedLiveKitRoom = prefs.getString(LiveKitManager.PREF_KEY_ROOM, LiveKitManager.DEFAULT_ROOM)
val savedLiveKitToken = prefs.getString(LiveKitManager.PREF_KEY_TOKEN, "") val savedLiveKitToken = prefs.getString(LiveKitManager.PREF_KEY_TOKEN, "")
@@ -55,26 +69,112 @@ class SettingsActivity : AppCompatActivity() {
binding.switchLiveKitAuto.setOnCheckedChangeListener(null) binding.switchLiveKitAuto.setOnCheckedChangeListener(null)
binding.switchLiveKitAuto.isChecked = isLiveKitEnabled binding.switchLiveKitAuto.isChecked = isLiveKitEnabled
// Set Version Name val versionName = getAppVersionName()
val versionName = "2603131822"
binding.tvVersion.text = getString(R.string.version_prefix, versionName) binding.tvVersion.text = getString(R.string.version_prefix, versionName)
updateActivationUi(isActivated())
binding.root.setOnClickListener { hideKeyboard() } binding.root.setOnClickListener { hideKeyboard() }
binding.btnSave.setOnClickListener { binding.btnSave.setOnClickListener {
hideKeyboard() hideKeyboard()
val ip = binding.etIpAddress.text.toString().trim() val baseUrl = normalizeBaseUrl(binding.etIpAddress.text.toString())
val dempId = binding.etAgentDempId.text?.toString()?.trim().orEmpty() val dempId = binding.etAgentDempId.text?.toString()?.trim().orEmpty()
if (ip.isNotEmpty()) { val loginUsername = binding.etLoginUsername.text?.toString()?.trim().orEmpty()
prefs.edit() val loginPassword = binding.etLoginPassword.text?.toString().orEmpty()
.putString("network_ip", ip) 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) .putString(agentDempIdKey, dempId)
.apply() .putString(HttpManager.PREF_KEY_LOGIN_USERNAME, loginUsername)
Toast.makeText(this, getString(R.string.msg_ip_saved, ip), Toast.LENGTH_SHORT).show() .putString(HttpManager.PREF_KEY_LOGIN_PASSWORD, loginPassword)
finish() 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 { } else {
Toast.makeText(this, getString(R.string.msg_invalid_ip), Toast.LENGTH_SHORT).show() 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() finish()
} }
binding.btnAbout.setOnClickListener {
showAboutDialog()
}
setupRestartButton() setupRestartButton()
setupSpecialStateSwitch() setupSpecialStateSwitch()
setupLocationSelector() setupLocationSelector()
@@ -116,6 +220,17 @@ class SettingsActivity : AppCompatActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
refreshLocationList() refreshLocationList()
updateActivationUi(isActivated())
}
override fun onPause() {
super.onPause()
persistDraftInputs()
}
override fun onDestroy() {
super.onDestroy()
uiScope.cancel()
} }
private fun setupSpecialStateSwitch() { private fun setupSpecialStateSwitch() {
@@ -259,4 +374,84 @@ class SettingsActivity : AppCompatActivity() {
} }
return LiveKitManager.DEFAULT_URL 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_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/statusIndicator" /> 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 <com.example.lzwcai_terminal_temi.AnimatedEmojiView
android:id="@+id/animatedEmojiView" android:id="@+id/animatedEmojiView"
android:layout_width="0dp" android:layout_width="0dp"

View File

@@ -96,8 +96,7 @@
android:id="@+id/etIpAddress" android:id="@+id/etIpAddress"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="80dp" android:layout_height="80dp"
android:inputType="number|numberDecimal" android:inputType="textUri"
android:digits="0123456789."
android:textSize="20sp" android:textSize="20sp"
android:textColor="@color/text_primary" /> android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
@@ -118,6 +117,38 @@
android:textColor="@color/text_primary" /> android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout> </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 <Button
android:id="@+id/btnSave" android:id="@+id/btnSave"
style="@style/Widget.App.Button" style="@style/Widget.App.Button"
@@ -127,6 +158,72 @@
android:textSize="20sp" android:textSize="20sp"
android:text="@string/btn_save" /> 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> </LinearLayout>
</androidx.cardview.widget.CardView> </androidx.cardview.widget.CardView>
@@ -310,7 +407,7 @@
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="特殊状态" android:text="@string/label_special_state"
android:textColor="@color/text_primary" android:textColor="@color/text_primary"
android:textSize="22sp" android:textSize="22sp"
android:textStyle="bold" /> android:textStyle="bold" />
@@ -319,7 +416,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:text="启用特定场景下的行为逻辑" android:text="@string/desc_special_state"
android:textColor="@color/text_secondary" android:textColor="@color/text_secondary"
android:textSize="16sp" /> android:textSize="16sp" />
</LinearLayout> </LinearLayout>
@@ -387,6 +484,15 @@
android:textSize="20sp" android:textSize="20sp"
android:text="@string/btn_clear_task" /> 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 <TextView
android:id="@+id/tvVersion" android:id="@+id/tvVersion"
android:layout_width="wrap_content" android:layout_width="wrap_content"

View File

@@ -51,6 +51,27 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/statusIndicator" /> 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 <com.example.lzwcai_terminal_temi.AnimatedEmojiView
android:id="@+id/animatedEmojiView" android:id="@+id/animatedEmojiView"
android:layout_width="0dp" android:layout_width="0dp"

View File

@@ -87,8 +87,7 @@
android:id="@+id/etIpAddress" android:id="@+id/etIpAddress"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="80dp" android:layout_height="80dp"
android:inputType="number|numberDecimal" android:inputType="textUri"
android:digits="0123456789."
android:textSize="20sp" android:textSize="20sp"
android:textColor="@color/text_primary" /> android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
@@ -109,6 +108,38 @@
android:textColor="@color/text_primary" /> android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout> </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 <Button
android:id="@+id/btnSave" android:id="@+id/btnSave"
style="@style/Widget.App.Button" style="@style/Widget.App.Button"
@@ -118,6 +149,72 @@
android:textSize="20sp" android:textSize="20sp"
android:text="@string/btn_save" /> 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> </LinearLayout>
</androidx.cardview.widget.CardView> </androidx.cardview.widget.CardView>
@@ -293,7 +390,7 @@
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="特殊状态" android:text="@string/label_special_state"
android:textColor="@color/text_primary" android:textColor="@color/text_primary"
android:textSize="22sp" android:textSize="22sp"
android:textStyle="bold" /> android:textStyle="bold" />
@@ -302,7 +399,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:text="启用特定场景下的行为逻辑" android:text="@string/desc_special_state"
android:textColor="@color/text_secondary" android:textColor="@color/text_secondary"
android:textSize="16sp" /> android:textSize="16sp" />
</LinearLayout> </LinearLayout>
@@ -371,6 +468,15 @@
android:textSize="20sp" android:textSize="20sp"
android:text="@string/btn_clear_task" /> 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 <TextView
android:id="@+id/tvVersion" android:id="@+id/tvVersion"
android:layout_width="wrap_content" android:layout_width="wrap_content"

View File

@@ -4,9 +4,9 @@
<string name="title_main">主界面</string> <string name="title_main">主界面</string>
<string name="btn_settings">设置</string> <string name="btn_settings">设置</string>
<string name="title_settings">设置</string> <string name="title_settings">设置</string>
<string name="label_ip_config">网络 IP 配置</string> <string name="label_ip_config">服务配置</string>
<string name="hint_ip_address">请输入 IP 地址 (例如 192.168.1.100)</string> <string name="hint_ip_address">请输入 base_url (例如 http://192.168.11.24:8088)</string>
<string name="btn_save">保存</string> <string name="btn_save">确认生效</string>
<string name="btn_back">返回主界面</string> <string name="btn_back">返回主界面</string>
<string name="msg_ip_saved">IP 已保存: %1$s</string> <string name="msg_ip_saved">IP 已保存: %1$s</string>
<string name="msg_invalid_ip">请输入有效的 IP 地址</string> <string name="msg_invalid_ip">请输入有效的 IP 地址</string>
@@ -37,4 +37,30 @@
<string name="livekit_status_permission">需要麦克风/摄像头权限</string> <string name="livekit_status_permission">需要麦克风/摄像头权限</string>
<string name="btn_clear_task">清除当前任务</string> <string name="btn_clear_task">清除当前任务</string>
<string name="msg_task_cleared">当前任务已清除</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> </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>