FenixSearchEngineProvider.kt 11.8 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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
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
20
import mozilla.components.browser.search.provider.localization.LocaleSearchLocalizationProvider
21
import mozilla.components.browser.search.provider.localization.SearchLocalizationProvider
22
import mozilla.components.service.location.LocationService
23
24
25
import mozilla.components.service.location.MozillaLocationService
import mozilla.components.service.location.search.RegionSearchLocalizationProvider
import org.mozilla.fenix.BuildConfig
26
import org.mozilla.fenix.Config
27
import org.mozilla.fenix.ext.components
Jeff Boek's avatar
Jeff Boek committed
28
import org.mozilla.fenix.ext.settings
29
import org.mozilla.fenix.perf.runBlockingIncrement
Jeff Boek's avatar
Jeff Boek committed
30
31

@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
     */
143
    fun installedSearchEngines(context: Context): SearchEngineList = runBlockingIncrement {
Jeff Boek's avatar
Jeff Boek committed
144
        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
                if (installedIdentifiers.contains(it.identifier)) {
                    it
                } else {
                    null
                }
            }
        )
    }

161
    fun allSearchEngineIdentifiers() = runBlockingIncrement {
Jeff Boek's avatar
Jeff Boek committed
162
163
164
        loadedSearchEngines.await().list.map { it.identifier }
    }

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

169
        return@runBlockingIncrement engineList.copy(
170
171
            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
    fun installSearchEngine(
        context: Context,
        searchEngine: SearchEngine,
        isCustom: Boolean = false
182
    ) = runBlockingIncrement {
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
    fun uninstallSearchEngine(
        context: Context,
        searchEngine: SearchEngine,
        isCustom: Boolean = false
201
    ) = runBlockingIncrement {
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
    }
}