diff --git a/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/data/SocketRepositoryImpl.kt b/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/data/SocketRepositoryImpl.kt index 533cfc9..a2abf5f 100644 --- a/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/data/SocketRepositoryImpl.kt +++ b/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/data/SocketRepositoryImpl.kt @@ -23,6 +23,7 @@ class SocketRepositoryImpl @Inject constructor( true } catch (e: Exception) { Log.e( javaClass.simpleName, "Socket connection error; address: $address, port: $port", e) + socketSource.close() false } } diff --git a/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/data/SocketSource.kt b/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/data/SocketSource.kt index a2292f3..429dffb 100644 --- a/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/data/SocketSource.kt +++ b/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/data/SocketSource.kt @@ -1,6 +1,10 @@ package com.dmitryzenevich.remoteaudiocontrol.data +import android.util.Log +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.io.Closeable +import java.net.InetSocketAddress import java.net.Socket import javax.inject.Inject import javax.inject.Singleton @@ -9,6 +13,7 @@ import javax.inject.Singleton class SocketSource @Inject constructor() : Closeable { private var socket: Socket? = null + private val mutex = Mutex() val writer get() = socket().getOutputStream().writer() @@ -18,12 +23,16 @@ class SocketSource @Inject constructor() : Closeable { private fun socket(): Socket = socket ?: throw IllegalStateException("Socket is not created") - fun openSocketConnection(address: String, port: Int) { - socket = Socket(address, port) + suspend fun openSocketConnection(address: String, port: Int) { + mutex.withLock { + close() + socket = Socket().apply { connect(InetSocketAddress(address, port), 5000) } + } } - override fun close() { + Log.i(javaClass.simpleName, "Socket $socket is now closing") socket?.close() + socket = null } } diff --git a/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/di/AppModule.kt b/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/di/AppModule.kt index 505ef2b..9dc9859 100644 --- a/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/di/AppModule.kt +++ b/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/di/AppModule.kt @@ -1,13 +1,22 @@ package com.dmitryzenevich.remoteaudiocontrol.di +import android.content.Context +import android.content.Context.MODE_PRIVATE +import android.content.SharedPreferences import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -abstract class AppModule { +class AppModule { + @Singleton + @Provides + fun getSharedPreferences(@ApplicationContext appContext: Context): SharedPreferences { + return appContext.getSharedPreferences("AppPrefs", MODE_PRIVATE) + } } \ No newline at end of file diff --git a/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/MainActivity.kt b/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/MainActivity.kt index f90778f..125c785 100644 --- a/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/MainActivity.kt +++ b/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/MainActivity.kt @@ -3,8 +3,20 @@ package com.dmitryzenevich.remoteaudiocontrol.presentation import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.dmitryzenevich.remoteaudiocontrol.presentation.theme.Dimens.TabHeight import com.dmitryzenevich.remoteaudiocontrol.presentation.theme.RemoteAudioControlTheme import dagger.hilt.android.AndroidEntryPoint +import androidx.lifecycle.viewmodel.compose.viewModel +import com.dmitryzenevich.remoteaudiocontrol.R @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -12,9 +24,95 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - RemoteAudioControlTheme { - MainScreen() + RemoteAudioControlApp() + } + } +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RemoteAudioControlApp(viewModel: MainViewModel = viewModel()) { + val uiState = viewModel.uiState.collectAsState() + val showDialog = viewModel.showAddressDialog.collectAsState() + val isLoading = viewModel.isLoading.collectAsState() + + RemoteAudioControlTheme { + Scaffold( + topBar = { TopBar(viewModel::onAddressClick) } + ) { padding -> + Box(Modifier.padding(padding)) { + MainScreen( + uiState = uiState.value, + onVolumeChanged = viewModel::onVolumeChanged, + onMuteClick = viewModel::onMuteClick + ) + if (showDialog.value) { + IpAddressDialog(initIp = viewModel.getCurrentIp(), onConfirm = viewModel::onConfirmAddress) + } + if (isLoading.value) { + ConnectionProgressBar() + } } } } } + +@Composable +fun ConnectionProgressBar() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} + +@Composable +fun TopBar(onAddressClick: () -> Unit) { + Surface( + modifier = Modifier + .height(TabHeight) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.primary + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = stringResource(R.string.app_name)) + IconButton(onClick = onAddressClick) { + Icon( + imageVector = Icons.Filled.Build, + contentDescription = stringResource(R.string.accessibility_ip_address_button) + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun IpAddressDialog(initIp: String, onConfirm: (String) -> Unit) { + var ipText by remember { mutableStateOf(initIp) } + + AlertDialog( + onDismissRequest = {}, + title = { + Text(text = stringResource(R.string.ip_dialog_title)) + }, + text = { + TextField(value = ipText, onValueChange = { ipText = it }) + }, + confirmButton = { + Button( + onClick = { onConfirm(ipText) } + ) { + Text(text = "Ok") + } + } + ) +} diff --git a/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/MainScreen.kt b/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/MainScreen.kt index 6abee80..c511130 100644 --- a/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/MainScreen.kt +++ b/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/MainScreen.kt @@ -4,14 +4,14 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @Composable -fun MainScreen(viewModel: MainViewModel = androidx.lifecycle.viewmodel.compose.viewModel()) { - val uiState by viewModel.uiState.collectAsState() - +fun MainScreen( + uiState: MainScreenUiState, + onVolumeChanged: (VolumeItemState, Int) -> Unit, + onMuteClick: (VolumeItemState) -> Unit +) { LazyRow( modifier = Modifier.fillMaxSize() ) { @@ -22,9 +22,9 @@ fun MainScreen(viewModel: MainViewModel = androidx.lifecycle.viewmodel.compose.v VolumeItem( volumeItemState = itemState, onValueChanged = { newValue: Float -> - viewModel.onVolumeChanged(itemState, newValue.toInt()) + onVolumeChanged(itemState, newValue.toInt()) }, - onMuteClick = { viewModel.onMuteClick(itemState) } + onMuteClick = { onMuteClick(itemState) } ) } } diff --git a/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/MainViewModel.kt b/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/MainViewModel.kt index 0257a31..ed0c13d 100644 --- a/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/MainViewModel.kt +++ b/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/MainViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.dmitryzenevich.remoteaudiocontrol.data.SocketRepositoryImpl import com.dmitryzenevich.remoteaudiocontrol.data.model.* +import com.dmitryzenevich.remoteaudiocontrol.presentation.helper.PrefHelper import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -14,29 +15,42 @@ const val NOT_BLOCKED = -1L @HiltViewModel class MainViewModel @Inject constructor( - private val socketRepositoryImpl: SocketRepositoryImpl + private val socketRepositoryImpl: SocketRepositoryImpl, + private val prefHelper: PrefHelper ) : ViewModel() { private val _uiState = MutableStateFlow(MainScreenUiState()) val uiState = _uiState.asStateFlow() + private val _showAddressDialog = MutableStateFlow(false) + val showAddressDialog = _showAddressDialog.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading = _isLoading.asStateFlow() + @Volatile private var blockingPid: Long = NOT_BLOCKED private var blockingJob: Job? = null init { + connectToSocket() + } + + private fun connectToSocket() { viewModelScope.launch { - val isReady = socketRepositoryImpl.openSocketConnection(/*"10.0.2.2"*/"192.168.100.9", 54683) + _isLoading.value = true + val isReady = socketRepositoryImpl.openSocketConnection(prefHelper.getIpAddress(), 54683) if (isReady) { socketRepositoryImpl.bindSocketInput() .filterNotNull() .onEach { - Log.i(javaClass.simpleName, "received: $it") + Log.i(javaClass.simpleName, "Received: $it") withContext(Dispatchers.Main) { proceedEvent(it) } } .catch { Log.e(javaClass.simpleName, "Fetch event error", it) } .launchIn(CoroutineScope(Dispatchers.IO)) } + _isLoading.value = false } } @@ -110,6 +124,27 @@ class MainViewModel @Inject constructor( _uiState.value.volumes.add(event.toVolumeItemState()) } } + + fun onAddressClick() { + _showAddressDialog.value = true + Log.i(javaClass.simpleName, "address clicked") + } + + fun onConfirmAddress(ip: String) { + if (isValidIp(ip)) { + prefHelper.setIpAddress(ip) + _showAddressDialog.value = false + connectToSocket() + Log.i(javaClass.simpleName, "new ip: $ip") + } + } + + fun getCurrentIp() = prefHelper.getIpAddress().also { Log.i(javaClass.simpleName, "Current ip: $it") } + + private fun isValidIp(ip: String): Boolean = ip.matches(ipCheckRegex.toRegex()) + .also { Log.i(javaClass.simpleName, "Regex matches: $it") } } +const val ipCheckRegex = "^((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}\$" + fun NewSessionEvent.toVolumeItemState() = VolumeItemState(pid = PID) diff --git a/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/VolumeItem.kt b/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/VolumeItem.kt index 599dcd1..659a0b3 100644 --- a/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/VolumeItem.kt +++ b/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/VolumeItem.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.layout import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp import com.dmitryzenevich.remoteaudiocontrol.R @@ -39,12 +40,12 @@ fun VolumeItem( if (volumeItemState.isMuted.value) Icon( painter = painterResource(R.drawable.ic_volume_off), - contentDescription = "Muted" + contentDescription = stringResource(R.string.accessibility_muted) ) else Icon( painter = painterResource(R.drawable.ic_volume_up), - contentDescription = "Resumed" + contentDescription = stringResource(R.string.accessibility_resumed) ) } } diff --git a/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/helper/PrefHelper.kt b/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/helper/PrefHelper.kt new file mode 100644 index 0000000..b88280e --- /dev/null +++ b/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/helper/PrefHelper.kt @@ -0,0 +1,20 @@ +package com.dmitryzenevich.remoteaudiocontrol.presentation.helper + +import android.content.SharedPreferences +import androidx.core.content.edit +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PrefHelper @Inject constructor(private val sPref: SharedPreferences) { + + fun getIpAddress(): String { + return sPref.getString(KEY_IP, "192.168.100.9")!! + } + + fun setIpAddress(ip: String) = sPref.edit { putString(KEY_IP, ip) } + + private companion object { + const val KEY_IP = "key_ip" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/theme/Color.kt b/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/theme/Color.kt index 2e190e2..b23c39f 100644 --- a/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/theme/Color.kt +++ b/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/theme/Color.kt @@ -2,10 +2,10 @@ package com.dmitryzenevich.remoteaudiocontrol.presentation.theme import androidx.compose.ui.graphics.Color -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) +val Purple80 = Color(0xFF80CBC4) +val PurpleGrey80 = Color(0xFF4DB6AC) +val Pink80 = Color(0xFF81C784) -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +val Purple40 = Color(0xFF009688) +val PurpleGrey40 = Color(0xFF00897B) +val Pink40 = Color(0xFF80CBC4) \ No newline at end of file diff --git a/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/theme/Dimens.kt b/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/theme/Dimens.kt new file mode 100644 index 0000000..d2a700d --- /dev/null +++ b/app/src/main/java/com/dmitryzenevich/remoteaudiocontrol/presentation/theme/Dimens.kt @@ -0,0 +1,7 @@ +package com.dmitryzenevich.remoteaudiocontrol.presentation.theme + +import androidx.compose.ui.unit.dp + +object Dimens { + val TabHeight = 56.dp +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bc8103c..18a88c4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,9 @@ Remote Audio Control + Enter a valid ip address + + + Choose server ip address + Muted + Resumed \ No newline at end of file