fix(client/android): clear all favorites when other settings are reset (#6707)

This required some refactoring since we want to move away from
`LiveData` and towards `StateFlow`

Closes #6293
This commit is contained in:
Reactor Scram
2024-09-17 15:24:29 -05:00
committed by GitHub
parent 2d7fc8d4b9
commit 7c55859a98
7 changed files with 84 additions and 60 deletions

View File

@@ -10,6 +10,8 @@ import dev.firezone.android.BuildConfig
import dev.firezone.android.core.data.model.Config
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import java.security.MessageDigest
@@ -49,6 +51,10 @@ fun ResourceState.toggle(): ResourceState {
}
}
// Wrapper class used because `MutableStateFlow` will not
// notify subscribers if you submit the same object that's already in it.
class Favorites(val inner: HashSet<String>)
internal class Repository
@Inject
constructor(
@@ -56,6 +62,12 @@ internal class Repository
private val coroutineDispatcher: CoroutineDispatcher,
private val sharedPreferences: SharedPreferences,
) {
// We are the only thing that can modify favorites so we shouldn't need to reload it after
// this initial load
private val _favorites =
MutableStateFlow(Favorites(HashSet(sharedPreferences.getStringSet(FAVORITE_RESOURCES_KEY, null).orEmpty())))
val favorites = _favorites.asStateFlow()
fun getConfigSync(): Config {
return Config(
sharedPreferences.getString(AUTH_BASE_URL_KEY, null)
@@ -98,9 +110,25 @@ internal class Repository
fun getDeviceIdSync(): String? = sharedPreferences.getString(DEVICE_ID_KEY, null)
fun getFavoritesSync(): HashSet<String> = HashSet(sharedPreferences.getStringSet(FAVORITE_RESOURCES_KEY, null).orEmpty())
private fun saveFavoritesSync() {
sharedPreferences.edit().putStringSet(FAVORITE_RESOURCES_KEY, favorites.value.inner).apply()
_favorites.value = Favorites(favorites.value.inner)
}
fun saveFavoritesSync(value: HashSet<String>) = sharedPreferences.edit().putStringSet(FAVORITE_RESOURCES_KEY, value).apply()
fun addFavoriteResource(id: String) {
favorites.value.inner.add(id)
saveFavoritesSync()
}
fun removeFavoriteResource(id: String) {
favorites.value.inner.remove(id)
saveFavoritesSync()
}
fun resetFavorites() {
favorites.value.inner.clear()
saveFavoritesSync()
}
fun getToken(): Flow<String?> =
flow {

View File

@@ -12,6 +12,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dev.firezone.android.core.data.Repository
import kotlinx.coroutines.CoroutineDispatcher
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
@@ -21,6 +22,7 @@ class DataModule {
@ApplicationContext context: Context,
): Bundle = (context.getSystemService(Context.RESTRICTIONS_SERVICE) as android.content.RestrictionsManager).applicationRestrictions
@Singleton
@Provides
internal fun provideRepository(
@ApplicationContext context: Context,

View File

@@ -172,7 +172,7 @@ class ResourceDetailsBottomSheet(
private fun refreshButtons() {
val addToFavoritesBtn: MaterialButton = view.findViewById(R.id.addToFavoritesBtn)
val removeFromFavoritesBtn: MaterialButton = view.findViewById(R.id.removeFromFavoritesBtn)
val isFavorite = viewModel.favoriteResourcesLiveData.value!!.contains(resource.id)
val isFavorite = viewModel.isFavorite(resource.id)
addToFavoritesBtn.visibility = if (isFavorite) View.GONE else View.VISIBLE
removeFromFavoritesBtn.visibility = if (isFavorite) View.VISIBLE else View.GONE
}

View File

@@ -8,9 +8,9 @@ import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import android.view.View
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.tabs.TabLayout
@@ -20,6 +20,7 @@ import dev.firezone.android.core.data.toggle
import dev.firezone.android.databinding.ActivitySessionBinding
import dev.firezone.android.features.settings.ui.SettingsActivity
import dev.firezone.android.tunnel.TunnelService
import kotlinx.coroutines.launch
@AndroidEntryPoint
class SessionActivity : AppCompatActivity() {
@@ -129,6 +130,7 @@ class SessionActivity : AppCompatActivity() {
override fun onTabReselected(tab: TabLayout.Tab) {}
},
)
viewModel.tabSelected(binding.tabLayout.selectedTabPosition)
}
private fun setupObservers() {
@@ -143,24 +145,20 @@ class SessionActivity : AppCompatActivity() {
refreshList()
}
viewModel.favoriteResourcesLiveData.observe(this) {
refreshList()
// This coroutine could still resume while the Activity is not shown, but this is probably
// fine since the Flow will only emit if the user interacts with the UI anyway.
lifecycleScope.launch {
viewModel.favorites.collect {
refreshList()
}
}
viewModel.tabSelected(binding.tabLayout.selectedTabPosition)
viewModel.favoriteResourcesLiveData.value = viewModel.repo.getFavoritesSync()
}
private fun refreshList(afterLoad: () -> Unit = {}) {
if (viewModel.forceAllResourcesTab()) {
binding.tabLayout.selectTab(binding.tabLayout.getTabAt(SessionViewModel.RESOURCES_TAB_ALL), true)
}
binding.tabLayout.visibility =
if (viewModel.showFavoritesTab()) {
View.VISIBLE
} else {
View.GONE
}
viewModel.forceTab()?.let { tab -> binding.tabLayout.selectTab(binding.tabLayout.getTabAt(tab), true) }
binding.tabLayout.visibility = viewModel.tabLayoutVisibility()
resourcesAdapter.submitList(viewModel.resourcesList(internetState())) {
afterLoad()
}

View File

@@ -1,36 +1,36 @@
/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */
package dev.firezone.android.features.session.ui
import android.view.View
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.firezone.android.core.data.Favorites
import dev.firezone.android.core.data.Repository
import dev.firezone.android.core.data.ResourceState
import dev.firezone.android.tunnel.TunnelService.Companion.State
import dev.firezone.android.tunnel.model.Resource
import dev.firezone.android.tunnel.model.isInternetResource
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@HiltViewModel
internal class SessionViewModel
@Inject
constructor() : ViewModel() {
// Must be `internal` because Dagger does not support injection into `private` fields
@Inject
internal lateinit var repo: Repository
private val _favoriteResourcesLiveData = MutableLiveData<HashSet<String>>(HashSet())
private val _serviceStatusLiveData = MutableLiveData<State>()
private val _resourcesLiveData = MutableLiveData<List<Resource>>(emptyList())
private var showOnlyFavorites: Boolean = false
private var selectedTab = RESOURCES_TAB_FAVORITES
val favoriteResourcesLiveData: MutableLiveData<HashSet<String>>
get() = _favoriteResourcesLiveData
val serviceStatusLiveData: MutableLiveData<State>
get() = _serviceStatusLiveData
val resourcesLiveData: MutableLiveData<List<Resource>>
get() = _resourcesLiveData
private val favoriteResources: HashSet<String>
get() = favoriteResourcesLiveData.value!!
val favorites: StateFlow<Favorites>
get() = repo.favorites
// Actor name
fun clearActorName() = repo.clearActorName()
@@ -38,22 +38,11 @@ internal class SessionViewModel
fun getActorName() = repo.getActorNameSync()
fun addFavoriteResource(id: String) {
val value = favoriteResources
value.add(id)
repo.saveFavoritesSync(value)
// Update LiveData
_favoriteResourcesLiveData.value = value
repo.addFavoriteResource(id)
}
fun removeFavoriteResource(id: String) {
val value = favoriteResources
value.remove(id)
repo.saveFavoritesSync(value)
if (forceAllResourcesTab()) {
showOnlyFavorites = false
}
// Update LiveData
_favoriteResourcesLiveData.value = value
repo.removeFavoriteResource(id)
}
fun clearToken() = repo.clearToken()
@@ -69,40 +58,37 @@ internal class SessionViewModel
}
}
return if (favoriteResources.isEmpty()) {
return if (repo.favorites.value.inner.isEmpty()) {
resources
} else if (showOnlyFavorites) {
resources.filter { favoriteResources.contains(it.id) }
} else if (selectedTab == RESOURCES_TAB_FAVORITES) {
resources.filter { repo.favorites.value.inner.contains(it.id) }
} else {
resources
}
}
fun forceAllResourcesTab(): Boolean {
return favoriteResources.isEmpty()
}
fun showFavoritesTab(): Boolean {
return favoriteResources.isNotEmpty()
}
fun forceTab(): Int? =
if (repo.favorites.value.inner.isEmpty()) {
RESOURCES_TAB_ALL
} else {
null
}
fun tabSelected(position: Int) {
showOnlyFavorites =
when (position) {
RESOURCES_TAB_FAVORITES -> {
true
}
RESOURCES_TAB_ALL -> {
false
}
else -> throw IllegalArgumentException("Invalid tab position: $position")
}
selectedTab = position
}
fun isFavorite(id: String) = repo.favorites.value.inner.contains(id)
fun tabLayoutVisibility(): Int =
if (repo.favorites.value.inner.isNotEmpty()) {
View.VISIBLE
} else {
View.GONE
}
companion object {
const val RESOURCES_TAB_FAVORITES = 0
const val RESOURCES_TAB_ALL = 1
private const val RESOURCES_TAB_FAVORITES = 0
private const val RESOURCES_TAB_ALL = 1
}
}

View File

@@ -144,6 +144,7 @@ internal class SettingsViewModel
fun resetSettingsToDefaults() {
config = repo.getDefaultConfigSync()
repo.resetFavorites()
onFieldUpdated()
actionMutableLiveData.postValue(
ViewAction.FillSettings(config),

View File

@@ -9,6 +9,15 @@ export default function Android() {
href="https://play.google.com/store/apps/details?id=dev.firezone.android"
title="Android"
>
{/*
<Entry version="1.3.3" date={new Date("todo")}>
<ul className="list-disc space-y-2 pl-4 mb-4">
<ChangeItem pull="6707">
Resetting the settings now resets the list of favorited Resources, too.
</ChangeItem>
</ul>
</Entry>
*/}
<Entry version="1.3.2" date={new Date("2024-09-05")}>
<ul className="list-disc space-y-2 pl-4 mb-4">
<ChangeItem pull="6605">