From 98c074068d5852eb5d9e2fc56362ac22eb0e75e9 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Thu, 21 Nov 2024 10:07:22 +0200 Subject: [PATCH 1/2] MS-803 Add static network information to overview page --- .../dashboard/src/main/AndroidManifest.xml | 2 + .../TroubleshootingPagerAdapter.kt | 1 - .../overview/OverviewFragment.kt | 3 ++ .../overview/OverviewViewModel.kt | 14 ++++-- .../CollectNetworkInformationUseCase.kt | 50 +++++++++++++++++++ .../fragment_troubleshooting_overview.xml | 21 +++++++- .../overview/OverviewViewModelTest.kt | 9 ++++ 7 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/CollectNetworkInformationUseCase.kt diff --git a/feature/dashboard/src/main/AndroidManifest.xml b/feature/dashboard/src/main/AndroidManifest.xml index 8227460871..3127b98c6c 100644 --- a/feature/dashboard/src/main/AndroidManifest.xml +++ b/feature/dashboard/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + @@ -24,13 +27,14 @@ internal class OverviewViewModel @Inject constructor( get() = _licenseStates private val _licenseStates = MutableLiveData("") + val networkStates: LiveData + get() = _networkStates + private val _networkStates = MutableLiveData("") + fun collectData() { _projectIds.postValue(collectIds()) - - viewModelScope.launch { - _licenseStates.postValue(collectLicenseStates()) - } + viewModelScope.launch { _licenseStates.postValue(collectLicenseStates()) } + _networkStates.postValue(collectNetworkInformation()) } - } diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/CollectNetworkInformationUseCase.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/CollectNetworkInformationUseCase.kt new file mode 100644 index 0000000000..605a6aaa62 --- /dev/null +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/CollectNetworkInformationUseCase.kt @@ -0,0 +1,50 @@ +package com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import com.simprints.infra.uibase.annotations.ExcludedFromGeneratedTestCoverageReports +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +@ExcludedFromGeneratedTestCoverageReports("Mostly system calls") +class CollectNetworkInformationUseCase @Inject constructor( + @ApplicationContext private val context: Context, +) { + + operator fun invoke(): String { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val networkCapabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + ?: return "No network capabilities available" + + if (!networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) { + return "No internet connection" + } + + val isWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + val connectionType = if (isWifi) "Connected to Wi-Fi" else "Connected to Cellular" + val connectionValidated = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + + return """ + $connectionType + Strength: ${getSignalStrength(networkCapabilities)} + Internet access: $connectionValidated)} + Bandwidth up (kbps): ${networkCapabilities.linkUpstreamBandwidthKbps} + Bandwidth down (kbps): ${networkCapabilities.linkDownstreamBandwidthKbps} + """.trimIndent() + } + + private fun getSignalStrength(networkCapabilities: NetworkCapabilities): String = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) "N/A" + else { + val strength = networkCapabilities.signalStrength + val description = when { + strength >= -50 -> "Excellent" + strength >= -70 -> "Good" + strength >= -80 -> "Fair" + else -> "Weak" + } + "$strength ($description)" + } +} diff --git a/feature/dashboard/src/main/res/layout/fragment_troubleshooting_overview.xml b/feature/dashboard/src/main/res/layout/fragment_troubleshooting_overview.xml index af6377c0b8..4ea63492bc 100644 --- a/feature/dashboard/src/main/res/layout/fragment_troubleshooting_overview.xml +++ b/feature/dashboard/src/main/res/layout/fragment_troubleshooting_overview.xml @@ -15,7 +15,7 @@ style="@style/Text.Body1" android:layout_width="match_parent" android:layout_height="wrap_content" - android:lineSpacingMultiplier="1.5" + android:lineSpacingMultiplier="1.25" android:textIsSelectable="true" tools:text="Device ID:\nProject ID:\nUser ID:" /> @@ -38,6 +38,25 @@ android:textIsSelectable="true" tools:text="@tools:sample/lorem" /> + + + + diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/OverviewViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/OverviewViewModelTest.kt index 481203bfbd..36d5056328 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/OverviewViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/OverviewViewModelTest.kt @@ -6,6 +6,7 @@ import com.jraska.livedata.test import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase.CollectIdsUseCase import com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase.CollectLicenceStatesUseCase +import com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase.CollectNetworkInformationUseCase import com.simprints.infra.authstore.AuthStore import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.MockKAnnotations @@ -31,6 +32,10 @@ class OverviewViewModelTest { @MockK private lateinit var collectLicencesUseCase: CollectLicenceStatesUseCase + @MockK + private lateinit var collectNetworkInformationUseCase: CollectNetworkInformationUseCase + + private lateinit var viewModel: OverviewViewModel @Before @@ -40,6 +45,7 @@ class OverviewViewModelTest { viewModel = OverviewViewModel( collectIds = collectIdsUseCase, collectLicenseStates = collectLicencesUseCase, + collectNetworkInformation = collectNetworkInformationUseCase ) } @@ -47,13 +53,16 @@ class OverviewViewModelTest { fun `sets when data collected`() = runTest { every { collectIdsUseCase() } returns "ids" coEvery { collectLicencesUseCase() } returns "licences" + every { collectNetworkInformationUseCase() } returns "network" val idsText = viewModel.projectIds.test() val licenceText = viewModel.licenseStates.test() + val networkText = viewModel.networkStates.test() viewModel.collectData() assertThat(idsText.value()).isNotEmpty() assertThat(licenceText.value()).isNotEmpty() + assertThat(networkText.value()).isNotEmpty() } } From cdd069d6de0c482779c3d4838ea4e4e853710790 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Thu, 21 Nov 2024 14:58:06 +0200 Subject: [PATCH 2/2] MS-803 Implement server ping button to validate network connection --- .../overview/OverviewFragment.kt | 15 ++++ .../overview/OverviewViewModel.kt | 12 +++ .../overview/usecase/PingServerUseCase.kt | 66 +++++++++++++++ .../fragment_troubleshooting_overview.xml | 21 +++++ .../overview/OverviewViewModelTest.kt | 30 ++++++- .../overview/usecase/PingServerUseCaseTest.kt | 83 +++++++++++++++++++ .../infra/network/url/BaseUrlProvider.kt | 2 +- 7 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/PingServerUseCase.kt create mode 100644 feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/PingServerUseCaseTest.kt diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/OverviewFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/OverviewFragment.kt index 27209d884f..7d6e95f758 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/OverviewFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/OverviewFragment.kt @@ -6,6 +6,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import com.simprints.feature.dashboard.R import com.simprints.feature.dashboard.databinding.FragmentTroubleshootingOverviewBinding +import com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase.PingServerUseCase.PingResult import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint @@ -29,6 +30,20 @@ class OverviewFragment : Fragment(R.layout.fragment_troubleshooting_overview) { } viewModel.collectData() + + viewModel.pingResult.observe(viewLifecycleOwner) { + binding.troubleshootOverviewPingResult.text = when (it) { + PingResult.NotDone -> "Check not done yet" + PingResult.InProgress -> "Calling server" + is PingResult.Success -> "Success in ${it.message}" + is PingResult.Failure -> "Failed: ${it.message}" + } + binding.troubleshootOverviewPing.isEnabled = it != PingResult.InProgress + } + binding.troubleshootOverviewPing.setOnClickListener { + viewModel.pingServer() + } + } } diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/OverviewViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/OverviewViewModel.kt index d85f7b469e..4b0bd49644 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/OverviewViewModel.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/OverviewViewModel.kt @@ -4,9 +4,11 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase.PingServerUseCase.PingResult import com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase.CollectIdsUseCase import com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase.CollectLicenceStatesUseCase import com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase.CollectNetworkInformationUseCase +import com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase.PingServerUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -17,6 +19,7 @@ internal class OverviewViewModel @Inject constructor( private val collectIds: CollectIdsUseCase, private val collectLicenseStates: CollectLicenceStatesUseCase, private val collectNetworkInformation: CollectNetworkInformationUseCase, + private val doServerPing: PingServerUseCase, ) : ViewModel() { val projectIds: LiveData @@ -31,10 +34,19 @@ internal class OverviewViewModel @Inject constructor( get() = _networkStates private val _networkStates = MutableLiveData("") + val pingResult: LiveData + get() = _pingResult + private val _pingResult = MutableLiveData(PingResult.NotDone) + fun collectData() { _projectIds.postValue(collectIds()) viewModelScope.launch { _licenseStates.postValue(collectLicenseStates()) } _networkStates.postValue(collectNetworkInformation()) } + fun pingServer() { + viewModelScope.launch { + doServerPing().collect { _pingResult.postValue(it) } + } + } } diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/PingServerUseCase.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/PingServerUseCase.kt new file mode 100644 index 0000000000..30cebd3e7a --- /dev/null +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/PingServerUseCase.kt @@ -0,0 +1,66 @@ +package com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase + +import com.simprints.core.DispatcherIO +import com.simprints.infra.network.url.BaseUrlProvider +import com.simprints.infra.uibase.annotations.ExcludedFromGeneratedTestCoverageReports +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import java.net.HttpURLConnection +import java.net.URL +import javax.inject.Inject +import kotlin.time.measureTimedValue + +internal class PingServerUseCase @Inject constructor( + @DispatcherIO private val dispatcherIO: CoroutineDispatcher, + private val baseUrlProvider: BaseUrlProvider, + private val urlFactory: UrlFactory, +) { + + /** + * Does the most bare-bones version of HTTP request to access the root URL + * of the last configured BFSID server. Any response code below 500 is considered + * a success as it means that the server has been reached. + */ + operator fun invoke(): Flow = flow { + emit(PingResult.InProgress) + + val urlToPing = urlFactory(baseUrlProvider.getApiBaseUrlPrefix()) + val executionTime = measureTimedValue { + try { + val connection = urlToPing.openConnection().let { it as HttpURLConnection } + connection.connect() + + val response = connection.responseCode + val result = if (response < HttpURLConnection.HTTP_INTERNAL_ERROR) "" else "$response" + connection.disconnect() + + result + } catch (e: Throwable) { + e.message ?: e.localizedMessage ?: "Error during request" + } + } + if (executionTime.value.isEmpty()) { + emit(PingResult.Success("${executionTime.duration}")) + } else { + emit(PingResult.Failure(executionTime.value)) + } + }.flowOn(dispatcherIO) + + internal sealed class PingResult { + + data object NotDone : PingResult() + data object InProgress : PingResult() + data class Success(val message: String) : PingResult() + data class Failure(val message: String) : PingResult() + } + + @ExcludedFromGeneratedTestCoverageReports( + "Wrapper to allow substituting the URL constructor for testing purposes" + ) + internal class UrlFactory @Inject constructor() { + + operator fun invoke(baseUrl: String) = URL(baseUrl) + } +} diff --git a/feature/dashboard/src/main/res/layout/fragment_troubleshooting_overview.xml b/feature/dashboard/src/main/res/layout/fragment_troubleshooting_overview.xml index 4ea63492bc..2fd73752ef 100644 --- a/feature/dashboard/src/main/res/layout/fragment_troubleshooting_overview.xml +++ b/feature/dashboard/src/main/res/layout/fragment_troubleshooting_overview.xml @@ -57,6 +57,27 @@ android:textIsSelectable="true" tools:text="@tools:sample/lorem" /> + + + + + + + diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/OverviewViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/OverviewViewModelTest.kt index 36d5056328..aca76d1630 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/OverviewViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/OverviewViewModelTest.kt @@ -3,16 +3,17 @@ package com.simprints.feature.dashboard.settings.troubleshooting.overview import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.google.common.truth.Truth.assertThat import com.jraska.livedata.test -import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase.CollectIdsUseCase import com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase.CollectLicenceStatesUseCase import com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase.CollectNetworkInformationUseCase -import com.simprints.infra.authstore.AuthStore +import com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase.PingServerUseCase +import com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase.PingServerUseCase.PingResult import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.every import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule @@ -35,6 +36,8 @@ class OverviewViewModelTest { @MockK private lateinit var collectNetworkInformationUseCase: CollectNetworkInformationUseCase + @MockK + private lateinit var pingServerUseCase: PingServerUseCase private lateinit var viewModel: OverviewViewModel @@ -45,7 +48,8 @@ class OverviewViewModelTest { viewModel = OverviewViewModel( collectIds = collectIdsUseCase, collectLicenseStates = collectLicencesUseCase, - collectNetworkInformation = collectNetworkInformationUseCase + collectNetworkInformation = collectNetworkInformationUseCase, + doServerPing = pingServerUseCase, ) } @@ -58,11 +62,31 @@ class OverviewViewModelTest { val idsText = viewModel.projectIds.test() val licenceText = viewModel.licenseStates.test() val networkText = viewModel.networkStates.test() + val pingResult = viewModel.pingResult.test() viewModel.collectData() assertThat(idsText.value()).isNotEmpty() assertThat(licenceText.value()).isNotEmpty() assertThat(networkText.value()).isNotEmpty() + assertThat(pingResult.value()).isInstanceOf(PingResult.NotDone::class.java) + } + + @Test + fun `propagates server ping result`() = runTest { + val pingResult = viewModel.pingResult.test() + + every { pingServerUseCase.invoke() } returns flowOf( + PingResult.InProgress, + PingResult.Success("message"), + ) + + viewModel.pingServer() + + assertThat(pingResult.valueHistory()).containsExactly( + PingResult.NotDone, + PingResult.InProgress, + PingResult.Success("message"), + ) } } diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/PingServerUseCaseTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/PingServerUseCaseTest.kt new file mode 100644 index 0000000000..4e470b7eff --- /dev/null +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/PingServerUseCaseTest.kt @@ -0,0 +1,83 @@ +package com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.google.common.truth.Truth.assertThat +import com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase.PingServerUseCase.PingResult +import com.simprints.infra.network.url.BaseUrlProvider +import com.simprints.testtools.common.coroutines.TestCoroutineRule +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.net.HttpURLConnection +import java.net.URL + +class PingServerUseCaseTest { + + @get:Rule + val rule = InstantTaskExecutorRule() + + @get:Rule + val testCoroutineRule = TestCoroutineRule() + + @MockK + private lateinit var baseUrlProvider: BaseUrlProvider + + @MockK + private lateinit var connection: HttpURLConnection + + @MockK + private lateinit var urlFactory: PingServerUseCase.UrlFactory + + + private lateinit var useCase: PingServerUseCase + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + + every { baseUrlProvider.getApiBaseUrlPrefix() } returns "baseUrl" + every { urlFactory.invoke(any()) } returns mockk { + every { openConnection() } returns connection + } + + useCase = PingServerUseCase( + dispatcherIO = testCoroutineRule.testCoroutineDispatcher, + baseUrlProvider = baseUrlProvider, + urlFactory = urlFactory, + ) + } + + @Test + fun `returns success when response is 404`() = runTest { + every { connection.responseCode } returns 404 + val result = useCase().toList() + + assertThat(result.first()).isInstanceOf(PingResult.InProgress::class.java) + assertThat(result.last()).isInstanceOf(PingResult.Success::class.java) + } + + @Test + fun `returns failure when response is 502`() = runTest { + every { connection.responseCode } returns 502 + val result = useCase().toList() + + assertThat(result.first()).isInstanceOf(PingResult.InProgress::class.java) + assertThat(result.last()).isInstanceOf(PingResult.Failure::class.java) + } + + @Test + fun `returns failure when connection throws exception`() = runTest { + every { connection.connect() } throws RuntimeException("reason") + val result = useCase().toList() + + assertThat(result.first()).isInstanceOf(PingResult.InProgress::class.java) + assertThat(result.last()).isInstanceOf(PingResult.Failure::class.java) + } + +} diff --git a/infra/network/src/main/java/com/simprints/infra/network/url/BaseUrlProvider.kt b/infra/network/src/main/java/com/simprints/infra/network/url/BaseUrlProvider.kt index 06475a448a..d72fab7a6a 100644 --- a/infra/network/src/main/java/com/simprints/infra/network/url/BaseUrlProvider.kt +++ b/infra/network/src/main/java/com/simprints/infra/network/url/BaseUrlProvider.kt @@ -1,6 +1,6 @@ package com.simprints.infra.network.url -internal interface BaseUrlProvider { +interface BaseUrlProvider { fun getApiBaseUrl(): String fun getApiBaseUrlPrefix(): String fun setApiBaseUrl(apiBaseUrl: String?)