feat: add proper connection and status handling

This commit is contained in:
Fritz Heiden 2025-04-06 15:17:22 +02:00
parent 0b5c2a303c
commit c6f3a0b0f9
11 changed files with 196 additions and 102 deletions

View File

@ -4,7 +4,7 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-03-18T16:06:30.698647383Z">
<DropdownSelection timestamp="2025-04-05T11:04:12.656433726Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=d3e11beb" />

View File

@ -26,6 +26,7 @@ import androidx.navigation.compose.rememberNavController
import com.example.tvcontroller.client.WebClient
import com.example.tvcontroller.client.WebsocketClient
import com.example.tvcontroller.services.BluetoothService
import com.example.tvcontroller.services.CameraService
import com.example.tvcontroller.services.ControllerService
import com.example.tvcontroller.services.DeviceService
import com.example.tvcontroller.services.webrtc.WebRtcService
@ -41,9 +42,16 @@ const val TAG = "MainActivity"
class MainActivity : ComponentActivity() {
private val webClient by lazy { WebClient() }
private val websocketClient by lazy { WebsocketClient(webClient.client) }
private val webRtcService by lazy { WebRtcService(applicationContext, websocketClient) }
private val cameraService by lazy { CameraService(applicationContext) }
private val webRtcService by lazy {
WebRtcService(
applicationContext,
websocketClient,
cameraService
)
}
private val bluetoothService by lazy { BluetoothService(applicationContext) }
private val deviceService by lazy { DeviceService(applicationContext, webClient) }
private val deviceService by lazy { DeviceService(applicationContext, webClient, websocketClient) }
private val controllerService by lazy { ControllerService(bluetoothService) }
override fun onCreate(savedInstanceState: Bundle?) {
@ -51,12 +59,20 @@ class MainActivity : ComponentActivity() {
checkPermissions()
lifecycleScope.launch {
deviceService.initialize()
if (deviceService.serverAddress.isEmpty() || deviceService.token.isEmpty()) return@launch
lifecycleScope.launch(Dispatchers.IO) {
websocketClient.connect(deviceService.serverAddress, deviceService.token)
deviceService.onStatusChanged {
when (it) {
DeviceService.STATUS_REGISTERED -> {
lifecycleScope.launch(Dispatchers.IO) {
websocketClient.connect(deviceService.serverAddress, deviceService.token)
}
webRtcService.connect()
}
DeviceService.STATUS_UNREGISTERED -> {
Log.i(TAG, "Device unregistered")
}
}
}
webRtcService.connect()
deviceService.initialize()
}
enableEdgeToEdge()
setContent {
@ -65,7 +81,8 @@ class MainActivity : ComponentActivity() {
deviceService = deviceService,
controllerService = controllerService,
bluetoothService = bluetoothService,
webRtcService = webRtcService
webRtcService = webRtcService,
cameraService = cameraService
)
}
}
@ -77,6 +94,10 @@ class MainActivity : ComponentActivity() {
Log.i(TAG, "Requesting Bluetooth permissions")
ActivityCompat.requestPermissions(this, BluetoothService.BLUETOOTH_PERMISSIONS, 0)
}
if (!cameraService.hasRequiredPermissions()) {
Log.i(TAG, "Requesting Camera permissions")
ActivityCompat.requestPermissions(this, CameraService.CAMERA_PERMISSIONS, 0)
}
}
}
@ -86,7 +107,8 @@ fun TvControllerApp(
deviceService: DeviceService,
controllerService: ControllerService,
bluetoothService: BluetoothService,
webRtcService: WebRtcService
webRtcService: WebRtcService,
cameraService: CameraService
) {
val backStackEntry by navController.currentBackStackEntryAsState()
val currentScreen = Screen.valueOf(backStackEntry?.destination?.route ?: Screen.Camera.name)
@ -125,7 +147,7 @@ fun TvControllerApp(
) {
composable(route = Screen.Camera.name) {
CameraView(
eglBaseContext = webRtcService.eglBaseContext,
eglBaseContext = cameraService.eglBaseContext,
videoTrack = webRtcService.videoTrack
)
}

View File

@ -1,8 +0,0 @@
package com.example.tvcontroller
object Settings {
enum class ConnectionState {
Unregistered,
Registered,
}
}

View File

@ -15,11 +15,14 @@ private const val TAG = "WebsocketClient"
class WebsocketClient(private val client: HttpClient) {
private var websocket: DefaultWebSocketSession? = null
private val dataHandlers = mutableListOf<(String) -> Unit>()
private val connectedHandlers = mutableListOf<() -> Unit>()
private val disconnectedHandlers = mutableListOf<() -> Unit>()
suspend fun connect(serverAddress: String, token: String) {
Log.i(TAG, "Connecting to websocket at $serverAddress")
val (host, port) = serverAddress.split(":")
val portInt = if (port.isEmpty()) 80 else port.toInt()
connectedHandlers.forEach { it() }
client.webSocket(
method = HttpMethod.Get,
host = host,
@ -40,6 +43,16 @@ class WebsocketClient(private val client: HttpClient) {
}
}
}
Log.i(TAG, "Websocket connection closed")
disconnectedHandlers.forEach { it() }
}
fun onConnected(handler: () -> Unit) {
connectedHandlers.add(handler)
}
fun onDisconnected(handler: () -> Unit) {
disconnectedHandlers.add(handler)
}
fun onData(handler: (String) -> Unit) {

View File

@ -2,16 +2,54 @@ package com.example.tvcontroller.services
import android.content.Context
import android.content.pm.PackageManager
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
import org.webrtc.Camera2Capturer
import org.webrtc.DefaultVideoDecoderFactory
import org.webrtc.EglBase
import org.webrtc.HardwareVideoEncoderFactory
import org.webrtc.SimulcastVideoEncoderFactory
import org.webrtc.SoftwareVideoEncoderFactory
class CameraService(private val context: Context) {
var eglBaseContext: EglBase.Context = EglBase.create().eglBaseContext
val cameraManager by lazy { context.getSystemService(Context.CAMERA_SERVICE) as CameraManager }
val videoDecoderFactory by lazy { DefaultVideoDecoderFactory(eglBaseContext) }
val videoEncoderFactory by lazy {
SimulcastVideoEncoderFactory(
HardwareVideoEncoderFactory(eglBaseContext, true, true),
SoftwareVideoEncoderFactory()
)
}
fun createCameraCapturer(): Camera2Capturer {
val ids = cameraManager.cameraIdList
var foundCamera = false;
var cameraId = ""
for (id in ids) {
val characteristics = cameraManager.getCameraCharacteristics(id)
val facing = characteristics.get(CameraCharacteristics.LENS_FACING)
if (facing == CameraCharacteristics.LENS_FACING_BACK) {
cameraId = id
foundCamera = true
break
}
}
if (!foundCamera) {
cameraId = ids.first()
}
val cameraCapturer = Camera2Capturer(context, cameraId, null)
return cameraCapturer
}
fun hasRequiredPermissions(): Boolean {
return CAMERAX_PERMISSIONS.all {
return CAMERA_PERMISSIONS.all {
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
}
}
companion object {
val CAMERAX_PERMISSIONS = arrayOf(
val CAMERA_PERMISSIONS = arrayOf(
android.Manifest.permission.CAMERA,
android.Manifest.permission.RECORD_AUDIO
)

View File

@ -3,28 +3,62 @@ package com.example.tvcontroller.services
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.util.Log
import androidx.lifecycle.lifecycleScope
import com.example.tvcontroller.client.WebClient
import com.example.tvcontroller.client.WebsocketClient
import com.example.tvcontroller.data.Integration
import io.ktor.client.call.body
import io.ktor.http.HttpMethod
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.json.JSONObject
private const val SHARED_PREFERENCES_NAME = "devices";
private const val TAG = "DeviceService"
class DeviceService(private val context: Context, private val client: WebClient) {
class DeviceService(
private val context: Context,
private val client: WebClient,
private val websocketClient: WebsocketClient
) {
var serverAddress: String = ""
var token: String = ""
private set(value) {
field = value
updateDefaultCookies()
var status: String = STATUS_UNREGISTERED
private set(status) {
field = status
statusChangedListeners.forEach { it(status) }
}
private var deviceId: String = ""
private val statusChangedListeners = mutableListOf<(String) -> Unit>()
suspend fun initialize() {
websocketClient.onConnected { status = STATUS_CONNECTED }
websocketClient.onDisconnected { status = STATUS_REGISTERED }
onStatusChanged {
when(it) {
STATUS_UNREGISTERED -> {
token = ""
deviceId = ""
updateDefaultCookies()
savePreferences()
}
STATUS_REGISTERED -> {
Log.i(TAG, "Device registered with id $deviceId")
savePreferences()
connectWebsocket()
}
}
}
loadPreferences()
if (token.isEmpty()) return
getIntegration()
updateDefaultCookies()
val integration = getIntegration()
Log.i(TAG, "Integration: $integration")
status = if (integration != null) {
STATUS_REGISTERED
} else {
STATUS_UNREGISTERED
}
}
private fun updateDefaultCookies() {
@ -34,14 +68,15 @@ class DeviceService(private val context: Context, private val client: WebClient)
}
suspend fun registerIntegration(name: String, code: String) {
Log.i(TAG, "Creating integration for $name with code $code at $serverAddress")
Log.i(TAG, "Registering integration for $name with code $code at $serverAddress")
savePreferences()
val requestJson = JSONObject().apply {
put("name", name)
put("code", code)
}
val response =
client.sendJsonRequest(
"http://$serverAddress/api/integrations",
"http://$serverAddress/api/integrations/register",
HttpMethod.Post,
requestJson
) ?: return
@ -49,12 +84,23 @@ class DeviceService(private val context: Context, private val client: WebClient)
val responseJson = JSONObject(response.body<String>())
if (response.status.value != 200) {
val error = responseJson.getString("error")
Log.e(TAG, "Error getting integration: ${response.status.value} $error")
Log.e(TAG, "Error registering integration: ${response.status.value} $error")
return
}
token = responseJson.getString("token")
deviceId = responseJson.getString("id")
savePreferences()
status = STATUS_REGISTERED
}
fun onStatusChanged(listener: (String) -> Unit) {
statusChangedListeners.add(listener)
}
@OptIn(DelicateCoroutinesApi::class)
fun connectWebsocket() {
GlobalScope.launch() {
websocketClient.connect(serverAddress, token)
}
}
suspend fun getIntegration(): Integration? {
@ -77,24 +123,33 @@ class DeviceService(private val context: Context, private val client: WebClient)
private fun loadPreferences() {
val sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, MODE_PRIVATE)
serverAddress = sharedPreferences.getString("server_address", "")!!
token = sharedPreferences.getString("token", "")!!
deviceId = sharedPreferences.getString("device_id", "")!!
serverAddress = sharedPreferences.getString(SERVER_ADDRESS_KEY, "")!!
token = sharedPreferences.getString(TOKEN_KEY, "")!!
deviceId = sharedPreferences.getString(DEVICE_ID_KEY, "")!!
Log.i(TAG, "Loaded preferences: $serverAddress $token")
}
private fun savePreferences() {
Log.i(TAG, "Saving preferences")
val sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.apply {
putString("server_address", serverAddress)
putString("token", token)
putString("device_id", deviceId)
putString(SERVER_ADDRESS_KEY, serverAddress)
putString(TOKEN_KEY, token)
putString(DEVICE_ID_KEY, deviceId)
apply()
}
}
companion object {
const val TYPE_SIGNALING = "signaling"
const val STATUS_UNREGISTERED = "unregistered"
const val STATUS_REGISTERED = "registered"
const val STATUS_CONNECTED = "connected"
private const val SHARED_PREFERENCES_NAME = "devices";
private const val SERVER_ADDRESS_KEY = "server_address"
private const val TOKEN_KEY = "token"
private const val DEVICE_ID_KEY = "device_id"
private const val TAG = "DeviceService"
}
}

View File

@ -4,6 +4,7 @@ import android.content.Context
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
import android.util.Log
import com.example.tvcontroller.services.CameraService
import org.webrtc.AudioTrack
import org.webrtc.Camera2Capturer
import org.webrtc.DataChannel
@ -27,21 +28,13 @@ import org.webrtc.VideoTrack
private const val TAG = "RtcPeerConnection"
class RtcPeerConnection(private val context: Context) {
class RtcPeerConnection(private val context: Context, private val cameraService: CameraService) {
private val peerConnectionFactory by lazy { initializeFactory() }
private var iceServers = ArrayList<PeerConnection.IceServer>()
var eglBaseContext: EglBase.Context = EglBase.create().eglBaseContext
private val cameraManager by lazy { context.getSystemService(Context.CAMERA_SERVICE) as CameraManager }
private val videoCapturer by lazy { createCameraCapturer() }
var eglBaseContext: EglBase.Context = cameraService.eglBaseContext
private val videoCapturer by lazy { cameraService.createCameraCapturer() }
private val surfaceTextureHelper by lazy { createSurfaceTextureHelper() }
private val videoSource by lazy { createVideoSource() }
private val videoDecoderFactory by lazy { DefaultVideoDecoderFactory(eglBaseContext) }
private val videoEncoderFactory by lazy {
SimulcastVideoEncoderFactory(
HardwareVideoEncoderFactory(eglBaseContext, true, true),
SoftwareVideoEncoderFactory()
)
}
private var peerConnection: PeerConnection? = null
private val iceCandidateHandlers = ArrayList<((IceCandidate) -> Unit)>()
@ -55,6 +48,7 @@ class RtcPeerConnection(private val context: Context) {
override fun onIceCandidate(iceCandidate: IceCandidate?) {
iceCandidateHandlers.forEach { it(iceCandidate!!) }
}
override fun onIceCandidatesRemoved(p0: Array<out IceCandidate?>?) {}
override fun onAddStream(p0: MediaStream?) {}
override fun onRemoveStream(p0: MediaStream?) {}
@ -144,32 +138,13 @@ class RtcPeerConnection(private val context: Context) {
val options = PeerConnectionFactory.Options()
val peerConnectionFactory =
PeerConnectionFactory.builder().setVideoDecoderFactory(videoDecoderFactory)
.setVideoEncoderFactory(videoEncoderFactory).setOptions(options)
PeerConnectionFactory.builder()
.setVideoDecoderFactory(cameraService.videoDecoderFactory)
.setVideoEncoderFactory(cameraService.videoEncoderFactory).setOptions(options)
.createPeerConnectionFactory()
return peerConnectionFactory
}
private fun createCameraCapturer(): Camera2Capturer {
val ids = cameraManager.cameraIdList
var foundCamera = false;
var cameraId = ""
for (id in ids) {
val characteristics = cameraManager.getCameraCharacteristics(id)
val facing = characteristics.get(CameraCharacteristics.LENS_FACING)
if (facing == CameraCharacteristics.LENS_FACING_BACK) {
cameraId = id
foundCamera = true
break
}
}
if (!foundCamera) {
cameraId = ids.first()
}
val cameraCapturer = Camera2Capturer(context, cameraId, null)
return cameraCapturer
}
private fun createSurfaceTextureHelper(): SurfaceTextureHelper {
val surfaceTextureHelper =
SurfaceTextureHelper.create("SurfaceTextureHelperThread", eglBaseContext)

View File

@ -3,22 +3,21 @@ package com.example.tvcontroller.services.webrtc
import android.content.Context
import android.util.Log
import com.example.tvcontroller.client.WebsocketClient
import com.example.tvcontroller.services.DeviceService
import com.example.tvcontroller.services.CameraService
import kotlinx.coroutines.runBlocking
import org.json.JSONObject
import org.webrtc.EglBase
import org.webrtc.IceCandidate
import org.webrtc.MediaConstraints
import org.webrtc.SessionDescription
private const val TAG = "WebRtcService"
class WebRtcService(private val context: Context, private val websocketClient: WebsocketClient) {
class WebRtcService(
private val context: Context,
private val websocketClient: WebsocketClient,
private val cameraService: CameraService
) {
private val rtcPeerConnection by lazy { createRtcPeerConnection() }
val videoTrack by lazy { rtcPeerConnection.createVideoTrack() }
val audioTrack by lazy { rtcPeerConnection.createAudioTrack() }
val eglBaseContext: EglBase.Context
get() = rtcPeerConnection.eglBaseContext
private var peerId: String = ""
fun connect() {
@ -29,7 +28,7 @@ class WebRtcService(private val context: Context, private val websocketClient: W
private fun createRtcPeerConnection(): RtcPeerConnection {
val iceServers = arrayOf("stun:stun.l.google.com:19302")
val webRtcService = this
val rtcPeerConnection = RtcPeerConnection(context).apply {
val rtcPeerConnection = RtcPeerConnection(context, cameraService).apply {
setIceServers(iceServers)
onIceCandidate(webRtcService::sendIceCandidate)
initialize()
@ -89,7 +88,7 @@ class WebRtcService(private val context: Context, private val websocketClient: W
private fun sendIceCandidate(iceCandidate: IceCandidate) {
val messageJson = JSONObject()
messageJson.put("type", DeviceService.Companion.TYPE_SIGNALING)
messageJson.put("type", TYPE_SIGNALING)
messageJson.put("target", peerId)
messageJson.put("message", JSONObject().apply {
put("candidate", JSONObject().apply {
@ -108,7 +107,7 @@ class WebRtcService(private val context: Context, private val websocketClient: W
private fun sendAnswer(targetId: String, sdp: String) {
val messageJson = JSONObject()
messageJson.put("type", DeviceService.Companion.TYPE_SIGNALING)
messageJson.put("type", TYPE_SIGNALING)
messageJson.put("target", targetId)
messageJson.put("message", JSONObject().apply {
put("sdp", sdp)
@ -120,4 +119,10 @@ class WebRtcService(private val context: Context, private val websocketClient: W
websocketClient.sendJson(messageJson)
}
}
companion object {
const val TYPE_SIGNALING = "signaling"
private const val TAG = "WebRtcService"
}
}

View File

@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@ -31,11 +30,10 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.example.tvcontroller.R
import com.example.tvcontroller.Settings
import com.example.tvcontroller.ui.views.SettingsViewModel.Companion.CONNECT_CONTROLLER_VIEW
import com.example.tvcontroller.ui.views.SettingsViewModel.Companion.MAIN_SETTINGS_VIEW
import com.example.tvcontroller.services.BluetoothService
import com.example.tvcontroller.services.DeviceService
import com.example.tvcontroller.ui.views.SettingsViewModel.Companion.CONNECT_CONTROLLER_VIEW
import com.example.tvcontroller.ui.views.SettingsViewModel.Companion.MAIN_SETTINGS_VIEW
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -178,10 +176,12 @@ fun SettingsView(
}
@Composable
fun getConnectionStateString(state: Settings.ConnectionState): String {
fun getConnectionStateString(state: String): String {
return when (state) {
Settings.ConnectionState.Unregistered -> stringResource(id = R.string.connection_state_unregistered)
Settings.ConnectionState.Registered -> stringResource(id = R.string.connection_state_registered)
DeviceService.STATUS_UNREGISTERED -> stringResource(id = R.string.connection_state_unregistered)
DeviceService.STATUS_REGISTERED -> stringResource(id = R.string.connection_state_registered)
DeviceService.STATUS_CONNECTED -> stringResource(id = R.string.connection_state_connected)
else -> "Unknown"
}
}

View File

@ -8,7 +8,6 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.example.tvcontroller.Settings
import com.example.tvcontroller.data.BluetoothDevice
import com.example.tvcontroller.services.BluetoothService
import com.example.tvcontroller.services.DeviceService
@ -27,7 +26,7 @@ class SettingsViewModel(
private set
var registrationCode by mutableStateOf("")
private set
var connectionState by mutableStateOf(Settings.ConnectionState.Unregistered)
var connectionState by mutableStateOf(deviceService.status)
private set
var bluetoothConnectionState by mutableStateOf(bluetoothService.state)
private set
@ -45,10 +44,10 @@ class SettingsViewModel(
currentBluetoothDevice = bluetoothService.currentDevice
bluetoothConnectionState = it
}
deviceService.onStatusChanged { connectionState = it }
}
fun connect() {
//Log.i("SettingsScreen", "Save settings: $serverUrl, $deviceName, $registrationCode")
viewModelScope.launch {
deviceService.serverAddress = serverAddress
deviceService.registerIntegration(deviceName, registrationCode)
@ -59,21 +58,15 @@ class SettingsViewModel(
private fun updateConnectionState() {
Log.i(TAG, "Device token: ${deviceService.token}")
connectionState = if (deviceService.token.isEmpty()) {
Settings.ConnectionState.Unregistered
} else {
Settings.ConnectionState.Registered
}
connectionState = deviceService.status
}
private suspend fun updateDeviceInfo() {
if (connectionState == Settings.ConnectionState.Unregistered) return
if (connectionState == DeviceService.STATUS_UNREGISTERED) return
val integration = deviceService.getIntegration()
if (integration == null) {
connectionState = Settings.ConnectionState.Unregistered
return
integration?.let {
deviceName = it.name
}
deviceName = integration.name
}
fun connectBluetoothDevice(device: BluetoothDevice) {

View File

@ -21,4 +21,5 @@
<string name="bluetooth_state_connected">connected</string>
<string name="bluetooth_state_disconnected">disconnected</string>
<string name="disconnect_button_label">Disconnect</string>
<string name="connection_state_connected">connected</string>
</resources>