diff --git a/README.md b/README.md index e407be4..d111e07 100644 --- a/README.md +++ b/README.md @@ -1,114 +1,25 @@ # Temi 终端控制应用 -本项目是基于 Temi SDK 的 Android 应用,提供 MQTT 指令控制、导航/巡逻/接待流程,以及设置页管理网络 IP、当前位置与特殊任务模式。 +基于 Temi SDK 的 Android 应用,通过 MQTT 指令控制机器人导航、接待、巡逻与语音播报。 -## 1. 运行环境 +## 功能 +- MQTT 指令控制导航与播报 +- 接待与巡逻模式 +- 设置页:网络 IP、当前位置、特殊任务模式 -### 推荐工具:Android Studio -应用依赖 Gradle 与 Android 运行环境,无法通过浏览器直接预览。请使用 Android Studio 导入项目并运行到 Temi 机器人或模拟器。 +## 运行 +- 推荐使用 Android Studio 连接 Temi 设备运行 +- 模拟器仅用于 UI 预览 -### 运行方式 -- 方式 A:连接 Temi 机器人(推荐) - - 确保 Temi 已开启开发者模式 - - USB 连接或 ADB over Wi-Fi - - Android Studio 选择设备后 Run -- 方式 B:Android 模拟器(仅 UI 预览) - - Temi 系统服务缺失,Robot SDK 可能报错或不可用 - ---- - -## 2. 功能概览 - -- 主界面表情与状态反馈(AnimatedEmojiView) -- MQTT 控制机器人行为与 TTS 播报 -- 接待模式:到达指定地点后检测到人出现确认按钮 -- 巡逻模式:按指定或随机地点巡逻 -- 特殊任务模式开关 -- 设置页:网络 IP、当前位置、重启应用 - ---- - -## 3. 页面说明 - -### 主界面(MainActivity) -- 顶部设置按钮进入设置页 -- 表情组件根据 TTS 和任务状态切换表情 -- 接待模式触发时显示 “是的” 按钮,点击后前往目标地点 - -### 设置页(SettingsActivity) -- 网络 IP 配置:保存后作为 MQTT Broker 地址 -- 当前位置下拉选择:来源于 Temi 已保存地点 -- 特殊任务模式开关与指示灯 -- 长按 3 秒重启应用 -- 版本号显示(当前为硬编码字符串) - ---- - -## 4. MQTT 指令协议 - -应用连接 `tcp://:1883`,订阅主题 `robot/cmd`,仅处理 JSON payload。 - -### 基础指令 -- `recharge`:回充电桩 -- `goto`:前往地点(字段 `location` 或 `target`) -- `repose`:重新定位 -- `stop`:停止移动并暂停 TTS(保留队列与 stream buffer) -- `continue`:继续播报(优先重播被中断语句) -- `terminate`:停止移动并清空所有 TTS 队列与 buffer - -### 播报指令 -- `speak`:立即播报(字段 `text` 或 `speech`,可选 `lang`) -- `stream`:流式播报(字段 `text` 或 `content`,可选 `lang`) - - 按 `。!?!?\n` 分句进入队列 - -### 任务指令 -- `patrol`:巡逻 - - `flag=true` 随机抽取 3~6 个地点(排除 home base) - - `flag=false` + `locations` 指定地点列表 -- `reception`:接待 - - `location` 接待地点(默认 “前台”) - - `text` 询问语(默认 “你是我要接待的贵宾吗?”) - - `destination` 目的地(默认 “会议室”) - -### 示例 +## MQTT +- Broker:`tcp://:1883` +- 主题:`robot/cmd` +- 示例: ```json {"action":"goto","location":"前台"} ``` -```json -{"action":"speak","text":"欢迎光临","lang":"zh"} -``` - -```json -{"action":"patrol","flag":false,"locations":["前台","会议室","大厅"]} -``` - ---- - -## 5. 特殊任务模式 - -设置页开关 `special_task_mode` 控制以下行为: -- 跳过 Home Base 的开门/关门语音逻辑 -- 跳过检测到人时的问候语 -- 机器人到达地点时,不播报“已到达”提示(仅在当前无任务时生效) - ---- - -## 6. 项目结构 - -- `MainActivity.kt`:主逻辑与事件监听 -- `SettingsActivity.kt`:设置页逻辑 -- `MqttManager.kt`:MQTT 连接与指令解析 -- `NavController.kt`:导航与巡逻封装 -- `AnimatedEmojiView.kt`:表情动画绘制 -- `LogManager.kt`:Logcat 监听分发 -- `HttpManager.kt`:HTTP Workflow 请求封装(目前未在流程中启用) -- `activity_main.xml` / `activity_settings.xml`:主界面与设置页布局 - ---- - -## 7. 本地构建与安装 - +## 构建 ```bash .\gradlew.bat :app:installDebug ``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 69fb7a7..1028a86 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -44,9 +44,10 @@ dependencies { implementation(libs.androidx.appcompat) implementation(libs.material) implementation(libs.androidx.emoji2.views) + implementation(libs.livekit.android) implementation("org.eclipse.paho:org.eclipse.paho.android.service:1.1.1") implementation("org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5") testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 93e831d..0e1ccc7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,9 @@ + + + Unit) { + + private val context = appContext.applicationContext + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private var room: Room? = null + private var eventsJob: Job? = null + + fun connect(url: String, token: String, enableMic: Boolean, enableCamera: Boolean) { + val finalUrl = url.trim() + val finalToken = token.trim() + if (finalUrl.isEmpty() || finalToken.isEmpty()) { + Log.w("LiveKitManager", "LiveKit connect skipped: empty url or token.") + return + } + scope.launch { + val currentRoom = room ?: LiveKit.create(context).also { room = it } + eventsJob?.cancel() + eventsJob = launch { + currentRoom.events.collect { event -> + when (event) { + is RoomEvent.Connected -> { + Log.i("LiveKitManager", "LiveKit connected.") + statusListener(LiveKitStatus.Connected) + } + is RoomEvent.Disconnected -> { + Log.i("LiveKitManager", "LiveKit disconnected.") + statusListener(LiveKitStatus.Disconnected) + } + else -> {} + } + } + } + runCatching { + val options = ConnectOptions( + autoSubscribe = false, + audio = enableMic, + video = enableCamera + ) + currentRoom.connect(finalUrl, finalToken, options) + }.onFailure { e -> + Log.e("LiveKitManager", "LiveKit connect error: ${e.message}", e) + statusListener(LiveKitStatus.Failed) + } + } + } + + fun disconnect() { + scope.launch { + eventsJob?.cancel() + runCatching { room?.disconnect() } + statusListener(LiveKitStatus.Disconnected) + } + } + + fun release() { + eventsJob?.cancel() + runCatching { room?.disconnect() } + room = null + scope.cancel() + statusListener(LiveKitStatus.Disconnected) + } +} diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/MainActivity.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/MainActivity.kt index cefb3ff..8e9d63e 100644 --- a/app/src/main/java/com/example/lzwcai_terminal_temi/MainActivity.kt +++ b/app/src/main/java/com/example/lzwcai_terminal_temi/MainActivity.kt @@ -1,13 +1,20 @@ package com.example.lzwcai_terminal_temi +import android.Manifest import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.content.SharedPreferences +import android.content.pm.PackageManager import android.os.Bundle +import android.os.Build import android.util.Log import android.view.WindowManager +import android.util.Base64 +import android.graphics.drawable.GradientDrawable import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import com.example.lzwcai_terminal_temi.databinding.ActivityMainBinding import com.robotemi.sdk.Robot import com.robotemi.sdk.TtsRequest @@ -29,6 +36,10 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlin.random.Random +import org.json.JSONObject +import java.nio.charset.StandardCharsets +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnGoToLocationStatusChangedListener, OnDetectionStateChangedListener, OnReposeStatusChangedListener, SharedPreferences.OnSharedPreferenceChangeListener, @@ -41,6 +52,18 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG private lateinit var prefs: SharedPreferences private lateinit var navCon: NavController private lateinit var permissionManager: PermissionManager + private var liveKitManager: LiveKitManager? = null + private val liveKitUrlKey = "livekit_url" + private val liveKitRoomKey = "livekit_room" + private val liveKitTokenKey = "livekit_token" + private val liveKitEnabledKey = "livekit_enabled" + private val liveKitPermissionRequestCode = 2001 + private val liveKitUrlDefault = "ws://192.168.2.236:7880" + private val liveKitApiKeyDefault = "devkey" + private val liveKitApiSecretDefault = "secret" + private val liveKitRoomDefault = "temi-room" + private var isLiveKitConnected = false + private var isMqttConnected = false private var lastArrivalLocation: String? = null private var lastArrivalAt: Long = 0L @@ -87,6 +110,13 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE) prefs.registerOnSharedPreferenceChangeListener(this) + liveKitManager = LiveKitManager(applicationContext) { status -> + when (status) { + LiveKitStatus.Connected -> setLiveKitStatus(true) + LiveKitStatus.Disconnected -> setLiveKitStatus(false) + LiveKitStatus.Failed -> setLiveKitStatus(false) + } + } if (lastArrivalLocation == null) { lastArrivalLocation = prefs.getString("current_location", null) } @@ -109,6 +139,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG } updateMqttConnection() + updateLiveKitStatusSnapshot() } override fun onStart() { @@ -125,6 +156,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG } else { mqttManager?.connect() } + updateLiveKitConnection() startBlinking() } @@ -137,6 +169,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG robot.removeOnReposeStatusChangedListener(this) robot.removeOnRequestPermissionResultListener(this) // mqttManager?.disconnect() // Keep MQTT alive in background/settings + liveKitManager?.disconnect() stopBlinking() } @@ -144,6 +177,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG super.onDestroy() prefs.unregisterOnSharedPreferenceChangeListener(this) mqttManager?.disconnect() + liveKitManager?.release() LogManager.stopLogcatListener() mainScope.cancel() Log.i("MainActivity", "All resources released on destroy.") @@ -455,6 +489,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG if (key == "network_ip") { Log.i("MainActivity", "IP address changed, re-initializing MQTT connection.") updateMqttConnection() + updateLiveKitConnection() } if (key == "current_location") { lastArrivalLocation = sharedPreferences?.getString("current_location", null) @@ -464,6 +499,9 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG val isSpecial = sharedPreferences?.getBoolean("special_task_mode", false) == true Log.i("MainActivity", "Special mode pref changed: $isSpecial, currentTask: $currentTask") } + if (key == liveKitUrlKey || key == liveKitRoomKey || key == liveKitTokenKey || key == liveKitEnabledKey) { + updateLiveKitConnection() + } } private fun isSpecialModeEnabled(): Boolean { @@ -474,15 +512,183 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG mqttManager?.shutdown() val ip = prefs.getString("network_ip", null) if (!ip.isNullOrEmpty()) { - mqttManager = MqttManager(this, ip, robot, navCon) + 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() { + val enabled = prefs.getBoolean(liveKitEnabledKey, true) + val url = resolveLiveKitUrl() + val room = prefs.getString(liveKitRoomKey, liveKitRoomDefault).orEmpty() + val savedToken = prefs.getString(liveKitTokenKey, "").orEmpty() + val token = if (savedToken.isBlank()) { + buildLiveKitToken( + apiKey = liveKitApiKeyDefault, + apiSecret = liveKitApiSecretDefault, + 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 { + return ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + } + + private fun hasCameraPermission(): Boolean { + return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + } + + private fun requestMediaPermissions() { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA), + liveKitPermissionRequestCode + ) + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == liveKitPermissionRequestCode) { + val granted = grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED } + if (granted) { + updateLiveKitConnection() + } else { + Log.w("MainActivity", "LiveKit media permission denied.") + } + } + } + + private fun buildLiveKitIdentity(): String { + val model = Build.MODEL?.trim().orEmpty() + val normalized = model.replace("\\s+".toRegex(), "-").lowercase() + val suffix = if (normalized.isNotEmpty()) normalized else "temi" + return "temi-$suffix" + } + + private fun buildLiveKitToken( + apiKey: String, + apiSecret: String, + room: String, + identity: String + ): String { + val nowSeconds = System.currentTimeMillis() / 1000 + val header = JSONObject() + .put("alg", "HS256") + .put("typ", "JWT") + val grants = JSONObject() + .put("roomJoin", true) + .put("room", room) + .put("canPublish", true) + .put("canSubscribe", true) + .put("canPublishData", true) + val claims = JSONObject() + .put("iss", apiKey) + .put("sub", identity) + .put("nbf", nowSeconds) + .put("exp", nowSeconds + 3600) + .put("video", grants) + val headerB64 = base64Url(header.toString().toByteArray(StandardCharsets.UTF_8)) + val claimsB64 = base64Url(claims.toString().toByteArray(StandardCharsets.UTF_8)) + val signingInput = "$headerB64.$claimsB64" + val signature = hmacSha256(signingInput, apiSecret) + return "$signingInput.${base64Url(signature)}" + } + + private fun hmacSha256(data: String, secret: String): ByteArray { + val mac = Mac.getInstance("HmacSHA256") + val keySpec = SecretKeySpec(secret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256") + mac.init(keySpec) + return mac.doFinal(data.toByteArray(StandardCharsets.UTF_8)) + } + + private fun base64Url(input: ByteArray): String { + return Base64.encodeToString(input, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + } + + private fun updateLiveKitStatusSnapshot() { + val enabled = prefs.getBoolean(liveKitEnabledKey, true) + val url = resolveLiveKitUrl() + val room = prefs.getString(liveKitRoomKey, liveKitRoomDefault).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) { + isLiveKitConnected = connected + updateConnectionIndicator() + } + + private fun setMqttConnectionStatus(connected: Boolean) { + isMqttConnected = connected + updateConnectionIndicator() + } + + 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 + } + val indicatorDrawable = binding.statusIndicator.background as GradientDrawable + indicatorDrawable.setColor(ContextCompat.getColor(this, colorRes)) + } + + private fun resolveLiveKitUrl(): String { + val savedUrl = prefs.getString(liveKitUrlKey, "").orEmpty().trim() + if (savedUrl.isNotEmpty()) { + return savedUrl + } + val ip = prefs.getString("network_ip", "").orEmpty().trim() + if (ip.isNotEmpty()) { + return "ws://$ip:7880" + } + return liveKitUrlDefault + } + private fun startBlinking() { stopBlinking() blinkJob = mainScope.launch { diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/MqttManager.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/MqttManager.kt index b89b1ef..bd369b9 100644 --- a/app/src/main/java/com/example/lzwcai_terminal_temi/MqttManager.kt +++ b/app/src/main/java/com/example/lzwcai_terminal_temi/MqttManager.kt @@ -2,6 +2,8 @@ package com.example.lzwcai_terminal_temi import android.content.Context import android.util.Log +import android.os.Handler +import android.os.Looper import com.robotemi.sdk.Robot import com.robotemi.sdk.TtsRequest import kotlinx.coroutines.* @@ -15,7 +17,8 @@ class MqttManager( private val context: Context, private val serverIp: String, private val robot: Robot, - private val navController: NavController + private val navController: NavController, + private val statusListener: (Boolean) -> Unit ) { private var mqttClient: MqttClient? = null @@ -25,6 +28,8 @@ 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 agentDempIdKey = "agent_demp_id" // Streaming text buffer private val speechBuffer = StringBuilder() @@ -39,6 +44,8 @@ class MqttManager( private var interruptedLanguage: TtsRequest.Language? = null private var lastStreamLangCode: String? = null private val ttsLanguageMap = mutableMapOf() + private var currentStreamSessionId: String? = null + private var currentStreamMessageId: String? = null init { try { @@ -47,10 +54,13 @@ class MqttManager( override fun connectComplete(reconnect: Boolean, serverURI: String?) { Log.i(TAG, "MQTT connection complete. Reconnect: $reconnect") subscribeTopic("robot/cmd") + subscribeTopic("soul2user") + updateConnectionStatus(true) } override fun connectionLost(cause: Throwable?) { Log.e(TAG, "Connection lost: ${cause?.message}") + updateConnectionStatus(false) scheduleReconnect() } @@ -74,6 +84,7 @@ class MqttManager( scope.launch { if (mqttClient?.isConnected == true) { Log.d(TAG, "MQTT client is already connected.") + updateConnectionStatus(true) return@launch } Log.i(TAG, "Attempting to connect to MQTT broker at $brokerUri") @@ -89,6 +100,7 @@ class MqttManager( mqttClient?.connect(options) } catch (e: MqttException) { Log.e(TAG, "Initial connection failed: ${e.message}") + updateConnectionStatus(false) scheduleReconnect() } } @@ -113,8 +125,10 @@ class MqttManager( mqttClient?.disconnect() Log.i(TAG, "Disconnected from MQTT broker.") } + updateConnectionStatus(false) } catch (e: MqttException) { Log.e(TAG, "Error disconnecting from MQTT broker: ${e.message}") + updateConnectionStatus(false) } } } @@ -131,6 +145,13 @@ class MqttManager( Log.e(TAG, "Error shutting down MQTT client: ${e.message}") } finally { mqttClient = null + updateConnectionStatus(false) + } + } + + private fun updateConnectionStatus(connected: Boolean) { + Handler(Looper.getMainLooper()).post { + statusListener(connected) } } @@ -176,7 +197,11 @@ class MqttManager( } try { val obj = JSONObject(trimmed) - handleJsonCommand(obj) + if (topic == "soul2user") { + handleSoulStream(obj) + } else { + handleJsonCommand(obj) + } } catch (e: Exception) { Log.e(TAG, "Invalid JSON payload: $payload", e) val ttsRequest = TtsRequest.create("指令格式错误,请检查指令格式", false, language = TtsRequest.Language.ZH_CN) @@ -184,6 +209,63 @@ class MqttManager( } } + private fun handleSoulStream(obj: JSONObject) { + val configuredDempId = prefs.getString(agentDempIdKey, "").orEmpty().trim() + val dempId = obj.optString("demp_id", "").trim() + if (configuredDempId.isNotEmpty() && configuredDempId != dempId) { + Log.i(TAG, "Stream ignored: demp_id mismatch ($dempId)") + return + } + val sessionId = obj.optString("session_id", "").trim() + val messageId = obj.optString("message_id", "").trim() + ensureStreamContext(sessionId, messageId) + val text = obj.optString("text", "") + val isFinal = obj.optBoolean("is_final", false) + val lang = obj.optString("lang", "").trim() + scope.launch(Dispatchers.Main) { + (context as? MainActivity)?.markSpeechTaskActive() + } + processStreamText(text, lang) + if (isFinal) { + flushStreamRemainder(lang) + currentStreamSessionId = null + currentStreamMessageId = null + } + } + + private fun ensureStreamContext(sessionId: String, messageId: String) { + if (sessionId.isEmpty() && messageId.isEmpty()) { + return + } + val sessionChanged = currentStreamSessionId != null && sessionId.isNotEmpty() && sessionId != currentStreamSessionId + val messageChanged = currentStreamMessageId != null && messageId.isNotEmpty() && messageId != currentStreamMessageId + if (currentStreamSessionId == null && currentStreamMessageId == null) { + currentStreamSessionId = sessionId.ifEmpty { null } + currentStreamMessageId = messageId.ifEmpty { null } + return + } + if (sessionChanged || messageChanged) { + stopTts() + currentStreamSessionId = sessionId.ifEmpty { null } + currentStreamMessageId = messageId.ifEmpty { null } + return + } + if (currentStreamSessionId == null && sessionId.isNotEmpty()) { + currentStreamSessionId = sessionId + } + if (currentStreamMessageId == null && messageId.isNotEmpty()) { + currentStreamMessageId = messageId + } + } + + private fun flushStreamRemainder(langCode: String?) { + val remaining = speechBuffer.toString().trim() + if (remaining.isNotEmpty()) { + speechBuffer.setLength(0) + speak(remaining, langCode) + } + } + private fun handleJsonCommand(obj: JSONObject) { val action = obj.optString("action", obj.optString("cmd", obj.optString("type", ""))).lowercase() when (action) { diff --git a/app/src/main/java/com/example/lzwcai_terminal_temi/SettingsActivity.kt b/app/src/main/java/com/example/lzwcai_terminal_temi/SettingsActivity.kt index f7ec1d4..46ae0b5 100644 --- a/app/src/main/java/com/example/lzwcai_terminal_temi/SettingsActivity.kt +++ b/app/src/main/java/com/example/lzwcai_terminal_temi/SettingsActivity.kt @@ -29,6 +29,13 @@ class SettingsActivity : AppCompatActivity() { private lateinit var locationAdapter: ArrayAdapter private val currentLocationKey = "current_location" private lateinit var prefs: SharedPreferences + private val liveKitUrlKey = "livekit_url" + private val liveKitRoomKey = "livekit_room" + private val liveKitTokenKey = "livekit_token" + private val liveKitEnabledKey = "livekit_enabled" + private val agentDempIdKey = "agent_demp_id" + private val liveKitUrlDefault = "ws://192.168.2.236:7880" + private val liveKitRoomDefault = "temi-room" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -41,9 +48,20 @@ class SettingsActivity : AppCompatActivity() { prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE) val savedIp = prefs.getString("network_ip", "") binding.etIpAddress.setText(savedIp) + val savedDempId = prefs.getString(agentDempIdKey, "") + binding.etAgentDempId.setText(savedDempId) + val savedLiveKitUrl = prefs.getString(liveKitUrlKey, resolveLiveKitUrl()) + val savedLiveKitRoom = prefs.getString(liveKitRoomKey, liveKitRoomDefault) + val savedLiveKitToken = prefs.getString(liveKitTokenKey, "") + val isLiveKitEnabled = prefs.getBoolean(liveKitEnabledKey, true) + binding.etLiveKitUrl.setText(savedLiveKitUrl) + binding.etLiveKitRoom.setText(savedLiveKitRoom) + binding.etLiveKitToken.setText(savedLiveKitToken) + binding.switchLiveKitAuto.setOnCheckedChangeListener(null) + binding.switchLiveKitAuto.isChecked = isLiveKitEnabled // Set Version Name - val versionName = "2603121722" + val versionName = "2603131822" binding.tvVersion.text = getString(R.string.version_prefix, versionName) binding.root.setOnClickListener { hideKeyboard() } @@ -51,8 +69,12 @@ class SettingsActivity : AppCompatActivity() { binding.btnSave.setOnClickListener { hideKeyboard() val ip = binding.etIpAddress.text.toString().trim() + val dempId = binding.etAgentDempId.text?.toString()?.trim().orEmpty() if (ip.isNotEmpty()) { - prefs.edit().putString("network_ip", ip).apply() + prefs.edit() + .putString("network_ip", ip) + .putString(agentDempIdKey, dempId) + .apply() Toast.makeText(this, getString(R.string.msg_ip_saved, ip), Toast.LENGTH_SHORT).show() finish() } else { @@ -61,6 +83,30 @@ class SettingsActivity : AppCompatActivity() { } } + binding.switchLiveKitAuto.setOnCheckedChangeListener { _, isChecked -> + prefs.edit().putBoolean(liveKitEnabledKey, isChecked).apply() + } + + binding.btnLiveKitSave.setOnClickListener { + hideKeyboard() + val url = binding.etLiveKitUrl.text?.toString()?.trim().orEmpty() + val room = binding.etLiveKitRoom.text?.toString()?.trim().orEmpty() + val token = binding.etLiveKitToken.text?.toString()?.trim().orEmpty() + val enabled = binding.switchLiveKitAuto.isChecked + prefs.edit() + .putString(liveKitUrlKey, url) + .putString(liveKitRoomKey, room) + .putString(liveKitTokenKey, token) + .putBoolean(liveKitEnabledKey, enabled) + .apply() + if (url.isBlank() || room.isBlank()) { + Toast.makeText(this, getString(R.string.msg_livekit_cleared), Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this, getString(R.string.msg_livekit_saved), Toast.LENGTH_SHORT).show() + } + finish() + } + binding.btnBack.setOnClickListener { hideKeyboard() finish() @@ -195,4 +241,16 @@ class SettingsActivity : AppCompatActivity() { imm.hideSoftInputFromWindow(it.windowToken, 0) } } + + private fun resolveLiveKitUrl(): String { + val savedUrl = prefs.getString(liveKitUrlKey, "").orEmpty().trim() + if (savedUrl.isNotEmpty()) { + return savedUrl + } + val ip = prefs.getString("network_ip", "").orEmpty().trim() + if (ip.isNotEmpty()) { + return "ws://$ip:7880" + } + return liveKitUrlDefault + } } diff --git a/app/src/main/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml index 0ac98d7..ec86bac 100644 --- a/app/src/main/res/layout-land/activity_main.xml +++ b/app/src/main/res/layout-land/activity_main.xml @@ -20,6 +20,16 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + - \ No newline at end of file + diff --git a/app/src/main/res/layout-land/activity_settings.xml b/app/src/main/res/layout-land/activity_settings.xml index 95fcf07..fa71e09 100644 --- a/app/src/main/res/layout-land/activity_settings.xml +++ b/app/src/main/res/layout-land/activity_settings.xml @@ -102,6 +102,22 @@ android:textColor="@color/text_primary" /> + + + + +