Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions feature/dashboard/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,13 +37,23 @@ 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
binding.tvDeviceId.text = getString(IDR.string.dashboard_request_login_device_id, deviceId)
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand All @@ -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()
}
Expand All @@ -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))

Expand All @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -43,15 +45,26 @@ internal class AboutViewModel @Inject constructor(
get() = _settingsLocked
private val _settingsLocked =
MutableLiveData<SettingsPasswordConfig>(SettingsPasswordConfig.NotSet)

val logoutDestinationEvent: LiveData<LiveDataEventWithContent<LogoutDestination>>
get() = _logoutDestinationEvent
private val _logoutDestinationEvent =
MutableLiveData<LiveDataEventWithContent<LogoutDestination>>()

val openTroubleshooting: LiveData<LiveDataEvent>
get() = _openTroubleshooting
private val _openTroubleshooting = MutableLiveData<LiveDataEvent>()

private var troubleshootingClickCounter = AutoResettingClickCounter()

init {
load()
}

fun unlockSettings() {
_settingsLocked.postValue(SettingsPasswordConfig.Unlocked)
}

fun processLogoutRequest() {
viewModelScope.launch {
val logoutDestination =
Expand Down Expand Up @@ -85,4 +98,9 @@ internal class AboutViewModel @Inject constructor(
_settingsLocked.postValue(configuration.general.settingsPassword)
}

fun troubleshootingClick() {
if (troubleshootingClickCounter.handleClick(viewModelScope)) {
_openTroubleshooting.send()
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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() }),
;
}
}
Original file line number Diff line number Diff line change
@@ -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<OverviewViewModel>()
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()
}

}
Original file line number Diff line number Diff line change
@@ -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<String>
get() = _projectIds
private val _projectIds = MutableLiveData("")

val licenseStates: LiveData<String>
get() = _licenseStates
private val _licenseStates = MutableLiveData("")

fun collectData() {
_projectIds.postValue(collectIds())

viewModelScope.launch {
_licenseStates.postValue(collectLicenseStates())
}
}


}
Original file line number Diff line number Diff line change
@@ -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()
}
Loading