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 d88ee4acadc2..b8f330911929 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -2423,7 +2423,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 @@ -2431,6 +2432,20 @@ class BrowserTabViewModelTest { verify(mockPixel, never()).fire(AppPixelName.TAB_MANAGER_NEW_TAB_LONG_PRESSED) } + @Test + fun whenUserRequestedToOpenNewTabAndEmptyTabExistsThenSelectTheEmptyTab() = runTest { + val emptyTabId = "EMPTY_TAB" + tabsLiveData.value = 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 ce94e52beb1c..d296f3f654c4 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -30,7 +30,10 @@ import androidx.activity.OnBackPressedCallback import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.VisibleForTesting +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 @@ -71,6 +74,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 @@ -80,6 +84,7 @@ import com.duckduckgo.privacy.dashboard.api.ui.PrivacyDashboardHybridScreenParam import com.duckduckgo.savedsites.impl.bookmarks.BookmarksActivity.Companion.SAVED_SITE_URL_EXTRA import javax.inject.Inject import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import timber.log.Timber @@ -129,6 +134,9 @@ open class BrowserActivity : DuckDuckGoActivity() { @Inject lateinit var appBuildConfig: AppBuildConfig + @Inject + lateinit var isSwipingTabsFeatureEnabled: IsSwipingTabsFeatureEnabled + @Inject lateinit var tabManager: TabManager @@ -148,8 +156,33 @@ open class BrowserActivity : DuckDuckGoActivity() { private val binding: ActivityBrowserBinding by viewBinding() + val tabPager: ViewPager2 by lazy { + binding.tabPager + } + private lateinit var toolbarMockupBinding: IncludeOmnibarToolbarMockupBinding + private val onTabPageChangeListener = object : ViewPager2.OnPageChangeCallback() { + private var wasSwipingStarted = false + + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + Timber.d("### onPageChanged requested for: $position") + if (wasSwipingStarted) { + Timber.d("### onPageChanged: $position") + tabManager.tabPagerAdapter.onPageChanged(position) + wasSwipingStarted = false + } + } + + override fun onPageScrollStateChanged(state: Int) { + super.onPageScrollStateChanged(state) + if (state == ViewPager2.SCROLL_STATE_DRAGGING) { + wasSwipingStarted = true + } + } + } + @VisibleForTesting var destroyedByBackPress: Boolean = false @@ -205,6 +238,10 @@ open class BrowserActivity : DuckDuckGoActivity() { override fun onDestroy() { currentTab = null + + binding.tabPager.adapter = null + binding.tabPager.unregisterOnPageChangeCallback(onTabPageChangeListener) + super.onDestroy() } @@ -341,18 +378,31 @@ open class BrowserActivity : DuckDuckGoActivity() { viewModel.command.observe(this) { processCommand(it) } - viewModel.selectedTab.observe(this) { - tabManager.onSelectedTabChanged(it) + + lifecycleScope.launch { + viewModel.tabs.flowWithLifecycle(lifecycle).collectLatest { + tabManager.onTabsUpdated(it) + lifecycleScope.launch { + viewModel.onTabsUpdated(it.isEmpty()) + } + } } - viewModel.tabs.observe(this) { - tabManager.onTabsUpdated(it) + + lifecycleScope.launch { + viewModel.selectedTab.flowWithLifecycle(lifecycle).collectLatest { + tabManager.onSelectedTabChanged(it) + } + } + + // listen to onboarding completion to enable/disable swiping + viewModel.isOnboardingCompleted.observe(this) { isOnboardingCompleted -> + tabPager.isUserInputEnabled = isOnboardingCompleted } } private fun removeObservers() { viewModel.command.removeObservers(this) - viewModel.selectedTab.removeObservers(this) - viewModel.tabs.removeObservers(this) + viewModel.isOnboardingCompleted.removeObservers(this) } private fun processCommand(command: Command) { @@ -515,6 +565,7 @@ open class BrowserActivity : DuckDuckGoActivity() { private fun showWebContent() { Timber.d("BrowserActivity can now start displaying web content. instance state is $instanceStateBundles") + initializeTabs() configureObservers() binding.clearingInProgressView.gone() @@ -533,6 +584,22 @@ open class BrowserActivity : DuckDuckGoActivity() { } } + @SuppressLint("ClickableViewAccessibility", "WrongConstant") + private fun initializeTabs() { + if (isSwipingTabsFeatureEnabled()) { + tabPager.adapter = tabManager.tabPagerAdapter + tabPager.offscreenPageLimit = 1 + tabPager.registerOnPageChangeCallback(onTabPageChangeListener) + tabPager.setPageTransformer(MarginPageTransformer(resources.getDimension(com.duckduckgo.mobile.android.R.dimen.keyline_2).toPx().toInt())) + + binding.fragmentContainer.gone() + tabPager.show() + } else { + binding.fragmentContainer.show() + tabPager.gone() + } + } + private val Intent.launchedFromRecents: Boolean get() = (flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY @@ -632,6 +699,12 @@ open class BrowserActivity : DuckDuckGoActivity() { playStoreUtils.launchPlayStore() } + fun onMoveToTabRequested(index: Int) { + Timber.d("### onMoveToTabRequested: $index") + + tabPager.currentItem = index + } + 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 a0b66dd0c039..f9a2ec73a65d 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -606,6 +606,8 @@ class BrowserTabFragment : private var webView: DuckDuckGoWebView? = null + var isInitialized = false + private val activityResultHandlerEmailProtectionInContextSignup = registerForActivityResult(StartActivityForResult()) { result: ActivityResult -> when (result.resultCode) { EmailProtectionInContextSignUpScreenResult.SUCCESS -> { @@ -814,13 +816,16 @@ class BrowserTabFragment : } private fun resumeWebView() { + Timber.d("Resuming webview: $tabId") webView?.let { if (it.isShown) it.onResume() } } - override fun onActivityCreated(savedInstanceState: Bundle?) { - super.onActivityCreated(savedInstanceState) + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { omnibar = Omnibar(settingsDataStore.omnibarPosition, changeOmnibarPositionFeature.refactor().isEnabled(), binding) webViewContainer = binding.webViewContainer @@ -836,9 +841,8 @@ class BrowserTabFragment : configureOmnibar() if (savedInstanceState == null) { - viewModel.onViewReady() - messageFromPreviousTab?.let { - processMessage(it) + if (isActiveTab) { + initFragmentIfNecessary() } } else { viewModel.onViewRecreated() @@ -847,7 +851,7 @@ class BrowserTabFragment : lifecycle.addObserver( @SuppressLint("NoLifecycleObserver") // we don't observe app lifecycle object : DefaultLifecycleObserver { - override fun onStop(owner: LifecycleOwner) { + override fun onPause(owner: LifecycleOwner) { if (isVisible) { updateOrDeleteWebViewPreview() } @@ -859,6 +863,16 @@ class BrowserTabFragment : (dialog as EditSavedSiteDialogFragment).listener = viewModel dialog.deleteBookmarkListener = viewModel } + + disableSwipingOutsideTheOmnibar() + } + + private fun disableSwipingOutsideTheOmnibar() { + newBrowserTab.newTabLayout.setOnTouchListener { v, event -> + (v as FrameLayout).requestDisallowInterceptTouchEvent(true) + return@setOnTouchListener true + } + binding.legacyOmnibar.setOnTouchListener { v, event -> false } } private fun configureOmnibar() { @@ -1136,9 +1150,22 @@ class BrowserTabFragment : startActivity(TabSwitcherActivity.intent(activity, tabId)) } + private fun initFragmentIfNecessary() { + if (!isInitialized) { + isInitialized = true + + viewModel.onViewReady() + messageFromPreviousTab?.let { + processMessage(it) + } + } + } + override fun onResume() { super.onResume() + initFragmentIfNecessary() + if (viewModel.hasOmnibarPositionChanged(omnibar.omnibarPosition)) { requireActivity().recreate() return @@ -1308,6 +1335,10 @@ class BrowserTabFragment : // want to ensure that we aren't offering to inject credentials from an inactive tab hideDialogWithTag(CredentialAutofillPickerDialog.TAG) } + + if (isActiveTab) { + initFragmentIfNecessary() + } } }, ) @@ -1331,7 +1362,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() @@ -1347,7 +1378,7 @@ class BrowserTabFragment : webView?.onResume() errorView.errorLayout.gone() sslErrorView.gone() - omnibar.setViewMode(Omnibar.ViewMode.Browser(viewModel.url)) + omnibar.setViewMode(ViewMode.Browser(viewModel.url)) } private fun showError( @@ -1358,7 +1389,7 @@ class BrowserTabFragment : newBrowserTab.newTabLayout.gone() newBrowserTab.newTabContainerLayout.gone() sslErrorView.gone() - omnibar.setViewMode(Omnibar.ViewMode.Error) + omnibar.setViewMode(ViewMode.Error) webView?.onPause() webView?.hide() errorView.errorMessage.text = getString(errorType.errorId, url).html(requireContext()) @@ -1379,7 +1410,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() sslErrorView.bind(handler, errorResponse) { action -> @@ -2424,7 +2455,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() + } } } 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 d4851400f5d1..0be337eff5b5 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -2618,7 +2618,15 @@ class BrowserTabViewModel @Inject constructor( fun userRequestedOpeningNewTab(longPress: Boolean = false) { command.value = GenerateWebViewPreviewImage - command.value = LaunchNewTab + + val emptyTab = tabs.value?.firstOrNull { it.url.isNullOrBlank() }?.tabId + if (emptyTab != null) { + viewModelScope.launch { + tabRepository.select(tabId = emptyTab) + } + } 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 b21bb639abd3..299265df570e 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesRemoteFeature import com.duckduckgo.anvil.annotations.ContributesViewModel @@ -33,6 +34,8 @@ import com.duckduckgo.app.global.rating.AppEnjoymentPromptEmitter import com.duckduckgo.app.global.rating.AppEnjoymentPromptOptions import com.duckduckgo.app.global.rating.AppEnjoymentUserEventRecorder import com.duckduckgo.app.global.rating.PromptCount +import com.duckduckgo.app.onboarding.store.AppStage +import com.duckduckgo.app.onboarding.store.UserStageStore import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.pixels.AppPixelName.APP_ENJOYMENT_DIALOG_SHOWN import com.duckduckgo.app.pixels.AppPixelName.APP_ENJOYMENT_DIALOG_USER_CANCELLED @@ -48,7 +51,6 @@ 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.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.SingleLiveEvent @@ -58,6 +60,11 @@ import com.duckduckgo.feature.toggles.api.Toggle import javax.inject.Inject import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import timber.log.Timber @@ -74,6 +81,7 @@ class BrowserViewModel @Inject constructor( private val skipUrlConversionOnNewTabFeature: SkipUrlConversionOnNewTabFeature, private val showOnAppLaunchFeature: ShowOnAppLaunchFeature, private val showOnAppLaunchOptionHandler: ShowOnAppLaunchOptionHandler, + userStageStore: UserStageStore, ) : ViewModel(), CoroutineScope { @@ -101,39 +109,48 @@ class BrowserViewModel @Inject constructor( private val currentViewState: ViewState get() = viewState.value!! - var tabs: LiveData> = tabRepository.liveTabs - 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 selectedTab: Flow = tabRepository.flowSelectedTab + .map { tab -> tab?.tabId } + .filterNotNull() + .distinctUntilChanged() + + val tabs: Flow> = tabRepository.flowTabs + .map { tabs -> tabs.map { tab -> tab.tabId } } + .filterNot { it.isEmpty() } + .distinctUntilChanged() + + val isOnboardingCompleted: LiveData = userStageStore.currentAppStage + .distinctUntilChanged() + .map { it != AppStage.DAX_ONBOARDING } + .asLiveData() + + 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 -> {} } } @@ -186,8 +203,8 @@ class BrowserViewModel @Inject constructor( ) } - suspend fun onTabsUpdated(tabs: List?) { - if (tabs.isNullOrEmpty()) { + suspend fun onTabsUpdated(areTabsEmpty: Boolean) { + if (areTabsEmpty) { Timber.i("Tabs list is null or empty; adding default tab") tabRepository.addDefaultTab() return @@ -292,7 +309,9 @@ class BrowserViewModel @Inject constructor( fun onTabSelected(tabId: String) { launch(dispatchers.io()) { - tabRepository.select(tabId) + if (tabId != tabRepository.getSelectedTab()?.tabId) { + tabRepository.select(tabId) + } } } 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..717215f8190d 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 + 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 index cc47d0977e1b..b59c6c35ddd2 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/SwipingTabsFeature.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/SwipingTabsFeature.kt @@ -26,5 +26,6 @@ import com.duckduckgo.feature.toggles.api.Toggle ) interface SwipingTabsFeature { @Toggle.DefaultValue(false) + @Toggle.InternalAlwaysEnabled fun self(): Toggle } diff --git a/app/src/main/java/com/duckduckgo/app/browser/tabs/RealTabManager.kt b/app/src/main/java/com/duckduckgo/app/browser/tabs/DefaultTabManager.kt similarity index 52% rename from app/src/main/java/com/duckduckgo/app/browser/tabs/RealTabManager.kt rename to app/src/main/java/com/duckduckgo/app/browser/tabs/DefaultTabManager.kt index a9cecb61e5cd..d8228cae29e3 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/tabs/RealTabManager.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/tabs/DefaultTabManager.kt @@ -22,36 +22,58 @@ import com.duckduckgo.app.browser.BrowserActivity import com.duckduckgo.app.browser.BrowserTabFragment import com.duckduckgo.app.browser.IsSwipingTabsFeatureEnabled import com.duckduckgo.app.browser.R +import com.duckduckgo.app.browser.tabs.TabManager.Companion.MAX_ACTIVE_TABS 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 dagger.SingleInstanceIn import dagger.android.DaggerActivity import javax.inject.Inject import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import timber.log.Timber @ContributesBinding(ActivityScope::class) @SingleInstanceIn(ActivityScope::class) -class RealTabManager @Inject constructor( +class DefaultTabManager @Inject constructor( activity: DaggerActivity, private val isSwipingTabsFeatureEnabled: IsSwipingTabsFeatureEnabled, + private val tabRepository: TabRepository, + private val dispatchers: DispatcherProvider, ) : TabManager { - companion object { - private const val MAX_ACTIVE_TABS = 40 - } - private val browserActivity = activity as BrowserActivity private val lastActiveTabs = TabList() private val supportFragmentManager = activity.supportFragmentManager private var openMessageInNewTabJob: Job? = null + private val keepSingleTab: Boolean + get() = !browserActivity.tabPager.isUserInputEnabled + + override val tabPagerAdapter by lazy { + TabPagerAdapter( + fragmentManager = supportFragmentManager, + lifecycleOwner = browserActivity, + activityIntent = browserActivity.intent, + moveToTabIndex = { index -> browserActivity.onMoveToTabRequested(index) }, + getSelectedTabId = ::getSelectedTabId, + getTabById = ::getTabById, + requestNewTab = ::requestNewTab, + onTabSelected = { tabId -> browserActivity.viewModel.onTabSelected(tabId) }, + setOffScreenPageLimit = { limit -> browserActivity.tabPager.offscreenPageLimit = limit }, + getOffScreenPageLimit = { browserActivity.tabPager.offscreenPageLimit }, + ) + } + private var _currentTab: BrowserTabFragment? = null override var currentTab: BrowserTabFragment? get() { return if (isSwipingTabsFeatureEnabled()) { - null + tabPagerAdapter.currentFragment } else { _currentTab } @@ -60,19 +82,30 @@ class RealTabManager @Inject constructor( _currentTab = value } - override fun onSelectedTabChanged(tab: TabEntity?) { + override fun onSelectedTabChanged(tabId: String) { if (isSwipingTabsFeatureEnabled()) { - return - } else if (tab != null) { - selectTab(tab) + Timber.d("### TabManager.onSelectedTabChanged: $tabId") + tabPagerAdapter.onSelectedTabChanged(tabId) + if (keepSingleTab) { + tabPagerAdapter.onTabsUpdated(listOf(tabId)) + } + } else { + selectTab(tabId) } } - override fun onTabsUpdated(updatedTabs: List) { + override fun onTabsUpdated(updatedTabIds: List) { + Timber.d("### TabManager.onTabsUpdated: $updatedTabIds") if (isSwipingTabsFeatureEnabled()) { - return + if (keepSingleTab) { + updatedTabIds.firstOrNull { it == getSelectedTabId() }?.let { + tabPagerAdapter.onTabsUpdated(listOf(it)) + } + } else { + tabPagerAdapter.onTabsUpdated(updatedTabIds) + } } else { - clearStaleTabs(updatedTabs) + clearStaleTabs(updatedTabIds) } } @@ -80,18 +113,25 @@ class RealTabManager @Inject constructor( message: Message, sourceTabId: String?, ) { - openMessageInNewTabJob = browserActivity.lifecycleScope.launch { - val tabId = browserActivity.viewModel.onNewTabRequested(sourceTabId) - val fragment = openNewTab( - tabId = tabId, - url = null, - skipHome = false, - isExternal = browserActivity.intent?.getBooleanExtra( - BrowserActivity.LAUNCH_FROM_EXTERNAL_EXTRA, - false, - ) ?: false, - ) - fragment.messageFromPreviousTab = message + if (isSwipingTabsFeatureEnabled()) { + openMessageInNewTabJob = browserActivity.lifecycleScope.launch { + tabPagerAdapter.setMessageForNewFragment(message) + browserActivity.viewModel.onNewTabRequested(sourceTabId) + } + } else { + openMessageInNewTabJob = browserActivity.lifecycleScope.launch { + val tabId = browserActivity.viewModel.onNewTabRequested(sourceTabId) + val fragment = openNewTab( + tabId = tabId, + url = null, + skipHome = false, + isExternal = browserActivity.intent?.getBooleanExtra( + BrowserActivity.LAUNCH_FROM_EXTERNAL_EXTRA, + false, + ) == true, + ) + fragment.messageFromPreviousTab = message + } } } @@ -121,41 +161,54 @@ class RealTabManager @Inject constructor( openMessageInNewTabJob?.cancel() } - private fun selectTab(tab: TabEntity?) { - if (isSwipingTabsFeatureEnabled()) { - return - } - - Timber.v("Select tab: $tab") + private fun requestNewTab(): TabEntity = runBlocking(dispatchers.io()) { + val tabId = browserActivity.viewModel.onNewTabRequested() + return@runBlocking tabRepository.flowTabs.transformWhile { result -> + result.firstOrNull { it.tabId == tabId }?.let { entity -> + emit(entity) + return@transformWhile true + } + return@transformWhile false + }.first() + } - if (tab == null) return + private fun getSelectedTabId(): String? = runBlocking { + tabRepository.getSelectedTab()?.tabId + } - if (tab.tabId == currentTab?.tabId) return + private fun getTabById(tabId: String): TabEntity? = runBlocking { + tabRepository.getTab(tabId) + } - lastActiveTabs.add(tab.tabId) + private fun selectTab(tabId: String) = browserActivity.lifecycleScope.launch { + if (tabId != currentTab?.tabId) { + lastActiveTabs.add(tabId) - browserActivity.viewModel.onTabActivated(tab.tabId) + browserActivity.viewModel.onTabActivated(tabId) - val fragment = supportFragmentManager.findFragmentByTag(tab.tabId) as? BrowserTabFragment - if (fragment == null) { - openNewTab( - tabId = tab.tabId, - url = tab.url, - skipHome = tab.skipHome, - isExternal = browserActivity.intent?.getBooleanExtra( - BrowserActivity.LAUNCH_FROM_EXTERNAL_EXTRA, - false, - ) ?: false, - ) - return - } - val transaction = supportFragmentManager.beginTransaction() - currentTab?.let { - transaction.hide(it) + val fragment = supportFragmentManager.findFragmentByTag(tabId) as? BrowserTabFragment + if (fragment == null) { + tabRepository.getTab(tabId)?.let { tab -> + openNewTab( + tabId = tab.tabId, + url = tab.url, + skipHome = tab.skipHome, + isExternal = browserActivity.intent?.getBooleanExtra( + BrowserActivity.LAUNCH_FROM_EXTERNAL_EXTRA, + false, + ) == true, + ) + } + return@launch + } + val transaction = supportFragmentManager.beginTransaction() + currentTab?.let { + transaction.hide(it) + } + transaction.show(fragment) + transaction.commit() + currentTab = fragment } - transaction.show(fragment) - transaction.commit() - currentTab = fragment } private fun openNewTab( @@ -189,7 +242,7 @@ class RealTabManager @Inject constructor( transaction.commit() } - private fun clearStaleTabs(updatedTabs: List?) { + private fun clearStaleTabs(updatedTabs: List?) { if (isSwipingTabsFeatureEnabled()) { return } @@ -200,7 +253,7 @@ class RealTabManager @Inject constructor( val stale = supportFragmentManager .fragments.mapNotNull { it as? BrowserTabFragment } - .filter { fragment -> updatedTabs.none { it.tabId == fragment.tabId } } + .filter { fragment -> updatedTabs.none { it == fragment.tabId } } if (stale.isNotEmpty()) { removeTabs(stale) diff --git a/app/src/main/java/com/duckduckgo/app/browser/tabs/TabManager.kt b/app/src/main/java/com/duckduckgo/app/browser/tabs/TabManager.kt index e6d4887d5e76..0c0c450eaee7 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/tabs/TabManager.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/tabs/TabManager.kt @@ -18,24 +18,22 @@ package com.duckduckgo.app.browser.tabs import android.os.Message import com.duckduckgo.app.browser.BrowserTabFragment -import com.duckduckgo.app.tabs.model.TabEntity interface TabManager { + companion object { + const val MAX_ACTIVE_TABS = 40 + } + var currentTab: BrowserTabFragment? + val tabPagerAdapter: TabPagerAdapter - fun onSelectedTabChanged(tab: TabEntity?) - fun onTabsUpdated(updatedTabs: List) - fun openMessageInNewTab( - message: Message, - sourceTabId: String?, - ) + fun onSelectedTabChanged(tabId: String) + fun onTabsUpdated(updatedTabIds: List) + fun openMessageInNewTab(message: Message, sourceTabId: String?) fun openExistingTab(tabId: String) fun launchNewTab() - fun openQueryInNewTab( - query: String, - sourceTabId: String?, - ) + fun openQueryInNewTab(query: String, sourceTabId: String?) fun onCleanup() } diff --git a/app/src/main/java/com/duckduckgo/app/browser/tabs/TabPagerAdapter.kt b/app/src/main/java/com/duckduckgo/app/browser/tabs/TabPagerAdapter.kt new file mode 100644 index 000000000000..f9192601aba3 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/tabs/TabPagerAdapter.kt @@ -0,0 +1,131 @@ +/* + * 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 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 androidx.viewpager2.adapter.FragmentStateAdapter +import com.duckduckgo.app.browser.BrowserActivity +import com.duckduckgo.app.browser.BrowserTabFragment +import com.duckduckgo.app.tabs.model.TabEntity +import timber.log.Timber + +class TabPagerAdapter( + lifecycleOwner: LifecycleOwner, + private val fragmentManager: FragmentManager, + private val activityIntent: Intent?, + private val moveToTabIndex: (Int) -> Unit, + private val getTabById: (String) -> TabEntity?, + private val requestNewTab: () -> TabEntity, + private val getSelectedTabId: () -> String?, + private val onTabSelected: (String) -> Unit, + private val getOffScreenPageLimit: () -> Int, + private val setOffScreenPageLimit: (Int) -> Unit, +) : 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() } + + private val activeTabCount + get() = fragmentManager.fragments + .filterIsInstance() + .filter { it.isInitialized }.size + + override fun createFragment(position: Int): Fragment { + Timber.d("### TabPagerAdapter.createFragment: $position") + increaseOffscreenTabLimitIfNeeded() + + val tab = getTabById(tabIds[position]) ?: requestNewTab() + 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) + + onSelectedTabChanged(getSelectedTabId() ?: tabIds.first()) + } + + fun onSelectedTabChanged(tabId: String) { + val selectedTabIndex = tabIds.indexOfFirst { it == tabId } + if (selectedTabIndex != -1) { + moveToTabIndex(selectedTabIndex) + } + } + + fun onPageChanged(position: Int) { + if (position < tabIds.size) { + onTabSelected(tabIds[position]) + } + } + + private fun increaseOffscreenTabLimitIfNeeded() { + val offscreenPageLimit = getOffScreenPageLimit() + if (activeTabCount >= offscreenPageLimit * 2 - 1 && activeTabCount < TabManager.MAX_ACTIVE_TABS) { + setOffScreenPageLimit(offscreenPageLimit + 1) + } + } + + 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 d2effdbb646e..b3141fb6bf84 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 @@ -17,6 +17,7 @@ package com.duckduckgo.app.onboarding.store import androidx.room.* +import kotlinx.coroutines.flow.Flow @Dao interface UserStageDao { @@ -24,6 +25,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 84bf15267e9d..a72241c4df07 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 @@ -18,12 +18,15 @@ package com.duckduckgo.app.onboarding.store import com.duckduckgo.common.utils.DispatcherProvider import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext interface UserStageStore { suspend fun getUserAppStage(): AppStage suspend fun stageCompleted(appStage: AppStage): AppStage suspend fun moveToStage(appStage: AppStage) + val currentAppStage: Flow } class AppUserStageStore @Inject constructor( @@ -57,6 +60,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/tabs/db/TabsDao.kt b/app/src/main/java/com/duckduckgo/app/tabs/db/TabsDao.kt index 32cedd2d886f..149e0569e362 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 @@ -40,6 +40,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 6e35c1172fca..452834a954ad 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 @@ -77,6 +77,8 @@ class TabDataRepository @Inject constructor( override val liveSelectedTab: LiveData = tabsDao.liveSelectedTab() + override val flowSelectedTab: Flow = tabsDao.flowSelectedTab() + 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/TabSwitcherActivity.kt b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt index 4b2529dbd8c5..f39c72c6d2c8 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt @@ -129,6 +129,7 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine private var layoutTypeMenuItem: MenuItem? = null private var layoutType: LayoutType? = null + private var swipeListener: ItemTouchHelper? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -174,8 +175,8 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine onTabDraggingFinished = this::onTabDraggingFinished, ) - val swipeListener = ItemTouchHelper(tabTouchHelper) - swipeListener.attachToRecyclerView(tabsRecycler) + swipeListener = ItemTouchHelper(tabTouchHelper) + swipeListener?.attachToRecyclerView(tabsRecycler) tabItemDecorator = TabItemDecorator(this, selectedTabId) tabsRecycler.addItemDecoration(tabItemDecorator) @@ -208,6 +209,13 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine } } + lifecycleScope.launch { + viewModel.isDeletingEnabled.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).collect { isEnabled -> + tabsAdapter.onIsDeletingEnabledChanged(isEnabled) + swipeListener?.attachToRecyclerView(if (isEnabled) tabsRecycler else null) + } + } + viewModel.command.observe(this) { processCommand(it) } diff --git a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherAdapter.kt b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherAdapter.kt index b33be228fc2a..6a613753f1da 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherAdapter.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherAdapter.kt @@ -49,7 +49,6 @@ import com.duckduckgo.app.tabs.ui.TabSwitcherAdapter.TabViewHolder.ListTabViewHo import com.duckduckgo.common.ui.view.show import com.duckduckgo.common.utils.swap import java.io.File -import java.util.Collections.swap import kotlinx.coroutines.launch import timber.log.Timber @@ -63,6 +62,7 @@ class TabSwitcherAdapter( private val list = mutableListOf() private var isDragging: Boolean = false private var layoutType: LayoutType = LayoutType.GRID + private var isDeletingEnabled: Boolean = true init { setHasStableIds(true) @@ -129,6 +129,12 @@ class TabSwitcherAdapter( } override fun onBindViewHolder(holder: TabViewHolder, position: Int, payloads: MutableList) { + if (isDeletingEnabled) { + holder.close.show() + } else { + holder.close.visibility = View.GONE + } + if (payloads.isEmpty()) { onBindViewHolder(holder, position) return @@ -202,6 +208,11 @@ class TabSwitcherAdapter( diffResult.dispatchUpdatesTo(this) } + fun onIsDeletingEnabledChanged(isDeletingEnabled: Boolean) { + this.isDeletingEnabled = isDeletingEnabled + notifyDataSetChanged() + } + fun getTab(position: Int): TabEntity? = list.getOrNull(position) fun adapterPositionForTab(tabId: String?): Int = list.indexOfFirst { it.tabId == tabId } 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..7ee57e3f6a62 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 @@ -35,6 +35,7 @@ import com.duckduckgo.common.utils.SingleLiveEvent import com.duckduckgo.di.scopes.ActivityScope import javax.inject.Inject import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -54,6 +55,10 @@ class TabSwitcherViewModel @Inject constructor( context = viewModelScope.coroutineContext, ) + val isDeletingEnabled = tabRepository.flowTabs.map { it.size > 1 } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), true) + val layoutType = tabRepository.tabSwitcherData .map { it.layoutType } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) @@ -66,7 +71,12 @@ class TabSwitcherViewModel @Inject constructor( } suspend fun onNewTabRequested(fromOverflowMenu: Boolean) { - tabRepository.add() + val emptyTab = tabs.value?.firstOrNull { it.url.isNullOrBlank() }?.tabId + if (emptyTab != null) { + tabRepository.select(tabId = emptyTab) + } 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" /> + + 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/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?,