Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -17,18 +17,28 @@
package com.duckduckgo.app.bookmarks.dialog

import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.graphics.Typeface
import android.text.Spannable
import android.text.SpannableString
import android.text.style.StyleSpan
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.core.view.children
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.duckduckgo.app.bookmarks.BookmarkAddedPromotionPlugin
import com.duckduckgo.app.browser.R
import com.duckduckgo.app.browser.databinding.BottomSheetAddBookmarkBinding
import com.duckduckgo.common.ui.view.gone
import com.duckduckgo.common.ui.view.show
import com.duckduckgo.common.utils.ConflatedJob
import com.duckduckgo.common.utils.plugins.PluginPoint
import com.duckduckgo.savedsites.api.models.BookmarkFolder
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
Expand All @@ -37,14 +47,16 @@ import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import logcat.logcat
import com.duckduckgo.mobile.android.R as CommonR
import com.google.android.material.R as MaterialR

@SuppressLint("NoBottomSheetDialog")
class BookmarkAddedConfirmationDialog(
context: Context,
activity: Activity,
private val bookmarkFolder: BookmarkFolder?,
) : BottomSheetDialog(context) {
private val promoPlugins: PluginPoint<BookmarkAddedPromotionPlugin>,
) : BottomSheetDialog(activity) {

abstract class EventListener {
/** Sets a listener to be invoked when favorite state is changed */
Expand All @@ -63,6 +75,8 @@ class BookmarkAddedConfirmationDialog(
override fun show() {
setContentView(binding.root)

addInteractionListeners()

window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
behavior.state = BottomSheetBehavior.STATE_EXPANDED
behavior.isDraggable = false
Expand All @@ -84,11 +98,47 @@ class BookmarkAddedConfirmationDialog(
dismiss()
}

watchForPromoViewChanges()
updatePromoViews()

startAutoDismissTimer()
super.show()
}

private fun updatePromoViews() {
lifecycleScope.launch {
val viewsToInclude = promoPlugins.getPlugins().mapNotNull { it.getView() }
logcat { "Sync-promo: updating promo views. Found ${viewsToInclude.size} promos" }

with(binding.promotionContainer) {
removeAllViews()
viewsToInclude.forEach { addView(it) }
}
}
}

private fun addInteractionListeners() {
// any touches anywhere in the dialog will cancel auto-dismiss
binding.root.onTouchObserved = { cancelDialogAutoDismiss() }
}

private fun watchForPromoViewChanges() {
with(binding.promotionContainer) {
viewTreeObserver.addOnGlobalLayoutListener {
if (binding.promotionContainer.children.any { it.isVisible }) {
binding.promotionContainer.show()
} else {
binding.promotionContainer.gone()
}
}
}
}

private fun startAutoDismissTimer() {
autoDismissDialogJob += lifecycleScope.launch {
delay(BOOKMARKS_BOTTOM_SHEET_DURATION)
dismiss()
}
super.show()
}

private fun cancelDialogAutoDismiss() {
Expand Down Expand Up @@ -130,3 +180,23 @@ class BookmarkAddedConfirmationDialog(
private const val BOOKMARKS_BOTTOM_SHEET_DURATION = 3_500L
}
}

/**
* A LinearLayout that observes all touch events flowing through it
* without interfering with child view touch handling.
*/
class TouchObservingLinearLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : LinearLayout(context, attrs, defStyleAttr) {

var onTouchObserved: (() -> Unit)? = null

override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
if (ev.action == MotionEvent.ACTION_DOWN) {
onTouchObserved?.invoke()
}
return super.dispatchTouchEvent(ev)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 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.bookmarks.dialog

import android.app.Activity
import com.duckduckgo.app.bookmarks.BookmarkAddedPromotionPlugin
import com.duckduckgo.common.utils.plugins.PluginPoint
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.savedsites.api.models.BookmarkFolder
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject

interface BookmarkAddedConfirmationDialogFactory {

fun create(activity: Activity, bookmarkFolder: BookmarkFolder?): BookmarkAddedConfirmationDialog
}

@ContributesBinding(AppScope::class)
class ReadyBookmarkAddedConfirmationDialogFactory @Inject constructor(
private val plugins: PluginPoint<BookmarkAddedPromotionPlugin>,
) : BookmarkAddedConfirmationDialogFactory {
override fun create(activity: Activity, bookmarkFolder: BookmarkFolder?): BookmarkAddedConfirmationDialog {
return BookmarkAddedConfirmationDialog(activity, bookmarkFolder, plugins)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ import androidx.webkit.WebViewFeature
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.app.accessibility.data.AccessibilitySettingsDataStore
import com.duckduckgo.app.bookmarks.dialog.BookmarkAddedConfirmationDialog
import com.duckduckgo.app.bookmarks.dialog.BookmarkAddedConfirmationDialogFactory
import com.duckduckgo.app.browser.BrowserTabViewModel.FileChooserRequestedParams
import com.duckduckgo.app.browser.R.string
import com.duckduckgo.app.browser.SSLErrorType.NONE
Expand Down Expand Up @@ -425,6 +426,9 @@ class BrowserTabFragment :
@Inject
lateinit var blobConverterInjector: BlobConverterInjector

@Inject
lateinit var bookmarkAddedConfirmationDialogFactory: BookmarkAddedConfirmationDialogFactory

val tabId get() = requireArguments()[TAB_ID_ARG] as String
private val customTabToolbarColor get() = requireArguments().getInt(CUSTOM_TAB_TOOLBAR_COLOR_ARG)
private val tabDisplayedInCustomTabScreen get() = requireArguments().getBoolean(TAB_DISPLAYED_IN_CUSTOM_TAB_SCREEN_ARG)
Expand Down Expand Up @@ -3762,8 +3766,8 @@ class BrowserTabFragment :
}

private fun savedSiteAdded(savedSiteChangedViewState: SavedSiteChangedViewState) {
context?.let { ctx ->
val dialog = BookmarkAddedConfirmationDialog(ctx, savedSiteChangedViewState.bookmarkFolder)
activity?.let { activity ->
val dialog = bookmarkAddedConfirmationDialogFactory.create(activity, savedSiteChangedViewState.bookmarkFolder)
dialog.addEventListener(
object : BookmarkAddedConfirmationDialog.EventListener() {
override fun onFavoriteStateChangeClicked(isFavorited: Boolean) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import androidx.work.WorkManager
import com.duckduckgo.adclick.api.AdClickManager
import com.duckduckgo.anvil.annotations.ContributesPluginPoint
import com.duckduckgo.app.bookmarks.BookmarkAddedPromotionPlugin
import com.duckduckgo.app.browser.*
import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector
import com.duckduckgo.app.browser.addtohome.AddToHomeSystemCapabilityDetector
Expand Down Expand Up @@ -383,3 +385,6 @@ class BrowserModule {

@Qualifier
annotation class IndonesiaNewTabSection

@ContributesPluginPoint(scope = AppScope::class, boundType = BookmarkAddedPromotionPlugin::class)
private interface BookmarkAddedPromotionPluginPoint
13 changes: 11 additions & 2 deletions app/src/main/res/layout/bottom_sheet_add_bookmark.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
~ limitations under the License.
-->

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<com.duckduckgo.app.bookmarks.dialog.TouchObservingLinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
Expand Down Expand Up @@ -64,4 +64,13 @@
app:leadingIconBackground="circular"
app:primaryText="@string/addBookmarkDialogEditBookmark" />

</LinearLayout>
<LinearLayout
android:id="@+id/promotionContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/keyline_4"
android:layout_marginEnd="@dimen/keyline_4"
android:layout_marginTop="@dimen/keyline_5"
android:orientation="vertical" />

</com.duckduckgo.app.bookmarks.dialog.TouchObservingLinearLayout>
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 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.bookmarks

import android.view.View

interface BookmarkAddedPromotionPlugin {

/**
* Returns a view to be displayed in the bookmark added confirmation dialog, or null if the promotion should not be shown.
* @return Some promotions may require criteria to be met before they are shown. If the criteria is not met, this method should return null.
*/
suspend fun getView(): View?

companion object {
const val PRIORITY_KEY_BOOKMARK_ADDED_PROMOTION = 100
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,42 +22,58 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.sync.impl.di.SyncPromotion
import com.duckduckgo.sync.impl.promotion.SyncPromotionDataStore.PromotionType
import com.duckduckgo.sync.impl.promotion.SyncPromotionDataStore.PromotionType.BookmarkAddedDialog
import com.duckduckgo.sync.impl.promotion.SyncPromotionDataStore.PromotionType.BookmarksScreen
import com.duckduckgo.sync.impl.promotion.SyncPromotionDataStore.PromotionType.PasswordsScreen
import com.squareup.anvil.annotations.ContributesBinding
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import javax.inject.Inject

interface SyncPromotionDataStore {
suspend fun hasBookmarksPromoBeenDismissed(): Boolean
suspend fun recordBookmarksPromoDismissed()
suspend fun hasPromoBeenDismissed(promotionType: PromotionType): Boolean
suspend fun recordPromoDismissed(promotionType: PromotionType)
suspend fun clearPromoHistory(promotionType: PromotionType)

suspend fun hasPasswordsPromoBeenDismissed(): Boolean
suspend fun recordPasswordsPromoDismissed()
sealed interface PromotionType {
object BookmarksScreen : PromotionType
object PasswordsScreen : PromotionType
object BookmarkAddedDialog : PromotionType
}
}

@ContributesBinding(AppScope::class)
class SyncPromotionDataStoreImpl @Inject constructor(
@SyncPromotion private val dataStore: DataStore<Preferences>,
) : SyncPromotionDataStore {

override suspend fun hasBookmarksPromoBeenDismissed(): Boolean {
return dataStore.data.map { it[bookmarksPromoDismissedKey] }.firstOrNull() != null
override suspend fun hasPromoBeenDismissed(promotionType: PromotionType): Boolean {
val key = promotionType.key()
return dataStore.data.map { it[key] }.firstOrNull() != null
}

override suspend fun recordBookmarksPromoDismissed() {
dataStore.edit { it[bookmarksPromoDismissedKey] = System.currentTimeMillis() }
override suspend fun recordPromoDismissed(promotionType: PromotionType) {
val key = promotionType.key()
dataStore.edit { it[key] = System.currentTimeMillis() }
}

override suspend fun hasPasswordsPromoBeenDismissed(): Boolean {
return dataStore.data.map { it[passwordsPromoDismissedKey] }.firstOrNull() != null
override suspend fun clearPromoHistory(promotionType: PromotionType) {
val key = promotionType.key()
dataStore.edit { it.remove(key) }
}

override suspend fun recordPasswordsPromoDismissed() {
dataStore.edit { it[passwordsPromoDismissedKey] = System.currentTimeMillis() }
private fun PromotionType.key(): Preferences.Key<Long> {
return when (this) {
BookmarkAddedDialog -> bookmarkAddedDialogPromoDismissedKey
BookmarksScreen -> bookmarksScreenPromoDismissedKey
PasswordsScreen -> passwordsPromoDismissedKey
}
}

companion object {
private val bookmarksPromoDismissedKey = longPreferencesKey("bookmarks_promo_dismissed")
private val bookmarksScreenPromoDismissedKey = longPreferencesKey("bookmarks_promo_dismissed")
private val bookmarkAddedDialogPromoDismissedKey = longPreferencesKey("bookmark_added_dialog_promo_dismissed")
private val passwordsPromoDismissedKey = longPreferencesKey("passwords_promo_dismissed")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,8 @@ interface SyncPromotionFeature {
@Toggle.InternalAlwaysEnabled
@Toggle.DefaultValue(DefaultFeatureValue.FALSE)
fun passwords(): Toggle

@Toggle.InternalAlwaysEnabled
@Toggle.DefaultValue(DefaultFeatureValue.FALSE)
fun bookmarkAddedDialog(): Toggle
}
Loading
Loading