Compare commits

..

19 Commits

Author SHA1 Message Date
f7a3be7b40 Add LICENSE.md 2025-06-14 22:15:14 +02:00
c8a0c4160c feat: handle webrtc data channel to send commands 2025-04-15 00:28:56 +02:00
22570e0e6d fix: crash on second webrtc connection 2025-04-07 15:46:13 +02:00
8e1e8dc4e6 feat: add proper navigation in landscape mode 2025-04-06 18:41:21 +02:00
c0a86c5635 refactor: put navigation logic into main view 2025-04-06 16:32:29 +02:00
c6f3a0b0f9 feat: add proper connection and status handling 2025-04-06 15:17:22 +02:00
0b5c2a303c feat: add webrtc streaming 2025-04-04 11:26:06 +02:00
7471168a21 feat: connect to server on app launch 2025-04-01 19:11:03 +02:00
645f8e2f04 chore: upgrade project dependencies 2025-04-01 08:14:41 +02:00
60f146afbc feat: listen to websocket 2025-04-01 08:13:06 +02:00
5db2caeaa4 feat: properly register device and keep token as cookie 2025-03-31 13:26:42 +02:00
cba125738d feat: add remote ui to send commands via bt 2025-03-27 18:43:11 +01:00
4f40490fee feat: add listening and sending data to bt service 2025-03-27 13:34:34 +01:00
a74d0ddef5 feat: reflect bt connection state in ui 2025-03-26 18:51:57 +01:00
afc3378828 feat: implement serial bt connection to device 2025-03-26 17:35:35 +01:00
07c3ef066b feat: use clickable list items when selecting bt devices 2025-03-26 14:30:01 +01:00
8eef70f59b feat: scan for bluetooth devices, list in connect view 2025-03-25 18:54:32 +01:00
e3623cb128 refactor: move bluetooth state to settings view model 2025-03-25 14:55:23 +01:00
0c1f4801c3 feat: add camera preview 2025-03-25 14:51:32 +01:00
61 changed files with 2148 additions and 1411 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" />

21
LICENSE.md Normal file
View File

@ -0,0 +1,21 @@
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

@ -6,7 +6,7 @@ plugins {
android {
namespace = "com.example.tvcontroller"
compileSdk = 34
compileSdk = 35
defaultConfig {
applicationId = "com.example.tvcontroller"
@ -52,6 +52,7 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.websockets)
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
@ -59,8 +60,8 @@ dependencies {
implementation(libs.androidx.camera.view)
implementation(libs.androidx.camera.mlkit.vision)
implementation(libs.androidx.camera.extensions)
implementation(libs.material3)
implementation(libs.stream.webrtc.android)
implementation(libs.stream.log)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@ -5,11 +5,14 @@
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-feature android:name="android.hardware.bluetooth" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
@ -25,6 +28,7 @@
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true"
android:theme="@style/Theme.TVController">
<intent-filter>

View File

@ -1,157 +1,85 @@
package com.example.tvcontroller
import android.bluetooth.BluetoothAdapter
import android.content.ContentValues.TAG
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
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.ui.Modifier
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.ui.theme.TVControllerTheme
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.res.painterResource
import androidx.core.app.ActivityCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.lifecycleScope
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.ui.AppViewModel
import com.example.tvcontroller.ui.views.CameraView
import com.example.tvcontroller.ui.views.SettingsView
import com.example.tvcontroller.services.webrtc.WebRtcService
import com.example.tvcontroller.ui.theme.TVControllerTheme
import com.example.tvcontroller.ui.views.MainView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
const val TAG = "MainActivity"
class MainActivity : ComponentActivity() {
private lateinit var bluetoothService: BluetoothService
private lateinit var deviceService: DeviceService
private lateinit var cameraService: CameraService
private val appViewModel by viewModels<AppViewModel>()
private val webClient by lazy { WebClient() }
private val websocketClient by lazy { WebsocketClient(webClient.client) }
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, websocketClient) }
private val controllerService by lazy { ControllerService(bluetoothService, webRtcService) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
bluetoothService = BluetoothService(this.applicationContext)
bluetoothService.onBluetoothStateChanged { state -> appViewModel.setBluetoothEnabled(state == BluetoothAdapter.STATE_ON) }
appViewModel.setBluetoothEnabled(bluetoothService.isBluetoothEnabled())
deviceService = DeviceService(this.applicationContext)
cameraService = CameraService(this.applicationContext)
checkPermissions()
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()
}
enableEdgeToEdge()
setContent {
TVControllerTheme {
TvControllerApp(
appViewModel = appViewModel,
MainView(
deviceService = deviceService,
controllerService = controllerService,
bluetoothService = bluetoothService,
deviceService = deviceService
webRtcService = webRtcService,
cameraService = cameraService
)
}
}
}
private fun checkPermissions() {
Log.i(TAG, "Checking permissions")
if (!bluetoothService.hasRequiredPermissions()) {
Log.i(TAG, "Requesting Bluetooth permissions")
ActivityCompat.requestPermissions(this, BluetoothService.BLUETOOTH_PERMISSIONS, 0)
}
if (!cameraService.hasRequiredPermissions()) {
ActivityCompat.requestPermissions(this, CameraService.CAMERAX_PERMISSIONS, 0)
Log.i(TAG, "Requesting Camera permissions")
ActivityCompat.requestPermissions(this, CameraService.CAMERA_PERMISSIONS, 0)
}
}
}
@Composable
fun TvControllerApp(
navController: NavHostController = rememberNavController(),
appViewModel: AppViewModel = viewModel(),
bluetoothService: BluetoothService? = null,
deviceService: DeviceService
) {
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 ->
Column {
NavHost(
navController = navController,
startDestination = Screen.Camera.name,
modifier = Modifier.padding(innerPadding)
) {
composable(route = Screen.Camera.name) {
CameraView()
}
composable(route = Screen.Remote.name) {
RemoteScreen()
}
composable(route = Screen.Settings.name) {
SettingsView(appViewModel = appViewModel, deviceService = deviceService)
}
}
}
}
}
@Composable
fun RemoteScreen(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Remote Screen", modifier = modifier)
Button(onClick = { Log.i(TAG, "RemoteScreen: Button clicked") }) {
Text(text = "Button")
}
}
}

View File

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

View File

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

View File

@ -1,79 +0,0 @@
package com.example.tvcontroller
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.example.tvcontroller.services.DeviceService
import kotlinx.coroutines.launch
class SettingsViewModel(private val deviceService: DeviceService) : ViewModel() {
var serverAddress by mutableStateOf(deviceService.getServerAddress())
private set
var deviceName by mutableStateOf(android.os.Build.MANUFACTURER + " " + android.os.Build.MODEL)
private set
var registrationCode by mutableStateOf("")
private set
var connectionState by mutableStateOf(Settings.ConnectionState.Unregistered)
private set
init {
updateConnectionState()
viewModelScope.launch {
updateDeviceInfo()
}
}
fun connect() {
//Log.i("SettingsScreen", "Save settings: $serverUrl, $deviceName, $registrationCode")
viewModelScope.launch {
deviceService.setServerAddress(serverAddress)
deviceService.createIntegration(deviceName, registrationCode)
updateConnectionState()
}
}
private fun updateConnectionState() {
connectionState = if (deviceService.getToken().isEmpty()) {
Settings.ConnectionState.Unregistered
return
} else {
Settings.ConnectionState.Registered
}
}
private suspend fun updateDeviceInfo() {
if (connectionState == Settings.ConnectionState.Unregistered) return
val integration = deviceService.getIntegration()
if (integration == null) {
connectionState = Settings.ConnectionState.Unregistered
return
}
deviceName = integration.name
}
fun onServerAddressChanged(url: String) {
serverAddress = url
}
fun onDeviceNameChanged(name: String) {
deviceName = name
}
fun onRegistrationCodeChanged(code: String) {
registrationCode = code
}
companion object {
fun provideFactory(
deviceService: DeviceService,
) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return SettingsViewModel(deviceService) as T
}
}
}
}

View File

@ -0,0 +1,54 @@
package com.example.tvcontroller.client
import android.util.Log
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.request.cookie
import io.ktor.client.request.headers
import io.ktor.client.request.request
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.HttpMethod
import org.json.JSONObject
private const val TAG = "WebClient"
class WebClient {
val client = HttpClient(CIO) {
install(WebSockets)
}
var defaultCookies = mutableMapOf<String, String>()
suspend fun sendRequest(
url: String,
method: HttpMethod,
headers: Map<String, String> = emptyMap(),
body: String? = ""
): HttpResponse? {
try {
val response = client.request(url) {
this.method = method
setBody(body)
headers {
headers.forEach { (key, value) ->
append(key, value)
}
}
defaultCookies.forEach { (key, value) ->
cookie(name = key, value = value)
}
}
return response
} catch (e: Exception) {
Log.e(TAG, "error sending json request", e)
}
return null
}
suspend fun sendJsonRequest(url: String, method: HttpMethod, json: JSONObject): HttpResponse? {
val headers = mutableMapOf<String, String>()
headers.put("Content-Type", "application/json")
return sendRequest(url, method, headers = headers, body = json.toString())
}
}

View File

@ -0,0 +1,67 @@
package com.example.tvcontroller.client
import android.util.Log
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.client.request.cookie
import io.ktor.http.HttpMethod
import io.ktor.websocket.DefaultWebSocketSession
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import org.json.JSONObject
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,
port = portInt,
path = "/ws",
request = {
cookie(name = "token", value = token)
}
) {
Log.i(TAG, "Listening for incoming websocket messages")
websocket = this
while (true) {
val frame = incoming.receive()
Log.d(TAG, "Received frame: $frame")
if (frame is Frame.Text) {
val dataString = frame.readText()
dataHandlers.forEach { it(dataString) }
}
}
}
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) {
Log.d(TAG, "Adding data handler")
dataHandlers.add(handler)
}
suspend fun sendJson(json: JSONObject) {
val frame = Frame.Text(json.toString())
websocket?.send(frame)
}
}

View File

@ -0,0 +1,27 @@
package com.example.tvcontroller.data
import android.util.Log
class BluetoothDevice(name: String?, address: String?) {
private var _name: String? = name
private var _address: String? = address
fun getName(): String {
return if (_name.isNullOrEmpty()) getAddress() else _name!!
}
fun getAddress(): String {
return if (_address == null) "" else _address!!
}
companion object {
fun fromBluetoothDevice(device: android.bluetooth.BluetoothDevice): BluetoothDevice {
try {
return BluetoothDevice(device.name, device.address)
} catch (e: SecurityException) {
Log.e("BluetoothDevice", "Error creating BluetoothDevice", e)
}
return BluetoothDevice(null, null)
}
}
}

View File

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

View File

@ -1,66 +1,191 @@
package com.example.tvcontroller.services
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothSocket
import android.content.Context
import android.content.Intent
import android.content.BroadcastReceiver
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat.getSystemService
import com.example.tvcontroller.data.BluetoothDevice
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.io.IOException
import java.util.UUID
private const val SERIAL_PORT_SERVICE_UUID = "00001101-0000-1000-8000-00805F9B34FB"
private const val TAG = "BluetoothService"
class BluetoothService(private val context: Context) {
private var bluetoothManager: BluetoothManager =
getSystemService(context, BluetoothManager::class.java)!!;
getSystemService(context, BluetoothManager::class.java)!!
private var bluetoothAdapter: BluetoothAdapter = bluetoothManager.adapter
private var bluetoothStateReceiver: BroadcastReceiver? = null
private var bluetoothStateChangedCallbacks: MutableList<(Int) -> Unit> = mutableListOf()
private val _pairedDevices = MutableStateFlow<List<BluetoothDevice>>(emptyList())
var pairedDevices: StateFlow<List<BluetoothDevice>> = _pairedDevices.asStateFlow()
private var clientSocket: BluetoothSocket? = null
private var bluetoothStateChangedCallbacks: MutableList<(String) -> Unit> = mutableListOf()
private var bluetoothStateReceiver: BroadcastReceiver? = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val btState = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
Log.i(TAG, "Bluetooth state changed to: $btState")
state = parseBluetoothState(btState)
}
}
var currentDevice: BluetoothDevice? = null
private set
var state: String = STATE_OFF
private set(value) {
if (value != STATE_CONNECTED && value != STATE_CONNECTING) currentDevice = null
Log.i(TAG, "Bluetooth state changed to: $value")
bluetoothStateChangedCallbacks.forEach { it(value) }
field = value
}
init {
registerBluetoothStateReceiver()
state = parseBluetoothState(bluetoothAdapter.state)
context.registerReceiver(
bluetoothStateReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)
)
updatePairedDevices()
}
private fun parseBluetoothState(state: Int): String {
return when (state) {
BluetoothAdapter.STATE_OFF -> STATE_OFF
BluetoothAdapter.STATE_TURNING_OFF -> STATE_OFF
BluetoothAdapter.STATE_TURNING_ON -> STATE_OFF
BluetoothAdapter.STATE_ON -> STATE_DISCONNECTED
else -> STATE_DISCONNECTED
}
}
fun isBluetoothEnabled(): Boolean {
return bluetoothAdapter.isEnabled ?: false
return bluetoothAdapter.isEnabled
}
private fun registerBluetoothStateReceiver() {
bluetoothStateReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
bluetoothStateChangedCallbacks.forEach { it(state) }
when (state) {
BluetoothAdapter.STATE_ON -> {
// Handle Bluetooth turned on
println("SIGNAL: Bluetooth is enabled")
}
BluetoothAdapter.STATE_OFF -> {
// Handle Bluetooth turned off
println("SIGNAL: Bluetooth is disabled")
}
BluetoothAdapter.STATE_TURNING_ON -> {
// Handle Bluetooth turning on
println("SIGNAL: Bluetooth is turning on")
}
BluetoothAdapter.STATE_TURNING_OFF -> {
// Handle Bluetooth turning off
println("SIGNAL: Bluetooth is turning off")
}
}
}
}
context.registerReceiver(bluetoothStateReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED))
}
fun onBluetoothStateChanged(callback: (Int) -> Unit) {
fun onBluetoothStateChanged(callback: (String) -> Unit) {
bluetoothStateChangedCallbacks.add(callback)
}
fun offBluetoothStateChanged(callback: (Int) -> Unit) {
fun offBluetoothStateChanged(callback: (String) -> Unit) {
bluetoothStateChangedCallbacks.remove(callback)
}
fun cleanUp() {
context.unregisterReceiver(bluetoothStateReceiver)
bluetoothStateChangedCallbacks.clear()
closeBluetoothConnection()
}
fun updatePairedDevices() {
try {
_pairedDevices.update {
bluetoothAdapter.bondedDevices.toList()
.map { device -> BluetoothDevice.fromBluetoothDevice(device) }
}
} catch (e: SecurityException) {
println("Error updating paired devices: $e")
}
}
fun connectToDevice(device: BluetoothDevice) {
currentDevice = device
state = STATE_CONNECTING
Log.i(TAG, "Initiating connection process")
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_ADMIN) != PackageManager.PERMISSION_GRANTED) {
Log.e(TAG, "Bluetooth permission not granted")
state = STATE_DISCONNECTED
return
}
Log.i(TAG, "Connecting to device: $device")
try {
var androidBluetoothDevice = bluetoothAdapter.getRemoteDevice(device.getAddress())
Log.i(TAG, "Creating socket to device: $device")
clientSocket = androidBluetoothDevice.createRfcommSocketToServiceRecord(
UUID.fromString(SERIAL_PORT_SERVICE_UUID)
)
Log.i(TAG, "Connecting to socket")
clientSocket?.connect()
currentDevice = device
state = STATE_CONNECTED
Log.i(TAG, "Connected to device: $device")
listenForIncomingData()
} catch (e: IOException) {
state = STATE_DISCONNECTED
Log.e(TAG, "Error connecting to device: $e")
}
}
fun hasRequiredPermissions(): Boolean {
return BLUETOOTH_PERMISSIONS.all {
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
}
}
fun closeBluetoothConnection() {
clientSocket?.close()
clientSocket = null
currentDevice = null
state = STATE_DISCONNECTED
}
private fun listenForIncomingData() {
if (clientSocket == null || !clientSocket!!.isConnected) return
Log.i(TAG, "Listening for incoming data")
val buffer = ByteArray(1024)
while (true) {
try {
val bytesRead = clientSocket?.inputStream?.read(buffer)
if (bytesRead != null && bytesRead != -1) {
val data = buffer.decodeToString(endIndex = bytesRead)
Log.i(TAG, "Received data: $data")
}
} catch (e: IOException) {
Log.e(TAG, "Error reading from socket: $e")
} catch (e: Exception) {
Log.e(TAG, "Error receiving data: $e")
}
}
}
fun sendData(data: String) {
if (clientSocket == null || !clientSocket!!.isConnected) return
try {
val bytes = data.encodeToByteArray()
clientSocket?.outputStream?.write(bytes)
Log.i(TAG, "Sent data: $data")
} catch (e: IOException) {
Log.e(TAG, "Error writing to socket: $e")
closeBluetoothConnection()
}
}
companion object {
const val STATE_OFF = "off"
const val STATE_DISCONNECTED = "disconnected"
const val STATE_CONNECTING = "connecting"
const val STATE_CONNECTED = "connected"
val BLUETOOTH_PERMISSIONS = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN
)
} else {
arrayOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_ADMIN
)
}
}
}

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

@ -0,0 +1,105 @@
package com.example.tvcontroller.services
import android.util.Log
import com.example.tvcontroller.data.RemoteCommand
import com.example.tvcontroller.services.webrtc.WebRtcService
import org.json.JSONObject
import java.nio.ByteBuffer
import java.nio.charset.StandardCharsets
class ControllerService(
private val bluetoothService: BluetoothService,
private val webRtcService: WebRtcService
) {
init {
loadCommands()
webRtcService.onDataChannelData(this::handleWebRtcData)
}
fun sendCommand(command: RemoteCommand) {
val jsonString = remoteCommandToJsonString(command)
Log.i(TAG, "Sending command: $jsonString")
sendData(jsonString)
}
fun remoteCommandToJsonString(command: RemoteCommand): String {
var commandObject = JSONObject()
commandObject.put("protocol", command.protocol)
commandObject.put("device", command.device)
commandObject.put("command", command.command)
return commandObject.toString()
}
fun sendData(data: String) {
bluetoothService.sendData(data)
}
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 {
private const val TAG = "ControllerService"
const val MESSAGE_TYPE_COMMAND = "command"
const val POWER = "POWER"
const val CURSOR_UP = "CURSOR UP"
const val CURSOR_DOWN = "CURSOR DOWN"
const val CURSOR_LEFT = "CURSOR LEFT"
const val CURSOR_RIGHT = "CURSOR RIGHT"
const val ENTER = "ENTER"
const val ONE = "1"
const val TWO = "2"
const val THREE = "3"
const val FOUR = "4"
const val FIVE = "5"
const val SIX = "6"
const val SEVEN = "7"
const val EIGHT = "8"
const val NINE = "9"
const val ZERO = "0"
const val CHANNEL_UP = "CHANNEL +"
const val CHANNEL_DOWN = "CHANNEL -"
const val VOLUME_UP = "VOLUME +"
const val VOLUME_DOWN = "VOLUME -"
const val MUTE = "MUTE"
const val GUIDE = "GUIDE"
const val INFO = "INFO"
const val AD_SUBTITLE = "AD/SUBT"
const val E_MANUAL = "E-MANUAL"
const val TOOLS = "TOOLS"
const val RETURN = "RETURN"
const val MENU = "MENU"
const val SMART_HUB = "SMART HUB"
const val EXIT = "EXIT"
const val CHANNEL_LIST = "CH LIST"
const val HOME = "HOME"
const val SETTINGS = "SETTINGS"
const val RED = "RED"
const val GREEN = "GREEN"
const val YELLOW = "YELLOW"
const val BLUE = "BLUE"
const val INPUT_SOURCE = "INPUT SOURCE"
const val REWIND = "REWIND"
const val PLAY = "PLAY"
const val PAUSE = "PAUSE"
const val FORWARD = "FORWARD"
}
}

View File

@ -3,77 +3,115 @@ 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.engine.cio.*
import io.ktor.client.*
import io.ktor.client.call.body
import io.ktor.client.request.headers
import io.ktor.client.request.request
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.HeadersBuilder
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";
class DeviceService(private val context: Context) {
private var client = HttpClient(CIO)
private var serverAddress: String = ""
private var token: String = ""
class DeviceService(
private val context: Context,
private val client: WebClient,
private val websocketClient: WebsocketClient
) {
var serverAddress: String = ""
var token: String = ""
var status: String = STATUS_UNREGISTERED
private set(status) {
field = status
statusChangedListeners.forEach { it(status) }
}
private var deviceId: String = ""
private val statusChangedListeners = mutableListOf<(String) -> Unit>()
init {
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()
}
suspend fun createIntegration(name: String, code: String) {
Log.i("DeviceService", "Creating integration for $name with code $code at $serverAddress")
val requestJson = JSONObject()
requestJson.put("name", name)
requestJson.put("code", code)
try {
val response: HttpResponse = client.request("http://$serverAddress/api/integrations") {
method = HttpMethod.Post
setBody(requestJson.toString())
headers {
append("Content-Type", "application/json")
if (token.isEmpty()) return
updateDefaultCookies()
val integration = getIntegration()
Log.i(TAG, "Integration: $integration")
status = if (integration != null) {
STATUS_REGISTERED
} else {
STATUS_UNREGISTERED
}
}
val body: String = response.body()
val responseJson = JSONObject(body)
private fun updateDefaultCookies() {
Log.i(TAG, "Updating default cookies with token $token")
client.defaultCookies["token"] = token
Log.i(TAG, "Default cookies: ${client.defaultCookies}")
}
suspend fun registerIntegration(name: String, code: String) {
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/register",
HttpMethod.Post,
requestJson
) ?: return
val responseJson = JSONObject(response.body<String>())
if (response.status.value != 200) {
val error = responseJson.getString("error")
Log.e("DeviceService", "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
}
Log.i("DeviceService", "Response: ${response.status.value} $body")
} catch (e: Exception) {
Log.e("DeviceService", "Error creating integration", e)
fun onStatusChanged(listener: (String) -> Unit) {
statusChangedListeners.add(listener)
}
@OptIn(DelicateCoroutinesApi::class)
fun connectWebsocket() {
GlobalScope.launch() {
websocketClient.connect(serverAddress, token)
}
}
suspend fun getIntegration(): Integration? {
Log.i("DeviceService", "Getting integration $deviceId at $serverAddress")
try {
val response: HttpResponse =
client.request("http://$serverAddress/api/integrations/$deviceId") {
method = HttpMethod.Get
headers {
append("Authorization", "Bearer $token")
}
}
val response =
client.sendRequest("http://$serverAddress/api/integrations/$deviceId", HttpMethod.Get)
?: return null
val body: String = response.body()
val responseJson = JSONObject(body)
val responseJson = JSONObject(response.body<String>())
if (response.status.value != 200) {
val error = responseJson.getString("error")
Log.e("DeviceService", "Error getting integration: ${response.status.value} $error")
Log.e(TAG, "Error getting integration: ${response.status.value} $error")
return null
}
val integration = Integration(
@ -81,40 +119,37 @@ class DeviceService(private val context: Context) {
responseJson.getString("name")
)
return integration
} catch (e: Exception) {
Log.e("DeviceService", "Error getting integration", e)
}
return null
}
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", "")!!
Log.i("DeviceService", "Loaded preferences: $serverAddress $token")
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()
}
}
fun setServerAddress(url: String) {
serverAddress = url
}
companion object {
const val STATUS_UNREGISTERED = "unregistered"
const val STATUS_REGISTERED = "registered"
const val STATUS_CONNECTED = "connected"
fun getServerAddress(): String {
return serverAddress
}
fun getToken(): String {
return token
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,16 +0,0 @@
package com.example.tvcontroller.services
import android.util.Log
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
class WebRtcService : ImageAnalysis.Analyzer {
val sessionManager = LocalWebRtcSessionManager.current
val localVideoTrackState by sessionManager.localVideoTrackFlow.collectAsState(null)
val localVideoTrack = localVideoTrackState
override fun analyze(image: ImageProxy) {
Log.i("WebRtcService", "Image received")
image.close()
}
}

View File

@ -0,0 +1,175 @@
package com.example.tvcontroller.services.webrtc
import android.content.Context
import android.util.Log
import com.example.tvcontroller.services.CameraService
import org.webrtc.AudioTrack
import org.webrtc.DataChannel
import org.webrtc.EglBase
import org.webrtc.IceCandidate
import org.webrtc.MediaConstraints
import org.webrtc.MediaStream
import org.webrtc.MediaStreamTrack
import org.webrtc.PeerConnection
import org.webrtc.PeerConnectionFactory
import org.webrtc.PeerConnectionFactory.InitializationOptions
import org.webrtc.SdpObserver
import org.webrtc.SessionDescription
import org.webrtc.SurfaceTextureHelper
import org.webrtc.VideoSource
import org.webrtc.VideoTrack
import java.nio.ByteBuffer
import java.nio.charset.StandardCharsets
private const val TAG = "RtcPeerConnection"
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 = cameraService.eglBaseContext
private val videoCapturer by lazy { cameraService.createCameraCapturer() }
private val surfaceTextureHelper by lazy { createSurfaceTextureHelper() }
private val videoSource by lazy { createVideoSource() }
private var peerConnection: PeerConnection? = null
private val iceCandidateHandlers = ArrayList<((IceCandidate) -> Unit)>()
private val dataChannelHandlers = ArrayList<((ByteBuffer) -> Unit)>()
fun initialize() {
var observer = object : PeerConnection.Observer {
override fun onSignalingChange(p0: PeerConnection.SignalingState?) {}
override fun onIceConnectionChange(p0: PeerConnection.IceConnectionState?) {}
override fun onIceConnectionReceivingChange(p0: Boolean) {}
override fun onIceGatheringChange(p0: PeerConnection.IceGatheringState?) {}
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?) {}
override fun onDataChannel(channel: 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() {}
}
var rtcConfig = PeerConnection.RTCConfiguration(iceServers)
peerConnection = peerConnectionFactory.createPeerConnection(rtcConfig, observer)
}
fun setIceServers(iceServerStrings: Array<String>) {
iceServers = ArrayList<PeerConnection.IceServer>()
iceServerStrings.forEach {
iceServers.add(
PeerConnection.IceServer.builder(it).createIceServer()
)
}
}
fun createAudioTrack(): AudioTrack {
val audioConstraints = MediaConstraints()
val audioSource = peerConnectionFactory.createAudioSource(audioConstraints)
val audioTrack = peerConnectionFactory.createAudioTrack("audio_track", audioSource)
return audioTrack
}
fun addTack(track: MediaStreamTrack) {
peerConnection?.addTrack(track)
}
fun createVideoTrack(): VideoTrack {
val videoTrack = peerConnectionFactory.createVideoTrack("video_track", videoSource)
return videoTrack
}
fun setLocalDescription(sessionDescription: SessionDescription, callback: () -> Unit) {
val onLocalDescriptionSet = object : SdpObserver {
override fun onSetSuccess() {
callback()
}
override fun onCreateSuccess(p0: SessionDescription?) {}
override fun onCreateFailure(p0: String?) {}
override fun onSetFailure(p0: String?) {}
}
peerConnection?.setLocalDescription(onLocalDescriptionSet, sessionDescription)
}
fun setRemoteDescription(sessionDescription: SessionDescription, callback: () -> Unit) {
val onRemoteDescriptionSet = object : SdpObserver {
override fun onSetSuccess() {
callback()
}
override fun onCreateSuccess(p0: SessionDescription?) {}
override fun onCreateFailure(p0: String?) {}
override fun onSetFailure(p0: String?) {}
}
peerConnection?.setRemoteDescription(onRemoteDescriptionSet, sessionDescription)
}
fun createAnswer(mediaConstraints: MediaConstraints, callback: (SessionDescription) -> Unit) {
val onAnswerCreated = object : SdpObserver {
override fun onCreateSuccess(sessionDescription: SessionDescription?) {
callback(sessionDescription!!)
}
override fun onSetSuccess() {}
override fun onCreateFailure(p0: String?) {}
override fun onSetFailure(p0: String?) {}
}
peerConnection?.createAnswer(onAnswerCreated, mediaConstraints)
}
fun addIceCandidate(iceCandidate: IceCandidate) {
Log.i(TAG, "Adding ice candidate $iceCandidate to $peerConnection")
peerConnection?.addIceCandidate(iceCandidate)
}
fun onIceCandidate(handler: (IceCandidate) -> Unit) {
iceCandidateHandlers.add(handler)
}
fun onDataChannelData(handler: (ByteBuffer) -> Unit) {
dataChannelHandlers.add(handler)
}
private fun initializeFactory(): PeerConnectionFactory {
val initOptions = InitializationOptions.builder(context).createInitializationOptions()
PeerConnectionFactory.initialize(initOptions)
val options = PeerConnectionFactory.Options()
val peerConnectionFactory =
PeerConnectionFactory.builder()
.setVideoDecoderFactory(cameraService.videoDecoderFactory)
.setVideoEncoderFactory(cameraService.videoEncoderFactory).setOptions(options)
.createPeerConnectionFactory()
return peerConnectionFactory
}
private fun createSurfaceTextureHelper(): SurfaceTextureHelper {
val surfaceTextureHelper =
SurfaceTextureHelper.create("SurfaceTextureHelperThread", eglBaseContext)
return surfaceTextureHelper
}
private fun createVideoSource(): VideoSource {
val videoSource = peerConnectionFactory.createVideoSource(false)
videoCapturer.initialize(surfaceTextureHelper, context, videoSource.capturerObserver)
videoCapturer.startCapture(1920, 1080, 30)
return videoSource
}
companion object {
const val TYPE_OFFER = "offer"
const val TYPE_ANSWER = "answer"
const val TYPE_ICE_CANDIDATE = "ice_candidate"
}
}

View File

@ -1,129 +0,0 @@
package com.example.tvcontroller.services.webrtc
/*
* Copyright 2023 Stream.IO, Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import io.getstream.log.taggedLogger
import io.ktor.client.*
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocketSession
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.util.logging.Logger
import io.ktor.websocket.CloseReason
import io.ktor.websocket.DefaultWebSocketSession
import io.ktor.websocket.close
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.webrtc.Logging
const val SIGNALING_SERVER_IP_ADDRESS = "wss://signaling.stream.io"
class SignalingClient {
private val logger by taggedLogger("Call:SignalingClient")
private val signalingScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val client = HttpClient {
install(WebSockets)
}
// opening web socket with signaling server
private lateinit var ws: DefaultWebSocketSession
init {
signalingScope.launch {
try {
ws = client.webSocketSession {
url(SIGNALING_SERVER_IP_ADDRESS)
}
} catch (e: Exception) {
logger.e { "Error connecting to signaling server: ${e.message}" }
_sessionStateFlow.value = WebRTCSessionState.Offline
}
}
}
// session flow to send information about the session state to the subscribers
private val _sessionStateFlow = MutableStateFlow(WebRTCSessionState.Offline)
val sessionStateFlow: StateFlow<WebRTCSessionState> = _sessionStateFlow
// signaling commands to send commands to value pairs to the subscribers
private val _signalingCommandFlow = MutableSharedFlow<Pair<SignalingCommand, String>>()
val signalingCommandFlow: SharedFlow<Pair<SignalingCommand, String>> = _signalingCommandFlow
fun sendCommand(signalingCommand: SignalingCommand, message: String) {
logger.d { "[sendCommand] $signalingCommand $message" }
signalingScope.launch {
ws.send("$signalingCommand $message")
}
}
private inner class SignalingWebSocketListener : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
when {
text.startsWith(SignalingCommand.STATE.toString(), true) ->
handleStateMessage(text)
text.startsWith(SignalingCommand.OFFER.toString(), true) ->
handleSignalingCommand(SignalingCommand.OFFER, text)
text.startsWith(SignalingCommand.ANSWER.toString(), true) ->
handleSignalingCommand(SignalingCommand.ANSWER, text)
text.startsWith(SignalingCommand.ICE.toString(), true) ->
handleSignalingCommand(SignalingCommand.ICE, text)
}
}
}
private fun handleStateMessage(message: String) {
val state = getSeparatedMessage(message)
_sessionStateFlow.value = WebRTCSessionState.valueOf(state)
}
private fun handleSignalingCommand(command: SignalingCommand, text: String) {
val value = getSeparatedMessage(text)
logger.d { "received signaling: $command $value" }
signalingScope.launch {
_signalingCommandFlow.emit(command to value)
}
}
private fun getSeparatedMessage(text: String) = text.substringAfter(' ')
fun dispose() {
_sessionStateFlow.value = WebRTCSessionState.Offline
signalingScope.cancel()
ws.close(CloseReason(CloseReason.Codes.NORMAL, "Client is shutting down"))
}
}
enum class WebRTCSessionState {
Active, // Offer and Answer messages has been sent
Creating, // Creating session, offer has been sent
Ready, // Both clients available and ready to initiate session
Impossible, // We have less than two clients connected to the server
Offline // unable to connect signaling server
}
enum class SignalingCommand {
STATE, // Command for WebRTCSessionState
OFFER, // to send or receive offer
ANSWER, // to send or receive answer
ICE // to send and receive ice candidates
}

View File

@ -0,0 +1,136 @@
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.CameraService
import kotlinx.coroutines.runBlocking
import org.json.JSONObject
import org.webrtc.IceCandidate
import org.webrtc.MediaConstraints
import org.webrtc.SessionDescription
import java.nio.ByteBuffer
class WebRtcService(
private val context: Context,
private val websocketClient: WebsocketClient,
private val cameraService: CameraService
) {
private val dataChannelHandlers = ArrayList<((ByteBuffer) -> Unit)>()
private var rtcPeerConnection: RtcPeerConnection = createRtcPeerConnection()
val videoTrack by lazy { rtcPeerConnection.createVideoTrack() }
val audioTrack by lazy { rtcPeerConnection.createAudioTrack() }
private var peerId: String = ""
fun connect() {
Log.i(TAG, "Connecting to signaling server")
websocketClient.onData(this::handleData)
}
fun onDataChannelData(handler: (ByteBuffer) -> Unit) {
dataChannelHandlers.add(handler)
}
private fun createRtcPeerConnection(): RtcPeerConnection {
val iceServers = arrayOf("stun:stun.l.google.com:19302")
val webRtcService = this
val rtcPeerConnection = RtcPeerConnection(context, cameraService).apply {
setIceServers(iceServers)
onIceCandidate(webRtcService::sendIceCandidate)
initialize()
}
dataChannelHandlers.forEach { rtcPeerConnection.onDataChannelData(it) }
return rtcPeerConnection
}
private fun handleOffer(sdp: String) {
rtcPeerConnection = createRtcPeerConnection()
var mediaConstraints = MediaConstraints()
val remoteSessionDescription = SessionDescription(SessionDescription.Type.OFFER, sdp)
rtcPeerConnection.apply {
addTack(audioTrack)
addTack(videoTrack)
setRemoteDescription(remoteSessionDescription) {
createAnswer(mediaConstraints) { localSessionDescription ->
setLocalDescription(localSessionDescription) {
sendAnswer(
peerId, localSessionDescription.description ?: ""
)
}
}
}
}
}
private fun handleData(data: String) {
Log.d(TAG, "Received data: $data")
val dataJson = JSONObject(data)
val senderId = dataJson.getString("sender")
val message = dataJson.getJSONObject("message")
val type = message.getString("type")
when (type) {
RtcPeerConnection.TYPE_OFFER -> {
Log.i(TAG, "Received offer from $senderId")
val sdp = message.getString("sdp")
peerId = senderId
handleOffer(sdp)
}
RtcPeerConnection.TYPE_ICE_CANDIDATE -> {
val candidateString = message.getString("candidate")
handleReceiveIceCandidate(candidateString)
}
}
}
private fun handleReceiveIceCandidate(candidateString: String) {
Log.i(TAG, "Received ice candidate")
val candidateJson = JSONObject(candidateString)
val sdpMid = candidateJson.getString("sdpMid")
val sdpMLineIndex = candidateJson.getInt("sdpMLineIndex")
val sdp = candidateJson.getString("candidate")
val candidate = IceCandidate(sdpMid, sdpMLineIndex, sdp)
Log.i(TAG, "Candidate: $candidate")
rtcPeerConnection.addIceCandidate(candidate)
}
private fun sendIceCandidate(iceCandidate: IceCandidate) {
val messageJson = JSONObject()
messageJson.put("type", TYPE_SIGNALING)
messageJson.put("target", peerId)
messageJson.put("message", JSONObject().apply {
put("candidate", JSONObject().apply {
put("sdpMid", iceCandidate.sdpMid)
put("sdpMLineIndex", iceCandidate.sdpMLineIndex)
put("candidate", iceCandidate.sdp)
})
put("type", RtcPeerConnection.TYPE_ICE_CANDIDATE)
})
runBlocking {
Log.i(TAG, "Sending ice candidate")
websocketClient.sendJson(messageJson)
}
}
private fun sendAnswer(targetId: String, sdp: String) {
val messageJson = JSONObject()
messageJson.put("type", TYPE_SIGNALING)
messageJson.put("target", targetId)
messageJson.put("message", JSONObject().apply {
put("sdp", sdp)
put("type", RtcPeerConnection.TYPE_ANSWER)
})
runBlocking {
Log.i(TAG, "Sending answer")
websocketClient.sendJson(messageJson)
}
}
companion object {
const val TYPE_SIGNALING = "signaling"
private const val TAG = "WebRtcService"
}
}

View File

@ -1,338 +0,0 @@
package com.example.tvcontroller.services.webrtc.peer
/*
* Copyright 2023 Stream.IO, Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import io.getstream.log.taggedLogger
import com.example.tvcontroller.services.webrtc.utils.addRtcIceCandidate
import com.example.tvcontroller.services.webrtc.utils.createValue
import com.example.tvcontroller.services.webrtc.utils.setValue
import com.example.tvcontroller.services.webrtc.utils.stringify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.webrtc.CandidatePairChangeEvent
import org.webrtc.DataChannel
import org.webrtc.IceCandidate
import org.webrtc.IceCandidateErrorEvent
import org.webrtc.MediaConstraints
import org.webrtc.MediaStream
import org.webrtc.MediaStreamTrack
import org.webrtc.PeerConnection
import org.webrtc.RTCStatsReport
import org.webrtc.RtpReceiver
import org.webrtc.RtpTransceiver
import org.webrtc.SessionDescription
/**
* Wrapper around the WebRTC connection that contains tracks.
*
* @param coroutineScope The scope used to listen to stats events.
* @param type The internal type of the PeerConnection. Check [StreamPeerType].
* @param mediaConstraints Constraints used for the connections.
* @param onStreamAdded Handler when a new [MediaStream] gets added.
* @param onNegotiationNeeded Handler when there's a new negotiation.
* @param onIceCandidate Handler whenever we receive [IceCandidate]s.
*/
class StreamPeerConnection(
private val coroutineScope: CoroutineScope,
private val type: StreamPeerType,
private val mediaConstraints: MediaConstraints,
private val onStreamAdded: ((MediaStream) -> Unit)?,
private val onNegotiationNeeded: ((StreamPeerConnection, StreamPeerType) -> Unit)?,
private val onIceCandidate: ((IceCandidate, StreamPeerType) -> Unit)?,
private val onVideoTrack: ((RtpTransceiver?) -> Unit)?
) : PeerConnection.Observer {
private val typeTag = type.stringify()
private val logger by taggedLogger("Call:PeerConnection")
/**
* The wrapped connection for all the WebRTC communication.
*/
lateinit var connection: PeerConnection
private set
/**
* Used to manage the stats observation lifecycle.
*/
private var statsJob: Job? = null
/**
* Used to pool together and store [IceCandidate]s before consuming them.
*/
private val pendingIceMutex = Mutex()
private val pendingIceCandidates = mutableListOf<IceCandidate>()
/**
* Contains stats events for observation.
*/
private val statsFlow: MutableStateFlow<RTCStatsReport?> = MutableStateFlow(null)
init {
logger.i { "<init> #sfu; #$typeTag; mediaConstraints: $mediaConstraints" }
}
/**
* Initialize a [StreamPeerConnection] using a WebRTC [PeerConnection].
*
* @param peerConnection The connection that holds audio and video tracks.
*/
fun initialize(peerConnection: PeerConnection) {
logger.d { "[initialize] #sfu; #$typeTag; peerConnection: $peerConnection" }
this.connection = peerConnection
}
/**
* Used to create an offer whenever there's a negotiation that we need to process on the
* publisher side.
*
* @return [Result] wrapper of the [SessionDescription] for the publisher.
*/
suspend fun createOffer(): Result<SessionDescription> {
logger.d { "[createOffer] #sfu; #$typeTag; no args" }
return createValue { connection.createOffer(it, mediaConstraints) }
}
/**
* Used to create an answer whenever there's a subscriber offer.
*
* @return [Result] wrapper of the [SessionDescription] for the subscriber.
*/
suspend fun createAnswer(): Result<SessionDescription> {
logger.d { "[createAnswer] #sfu; #$typeTag; no args" }
return createValue { connection.createAnswer(it, mediaConstraints) }
}
/**
* Used to set up the SDP on underlying connections and to add [pendingIceCandidates] to the
* connection for listening.
*
* @param sessionDescription That contains the remote SDP.
* @return An empty [Result], if the operation has been successful or not.
*/
suspend fun setRemoteDescription(sessionDescription: SessionDescription): Result<Unit> {
logger.d { "[setRemoteDescription] #sfu; #$typeTag; answerSdp: ${sessionDescription.stringify()}" }
return setValue {
connection.setRemoteDescription(
it,
SessionDescription(
sessionDescription.type,
sessionDescription.description.mungeCodecs()
)
)
}.also {
pendingIceMutex.withLock {
pendingIceCandidates.forEach { iceCandidate ->
logger.i { "[setRemoteDescription] #sfu; #subscriber; pendingRtcIceCandidate: $iceCandidate" }
connection.addRtcIceCandidate(iceCandidate)
}
pendingIceCandidates.clear()
}
}
}
/**
* Sets the local description for a connection either for the subscriber or publisher based on
* the flow.
*
* @param sessionDescription That contains the subscriber or publisher SDP.
* @return An empty [Result], if the operation has been successful or not.
*/
suspend fun setLocalDescription(sessionDescription: SessionDescription): Result<Unit> {
val sdp = SessionDescription(
sessionDescription.type,
sessionDescription.description.mungeCodecs()
)
logger.d { "[setLocalDescription] #sfu; #$typeTag; offerSdp: ${sessionDescription.stringify()}" }
return setValue { connection.setLocalDescription(it, sdp) }
}
/**
* Adds an [IceCandidate] to the underlying [connection] if it's already been set up, or stores
* it for later consumption.
*
* @param iceCandidate To process and add to the connection.
* @return An empty [Result], if the operation has been successful or not.
*/
suspend fun addIceCandidate(iceCandidate: IceCandidate): Result<Unit> {
if (connection.remoteDescription == null) {
logger.w { "[addIceCandidate] #sfu; #$typeTag; postponed (no remoteDescription): $iceCandidate" }
pendingIceMutex.withLock {
pendingIceCandidates.add(iceCandidate)
}
return Result.failure(RuntimeException("RemoteDescription is not set"))
}
logger.d { "[addIceCandidate] #sfu; #$typeTag; rtcIceCandidate: $iceCandidate" }
return connection.addRtcIceCandidate(iceCandidate).also {
logger.v { "[addIceCandidate] #sfu; #$typeTag; completed: $it" }
}
}
/**
* Peer connection listeners.
*/
/**
* Triggered whenever there's a new [RtcIceCandidate] for the call. Used to update our tracks
* and subscriptions.
*
* @param candidate The new candidate.
*/
override fun onIceCandidate(candidate: IceCandidate?) {
logger.i { "[onIceCandidate] #sfu; #$typeTag; candidate: $candidate" }
if (candidate == null) return
onIceCandidate?.invoke(candidate, type)
}
/**
* Triggered whenever there's a new [MediaStream] that was added to the connection.
*
* @param stream The stream that contains audio or video.
*/
override fun onAddStream(stream: MediaStream?) {
logger.i { "[onAddStream] #sfu; #$typeTag; stream: $stream" }
if (stream != null) {
onStreamAdded?.invoke(stream)
}
}
/**
* Triggered whenever there's a new [MediaStream] or [MediaStreamTrack] that's been added
* to the call. It contains all audio and video tracks for a given session.
*
* @param receiver The receiver of tracks.
* @param mediaStreams The streams that were added containing their appropriate tracks.
*/
override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array<out MediaStream>?) {
logger.i { "[onAddTrack] #sfu; #$typeTag; receiver: $receiver, mediaStreams: $mediaStreams" }
mediaStreams?.forEach { mediaStream ->
logger.v { "[onAddTrack] #sfu; #$typeTag; mediaStream: $mediaStream" }
mediaStream.audioTracks?.forEach { remoteAudioTrack ->
logger.v { "[onAddTrack] #sfu; #$typeTag; remoteAudioTrack: ${remoteAudioTrack.stringify()}" }
remoteAudioTrack.setEnabled(true)
}
onStreamAdded?.invoke(mediaStream)
}
}
/**
* Triggered whenever there's a new negotiation needed for the active [PeerConnection].
*/
override fun onRenegotiationNeeded() {
logger.i { "[onRenegotiationNeeded] #sfu; #$typeTag; no args" }
onNegotiationNeeded?.invoke(this, type)
}
/**
* Triggered whenever a [MediaStream] was removed.
*
* @param stream The stream that was removed from the connection.
*/
override fun onRemoveStream(stream: MediaStream?) {}
/**
* Triggered when the connection state changes. Used to start and stop the stats observing.
*
* @param newState The new state of the [PeerConnection].
*/
override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) {
logger.i { "[onIceConnectionChange] #sfu; #$typeTag; newState: $newState" }
when (newState) {
PeerConnection.IceConnectionState.CLOSED,
PeerConnection.IceConnectionState.FAILED,
PeerConnection.IceConnectionState.DISCONNECTED -> statsJob?.cancel()
PeerConnection.IceConnectionState.CONNECTED -> statsJob = observeStats()
else -> Unit
}
}
/**
* @return The [RTCStatsReport] for the active connection.
*/
fun getStats(): StateFlow<RTCStatsReport?> {
return statsFlow
}
/**
* Observes the local connection stats and emits it to [statsFlow] that users can consume.
*/
private fun observeStats() = coroutineScope.launch {
while (isActive) {
delay(10_000L)
connection.getStats {
logger.v { "[observeStats] #sfu; #$typeTag; stats: $it" }
statsFlow.value = it
}
}
}
override fun onTrack(transceiver: RtpTransceiver?) {
logger.i { "[onTrack] #sfu; #$typeTag; transceiver: $transceiver" }
onVideoTrack?.invoke(transceiver)
}
/**
* Domain - [PeerConnection] and [PeerConnection.Observer] related callbacks.
*/
override fun onRemoveTrack(receiver: RtpReceiver?) {
logger.i { "[onRemoveTrack] #sfu; #$typeTag; receiver: $receiver" }
}
override fun onSignalingChange(newState: PeerConnection.SignalingState?) {
logger.d { "[onSignalingChange] #sfu; #$typeTag; newState: $newState" }
}
override fun onIceConnectionReceivingChange(receiving: Boolean) {
logger.i { "[onIceConnectionReceivingChange] #sfu; #$typeTag; receiving: $receiving" }
}
override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) {
logger.i { "[onIceGatheringChange] #sfu; #$typeTag; newState: $newState" }
}
override fun onIceCandidatesRemoved(iceCandidates: Array<out org.webrtc.IceCandidate>?) {
logger.i { "[onIceCandidatesRemoved] #sfu; #$typeTag; iceCandidates: $iceCandidates" }
}
override fun onIceCandidateError(event: IceCandidateErrorEvent?) {
logger.e { "[onIceCandidateError] #sfu; #$typeTag; event: ${event?.stringify()}" }
}
override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
logger.i { "[onConnectionChange] #sfu; #$typeTag; newState: $newState" }
}
override fun onSelectedCandidatePairChanged(event: CandidatePairChangeEvent?) {
logger.i { "[onSelectedCandidatePairChanged] #sfu; #$typeTag; event: $event" }
}
override fun onDataChannel(channel: DataChannel?): Unit = Unit
override fun toString(): String =
"StreamPeerConnection(type='$typeTag', constraints=$mediaConstraints)"
private fun String.mungeCodecs(): String {
return this.replace("vp9", "VP9").replace("vp8", "VP8").replace("h264", "H264")
}
}

View File

@ -1,287 +0,0 @@
package com.example.tvcontroller.services.webrtc.peer
/*
* Copyright 2023 Stream.IO, Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import android.content.Context
import android.os.Build
import io.getstream.log.taggedLogger
import kotlinx.coroutines.CoroutineScope
import org.webrtc.AudioSource
import org.webrtc.AudioTrack
import org.webrtc.DefaultVideoDecoderFactory
import org.webrtc.EglBase
import org.webrtc.HardwareVideoEncoderFactory
import org.webrtc.IceCandidate
import org.webrtc.Logging
import org.webrtc.MediaConstraints
import org.webrtc.MediaStream
import org.webrtc.PeerConnection
import org.webrtc.PeerConnectionFactory
import org.webrtc.RtpTransceiver
import org.webrtc.SimulcastVideoEncoderFactory
import org.webrtc.SoftwareVideoEncoderFactory
import org.webrtc.VideoSource
import org.webrtc.VideoTrack
import org.webrtc.audio.JavaAudioDeviceModule
class StreamPeerConnectionFactory constructor(
private val context: Context
) {
private val webRtcLogger by taggedLogger("Call:WebRTC")
private val audioLogger by taggedLogger("Call:AudioTrackCallback")
val eglBaseContext: EglBase.Context by lazy {
EglBase.create().eglBaseContext
}
/**
* Default video decoder factory used to unpack video from the remote tracks.
*/
private val videoDecoderFactory by lazy {
DefaultVideoDecoderFactory(
eglBaseContext
)
}
// rtcConfig contains STUN and TURN servers list
val rtcConfig = PeerConnection.RTCConfiguration(
arrayListOf(
// adding google's standard server
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
)
).apply {
// it's very important to use new unified sdp semantics PLAN_B is deprecated
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
}
/**
* Default encoder factory that supports Simulcast, used to send video tracks to the server.
*/
private val videoEncoderFactory by lazy {
val hardwareEncoder = HardwareVideoEncoderFactory(eglBaseContext, true, true)
SimulcastVideoEncoderFactory(hardwareEncoder, SoftwareVideoEncoderFactory())
}
/**
* Factory that builds all the connections based on the extensive configuration provided under
* the hood.
*/
private val factory by lazy {
PeerConnectionFactory.initialize(
PeerConnectionFactory.InitializationOptions.builder(context)
.setInjectableLogger({ message, severity, label ->
when (severity) {
Logging.Severity.LS_VERBOSE -> {
webRtcLogger.v { "[onLogMessage] label: $label, message: $message" }
}
Logging.Severity.LS_INFO -> {
webRtcLogger.i { "[onLogMessage] label: $label, message: $message" }
}
Logging.Severity.LS_WARNING -> {
webRtcLogger.w { "[onLogMessage] label: $label, message: $message" }
}
Logging.Severity.LS_ERROR -> {
webRtcLogger.e { "[onLogMessage] label: $label, message: $message" }
}
Logging.Severity.LS_NONE -> {
webRtcLogger.d { "[onLogMessage] label: $label, message: $message" }
}
else -> {}
}
}, Logging.Severity.LS_VERBOSE)
.createInitializationOptions()
)
PeerConnectionFactory.builder()
.setVideoDecoderFactory(videoDecoderFactory)
.setVideoEncoderFactory(videoEncoderFactory)
.setAudioDeviceModule(
JavaAudioDeviceModule
.builder(context)
.setUseHardwareAcousticEchoCanceler(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
.setUseHardwareNoiseSuppressor(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
.setAudioRecordErrorCallback(object :
JavaAudioDeviceModule.AudioRecordErrorCallback {
override fun onWebRtcAudioRecordInitError(p0: String?) {
audioLogger.w { "[onWebRtcAudioRecordInitError] $p0" }
}
override fun onWebRtcAudioRecordStartError(
p0: JavaAudioDeviceModule.AudioRecordStartErrorCode?,
p1: String?
) {
audioLogger.w { "[onWebRtcAudioRecordInitError] $p1" }
}
override fun onWebRtcAudioRecordError(p0: String?) {
audioLogger.w { "[onWebRtcAudioRecordError] $p0" }
}
})
.setAudioTrackErrorCallback(object :
JavaAudioDeviceModule.AudioTrackErrorCallback {
override fun onWebRtcAudioTrackInitError(p0: String?) {
audioLogger.w { "[onWebRtcAudioTrackInitError] $p0" }
}
override fun onWebRtcAudioTrackStartError(
p0: JavaAudioDeviceModule.AudioTrackStartErrorCode?,
p1: String?
) {
audioLogger.w { "[onWebRtcAudioTrackStartError] $p0" }
}
override fun onWebRtcAudioTrackError(p0: String?) {
audioLogger.w { "[onWebRtcAudioTrackError] $p0" }
}
})
.setAudioRecordStateCallback(object :
JavaAudioDeviceModule.AudioRecordStateCallback {
override fun onWebRtcAudioRecordStart() {
audioLogger.d { "[onWebRtcAudioRecordStart] no args" }
}
override fun onWebRtcAudioRecordStop() {
audioLogger.d { "[onWebRtcAudioRecordStop] no args" }
}
})
.setAudioTrackStateCallback(object :
JavaAudioDeviceModule.AudioTrackStateCallback {
override fun onWebRtcAudioTrackStart() {
audioLogger.d { "[onWebRtcAudioTrackStart] no args" }
}
override fun onWebRtcAudioTrackStop() {
audioLogger.d { "[onWebRtcAudioTrackStop] no args" }
}
})
.createAudioDeviceModule().also {
it.setMicrophoneMute(false)
it.setSpeakerMute(false)
}
)
.createPeerConnectionFactory()
}
/**
* Builds a [StreamPeerConnection] that wraps the WebRTC [PeerConnection] and exposes several
* helpful handlers.
*
* @param coroutineScope Scope used for asynchronous operations.
* @param configuration The [PeerConnection.RTCConfiguration] used to set up the connection.
* @param type The type of connection, either a subscriber of a publisher.
* @param mediaConstraints Constraints used for audio and video tracks in the connection.
* @param onStreamAdded Handler when a new [MediaStream] gets added.
* @param onNegotiationNeeded Handler when there's a new negotiation.
* @param onIceCandidateRequest Handler whenever we receive [IceCandidate]s.
* @return [StreamPeerConnection] That's fully set up and can be observed and used to send and
* receive tracks.
*/
fun makePeerConnection(
coroutineScope: CoroutineScope,
configuration: PeerConnection.RTCConfiguration,
type: StreamPeerType,
mediaConstraints: MediaConstraints,
onStreamAdded: ((MediaStream) -> Unit)? = null,
onNegotiationNeeded: ((StreamPeerConnection, StreamPeerType) -> Unit)? = null,
onIceCandidateRequest: ((IceCandidate, StreamPeerType) -> Unit)? = null,
onVideoTrack: ((RtpTransceiver?) -> Unit)? = null
): StreamPeerConnection {
val peerConnection = StreamPeerConnection(
coroutineScope = coroutineScope,
type = type,
mediaConstraints = mediaConstraints,
onStreamAdded = onStreamAdded,
onNegotiationNeeded = onNegotiationNeeded,
onIceCandidate = onIceCandidateRequest,
onVideoTrack = onVideoTrack
)
val connection = makePeerConnectionInternal(
configuration = configuration,
observer = peerConnection
)
return peerConnection.apply { initialize(connection) }
}
/**
* Builds a [PeerConnection] internally that connects to the server and is able to send and
* receive tracks.
*
* @param configuration The [PeerConnection.RTCConfiguration] used to set up the connection.
* @param observer Handler used to observe different states of the connection.
* @return [PeerConnection] that's fully set up.
*/
private fun makePeerConnectionInternal(
configuration: PeerConnection.RTCConfiguration,
observer: PeerConnection.Observer?
): PeerConnection {
return requireNotNull(
factory.createPeerConnection(
configuration,
observer
)
)
}
/**
* Builds a [VideoSource] from the [factory] that can be used for regular video share (camera)
* or screen sharing.
*
* @param isScreencast If we're screen sharing using this source.
* @return [VideoSource] that can be used to build tracks.
*/
fun makeVideoSource(isScreencast: Boolean): VideoSource =
factory.createVideoSource(isScreencast)
/**
* Builds a [VideoTrack] from the [factory] that can be used for regular video share (camera)
* or screen sharing.
*
* @param source The [VideoSource] used for the track.
* @param trackId The unique ID for this track.
* @return [VideoTrack] That represents a video feed.
*/
fun makeVideoTrack(
source: VideoSource,
trackId: String
): VideoTrack = factory.createVideoTrack(trackId, source)
/**
* Builds an [AudioSource] from the [factory] that can be used for audio sharing.
*
* @param constraints The constraints used to change the way the audio behaves.
* @return [AudioSource] that can be used to build tracks.
*/
fun makeAudioSource(constraints: MediaConstraints = MediaConstraints()): AudioSource =
factory.createAudioSource(constraints)
/**
* Builds an [AudioTrack] from the [factory] that can be used for regular video share (camera)
* or screen sharing.
*
* @param source The [AudioSource] used for the track.
* @param trackId The unique ID for this track.
* @return [AudioTrack] That represents an audio feed.
*/
fun makeAudioTrack(
source: AudioSource,
trackId: String
): AudioTrack = factory.createAudioTrack(trackId, source)
}

View File

@ -1,25 +0,0 @@
package com.example.tvcontroller.services.webrtc.peer
/*
* Copyright 2023 Stream.IO, Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* The type of peer connections, either a [PUBLISHER] that sends data to the call or a [SUBSCRIBER]
* that receives and decodes the data from the server.
*/
enum class StreamPeerType {
PUBLISHER,
SUBSCRIBER
}

View File

@ -1,39 +0,0 @@
package com.example.tvcontroller.services.webrtc.utils
/*
* Copyright 2023 Stream.IO, Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import org.webrtc.AddIceObserver
import org.webrtc.IceCandidate
import org.webrtc.PeerConnection
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
suspend fun PeerConnection.addRtcIceCandidate(iceCandidate: IceCandidate): Result<Unit> {
return suspendCoroutine { cont ->
addIceCandidate(
iceCandidate,
object : AddIceObserver {
override fun onAddSuccess() {
cont.resume(Result.success(Unit))
}
override fun onAddFailure(error: String?) {
cont.resume(Result.failure(RuntimeException(error)))
}
}
)
}
}

View File

@ -1,71 +0,0 @@
package com.example.tvcontroller.services.webrtc.utils
/*
* Copyright 2023 Stream.IO, Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import org.webrtc.SdpObserver
import org.webrtc.SessionDescription
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
suspend inline fun createValue(
crossinline call: (SdpObserver) -> Unit
): Result<SessionDescription> = suspendCoroutine {
val observer = object : SdpObserver {
/**
* Handling of create values.
*/
override fun onCreateSuccess(description: SessionDescription?) {
if (description != null) {
it.resume(Result.success(description))
} else {
it.resume(Result.failure(RuntimeException("SessionDescription is null!")))
}
}
override fun onCreateFailure(message: String?) =
it.resume(Result.failure(RuntimeException(message)))
/**
* We ignore set results.
*/
override fun onSetSuccess() = Unit
override fun onSetFailure(p0: String?) = Unit
}
call(observer)
}
suspend inline fun setValue(
crossinline call: (SdpObserver) -> Unit
): Result<Unit> = suspendCoroutine {
val observer = object : SdpObserver {
/**
* We ignore create results.
*/
override fun onCreateFailure(p0: String?) = Unit
override fun onCreateSuccess(p0: SessionDescription?) = Unit
/**
* Handling of set values.
*/
override fun onSetSuccess() = it.resume(Result.success(Unit))
override fun onSetFailure(message: String?) =
it.resume(Result.failure(RuntimeException(message)))
}
call(observer)
}

View File

@ -1,43 +0,0 @@
package com.example.tvcontroller.services.webrtc.utils
/*
* Copyright 2023 Stream.IO, Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.example.tvcontroller.services.webrtc.peer.StreamPeerType
import org.webrtc.IceCandidateErrorEvent
import org.webrtc.MediaStreamTrack
import org.webrtc.SessionDescription
import org.webrtc.audio.JavaAudioDeviceModule
fun SessionDescription.stringify(): String =
"SessionDescription(type=$type, description=$description)"
fun MediaStreamTrack.stringify(): String {
return "MediaStreamTrack(id=${id()}, kind=${kind()}, enabled: ${enabled()}, state=${state()})"
}
fun IceCandidateErrorEvent.stringify(): String {
return "IceCandidateErrorEvent(errorCode=$errorCode, $errorText, address=$address, port=$port, url=$url)"
}
fun JavaAudioDeviceModule.AudioSamples.stringify(): String {
return "AudioSamples(audioFormat=$audioFormat, channelCount=$channelCount" +
", sampleRate=$sampleRate, data.size=${data.size})"
}
fun StreamPeerType.stringify() = when (this) {
StreamPeerType.PUBLISHER -> "publisher"
StreamPeerType.SUBSCRIBER -> "subscriber"
}

View File

@ -1,7 +0,0 @@
package com.example.tvcontroller.ui
import com.example.tvcontroller.Screen
data class AppUiState(
val currentScreen: Screen = Screen.Settings,
)

View File

@ -1,16 +0,0 @@
package com.example.tvcontroller.ui
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
class AppViewModel() : ViewModel() {
private var isBluetoothEnabled = mutableStateOf(false)
fun setBluetoothEnabled(enabled: Boolean) {
isBluetoothEnabled.value = enabled
}
fun isBluetoothEnabled(): Boolean {
return isBluetoothEnabled.value
}
}

View File

@ -3,23 +3,62 @@ package com.example.tvcontroller.ui.components
import androidx.camera.view.LifecycleCameraController
import androidx.camera.view.PreviewView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.LocalLifecycleOwner
import org.webrtc.EglBase
import org.webrtc.RendererCommon
import org.webrtc.VideoTrack
@Composable
fun CameraPreview(
controller: LifecycleCameraController,
modifier: Modifier = Modifier
eglBaseContext: EglBase.Context, videoTrack: VideoTrack, modifier: Modifier = Modifier
) {
val lifecycleOwner = LocalLifecycleOwner.current
val trackState: MutableState<VideoTrack?> = remember { mutableStateOf(null) }
var view: VideoTextureViewRenderer? by remember { mutableStateOf(null) }
AndroidView(
modifier = modifier,
factory = {
PreviewView(it).apply {
this.controller = controller
controller.bindToLifecycle(lifecycleOwner)
VideoTextureViewRenderer(it).apply {
init(
eglBaseContext,
object : RendererCommon.RendererEvents {
override fun onFirstFrameRendered() = Unit
override fun onFrameResolutionChanged(p0: Int, p1: Int, p2: Int) = Unit
}
)
setupVideo(trackState, videoTrack, this)
view = this
}
},
update = { v -> setupVideo(trackState, videoTrack, v) },
modifier = modifier
)
}
private fun cleanTrack(
view: VideoTextureViewRenderer?,
trackState: MutableState<VideoTrack?>
) {
view?.let { trackState.value?.removeSink(it) }
trackState.value = null
}
private fun setupVideo(
trackState: MutableState<VideoTrack?>,
track: VideoTrack,
renderer: VideoTextureViewRenderer
) {
if (trackState.value == track) {
return
}
cleanTrack(renderer, trackState)
trackState.value = track
track.addSink(renderer)
}

View File

@ -0,0 +1,44 @@
package com.example.tvcontroller.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun RemoteButton(
modifier: Modifier = Modifier,
text: String? = "",
onClick: () -> Unit,
icon: Painter? = null,
width: Dp = 64.dp,
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.width(width)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onClick)
.then(modifier)
) {
if (!text.isNullOrBlank()) Text(text = text)
if (icon != null) {
Icon(
icon,
contentDescription = "Settings"
)
}
}
}

View File

@ -0,0 +1,18 @@
package com.example.tvcontroller.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun RemoteButtonPlaceholder(
modifier: Modifier = Modifier,
) {
Box(
modifier = Modifier
.width(64.dp)
.then(modifier)
) {}
}

View File

@ -0,0 +1,191 @@
package com.example.tvcontroller.ui.components
/*
* Copyright 2023 Stream.IO, Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import android.content.Context
import android.content.res.Resources
import android.graphics.SurfaceTexture
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.view.TextureView
import android.view.TextureView.SurfaceTextureListener
import org.webrtc.EglBase
import org.webrtc.EglRenderer
import org.webrtc.GlRectDrawer
import org.webrtc.RendererCommon.RendererEvents
import org.webrtc.ThreadUtils
import org.webrtc.VideoFrame
import org.webrtc.VideoSink
import java.util.concurrent.CountDownLatch
/**
* Custom [TextureView] used to render local/incoming videos on the screen.
*/
open class VideoTextureViewRenderer @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : TextureView(context, attrs), VideoSink, SurfaceTextureListener {
/**
* Cached resource name.
*/
private val resourceName: String = getResourceName()
/**
* Renderer used to render the video.
*/
private val eglRenderer: EglRenderer = EglRenderer(resourceName)
/**
* Callback used for reporting render events.
*/
private var rendererEvents: RendererEvents? = null
/**
* Handler to access the UI thread.
*/
private val uiThreadHandler = Handler(Looper.getMainLooper())
/**
* Whether the first frame has been rendered or not.
*/
private var isFirstFrameRendered = false
/**
* The rotated [VideoFrame] width.
*/
private var rotatedFrameWidth = 0
/**
* The rotated [VideoFrame] height.
*/
private var rotatedFrameHeight = 0
/**
* The rotated [VideoFrame] rotation.
*/
private var frameRotation = 0
init {
surfaceTextureListener = this
}
/**
* Called when a new frame is received. Sends the frame to be rendered.
*
* @param videoFrame The [VideoFrame] received from WebRTC connection to draw on the screen.
*/
override fun onFrame(videoFrame: VideoFrame) {
eglRenderer.onFrame(videoFrame)
updateFrameData(videoFrame)
}
/**
* Updates the frame data and notifies [rendererEvents] about the changes.
*/
private fun updateFrameData(videoFrame: VideoFrame) {
if (isFirstFrameRendered) {
rendererEvents?.onFirstFrameRendered()
isFirstFrameRendered = true
}
if (videoFrame.rotatedWidth != rotatedFrameWidth ||
videoFrame.rotatedHeight != rotatedFrameHeight ||
videoFrame.rotation != frameRotation
) {
rotatedFrameWidth = videoFrame.rotatedWidth
rotatedFrameHeight = videoFrame.rotatedHeight
frameRotation = videoFrame.rotation
uiThreadHandler.post {
rendererEvents?.onFrameResolutionChanged(
rotatedFrameWidth,
rotatedFrameHeight,
frameRotation
)
}
}
}
/**
* After the view is laid out we need to set the correct layout aspect ratio to the renderer so that the image
* is scaled correctly.
*/
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
eglRenderer.setLayoutAspectRatio((right - left) / (bottom.toFloat() - top))
}
/**
* Initialise the renderer. Should be called from the main thread.
*
* @param sharedContext [EglBase.Context]
* @param rendererEvents Sets the render event listener.
*/
fun init(
sharedContext: EglBase.Context,
rendererEvents: RendererEvents
) {
ThreadUtils.checkIsOnMainThread()
this.rendererEvents = rendererEvents
eglRenderer.init(sharedContext, EglBase.CONFIG_PLAIN, GlRectDrawer())
}
/**
* [SurfaceTextureListener] callback that lets us know when a surface texture is ready and we can draw on it.
*/
override fun onSurfaceTextureAvailable(
surfaceTexture: SurfaceTexture,
width: Int,
height: Int
) {
eglRenderer.createEglSurface(surfaceTexture)
}
/**
* [SurfaceTextureListener] callback that lets us know when a surface texture is destroyed we need to stop drawing
* on it.
*/
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
val completionLatch = CountDownLatch(1)
eglRenderer.releaseEglSurface { completionLatch.countDown() }
ThreadUtils.awaitUninterruptibly(completionLatch)
return true
}
override fun onSurfaceTextureSizeChanged(
surfaceTexture: SurfaceTexture,
width: Int,
height: Int
) {
}
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {}
override fun onDetachedFromWindow() {
eglRenderer.release()
super.onDetachedFromWindow()
}
private fun getResourceName(): String {
return try {
resources.getResourceEntryName(id) + ": "
} catch (e: Resources.NotFoundException) {
""
}
}
}

View File

@ -1,34 +1,34 @@
package com.example.tvcontroller.ui.views
import androidx.camera.view.CameraController
import androidx.camera.view.LifecycleCameraController
import android.content.res.Configuration
import android.util.Log
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.example.tvcontroller.services.WebRtcService
import com.example.tvcontroller.ui.components.CameraPreview
import org.webrtc.EglBase
import org.webrtc.VideoTrack
@Composable
fun CameraView() {
val webRtcService = remember { WebRtcService() }
val context = LocalContext.current
val controller = remember {
LifecycleCameraController(context).apply {
setEnabledUseCases(CameraController.IMAGE_ANALYSIS)
setImageAnalysisAnalyzer(ContextCompat.getMainExecutor(context), webRtcService)
}
}
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(
modifier = Modifier
.fillMaxSize()
.padding(all = 16.dp),
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CameraPreview(controller = controller, modifier = Modifier.fillMaxSize())
Box(modifier = if (isLandscape) Modifier.aspectRatio(ratio).fillMaxHeight() else Modifier.aspectRatio(ratio).fillMaxWidth()) {
CameraPreview(eglBaseContext = eglBaseContext, videoTrack = videoTrack, modifier = Modifier.fillMaxSize())
}
}
}

View File

@ -0,0 +1,152 @@
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

@ -0,0 +1,14 @@
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

@ -0,0 +1,269 @@
package com.example.tvcontroller.ui.views
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.tvcontroller.R
import com.example.tvcontroller.services.ControllerService
import com.example.tvcontroller.ui.components.RemoteButton
import com.example.tvcontroller.ui.components.RemoteButtonPlaceholder
@Composable
fun RemoteView(modifier: Modifier = Modifier, controllerService: ControllerService) {
val viewModel = viewModel<RemoteViewModel>(
factory = RemoteViewModel.provideFactory(
controllerService
)
)
Column(
modifier = modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.POWER) },
modifier = Modifier.aspectRatio(1.5f),
icon = painterResource(id = R.drawable.baseline_power_settings_new_24)
)
RemoteButtonPlaceholder()
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.INPUT_SOURCE) },
modifier = Modifier.aspectRatio(1.5f),
icon = painterResource(id = R.drawable.baseline_login_24)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.ONE) },
modifier = Modifier.aspectRatio(1.5f),
text = "1"
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.TWO) },
modifier = Modifier.aspectRatio(1.5f),
text = "2"
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.THREE) },
modifier = Modifier.aspectRatio(1.5f),
text = "3"
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.FOUR) },
modifier = Modifier.aspectRatio(1.5f),
text = "4"
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.FIVE) },
modifier = Modifier.aspectRatio(1.5f),
text = "5"
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.SIX) },
modifier = Modifier.aspectRatio(1.5f),
text = "6"
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.SEVEN) },
modifier = Modifier.aspectRatio(1.5f),
text = "7"
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.EIGHT) },
modifier = Modifier.aspectRatio(1.5f),
text = "8"
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.NINE) },
modifier = Modifier.aspectRatio(1.5f),
text = "9"
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.VOLUME_UP) },
modifier = Modifier.aspectRatio(1.5f),
icon=painterResource(R.drawable.baseline_volume_up_24)
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.ZERO) },
modifier = Modifier.aspectRatio(1.5f),
text = "0"
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.CHANNEL_UP) },
modifier = Modifier.aspectRatio(1.5f),
icon = painterResource(id = R.drawable.baseline_keyboard_arrow_up_24)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.VOLUME_DOWN) },
modifier = Modifier.aspectRatio(1.5f),
icon=painterResource(R.drawable.baseline_volume_down_24)
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.MUTE) },
modifier = Modifier.aspectRatio(1.5f),
icon = painterResource(id = R.drawable.baseline_volume_off_24)
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.CHANNEL_DOWN) },
modifier = Modifier.aspectRatio(1.5f),
icon = painterResource(id = R.drawable.baseline_keyboard_arrow_down_24)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.MENU) },
modifier = Modifier.aspectRatio(1.5f),
text = "MENU"
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.HOME) },
modifier = Modifier.aspectRatio(1.5f),
icon = painterResource(id = R.drawable.baseline_home_24)
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.SETTINGS) },
modifier = Modifier.aspectRatio(1.5f),
icon = painterResource(id = R.drawable.baseline_settings_24)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.CHANNEL_LIST) },
modifier = Modifier.aspectRatio(1.5f),
icon = painterResource(id = R.drawable.baseline_list_alt_24)
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.CURSOR_UP) },
modifier = Modifier.aspectRatio(1.5f),
icon = painterResource(id = R.drawable.baseline_arrow_upward_24)
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.INFO) },
modifier = Modifier.aspectRatio(1.5f),
icon = painterResource(id = R.drawable.baseline_info_24)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.CURSOR_LEFT) },
modifier = Modifier.aspectRatio(1.5f),
icon = painterResource(id = R.drawable.baseline_arrow_back_24)
)
RemoteButton(
text = "OK",
onClick = { viewModel.sendCommand(ControllerService.ENTER) },
modifier = Modifier.aspectRatio(1.5f),
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.CURSOR_RIGHT) },
modifier = Modifier.aspectRatio(1.5f),
icon = painterResource(id = R.drawable.baseline_arrow_forward_24)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.RETURN) },
modifier = Modifier.aspectRatio(1.5f),
icon = painterResource(id = R.drawable.baseline_keyboard_return_24)
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.CURSOR_DOWN) },
modifier = Modifier.aspectRatio(1.5f),
icon = painterResource(id = R.drawable.baseline_arrow_downward_24)
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.EXIT) },
modifier = Modifier.aspectRatio(1.5f),
text = "EXIT"
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val width = 46.dp
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.RED) },
modifier = Modifier.aspectRatio(1.5f).background(color = Color.Red).clip(
RoundedCornerShape(12.dp)
),
width = width,
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.GREEN) },
modifier = Modifier.aspectRatio(1.5f).background(color = Color.Green).clip(
RoundedCornerShape(12.dp)
),
width = width,
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.YELLOW) },
modifier = Modifier.aspectRatio(1.5f).background(color = Color.Yellow).clip(
RoundedCornerShape(12.dp)
),
width = width,
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.BLUE) },
modifier = Modifier.aspectRatio(1.5f).background(color = Color.Blue).clip(
RoundedCornerShape(12.dp)
),
width = width,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val width = 46.dp
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.REWIND) },
modifier = Modifier.aspectRatio(1.5f),
width = width,
icon = painterResource(id = R.drawable.baseline_fast_rewind_24)
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.PLAY) },
modifier = Modifier.aspectRatio(1.5f),
width = width,
icon = painterResource(id = R.drawable.baseline_play_arrow_24)
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.PAUSE) },
modifier = Modifier.aspectRatio(1.5f),
width = width,
icon = painterResource(id = R.drawable.baseline_pause_24)
)
RemoteButton(
onClick = { viewModel.sendCommand(ControllerService.FORWARD) },
modifier = Modifier.aspectRatio(1.5f),
width = width,
icon = painterResource(id = R.drawable.baseline_fast_forward_24)
)
}
}
}
}

View File

@ -0,0 +1,35 @@
package com.example.tvcontroller.ui.views
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.example.tvcontroller.data.RemoteCommand
import com.example.tvcontroller.services.BluetoothService
import com.example.tvcontroller.services.ControllerService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.json.JSONObject
class RemoteViewModel(
private val controllerService: ControllerService
) : ViewModel() {
private val commands = mutableMapOf<String, RemoteCommand>()
fun sendCommand(commandType: String) {
val command = commands[commandType]?: return
viewModelScope.launch(Dispatchers.IO) {
controllerService.sendCommand(command)
}
}
companion object {
fun provideFactory(
controllerService: ControllerService
) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return RemoteViewModel(controllerService) as T
}
}
}
}

View File

@ -1,32 +1,55 @@
package com.example.tvcontroller.ui.views
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
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.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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.rememberNavController
import com.example.tvcontroller.R
import com.example.tvcontroller.Settings
import com.example.tvcontroller.SettingsViewModel
import com.example.tvcontroller.services.BluetoothService
import com.example.tvcontroller.services.DeviceService
import com.example.tvcontroller.ui.AppViewModel
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
fun SettingsView(
deviceService: DeviceService,
bluetoothService: BluetoothService
) {
val viewModel = viewModel<SettingsViewModel>(
factory = SettingsViewModel.provideFactory(
deviceService, bluetoothService
)
)
val navController = rememberNavController()
@Composable
fun SettingsView(deviceService: DeviceService, appViewModel: AppViewModel) {
val viewModel =
viewModel<SettingsViewModel>(factory = SettingsViewModel.Companion.provideFactory(deviceService))
fun MainSettingsView() {
Column(
modifier = Modifier
.padding(16.dp, 16.dp)
@ -45,27 +68,23 @@ fun SettingsView(deviceService: DeviceService, appViewModel: AppViewModel) {
Text(
text = stringResource(id = R.string.server_connection_label) + ": " + getConnectionStateString(
viewModel.connectionState
),
style = MaterialTheme.typography.bodyMedium
), style = MaterialTheme.typography.bodyMedium
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = viewModel.serverAddress,
onValueChange = viewModel::onServerAddressChanged,
label = { Text(stringResource(id = R.string.server_address_label)) }
)
label = { Text(stringResource(id = R.string.server_address_label)) })
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = viewModel.deviceName,
onValueChange = viewModel::onDeviceNameChanged,
label = { Text(stringResource(id = R.string.device_name_label)) }
)
label = { Text(stringResource(id = R.string.device_name_label)) })
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = viewModel.registrationCode,
onValueChange = viewModel::onRegistrationCodeChanged,
label = { Text(stringResource(id = R.string.registration_code_label)) }
)
label = { Text(stringResource(id = R.string.registration_code_label)) })
OutlinedButton(onClick = { viewModel.connect() }, modifier = Modifier.fillMaxWidth()) {
Text(
stringResource(id = R.string.connect_button_label)
@ -76,24 +95,103 @@ fun SettingsView(deviceService: DeviceService, appViewModel: AppViewModel) {
text = stringResource(id = R.string.controller_settings_heading),
style = MaterialTheme.typography.headlineSmall
)
if (appViewModel.isBluetoothEnabled()) {
Text(
text = "Controller settings: Bluetooth is enabled.",
text = "Controller status: " + getBluetoothConnectionStateString(viewModel.bluetoothConnectionState) + ".",
style = MaterialTheme.typography.bodyMedium
)
} else {
if (viewModel.bluetoothConnectionState == BluetoothService.STATE_CONNECTED) OutlinedButton(
onClick = viewModel::disconnectBluetoothDevice, modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Bluetooth is disabled. Please enable it in settings.",
style = MaterialTheme.typography.bodyMedium
stringResource(id = R.string.disconnect_button_label)
)
}
else OutlinedButton(
onClick = { navController.navigate(CONNECT_CONTROLLER_VIEW.toString()) },
enabled = viewModel.bluetoothConnectionState != BluetoothService.STATE_OFF,
modifier = Modifier.fillMaxWidth()
) {
Text(
stringResource(id = R.string.connect_button_label)
)
}
}
}
@Composable
fun getConnectionStateString(state: Settings.ConnectionState): String {
fun ConnectControllerView() {
val pairedDevices = viewModel.pairedDevices.collectAsState()
Column {
TopAppBar(
title = { Text(text = stringResource(id = R.string.connect_controller_title)) },
navigationIcon = {
IconButton(onClick = {
navController.navigate(MAIN_SETTINGS_VIEW.toString())
}) {
Icon(
painterResource(id = R.drawable.baseline_arrow_back_24),
contentDescription = "Back"
)
}
},
)
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
Text(
text = stringResource(id = R.string.paired_devices_label),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(start = 16.dp)
)
pairedDevices.value.forEach { device ->
if (device == null) return
ListItem(headlineContent = { Text(device.getName()) }, supportingContent = {
if (device.getName() != device.getAddress()) Text(
device.getAddress()
)
}, modifier = Modifier.clickable(onClick = {
viewModel.connectBluetoothDevice(
device
)
}), trailingContent = {
if (device == viewModel.currentBluetoothDevice) if (viewModel.bluetoothConnectionState == BluetoothService.STATE_CONNECTED) Icon(
painterResource(id = R.drawable.baseline_check_24),
contentDescription = "state"
)
else CircularProgressIndicator(
modifier = Modifier.width(16.dp),
color = MaterialTheme.colorScheme.secondary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
})
}
}
}
}
NavHost(navController = navController, startDestination = MAIN_SETTINGS_VIEW.toString()) {
composable(route = MAIN_SETTINGS_VIEW.toString()) { MainSettingsView() }
composable(route = CONNECT_CONTROLLER_VIEW.toString()) { ConnectControllerView() }
}
}
@Composable
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"
}
}
@Composable
fun getBluetoothConnectionStateString(state: String): String {
return when (state) {
BluetoothService.STATE_OFF -> stringResource(id = R.string.controller_state_bluetooth_disabled)
BluetoothService.STATE_DISCONNECTED -> stringResource(id = R.string.bluetooth_state_disconnected)
BluetoothService.STATE_CONNECTING -> stringResource(id = R.string.bluetooth_state_connecting)
BluetoothService.STATE_CONNECTED -> stringResource(id = R.string.bluetooth_state_connected)
else -> "Unknown"
}
}

View File

@ -0,0 +1,110 @@
package com.example.tvcontroller.ui.views
import android.os.Build
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.example.tvcontroller.data.BluetoothDevice
import com.example.tvcontroller.services.BluetoothService
import com.example.tvcontroller.services.DeviceService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
private const val TAG = "SettingsViewModel"
class SettingsViewModel(
private val deviceService: DeviceService,
private val bluetoothService: BluetoothService
) : ViewModel() {
var serverAddress by mutableStateOf(deviceService.serverAddress)
private set
var deviceName by mutableStateOf(Build.MANUFACTURER + " " + Build.MODEL)
private set
var registrationCode by mutableStateOf("")
private set
var connectionState by mutableStateOf(deviceService.status)
private set
var bluetoothConnectionState by mutableStateOf(bluetoothService.state)
private set
var currentBluetoothDevice by mutableStateOf<BluetoothDevice?>(bluetoothService.currentDevice)
private set
var pairedDevices = bluetoothService.pairedDevices
init {
updateConnectionState()
viewModelScope.launch {
updateDeviceInfo()
}
bluetoothService.onBluetoothStateChanged {
currentBluetoothDevice = bluetoothService.currentDevice
bluetoothConnectionState = it
}
deviceService.onStatusChanged { connectionState = it }
}
fun connect() {
viewModelScope.launch {
deviceService.serverAddress = serverAddress
deviceService.registerIntegration(deviceName, registrationCode)
updateConnectionState()
updateDeviceInfo()
}
}
private fun updateConnectionState() {
Log.i(TAG, "Device token: ${deviceService.token}")
connectionState = deviceService.status
}
private suspend fun updateDeviceInfo() {
if (connectionState == DeviceService.STATUS_UNREGISTERED) return
val integration = deviceService.getIntegration()
integration?.let {
deviceName = it.name
}
}
fun connectBluetoothDevice(device: BluetoothDevice) {
viewModelScope.launch(Dispatchers.IO) {
bluetoothService.connectToDevice(device)
}
}
fun disconnectBluetoothDevice() {
viewModelScope.launch(Dispatchers.IO) {
bluetoothService.closeBluetoothConnection()
}
}
fun onServerAddressChanged(url: String) {
serverAddress = url
}
fun onDeviceNameChanged(name: String) {
deviceName = name
}
fun onRegistrationCodeChanged(code: String) {
registrationCode = code
}
companion object {
const val MAIN_SETTINGS_VIEW = "main_settings_view"
const val CONNECT_CONTROLLER_VIEW = "connect_controller_view"
fun provideFactory(
deviceService: DeviceService,
bluetoothService: BluetoothService
) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return SettingsViewModel(deviceService, bluetoothService) as T
}
}
}
}

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M20,12l-1.41,-1.41L13,16.17V4h-2v12.17l-5.58,-5.59L4,12l8,8 8,-8z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M12,4l-1.41,1.41L16.17,11H4v2h12.17l-5.58,5.59L12,20l8,-8z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M4,12l1.41,1.41L11,7.83V20h2V7.83l5.58,5.59L20,12l-8,-8 -8,8z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M4,18l8.5,-6L4,6v12zM13,6v12l8.5,-6L13,6z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M11,18L11,6l-8.5,6 8.5,6zM11.5,12l8.5,6L20,6l-8.5,6z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M10,20v-6h4v6h5v-8h3L12,3 2,12h3v8z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2L11,7h2v2z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M7.41,8.59L12,13.17l4.59,-4.58L18,10l-6,6 -6,-6 1.41,-1.41z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M7.41,15.41L12,10.83l4.59,4.58L18,14l-6,-6 -6,6z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M19,7v4H5.83l3.58,-3.59L8,6l-6,6 6,6 1.41,-1.41L5.83,13H21V7z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M19,5v14L5,19L5,5h14m1.1,-2L3.9,3c-0.5,0 -0.9,0.4 -0.9,0.9v16.2c0,0.4 0.4,0.9 0.9,0.9h16.2c0.4,0 0.9,-0.5 0.9,-0.9L21,3.9c0,-0.5 -0.5,-0.9 -0.9,-0.9zM11,7h6v2h-6L11,7zM11,11h6v2h-6v-2zM11,15h6v2h-6zM7,7h2v2L7,9zM7,11h2v2L7,13zM7,15h2v2L7,17z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M11,7L9.6,8.4l2.6,2.6H2v2h10.2l-2.6,2.6L11,17l5,-5L11,7zM20,19h-8v2h8c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2h-8v2h8V19z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M8,5v14l11,-7z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M13,3h-2v10h2L13,3zM17.83,5.17l-1.42,1.42C17.99,7.86 19,9.81 19,12c0,3.87 -3.13,7 -7,7s-7,-3.13 -7,-7c0,-2.19 1.01,-4.14 2.58,-5.42L6.17,5.17C4.23,6.82 3,9.26 3,12c0,4.97 4.03,9 9,9s9,-4.03 9,-9c0,-2.74 -1.23,-5.18 -3.17,-6.83z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M18.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v8.05c1.48,-0.73 2.5,-2.25 2.5,-4.02zM5,9v6h4l5,5V4L9,9H5z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v2.21l2.45,2.45c0.03,-0.2 0.05,-0.41 0.05,-0.63zM19,12c0,0.94 -0.2,1.82 -0.54,2.64l1.51,1.51C20.63,14.91 21,13.5 21,12c0,-4.28 -2.99,-7.86 -7,-8.77v2.06c2.89,0.86 5,3.54 5,6.71zM4.27,3L3,4.27 7.73,9L3,9v6h4l5,5v-6.73l4.25,4.25c-0.67,0.52 -1.42,0.93 -2.25,1.18v2.06c1.38,-0.31 2.63,-0.95 3.69,-1.81L19.73,21 21,19.73l-9,-9L4.27,3zM12,4L9.91,6.09 12,8.18L12,4z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M3,9v6h4l5,5L12,4L7,9L3,9zM16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v8.05c1.48,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"/>
</vector>

View File

@ -13,4 +13,13 @@
<string name="server_address_label">Server address</string>
<string name="connection_state_unregistered">unregistered</string>
<string name="connection_state_registered">registered</string>
<string name="controller_state_bluetooth_disabled">Bluetooth is turned off</string>
<string name="paired_devices_label">Paired devices</string>
<string name="scanned_devices_label">Scanned devices</string>
<string name="connect_controller_title">Connect controller</string>
<string name="bluetooth_state_connecting">connecting</string>
<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>

View File

@ -1,17 +1,17 @@
[versions]
streamLog = "1.1.4"
agp = "8.9.0"
cameraCore = "1.4.1"
agp = "8.9.1"
cameraCore = "1.4.2"
kotlin = "2.0.0"
coreKtx = "1.10.1"
coreKtx = "1.15.0"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
ktor = "3.1.0"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
composeBom = "2024.04.01"
navigationCompose = "2.8.4"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.10.1"
composeBom = "2025.03.01"
material3 = "1.4.0-alpha11"
navigationCompose = "2.8.9"
streamWebrtcAndroid = "1.3.8"
[libraries]
@ -39,9 +39,9 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" }
stream-webrtc-android = { module = "io.getstream:stream-webrtc-android", version.ref = "streamWebrtcAndroid" }
stream-log = { group = "io.getstream", name = "stream-log-android", version.ref = "streamLog" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }