Compare commits

..

3 Commits
main ... webrtc

20 changed files with 216 additions and 460 deletions

View File

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

View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Fritz Heiden
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -28,7 +28,6 @@
tools:targetApi="31"> tools:targetApi="31">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true" android:exported="true"
android:theme="@style/Theme.TVController"> android:theme="@style/Theme.TVController">
<intent-filter> <intent-filter>

View File

@ -5,17 +5,34 @@ import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.example.tvcontroller.client.WebClient import com.example.tvcontroller.client.WebClient
import com.example.tvcontroller.client.WebsocketClient import com.example.tvcontroller.client.WebsocketClient
import com.example.tvcontroller.services.BluetoothService import com.example.tvcontroller.services.BluetoothService
import com.example.tvcontroller.services.CameraService
import com.example.tvcontroller.services.ControllerService import com.example.tvcontroller.services.ControllerService
import com.example.tvcontroller.services.DeviceService import com.example.tvcontroller.services.DeviceService
import com.example.tvcontroller.services.webrtc.WebRtcService import com.example.tvcontroller.services.webrtc.WebRtcService
import com.example.tvcontroller.ui.theme.TVControllerTheme import com.example.tvcontroller.ui.theme.TVControllerTheme
import com.example.tvcontroller.ui.views.MainView import com.example.tvcontroller.ui.views.CameraView
import com.example.tvcontroller.ui.views.RemoteView
import com.example.tvcontroller.ui.views.SettingsView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -24,47 +41,31 @@ const val TAG = "MainActivity"
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val webClient by lazy { WebClient() } private val webClient by lazy { WebClient() }
private val websocketClient by lazy { WebsocketClient(webClient.client) } private val websocketClient by lazy { WebsocketClient(webClient.client) }
private val cameraService by lazy { CameraService(applicationContext) } private val webRtcService by lazy { WebRtcService(applicationContext, websocketClient) }
private val webRtcService by lazy {
WebRtcService(
applicationContext,
websocketClient,
cameraService
)
}
private val bluetoothService by lazy { BluetoothService(applicationContext) } private val bluetoothService by lazy { BluetoothService(applicationContext) }
private val deviceService by lazy { DeviceService(applicationContext, webClient, websocketClient) } private val deviceService by lazy { DeviceService(applicationContext, webClient) }
private val controllerService by lazy { ControllerService(bluetoothService, webRtcService) } private val controllerService by lazy { ControllerService(bluetoothService) }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
checkPermissions() checkPermissions()
lifecycleScope.launch { lifecycleScope.launch {
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")
}
}
}
deviceService.initialize() deviceService.initialize()
if (deviceService.serverAddress.isEmpty() || deviceService.token.isEmpty()) return@launch
lifecycleScope.launch(Dispatchers.IO) {
websocketClient.connect(deviceService.serverAddress, deviceService.token)
}
webRtcService.connect()
} }
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
TVControllerTheme { TVControllerTheme {
MainView( TvControllerApp(
deviceService = deviceService, deviceService = deviceService,
controllerService = controllerService, controllerService = controllerService,
bluetoothService = bluetoothService, bluetoothService = bluetoothService,
webRtcService = webRtcService, webRtcService = webRtcService
cameraService = cameraService
) )
} }
} }
@ -76,9 +77,68 @@ class MainActivity : ComponentActivity() {
Log.i(TAG, "Requesting Bluetooth permissions") Log.i(TAG, "Requesting Bluetooth permissions")
ActivityCompat.requestPermissions(this, BluetoothService.BLUETOOTH_PERMISSIONS, 0) ActivityCompat.requestPermissions(this, BluetoothService.BLUETOOTH_PERMISSIONS, 0)
} }
if (!cameraService.hasRequiredPermissions()) { }
Log.i(TAG, "Requesting Camera permissions") }
ActivityCompat.requestPermissions(this, CameraService.CAMERA_PERMISSIONS, 0)
@Composable
fun TvControllerApp(
navController: NavHostController = rememberNavController(),
deviceService: DeviceService,
controllerService: ControllerService,
bluetoothService: BluetoothService,
webRtcService: WebRtcService
) {
val backStackEntry by navController.currentBackStackEntryAsState()
val currentScreen = Screen.valueOf(backStackEntry?.destination?.route ?: Screen.Camera.name)
val baselineCamera24 = painterResource(R.drawable.baseline_camera_24)
val baselineRemote24 = painterResource(R.drawable.baseline_settings_remote_24)
val baselineSettings24 = painterResource(R.drawable.baseline_settings_24)
Scaffold(modifier = Modifier.fillMaxSize(), bottomBar = {
NavigationBar {
NavigationBarItem(
onClick = { navController.navigate(Screen.Camera.name) }, icon = {
Icon(
baselineCamera24, contentDescription = "Camera"
)
}, label = { Text("Camera") }, selected = currentScreen == Screen.Camera
)
NavigationBarItem(
onClick = { navController.navigate(Screen.Remote.name) }, icon = {
Icon(
baselineRemote24, contentDescription = "Remote"
)
}, label = { Text("Remote") }, selected = currentScreen == Screen.Remote
)
NavigationBarItem(
onClick = { navController.navigate(Screen.Settings.name) }, icon = {
Icon(
baselineSettings24, contentDescription = "Settings"
)
}, label = { Text("Settings") }, selected = currentScreen == Screen.Settings
)
}
}) { innerPadding ->
NavHost(
navController = navController,
startDestination = Screen.Settings.name,
modifier = Modifier.padding(innerPadding)
) {
composable(route = Screen.Camera.name) {
CameraView(
eglBaseContext = webRtcService.eglBaseContext,
videoTrack = webRtcService.videoTrack
)
}
composable(route = Screen.Remote.name) {
RemoteView(
controllerService = controllerService
)
}
composable(route = Screen.Settings.name) {
SettingsView(
deviceService = deviceService, bluetoothService = bluetoothService
)
}
} }
} }
} }

View File

@ -0,0 +1,7 @@
package com.example.tvcontroller
enum class Screen {
Camera,
Remote,
Settings
}

View File

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

View File

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

View File

@ -1,7 +1,9 @@
package com.example.tvcontroller.data package com.example.tvcontroller.data
class RemoteCommand { class RemoteCommand {
var functionName: String? = null
var protocol: String? = null var protocol: String? = null
var device: String? = null var device: String? = null
var command: String? = null var subdevice: String? = null
var function: String? = null
} }

View File

@ -2,54 +2,16 @@ package com.example.tvcontroller.services
import android.content.Context import android.content.Context
import android.content.pm.PackageManager 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) { 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 { fun hasRequiredPermissions(): Boolean {
return CAMERA_PERMISSIONS.all { return CAMERAX_PERMISSIONS.all {
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
} }
} }
companion object { companion object {
val CAMERA_PERMISSIONS = arrayOf( val CAMERAX_PERMISSIONS = arrayOf(
android.Manifest.permission.CAMERA, android.Manifest.permission.CAMERA,
android.Manifest.permission.RECORD_AUDIO android.Manifest.permission.RECORD_AUDIO
) )

View File

@ -1,33 +1,32 @@
package com.example.tvcontroller.services package com.example.tvcontroller.services
import android.content.Context
import android.util.Log import android.util.Log
import com.example.tvcontroller.data.RemoteCommand import com.example.tvcontroller.data.RemoteCommand
import com.example.tvcontroller.services.webrtc.WebRtcService
import org.json.JSONObject import org.json.JSONObject
import java.nio.ByteBuffer
import java.nio.charset.StandardCharsets
class ControllerService( class ControllerService(
private val bluetoothService: BluetoothService, private val bluetoothService: BluetoothService
private val webRtcService: WebRtcService
) { ) {
private val samsungCommands = mutableMapOf<String, RemoteCommand>()
init { init {
loadCommands() loadCommands()
webRtcService.onDataChannelData(this::handleWebRtcData)
} }
fun sendCommand(command: RemoteCommand) { fun sendCommand(command: String) {
val jsonString = remoteCommandToJsonString(command) if (samsungCommands[command] == null) return
Log.i(TAG, "Sending command: $jsonString") Log.i("ControllerService", "Sending command: $command")
val jsonString = remoteCommandToJsonString(samsungCommands[command]!!)
sendData(jsonString) sendData(jsonString)
} }
fun remoteCommandToJsonString(command: RemoteCommand): String { fun remoteCommandToJsonString(command: RemoteCommand): String {
var commandObject = JSONObject() var commandObject = JSONObject()
commandObject.put("protocol", command.protocol) commandObject.put("protocol", command.protocol)
commandObject.put("device", command.device) commandObject.put("address", command.device)
commandObject.put("command", command.command) commandObject.put("command", command.function)
return commandObject.toString() return commandObject.toString()
} }
@ -38,27 +37,7 @@ class ControllerService(
fun loadCommands() { fun loadCommands() {
} }
fun handleWebRtcData(data: ByteBuffer) {
val dataString = StandardCharsets.UTF_8.decode(data).toString()
val json = JSONObject(dataString)
if (!json.has("type") || json.getString("type") != MESSAGE_TYPE_COMMAND) return
val commandJson = json.getJSONObject("data")
val protocol = if (commandJson.has("protocol")) commandJson.getString("protocol") else null
val device = if (commandJson.has("device")) commandJson.getString("device") else null
val command = if (commandJson.has("commandNumber")) commandJson.getString("commandNumber") else null
val remoteCommand = RemoteCommand().apply {
this.protocol = protocol
this.device = device
this.command = command
}
sendCommand(remoteCommand)
}
companion object { companion object {
private const val TAG = "ControllerService"
const val MESSAGE_TYPE_COMMAND = "command"
const val POWER = "POWER" const val POWER = "POWER"
const val CURSOR_UP = "CURSOR UP" const val CURSOR_UP = "CURSOR UP"
const val CURSOR_DOWN = "CURSOR DOWN" const val CURSOR_DOWN = "CURSOR DOWN"

View File

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

@ -1,11 +1,15 @@
package com.example.tvcontroller.services.webrtc package com.example.tvcontroller.services.webrtc
import android.content.Context import android.content.Context
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
import android.util.Log import android.util.Log
import com.example.tvcontroller.services.CameraService
import org.webrtc.AudioTrack import org.webrtc.AudioTrack
import org.webrtc.Camera2Capturer
import org.webrtc.DataChannel import org.webrtc.DataChannel
import org.webrtc.DefaultVideoDecoderFactory
import org.webrtc.EglBase import org.webrtc.EglBase
import org.webrtc.HardwareVideoEncoderFactory
import org.webrtc.IceCandidate import org.webrtc.IceCandidate
import org.webrtc.MediaConstraints import org.webrtc.MediaConstraints
import org.webrtc.MediaStream import org.webrtc.MediaStream
@ -15,24 +19,31 @@ import org.webrtc.PeerConnectionFactory
import org.webrtc.PeerConnectionFactory.InitializationOptions import org.webrtc.PeerConnectionFactory.InitializationOptions
import org.webrtc.SdpObserver import org.webrtc.SdpObserver
import org.webrtc.SessionDescription import org.webrtc.SessionDescription
import org.webrtc.SimulcastVideoEncoderFactory
import org.webrtc.SoftwareVideoEncoderFactory
import org.webrtc.SurfaceTextureHelper import org.webrtc.SurfaceTextureHelper
import org.webrtc.VideoSource import org.webrtc.VideoSource
import org.webrtc.VideoTrack import org.webrtc.VideoTrack
import java.nio.ByteBuffer
import java.nio.charset.StandardCharsets
private const val TAG = "RtcPeerConnection" private const val TAG = "RtcPeerConnection"
class RtcPeerConnection(private val context: Context, private val cameraService: CameraService) { class RtcPeerConnection(private val context: Context) {
private val peerConnectionFactory by lazy { initializeFactory() } private val peerConnectionFactory by lazy { initializeFactory() }
private var iceServers = ArrayList<PeerConnection.IceServer>() private var iceServers = ArrayList<PeerConnection.IceServer>()
var eglBaseContext: EglBase.Context = cameraService.eglBaseContext var eglBaseContext: EglBase.Context = EglBase.create().eglBaseContext
private val videoCapturer by lazy { cameraService.createCameraCapturer() } private val cameraManager by lazy { context.getSystemService(Context.CAMERA_SERVICE) as CameraManager }
private val videoCapturer by lazy { createCameraCapturer() }
private val surfaceTextureHelper by lazy { createSurfaceTextureHelper() } private val surfaceTextureHelper by lazy { createSurfaceTextureHelper() }
private val videoSource by lazy { createVideoSource() } 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 var peerConnection: PeerConnection? = null
private val iceCandidateHandlers = ArrayList<((IceCandidate) -> Unit)>() private val iceCandidateHandlers = ArrayList<((IceCandidate) -> Unit)>()
private val dataChannelHandlers = ArrayList<((ByteBuffer) -> Unit)>()
fun initialize() { fun initialize() {
@ -44,20 +55,10 @@ class RtcPeerConnection(private val context: Context, private val cameraService:
override fun onIceCandidate(iceCandidate: IceCandidate?) { override fun onIceCandidate(iceCandidate: IceCandidate?) {
iceCandidateHandlers.forEach { it(iceCandidate!!) } iceCandidateHandlers.forEach { it(iceCandidate!!) }
} }
override fun onIceCandidatesRemoved(p0: Array<out IceCandidate?>?) {} override fun onIceCandidatesRemoved(p0: Array<out IceCandidate?>?) {}
override fun onAddStream(p0: MediaStream?) {} override fun onAddStream(p0: MediaStream?) {}
override fun onRemoveStream(p0: MediaStream?) {} override fun onRemoveStream(p0: MediaStream?) {}
override fun onDataChannel(channel: DataChannel?) { override fun onDataChannel(p0: DataChannel?) {}
Log.i(TAG, "Data channel created: $channel")
channel?.registerObserver(object : DataChannel.Observer {
override fun onBufferedAmountChange(p0: Long) { }
override fun onStateChange() { }
override fun onMessage(p0: DataChannel.Buffer?) {
dataChannelHandlers.forEach { it(p0?.data!!) }
}
})
}
override fun onRenegotiationNeeded() {} override fun onRenegotiationNeeded() {}
} }
var rtcConfig = PeerConnection.RTCConfiguration(iceServers) var rtcConfig = PeerConnection.RTCConfiguration(iceServers)
@ -137,23 +138,38 @@ class RtcPeerConnection(private val context: Context, private val cameraService:
iceCandidateHandlers.add(handler) iceCandidateHandlers.add(handler)
} }
fun onDataChannelData(handler: (ByteBuffer) -> Unit) {
dataChannelHandlers.add(handler)
}
private fun initializeFactory(): PeerConnectionFactory { private fun initializeFactory(): PeerConnectionFactory {
val initOptions = InitializationOptions.builder(context).createInitializationOptions() val initOptions = InitializationOptions.builder(context).createInitializationOptions()
PeerConnectionFactory.initialize(initOptions) PeerConnectionFactory.initialize(initOptions)
val options = PeerConnectionFactory.Options() val options = PeerConnectionFactory.Options()
val peerConnectionFactory = val peerConnectionFactory =
PeerConnectionFactory.builder() PeerConnectionFactory.builder().setVideoDecoderFactory(videoDecoderFactory)
.setVideoDecoderFactory(cameraService.videoDecoderFactory) .setVideoEncoderFactory(videoEncoderFactory).setOptions(options)
.setVideoEncoderFactory(cameraService.videoEncoderFactory).setOptions(options)
.createPeerConnectionFactory() .createPeerConnectionFactory()
return peerConnectionFactory 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 { private fun createSurfaceTextureHelper(): SurfaceTextureHelper {
val surfaceTextureHelper = val surfaceTextureHelper =
SurfaceTextureHelper.create("SurfaceTextureHelperThread", eglBaseContext) SurfaceTextureHelper.create("SurfaceTextureHelperThread", eglBaseContext)

View File

@ -3,23 +3,22 @@ package com.example.tvcontroller.services.webrtc
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import com.example.tvcontroller.client.WebsocketClient import com.example.tvcontroller.client.WebsocketClient
import com.example.tvcontroller.services.CameraService import com.example.tvcontroller.services.DeviceService
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.json.JSONObject import org.json.JSONObject
import org.webrtc.EglBase
import org.webrtc.IceCandidate import org.webrtc.IceCandidate
import org.webrtc.MediaConstraints import org.webrtc.MediaConstraints
import org.webrtc.SessionDescription import org.webrtc.SessionDescription
import java.nio.ByteBuffer
class WebRtcService( private const val TAG = "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() }
) {
private val dataChannelHandlers = ArrayList<((ByteBuffer) -> Unit)>()
private var rtcPeerConnection: RtcPeerConnection = createRtcPeerConnection()
val videoTrack by lazy { rtcPeerConnection.createVideoTrack() } val videoTrack by lazy { rtcPeerConnection.createVideoTrack() }
val audioTrack by lazy { rtcPeerConnection.createAudioTrack() } val audioTrack by lazy { rtcPeerConnection.createAudioTrack() }
val eglBaseContext: EglBase.Context
get() = rtcPeerConnection.eglBaseContext
private var peerId: String = "" private var peerId: String = ""
fun connect() { fun connect() {
@ -27,24 +26,18 @@ class WebRtcService(
websocketClient.onData(this::handleData) websocketClient.onData(this::handleData)
} }
fun onDataChannelData(handler: (ByteBuffer) -> Unit) {
dataChannelHandlers.add(handler)
}
private fun createRtcPeerConnection(): RtcPeerConnection { private fun createRtcPeerConnection(): RtcPeerConnection {
val iceServers = arrayOf("stun:stun.l.google.com:19302") val iceServers = arrayOf("stun:stun.l.google.com:19302")
val webRtcService = this val webRtcService = this
val rtcPeerConnection = RtcPeerConnection(context, cameraService).apply { val rtcPeerConnection = RtcPeerConnection(context).apply {
setIceServers(iceServers) setIceServers(iceServers)
onIceCandidate(webRtcService::sendIceCandidate) onIceCandidate(webRtcService::sendIceCandidate)
initialize() initialize()
} }
dataChannelHandlers.forEach { rtcPeerConnection.onDataChannelData(it) }
return rtcPeerConnection return rtcPeerConnection
} }
private fun handleOffer(sdp: String) { private fun handleOffer(sdp: String) {
rtcPeerConnection = createRtcPeerConnection()
var mediaConstraints = MediaConstraints() var mediaConstraints = MediaConstraints()
val remoteSessionDescription = SessionDescription(SessionDescription.Type.OFFER, sdp) val remoteSessionDescription = SessionDescription(SessionDescription.Type.OFFER, sdp)
rtcPeerConnection.apply { rtcPeerConnection.apply {
@ -96,7 +89,7 @@ class WebRtcService(
private fun sendIceCandidate(iceCandidate: IceCandidate) { private fun sendIceCandidate(iceCandidate: IceCandidate) {
val messageJson = JSONObject() val messageJson = JSONObject()
messageJson.put("type", TYPE_SIGNALING) messageJson.put("type", DeviceService.Companion.TYPE_SIGNALING)
messageJson.put("target", peerId) messageJson.put("target", peerId)
messageJson.put("message", JSONObject().apply { messageJson.put("message", JSONObject().apply {
put("candidate", JSONObject().apply { put("candidate", JSONObject().apply {
@ -115,7 +108,7 @@ class WebRtcService(
private fun sendAnswer(targetId: String, sdp: String) { private fun sendAnswer(targetId: String, sdp: String) {
val messageJson = JSONObject() val messageJson = JSONObject()
messageJson.put("type", TYPE_SIGNALING) messageJson.put("type", DeviceService.Companion.TYPE_SIGNALING)
messageJson.put("target", targetId) messageJson.put("target", targetId)
messageJson.put("message", JSONObject().apply { messageJson.put("message", JSONObject().apply {
put("sdp", sdp) put("sdp", sdp)
@ -127,10 +120,4 @@ class WebRtcService(
websocketClient.sendJson(messageJson) websocketClient.sendJson(messageJson)
} }
} }
companion object {
const val TYPE_SIGNALING = "signaling"
private const val TAG = "WebRtcService"
}
} }

View File

@ -1,17 +1,10 @@
package com.example.tvcontroller.ui.views package com.example.tvcontroller.ui.views
import android.content.res.Configuration
import android.util.Log
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.tvcontroller.ui.components.CameraPreview import com.example.tvcontroller.ui.components.CameraPreview
import org.webrtc.EglBase import org.webrtc.EglBase
@ -19,16 +12,11 @@ import org.webrtc.VideoTrack
@Composable @Composable
fun CameraView(eglBaseContext: EglBase.Context, videoTrack: VideoTrack) { fun CameraView(eglBaseContext: EglBase.Context, videoTrack: VideoTrack) {
val configuration = LocalConfiguration.current
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
var ratio = if (isLandscape) 16/9f else 9/16f
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize(), .fillMaxSize()
contentAlignment = Alignment.Center .padding(all = 16.dp),
) { ) {
Box(modifier = if (isLandscape) Modifier.aspectRatio(ratio).fillMaxHeight() else Modifier.aspectRatio(ratio).fillMaxWidth()) { CameraPreview(eglBaseContext = eglBaseContext, videoTrack = videoTrack, modifier = Modifier.fillMaxSize())
CameraPreview(eglBaseContext = eglBaseContext, videoTrack = videoTrack, modifier = Modifier.fillMaxSize())
}
} }
} }

View File

@ -1,152 +0,0 @@
package com.example.tvcontroller.ui.views
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.example.tvcontroller.R
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
import com.example.tvcontroller.ui.views.MainViewModel.Companion.CAMERA_VIEW
import com.example.tvcontroller.ui.views.MainViewModel.Companion.REMOTE_VIEW
import com.example.tvcontroller.ui.views.MainViewModel.Companion.SETTINGS_VIEW
private data class NavigationItem(
var onClick: () -> Unit = {},
var icon: @Composable () -> Unit = {},
var label: @Composable () -> Unit = {},
var selected: Boolean = false
)
@Composable
fun MainView(
deviceService: DeviceService,
controllerService: ControllerService,
bluetoothService: BluetoothService,
webRtcService: WebRtcService,
cameraService: CameraService
) {
val viewModel = viewModel<MainViewModel>()
val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState()
val currentView = backStackEntry?.destination?.route
val baselineCamera24 = painterResource(R.drawable.baseline_camera_24)
val baselineRemote24 = painterResource(R.drawable.baseline_settings_remote_24)
val baselineSettings24 = painterResource(R.drawable.baseline_settings_24)
val configuration = LocalConfiguration.current
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
val navigationItems = listOf(
NavigationItem(
onClick = { navController.navigate(CAMERA_VIEW) },
icon = { Icon(baselineCamera24, contentDescription = "Camera") },
label = { Text("Camera") },
selected = currentView == CAMERA_VIEW
), NavigationItem(
onClick = { navController.navigate(REMOTE_VIEW) },
icon = { Icon(baselineRemote24, contentDescription = "Remote") },
label = { Text("Remote") },
selected = currentView == REMOTE_VIEW
), NavigationItem(
onClick = { navController.navigate(SETTINGS_VIEW) },
icon = { Icon(baselineSettings24, contentDescription = "Settings") },
label = { Text("Settings") },
selected = currentView == SETTINGS_VIEW
)
)
Surface(modifier = Modifier.fillMaxSize()) {
Scaffold(
modifier = Modifier
.fillMaxHeight(),
bottomBar = {
if (!isLandscape) {
NavigationBar {
navigationItems.forEach { item ->
NavigationBarItem(
onClick = item.onClick,
icon = item.icon,
label = item.label,
selected = item.selected,
)
}
}
}
}) { innerPadding ->
Row(Modifier.padding(innerPadding)) {
if (isLandscape) {
NavigationRail(
modifier = Modifier
.fillMaxHeight()
.width(64.dp)
) {
Column(
modifier = Modifier
.fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(
12.dp, Alignment.CenterVertically
)
) {
navigationItems.forEach { item ->
NavigationRailItem(
onClick = item.onClick,
icon = item.icon,
label = item.label,
selected = item.selected,
)
}
}
}
}
NavHost(
navController = navController,
startDestination = viewModel.currentView.value,
) {
composable(route = CAMERA_VIEW) {
CameraView(
eglBaseContext = cameraService.eglBaseContext,
videoTrack = webRtcService.videoTrack
)
}
composable(route = REMOTE_VIEW) {
RemoteView(
controllerService = controllerService
)
}
composable(route = SETTINGS_VIEW) {
SettingsView(
deviceService = deviceService, bluetoothService = bluetoothService
)
}
}
}
}
}
}

View File

@ -1,14 +0,0 @@
package com.example.tvcontroller.ui.views
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
class MainViewModel : ViewModel() {
var currentView = mutableStateOf(CAMERA_VIEW)
companion object {
const val CAMERA_VIEW = "camera_view"
const val REMOTE_VIEW = "remote_view"
const val SETTINGS_VIEW = "settings_view"
}
}

View File

@ -3,7 +3,6 @@ package com.example.tvcontroller.ui.views
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.example.tvcontroller.data.RemoteCommand
import com.example.tvcontroller.services.BluetoothService import com.example.tvcontroller.services.BluetoothService
import com.example.tvcontroller.services.ControllerService import com.example.tvcontroller.services.ControllerService
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -13,10 +12,8 @@ import org.json.JSONObject
class RemoteViewModel( class RemoteViewModel(
private val controllerService: ControllerService private val controllerService: ControllerService
) : ViewModel() { ) : ViewModel() {
private val commands = mutableMapOf<String, RemoteCommand>()
fun sendCommand(commandType: String) { fun sendCommand(command: String) {
val command = commands[commandType]?: return
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
controllerService.sendCommand(command) controllerService.sendCommand(command)
} }

View File

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

View File

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

View File

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