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

1
.gitignore vendored
View File

@@ -13,3 +13,4 @@
.externalNativeBuild .externalNativeBuild
.cxx .cxx
local.properties local.properties
*.log

1
.idea/gradle.xml generated
View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>

View File

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

View File

@@ -4,8 +4,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -17,23 +16,19 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Lzwcaiterminaltemi" android:theme="@style/Theme.Lzwcaiterminaltemi"
tools:targetApi="31"> tools:targetApi="31">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity android:name=".SettingsActivity" />
<activity android:name=".SettingsActivity" <service android:name="org.eclipse.paho.android.service.MqttService" />
android:exported="false"
android:label="Settings"/>
<meta-data
android:name="com.robotemi.sdk.metadata.SKILL"
android:value="@string/app_name" />
</application> </application>
</manifest> </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 package com.example.lzwcai_terminal_temi
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData import kotlinx.coroutines.CoroutineScope
import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.io.BufferedReader import java.io.BufferedReader
import java.io.InputStreamReader import java.io.InputStreamReader
object LogManager { object LogManager {
private val _logs = MutableLiveData<String>("") private var logcatJob: Job? = null
val logs: LiveData<String> = _logs private var logcatProcess: Process? = null
private val logBuffer = StringBuffer() private val logListeners = mutableListOf<(String) -> Unit>()
private var isReading = false
private val currentPid = android.os.Process.myPid().toString()
fun startLogcatListener() { fun startLogcatListener() {
if (isReading) return if (logcatJob?.isActive == true) {
isReading = true return
}
Thread { logcatJob = CoroutineScope(Dispatchers.IO).launch {
try { try {
// 清除之前的日志缓存 logcatProcess = ProcessBuilder("logcat", "-v", "time").start()
Runtime.getRuntime().exec("logcat -c") val reader = BufferedReader(InputStreamReader(logcatProcess!!.inputStream))
// 开始读取日志过滤当前进程ID
val process = Runtime.getRuntime().exec("logcat -v threadtime")
val reader = BufferedReader(InputStreamReader(process.inputStream))
var line: String? var line: String?
while (reader.readLine().also { line = it } != null) { while (reader.readLine().also { line = it } != null) {
line?.let { CoroutineScope(Dispatchers.Main).launch {
if (it.contains(currentPid)) { updateLog(line!!)
// 简单的过滤只显示包含当前PID的行
// 可以根据需要进一步处理日志格式
updateLog(it)
}
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("LogManager", "Error reading logcat", e) Log.e("LogManager", "Error starting logcat listener", e)
updateLog("Error reading logs: ${e.message}") }
} }
}.start()
} }
private fun updateLog(msg: String) { fun stopLogcatListener() {
// 限制日志缓冲区大小,避免内存溢出 logcatJob?.cancel()
if (logBuffer.length > 50000) { logcatProcess?.destroy()
logBuffer.delete(0, 10000) logcatProcess = null
} logcatJob = null
logBuffer.append("$msg\n") Log.i("LogManager", "Logcat listener stopped.")
_logs.postValue(logBuffer.toString())
} }
fun clearLogs() { fun addLogListener(listener: (String) -> Unit) {
logBuffer.setLength(0) logListeners.add(listener)
_logs.postValue("")
// 也可以尝试清除系统日志缓存,但通常需要权限或只能清除应用自己的
Thread {
try {
Runtime.getRuntime().exec("logcat -c")
} catch (e: Exception) {
e.printStackTrace()
} }
}.start()
fun removeLogListener(listener: (String) -> Unit) {
logListeners.remove(listener)
}
private fun updateLog(logLine: String) {
logListeners.forEach { it(logLine) }
} }
} }

View File

@@ -3,17 +3,23 @@ package com.example.lzwcai_terminal_temi
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.WindowManager import android.view.WindowManager
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.example.lzwcai_terminal_temi.databinding.ActivityMainBinding import com.example.lzwcai_terminal_temi.databinding.ActivityMainBinding
import com.robotemi.sdk.Robot 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 robot: Robot
private lateinit var binding: ActivityMainBinding private lateinit var binding: ActivityMainBinding
private var mqttManager: MqttManager? = null
private lateinit var prefs: SharedPreferences
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -21,23 +27,104 @@ class MainActivity : AppCompatActivity() {
binding = ActivityMainBinding.inflate(layoutInflater) binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
// 启动日志监听
LogManager.startLogcatListener() LogManager.startLogcatListener()
// 隐藏软键盘
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN) window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN)
// 获取 Robot 实例
robot = Robot.getInstance() robot = Robot.getInstance()
// 设置按钮点击事件 prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
binding.btnSettings.setOnClickListener { binding.btnSettings.setOnClickListener {
startActivity(Intent(this, SettingsActivity::class.java)) startActivity(Intent(this, SettingsActivity::class.java))
} }
// 读取配置的 IP 并记录日志 binding.btnRandomExpression.setOnClickListener {
val prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE) binding.animatedEmojiView.currentExpression = AnimatedEmojiView.Expression.values().random()
val ip = prefs.getString("network_ip", "未设置") }
Log.i("MainActivity", getString(R.string.log_init, ip))
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() 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() { private fun hideKeyboard() {

View File

@@ -16,4 +16,32 @@
android:contentDescription="@string/btn_settings" android:contentDescription="@string/btn_settings"
android:src="@android:drawable/ic_menu_manage" /> 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> </RelativeLayout>

View File

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

View File

@@ -7,6 +7,7 @@ junitVersion = "1.3.0"
espressoCore = "3.7.0" espressoCore = "3.7.0"
appcompat = "1.7.1" appcompat = "1.7.1"
material = "1.13.0" material = "1.13.0"
emoji2-views = "1.5.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -15,6 +16,8 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "j
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" } material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-emoji2-views = { group = "androidx.emoji2", name = "emoji2-views", version.ref = "emoji2-views" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }