Skip to content

Commit 56b3bc7

Browse files
authored
Add sync promo to "bookmark added" dialog (#7136)
Task/Issue URL: https://app.asana.com/1/137249556945/task/1211704631712055 ### Description Adds a way for users adding a bookmark to see that they can sync bookmarks. Only shows if: - user does not have sync set up already - user has never dismissed it before - feature flag is enabled for both `syncPromotion.bookmarkAddedDialog` and `syncPromotion` <img width="30%" height="2424" alt="Screenshot_20251119_163058" src="https://github.com/user-attachments/assets/54764911-f91c-4e15-98b1-16cabe0404a9" /> ### Steps to test this PR #### Sync disabled - [x] Fresh install on `internal` build type - [x] Visit a site, and use overflow to `Add Bookmark` - [x] Confirm the dialog contains the option to jump to sync - [x] Let the dialog auto-dismiss (after ~3.5s) - [x] Use overflow to `Edit Bookmark` and then delete it. Return to browser. - [x] Add bookmark again; confirm again the promo line is still shown and this time tap on it; verify it takes you to `Sync & Backup` #### Sync already enabled - [x] Set up sync - [x] Visit a site, and use overflow to `Add Bookmark` - [x] Verify the sync promo is NOT shown #### Feature disabled - [x] Fresh install - [x] Use feature flag inventory to disable `syncPromotion.bookmarkAddedDialog` - [x] Visit a site, and use overflow to `Add Bookmark` - [x] Verify the sync promo is NOT shown #### Permanently dismiss - [x] Fresh install - [x] Visit a site, and use overflow to `Add Bookmark` - [x] Tap on the promo's overflow and choose to `Hide` - [x] Verify it disappears - [x] Bring up the dialog again and verify it doesn't reappear --------- Co-authored-by: Craig Russell <1336281+CDRussell@users.noreply.github.com>
1 parent 55d4718 commit 56b3bc7

File tree

20 files changed

+671
-34
lines changed

20 files changed

+671
-34
lines changed

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

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,22 @@ import android.graphics.Typeface
2222
import android.text.Spannable
2323
import android.text.SpannableString
2424
import android.text.style.StyleSpan
25+
import android.util.AttributeSet
2526
import android.view.LayoutInflater
27+
import android.view.MotionEvent
2628
import android.view.WindowManager
2729
import android.widget.FrameLayout
30+
import android.widget.LinearLayout
31+
import androidx.core.view.children
32+
import androidx.core.view.isVisible
2833
import androidx.lifecycle.lifecycleScope
34+
import com.duckduckgo.app.bookmarks.BookmarkAddedPromotionPlugin
2935
import com.duckduckgo.app.browser.R
3036
import com.duckduckgo.app.browser.databinding.BottomSheetAddBookmarkBinding
37+
import com.duckduckgo.common.ui.view.gone
38+
import com.duckduckgo.common.ui.view.show
3139
import com.duckduckgo.common.utils.ConflatedJob
40+
import com.duckduckgo.common.utils.plugins.PluginPoint
3241
import com.duckduckgo.savedsites.api.models.BookmarkFolder
3342
import com.google.android.material.bottomsheet.BottomSheetBehavior
3443
import com.google.android.material.bottomsheet.BottomSheetDialog
@@ -37,13 +46,15 @@ import com.google.android.material.shape.MaterialShapeDrawable
3746
import com.google.android.material.shape.ShapeAppearanceModel
3847
import kotlinx.coroutines.delay
3948
import kotlinx.coroutines.launch
49+
import logcat.logcat
4050
import com.duckduckgo.mobile.android.R as CommonR
4151
import com.google.android.material.R as MaterialR
4252

4353
@SuppressLint("NoBottomSheetDialog")
4454
class BookmarkAddedConfirmationDialog(
4555
context: Context,
4656
private val bookmarkFolder: BookmarkFolder?,
57+
private val promoPlugins: PluginPoint<BookmarkAddedPromotionPlugin>,
4758
) : BottomSheetDialog(context) {
4859

4960
abstract class EventListener {
@@ -63,6 +74,8 @@ class BookmarkAddedConfirmationDialog(
6374
override fun show() {
6475
setContentView(binding.root)
6576

77+
addInteractionListeners()
78+
6679
window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
6780
behavior.state = BottomSheetBehavior.STATE_EXPANDED
6881
behavior.isDraggable = false
@@ -84,11 +97,47 @@ class BookmarkAddedConfirmationDialog(
8497
dismiss()
8598
}
8699

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

94143
private fun cancelDialogAutoDismiss() {
@@ -130,3 +179,23 @@ class BookmarkAddedConfirmationDialog(
130179
private const val BOOKMARKS_BOTTOM_SHEET_DURATION = 3_500L
131180
}
132181
}
182+
183+
/**
184+
* A LinearLayout that observes all touch events flowing through it
185+
* without interfering with child view touch handling.
186+
*/
187+
class TouchObservingLinearLayout @JvmOverloads constructor(
188+
context: Context,
189+
attrs: AttributeSet? = null,
190+
defStyleAttr: Int = 0,
191+
) : LinearLayout(context, attrs, defStyleAttr) {
192+
193+
var onTouchObserved: (() -> Unit)? = null
194+
195+
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
196+
if (ev.action == MotionEvent.ACTION_DOWN) {
197+
onTouchObserved?.invoke()
198+
}
199+
return super.dispatchTouchEvent(ev)
200+
}
201+
}
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.content.Context
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(context: Context, 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(context: Context, bookmarkFolder: BookmarkFolder?): BookmarkAddedConfirmationDialog {
37+
return BookmarkAddedConfirmationDialog(context, 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
@@ -426,6 +427,9 @@ class BrowserTabFragment :
426427
@Inject
427428
lateinit var blobConverterInjector: BlobConverterInjector
428429

430+
@Inject
431+
lateinit var bookmarkAddedConfirmationDialogFactory: BookmarkAddedConfirmationDialogFactory
432+
429433
val tabId get() = requireArguments()[TAB_ID_ARG] as String
430434
private val customTabToolbarColor get() = requireArguments().getInt(CUSTOM_TAB_TOOLBAR_COLOR_ARG)
431435
private val tabDisplayedInCustomTabScreen get() = requireArguments().getBoolean(TAB_DISPLAYED_IN_CUSTOM_TAB_SCREEN_ARG)
@@ -3765,8 +3769,8 @@ class BrowserTabFragment :
37653769
}
37663770

37673771
private fun savedSiteAdded(savedSiteChangedViewState: SavedSiteChangedViewState) {
3768-
context?.let { ctx ->
3769-
val dialog = BookmarkAddedConfirmationDialog(ctx, savedSiteChangedViewState.bookmarkFolder)
3772+
activity?.let { activity ->
3773+
val dialog = bookmarkAddedConfirmationDialogFactory.create(activity, savedSiteChangedViewState.bookmarkFolder)
37703774
dialog.addEventListener(
37713775
object : BookmarkAddedConfirmationDialog.EventListener() {
37723776
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
@@ -386,3 +388,6 @@ class BrowserModule {
386388

387389
@Qualifier
388390
annotation class IndonesiaNewTabSection
391+
392+
@ContributesPluginPoint(scope = AppScope::class, boundType = BookmarkAddedPromotionPlugin::class)
393+
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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,7 @@ interface SyncPromotionFeature {
4444
@Toggle.InternalAlwaysEnabled
4545
@Toggle.DefaultValue(DefaultFeatureValue.FALSE)
4646
fun passwords(): Toggle
47+
48+
@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
49+
fun bookmarkAddedDialog(): Toggle
4750
}

0 commit comments

Comments
 (0)