Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 62ce5bb3 authored by Fahim M. Choudhury's avatar Fahim M. Choudhury Committed by Fahim M. Choudhury
Browse files

refactor: observe changing source toggles via Stores flow

parent ca34fdd6
Loading
Loading
Loading
Loading
+20 −2
Original line number Diff line number Diff line
@@ -27,6 +27,10 @@ import foundation.e.apps.data.enums.Source.PLAY_STORE
import foundation.e.apps.data.enums.Source.PWA
import foundation.e.apps.data.playstore.PlayStoreRepository
import foundation.e.apps.data.preference.AppLoungePreference
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
import javax.inject.Singleton

@@ -45,6 +49,9 @@ class Stores @Inject constructor(
        appLoungePreference
    )

    private val _enabledStoresFlow = MutableStateFlow(provideEnabledStores())
    val enabledStoresFlow: StateFlow<Set<Source>> = _enabledStoresFlow.asStateFlow()

    /**
     * Retrieves a map of enabled store repositories based on user preferences.
     *
@@ -59,16 +66,27 @@ class Stores @Inject constructor(

    fun getStore(source: Source): StoreRepository? = getStores()[source]

    fun enableStore(source: Source) =
    fun enableStore(source: Source) {
        storeConfigs[source]?.enable?.invoke()
            ?: error("No matching Store found for $source.")

    fun disableStore(source: Source) =
        _enabledStoresFlow.update { provideEnabledStores() }
    }

    fun disableStore(source: Source) {
        storeConfigs[source]?.disable?.invoke()
            ?: error("No matching Store found for $source.")

        _enabledStoresFlow.update { provideEnabledStores() }
    }

    fun isStoreEnabled(source: Source): Boolean =
        storeConfigs[source]?.isEnabled?.invoke() == true

    private fun provideEnabledStores(): Set<Source> =
        storeConfigs
            .filterValues { it.isEnabled() }
            .keys
}

internal data class StoreConfig(
+5 −14
Original line number Diff line number Diff line
@@ -20,7 +20,6 @@
package foundation.e.apps.data.preference

import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -66,18 +65,6 @@ class AppLoungePreference @Inject constructor(
    fun enableOpenSource() = preferenceManager.edit { putBoolean(PREFERENCE_SHOW_FOSS, true) }
    fun enablePwa() = preferenceManager.edit { putBoolean(PREFERENCE_SHOW_PWA, true) }

    /**
     * Expose preference change registration so UI layers can react to store toggles immediately.
     * Callers must unregister to avoid leaking the listener beyond the consumer lifecycle.
     */
    fun registerStorePreferenceListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
        preferenceManager.registerOnSharedPreferenceChangeListener(listener)
    }

    fun unregisterStorePreferenceListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
        preferenceManager.unregisterOnSharedPreferenceChangeListener(listener)
    }

    fun getUpdateInterval(): Long {
        val currentUser = appLoungeDataStore.getUser()
        return when (currentUser) {
@@ -85,6 +72,7 @@ class AppLoungePreference @Inject constructor(
                context.getString(R.string.update_check_intervals_anonymous),
                context.getString(R.string.preference_update_interval_default_anonymous)
            )!!.toLong()

            else -> preferenceManager.getString(
                context.getString(R.string.update_check_intervals),
                context.getString(R.string.preference_update_interval_default)
@@ -135,6 +123,9 @@ class AppLoungePreference @Inject constructor(
    }

    fun isOnlyUnmeteredNetworkEnabled(): Boolean {
        return preferenceManager.getBoolean(context.getString(R.string.only_unmetered_network), true)
        return preferenceManager.getBoolean(
            context.getString(R.string.only_unmetered_network),
            true
        )
    }
}
+4 −26
Original line number Diff line number Diff line
@@ -18,13 +18,9 @@

package foundation.e.apps.ui.search.v2

import android.content.SharedPreferences
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import foundation.e.apps.data.Constants.PREFERENCE_SHOW_FOSS
import foundation.e.apps.data.Constants.PREFERENCE_SHOW_GPLAY
import foundation.e.apps.data.Constants.PREFERENCE_SHOW_PWA
import foundation.e.apps.data.Stores
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.application.data.Ratings
@@ -82,21 +78,11 @@ class SearchViewModelV2 @Inject constructor(

    private var suggestionJob: Job? = null

    private val preferenceListener =
        SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
            if (key in STORE_PREFERENCE_KEYS) {
                handleStoreSelectionChanged()
            }
        }

    init {
        appLoungePreference.registerStorePreferenceListener(preferenceListener)
        handleStoreSelectionChanged()
        viewModelScope.launch {
            stores.enabledStoresFlow
                .collect { handleStoreSelectionChanged() }
        }

    override fun onCleared() {
        appLoungePreference.unregisterStorePreferenceListener(preferenceListener)
        super.onCleared()
    }

    fun onQueryChanged(newQuery: String) {
@@ -307,14 +293,6 @@ class SearchViewModelV2 @Inject constructor(
        }
    }

    companion object {
        private val STORE_PREFERENCE_KEYS = setOf(
            PREFERENCE_SHOW_GPLAY,
            PREFERENCE_SHOW_FOSS,
            PREFERENCE_SHOW_PWA,
        )
    }

    private fun SearchTabType.toReadable(): String = when (this) {
        SearchTabType.COMMON_APPS -> "Standard app"
        SearchTabType.OPEN_SOURCE -> "Open source app"
+61 −13
Original line number Diff line number Diff line
@@ -8,6 +8,7 @@ import foundation.e.apps.data.playstore.PlayStoreRepository
import foundation.e.apps.data.preference.AppLoungePreference
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test

@@ -16,19 +17,33 @@ class StoresTest {
    private val playStoreRepository: PlayStoreRepository = mockk(relaxed = true)
    private val cleanApkAppsRepository: CleanApkAppsRepository = mockk(relaxed = true)
    private val cleanApkPwaRepository: CleanApkPwaRepository = mockk(relaxed = true)
    private val preference: AppLoungePreference = mockk()
    private lateinit var preference: AppLoungePreference
    private lateinit var stores: Stores
    private var playStoreSelected = true
    private var openSourceSelected = true
    private var pwaSelected = false

    @Before
    fun setUp() {
        stores = Stores(playStoreRepository, cleanApkAppsRepository, cleanApkPwaRepository, preference)
        preference = mockk(relaxed = true)
        every { preference.isPlayStoreSelected() } answers { playStoreSelected }
        every { preference.isOpenSourceSelected() } answers { openSourceSelected }
        every { preference.isPWASelected() } answers { pwaSelected }
        every { preference.enablePlayStore() } answers { playStoreSelected = true }
        every { preference.disablePlayStore() } answers { playStoreSelected = false }
        every { preference.enableOpenSource() } answers { openSourceSelected = true }
        every { preference.disableOpenSource() } answers { openSourceSelected = false }
        every { preference.enablePwa() } answers { pwaSelected = true }
        every { preference.disablePwa() } answers { pwaSelected = false }

        buildStores()
    }

    @Test
    fun getStoresReturnsOnlyEnabledSources() {
        every { preference.isPlayStoreSelected() } returns true
        every { preference.isOpenSourceSelected() } returns false
        every { preference.isPWASelected() } returns true
        playStoreSelected = true
        openSourceSelected = false
        pwaSelected = true

        val result = stores.getStores()

@@ -39,21 +54,18 @@ class StoresTest {

    @Test
    fun enableAndDisableStoreProxiesPreference() {
        every { preference.enableOpenSource() } returns Unit
        every { preference.disableOpenSource() } returns Unit

        stores.enableStore(Source.OPEN_SOURCE)
        stores.disableStore(Source.OPEN_SOURCE)

        io.mockk.verify { preference.enableOpenSource() }
        io.mockk.verify { preference.disableOpenSource() }
        verify { preference.enableOpenSource() }
        verify { preference.disableOpenSource() }
    }

    @Test
    fun isStoreEnabledReflectsPreferenceFlags() {
        every { preference.isPlayStoreSelected() } returns false
        every { preference.isOpenSourceSelected() } returns false
        every { preference.isPWASelected() } returns true
        playStoreSelected = false
        openSourceSelected = false
        pwaSelected = true

        val enabled = stores.isStoreEnabled(Source.PWA)
        val disabled = stores.isStoreEnabled(Source.PLAY_STORE)
@@ -66,4 +78,40 @@ class StoresTest {
    fun enableStoreThrowsForUnknownSource() {
        stores.enableStore(Source.SYSTEM_APP)
    }

    @Test
    fun enabledStoresFlowReflectsInitialSelection() {
        playStoreSelected = true
        openSourceSelected = false
        pwaSelected = true
        buildStores()

        assertThat(stores.enabledStoresFlow.value)
            .containsExactly(Source.PLAY_STORE, Source.PWA)
    }

    @Test
    fun enabledStoresFlowUpdatesAfterToggleChanges() {
        playStoreSelected = true
        openSourceSelected = false
        pwaSelected = false
        buildStores()

        stores.enableStore(Source.OPEN_SOURCE)
        assertThat(stores.enabledStoresFlow.value)
            .containsExactly(Source.PLAY_STORE, Source.OPEN_SOURCE)

        stores.disableStore(Source.PLAY_STORE)
        assertThat(stores.enabledStoresFlow.value)
            .containsExactly(Source.OPEN_SOURCE)
    }

    private fun buildStores() {
        stores = Stores(
            playStoreRepository,
            cleanApkAppsRepository,
            cleanApkPwaRepository,
            preference,
        )
    }
}
+28 −41
Original line number Diff line number Diff line
@@ -18,12 +18,10 @@

package foundation.e.apps.ui.search.v2

import android.content.SharedPreferences
import foundation.e.apps.data.Constants.PREFERENCE_SHOW_FOSS
import foundation.e.apps.data.Constants.PREFERENCE_SHOW_GPLAY
import foundation.e.apps.data.Stores
import foundation.e.apps.data.cleanapk.repositories.CleanApkAppsRepository
import foundation.e.apps.data.cleanapk.repositories.CleanApkPwaRepository
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.playstore.PlayStoreRepository
import foundation.e.apps.data.preference.AppLoungePreference
import foundation.e.apps.data.search.FakeSuggestionSource
@@ -34,7 +32,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
@@ -55,7 +52,6 @@ class SearchViewModelV2Test {
    private var playStoreSelected = true
    private var openSourceSelected = true
    private var pwaSelected = false
    private var preferenceListener: SharedPreferences.OnSharedPreferenceChangeListener? = null
    private lateinit var viewModel: SearchViewModelV2

    @Before
@@ -63,21 +59,15 @@ class SearchViewModelV2Test {
        suggestionSource = FakeSuggestionSource()
        preference = mockk(relaxed = true)

        stores = buildStores()

        every { preference.isPlayStoreSelected() } answers { playStoreSelected }
        every { preference.isOpenSourceSelected() } answers { openSourceSelected }
        every { preference.isPWASelected() } answers { pwaSelected }
        every { preference.registerStorePreferenceListener(any()) } answers {
            preferenceListener = arg(0)
            Unit
        }
        every { preference.unregisterStorePreferenceListener(any()) } answers {
            if (preferenceListener == arg<SharedPreferences.OnSharedPreferenceChangeListener>(0)) {
                preferenceListener = null
            }
            Unit
        }
        every { preference.enablePlayStore() } answers { playStoreSelected = true }
        every { preference.disablePlayStore() } answers { playStoreSelected = false }
        every { preference.enableOpenSource() } answers { openSourceSelected = true }
        every { preference.disableOpenSource() } answers { openSourceSelected = false }
        every { preference.enablePwa() } answers { pwaSelected = true }
        every { preference.disablePwa() } answers { pwaSelected = false }

        buildViewModel()
    }
@@ -224,9 +214,9 @@ class SearchViewModelV2Test {
        buildViewModel()
        viewModel.onSearchSubmitted("apps")

        playStoreSelected = false
        openSourceSelected = true
        notifyPreferenceChange(PREFERENCE_SHOW_FOSS)
        stores.disableStore(Source.PLAY_STORE)
        stores.enableStore(Source.OPEN_SOURCE)
        runStoreUpdates()

        val state = viewModel.uiState.value
        assertEquals(listOf(SearchTabType.OPEN_SOURCE), state.availableTabs)
@@ -244,9 +234,8 @@ class SearchViewModelV2Test {
        advanceDebounce()
        assertTrue(viewModel.uiState.value.isSuggestionVisible)

        playStoreSelected = false
        notifyPreferenceChange(PREFERENCE_SHOW_GPLAY)
        advanceDebounce()
        stores.disableStore(Source.PLAY_STORE)
        runStoreUpdates()

        val state = viewModel.uiState.value
        assertFalse(state.isSuggestionVisible)
@@ -260,10 +249,8 @@ class SearchViewModelV2Test {
        buildViewModel()
        viewModel.onSearchSubmitted("apps")

        playStoreSelected = false
        openSourceSelected = false
        pwaSelected = false
        notifyPreferenceChange(PREFERENCE_SHOW_GPLAY)
        stores.disableStore(Source.PLAY_STORE)
        runStoreUpdates()

        val state = viewModel.uiState.value
        assertTrue(state.availableTabs.isEmpty())
@@ -299,14 +286,19 @@ class SearchViewModelV2Test {
    }

    @Test
    fun `on cleared unregisters preference listener`() {
    fun `store change before submit updates available tabs`() = runTest {
        playStoreSelected = true
        openSourceSelected = false
        pwaSelected = false
        buildViewModel()
        assertNotNull(preferenceListener)

        invokeOnCleared()
        stores.enableStore(Source.OPEN_SOURCE)
        runStoreUpdates()

        assertNull(preferenceListener)
        val state = viewModel.uiState.value
        assertEquals(listOf(SearchTabType.COMMON_APPS, SearchTabType.OPEN_SOURCE), state.availableTabs)
        assertEquals(SearchTabType.COMMON_APPS, state.selectedTab)
        assertFalse(state.hasSubmittedSearch)
    }

    private fun advanceDebounce() {
@@ -314,23 +306,18 @@ class SearchViewModelV2Test {
        mainCoroutineRule.testDispatcher.scheduler.runCurrent()
    }

    private fun runStoreUpdates() {
        mainCoroutineRule.testDispatcher.scheduler.runCurrent()
    }

    private fun visibleTabs(): List<SearchTabType> = buildList {
        if (playStoreSelected) add(SearchTabType.COMMON_APPS)
        if (openSourceSelected) add(SearchTabType.OPEN_SOURCE)
        if (pwaSelected) add(SearchTabType.PWA)
    }

    private fun notifyPreferenceChange(key: String) {
        preferenceListener?.onSharedPreferenceChanged(null, key)
    }

    private fun buildViewModel() {
        stores = buildStores()
        viewModel = SearchViewModelV2(suggestionSource, preference, stores)
    }

    private fun invokeOnCleared() {
        val method = SearchViewModelV2::class.java.getDeclaredMethod("onCleared")
        method.isAccessible = true
        method.invoke(viewModel)
    }
}