Skip to content

Commit b90c93d

Browse files
SERP Easter Egg Logos in Omnibar (#6633)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1207908166761516/task/1211153151373086?focus=true ### Description This PR adds support for displaying special Easter Egg logos on DuckDuckGo search results pages. When a user performs a search that triggers an Easter Egg logo, the app will extract and display it in the omnibar, and allow users to view an enlarged version by tapping on it. Enabled only on internal builds for testing. ## Steps to test this PR Ensure to test on at least 2 Android versions, one below 30 and above 30 should suffice. _Happy path_ - [x] Enable the `serpEasterEggLogos` feature flag (It should be enabled by default if using an internal build) - [x] Search for a term that has an Easter Egg logo (e.g., "predator", "terminator", "android" etc.) - [x] Verify the special logo appears in the omnibar - [x] Tap on the logo to see the enlarged version - [x] Tap anywhere on the screen to dismiss the enlarged view - [x] Navigate away from the SERP page and verify the logo is replaced with the standard DuckDuckGo logo _Search term edits_ - [x] Search for a term that has an Easter Egg logo (e.g., "predator", "terminator", "android" etc.) - [x] Verify the special logo appears in the omnibar - [x] Click on the omnibar - [x] Press the back arrow - [x] The special logo should still be there - [x] Click on the omnibar - [x] Delete some letters - [x] Press the back arrow - [x] The special logo should still be there - [x] Click on the omnibar - [x] Delete all text via X - [x] Press the back arrow - [x] The special logo should still be there - [x] Click on the omnibar - [x] Enter a search term without special logo e.g. "monkeys" - [x] Verify the logo is replaced with the standard DuckDuckGo logo ## UI changes [See Ship Review main task](https://app.asana.com/1/137249556945/project/1142021229838617/task/1211147072333522?focus=true) --------- Co-authored-by: Dax The Translator <[email protected]>
1 parent 275ddce commit b90c93d

File tree

56 files changed

+2045
-103
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+2045
-103
lines changed

app/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,9 @@ dependencies {
406406
implementation project(':dax-prompts-api')
407407
implementation project(':dax-prompts-impl')
408408

409+
implementation project(':serp-logos-api')
410+
implementation project(':serp-logos-impl')
411+
409412
// Deprecated. TODO: Stop using this artifact.
410413
implementation "androidx.legacy:legacy-support-v4:_"
411414
debugImplementation Square.leakCanary.android

app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,8 @@ import com.duckduckgo.savedsites.api.SavedSitesRepository
279279
import com.duckduckgo.savedsites.api.models.SavedSite.Bookmark
280280
import com.duckduckgo.savedsites.api.models.SavedSite.Favorite
281281
import com.duckduckgo.savedsites.impl.SavedSitesPixelName
282+
import com.duckduckgo.serp.logos.api.SerpEasterEggLogosToggles
283+
import com.duckduckgo.serp.logos.api.SerpLogo
282284
import com.duckduckgo.site.permissions.api.SitePermissionsManager
283285
import com.duckduckgo.site.permissions.api.SitePermissionsManager.LocationPermissionRequest
284286
import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissionQueryResponse
@@ -580,6 +582,7 @@ class BrowserTabViewModelTest {
580582
}
581583

582584
private val mockOnboardingDesignExperimentManager: OnboardingDesignExperimentManager = mock()
585+
private val mockSerpEasterEggLogoToggles: SerpEasterEggLogosToggles = mock()
583586

584587
private val EXAMPLE_URL = "http://example.com"
585588
private val SHORT_EXAMPLE_URL = "example.com"
@@ -648,6 +651,7 @@ class BrowserTabViewModelTest {
648651
whenever(mockOnboardingDesignExperimentManager.isModifiedControlEnrolledAndEnabled()).thenReturn(false)
649652
whenever(mockOnboardingDesignExperimentManager.isBuckEnrolledAndEnabled()).thenReturn(false)
650653
whenever(mockOnboardingDesignExperimentManager.isBbEnrolledAndEnabled()).thenReturn(false)
654+
whenever(mockSerpEasterEggLogoToggles.feature()).thenReturn(mockDisabledToggle)
651655

652656
remoteMessagingModel = givenRemoteMessagingModel(mockRemoteMessagingRepository, mockPixel, coroutineRule.testDispatcherProvider)
653657

@@ -793,6 +797,7 @@ class BrowserTabViewModelTest {
793797
tabManager = tabManager,
794798
addressDisplayFormatter = mockAddressDisplayFormatter,
795799
autoCompleteSettings = mockAutoCompleteSettings,
800+
serpEasterEggLogosToggles = mockSerpEasterEggLogoToggles,
796801
)
797802

798803
testee.loadData("abc", null, false, false)
@@ -7134,6 +7139,64 @@ class BrowserTabViewModelTest {
71347139
)
71357140
}
71367141

7142+
@Test
7143+
fun whenEvaluateSerpLogoStateCalledWithDuckDuckGoUrlAndFeatureEnabledThenExtractSerpLogoCommandIssued() {
7144+
whenever(mockSerpEasterEggLogoToggles.feature()).thenReturn(mockEnabledToggle)
7145+
val ddgUrl = "https://duckduckgo.com/?q=test"
7146+
val webViewNavState = WebViewNavigationState(mockStack, 100)
7147+
7148+
testee.pageFinished(webViewNavState, ddgUrl)
7149+
7150+
verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture())
7151+
val commands = commandCaptor.allValues
7152+
assertTrue(
7153+
"ExtractSerpLogo command should be issued when SERP logos feature is enabled and URL is DuckDuckGo query",
7154+
commands.any { it is Command.ExtractSerpLogo && it.currentUrl == ddgUrl },
7155+
)
7156+
}
7157+
7158+
@Test
7159+
fun whenEvaluateSerpLogoStateCalledWithDuckDuckGoUrlAndFeatureDisabledThenExtractSerpLogoCommandNotIssued() {
7160+
whenever(mockSerpEasterEggLogoToggles.feature()).thenReturn(mockDisabledToggle)
7161+
val ddgUrl = "https://duckduckgo.com/?q=test"
7162+
val webViewNavState = WebViewNavigationState(mockStack, 100)
7163+
7164+
testee.pageFinished(webViewNavState, ddgUrl)
7165+
7166+
val commands = commandCaptor.allValues
7167+
assertFalse(
7168+
"ExtractSerpLogo command should NOT be issued when SERP logos feature is disabled",
7169+
commands.any { it is Command.ExtractSerpLogo },
7170+
)
7171+
}
7172+
7173+
@Test
7174+
fun whenEvaluateSerpLogoStateCalledWithNonDuckDuckGoUrlAndFeatureEnabledThenExtractSerpLogoCommandNotIssued() {
7175+
whenever(mockSerpEasterEggLogoToggles.feature()).thenReturn(mockEnabledToggle)
7176+
val nonDdgUrl = "https://example.com/search?q=test"
7177+
val webViewNavState = WebViewNavigationState(mockStack, 100)
7178+
7179+
testee.pageFinished(webViewNavState, nonDdgUrl)
7180+
7181+
val commands = commandCaptor.allValues
7182+
assertFalse(
7183+
"ExtractSerpLogo command should NOT be issued for non-DuckDuckGo URLs even when feature is enabled",
7184+
commands.any { it is Command.ExtractSerpLogo },
7185+
)
7186+
}
7187+
7188+
@Test
7189+
fun whenEvaluateSerpLogoStateCalledWithNonDuckDuckGoUrlAndFeatureEnabledThenSerpLogoIsCleared() {
7190+
whenever(mockSerpEasterEggLogoToggles.feature()).thenReturn(mockEnabledToggle)
7191+
val nonDdgUrl = "https://example.com/search?q=test"
7192+
val webViewNavState = WebViewNavigationState(mockStack, 100)
7193+
7194+
testee.omnibarViewState.value = omnibarViewState().copy(serpLogo = SerpLogo.EasterEgg("some-logo-url"))
7195+
testee.pageFinished(webViewNavState, nonDdgUrl)
7196+
7197+
assertNull("SERP logo should be cleared when navigating to non-DuckDuckGo URL", omnibarViewState().serpLogo)
7198+
}
7199+
71377200
private fun aCredential(): LoginCredentials {
71387201
return LoginCredentials(domain = null, username = null, password = null)
71397202
}

app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import androidx.core.net.toUri
7878
import androidx.core.text.HtmlCompat
7979
import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY
8080
import androidx.core.text.toSpannable
81+
import androidx.core.view.ViewCompat
8182
import androidx.core.view.isGone
8283
import androidx.core.view.isVisible
8384
import androidx.core.view.postDelayed
@@ -320,6 +321,8 @@ import com.duckduckgo.savedsites.api.models.SavedSitesNames
320321
import com.duckduckgo.savedsites.impl.bookmarks.BookmarksBottomSheetDialog
321322
import com.duckduckgo.savedsites.impl.bookmarks.FaviconPromptSheet
322323
import com.duckduckgo.savedsites.impl.dialogs.EditSavedSiteDialogFragment
324+
import com.duckduckgo.serp.logos.api.SerpLogoScreens.*
325+
import com.duckduckgo.serp.logos.api.SerpLogos
323326
import com.duckduckgo.site.permissions.api.SitePermissionsDialogLauncher
324327
import com.duckduckgo.site.permissions.api.SitePermissionsGrantedListener
325328
import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissions
@@ -438,6 +441,9 @@ class BrowserTabFragment :
438441
@Inject
439442
lateinit var browserAutofill: BrowserAutofill
440443

444+
@Inject
445+
lateinit var serpLogos: SerpLogos
446+
441447
@Inject
442448
lateinit var faviconManager: FaviconManager
443449

@@ -1060,6 +1066,17 @@ class BrowserTabFragment :
10601066
configureCustomTab()
10611067
configureEditModeChangeDetection()
10621068
configureInputScreenLauncher()
1069+
configureLogoClickListener()
1070+
}
1071+
1072+
private fun configureLogoClickListener() {
1073+
omnibar.configureLogoClickListener(
1074+
object : Omnibar.LogoClickListener {
1075+
override fun onClick(url: String) {
1076+
viewModel.onDynamicLogoClicked(url)
1077+
}
1078+
},
1079+
)
10631080
}
10641081

10651082
private fun configureInputScreenLauncher() {
@@ -2171,12 +2188,24 @@ class BrowserTabFragment :
21712188
launchInputScreen(query = "")
21722189
}
21732190
}
2174-
else -> {
2191+
is Command.ExtractSerpLogo -> extractSerpLogo(webView, it.currentUrl)
2192+
is Command.ShowSerpEasterEggLogo -> launchSerpEasterEggLogoActivity(it.logoUrl)
2193+
null -> {
21752194
// NO OP
21762195
}
21772196
}
21782197
}
21792198

2199+
private fun extractSerpLogo(webView: WebView?, url: String) {
2200+
lifecycleScope.launch {
2201+
webView?.let {
2202+
val serpLogo = serpLogos.extractSerpLogo(webView = webView)
2203+
logcat { "Serp logo extracted: $serpLogo" }
2204+
viewModel.onLogoReceived(serpLogo)
2205+
}
2206+
}
2207+
}
2208+
21802209
private fun showAutoconsentAnimation(isCosmetic: Boolean) {
21812210
launch {
21822211
if (isCosmetic) {
@@ -4783,6 +4812,20 @@ class BrowserTabFragment :
47834812
fun launchTabSwitcherAfterTabsUndeleted() {
47844813
viewModel.onLaunchTabSwitcherAfterTabsUndeletedRequest()
47854814
}
4815+
4816+
private fun launchSerpEasterEggLogoActivity(logoUrl: String) {
4817+
ViewCompat.setTransitionName(omnibar.daxIcon, logoUrl)
4818+
val activityOptions = ActivityOptionsCompat.makeSceneTransitionAnimation(
4819+
requireActivity(),
4820+
omnibar.daxIcon,
4821+
logoUrl,
4822+
).toBundle()
4823+
globalActivityStarter.start(
4824+
context = requireContext(),
4825+
params = EasterEggLogoScreen(logoUrl = logoUrl, transitionName = logoUrl),
4826+
options = activityOptions,
4827+
)
4828+
}
47864829
}
47874830

47884831
private class JsOrientationHandler {

app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import com.duckduckgo.app.browser.commands.Command.DownloadImage
8888
import com.duckduckgo.app.browser.commands.Command.EditWithSelectedQuery
8989
import com.duckduckgo.app.browser.commands.Command.EmailSignEvent
9090
import com.duckduckgo.app.browser.commands.Command.EscapeMaliciousSite
91+
import com.duckduckgo.app.browser.commands.Command.ExtractSerpLogo
9192
import com.duckduckgo.app.browser.commands.Command.ExtractUrlFromCloakedAmpLink
9293
import com.duckduckgo.app.browser.commands.Command.FindInPageCommand
9394
import com.duckduckgo.app.browser.commands.Command.GenerateWebViewPreviewImage
@@ -190,7 +191,6 @@ import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter
190191
import com.duckduckgo.app.browser.omnibar.QueryOrigin
191192
import com.duckduckgo.app.browser.omnibar.QueryOrigin.FromAutocomplete
192193
import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition
193-
import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.BOTTOM
194194
import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.TOP
195195
import com.duckduckgo.app.browser.refreshpixels.RefreshPixelSender
196196
import com.duckduckgo.app.browser.senseofprotection.SenseOfProtectionExperiment
@@ -344,6 +344,8 @@ import com.duckduckgo.savedsites.api.models.SavedSite.Favorite
344344
import com.duckduckgo.savedsites.impl.SavedSitesPixelName
345345
import com.duckduckgo.savedsites.impl.dialogs.EditSavedSiteDialogFragment.DeleteBookmarkListener
346346
import com.duckduckgo.savedsites.impl.dialogs.EditSavedSiteDialogFragment.EditSavedSiteListener
347+
import com.duckduckgo.serp.logos.api.SerpEasterEggLogosToggles
348+
import com.duckduckgo.serp.logos.api.SerpLogo
347349
import com.duckduckgo.site.permissions.api.SitePermissionsManager
348350
import com.duckduckgo.site.permissions.api.SitePermissionsManager.LocationPermissionRequest
349351
import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissionQueryResponse
@@ -473,6 +475,7 @@ class BrowserTabViewModel @Inject constructor(
473475
private val tabManager: TabManager,
474476
private val addressDisplayFormatter: AddressDisplayFormatter,
475477
private val onboardingDesignExperimentManager: OnboardingDesignExperimentManager,
478+
private val serpEasterEggLogosToggles: SerpEasterEggLogosToggles,
476479
) : WebViewClientListener,
477480
EditSavedSiteListener,
478481
DeleteBookmarkListener,
@@ -1504,10 +1507,6 @@ class BrowserTabViewModel @Inject constructor(
15041507

15051508
if (!currentBrowserViewState().browserShowing) return
15061509

1507-
if (settingsDataStore.omnibarPosition == BOTTOM) {
1508-
showOmniBar()
1509-
}
1510-
15111510
canAutofillSelectCredentialsDialogCanAutomaticallyShow = true
15121511

15131512
browserViewState.value = currentBrowserViewState().copy(
@@ -1610,6 +1609,7 @@ class BrowserTabViewModel @Inject constructor(
16101609
queryOrFullUrl = omnibarTextForUrl(url, true),
16111610
omnibarText = omnibarTextForUrl(url, settingsDataStore.isFullUrlEnabled),
16121611
forceExpand = true,
1612+
serpLogo = null,
16131613
)
16141614
val currentBrowserViewState = currentBrowserViewState()
16151615
val domain = site?.domain
@@ -1912,6 +1912,18 @@ class BrowserTabViewModel @Inject constructor(
19121912
viewModelScope.launch {
19131913
onboardingDesignExperimentManager.onWebPageFinishedLoading(url)
19141914
}
1915+
1916+
evaluateSerpLogoState(url)
1917+
}
1918+
}
1919+
1920+
private fun evaluateSerpLogoState(url: String?) {
1921+
if (serpEasterEggLogosToggles.feature().isEnabled()) {
1922+
if (url != null && duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(url)) {
1923+
command.value = ExtractSerpLogo(url)
1924+
} else {
1925+
omnibarViewState.value = currentOmnibarViewState().copy(serpLogo = null)
1926+
}
19151927
}
19161928
}
19171929

@@ -3986,12 +3998,6 @@ class BrowserTabViewModel @Inject constructor(
39863998
}
39873999
}
39884000

3989-
private fun showOmniBar() {
3990-
omnibarViewState.value = currentOmnibarViewState().copy(
3991-
navigationChange = true,
3992-
)
3993-
}
3994-
39954001
fun onUserDismissedAutoCompleteInAppMessage() {
39964002
viewModelScope.launch(dispatchers.io()) {
39974003
autoComplete.userDismissedHistoryInAutoCompleteIAM()
@@ -4300,6 +4306,14 @@ class BrowserTabViewModel @Inject constructor(
43004306
}
43014307
}
43024308

4309+
fun onLogoReceived(serpLogo: SerpLogo) {
4310+
omnibarViewState.value = currentOmnibarViewState().copy(serpLogo = serpLogo)
4311+
}
4312+
4313+
fun onDynamicLogoClicked(url: String) {
4314+
command.value = Command.ShowSerpEasterEggLogo(url)
4315+
}
4316+
43034317
companion object {
43044318
private const val FIXED_PROGRESS = 50
43054319

app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,4 +279,6 @@ sealed class Command {
279279
data object LaunchBookmarksActivity : Command()
280280
data object RefreshOmnibar : Command()
281281
data object LaunchInputScreen : Command()
282+
data class ExtractSerpLogo(val currentUrl: String) : Command()
283+
data class ShowSerpEasterEggLogo(val logoUrl: String) : Command()
282284
}

app/src/main/java/com/duckduckgo/app/browser/omnibar/Omnibar.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import android.annotation.SuppressLint
2020
import android.text.Editable
2121
import android.view.MotionEvent
2222
import android.view.View
23+
import android.widget.ImageView
2324
import androidx.appcompat.widget.Toolbar
2425
import androidx.coordinatorlayout.widget.CoordinatorLayout
2526
import androidx.core.view.postDelayed
@@ -127,6 +128,10 @@ class Omnibar(
127128
)
128129
}
129130

131+
interface LogoClickListener {
132+
fun onClick(url: String)
133+
}
134+
130135
data class OmnibarTextState(
131136
val text: String,
132137
val hasFocus: Boolean,
@@ -202,6 +207,10 @@ class Omnibar(
202207
newOmnibar.shieldIcon
203208
}
204209

210+
val daxIcon: ImageView by lazy {
211+
newOmnibar.daxIcon
212+
}
213+
205214
val textInputRootView: View by lazy {
206215
newOmnibar.omnibarTextInput.rootView
207216
}
@@ -252,6 +261,10 @@ class Omnibar(
252261
newOmnibar.setOmnibarItemPressedListener(listener)
253262
}
254263

264+
fun configureLogoClickListener(logoClickListener: LogoClickListener) {
265+
newOmnibar.setLogoClickListener(logoClickListener)
266+
}
267+
255268
fun configureOmnibarItemPressedListeners(listener: OmnibarItemPressedListener) {
256269
val omnibar = newOmnibar
257270
if (omnibar is SingleOmnibarLayout) {

0 commit comments

Comments
 (0)