diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/data/Repository.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/data/Repository.kt index 8100fb855..289fea617 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/data/Repository.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/core/data/Repository.kt @@ -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) + 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 = 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) = 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 = flow { diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/di/DataModule.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/di/DataModule.kt index d7317d8c9..fe8b51898 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/di/DataModule.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/core/di/DataModule.kt @@ -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, diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourceDetailsBottomSheet.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourceDetailsBottomSheet.kt index 9ed4c1de9..685198e58 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourceDetailsBottomSheet.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourceDetailsBottomSheet.kt @@ -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 } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionActivity.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionActivity.kt index 322e22321..b348982a0 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionActivity.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionActivity.kt @@ -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() } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionViewModel.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionViewModel.kt index 00e9a0fba..b15baa13b 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionViewModel.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionViewModel.kt @@ -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()) private val _serviceStatusLiveData = MutableLiveData() private val _resourcesLiveData = MutableLiveData>(emptyList()) - private var showOnlyFavorites: Boolean = false + private var selectedTab = RESOURCES_TAB_FAVORITES - val favoriteResourcesLiveData: MutableLiveData> - get() = _favoriteResourcesLiveData val serviceStatusLiveData: MutableLiveData get() = _serviceStatusLiveData val resourcesLiveData: MutableLiveData> get() = _resourcesLiveData - - private val favoriteResources: HashSet - get() = favoriteResourcesLiveData.value!! + val favorites: StateFlow + 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 } } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/settings/ui/SettingsViewModel.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/settings/ui/SettingsViewModel.kt index b7ef20007..65b8bc16f 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/settings/ui/SettingsViewModel.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/settings/ui/SettingsViewModel.kt @@ -144,6 +144,7 @@ internal class SettingsViewModel fun resetSettingsToDefaults() { config = repo.getDefaultConfigSync() + repo.resetFavorites() onFieldUpdated() actionMutableLiveData.postValue( ViewAction.FillSettings(config), diff --git a/website/src/components/Changelog/Android.tsx b/website/src/components/Changelog/Android.tsx index 97d48d083..adc4ce1f7 100644 --- a/website/src/components/Changelog/Android.tsx +++ b/website/src/components/Changelog/Android.tsx @@ -9,6 +9,15 @@ export default function Android() { href="https://play.google.com/store/apps/details?id=dev.firezone.android" title="Android" > + {/* + +
    + + Resetting the settings now resets the list of favorited Resources, too. + +
+
+ */}