Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show on App Launch: Open existing tab if Specific Page url matches #5152

Merged
Show file tree
Hide file tree
Changes from 14 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 @@ -131,6 +131,7 @@ import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao
import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity
import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepositoryImpl
import com.duckduckgo.app.fire.fireproofwebsite.ui.AutomaticFireproofSetting
import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchOptionHandler
import com.duckduckgo.app.global.db.AppDatabase
import com.duckduckgo.app.global.events.db.UserEventsStore
import com.duckduckgo.app.global.install.AppInstallStore
Expand Down Expand Up @@ -403,6 +404,8 @@ class BrowserTabViewModelTest {

private var loadingBarExperimentManager: LoadingBarExperimentManager = mock()

private val mockShowOnAppLaunchHandler: ShowOnAppLaunchOptionHandler = mock()

private lateinit var remoteMessagingModel: RemoteMessagingModel

private val lazyFaviconManager = Lazy { mockFaviconManager }
Expand Down Expand Up @@ -649,6 +652,7 @@ class BrowserTabViewModelTest {
loadingBarExperimentManager = loadingBarExperimentManager,
refreshPixelSender = refreshPixelSender,
changeOmnibarPositionFeature = changeOmnibarPositionFeature,
showOnAppLaunchOptionHandler = mockShowOnAppLaunchHandler,
)

testee.loadData("abc", null, false, false)
Expand Down Expand Up @@ -5982,6 +5986,13 @@ class BrowserTabViewModelTest {
verify(refreshPixelSender).sendCustomTabRefreshPixel()
}

@Test
fun whenNavigationStateChangedCalledThenHandleResolvedUrlIsChecked() = runTest {
testee.navigationStateChanged(buildWebNavigation("https://example.com"))

verify(mockShowOnAppLaunchHandler).handleResolvedUrlStorage(eq("https://example.com"), any(), any())
}

private fun aCredential(): LoginCredentials {
return LoginCredentials(domain = null, username = null, password = null)
}
Expand Down
30 changes: 30 additions & 0 deletions app/src/androidTest/java/com/duckduckgo/app/tabs/db/TabsDaoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import com.duckduckgo.app.global.db.AppDatabase
import com.duckduckgo.app.tabs.model.TabEntity
import com.duckduckgo.app.tabs.model.TabSelectionEntity
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
Expand Down Expand Up @@ -337,4 +338,33 @@ class TabsDaoTest {

assertEquals(tab.copy(deletable = false), testee.tab(tab.tabId))
}

@Test
fun whenSelectTabByUrlAndTabExistsThenTabIdReturned() = runTest {
val tab = TabEntity(
tabId = "TAB_ID",
url = "https://www.duckduckgo.com/",
position = 0,
deletable = true,
)

testee.insertTab(tab)
val tabId = testee.selectTabByUrl("https://www.duckduckgo.com/")

assertEquals(tabId, tab.tabId)
}

@Test
fun whenSelectTabByUrlAndTabDoesNotExistThenNullReturned() = runTest {
val tab = TabEntity(
tabId = "TAB_ID",
url = "https://www.duckduckgo.com/",
position = 0,
)

testee.insertTab(tab)
val tabId = testee.selectTabByUrl("https://www.quackquackno.com/")

assertNull(tabId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity
import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository
import com.duckduckgo.app.fire.fireproofwebsite.ui.AutomaticFireproofSetting.ALWAYS
import com.duckduckgo.app.fire.fireproofwebsite.ui.AutomaticFireproofSetting.ASK_EVERY_TIME
import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchOptionHandler
import com.duckduckgo.app.global.events.db.UserEventKey
import com.duckduckgo.app.global.events.db.UserEventsStore
import com.duckduckgo.app.global.model.PrivacyShield
Expand Down Expand Up @@ -420,6 +421,7 @@ class BrowserTabViewModel @Inject constructor(
private val loadingBarExperimentManager: LoadingBarExperimentManager,
private val refreshPixelSender: RefreshPixelSender,
private val changeOmnibarPositionFeature: ChangeOmnibarPositionFeature,
private val showOnAppLaunchOptionHandler: ShowOnAppLaunchOptionHandler,
) : WebViewClientListener,
EditSavedSiteListener,
DeleteBookmarkListener,
Expand Down Expand Up @@ -1306,6 +1308,15 @@ class BrowserTabViewModel @Inject constructor(

override fun navigationStateChanged(newWebNavigationState: WebNavigationState) {
val stateChange = newWebNavigationState.compare(webNavigationState)

viewModelScope.launch {
showOnAppLaunchOptionHandler.handleResolvedUrlStorage(
currentUrl = newWebNavigationState.currentUrl,
isRootOfTab = !newWebNavigationState.canGoBack,
tabId = tabId,
)
}

webNavigationState = newWebNavigationState

if (!currentBrowserViewState().browserShowing) return
Expand Down
19 changes: 3 additions & 16 deletions app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,7 @@ import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector
import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter
import com.duckduckgo.app.fire.DataClearer
import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchFeature
import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.LastOpenedTab
import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.NewTabPage
import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.SpecificPage
import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore
import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchOptionHandler
import com.duckduckgo.app.global.ApplicationClearDataState
import com.duckduckgo.app.global.rating.AppEnjoymentPromptEmitter
import com.duckduckgo.app.global.rating.AppEnjoymentPromptOptions
Expand Down Expand Up @@ -61,7 +58,6 @@ import com.duckduckgo.feature.toggles.api.Toggle
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import timber.log.Timber

Expand All @@ -77,7 +73,7 @@ class BrowserViewModel @Inject constructor(
private val pixel: Pixel,
private val skipUrlConversionOnNewTabFeature: SkipUrlConversionOnNewTabFeature,
private val showOnAppLaunchFeature: ShowOnAppLaunchFeature,
private val showOnAppLaunchOptionDataStore: ShowOnAppLaunchOptionDataStore,
private val showOnAppLaunchOptionHandler: ShowOnAppLaunchOptionHandler,
) : ViewModel(),
CoroutineScope {

Expand Down Expand Up @@ -297,16 +293,7 @@ class BrowserViewModel @Inject constructor(
fun handleShowOnAppLaunchOption() {
if (showOnAppLaunchFeature.self().isEnabled()) {
viewModelScope.launch {
when (val option = showOnAppLaunchOptionDataStore.optionFlow.first()) {
LastOpenedTab -> Unit
NewTabPage -> onNewTabRequested()
is SpecificPage -> {
val liveSelectedTabUrl = tabRepository.getSelectedTab()?.url
if (liveSelectedTabUrl != option.url) {
onOpenInNewTabRequested(option.url)
}
}
}
showOnAppLaunchOptionHandler.handleAppLaunchOption()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* 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.generalsettings.showonapplaunch

import android.net.Uri
import androidx.core.net.toUri
import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.LastOpenedTab
import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.NewTabPage
import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.SpecificPage
import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore
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.isHttpOrHttps
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext

interface ShowOnAppLaunchOptionHandler {
suspend fun handleAppLaunchOption()
suspend fun handleResolvedUrlStorage(
currentUrl: String?,
isRootOfTab: Boolean,
tabId: String,
)
}

@ContributesBinding(AppScope::class)
class ShowOnAppLaunchOptionHandlerImpl @Inject constructor(
private val dispatchers: DispatcherProvider,
private val showOnAppLaunchOptionDataStore: ShowOnAppLaunchOptionDataStore,
private val tabRepository: TabRepository,
) : ShowOnAppLaunchOptionHandler {

override suspend fun handleAppLaunchOption() {
when (val option = showOnAppLaunchOptionDataStore.optionFlow.first()) {
LastOpenedTab -> Unit
NewTabPage -> tabRepository.add()
is SpecificPage -> handleSpecificPageOption(option)
}
}

override suspend fun handleResolvedUrlStorage(
currentUrl: String?,
isRootOfTab: Boolean,
tabId: String,
) {
withContext(dispatchers.io()) {
val shouldSaveCurrentUrlForShowOnAppLaunch = currentUrl != null &&
isRootOfTab &&
tabId == showOnAppLaunchOptionDataStore.showOnAppLaunchTabId

if (shouldSaveCurrentUrlForShowOnAppLaunch) {
showOnAppLaunchOptionDataStore.setResolvedPageUrl(currentUrl!!)
}
}
}

private suspend fun handleSpecificPageOption(option: SpecificPage) {
val userUri = option.url.toUri()
val resolvedUri = option.resolvedUrl?.toUri()

val urls = listOfNotNull(userUri, resolvedUri).map { uri ->
stripIfHttpOrHttps(uri)
}

val tabIdUrlMap = getTabIdUrlMap(tabRepository.flowTabs.first())

val existingTabId = tabIdUrlMap.entries.findLast { it.value in urls }?.key

if (existingTabId != null) {
showOnAppLaunchOptionDataStore.setShowOnAppLaunchTabId(existingTabId)
tabRepository.select(existingTabId)
} else {
val tabId = tabRepository.add(url = option.url)
showOnAppLaunchOptionDataStore.setShowOnAppLaunchTabId(tabId)
}
}

private fun stripIfHttpOrHttps(uri: Uri): String {
return if (uri.isHttpOrHttps) {
stripUri(uri)
} else {
uri.toString()
}
}

private fun stripUri(uri: Uri): String = uri.run {
val authority = uri.authority?.removePrefix("www.")
uri.buildUpon()
.scheme(null)
.authority(authority)
.toString()
.replaceFirst("//", "")
}

private fun getTabIdUrlMap(tabs: List<TabEntity>): Map<String, String> {
return tabs
.filterNot { tab -> tab.url.isNullOrBlank() }
.associate { tab ->
val tabUri = tab.url!!.toUri()
val strippedUrl = stripIfHttpOrHttps(tabUri)
tab.tabId to strippedUrl
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ class ShowOnAppLaunchUrlConverterImpl : UrlConverter {

val uri = Uri.parse(url.trim())

val convertedUri = if (uri.scheme == null) {
Uri.Builder().scheme("http").authority(uri.path?.lowercase())
val uriWithScheme = if (uri.scheme == null) {
Uri.Builder()
.scheme("http")
.authority(uri.path?.lowercase())
} else {
uri.buildUpon()
.scheme(uri.scheme?.lowercase())
Expand All @@ -37,9 +39,15 @@ class ShowOnAppLaunchUrlConverterImpl : UrlConverter {
query(uri.query)
fragment(uri.fragment)
}
.build()
.toString()

return Uri.decode(convertedUri)
val uriWithPath = if (uri.path.isNullOrBlank()) {
uriWithScheme.path("/")
} else {
uriWithScheme
}

val processedUrl = uriWithPath.build().toString()

return Uri.decode(processedUrl)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ sealed class ShowOnAppLaunchOption(val id: Int) {

data object LastOpenedTab : ShowOnAppLaunchOption(1)
data object NewTabPage : ShowOnAppLaunchOption(2)
data class SpecificPage(val url: String) : ShowOnAppLaunchOption(3)
data class SpecificPage(val url: String, val resolvedUrl: String? = null) : ShowOnAppLaunchOption(3)

companion object {

Expand Down
Loading
Loading