From 099a42989e6267cffeac99076eed6980a26c2580 Mon Sep 17 00:00:00 2001 From: Mugurell Date: Wed, 16 Nov 2022 12:55:46 +0200 Subject: [PATCH] Bug 1800268 - Add new autocomplete providers for bookmarks and local/sync tabs --- .../browser/storage/sync/PlacesBookmarksStorage.kt | 38 +++++++++- .../storage/sync/PlacesBookmarksStorageTest.kt | 32 +++++++++ .../provider/SessionAutocompleteProvider.kt | 55 +++++++++++++++ .../provider/SessionAutocompleteProviderTest.kt | 82 ++++++++++++++++++++++ .../components/feature/syncedtabs/build.gradle | 2 + .../components/feature/syncedtabs/ClientTabPair.kt | 17 +++++ .../syncedtabs/SyncedTabsAutocompleteProvider.kt | 51 ++++++++++++++ .../SyncedTabsStorageSuggestionProvider.kt | 33 ++------- .../feature/syncedtabs/ext/SyncedTabsStorage.kt | 43 ++++++++++++ .../SyncedTabsAutocompleteProviderKtTest.kt | 68 ++++++++++++++++++ .../SyncedTabsStorageSuggestionProviderTest.kt | 68 +----------------- .../syncedtabs/ext/SyncedTabsStorageKtTest.kt | 65 +++++++++++++++++ .../syncedtabs/helper/SyncedTabsProvider.kt | 79 +++++++++++++++++++++ .../components/support/utils/DomainMatcher.kt | 56 +++++++++------ .../android-components/samples/sync/build.gradle | 1 + mobile/android/docs/changelog.md | 5 ++ 16 files changed, 578 insertions(+), 117 deletions(-) create mode 100644 mobile/android/android-components/components/feature/awesomebar/src/main/java/mozilla/components/feature/awesomebar/provider/SessionAutocompleteProvider.kt create mode 100644 mobile/android/android-components/components/feature/awesomebar/src/test/java/mozilla/components/feature/awesomebar/provider/SessionAutocompleteProviderTest.kt create mode 100644 mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/ClientTabPair.kt create mode 100644 mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/SyncedTabsAutocompleteProvider.kt create mode 100644 mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/ext/SyncedTabsStorage.kt create mode 100644 mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/SyncedTabsAutocompleteProviderKtTest.kt create mode 100644 mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/ext/SyncedTabsStorageKtTest.kt create mode 100644 mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/helper/SyncedTabsProvider.kt diff --git a/mobile/android/android-components/components/browser/storage-sync/src/main/java/mozilla/components/browser/storage/sync/PlacesBookmarksStorage.kt b/mobile/android/android-components/components/browser/storage-sync/src/main/java/mozilla/components/browser/storage/sync/PlacesBookmarksStorage.kt index b91ac7a31fd4..9c7b12826c5b 100644 --- a/mobile/android/android-components/components/browser/storage-sync/src/main/java/mozilla/components/browser/storage/sync/PlacesBookmarksStorage.kt +++ b/mobile/android/android-components/components/browser/storage-sync/src/main/java/mozilla/components/browser/storage/sync/PlacesBookmarksStorage.kt @@ -15,12 +15,30 @@ import mozilla.components.concept.storage.BookmarksStorage import mozilla.components.concept.sync.SyncAuthInfo import mozilla.components.concept.sync.SyncStatus import mozilla.components.concept.sync.SyncableStore +import mozilla.components.concept.toolbar.AutocompleteProvider +import mozilla.components.concept.toolbar.AutocompleteResult import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.utils.doesUrlStartsWithText +import mozilla.components.support.utils.segmentAwareDomainMatch + +@VisibleForTesting +internal const val BOOKMARKS_AUTOCOMPLETE_SOURCE_NAME = "placesBookmarks" + +/** + * How many bookmarks to try and find from which to pick one that can be an autocomplete suggestion. + */ +private const val BOOKMARKS_AUTOCOMPLETE_QUERY_LIMIT = 20 /** * Implementation of the [BookmarksStorage] which is backed by a Rust Places lib via [PlacesApi]. */ -open class PlacesBookmarksStorage(context: Context) : PlacesStorage(context), BookmarksStorage, SyncableStore { +open class PlacesBookmarksStorage( + context: Context, + override val autocompletePriority: Int = 0, +) : PlacesStorage(context), + BookmarksStorage, + SyncableStore, + AutocompleteProvider { override val logger = Logger("PlacesBookmarksStorage") @@ -240,4 +258,22 @@ open class PlacesBookmarksStorage(context: Context) : PlacesStorage(context), Bo override fun registerWithSyncManager() { places.registerWithSyncManager() } + + override suspend fun getAutocompleteSuggestion(query: String): AutocompleteResult? { + val bookmarkUrl = searchBookmarks(query, BOOKMARKS_AUTOCOMPLETE_QUERY_LIMIT) + .mapNotNull { it.url } + .firstOrNull { doesUrlStartsWithText(it, query) } + ?: return null + + val resultText = segmentAwareDomainMatch(query, arrayListOf(bookmarkUrl)) + return resultText?.let { + AutocompleteResult( + input = query, + text = it.matchedSegment, + url = it.url, + source = BOOKMARKS_AUTOCOMPLETE_SOURCE_NAME, + totalItems = 1, + ) + } + } } diff --git a/mobile/android/android-components/components/browser/storage-sync/src/test/java/mozilla/components/browser/storage/sync/PlacesBookmarksStorageTest.kt b/mobile/android/android-components/components/browser/storage-sync/src/test/java/mozilla/components/browser/storage/sync/PlacesBookmarksStorageTest.kt index b3429fae7cc6..3ff44d46c2bc 100644 --- a/mobile/android/android-components/components/browser/storage-sync/src/test/java/mozilla/components/browser/storage/sync/PlacesBookmarksStorageTest.kt +++ b/mobile/android/android-components/components/browser/storage-sync/src/test/java/mozilla/components/browser/storage/sync/PlacesBookmarksStorageTest.kt @@ -6,6 +6,7 @@ package mozilla.components.browser.storage.sync import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest import mozilla.appservices.places.BookmarkRoot import mozilla.appservices.places.uniffi.PlacesApiException import mozilla.components.concept.storage.BookmarkInfo @@ -204,4 +205,35 @@ class PlacesBookmarksStorageTest { assertTrue(this.isEmpty()) } } + + @Test + fun `GIVEN bookmarks exist WHEN asked for autocomplete suggestions THEN return the first matching bookmark`() = runTest { + bookmarks.apply { + addItem(BookmarkRoot.Mobile.id, "https://www.mozilla.org/en-us/firefox", "Mozilla", 5u) + addItem(BookmarkRoot.Toolbar.id, "https://support.mozilla.org/", "Support", 2u) + } + + // Try querying for a bookmarks that doesn't exist + var suggestion = bookmarks.getAutocompleteSuggestion("test") + assertNull(suggestion) + + // And now for ones that do exist + suggestion = bookmarks.getAutocompleteSuggestion("moz") + assertNotNull(suggestion) + assertEquals("moz", suggestion?.input) + // There are multiple bookmarks from the mozilla host with no guarantee about the read order. + // Use a smaller URL that would match all. + assertTrue(suggestion?.text?.startsWith("mozilla.org/en-us/") ?: false) + assertTrue(suggestion?.url?.startsWith("https://www.mozilla.org/en-us/") ?: false) + assertEquals(BOOKMARKS_AUTOCOMPLETE_SOURCE_NAME, suggestion?.source) + assertEquals(1, suggestion?.totalItems) + + suggestion = bookmarks.getAutocompleteSuggestion("sup") + assertNotNull(suggestion) + assertEquals("sup", suggestion?.input) + assertEquals("support.mozilla.org/", suggestion?.text) + assertEquals("https://support.mozilla.org/", suggestion?.url) + assertEquals(BOOKMARKS_AUTOCOMPLETE_SOURCE_NAME, suggestion?.source) + assertEquals(1, suggestion?.totalItems) + } } diff --git a/mobile/android/android-components/components/feature/awesomebar/src/main/java/mozilla/components/feature/awesomebar/provider/SessionAutocompleteProvider.kt b/mobile/android/android-components/components/feature/awesomebar/src/main/java/mozilla/components/feature/awesomebar/provider/SessionAutocompleteProvider.kt new file mode 100644 index 000000000000..e044586ac023 --- /dev/null +++ b/mobile/android/android-components/components/feature/awesomebar/src/main/java/mozilla/components/feature/awesomebar/provider/SessionAutocompleteProvider.kt @@ -0,0 +1,55 @@ +/* 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 mozilla.components.feature.awesomebar.provider + +import androidx.annotation.VisibleForTesting +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.toolbar.AutocompleteProvider +import mozilla.components.concept.toolbar.AutocompleteResult +import mozilla.components.support.utils.doesUrlStartsWithText +import mozilla.components.support.utils.segmentAwareDomainMatch + +@VisibleForTesting +internal const val LOCAL_TABS_AUTOCOMPLETE_SOURCE_NAME = "localTabs" + +/** + * Provide autocomplete suggestions from the currently opened tabs. + * + * @param store [BrowserStore] containing the information about the currently open tabs. + * @param autocompletePriority Order in which this provider will be queried for autocomplete suggestions + * in relation ot others. + * - a lower priority means that this provider must be called before others with a higher priority. + * - an equal priority offers no ordering guarantees. + * + * Defaults to `0`. + */ +class SessionAutocompleteProvider( + private val store: BrowserStore, + override val autocompletePriority: Int = 0, +) : AutocompleteProvider { + override suspend fun getAutocompleteSuggestion(query: String): AutocompleteResult? { + if (query.isEmpty()) { + return null + } + + val tabUrl = store.state.tabs + .firstOrNull { + !it.content.private && doesUrlStartsWithText(it.content.url, query) + } + ?.content?.url + ?: return null + + val resultText = segmentAwareDomainMatch(query, arrayListOf(tabUrl)) + return resultText?.let { + AutocompleteResult( + input = query, + text = it.matchedSegment, + url = it.url, + source = LOCAL_TABS_AUTOCOMPLETE_SOURCE_NAME, + totalItems = 1, + ) + } + } +} diff --git a/mobile/android/android-components/components/feature/awesomebar/src/test/java/mozilla/components/feature/awesomebar/provider/SessionAutocompleteProviderTest.kt b/mobile/android/android-components/components/feature/awesomebar/src/test/java/mozilla/components/feature/awesomebar/provider/SessionAutocompleteProviderTest.kt new file mode 100644 index 000000000000..6569710edde1 --- /dev/null +++ b/mobile/android/android-components/components/feature/awesomebar/src/test/java/mozilla/components/feature/awesomebar/provider/SessionAutocompleteProviderTest.kt @@ -0,0 +1,82 @@ +/* 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 mozilla.components.feature.awesomebar.provider + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.createTab +import mozilla.components.browser.state.store.BrowserStore +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class SessionAutocompleteProviderTest { + @Test + fun `GIVEN open tabs exist WHEN asked for autocomplete suggestions THEN return the first matching tab`() = runTest { + val tab1 = createTab("https://allizom.org") + val tab2 = createTab("https://getpocket.com") + val tab3 = createTab("https://www.firefox.com") + val store = BrowserStore( + BrowserState( + tabs = listOf(tab1, tab2, tab3), + ), + ) + val provider = SessionAutocompleteProvider(store) + + var suggestion = provider.getAutocompleteSuggestion("mozilla") + assertNull(suggestion) + + suggestion = provider.getAutocompleteSuggestion("all") + assertNotNull(suggestion) + assertEquals("all", suggestion?.input) + assertEquals("allizom.org", suggestion?.text) + assertEquals("https://allizom.org", suggestion?.url) + assertEquals(LOCAL_TABS_AUTOCOMPLETE_SOURCE_NAME, suggestion?.source) + assertEquals(1, suggestion?.totalItems) + + suggestion = provider.getAutocompleteSuggestion("www") + assertNotNull(suggestion) + assertEquals("www", suggestion?.input) + assertEquals("www.firefox.com", suggestion?.text) + assertEquals("https://www.firefox.com", suggestion?.url) + assertEquals(LOCAL_TABS_AUTOCOMPLETE_SOURCE_NAME, suggestion?.source) + assertEquals(1, suggestion?.totalItems) + } + + @Test + fun `GIVEN open tabs exist WHEN asked for autocomplete suggestions and only private tabs match THEN return null`() = runTest { + val tab1 = createTab(url = "https://allizom.org", private = true) + val tab2 = createTab(url = "https://getpocket.com") + val tab3 = createTab(url = "https://www.firefox.com", private = true) + val store = BrowserStore( + BrowserState( + tabs = listOf(tab1, tab2, tab3), + ), + ) + val provider = SessionAutocompleteProvider(store) + + var suggestion = provider.getAutocompleteSuggestion("mozilla") + assertNull(suggestion) + + suggestion = provider.getAutocompleteSuggestion("all") + assertNull(suggestion) + + suggestion = provider.getAutocompleteSuggestion("www") + assertNull(suggestion) + } + + @Test + fun `GIVEN no open tabs exist WHEN asked for autocomplete suggestions THEN return null`() = runTest { + val provider = SessionAutocompleteProvider(BrowserStore()) + + assertNull(provider.getAutocompleteSuggestion("test")) + } +} diff --git a/mobile/android/android-components/components/feature/syncedtabs/build.gradle b/mobile/android/android-components/components/feature/syncedtabs/build.gradle index f8c55a1ea94f..0fb3af757884 100644 --- a/mobile/android/android-components/components/feature/syncedtabs/build.gradle +++ b/mobile/android/android-components/components/feature/syncedtabs/build.gradle @@ -40,7 +40,9 @@ dependencies { implementation project(':browser-storage-sync') implementation project(':concept-awesomebar') implementation project(':concept-engine') + implementation project(':concept-toolbar') implementation project(':feature-session') + implementation project(':support-utils') implementation project(':support-ktx') implementation project(':support-base') diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/ClientTabPair.kt b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/ClientTabPair.kt new file mode 100644 index 000000000000..afcc2c570b08 --- /dev/null +++ b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/ClientTabPair.kt @@ -0,0 +1,17 @@ +/* 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 mozilla.components.feature.syncedtabs + +import mozilla.components.browser.storage.sync.TabEntry +import mozilla.components.concept.sync.DeviceType + +/** + * Mapping of a device and the active [TabEntry] for each synced tab. + */ +internal data class ClientTabPair( + val clientName: String, + val tab: TabEntry, + val lastUsed: Long, + val deviceType: DeviceType, +) diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/SyncedTabsAutocompleteProvider.kt b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/SyncedTabsAutocompleteProvider.kt new file mode 100644 index 000000000000..71ee683f59d0 --- /dev/null +++ b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/SyncedTabsAutocompleteProvider.kt @@ -0,0 +1,51 @@ +/* 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 mozilla.components.feature.syncedtabs + +import androidx.annotation.VisibleForTesting +import mozilla.components.concept.toolbar.AutocompleteProvider +import mozilla.components.concept.toolbar.AutocompleteResult +import mozilla.components.feature.syncedtabs.ext.getActiveDeviceTabs +import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage +import mozilla.components.support.utils.doesUrlStartsWithText +import mozilla.components.support.utils.segmentAwareDomainMatch + +@VisibleForTesting +internal const val SYNCED_TABS_AUTOCOMPLETE_SOURCE_NAME = "syncedTabs" + +/** + * Provide autocomplete suggestions from synced tabs. + * + * @param syncedTabs [SyncedTabsStorage] containing the information about the available synced tabs. + * @param autocompletePriority Order in which this provider will be queried for autocomplete suggestions + * in relation ot others. + * - a lower priority means that this provider must be called before others with a higher priority. + * - an equal priority offers no ordering guarantees. + * + * Defaults to `0`. + */ +class SyncedTabsAutocompleteProvider( + private val syncedTabs: SyncedTabsStorage, + override val autocompletePriority: Int = 0, +) : AutocompleteProvider { + override suspend fun getAutocompleteSuggestion(query: String): AutocompleteResult? { + val tabUrl = syncedTabs + .getActiveDeviceTabs { doesUrlStartsWithText(it.url, query) } + .firstOrNull() + ?.tab?.url + ?: return null + + val resultText = segmentAwareDomainMatch(query, arrayListOf(tabUrl)) + return resultText?.let { + AutocompleteResult( + input = query, + text = it.matchedSegment, + url = it.url, + source = SYNCED_TABS_AUTOCOMPLETE_SOURCE_NAME, + totalItems = 1, + ) + } + } +} diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/SyncedTabsStorageSuggestionProvider.kt b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/SyncedTabsStorageSuggestionProvider.kt index 0e15ada3afd2..aa6a6adbf0cd 100644 --- a/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/SyncedTabsStorageSuggestionProvider.kt +++ b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/SyncedTabsStorageSuggestionProvider.kt @@ -7,11 +7,11 @@ import android.graphics.drawable.Drawable import androidx.annotation.VisibleForTesting import mozilla.components.browser.icons.BrowserIcons import mozilla.components.browser.icons.IconRequest -import mozilla.components.browser.storage.sync.TabEntry import mozilla.components.concept.awesomebar.AwesomeBar import mozilla.components.concept.awesomebar.AwesomeBar.Suggestion.Flag import mozilla.components.concept.sync.DeviceType import mozilla.components.feature.session.SessionUseCases +import mozilla.components.feature.syncedtabs.ext.getActiveDeviceTabs import mozilla.components.feature.syncedtabs.facts.emitSyncedTabSuggestionClickedFact import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl @@ -40,26 +40,12 @@ class SyncedTabsStorageSuggestionProvider( return emptyList() } - val results = mutableListOf() - for ((client, tabs) in syncedTabs.getSyncedDeviceTabs()) { - for (tab in tabs) { - val activeTabEntry = tab.active() - // This is a fairly naive match implementation, but this is what we do on Desktop 🤷. - if ((activeTabEntry.url.contains(text, ignoreCase = true) || - activeTabEntry.title.contains(text, ignoreCase = true)) && - resultsHostFilter?.equals(activeTabEntry.url.tryGetHostFromUrl()) != false - ) { - results.add( - ClientTabPair( - clientName = client.displayName, - tab = activeTabEntry, - lastUsed = tab.lastUsed, - deviceType = client.deviceType, - ), - ) - } - } + val results = syncedTabs.getActiveDeviceTabs { tab -> + // This is a fairly naive match implementation, but this is what we do on Desktop 🤷. + (tab.url.contains(text, ignoreCase = true) || tab.title.contains(text, ignoreCase = true)) && + resultsHostFilter?.equals(tab.url.tryGetHostFromUrl()) != false } + return results.sortedByDescending { it.lastUsed }.into() } @@ -105,10 +91,3 @@ data class DeviceIndicators( val mobile: Drawable? = null, val tablet: Drawable? = null, ) - -private data class ClientTabPair( - val clientName: String, - val tab: TabEntry, - val lastUsed: Long, - val deviceType: DeviceType, -) diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/ext/SyncedTabsStorage.kt b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/ext/SyncedTabsStorage.kt new file mode 100644 index 000000000000..0b8247365ac2 --- /dev/null +++ b/mobile/android/android-components/components/feature/syncedtabs/src/main/java/mozilla/components/feature/syncedtabs/ext/SyncedTabsStorage.kt @@ -0,0 +1,43 @@ +/* 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 mozilla.components.feature.syncedtabs.ext + +import mozilla.components.browser.storage.sync.TabEntry +import mozilla.components.feature.syncedtabs.ClientTabPair +import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage + +/** + * Get all the synced tabs that match the optional filter. + * + * @param limit How many synced tabs to query. A negative value will query all tabs. Defaults to `-1`. + * @param filter Optional filter for the active [TabEntry] of each tab. + */ +internal suspend fun SyncedTabsStorage.getActiveDeviceTabs( + limit: Int = -1, + filter: (TabEntry) -> Boolean = { true }, +): List { + if (limit == 0) return emptyList() + + return getSyncedDeviceTabs().fold(mutableListOf()) { result, (client, tabs) -> + tabs.forEach { tab -> + val activeTabEntry = tab.active() + if (filter(activeTabEntry)) { + result.add( + ClientTabPair( + clientName = client.displayName, + tab = activeTabEntry, + lastUsed = tab.lastUsed, + deviceType = client.deviceType, + ), + ) + + if (result.size == limit) { + return result + } + } + } + result + } +} diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/SyncedTabsAutocompleteProviderKtTest.kt b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/SyncedTabsAutocompleteProviderKtTest.kt new file mode 100644 index 000000000000..6f939958a6ef --- /dev/null +++ b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/SyncedTabsAutocompleteProviderKtTest.kt @@ -0,0 +1,68 @@ +/* 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 mozilla.components.feature.syncedtabs + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.browser.storage.sync.SyncedDeviceTabs +import mozilla.components.feature.syncedtabs.helper.getDevice1Tabs +import mozilla.components.feature.syncedtabs.helper.getDevice2Tabs +import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class SyncedTabsAutocompleteProviderKtTest { + private val syncedTabs: SyncedTabsStorage = mock() + + @Test + fun `GIVEN synced tabs exist WHEN asked for autocomplete suggestions THEN return the first matching tab`() = runTest { + val deviceTabs1 = getDevice1Tabs() + val deviceTabs2 = getDevice2Tabs() + doReturn(listOf(deviceTabs1, deviceTabs2)).`when`(syncedTabs).getSyncedDeviceTabs() + val provider = SyncedTabsAutocompleteProvider(syncedTabs) + + var suggestion = provider.getAutocompleteSuggestion("mozilla") + assertNull(suggestion) + + suggestion = provider.getAutocompleteSuggestion("foo") + assertNotNull(suggestion) + assertEquals("foo", suggestion?.input) + assertEquals("foo.bar", suggestion?.text) + assertEquals("https://foo.bar", suggestion?.url) + assertEquals(SYNCED_TABS_AUTOCOMPLETE_SOURCE_NAME, suggestion?.source) + assertEquals(1, suggestion?.totalItems) + + suggestion = provider.getAutocompleteSuggestion("obob") + assertNotNull(suggestion) + assertEquals("obob", suggestion?.input) + assertEquals("obob.bar", suggestion?.text) + assertEquals("https://obob.bar", suggestion?.url) + assertEquals(SYNCED_TABS_AUTOCOMPLETE_SOURCE_NAME, suggestion?.source) + assertEquals(1, suggestion?.totalItems) + } + + @Test + fun `GIVEN open tabs exist WHEN asked for autocomplete suggestions and only private tabs match THEN return null`() = runTest { + doReturn(emptyList()).`when`(syncedTabs).getSyncedDeviceTabs() + val provider = SyncedTabsAutocompleteProvider(syncedTabs) + + var suggestion = provider.getAutocompleteSuggestion("mozilla") + assertNull(suggestion) + + suggestion = provider.getAutocompleteSuggestion("foo") + assertNull(suggestion) + + suggestion = provider.getAutocompleteSuggestion("bar") + assertNull(suggestion) + } +} diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/SyncedTabsStorageSuggestionProviderTest.kt b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/SyncedTabsStorageSuggestionProviderTest.kt index 6c44c96cee0b..65f0440dab36 100644 --- a/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/SyncedTabsStorageSuggestionProviderTest.kt +++ b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/SyncedTabsStorageSuggestionProviderTest.kt @@ -7,12 +7,9 @@ package mozilla.components.feature.syncedtabs import android.graphics.drawable.Drawable import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.test.runTest -import mozilla.components.browser.storage.sync.SyncedDeviceTabs -import mozilla.components.browser.storage.sync.Tab -import mozilla.components.browser.storage.sync.TabEntry import mozilla.components.concept.awesomebar.AwesomeBar.Suggestion.Flag -import mozilla.components.concept.sync.Device -import mozilla.components.concept.sync.DeviceType +import mozilla.components.feature.syncedtabs.helper.getDevice1Tabs +import mozilla.components.feature.syncedtabs.helper.getDevice2Tabs import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl import mozilla.components.support.test.mock @@ -90,64 +87,3 @@ class SyncedTabsStorageSuggestionProviderTest { assertEquals(2, suggestions.map { it.description }.filter { it == "Foo Client" }.size) } } - -private fun getDevice1Tabs() = SyncedDeviceTabs( - Device( - id = "client1", - displayName = "Foo Client", - deviceType = DeviceType.DESKTOP, - isCurrentDevice = false, - lastAccessTime = null, - capabilities = listOf(), - subscriptionExpired = false, - subscription = null, - ), - listOf( - Tab( - listOf( - TabEntry("Foo", "https://foo.bar", null), /* active tab */ - TabEntry("Bobo", "https://foo.bar", null), - TabEntry("Foo", "https://bobo.bar", null), - ), - 0, - 1, - ), - Tab( - listOf( - TabEntry("Hello Bobo", "https://foo.bar", null), /* active tab */ - ), - 0, - 5, - ), - Tab( - listOf( - TabEntry("In URL", "https://bobo.bar", null), /* active tab */ - ), - 0, - 2, - ), - ), -) - -private fun getDevice2Tabs() = SyncedDeviceTabs( - Device( - id = "client2", - displayName = "Bar Client", - deviceType = DeviceType.MOBILE, - isCurrentDevice = false, - lastAccessTime = null, - capabilities = listOf(), - subscriptionExpired = false, - subscription = null, - ), - listOf( - Tab( - listOf( - TabEntry("Bar", "https://bar.bar", null), - TabEntry("BOBO in CAPS", "https://obob.bar", null), /* active tab */ - ), - 1, - 1, - ), - ), -) diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/ext/SyncedTabsStorageKtTest.kt b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/ext/SyncedTabsStorageKtTest.kt new file mode 100644 index 000000000000..def4a3479de6 --- /dev/null +++ b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/ext/SyncedTabsStorageKtTest.kt @@ -0,0 +1,65 @@ +/* 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 mozilla.components.feature.syncedtabs.ext + +import kotlinx.coroutines.test.runTest +import mozilla.components.feature.syncedtabs.helper.getDevice1Tabs +import mozilla.components.feature.syncedtabs.helper.getDevice2Tabs +import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.mockito.Mockito.doReturn + +class SyncedTabsStorageKtTest { + private val syncedTabs: SyncedTabsStorage = mock() + + @Test + fun `GIVEN synced tabs exist WHEN asked for active device tabs THEN return all tabs`() = runTest { + val device1Tabs = getDevice1Tabs() + val device2Tabs = getDevice2Tabs() + doReturn(listOf(device1Tabs, device2Tabs)).`when`(syncedTabs).getSyncedDeviceTabs() + + val result = syncedTabs.getActiveDeviceTabs() + assertNotNull(result) + assertEquals(4, result.size) + assertEquals(3, result.filter { it.clientName == device1Tabs.device.displayName }.size) + assertEquals(1, result.filter { it.clientName == device2Tabs.device.displayName }.size) + } + + @Test + fun `GIVEN synced tabs exist WHEN asked for a lower number of active device tabs THEN return tabs up to that number`() = runTest { + val device1Tabs = getDevice1Tabs() + val device2Tabs = getDevice2Tabs() + doReturn(listOf(device1Tabs, device2Tabs)).`when`(syncedTabs).getSyncedDeviceTabs() + + var result = syncedTabs.getActiveDeviceTabs(2) + assertNotNull(result) + assertEquals(2, result.size) + assertEquals(2, result.filter { it.clientName == device1Tabs.device.displayName }.size) + + result = syncedTabs.getActiveDeviceTabs(7) + assertNotNull(result) + assertEquals(4, result.size) + assertEquals(3, result.filter { it.clientName == device1Tabs.device.displayName }.size) + assertEquals(1, result.filter { it.clientName == device2Tabs.device.displayName }.size) + } + + @Test + fun `GIVEN synced tabs exist WHEN asked for active device tabs and a filter is passed THEN return all tabs matching the filter`() = runTest { + val device1Tabs = getDevice1Tabs() + val device2Tabs = getDevice2Tabs() + doReturn(listOf(device1Tabs, device2Tabs)).`when`(syncedTabs).getSyncedDeviceTabs() + val filteredTitle = device1Tabs.tabs[0].active().title + + val result = syncedTabs.getActiveDeviceTabs { + it.title == filteredTitle + } + assertNotNull(result) + assertEquals(1, result.size) + assertEquals(filteredTitle, result[0].tab.title) + } +} diff --git a/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/helper/SyncedTabsProvider.kt b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/helper/SyncedTabsProvider.kt new file mode 100644 index 000000000000..f2d8aa6a9813 --- /dev/null +++ b/mobile/android/android-components/components/feature/syncedtabs/src/test/java/mozilla/components/feature/syncedtabs/helper/SyncedTabsProvider.kt @@ -0,0 +1,79 @@ +/* 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 mozilla.components.feature.syncedtabs.helper + +import mozilla.components.browser.storage.sync.SyncedDeviceTabs +import mozilla.components.browser.storage.sync.Tab +import mozilla.components.browser.storage.sync.TabEntry +import mozilla.components.concept.sync.Device +import mozilla.components.concept.sync.DeviceType.DESKTOP +import mozilla.components.concept.sync.DeviceType.MOBILE + +/** + * Get fake tabs from a fake desktop device. + */ +internal fun getDevice1Tabs() = SyncedDeviceTabs( + Device( + id = "client1", + displayName = "Foo Client", + deviceType = DESKTOP, + isCurrentDevice = false, + lastAccessTime = null, + capabilities = listOf(), + subscriptionExpired = false, + subscription = null, + ), + listOf( + Tab( + listOf( + TabEntry("Foo", "https://foo.bar", null), /* active tab */ + TabEntry("Bobo", "https://foo.bar", null), + TabEntry("Foo", "https://bobo.bar", null), + ), + 0, + 1, + ), + Tab( + listOf( + TabEntry("Hello Bobo", "https://foo.bar", null), /* active tab */ + ), + 0, + 5, + ), + Tab( + listOf( + TabEntry("In URL", "https://bobo.bar", null), /* active tab */ + ), + 0, + 2, + ), + ), +) + +/** + * Get fake tabs from a fake mobile device. + */ +internal fun getDevice2Tabs() = SyncedDeviceTabs( + Device( + id = "client2", + displayName = "Bar Client", + deviceType = MOBILE, + isCurrentDevice = false, + lastAccessTime = null, + capabilities = listOf(), + subscriptionExpired = false, + subscription = null, + ), + listOf( + Tab( + listOf( + TabEntry("Bar", "https://bar.bar", null), + TabEntry("BOBO in CAPS", "https://obob.bar", null), /* active tab */ + ), + 1, + 1, + ), + ), +) diff --git a/mobile/android/android-components/components/support/utils/src/main/java/mozilla/components/support/utils/DomainMatcher.kt b/mobile/android/android-components/components/support/utils/src/main/java/mozilla/components/support/utils/DomainMatcher.kt index 98f02927fbd4..867499eb3393 100644 --- a/mobile/android/android-components/components/support/utils/src/main/java/mozilla/components/support/utils/DomainMatcher.kt +++ b/mobile/android/android-components/components/support/utils/src/main/java/mozilla/components/support/utils/DomainMatcher.kt @@ -27,37 +27,47 @@ fun segmentAwareDomainMatch(query: String, urls: Iterable): DomainMatch? } } -@SuppressWarnings("ReturnCount") +/** + * Check if [url] starts with the exact [text] or if [url]'s domain start with the exact [text]. + * + * @return `true` if [url] starts with [text], `false` otherwise. + */ +fun doesUrlStartsWithText(url: String, text: String) = findUrlMatchingText(url, text) != null + private fun basicMatch(query: String, urls: Sequence): String? { - for (rawUrl in urls) { - if (rawUrl.startsWith(query)) { - return rawUrl - } + return urls.firstOrNull { findUrlMatchingText(it, query) != null } +} - val url = try { - Uri.parse(rawUrl) - } catch (e: MalformedURLException) { - null - } +@SuppressWarnings("ReturnCount") +private fun findUrlMatchingText(url: String, text: String): String? { + if (url.startsWith(text)) { + return url + } - var urlSansProtocol = url?.host - urlSansProtocol += url?.port?.orEmpty() + url?.path - urlSansProtocol?.let { - if (it.startsWith(query) || it.noCommonSubdomains().startsWith(query)) { - return rawUrl - } + val uri = try { + Uri.parse(url) + } catch (e: MalformedURLException) { + null + } + + var urlSansProtocol = uri?.host + urlSansProtocol += uri?.port?.orEmpty() + uri?.path + urlSansProtocol?.let { + if (it.startsWith(text) || it.noCommonSubdomains().startsWith(text)) { + return url } + } - val host = url?.host ?: "" + val host = uri?.host ?: "" - if (host.startsWith(query)) { - return rawUrl - } + if (host.startsWith(text)) { + return url + } - if (host.noCommonSubdomains().startsWith(query)) { - return rawUrl - } + if (host.noCommonSubdomains().startsWith(text)) { + return url } + return null } diff --git a/mobile/android/android-components/samples/sync/build.gradle b/mobile/android/android-components/samples/sync/build.gradle index 40266c8a89de..7de3a67abac6 100644 --- a/mobile/android/android-components/samples/sync/build.gradle +++ b/mobile/android/android-components/samples/sync/build.gradle @@ -40,6 +40,7 @@ android { dependencies { implementation project(':concept-storage') + implementation project(':concept-toolbar') implementation project(':browser-storage-sync') implementation project(':service-firefox-accounts') implementation project(':service-sync-logins') diff --git a/mobile/android/docs/changelog.md b/mobile/android/docs/changelog.md index e67b75110fe7..c2a2ce6700ce 100644 --- a/mobile/android/docs/changelog.md +++ b/mobile/android/docs/changelog.md @@ -9,6 +9,11 @@ permalink: /changelog/ * [Gecko](https://github.com/mozilla-mobile/firefox-android/blob/main/android-components/plugins/dependencies/src/main/java/Gecko.kt) * [Configuration](https://github.com/mozilla-mobile/firefox-android/blob/main/android-components/.config.yml) +* **browser-storage-sync**: +* **feature-awesomebar** +* **feature-syncedtabs** + * 🆕 [Bug #1800268](https://bugzilla.mozilla.org/show_bug.cgi?id=1800268) New autocomplete providers for bookmarks, local tabs or synced tabs that can be set for `ToolbarAutocompleteFeature`. + * **feature-toolbar** * ⚠️ **This is a breaking change**: `ToolbarAutocompleteFeature` has a new API for updating at any time `AutocompleteProvider` (add or remove any of them) individually or in bulk. This change allows supporting any instance and any number of autocomplete providers. [bug #1800268](https://bugzilla.mozilla.org/show_bug.cgi?id=1800268) -- 2.11.4.GIT