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
54 changed files with 2262 additions and 466 deletions

View File

@ -4,7 +4,7 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-03-18T16:06:30.698647383Z"> <DropdownSelection timestamp="2025-04-05T11:04:12.656433726Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=d3e11beb" /> <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 { android {
namespace = "com.example.tvcontroller" namespace = "com.example.tvcontroller"
compileSdk = 34 compileSdk = 35
defaultConfig { defaultConfig {
applicationId = "com.example.tvcontroller" applicationId = "com.example.tvcontroller"
@ -52,6 +52,16 @@ dependencies {
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.ktor.client.core) implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio) 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)
implementation(libs.androidx.camera.video)
implementation(libs.androidx.camera.view)
implementation(libs.androidx.camera.mlkit.vision)
implementation(libs.androidx.camera.extensions)
implementation(libs.material3)
implementation(libs.stream.webrtc.android)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)

View File

@ -1,10 +1,20 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<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" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@ -18,6 +28,7 @@
tools:targetApi="31"> tools:targetApi="31">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true" android:exported="true"
android:theme="@style/Theme.TVController"> android:theme="@style/Theme.TVController">
<intent-filter> <intent-filter>

View File

@ -1,156 +1,85 @@
package com.example.tvcontroller package com.example.tvcontroller
import android.bluetooth.BluetoothAdapter
import android.content.ContentValues.TAG
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.core.app.ActivityCompat
import androidx.compose.foundation.layout.Arrangement import androidx.lifecycle.lifecycleScope
import androidx.compose.foundation.layout.Column import com.example.tvcontroller.client.WebClient
import androidx.compose.foundation.layout.fillMaxSize import com.example.tvcontroller.client.WebsocketClient
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.lifecycle.viewmodel.compose.viewModel
import com.example.tvcontroller.services.BluetoothService import com.example.tvcontroller.services.BluetoothService
import com.example.tvcontroller.services.CameraService
import com.example.tvcontroller.services.ControllerService
import com.example.tvcontroller.services.DeviceService import com.example.tvcontroller.services.DeviceService
import com.example.tvcontroller.ui.AppViewModel 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() { class MainActivity : ComponentActivity() {
private lateinit var bluetoothService: BluetoothService private val webClient by lazy { WebClient() }
private lateinit var deviceService: DeviceService private val websocketClient by lazy { WebsocketClient(webClient.client) }
private val appViewModel by viewModels<AppViewModel>() 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
bluetoothService = BluetoothService(this.applicationContext) checkPermissions()
bluetoothService.onBluetoothStateChanged { state -> appViewModel.setBluetoothEnabled(state == BluetoothAdapter.STATE_ON) }
appViewModel.setBluetoothEnabled(bluetoothService.isBluetoothEnabled()) lifecycleScope.launch {
deviceService = DeviceService(this.applicationContext) 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() enableEdgeToEdge()
setContent { setContent {
TVControllerTheme { TVControllerTheme {
TvControllerApp( MainView(
appViewModel = appViewModel, deviceService = deviceService,
controllerService = controllerService,
bluetoothService = bluetoothService, bluetoothService = bluetoothService,
deviceService = deviceService webRtcService = webRtcService,
cameraService = cameraService
) )
} }
} }
} }
}
@Composable private fun checkPermissions() {
fun TvControllerApp( Log.i(TAG, "Checking permissions")
navController: NavHostController = rememberNavController(), if (!bluetoothService.hasRequiredPermissions()) {
appViewModel: AppViewModel = viewModel(), Log.i(TAG, "Requesting Bluetooth permissions")
bluetoothService: BluetoothService? = null, ActivityCompat.requestPermissions(this, BluetoothService.BLUETOOTH_PERMISSIONS, 0)
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 -> if (!cameraService.hasRequiredPermissions()) {
Column { Log.i(TAG, "Requesting Camera permissions")
NavHost( ActivityCompat.requestPermissions(this, CameraService.CAMERA_PERMISSIONS, 0)
navController = navController,
startDestination = Screen.Camera.name,
modifier = Modifier.padding(innerPadding)
) {
composable(route = Screen.Camera.name) {
CameraScreen()
}
composable(route = Screen.Remote.name) {
RemoteScreen()
}
composable(route = Screen.Settings.name) {
SettingsScreen(appViewModel = appViewModel, deviceService = deviceService)
}
}
} }
} }
} }
@Composable
fun CameraScreen(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Camera Screen", modifier = modifier)
}
}
@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,96 +0,0 @@
package com.example.tvcontroller
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.tvcontroller.services.DeviceService
import com.example.tvcontroller.ui.AppViewModel
@Composable
fun SettingsScreen(deviceService: DeviceService, appViewModel: AppViewModel) {
val viewModel =
viewModel<SettingsViewModel>(factory = SettingsViewModel.provideFactory(deviceService))
Column(
modifier = Modifier
.padding(16.dp, 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(id = R.string.settings_title),
style = MaterialTheme.typography.displaySmall
)
Spacer(modifier = Modifier.padding(8.dp))
Text(
text = stringResource(id = R.string.server_settings_heading),
style = MaterialTheme.typography.headlineSmall
)
Text(
text = stringResource(id = R.string.server_connection_label) + ": " + getConnectionStateString(
viewModel.connectionState
),
style = MaterialTheme.typography.bodyMedium
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = viewModel.serverAddress,
onValueChange = viewModel::onServerAddressChanged,
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)) }
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = viewModel.registrationCode,
onValueChange = viewModel::onRegistrationCodeChanged,
label = { Text(stringResource(id = R.string.registration_code_label)) }
)
OutlinedButton(onClick = { viewModel.connect() }, modifier = Modifier.fillMaxWidth()) {
Text(
stringResource(id = R.string.connect_button_label)
)
}
Spacer(modifier = Modifier.padding(8.dp))
Text(
text = stringResource(id = R.string.controller_settings_heading),
style = MaterialTheme.typography.headlineSmall
)
if (appViewModel.isBluetoothEnabled()) {
Text(
text = "Controller settings: Bluetooth is enabled.",
style = MaterialTheme.typography.bodyMedium
)
} else {
Text(
text = "Bluetooth is disabled. Please enable it in settings.",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
@Composable
fun getConnectionStateString(state: Settings.ConnectionState): String {
return when (state) {
Settings.ConnectionState.Unregistered -> stringResource(id = R.string.connection_state_unregistered)
Settings.ConnectionState.Registered -> stringResource(id = R.string.connection_state_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 package com.example.tvcontroller.services
import android.Manifest
import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothSocket
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat.getSystemService 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) { class BluetoothService(private val context: Context) {
private var bluetoothManager: BluetoothManager = private var bluetoothManager: BluetoothManager =
getSystemService(context, BluetoothManager::class.java)!!; getSystemService(context, BluetoothManager::class.java)!!
private var bluetoothAdapter: BluetoothAdapter = bluetoothManager.adapter 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 { 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 { fun isBluetoothEnabled(): Boolean {
return bluetoothAdapter.isEnabled ?: false return bluetoothAdapter.isEnabled
} }
private fun registerBluetoothStateReceiver() { fun onBluetoothStateChanged(callback: (String) -> Unit) {
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) {
bluetoothStateChangedCallbacks.add(callback) bluetoothStateChangedCallbacks.add(callback)
} }
fun offBluetoothStateChanged(callback: (Int) -> Unit) { fun offBluetoothStateChanged(callback: (String) -> Unit) {
bluetoothStateChangedCallbacks.remove(callback) bluetoothStateChangedCallbacks.remove(callback)
} }
fun cleanUp() { fun cleanUp() {
context.unregisterReceiver(bluetoothStateReceiver) 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

@ -0,0 +1,57 @@
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 CAMERA_PERMISSIONS.all {
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
}
}
companion object {
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,118 +3,153 @@ package com.example.tvcontroller.services
import android.content.Context import android.content.Context
import android.content.Context.MODE_PRIVATE import android.content.Context.MODE_PRIVATE
import android.util.Log import android.util.Log
import androidx.lifecycle.lifecycleScope
import com.example.tvcontroller.client.WebClient
import com.example.tvcontroller.client.WebsocketClient
import com.example.tvcontroller.data.Integration 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.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 io.ktor.http.HttpMethod
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.json.JSONObject import org.json.JSONObject
private const val SHARED_PREFERENCES_NAME = "devices";
class DeviceService(private val context: Context) { class DeviceService(
private var client = HttpClient(CIO) private val context: Context,
private var serverAddress: String = "" private val client: WebClient,
private var token: String = "" 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 var deviceId: String = ""
private val statusChangedListeners = mutableListOf<(String) -> Unit>()
init { suspend fun initialize() {
loadPreferences() websocketClient.onConnected { status = STATUS_CONNECTED }
} websocketClient.onDisconnected { status = STATUS_REGISTERED }
onStatusChanged {
suspend fun createIntegration(name: String, code: String) { when(it) {
Log.i("DeviceService", "Creating integration for $name with code $code at $serverAddress") STATUS_UNREGISTERED -> {
val requestJson = JSONObject() token = ""
requestJson.put("name", name) deviceId = ""
requestJson.put("code", code) updateDefaultCookies()
try { savePreferences()
val response: HttpResponse = client.request("http://$serverAddress/api/integrations") { }
method = HttpMethod.Post STATUS_REGISTERED -> {
setBody(requestJson.toString()) Log.i(TAG, "Device registered with id $deviceId")
headers { savePreferences()
append("Content-Type", "application/json") connectWebsocket()
} }
} }
}
loadPreferences()
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() private fun updateDefaultCookies() {
val responseJson = JSONObject(body) Log.i(TAG, "Updating default cookies with token $token")
if (response.status.value != 200) { client.defaultCookies["token"] = token
val error = responseJson.getString("error") Log.i(TAG, "Default cookies: ${client.defaultCookies}")
Log.e("DeviceService", "Error getting integration: ${response.status.value} $error") }
return
}
token = responseJson.getString("token")
deviceId = responseJson.getString("id")
savePreferences()
Log.i("DeviceService", "Response: ${response.status.value} $body") suspend fun registerIntegration(name: String, code: String) {
} catch (e: Exception) { Log.i(TAG, "Registering integration for $name with code $code at $serverAddress")
Log.e("DeviceService", "Error creating integration", e) 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(TAG, "Error registering integration: ${response.status.value} $error")
return
}
token = responseJson.getString("token")
deviceId = responseJson.getString("id")
status = STATUS_REGISTERED
}
fun onStatusChanged(listener: (String) -> Unit) {
statusChangedListeners.add(listener)
}
@OptIn(DelicateCoroutinesApi::class)
fun connectWebsocket() {
GlobalScope.launch() {
websocketClient.connect(serverAddress, token)
} }
} }
suspend fun getIntegration(): Integration? { suspend fun getIntegration(): Integration? {
Log.i("DeviceService", "Getting integration $deviceId at $serverAddress") val response =
try { client.sendRequest("http://$serverAddress/api/integrations/$deviceId", HttpMethod.Get)
val response: HttpResponse = ?: return null
client.request("http://$serverAddress/api/integrations/$deviceId") {
method = HttpMethod.Get
headers {
append("Authorization", "Bearer $token")
}
}
val body: String = response.body() val responseJson = JSONObject(response.body<String>())
val responseJson = JSONObject(body) if (response.status.value != 200) {
if (response.status.value != 200) { val error = responseJson.getString("error")
val error = responseJson.getString("error") Log.e(TAG, "Error getting integration: ${response.status.value} $error")
Log.e("DeviceService", "Error getting integration: ${response.status.value} $error") return null
return null
}
val integration = Integration(
responseJson.getString("id"),
responseJson.getString("name")
)
return integration
} catch (e: Exception) {
Log.e("DeviceService", "Error getting integration", e)
} }
return null val integration = Integration(
responseJson.getString("id"),
responseJson.getString("name")
)
return integration
} }
private fun loadPreferences() { private fun loadPreferences() {
val sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, MODE_PRIVATE) val sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, MODE_PRIVATE)
serverAddress = sharedPreferences.getString("server_address", "")!! serverAddress = sharedPreferences.getString(SERVER_ADDRESS_KEY, "")!!
token = sharedPreferences.getString("token", "")!! token = sharedPreferences.getString(TOKEN_KEY, "")!!
deviceId = sharedPreferences.getString("device_id", "")!! deviceId = sharedPreferences.getString(DEVICE_ID_KEY, "")!!
Log.i("DeviceService", "Loaded preferences: $serverAddress $token") Log.i(TAG, "Loaded preferences: $serverAddress $token")
} }
private fun savePreferences() { private fun savePreferences() {
Log.i(TAG, "Saving preferences")
val sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, MODE_PRIVATE) val sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, MODE_PRIVATE)
val editor = sharedPreferences.edit() val editor = sharedPreferences.edit()
editor.apply { editor.apply {
putString("server_address", serverAddress) putString(SERVER_ADDRESS_KEY, serverAddress)
putString("token", token) putString(TOKEN_KEY, token)
putString("device_id", deviceId) putString(DEVICE_ID_KEY, deviceId)
apply() apply()
} }
} }
fun setServerAddress(url: String) { companion object {
serverAddress = url const val STATUS_UNREGISTERED = "unregistered"
} const val STATUS_REGISTERED = "registered"
const val STATUS_CONNECTED = "connected"
fun getServerAddress(): String { private const val SHARED_PREFERENCES_NAME = "devices";
return serverAddress private const val SERVER_ADDRESS_KEY = "server_address"
} private const val TOKEN_KEY = "token"
private const val DEVICE_ID_KEY = "device_id"
fun getToken(): String { private const val TAG = "DeviceService"
return token
} }
} }

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

@ -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,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

@ -0,0 +1,64 @@
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 org.webrtc.EglBase
import org.webrtc.RendererCommon
import org.webrtc.VideoTrack
@Composable
fun CameraPreview(
eglBaseContext: EglBase.Context, videoTrack: VideoTrack, modifier: Modifier = Modifier
) {
val trackState: MutableState<VideoTrack?> = remember { mutableStateOf(null) }
var view: VideoTextureViewRenderer? by remember { mutableStateOf(null) }
AndroidView(
factory = {
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

@ -0,0 +1,34 @@
package com.example.tvcontroller.ui.views
import android.content.res.Configuration
import android.util.Log
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import com.example.tvcontroller.ui.components.CameraPreview
import org.webrtc.EglBase
import org.webrtc.VideoTrack
@Composable
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(),
contentAlignment = Alignment.Center
) {
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

@ -0,0 +1,197 @@
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.services.BluetoothService
import com.example.tvcontroller.services.DeviceService
import com.example.tvcontroller.ui.views.SettingsViewModel.Companion.CONNECT_CONTROLLER_VIEW
import com.example.tvcontroller.ui.views.SettingsViewModel.Companion.MAIN_SETTINGS_VIEW
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsView(
deviceService: DeviceService,
bluetoothService: BluetoothService
) {
val viewModel = viewModel<SettingsViewModel>(
factory = SettingsViewModel.provideFactory(
deviceService, bluetoothService
)
)
val navController = rememberNavController()
@Composable
fun MainSettingsView() {
Column(
modifier = Modifier
.padding(16.dp, 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(id = R.string.settings_title),
style = MaterialTheme.typography.displaySmall
)
Spacer(modifier = Modifier.padding(8.dp))
Text(
text = stringResource(id = R.string.server_settings_heading),
style = MaterialTheme.typography.headlineSmall
)
Text(
text = stringResource(id = R.string.server_connection_label) + ": " + getConnectionStateString(
viewModel.connectionState
), style = MaterialTheme.typography.bodyMedium
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = viewModel.serverAddress,
onValueChange = viewModel::onServerAddressChanged,
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)) })
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = viewModel.registrationCode,
onValueChange = viewModel::onRegistrationCodeChanged,
label = { Text(stringResource(id = R.string.registration_code_label)) })
OutlinedButton(onClick = { viewModel.connect() }, modifier = Modifier.fillMaxWidth()) {
Text(
stringResource(id = R.string.connect_button_label)
)
}
Spacer(modifier = Modifier.padding(8.dp))
Text(
text = stringResource(id = R.string.controller_settings_heading),
style = MaterialTheme.typography.headlineSmall
)
Text(
text = "Controller status: " + getBluetoothConnectionStateString(viewModel.bluetoothConnectionState) + ".",
style = MaterialTheme.typography.bodyMedium
)
if (viewModel.bluetoothConnectionState == BluetoothService.STATE_CONNECTED) OutlinedButton(
onClick = viewModel::disconnectBluetoothDevice, modifier = Modifier.fillMaxWidth()
) {
Text(
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 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) {
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="server_address_label">Server address</string>
<string name="connection_state_unregistered">unregistered</string> <string name="connection_state_unregistered">unregistered</string>
<string name="connection_state_registered">registered</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> </resources>

View File

@ -1,17 +1,27 @@
[versions] [versions]
agp = "8.9.0" agp = "8.9.1"
cameraCore = "1.4.2"
kotlin = "2.0.0" kotlin = "2.0.0"
coreKtx = "1.10.1" coreKtx = "1.15.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.1.5" junitVersion = "1.2.1"
espressoCore = "3.5.1" espressoCore = "3.6.1"
ktor = "3.1.0" ktor = "3.1.0"
lifecycleRuntimeKtx = "2.6.1" lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.8.0" activityCompose = "1.10.1"
composeBom = "2024.04.01" composeBom = "2025.03.01"
navigationCompose = "2.8.4" material3 = "1.4.0-alpha11"
navigationCompose = "2.8.9"
streamWebrtcAndroid = "1.3.8"
[libraries] [libraries]
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraCore" }
androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "cameraCore" }
androidx-camera-extensions = { module = "androidx.camera:camera-extensions", version.ref = "cameraCore" }
androidx-camera-mlkit-vision = { module = "androidx.camera:camera-mlkit-vision", version.ref = "cameraCore" }
androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "cameraCore" }
androidx-camera-video = { module = "androidx.camera:camera-video", version.ref = "cameraCore" }
androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "cameraCore" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
@ -29,6 +39,9 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } 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-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" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }