diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index 59f0296e1d4d..9f5ba3627ed8 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -102,6 +102,8 @@ import com.duckduckgo.app.browser.model.LongPressTarget import com.duckduckgo.app.browser.newtab.FavoritesQuickAccessAdapter import com.duckduckgo.app.browser.newtab.FavoritesQuickAccessAdapter.QuickAccessFavorite import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.BOTTOM +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.TOP import com.duckduckgo.app.browser.remotemessage.RemoteMessagingModel import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.browser.viewstate.BrowserViewState @@ -197,7 +199,13 @@ import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.history.api.HistoryEntry.VisitedPage import com.duckduckgo.history.api.NavigationHistory import com.duckduckgo.newtabpage.impl.pixels.NewTabPixels -import com.duckduckgo.privacy.config.api.* +import com.duckduckgo.privacy.config.api.AmpLinkInfo +import com.duckduckgo.privacy.config.api.AmpLinks +import com.duckduckgo.privacy.config.api.ContentBlocking +import com.duckduckgo.privacy.config.api.GpcException +import com.duckduckgo.privacy.config.api.PrivacyFeatureName +import com.duckduckgo.privacy.config.api.TrackingParameters +import com.duckduckgo.privacy.config.api.UnprotectedTemporary import com.duckduckgo.privacy.config.impl.features.gpc.RealGpc import com.duckduckgo.privacy.config.impl.features.gpc.RealGpc.Companion.GPC_HEADER import com.duckduckgo.privacy.config.impl.features.gpc.RealGpc.Companion.GPC_HEADER_VALUE @@ -247,7 +255,14 @@ import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.json.JSONObject import org.junit.After -import org.junit.Assert.* +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -258,11 +273,15 @@ import org.mockito.Mockito.times import org.mockito.Mockito.verify import org.mockito.MockitoAnnotations import org.mockito.internal.util.DefaultMockingDetails -import org.mockito.kotlin.* +import org.mockito.kotlin.KArgumentCaptor import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq +import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @FlowPreview @@ -493,6 +512,7 @@ class BrowserTabViewModelTest { whenever(mockSavedSitesRepository.getBookmarks()).thenReturn(bookmarksListFlow.consumeAsFlow()) whenever(mockRemoteMessagingRepository.messageFlow()).thenReturn(remoteMessageFlow.consumeAsFlow()) whenever(mockSettingsDataStore.automaticFireproofSetting).thenReturn(AutomaticFireproofSetting.ASK_EVERY_TIME) + whenever(mockSettingsDataStore.omnibarPosition).thenReturn(TOP) whenever(androidBrowserConfig.screenLock()).thenReturn(mockEnabledToggle) whenever(mockSSLCertificatesFeature.allowBypass()).thenReturn(mockEnabledToggle) whenever(mockExtendedOnboardingFeatureToggles.aestheticUpdates()).thenReturn(mockEnabledToggle) @@ -5167,11 +5187,20 @@ class BrowserTabViewModelTest { } @Test - fun whenRefreshIsTriggeredByUserThenPrivacyProtectionsPopupManagerIsNotified() = runTest { + fun whenRefreshIsTriggeredByUserThenPrivacyProtectionsPopupManagerIsNotifiedWithTopPosition() = runTest { testee.onRefreshRequested(triggeredByUser = false) - verify(mockPrivacyProtectionsPopupManager, never()).onPageRefreshTriggeredByUser() + verify(mockPrivacyProtectionsPopupManager, never()).onPageRefreshTriggeredByUser(isOmnibarAtTheTop = true) testee.onRefreshRequested(triggeredByUser = true) - verify(mockPrivacyProtectionsPopupManager).onPageRefreshTriggeredByUser() + verify(mockPrivacyProtectionsPopupManager).onPageRefreshTriggeredByUser(isOmnibarAtTheTop = true) + } + + @Test + fun whenRefreshIsTriggeredByUserThenPrivacyProtectionsPopupManagerIsNotifiedWithBottomPosition() = runTest { + whenever(mockSettingsDataStore.omnibarPosition).thenReturn(BOTTOM) + testee.onRefreshRequested(triggeredByUser = false) + verify(mockPrivacyProtectionsPopupManager, never()).onPageRefreshTriggeredByUser(isOmnibarAtTheTop = false) + testee.onRefreshRequested(triggeredByUser = true) + verify(mockPrivacyProtectionsPopupManager).onPageRefreshTriggeredByUser(isOmnibarAtTheTop = false) } @Test @@ -5459,7 +5488,7 @@ class BrowserTabViewModelTest { @Test fun whenTrackersBlockedCtaShownThenPrivacyShieldIsHighlighted() = runTest { - val cta = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, emptyList()) + val cta = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, emptyList(), mockSettingsDataStore) testee.ctaViewState.value = ctaViewState().copy(cta = cta) testee.onOnboardingDaxTypingAnimationFinished() @@ -5469,7 +5498,7 @@ class BrowserTabViewModelTest { @Test fun givenPrivacyShieldHighlightedWhenShieldIconSelectedThenStopPulse() = runTest { - val cta = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, emptyList()) + val cta = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, emptyList(), mockSettingsDataStore) testee.ctaViewState.value = ctaViewState().copy(cta = cta) testee.onPrivacyShieldSelected() @@ -5488,7 +5517,7 @@ class BrowserTabViewModelTest { @Test fun whenUserDismissDaxTrackersBlockedDialogThenFinishPrivacyShieldPulse() { - val cta = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, emptyList()) + val cta = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, emptyList(), mockSettingsDataStore) setCta(cta) testee.onUserDismissedCta(cta) diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/TestBackForwardList.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/TestBackForwardList.kt new file mode 100644 index 000000000000..54352fcdc115 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/TestBackForwardList.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser + +import android.webkit.WebBackForwardList +import android.webkit.WebHistoryItem + +class TestBackForwardList : WebBackForwardList() { + private val fakeHistory: MutableList = mutableListOf() + private var fakeCurrentIndex = -1 + + fun addPageToHistory(webHistoryItem: WebHistoryItem) { + fakeHistory.add(webHistoryItem) + fakeCurrentIndex++ + } + + override fun getSize() = fakeHistory.size + + override fun getItemAtIndex(index: Int): WebHistoryItem = fakeHistory[index] + + override fun getCurrentItem(): WebHistoryItem? = null + + override fun getCurrentIndex(): Int = fakeCurrentIndex + + override fun clone(): WebBackForwardList = throw NotImplementedError() +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt index 112d9b18d791..3b4d81d69a5a 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt @@ -674,7 +674,7 @@ class CtaViewModelTest { @Test fun whenCtaShownIfCtaIsMarkedAsReadOnShowThenCtaInsertedInDatabase() { - testee.onCtaShown(OnboardingDaxDialogCta.DaxEndCta(mockOnboardingStore, mockAppInstallStore)) + testee.onCtaShown(OnboardingDaxDialogCta.DaxEndCta(mockOnboardingStore, mockAppInstallStore, mockSettingsDataStore)) verify(mockDismissedCtaDao).insert(DismissedCta(CtaId.DAX_END)) } diff --git a/app/src/main/java/com/duckduckgo/app/appearance/AppearanceActivity.kt b/app/src/main/java/com/duckduckgo/app/appearance/AppearanceActivity.kt index f9bf820f4781..06855af2ca2a 100644 --- a/app/src/main/java/com/duckduckgo/app/appearance/AppearanceActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/appearance/AppearanceActivity.kt @@ -25,14 +25,21 @@ import androidx.lifecycle.lifecycleScope import com.duckduckgo.anvil.annotations.ContributeToActivityStarter import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.appearance.AppearanceViewModel.Command +import com.duckduckgo.app.appearance.AppearanceViewModel.Command.LaunchAppIcon +import com.duckduckgo.app.appearance.AppearanceViewModel.Command.LaunchOmnibarPositionSettings +import com.duckduckgo.app.appearance.AppearanceViewModel.Command.LaunchThemeSettings +import com.duckduckgo.app.appearance.AppearanceViewModel.Command.UpdateTheme import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.databinding.ActivityAppearanceBinding +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition import com.duckduckgo.app.fire.FireActivity import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.common.ui.DuckDuckGoTheme import com.duckduckgo.common.ui.sendThemeChangedBroadcast import com.duckduckgo.common.ui.view.dialog.RadioListAlertDialogBuilder import com.duckduckgo.common.ui.view.dialog.TextAlertDialogBuilder +import com.duckduckgo.common.ui.view.gone +import com.duckduckgo.common.ui.view.show import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.di.scopes.ActivityScope import kotlinx.coroutines.flow.launchIn @@ -46,7 +53,7 @@ class AppearanceActivity : DuckDuckGoActivity() { private val viewModel: AppearanceViewModel by bindViewModel() private val binding: ActivityAppearanceBinding by viewBinding() - private val forceDarkModeToggleListener = CompoundButton.OnCheckedChangeListener { view, isChecked -> + private val forceDarkModeToggleListener = CompoundButton.OnCheckedChangeListener { _, isChecked -> viewModel.onForceDarkModeSettingChanged(isChecked) TextAlertDialogBuilder(this) @@ -87,6 +94,7 @@ class AppearanceActivity : DuckDuckGoActivity() { private fun configureUiEventHandlers() { binding.selectedThemeSetting.setClickListener { viewModel.userRequestedToChangeTheme() } binding.changeAppIconSetting.setOnClickListener { viewModel.userRequestedToChangeIcon() } + binding.addressBarPositionSetting.setOnClickListener { viewModel.userRequestedToChangeAddressBarPosition() } } private fun observeViewModel() { @@ -99,6 +107,7 @@ class AppearanceActivity : DuckDuckGoActivity() { binding.experimentalNightMode.quietlySetIsChecked(viewState.forceDarkModeEnabled, forceDarkModeToggleListener) binding.experimentalNightMode.isEnabled = viewState.canForceDarkMode binding.experimentalNightMode.isVisible = viewState.supportsForceDarkMode + updateSelectedOmnibarPosition(it.isOmnibarPositionFeatureEnabled, it.omnibarPosition) } }.launchIn(lifecycleScope) @@ -119,11 +128,29 @@ class AppearanceActivity : DuckDuckGoActivity() { binding.selectedThemeSetting.setSecondaryText(subtitle) } + private fun updateSelectedOmnibarPosition(isFeatureEnabled: Boolean, position: OmnibarPosition) { + if (isFeatureEnabled) { + val subtitle = getString( + when (position) { + OmnibarPosition.TOP -> R.string.settingsAddressBarPositionTop + OmnibarPosition.BOTTOM -> R.string.settingsAddressBarPositionBottom + }, + ) + binding.addressBarPositionSetting.setSecondaryText(subtitle) + binding.addressBarPositionSettingDivider.show() + binding.addressBarPositionSetting.show() + } else { + binding.addressBarPositionSettingDivider.gone() + binding.addressBarPositionSetting.gone() + } + } + private fun processCommand(it: Command) { when (it) { - is Command.LaunchAppIcon -> launchAppIconChange() - is Command.UpdateTheme -> sendThemeChangedBroadcast() - is Command.LaunchThemeSettings -> launchThemeSelector(it.theme) + is LaunchAppIcon -> launchAppIconChange() + is UpdateTheme -> sendThemeChangedBroadcast() + is LaunchThemeSettings -> launchThemeSelector(it.theme) + is LaunchOmnibarPositionSettings -> launchOmnibarPositionSelector(it.position) } } @@ -159,4 +186,27 @@ class AppearanceActivity : DuckDuckGoActivity() { ) .show() } + + private fun launchOmnibarPositionSelector(position: OmnibarPosition) { + RadioListAlertDialogBuilder(this) + .setTitle(R.string.settingsAddressBarPositionTitle) + .setOptions( + listOf( + R.string.settingsAddressBarPositionTop, + R.string.settingsAddressBarPositionBottom, + ), + OmnibarPosition.entries.indexOf(position) + 1, + ) + .setPositiveButton(R.string.dialogSave) + .setNegativeButton(R.string.cancel) + .addEventListener( + object : RadioListAlertDialogBuilder.EventListener() { + override fun onPositiveButtonClicked(selectedItem: Int) { + val newPosition = OmnibarPosition.entries[selectedItem - 1] + viewModel.onOmnibarPositionUpdated(newPosition) + } + }, + ) + .show() + } } diff --git a/app/src/main/java/com/duckduckgo/app/appearance/AppearanceViewModel.kt b/app/src/main/java/com/duckduckgo/app/appearance/AppearanceViewModel.kt index e6fccd6f8a4b..d10b3157b0a5 100644 --- a/app/src/main/java/com/duckduckgo/app/appearance/AppearanceViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/appearance/AppearanceViewModel.kt @@ -20,6 +20,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.webkit.WebViewFeature import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.app.browser.omnibar.ChangeOmnibarPositionFeature +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition import com.duckduckgo.app.icon.api.AppIcon import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.settings.db.SettingsDataStore @@ -28,6 +30,7 @@ import com.duckduckgo.common.ui.DuckDuckGoTheme import com.duckduckgo.common.ui.store.ThemingDataStore import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager import javax.inject.Inject import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel @@ -35,6 +38,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -45,6 +49,8 @@ class AppearanceViewModel @Inject constructor( private val settingsDataStore: SettingsDataStore, private val pixel: Pixel, private val dispatcherProvider: DispatcherProvider, + private val changeOmnibarPositionFeature: ChangeOmnibarPositionFeature, + private val loadingBarExperimentManager: LoadingBarExperimentManager, ) : ViewModel() { data class ViewState( @@ -53,12 +59,15 @@ class AppearanceViewModel @Inject constructor( val forceDarkModeEnabled: Boolean = false, val canForceDarkMode: Boolean = false, val supportsForceDarkMode: Boolean = true, + val omnibarPosition: OmnibarPosition = OmnibarPosition.TOP, + val isOmnibarPositionFeatureEnabled: Boolean = true, ) sealed class Command { data class LaunchThemeSettings(val theme: DuckDuckGoTheme) : Command() - object LaunchAppIcon : Command() - object UpdateTheme : Command() + data object LaunchAppIcon : Command() + data object UpdateTheme : Command() + data class LaunchOmnibarPositionSettings(val position: OmnibarPosition) : Command() } private val viewState = MutableStateFlow(ViewState()) @@ -66,15 +75,18 @@ class AppearanceViewModel @Inject constructor( fun viewState(): Flow = viewState.onStart { viewModelScope.launch { - viewState.emit( + viewState.update { currentViewState().copy( theme = themingDataStore.theme, appIcon = settingsDataStore.appIcon, forceDarkModeEnabled = settingsDataStore.experimentalWebsiteDarkMode, canForceDarkMode = canForceDarkMode(), supportsForceDarkMode = WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING), - ), - ) + omnibarPosition = settingsDataStore.omnibarPosition, + isOmnibarPositionFeatureEnabled = changeOmnibarPositionFeature.self().isEnabled() && + !loadingBarExperimentManager.isExperimentEnabled(), // feature disabled during loading experiment to avoid conflicts + ) + } } } @@ -96,6 +108,11 @@ class AppearanceViewModel @Inject constructor( pixel.fire(AppPixelName.SETTINGS_APP_ICON_PRESSED) } + fun userRequestedToChangeAddressBarPosition() { + viewModelScope.launch { command.send(Command.LaunchOmnibarPositionSettings(viewState.value.omnibarPosition)) } + pixel.fire(AppPixelName.SETTINGS_ADDRESS_BAR_POSITION_PRESSED) + } + fun onThemeSelected(selectedTheme: DuckDuckGoTheme) { Timber.d("User toggled theme, theme to set: $selectedTheme") if (themingDataStore.isCurrentlySelected(selectedTheme)) { @@ -105,7 +122,7 @@ class AppearanceViewModel @Inject constructor( viewModelScope.launch(dispatcherProvider.io()) { themingDataStore.theme = selectedTheme withContext(dispatcherProvider.main()) { - viewState.emit(currentViewState().copy(theme = selectedTheme, forceDarkModeEnabled = canForceDarkMode())) + viewState.update { currentViewState().copy(theme = selectedTheme, forceDarkModeEnabled = canForceDarkMode()) } command.send(Command.UpdateTheme) } } @@ -119,6 +136,18 @@ class AppearanceViewModel @Inject constructor( pixel.fire(pixelName) } + fun onOmnibarPositionUpdated(position: OmnibarPosition) { + viewModelScope.launch(dispatcherProvider.io()) { + settingsDataStore.omnibarPosition = position + viewState.update { currentViewState().copy(omnibarPosition = position) } + + when (position) { + OmnibarPosition.TOP -> pixel.fire(AppPixelName.SETTINGS_ADDRESS_BAR_POSITION_SELECTED_TOP) + OmnibarPosition.BOTTOM -> pixel.fire(AppPixelName.SETTINGS_ADDRESS_BAR_POSITION_SELECTED_BOTTOM) + } + } + } + private fun currentViewState(): ViewState { return viewState.value } diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index aa8fd23901fc..334cd0e784b1 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -22,6 +22,7 @@ import android.content.Intent import android.content.Intent.EXTRA_TEXT import android.os.Bundle import android.os.Handler +import android.os.Looper import android.os.Message import android.view.KeyEvent import android.view.View @@ -39,6 +40,8 @@ import com.duckduckgo.app.browser.BrowserViewModel.Command import com.duckduckgo.app.browser.BrowserViewModel.Command.Query import com.duckduckgo.app.browser.databinding.ActivityBrowserBinding import com.duckduckgo.app.browser.databinding.IncludeOmnibarToolbarMockupBinding +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.BOTTOM +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.TOP import com.duckduckgo.app.browser.shortcut.ShortcutBuilder import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.downloads.DownloadsScreens.DownloadsScreenNoParams @@ -167,7 +170,18 @@ open class BrowserActivity : DuckDuckGoActivity() { instanceStateBundles = CombinedInstanceState(originalInstanceState = savedInstanceState, newInstanceState = newInstanceState) super.onCreate(savedInstanceState = newInstanceState, daggerInject = false) - toolbarMockupBinding = IncludeOmnibarToolbarMockupBinding.bind(binding.root) + + toolbarMockupBinding = when (settingsDataStore.omnibarPosition) { + TOP -> { + binding.bottomMockupToolbar.appBarLayoutMockup.gone() + binding.topMockupToolbar + } + BOTTOM -> { + binding.topMockupToolbar.appBarLayoutMockup.gone() + binding.bottomMockupToolbar + } + } + setContentView(binding.root) viewModel.viewState.observe(this) { renderer.renderBrowserViewState(it) @@ -528,7 +542,7 @@ open class BrowserActivity : DuckDuckGoActivity() { private fun hideMockupOmnibar() { // Delaying this code to avoid race condition when fragment and activity recreated - Handler().postDelayed( + Handler(Looper.getMainLooper()).postDelayed( { if (this::toolbarMockupBinding.isInitialized) { toolbarMockupBinding.appBarLayoutMockup.visibility = View.GONE diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index 46b797ba38b8..eff45a253c6b 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -130,7 +130,6 @@ import com.duckduckgo.app.browser.databinding.ContentSiteLocationPermissionDialo import com.duckduckgo.app.browser.databinding.ContentSystemLocationPermissionDialogBinding import com.duckduckgo.app.browser.databinding.FragmentBrowserTabBinding import com.duckduckgo.app.browser.databinding.HttpAuthenticationBinding -import com.duckduckgo.app.browser.databinding.PopupWindowBrowserMenuBinding import com.duckduckgo.app.browser.downloader.BlobConverterInjector import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.browser.filechooser.FileChooserIntentBuilder @@ -148,6 +147,7 @@ import com.duckduckgo.app.browser.model.BasicAuthenticationCredentials import com.duckduckgo.app.browser.model.BasicAuthenticationRequest import com.duckduckgo.app.browser.model.LongPressTarget import com.duckduckgo.app.browser.newtab.NewTabPageProvider +import com.duckduckgo.app.browser.omnibar.Omnibar import com.duckduckgo.app.browser.omnibar.OmnibarScrolling import com.duckduckgo.app.browser.omnibar.animations.BrowserTrackersAnimatorHelper import com.duckduckgo.app.browser.omnibar.animations.PrivacyShieldAnimationHelper @@ -591,7 +591,7 @@ class BrowserTabFragment : private val downloadMessagesJob = ConflatedJob() private val viewModel: BrowserTabViewModel by lazy { - val viewModel = ViewModelProvider(this, viewModelFactory).get(BrowserTabViewModel::class.java) + val viewModel = ViewModelProvider(this, viewModelFactory)[BrowserTabViewModel::class.java] viewModel.loadData(tabId, initialUrl, skipHome, isLaunchedFromExternalApp) launchDownloadMessagesJob() viewModel @@ -599,6 +599,8 @@ class BrowserTabFragment : private val binding: FragmentBrowserTabBinding by viewBinding() + private lateinit var omnibar: Omnibar + private lateinit var webViewContainer: FrameLayout private var bookmarksBottomSheetDialog: BookmarksBottomSheetDialog.Builder? = null @@ -607,7 +609,7 @@ class BrowserTabFragment : private var autocompleteFirstVisibleItemPosition: Int = 0 private val findInPage - get() = binding.legacyOmnibar.findInPage + get() = omnibar.findInPage private val newBrowserTab get() = binding.includeNewBrowserTab @@ -624,7 +626,7 @@ class BrowserTabFragment : private val daxDialogOnboardingCta get() = binding.includeOnboardingDaxDialog - private val smoothProgressAnimator by lazy { SmoothProgressAnimator(binding.legacyOmnibar.pageLoadingIndicator) } + private val smoothProgressAnimator by lazy { SmoothProgressAnimator(omnibar.pageLoadingIndicator) } // Optimization to prevent against excessive work generating WebView previews; an existing job will be cancelled if a new one is launched private var bitmapGeneratorJob: Job? = null @@ -633,13 +635,13 @@ class BrowserTabFragment : get() = activity as? BrowserActivity private val tabsButton: TabSwitcherButton? - get() = binding.legacyOmnibar.tabsMenu + get() = omnibar.tabsMenu private val fireMenuButton: ViewGroup? - get() = binding.legacyOmnibar.fireIconMenu + get() = omnibar.fireIconMenu private val menuButton: ViewGroup? - get() = binding.legacyOmnibar.browserMenu + get() = omnibar.browserMenu private var webView: DuckDuckGoWebView? = null @@ -676,13 +678,13 @@ class BrowserTabFragment : private val omnibarInputTextWatcher = object : TextChangedWatcher() { override fun afterTextChanged(editable: Editable) { viewModel.onOmnibarInputStateChanged( - binding.legacyOmnibar.omnibarTextInput.text.toString(), - binding.legacyOmnibar.omnibarTextInput.hasFocus(), + omnibar.omnibarTextInput.text.toString(), + omnibar.omnibarTextInput.hasFocus(), true, ) viewModel.triggerAutocomplete( - binding.legacyOmnibar.omnibarTextInput.text.toString(), - binding.legacyOmnibar.omnibarTextInput.hasFocus(), + omnibar.omnibarTextInput.text.toString(), + omnibar.omnibarTextInput.hasFocus(), true, ) } @@ -691,8 +693,8 @@ class BrowserTabFragment : private val showSuggestionsListener = object : ShowSuggestionsListener { override fun showSuggestions() { viewModel.triggerAutocomplete( - binding.legacyOmnibar.omnibarTextInput.text.toString(), - binding.legacyOmnibar.omnibarTextInput.hasFocus(), + omnibar.omnibarTextInput.text.toString(), + omnibar.omnibarTextInput.hasFocus(), true, ) } @@ -710,9 +712,9 @@ class BrowserTabFragment : animatorHelper.createCookiesAnimation( it, omnibarViews(), - binding.legacyOmnibar.cookieDummyView, - binding.legacyOmnibar.cookieAnimation, - binding.legacyOmnibar.sceneRoot, + omnibar.cookieDummyView, + omnibar.cookieAnimation, + omnibar.sceneRoot, isCosmetic, ) } @@ -746,7 +748,7 @@ class BrowserTabFragment : generatedPassword: String, ) { // small delay added to let keyboard disappear if it was present; helps avoid jarring transition - delay(100) + delay(KEYBOARD_DELAY) withContext(dispatchers.main()) { showUserAutoGeneratedPasswordDialog(originalUrl, username, generatedPassword) @@ -777,7 +779,7 @@ class BrowserTabFragment : Timber.v("MatchType is %s", matchType.javaClass.simpleName) // we need this delay to ensure web navigation / form submission events aren't blocked - delay(100) + delay(NAVIGATION_DELAY) withContext(dispatchers.main()) { when (matchType) { @@ -858,7 +860,7 @@ class BrowserTabFragment : voiceSearchLauncher.registerResultsCallback(this, requireActivity(), BROWSER) { when (it) { is VoiceSearchLauncher.Event.VoiceRecognitionSuccess -> { - binding.legacyOmnibar.omnibarTextInput.setText(it.result) + omnibar.omnibarTextInput.setText(it.result) userEnteredQuery(it.result) resumeWebView() } @@ -897,6 +899,7 @@ class BrowserTabFragment : override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) + omnibar = Omnibar(settingsDataStore.omnibarPosition, binding) webViewContainer = binding.webViewContainer configureObservers() configurePrivacyShield() @@ -944,20 +947,20 @@ class BrowserTabFragment : } private fun configureCustomTab() { - binding.legacyOmnibar.omniBarContainer.hide() - binding.legacyOmnibar.fireIconMenu.hide() - binding.legacyOmnibar.tabsMenu.hide() + omnibar.omniBarContainer.hide() + omnibar.fireIconMenu.hide() + omnibar.tabsMenu.hide() - binding.legacyOmnibar.toolbar.background = ColorDrawable(customTabToolbarColor) - binding.legacyOmnibar.toolbarContainer.background = ColorDrawable(customTabToolbarColor) + omnibar.toolbar.background = ColorDrawable(customTabToolbarColor) + omnibar.toolbarContainer.background = ColorDrawable(customTabToolbarColor) - binding.legacyOmnibar.customTabToolbarContainer.customTabToolbar.show() + omnibar.customTabToolbarContainer.customTabToolbar.show() - binding.legacyOmnibar.customTabToolbarContainer.customTabCloseIcon.setOnClickListener { + omnibar.customTabToolbarContainer.customTabCloseIcon.setOnClickListener { requireActivity().finish() } - binding.legacyOmnibar.customTabToolbarContainer.customTabShieldIcon.setOnClickListener { _ -> + omnibar.customTabToolbarContainer.customTabShieldIcon.setOnClickListener { _ -> val params = PrivacyDashboardHybridScreenParams.PrivacyDashboardPrimaryScreen(tabId) val intent = globalActivityStarter.startIntent(requireContext(), params) contentScopeScripts.sendSubscriptionEvent(createBreakageReportingEventData()) @@ -965,16 +968,16 @@ class BrowserTabFragment : pixel.fire(CustomTabPixelNames.CUSTOM_TABS_PRIVACY_DASHBOARD_OPENED) } - binding.legacyOmnibar.customTabToolbarContainer.customTabDomain.text = viewModel.url?.extractDomain() - binding.legacyOmnibar.customTabToolbarContainer.customTabDomainOnly.text = viewModel.url?.extractDomain() - binding.legacyOmnibar.customTabToolbarContainer.customTabDomainOnly.show() + omnibar.customTabToolbarContainer.customTabDomain.text = viewModel.url?.extractDomain() + omnibar.customTabToolbarContainer.customTabDomainOnly.text = viewModel.url?.extractDomain() + omnibar.customTabToolbarContainer.customTabDomainOnly.show() val foregroundColor = calculateBlackOrWhite(customTabToolbarColor) - binding.legacyOmnibar.customTabToolbarContainer.customTabCloseIcon.setColorFilter(foregroundColor) - binding.legacyOmnibar.customTabToolbarContainer.customTabDomain.setTextColor(foregroundColor) - binding.legacyOmnibar.customTabToolbarContainer.customTabDomainOnly.setTextColor(foregroundColor) - binding.legacyOmnibar.customTabToolbarContainer.customTabTitle.setTextColor(foregroundColor) - binding.legacyOmnibar.browserMenuImageView.setColorFilter(foregroundColor) + omnibar.customTabToolbarContainer.customTabCloseIcon.setColorFilter(foregroundColor) + omnibar.customTabToolbarContainer.customTabDomain.setTextColor(foregroundColor) + omnibar.customTabToolbarContainer.customTabDomainOnly.setTextColor(foregroundColor) + omnibar.customTabToolbarContainer.customTabTitle.setTextColor(foregroundColor) + omnibar.browserMenuImageView.setColorFilter(foregroundColor) requireActivity().window.navigationBarColor = customTabToolbarColor requireActivity().window.statusBarColor = customTabToolbarColor @@ -999,7 +1002,7 @@ class BrowserTabFragment : private fun initPrivacyProtectionsPopup() { privacyProtectionsPopup = privacyProtectionsPopupFactory.createPopup( - anchor = binding.legacyOmnibar.shieldIcon, + anchor = omnibar.shieldIcon, ) privacyProtectionsPopup.events .onEach(viewModel::onPrivacyProtectionsPopupUiEvent) @@ -1044,7 +1047,13 @@ class BrowserTabFragment : override fun onResume() { super.onResume() - binding.legacyOmnibar.setExpanded(true) + + if (viewModel.hasOmnibarPositionChanged(omnibar.omnibarPosition)) { + requireActivity().recreate() + return + } + omnibar.appBarLayout.setExpanded(true) + viewModel.onViewResumed() // onResume can be called for a hidden/backgrounded fragment, ensure this tab is visible. @@ -1231,8 +1240,8 @@ class BrowserTabFragment : newBrowserTab.newTabContainerLayout.show() binding.browserLayout.gone() webViewContainer.gone() - omnibarScrolling.disableOmnibarScrolling(binding.legacyOmnibar.toolbarContainer) - binding.legacyOmnibar.setExpanded(true) + omnibarScrolling.disableOmnibarScrolling(omnibar.toolbarContainer) + omnibar.appBarLayout.setExpanded(true) webView?.onPause() webView?.hide() errorView.errorLayout.gone() @@ -1258,8 +1267,8 @@ class BrowserTabFragment : newBrowserTab.newTabLayout.gone() newBrowserTab.newTabContainerLayout.gone() sslErrorView.gone() - binding.legacyOmnibar.setExpanded(true) - binding.legacyOmnibar.shieldIcon.isInvisible = true + omnibar.appBarLayout.setExpanded(true) + omnibar.shieldIcon.isInvisible = true webView?.onPause() webView?.hide() errorView.errorMessage.text = getString(errorType.errorId, url).html(requireContext()) @@ -1280,10 +1289,10 @@ class BrowserTabFragment : newBrowserTab.newTabContainerLayout.gone() webView?.onPause() webView?.hide() - binding.legacyOmnibar.setExpanded(true) - binding.legacyOmnibar.shieldIcon.isInvisible = true - binding.legacyOmnibar.searchIcon.isInvisible = true - binding.legacyOmnibar.daxIcon.isInvisible = true + omnibar.appBarLayout.setExpanded(true) + omnibar.shieldIcon.isInvisible = true + omnibar.searchIcon.isInvisible = true + omnibar.daxIcon.isInvisible = true errorView.errorLayout.gone() binding.browserLayout.gone() sslErrorView.bind(handler, errorResponse) { action -> @@ -1588,8 +1597,8 @@ class BrowserTabFragment : is Command.CancelIncomingAutofillRequest -> injectAutofillCredentials(it.url, null) is Command.LaunchAutofillSettings -> launchAutofillManagementScreen(it.privacyProtectionEnabled) is Command.EditWithSelectedQuery -> { - binding.legacyOmnibar.omnibarTextInput.setText(it.query) - binding.legacyOmnibar.omnibarTextInput.setSelection(it.query.length) + omnibar.omnibarTextInput.setText(it.query) + omnibar.omnibarTextInput.setSelection(it.query.length) } is ShowBackNavigationHistory -> showBackNavigationHistory(it) @@ -1637,7 +1646,6 @@ class BrowserTabFragment : contentScopeScripts.sendSubscriptionEvent(it.cssData) duckPlayerScripts.sendSubscriptionEvent(it.duckPlayerData) } - else -> { // NO OP } @@ -1656,7 +1664,7 @@ class BrowserTabFragment : .addEventListener( object : TextAlertDialogBuilder.EventListener() { override fun onPositiveButtonClicked() { - viewModel.onRemoveSearchSuggestionConfirmed(suggestion, binding.legacyOmnibar.omnibarTextInput.text.toString()) + viewModel.onRemoveSearchSuggestionConfirmed(suggestion, omnibar.omnibarTextInput.text.toString()) } override fun onNegativeButtonClicked() { @@ -1685,7 +1693,7 @@ class BrowserTabFragment : position: Int, offset: Int, ) { - val rootView = binding.legacyOmnibar.omnibarTextInput.rootView + val rootView = omnibar.omnibarTextInput.rootView val keyboardVisibilityUtil = KeyboardVisibilityUtil(rootView) keyboardVisibilityUtil.addKeyboardVisibilityListener { scrollToPositionWithOffset(position, offset) @@ -1715,16 +1723,16 @@ class BrowserTabFragment : url: String?, ) { if (isActiveCustomTab()) { - binding.legacyOmnibar.customTabToolbarContainer.customTabTitle.text = title + omnibar.customTabToolbarContainer.customTabTitle.text = title val redirectedDomain = url?.extractDomain() redirectedDomain?.let { - binding.legacyOmnibar.customTabToolbarContainer.customTabDomain.text = redirectedDomain + omnibar.customTabToolbarContainer.customTabDomain.text = redirectedDomain } - binding.legacyOmnibar.customTabToolbarContainer.customTabTitle.show() - binding.legacyOmnibar.customTabToolbarContainer.customTabDomainOnly.hide() - binding.legacyOmnibar.customTabToolbarContainer.customTabDomain.show() + omnibar.customTabToolbarContainer.customTabTitle.show() + omnibar.customTabToolbarContainer.customTabDomainOnly.hide() + omnibar.customTabToolbarContainer.customTabDomain.show() } } @@ -1961,7 +1969,7 @@ class BrowserTabFragment : } private fun openInNewBackgroundTab() { - binding.legacyOmnibar.setExpanded(true, true) + omnibar.appBarLayout.setExpanded(true, true) viewModel.tabs.removeObservers(this) decorator.incrementTabs() } @@ -2289,22 +2297,23 @@ class BrowserTabFragment : autoCompleteLongPressClickListener = { viewModel.userLongPressedAutocomplete(it) }, + omnibarPosition = settingsDataStore.omnibarPosition, ) binding.autoCompleteSuggestionsList.adapter = autoCompleteSuggestionsAdapter } private fun configureNewTab() { - newBrowserTab.newTabLayout.setOnScrollChangeListener { v, scrollX, scrollY, oldScrollX, oldScrollY -> - if (binding.legacyOmnibar.omniBarContainer.isPressed) { - binding.legacyOmnibar.omnibarTextInput.hideKeyboard() + newBrowserTab.newTabLayout.setOnScrollChangeListener { _, _, _, _, _ -> + if (omnibar.omniBarContainer.isPressed) { + omnibar.omnibarTextInput.hideKeyboard() binding.focusDummy.requestFocus() - binding.legacyOmnibar.omniBarContainer.isPressed = false + omnibar.omniBarContainer.isPressed = false } } } private fun configurePrivacyShield() { - binding.legacyOmnibar.shieldIcon.setOnClickListener { + omnibar.shieldIcon.setOnClickListener { contentScopeScripts.sendSubscriptionEvent(createBreakageReportingEventData()) browserActivity?.launchPrivacyDashboard() viewModel.onPrivacyShieldSelected() @@ -2326,50 +2335,50 @@ class BrowserTabFragment : } private fun configureOmnibarTextInput() { - binding.legacyOmnibar.omnibarTextInput.onFocusChangeListener = + omnibar.omnibarTextInput.onFocusChangeListener = OnFocusChangeListener { _, hasFocus: Boolean -> - viewModel.onOmnibarInputStateChanged(binding.legacyOmnibar.omnibarTextInput.text.toString(), hasFocus, false) - viewModel.triggerAutocomplete(binding.legacyOmnibar.omnibarTextInput.text.toString(), hasFocus, false) + viewModel.onOmnibarInputStateChanged(omnibar.omnibarTextInput.text.toString(), hasFocus, false) + viewModel.triggerAutocomplete(omnibar.omnibarTextInput.text.toString(), hasFocus, false) if (hasFocus) { cancelPendingAutofillRequestsToChooseCredentials() - binding.legacyOmnibar.omniBarContainer.isPressed = true + omnibar.omniBarContainer.isPressed = true } else { - binding.legacyOmnibar.omnibarTextInput.hideKeyboard() + omnibar.omnibarTextInput.hideKeyboard() binding.focusDummy.requestFocus() - binding.legacyOmnibar.omniBarContainer.isPressed = false + omnibar.omniBarContainer.isPressed = false } } - binding.legacyOmnibar.omnibarTextInput.onBackKeyListener = object : KeyboardAwareEditText.OnBackKeyListener { + omnibar.omnibarTextInput.onBackKeyListener = object : KeyboardAwareEditText.OnBackKeyListener { override fun onBackKey(): Boolean { viewModel.sendPixelsOnBackKeyPressed() - binding.legacyOmnibar.omnibarTextInput.hideKeyboard() + omnibar.omnibarTextInput.hideKeyboard() binding.focusDummy.requestFocus() - binding.legacyOmnibar.omniBarContainer.isPressed = false + omnibar.omniBarContainer.isPressed = false // Allow the event to be handled by the next receiver. return false } } - binding.legacyOmnibar.omnibarTextInput.setOnEditorActionListener( + omnibar.omnibarTextInput.setOnEditorActionListener( TextView.OnEditorActionListener { _, actionId, keyEvent -> if (actionId == EditorInfo.IME_ACTION_GO || keyEvent?.keyCode == KeyEvent.KEYCODE_ENTER) { viewModel.sendPixelsOnEnterKeyPressed() - userEnteredQuery(binding.legacyOmnibar.omnibarTextInput.text.toString()) + userEnteredQuery(omnibar.omnibarTextInput.text.toString()) return@OnEditorActionListener true } false }, ) - binding.legacyOmnibar.omnibarTextInput.setOnTouchListener { _, event -> + omnibar.omnibarTextInput.setOnTouchListener { _, event -> viewModel.onUserTouchedOmnibarTextInput(event.action) false } - binding.legacyOmnibar.clearTextButton.setOnClickListener { + omnibar.clearTextButton.setOnClickListener { viewModel.onClearOmnibarTextInput() - binding.legacyOmnibar.omnibarTextInput.setText("") + omnibar.omnibarTextInput.setText("") } } @@ -2421,7 +2430,7 @@ class BrowserTabFragment : } it.setOnTouchListener { _, _ -> - if (binding.legacyOmnibar.omnibarTextInput.isFocused) { + if (omnibar.omnibarTextInput.isFocused) { binding.focusDummy.requestFocus() } dismissAppLinkSnackBar() @@ -2799,7 +2808,7 @@ class BrowserTabFragment : } // avoids progressView from showing under toolbar - binding.swipeRefreshContainer.progressViewStartOffset = binding.swipeRefreshContainer.progressViewStartOffset - 15 + binding.swipeRefreshContainer.progressViewStartOffset -= 15 } /** @@ -2819,8 +2828,8 @@ class BrowserTabFragment : private fun addTextChangedListeners() { findInPage.findInPageInput.replaceTextChangedListener(findInPageTextWatcher) - binding.legacyOmnibar.omnibarTextInput.replaceTextChangedListener(omnibarInputTextWatcher) - binding.legacyOmnibar.omnibarTextInput.showSuggestionsListener = showSuggestionsListener + omnibar.omnibarTextInput.replaceTextChangedListener(omnibarInputTextWatcher) + omnibar.omnibarTextInput.showSuggestionsListener = showSuggestionsListener } override fun onCreateContextMenu( @@ -3076,33 +3085,33 @@ class BrowserTabFragment : private fun hideKeyboardImmediately() { if (!isHidden) { Timber.v("Keyboard now hiding") - binding.legacyOmnibar.omnibarTextInput.hideKeyboard() + omnibar.omnibarTextInput.hideKeyboard() binding.focusDummy.requestFocus() - binding.legacyOmnibar.omniBarContainer.isPressed = false + omnibar.omniBarContainer.isPressed = false } } private fun hideKeyboard() { if (!isHidden) { Timber.v("Keyboard now hiding") - hideKeyboard(binding.legacyOmnibar.omnibarTextInput) + hideKeyboard(omnibar.omnibarTextInput) binding.focusDummy.requestFocus() - binding.legacyOmnibar.omniBarContainer.isPressed = false + omnibar.omniBarContainer.isPressed = false } } private fun hideKeyboardRetainFocus() { if (!isHidden) { Timber.v("Keyboard now hiding") - binding.legacyOmnibar.omnibarTextInput.postDelayed(KEYBOARD_DELAY) { binding.legacyOmnibar.omnibarTextInput.hideKeyboard() } + omnibar.omnibarTextInput.postDelayed(KEYBOARD_DELAY) { omnibar.omnibarTextInput.hideKeyboard() } } } private fun showKeyboard() { if (!isHidden) { Timber.v("Keyboard now showing") - showKeyboard(binding.legacyOmnibar.omnibarTextInput) - binding.legacyOmnibar.omniBarContainer.isPressed = true + showKeyboard(omnibar.omnibarTextInput) + omnibar.omniBarContainer.isPressed = true } } @@ -3129,7 +3138,7 @@ class BrowserTabFragment : } override fun onViewStateRestored(bundle: Bundle?) { - viewModel.restoreWebViewState(webView, binding.legacyOmnibar.omnibarTextInput.text.toString()) + viewModel.restoreWebViewState(webView, omnibar.omnibarTextInput.text.toString()) viewModel.determineShowBrowser() super.onViewStateRestored(bundle) } @@ -3353,7 +3362,7 @@ class BrowserTabFragment : downloadFile(requestUserConfirmation = true) } else { Timber.i("Write external storage permission refused") - binding.legacyOmnibar.toolbar.makeSnackbarWithNoBottomInset(R.string.permissionRequiredToDownload, Snackbar.LENGTH_LONG).show() + omnibar.toolbar.makeSnackbarWithNoBottomInset(R.string.permissionRequiredToDownload, Snackbar.LENGTH_LONG).show() } } @@ -3420,9 +3429,9 @@ class BrowserTabFragment : } fun omnibarViews(): List = listOf( - binding.legacyOmnibar.clearTextButton, - binding.legacyOmnibar.omnibarTextInput, - binding.legacyOmnibar.searchIcon, + omnibar.clearTextButton, + omnibar.omnibarTextInput, + omnibar.searchIcon, ) override fun onAnimationFinished() { @@ -3481,6 +3490,8 @@ class BrowserTabFragment : const val ADD_SAVED_SITE_FRAGMENT_TAG = "ADD_SAVED_SITE" private const val KEYBOARD_DELAY = 200L + private const val NAVIGATION_DELAY = 100L + private const val POPUP_MENU_DELAY = 200L private const val REQUEST_CODE_CHOOSE_FILE = 100 private const val PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE = 200 @@ -3499,6 +3510,8 @@ class BrowserTabFragment : private const val COOKIES_ANIMATION_DELAY = 400L + private const val SCROLLABILITY_CHECK_DELAY = 2000L + private const val BOOKMARKS_BOTTOM_SHEET_DURATION = 3500L private const val AUTOCOMPLETE_PADDING_DP = 6 @@ -3563,23 +3576,23 @@ class BrowserTabFragment : menuButton?.isVisible = viewState.showMenuButton is HighlightableButton.Visible val targetView = if (viewState.showMenuButton.isHighlighted()) { - binding.legacyOmnibar.browserMenuImageView + omnibar.browserMenuImageView } else if (viewState.fireButton.isHighlighted()) { - binding.legacyOmnibar.fireIconImageView + omnibar.fireIconImageView } else if (viewState.showPrivacyShield.isHighlighted()) { - binding.legacyOmnibar.placeholder + omnibar.placeholder } else { null } // omnibar only scrollable when browser showing and the fire button is not promoted if (targetView != null) { - omnibarScrolling.disableOmnibarScrolling(binding.legacyOmnibar.toolbarContainer) + omnibarScrolling.disableOmnibarScrolling(omnibar.toolbarContainer) playPulseAnimation(targetView) webView?.setBottomMatchingBehaviourEnabled(false) } else { if (viewState.browserShowing) { - omnibarScrolling.enableOmnibarScrolling(binding.legacyOmnibar.toolbarContainer) + omnibarScrolling.enableOmnibarScrolling(omnibar.toolbarContainer) } if (pulseAnimation.isActive) { webView?.setBottomMatchingBehaviourEnabled(true) // only execute if animation is playing @@ -3589,7 +3602,7 @@ class BrowserTabFragment : } private fun playPulseAnimation(targetView: View) { - binding.legacyOmnibar.toolbarContainer.doOnLayout { + omnibar.toolbarContainer.doOnLayout { pulseAnimation.playOn(targetView) } } @@ -3612,22 +3625,21 @@ class BrowserTabFragment : popupMenu = BrowserPopupMenu( context = requireContext(), layoutInflater = layoutInflater, - displayedInCustomTabScreen = tabDisplayedInCustomTabScreen, + settingsDataStore.omnibarPosition, ) - val menuBinding = PopupWindowBrowserMenuBinding.bind(popupMenu.contentView) popupMenu.apply { - onMenuItemClicked(menuBinding.forwardMenuItem) { + onMenuItemClicked(forwardMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_NAVIGATE_FORWARD_PRESSED) viewModel.onUserPressedForward() } - onMenuItemClicked(menuBinding.backMenuItem) { + onMenuItemClicked(backMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_NAVIGATE_BACK_PRESSED) activity?.onBackPressed() } - onMenuItemLongClicked(menuBinding.backMenuItem) { + onMenuItemLongClicked(backMenuItem) { viewModel.onUserLongPressedBack() } - onMenuItemClicked(menuBinding.refreshMenuItem) { + onMenuItemClicked(refreshMenuItem) { viewModel.onRefreshRequested(triggeredByUser = true) if (isActiveCustomTab()) { pixel.fire(CustomTabPixelNames.CUSTOM_TABS_MENU_REFRESH) @@ -3649,73 +3661,73 @@ class BrowserTabFragment : } } } - onMenuItemClicked(menuBinding.newTabMenuItem) { + onMenuItemClicked(newTabMenuItem) { viewModel.userRequestedOpeningNewTab() pixel.fire(AppPixelName.MENU_ACTION_NEW_TAB_PRESSED.pixelName) } - onMenuItemClicked(menuBinding.bookmarksMenuItem) { + onMenuItemClicked(bookmarksMenuItem) { browserActivity?.launchBookmarks() pixel.fire(AppPixelName.MENU_ACTION_BOOKMARKS_PRESSED.pixelName) } - onMenuItemClicked(menuBinding.fireproofWebsiteMenuItem) { + onMenuItemClicked(fireproofWebsiteMenuItem) { viewModel.onFireproofWebsiteMenuClicked() } - onMenuItemClicked(menuBinding.addBookmarksMenuItem) { + onMenuItemClicked(addBookmarksMenuItem) { viewModel.onBookmarkMenuClicked() } - onMenuItemClicked(menuBinding.findInPageMenuItem) { + onMenuItemClicked(findInPageMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_FIND_IN_PAGE_PRESSED) viewModel.onFindInPageSelected() } - onMenuItemClicked(menuBinding.privacyProtectionMenuItem) { viewModel.onPrivacyProtectionMenuClicked(isActiveCustomTab()) } - onMenuItemClicked(menuBinding.brokenSiteMenuItem) { + onMenuItemClicked(privacyProtectionMenuItem) { viewModel.onPrivacyProtectionMenuClicked(isActiveCustomTab()) } + onMenuItemClicked(brokenSiteMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_REPORT_BROKEN_SITE_PRESSED) viewModel.onBrokenSiteSelected() } - onMenuItemClicked(menuBinding.downloadsMenuItem) { + onMenuItemClicked(downloadsMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_DOWNLOADS_PRESSED) browserActivity?.launchDownloads() } - onMenuItemClicked(menuBinding.settingsMenuItem) { + onMenuItemClicked(settingsMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_SETTINGS_PRESSED) browserActivity?.launchSettings() } - onMenuItemClicked(menuBinding.changeBrowserModeMenuItem) { + onMenuItemClicked(changeBrowserModeMenuItem) { viewModel.onChangeBrowserModeClicked() } - onMenuItemClicked(menuBinding.sharePageMenuItem) { + onMenuItemClicked(sharePageMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_SHARE_PRESSED) viewModel.onShareSelected() } - onMenuItemClicked(menuBinding.addToHomeMenuItem) { + onMenuItemClicked(addToHomeMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_ADD_TO_HOME_PRESSED) viewModel.onPinPageToHomeSelected() } - onMenuItemClicked(menuBinding.createAliasMenuItem) { viewModel.consumeAliasAndCopyToClipboard() } - onMenuItemClicked(menuBinding.openInAppMenuItem) { + onMenuItemClicked(createAliasMenuItem) { viewModel.consumeAliasAndCopyToClipboard() } + onMenuItemClicked(openInAppMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_APP_LINKS_OPEN_PRESSED) viewModel.openAppLink() } - onMenuItemClicked(menuBinding.printPageMenuItem) { + onMenuItemClicked(printPageMenuItem) { viewModel.onPrintSelected() } - onMenuItemClicked(menuBinding.autofillMenuItem) { + onMenuItemClicked(autofillMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_AUTOFILL_PRESSED) viewModel.onAutofillMenuSelected() } - onMenuItemClicked(menuBinding.openInDdgBrowserMenuItem) { + onMenuItemClicked(openInDdgBrowserMenuItem) { viewModel.url?.let { launchCustomTabUrlInDdg(it) pixel.fire(CustomTabPixelNames.CUSTOM_TABS_OPEN_IN_DDG) } } } - binding.legacyOmnibar.browserMenu.setOnClickListener { + omnibar.browserMenu.setOnClickListener { contentScopeScripts.sendSubscriptionEvent(createBreakageReportingEventData()) viewModel.onBrowserMenuClicked() hideKeyboardImmediately() - launchTopAnchoredPopupMenu() + launchPopupMenu() } } @@ -3726,12 +3738,15 @@ class BrowserTabFragment : startActivity(intent) } - private fun launchTopAnchoredPopupMenu() { - popupMenu.show(binding.rootView, binding.legacyOmnibar.toolbar) - if (isActiveCustomTab()) { - pixel.fire(CustomTabPixelNames.CUSTOM_TABS_MENU_OPENED) - } else { - pixel.fire(AppPixelName.MENU_ACTION_POPUP_OPENED.pixelName) + private fun launchPopupMenu() { + // small delay added to let keyboard disappear and avoid jarring transition + binding.rootView.postDelayed(POPUP_MENU_DELAY) { + popupMenu.show(binding.rootView, omnibar.toolbar) + if (isActiveCustomTab()) { + pixel.fire(CustomTabPixelNames.CUSTOM_TABS_MENU_OPENED) + } else { + pixel.fire(AppPixelName.MENU_ACTION_POPUP_OPENED.pixelName) + } } } @@ -3782,9 +3797,9 @@ class BrowserTabFragment : if (viewState.privacyShield != UNKNOWN) { lastSeenPrivacyShieldViewState = viewState val animationViewHolder = if (isActiveCustomTab()) { - binding.legacyOmnibar.customTabToolbarContainer.customTabShieldIcon + omnibar.customTabToolbarContainer.customTabShieldIcon } else { - binding.legacyOmnibar.shieldIcon + omnibar.shieldIcon } privacyShieldView.setAnimationView(animationViewHolder, viewState.privacyShield) cancelTrackersAnimation() @@ -3832,14 +3847,14 @@ class BrowserTabFragment : } if (viewState.navigationChange) { - binding.legacyOmnibar.setExpanded(true, true) + omnibar.appBarLayout.setExpanded(true, true) } else if (shouldUpdateOmnibarTextInput(viewState, viewState.omnibarText)) { - binding.legacyOmnibar.omnibarTextInput.setText(viewState.omnibarText) + omnibar.omnibarTextInput.setText(viewState.omnibarText) if (viewState.forceExpand) { - binding.legacyOmnibar.setExpanded(true, true) + omnibar.appBarLayout.setExpanded(true, true) } if (viewState.shouldMoveCaretToEnd) { - binding.legacyOmnibar.omnibarTextInput.setSelection(viewState.omnibarText.length) + omnibar.omnibarTextInput.setSelection(viewState.omnibarText.length) } } @@ -3851,14 +3866,14 @@ class BrowserTabFragment : private fun renderVoiceSearch(viewState: BrowserViewState) { if (viewState.showVoiceSearch) { - binding.legacyOmnibar.voiceSearchButton.visibility = VISIBLE - binding.legacyOmnibar.voiceSearchButton.setOnClickListener { + omnibar.voiceSearchButton.visibility = VISIBLE + omnibar.voiceSearchButton.setOnClickListener { webView?.onPause() hideKeyboardImmediately() voiceSearchLauncher.launch(requireActivity()) } } else { - binding.legacyOmnibar.voiceSearchButton.visibility = GONE + omnibar.voiceSearchButton.visibility = GONE } } @@ -3875,7 +3890,7 @@ class BrowserTabFragment : webView?.setBottomMatchingBehaviourEnabled(true) } - binding.legacyOmnibar.pageLoadingIndicator.apply { + omnibar.pageLoadingIndicator.apply { if (viewState.isLoading) show() smoothProgressAnimator.onNewProgress(viewState.progress) { if (!viewState.isLoading) hide() } } @@ -3912,8 +3927,8 @@ class BrowserTabFragment : activity?.let { activity -> animatorHelper.startTrackersAnimation( context = activity, - shieldAnimationView = binding.legacyOmnibar.shieldIcon, - trackersAnimationView = binding.legacyOmnibar.trackersAnimation, + shieldAnimationView = omnibar.shieldIcon, + trackersAnimationView = omnibar.trackersAnimation, omnibarViews = omnibarViews(), entities = events, ) @@ -3985,10 +4000,12 @@ class BrowserTabFragment : } renderToolbarMenus(viewState) + popupMenu.renderState(browserShowing, viewState, tabDisplayedInCustomTabScreen) + renderFullscreenMode(viewState) renderVoiceSearch(viewState) - binding.legacyOmnibar.spacer.isVisible = viewState.showVoiceSearch && lastSeenBrowserViewState?.showClearButton ?: false + omnibar.spacer.isVisible = viewState.showVoiceSearch && lastSeenBrowserViewState?.showClearButton ?: false privacyProtectionsPopup.setViewState(viewState.privacyProtectionsPopupViewState) bookmarksBottomSheetDialog?.dialog?.toggleSwitch(viewState.favorite != null) @@ -4031,21 +4048,20 @@ class BrowserTabFragment : private fun renderToolbarMenus(viewState: BrowserViewState) { if (viewState.browserShowing) { - binding.legacyOmnibar.daxIcon?.isVisible = viewState.showDaxIcon - binding.legacyOmnibar.duckPlayerIcon.isVisible = viewState.showDuckPlayerIcon - binding.legacyOmnibar.shieldIcon?.isInvisible = - !viewState.showPrivacyShield.isEnabled() || viewState.showDaxIcon || viewState.showDuckPlayerIcon - binding.legacyOmnibar.clearTextButton?.isVisible = viewState.showClearButton - binding.legacyOmnibar.searchIcon?.isVisible = viewState.showSearchIcon + omnibar.daxIcon.isVisible = viewState.showDaxIcon + omnibar.duckPlayerIcon.isVisible = viewState.showDuckPlayerIcon + omnibar.shieldIcon.isInvisible = !viewState.showPrivacyShield.isEnabled() || viewState.showDaxIcon || viewState.showDuckPlayerIcon + omnibar.clearTextButton.isVisible = viewState.showClearButton + omnibar.searchIcon.isVisible = viewState.showSearchIcon } else { - binding.legacyOmnibar.daxIcon.isVisible = false - binding.legacyOmnibar.duckPlayerIcon.isVisible = false - binding.legacyOmnibar.shieldIcon?.isVisible = false - binding.legacyOmnibar.clearTextButton?.isVisible = viewState.showClearButton - binding.legacyOmnibar.searchIcon?.isVisible = true + omnibar.daxIcon.isVisible = false + omnibar.duckPlayerIcon.isVisible = false + omnibar.shieldIcon.isVisible = false + omnibar.clearTextButton.isVisible = viewState.showClearButton + omnibar.searchIcon.isVisible = true } - binding.legacyOmnibar.spacer.isVisible = viewState.showClearButton && lastSeenBrowserViewState?.showVoiceSearch ?: false + omnibar.spacer.isVisible = viewState.showClearButton && lastSeenBrowserViewState?.showVoiceSearch ?: false decorator.updateToolbarActionsVisibility(viewState) } @@ -4208,7 +4224,7 @@ class BrowserTabFragment : .launchIn(lifecycleScope) newBrowserTab.newTabContainerLayout.show() newBrowserTab.newTabLayout.show() - omnibarScrolling.disableOmnibarScrolling(binding.legacyOmnibar.toolbarContainer) + omnibarScrolling.disableOmnibarScrolling(omnibar.toolbarContainer) viewModel.onNewTabShown() } @@ -4275,7 +4291,7 @@ class BrowserTabFragment : viewState: OmnibarViewState, omnibarInput: String?, ) = - (!viewState.isEditing || omnibarInput.isNullOrEmpty()) && binding.legacyOmnibar.omnibarTextInput.isDifferent(omnibarInput) + (!viewState.isEditing || omnibarInput.isNullOrEmpty()) && omnibar.omnibarTextInput.isDifferent(omnibarInput) } private fun launchPrint( diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index b58df1e2ea29..3f29f345862d 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -39,8 +39,12 @@ import android.webkit.WebView import androidx.annotation.AnyThread import androidx.annotation.VisibleForTesting import androidx.core.net.toUri -import androidx.lifecycle.* +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope import androidx.webkit.JavaScriptReplyProxy import com.duckduckgo.adclick.api.AdClickManager import com.duckduckgo.anvil.annotations.ContributesViewModel @@ -71,7 +75,85 @@ import com.duckduckgo.app.browser.camera.CameraHardwareChecker import com.duckduckgo.app.browser.certificates.BypassedSSLCertificatesRepository import com.duckduckgo.app.browser.certificates.remoteconfig.SSLCertificatesFeature import com.duckduckgo.app.browser.commands.Command -import com.duckduckgo.app.browser.commands.Command.* +import com.duckduckgo.app.browser.commands.Command.AddHomeShortcut +import com.duckduckgo.app.browser.commands.Command.AskDomainPermission +import com.duckduckgo.app.browser.commands.Command.AskToAutomateFireproofWebsite +import com.duckduckgo.app.browser.commands.Command.AskToDisableLoginDetection +import com.duckduckgo.app.browser.commands.Command.AskToFireproofWebsite +import com.duckduckgo.app.browser.commands.Command.AutocompleteItemRemoved +import com.duckduckgo.app.browser.commands.Command.BrokenSiteFeedback +import com.duckduckgo.app.browser.commands.Command.CancelIncomingAutofillRequest +import com.duckduckgo.app.browser.commands.Command.CheckSystemLocationPermission +import com.duckduckgo.app.browser.commands.Command.ChildTabClosed +import com.duckduckgo.app.browser.commands.Command.ConvertBlobToDataUri +import com.duckduckgo.app.browser.commands.Command.CopyAliasToClipboard +import com.duckduckgo.app.browser.commands.Command.CopyLink +import com.duckduckgo.app.browser.commands.Command.DeleteFavoriteConfirmation +import com.duckduckgo.app.browser.commands.Command.DeleteFireproofConfirmation +import com.duckduckgo.app.browser.commands.Command.DeleteSavedSiteConfirmation +import com.duckduckgo.app.browser.commands.Command.DialNumber +import com.duckduckgo.app.browser.commands.Command.DismissFindInPage +import com.duckduckgo.app.browser.commands.Command.DownloadImage +import com.duckduckgo.app.browser.commands.Command.EditWithSelectedQuery +import com.duckduckgo.app.browser.commands.Command.EmailSignEvent +import com.duckduckgo.app.browser.commands.Command.ExtractUrlFromCloakedAmpLink +import com.duckduckgo.app.browser.commands.Command.FindInPageCommand +import com.duckduckgo.app.browser.commands.Command.GenerateWebViewPreviewImage +import com.duckduckgo.app.browser.commands.Command.HandleNonHttpAppLink +import com.duckduckgo.app.browser.commands.Command.HideKeyboard +import com.duckduckgo.app.browser.commands.Command.HideOnboardingDaxDialog +import com.duckduckgo.app.browser.commands.Command.HideSSLError +import com.duckduckgo.app.browser.commands.Command.HideWebContent +import com.duckduckgo.app.browser.commands.Command.InjectEmailAddress +import com.duckduckgo.app.browser.commands.Command.LaunchAddWidget +import com.duckduckgo.app.browser.commands.Command.LaunchAutofillSettings +import com.duckduckgo.app.browser.commands.Command.LaunchNewTab +import com.duckduckgo.app.browser.commands.Command.LaunchPrivacyPro +import com.duckduckgo.app.browser.commands.Command.LaunchTabSwitcher +import com.duckduckgo.app.browser.commands.Command.LoadExtractedUrl +import com.duckduckgo.app.browser.commands.Command.OpenAppLink +import com.duckduckgo.app.browser.commands.Command.OpenInNewBackgroundTab +import com.duckduckgo.app.browser.commands.Command.OpenInNewTab +import com.duckduckgo.app.browser.commands.Command.OpenMessageInNewTab +import com.duckduckgo.app.browser.commands.Command.PrintLink +import com.duckduckgo.app.browser.commands.Command.RefreshUserAgent +import com.duckduckgo.app.browser.commands.Command.RequestFileDownload +import com.duckduckgo.app.browser.commands.Command.RequestSystemLocationPermission +import com.duckduckgo.app.browser.commands.Command.RequiresAuthentication +import com.duckduckgo.app.browser.commands.Command.ResetHistory +import com.duckduckgo.app.browser.commands.Command.SaveCredentials +import com.duckduckgo.app.browser.commands.Command.ScreenLock +import com.duckduckgo.app.browser.commands.Command.ScreenUnlock +import com.duckduckgo.app.browser.commands.Command.SendEmail +import com.duckduckgo.app.browser.commands.Command.SendResponseToJs +import com.duckduckgo.app.browser.commands.Command.SendSms +import com.duckduckgo.app.browser.commands.Command.ShareLink +import com.duckduckgo.app.browser.commands.Command.ShowAppLinkPrompt +import com.duckduckgo.app.browser.commands.Command.ShowBackNavigationHistory +import com.duckduckgo.app.browser.commands.Command.ShowDomainHasPermissionMessage +import com.duckduckgo.app.browser.commands.Command.ShowEditSavedSiteDialog +import com.duckduckgo.app.browser.commands.Command.ShowEmailProtectionChooseEmailPrompt +import com.duckduckgo.app.browser.commands.Command.ShowErrorWithAction +import com.duckduckgo.app.browser.commands.Command.ShowExistingImageOrCameraChooser +import com.duckduckgo.app.browser.commands.Command.ShowFaviconsPrompt +import com.duckduckgo.app.browser.commands.Command.ShowFileChooser +import com.duckduckgo.app.browser.commands.Command.ShowFireproofWebSiteConfirmation +import com.duckduckgo.app.browser.commands.Command.ShowFullScreen +import com.duckduckgo.app.browser.commands.Command.ShowImageCamera +import com.duckduckgo.app.browser.commands.Command.ShowKeyboard +import com.duckduckgo.app.browser.commands.Command.ShowPrivacyProtectionDisabledConfirmation +import com.duckduckgo.app.browser.commands.Command.ShowPrivacyProtectionEnabledConfirmation +import com.duckduckgo.app.browser.commands.Command.ShowRemoveSearchSuggestionDialog +import com.duckduckgo.app.browser.commands.Command.ShowSSLError +import com.duckduckgo.app.browser.commands.Command.ShowSavedSiteAddedConfirmation +import com.duckduckgo.app.browser.commands.Command.ShowSitePermissionsDialog +import com.duckduckgo.app.browser.commands.Command.ShowSoundRecorder +import com.duckduckgo.app.browser.commands.Command.ShowUserCredentialSavedOrUpdatedConfirmation +import com.duckduckgo.app.browser.commands.Command.ShowVideoCamera +import com.duckduckgo.app.browser.commands.Command.ShowWebContent +import com.duckduckgo.app.browser.commands.Command.ShowWebPageTitle +import com.duckduckgo.app.browser.commands.Command.WebShareRequest +import com.duckduckgo.app.browser.commands.Command.WebViewError import com.duckduckgo.app.browser.commands.NavigationCommand import com.duckduckgo.app.browser.customtabs.CustomTabPixelNames import com.duckduckgo.app.browser.duckplayer.DUCK_PLAYER_FEATURE_NAME @@ -95,6 +177,9 @@ import com.duckduckgo.app.browser.newtab.FavoritesQuickAccessAdapter import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter import com.duckduckgo.app.browser.omnibar.QueryOrigin import com.duckduckgo.app.browser.omnibar.QueryOrigin.FromAutocomplete +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.BOTTOM +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.TOP import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.browser.urlextraction.UrlExtractionListener import com.duckduckgo.app.browser.viewstate.AccessibilityViewState @@ -111,7 +196,10 @@ import com.duckduckgo.app.browser.viewstate.OmnibarViewState import com.duckduckgo.app.browser.viewstate.PrivacyShieldViewState import com.duckduckgo.app.browser.viewstate.SavedSiteChangedViewState import com.duckduckgo.app.browser.webview.SslWarningLayout.Action -import com.duckduckgo.app.cta.ui.* +import com.duckduckgo.app.cta.ui.Cta +import com.duckduckgo.app.cta.ui.CtaViewModel +import com.duckduckgo.app.cta.ui.DaxBubbleCta +import com.duckduckgo.app.cta.ui.HomePanelCta import com.duckduckgo.app.cta.ui.OnboardingDaxDialogCta import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity @@ -180,7 +268,11 @@ import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentM import com.duckduckgo.history.api.NavigationHistory import com.duckduckgo.js.messaging.api.JsCallbackData import com.duckduckgo.newtabpage.impl.pixels.NewTabPixels -import com.duckduckgo.privacy.config.api.* +import com.duckduckgo.privacy.config.api.AmpLinkInfo +import com.duckduckgo.privacy.config.api.AmpLinks +import com.duckduckgo.privacy.config.api.ContentBlocking +import com.duckduckgo.privacy.config.api.Gpc +import com.duckduckgo.privacy.config.api.TrackingParameters import com.duckduckgo.privacy.dashboard.impl.pixels.PrivacyDashboardPixels import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupExperimentExternalPixels import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupManager @@ -208,12 +300,54 @@ import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import java.net.URI import java.net.URISyntaxException -import java.util.* +import java.util.Locale import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* +import kotlin.collections.List +import kotlin.collections.Map +import kotlin.collections.MutableMap +import kotlin.collections.any +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.contains +import kotlin.collections.drop +import kotlin.collections.emptyList +import kotlin.collections.emptyMap +import kotlin.collections.filter +import kotlin.collections.filterNot +import kotlin.collections.firstOrNull +import kotlin.collections.forEach +import kotlin.collections.isNotEmpty +import kotlin.collections.iterator +import kotlin.collections.map +import kotlin.collections.mapOf +import kotlin.collections.minus +import kotlin.collections.mutableMapOf +import kotlin.collections.mutableSetOf +import kotlin.collections.plus +import kotlin.collections.set +import kotlin.collections.setOf +import kotlin.collections.take +import kotlin.collections.toList +import kotlin.collections.toMutableMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.json.JSONArray import org.json.JSONObject @@ -1045,7 +1179,7 @@ class BrowserTabViewModel @Inject constructor( if (triggeredByUser) { site?.realBrokenSiteContext?.onUserTriggeredRefresh() - privacyProtectionsPopupManager.onPageRefreshTriggeredByUser() + privacyProtectionsPopupManager.onPageRefreshTriggeredByUser(isOmnibarAtTheTop = settingsDataStore.omnibarPosition == TOP) } } @@ -1159,7 +1293,7 @@ class BrowserTabViewModel @Inject constructor( if (!currentBrowserViewState().browserShowing) return - if (loadingBarExperimentManager.isExperimentEnabled()) { + if (loadingBarExperimentManager.isExperimentEnabled() || settingsDataStore.omnibarPosition == BOTTOM) { showOmniBar() } @@ -3577,6 +3711,8 @@ class BrowserTabViewModel @Inject constructor( ) } + fun hasOmnibarPositionChanged(currentPosition: OmnibarPosition): Boolean = settingsDataStore.omnibarPosition != currentPosition + private fun firePixelBasedOnCurrentUrl(emptyUrlPixel: AppPixelName, duckDuckGoQueryUrlPixel: AppPixelName, websiteUrlPixel: AppPixelName) { val text = url.orEmpty() if (text.isEmpty()) { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt index 4ba3a6481f2d..9cce3afcdd61 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt @@ -80,7 +80,10 @@ import com.duckduckgo.subscriptions.api.Subscriptions import com.duckduckgo.user.agent.api.ClientBrandHintProvider import java.net.URI import javax.inject.Inject -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import timber.log.Timber private const val ABOUT_BLANK = "about:blank" @@ -358,11 +361,11 @@ class BrowserWebViewClient @Inject constructor( } @UiThread - override fun onPageFinished( - webView: WebView, - url: String?, - ) { - Timber.v("onPageFinished webViewUrl: ${webView.url} URL: $url progress: ${webView.progress}") + override fun onPageFinished(webView: WebView, url: String?) { + Timber.v( + "onPageFinished webViewUrl: ${webView.url} URL: $url progress: ${webView.progress}", + ) + // See https://app.asana.com/0/0/1206159443951489/f (WebView limitations) if (webView.progress == 100) { jsPlugins.getPlugins().forEach { diff --git a/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt b/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt index 236b236adc73..f8bcf7138c4c 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt @@ -246,6 +246,7 @@ class DuckDuckGoWebView : WebView, NestedScrollingChild3 { returnValue = super.onTouchEvent(event) stopNestedScroll() } + MotionEvent.ACTION_MOVE -> { var deltaY = lastY - eventY diff --git a/app/src/main/java/com/duckduckgo/app/browser/autocomplete/BrowserAutoCompleteSuggestionsAdapter.kt b/app/src/main/java/com/duckduckgo/app/browser/autocomplete/BrowserAutoCompleteSuggestionsAdapter.kt index 7b9e6caa970b..af698625923f 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/autocomplete/BrowserAutoCompleteSuggestionsAdapter.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/autocomplete/BrowserAutoCompleteSuggestionsAdapter.kt @@ -33,6 +33,7 @@ import com.duckduckgo.app.browser.autocomplete.BrowserAutoCompleteSuggestionsAda import com.duckduckgo.app.browser.autocomplete.BrowserAutoCompleteSuggestionsAdapter.Type.HISTORY_TYPE import com.duckduckgo.app.browser.autocomplete.BrowserAutoCompleteSuggestionsAdapter.Type.IN_APP_MESSAGE_TYPE import com.duckduckgo.app.browser.autocomplete.BrowserAutoCompleteSuggestionsAdapter.Type.SUGGESTION_TYPE +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition class BrowserAutoCompleteSuggestionsAdapter( private val immediateSearchClickListener: (AutoCompleteSuggestion) -> Unit, @@ -40,6 +41,7 @@ class BrowserAutoCompleteSuggestionsAdapter( private val autoCompleteInAppMessageDismissedListener: () -> Unit, private val autoCompleteOpenSettingsClickListener: () -> Unit, private val autoCompleteLongPressClickListener: (AutoCompleteSuggestion) -> Unit, + omnibarPosition: OmnibarPosition, ) : RecyclerView.Adapter() { private val deleteClickListener: (AutoCompleteSuggestion) -> Unit = { @@ -50,12 +52,12 @@ class BrowserAutoCompleteSuggestionsAdapter( private val viewHolderFactoryMap: Map = mapOf( EMPTY_TYPE to EmptySuggestionViewHolderFactory(), - SUGGESTION_TYPE to SearchSuggestionViewHolderFactory(), - BOOKMARK_TYPE to BookmarkSuggestionViewHolderFactory(), - HISTORY_TYPE to HistorySuggestionViewHolderFactory(), - HISTORY_SEARCH_TYPE to HistorySearchSuggestionViewHolderFactory(), + SUGGESTION_TYPE to SearchSuggestionViewHolderFactory(omnibarPosition), + BOOKMARK_TYPE to BookmarkSuggestionViewHolderFactory(omnibarPosition), + HISTORY_TYPE to HistorySuggestionViewHolderFactory(omnibarPosition), + HISTORY_SEARCH_TYPE to HistorySearchSuggestionViewHolderFactory(omnibarPosition), IN_APP_MESSAGE_TYPE to InAppMessageViewHolderFactory(), - DEFAULT_TYPE to DefaultSuggestionViewHolderFactory(), + DEFAULT_TYPE to DefaultSuggestionViewHolderFactory(omnibarPosition), ) private var phrase = "" diff --git a/app/src/main/java/com/duckduckgo/app/browser/autocomplete/SuggestionViewHolderFactory.kt b/app/src/main/java/com/duckduckgo/app/browser/autocomplete/SuggestionViewHolderFactory.kt index 0aa4e993e474..676b2bf0186b 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/autocomplete/SuggestionViewHolderFactory.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/autocomplete/SuggestionViewHolderFactory.kt @@ -33,6 +33,7 @@ import com.duckduckgo.app.browser.databinding.ItemAutocompleteDefaultBinding import com.duckduckgo.app.browser.databinding.ItemAutocompleteHistorySuggestionBinding import com.duckduckgo.app.browser.databinding.ItemAutocompleteInAppMessageBinding import com.duckduckgo.app.browser.databinding.ItemAutocompleteSearchSuggestionBinding +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition import com.duckduckgo.common.ui.view.MessageCta.Message interface SuggestionViewHolderFactory { @@ -50,7 +51,7 @@ interface SuggestionViewHolderFactory { ) } -class SearchSuggestionViewHolderFactory : SuggestionViewHolderFactory { +class SearchSuggestionViewHolderFactory(private val omnibarPosition: OmnibarPosition) : SuggestionViewHolderFactory { override fun onCreateViewHolder(parent: ViewGroup): AutoCompleteViewHolder { val inflater = LayoutInflater.from(parent.context) @@ -68,11 +69,16 @@ class SearchSuggestionViewHolderFactory : SuggestionViewHolderFactory { longPressClickListener: (AutoCompleteSuggestion) -> Unit, ) { val searchSuggestionViewHolder = holder as AutoCompleteViewHolder.SearchSuggestionViewHolder - searchSuggestionViewHolder.bind(suggestion as AutoCompleteSearchSuggestion, immediateSearchClickListener, editableSearchClickListener) + searchSuggestionViewHolder.bind( + suggestion as AutoCompleteSearchSuggestion, + immediateSearchClickListener, + editableSearchClickListener, + omnibarPosition, + ) } } -class HistorySuggestionViewHolderFactory : SuggestionViewHolderFactory { +class HistorySuggestionViewHolderFactory(private val omnibarPosition: OmnibarPosition) : SuggestionViewHolderFactory { override fun onCreateViewHolder(parent: ViewGroup): AutoCompleteViewHolder { val inflater = LayoutInflater.from(parent.context) @@ -95,11 +101,12 @@ class HistorySuggestionViewHolderFactory : SuggestionViewHolderFactory { immediateSearchClickListener, editableSearchClickListener, longPressClickListener, + omnibarPosition, ) } } -class HistorySearchSuggestionViewHolderFactory : SuggestionViewHolderFactory { +class HistorySearchSuggestionViewHolderFactory(private val omnibarPosition: OmnibarPosition) : SuggestionViewHolderFactory { override fun onCreateViewHolder(parent: ViewGroup): AutoCompleteViewHolder { val inflater = LayoutInflater.from(parent.context) @@ -122,11 +129,12 @@ class HistorySearchSuggestionViewHolderFactory : SuggestionViewHolderFactory { immediateSearchClickListener, editableSearchClickListener, longPressClickListener, + omnibarPosition, ) } } -class BookmarkSuggestionViewHolderFactory : SuggestionViewHolderFactory { +class BookmarkSuggestionViewHolderFactory(private val omnibarPosition: OmnibarPosition) : SuggestionViewHolderFactory { override fun onCreateViewHolder(parent: ViewGroup): AutoCompleteViewHolder { val inflater = LayoutInflater.from(parent.context) @@ -144,7 +152,12 @@ class BookmarkSuggestionViewHolderFactory : SuggestionViewHolderFactory { longPressClickListener: (AutoCompleteSuggestion) -> Unit, ) { val bookmarkSuggestionViewHolder = holder as AutoCompleteViewHolder.BookmarkSuggestionViewHolder - bookmarkSuggestionViewHolder.bind(suggestion as AutoCompleteBookmarkSuggestion, immediateSearchClickListener, editableSearchClickListener) + bookmarkSuggestionViewHolder.bind( + suggestion as AutoCompleteBookmarkSuggestion, + immediateSearchClickListener, + editableSearchClickListener, + omnibarPosition, + ) } } @@ -172,7 +185,7 @@ class EmptySuggestionViewHolderFactory : SuggestionViewHolderFactory { } } -class DefaultSuggestionViewHolderFactory : SuggestionViewHolderFactory { +class DefaultSuggestionViewHolderFactory(private val omnibarPosition: OmnibarPosition) : SuggestionViewHolderFactory { override fun onCreateViewHolder(parent: ViewGroup): AutoCompleteViewHolder { val inflater = LayoutInflater.from(parent.context) @@ -189,7 +202,7 @@ class DefaultSuggestionViewHolderFactory : SuggestionViewHolderFactory { longPressClickListener: (AutoCompleteSuggestion) -> Unit, ) { val viewholder = holder as AutoCompleteViewHolder.DefaultSuggestionViewHolder - viewholder.bind(suggestion as AutoCompleteDefaultSuggestion, immediateSearchClickListener) + viewholder.bind(suggestion as AutoCompleteDefaultSuggestion, immediateSearchClickListener, omnibarPosition) } } @@ -220,6 +233,7 @@ sealed class AutoCompleteViewHolder(itemView: View) : RecyclerView.ViewHolder(it item: AutoCompleteSearchSuggestion, immediateSearchListener: (AutoCompleteSuggestion) -> Unit, editableSearchClickListener: (AutoCompleteSuggestion) -> Unit, + omnibarPosition: OmnibarPosition, ) = with(binding) { phrase.text = item.phrase @@ -228,6 +242,10 @@ sealed class AutoCompleteViewHolder(itemView: View) : RecyclerView.ViewHolder(it editQueryImage.setOnClickListener { editableSearchClickListener(item) } root.setOnClickListener { immediateSearchListener(item) } + + if (omnibarPosition == OmnibarPosition.BOTTOM) { + editQueryImage.setImageResource(R.drawable.ic_autocomplete_down_20dp) + } } } @@ -237,6 +255,7 @@ sealed class AutoCompleteViewHolder(itemView: View) : RecyclerView.ViewHolder(it immediateSearchListener: (AutoCompleteSuggestion) -> Unit, editableSearchClickListener: (AutoCompleteSuggestion) -> Unit, longPressClickListener: (AutoCompleteSuggestion) -> Unit, + omnibarPosition: OmnibarPosition, ) = with(binding) { phrase.text = item.phrase @@ -248,6 +267,10 @@ sealed class AutoCompleteViewHolder(itemView: View) : RecyclerView.ViewHolder(it longPressClickListener(item) true } + + if (omnibarPosition == OmnibarPosition.BOTTOM) { + editQueryImage.setImageResource(R.drawable.ic_autocomplete_down_20dp) + } } } @@ -256,6 +279,7 @@ sealed class AutoCompleteViewHolder(itemView: View) : RecyclerView.ViewHolder(it item: AutoCompleteBookmarkSuggestion, immediateSearchListener: (AutoCompleteSuggestion) -> Unit, editableSearchClickListener: (AutoCompleteSuggestion) -> Unit, + omnibarPosition: OmnibarPosition, ) = with(binding) { title.text = item.title url.text = item.phrase @@ -263,6 +287,10 @@ sealed class AutoCompleteViewHolder(itemView: View) : RecyclerView.ViewHolder(it bookmarkIndicator.setImageResource(if (item.isFavorite) R.drawable.ic_bookmark_favorite_20 else R.drawable.ic_bookmark_20) goToBookmarkImage.setOnClickListener { editableSearchClickListener(item) } root.setOnClickListener { immediateSearchListener(item) } + + if (omnibarPosition == OmnibarPosition.BOTTOM) { + goToBookmarkImage.setImageResource(R.drawable.ic_autocomplete_down_20dp) + } } } @@ -272,6 +300,7 @@ sealed class AutoCompleteViewHolder(itemView: View) : RecyclerView.ViewHolder(it immediateSearchListener: (AutoCompleteSuggestion) -> Unit, editableSearchClickListener: (AutoCompleteSuggestion) -> Unit, longPressClickListener: (AutoCompleteSuggestion) -> Unit, + omnibarPosition: OmnibarPosition, ) = with(binding) { title.text = item.title url.text = item.phrase @@ -282,6 +311,10 @@ sealed class AutoCompleteViewHolder(itemView: View) : RecyclerView.ViewHolder(it longPressClickListener(item) true } + + if (omnibarPosition == OmnibarPosition.BOTTOM) { + goToSuggestionImage.setImageResource(R.drawable.ic_autocomplete_down_20dp) + } } } @@ -291,9 +324,14 @@ sealed class AutoCompleteViewHolder(itemView: View) : RecyclerView.ViewHolder(it fun bind( item: AutoCompleteDefaultSuggestion, immediateSearchListener: (AutoCompleteSuggestion) -> Unit, + omnibarPosition: OmnibarPosition, ) { binding.phrase.text = item.phrase binding.root.setOnClickListener { immediateSearchListener(item) } + + if (omnibarPosition == OmnibarPosition.BOTTOM) { + binding.editQueryImage.setImageResource(R.drawable.ic_autocomplete_down_20dp) + } } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/menu/BrowserPopupMenu.kt b/app/src/main/java/com/duckduckgo/app/browser/menu/BrowserPopupMenu.kt index 8aa5263318df..b955233543cb 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/menu/BrowserPopupMenu.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/menu/BrowserPopupMenu.kt @@ -18,28 +18,213 @@ package com.duckduckgo.app.browser.menu import android.content.Context import android.view.LayoutInflater +import android.view.View import androidx.core.view.isVisible import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.SSLErrorType.NONE import com.duckduckgo.app.browser.databinding.PopupWindowBrowserMenuBinding +import com.duckduckgo.app.browser.databinding.PopupWindowBrowserMenuBottomBinding +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.BOTTOM +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.TOP import com.duckduckgo.app.browser.viewstate.BrowserViewState import com.duckduckgo.common.ui.menu.PopupMenu +import com.duckduckgo.common.ui.view.MenuItemView import com.duckduckgo.mobile.android.R.dimen import com.duckduckgo.mobile.android.R.drawable class BrowserPopupMenu( - context: Context, + private val context: Context, layoutInflater: LayoutInflater, - displayedInCustomTabScreen: Boolean, + private val omnibarPosition: OmnibarPosition, ) : PopupMenu( layoutInflater, - resourceId = R.layout.popup_window_browser_menu, + resourceId = if (omnibarPosition == TOP) R.layout.popup_window_browser_menu else R.layout.popup_window_browser_menu_bottom, width = context.resources.getDimensionPixelSize(dimen.popupMenuWidth), ) { - private val binding = PopupWindowBrowserMenuBinding.inflate(layoutInflater) + private val topBinding = PopupWindowBrowserMenuBinding.bind(contentView) + private val bottomBinding = PopupWindowBrowserMenuBottomBinding.bind(contentView) init { - contentView = binding.root + contentView = when (omnibarPosition) { + TOP -> topBinding.root + BOTTOM -> bottomBinding.root + } + } + + internal val backMenuItem: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.backMenuItem + BOTTOM -> bottomBinding.backMenuItem + } + } + + internal val forwardMenuItem: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.forwardMenuItem + BOTTOM -> bottomBinding.forwardMenuItem + } + } + + internal val refreshMenuItem: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.refreshMenuItem + BOTTOM -> bottomBinding.refreshMenuItem + } + } + + internal val printPageMenuItem: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.printPageMenuItem + BOTTOM -> bottomBinding.printPageMenuItem + } + } + + internal val newTabMenuItem: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.newTabMenuItem + BOTTOM -> bottomBinding.newTabMenuItem + } + } + + internal val sharePageMenuItem: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.sharePageMenuItem + BOTTOM -> bottomBinding.sharePageMenuItem + } + } + + internal val bookmarksMenuItem: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.bookmarksMenuItem + BOTTOM -> bottomBinding.bookmarksMenuItem + } + } + + internal val downloadsMenuItem: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.downloadsMenuItem + BOTTOM -> bottomBinding.downloadsMenuItem + } + } + + internal val settingsMenuItem: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.settingsMenuItem + BOTTOM -> bottomBinding.settingsMenuItem + } + } + + internal val addBookmarksMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.addBookmarksMenuItem + BOTTOM -> bottomBinding.addBookmarksMenuItem + } + } + + internal val fireproofWebsiteMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.fireproofWebsiteMenuItem + BOTTOM -> bottomBinding.fireproofWebsiteMenuItem + } + } + + internal val createAliasMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.createAliasMenuItem + BOTTOM -> bottomBinding.createAliasMenuItem + } + } + + internal val changeBrowserModeMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.changeBrowserModeMenuItem + BOTTOM -> bottomBinding.changeBrowserModeMenuItem + } + } + + internal val openInAppMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.openInAppMenuItem + BOTTOM -> bottomBinding.openInAppMenuItem + } + } + + internal val findInPageMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.findInPageMenuItem + BOTTOM -> bottomBinding.findInPageMenuItem + } + } + + internal val addToHomeMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.addToHomeMenuItem + BOTTOM -> bottomBinding.addToHomeMenuItem + } + } + + internal val privacyProtectionMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.privacyProtectionMenuItem + BOTTOM -> bottomBinding.privacyProtectionMenuItem + } + } + + internal val brokenSiteMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.brokenSiteMenuItem + BOTTOM -> bottomBinding.brokenSiteMenuItem + } + } + + internal val autofillMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.autofillMenuItem + BOTTOM -> bottomBinding.autofillMenuItem + } + } + + internal val runningInDdgBrowserMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.runningInDdgBrowserMenuItem + BOTTOM -> bottomBinding.runningInDdgBrowserMenuItem + } + } + + internal val siteOptionsMenuDivider: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.siteOptionsMenuDivider + BOTTOM -> bottomBinding.siteOptionsMenuDivider + } + } + + internal val browserOptionsMenuDivider: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.browserOptionsMenuDivider + BOTTOM -> bottomBinding.browserOptionsMenuDivider + } + } + + internal val settingsMenuDivider: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.settingsMenuDivider + BOTTOM -> bottomBinding.settingsMenuDivider + } + } + + internal val customTabsMenuDivider: View by lazy { + when (omnibarPosition) { + TOP -> topBinding.customTabsMenuDivider + BOTTOM -> bottomBinding.customTabsMenuDivider + } + } + + internal val openInDdgBrowserMenuItem: MenuItemView by lazy { + when (omnibarPosition) { + TOP -> topBinding.openInDdgBrowserMenuItem + BOTTOM -> bottomBinding.openInDdgBrowserMenuItem + } } fun renderState( @@ -47,92 +232,89 @@ class BrowserPopupMenu( viewState: BrowserViewState, displayedInCustomTabScreen: Boolean, ) { - contentView.apply { - binding.backMenuItem.isEnabled = viewState.canGoBack - binding.forwardMenuItem.isEnabled = viewState.canGoForward - binding.refreshMenuItem.isEnabled = browserShowing - binding.printPageMenuItem.isEnabled = browserShowing - - binding.newTabMenuItem.isVisible = browserShowing && !displayedInCustomTabScreen - binding.sharePageMenuItem.isVisible = viewState.canSharePage - - binding.bookmarksMenuItem.isVisible = !displayedInCustomTabScreen - binding.downloadsMenuItem.isVisible = !displayedInCustomTabScreen - binding.settingsMenuItem.isVisible = !displayedInCustomTabScreen - - binding.addBookmarksMenuItem.isVisible = viewState.canSaveSite && !displayedInCustomTabScreen - val isBookmark = viewState.bookmark != null - binding.addBookmarksMenuItem.label { - context.getString(if (isBookmark) R.string.editBookmarkMenuTitle else R.string.addBookmarkMenuTitle) - } - binding.addBookmarksMenuItem.setIcon(if (isBookmark) drawable.ic_bookmark_solid_16 else drawable.ic_bookmark_16) - - binding.fireproofWebsiteMenuItem.isVisible = viewState.canFireproofSite && !displayedInCustomTabScreen - binding.fireproofWebsiteMenuItem.label { - context.getString( - if (viewState.isFireproofWebsite) { - R.string.fireproofWebsiteMenuTitleRemove - } else { - R.string.fireproofWebsiteMenuTitleAdd - }, - ) - } - binding.fireproofWebsiteMenuItem.setIcon(if (viewState.isFireproofWebsite) drawable.ic_fire_16 else drawable.ic_fireproofed_16) - - binding.createAliasMenuItem.isVisible = viewState.isEmailSignedIn && !displayedInCustomTabScreen - - binding.changeBrowserModeMenuItem.isVisible = viewState.canChangeBrowsingMode - binding.changeBrowserModeMenuItem.label { - context.getString( - if (viewState.isDesktopBrowsingMode) { - R.string.requestMobileSiteMenuTitle - } else { - R.string.requestDesktopSiteMenuTitle - }, - ) - } - binding.changeBrowserModeMenuItem.setIcon( - if (viewState.isDesktopBrowsingMode) drawable.ic_device_mobile_16 else drawable.ic_device_desktop_16, - ) + backMenuItem.isEnabled = viewState.canGoBack + forwardMenuItem.isEnabled = viewState.canGoForward + refreshMenuItem.isEnabled = browserShowing + printPageMenuItem.isEnabled = browserShowing - binding.openInAppMenuItem.isVisible = viewState.previousAppLink != null - binding.findInPageMenuItem.isVisible = viewState.canFindInPage - binding.addToHomeMenuItem.isVisible = viewState.addToHomeVisible && viewState.addToHomeEnabled && !displayedInCustomTabScreen - binding.privacyProtectionMenuItem.isVisible = viewState.canChangePrivacyProtection - binding.privacyProtectionMenuItem.label { - context.getText( - if (viewState.isPrivacyProtectionDisabled) { - R.string.enablePrivacyProtection - } else { - R.string.disablePrivacyProtection - }, - ).toString() - } - binding.privacyProtectionMenuItem.setIcon( - if (viewState.isPrivacyProtectionDisabled) drawable.ic_protections_16 else drawable.ic_protections_blocked_16, + newTabMenuItem.isVisible = browserShowing && !displayedInCustomTabScreen + sharePageMenuItem.isVisible = viewState.canSharePage + + bookmarksMenuItem.isVisible = !displayedInCustomTabScreen + downloadsMenuItem.isVisible = !displayedInCustomTabScreen + settingsMenuItem.isVisible = !displayedInCustomTabScreen + + addBookmarksMenuItem.isVisible = viewState.canSaveSite && !displayedInCustomTabScreen + val isBookmark = viewState.bookmark != null + addBookmarksMenuItem.label { + context.getString(if (isBookmark) R.string.editBookmarkMenuTitle else R.string.addBookmarkMenuTitle) + } + addBookmarksMenuItem.setIcon(if (isBookmark) drawable.ic_bookmark_solid_16 else drawable.ic_bookmark_16) + + fireproofWebsiteMenuItem.isVisible = viewState.canFireproofSite && !displayedInCustomTabScreen + fireproofWebsiteMenuItem.label { + context.getString( + if (viewState.isFireproofWebsite) { + R.string.fireproofWebsiteMenuTitleRemove + } else { + R.string.fireproofWebsiteMenuTitleAdd + }, ) - binding.brokenSiteMenuItem.isVisible = viewState.canReportSite && !displayedInCustomTabScreen + } + fireproofWebsiteMenuItem.setIcon(if (viewState.isFireproofWebsite) drawable.ic_fire_16 else drawable.ic_fireproofed_16) - binding.siteOptionsMenuDivider.isVisible = viewState.browserShowing && !displayedInCustomTabScreen - binding.browserOptionsMenuDivider.isVisible = viewState.browserShowing && !displayedInCustomTabScreen - binding.settingsMenuDivider.isVisible = viewState.browserShowing && !displayedInCustomTabScreen - binding.printPageMenuItem.isVisible = viewState.canPrintPage && !displayedInCustomTabScreen - binding.autofillMenuItem.isVisible = viewState.showAutofill && !displayedInCustomTabScreen + createAliasMenuItem.isVisible = viewState.isEmailSignedIn && !displayedInCustomTabScreen - binding.openInDdgBrowserMenuItem.isVisible = displayedInCustomTabScreen - binding.customTabsMenuDivider.isVisible = displayedInCustomTabScreen - binding.runningInDdgBrowserMenuItem.isVisible = displayedInCustomTabScreen - overrideForSSlError(binding, viewState) + changeBrowserModeMenuItem.isVisible = viewState.canChangeBrowsingMode + changeBrowserModeMenuItem.label { + context.getString( + if (viewState.isDesktopBrowsingMode) { + R.string.requestMobileSiteMenuTitle + } else { + R.string.requestDesktopSiteMenuTitle + }, + ) + } + changeBrowserModeMenuItem.setIcon( + if (viewState.isDesktopBrowsingMode) drawable.ic_device_mobile_16 else drawable.ic_device_desktop_16, + ) + + openInAppMenuItem.isVisible = viewState.previousAppLink != null + findInPageMenuItem.isVisible = viewState.canFindInPage + addToHomeMenuItem.isVisible = viewState.addToHomeVisible && viewState.addToHomeEnabled && !displayedInCustomTabScreen + privacyProtectionMenuItem.isVisible = viewState.canChangePrivacyProtection + privacyProtectionMenuItem.label { + context.getText( + if (viewState.isPrivacyProtectionDisabled) { + R.string.enablePrivacyProtection + } else { + R.string.disablePrivacyProtection + }, + ).toString() } + privacyProtectionMenuItem.setIcon( + if (viewState.isPrivacyProtectionDisabled) drawable.ic_protections_16 else drawable.ic_protections_blocked_16, + ) + brokenSiteMenuItem.isVisible = viewState.canReportSite && !displayedInCustomTabScreen + + siteOptionsMenuDivider.isVisible = viewState.browserShowing && !displayedInCustomTabScreen + browserOptionsMenuDivider.isVisible = viewState.browserShowing && !displayedInCustomTabScreen + settingsMenuDivider.isVisible = viewState.browserShowing && !displayedInCustomTabScreen + printPageMenuItem.isVisible = viewState.canPrintPage && !displayedInCustomTabScreen + autofillMenuItem.isVisible = viewState.showAutofill && !displayedInCustomTabScreen + + openInDdgBrowserMenuItem.isVisible = displayedInCustomTabScreen + customTabsMenuDivider.isVisible = displayedInCustomTabScreen + runningInDdgBrowserMenuItem.isVisible = displayedInCustomTabScreen + overrideForSSlError(viewState) } private fun overrideForSSlError( - binding: PopupWindowBrowserMenuBinding, viewState: BrowserViewState, ) { if (viewState.sslError != NONE) { - binding.newTabMenuItem.isVisible = true - binding.siteOptionsMenuDivider.isVisible = true + newTabMenuItem.isVisible = true + siteOptionsMenuDivider.isVisible = true } } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/BottomAppBarBehavior.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/BottomAppBarBehavior.kt new file mode 100644 index 000000000000..153427b4dc1d --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/BottomAppBarBehavior.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.omnibar + +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.view.View +import android.view.animation.DecelerateInterpolator +import android.widget.RelativeLayout +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.ViewCompat +import androidx.core.view.ViewCompat.NestedScrollType +import androidx.core.view.updateLayoutParams +import com.duckduckgo.app.browser.R +import com.google.android.material.snackbar.Snackbar +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/* + * This custom behavior for the bottom omnibar is necessary because the default `HideBottomViewOnScrollBehavior` does not work. + * The reason is that the `DuckDuckGoWebView` is passing only unconsumed movement, which `HideBottomViewOnScrollBehavior` ignores. + */ +class BottomAppBarBehavior( + context: Context, + private val toolbar: LegacyOmnibarView, + attrs: AttributeSet? = null, +) : CoordinatorLayout.Behavior(context, attrs) { + @NestedScrollType + private var lastStartedType: Int = 0 + private var offsetAnimator: ValueAnimator? = null + + private var browserLayout: RelativeLayout? = null + + @SuppressLint("RestrictedApi") + override fun layoutDependsOn(parent: CoordinatorLayout, child: V, dependency: View): Boolean { + if (dependency is Snackbar.SnackbarLayout) { + updateSnackbar(child, dependency) + } + + if (dependency.id == R.id.browserLayout) { + browserLayout = dependency as RelativeLayout + offsetBottomByToolbar(browserLayout) + } + + return super.layoutDependsOn(parent, child, dependency) + } + + override fun onStartNestedScroll( + coordinatorLayout: CoordinatorLayout, + child: V, + directTargetChild: View, + target: View, + axes: Int, + type: Int, + ): Boolean { + if (axes == ViewCompat.SCROLL_AXIS_VERTICAL) { + lastStartedType = type + offsetAnimator?.cancel() + return true + } else { + return false + } + } + + override fun onNestedPreScroll( + coordinatorLayout: CoordinatorLayout, + toolbar: V, + target: View, + dx: Int, + dy: Int, + consumed: IntArray, + type: Int, + ) { + super.onNestedPreScroll(coordinatorLayout, toolbar, target, dx, dy, consumed, type) + + // only hide the app bar in the browser layout + if (target.id == R.id.browserWebView) { + toolbar.translationY = max(0f, min(toolbar.height.toFloat(), toolbar.translationY + dy)) + offsetBottomByToolbar(browserLayout) + } + + offsetBottomByToolbar(target) + } + + private fun offsetBottomByToolbar(view: View?) { + if (view?.layoutParams is CoordinatorLayout.LayoutParams) { + view.updateLayoutParams { + this.bottomMargin = toolbar.measuredHeight - toolbar.translationY.roundToInt() + } + view.requestLayout() + } + } + + override fun onStopNestedScroll(coordinatorLayout: CoordinatorLayout, child: V, target: View, type: Int) { + if (lastStartedType == ViewCompat.TYPE_TOUCH || type == ViewCompat.TYPE_NON_TOUCH) { + val dY = child.translationY + val threshold = child.height * 0.5f + if (dY >= threshold) { + // slide down + animateToolbarVisibility(isVisible = false) + } else { + // slide up + animateToolbarVisibility(isVisible = true) + } + } + } + + fun animateToolbarVisibility(isVisible: Boolean) { + if (offsetAnimator == null) { + offsetAnimator = ValueAnimator().apply { + interpolator = DecelerateInterpolator() + duration = 300L + } + } else { + offsetAnimator?.cancel() + } + + offsetAnimator?.addUpdateListener { animation -> + val animatedValue = animation.animatedValue as Float + toolbar.translationY = animatedValue + offsetBottomByToolbar(browserLayout) + } + + val targetTranslation = if (isVisible) 0f else toolbar.height.toFloat() + offsetAnimator?.setFloatValues(toolbar.translationY, targetTranslation) + offsetAnimator?.start() + } + + @SuppressLint("RestrictedApi") + private fun updateSnackbar(child: View, snackbarLayout: Snackbar.SnackbarLayout) { + if (snackbarLayout.layoutParams is CoordinatorLayout.LayoutParams) { + val params = snackbarLayout.layoutParams as CoordinatorLayout.LayoutParams + + params.anchorId = child.id + params.anchorGravity = Gravity.TOP + params.gravity = Gravity.TOP + snackbarLayout.layoutParams = params + + // add a padding to the snackbar to avoid it touching the anchor view + if (snackbarLayout.translationY == 0f) { + snackbarLayout.translationY -= child.context.resources.getDimension(com.duckduckgo.mobile.android.R.dimen.keyline_2) + } + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/ChangeOmnibarPositionFeature.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/ChangeOmnibarPositionFeature.kt new file mode 100644 index 000000000000..91e6cba5bd53 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/ChangeOmnibarPositionFeature.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.omnibar + +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.Toggle + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "changeOmnibarPosition", +) +interface ChangeOmnibarPositionFeature { + @Toggle.DefaultValue(false) + @Toggle.InternalAlwaysEnabled + fun self(): Toggle + + @Toggle.DefaultValue(false) + fun refactor(): Toggle +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/LegacyOmnibarView.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/LegacyOmnibarView.kt index 0bb9724dd3c0..c7d8f923bf08 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/omnibar/LegacyOmnibarView.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/LegacyOmnibarView.kt @@ -18,8 +18,21 @@ package com.duckduckgo.app.browser.omnibar import android.content.Context import android.util.AttributeSet -import com.duckduckgo.app.browser.databinding.ViewLegacyOmnibarBinding -import com.duckduckgo.common.ui.viewbinding.viewBinding +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.ProgressBar +import androidx.appcompat.widget.Toolbar +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.coordinatorlayout.widget.CoordinatorLayout.Behavior +import com.airbnb.lottie.LottieAnimationView +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.browser.TabSwitcherButton +import com.duckduckgo.app.browser.databinding.IncludeCustomTabToolbarBinding +import com.duckduckgo.app.browser.databinding.IncludeFindInPageBinding +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition +import com.duckduckgo.common.ui.view.KeyboardAwareEditText import com.google.android.material.appbar.AppBarLayout class LegacyOmnibarView @JvmOverloads constructor( @@ -28,77 +41,63 @@ class LegacyOmnibarView @JvmOverloads constructor( defStyle: Int = 0, ) : AppBarLayout(context, attrs, defStyle) { - private val binding: ViewLegacyOmnibarBinding by viewBinding() - - val findInPage - get() = binding.findInPage - - val omnibarTextInput - get() = binding.omnibarTextInput - - val tabsMenu - get() = binding.tabsMenu - - val fireIconMenu - get() = binding.fireIconMenu - - val browserMenu - get() = binding.browserMenu - - val cookieDummyView - get() = binding.cookieDummyView - - val cookieAnimation - get() = binding.cookieAnimation - - val sceneRoot - get() = binding.sceneRoot - - val omniBarContainer - get() = binding.omniBarContainer - - val toolbar - get() = binding.toolbar - - val toolbarContainer - get() = binding.toolbarContainer - - val customTabToolbarContainer - get() = binding.customTabToolbarContainer - - val browserMenuImageView - get() = binding.browserMenuImageView - - val shieldIcon - get() = binding.shieldIcon - - val pageLoadingIndicator - get() = binding.pageLoadingIndicator - - val searchIcon - get() = binding.searchIcon - - val daxIcon - get() = binding.daxIcon - - val clearTextButton - get() = binding.clearTextButton - - val fireIconImageView - get() = binding.fireIconImageView - - val placeholder - get() = binding.placeholder - - val voiceSearchButton - get() = binding.voiceSearchButton - - val spacer - get() = binding.spacer - - val trackersAnimation - get() = binding.trackersAnimation - - val duckPlayerIcon - get() = binding.duckPlayerIcon + private val omnibarPosition: OmnibarPosition + + init { + val attr = context.theme.obtainStyledAttributes(attrs, R.styleable.LegacyOmnibarView, defStyle, 0) + omnibarPosition = OmnibarPosition.entries[attr.getInt(R.styleable.LegacyOmnibarView_omnibarPosition, 0)] + + val layout = if (omnibarPosition == OmnibarPosition.BOTTOM) { + R.layout.view_legacy_omnibar_bottom + } else { + R.layout.view_legacy_omnibar + } + inflate(context, layout, this) + } + + override fun setExpanded(expanded: Boolean) { + when (omnibarPosition) { + OmnibarPosition.TOP -> super.setExpanded(expanded) + OmnibarPosition.BOTTOM -> (behavior as BottomAppBarBehavior).animateToolbarVisibility(expanded) + } + } + + override fun setExpanded(expanded: Boolean, animate: Boolean) { + when (omnibarPosition) { + OmnibarPosition.TOP -> super.setExpanded(expanded, animate) + OmnibarPosition.BOTTOM -> (behavior as BottomAppBarBehavior).animateToolbarVisibility(expanded) + } + } + + val findInPage by lazy { IncludeFindInPageBinding.bind(findViewById(R.id.findInPage)) } + val omnibarTextInput: KeyboardAwareEditText by lazy { findViewById(R.id.omnibarTextInput) } + val tabsMenu: TabSwitcherButton by lazy { findViewById(R.id.tabsMenu) } + val fireIconMenu: FrameLayout by lazy { findViewById(R.id.fireIconMenu) } + val browserMenu: FrameLayout by lazy { findViewById(R.id.browserMenu) } + val cookieDummyView: View by lazy { findViewById(R.id.cookieDummyView) } + val cookieAnimation: LottieAnimationView by lazy { findViewById(R.id.cookieAnimation) } + val sceneRoot: ViewGroup by lazy { findViewById(R.id.sceneRoot) } + val omniBarContainer: View by lazy { findViewById(R.id.omniBarContainer) } + val toolbar: Toolbar by lazy { findViewById(R.id.toolbar) } + val toolbarContainer: View by lazy { findViewById(R.id.toolbarContainer) } + val customTabToolbarContainer by lazy { IncludeCustomTabToolbarBinding.bind(findViewById(R.id.customTabToolbarContainer)) } + val browserMenuImageView: ImageView by lazy { findViewById(R.id.browserMenuImageView) } + val shieldIcon: LottieAnimationView by lazy { findViewById(R.id.shieldIcon) } + val pageLoadingIndicator: ProgressBar by lazy { findViewById(R.id.pageLoadingIndicator) } + val searchIcon: ImageView by lazy { findViewById(R.id.searchIcon) } + val daxIcon: ImageView by lazy { findViewById(R.id.daxIcon) } + val clearTextButton: ImageView by lazy { findViewById(R.id.clearTextButton) } + val fireIconImageView: ImageView by lazy { findViewById(R.id.fireIconImageView) } + val placeholder: View by lazy { findViewById(R.id.placeholder) } + val voiceSearchButton: ImageView by lazy { findViewById(R.id.voiceSearchButton) } + val spacer: View by lazy { findViewById(R.id.spacer) } + val trackersAnimation: LottieAnimationView by lazy { findViewById(R.id.trackersAnimation) } + val duckPlayerIcon: ImageView by lazy { findViewById(R.id.duckPlayerIcon) } + + override fun getBehavior(): CoordinatorLayout.Behavior { + return when (omnibarPosition) { + OmnibarPosition.TOP -> TopAppBarBehavior(context) + OmnibarPosition.BOTTOM -> BottomAppBarBehavior(context, this) + } + } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/Omnibar.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/Omnibar.kt new file mode 100644 index 000000000000..b5ce06d2447c --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/Omnibar.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.omnibar + +import android.annotation.SuppressLint +import android.content.res.TypedArray +import android.view.View +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.updateLayoutParams +import com.duckduckgo.app.browser.databinding.FragmentBrowserTabBinding +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition +import com.duckduckgo.mobile.android.R as CommonR + +@SuppressLint("ClickableViewAccessibility") +class Omnibar( + val omnibarPosition: OmnibarPosition, + private val binding: FragmentBrowserTabBinding, +) { + private val actionBarSize: Int by lazy { + val array: TypedArray = binding.rootView.context.theme.obtainStyledAttributes(intArrayOf(android.R.attr.actionBarSize)) + val actionBarSize = array.getDimensionPixelSize(0, -1) + array.recycle() + actionBarSize + } + + val appBarLayout: LegacyOmnibarView by lazy { + when (omnibarPosition) { + OmnibarPosition.TOP -> { + binding.rootView.removeView(binding.legacyOmnibarBottom) + binding.legacyOmnibar + } + OmnibarPosition.BOTTOM -> { + binding.rootView.removeView(binding.legacyOmnibar) + + // remove the default top abb bar behavior + removeAppBarBehavior(binding.autoCompleteSuggestionsList) + removeAppBarBehavior(binding.browserLayout) + removeAppBarBehavior(binding.focusedView) + + // add padding to the NTP to prevent the bottom toolbar from overlapping the settings button + binding.includeNewBrowserTab.browserBackground.apply { + setPadding(paddingLeft, context.resources.getDimensionPixelSize(CommonR.dimen.keyline_2), paddingRight, actionBarSize) + } + + // prevent the touch event leaking to the webView below + binding.legacyOmnibarBottom.setOnTouchListener { _, _ -> true } + + binding.legacyOmnibarBottom + } + } + } + + private fun removeAppBarBehavior(view: View) { + view.updateLayoutParams { + behavior = null + } + } + + val findInPage = appBarLayout.findInPage + val omnibarTextInput = appBarLayout.omnibarTextInput + val tabsMenu = appBarLayout.tabsMenu + val fireIconMenu = appBarLayout.fireIconMenu + val browserMenu = appBarLayout.browserMenu + val cookieDummyView = appBarLayout.cookieDummyView + val cookieAnimation = appBarLayout.cookieAnimation + val sceneRoot = appBarLayout.sceneRoot + val omniBarContainer = appBarLayout.omniBarContainer + val toolbar = appBarLayout.toolbar + val toolbarContainer = appBarLayout.toolbarContainer + val customTabToolbarContainer = appBarLayout.customTabToolbarContainer + val browserMenuImageView = appBarLayout.browserMenuImageView + val shieldIcon = appBarLayout.shieldIcon + val pageLoadingIndicator = appBarLayout.pageLoadingIndicator + val searchIcon = appBarLayout.searchIcon + val daxIcon = appBarLayout.daxIcon + val clearTextButton = appBarLayout.clearTextButton + val fireIconImageView = appBarLayout.fireIconImageView + val placeholder = appBarLayout.placeholder + val voiceSearchButton = appBarLayout.voiceSearchButton + val spacer = appBarLayout.spacer + val trackersAnimation = appBarLayout.trackersAnimation + val duckPlayerIcon = appBarLayout.duckPlayerIcon +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarFeatureFlagObserver.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarFeatureFlagObserver.kt new file mode 100644 index 000000000000..dd6f36d1429a --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarFeatureFlagObserver.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2019 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.omnibar + +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.settings.db.SettingsDataStore +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager +import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@ContributesMultibinding( + scope = AppScope::class, + boundType = PrivacyConfigCallbackPlugin::class, +) +@SingleInstanceIn(AppScope::class) +class OmnibarFeatureFlagObserver @Inject constructor( + private val changeOmnibarPositionFeature: ChangeOmnibarPositionFeature, + private val settingsDataStore: SettingsDataStore, + private val dispatchers: DispatcherProvider, + private val loadingBarExperimentManager: LoadingBarExperimentManager, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : PrivacyConfigCallbackPlugin { + override fun onPrivacyConfigDownloaded() { + appCoroutineScope.launch(dispatchers.io()) { + // If the feature is not enabled, set the omnibar position to top in case it was set to bottom. + // The feature will only available if the loading experiment is disabled to avoid conflicts. + if (!changeOmnibarPositionFeature.self().isEnabled() || loadingBarExperimentManager.isExperimentEnabled()) { + settingsDataStore.omnibarPosition = OmnibarPosition.TOP + } + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarPositionDetector.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarPositionDetector.kt new file mode 100644 index 000000000000..df332394c22f --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarPositionDetector.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.omnibar + +import com.duckduckgo.app.settings.db.SettingsDataStore +import com.duckduckgo.app.statistics.api.BrowserFeatureStateReporterPlugin +import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject + +interface OmnibarPositionReporterPlugin + +@ContributesMultibinding(scope = AppScope::class, boundType = BrowserFeatureStateReporterPlugin::class) +@ContributesBinding(scope = AppScope::class, boundType = OmnibarPositionReporterPlugin::class) +@SingleInstanceIn(AppScope::class) +class OmnibarPositionDetector @Inject constructor( + private val settingsDataStore: SettingsDataStore, +) : OmnibarPositionReporterPlugin, BrowserFeatureStateReporterPlugin { + override fun featureStateParams(): Map { + return mapOf(PixelParameter.ADDRESS_BAR to settingsDataStore.omnibarPosition.name) + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/TopAppBarBehavior.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/TopAppBarBehavior.kt new file mode 100644 index 000000000000..cb568164cf3b --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/TopAppBarBehavior.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.omnibar + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.duckduckgo.app.browser.R +import com.google.android.material.appbar.AppBarLayout + +/* + * This custom behavior prevents the top omnibar from hiding everywhere except for the browser view (i.e. the autocomplete suggestions) + */ +class TopAppBarBehavior(context: Context, attrs: AttributeSet? = null) : AppBarLayout.Behavior(context, attrs) { + override fun onNestedPreScroll( + coordinatorLayout: CoordinatorLayout, + child: AppBarLayout, + target: View, + dx: Int, + dy: Int, + consumed: IntArray, + type: Int, + ) { + if (target.id == R.id.browserWebView) { + super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type) + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/model/OmnibarPosition.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/model/OmnibarPosition.kt new file mode 100644 index 000000000000..6e266d963efe --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/model/OmnibarPosition.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.omnibar.model + +enum class OmnibarPosition { + TOP, BOTTOM +} diff --git a/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt b/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt index d3874668838a..0c2cad73aeb5 100644 --- a/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt +++ b/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt @@ -30,6 +30,7 @@ import androidx.transition.TransitionManager import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.databinding.FragmentBrowserTabBinding import com.duckduckgo.app.browser.databinding.IncludeOnboardingViewDaxDialogBinding +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition import com.duckduckgo.app.cta.model.CtaId import com.duckduckgo.app.cta.ui.DaxBubbleCta.DaxDialogIntroOption import com.duckduckgo.app.cta.ui.DaxCta.Companion.MAX_DAYS_ALLOWED @@ -37,6 +38,7 @@ import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.global.install.daysInstalled import com.duckduckgo.app.onboarding.store.OnboardingStore import com.duckduckgo.app.pixels.AppPixelName +import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelValues.DAX_FIRE_DIALOG_CTA import com.duckduckgo.app.trackerdetection.model.Entity @@ -176,6 +178,7 @@ sealed class OnboardingDaxDialogCta( override val onboardingStore: OnboardingStore, override val appInstallStore: AppInstallStore, val trackers: List, + val settingsDataStore: SettingsDataStore, ) : OnboardingDaxDialogCta( CtaId.DAX_DIALOG_TRACKERS_FOUND, null, @@ -217,8 +220,10 @@ sealed class OnboardingDaxDialogCta( val quantityString = if (size == 0) { context.resources.getQuantityString(R.plurals.onboardingTrackersBlockedZeroDialogDescription, trackersFiltered.size) + .getStringForOmnibarPosition(settingsDataStore.omnibarPosition) } else { context.resources.getQuantityString(R.plurals.onboardingTrackersBlockedDialogDescription, size, size) + .getStringForOmnibarPosition(settingsDataStore.omnibarPosition) } return "$trackersText$quantityString" } @@ -308,6 +313,7 @@ sealed class OnboardingDaxDialogCta( class DaxFireButtonCta( override val onboardingStore: OnboardingStore, override val appInstallStore: AppInstallStore, + val settingsDataStore: SettingsDataStore, ) : OnboardingDaxDialogCta( CtaId.DAX_FIRE_BUTTON, R.string.onboardingFireButtonDaxDialogDescription, @@ -326,7 +332,7 @@ sealed class OnboardingDaxDialogCta( ) { val context = binding.root.context val daxDialog = binding.includeOnboardingDaxDialog - val daxText = description?.let { context.getString(it) }.orEmpty() + val daxText = description?.let { context.getString(it) }?.getStringForOmnibarPosition(settingsDataStore.omnibarPosition).orEmpty() daxDialog.primaryCta.gone() daxDialog.dialogTextCta.text = "" @@ -395,6 +401,7 @@ sealed class OnboardingDaxDialogCta( class DaxEndCta( override val onboardingStore: OnboardingStore, override val appInstallStore: AppInstallStore, + val settingsDataStore: SettingsDataStore, ) : OnboardingDaxDialogCta( CtaId.DAX_END, R.string.onboardingEndDaxDialogDescription, @@ -416,7 +423,7 @@ sealed class OnboardingDaxDialogCta( val context = binding.root.context setOnboardingDialogView( daxTitle = context.getString(R.string.onboardingEndDaxDialogTitle), - daxText = description?.let { context.getString(it) }.orEmpty(), + daxText = description?.let { context.getString(it) }?.getStringForOmnibarPosition(settingsDataStore.omnibarPosition).orEmpty(), buttonText = buttonText?.let { context.getString(it) }, binding = binding, ) @@ -692,3 +699,10 @@ fun DaxCta.canSendShownPixel(): Boolean { val param = onboardingStore.onboardingDialogJourney?.split("-").orEmpty().toMutableList() return !(param.isNotEmpty() && param.any { it.split(":").firstOrNull().orEmpty() == ctaPixelParam }) } + +fun String.getStringForOmnibarPosition(position: OmnibarPosition): String { + return when (position) { + OmnibarPosition.TOP -> this + OmnibarPosition.BOTTOM -> replace("☝", "\uD83D\uDC47") + } +} diff --git a/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt b/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt index a44aa5f1e79e..ad03c178cef6 100644 --- a/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt @@ -181,7 +181,7 @@ class CtaViewModel @Inject constructor( suspend fun getFireDialogCta(): OnboardingDaxDialogCta.DaxFireButtonCta? { if (!daxOnboardingActive() || daxDialogFireEducationShown()) return null return withContext(dispatchers.io()) { - return@withContext OnboardingDaxDialogCta.DaxFireButtonCta(onboardingStore, appInstallStore) + return@withContext OnboardingDaxDialogCta.DaxFireButtonCta(onboardingStore, appInstallStore, settingsDataStore) } } @@ -281,6 +281,7 @@ class CtaViewModel @Inject constructor( onboardingStore, appInstallStore, it.orderedTrackerBlockedEntities(), + settingsDataStore, ) } @@ -308,7 +309,7 @@ class CtaViewModel @Inject constructor( // End if (canShowDaxCtaEndOfJourney() && daxDialogFireEducationShown()) { - return OnboardingDaxDialogCta.DaxEndCta(onboardingStore, appInstallStore) + return OnboardingDaxDialogCta.DaxEndCta(onboardingStore, appInstallStore, settingsDataStore) } return null diff --git a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt index f90ce9620c9c..8be0a197711e 100644 --- a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt +++ b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt @@ -123,6 +123,9 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName { SETTINGS_PERMISSIONS_PRESSED("ms_permissions_setting_pressed"), SETTINGS_APPEARANCE_PRESSED("ms_appearance_setting_pressed"), SETTINGS_APP_ICON_PRESSED("ms_app_icon_setting_pressed"), + SETTINGS_ADDRESS_BAR_POSITION_PRESSED("ms_address_bar_position_setting_pressed"), + SETTINGS_ADDRESS_BAR_POSITION_SELECTED_TOP("ms_address_bar_position_setting_selected_top"), + SETTINGS_ADDRESS_BAR_POSITION_SELECTED_BOTTOM("ms_address_bar_position_setting_selected_bottom"), SETTINGS_MAC_APP_PRESSED("ms_mac_app_setting_pressed"), SETTINGS_WINDOWS_APP_PRESSED("ms_windows_app_setting_pressed"), SETTINGS_EMAIL_PROTECTION_PRESSED("ms_email_protection_setting_pressed"), diff --git a/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt b/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt index fd06ac011e08..f643381b048b 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt @@ -19,6 +19,7 @@ package com.duckduckgo.app.settings.db import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition import com.duckduckgo.app.fire.fireproofwebsite.ui.AutomaticFireproofSetting import com.duckduckgo.app.fire.fireproofwebsite.ui.AutomaticFireproofSetting.ASK_EVERY_TIME import com.duckduckgo.app.fire.fireproofwebsite.ui.AutomaticFireproofSetting.NEVER @@ -52,6 +53,7 @@ interface SettingsDataStore { var appLinksEnabled: Boolean var showAppLinksPrompt: Boolean var showAutomaticFireproofDialog: Boolean + var omnibarPosition: OmnibarPosition /** * This will be checked upon app startup and used to decide whether it should perform a clear or not. @@ -176,6 +178,10 @@ class SettingsSharedPreferences @Inject constructor( get() = preferences.getBoolean(SHOW_AUTOMATIC_FIREPROOF_DIALOG, true) set(enabled) = preferences.edit { putBoolean(SHOW_AUTOMATIC_FIREPROOF_DIALOG, enabled) } + override var omnibarPosition: OmnibarPosition + get() = OmnibarPosition.valueOf(preferences.getString(KEY_OMNIBAR_POSITION, OmnibarPosition.TOP.name) ?: OmnibarPosition.TOP.name) + set(value) = preferences.edit { putString(KEY_OMNIBAR_POSITION, value.name) } + override fun hasBackgroundTimestampRecorded(): Boolean = preferences.contains(KEY_APP_BACKGROUNDED_TIMESTAMP) override fun clearAppBackgroundTimestamp() = preferences.edit { remove(KEY_APP_BACKGROUNDED_TIMESTAMP) } @@ -248,6 +254,7 @@ class SettingsSharedPreferences @Inject constructor( const val SHOW_AUTOMATIC_FIREPROOF_DIALOG = "SHOW_AUTOMATIC_FIREPROOF_DIALOG" const val KEY_NOTIFY_ME_IN_DOWNLOADS_DISMISSED = "KEY_NOTIFY_ME_IN_DOWNLOADS_DISMISSED" const val KEY_EXPERIMENTAL_SITE_DARK_MODE = "KEY_EXPERIMENTAL_SITE_DARK_MODE" + const val KEY_OMNIBAR_POSITION = "KEY_OMNIBAR_POSITION" } private class FireAnimationPrefsMapper { diff --git a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt index 5f71cfd22746..fe018674b0fb 100644 --- a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt @@ -52,6 +52,7 @@ import com.duckduckgo.app.fire.DataClearerForegroundAppRestartPixel import com.duckduckgo.app.global.view.TextChangedWatcher import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.privatesearch.PrivateSearchScreenNoParams +import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.systemsearch.SystemSearchViewModel.Command.* import com.duckduckgo.app.tabs.ui.GridViewColumnCalculator @@ -103,6 +104,9 @@ class SystemSearchActivity : DuckDuckGoActivity() { @Inject lateinit var globalActivityStarter: GlobalActivityStarter + @Inject + lateinit var settingsDataStore: SettingsDataStore + private val viewModel: SystemSearchViewModel by bindViewModel() private val binding: ActivitySystemSearchBinding by viewBinding() private lateinit var quickAccessItemsBinding: IncludeQuickAccessItemsBinding @@ -236,6 +240,7 @@ class SystemSearchActivity : DuckDuckGoActivity() { autoCompleteLongPressClickListener = { viewModel.userLongPressedAutocomplete(it) }, + omnibarPosition = settingsDataStore.omnibarPosition, ) binding.autocompleteSuggestions.adapter = autocompleteSuggestionsAdapter diff --git a/app/src/main/res/drawable/ic_autocomplete_24dp.xml b/app/src/main/res/drawable/ic_autocomplete_20dp.xml similarity index 100% rename from app/src/main/res/drawable/ic_autocomplete_24dp.xml rename to app/src/main/res/drawable/ic_autocomplete_20dp.xml diff --git a/app/src/main/res/drawable/ic_autocomplete_down_20dp.xml b/app/src/main/res/drawable/ic_autocomplete_down_20dp.xml new file mode 100644 index 000000000000..2edaf5453ce0 --- /dev/null +++ b/app/src/main/res/drawable/ic_autocomplete_down_20dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_appearance.xml b/app/src/main/res/layout/activity_appearance.xml index e17a9b80cb75..d0f9a4dc94a2 100644 --- a/app/src/main/res/layout/activity_appearance.xml +++ b/app/src/main/res/layout/activity_appearance.xml @@ -87,6 +87,20 @@ + + + + diff --git a/app/src/main/res/layout/activity_browser.xml b/app/src/main/res/layout/activity_browser.xml index 7e30ddc6ebae..49022e924971 100644 --- a/app/src/main/res/layout/activity_browser.xml +++ b/app/src/main/res/layout/activity_browser.xml @@ -16,13 +16,18 @@ ~ limitations under the License. --> - - + - + + + diff --git a/app/src/main/res/layout/fragment_browser_tab.xml b/app/src/main/res/layout/fragment_browser_tab.xml index 4820dc917802..fb00b6add984 100644 --- a/app/src/main/res/layout/fragment_browser_tab.xml +++ b/app/src/main/res/layout/fragment_browser_tab.xml @@ -13,7 +13,6 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - + android:layout_height="?attr/actionBarSize" + app:omnibarPosition="top" /> + android:visibility="gone" /> + + + \ No newline at end of file diff --git a/app/src/main/res/layout/include_omnibar_toolbar_mockup.xml b/app/src/main/res/layout/include_omnibar_toolbar_mockup.xml index 907212fa49de..2f5e980ed791 100644 --- a/app/src/main/res/layout/include_omnibar_toolbar_mockup.xml +++ b/app/src/main/res/layout/include_omnibar_toolbar_mockup.xml @@ -16,111 +16,101 @@ ~ limitations under the License. --> - + android:layout_height="?attr/actionBarSize" + android:background="?daxColorSurface" + android:theme="@style/Widget.DuckDuckGo.ToolbarTheme"> - + - + android:gravity="center" + android:importantForAccessibility="no" + android:padding="6dp" + android:src="@drawable/ic_find_search_20_a05" /> - + - + - + - + android:layout_height="wrap_content" + android:layout_gravity="center" + android:background="?attr/selectableItemBackgroundBorderless" + android:contentDescription="@string/browserPopupMenu" + android:padding="@dimen/toolbarIconPadding" + android:src="@drawable/ic_fire" /> + - - + - + + - - - - + android:src="@drawable/ic_menu_vertical_24" /> - - - \ No newline at end of file + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_autocomplete_bookmark_suggestion.xml b/app/src/main/res/layout/item_autocomplete_bookmark_suggestion.xml index 26bbbaf906b1..ca16a97c84c0 100644 --- a/app/src/main/res/layout/item_autocomplete_bookmark_suggestion.xml +++ b/app/src/main/res/layout/item_autocomplete_bookmark_suggestion.xml @@ -76,7 +76,7 @@ android:background="?selectableItemBackgroundBorderless" android:contentDescription="@string/editQueryBeforeSubmitting" android:padding="3dp" - android:src="@drawable/ic_autocomplete_24dp" + android:src="@drawable/ic_autocomplete_20dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent"/> diff --git a/app/src/main/res/layout/item_autocomplete_default.xml b/app/src/main/res/layout/item_autocomplete_default.xml index e212c8299ccb..478d3ac282df 100644 --- a/app/src/main/res/layout/item_autocomplete_default.xml +++ b/app/src/main/res/layout/item_autocomplete_default.xml @@ -60,7 +60,7 @@ android:layout_marginStart="6dp" android:contentDescription="@string/editQueryBeforeSubmitting" android:padding="3dp" - android:src="@drawable/ic_autocomplete_24dp" + android:src="@drawable/ic_autocomplete_20dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" diff --git a/app/src/main/res/layout/item_autocomplete_history_suggestion.xml b/app/src/main/res/layout/item_autocomplete_history_suggestion.xml index a280a9655631..d801e97d9e61 100644 --- a/app/src/main/res/layout/item_autocomplete_history_suggestion.xml +++ b/app/src/main/res/layout/item_autocomplete_history_suggestion.xml @@ -78,7 +78,7 @@ android:background="?selectableItemBackgroundBorderless" android:contentDescription="@string/editQueryBeforeSubmitting" android:padding="3dp" - android:src="@drawable/ic_autocomplete_24dp" + android:src="@drawable/ic_autocomplete_20dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent"/> diff --git a/app/src/main/res/layout/item_autocomplete_search_suggestion.xml b/app/src/main/res/layout/item_autocomplete_search_suggestion.xml index d626eecde7a9..4da36a46f166 100644 --- a/app/src/main/res/layout/item_autocomplete_search_suggestion.xml +++ b/app/src/main/res/layout/item_autocomplete_search_suggestion.xml @@ -61,7 +61,7 @@ android:background="?selectableItemBackgroundBorderless" android:contentDescription="@string/editQueryBeforeSubmitting" android:padding="3dp" - android:src="@drawable/ic_autocomplete_24dp" + android:src="@drawable/ic_autocomplete_20dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent"/> diff --git a/app/src/main/res/layout/popup_window_browser_menu_bottom.xml b/app/src/main/res/layout/popup_window_browser_menu_bottom.xml new file mode 100644 index 000000000000..5b0ff818e2fd --- /dev/null +++ b/app/src/main/res/layout/popup_window_browser_menu_bottom.xml @@ -0,0 +1,249 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_legacy_omnibar.xml b/app/src/main/res/layout/view_legacy_omnibar.xml index 96861f4874b8..a8756d3d7416 100644 --- a/app/src/main/res/layout/view_legacy_omnibar.xml +++ b/app/src/main/res/layout/view_legacy_omnibar.xml @@ -15,303 +15,300 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - + android:layout_height="wrap_content" + android:theme="@style/Widget.DuckDuckGo.ToolbarTheme" + tools:parentTag="androidx.coordinatorlayout.widget.CoordinatorLayout"> + + + + - - - - - + - + + + + + android:importantForAccessibility="no" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="@id/omniBarContainer" + app:layout_constraintTop_toTopOf="parent" /> - - - + + - - - - - - - - - - - - - - - - - - + android:padding="4dp" + android:visibility="gone" + app:srcCompat="@drawable/ic_ddg_logo" /> - - - - - - - + app:srcCompat="@drawable/ic_duckplayer" /> + + + android:layout_gravity="start|center_vertical" + android:layout_marginStart="17dp" + android:layout_marginEnd="4dp" + android:visibility="gone"> - + + - + + + + + + + + + + - - - + + + - + android:src="@drawable/ic_close_24" + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" + tools:visibility="visible" /> + + + + - + + + + - - + + + + + + + + android:layout_height="wrap_content" + android:layout_gravity="center" + android:background="?attr/selectableItemBackgroundBorderless" + android:contentDescription="@string/browserPopupMenu" + android:padding="@dimen/toolbarIconPadding" + android:src="@drawable/ic_menu_vertical_24" /> - + - + - - - + diff --git a/app/src/main/res/layout/view_legacy_omnibar_bottom.xml b/app/src/main/res/layout/view_legacy_omnibar_bottom.xml new file mode 100644 index 000000000000..291f64b24b50 --- /dev/null +++ b/app/src/main/res/layout/view_legacy_omnibar_bottom.xml @@ -0,0 +1,315 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 8161436a27c5..838fbf340889 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -804,4 +804,9 @@ Изпробвайте го Пропускане + + Адресна лента + Най-горе + Отдолу + \ No newline at end of file diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 422bb50038d8..170c3250054a 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -808,4 +808,9 @@ Vyzkoušejte ho Přeskočit + + Adresní řádek + Nahoru + Dole + \ No newline at end of file diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index a3bf45069a0b..f79dd945be36 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -804,4 +804,9 @@ Prøv det Spring over + + Adresselinje + Top + Nederst + \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 894a32e87eaa..9818af03cb78 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -804,4 +804,9 @@ Ausprobieren Überspringen + + Adresszeile + Nach oben + Unten + \ No newline at end of file diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 5fded24fe51f..e61a211df4fe 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -804,4 +804,9 @@ Δοκιμάστε το Παράλειψη + + Γραμμή διευθύνσεων + Κορυφή + Κάτω μέρος + \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 3304f5f9d577..a3e1bb7d820c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -804,4 +804,9 @@ Pruébalo Omitir + + Barra de direcciones + Arriba + Inferior + \ No newline at end of file diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 02787d4afe39..ce8364d055ad 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -804,4 +804,9 @@ Proovi seda Jäta vahele + + Aadressiriba + Tipp + All + \ No newline at end of file diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 9f5156b7101f..389aaa9eae94 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -804,4 +804,9 @@ Kokeile sitä Ohita + + Osoitekenttä + Ylös + Alareuna + \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a1dda1311df5..38e4e4322453 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -804,4 +804,9 @@ Essayez Ignorer + + Barre d\'adresse + Haut de page + En bas + \ No newline at end of file diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 581fb976678c..92c0d0ac7eb2 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -808,4 +808,9 @@ Isprobaj ga Preskoči + + Adresna traka + Vrh + Dno + \ No newline at end of file diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 9356d23c557b..8e4b791ba1a4 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -804,4 +804,9 @@ Kipróbálom Kihagyás + + Címsor + Fel + Alul + \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index f174a40d6662..4d13c4bac549 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -804,4 +804,9 @@ Provalo Salta + + Barra degli indirizzi + Inizio + Parte inferiore + \ No newline at end of file diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index aee51daa759d..a35ba8ccd515 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -808,4 +808,9 @@ Išbandykite Praleisti + + Adreso juosta + Viršus + Apačia + \ No newline at end of file diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index aa511c0b9385..0ab5b8f08729 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -806,4 +806,9 @@ Izmēģini Izlaist + + Adreses josla + Populārākie + Apakšā + \ No newline at end of file diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 98ad6c994096..bd06e8aaf17f 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -804,4 +804,9 @@ Prøv det Hopp over + + Adressefelt + Topp + Nederst + \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 58ab7c7d4620..b37d37c24752 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -804,4 +804,9 @@ Probeer het zelf Overslaan + + Adresbalk + Boven + Onderkant + \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 96ae2a15ab5e..b6f63f2714b4 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -808,4 +808,9 @@ Wypróbuj Pomiń + + Pasek adresu + Do góry + Dół + \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 1d97c164e0fe..1dd39739c696 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -804,4 +804,9 @@ Experimenta-o Ignorar + + Barra de endereço + Topo + Parte inferior + \ No newline at end of file diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index ba2788d7b2bb..047cc4541618 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -806,4 +806,9 @@ Încearcă-l Ignorare + + Bara de adrese + Sus + Partea de jos + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 14a438cd1928..211520c70a8d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -808,4 +808,9 @@ Попробовать Пропустить + + Адресная строка + Вверх + Внизу + \ No newline at end of file diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 42487000ef4e..c1747c42a6af 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -808,4 +808,9 @@ Vyskúšajte to Preskočiť + + Riadok adresy + Hore + Spodná časť + \ No newline at end of file diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index a556e128a75b..d98e9b87ec15 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -808,4 +808,9 @@ Preizkusite Preskoči + + Naslovna vrstica + Vrh + Spodaj + \ No newline at end of file diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index ced34339ff33..79e322e47631 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -804,4 +804,9 @@ Prova Hoppa över + + Adressfält + Topp + Botten + \ No newline at end of file diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 0d1db20540bc..6cdd0cf53059 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -804,4 +804,9 @@ Deneyin Atla + + Adres Çubuğu + Başa dön + Alt + \ No newline at end of file diff --git a/app/src/main/res/values/attrs-omnibar-view.xml b/app/src/main/res/values/attrs-omnibar-view.xml new file mode 100644 index 000000000000..877b35a85d7e --- /dev/null +++ b/app/src/main/res/values/attrs-omnibar-view.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 6a1e7fd62151..727181f5eb9f 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -62,4 +62,5 @@ DuckDuckGo Blocked in Indonesia The government may be blocking access to duckduckgo.com on this network provider, which could affect this app\'s functionality. Other providers may not be affected. Okay + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a20c026f17f1..29f751867945 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -803,4 +803,9 @@ Try it Skip + + Address Bar + Top + Bottom + \ No newline at end of file diff --git a/app/src/test/java/com/duckduckgo/app/Fakes.kt b/app/src/test/java/com/duckduckgo/app/Fakes.kt index 49c107ca3292..b8720c50c6a1 100644 --- a/app/src/test/java/com/duckduckgo/app/Fakes.kt +++ b/app/src/test/java/com/duckduckgo/app/Fakes.kt @@ -16,6 +16,7 @@ package com.duckduckgo.app +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition import com.duckduckgo.app.fire.fireproofwebsite.ui.AutomaticFireproofSetting import com.duckduckgo.app.fire.fireproofwebsite.ui.AutomaticFireproofSetting.ASK_EVERY_TIME import com.duckduckgo.app.icon.api.AppIcon @@ -108,6 +109,10 @@ class FakeSettingsDataStore : SettingsDataStore { get() = store["showAutomaticFireproofDialog"] as Boolean? ?: true set(value) { store["showAutomaticFireproofDialog"] = value } + override var omnibarPosition: OmnibarPosition + get() = OmnibarPosition.valueOf(store["omnibarPosition"] as String) + set(value) { store["omnibarPosition"] = value.name } + override var notifyMeInDownloadsDismissed: Boolean get() = store["notifyMeInDownloadsDismissed"] as Boolean? ?: false set(value) { store["notifyMeInDownloadsDismissed"] = value } diff --git a/app/src/test/java/com/duckduckgo/app/appearance/AppearanceViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/appearance/AppearanceViewModelTest.kt index 9543541db36b..827e22afb13b 100644 --- a/app/src/test/java/com/duckduckgo/app/appearance/AppearanceViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/appearance/AppearanceViewModelTest.kt @@ -19,6 +19,9 @@ package com.duckduckgo.app.appearance import androidx.arch.core.executor.testing.InstantTaskExecutorRule import app.cash.turbine.test import com.duckduckgo.app.appearance.AppearanceViewModel.Command +import com.duckduckgo.app.browser.omnibar.ChangeOmnibarPositionFeature +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.BOTTOM +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.TOP import com.duckduckgo.app.icon.api.AppIcon import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.settings.clear.FireAnimation @@ -28,8 +31,13 @@ import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.ui.DuckDuckGoTheme import com.duckduckgo.common.ui.store.AppTheme import com.duckduckgo.common.ui.store.ThemingDataStore +import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -61,6 +69,11 @@ internal class AppearanceViewModelTest { @Mock private lateinit var mockAppTheme: AppTheme + @Mock + private lateinit var loadingBarExperimentManager: LoadingBarExperimentManager + + private val featureFlag = FakeFeatureToggleFactory.create(ChangeOmnibarPositionFeature::class.java) + @Before fun before() { MockitoAnnotations.openMocks(this) @@ -68,12 +81,18 @@ internal class AppearanceViewModelTest { whenever(mockAppSettingsDataStore.appIcon).thenReturn(AppIcon.DEFAULT) whenever(mockThemeSettingsDataStore.theme).thenReturn(DuckDuckGoTheme.SYSTEM_DEFAULT) whenever(mockAppSettingsDataStore.selectedFireAnimation).thenReturn(FireAnimation.HeroFire) + whenever(mockAppSettingsDataStore.omnibarPosition).thenReturn(TOP) + whenever(loadingBarExperimentManager.isExperimentEnabled()).thenReturn(false) + + featureFlag.self().setEnabled(Toggle.State(enable = true)) testee = AppearanceViewModel( mockThemeSettingsDataStore, mockAppSettingsDataStore, mockPixel, coroutineTestRule.testDispatcherProvider, + featureFlag, + loadingBarExperimentManager, ) } @@ -182,6 +201,60 @@ internal class AppearanceViewModelTest { verify(mockPixel).fire(AppPixelName.FORCE_DARK_MODE_DISABLED) } + @Test + fun whenOmnibarPositionSettingPressed() = runTest { + testee.commands().test { + testee.userRequestedToChangeAddressBarPosition() + assertEquals(Command.LaunchOmnibarPositionSettings(TOP), awaitItem()) + verify(mockPixel).fire(AppPixelName.SETTINGS_ADDRESS_BAR_POSITION_PRESSED) + } + } + + @Test + fun whenOmnibarPositionUpdatedToBottom() = runTest { + testee.onOmnibarPositionUpdated(BOTTOM) + verify(mockAppSettingsDataStore).omnibarPosition = BOTTOM + verify(mockPixel).fire(AppPixelName.SETTINGS_ADDRESS_BAR_POSITION_SELECTED_BOTTOM) + } + + @Test + fun whenOmnibarPositionUpdatedToTop() = runTest { + testee.onOmnibarPositionUpdated(TOP) + verify(mockAppSettingsDataStore).omnibarPosition = TOP + verify(mockPixel).fire(AppPixelName.SETTINGS_ADDRESS_BAR_POSITION_SELECTED_TOP) + } + + @Test + fun whenLoadingBarExperimentDisabledAndFeatureFlagEnabledTheOmnibarFeatureIsEnabled() = runTest { + whenever(loadingBarExperimentManager.isExperimentEnabled()).thenReturn(false) + + testee.viewState().test { + val value = awaitItem() + assertTrue(value.isOmnibarPositionFeatureEnabled) + } + } + + @Test + fun whenLoadingBarExperimentDisabledAndFeatureFlagDisabledTheOmnibarFeatureIsDisabled() = runTest { + whenever(loadingBarExperimentManager.isExperimentEnabled()).thenReturn(false) + featureFlag.self().setEnabled(Toggle.State(enable = false)) + + testee.viewState().test { + val value = awaitItem() + assertFalse(value.isOmnibarPositionFeatureEnabled) + } + } + + @Test + fun whenLoadingBarExperimentEnabledTheBottomOmnibarIsDisabled() = runTest { + whenever(loadingBarExperimentManager.isExperimentEnabled()).thenReturn(true) + + testee.viewState().test { + val value = awaitItem() + assertFalse(value.isOmnibarPositionFeatureEnabled) + } + } + @Test fun whenInitialisedAndLightThemeThenViewStateEmittedWithProperValues() = runTest { whenever(mockThemeSettingsDataStore.theme).thenReturn(DuckDuckGoTheme.LIGHT) diff --git a/app/src/test/java/com/duckduckgo/app/cta/ui/CtaTest.kt b/app/src/test/java/com/duckduckgo/app/cta/ui/CtaTest.kt index 2d840efcbc99..06e1eebc6797 100644 --- a/app/src/test/java/com/duckduckgo/app/cta/ui/CtaTest.kt +++ b/app/src/test/java/com/duckduckgo/app/cta/ui/CtaTest.kt @@ -19,12 +19,15 @@ package com.duckduckgo.app.cta.ui import android.content.res.Resources import android.net.Uri import androidx.fragment.app.FragmentActivity +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.BOTTOM +import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.TOP import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.global.model.orderedTrackerBlockedEntities import com.duckduckgo.app.onboarding.store.OnboardingStore import com.duckduckgo.app.privacy.model.HttpsStatus import com.duckduckgo.app.privacy.model.TestingEntity +import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.CTA_SHOWN import com.duckduckgo.app.trackerdetection.model.Entity import com.duckduckgo.app.trackerdetection.model.TrackerStatus @@ -56,6 +59,9 @@ class CtaTest { @Mock private lateinit var mockResources: Resources + @Mock + private lateinit var mockSettingsDataStore: SettingsDataStore + @Before fun before() { MockitoAnnotations.openMocks(this) @@ -63,6 +69,7 @@ class CtaTest { whenever(mockActivity.resources).thenReturn(mockResources) whenever(mockResources.getQuantityString(any(), any())).thenReturn("withZero") whenever(mockResources.getQuantityString(any(), any(), any())).thenReturn("withMultiple") + whenever(mockSettingsDataStore.omnibarPosition).thenReturn(TOP) } @Test @@ -197,6 +204,19 @@ class CtaTest { assertTrue(testee.canSendShownPixel()) } + @Test + fun whenOmnibarPositionIsTopKeepTopPointingEmoji() { + val inputString = "
☝️ Tap the shield for more info.️]]" + assertEquals(inputString.getStringForOmnibarPosition(TOP), inputString) + } + + @Test + fun whenOmnibarPositionIsBottomUpdateHandEmojiToPointDown() { + val inputString = "
☝️ Tap the shield for more info.️]]" + val expectedString = "
\uD83D\uDC47️ Tap the shield for more info.️]]" + assertEquals(inputString.getStringForOmnibarPosition(BOTTOM), expectedString) + } + @Test fun whenCanSendPixelAndCtaNotPartOfHistoryButIsASubstringThenReturnTrue() { whenever(mockOnboardingStore.onboardingDialogJourney).thenReturn("s:0-te:0") @@ -265,7 +285,7 @@ class CtaTest { TestingEntity("Amazon", "Amazon", 9.0), ) - val testee = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, trackers) + val testee = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, trackers, mockSettingsDataStore) val value = testee.getTrackersDescription(mockActivity, trackers) assertEquals("Facebook, OtherwithMultiple", value) @@ -278,7 +298,7 @@ class CtaTest { TestingEntity("Other", "Other", 9.0), ) - val testee = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, trackers) + val testee = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, trackers, mockSettingsDataStore) val value = testee.getTrackersDescription(mockActivity, trackers) assertEquals("Facebook, OtherwithZero", value) @@ -313,6 +333,7 @@ class CtaTest { mockOnboardingStore, mockAppInstallStore, site.orderedTrackerBlockedEntities(), + mockSettingsDataStore, ) val value = testee.getTrackersDescription(mockActivity, site.orderedTrackerBlockedEntities()) @@ -347,6 +368,7 @@ class CtaTest { mockOnboardingStore, mockAppInstallStore, site.orderedTrackerBlockedEntities(), + mockSettingsDataStore, ) val value = testee.getTrackersDescription(mockActivity, site.orderedTrackerBlockedEntities()) @@ -381,6 +403,7 @@ class CtaTest { mockOnboardingStore, mockAppInstallStore, site.orderedTrackerBlockedEntities(), + mockSettingsDataStore, ) val value = testee.getTrackersDescription(mockActivity, site.orderedTrackerBlockedEntities()) @@ -395,7 +418,7 @@ class CtaTest { TestingEntity("Facebook", "Facebook", 9.0), ) - val testee = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, trackers) + val testee = OnboardingDaxDialogCta.DaxTrackersBlockedCta(mockOnboardingStore, mockAppInstallStore, trackers, mockSettingsDataStore) val value = testee.getTrackersDescription(mockActivity, trackers) assertEquals("FacebookwithZero", value) @@ -406,7 +429,7 @@ class CtaTest { val existingJourney = "s:0-t:1" whenever(mockOnboardingStore.onboardingDialogJourney).thenReturn(existingJourney) whenever(mockAppInstallStore.installTimestamp).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)) - val testee = OnboardingDaxDialogCta.DaxFireButtonCta(mockOnboardingStore, mockAppInstallStore) + val testee = OnboardingDaxDialogCta.DaxFireButtonCta(mockOnboardingStore, mockAppInstallStore, mockSettingsDataStore) val expectedValue = "$existingJourney-${testee.ctaPixelParam}:1" val value = testee.pixelShownParameters() diff --git a/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupManager.kt b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupManager.kt index b0587ec29fcf..cb2f088c9972 100644 --- a/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupManager.kt +++ b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupManager.kt @@ -42,8 +42,10 @@ interface PrivacyProtectionsPopupManager { * * This function should be called whenever the user triggers page refresh, * either by the pull-to-refresh gesture or the button in the menu. + * + * @param isOmnibarAtTop The position of the omnibar can be at the top or bottom. */ - fun onPageRefreshTriggeredByUser() + fun onPageRefreshTriggeredByUser(isOmnibarAtTheTop: Boolean) /** * Handles the event of a page being fully loaded. diff --git a/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupViewState.kt b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupViewState.kt index 1ecbfca4f094..7152a551f765 100644 --- a/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupViewState.kt +++ b/privacy-protections-popup/privacy-protections-popup-api/src/main/java/com/duckduckgo/privacyprotectionspopup/api/PrivacyProtectionsPopupViewState.kt @@ -27,6 +27,10 @@ sealed class PrivacyProtectionsPopupViewState { * Indicates whether the popup should show the "Don't show again" button. */ val doNotShowAgainOptionAvailable: Boolean, + /** + * Indicates whether the the position of the omnibar is at the top. + */ + val isOmnibarAtTheTop: Boolean, ) : PrivacyProtectionsPopupViewState() data object Gone : PrivacyProtectionsPopupViewState() diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupImpl.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupImpl.kt index 6e058ce26d0f..45249bc0d255 100644 --- a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupImpl.kt +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupImpl.kt @@ -18,17 +18,22 @@ package com.duckduckgo.privacyprotectionspopup.impl import android.content.Context import android.graphics.Point +import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup.LayoutParams import android.view.ViewGroup.MarginLayoutParams import android.widget.Button +import android.widget.FrameLayout import android.widget.PopupWindow +import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.doOnDetach import androidx.core.view.doOnLayout import androidx.core.view.isVisible import androidx.core.view.updatePaddingRelative +import com.duckduckgo.common.ui.view.shape.DaxBubbleCardView.EdgePosition.LEFT import com.duckduckgo.common.ui.view.shape.DaxBubbleEdgeTreatment +import com.duckduckgo.common.ui.view.text.DaxTextView import com.duckduckgo.common.ui.view.toPx import com.duckduckgo.mobile.android.R import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopup @@ -43,6 +48,7 @@ import com.duckduckgo.privacyprotectionspopup.impl.R.* import com.duckduckgo.privacyprotectionspopup.impl.databinding.PopupButtonsHorizontalBinding import com.duckduckgo.privacyprotectionspopup.impl.databinding.PopupButtonsVerticalBinding import com.duckduckgo.privacyprotectionspopup.impl.databinding.PopupPrivacyDashboardBinding +import com.duckduckgo.privacyprotectionspopup.impl.databinding.PopupPrivacyDashboardBottomBinding import com.google.android.material.shape.ShapeAppearanceModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -89,7 +95,7 @@ class PrivacyProtectionsPopupImpl( } private fun showPopup(viewState: PrivacyProtectionsPopupViewState.Visible) = anchor.doOnLayout { - val popupContent = createPopupContentView(viewState.doNotShowAgainOptionAvailable) + val popupContent = createPopupContentView(viewState.doNotShowAgainOptionAvailable, viewState.isOmnibarAtTheTop) val popupWindowSpec = createPopupWindowSpec(popupContent = popupContent.root) popupWindowSpec.overrideContentPaddingStartPx?.let { contentPaddingStartPx -> @@ -122,7 +128,11 @@ class PrivacyProtectionsPopupImpl( _events.tryEmit(DISMISSED) popupWindow = null } - showAsDropDown(anchor, popupWindowSpec.horizontalOffsetPx, popupWindowSpec.verticalOffsetPx) + if (viewState.isOmnibarAtTheTop) { + showAsDropDown(anchor, popupWindowSpec.horizontalOffsetPx, popupWindowSpec.verticalOffsetPx) + } else { + showAtLocation(anchor, Gravity.BOTTOM, popupWindowSpec.horizontalOffsetPx, popupWindowSpec.verticalOffsetPx) + } } anchor.doOnDetach { dismissPopup() } @@ -134,10 +144,17 @@ class PrivacyProtectionsPopupImpl( popupWindow = null } - private fun createPopupContentView(doNotShowAgainAvailable: Boolean): PopupViewHolder { - val popupContent = PopupPrivacyDashboardBinding.inflate(LayoutInflater.from(context)) - val buttonsViewHolder = inflateButtons(popupContent, doNotShowAgainAvailable) - adjustBodyTextToAvailableWidth(popupContent) + private fun createPopupContentView(doNotShowAgainAvailable: Boolean, isOmnibarAtTheTop: Boolean): PopupViewHolder { + return when (isOmnibarAtTheTop) { + true -> createPopupContentViewTop(doNotShowAgainAvailable) + false -> createPopupContentViewBottom(doNotShowAgainAvailable) + } + } + + private fun createPopupContentViewBottom(doNotShowAgainAvailable: Boolean): PopupViewHolder { + val popupContent = PopupPrivacyDashboardBottomBinding.inflate(LayoutInflater.from(context)) + val buttonsViewHolder = inflateButtons(popupContent.cardViewContent, popupContent.buttonsContainer, doNotShowAgainAvailable) + adjustBodyTextToAvailableWidth(popupContent.cardViewContent, popupContent.bodyText) // Override CardView's default elevation with popup/dialog elevation popupContent.cardView.cardElevation = POPUP_DEFAULT_ELEVATION_DP.toPx() @@ -145,11 +162,35 @@ class PrivacyProtectionsPopupImpl( val cornerRadius = context.resources.getDimension(R.dimen.mediumShapeCornerRadius) val cornerSize = context.resources.getDimension(R.dimen.daxBubbleDialogEdge) val distanceFromEdge = EDGE_TREATMENT_DISTANCE_FROM_EDGE.toPx() - POPUP_HORIZONTAL_OFFSET_DP.toPx() - val edgeTreatment = DaxBubbleEdgeTreatment(cornerSize, distanceFromEdge) + popupContent.cardView.shapeAppearanceModel = ShapeAppearanceModel.builder() + .setAllCornerSizes(cornerRadius) + .setBottomEdge(DaxBubbleEdgeTreatment(cornerSize, distanceFromEdge, LEFT)) + .build() + + popupContent.shieldIconHighlight.startAnimation(buildShieldIconHighlightAnimation()) + + return PopupViewHolder( + root = popupContent.root, + anchorOverlay = popupContent.anchorOverlay, + omnibarOverlay = popupContent.omnibarOverlay, + buttons = buttonsViewHolder, + ) + } + + private fun createPopupContentViewTop(doNotShowAgainAvailable: Boolean): PopupViewHolder { + val popupContent = PopupPrivacyDashboardBinding.inflate(LayoutInflater.from(context)) + val buttonsViewHolder = inflateButtons(popupContent.cardViewContent, popupContent.buttonsContainer, doNotShowAgainAvailable) + adjustBodyTextToAvailableWidth(popupContent.cardViewContent, popupContent.bodyText) + // Override CardView's default elevation with popup/dialog elevation + popupContent.cardView.cardElevation = POPUP_DEFAULT_ELEVATION_DP.toPx() + + val cornerRadius = context.resources.getDimension(R.dimen.mediumShapeCornerRadius) + val cornerSize = context.resources.getDimension(R.dimen.daxBubbleDialogEdge) + val distanceFromEdge = EDGE_TREATMENT_DISTANCE_FROM_EDGE.toPx() - POPUP_HORIZONTAL_OFFSET_DP.toPx() popupContent.cardView.shapeAppearanceModel = ShapeAppearanceModel.builder() .setAllCornerSizes(cornerRadius) - .setTopEdge(edgeTreatment) + .setTopEdge(DaxBubbleEdgeTreatment(cornerSize, distanceFromEdge)) .build() popupContent.shieldIconHighlight.startAnimation(buildShieldIconHighlightAnimation()) @@ -162,11 +203,15 @@ class PrivacyProtectionsPopupImpl( ) } - private fun inflateButtons(popupContent: PopupPrivacyDashboardBinding, doNotShowAgainAvailable: Boolean): PopupButtonsViewHolder { - val availableWidth = getAvailablePopupCardViewContentWidthPx(popupContent) + private fun inflateButtons( + cardViewContent: ConstraintLayout, + buttonsContainer: FrameLayout, + doNotShowAgainAvailable: Boolean, + ): PopupButtonsViewHolder { + val availableWidth = getAvailablePopupCardViewContentWidthPx(cardViewContent) val horizontalButtons = PopupButtonsHorizontalBinding - .inflate(LayoutInflater.from(context), popupContent.buttonsContainer, false) + .inflate(LayoutInflater.from(context), buttonsContainer, false) .apply { dontShowAgainButton.isVisible = doNotShowAgainAvailable dismissButton.isVisible = !doNotShowAgainAvailable @@ -177,7 +222,7 @@ class PrivacyProtectionsPopupImpl( .measuredWidth return if (horizontalButtonsWidth <= availableWidth) { - popupContent.buttonsContainer.addView(horizontalButtons.root) + buttonsContainer.addView(horizontalButtons.root) PopupButtonsViewHolder( dismiss = horizontalButtons.dismissButton, doNotShowAgain = horizontalButtons.dontShowAgainButton, @@ -185,12 +230,12 @@ class PrivacyProtectionsPopupImpl( ) } else { val verticalButtons = PopupButtonsVerticalBinding - .inflate(LayoutInflater.from(context), popupContent.buttonsContainer, true) + .inflate(LayoutInflater.from(context), buttonsContainer, true) .apply { dontShowAgainButton.isVisible = doNotShowAgainAvailable dismissButton.isVisible = !doNotShowAgainAvailable } - popupContent.buttonsContainer.layoutParams = popupContent.buttonsContainer.layoutParams.apply { width = 0 } + buttonsContainer.layoutParams = buttonsContainer.layoutParams.apply { width = 0 } PopupButtonsViewHolder( dismiss = verticalButtons.dismissButton, doNotShowAgain = verticalButtons.dontShowAgainButton, @@ -199,16 +244,19 @@ class PrivacyProtectionsPopupImpl( } } - private fun adjustBodyTextToAvailableWidth(popupContent: PopupPrivacyDashboardBinding) { - val availableWidth = getAvailablePopupCardViewContentWidthPx(popupContent) + private fun adjustBodyTextToAvailableWidth( + cardViewContent: ConstraintLayout, + bodyText: DaxTextView, + ) { + val availableWidth = getAvailablePopupCardViewContentWidthPx(cardViewContent) val defaultText = context.getString(string.privacy_protections_popup_body) val shortText = context.getString(string.privacy_protections_popup_body_short) - popupContent.bodyText.post { - val textPaint = popupContent.bodyText.paint + bodyText.post { + val textPaint = bodyText.paint - popupContent.bodyText.text = when { + bodyText.text = when { textPaint.measureText(defaultText) <= availableWidth -> defaultText textPaint.measureText(shortText) <= availableWidth -> shortText else -> defaultText // No need to use the shorter text if it wraps anyway @@ -258,9 +306,9 @@ class PrivacyProtectionsPopupImpl( ) } - private fun getAvailablePopupCardViewContentWidthPx(popupContent: PopupPrivacyDashboardBinding): Int { + private fun getAvailablePopupCardViewContentWidthPx(cardViewContent: ConstraintLayout): Int { val popupExternalMarginsWidth = 2 * anchor.locationInWindow.x + POPUP_HORIZONTAL_OFFSET_DP.toPx() - val popupInternalPaddingWidth = popupContent.cardViewContent.paddingStart + popupContent.cardViewContent.paddingEnd + val popupInternalPaddingWidth = cardViewContent.paddingStart + cardViewContent.paddingEnd return context.screenWidth - popupExternalMarginsWidth - popupInternalPaddingWidth } diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImpl.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImpl.kt index 9a37a3c92f1c..2528e6519957 100644 --- a/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImpl.kt +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImpl.kt @@ -130,7 +130,7 @@ class PrivacyProtectionsPopupManagerImpl @Inject constructor( } } - override fun onPageRefreshTriggeredByUser() { + override fun onPageRefreshTriggeredByUser(isOmnibarAtTheTop: Boolean) { var popupTriggered = false var experimentVariantToStore: PrivacyProtectionsPopupExperimentVariant? = null @@ -154,7 +154,10 @@ class PrivacyProtectionsPopupManagerImpl @Inject constructor( oldState.copy( viewState = if (shouldShowPopup) { - PrivacyProtectionsPopupViewState.Visible(doNotShowAgainOptionAvailable = oldState.popupData.popupTriggerCount > 0) + PrivacyProtectionsPopupViewState.Visible( + doNotShowAgainOptionAvailable = oldState.popupData.popupTriggerCount > 0, + isOmnibarAtTheTop = isOmnibarAtTheTop, + ) } else { PrivacyProtectionsPopupViewState.Gone }, diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/layout/popup_privacy_dashboard_bottom.xml b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/layout/popup_privacy_dashboard_bottom.xml new file mode 100644 index 000000000000..3a83573f8b2e --- /dev/null +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/main/res/layout/popup_privacy_dashboard_bottom.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImplTest.kt b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImplTest.kt index dc2de8b3d4c2..93223d4b3750 100644 --- a/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImplTest.kt +++ b/privacy-protections-popup/privacy-protections-popup-impl/src/test/java/com/duckduckgo/privacyprotectionspopup/impl/PrivacyProtectionsPopupManagerImplTest.kt @@ -118,7 +118,7 @@ class PrivacyProtectionsPopupManagerImplTest { assertEquals(PrivacyProtectionsPopupViewState.Gone, awaitItem()) subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) expectNoEvents() - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertTrue(awaitItem() is PrivacyProtectionsPopupViewState.Visible) expectNoEvents() } @@ -128,7 +128,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenRefreshIsTriggeredThenPopupIsShown() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) } @@ -138,7 +138,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenUrlIsDuckDuckGoThenPopupIsNotShown() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://duckduckgo.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) } @@ -149,7 +149,7 @@ class PrivacyProtectionsPopupManagerImplTest { featureFlag.self().setEnabled(State(enable = false)) subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) } @@ -161,7 +161,7 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) } @@ -171,7 +171,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenUrlIsMissingThenPopupIsNotShown() = runTest { subject.viewState.test { subject.onPageLoaded(url = "", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) } @@ -181,7 +181,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPageLoadedWithHttpErrorThenPopupIsNotShown() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = listOf(500), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) } @@ -191,7 +191,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPageLoadedWithBrowserErrorThenPopupIsNotShown() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = true) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) } @@ -201,7 +201,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPageIsChangedThenPopupIsNotDismissed() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) @@ -215,7 +215,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenDismissEventIsHandledThenViewStateIsUpdated() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) @@ -229,7 +229,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenDismissButtonClickedEventIsHandledThenPopupIsDismissed() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) @@ -245,7 +245,7 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { assertEquals(PrivacyProtectionsPopupViewState.Gone, awaitItem()) subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertTrue(awaitItem() is PrivacyProtectionsPopupViewState.Visible) @@ -260,7 +260,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenDisableProtectionsClickedEventIsHandledThenDomainIsAddedToUserAllowlist() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertFalse(userAllowListRepository.isUrlInUserAllowList("https://www.example.com")) assertPopupVisible(visible = true) @@ -275,11 +275,11 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPopupWasDismissedRecentlyForTheSameDomainThenItWontBeShownOnRefresh() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) subject.onUiEvent(DISMISSED) assertStoredPopupDismissTimestamp(url = "https://www.example.com", expectedTimestamp = timeProvider.time) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) assertStoredPopupDismissTimestamp(url = "https://www.example.com", expectedTimestamp = timeProvider.time) @@ -291,12 +291,12 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { timeProvider.time = Instant.parse("2023-11-29T10:15:30.000Z") subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) subject.onUiEvent(DISMISSED) assertStoredPopupDismissTimestamp(url = "https://www.example.com", expectedTimestamp = timeProvider.time) timeProvider.time += Duration.ofDays(2) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) } @@ -306,21 +306,21 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPopupWasDismissedRecentlyThenItWontBeShownOnForTheSameDomainButWillBeForOtherDomains() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) subject.onUiEvent(DISMISSED) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) subject.onPageLoaded(url = "https://www.example2.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) subject.onUiEvent(DISMISSED) subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) } @@ -334,7 +334,7 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { assertPopupVisible(visible = false) subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) protectionsEnabledFlow.emit(true) expectNoEvents() } @@ -347,7 +347,7 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) timeProvider.time += Duration.ofSeconds(5) protectionsEnabledFlow.emit(true) assertPopupVisible(visible = false) @@ -361,7 +361,7 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) } @@ -374,7 +374,7 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) } @@ -384,7 +384,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPageReloadsOnRefreshWithHttpErrorThenPopupIsNotDismissed() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) @@ -399,7 +399,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPopupIsShownThenTriggerCountIsIncremented() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) assertEquals(1, dataStore.getPopupTriggerCount()) @@ -409,7 +409,7 @@ class PrivacyProtectionsPopupManagerImplTest { assertPopupVisible(visible = false) subject.onPageLoaded(url = "https://www.example2.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) assertEquals(2, dataStore.getPopupTriggerCount()) @@ -422,9 +422,15 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) - assertEquals(PrivacyProtectionsPopupViewState.Visible(doNotShowAgainOptionAvailable = false), expectMostRecentItem()) + assertEquals( + PrivacyProtectionsPopupViewState.Visible( + doNotShowAgainOptionAvailable = false, + isOmnibarAtTheTop = true, + ), + expectMostRecentItem(), + ) } } @@ -434,9 +440,15 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) - assertEquals(PrivacyProtectionsPopupViewState.Visible(doNotShowAgainOptionAvailable = true), expectMostRecentItem()) + assertEquals( + PrivacyProtectionsPopupViewState.Visible( + doNotShowAgainOptionAvailable = true, + isOmnibarAtTheTop = true, + ), + expectMostRecentItem(), + ) } } @@ -446,9 +458,15 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) - assertEquals(PrivacyProtectionsPopupViewState.Visible(doNotShowAgainOptionAvailable = true), expectMostRecentItem()) + assertEquals( + PrivacyProtectionsPopupViewState.Visible( + doNotShowAgainOptionAvailable = true, + isOmnibarAtTheTop = true, + ), + expectMostRecentItem(), + ) subject.onUiEvent(DONT_SHOW_AGAIN_CLICKED) @@ -456,7 +474,7 @@ class PrivacyProtectionsPopupManagerImplTest { assertTrue(dataStore.getDoNotShowAgainClicked()) subject.onPageLoaded(url = "https://www.example2.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) expectNoEvents() } @@ -467,7 +485,7 @@ class PrivacyProtectionsPopupManagerImplTest { dataStore.setExperimentVariant(CONTROL) subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) } @@ -480,7 +498,7 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = false) assertEquals(CONTROL, dataStore.getExperimentVariant()) @@ -498,7 +516,7 @@ class PrivacyProtectionsPopupManagerImplTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) cancelAndIgnoreRemainingEvents() verify(pixels).reportExperimentVariantAssigned() @@ -511,7 +529,7 @@ class PrivacyProtectionsPopupManagerImplTest { dataStore.setExperimentVariant(TEST) subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) @@ -523,7 +541,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPopupIsTriggeredThenPixelIsSent() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) verify(pixels).reportPopupTriggered() @@ -534,7 +552,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPrivacyProtectionsDisableButtonIsClickedThenPixelIsSent() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) subject.onUiEvent(DISABLE_PROTECTIONS_CLICKED) @@ -548,7 +566,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenDismissButtonIsClickedThenPixelIsSent() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) subject.onUiEvent(DISMISS_CLICKED) @@ -562,7 +580,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPopupIsDismissedViaClickOutsideThenPixelIsSent() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) subject.onUiEvent(DISMISSED) @@ -576,7 +594,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenDoNotShowAgainButtonIsClickedThenPixelIsSent() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) subject.onUiEvent(DONT_SHOW_AGAIN_CLICKED) @@ -590,7 +608,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPrivacyDashboardIsOpenedThenPixelIsSent() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) assertPopupVisible(visible = true) subject.onUiEvent(PRIVACY_DASHBOARD_CLICKED) @@ -604,7 +622,7 @@ class PrivacyProtectionsPopupManagerImplTest { fun whenPageIsRefreshedAndConditionsAreMetThenPixelIsSent() = runTest { subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) verify(pixels).reportPageRefreshOnPossibleBreakage() cancelAndIgnoreRemainingEvents() @@ -616,7 +634,7 @@ class PrivacyProtectionsPopupManagerImplTest { featureFlag.self().setEnabled(State(enable = false)) subject.viewState.test { subject.onPageLoaded(url = "https://www.example.com", httpErrorCodes = emptyList(), hasBrowserError = false) - subject.onPageRefreshTriggeredByUser() + subject.onPageRefreshTriggeredByUser(true) verify(pixels).reportPageRefreshOnPossibleBreakage() cancelAndIgnoreRemainingEvents() diff --git a/statistics/statistics-api/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt b/statistics/statistics-api/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt index c0fb603e92a4..c4819bf2b771 100644 --- a/statistics/statistics-api/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt +++ b/statistics/statistics-api/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt @@ -57,6 +57,7 @@ interface Pixel { const val VOICE_SEARCH = "voice_search" const val LOCALE = "locale" const val FROM_ONBOARDING = "from_onboarding" + const val ADDRESS_BAR = "address_bar" // Loading Bar Experiment const val LOADING_BAR_EXPERIMENT = "loading_bar_exp"