From 0e2dde9fcd6a253835e7e0aabc433e98c838fc73 Mon Sep 17 00:00:00 2001 From: Giovanni Salinas Date: Tue, 21 Oct 2025 17:00:34 +0800 Subject: [PATCH] feath: add network interface refresh button --- .../RadioBroadcasterEspotiConnectTest.kt | 21 ++- .../radio/data/RadioAdvertisingDataSource.kt | 18 ++- .../capullo/radio/data/RadioRepository.kt | 12 +- .../capullo/radio/ui/BroadcasterScreen.kt | 140 ++++++++++++++---- .../radio/viewmodels/BroadcasterViewModel.kt | 30 ++-- .../capullo/radio/BroadcasterViewModelTest.kt | 53 +++++++ gradle/libs.versions.toml | 6 +- 7 files changed, 224 insertions(+), 56 deletions(-) create mode 100644 app/src/test/java/tech/capullo/radio/BroadcasterViewModelTest.kt diff --git a/app/src/androidTest/java/tech/capullo/radio/RadioBroadcasterEspotiConnectTest.kt b/app/src/androidTest/java/tech/capullo/radio/RadioBroadcasterEspotiConnectTest.kt index e95217d..99733bd 100644 --- a/app/src/androidTest/java/tech/capullo/radio/RadioBroadcasterEspotiConnectTest.kt +++ b/app/src/androidTest/java/tech/capullo/radio/RadioBroadcasterEspotiConnectTest.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText @@ -11,6 +12,7 @@ import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.printToLog import org.junit.Rule import org.junit.Test +import tech.capullo.radio.data.RadioRepository.IPv4AddressesResult import tech.capullo.radio.snapcast.Client import tech.capullo.radio.ui.BroadcasterScreenContent import tech.capullo.radio.ui.model.AudioChannel @@ -33,6 +35,7 @@ class RadioBroadcasterEspotiConnectTest { BroadcasterScreenContent( uiState, onAudioChannelChange = { }, + onRefreshHostAddresses = { }, ) } composeTestRule.onRoot().printToLog("TAG") @@ -64,6 +67,7 @@ class RadioBroadcasterEspotiConnectTest { BroadcasterScreenContent( uiState, onAudioChannelChange = { }, + onRefreshHostAddresses = { }, ) } composeTestRule.onRoot().printToLog("TAG") @@ -75,13 +79,15 @@ class RadioBroadcasterEspotiConnectTest { } @Test - fun whenPlayerReadyState_showsRabioBroadcasterPlaybackScreen() { + fun whenPlayerReadyState_showsRadioBroadcasterPlaybackScreen() { // Given: UI state is EspotiPlayerReady - val hostAddresses = listOf("192.168.0.1", "10.0.0.2") + var ipv4AddressesResult: IPv4AddressesResult = IPv4AddressesResult.Success( + listOf("192.168.0.1", "10.0.0.2"), + ) val mockClients = emptyList() - val uiState = BroadcasterUiState.EspotiPlayerReady( - hostAddresses = hostAddresses, + var uiState = BroadcasterUiState.EspotiPlayerReady( + ipv4AddressesResult = ipv4AddressesResult, snapcastClients = mockClients, audioChannel = AudioChannel.STEREO, ) @@ -91,6 +97,7 @@ class RadioBroadcasterEspotiConnectTest { BroadcasterScreenContent( uiState, onAudioChannelChange = { }, + onRefreshHostAddresses = { }, ) } @@ -98,5 +105,11 @@ class RadioBroadcasterEspotiConnectTest { composeTestRule.onNodeWithText("Host Addresses:").assertIsDisplayed() composeTestRule.onNodeWithText("192.168.0.1").assertIsDisplayed() composeTestRule.onNodeWithText("10.0.0.2").assertIsDisplayed() + + val refreshIpAdressesButton = SemanticsMatcher.expectValue( + SemanticsProperties.Role, + Role.Button, + ) and hasContentDescription("Refresh ip addresses") + composeTestRule.onNode(refreshIpAdressesButton).assertIsEnabled() } } diff --git a/app/src/main/java/tech/capullo/radio/data/RadioAdvertisingDataSource.kt b/app/src/main/java/tech/capullo/radio/data/RadioAdvertisingDataSource.kt index 4e954dc..7812c8f 100644 --- a/app/src/main/java/tech/capullo/radio/data/RadioAdvertisingDataSource.kt +++ b/app/src/main/java/tech/capullo/radio/data/RadioAdvertisingDataSource.kt @@ -4,6 +4,9 @@ import android.content.Context import android.os.Build import android.provider.Settings import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.net.NetworkInterface import java.util.Collections import javax.inject.Inject @@ -11,15 +14,14 @@ import javax.inject.Inject class RadioAdvertisingDataSource @Inject constructor( @ApplicationContext private val applicationContext: Context, ) { - fun getInetAddresses(): List = - Collections.list(NetworkInterface.getNetworkInterfaces()).flatMap { networkInterface -> - Collections.list(networkInterface.inetAddresses).filter { inetAddress -> - inetAddress.hostAddress != null && - inetAddress.hostAddress?.takeIf { - it.indexOf(":") < 0 && !inetAddress.isLoopbackAddress - }?.let { true } ?: false - }.map { it.hostAddress!! } + // TODO: injected dispatcher + suspend fun getIPv4Addresses(): List = withContext(Dispatchers.Default) { + NetworkInterface.getNetworkInterfaces().toList().flatMap { networkInterface -> + networkInterface.inetAddresses.toList().filter { inetAddress -> + inetAddress.address.size == 4 && !inetAddress.isLoopbackAddress + }.map { it.hostAddress } } + } fun getDeviceName(): String = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { val deviceName = Settings.Global.getString( diff --git a/app/src/main/java/tech/capullo/radio/data/RadioRepository.kt b/app/src/main/java/tech/capullo/radio/data/RadioRepository.kt index ddc0ec6..9f53872 100644 --- a/app/src/main/java/tech/capullo/radio/data/RadioRepository.kt +++ b/app/src/main/java/tech/capullo/radio/data/RadioRepository.kt @@ -14,7 +14,17 @@ class RadioRepository @Inject constructor( fun getCacheDirPath(): String = pipeFileDataSource.getCacheDirPath() - fun getInetAddresses(): List = radioAdvertisingDataSource.getInetAddresses() + sealed class IPv4AddressesResult { + object Loading : IPv4AddressesResult() + data class Success(val addresses: List) : IPv4AddressesResult() + data class Error(val message: String?) : IPv4AddressesResult() + } + + suspend fun getIPv4Addresses(): IPv4AddressesResult = try { + IPv4AddressesResult.Success(radioAdvertisingDataSource.getIPv4Addresses()) + } catch (e: Exception) { + IPv4AddressesResult.Error(e.message) + } fun getDeviceName(): String = radioAdvertisingDataSource.getDeviceName() diff --git a/app/src/main/java/tech/capullo/radio/ui/BroadcasterScreen.kt b/app/src/main/java/tech/capullo/radio/ui/BroadcasterScreen.kt index a5a771a..191bc2e 100644 --- a/app/src/main/java/tech/capullo/radio/ui/BroadcasterScreen.kt +++ b/app/src/main/java/tech/capullo/radio/ui/BroadcasterScreen.kt @@ -1,6 +1,9 @@ package tech.capullo.radio.ui import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -13,8 +16,10 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon @@ -28,17 +33,20 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import tech.capullo.radio.R +import tech.capullo.radio.data.RadioRepository.IPv4AddressesResult import tech.capullo.radio.snapcast.Client import tech.capullo.radio.snapcast.ClientConfig import tech.capullo.radio.snapcast.Host @@ -59,6 +67,7 @@ fun BroadcasterScreen(viewModel: BroadcasterViewModel = hiltViewModel()) { BroadcasterScreenContent( uiState = uiState, onAudioChannelChange = viewModel::updateAudioChannel, + onRefreshHostAddresses = viewModel::refreshIPv4Addresses, ) } @@ -66,23 +75,25 @@ fun BroadcasterScreen(viewModel: BroadcasterViewModel = hiltViewModel()) { fun BroadcasterScreenContent( uiState: BroadcasterUiState, onAudioChannelChange: (AudioChannel) -> Unit, + onRefreshHostAddresses: () -> Unit, ) { - when (val state = uiState) { + when (uiState) { is BroadcasterUiState.EspotiPlayerReady -> { BroadcasterPlayback( - hostAddresses = state.hostAddresses, - snapcastClients = state.snapcastClients, - audioChannel = state.audioChannel, + ipv4AddressesResult = uiState.ipv4AddressesResult, + snapcastClients = uiState.snapcastClients, + audioChannel = uiState.audioChannel, + onRefreshHostAddresses = onRefreshHostAddresses, onAudioChannelChange = onAudioChannelChange, ) } is BroadcasterUiState.EspotiConnect -> { - if (state.isLoading) { + if (uiState.isLoading) { LoadingSessionScreen() } else { BroadcasterEspotiConnect( - deviceName = state.deviceName, + deviceName = uiState.deviceName, ) } } @@ -91,9 +102,10 @@ fun BroadcasterScreenContent( @OptIn(ExperimentalMaterial3Api::class) @Composable fun BroadcasterPlayback( - hostAddresses: List, + ipv4AddressesResult: IPv4AddressesResult, snapcastClients: List = emptyList(), audioChannel: AudioChannel, + onRefreshHostAddresses: () -> Unit, onAudioChannelChange: (AudioChannel) -> Unit, ) { var showChannelDialog by remember { mutableStateOf(false) } @@ -115,28 +127,7 @@ fun BroadcasterScreenContent( .fillMaxSize() .padding(innerPadding), ) { - Card( - modifier = Modifier - .padding(vertical = 4.dp, horizontal = 8.dp) - .fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), - shape = MaterialTheme.shapes.medium, - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "Host Addresses:", - style = Typography.bodyMedium, - ) - LazyColumn(modifier = Modifier.padding(vertical = 4.dp)) { - items(items = hostAddresses) { name -> - Text( - text = name, - style = Typography.titleLarge, - ) - } - } - } - } + IPv4AddressesCard(ipv4AddressesResult, onRefreshHostAddresses) SnapclientList(snapcastClients) } @@ -155,6 +146,74 @@ fun BroadcasterScreenContent( } } +@Composable +fun IPv4AddressesCard( + ipv4AddressesResult: IPv4AddressesResult, + onRefreshHostAddresses: () -> Unit, +) { + Card( + modifier = Modifier + .padding(vertical = 4.dp, horizontal = 8.dp) + .fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + shape = MaterialTheme.shapes.medium, + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Host Addresses:", + style = Typography.titleLarge, + ) + IconButton( + onClick = { + onRefreshHostAddresses() + }, + enabled = ipv4AddressesResult !is IPv4AddressesResult.Loading, + ) { + if (ipv4AddressesResult is IPv4AddressesResult.Loading) { + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + // LoadingIndicator(modifier = Modifier.size(32.dp)) + CircularWavyProgressIndicator() + } else { + Icon( + Icons.Filled.Refresh, + contentDescription = "Refresh ip addresses", + modifier = Modifier + .size(32.dp), + ) + } + } + } + + when (ipv4AddressesResult) { + is IPv4AddressesResult.Success -> { + LazyColumn(modifier = Modifier.padding(vertical = 4.dp)) { + items(items = ipv4AddressesResult.addresses) { address -> + Text( + text = address, + style = Typography.displaySmall, + ) + } + } + } + is IPv4AddressesResult.Error -> { + Text( + text = + ipv4AddressesResult.message + ?: "Error loading network interfaces", + style = Typography.displaySmall, + ) + } + else -> Unit + } + } + } +} + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun LoadingSessionScreen() { @@ -233,6 +292,22 @@ fun PreviewBroadcasterEspotiConnect() { } } +@Preview( + showBackground = true, + uiMode = UI_MODE_NIGHT_YES, + name = "PreviewLoadingIPv4AddressesCard", +) +@Preview(showBackground = true) +@Composable +fun PreviewLoadingIPv4AddressesCard() { + RadioTheme(schemeChoice = SchemeChoice.GREEN) { + IPv4AddressesCard( + ipv4AddressesResult = IPv4AddressesResult.Loading, + onRefreshHostAddresses = {}, + ) + } +} + @Preview( showBackground = true, uiMode = UI_MODE_NIGHT_YES, @@ -258,7 +333,9 @@ fun PreviewLoadingIndicator() { ) @Composable fun PreviewBroadcasterPlayback() { - val hostAddresses = listOf("192.168.0.1", "0.0.0.0", "100.10.14.7") + val ipv4AddressesResult = IPv4AddressesResult.Success( + listOf("192.168.0.1", "0.0.0.0", "100.10.14.7"), + ) val sampleClients = listOf( Client( @@ -334,10 +411,11 @@ fun PreviewBroadcasterPlayback() { RadioTheme(schemeChoice = SchemeChoice.GREEN) { BroadcasterPlayback( - hostAddresses = hostAddresses, + ipv4AddressesResult = ipv4AddressesResult, snapcastClients = sampleClients, audioChannel = AudioChannel.STEREO, onAudioChannelChange = { _: AudioChannel -> }, + onRefreshHostAddresses = {}, ) } } diff --git a/app/src/main/java/tech/capullo/radio/viewmodels/BroadcasterViewModel.kt b/app/src/main/java/tech/capullo/radio/viewmodels/BroadcasterViewModel.kt index 7e7dc9d..5e8acff 100644 --- a/app/src/main/java/tech/capullo/radio/viewmodels/BroadcasterViewModel.kt +++ b/app/src/main/java/tech/capullo/radio/viewmodels/BroadcasterViewModel.kt @@ -12,6 +12,7 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow @@ -19,6 +20,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import tech.capullo.radio.data.RadioRepository +import tech.capullo.radio.data.RadioRepository.IPv4AddressesResult import tech.capullo.radio.espoti.EspotiNsdManager import tech.capullo.radio.espoti.EspotiSessionRepository import tech.capullo.radio.services.RadioBroadcasterService @@ -29,22 +31,21 @@ import javax.inject.Inject sealed interface BroadcasterUiState { + data class EspotiConnect(val isLoading: Boolean, val deviceName: String) : BroadcasterUiState + data class EspotiPlayerReady( - val hostAddresses: List, + val ipv4AddressesResult: IPv4AddressesResult, val snapcastClients: List, val audioChannel: AudioChannel, ) : BroadcasterUiState - - data class EspotiConnect(val isLoading: Boolean, val deviceName: String) : - BroadcasterUiState } private data class BroadcasterViewModelState( val isPlaybackReady: Boolean = false, val isLoading: Boolean = true, val deviceName: String = "", + val ipv4AddressesResult: IPv4AddressesResult = IPv4AddressesResult.Loading, val snapcastClients: List = emptyList(), - val hostAddresses: List = emptyList(), val audioChannel: AudioChannel = AudioChannel.STEREO, ) { /** @@ -58,7 +59,7 @@ private data class BroadcasterViewModelState( ) } else { BroadcasterUiState.EspotiPlayerReady( - hostAddresses = hostAddresses, + ipv4AddressesResult = ipv4AddressesResult, snapcastClients = snapcastClients, audioChannel = audioChannel, ) @@ -95,8 +96,8 @@ class BroadcasterViewModel @Inject constructor( isPlaybackReady = false, isLoading = true, deviceName = repository.getDeviceName(), + ipv4AddressesResult = IPv4AddressesResult.Loading, snapcastClients = emptyList(), - hostAddresses = emptyList(), audioChannel = AudioChannel.STEREO, ), ) @@ -127,13 +128,12 @@ class BroadcasterViewModel @Inject constructor( // TODO: (potentially) display a screen saying the sessions is established // and the player is loading if (!isLoading) { - val hostAddresses = repository.getInetAddresses() viewModelState.value = viewModelState.value.copy( isPlaybackReady = true, - hostAddresses = hostAddresses, snapcastClients = snapcastClients.value, ) + refreshIPv4Addresses() } } } @@ -218,6 +218,18 @@ class BroadcasterViewModel @Inject constructor( mService?.updateAudioChannel(channel) } + fun refreshIPv4Addresses() { + viewModelState.value = viewModelState.value.copy( + ipv4AddressesResult = IPv4AddressesResult.Loading, + ) + viewModelScope.launch { + delay(500) // make the loading/result transition last for at least 0.5 sec + val ipv4AddressesResult = repository.getIPv4Addresses() + viewModelState.value = + viewModelState.value.copy(ipv4AddressesResult = ipv4AddressesResult) + } + } + fun unbindBroadcasterService() { if (mBound) { applicationContext.unbindService(serviceConnection) diff --git a/app/src/test/java/tech/capullo/radio/BroadcasterViewModelTest.kt b/app/src/test/java/tech/capullo/radio/BroadcasterViewModelTest.kt new file mode 100644 index 0000000..96036c2 --- /dev/null +++ b/app/src/test/java/tech/capullo/radio/BroadcasterViewModelTest.kt @@ -0,0 +1,53 @@ +package tech.capullo.radio + +import android.content.Context +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import tech.capullo.radio.data.PipeFileDataSource +import tech.capullo.radio.data.RadioAdvertisingDataSource +import tech.capullo.radio.data.RadioRepository +import java.net.SocketException + +class BroadcasterViewModelTest { + + @Test + fun testGetIPv4AddressesSuccess() = runTest { + val mockContext = mockk() + + val radioAdvertisingDataSource = RadioAdvertisingDataSource(mockContext) + val pipeFileDataSource = mockk() + val radioRepository = RadioRepository(pipeFileDataSource, radioAdvertisingDataSource) + + val iPv4AddressesResult = radioRepository.getIPv4Addresses() + + assert(iPv4AddressesResult is RadioRepository.IPv4AddressesResult.Success) + + // If the cast were to fail (i.e. iPv4AddressesResult is not .Success) + // the test would fail here as well + (iPv4AddressesResult as RadioRepository.IPv4AddressesResult.Success).run { + assert(addresses.isNotEmpty()) + } + } + + @Test + fun testGetIPv4AddressesError() = runTest { + val socketExceptionTestMessage = "socketExceptionTestMessage " + + val radioAdvertisingDataSource = mockk() + coEvery { radioAdvertisingDataSource.getIPv4Addresses() } throws + SocketException(socketExceptionTestMessage) + + val pipeFileDataSource = mockk() + val radioRepository = RadioRepository(pipeFileDataSource, radioAdvertisingDataSource) + + val iPv4AddressesResult = radioRepository.getIPv4Addresses() + + assert(iPv4AddressesResult is RadioRepository.IPv4AddressesResult.Error) + assert( + (iPv4AddressesResult as RadioRepository.IPv4AddressesResult.Error).message == + socketExceptionTestMessage, + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ca9fbb9..eb6be56 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ activityCompose = "1.11.0" agp = "8.13.0" androidx-junit = "1.3.0" composeLatest = "1.7.8" -composeBom = "2025.10.00" +composeBom = "2025.10.01" datastorePreferences = "1.1.7" espressoCore = "3.7.0" hiltViewmodelCompose = "1.3.0" @@ -19,10 +19,10 @@ ksp = "2.2.20-2.0.3" ktor = "3.3.1" libSnapcastAndroid = "63f3e4c14c" lifecycleRuntimeKtx = "2.9.4" -material3 = "1.5.0-alpha06" +material3 = "1.5.0-alpha07" media = "1.7.1" mockk = "1.14.6" -navigation3 = "1.0.0-alpha11" +navigation3 = "1.0.0-beta01" startupRuntime = "1.2.0" spotless = "8.0.0"