diff --git a/feature/dashboard/build.gradle.kts b/feature/dashboard/build.gradle.kts index fccff5a847..29e9254473 100644 --- a/feature/dashboard/build.gradle.kts +++ b/feature/dashboard/build.gradle.kts @@ -18,6 +18,8 @@ dependencies { implementation(project(":infra:auth-store")) implementation(project(":infra:auth-logic")) implementation(project(":infra:recent-user-activity")) + implementation(project(":infra:license")) + implementation(project(":feature:consent")) implementation(project(":feature:login")) diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/requestlogin/RequestLoginFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/requestlogin/RequestLoginFragment.kt index fac06c5f9d..c2d02a7485 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/requestlogin/RequestLoginFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/requestlogin/RequestLoginFragment.kt @@ -4,12 +4,14 @@ import android.os.Bundle import android.view.View import androidx.appcompat.app.AlertDialog import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.simprints.core.DeviceID import com.simprints.core.PackageVersionName import com.simprints.feature.dashboard.R import com.simprints.feature.dashboard.databinding.FragmentRequestLoginBinding +import com.simprints.feature.dashboard.settings.troubleshooting.AutoResettingClickCounter import com.simprints.infra.authstore.AuthStore import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint @@ -35,6 +37,10 @@ internal class RequestLoginFragment : Fragment(R.layout.fragment_request_login) private var wasLogoutReasonDisplayed = false + // Requires so many clicks in short window to make it less likely to open on accident + private val clickCounter = AutoResettingClickCounter(requiredClicks = 10) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) wasLogoutReasonDisplayed = savedInstanceState?.getBoolean(KEY_WAS_LOGOUT_REASON_DISPLAYED) ?: false @@ -42,6 +48,12 @@ internal class RequestLoginFragment : Fragment(R.layout.fragment_request_login) binding.simprintsIdVersionTextView.text = String.format(getString(IDR.string.dashboard_request_login_simprints_version), packageVersionName) args.logoutReason?.takeIf { !wasLogoutReasonDisplayed }?.run(::displayLogoutReasonDialog) + + binding.loginImageViewLogo.setOnClickListener { + if (clickCounter.handleClick(lifecycleScope)) { + findNavController().navigate(R.id.action_requestLoginFragment_to_troubleshootingFragment) + } + } } override fun onSaveInstanceState(outState: Bundle) { diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/about/AboutFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/about/AboutFragment.kt index 8ecb3b8677..198a194391 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/about/AboutFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/about/AboutFragment.kt @@ -13,6 +13,7 @@ import androidx.preference.PreferenceFragmentCompat import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.simprints.core.DeviceID import com.simprints.core.PackageVersionName +import com.simprints.core.livedata.LiveDataEventObserver import com.simprints.core.livedata.LiveDataEventWithContentObserver import com.simprints.feature.dashboard.R import com.simprints.feature.dashboard.databinding.FragmentSettingsAboutBinding @@ -55,7 +56,7 @@ internal class AboutFragment : PreferenceFragmentCompat() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ): View { val settingsView = inflater.inflate(R.layout.fragment_settings_about, container, false) as ViewGroup @@ -89,6 +90,21 @@ internal class AboutFragment : PreferenceFragmentCompat() { } findNavController().navigate(destination) }) + viewModel.openTroubleshooting.observe(viewLifecycleOwner, LiveDataEventObserver { + showPasswordIfRequired(ACTION_TROUBLESHOOTING) { openTroubleshooting() } + }) + + SettingsPasswordDialogFragment.registerForResult( + fragmentManager = childFragmentManager, + lifecycleOwner = this, + onSuccess = { action -> + viewModel.unlockSettings() + when (action) { + ACTION_LOGOUT -> viewModel.processLogoutRequest() + ACTION_TROUBLESHOOTING -> openTroubleshooting() + } + }, + ) } private fun initLayout() { @@ -105,20 +121,16 @@ internal class AboutFragment : PreferenceFragmentCompat() { true } } + getSyncAndSearchConfigurationPreference()?.setOnPreferenceClickListener { + viewModel.troubleshootingClick() + true + } + getLogoutPreference()?.setOnPreferenceClickListener { activity?.runOnUiThread { val password = viewModel.settingsLocked.value?.getNullablePassword() if (password != null) { - SettingsPasswordDialogFragment.registerForResult( - fragmentManager = childFragmentManager, - lifecycleOwner = this@AboutFragment, - onSuccess = { viewModel.processLogoutRequest() } - ) - SettingsPasswordDialogFragment.newInstance( - title = IDR.string.dashboard_password_lock_title_logout, - passwordToMatch = password, - - ).show(childFragmentManager, SettingsPasswordDialogFragment.TAG) + showPasswordIfRequired(ACTION_LOGOUT) { viewModel.processLogoutRequest() } } else { confirmationDialogForLogout.show() } @@ -127,6 +139,10 @@ internal class AboutFragment : PreferenceFragmentCompat() { } } + private fun openTroubleshooting() { + findNavController().navigate(R.id.action_aboutFragment_to_troubleshootingFragment) + } + private fun getAppVersionPreference(): Preference? = findPreference(getString(R.string.preference_app_version_key)) @@ -144,4 +160,20 @@ internal class AboutFragment : PreferenceFragmentCompat() { private fun String.lowerCaseCapitalized() = lowercase(Locale.getDefault()).replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + + private fun showPasswordIfRequired(action: String, cb: () -> Unit) { + val password = viewModel.settingsLocked.value?.getNullablePassword() + if (password != null) { + SettingsPasswordDialogFragment.newInstance( + passwordToMatch = password, + action = action, + ).show(childFragmentManager, SettingsPasswordDialogFragment.TAG) + } else cb() + } + + companion object { + + private const val ACTION_LOGOUT = "logout" + private const val ACTION_TROUBLESHOOTING = "troubleshooting" + } } diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/about/AboutViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/about/AboutViewModel.kt index 8d03b9f05d..8b3277942d 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/about/AboutViewModel.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/about/AboutViewModel.kt @@ -4,9 +4,11 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.simprints.core.livedata.LiveDataEvent import com.simprints.core.livedata.LiveDataEventWithContent import com.simprints.core.livedata.send import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase +import com.simprints.feature.dashboard.settings.troubleshooting.AutoResettingClickCounter import com.simprints.infra.config.store.models.GeneralConfiguration import com.simprints.infra.config.store.models.SettingsPasswordConfig import com.simprints.infra.config.store.models.canSyncDataToSimprints @@ -43,15 +45,26 @@ internal class AboutViewModel @Inject constructor( get() = _settingsLocked private val _settingsLocked = MutableLiveData(SettingsPasswordConfig.NotSet) + val logoutDestinationEvent: LiveData> get() = _logoutDestinationEvent private val _logoutDestinationEvent = MutableLiveData>() + val openTroubleshooting: LiveData + get() = _openTroubleshooting + private val _openTroubleshooting = MutableLiveData() + + private var troubleshootingClickCounter = AutoResettingClickCounter() + init { load() } + fun unlockSettings() { + _settingsLocked.postValue(SettingsPasswordConfig.Unlocked) + } + fun processLogoutRequest() { viewModelScope.launch { val logoutDestination = @@ -85,4 +98,9 @@ internal class AboutViewModel @Inject constructor( _settingsLocked.postValue(configuration.general.settingsPassword) } + fun troubleshootingClick() { + if (troubleshootingClickCounter.handleClick(viewModelScope)) { + _openTroubleshooting.send() + } + } } diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/AutoResettingClickCounter.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/AutoResettingClickCounter.kt new file mode 100644 index 0000000000..472da85b8d --- /dev/null +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/AutoResettingClickCounter.kt @@ -0,0 +1,42 @@ +package com.simprints.feature.dashboard.settings.troubleshooting + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicInteger + +internal class AutoResettingClickCounter( + private val requiredClicks: Int = DEFAULT_CLICKS_FOR_TROUBLESHOOTING, + private val resetDelayMs: Long = DEFAULT_CLICK_COUNTER_RESET_MS, +) { + + private var counterReset: Job? = null + private var counter: AtomicInteger = AtomicInteger(0) + + /** + * Returns true if counter has reached required clicks. + * + * Counter gets reset after the delay has passed. + */ + fun handleClick(resetScope: CoroutineScope): Boolean { + counter.incrementAndGet() + if (counter.compareAndSet(requiredClicks, 0)) { + return true + } else { + counterReset?.cancel() + counterReset = resetScope.launch { + delay(resetDelayMs) + if (isActive) counter.set(0) + } + return false + } + } + + companion object { + + private const val DEFAULT_CLICKS_FOR_TROUBLESHOOTING = 5 + private const val DEFAULT_CLICK_COUNTER_RESET_MS = 1000L + } +} diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/StubFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/StubFragment.kt new file mode 100644 index 0000000000..c32f66d0e5 --- /dev/null +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/StubFragment.kt @@ -0,0 +1,7 @@ +package com.simprints.feature.dashboard.settings.troubleshooting + +import androidx.fragment.app.Fragment +import com.simprints.feature.dashboard.R + +// TODO remove once few tab fragments are implemented +class StubFragment : Fragment(R.layout.fragment_troubleshooting_stub) diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/TroubleshootingFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/TroubleshootingFragment.kt new file mode 100644 index 0000000000..ad18f5b6fd --- /dev/null +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/TroubleshootingFragment.kt @@ -0,0 +1,32 @@ +package com.simprints.feature.dashboard.settings.troubleshooting + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import com.google.android.material.tabs.TabLayoutMediator +import com.simprints.feature.dashboard.R +import com.simprints.feature.dashboard.databinding.FragmentTroubleshootingBinding +import com.simprints.infra.uibase.viewbinding.viewBinding +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +internal class TroubleshootingFragment : Fragment(R.layout.fragment_troubleshooting) { + + private val binding by viewBinding(FragmentTroubleshootingBinding::bind) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.troubleshootingToolbar.setNavigationOnClickListener { + findNavController().popBackStack() + } + + val adapter = TroubleshootingPagerAdapter(requireActivity()) + binding.troubleshootingPager.adapter = adapter + + TabLayoutMediator(binding.troubleshootingTabs, binding.troubleshootingPager) { tab, position -> + tab.text = TroubleshootingPagerAdapter.Tabs.entries[position].title + }.attach() + } +} diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/TroubleshootingPagerAdapter.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/TroubleshootingPagerAdapter.kt new file mode 100644 index 0000000000..bc354ee938 --- /dev/null +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/TroubleshootingPagerAdapter.kt @@ -0,0 +1,30 @@ +package com.simprints.feature.dashboard.settings.troubleshooting + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.simprints.feature.dashboard.settings.troubleshooting.overview.OverviewFragment +import com.simprints.infra.uibase.annotations.ExcludedFromGeneratedTestCoverageReports + +@ExcludedFromGeneratedTestCoverageReports("UI code") +internal class TroubleshootingPagerAdapter( + fragmentActivity: FragmentActivity, +) : FragmentStateAdapter(fragmentActivity) { + + override fun getItemCount(): Int = Tabs.entries.size + + override fun createFragment(position: Int): Fragment = Tabs.entries[position].factory() + + internal enum class Tabs( + val title: String, + val factory: () -> Fragment, + ) { + + // TODO Replace stub fragments with proper ones + Overview("Overview", { OverviewFragment() }), + Network("Network", { StubFragment() }), + Events("Events", { StubFragment() }), + Workers("Worker log", { StubFragment() }), + ; + } +} 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 new file mode 100644 index 0000000000..58d367eb40 --- /dev/null +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/OverviewFragment.kt @@ -0,0 +1,31 @@ +package com.simprints.feature.dashboard.settings.troubleshooting.overview + +import android.os.Bundle +import android.view.View +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.infra.uibase.viewbinding.viewBinding +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class OverviewFragment : Fragment(R.layout.fragment_troubleshooting_overview) { + + private val viewModel by viewModels() + private val binding by viewBinding(FragmentTroubleshootingOverviewBinding::bind) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel.projectIds.observe(viewLifecycleOwner) { + binding.troubleshootOverviewIds.text = it.orEmpty() + } + viewModel.licenseStates.observe(viewLifecycleOwner) { + binding.troubleshootOverviewLicences.text = it.ifBlank { "No licenses found" } + } + + viewModel.collectData() + } + +} 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 new file mode 100644 index 0000000000..8b0bada03f --- /dev/null +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/OverviewViewModel.kt @@ -0,0 +1,36 @@ +package com.simprints.feature.dashboard.settings.troubleshooting.overview + +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.CollectIdsUseCase +import com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase.CollectLicenceStatesUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class OverviewViewModel @Inject constructor( + private val collectIds: CollectIdsUseCase, + private val collectLicenseStates: CollectLicenceStatesUseCase, +) : ViewModel() { + + val projectIds: LiveData + get() = _projectIds + private val _projectIds = MutableLiveData("") + + val licenseStates: LiveData + get() = _licenseStates + private val _licenseStates = MutableLiveData("") + + fun collectData() { + _projectIds.postValue(collectIds()) + + viewModelScope.launch { + _licenseStates.postValue(collectLicenseStates()) + } + } + + +} diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/CollectIdsUseCase.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/CollectIdsUseCase.kt new file mode 100644 index 0000000000..9a811f0f81 --- /dev/null +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/CollectIdsUseCase.kt @@ -0,0 +1,17 @@ +package com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase + +import com.simprints.core.DeviceID +import com.simprints.infra.authstore.AuthStore +import javax.inject.Inject + +class CollectIdsUseCase @Inject constructor( + @DeviceID private val deviceID: String, + private val authStore: AuthStore, +) { + + operator fun invoke() = """ + Device ID: $deviceID + Project ID: ${authStore.signedInProjectId} + User ID: ${authStore.signedInUserId?.value.orEmpty()} + """.trimIndent() +} diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/CollectLicenceStatesUseCase.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/CollectLicenceStatesUseCase.kt new file mode 100644 index 0000000000..cb1f84ab86 --- /dev/null +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/CollectLicenceStatesUseCase.kt @@ -0,0 +1,15 @@ +package com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase + +import com.simprints.infra.license.LicenseRepository +import com.simprints.infra.license.models.Vendor +import javax.inject.Inject + +class CollectLicenceStatesUseCase @Inject constructor( + private val licenseRepository: LicenseRepository, +) { + + suspend operator fun invoke(): String = Vendor.listAll() + .map { it to licenseRepository.getCachedLicense(it) } + .filter { it.second != null } + .joinToString(separator = "\n\n") { (vendor, expiration) -> "${vendor.value} ${expiration?.version?.value}\n- Expires on ${expiration?.expiration}" } +} diff --git a/feature/dashboard/src/main/res/layout/fragment_troubleshooting.xml b/feature/dashboard/src/main/res/layout/fragment_troubleshooting.xml new file mode 100644 index 0000000000..309e76160f --- /dev/null +++ b/feature/dashboard/src/main/res/layout/fragment_troubleshooting.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + diff --git a/feature/dashboard/src/main/res/layout/fragment_troubleshooting_overview.xml b/feature/dashboard/src/main/res/layout/fragment_troubleshooting_overview.xml new file mode 100644 index 0000000000..af6377c0b8 --- /dev/null +++ b/feature/dashboard/src/main/res/layout/fragment_troubleshooting_overview.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + diff --git a/feature/dashboard/src/main/res/layout/fragment_troubleshooting_stub.xml b/feature/dashboard/src/main/res/layout/fragment_troubleshooting_stub.xml new file mode 100644 index 0000000000..62a3c7a877 --- /dev/null +++ b/feature/dashboard/src/main/res/layout/fragment_troubleshooting_stub.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/feature/dashboard/src/main/res/navigation/graph_dashboard.xml b/feature/dashboard/src/main/res/navigation/graph_dashboard.xml index 04053eb3e8..dd6f95bee3 100644 --- a/feature/dashboard/src/main/res/navigation/graph_dashboard.xml +++ b/feature/dashboard/src/main/res/navigation/graph_dashboard.xml @@ -23,6 +23,10 @@ app:destination="@id/mainFragment" app:popUpTo="@id/dashboard_navigation" app:popUpToInclusive="true" /> + + + + diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/about/AboutViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/about/AboutViewModelTest.kt index bd06ec4c3f..e4af603149 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/about/AboutViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/about/AboutViewModelTest.kt @@ -2,6 +2,7 @@ package com.simprints.feature.dashboard.settings.about 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.asTokenizableEncrypted import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase import com.simprints.infra.config.store.models.DownSynchronizationConfiguration @@ -20,7 +21,9 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -28,6 +31,7 @@ import org.junit.Test class AboutViewModelTest { companion object { + private val MODALITIES = listOf(GeneralConfiguration.Modality.FINGERPRINT) private val POOL_TYPE = IdentificationConfiguration.PoolType.MODULE private val PARTITION_TYPE = DownSynchronizationConfiguration.PartitionType.PROJECT @@ -79,7 +83,7 @@ class AboutViewModelTest { @Test fun `should sign out from signer manager when cannot sync data to simprints`() { val viewModel = - buildLogoutViewModel(canSyncDataToSimprints = false, hasEventsToUpload = true) + buildAboutViewModel(canSyncDataToSimprints = false, hasEventsToUpload = true) runTest { viewModel.processLogoutRequest() coVerify(exactly = 1) { logoutUseCase.invoke() } @@ -89,7 +93,7 @@ class AboutViewModelTest { @Test fun `should sign out from signer manager when can sync data to simprints but there are no events to upload`() { val viewModel = - buildLogoutViewModel(canSyncDataToSimprints = true, hasEventsToUpload = false) + buildAboutViewModel(canSyncDataToSimprints = true, hasEventsToUpload = false) runTest { viewModel.processLogoutRequest() coVerify(exactly = 1) { logoutUseCase.invoke() } @@ -99,7 +103,7 @@ class AboutViewModelTest { @Test fun `should not sign out from signer manager when can sync data to simprints and there are events to upload`() { val viewModel = - buildLogoutViewModel(canSyncDataToSimprints = true, hasEventsToUpload = true) + buildAboutViewModel(canSyncDataToSimprints = true, hasEventsToUpload = true) runTest { viewModel.processLogoutRequest() coVerify(exactly = 0) { logoutUseCase.invoke() } @@ -109,7 +113,7 @@ class AboutViewModelTest { @Test fun `should emit LogoutDestination_LogoutDataSyncScreen when can sync data to simprints and there are events to upload`() { val viewModel = - buildLogoutViewModel(canSyncDataToSimprints = true, hasEventsToUpload = true) + buildAboutViewModel(canSyncDataToSimprints = true, hasEventsToUpload = true) runTest { viewModel.processLogoutRequest() assertThat(viewModel.logoutDestinationEvent.getOrAwaitValue().peekContent()).isEqualTo( @@ -121,7 +125,7 @@ class AboutViewModelTest { @Test fun `should emit LogoutDestination_LoginScreen when can sync data to simprints but there are no events to upload`() { val viewModel = - buildLogoutViewModel(canSyncDataToSimprints = true, hasEventsToUpload = false) + buildAboutViewModel(canSyncDataToSimprints = true, hasEventsToUpload = false) runTest { viewModel.processLogoutRequest() assertThat(viewModel.logoutDestinationEvent.getOrAwaitValue().peekContent()).isEqualTo( @@ -133,7 +137,7 @@ class AboutViewModelTest { @Test fun `should emit LogoutDestination_LoginScreen when cannot sync data to simprints`() { val viewModel = - buildLogoutViewModel(canSyncDataToSimprints = false, hasEventsToUpload = true) + buildAboutViewModel(canSyncDataToSimprints = false, hasEventsToUpload = true) runTest { viewModel.processLogoutRequest() assertThat(viewModel.logoutDestinationEvent.getOrAwaitValue().peekContent()).isEqualTo( @@ -142,6 +146,31 @@ class AboutViewModelTest { } } + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `should emit reset troubleshooting counter`() { + val viewModel = buildAboutViewModel(canSyncDataToSimprints = false, hasEventsToUpload = true) + runTest { + val navigationEvent = viewModel.openTroubleshooting.test() + repeat(3) { viewModel.troubleshootingClick() } + advanceTimeBy(5000L) + repeat(2) { viewModel.troubleshootingClick() } + navigationEvent.assertNoValue() + repeat(3) { viewModel.troubleshootingClick() } + navigationEvent.assertHasValue() + } + } + + @Test + fun `mark settings as unlocked when called`() { + val viewModel = buildAboutViewModel(canSyncDataToSimprints = false, hasEventsToUpload = true) + runTest { + assertThat(viewModel.settingsLocked.value).isEqualTo(SettingsPasswordConfig.Locked("1234")) + viewModel.unlockSettings() + assertThat(viewModel.settingsLocked.value).isEqualTo(SettingsPasswordConfig.Unlocked) + } + } + private fun buildProjectConfigurationMock(upSyncKind: UpSynchronizationConfiguration.UpSynchronizationKind = UpSynchronizationConfiguration.UpSynchronizationKind.ALL): ProjectConfiguration = mockk { every { general } returns mockk { @@ -163,8 +192,8 @@ class AboutViewModelTest { } } - private fun buildLogoutViewModel( - canSyncDataToSimprints: Boolean, hasEventsToUpload: Boolean + private fun buildAboutViewModel( + canSyncDataToSimprints: Boolean, hasEventsToUpload: Boolean, ): AboutViewModel { val upSyncKind = when (canSyncDataToSimprints) { true -> UpSynchronizationConfiguration.UpSynchronizationKind.ALL diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/AutoResettingClickCounterTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/AutoResettingClickCounterTest.kt new file mode 100644 index 0000000000..6393625362 --- /dev/null +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/AutoResettingClickCounterTest.kt @@ -0,0 +1,48 @@ +package com.simprints.feature.dashboard.settings.troubleshooting + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.google.common.truth.Truth.assertThat +import com.simprints.testtools.common.coroutines.TestCoroutineRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AutoResettingClickCounterTest { + + @get:Rule + val rule = InstantTaskExecutorRule() + + @get:Rule + val testCoroutineRule = TestCoroutineRule() + + private lateinit var counter: AutoResettingClickCounter + + @Before + fun setUp() { + counter = AutoResettingClickCounter(requiredClicks = 3, resetDelayMs = 2000L) + } + + @Test + fun `returns true after required amount of clicks`() = runTest { + assertThat(counter.handleClick(backgroundScope)).isFalse() + assertThat(counter.handleClick(backgroundScope)).isFalse() + // Third time the charm + assertThat(counter.handleClick(backgroundScope)).isTrue() + } + + @Test + fun `should reset counter after delay if clicks count not reached`() = runTest { + assertThat(counter.handleClick(backgroundScope)).isFalse() + assertThat(counter.handleClick(backgroundScope)).isFalse() + + advanceTimeBy(5000L) + + assertThat(counter.handleClick(backgroundScope)).isFalse() + assertThat(counter.handleClick(backgroundScope)).isFalse() + assertThat(counter.handleClick(backgroundScope)).isTrue() + } +} 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 new file mode 100644 index 0000000000..481203bfbd --- /dev/null +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/OverviewViewModelTest.kt @@ -0,0 +1,59 @@ +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.infra.authstore.AuthStore +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.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class OverviewViewModelTest { + + @get:Rule + val rule = InstantTaskExecutorRule() + + @get:Rule + val testCoroutineRule = TestCoroutineRule() + + @MockK + private lateinit var collectIdsUseCase: CollectIdsUseCase + + @MockK + private lateinit var collectLicencesUseCase: CollectLicenceStatesUseCase + + private lateinit var viewModel: OverviewViewModel + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + + viewModel = OverviewViewModel( + collectIds = collectIdsUseCase, + collectLicenseStates = collectLicencesUseCase, + ) + } + + @Test + fun `sets when data collected`() = runTest { + every { collectIdsUseCase() } returns "ids" + coEvery { collectLicencesUseCase() } returns "licences" + + val idsText = viewModel.projectIds.test() + val licenceText = viewModel.licenseStates.test() + + viewModel.collectData() + + assertThat(idsText.value()).isNotEmpty() + assertThat(licenceText.value()).isNotEmpty() + } +} diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/CollectIdsUseCaseTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/CollectIdsUseCaseTest.kt new file mode 100644 index 0000000000..f49cac6b6e --- /dev/null +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/CollectIdsUseCaseTest.kt @@ -0,0 +1,45 @@ +package com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase + +import com.google.common.truth.Truth.assertThat +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.infra.authstore.AuthStore +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class CollectIdsUseCaseTest { + + @MockK + private lateinit var authStore: AuthStore + + private lateinit var useCase: CollectIdsUseCase + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + + every { authStore.signedInUserId } returns TokenizableString.Raw(USER_ID) + every { authStore.signedInProjectId } returns PROJECT_ID + + useCase = CollectIdsUseCase(DEVICE_ID, authStore) + } + + @Test + fun `result contains ids from all sources`() = runTest { + val ids = useCase() + + assertThat(ids).contains(PROJECT_ID) + assertThat(ids).contains(DEVICE_ID) + assertThat(ids).contains(USER_ID) + } + + companion object { + + private const val PROJECT_ID = "projectId" + private const val DEVICE_ID = "deviceId" + private const val USER_ID = "userId" + } +} diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/CollectLicenceStatesUseCaseTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/CollectLicenceStatesUseCaseTest.kt new file mode 100644 index 0000000000..b54d5d43d6 --- /dev/null +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/troubleshooting/overview/usecase/CollectLicenceStatesUseCaseTest.kt @@ -0,0 +1,52 @@ +package com.simprints.feature.dashboard.settings.troubleshooting.overview.usecase + +import com.google.common.truth.Truth.assertThat +import com.simprints.infra.license.LicenseRepository +import com.simprints.infra.license.models.License +import com.simprints.infra.license.models.LicenseVersion +import com.simprints.infra.license.models.Vendor +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class CollectLicenceStatesUseCaseTest { + + @MockK + private lateinit var licenseRepository: LicenseRepository + + private lateinit var useCase: CollectLicenceStatesUseCase + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + + useCase = CollectLicenceStatesUseCase(licenseRepository) + } + + @Test + fun `returns empty if no licenses`() = runTest { + coEvery { licenseRepository.getCachedLicense(any()) } returns null + val licenseText = useCase() + + coVerify(exactly = 2) { licenseRepository.getCachedLicense(any()) } + assertThat(licenseText).isEmpty() + } + + @Test + fun `sets license state when data collected`() = runTest { + coEvery { licenseRepository.getCachedLicense(any()) } returns null + coEvery { + licenseRepository.getCachedLicense(Vendor.RankOne) + } returns License("2024", "data", LicenseVersion.UNLIMITED) + + val licenseText = useCase() + + coVerify(exactly = 2) { licenseRepository.getCachedLicense(any()) } + assertThat(licenseText).isNotEmpty() + } + +} diff --git a/infra/license/src/main/java/com/simprints/infra/license/Vendor.kt b/infra/license/src/main/java/com/simprints/infra/license/Vendor.kt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/infra/license/src/main/java/com/simprints/infra/license/models/Vendor.kt b/infra/license/src/main/java/com/simprints/infra/license/models/Vendor.kt index 915a7f100c..e8bcd0369e 100644 --- a/infra/license/src/main/java/com/simprints/infra/license/models/Vendor.kt +++ b/infra/license/src/main/java/com/simprints/infra/license/models/Vendor.kt @@ -6,12 +6,12 @@ import com.simprints.infra.license.models.comparators.SemanticVersionComparator sealed class Vendor(val value: String) { abstract val versionComparator: Comparator - object RankOne : Vendor("RANK_ONE_FACE") { + data object RankOne : Vendor("RANK_ONE_FACE") { override val versionComparator: Comparator get() = SemanticVersionComparator() } - object Nec : Vendor("NEC_FINGERPRINT") { + data object Nec : Vendor("NEC_FINGERPRINT") { override val versionComparator: Comparator get() = DefaultVersionComparator() } @@ -22,5 +22,7 @@ sealed class Vendor(val value: String) { Nec.value -> Nec else -> error("Invalid licence vendor requested") } + + fun listAll() = listOf(RankOne, Nec) } } diff --git a/infra/resources/src/main/res/values/styles-text.xml b/infra/resources/src/main/res/values/styles-text.xml index ea1ca5d89e..2966c40cbe 100644 --- a/infra/resources/src/main/res/values/styles-text.xml +++ b/infra/resources/src/main/res/values/styles-text.xml @@ -164,6 +164,10 @@ @color/simprints_text_grey + +