Skip to content

Commit 343bf34

Browse files
committed
Add sync promo to "bookmark added" dialog
1 parent f06d284 commit 343bf34

File tree

20 files changed

+660
-36
lines changed

20 files changed

+660
-36
lines changed

app/src/main/java/com/duckduckgo/app/bookmarks/dialog/BookmarkAddedConfirmationDialog.kt

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,28 @@
1717
package com.duckduckgo.app.bookmarks.dialog
1818

1919
import android.annotation.SuppressLint
20+
import android.app.Activity
2021
import android.content.Context
2122
import android.graphics.Typeface
2223
import android.text.Spannable
2324
import android.text.SpannableString
2425
import android.text.style.StyleSpan
26+
import android.util.AttributeSet
2527
import android.view.LayoutInflater
28+
import android.view.MotionEvent
2629
import android.view.WindowManager
2730
import android.widget.FrameLayout
31+
import android.widget.LinearLayout
32+
import androidx.core.view.children
33+
import androidx.core.view.isVisible
2834
import androidx.lifecycle.lifecycleScope
35+
import com.duckduckgo.app.bookmarks.BookmarkAddedPromotionPlugin
2936
import com.duckduckgo.app.browser.R
3037
import com.duckduckgo.app.browser.databinding.BottomSheetAddBookmarkBinding
38+
import com.duckduckgo.common.ui.view.gone
39+
import com.duckduckgo.common.ui.view.show
3140
import com.duckduckgo.common.utils.ConflatedJob
41+
import com.duckduckgo.common.utils.plugins.PluginPoint
3242
import com.duckduckgo.savedsites.api.models.BookmarkFolder
3343
import com.google.android.material.bottomsheet.BottomSheetBehavior
3444
import com.google.android.material.bottomsheet.BottomSheetDialog
@@ -37,14 +47,16 @@ import com.google.android.material.shape.MaterialShapeDrawable
3747
import com.google.android.material.shape.ShapeAppearanceModel
3848
import kotlinx.coroutines.delay
3949
import kotlinx.coroutines.launch
50+
import logcat.logcat
4051
import com.duckduckgo.mobile.android.R as CommonR
4152
import com.google.android.material.R as MaterialR
4253

4354
@SuppressLint("NoBottomSheetDialog")
4455
class BookmarkAddedConfirmationDialog(
45-
context: Context,
56+
activity: Activity,
4657
private val bookmarkFolder: BookmarkFolder?,
47-
) : BottomSheetDialog(context) {
58+
private val promoPlugins: PluginPoint<BookmarkAddedPromotionPlugin>,
59+
) : BottomSheetDialog(activity) {
4860

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

78+
addInteractionListeners()
79+
6680
window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
6781
behavior.state = BottomSheetBehavior.STATE_EXPANDED
6882
behavior.isDraggable = false
@@ -84,11 +98,47 @@ class BookmarkAddedConfirmationDialog(
8498
dismiss()
8599
}
86100

101+
watchForPromoViewChanges()
102+
updatePromoViews()
103+
104+
startAutoDismissTimer()
105+
super.show()
106+
}
107+
108+
private fun updatePromoViews() {
109+
lifecycleScope.launch {
110+
val viewsToInclude = promoPlugins.getPlugins().mapNotNull { it.getView() }
111+
logcat { "Sync-promo: updating promo views. Found ${viewsToInclude.size} promos" }
112+
113+
with(binding.promotionContainer) {
114+
removeAllViews()
115+
viewsToInclude.forEach { addView(it) }
116+
}
117+
}
118+
}
119+
120+
private fun addInteractionListeners() {
121+
// any touches anywhere in the dialog will cancel auto-dismiss
122+
binding.root.onTouchObserved = { cancelDialogAutoDismiss() }
123+
}
124+
125+
private fun watchForPromoViewChanges() {
126+
with(binding.promotionContainer) {
127+
viewTreeObserver.addOnGlobalLayoutListener {
128+
if (binding.promotionContainer.children.any { it.isVisible }) {
129+
binding.promotionContainer.show()
130+
} else {
131+
binding.promotionContainer.gone()
132+
}
133+
}
134+
}
135+
}
136+
137+
private fun startAutoDismissTimer() {
87138
autoDismissDialogJob += lifecycleScope.launch {
88139
delay(BOOKMARKS_BOTTOM_SHEET_DURATION)
89140
dismiss()
90141
}
91-
super.show()
92142
}
93143

94144
private fun cancelDialogAutoDismiss() {
@@ -130,3 +180,23 @@ class BookmarkAddedConfirmationDialog(
130180
private const val BOOKMARKS_BOTTOM_SHEET_DURATION = 3_500L
131181
}
132182
}
183+
184+
/**
185+
* A LinearLayout that observes all touch events flowing through it
186+
* without interfering with child view touch handling.
187+
*/
188+
class TouchObservingLinearLayout @JvmOverloads constructor(
189+
context: Context,
190+
attrs: AttributeSet? = null,
191+
defStyleAttr: Int = 0,
192+
) : LinearLayout(context, attrs, defStyleAttr) {
193+
194+
var onTouchObserved: (() -> Unit)? = null
195+
196+
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
197+
if (ev.action == MotionEvent.ACTION_DOWN) {
198+
onTouchObserved?.invoke()
199+
}
200+
return super.dispatchTouchEvent(ev)
201+
}
202+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.app.bookmarks.dialog
18+
19+
import android.app.Activity
20+
import com.duckduckgo.app.bookmarks.BookmarkAddedPromotionPlugin
21+
import com.duckduckgo.common.utils.plugins.PluginPoint
22+
import com.duckduckgo.di.scopes.AppScope
23+
import com.duckduckgo.savedsites.api.models.BookmarkFolder
24+
import com.squareup.anvil.annotations.ContributesBinding
25+
import javax.inject.Inject
26+
27+
interface BookmarkAddedConfirmationDialogFactory {
28+
29+
fun create(activity: Activity, bookmarkFolder: BookmarkFolder?): BookmarkAddedConfirmationDialog
30+
}
31+
32+
@ContributesBinding(AppScope::class)
33+
class ReadyBookmarkAddedConfirmationDialogFactory @Inject constructor(
34+
private val plugins: PluginPoint<BookmarkAddedPromotionPlugin>,
35+
) : BookmarkAddedConfirmationDialogFactory {
36+
override fun create(activity: Activity, bookmarkFolder: BookmarkFolder?): BookmarkAddedConfirmationDialog {
37+
return BookmarkAddedConfirmationDialog(activity, bookmarkFolder, plugins)
38+
}
39+
}

app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ import androidx.webkit.WebViewFeature
9898
import com.duckduckgo.anvil.annotations.InjectWith
9999
import com.duckduckgo.app.accessibility.data.AccessibilitySettingsDataStore
100100
import com.duckduckgo.app.bookmarks.dialog.BookmarkAddedConfirmationDialog
101+
import com.duckduckgo.app.bookmarks.dialog.BookmarkAddedConfirmationDialogFactory
101102
import com.duckduckgo.app.browser.BrowserTabViewModel.FileChooserRequestedParams
102103
import com.duckduckgo.app.browser.R.string
103104
import com.duckduckgo.app.browser.SSLErrorType.NONE
@@ -425,6 +426,9 @@ class BrowserTabFragment :
425426
@Inject
426427
lateinit var blobConverterInjector: BlobConverterInjector
427428

429+
@Inject
430+
lateinit var bookmarkAddedConfirmationDialogFactory: BookmarkAddedConfirmationDialogFactory
431+
428432
val tabId get() = requireArguments()[TAB_ID_ARG] as String
429433
private val customTabToolbarColor get() = requireArguments().getInt(CUSTOM_TAB_TOOLBAR_COLOR_ARG)
430434
private val tabDisplayedInCustomTabScreen get() = requireArguments().getBoolean(TAB_DISPLAYED_IN_CUSTOM_TAB_SCREEN_ARG)
@@ -3762,8 +3766,8 @@ class BrowserTabFragment :
37623766
}
37633767

37643768
private fun savedSiteAdded(savedSiteChangedViewState: SavedSiteChangedViewState) {
3765-
context?.let { ctx ->
3766-
val dialog = BookmarkAddedConfirmationDialog(ctx, savedSiteChangedViewState.bookmarkFolder)
3769+
activity?.let { activity ->
3770+
val dialog = bookmarkAddedConfirmationDialogFactory.create(activity, savedSiteChangedViewState.bookmarkFolder)
37673771
dialog.addEventListener(
37683772
object : BookmarkAddedConfirmationDialog.EventListener() {
37693773
override fun onFavoriteStateChangeClicked(isFavorited: Boolean) {

app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import androidx.datastore.preferences.core.Preferences
2424
import androidx.datastore.preferences.preferencesDataStore
2525
import androidx.work.WorkManager
2626
import com.duckduckgo.adclick.api.AdClickManager
27+
import com.duckduckgo.anvil.annotations.ContributesPluginPoint
28+
import com.duckduckgo.app.bookmarks.BookmarkAddedPromotionPlugin
2729
import com.duckduckgo.app.browser.*
2830
import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector
2931
import com.duckduckgo.app.browser.addtohome.AddToHomeSystemCapabilityDetector
@@ -383,3 +385,6 @@ class BrowserModule {
383385

384386
@Qualifier
385387
annotation class IndonesiaNewTabSection
388+
389+
@ContributesPluginPoint(scope = AppScope::class, boundType = BookmarkAddedPromotionPlugin::class)
390+
private interface BookmarkAddedPromotionPluginPoint

app/src/main/res/layout/bottom_sheet_add_bookmark.xml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
~ limitations under the License.
1515
-->
1616

17-
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
17+
<com.duckduckgo.app.bookmarks.dialog.TouchObservingLinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
1818
xmlns:app="http://schemas.android.com/apk/res-auto"
1919
xmlns:tools="http://schemas.android.com/tools"
2020
android:layout_width="match_parent"
@@ -64,4 +64,13 @@
6464
app:leadingIconBackground="circular"
6565
app:primaryText="@string/addBookmarkDialogEditBookmark" />
6666

67-
</LinearLayout>
67+
<LinearLayout
68+
android:id="@+id/promotionContainer"
69+
android:layout_width="match_parent"
70+
android:layout_height="wrap_content"
71+
android:layout_marginStart="@dimen/keyline_4"
72+
android:layout_marginEnd="@dimen/keyline_4"
73+
android:layout_marginTop="@dimen/keyline_5"
74+
android:orientation="vertical" />
75+
76+
</com.duckduckgo.app.bookmarks.dialog.TouchObservingLinearLayout>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.app.bookmarks
18+
19+
import android.view.View
20+
21+
interface BookmarkAddedPromotionPlugin {
22+
23+
/**
24+
* Returns a view to be displayed in the bookmark added confirmation dialog, or null if the promotion should not be shown.
25+
* @return Some promotions may require criteria to be met before they are shown. If the criteria is not met, this method should return null.
26+
*/
27+
suspend fun getView(): View?
28+
29+
companion object {
30+
const val PRIORITY_KEY_BOOKMARK_ADDED_PROMOTION = 100
31+
}
32+
}

sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/promotion/SyncPromotionDataStore.kt

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,42 +22,58 @@ import androidx.datastore.preferences.core.edit
2222
import androidx.datastore.preferences.core.longPreferencesKey
2323
import com.duckduckgo.di.scopes.AppScope
2424
import com.duckduckgo.sync.impl.di.SyncPromotion
25+
import com.duckduckgo.sync.impl.promotion.SyncPromotionDataStore.PromotionType
26+
import com.duckduckgo.sync.impl.promotion.SyncPromotionDataStore.PromotionType.BookmarkAddedDialog
27+
import com.duckduckgo.sync.impl.promotion.SyncPromotionDataStore.PromotionType.BookmarksScreen
28+
import com.duckduckgo.sync.impl.promotion.SyncPromotionDataStore.PromotionType.PasswordsScreen
2529
import com.squareup.anvil.annotations.ContributesBinding
2630
import kotlinx.coroutines.flow.firstOrNull
2731
import kotlinx.coroutines.flow.map
2832
import javax.inject.Inject
2933

3034
interface SyncPromotionDataStore {
31-
suspend fun hasBookmarksPromoBeenDismissed(): Boolean
32-
suspend fun recordBookmarksPromoDismissed()
35+
suspend fun hasPromoBeenDismissed(promotionType: PromotionType): Boolean
36+
suspend fun recordPromoDismissed(promotionType: PromotionType)
37+
suspend fun clearPromoHistory(promotionType: PromotionType)
3338

34-
suspend fun hasPasswordsPromoBeenDismissed(): Boolean
35-
suspend fun recordPasswordsPromoDismissed()
39+
sealed interface PromotionType {
40+
object BookmarksScreen : PromotionType
41+
object PasswordsScreen : PromotionType
42+
object BookmarkAddedDialog : PromotionType
43+
}
3644
}
3745

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

43-
override suspend fun hasBookmarksPromoBeenDismissed(): Boolean {
44-
return dataStore.data.map { it[bookmarksPromoDismissedKey] }.firstOrNull() != null
51+
override suspend fun hasPromoBeenDismissed(promotionType: PromotionType): Boolean {
52+
val key = promotionType.key()
53+
return dataStore.data.map { it[key] }.firstOrNull() != null
4554
}
4655

47-
override suspend fun recordBookmarksPromoDismissed() {
48-
dataStore.edit { it[bookmarksPromoDismissedKey] = System.currentTimeMillis() }
56+
override suspend fun recordPromoDismissed(promotionType: PromotionType) {
57+
val key = promotionType.key()
58+
dataStore.edit { it[key] = System.currentTimeMillis() }
4959
}
5060

51-
override suspend fun hasPasswordsPromoBeenDismissed(): Boolean {
52-
return dataStore.data.map { it[passwordsPromoDismissedKey] }.firstOrNull() != null
61+
override suspend fun clearPromoHistory(promotionType: PromotionType) {
62+
val key = promotionType.key()
63+
dataStore.edit { it.remove(key) }
5364
}
5465

55-
override suspend fun recordPasswordsPromoDismissed() {
56-
dataStore.edit { it[passwordsPromoDismissedKey] = System.currentTimeMillis() }
66+
private fun PromotionType.key(): Preferences.Key<Long> {
67+
return when (this) {
68+
BookmarkAddedDialog -> bookmarkAddedDialogPromoDismissedKey
69+
BookmarksScreen -> bookmarksScreenPromoDismissedKey
70+
PasswordsScreen -> passwordsPromoDismissedKey
71+
}
5772
}
5873

5974
companion object {
60-
private val bookmarksPromoDismissedKey = longPreferencesKey("bookmarks_promo_dismissed")
75+
private val bookmarksScreenPromoDismissedKey = longPreferencesKey("bookmarks_promo_dismissed")
76+
private val bookmarkAddedDialogPromoDismissedKey = longPreferencesKey("bookmark_added_dialog_promo_dismissed")
6177
private val passwordsPromoDismissedKey = longPreferencesKey("passwords_promo_dismissed")
6278
}
6379
}

sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/promotion/SyncPromotionFeature.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,8 @@ interface SyncPromotionFeature {
4444
@Toggle.InternalAlwaysEnabled
4545
@Toggle.DefaultValue(DefaultFeatureValue.FALSE)
4646
fun passwords(): Toggle
47+
48+
@Toggle.InternalAlwaysEnabled
49+
@Toggle.DefaultValue(DefaultFeatureValue.FALSE)
50+
fun bookmarkAddedDialog(): Toggle
4751
}

0 commit comments

Comments
 (0)