Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7455,43 +7455,6 @@ class BrowserTabViewModelTest {
)
}

@Test
fun whenInputScreenEnabledAndExternalIntentProcessingCompletedThenLaunchInputScreenCommandTriggered() =
runTest {
val initialTabId = "initial-tab"
val initialTab =
TabEntity(
tabId = initialTabId,
url = "https://example.com",
title = "EX",
skipHome = false,
viewed = true,
position = 0,
)
val ntpTabId = "ntp-tab"
val ntpTab = TabEntity(tabId = ntpTabId, url = null, title = "", skipHome = false, viewed = true, position = 0)
whenever(mockTabRepository.getTab(initialTabId)).thenReturn(initialTab)
whenever(mockTabRepository.getTab(ntpTabId)).thenReturn(ntpTab)
flowSelectedTab.emit(initialTab)

testee.loadData(tabId = ntpTabId, initialUrl = null, skipHome = false, isExternal = false)
mockDuckAiFeatureStateInputScreenOpenAutomaticallyFlow.emit(true)
mockHasPendingTabLaunchFlow.emit(true)

// Switch to a new tab with no URL
flowSelectedTab.emit(ntpTab)

// Complete external intent processing
mockHasPendingTabLaunchFlow.emit(false)

verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture())
val commands = commandCaptor.allValues
assertTrue(
"LaunchInputScreen command should be triggered when external intent processing completes",
commands.any { it is Command.LaunchInputScreen },
)
}

@Test
fun whenInputScreenEnabledAndDuckAiOpenThenLaunchInputScreenCommandSuppressed() =
runTest {
Expand Down Expand Up @@ -7525,51 +7488,6 @@ class BrowserTabViewModelTest {
)
}

@Test
fun whenInputScreenEnabledAndDuckAiClosedThenLaunchInputScreenCommandTriggered() =
runTest {
val initialTabId = "initial-tab"
val initialTab =
TabEntity(
tabId = initialTabId,
url = "https://example.com",
title = "EX",
skipHome = false,
viewed = true,
position = 0,
)
val ntpTabId = "ntp-tab"
val ntpTab =
TabEntity(
tabId = ntpTabId,
url = null,
title = "",
skipHome = false,
viewed = true,
position = 0,
)
whenever(mockTabRepository.getTab(initialTabId)).thenReturn(initialTab)
whenever(mockTabRepository.getTab(ntpTabId)).thenReturn(ntpTab)
flowSelectedTab.emit(initialTab)

testee.loadData(tabId = ntpTabId, initialUrl = null, skipHome = false, isExternal = false)
mockDuckAiFeatureStateInputScreenOpenAutomaticallyFlow.emit(true)
mockHasPendingDuckAiOpenFlow.emit(true)

// Switch to a new tab with no URL
flowSelectedTab.emit(ntpTab)

// Close Duck.ai
mockHasPendingDuckAiOpenFlow.emit(false)

verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture())
val commands = commandCaptor.allValues
assertTrue(
"LaunchInputScreen command should be triggered when Duck.ai is closed",
commands.any { it is Command.LaunchInputScreen },
)
}

@Test
fun whenEvaluateSerpLogoStateCalledWithDuckDuckGoUrlAndFeatureEnabledThenExtractSerpLogoCommandIssued() {
whenever(mockSerpEasterEggLogoToggles.feature()).thenReturn(mockEnabledToggle)
Expand Down
47 changes: 47 additions & 0 deletions app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,12 @@ import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.activity.viewModels
import androidx.annotation.VisibleForTesting
import androidx.core.view.isVisible
import androidx.core.view.postDelayed
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Lifecycle.State.STARTED
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
Expand Down Expand Up @@ -90,6 +93,7 @@ import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter
import com.duckduckgo.app.tabs.TabManagerFeatureFlags
import com.duckduckgo.app.tabs.model.TabEntity
import com.duckduckgo.app.tabs.ui.DefaultSnackbar
import com.duckduckgo.app.tabs.ui.TabSwitcherActivity
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
import com.duckduckgo.autofill.api.emailprotection.EmailProtectionLinkVerifier
import com.duckduckgo.browser.api.ui.BrowserScreens.BookmarksScreenNoParams
Expand All @@ -110,6 +114,7 @@ import com.duckduckgo.common.utils.playstore.PlayStoreUtils
import com.duckduckgo.di.scopes.ActivityScope
import com.duckduckgo.duckchat.api.DuckAiFeatureState
import com.duckduckgo.duckchat.api.DuckChat
import com.duckduckgo.duckchat.api.viewmodel.DuckChatSharedViewModel
import com.duckduckgo.duckchat.impl.ui.DuckChatWebViewFragment
import com.duckduckgo.duckchat.impl.ui.DuckChatWebViewFragment.Companion.KEY_DUCK_AI_TABS
import com.duckduckgo.duckchat.impl.ui.DuckChatWebViewFragment.Companion.KEY_DUCK_AI_URL
Expand Down Expand Up @@ -249,6 +254,7 @@ open class BrowserActivity : DuckDuckGoActivity() {
}

private val viewModel: BrowserViewModel by bindViewModel()
private val duckChatViewModel: DuckChatSharedViewModel by viewModels()

private var instanceStateBundles: CombinedInstanceState? = null

Expand Down Expand Up @@ -360,6 +366,8 @@ open class BrowserActivity : DuckDuckGoActivity() {
viewModel.viewState.observe(this) {
renderer.renderBrowserViewState(it)
}
observeDuckChatSharedCommands()

viewModel.awaitClearDataFinishedNotification()
initializeServiceWorker()

Expand Down Expand Up @@ -979,6 +987,45 @@ open class BrowserActivity : DuckDuckGoActivity() {
)
}

private val tabSwitcherActivityResult =
registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
// Handle any result data if needed
result.data?.let { intent ->
intent.extras?.let { extras ->
val deletedTabIds = extras.getStringArrayList(TabSwitcherActivity.EXTRA_KEY_DELETED_TAB_IDS)
if (!deletedTabIds.isNullOrEmpty()) {
onTabsDeletedInTabSwitcher(deletedTabIds)
}
}
}
}
}

private fun observeDuckChatSharedCommands() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
duckChatViewModel.command.collect { command ->
when (command) {
DuckChatSharedViewModel.Command.LaunchFire -> launchFire()
DuckChatSharedViewModel.Command.LaunchTabSwitcher -> {
val intent = TabSwitcherActivity.intent(this@BrowserActivity)
tabSwitcherActivityResult.launch(intent)
}
is DuckChatSharedViewModel.Command.SearchRequested -> {
closeDuckChat()
currentTab?.submitQuery(command.query)
}

is DuckChatSharedViewModel.Command.OpenTab -> {
openExistingTab(command.tabId)
}
}
}
}
}
}

override fun onAttachFragment(fragment: androidx.fragment.app.Fragment) {
super.onAttachFragment(fragment)
hideMockupOmnibar()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1150,6 +1150,7 @@ class BrowserTabFragment :
}

private fun launchInputScreen(query: String) {
logcat { "Duck.ai: launchInputScreen" }
val isTopOmnibar = omnibar.omnibarType != OmnibarType.SINGLE_BOTTOM
val intent =
globalActivityStarter.startIntent(
Expand Down
77 changes: 43 additions & 34 deletions app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,6 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
Expand Down Expand Up @@ -677,6 +676,7 @@ class BrowserTabViewModel @Inject constructor(
}

init {
logcat { "Duck.ai: init" }
initializeViewStates()

fireproofWebsiteState.observeForever(fireproofWebsitesObserver)
Expand Down Expand Up @@ -775,33 +775,24 @@ class BrowserTabViewModel @Inject constructor(
}.launchIn(viewModelScope)

// auto-launch input screen for new, empty tabs (New Tab Page)
combine(
externalIntentProcessingState.hasPendingTabLaunch,
externalIntentProcessingState.hasPendingDuckAiOpen,
) { hasPendingTabLaunch, hasPendingDuckAiOpen ->
hasPendingTabLaunch || hasPendingDuckAiOpen
}.flatMapLatest {
if (it) {
// suppress auto-launch while processing external intents (for example, opening links from other apps)
// this prevents the New Tab Page from incorrectly triggering the input screen when the app
// is started via external intent while previously left on NTP
emptyFlow()
} else {
tabRepository.flowSelectedTab
.distinctUntilChangedBy { selectedTab -> selectedTab?.tabId } // only observe when the tab changes and ignore further updates
.filter { selectedTab ->
// fire event when activating a new, empty tab
// (has no URL and wasn't opened from another tab)
val showInputScreenAutomatically = duckAiFeatureState.showInputScreenAutomaticallyOnNewTab.value
val isActiveTab = ::tabId.isInitialized && selectedTab?.tabId == tabId
val isOpenedFromAnotherTab = selectedTab?.sourceTabId != null
showInputScreenAutomatically && isActiveTab && selectedTab?.url.isNullOrBlank() && !isOpenedFromAnotherTab
}.flowOn(dispatchers.main()) // don't use the immediate dispatcher so that the tabId field has a chance to initialize
}
}.onEach {
// whenever an event fires, so the user switched to a new tab page, launch the input screen
command.value = LaunchInputScreen
}.launchIn(viewModelScope)
tabRepository.flowSelectedTab
.distinctUntilChangedBy { selectedTab -> selectedTab?.tabId } // only observe when the tab changes and ignore further updates
.filter { selectedTab ->
// fire event when activating a new, empty tab
// (has no URL and wasn't opened from another tab)
val showInputScreenAutomatically = duckAiFeatureState.showInputScreenAutomaticallyOnNewTab.value
val isActiveTab = ::tabId.isInitialized && selectedTab?.tabId == tabId
val isOpenedFromAnotherTab = selectedTab?.sourceTabId != null
showInputScreenAutomatically && isActiveTab && selectedTab?.url.isNullOrBlank() && !isOpenedFromAnotherTab
}.flowOn(dispatchers.main()) // don't use the immediate dispatcher so that the tabId field has a chance to initialize
.onEach {
val hasPendingTabLaunch = externalIntentProcessingState.hasPendingTabLaunch.value
val hasPendingDuckAiOpen = externalIntentProcessingState.hasPendingDuckAiOpen.value
if (!hasPendingTabLaunch && !hasPendingDuckAiOpen) {
// whenever an event fires, so the user switched to a new tab page, launch the input screen
command.value = LaunchInputScreen
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs updated tests

}
}.launchIn(viewModelScope)
}

fun loadData(
Expand Down Expand Up @@ -2148,9 +2139,9 @@ class BrowserTabViewModel @Inject constructor(
currentLoadingViewState().copy(
isLoading = true,
trackersAnimationEnabled = true,
/*We set the progress to 20 so the omnibar starts animating and the user knows we are loading the page.
* We don't show the browser until the page actually starts loading, to prevent previous sites from briefly
* showing in case the URL was blocked locally and therefore never started to show*/
/*We set the progress to 20 so the omnibar starts animating and the user knows we are loading the page.
* We don't show the browser until the page actually starts loading, to prevent previous sites from briefly
* showing in case the URL was blocked locally and therefore never started to show*/
progress = 20,
url = documentUrlString,
)
Expand Down Expand Up @@ -2988,8 +2979,12 @@ class BrowserTabViewModel @Inject constructor(
// we hide the keyboard when showing a DialogCta and HomeCta type in the home screen otherwise we show it
val shouldHideKeyboard =
cta is HomePanelCta || cta is DaxBubbleCta.DaxPrivacyProCta ||
duckAiFeatureState.showInputScreen.value || currentBrowserViewState().lastQueryOrigin == QueryOrigin.FromBookmark
command.value = if (shouldHideKeyboard || (settingsDataStore.omnibarType == OmnibarType.SPLIT && alreadyShownKeyboard)) {
duckAiFeatureState.showInputScreen.value || currentBrowserViewState().lastQueryOrigin == QueryOrigin.FromBookmark ||
(settingsDataStore.omnibarType == OmnibarType.SPLIT && alreadyShownKeyboard)

logcat { "Duck.ai: shouldHideKeyboard: $shouldHideKeyboard" }

command.value = if (shouldHideKeyboard) {
HideKeyboard
} else {
alreadyShownKeyboard = true
Expand All @@ -3006,6 +3001,7 @@ class BrowserTabViewModel @Inject constructor(
is HomePanelCta.AddWidgetAuto, is HomePanelCta.AddWidgetAutoOnboardingExperiment, is HomePanelCta.AddWidgetInstructions -> {
LaunchAddWidget
}

is OnboardingDaxDialogCta -> onOnboardingCtaOkButtonClicked(cta)
is DaxBubbleCta -> onDaxBubbleCtaOkButtonClicked(cta)
is BrokenSitePromptDialogCta -> onBrokenSiteCtaOkButtonClicked(cta)
Expand Down Expand Up @@ -3299,7 +3295,10 @@ class BrowserTabViewModel @Inject constructor(
}

@SuppressLint("RequiresFeature", "PostMessageUsage") // it's already checked in isBlobDownloadWebViewFeatureEnabled
private fun postMessageToConvertBlobToDataUri(webView: WebView, url: String) {
private fun postMessageToConvertBlobToDataUri(
webView: WebView,
url: String,
) {
viewModelScope.launch(dispatchers.main()) {
// main because postMessage is not always safe in another thread
for ((key, proxies) in fixedReplyProxyMap) {
Expand Down Expand Up @@ -3815,6 +3814,7 @@ class BrowserTabViewModel @Inject constructor(
if (id != null && data != null) {
webViewCompatWebShare(featureName, method, id, data, onResponse)
}

"permissionsQuery" ->
if (id != null && data != null) {
webViewCompatPermissionsQuery(featureName, method, id, data, onResponse)
Expand All @@ -3828,6 +3828,7 @@ class BrowserTabViewModel @Inject constructor(
"screenUnlock" -> screenUnlock()
}
}

"breakageReporting" ->
if (data != null) {
when (method) {
Expand All @@ -3836,6 +3837,7 @@ class BrowserTabViewModel @Inject constructor(
}
}
}

"messaging" ->
when (method) {
"initialPing" -> {
Expand Down Expand Up @@ -4381,10 +4383,12 @@ class BrowserTabViewModel @Inject constructor(
onboardingDesignExperimentManager.isBuckEnrolledAndEnabled() -> {
command.value = SetBrowserBackgroundColor(getBuckOnboardingExperimentBackgroundColor(lightModeEnabled))
}

onboardingDesignExperimentManager.isBbEnrolledAndEnabled() -> {
// TODO if BB wins the we should rename the function to SetBubbleDialogBackground
command.value = Command.SetBubbleDialogBackground(getBBBackgroundResource(lightModeEnabled))
}

else -> {
command.value = SetBrowserBackground(getBackgroundResource(lightModeEnabled))
}
Expand All @@ -4403,9 +4407,11 @@ class BrowserTabViewModel @Inject constructor(
onboardingDesignExperimentManager.isBuckEnrolledAndEnabled() -> {
command.value = SetOnboardingDialogBackgroundColor(getBuckOnboardingExperimentBackgroundColor(lightModeEnabled))
}

onboardingDesignExperimentManager.isBbEnrolledAndEnabled() -> {
command.value = SetOnboardingDialogBackground(getBBBackgroundResource(lightModeEnabled))
}

else -> {
command.value = SetOnboardingDialogBackground(getBackgroundResource(lightModeEnabled))
}
Expand Down Expand Up @@ -4470,14 +4476,17 @@ class BrowserTabViewModel @Inject constructor(
command.value = LaunchPrivacyPro("https://duckduckgo.com/pro?origin=funnel_appmenu_android".toUri())
"pill"
}

VpnMenuState.NotSubscribedNoPill -> {
command.value = LaunchPrivacyPro("https://duckduckgo.com/pro?origin=funnel_appmenu_android".toUri())
"no_pill"
}

is VpnMenuState.Subscribed -> {
command.value = LaunchVpnManagement
"subscribed"
}

VpnMenuState.Hidden -> "" // Should not happen as menu item should not be visible
}
pixel.fire(AppPixelName.MENU_ACTION_VPN_PRESSED, mapOf(PixelParameter.STATUS to statusParam))
Expand Down
Loading
Loading