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 78c3b76..a421569 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 @@ -82,6 +105,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) } @@ -104,6 +134,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG } updateMqttConnection() + updateLiveKitStatusSnapshot() } override fun onStart() { @@ -120,6 +151,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG } else { mqttManager?.connect() } + updateLiveKitConnection() startBlinking() } @@ -132,6 +164,7 @@ class MainActivity : AppCompatActivity(), OnRobotReadyListener, TtsListener, OnG robot.removeOnReposeStatusChangedListener(this) robot.removeOnRequestPermissionResultListener(this) // mqttManager?.disconnect() // Keep MQTT alive in background/settings + liveKitManager?.disconnect() stopBlinking() } @@ -139,6 +172,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.") @@ -396,6 +430,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) @@ -405,6 +440,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 { @@ -415,15 +453,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 6ffe1de..f5e5b77 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 @@ -47,10 +50,12 @@ class MqttManager( override fun connectComplete(reconnect: Boolean, serverURI: String?) { Log.i(TAG, "MQTT connection complete. Reconnect: $reconnect") subscribeTopic("robot/cmd") + updateConnectionStatus(true) } override fun connectionLost(cause: Throwable?) { Log.e(TAG, "Connection lost: ${cause?.message}") + updateConnectionStatus(false) scheduleReconnect() } @@ -74,6 +79,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 +95,7 @@ class MqttManager( mqttClient?.connect(options) } catch (e: MqttException) { Log.e(TAG, "Initial connection failed: ${e.message}") + updateConnectionStatus(false) scheduleReconnect() } } @@ -113,8 +120,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 +140,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) } } 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..1ed54d6 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,12 @@ 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 liveKitUrlDefault = "ws://192.168.2.236:7880" + private val liveKitRoomDefault = "temi-room" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -41,9 +47,18 @@ class SettingsActivity : AppCompatActivity() { prefs = getSharedPreferences("app_prefs", Context.MODE_PRIVATE) val savedIp = prefs.getString("network_ip", "") binding.etIpAddress.setText(savedIp) + 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() } @@ -61,6 +76,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 +234,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..4ca7bf2 100644 --- a/app/src/main/res/layout-land/activity_settings.xml +++ b/app/src/main/res/layout-land/activity_settings.xml @@ -114,6 +114,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +