From 40269e8407e834e24316c1ebb93c69d198a9de7c Mon Sep 17 00:00:00 2001 From: Ondrej Ruttkay <0nko@users.noreply.github.com> Date: Mon, 3 Feb 2025 13:04:26 +0100 Subject: [PATCH] Tab swiping (#5408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/1207418217763355/1209006734942815/f ### Description This PR includes the complete implementation of the tab-swiping functionality. ### Steps to test this PR **Make sure you’ve completed the onboarding!** #### New tab from long press - [x] Long press on a tabs button - [x] Verify a new tab is opened #### New tab from the menu - [x] Tap on the New tab menu item - [x] Verify a new tab is opened #### New tab from tab switcher - [x] Go to tab switcher - [x] Tap on the New tab button - [x] Verify a new tab is opened #### New tab recreation - [x] Open a few tabs - [x] Go to the tab switcher - [x] Add a new tab - [x] Verify a new tab is correctly displayed - [x] Go back to the tab switcher - [x] Close the NTP - [x] Tap on the new tab button - [x] Verify the new tab is correctly opened #### Swiping position - [x] Open at least 3 tabs - [x] Select the 1st one in the tab switcher - [x] Swipe to the last one - [x] Go to tab switcher and verify the correct tab is selected - [x] Tap on the 2nd tab - [x] Verify the correct tab is opened #### New link - [x] Open any website - [x] Long press on a link to open in new tab - [x] Verify the link is opened in a new tab #### New link in the background - [x] Open any website - [x] Long press on a link to open in background tab - [x] Verify the link is opened in a new tab in the background #### New link in the background - [x] Make sure the DDG app is the default browser app - [x] Open an app that can open links in custom tabs (i.e. Twitter, Facebook) - [x] Open a link in a custom tab - [x] Verify the link opens correctly - [x] Move the tab to the app - [x] Verify the tab is correctly displayed within the app #### Switch to tab - [x] Start typing a site address that’s already opened in another tab - [x] Tap on the “Switch to tab” item - [x] Verify the existing tab with the link is shown instead of opening a new tab #### Changing the omnibar position - [x] Open a few tabs - [x] Go to settings and change the omnibar position - [x] The app will reload (the cached sites are cleared) - [x] Swipe through the tabs and verify the load correctly #### Bookmarks - [x] Load a site - [x] Add it to the boorkmarks - [x] Open a different tab - [x] Go to Bookmarks and select the saved boormark - [x] Verify the bookmark loads correctly #### Default site - [x] Set some default URL in Settings -> General -> Show on App Launch - [x] Kill the app and relaunch it - [x] Verify the default site is correctly loaded in a new tab - [x] Kill the app and relaunch it - [x] Verify the default site is loaded in the same tab as before (no new tab is added) --------- Co-authored-by: Noelia Alcala --- .../app/browser/BrowserTabViewModelTest.kt | 23 +- .../duckduckgo/app/browser/BrowserActivity.kt | 279 ++++- .../app/browser/BrowserTabFragment.kt | 105 +- .../app/browser/BrowserTabViewModel.kt | 17 +- .../app/browser/BrowserViewModel.kt | 98 +- .../app/browser/DuckDuckGoWebView.kt | 4 +- .../app/browser/SwipingTabsFeature.kt | 30 + .../app/browser/SwipingTabsFeatureProvider.kt | 30 + .../duckduckgo/app/browser/omnibar/Omnibar.kt | 2 + .../app/browser/omnibar/OmnibarLayout.kt | 80 +- .../browser/omnibar/OmnibarLayoutViewModel.kt | 44 +- .../app/browser/tabs/DefaultTabManager.kt | 126 ++ .../tabs/adapter/FragmentStateAdapter.java | 1032 +++++++++++++++++ .../tabs/adapter/FragmentViewHolder.kt | 46 + .../browser/tabs/adapter/TabPagerAdapter.kt | 103 ++ .../app/onboarding/store/UserStageDao.kt | 3 + .../app/onboarding/store/UserStageStore.kt | 3 + .../com/duckduckgo/app/pixels/AppPixelName.kt | 3 + .../com/duckduckgo/app/tabs/db/TabsDao.kt | 3 + .../app/tabs/model/TabDataRepository.kt | 8 +- .../app/tabs/ui/TabSwitcherViewModel.kt | 14 +- app/src/main/res/layout/activity_browser.xml | 6 + .../app/browser/BrowserViewModelTest.kt | 69 +- .../omnibar/OmnibarLayoutViewModelTest.kt | 41 +- .../app/browser/tabs/DefaultTabManagerTest.kt | 167 +++ .../ShowOnAppLaunchOptionHandlerImplTest.kt | 6 + .../app/tabs/ui/TabSwitcherViewModelTest.kt | 11 + .../app/tabs/model/TabRepository.kt | 4 + code-formatting.gradle | 1 + .../common/ui/view/TypeAnimationTextView.kt | 21 + 30 files changed, 2189 insertions(+), 190 deletions(-) create mode 100644 app/src/main/java/com/duckduckgo/app/browser/SwipingTabsFeature.kt create mode 100644 app/src/main/java/com/duckduckgo/app/browser/SwipingTabsFeatureProvider.kt create mode 100644 app/src/main/java/com/duckduckgo/app/browser/tabs/DefaultTabManager.kt create mode 100644 app/src/main/java/com/duckduckgo/app/browser/tabs/adapter/FragmentStateAdapter.java create mode 100644 app/src/main/java/com/duckduckgo/app/browser/tabs/adapter/FragmentViewHolder.kt create mode 100644 app/src/main/java/com/duckduckgo/app/browser/tabs/adapter/TabPagerAdapter.kt create mode 100644 app/src/test/java/com/duckduckgo/app/browser/tabs/DefaultTabManagerTest.kt 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 5eb923493102..c479548edc46 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -503,6 +503,8 @@ class BrowserTabViewModelTest { private val mockBrokenSitePrompt: BrokenSitePrompt = mock() private val mockTabStatsBucketing: TabStatsBucketing = mock() private val mockDuckChatJSHelper: DuckChatJSHelper = mock() + private val swipingTabsFeature = FakeFeatureToggleFactory.create(SwipingTabsFeature::class.java) + private val swipingTabsFeatureProvider = SwipingTabsFeatureProvider(swipingTabsFeature) private val defaultBrowserPromptsExperimentShowPopupMenuItemFlow = MutableStateFlow(false) private val mockDefaultBrowserPromptsExperiment: DefaultBrowserPromptsExperiment = mock() @@ -510,6 +512,9 @@ class BrowserTabViewModelTest { @Before fun before() = runTest { MockitoAnnotations.openMocks(this) + + swipingTabsFeature.self().setRawStoredState(State(enable = true)) + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) .allowMainThreadQueries() .build() @@ -680,6 +685,7 @@ class BrowserTabViewModelTest { tabStatsBucketing = mockTabStatsBucketing, maliciousSiteBlockerWebViewIntegration = mock(), defaultBrowserPromptsExperiment = mockDefaultBrowserPromptsExperiment, + swipingTabsFeature = swipingTabsFeatureProvider, ) testee.loadData("abc", null, false, false) @@ -2325,7 +2331,8 @@ class BrowserTabViewModelTest { } @Test - fun whenUserRequestedToOpenNewTabThenNewTabCommandIssued() { + fun whenUserRequestedToOpenNewTabAndNoEmptyTabExistsThenNewTabCommandIssued() { + tabsLiveData.value = listOf(TabEntity("1", "https://example.com", position = 0)) testee.userRequestedOpeningNewTab() verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) val command = commandCaptor.lastValue @@ -2333,6 +2340,20 @@ class BrowserTabViewModelTest { verify(mockPixel, never()).fire(AppPixelName.TAB_MANAGER_NEW_TAB_LONG_PRESSED) } + @Test + fun whenUserRequestedToOpenNewTabAndEmptyTabExistsThenSelectTheEmptyTab() = runTest { + val emptyTabId = "EMPTY_TAB" + whenever(mockTabRepository.flowTabs).thenReturn(flowOf(listOf(TabEntity(emptyTabId)))) + testee.userRequestedOpeningNewTab() + + verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) + val command = commandCaptor.lastValue + assertFalse(command is Command.LaunchNewTab) + + verify(mockTabRepository).select(emptyTabId) + verify(mockPixel, never()).fire(AppPixelName.TAB_MANAGER_NEW_TAB_LONG_PRESSED) + } + @Test fun whenUserRequestedToOpenNewTabByLongPressThenPixelFired() { testee.userRequestedOpeningNewTab(longPress = true) 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 1e98b2f668e4..b1fb9e4eb357 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -24,14 +24,21 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.os.Message +import android.os.SystemClock import android.view.KeyEvent +import android.view.MotionEvent import android.view.View import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.VisibleForTesting +import androidx.core.view.isVisible +import androidx.core.view.postDelayed +import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope +import androidx.viewpager2.widget.MarginPageTransformer +import androidx.viewpager2.widget.ViewPager2 import androidx.webkit.ServiceWorkerClientCompat import androidx.webkit.ServiceWorkerControllerCompat import androidx.webkit.WebViewFeature @@ -47,6 +54,8 @@ import com.duckduckgo.app.browser.defaultbrowsing.prompts.ui.DefaultBrowserBotto 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.browser.tabs.TabManager +import com.duckduckgo.app.browser.tabs.adapter.TabPagerAdapter import com.duckduckgo.app.browser.webview.RealMaliciousSiteBlockerWebViewIntegration import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.downloads.DownloadsScreens.DownloadsScreenNoParams @@ -76,6 +85,7 @@ import com.duckduckgo.common.ui.DuckDuckGoActivity 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.view.toPx import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.playstore.PlayStoreUtils @@ -86,7 +96,9 @@ import com.duckduckgo.site.permissions.impl.ui.SitePermissionScreenNoParams import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import timber.log.Timber // open class so that we can test BrowserApplicationStateInfo @@ -138,9 +150,26 @@ open class BrowserActivity : DuckDuckGoActivity() { @Inject lateinit var maliciousSiteBlockerWebViewIntegration: RealMaliciousSiteBlockerWebViewIntegration + @Inject + lateinit var swipingTabsFeature: SwipingTabsFeatureProvider + + @Inject + lateinit var tabManager: TabManager + private val lastActiveTabs = TabList() - private var currentTab: BrowserTabFragment? = null + private var _currentTab: BrowserTabFragment? = null + private var currentTab: BrowserTabFragment? + get() { + return if (swipingTabsFeature.isEnabled) { + tabPagerAdapter.currentFragment + } else { + _currentTab + } + } + set(value) { + _currentTab = value + } private val viewModel: BrowserViewModel by bindViewModel() @@ -152,10 +181,59 @@ open class BrowserActivity : DuckDuckGoActivity() { private val binding: ActivityBrowserBinding by viewBinding() + private val tabPager: ViewPager2 by lazy { + binding.tabPager + } + + private val tabPagerAdapter by lazy { + TabPagerAdapter( + fragmentManager = supportFragmentManager, + lifecycleOwner = this, + activityIntent = intent, + getSelectedTabId = tabManager::getSelectedTabId, + getTabById = ::getTabById, + requestAndWaitForNewTab = ::requestAndWaitForNewTab, + ) + } + private lateinit var toolbarMockupBinding: IncludeOmnibarToolbarMockupBinding private var openMessageInNewTabJob: Job? = null + private val onTabPageChangeListener = object : ViewPager2.OnPageChangeCallback() { + private val TOUCH_DELAY_MS = 200L + private var wasSwipingStarted = false + + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + if (wasSwipingStarted) { + wasSwipingStarted = false + + viewModel.onTabsSwiped() + onTabPageSwiped(position) + + enableWebViewScrolling() + } + } + + private fun enableWebViewScrolling() { + // ViewPager2 requires an artificial tap to disable intercepting touch events and enable nested scrolling + val time = SystemClock.uptimeMillis() + val motionEvent = MotionEvent.obtain(time, time + 1, MotionEvent.ACTION_DOWN, 0f, 0f, 0) + + tabPager.postDelayed(TOUCH_DELAY_MS) { + tabPager.dispatchTouchEvent(motionEvent) + } + } + + override fun onPageScrollStateChanged(state: Int) { + super.onPageScrollStateChanged(state) + if (state == ViewPager2.SCROLL_STATE_DRAGGING) { + wasSwipingStarted = true + } + } + } + @VisibleForTesting var destroyedByBackPress: Boolean = false @@ -209,6 +287,9 @@ open class BrowserActivity : DuckDuckGoActivity() { } setContentView(binding.root) + + initializeTabs() + viewModel.viewState.observe(this) { renderer.renderBrowserViewState(it) } @@ -223,11 +304,18 @@ open class BrowserActivity : DuckDuckGoActivity() { override fun onStop() { openMessageInNewTabJob?.cancel() + super.onStop() } override fun onDestroy() { currentTab = null + + if (swipingTabsFeature.isEnabled) { + binding.tabPager.adapter = null + binding.tabPager.unregisterOnPageChangeCallback(onTabPageChangeListener) + } + super.onDestroy() } @@ -376,7 +464,7 @@ open class BrowserActivity : DuckDuckGoActivity() { if (launchNewSearch(intent)) { Timber.w("new tab requested") - lifecycleScope.launch { viewModel.onNewTabRequested() } + launchNewTab() return } @@ -400,7 +488,11 @@ open class BrowserActivity : DuckDuckGoActivity() { currentTab?.submitQuery(sharedText) } else { Timber.w("can't use current tab, opening in new tab instead") - lifecycleScope.launch { viewModel.onOpenInNewTabRequested(query = sharedText, skipHome = true) } + if (swipingTabsFeature.isEnabled) { + launchNewTab(query = sharedText, skipHome = true) + } else { + lifecycleScope.launch { viewModel.onOpenInNewTabRequested(query = sharedText, skipHome = true) } + } } } else { val isExternal = intent.getBooleanExtra(LAUNCH_FROM_EXTERNAL_EXTRA, false) @@ -413,7 +505,11 @@ open class BrowserActivity : DuckDuckGoActivity() { val selectedText = intent.getBooleanExtra(SELECTED_TEXT_EXTRA, false) val sourceTabId = if (selectedText) currentTab?.tabId else null val skipHome = !selectedText - lifecycleScope.launch { viewModel.onOpenInNewTabRequested(sourceTabId = sourceTabId, query = sharedText, skipHome = skipHome) } + if (swipingTabsFeature.isEnabled) { + launchNewTab(query = sharedText, sourceTabId = sourceTabId, skipHome = skipHome) + } else { + lifecycleScope.launch { viewModel.onOpenInNewTabRequested(sourceTabId = sourceTabId, query = sharedText, skipHome = skipHome) } + } } } else { Timber.i("shared text empty, defaulting to show on app launch option") @@ -430,22 +526,47 @@ open class BrowserActivity : DuckDuckGoActivity() { viewModel.command.observe(this) { processCommand(it) } - viewModel.selectedTab.observe(this) { - if (it != null) { - selectTab(it) + + if (swipingTabsFeature.isEnabled) { + lifecycleScope.launch { + viewModel.tabsFlow.flowWithLifecycle(lifecycle).collectLatest { + tabManager.onTabsChanged(it) + } + } + + lifecycleScope.launch { + viewModel.selectedTabFlow.flowWithLifecycle(lifecycle).collectLatest { + tabManager.onSelectedTabChanged(it) + } + } + + lifecycleScope.launch { + viewModel.selectedTabIndex.flowWithLifecycle(lifecycle).collectLatest { + onMoveToTabRequested(it) + } + } + } else { + viewModel.selectedTab.observe(this) { + if (it != null) { + selectTab(it) + } + } + + viewModel.tabs.observe(this) { + clearStaleTabs(it) + removeOldTabs() + lifecycleScope.launch { viewModel.onTabsUpdated(it) } } - } - viewModel.tabs.observe(this) { - clearStaleTabs(it) - removeOldTabs() - lifecycleScope.launch { viewModel.onTabsUpdated(it) } } } private fun removeObservers() { viewModel.command.removeObservers(this) - viewModel.selectedTab.removeObservers(this) - viewModel.tabs.removeObservers(this) + + if (!swipingTabsFeature.isEnabled) { + viewModel.selectedTab.removeObservers(this) + viewModel.tabs.removeObservers(this) + } } private fun clearStaleTabs(updatedTabs: List?) { @@ -484,6 +605,8 @@ open class BrowserActivity : DuckDuckGoActivity() { is Command.ShowAppRatingPrompt -> showAppRatingDialog(command.promptCount) is Command.ShowAppFeedbackPrompt -> showGiveFeedbackDialog(command.promptCount) is Command.LaunchFeedbackView -> startActivity(FeedbackActivity.intent(this)) + is Command.SwitchToTab -> openExistingTab(command.tabId) + is Command.OpenInNewTab -> launchNewTab(command.url) is Command.OpenSavedSite -> currentTab?.submitQuery(command.url) is Command.ShowSetAsDefaultBrowserDialog -> showSetAsDefaultBrowserDialog() is Command.HideSetAsDefaultBrowserDialog -> hideSetAsDefaultBrowserDialog() @@ -496,6 +619,11 @@ open class BrowserActivity : DuckDuckGoActivity() { return intent.getBooleanExtra(NEW_SEARCH_EXTRA, false) } + fun clearTabsAndRecreate() { + tabPagerAdapter.clearFragments() + recreate() + } + fun launchFire() { pixel.fire(AppPixelName.FORGET_ALL_PRESSED_BROWSING) val dialog = FireDialog( @@ -520,36 +648,6 @@ open class BrowserActivity : DuckDuckGoActivity() { dialog.show() } - fun launchNewTab() { - lifecycleScope.launch { viewModel.onNewTabRequested() } - } - - fun openInNewTab( - query: String, - sourceTabId: String?, - ) { - lifecycleScope.launch { - viewModel.onOpenInNewTabRequested(query = query, sourceTabId = sourceTabId) - } - } - - fun openMessageInNewTab( - message: Message, - sourceTabId: String?, - ) { - openMessageInNewTabJob = lifecycleScope.launch { - val tabId = viewModel.onNewTabRequested(sourceTabId = sourceTabId) - val fragment = openNewTab(tabId, null, false, intent?.getBooleanExtra(LAUNCH_FROM_EXTERNAL_EXTRA, false) ?: false) - fragment.messageFromPreviousTab = message - } - } - - fun openExistingTab(tabId: String) { - lifecycleScope.launch { - viewModel.onTabSelected(tabId) - } - } - fun launchSettings() { startActivity(SettingsActivity.intent(this)) } @@ -640,7 +738,7 @@ open class BrowserActivity : DuckDuckGoActivity() { private const val LAUNCH_FROM_INTERSTITIAL_EXTRA = "INTERSTITIAL_SCREEN_EXTRA" const val OPEN_EXISTING_TAB_ID_EXTRA = "OPEN_EXISTING_TAB_ID_EXTRA" - private const val LAUNCH_FROM_EXTERNAL_EXTRA = "LAUNCH_FROM_EXTERNAL_EXTRA" + const val LAUNCH_FROM_EXTERNAL_EXTRA = "LAUNCH_FROM_EXTERNAL_EXTRA" private const val LAUNCH_FROM_CLEAR_DATA_ACTION = "LAUNCH_FROM_CLEAR_DATA_ACTION" private const val LAUNCH_FROM_DEDICATED_WEBVIEW = "LAUNCH_FROM_DEDICATED_WEBVIEW" @@ -661,6 +759,10 @@ open class BrowserActivity : DuckDuckGoActivity() { } else { showWebContent() } + + if (swipingTabsFeature.isEnabled) { + tabPager.isUserInputEnabled = viewState.isTabSwipingEnabled + } } } @@ -684,6 +786,21 @@ open class BrowserActivity : DuckDuckGoActivity() { } } + private fun initializeTabs() { + if (swipingTabsFeature.isEnabled) { + tabManager.registerCallbacks( + onTabsUpdated = ::onTabsUpdated, + ) + + tabPager.adapter = tabPagerAdapter + tabPager.registerOnPageChangeCallback(onTabPageChangeListener) + tabPager.setPageTransformer(MarginPageTransformer(resources.getDimension(com.duckduckgo.mobile.android.R.dimen.keyline_1).toPx().toInt())) + } + + binding.fragmentContainer.isVisible = !swipingTabsFeature.isEnabled + tabPager.isVisible = swipingTabsFeature.isEnabled + } + private val Intent.launchedFromRecents: Boolean get() = (flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY @@ -784,6 +901,78 @@ open class BrowserActivity : DuckDuckGoActivity() { playStoreUtils.launchPlayStore() } + private fun onMoveToTabRequested(index: Int) { + tabPager.post { + tabPager.setCurrentItem(index, false) + } + } + + private fun onTabPageSwiped(newPosition: Int) = lifecycleScope.launch { + val tabId = tabPagerAdapter.getTabIdAtPosition(newPosition) + if (tabId != null) { + tabManager.switchToTab(tabId) + } + } + + private fun onTabsUpdated(updatedTabIds: List) { + tabPagerAdapter.onTabsUpdated(updatedTabIds) + } + + private fun getTabById(tabId: String): TabEntity? = runBlocking { + return@runBlocking tabManager.getTabById(tabId) + } + + private fun requestAndWaitForNewTab(): TabEntity = runBlocking { + return@runBlocking tabManager.requestAndWaitForNewTab() + } + + fun launchNewTab(query: String? = null, sourceTabId: String? = null, skipHome: Boolean = false) { + lifecycleScope.launch { + if (swipingTabsFeature.isEnabled) { + tabManager.openNewTab(query, sourceTabId, skipHome) + } else { + viewModel.onNewTabRequested() + } + } + } + + fun openInNewTab( + query: String, + sourceTabId: String?, + ) { + lifecycleScope.launch { + viewModel.onOpenInNewTabRequested(query = query, sourceTabId = sourceTabId) + } + } + + fun openMessageInNewTab( + message: Message, + sourceTabId: String?, + ) { + openMessageInNewTabJob = lifecycleScope.launch { + if (swipingTabsFeature.isEnabled) { + tabPagerAdapter.setMessageForNewFragment(message) + tabManager.openNewTab(sourceTabId) + } else { + val tabId = viewModel.onNewTabRequested(sourceTabId = sourceTabId) + val fragment = openNewTab(tabId, null, false, intent?.getBooleanExtra(LAUNCH_FROM_EXTERNAL_EXTRA, false) ?: false) + fragment.messageFromPreviousTab = message + } + } + } + + fun openExistingTab(tabId: String) = lifecycleScope.launch { + if (swipingTabsFeature.isEnabled) { + tabManager.switchToTab(tabId) + } else { + viewModel.onTabSelected(tabId) + } + } + + fun onEditModeChanged(isInEditMode: Boolean) { + viewModel.onOmnibarEditModeChanged(isInEditMode) + } + private data class CombinedInstanceState( val originalInstanceState: Bundle?, val newInstanceState: Bundle?, 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 aafac827442c..5baf6dcba89e 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -82,10 +82,12 @@ import androidx.fragment.app.commitNow import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.transaction import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.webkit.JavaScriptReplyProxy @@ -312,6 +314,7 @@ import kotlinx.coroutines.Runnable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -524,6 +527,9 @@ class BrowserTabFragment : @Inject lateinit var webViewCapabilityChecker: WebViewCapabilityChecker + @Inject + lateinit var swipingTabsFeature: SwipingTabsFeatureProvider + /** * We use this to monitor whether the user was seeing the in-context Email Protection signup prompt * This is needed because the activity stack will be cleared if an external link is opened in our browser @@ -773,8 +779,6 @@ class BrowserTabFragment : private var automaticFireproofDialog: DaxAlertDialog? = null - private val pulseAnimation: PulseAnimation = PulseAnimation(this) - private var webShareRequest = registerForActivityResult(WebShareChooser()) { contentScopeScripts.onResponse(it) @@ -829,8 +833,19 @@ class BrowserTabFragment : } private fun resumeWebView() { - webView?.let { - if (it.isShown) it.onResume() + Timber.d("Resuming webview: $tabId") + webView?.let { webView -> + if (webView.isShown) { + webView.onResume() + } else if (swipingTabsFeature.isEnabled) { + // Sometimes the tab is brought back from the background but the WebView is not visible yet due to + // ViewPager page change delay; this fixes an issue when a tab was blank. + webView.post { + if (webView.isShown) { + webView.onResume() + } + } + } } } @@ -874,6 +889,22 @@ class BrowserTabFragment : (dialog as EditSavedSiteDialogFragment).listener = viewModel dialog.deleteBookmarkListener = viewModel } + + if (swipingTabsFeature.isEnabled) { + disableSwipingOutsideTheOmnibar() + } + } + + @SuppressLint("ClickableViewAccessibility") + private fun disableSwipingOutsideTheOmnibar() { + newBrowserTab.newTabLayout.setOnTouchListener { v, event -> + v.parent.requestDisallowInterceptTouchEvent(true) + false + } + binding.autoCompleteSuggestionsList.setOnTouchListener { v, event -> + v.parent.requestDisallowInterceptTouchEvent(true) + false + } } private fun configureOmnibar() { @@ -881,6 +912,17 @@ class BrowserTabFragment : configureOmnibarTextInput() configureItemPressedListener() configureCustomTab() + configureEditModeChangeDetection() + } + + private fun configureEditModeChangeDetection() { + if (swipingTabsFeature.isEnabled) { + omnibar.isInEditMode.onEach { isInEditMode -> + if (isActiveTab) { + browserActivity?.onEditModeChanged(isInEditMode) + } + }.launchIn(lifecycleScope) + } } private fun onOmnibarTabsButtonPressed() { @@ -1121,7 +1163,11 @@ class BrowserTabFragment : super.onResume() if (viewModel.hasOmnibarPositionChanged(omnibar.omnibarPosition)) { - requireActivity().recreate() + if (swipingTabsFeature.isEnabled && requireActivity() is BrowserActivity) { + (requireActivity() as BrowserActivity).clearTabsAndRecreate() + } else { + requireActivity().recreate() + } return } @@ -1303,7 +1349,7 @@ class BrowserTabFragment : newBrowserTab.newTabContainerLayout.show() binding.browserLayout.gone() webViewContainer.gone() - omnibar.setViewMode(Omnibar.ViewMode.NewTab) + omnibar.setViewMode(ViewMode.NewTab) webView?.onPause() webView?.hide() errorView.errorLayout.gone() @@ -1321,7 +1367,7 @@ class BrowserTabFragment : errorView.errorLayout.gone() sslErrorView.gone() maliciousWarningView.gone() - omnibar.setViewMode(Omnibar.ViewMode.Browser(viewModel.url)) + omnibar.setViewMode(ViewMode.Browser(viewModel.url)) } private fun showError( @@ -1333,7 +1379,7 @@ class BrowserTabFragment : newBrowserTab.newTabContainerLayout.gone() sslErrorView.gone() maliciousWarningView.gone() - omnibar.setViewMode(Omnibar.ViewMode.Error) + omnibar.setViewMode(ViewMode.Error) webView?.onPause() webView?.hide() errorView.errorMessage.text = getString(errorType.errorId, url).html(requireContext()) @@ -1419,7 +1465,7 @@ class BrowserTabFragment : newBrowserTab.newTabContainerLayout.gone() webView?.onPause() webView?.hide() - omnibar.setViewMode(Omnibar.ViewMode.SSLWarning) + omnibar.setViewMode(ViewMode.SSLWarning) errorView.errorLayout.gone() binding.browserLayout.gone() maliciousWarningView.gone() @@ -1531,7 +1577,11 @@ class BrowserTabFragment : when (it) { is NavigationCommand.Refresh -> refresh() is Command.OpenInNewTab -> { - browserActivity?.openInNewTab(it.query, it.sourceTabId) + if (swipingTabsFeature.isEnabled) { + browserActivity?.launchNewTab(it.query, it.sourceTabId) + } else { + browserActivity?.openInNewTab(it.query, it.sourceTabId) + } } is Command.OpenMessageInNewTab -> { @@ -1551,7 +1601,9 @@ class BrowserTabFragment : openInNewBackgroundTab() } - is Command.LaunchNewTab -> browserActivity?.launchNewTab() + is Command.LaunchNewTab -> { + browserActivity?.launchNewTab() + } is Command.ShowSavedSiteAddedConfirmation -> savedSiteAdded(it.savedSiteChangedViewState) is Command.ShowEditSavedSiteDialog -> editSavedSite(it.savedSiteChangedViewState) is Command.DeleteFavoriteConfirmation -> confirmDeleteSavedSite( @@ -1804,6 +1856,7 @@ class BrowserTabFragment : viewModel.autoCompleteSuggestionsGone() } binding.autoCompleteSuggestionsList.gone() + browserActivity?.openExistingTab(it.tabId) } @@ -2491,7 +2544,11 @@ class BrowserTabFragment : cancelPendingAutofillRequestsToChooseCredentials() } else { omnibar.omnibarTextInput.hideKeyboard() - binding.focusDummy.requestFocus() + + // prevent a crash when the view is not initiliazed yet + if (view != null) { + binding.focusDummy.requestFocus() + } } } @@ -2522,13 +2579,17 @@ class BrowserTabFragment : @SuppressLint("SetJavaScriptEnabled") private fun configureWebView() { + if (swipingTabsFeature.isEnabled) { + binding.daxDialogOnboardingCtaContent.layoutTransition.setAnimateParentHierarchy(false) + } + binding.daxDialogOnboardingCtaContent.layoutTransition.enableTransitionType(LayoutTransition.CHANGING) webView = layoutInflater.inflate( R.layout.include_duckduckgo_browser_webview, binding.webViewContainer, true, - ).findViewById(R.id.browserWebView) as DuckDuckGoWebView + ).findViewById(R.id.browserWebView) webView?.let { it.isSafeWebViewEnabled = safeWebViewFeature.self().isEnabled() @@ -2987,15 +3048,17 @@ class BrowserTabFragment : } override fun onContextItemSelected(item: MenuItem): Boolean { - runCatching { - webView?.safeHitTestResult?.let { - val target = getLongPressTarget(it) - if (target != null && viewModel.userSelectedItemFromLongPressMenu(target, item)) { - return true + if (this.isResumed) { + runCatching { + webView?.safeHitTestResult?.let { + val target = getLongPressTarget(it) + if (target != null && viewModel.userSelectedItemFromLongPressMenu(target, item)) { + return true + } } + }.onFailure { exception -> + Timber.e(exception, "Failed to get HitTestResult") } - }.onFailure { exception -> - Timber.e(exception, "Failed to get HitTestResult") } return super.onContextItemSelected(item) } @@ -3272,7 +3335,7 @@ class BrowserTabFragment : private fun launchDownloadMessagesJob() { downloadMessagesJob += lifecycleScope.launch { - viewModel.downloadCommands().cancellable().collect { + viewModel.downloadCommands().cancellable().flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED).collectLatest { processFileDownloadedCommand(it) } } 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 6c0e1b7f095c..ac4b6185efaa 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -375,6 +375,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn @@ -459,6 +460,7 @@ class BrowserTabViewModel @Inject constructor( private val tabStatsBucketing: TabStatsBucketing, private val maliciousSiteBlockerWebViewIntegration: MaliciousSiteBlockerWebViewIntegration, private val defaultBrowserPromptsExperiment: DefaultBrowserPromptsExperiment, + private val swipingTabsFeature: SwipingTabsFeatureProvider, ) : WebViewClientListener, EditSavedSiteListener, DeleteBookmarkListener, @@ -2625,7 +2627,20 @@ class BrowserTabViewModel @Inject constructor( longPress: Boolean = false, ) { command.value = GenerateWebViewPreviewImage - command.value = LaunchNewTab + + if (swipingTabsFeature.isEnabled) { + viewModelScope.launch { + val emptyTab = tabRepository.flowTabs.first().firstOrNull { it.url.isNullOrBlank() }?.tabId + if (emptyTab != null) { + tabRepository.select(tabId = emptyTab) + } else { + command.value = LaunchNewTab + } + } + } else { + command.value = LaunchNewTab + } + if (longPress) { pixel.fire(AppPixelName.TAB_MANAGER_NEW_TAB_LONG_PRESSED) } diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt index 0b3f3adef479..97631f5482ee 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt @@ -54,6 +54,7 @@ import com.duckduckgo.app.pixels.AppPixelName.APP_RATING_DIALOG_USER_DECLINED_RA import com.duckduckgo.app.pixels.AppPixelName.APP_RATING_DIALOG_USER_GAVE_RATING import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.common.utils.DispatcherProvider @@ -64,9 +65,19 @@ import com.duckduckgo.feature.toggles.api.Toggle import javax.inject.Inject import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import timber.log.Timber +@OptIn(FlowPreview::class) @ContributesViewModel(ActivityScope::class) class BrowserViewModel @Inject constructor( private val tabRepository: TabRepository, @@ -81,6 +92,7 @@ class BrowserViewModel @Inject constructor( private val showOnAppLaunchFeature: ShowOnAppLaunchFeature, private val showOnAppLaunchOptionHandler: ShowOnAppLaunchOptionHandler, private val defaultBrowserPromptsExperiment: DefaultBrowserPromptsExperiment, + private val swipingTabsFeature: SwipingTabsFeatureProvider, ) : ViewModel(), CoroutineScope { @@ -89,6 +101,7 @@ class BrowserViewModel @Inject constructor( data class ViewState( val hideWebContent: Boolean = true, + val isTabSwipingEnabled: Boolean = false, ) sealed class Command { @@ -98,6 +111,8 @@ class BrowserViewModel @Inject constructor( data class ShowAppEnjoymentPrompt(val promptCount: PromptCount) : Command() data class ShowAppRatingPrompt(val promptCount: PromptCount) : Command() data class ShowAppFeedbackPrompt(val promptCount: PromptCount) : Command() + data class SwitchToTab(val tabId: String) : Command() + data class OpenInNewTab(val url: String) : Command() data class OpenSavedSite(val url: String) : Command() data object ShowSetAsDefaultBrowserDialog : Command() data object HideSetAsDefaultBrowserDialog : Command() @@ -116,35 +131,45 @@ class BrowserViewModel @Inject constructor( var selectedTab: LiveData = tabRepository.liveSelectedTab val command: SingleLiveEvent = SingleLiveEvent() - private var dataClearingObserver = Observer { - it?.let { state -> - when (state) { - ApplicationClearDataState.INITIALIZING -> { - Timber.i("App clear state initializing") - viewState.value = currentViewState.copy(hideWebContent = true) - } - ApplicationClearDataState.FINISHED -> { - Timber.i("App clear state finished") - viewState.value = currentViewState.copy(hideWebContent = false) - } + val selectedTabFlow: Flow = tabRepository.flowSelectedTab + .map { tab -> tab?.tabId } + .filterNotNull() + .distinctUntilChanged() + .debounce(100) + + val tabsFlow: Flow> = tabRepository.flowTabs + .map { tabs -> tabs.map { tab -> tab.tabId } } + .distinctUntilChanged() + + val selectedTabIndex: Flow = combine(tabsFlow, selectedTabFlow) { tabs, selectedTab -> + tabs.indexOf(selectedTab) + }.filterNot { it == -1 } + + private var dataClearingObserver = Observer { state -> + when (state) { + ApplicationClearDataState.INITIALIZING -> { + Timber.i("App clear state initializing") + viewState.value = currentViewState.copy(hideWebContent = true) + } + ApplicationClearDataState.FINISHED -> { + Timber.i("App clear state finished") + viewState.value = currentViewState.copy(hideWebContent = false) } } } - private val appEnjoymentObserver = Observer { - it?.let { promptType -> - when (promptType) { - is AppEnjoymentPromptOptions.ShowEnjoymentPrompt -> { - command.value = Command.ShowAppEnjoymentPrompt(promptType.promptCount) - } - is AppEnjoymentPromptOptions.ShowRatingPrompt -> { - command.value = Command.ShowAppRatingPrompt(promptType.promptCount) - } - is AppEnjoymentPromptOptions.ShowFeedbackPrompt -> { - command.value = Command.ShowAppFeedbackPrompt(promptType.promptCount) - } - else -> {} + private val appEnjoymentObserver = Observer { promptType -> + when (promptType) { + is AppEnjoymentPromptOptions.ShowEnjoymentPrompt -> { + command.value = Command.ShowAppEnjoymentPrompt(promptType.promptCount) + } + is AppEnjoymentPromptOptions.ShowRatingPrompt -> { + command.value = Command.ShowAppRatingPrompt(promptType.promptCount) + } + is AppEnjoymentPromptOptions.ShowFeedbackPrompt -> { + command.value = Command.ShowAppFeedbackPrompt(promptType.promptCount) } + else -> {} } } @@ -315,7 +340,21 @@ class BrowserViewModel @Inject constructor( } fun onBookmarksActivityResult(url: String) { - command.value = Command.OpenSavedSite(url) + if (swipingTabsFeature.isEnabled) { + launch { + val existingTab = tabRepository.flowTabs + .first() + .firstOrNull { tab -> tab.url == url } + + if (existingTab == null) { + command.value = Command.OpenInNewTab(url) + } else { + command.value = Command.SwitchToTab(existingTab.tabId) + } + } + } else { + command.value = Command.OpenSavedSite(url) + } } fun onTabSelected(tabId: String) { @@ -375,6 +414,15 @@ class BrowserViewModel @Inject constructor( fun onSystemDefaultAppsActivityClosed() { defaultBrowserPromptsExperiment.onSystemDefaultAppsActivityClosed() } + + fun onTabsSwiped() { + pixel.fire(AppPixelName.SWIPE_TABS_USED) + pixel.fire(pixel = AppPixelName.SWIPE_TABS_USED_DAILY, type = Daily()) + } + + fun onOmnibarEditModeChanged(isInEditMode: Boolean) { + viewState.value = currentViewState.copy(isTabSwipingEnabled = !isInEditMode) + } } /** 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 dc1fe3011316..3603b55eb202 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoWebView.kt @@ -227,7 +227,9 @@ class DuckDuckGoWebView : WebView, NestedScrollingChild3 { @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(ev: MotionEvent): Boolean { - var returnValue = false + parent.requestDisallowInterceptTouchEvent(true) + + val returnValue: Boolean val event = MotionEvent.obtain(ev) val action = event.actionMasked diff --git a/app/src/main/java/com/duckduckgo/app/browser/SwipingTabsFeature.kt b/app/src/main/java/com/duckduckgo/app/browser/SwipingTabsFeature.kt new file mode 100644 index 000000000000..cc47d0977e1b --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/SwipingTabsFeature.kt @@ -0,0 +1,30 @@ +/* + * 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 com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.Toggle + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "swipingTabs", +) +interface SwipingTabsFeature { + @Toggle.DefaultValue(false) + fun self(): Toggle +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/SwipingTabsFeatureProvider.kt b/app/src/main/java/com/duckduckgo/app/browser/SwipingTabsFeatureProvider.kt new file mode 100644 index 000000000000..7a2346d546f0 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/SwipingTabsFeatureProvider.kt @@ -0,0 +1,30 @@ +/* + * 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 com.duckduckgo.di.scopes.AppScope +import dagger.SingleInstanceIn +import javax.inject.Inject + +@SingleInstanceIn(AppScope::class) +class SwipingTabsFeatureProvider @Inject constructor( + swipingTabsFeature: SwipingTabsFeature, +) { + val isEnabled: Boolean by lazy { + swipingTabsFeature.self().isEnabled() + } +} 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 index 4c99745891e4..5a4160c81509 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/omnibar/Omnibar.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/Omnibar.kt @@ -179,6 +179,8 @@ class Omnibar( newOmnibar.omnibarTextInput.rootView } + val isInEditMode = newOmnibar.isEditingFlow + var isScrollingEnabled: Boolean get() = newOmnibar.isScrollingEnabled diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarLayout.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarLayout.kt index 934bd09b0e0d..a2a44d57ef2a 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarLayout.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarLayout.kt @@ -16,6 +16,7 @@ package com.duckduckgo.app.browser.omnibar +import android.annotation.SuppressLint import android.content.Context import android.graphics.Color import android.graphics.drawable.ColorDrawable @@ -34,9 +35,11 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.doOnLayout import androidx.core.view.isInvisible import androidx.core.view.isVisible +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.findViewTreeViewModelStoreOwner +import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import com.airbnb.lottie.LottieAnimationView import com.duckduckgo.anvil.annotations.InjectWith @@ -84,9 +87,9 @@ import com.duckduckgo.di.scopes.FragmentScope import com.google.android.material.appbar.AppBarLayout import dagger.android.support.AndroidSupportInjection import javax.inject.Inject -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import timber.log.Timber @InjectWith(FragmentScope::class) @@ -139,7 +142,13 @@ class OmnibarLayout @JvmOverloads constructor( @Inject lateinit var dispatchers: DispatcherProvider - private lateinit var pulseAnimation: PulseAnimation + private val lifecycleOwner: LifecycleOwner by lazy { + requireNotNull(findViewTreeLifecycleOwner()) + } + + private val pulseAnimation: PulseAnimation by lazy { + PulseAnimation(lifecycleOwner) + } private var omnibarTextListener: Omnibar.TextListener? = null private var omnibarItemPressedListener: Omnibar.ItemPressedListener? = null @@ -190,6 +199,8 @@ class OmnibarLayout @JvmOverloads constructor( R.layout.view_new_omnibar } inflate(context, layout, this) + + AndroidSupportInjection.inject(this) } private fun omnibarViews(): List = listOf( @@ -221,6 +232,12 @@ class OmnibarLayout @JvmOverloads constructor( } } + val isEditingFlow by lazy { + viewModel.viewState.map { + isAttachedToWindow && it.hasFocus + } + } + private val smoothProgressAnimator by lazy { SmoothProgressAnimator(pageLoadingIndicator) } private val viewModel: OmnibarLayoutViewModel by lazy { @@ -234,22 +251,21 @@ class OmnibarLayout @JvmOverloads constructor( private val conflatedCommandJob = ConflatedJob() override fun onAttachedToWindow() { - AndroidSupportInjection.inject(this) super.onAttachedToWindow() - pulseAnimation = PulseAnimation(findViewTreeLifecycleOwner()!!) - - val coroutineScope = findViewTreeLifecycleOwner()?.lifecycleScope + val coroutineScope = requireNotNull(findViewTreeLifecycleOwner()?.lifecycleScope) - conflatedStateJob += viewModel.viewState - .onEach { render(it) } - .launchIn(coroutineScope!!) - - conflatedCommandJob += viewModel.commands() - .onEach { processCommand(it) } - .launchIn(coroutineScope!!) + conflatedStateJob += coroutineScope.launch { + viewModel.viewState.flowWithLifecycle(lifecycleOwner.lifecycle).collectLatest { + render(it) + } + } - viewModel.onAttachedToWindow() + conflatedCommandJob += coroutineScope.launch { + viewModel.commands().flowWithLifecycle(lifecycleOwner.lifecycle).collectLatest { + processCommand(it) + } + } if (decoration != null) { decorateDeferred(decoration!!) @@ -270,6 +286,7 @@ class OmnibarLayout @JvmOverloads constructor( super.onDetachedFromWindow() } + @SuppressLint("ClickableViewAccessibility") fun setOmnibarTextListener(textListener: Omnibar.TextListener) { omnibarTextListener = textListener @@ -415,8 +432,8 @@ class OmnibarLayout @JvmOverloads constructor( private fun renderTabIcon(viewState: ViewState) { if (viewState.shouldUpdateTabsCount) { - tabsMenu.count = viewState.tabs.count() - tabsMenu.hasUnread = viewState.tabs.firstOrNull { !it.viewed } != null + tabsMenu.count = viewState.tabCount + tabsMenu.hasUnread = viewState.hasUnreadTabs } } @@ -612,33 +629,20 @@ class OmnibarLayout @JvmOverloads constructor( null } - // omnibar only scrollable when browser showing and the fire button is not promoted if (targetView != null) { - if (this::pulseAnimation.isInitialized) { - if (pulseAnimation.isActive) { - pulseAnimation.stop() - } - doOnLayout { - if (this::pulseAnimation.isInitialized) { - pulseAnimation.playOn(targetView) - } - } - } - } else { - if (this::pulseAnimation.isInitialized) { + if (pulseAnimation.isActive) { pulseAnimation.stop() } - } - } - - fun isPulseAnimationPlaying(): Boolean { - return if (this::pulseAnimation.isInitialized) { - pulseAnimation.isActive + doOnLayout { + pulseAnimation.playOn(targetView) + } } else { - false + pulseAnimation.stop() } } + fun isPulseAnimationPlaying() = pulseAnimation.isActive + private fun createCookiesAnimation(isCosmetic: Boolean) { if (this::animatorHelper.isInitialized) { animatorHelper.createCookiesAnimation( diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModel.kt index 625dc5e0f6f3..4e7c7f5ea05f 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModel.kt @@ -46,7 +46,6 @@ import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.FIRE_BUTTON_STATE import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique -import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.app.trackerdetection.model.Entity import com.duckduckgo.browser.api.UserBrowserProperties @@ -61,11 +60,11 @@ import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber @@ -84,7 +83,18 @@ class OmnibarLayoutViewModel @Inject constructor( ) : ViewModel() { private val _viewState = MutableStateFlow(ViewState()) - val viewState = _viewState.asStateFlow() + val viewState = combine( + _viewState, + tabRepository.flowTabs, + defaultBrowserPromptsExperiment.highlightPopupMenu, + ) { state, tabs, highlightOverflowMenu -> + state.copy( + shouldUpdateTabsCount = tabs.size != state.tabCount && tabs.isNotEmpty(), + tabCount = tabs.size, + hasUnreadTabs = tabs.firstOrNull { !it.viewed } != null, + showBrowserMenuHighlight = highlightOverflowMenu, + ) + }.flowOn(dispatcherProvider.io()).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), ViewState()) private val command = Channel(1, DROP_OLDEST) fun commands(): Flow = command.receiveAsFlow() @@ -102,7 +112,8 @@ class OmnibarLayoutViewModel @Inject constructor( val updateOmnibarText: Boolean = false, val shouldMoveCaretToEnd: Boolean = false, val shouldMoveCaretToStart: Boolean = false, - val tabs: List = emptyList(), + val tabCount: Int = 0, + val hasUnreadTabs: Boolean = false, val shouldUpdateTabsCount: Boolean = false, val showVoiceSearch: Boolean = false, val showClearButton: Boolean = false, @@ -131,27 +142,6 @@ class OmnibarLayoutViewModel @Inject constructor( } init { - viewModelScope.launch { - defaultBrowserPromptsExperiment.highlightPopupMenu.collect { highlightOverflowMenu -> - _viewState.update { - it.copy(showBrowserMenuHighlight = highlightOverflowMenu) - } - } - } - } - - fun onAttachedToWindow() { - tabRepository.flowTabs - .onEach { tabs -> - _viewState.update { - it.copy( - shouldUpdateTabsCount = tabs.count() != it.tabs.count() || tabs.isNotEmpty(), - tabs = tabs, - ) - } - }.flowOn(dispatcherProvider.io()) - .launchIn(viewModelScope) - logVoiceSearchAvailability() } diff --git a/app/src/main/java/com/duckduckgo/app/browser/tabs/DefaultTabManager.kt b/app/src/main/java/com/duckduckgo/app/browser/tabs/DefaultTabManager.kt new file mode 100644 index 000000000000..e3726066df8f --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/tabs/DefaultTabManager.kt @@ -0,0 +1,126 @@ +/* + * 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.tabs + +import com.duckduckgo.app.browser.SkipUrlConversionOnNewTabFeature +import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter +import com.duckduckgo.app.tabs.model.TabEntity +import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.ActivityScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.transformWhile +import kotlinx.coroutines.withContext +import timber.log.Timber + +interface TabManager { + companion object { + const val MAX_ACTIVE_TABS = 20 + } + + fun registerCallbacks(onTabsUpdated: (List) -> Unit) + fun getSelectedTabId(): String? + fun onSelectedTabChanged(tabId: String) + + suspend fun onTabsChanged(updatedTabIds: List) + suspend fun switchToTab(tabId: String) + suspend fun requestAndWaitForNewTab(): TabEntity + suspend fun openNewTab(query: String? = null, sourceTabId: String? = null, skipHome: Boolean = false): String + suspend fun getTabById(tabId: String): TabEntity? +} + +@ContributesBinding(ActivityScope::class) +class DefaultTabManager @Inject constructor( + private val tabRepository: TabRepository, + private val dispatchers: DispatcherProvider, + private val queryUrlConverter: OmnibarEntryConverter, + private val skipUrlConversionOnNewTabFeature: SkipUrlConversionOnNewTabFeature, +) : TabManager { + private lateinit var onTabsUpdated: (List) -> Unit + private var selectedTabId: String? = null + + override fun registerCallbacks(onTabsUpdated: (List) -> Unit) { + this.onTabsUpdated = onTabsUpdated + } + + override fun getSelectedTabId(): String? = selectedTabId + + override fun onSelectedTabChanged(tabId: String) { + selectedTabId = tabId + } + + override suspend fun onTabsChanged(updatedTabIds: List) { + onTabsUpdated(updatedTabIds) + + if (updatedTabIds.isEmpty()) { + withContext(dispatchers.io()) { + Timber.i("Tabs list is null or empty; adding default tab") + tabRepository.addDefaultTab() + } + } + } + + override suspend fun requestAndWaitForNewTab(): TabEntity = withContext(dispatchers.io()) { + val tabId = openNewTab() + return@withContext tabRepository.flowTabs.transformWhile { result -> + result.firstOrNull { it.tabId == tabId }?.let { entity -> + emit(entity) + return@transformWhile true + } + return@transformWhile false + }.first() + } + + override suspend fun switchToTab(tabId: String) = withContext(dispatchers.io()) { + if (tabId != tabRepository.getSelectedTab()?.tabId) { + tabRepository.select(tabId) + } + } + + override suspend fun openNewTab( + query: String?, + sourceTabId: String?, + skipHome: Boolean, + ): String = withContext(dispatchers.io()) { + val url = query?.let { + if (skipUrlConversionOnNewTabFeature.self().isEnabled()) { + query + } else { + queryUrlConverter.convertQueryToUrl(query) + } + } + + return@withContext if (sourceTabId != null) { + tabRepository.addFromSourceTab( + url = url, + skipHome = skipHome, + sourceTabId = sourceTabId, + ) + } else { + tabRepository.add( + url = url, + skipHome = skipHome, + ) + } + } + + override suspend fun getTabById(tabId: String): TabEntity? = withContext(dispatchers.io()) { + return@withContext tabRepository.getTab(tabId) + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/tabs/adapter/FragmentStateAdapter.java b/app/src/main/java/com/duckduckgo/app/browser/tabs/adapter/FragmentStateAdapter.java new file mode 100644 index 000000000000..25123f59b8bf --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/tabs/adapter/FragmentStateAdapter.java @@ -0,0 +1,1032 @@ +/* + * Copyright 2018 The Android Open Source Project + * + * 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.tabs.adapter; + +import static androidx.lifecycle.Lifecycle.State.RESUMED; +import static androidx.lifecycle.Lifecycle.State.STARTED; +import static androidx.recyclerview.widget.RecyclerView.NO_ID; + +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.Parcelable; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.widget.FrameLayout; +import androidx.annotation.CallSuper; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.OptIn; +import androidx.annotation.RequiresOptIn; +import androidx.collection.ArraySet; +import androidx.collection.LongSparseArray; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleEventObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager2.adapter.StatefulAdapter; +import androidx.viewpager2.widget.ViewPager2; +import com.duckduckgo.app.browser.tabs.TabManager; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import timber.log.Timber; + +/** + * Similar in behavior to {@link androidx.fragment.app.FragmentStatePagerAdapter + * FragmentStatePagerAdapter} + * + *

Lifecycle within {@link RecyclerView}: + * + *

    + *
  • {@link RecyclerView.ViewHolder} initially an empty {@link FrameLayout}, serves as a + * re-usable container for a {@link Fragment} in later stages. + *
  • {@link RecyclerView.Adapter#onBindViewHolder} we ask for a {@link Fragment} for the + * position. If we already have the fragment, or have previously saved its state, we use + * those. + *
  • RecyclerView.Adapter#onAttachedToWindow we attach the {@link Fragment} to a container. + *
  • {@link RecyclerView.Adapter#onViewRecycled} we remove, save state, destroy the {@link + * Fragment}. + *
+ * + *

This is a copy of FragmentStateAdapter.java from the AndroidX library, with the following + * changes: < ul> + *

  • The package name has been changed to com.duckduckgo.app.browser.tabs.adapter + *
  • The fragments are removed from the FragmentManager when they are recycled, rather they are + * hidden until a limit is reached (TabManager.MAX_ACTIVE_TABS). When the limit is + * reached, the oldest fragment is removed + *
  • The list of fragments to remove is managed by a FIFO queue (itemIdQueue) + * + */ +public abstract class FragmentStateAdapter extends RecyclerView.Adapter + implements StatefulAdapter { + // State saving config + private static final String KEY_PREFIX_FRAGMENT = "f#"; + private static final String KEY_PREFIX_STATE = "s#"; + + // Fragment GC config + private static final long GRACE_WINDOW_TIME_MS = 10_000; // 10 seconds + + @SuppressWarnings("WeakerAccess") // to avoid creation of a synthetic accessor + final Lifecycle mLifecycle; + + @SuppressWarnings("WeakerAccess") // to avoid creation of a synthetic accessor + final FragmentManager mFragmentManager; + + // Fragment bookkeeping + @SuppressWarnings("WeakerAccess") // to avoid creation of a synthetic accessor + final LongSparseArray mFragments = new LongSparseArray<>(); + + private final LongSparseArray mSavedStates = new LongSparseArray<>(); + private final LongSparseArray mItemIdToViewHolder = new LongSparseArray<>(); + + private FragmentMaxLifecycleEnforcer mFragmentMaxLifecycleEnforcer; + + @SuppressWarnings("WeakerAccess") // to avoid creation of a synthetic accessor + FragmentEventDispatcher mFragmentEventDispatcher = new FragmentEventDispatcher(); + + // Fragment GC + @SuppressWarnings("WeakerAccess") // to avoid creation of a synthetic accessor + boolean mIsInGracePeriod = false; + + private boolean mHasStaleFragments = false; + + // Add a LinkedList to store itemIds in FIFO order + private final ArrayDeque itemIdQueue = new ArrayDeque<>(); + + /** + * @param fragmentActivity if the {@link ViewPager2} lives directly in a {@link + * FragmentActivity} subclass. + * @see FragmentStateAdapter#FragmentStateAdapter(Fragment) + * @see FragmentStateAdapter#FragmentStateAdapter(FragmentManager, Lifecycle) + */ + public FragmentStateAdapter(@NonNull FragmentActivity fragmentActivity) { + this(fragmentActivity.getSupportFragmentManager(), fragmentActivity.getLifecycle()); + } + + /** + * @param fragment if the {@link ViewPager2} lives directly in a {@link Fragment} subclass. + * @see FragmentStateAdapter#FragmentStateAdapter(FragmentActivity) + * @see FragmentStateAdapter#FragmentStateAdapter(FragmentManager, Lifecycle) + */ + public FragmentStateAdapter(@NonNull Fragment fragment) { + this(fragment.getChildFragmentManager(), fragment.getLifecycle()); + } + + /** + * @param fragmentManager of {@link ViewPager2}'s host + * @param lifecycle of {@link ViewPager2}'s host + * @see FragmentStateAdapter#FragmentStateAdapter(FragmentActivity) + * @see FragmentStateAdapter#FragmentStateAdapter(Fragment) + */ + public FragmentStateAdapter( + @NonNull FragmentManager fragmentManager, @NonNull Lifecycle lifecycle) { + mFragmentManager = fragmentManager; + mLifecycle = lifecycle; + super.setHasStableIds(true); + } + + @CallSuper + @Override + public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { + mFragmentMaxLifecycleEnforcer = new FragmentMaxLifecycleEnforcer(); + mFragmentMaxLifecycleEnforcer.register(recyclerView); + } + + @CallSuper + @Override + public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) { + mFragmentMaxLifecycleEnforcer.unregister(recyclerView); + mFragmentMaxLifecycleEnforcer = null; + } + + /** + * Provide a new Fragment associated with the specified position. + * + *

    The adapter will be responsible for the Fragment lifecycle: + * + *

      + *
    • The Fragment will be used to display an item. + *
    • The Fragment will be destroyed when it gets too far from the viewport, and its state + * will be saved. When the item is close to the viewport again, a new Fragment will be + * requested, and a previously saved state will be used to initialize it. + *
    + * + * @see ViewPager2#setOffscreenPageLimit + */ + public abstract @NonNull Fragment createFragment(int position); + + @NonNull + @Override + public final FragmentViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return FragmentViewHolder.create(parent); + } + + @Override + public final void onBindViewHolder(final @NonNull FragmentViewHolder holder, int position) { + final long itemId = holder.getItemId(); + final int viewHolderId = holder.getContainer().getId(); + final Long boundItemId = itemForViewHolder(viewHolderId); // item currently bound to the VH + if (boundItemId != null && boundItemId != itemId) { + removeFragment(boundItemId); + mItemIdToViewHolder.remove(boundItemId); + } + + mItemIdToViewHolder.put(itemId, viewHolderId); // this might overwrite an existing entry + ensureFragment(position); + + /* Special case when {@link RecyclerView} decides to keep the {@link container} + * attached to the window, resulting in no {@link `onViewAttachedToWindow} callback later */ + final FrameLayout container = holder.getContainer(); + if (container.isAttachedToWindow()) { + placeFragmentInViewHolder(holder); + } + + gcFragments(); + } + + @SuppressWarnings("WeakerAccess") // to avoid creation of a synthetic accessor + void gcFragments() { + if (!mHasStaleFragments || shouldDelayFragmentTransactions()) { + return; + } + + // Remove Fragments for items that are no longer part of the data-set + Set toRemove = new ArraySet<>(); + for (int ix = 0; ix < mFragments.size(); ix++) { + long itemId = mFragments.keyAt(ix); + if (!containsItem(itemId)) { + toRemove.add(itemId); + mItemIdToViewHolder.remove(itemId); // in case they're still bound + } + } + + // Remove Fragments that are not bound anywhere -- pending a grace period + if (!mIsInGracePeriod) { + mHasStaleFragments = false; // we've executed all GC checks + + for (int ix = 0; ix < mFragments.size(); ix++) { + long itemId = mFragments.keyAt(ix); + if (!isFragmentViewBound(itemId)) { + toRemove.add(itemId); + } + } + } + + for (Long itemId : toRemove) { + removeFragment(itemId); + } + } + + private boolean isFragmentViewBound(long itemId) { + if (mItemIdToViewHolder.containsKey(itemId)) { + return true; + } + + Fragment fragment = mFragments.get(itemId); + if (fragment == null) { + return false; + } + + View view = fragment.getView(); + if (view == null) { + return false; + } + + return view.getParent() != null; + } + + private Long itemForViewHolder(int viewHolderId) { + Long boundItemId = null; + for (int ix = 0; ix < mItemIdToViewHolder.size(); ix++) { + if (mItemIdToViewHolder.valueAt(ix) == viewHolderId) { + if (boundItemId != null) { + throw new IllegalStateException( + "Design assumption violated: " + + "a ViewHolder can only be bound to one item at a time."); + } + boundItemId = mItemIdToViewHolder.keyAt(ix); + } + } + return boundItemId; + } + + private void ensureFragment(int position) { + long itemId = getItemId(position); + if (!mFragments.containsKey(itemId)) { + // TODO(133419201): check if a Fragment provided here is a new Fragment + Fragment newFragment = createFragment(position); + newFragment.setInitialSavedState(mSavedStates.get(itemId)); + mFragments.put(itemId, newFragment); + } + } + + @Override + public final void onViewAttachedToWindow(@NonNull final FragmentViewHolder holder) { + placeFragmentInViewHolder(holder); + gcFragments(); + } + + /** + * @param holder that has been bound to a Fragment in the {@link #onBindViewHolder} stage. + */ + @SuppressWarnings("WeakerAccess") // to avoid creation of a synthetic accessor + void placeFragmentInViewHolder(@NonNull final FragmentViewHolder holder) { + long itemId = holder.getItemId(); + itemIdQueue.remove(itemId); + itemIdQueue.add(itemId); + + Fragment fragment = mFragments.get(itemId); + if (fragment == null) { + ensureFragment(holder.getBindingAdapterPosition()); + fragment = mFragments.get(itemId); + } + FrameLayout container = holder.getContainer(); + View view = fragment.getView(); + + /* + possible states: + - fragment: { added, notAdded } + - view: { created, notCreated } + - view: { attached, notAttached } + + combinations: + - { f:added, v:created, v:attached } -> check if attached to the right container + - { f:added, v:created, v:notAttached} -> attach view to container + - { f:added, v:notCreated, v:attached } -> impossible + - { f:added, v:notCreated, v:notAttached} -> schedule callback for when created + - { f:notAdded, v:created, v:attached } -> illegal state + - { f:notAdded, v:created, v:notAttached } -> illegal state + - { f:notAdded, v:notCreated, v:attached } -> impossible + - { f:notAdded, v:notCreated, v:notAttached } -> add, create, attach + */ + + // { f:notAdded, v:created, v:attached } -> illegal state + // { f:notAdded, v:created, v:notAttached } -> illegal state + if (!fragment.isAdded() && view != null) { + throw new IllegalStateException("Design assumption violated."); + } + + // { f:added, v:notCreated, v:notAttached} -> schedule callback for when created + if (fragment.isAdded() && view == null) { + scheduleViewAttach(fragment, container); + return; + } + + // { f:added, v:created, v:attached } -> check if attached to the right container + if (fragment.isAdded() && view.getParent() != null) { + if (view.getParent() != container) { + addViewToContainer(view, container); + } + showFragment(holder.getItemId()); + return; + } + + // { f:added, v:created, v:notAttached} -> attach view to container + if (fragment.isAdded()) { + addViewToContainer(view, container); + showFragment(holder.getItemId()); + return; + } + + // { f:notAdded, v:notCreated, v:notAttached } -> add, create, attach + if (!shouldDelayFragmentTransactions()) { + scheduleViewAttach(fragment, container); + List onPost = + mFragmentEventDispatcher.dispatchPreAdded(fragment); + try { + fragment.setMenuVisibility(false); // appropriate for maxLifecycle == STARTED + mFragmentManager + .beginTransaction() + .add(fragment, "f" + holder.getItemId()) + .setMaxLifecycle(fragment, STARTED) + .commitNow(); + mFragmentMaxLifecycleEnforcer.updateFragmentMaxLifecycle(false); + } finally { + mFragmentEventDispatcher.dispatchPostEvents(onPost); + } + } else { + if (mFragmentManager.isDestroyed()) { + return; // nothing we can do + } + mLifecycle.addObserver( + new LifecycleEventObserver() { + @Override + public void onStateChanged( + @NonNull LifecycleOwner source, @NonNull Lifecycle.Event event) { + if (shouldDelayFragmentTransactions()) { + return; + } + source.getLifecycle().removeObserver(this); + if (holder.getContainer().isAttachedToWindow()) { + placeFragmentInViewHolder(holder); + } + } + }); + } + } + + private void scheduleViewAttach(final Fragment fragment, @NonNull final FrameLayout container) { + // After a config change, Fragments that were in FragmentManager will be recreated. Since + // ViewHolder container ids are dynamically generated, we opted to manually handle + // attaching Fragment views to containers. For consistency, we use the same mechanism for + // all Fragment views. + mFragmentManager.registerFragmentLifecycleCallbacks( + new FragmentManager.FragmentLifecycleCallbacks() { + // TODO(b/141956012): Suppressed during upgrade to AGP 3.6. + @SuppressWarnings("ReferenceEquality") + @Override + public void onFragmentViewCreated( + @NonNull FragmentManager fm, + @NonNull Fragment f, + @NonNull View v, + @Nullable Bundle savedInstanceState) { + if (f == fragment) { + fm.unregisterFragmentLifecycleCallbacks(this); + addViewToContainer(v, container); + } + } + }, + false); + } + + @SuppressWarnings("WeakerAccess") // to avoid creation of a synthetic accessor + void addViewToContainer(@NonNull View v, @NonNull FrameLayout container) { + if (container.getChildCount() > 1) { + throw new IllegalStateException("Design assumption violated."); + } + + if (v.getParent() == container) { + return; + } + + if (container.getChildCount() > 0) { + container.removeAllViews(); + } + + if (v.getParent() != null) { + ((ViewGroup) v.getParent()).removeView(v); + } + + container.addView(v); + } + + @Override + public final void onViewRecycled(@NonNull FragmentViewHolder holder) { + final int viewHolderId = holder.getContainer().getId(); + final Long boundItemId = itemForViewHolder(viewHolderId); // item currently bound to the VH + if (boundItemId != null) { + if (mFragments.size() > TabManager.MAX_ACTIVE_TABS) { + // Remove the oldest itemId from the queue and recycle its fragment + Long oldestItemId = itemIdQueue.poll(); + if (oldestItemId != null) { + removeFragment(oldestItemId); + } + } else { + hideFragment(boundItemId); + } + mItemIdToViewHolder.remove(boundItemId); + } + } + + @Override + public final boolean onFailedToRecycleView(@NonNull FragmentViewHolder holder) { + /* + This happens when a ViewHolder is in a transient state (e.g. during an + animation). + + Our ViewHolders are effectively just FrameLayout instances in which we put Fragment + Views, so it's safe to force recycle them. This is because: + - FrameLayout instances are not to be directly manipulated, so no animations are + expected to be running directly on them. + - Fragment Views are not reused between position (one Fragment = one page). Animation + running in one of the Fragment Views won't affect another Fragment View. + - If a user chooses to violate these assumptions, they are also in the position to + correct the state in their code. + */ + return true; + } + + private void removeFragment(long itemId) { + Fragment fragment = mFragments.get(itemId); + + if (fragment == null) { + return; + } + + if (fragment.getView() != null) { + ViewParent viewParent = fragment.getView().getParent(); + if (viewParent != null) { + ((FrameLayout) viewParent).removeAllViews(); + } + } + + if (!containsItem(itemId)) { + mSavedStates.remove(itemId); + } + + if (!fragment.isAdded()) { + mFragments.remove(itemId); + Timber.d("$$$ Fragment (not added) removed: %s", itemId); + return; + } + + if (shouldDelayFragmentTransactions()) { + mHasStaleFragments = true; + return; + } + + if (fragment.isAdded() && containsItem(itemId)) { + List onPost = + mFragmentEventDispatcher.dispatchPreSavedInstanceState(fragment); + Fragment.SavedState savedState = mFragmentManager.saveFragmentInstanceState(fragment); + mFragmentEventDispatcher.dispatchPostEvents(onPost); + + mSavedStates.put(itemId, savedState); + } + List onPost = + mFragmentEventDispatcher.dispatchPreRemoved(fragment); + try { + mFragmentManager.beginTransaction().remove(fragment).commitNow(); + mFragments.remove(itemId); + Timber.d("$$$ Fragment removed (after transaction): %s", itemId); + } finally { + mFragmentEventDispatcher.dispatchPostEvents(onPost); + } + } + + private void hideFragment(long itemId) { + Fragment fragment = mFragments.get(itemId); + + if (fragment == null) { + return; + } + + if (fragment.isAdded() && !fragment.isHidden()) { + mFragmentManager.beginTransaction().hide(fragment).commitAllowingStateLoss(); + } + } + + public void clearFragments() { + FragmentTransaction transaction = mFragmentManager.beginTransaction(); + for (int i = 0; i < mFragments.size(); i++) { + long key = mFragments.keyAt(i); + Fragment fragment = mFragments.get(key); + if (fragment != null) { + transaction.remove(fragment); + } + } + transaction.commit(); + } + + private void showFragment(long itemId) { + Fragment fragment = mFragments.get(itemId); + + if (fragment == null) { + return; + } + + if (fragment.isAdded() && fragment.isHidden()) { + mFragmentManager.beginTransaction().show(fragment).commitNow(); + } + } + + @SuppressWarnings("WeakerAccess") // to avoid creation of a synthetic accessor + boolean shouldDelayFragmentTransactions() { + return mFragmentManager.isStateSaved(); + } + + /** + * Default implementation works for collections that don't add, move, remove items. + * + *

    When overriding, also override {@link #containsItem(long)}. + * + *

    If the item is not a part of the collection, return {@link RecyclerView#NO_ID}. + * + * @param position Adapter position + * @return stable item id {@link RecyclerView.Adapter#hasStableIds()} + */ + // TODO(b/122670460): add lint rule + @Override + public long getItemId(int position) { + return position; + } + + /** + * Default implementation works for collections that don't add, move, remove items. + * + *

    When overriding, also override {@link #getItemId(int)} + */ + // TODO(b/122670460): add lint rule + public boolean containsItem(long itemId) { + return itemId >= 0 && itemId < getItemCount(); + } + + @Override + public final void setHasStableIds(boolean hasStableIds) { + throw new UnsupportedOperationException( + "Stable Ids are required for the adapter to function properly, and the adapter " + + "takes care of setting the flag."); + } + + @Override + public final @NonNull Parcelable saveState() { + /* TODO(b/122670461): use custom {@link Parcelable} instead of Bundle to save space */ + Bundle savedState = new Bundle(mFragments.size() + mSavedStates.size()); + + /* save references to active fragments */ + for (int ix = 0; ix < mFragments.size(); ix++) { + long itemId = mFragments.keyAt(ix); + Fragment fragment = mFragments.get(itemId); + if (fragment != null && fragment.isAdded()) { + String key = createKey(KEY_PREFIX_FRAGMENT, itemId); + mFragmentManager.putFragment(savedState, key, fragment); + } + } + + /* Write {@link mSavedStates) into a {@link Parcelable} */ + for (int ix = 0; ix < mSavedStates.size(); ix++) { + long itemId = mSavedStates.keyAt(ix); + if (containsItem(itemId)) { + String key = createKey(KEY_PREFIX_STATE, itemId); + savedState.putParcelable(key, mSavedStates.get(itemId)); + } + } + + return savedState; + } + + @Override + @SuppressWarnings("deprecation") + public final void restoreState(@NonNull Parcelable savedState) { + if (!mSavedStates.isEmpty() || !mFragments.isEmpty()) { + throw new IllegalStateException( + "Expected the adapter to be 'fresh' while restoring state."); + } + + Bundle bundle = (Bundle) savedState; + if (bundle.getClassLoader() == null) { + /* TODO(b/133752041): pass the class loader from {@link ViewPager2.SavedState } */ + bundle.setClassLoader(getClass().getClassLoader()); + } + + for (String key : bundle.keySet()) { + if (isValidKey(key, KEY_PREFIX_FRAGMENT)) { + long itemId = parseIdFromKey(key, KEY_PREFIX_FRAGMENT); + Fragment fragment = mFragmentManager.getFragment(bundle, key); + mFragments.put(itemId, fragment); + continue; + } + + if (isValidKey(key, KEY_PREFIX_STATE)) { + long itemId = parseIdFromKey(key, KEY_PREFIX_STATE); + Fragment.SavedState state = bundle.getParcelable(key); + if (containsItem(itemId)) { + mSavedStates.put(itemId, state); + } + continue; + } + + throw new IllegalArgumentException("Unexpected key in savedState: " + key); + } + + if (!mFragments.isEmpty()) { + mHasStaleFragments = true; + mIsInGracePeriod = true; + gcFragments(); + scheduleGracePeriodEnd(); + } + } + + private void scheduleGracePeriodEnd() { + final Handler handler = new Handler(Looper.getMainLooper()); + final Runnable runnable = + new Runnable() { + @Override + public void run() { + mIsInGracePeriod = false; + gcFragments(); // good opportunity to GC + } + }; + + mLifecycle.addObserver( + new LifecycleEventObserver() { + @Override + public void onStateChanged( + @NonNull LifecycleOwner source, @NonNull Lifecycle.Event event) { + if (event == Lifecycle.Event.ON_DESTROY) { + handler.removeCallbacks(runnable); + source.getLifecycle().removeObserver(this); + } + } + }); + + handler.postDelayed(runnable, GRACE_WINDOW_TIME_MS); + } + + // Helper function for dealing with save / restore state + private static @NonNull String createKey(@NonNull String prefix, long id) { + return prefix + id; + } + + // Helper function for dealing with save / restore state + private static boolean isValidKey(@NonNull String key, @NonNull String prefix) { + return key.startsWith(prefix) && key.length() > prefix.length(); + } + + // Helper function for dealing with save / restore state + private static long parseIdFromKey(@NonNull String key, @NonNull String prefix) { + return Long.parseLong(key.substring(prefix.length())); + } + + /** + * Pauses (STARTED) all Fragments that are attached and not a primary item. Keeps primary item + * Fragment RESUMED. + */ + class FragmentMaxLifecycleEnforcer { + private ViewPager2.OnPageChangeCallback mPageChangeCallback; + private RecyclerView.AdapterDataObserver mDataObserver; + private LifecycleEventObserver mLifecycleObserver; + private ViewPager2 mViewPager; + + private long mPrimaryItemId = NO_ID; + + void register(@NonNull RecyclerView recyclerView) { + mViewPager = inferViewPager(recyclerView); + + // signal 1 of 3: current item has changed + mPageChangeCallback = + new ViewPager2.OnPageChangeCallback() { + @Override + public void onPageScrollStateChanged(int state) { + updateFragmentMaxLifecycle(false); + } + + @Override + public void onPageSelected(int position) { + updateFragmentMaxLifecycle(false); + } + }; + mViewPager.registerOnPageChangeCallback(mPageChangeCallback); + + // signal 2 of 3: underlying data-set has been updated + mDataObserver = + new DataSetChangeObserver() { + @Override + public void onChanged() { + updateFragmentMaxLifecycle(true); + } + }; + registerAdapterDataObserver(mDataObserver); + + // signal 3 of 3: we may have to catch-up after being in a lifecycle state that + // prevented us to perform transactions + mLifecycleObserver = + new LifecycleEventObserver() { + @Override + public void onStateChanged( + @NonNull LifecycleOwner source, @NonNull Lifecycle.Event event) { + updateFragmentMaxLifecycle(false); + } + }; + mLifecycle.addObserver(mLifecycleObserver); + } + + void unregister(@NonNull RecyclerView recyclerView) { + ViewPager2 viewPager = inferViewPager(recyclerView); + viewPager.unregisterOnPageChangeCallback(mPageChangeCallback); + unregisterAdapterDataObserver(mDataObserver); + mLifecycle.removeObserver(mLifecycleObserver); + mViewPager = null; + } + + void updateFragmentMaxLifecycle(boolean dataSetChanged) { + if (shouldDelayFragmentTransactions()) { + return; /* recovery step via {@link #mLifecycleObserver} */ + } + + if (mViewPager.getScrollState() != ViewPager2.SCROLL_STATE_IDLE) { + return; // do not update while not idle to avoid jitter + } + + if (mFragments.isEmpty() || getItemCount() == 0) { + return; // nothing to do + } + + final int currentItem = mViewPager.getCurrentItem(); + if (currentItem >= getItemCount()) { + /* current item is yet to be updated; it is guaranteed to change, so we will be + * notified via {@link ViewPager2.OnPageChangeCallback#onPageSelected(int)} */ + return; + } + + long currentItemId = getItemId(currentItem); + if (currentItemId == mPrimaryItemId && !dataSetChanged) { + return; // nothing to do + } + + Fragment currentItemFragment = mFragments.get(currentItemId); + if (currentItemFragment == null || !currentItemFragment.isAdded()) { + return; + } + + mPrimaryItemId = currentItemId; + FragmentTransaction transaction = mFragmentManager.beginTransaction(); + + Fragment toResume = null; + List> onPost = new ArrayList<>(); + for (int ix = 0; ix < mFragments.size(); ix++) { + long itemId = mFragments.keyAt(ix); + Fragment fragment = mFragments.valueAt(ix); + + if (!fragment.isAdded()) { + continue; + } + + if (itemId != mPrimaryItemId) { + transaction.setMaxLifecycle(fragment, STARTED); + onPost.add( + mFragmentEventDispatcher.dispatchMaxLifecyclePreUpdated( + fragment, STARTED)); + } else { + toResume = fragment; // itemId map key, so only one can match the predicate + } + + fragment.setMenuVisibility(itemId == mPrimaryItemId); + } + if (toResume != null) { // in case the Fragment wasn't added yet + transaction.setMaxLifecycle(toResume, RESUMED); + onPost.add( + mFragmentEventDispatcher.dispatchMaxLifecyclePreUpdated(toResume, RESUMED)); + } + + if (!transaction.isEmpty()) { + transaction.commitNow(); + Collections.reverse(onPost); // to assure 'nesting' of events + for (List event : onPost) { + mFragmentEventDispatcher.dispatchPostEvents(event); + } + } + } + + @NonNull + private ViewPager2 inferViewPager(@NonNull RecyclerView recyclerView) { + ViewParent parent = recyclerView.getParent(); + if (parent instanceof ViewPager2) { + return (ViewPager2) parent; + } + throw new IllegalStateException("Expected TabPager instance. Got: " + parent); + } + } + + /** + * Simplified {@link RecyclerView.AdapterDataObserver} for clients interested in any data-set + * changes regardless of their nature. + */ + private abstract static class DataSetChangeObserver extends RecyclerView.AdapterDataObserver { + @Override + public abstract void onChanged(); + + @Override + public final void onItemRangeChanged(int positionStart, int itemCount) { + onChanged(); + } + + @Override + public final void onItemRangeChanged( + int positionStart, int itemCount, @Nullable Object payload) { + onChanged(); + } + + @Override + public final void onItemRangeInserted(int positionStart, int itemCount) { + onChanged(); + } + + @Override + public final void onItemRangeRemoved(int positionStart, int itemCount) { + onChanged(); + } + + @Override + public final void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { + onChanged(); + } + } + + @SuppressWarnings("WeakerAccess") // to avoid creation of a synthetic accessor + static class FragmentEventDispatcher { + private List mCallbacks = new CopyOnWriteArrayList<>(); + + public void registerCallback(FragmentTransactionCallback callback) { + mCallbacks.add(callback); + } + + public void unregisterCallback(FragmentTransactionCallback callback) { + mCallbacks.remove(callback); + } + + public List dispatchMaxLifecyclePreUpdated( + Fragment fragment, Lifecycle.State maxState) { + List result = new ArrayList<>(); + for (FragmentTransactionCallback callback : mCallbacks) { + result.add(callback.onFragmentMaxLifecyclePreUpdated(fragment, maxState)); + } + return result; + } + + public void dispatchPostEvents( + List entries) { + for (FragmentTransactionCallback.OnPostEventListener entry : entries) { + entry.onPost(); + } + } + + public List dispatchPreAdded( + Fragment fragment) { + List result = new ArrayList<>(); + for (FragmentTransactionCallback callback : mCallbacks) { + result.add(callback.onFragmentPreAdded(fragment)); + } + return result; + } + + @OptIn(markerClass = ExperimentalFragmentStateAdapterApi.class) + public List dispatchPreSavedInstanceState( + Fragment fragment) { + List result = new ArrayList<>(); + for (FragmentTransactionCallback callback : mCallbacks) { + result.add(callback.onFragmentPreSavedInstanceState(fragment)); + } + return result; + } + + public List dispatchPreRemoved( + Fragment fragment) { + List result = new ArrayList<>(); + for (FragmentTransactionCallback callback : mCallbacks) { + result.add(callback.onFragmentPreRemoved(fragment)); + } + return result; + } + } + + /** + * Callback interface for listening to fragment lifecycle changes that happen inside the + * adapter. + */ + public abstract static class FragmentTransactionCallback { + private static final @NonNull OnPostEventListener NO_OP = + new OnPostEventListener() { + @Override + public void onPost() { + // do nothing + } + }; + + /** + * Called right before the Fragment is added to adapter's FragmentManager. + * + * @param fragment Fragment changing state + * @return Listener called after the operation + */ + @NonNull + public OnPostEventListener onFragmentPreAdded(@NonNull Fragment fragment) { + return NO_OP; + } + + /** + * Called right before Fragment's state is being saved through a {@link + * FragmentManager#saveFragmentInstanceState} call. + * + * @param fragment Fragment which state is being saved + * @return Listener called after the operation + */ + @NonNull + @ExperimentalFragmentStateAdapterApi // Experimental in v1.1.*. To become stable in v1.2.*. + public OnPostEventListener onFragmentPreSavedInstanceState(@NonNull Fragment fragment) { + return NO_OP; + } + + /** + * Called right before the Fragment is removed from adapter's FragmentManager. + * + * @param fragment Fragment changing state + * @return Listener called after the operation + */ + @NonNull + public OnPostEventListener onFragmentPreRemoved(@NonNull Fragment fragment) { + return NO_OP; + } + + /** + * Called right before Fragment's maximum state is capped via {@link + * FragmentTransaction#setMaxLifecycle}. + * + * @param fragment Fragment to have its state capped + * @param maxLifecycleState Ceiling state for the fragment + * @return Listener called after the operation + */ + @NonNull + public OnPostEventListener onFragmentMaxLifecyclePreUpdated( + @NonNull Fragment fragment, @NonNull Lifecycle.State maxLifecycleState) { + return NO_OP; + } + + /** + * Callback returned by {@link #onFragmentPreAdded}, {@link #onFragmentPreRemoved}, {@link + * #onFragmentMaxLifecyclePreUpdated} called after the operation ends. + */ + public interface OnPostEventListener { + /** Called after the operation is ends. */ + void onPost(); + } + } + + /** + * Registers a {@link FragmentTransactionCallback} to listen to fragment lifecycle changes that + * happen inside the adapter. + * + * @param callback Callback to register + */ + public void registerFragmentTransactionCallback(@NonNull FragmentTransactionCallback callback) { + mFragmentEventDispatcher.registerCallback(callback); + } + + /** + * Unregisters a {@link FragmentTransactionCallback}. + * + * @param callback Callback to unregister + * @see #registerFragmentTransactionCallback + */ + public void unregisterFragmentTransactionCallback( + @NonNull FragmentTransactionCallback callback) { + mFragmentEventDispatcher.unregisterCallback(callback); + } + + @RequiresOptIn(level = RequiresOptIn.Level.WARNING) + public @interface ExperimentalFragmentStateAdapterApi {} +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/tabs/adapter/FragmentViewHolder.kt b/app/src/main/java/com/duckduckgo/app/browser/tabs/adapter/FragmentViewHolder.kt new file mode 100644 index 000000000000..f3b140e59ffa --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/tabs/adapter/FragmentViewHolder.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2018 The Android Open Source Project + * + * 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.tabs.adapter + +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.recyclerview.widget.RecyclerView + +/** + * [ViewHolder] implementation for handling [Fragment]s. Used in [FragmentStateAdapter]. + */ +class FragmentViewHolder private constructor(container: FrameLayout) : + RecyclerView.ViewHolder(container) { + val container: FrameLayout + get() = itemView as FrameLayout + + companion object { + @JvmStatic + fun create(parent: ViewGroup): FragmentViewHolder { + val container = FrameLayout(parent.context) + container.setLayoutParams( + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ), + ) + container.setId(View.generateViewId()) + container.isSaveEnabled = false + return FragmentViewHolder(container) + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/tabs/adapter/TabPagerAdapter.kt b/app/src/main/java/com/duckduckgo/app/browser/tabs/adapter/TabPagerAdapter.kt new file mode 100644 index 000000000000..485bae7f8618 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/tabs/adapter/TabPagerAdapter.kt @@ -0,0 +1,103 @@ +/* + * 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.tabs.adapter + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Message +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.DiffUtil +import com.duckduckgo.app.browser.BrowserActivity +import com.duckduckgo.app.browser.BrowserTabFragment +import com.duckduckgo.app.tabs.model.TabEntity + +class TabPagerAdapter( + lifecycleOwner: LifecycleOwner, + private val fragmentManager: FragmentManager, + private val activityIntent: Intent?, + private val getTabById: (String) -> TabEntity?, + private val requestAndWaitForNewTab: () -> TabEntity, + private val getSelectedTabId: () -> String?, +) : FragmentStateAdapter(fragmentManager, lifecycleOwner.lifecycle) { + private val tabIds = mutableListOf() + private var messageForNewFragment: Message? = null + + override fun getItemCount() = tabIds.size + + override fun getItemId(position: Int) = tabIds[position].hashCode().toLong() + + override fun containsItem(itemId: Long) = tabIds.any { it.hashCode().toLong() == itemId } + + val currentFragment: BrowserTabFragment? + get() = fragmentManager.fragments + .filterIsInstance() + .firstOrNull { it.tabId == getSelectedTabId() } + + override fun createFragment(position: Int): Fragment { + val tab = getTabById(tabIds[position]) ?: requestAndWaitForNewTab() + val isExternal = activityIntent?.getBooleanExtra(BrowserActivity.LAUNCH_FROM_EXTERNAL_EXTRA, false) == true + + return if (messageForNewFragment != null) { + val message = messageForNewFragment + messageForNewFragment = null + return BrowserTabFragment.newInstance(tab.tabId, null, false, isExternal).apply { + this.messageFromPreviousTab = message + } + } else { + BrowserTabFragment.newInstance(tab.tabId, tab.url, tab.skipHome, isExternal) + } + } + + fun setMessageForNewFragment(message: Message) { + messageForNewFragment = message + } + + @SuppressLint("NotifyDataSetChanged") + fun onTabsUpdated(newTabs: List) { + val diff = DiffUtil.calculateDiff(PagerDiffUtil(tabIds, newTabs)) + diff.dispatchUpdatesTo(this) + tabIds.clear() + tabIds.addAll(newTabs) + } + + fun getTabIdAtPosition(position: Int): String? { + return if (position < tabIds.size) { + tabIds[position] + } else { + null + } + } + + inner class PagerDiffUtil( + private val oldList: List, + private val newList: List, + ) : DiffUtil.Callback() { + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return oldList[oldItemPosition] == newList[newItemPosition] + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return areItemsTheSame(oldItemPosition, newItemPosition) + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/store/UserStageDao.kt b/app/src/main/java/com/duckduckgo/app/onboarding/store/UserStageDao.kt index 4a4a33d8c681..bbefaf7c29ab 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/store/UserStageDao.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/store/UserStageDao.kt @@ -28,6 +28,9 @@ interface UserStageDao { @Query("select * from $USER_STAGE_TABLE_NAME limit 1") suspend fun currentUserAppStage(): UserStage? + @Query("select * from $USER_STAGE_TABLE_NAME limit 1") + fun currentAppStage(): Flow + @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(userStage: UserStage) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/store/UserStageStore.kt b/app/src/main/java/com/duckduckgo/app/onboarding/store/UserStageStore.kt index d27ff1780d52..c37f1337143f 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/store/UserStageStore.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/store/UserStageStore.kt @@ -27,6 +27,7 @@ interface UserStageStore { suspend fun getUserAppStage(): AppStage suspend fun stageCompleted(appStage: AppStage): AppStage suspend fun moveToStage(appStage: AppStage) + val currentAppStage: Flow } class AppUserStageStore @Inject constructor( @@ -64,6 +65,8 @@ class AppUserStageStore @Inject constructor( override suspend fun moveToStage(appStage: AppStage) { userStageDao.updateUserStage(appStage) } + + override val currentAppStage: Flow = userStageDao.currentAppStage().map { it.appStage } } suspend fun UserStageStore.isNewUser(): Boolean { 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 cacc84640075..a87d089b2f68 100644 --- a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt +++ b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt @@ -355,6 +355,9 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName { TAB_MANAGER_OPENED_FROM_SITE("m_tab_manager_open_from_website"), TAB_MANAGER_OPENED_FROM_NEW_TAB("m_tab_manager_open_from_newtabpage"), + SWIPE_TABS_USED("m_swipe_tabs_used"), + SWIPE_TABS_USED_DAILY("m_swipe_tabs_used_daily"), + DUCK_PLAYER_SETTING_ALWAYS_OVERLAY_YOUTUBE("duckplayer_setting_always_overlay_youtube"), DUCK_PLAYER_SETTING_ALWAYS_SERP("duckplayer_setting_always_overlay_serp"), DUCK_PLAYER_SETTING_NEVER_SERP("duckplayer_setting_never_overlay_serp"), diff --git a/app/src/main/java/com/duckduckgo/app/tabs/db/TabsDao.kt b/app/src/main/java/com/duckduckgo/app/tabs/db/TabsDao.kt index 09cb34c742eb..ddcf29ed85ce 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/db/TabsDao.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/db/TabsDao.kt @@ -39,6 +39,9 @@ abstract class TabsDao { @Query("select * from tabs inner join tab_selection on tabs.tabId = tab_selection.tabId order by position limit 1") abstract fun liveSelectedTab(): LiveData + @Query("select * from tabs inner join tab_selection on tabs.tabId = tab_selection.tabId order by position limit 1") + abstract fun flowSelectedTab(): Flow + @Query("select * from tabs where deletable is 0 order by position") abstract fun tabs(): List diff --git a/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt b/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt index 06e8cce5c1ad..110c2745a93e 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt @@ -63,7 +63,7 @@ class TabDataRepository @Inject constructor( override val liveTabs: LiveData> = tabsDao.liveTabs().distinctUntilChanged() - override val flowTabs: Flow> = tabsDao.flowTabs() + override val flowTabs: Flow> = tabsDao.flowTabs().distinctUntilChanged() private val childTabClosedSharedFlow = MutableSharedFlow() @@ -77,6 +77,8 @@ class TabDataRepository @Inject constructor( override val liveSelectedTab: LiveData = tabsDao.liveSelectedTab() + override val flowSelectedTab: Flow = tabsDao.flowSelectedTab().distinctUntilChanged() + override val tabSwitcherData: Flow = tabSwitcherDataStore.data private val siteData: LinkedHashMap> = LinkedHashMap() @@ -331,6 +333,10 @@ class TabDataRepository @Inject constructor( } } + override suspend fun getTab(tabId: String): TabEntity? { + return withContext(dispatchers.io()) { tabsDao.tab(tabId) } + } + override fun updateTabFavicon( tabId: String, fileName: String?, diff --git a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModel.kt b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModel.kt index 6121400e4375..8c22542abf37 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModel.kt @@ -22,6 +22,7 @@ import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import com.duckduckgo.adclick.api.AdClickManager import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.app.browser.SwipingTabsFeatureProvider import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.statistics.pixels.Pixel @@ -47,6 +48,7 @@ class TabSwitcherViewModel @Inject constructor( private val adClickManager: AdClickManager, private val dispatcherProvider: DispatcherProvider, private val pixel: Pixel, + private val swipingTabsFeature: SwipingTabsFeatureProvider, ) : ViewModel() { val tabs: LiveData> = tabRepository.liveTabs val activeTab = tabRepository.liveSelectedTab @@ -66,7 +68,17 @@ class TabSwitcherViewModel @Inject constructor( } suspend fun onNewTabRequested(fromOverflowMenu: Boolean) { - tabRepository.add() + if (swipingTabsFeature.isEnabled) { + val emptyTab = tabs.value?.firstOrNull { it.url.isNullOrBlank() }?.tabId + if (emptyTab != null) { + tabRepository.select(tabId = emptyTab) + } else { + tabRepository.add() + } + } else { + tabRepository.add() + } + command.value = Command.Close if (fromOverflowMenu) { pixel.fire(AppPixelName.TAB_MANAGER_MENU_NEW_TAB_PRESSED) diff --git a/app/src/main/res/layout/activity_browser.xml b/app/src/main/res/layout/activity_browser.xml index 49022e924971..13f7a7d20271 100644 --- a/app/src/main/res/layout/activity_browser.xml +++ b/app/src/main/res/layout/activity_browser.xml @@ -34,6 +34,12 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + + ()).whenever(mockAppEnjoymentPromptEmitter).promptType configureSkipUrlConversionInNewTabState(enabled = true) + swipingTabsFeature.self().setRawStoredState(State(enable = false)) whenever(mockDefaultBrowserPromptsExperiment.commands).thenReturn(defaultBrowserPromptsExperimentCommandsFlow.receiveAsFlow()) @@ -142,7 +149,7 @@ class BrowserViewModelTest { val url = "http://example.com" whenever(mockOmnibarEntryConverter.convertQueryToUrl(url)).thenReturn(url) whenever(mockTabRepository.liveSelectedTab).doReturn(MutableLiveData()) - testee.onOpenInNewTabRequested(url) + testee.onOpenInNewTabRequested(url, null, false) verify(mockTabRepository).add(url = url, skipHome = false) } @@ -151,19 +158,19 @@ class BrowserViewModelTest { val url = "http://example.com" whenever(mockOmnibarEntryConverter.convertQueryToUrl(url)).thenReturn(url) whenever(mockTabRepository.liveSelectedTab).doReturn(MutableLiveData()) - testee.onOpenInNewTabRequested(url, sourceTabId = "tabId") + testee.onOpenInNewTabRequested(url, sourceTabId = "tabId", skipHome = false) verify(mockTabRepository).addFromSourceTab(url = url, skipHome = false, sourceTabId = "tabId") } @Test fun whenTabsUpdatedAndNoTabsThenDefaultTabAddedToRepository() = runTest { - testee.onTabsUpdated(ArrayList()) + testee.onTabsUpdated(listOf()) verify(mockTabRepository).addDefaultTab() } @Test fun whenTabsUpdatedWithTabsThenNewTabNotLaunched() = runTest { - testee.onTabsUpdated(listOf(TabEntity(TAB_ID, "", "", skipHome = false, viewed = true, position = 0))) + testee.onTabsUpdated(listOf(TabEntity("123"))) verify(mockCommandObserver, never()).onChanged(any()) } @@ -202,6 +209,13 @@ class BrowserViewModelTest { verify(mockPixel).fire(AppPixelName.SHORTCUT_OPENED) } + @Test + fun whenTabsSwipedThenFireSwipingUsedPixels() = runTest { + testee.onTabsSwiped() + verify(mockPixel).fire(AppPixelName.SWIPE_TABS_USED) + verify(mockPixel).fire(AppPixelName.SWIPE_TABS_USED_DAILY, type = Daily()) + } + @Test fun whenOpenFavoriteThenSelectByUrlOrNewTab() = runTest { val url = "example.com" @@ -247,26 +261,46 @@ class BrowserViewModelTest { } @Test - fun whenOnBookmarksActivityResultCalledThenOpenSavedSiteCommandTriggered() { + fun whenOnBookmarksActivityResultCalledAndSiteAlreadyOpenedThenSwitchToTabCommandTriggered() = runTest { + swipingTabsFeature.self().setRawStoredState(State(enable = true)) + val bookmarkUrl = "https://www.example.com" + val tab = TabEntity("123", url = bookmarkUrl) + + whenever(mockTabRepository.flowTabs).thenReturn(flowOf(listOf(tab))) testee.onBookmarksActivityResult(bookmarkUrl) verify(mockCommandObserver).onChanged(commandCaptor.capture()) - assertEquals(Command.OpenSavedSite(bookmarkUrl), commandCaptor.lastValue) + assertEquals(Command.SwitchToTab(tab.tabId), commandCaptor.lastValue) + } + + @Test + fun whenOnBookmarksActivityResultCalledAndSiteNotOpenedThenOpenInNewTabCommandTriggered() = runTest { + swipingTabsFeature.self().setRawStoredState(State(enable = true)) + + val bookmarkUrl = "https://www.example.com" + val tab = TabEntity("123", url = "https://cnn.com") + + whenever(mockTabRepository.flowTabs).thenReturn(flowOf(listOf(tab))) + + testee.onBookmarksActivityResult(bookmarkUrl) + + verify(mockCommandObserver).onChanged(commandCaptor.capture()) + assertEquals(Command.OpenInNewTab(bookmarkUrl), commandCaptor.lastValue) } @Test fun whenOpenInNewTabWithSkipUrlConversionEnabledThenQueryNotConverted() = runTest { configureSkipUrlConversionInNewTabState(enabled = true) - testee.onOpenInNewTabRequested(query = "query") + testee.onOpenInNewTabRequested(query = "query", sourceTabId = null, skipHome = false) verify(mockOmnibarEntryConverter, never()).convertQueryToUrl("query") } @Test fun whenOpenInNewTabWithSkipUrlConversionDisabledThenQueryConverted() = runTest { configureSkipUrlConversionInNewTabState(enabled = false) - testee.onOpenInNewTabRequested(query = "query") + testee.onOpenInNewTabRequested(query = "query", sourceTabId = null, skipHome = false) verify(mockOmnibarEntryConverter).convertQueryToUrl("query") } @@ -390,6 +424,24 @@ class BrowserViewModelTest { verify(mockDefaultBrowserPromptsExperiment).onSystemDefaultAppsActivityClosed() } + @Test + fun whenOmnibarIsInEditModeTabSwipingIsDisabled() { + swipingTabsFeature.self().setRawStoredState(State(enable = true)) + + val isInEditMode = true + testee.onOmnibarEditModeChanged(isInEditMode) + assertEquals(!isInEditMode, testee.viewState.value!!.isTabSwipingEnabled) + } + + @Test + fun whenOmnibarIsInNotEditModeTabSwipingIsEnabled() { + swipingTabsFeature.self().setRawStoredState(State(enable = true)) + + val isInEditMode = false + testee.onOmnibarEditModeChanged(isInEditMode) + assertEquals(!isInEditMode, testee.viewState.value!!.isTabSwipingEnabled) + } + private fun initTestee() { testee = BrowserViewModel( tabRepository = mockTabRepository, @@ -404,6 +456,7 @@ class BrowserViewModelTest { showOnAppLaunchFeature = fakeShowOnAppLaunchFeatureToggle, showOnAppLaunchOptionHandler = showOnAppLaunchOptionHandler, defaultBrowserPromptsExperiment = mockDefaultBrowserPromptsExperiment, + swipingTabsFeature = swipingTabsFeatureProvider, ) } diff --git a/app/src/test/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModelTest.kt index 4d0bf716d9e8..9bfc352d5e1c 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModelTest.kt @@ -74,31 +74,18 @@ class OmnibarLayoutViewModelTest { @Before fun before() { whenever(defaultBrowserPromptsExperiment.highlightPopupMenu).thenReturn(defaultBrowserPromptsExperimentHighlightOverflowMenuFlow) - - testee = OmnibarLayoutViewModel( - tabRepository = tabRepository, - voiceSearchAvailability = voiceSearchAvailability, - voiceSearchPixelLogger = voiceSearchPixelLogger, - duckDuckGoUrlDetector = duckDuckGoUrlDetector, - duckPlayer = duckPlayer, - pixel = pixel, - userBrowserProperties = userBrowserProperties, - dispatcherProvider = coroutineTestRule.testDispatcherProvider, - defaultBrowserPromptsExperiment = defaultBrowserPromptsExperiment, - ) - whenever(tabRepository.flowTabs).thenReturn(flowOf(emptyList())) whenever(voiceSearchAvailability.shouldShowVoiceSearch(any(), any(), any(), any())).thenReturn(true) whenever(duckPlayer.isDuckPlayerUri(DUCK_PLAYER_URL)).thenReturn(true) + + initializeViewModel() } @Test fun whenViewModelAttachedAndNoTabsOpenThenTabsRetrieved() = runTest { - testee.onAttachedToWindow() - testee.viewState.test { val viewState = awaitItem() - assertTrue(viewState.tabs.isEmpty()) + assertTrue(viewState.tabCount == 0) } } @@ -106,20 +93,34 @@ class OmnibarLayoutViewModelTest { fun whenViewModelAttachedAndTabsOpenedThenTabsRetrieved() = runTest { whenever(tabRepository.flowTabs).thenReturn(flowOf(listOf(TabEntity(tabId = "0", position = 0)))) - testee.onAttachedToWindow() + initializeViewModel() testee.viewState.test { val viewState = awaitItem() - assertTrue(viewState.tabs.size == 1) + assertTrue(viewState.tabCount == 1) assertTrue(viewState.shouldUpdateTabsCount) } } + private fun initializeViewModel() { + testee = OmnibarLayoutViewModel( + tabRepository = tabRepository, + voiceSearchAvailability = voiceSearchAvailability, + voiceSearchPixelLogger = voiceSearchPixelLogger, + duckDuckGoUrlDetector = duckDuckGoUrlDetector, + duckPlayer = duckPlayer, + pixel = pixel, + userBrowserProperties = userBrowserProperties, + dispatcherProvider = coroutineTestRule.testDispatcherProvider, + defaultBrowserPromptsExperiment = defaultBrowserPromptsExperiment, + ) + } + @Test fun whenViewModelAttachedAndVoiceSearchSupportedThenPixelLogged() = runTest { whenever(voiceSearchAvailability.isVoiceSearchSupported).thenReturn(true) - testee.onAttachedToWindow() + initializeViewModel() verify(voiceSearchPixelLogger).log() } @@ -128,8 +129,6 @@ class OmnibarLayoutViewModelTest { fun whenViewModelAttachedAndVoiceSearchNotSupportedThenPixelLogged() = runTest { whenever(voiceSearchAvailability.isVoiceSearchSupported).thenReturn(false) - testee.onAttachedToWindow() - verifyNoInteractions(voiceSearchPixelLogger) } diff --git a/app/src/test/java/com/duckduckgo/app/browser/tabs/DefaultTabManagerTest.kt b/app/src/test/java/com/duckduckgo/app/browser/tabs/DefaultTabManagerTest.kt new file mode 100644 index 000000000000..37dc1406e8c1 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/browser/tabs/DefaultTabManagerTest.kt @@ -0,0 +1,167 @@ +package com.duckduckgo.app.browser.tabs + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.browser.SkipUrlConversionOnNewTabFeature +import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter +import com.duckduckgo.app.tabs.model.TabEntity +import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class DefaultTabManagerTest { + + @get:Rule + val coroutineTestRule = CoroutineTestRule() + + private val tabRepository: TabRepository = mock() + private val omnibarEntryConverter: OmnibarEntryConverter = mock() + private val skipUrlConversionOnNewTabFeature = FakeFeatureToggleFactory.create(SkipUrlConversionOnNewTabFeature::class.java) + + private lateinit var testee: DefaultTabManager + + @Before + fun setup() { + skipUrlConversionOnNewTabFeature.self().setRawStoredState(State(enable = false)) + + testee = DefaultTabManager( + tabRepository = tabRepository, + dispatchers = coroutineTestRule.testDispatcherProvider, + queryUrlConverter = omnibarEntryConverter, + skipUrlConversionOnNewTabFeature = skipUrlConversionOnNewTabFeature, + ) + + testee.registerCallbacks({}) + } + + @Test + fun whenOnSelectedTabChangedThenSelectedTabIdIsUpdated() { + val tabId = "tabId" + testee.onSelectedTabChanged(tabId) + + assertEquals(tabId, testee.getSelectedTabId()) + } + + @Test + fun whenOnTabsChangedThenOnTabsUpdatedCalledWithNewTabs() = runTest { + val tabId = "tabId" + val tabId2 = "tabId2" + val tabs = listOf(tabId, tabId2) + val onTabsUpdated: (List) -> Unit = mock() + + testee.registerCallbacks(onTabsUpdated) + testee.onSelectedTabChanged(tabId) + testee.onTabsChanged(tabs) + + verify(onTabsUpdated).invoke(tabs) + } + + @Test + fun whenOnTabsChangedAndNoTabsThenAddDefaultTabCalled() = runTest { + testee.onTabsChanged(emptyList()) + + verify(tabRepository).addDefaultTab() + } + + @Test + fun whenRequestAndWaitForNewTabThenReturnNewTabEntity() = runTest { + val tabId = "tabId" + val tabEntity = TabEntity(tabId = tabId, position = 0) + whenever(tabRepository.flowTabs).thenReturn(flowOf(listOf(tabEntity, tabEntity.copy(tabId = "tabId2")))) + whenever(tabRepository.add()).thenReturn(tabId) + + val result = testee.requestAndWaitForNewTab() + + assertEquals(tabEntity, result) + } + + @Test + fun whenSwitchToTabThenSelectTabCalled() = runTest { + val tabId = "tabId" + val tabEntity = TabEntity(tabId = tabId, position = 0) + whenever(tabRepository.getSelectedTab()).thenReturn(null) + whenever(tabRepository.getTab(tabId)).thenReturn(tabEntity) + + testee.switchToTab(tabId) + + verify(tabRepository).select(tabId) + } + + @Test + fun whenSwitchToSameTabThenSelectTabNotCalled() = runTest { + val tabId = "tabId" + val tabEntity = TabEntity(tabId = tabId, position = 0) + whenever(tabRepository.getSelectedTab()).thenReturn(tabEntity) + whenever(tabRepository.getTab(tabId)).thenReturn(tabEntity) + + testee.switchToTab(tabId) + + verify(tabRepository, never()).select(tabId) + } + + @Test + fun whenOpenNewTabWithParametersQueryThenAddTabWithAppropriateParameters() = runTest { + val query = "query" + val url = "http://example.com" + whenever(omnibarEntryConverter.convertQueryToUrl(query)).thenReturn(url) + + testee.openNewTab() + + verify(tabRepository).add() + + testee.openNewTab(query) + + verify(tabRepository).add(url = url, skipHome = false) + + testee.openNewTab(query, skipHome = true) + + verify(tabRepository).add(url = url, skipHome = true) + } + + @Test + fun whenOpenNewTabWithSourceTabThenAddTabFromSource() = runTest { + val sourceTabId = "sourceTabId" + + testee.openNewTab(sourceTabId = sourceTabId) + + verify(tabRepository).addFromSourceTab(sourceTabId = sourceTabId, skipHome = false) + + testee.openNewTab(sourceTabId = sourceTabId, skipHome = true) + + verify(tabRepository).addFromSourceTab(sourceTabId = sourceTabId, skipHome = true) + } + + @Test + fun whenGetTabByIdThenReturnTabEntity() = runTest { + val tabId = "tabId" + val tabEntity = TabEntity(tabId = tabId, position = 0) + whenever(tabRepository.getTab(tabId)).thenReturn(tabEntity) + + val result = testee.getTabById(tabId) + + assertEquals(tabEntity, result) + } + + @Test + fun whenOpenNewTabWithSkipUrlConversionFeatureEnabledThenQueryNotConverted() = runTest { + val query = "query" + skipUrlConversionOnNewTabFeature.self().setRawStoredState(State(enable = true)) + + testee.openNewTab(query) + + verify(tabRepository).add(url = query, skipHome = false) + verify(omnibarEntryConverter, never()).convertQueryToUrl(query) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandlerImplTest.kt b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandlerImplTest.kt index 0067de644847..9070e673141d 100644 --- a/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandlerImplTest.kt +++ b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandlerImplTest.kt @@ -754,9 +754,15 @@ class ShowOnAppLaunchOptionHandlerImplTest { get() = TODO("Not yet implemented") override val liveSelectedTab: LiveData get() = TODO("Not yet implemented") + override val flowSelectedTab: Flow + get() = TODO("Not yet implemented") override val tabSwitcherData: Flow get() = TODO("Not yet implemented") + override suspend fun getTab(tabId: String): TabEntity? { + TODO("Not yet implemented") + } + override suspend fun addDefaultTab(): String { TODO("Not yet implemented") } diff --git a/app/src/test/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModelTest.kt index af368d013686..0f36c452f9b4 100644 --- a/app/src/test/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModelTest.kt @@ -22,6 +22,8 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import com.duckduckgo.adclick.api.AdClickManager +import com.duckduckgo.app.browser.SwipingTabsFeature +import com.duckduckgo.app.browser.SwipingTabsFeatureProvider import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.statistics.pixels.Pixel @@ -36,6 +38,8 @@ import com.duckduckgo.app.tabs.model.TabSwitcherData.UserState.EXISTING import com.duckduckgo.app.tabs.model.TabSwitcherData.UserState.NEW import com.duckduckgo.app.tabs.ui.TabSwitcherViewModel.Command import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collect @@ -87,6 +91,10 @@ class TabSwitcherViewModelTest { @Mock private lateinit var statisticsDataStore: StatisticsDataStore + private val swipingTabsFeature = FakeFeatureToggleFactory.create(SwipingTabsFeature::class.java) + + private val swipingTabsFeatureProvider = SwipingTabsFeatureProvider(swipingTabsFeature) + private lateinit var testee: TabSwitcherViewModel private val repoDeletableTabs = Channel>() @@ -99,6 +107,8 @@ class TabSwitcherViewModelTest { fun before() { MockitoAnnotations.openMocks(this) + swipingTabsFeature.self().setRawStoredState(State(enable = true)) + whenever(mockTabRepository.flowDeletableTabs) .thenReturn(repoDeletableTabs.consumeAsFlow()) whenever(mockTabRepository.liveTabs) @@ -120,6 +130,7 @@ class TabSwitcherViewModelTest { mockAdClickManager, coroutinesTestRule.testDispatcherProvider, mockPixel, + swipingTabsFeatureProvider, ) testee.command.observeForever(mockCommandObserver) } diff --git a/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt b/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt index 41b0eff8d37e..310c1f92f5d8 100644 --- a/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt +++ b/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt @@ -41,6 +41,8 @@ interface TabRepository { val liveSelectedTab: LiveData + val flowSelectedTab: Flow + val tabSwitcherData: Flow /** @@ -99,6 +101,8 @@ interface TabRepository { suspend fun select(tabId: String) + suspend fun getTab(tabId: String): TabEntity? + fun updateTabPreviewImage( tabId: String, fileName: String?, diff --git a/code-formatting.gradle b/code-formatting.gradle index 09c4dad5f3a9..6e4fdbc1a6d8 100644 --- a/code-formatting.gradle +++ b/code-formatting.gradle @@ -5,6 +5,7 @@ spotless { java { target 'src/*/java/**/*.java' googleJavaFormat('1.22.0').aosp() + targetExclude("**/FragmentStateAdapter.java") // temporary removeUnusedImports() trimTrailingWhitespace() indentWithSpaces() diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/TypeAnimationTextView.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/TypeAnimationTextView.kt index b5e9c3bc124a..d49fec3979ab 100644 --- a/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/TypeAnimationTextView.kt +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/view/TypeAnimationTextView.kt @@ -49,6 +49,8 @@ class TypeAnimationTextView @JvmOverloads constructor( var typingDelayInMs: Long = 20 private var completeText: Spanned? = null + private var shouldRestartAnimation: Boolean = false + private var afterAnimation: () -> Unit = {} fun startTypingAnimation( htmlText: String, @@ -56,6 +58,7 @@ class TypeAnimationTextView @JvmOverloads constructor( afterAnimation: () -> Unit = {}, ) { completeText = htmlText.html(context) + this.afterAnimation = afterAnimation if (isCancellable) { setOnClickListener { @@ -69,6 +72,12 @@ class TypeAnimationTextView @JvmOverloads constructor( typingAnimationJob?.cancel() typingAnimationJob = launch { + animateTyping() + } + } + + private suspend fun animateTyping() { + if (completeText != null) { val transparentSpan = ForegroundColorSpan(Color.TRANSPARENT) val partialText = SpannableString(completeText) breakSequence(partialText).forEach { index -> @@ -98,8 +107,20 @@ class TypeAnimationTextView @JvmOverloads constructor( fun cancelAnimation() = typingAnimationJob?.cancel() + override fun onAttachedToWindow() { + super.onAttachedToWindow() + + if (shouldRestartAnimation) { + typingAnimationJob = launch { + animateTyping() + } + } + } + override fun onDetachedFromWindow() { + shouldRestartAnimation = hasAnimationStarted() && !hasAnimationFinished() cancelAnimation() + super.onDetachedFromWindow() } }