feat: 添加表情动画视图和MQTT连接支持

- 新增 AnimatedEmojiView 实现机器人表情动画显示
- 集成 MQTT 客户端库并实现 MqttManager 管理连接
- 添加机器人语音播报功能并同步表情状态
- 移除 SettingsActivity 中的日志显示相关代码
- 更新依赖项和权限配置以支持新功能
This commit is contained in:
2026-03-10 20:28:11 +08:00
parent 9c9a9552e2
commit 15fba9d1f9
12 changed files with 459 additions and 91 deletions

View File

@@ -43,6 +43,9 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.emoji2.views)
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)

View File

@@ -4,8 +4,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:allowBackup="true"
@@ -17,23 +16,19 @@
android:supportsRtl="true"
android:theme="@style/Theme.Lzwcaiterminaltemi"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".SettingsActivity" />
<activity android:name=".SettingsActivity"
android:exported="false"
android:label="Settings"/>
<service android:name="org.eclipse.paho.android.service.MqttService" />
<meta-data
android:name="com.robotemi.sdk.metadata.SKILL"
android:value="@string/app_name" />
</application>
</manifest>

View File

@@ -0,0 +1,157 @@
package com.example.lzwcai_terminal_temi
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import android.view.View
import android.view.animation.LinearInterpolator
class AnimatedEmojiView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs) {
private val facePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.YELLOW
style = Paint.Style.FILL
}
private val eyePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
style = Paint.Style.FILL
}
private val mouthPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
style = Paint.Style.STROKE
strokeWidth = 15f
strokeCap = Paint.Cap.ROUND
}
private var mouthPath = RectF()
private var mouthAnimator: ValueAnimator? = null
private var noddingAnimator: ValueAnimator? = null
private var mouthOpenRatio = 0.1f
private var noddingOffset = 0f
enum class Expression { SMILE, NEUTRAL, TALKING, HAPPY, SAD, WINK }
var currentExpression = Expression.SMILE
set(value) {
field = value
if (value == Expression.TALKING) {
startTalkingAnimation()
startNoddingAnimation()
} else {
stopTalkingAnimation()
stopNoddingAnimation()
}
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.save()
canvas.translate(0f, noddingOffset) // 应用点头动画的偏移
val centerX = width / 2f
val centerY = height / 2f
val radius = (minOf(width, height) / 2f) * 0.8f
// 1. 画脸 (已移除)
// canvas.drawCircle(centerX, centerY, radius, facePaint)
// 2. 画眼睛
val eyeRadius = radius * 0.1f
val eyeOffsetX = radius * 0.4f
val eyeOffsetY = radius * 0.3f
if (currentExpression == Expression.WINK) {
val closedEyeWidth = eyeRadius * 2.5f
mouthPaint.style = Paint.Style.STROKE
canvas.drawLine(centerX + eyeOffsetX - closedEyeWidth / 2, centerY - eyeOffsetY, centerX + eyeOffsetX + closedEyeWidth / 2, centerY - eyeOffsetY, mouthPaint)
canvas.drawCircle(centerX - eyeOffsetX, centerY - eyeOffsetY, eyeRadius, eyePaint)
} else {
canvas.drawCircle(centerX - eyeOffsetX, centerY - eyeOffsetY, eyeRadius, eyePaint)
canvas.drawCircle(centerX + eyeOffsetX, centerY - eyeOffsetY, eyeRadius, eyePaint)
}
// 3. 画嘴巴
mouthPaint.style = Paint.Style.STROKE
val mouthWidth = radius * 0.6f
val mouthHeight = radius * 0.4f
val mouthLeft = centerX - mouthWidth / 2
val mouthTop = centerY + radius * 0.1f
mouthPath.set(mouthLeft, mouthTop, mouthLeft + mouthWidth, mouthTop + mouthHeight)
when (currentExpression) {
Expression.SMILE -> canvas.drawArc(mouthPath, 20f, 140f, false, mouthPaint)
Expression.HAPPY -> {
val happyMouthPath = RectF(mouthLeft, mouthTop - radius * 0.1f, mouthLeft + mouthWidth, mouthTop + mouthHeight)
canvas.drawArc(happyMouthPath, 0f, 180f, false, mouthPaint)
}
Expression.SAD -> canvas.drawArc(mouthPath, 200f, -140f, false, mouthPaint)
Expression.NEUTRAL -> canvas.drawLine(mouthLeft, mouthTop + mouthHeight / 2, mouthLeft + mouthWidth, mouthTop + mouthHeight / 2, mouthPaint)
Expression.WINK -> canvas.drawArc(mouthPath, 20f, 140f, false, mouthPaint)
Expression.TALKING -> {
mouthPaint.style = Paint.Style.FILL
val dynamicMouthHeight = mouthHeight * mouthOpenRatio
val dynamicMouthTop = centerY + radius * 0.2f - dynamicMouthHeight / 2
mouthPath.set(mouthLeft, dynamicMouthTop, mouthLeft + mouthWidth, dynamicMouthTop + dynamicMouthHeight)
canvas.drawOval(mouthPath, mouthPaint)
}
}
canvas.restore()
}
private fun startTalkingAnimation() {
if (mouthAnimator?.isRunning == true) return
mouthAnimator = ValueAnimator.ofFloat(0.1f, 1.0f, 0.1f).apply {
duration = 600
interpolator = LinearInterpolator()
repeatCount = ValueAnimator.INFINITE
addUpdateListener { animation ->
mouthOpenRatio = animation.animatedValue as Float
invalidate()
}
start()
}
}
private fun stopTalkingAnimation() {
mouthAnimator?.cancel()
mouthOpenRatio = 0.1f
}
private fun startNoddingAnimation() {
if (noddingAnimator?.isRunning == true) return
noddingAnimator = ValueAnimator.ofFloat(-15f, 15f, -15f).apply {
duration = 800
interpolator = LinearInterpolator()
repeatCount = ValueAnimator.INFINITE
addUpdateListener { animation ->
noddingOffset = animation.animatedValue as Float
invalidate()
}
start()
}
}
private fun stopNoddingAnimation() {
noddingAnimator?.cancel()
noddingOffset = 0f
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
stopTalkingAnimation()
stopNoddingAnimation()
}
}

View File

@@ -1,67 +1,55 @@
package com.example.lzwcai_terminal_temi
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.io.BufferedReader
import java.io.InputStreamReader
object LogManager {
private val _logs = MutableLiveData<String>("")
val logs: LiveData<String> = _logs
private val logBuffer = StringBuffer()
private var isReading = false
private val currentPid = android.os.Process.myPid().toString()
private var logcatJob: Job? = null
private var logcatProcess: Process? = null
private val logListeners = mutableListOf<(String) -> Unit>()
fun startLogcatListener() {
if (isReading) return
isReading = true
Thread {
if (logcatJob?.isActive == true) {
return
}
logcatJob = CoroutineScope(Dispatchers.IO).launch {
try {
// 清除之前的日志缓存
Runtime.getRuntime().exec("logcat -c")
// 开始读取日志过滤当前进程ID
val process = Runtime.getRuntime().exec("logcat -v threadtime")
val reader = BufferedReader(InputStreamReader(process.inputStream))
logcatProcess = ProcessBuilder("logcat", "-v", "time").start()
val reader = BufferedReader(InputStreamReader(logcatProcess!!.inputStream))
var line: String?
while (reader.readLine().also { line = it } != null) {
line?.let {
if (it.contains(currentPid)) {
// 简单的过滤只显示包含当前PID的行
// 可以根据需要进一步处理日志格式
updateLog(it)
}
CoroutineScope(Dispatchers.Main).launch {
updateLog(line!!)
}
}
} catch (e: Exception) {
Log.e("LogManager", "Error reading logcat", e)
updateLog("Error reading logs: ${e.message}")
Log.e("LogManager", "Error starting logcat listener", e)
}
}.start()
}
private fun updateLog(msg: String) {
// 限制日志缓冲区大小,避免内存溢出
if (logBuffer.length > 50000) {
logBuffer.delete(0, 10000)
}
logBuffer.append("$msg\n")
_logs.postValue(logBuffer.toString())
}
fun clearLogs() {
logBuffer.setLength(0)
_logs.postValue("")
// 也可以尝试清除系统日志缓存,但通常需要权限或只能清除应用自己的
Thread {
try {
Runtime.getRuntime().exec("logcat -c")
} catch (e: Exception) {
e.printStackTrace()
}
}.start()
fun stopLogcatListener() {
logcatJob?.cancel()
logcatProcess?.destroy()
logcatProcess = null
logcatJob = null
Log.i("LogManager", "Logcat listener stopped.")
}
fun addLogListener(listener: (String) -> Unit) {
logListeners.add(listener)
}
fun removeLogListener(listener: (String) -> Unit) {
logListeners.remove(listener)
}
private fun updateLog(logLine: String) {
logListeners.forEach { it(logLine) }
}
}

View File

@@ -3,41 +3,128 @@ package com.example.lzwcai_terminal_temi
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.util.Log
import android.view.WindowManager
import androidx.appcompat.app.AppCompatActivity
import com.example.lzwcai_terminal_temi.databinding.ActivityMainBinding
import com.robotemi.sdk.Robot
import com.robotemi.sdk.TtsRequest
import com.robotemi.sdk.Robot.TtsListener
import com.robotemi.sdk.listeners.OnRobotReadyListener
class MainActivity : AppCompatActivity() {
class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, SharedPreferences.OnSharedPreferenceChangeListener {
private lateinit var robot: Robot
private lateinit var binding: ActivityMainBinding
private var mqttManager: MqttManager? = null
private lateinit var prefs: SharedPreferences
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// 启动日志监听
LogManager.startLogcatListener()
// 隐藏软键盘
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN)
// 获取 Robot 实例
robot = Robot.getInstance()
// 设置按钮点击事件
prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
binding.btnSettings.setOnClickListener {
startActivity(Intent(this, SettingsActivity::class.java))
}
// 读取配置的 IP 并记录日志
val prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
val ip = prefs.getString("network_ip", "未设置")
Log.i("MainActivity", getString(R.string.log_init, ip))
binding.btnRandomExpression.setOnClickListener {
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.values().random()
}
binding.btnSpeak.setOnClickListener {
speakLongSentence()
}
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.SMILE
updateMqttConnection()
}
override fun onStart() {
super.onStart()
robot.addOnRobotReadyListener(this)
robot.addTtsListener(this)
prefs.registerOnSharedPreferenceChangeListener(this)
mqttManager?.connect()
}
override fun onStop() {
super.onStop()
robot.removeOnRobotReadyListener(this)
robot.removeTtsListener(this)
prefs.unregisterOnSharedPreferenceChangeListener(this)
mqttManager?.disconnect()
}
override fun onDestroy() {
super.onDestroy()
// 确保在应用销毁时彻底释放资源
mqttManager?.disconnect()
LogManager.stopLogcatListener()
Log.i("MainActivity", "All resources released on destroy.")
}
override fun onRobotReady(isReady: Boolean) {
if (isReady) {
Log.i("MainActivity", "Robot is ready!")
}
}
private fun speakLongSentence() {
val longSentence = "你好,我是一个智能机器人。我很高兴能为您服务。现在,我将为您展示我的各种表情和语音能力。希望您喜欢!"
val ttsRequest = TtsRequest.create(longSentence, false, language = TtsRequest.Language.ZH_CN)
robot.speak(ttsRequest)
}
override fun onTtsStatusChanged(ttsRequest: TtsRequest) {
when (ttsRequest.status) {
TtsRequest.Status.STARTED -> {
Log.i("MainActivity", "TTS started: ${ttsRequest.speech}")
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.TALKING
}
TtsRequest.Status.COMPLETED -> {
Log.i("MainActivity", "TTS completed: ${ttsRequest.speech}")
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.SMILE
}
TtsRequest.Status.CANCELED -> {
Log.w("MainActivity", "TTS canceled: ${ttsRequest.speech}")
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.SMILE
}
TtsRequest.Status.ERROR -> {
Log.e("MainActivity", "TTS error: ${ttsRequest.speech}")
binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.SAD
}
else -> { /* PENDING, PROCESSING, NOT_ALLOWED */ }
}
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (key == "network_ip") {
Log.i("MainActivity", "IP address changed, re-initializing MQTT connection.")
updateMqttConnection()
}
}
private fun updateMqttConnection() {
mqttManager?.disconnect()
val ip = prefs.getString("network_ip", null)
if (!ip.isNullOrEmpty()) {
mqttManager = MqttManager(this, ip)
mqttManager?.connect()
Log.i("MainActivity", "MQTT Manager updated with new IP: $ip")
} else {
mqttManager = null
Log.w("MainActivity", "MQTT Manager disabled: IP address is not set.")
}
}
}

View File

@@ -0,0 +1,126 @@
package com.example.lzwcai_terminal_temi
import android.content.Context
import android.util.Log
import kotlinx.coroutines.*
import org.eclipse.paho.client.mqttv3.*
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence
class MqttManager(private val context: Context, private val serverIp: String) {
private var mqttClient: MqttClient? = null
private val TAG = "MqttManager"
private val brokerUri = "tcp://$serverIp:1883"
private val clientId = MqttClient.generateClientId()
private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO + job)
private var reconnectJob: Job? = null
init {
try {
mqttClient = MqttClient(brokerUri, clientId, MemoryPersistence())
mqttClient?.setCallback(object : MqttCallbackExtended {
override fun connectComplete(reconnect: Boolean, serverURI: String?) {
Log.i(TAG, "MQTT connection complete. Reconnect: $reconnect")
subscribeTopic("robot/cmd")
}
override fun connectionLost(cause: Throwable?) {
Log.e(TAG, "Connection lost: ${cause?.message}")
scheduleReconnect()
}
override fun messageArrived(topic: String?, message: MqttMessage?) {
val payload = String(message?.payload ?: ByteArray(0))
Log.i(TAG, "Message arrived: Topic=$topic, Payload=$payload")
}
override fun deliveryComplete(token: IMqttDeliveryToken?) {
// This is not critical for our use case
}
})
} catch (e: MqttException) {
Log.e(TAG, "Error creating MQTT client: ${e.message}")
}
}
fun connect() {
scope.launch {
if (mqttClient?.isConnected == true) {
Log.d(TAG, "MQTT client is already connected.")
return@launch
}
Log.i(TAG, "Attempting to connect to MQTT broker at $brokerUri")
try {
val options = MqttConnectOptions().apply {
isAutomaticReconnect = false
isCleanSession = true
connectionTimeout = 10
keepAliveInterval = 60 // 设置心跳间隔为60秒
userName = "lzwc"
password = "Lzwc@4187.".toCharArray()
}
mqttClient?.connect(options)
} catch (e: MqttException) {
Log.e(TAG, "Initial connection failed: ${e.message}")
scheduleReconnect()
}
}
}
private fun scheduleReconnect() {
if (reconnectJob?.isActive == true) {
return // 如果已经在计划重连,则不再创建新的
}
reconnectJob = scope.launch {
Log.d(TAG, "Scheduling reconnect in 5 seconds.")
delay(5000L)
connect()
}
}
fun disconnect() {
scope.launch {
try {
reconnectJob?.cancel() // 取消任何待处理的重连任务
if (mqttClient?.isConnected == true) {
mqttClient?.disconnect()
Log.i(TAG, "Disconnected from MQTT broker.")
}
} catch (e: MqttException) {
Log.e(TAG, "Error disconnecting from MQTT broker: ${e.message}")
}
}
}
private fun subscribeTopic(topic: String) {
try {
if (mqttClient?.isConnected == true) {
mqttClient?.subscribe(topic, 1)
Log.i(TAG, "Subscribed to topic: $topic")
} else {
Log.w(TAG, "Cannot subscribe, MQTT client not connected.")
}
} catch (e: MqttException) {
Log.e(TAG, "Error subscribing to topic: $topic", e)
}
}
fun publish(topic: String, msg: String, qos: Int = 1, retained: Boolean = false) {
scope.launch {
if (mqttClient?.isConnected != true) {
Log.w(TAG, "MQTT client not connected, cannot publish.")
return@launch
}
try {
val message = MqttMessage(msg.toByteArray()).apply {
this.qos = qos
this.isRetained = retained
}
mqttClient?.publish(topic, message)
} catch (e: MqttException) {
Log.e(TAG, "Error publishing message: ${e.message}")
}
}
}
}

View File

@@ -50,30 +50,6 @@ class SettingsActivity : AppCompatActivity() {
finish()
}
binding.btnToggleLogs.setOnClickListener {
hideKeyboard()
if (binding.tvLog.visibility == View.VISIBLE) {
binding.tvLog.visibility = View.GONE
binding.btnToggleLogs.text = getString(R.string.btn_show_logs)
} else {
binding.tvLog.visibility = View.VISIBLE
binding.btnToggleLogs.text = getString(R.string.btn_hide_logs)
}
}
LogManager.logs.observe(this) { logs ->
binding.tvLog.text = logs
// 自动滚动到底部
binding.tvLog.post {
if (binding.tvLog.layout != null) {
val scrollAmount = binding.tvLog.layout.getLineTop(binding.tvLog.lineCount) - binding.tvLog.height
if (scrollAmount > 0)
binding.tvLog.scrollTo(0, scrollAmount)
else
binding.tvLog.scrollTo(0, 0)
}
}
}
}
private fun hideKeyboard() {

View File

@@ -16,4 +16,32 @@
android:contentDescription="@string/btn_settings"
android:src="@android:drawable/ic_menu_manage" />
<com.example.lzwcai_terminal_temi.AnimatedEmojiView
android:id="@+id/animatedEmojiView"
android:layout_width="500dp"
android:layout_height="500dp"
android:layout_centerInParent="true" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:layout_marginBottom="32dp"
android:orientation="horizontal">
<Button
android:id="@+id/btnRandomExpression"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:text="@string/btn_random_expression" />
<Button
android:id="@+id/btnSpeak"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/btn_speak" />
</LinearLayout>
</RelativeLayout>

View File

@@ -1,3 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">lzwcai-terminal-temi</string>
<string name="title_main">主界面</string>
@@ -13,4 +14,6 @@
<string name="msg_invalid_ip">请输入有效的 IP 地址</string>
<string name="log_init">应用启动完成。目标 IP: %1$s</string>
<string name="log_placeholder">日志将显示在这里...</string>
<string name="btn_random_expression">随机表情</string>
<string name="btn_speak">让机器人说话</string>
</resources>