GitLab is used only for code review, issue tracking and project management. Canonical locations for source code are still https://gitweb.torproject.org/ https://git.torproject.org/ and git-rw.torproject.org.

FenixSearchEngineProvider.kt 11.7 KB
Newer Older
Jeff Boek's avatar
Jeff Boek committed
1 2 3 4 5 6 7
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.fenix.components.searchengine

import android.content.Context
8
import androidx.annotation.VisibleForTesting
Jeff Boek's avatar
Jeff Boek committed
9
import kotlinx.coroutines.CoroutineScope
10
import kotlinx.coroutines.Deferred
Jeff Boek's avatar
Jeff Boek committed
11 12 13 14 15 16 17 18 19 20
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import mozilla.components.browser.search.SearchEngine
import mozilla.components.browser.search.provider.AssetsSearchEngineProvider
import mozilla.components.browser.search.provider.SearchEngineList
import mozilla.components.browser.search.provider.SearchEngineProvider
import mozilla.components.browser.search.provider.filter.SearchEngineFilter
21
import mozilla.components.browser.search.provider.localization.LocaleSearchLocalizationProvider
22
import mozilla.components.browser.search.provider.localization.SearchLocalizationProvider
23
import mozilla.components.service.location.LocationService
24 25 26
import mozilla.components.service.location.MozillaLocationService
import mozilla.components.service.location.search.RegionSearchLocalizationProvider
import org.mozilla.fenix.BuildConfig
27
import org.mozilla.fenix.Config
28
import org.mozilla.fenix.ext.components
Jeff Boek's avatar
Jeff Boek committed
29 30 31
import org.mozilla.fenix.ext.settings

@SuppressWarnings("TooManyFunctions")
32
open class FenixSearchEngineProvider(
Jeff Boek's avatar
Jeff Boek committed
33 34
    private val context: Context
) : SearchEngineProvider, CoroutineScope by CoroutineScope(Job() + Dispatchers.IO) {
35
    private val shouldMockMLS = Config.channel.isDebug || BuildConfig.MLS_TOKEN.isEmpty()
36
    private val locationService: LocationService = if (shouldMockMLS) {
37 38 39 40 41 42 43
        LocationService.dummy()
    } else {
        MozillaLocationService(
            context,
            context.components.core.client,
            BuildConfig.MLS_TOKEN
        )
44
    }
45

46 47 48 49 50 51 52
    // We have two search engine types: one based on MLS reported region, one based only on Locale.
    // There are multiple steps involved in returning the default search engine for example.
    // Simplest and most effective way to make sure the MLS engines do not mix with Locale based engines
    // is to use the same type of engines for the entire duration of the app's run.
    // See fenix/issues/11875
    private val isRegionCachedByLocationService = locationService.hasRegionCached()

53 54
    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    open val localizationProvider: SearchLocalizationProvider =
55
        RegionSearchLocalizationProvider(locationService)
56

57 58 59
    /**
     * Unfiltered list of search engines based on locale.
     */
60
    open var baseSearchEngines = async {
61 62
        AssetsSearchEngineProvider(localizationProvider)
            .loadSearchEngines(context)
Jeff Boek's avatar
Jeff Boek committed
63 64
    }

65 66
    private val loadedRegion = async { localizationProvider.determineRegion() }

67 68 69
    // https://github.com/mozilla-mobile/fenix/issues/9935
    // Adds a Locale search engine provider as a fallback in case the MLS lookup takes longer
    // than the time it takes for a user to try to search.
70 71 72 73
    private val fallbackLocationService: SearchLocalizationProvider = LocaleSearchLocalizationProvider()
    private val fallBackProvider =
        AssetsSearchEngineProvider(fallbackLocationService)

74 75
    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    open val fallbackEngines = async { fallBackProvider.loadSearchEngines(context) }
76
    private val fallbackRegion = async { fallbackLocationService.determineRegion() }
77

78 79 80
    /**
     * Default bundled search engines based on locale.
     */
81
    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
82
    open val bundledSearchEngines = async {
83 84
        val defaultEngineIdentifiers =
            baseSearchEngines.await().list.map { it.identifier }.toSet()
Jeff Boek's avatar
Jeff Boek committed
85
        AssetsSearchEngineProvider(
86
            localizationProvider,
Jeff Boek's avatar
Jeff Boek committed
87 88
            filters = listOf(object : SearchEngineFilter {
                override fun filter(context: Context, searchEngine: SearchEngine): Boolean {
89 90
                    return BUNDLED_SEARCH_ENGINES.contains(searchEngine.identifier) &&
                            !defaultEngineIdentifiers.contains(searchEngine.identifier)
Jeff Boek's avatar
Jeff Boek committed
91 92 93 94 95 96
                }
            }),
            additionalIdentifiers = BUNDLED_SEARCH_ENGINES
        ).loadSearchEngines(context)
    }

97 98 99
    /**
     * Search engines that have been manually added by a user.
     */
100
    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
101
    open var customSearchEngines = async {
Jeff Boek's avatar
Jeff Boek committed
102 103 104
        CustomSearchEngineProvider().loadSearchEngines(context)
    }

105
    private var loadedSearchEngines = refreshInstalledEngineListAsync(baseSearchEngines)
Jeff Boek's avatar
Jeff Boek committed
106

107 108 109 110 111 112 113 114
    // https://github.com/mozilla-mobile/fenix/issues/9935
    // Create new getter that will return the fallback SearchEngineList if
    // the main one hasn't completed yet
    private val searchEngines: Deferred<SearchEngineList>
        get() =
            if (isRegionCachedByLocationService) {
                loadedSearchEngines
            } else {
115
                refreshInstalledEngineListAsync(fallbackEngines)
116 117
            }

Jeff Boek's avatar
Jeff Boek committed
118 119 120 121
    fun getDefaultEngine(context: Context): SearchEngine {
        val engines = installedSearchEngines(context)
        val selectedName = context.settings().defaultSearchEngineName

122 123 124 125 126 127 128 129 130 131 132 133 134 135
        return engines.list.find { it.name == selectedName }
            ?: engines.default
            ?: engines.list.first()
    }

    // We should only be setting the default search engine here
    fun setDefaultEngine(context: Context, id: String) {
        val engines = installedSearchEngines(context)
        val newDefault = engines.list.find { it.name == id }
            ?: engines.default
            ?: engines.list.first()

        context.settings().defaultSearchEngineName = newDefault.name
        context.components.search.searchEngineManager.defaultSearchEngine = newDefault
Jeff Boek's avatar
Jeff Boek committed
136 137
    }

138 139
    /**
     * @return a list of all SearchEngines that are currently active. These are the engines that
140 141
     * are readily available throughout the app. Includes all installed engines, both
     * default and custom
142
     */
Jeff Boek's avatar
Jeff Boek committed
143 144
    fun installedSearchEngines(context: Context): SearchEngineList = runBlocking {
        val installedIdentifiers = installedSearchEngineIdentifiers(context)
145
        val defaultList = searchEngines.await()
Jeff Boek's avatar
Jeff Boek committed
146

147 148
        defaultList.copy(
            list = defaultList.list.filter {
Jeff Boek's avatar
Jeff Boek committed
149
                installedIdentifiers.contains(it.identifier)
150 151
            },
            default = defaultList.default?.let {
Jeff Boek's avatar
Jeff Boek committed
152 153 154 155 156 157 158 159 160 161 162 163 164 165 166
                if (installedIdentifiers.contains(it.identifier)) {
                    it
                } else {
                    null
                }
            }
        )
    }

    fun allSearchEngineIdentifiers() = runBlocking {
        loadedSearchEngines.await().list.map { it.identifier }
    }

    fun uninstalledSearchEngines(context: Context): SearchEngineList = runBlocking {
        val installedIdentifiers = installedSearchEngineIdentifiers(context)
167
        val engineList = loadedSearchEngines.await()
Jeff Boek's avatar
Jeff Boek committed
168

169 170 171
        return@runBlocking engineList.copy(
            list = engineList.list.filterNot { installedIdentifiers.contains(it.identifier) }
        )
Jeff Boek's avatar
Jeff Boek committed
172 173 174 175 176 177
    }

    override suspend fun loadSearchEngines(context: Context): SearchEngineList {
        return installedSearchEngines(context)
    }

178 179 180 181 182
    fun installSearchEngine(
        context: Context,
        searchEngine: SearchEngine,
        isCustom: Boolean = false
    ) = runBlocking {
183 184 185 186 187 188 189 190
        if (isCustom) {
            val searchUrl = searchEngine.getSearchTemplate()
            CustomSearchEngineStore.addSearchEngine(context, searchEngine.name, searchUrl)
            reload()
        } else {
            val installedIdentifiers = installedSearchEngineIdentifiers(context).toMutableSet()
            installedIdentifiers.add(searchEngine.identifier)
            prefs(context).edit()
191 192 193
                .putStringSet(
                    localeAwareInstalledEnginesKey(), installedIdentifiers
                ).apply()
194
        }
Jeff Boek's avatar
Jeff Boek committed
195 196
    }

197 198 199 200 201
    fun uninstallSearchEngine(
        context: Context,
        searchEngine: SearchEngine,
        isCustom: Boolean = false
    ) = runBlocking {
Jeff Boek's avatar
Jeff Boek committed
202 203
        if (isCustom) {
            CustomSearchEngineStore.removeSearchEngine(context, searchEngine.identifier)
204
            reload()
Jeff Boek's avatar
Jeff Boek committed
205 206 207
        } else {
            val installedIdentifiers = installedSearchEngineIdentifiers(context).toMutableSet()
            installedIdentifiers.remove(searchEngine.identifier)
208 209 210 211
            prefs(context).edit().putStringSet(
                localeAwareInstalledEnginesKey(),
                installedIdentifiers
            ).apply()
Jeff Boek's avatar
Jeff Boek committed
212 213 214 215 216
        }
    }

    fun reload() {
        launch {
217
            customSearchEngines = async { CustomSearchEngineProvider().loadSearchEngines(context) }
218
            loadedSearchEngines = refreshInstalledEngineListAsync(baseSearchEngines)
Jeff Boek's avatar
Jeff Boek committed
219 220 221
        }
    }

222
    // When we change the locale we need to update the baseSearchEngines list
223 224
    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    open fun updateBaseSearchEngines() {
225
        baseSearchEngines = async {
226 227
            AssetsSearchEngineProvider(localizationProvider)
                .loadSearchEngines(context)
228 229 230
        }
    }

231 232 233 234
    private fun refreshInstalledEngineListAsync(
        engines: Deferred<SearchEngineList>
    ): Deferred<SearchEngineList> = async {
        val engineList = engines.await()
235 236
        val bundledList = bundledSearchEngines.await().list
        val customList = customSearchEngines.await().list
Jeff Boek's avatar
Jeff Boek committed
237

238
        return@async engineList.copy(list = engineList.list + bundledList + customList)
Jeff Boek's avatar
Jeff Boek committed
239 240 241
    }

    private fun prefs(context: Context) = context.getSharedPreferences(
242
        PREF_FILE_SEARCH_ENGINES,
Jeff Boek's avatar
Jeff Boek committed
243 244 245
        Context.MODE_PRIVATE
    )

246 247
    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    suspend fun installedSearchEngineIdentifiers(context: Context): Set<String> {
Jeff Boek's avatar
Jeff Boek committed
248
        val prefs = prefs(context)
249 250 251
        val installedEnginesKey = localeAwareInstalledEnginesKey()

        if (!prefs.contains(installedEnginesKey)) {
252
            val searchEngines =
253 254 255 256 257
                if (isRegionCachedByLocationService) {
                    baseSearchEngines
                } else {
                    fallbackEngines
                }
258 259

            val defaultSet = searchEngines.await()
Jeff Boek's avatar
Jeff Boek committed
260 261 262 263
                .list
                .map { it.identifier }
                .toSet()

264
            prefs.edit().putStringSet(installedEnginesKey, defaultSet).apply()
Jeff Boek's avatar
Jeff Boek committed
265 266
        }

267 268 269 270 271
        val installedIdentifiers: Set<String> =
            prefs(context).getStringSet(installedEnginesKey, setOf()) ?: setOf()

        val customEngineIdentifiers =
            customSearchEngines.await().list.map { it.identifier }.toSet()
272

273
        return installedIdentifiers + customEngineIdentifiers
Jeff Boek's avatar
Jeff Boek committed
274 275
    }

276 277
    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    suspend fun localeAwareInstalledEnginesKey(): String {
278
        val tag = if (isRegionCachedByLocationService) {
279 280 281 282 283 284 285 286 287 288
            val localization = loadedRegion.await()
            val region = localization.region?.let {
                if (it.isEmpty()) "" else "-$it"
            }

            "${localization.languageTag}$region"
        } else {
            val localization = fallbackRegion.await()
            val region = localization.region?.let {
                if (it.isEmpty()) "" else "-$it"
289 290
            }

291
            "${localization.languageTag}$region-fallback"
292 293 294 295 296
        }

        return "$INSTALLED_ENGINES_KEY-$tag"
    }

297
    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
Jeff Boek's avatar
Jeff Boek committed
298
    companion object {
299
        val BUNDLED_SEARCH_ENGINES = listOf("reddit", "youtube")
300
        const val PREF_FILE_SEARCH_ENGINES = "fenix-search-engine-provider"
301
        const val INSTALLED_ENGINES_KEY = "fenix-installed-search-engines"
Jeff Boek's avatar
Jeff Boek committed
302 303
    }
}