Merge pull request 'feature/agent' (#1) from feature/agent into master
Reviewed-on: http://git.lzwcai.com:3000/tanjianbin/lzwcai-terminal-temi/pulls/1
This commit is contained in:
@@ -5,6 +5,9 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.example.lzwcai_terminal_temi
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import io.livekit.android.ConnectOptions
|
||||
import io.livekit.android.LiveKit
|
||||
import io.livekit.android.events.RoomEvent
|
||||
import io.livekit.android.events.collect
|
||||
import io.livekit.android.room.Room
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class LiveKitStatus {
|
||||
Connected,
|
||||
Disconnected,
|
||||
Failed
|
||||
}
|
||||
|
||||
class LiveKitManager(appContext: Context, private val statusListener: (LiveKitStatus) -> 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)
|
||||
}
|
||||
}
|
||||
@@ -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<out String>,
|
||||
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 {
|
||||
|
||||
@@ -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<TtsRequest, TtsRequest.Language>()
|
||||
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) {
|
||||
|
||||
@@ -29,6 +29,13 @@ class SettingsActivity : AppCompatActivity() {
|
||||
private lateinit var locationAdapter: ArrayAdapter<String>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,16 @@
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<View
|
||||
android:id="@+id/statusIndicator"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:background="@drawable/status_indicator"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<com.example.lzwcai_terminal_temi.AnimatedEmojiView
|
||||
android:id="@+id/animatedEmojiView"
|
||||
android:layout_width="0dp"
|
||||
@@ -49,4 +59,4 @@
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
@@ -102,6 +102,22 @@
|
||||
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_agent_id">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etAgentDempId"
|
||||
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/btnSave"
|
||||
style="@style/Widget.App.Button"
|
||||
@@ -114,6 +130,109 @@
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<androidx.cardview.widget.CardView
|
||||
style="@style/CardView.App"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="@string/label_livekit_config"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="22sp"
|
||||
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_livekit_url">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etLiveKitUrl"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="72dp"
|
||||
android:inputType="textUri"
|
||||
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_livekit_room">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etLiveKitRoom"
|
||||
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_livekit_token">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etLiveKitToken"
|
||||
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>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/label_livekit_auto"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/switchLiveKitAuto"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:scaleX="1.2"
|
||||
android:scaleY="1.2" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnLiveKitSave"
|
||||
style="@style/Widget.App.Button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="72dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:textSize="20sp"
|
||||
android:text="@string/btn_save_livekit" />
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<androidx.cardview.widget.CardView
|
||||
style="@style/CardView.App"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -20,6 +20,16 @@
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<View
|
||||
android:id="@+id/statusIndicator"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:background="@drawable/status_indicator"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<com.example.lzwcai_terminal_temi.AnimatedEmojiView
|
||||
android:id="@+id/animatedEmojiView"
|
||||
android:layout_width="0dp"
|
||||
@@ -49,4 +59,4 @@
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
@@ -93,6 +93,22 @@
|
||||
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_agent_id">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etAgentDempId"
|
||||
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/btnSave"
|
||||
style="@style/Widget.App.Button"
|
||||
@@ -105,6 +121,109 @@
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<androidx.cardview.widget.CardView
|
||||
style="@style/CardView.App"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="@string/label_livekit_config"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="22sp"
|
||||
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_livekit_url">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etLiveKitUrl"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="72dp"
|
||||
android:inputType="textUri"
|
||||
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_livekit_room">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etLiveKitRoom"
|
||||
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_livekit_token">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etLiveKitToken"
|
||||
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>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/label_livekit_auto"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/switchLiveKitAuto"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:scaleX="1.2"
|
||||
android:scaleY="1.2" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnLiveKitSave"
|
||||
style="@style/Widget.App.Button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="72dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:textSize="20sp"
|
||||
android:text="@string/btn_save_livekit" />
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<androidx.cardview.widget.CardView
|
||||
style="@style/CardView.App"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -16,5 +16,23 @@
|
||||
<string name="btn_restart_app">长按重启应用</string>
|
||||
<string name="label_location_config">当前位置</string>
|
||||
<string name="hint_current_location">请选择当前地点</string>
|
||||
<string name="label_agent_filter">Agent 过滤</string>
|
||||
<string name="hint_agent_id">请输入 demp_id(留空接收全部)</string>
|
||||
<string name="location_unknown">未知</string>
|
||||
<string name="label_livekit_config">LiveKit 配置</string>
|
||||
<string name="hint_livekit_url">请输入 LiveKit 地址 (wss://your-host)</string>
|
||||
<string name="hint_livekit_token">请输入 LiveKit Token</string>
|
||||
<string name="label_livekit_auto">LiveKit 自动连接</string>
|
||||
<string name="btn_save_livekit">保存 LiveKit 配置</string>
|
||||
<string name="msg_livekit_saved">LiveKit 配置已保存</string>
|
||||
<string name="msg_livekit_cleared">LiveKit 配置已清除</string>
|
||||
<string name="hint_livekit_room">请输入房间名 (例如 temi-room)</string>
|
||||
<string name="label_livekit_status">LiveKit 状态</string>
|
||||
<string name="livekit_status_disabled">LiveKit 未启用</string>
|
||||
<string name="livekit_status_missing">LiveKit 待配置</string>
|
||||
<string name="livekit_status_connecting">LiveKit 连接中</string>
|
||||
<string name="livekit_status_connected">LiveKit 已连接</string>
|
||||
<string name="livekit_status_disconnected">LiveKit 已断开</string>
|
||||
<string name="livekit_status_failed">LiveKit 连接失败</string>
|
||||
<string name="livekit_status_permission">需要麦克风/摄像头权限</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user