diff --git a/app/src/main/java/com/duckduckgo/app/bookmarks/dialog/BookmarkAddedConfirmationDialog.kt b/app/src/main/java/com/duckduckgo/app/bookmarks/dialog/BookmarkAddedConfirmationDialog.kt index 9aeb432c1c70..63443eb6762d 100644 --- a/app/src/main/java/com/duckduckgo/app/bookmarks/dialog/BookmarkAddedConfirmationDialog.kt +++ b/app/src/main/java/com/duckduckgo/app/bookmarks/dialog/BookmarkAddedConfirmationDialog.kt @@ -22,13 +22,22 @@ 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 @@ -37,6 +46,7 @@ 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 @@ -44,6 +54,7 @@ import com.google.android.material.R as MaterialR class BookmarkAddedConfirmationDialog( context: Context, private val bookmarkFolder: BookmarkFolder?, + private val promoPlugins: PluginPoint, ) : BottomSheetDialog(context) { abstract class EventListener { @@ -63,6 +74,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 @@ -84,11 +97,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() { @@ -130,3 +179,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) + } +} diff --git a/app/src/main/java/com/duckduckgo/app/bookmarks/dialog/BookmarkAddedConfirmationDialogFactory.kt b/app/src/main/java/com/duckduckgo/app/bookmarks/dialog/BookmarkAddedConfirmationDialogFactory.kt new file mode 100644 index 000000000000..7d46dbdd6314 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/bookmarks/dialog/BookmarkAddedConfirmationDialogFactory.kt @@ -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.content.Context +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(context: Context, bookmarkFolder: BookmarkFolder?): BookmarkAddedConfirmationDialog +} + +@ContributesBinding(AppScope::class) +class ReadyBookmarkAddedConfirmationDialogFactory @Inject constructor( + private val plugins: PluginPoint, +) : BookmarkAddedConfirmationDialogFactory { + override fun create(context: Context, bookmarkFolder: BookmarkFolder?): BookmarkAddedConfirmationDialog { + return BookmarkAddedConfirmationDialog(context, bookmarkFolder, plugins) + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index 5a73e977262e..5a596283a437 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -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 @@ -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) @@ -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) { diff --git a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt index f068e06af9f9..e946786b6d7a 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt @@ -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 @@ -383,3 +385,6 @@ class BrowserModule { @Qualifier annotation class IndonesiaNewTabSection + +@ContributesPluginPoint(scope = AppScope::class, boundType = BookmarkAddedPromotionPlugin::class) +private interface BookmarkAddedPromotionPluginPoint diff --git a/app/src/main/res/layout/bottom_sheet_add_bookmark.xml b/app/src/main/res/layout/bottom_sheet_add_bookmark.xml index 84e8e3cf8fff..e8cc5acacc1a 100644 --- a/app/src/main/res/layout/bottom_sheet_add_bookmark.xml +++ b/app/src/main/res/layout/bottom_sheet_add_bookmark.xml @@ -14,7 +14,7 @@ ~ limitations under the License. --> - - \ No newline at end of file + + + \ No newline at end of file diff --git a/browser-api/src/main/java/com/duckduckgo/app/bookmarks/BookmarkAddedPromotionPlugin.kt b/browser-api/src/main/java/com/duckduckgo/app/bookmarks/BookmarkAddedPromotionPlugin.kt new file mode 100644 index 000000000000..74ee4f5e76f2 --- /dev/null +++ b/browser-api/src/main/java/com/duckduckgo/app/bookmarks/BookmarkAddedPromotionPlugin.kt @@ -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 + } +} diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/promotion/SyncPromotionDataStore.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/promotion/SyncPromotionDataStore.kt index 6830c87c61a1..78d9947884d4 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/promotion/SyncPromotionDataStore.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/promotion/SyncPromotionDataStore.kt @@ -22,17 +22,25 @@ 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) @@ -40,24 +48,32 @@ class SyncPromotionDataStoreImpl @Inject constructor( @SyncPromotion private val dataStore: DataStore, ) : 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 { + 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") } } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/promotion/SyncPromotionFeature.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/promotion/SyncPromotionFeature.kt index 86f911d94d04..1d968a994b7c 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/promotion/SyncPromotionFeature.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/promotion/SyncPromotionFeature.kt @@ -44,4 +44,7 @@ interface SyncPromotionFeature { @Toggle.InternalAlwaysEnabled @Toggle.DefaultValue(DefaultFeatureValue.FALSE) fun passwords(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun bookmarkAddedDialog(): Toggle } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/promotion/SyncPromotions.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/promotion/SyncPromotions.kt index fb81cbe3ea68..6cdc1aeb8978 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/promotion/SyncPromotions.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/promotion/SyncPromotions.kt @@ -19,6 +19,8 @@ package com.duckduckgo.sync.impl.promotion import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.sync.api.DeviceSyncState +import com.duckduckgo.sync.impl.promotion.SyncPromotionDataStore.PromotionType +import com.duckduckgo.sync.impl.promotion.bookmarks.addeddialog.SetupSyncBookmarkAddedPromoRules import com.squareup.anvil.annotations.ContributesBinding import kotlinx.coroutines.withContext import javax.inject.Inject @@ -39,6 +41,16 @@ interface SyncPromotions { */ suspend fun recordBookmarksPromotionDismissed() + /** + * Returns true if the `bookmark added` dialog promotion should be shown to the user. + */ + suspend fun canShowBookmarkAddedDialogPromotion(): Boolean + + /** + * Records that the `bookmark added` promotion has been dismissed. + */ + suspend fun recordBookmarkAddedDialogPromotionDismissed() + /** * Returns true if the passwords promotion should be shown to the user. */ @@ -56,6 +68,7 @@ class SyncPromotionsImpl @Inject constructor( private val syncPromotionFeature: SyncPromotionFeature, private val dispatchers: DispatcherProvider, private val dataStore: SyncPromotionDataStore, + private val bookmarkAddedPromoRules: SetupSyncBookmarkAddedPromoRules, ) : SyncPromotions { override suspend fun canShowBookmarksPromotion(savedBookmarks: Int): Boolean { @@ -65,7 +78,7 @@ class SyncPromotionsImpl @Inject constructor( if (!isSyncFeatureEnabled() || isUserSyncingAlready()) return@withContext false if (!isBookmarksPromoEnabled()) return@withContext false - if (dataStore.hasBookmarksPromoBeenDismissed()) return@withContext false + if (dataStore.hasPromoBeenDismissed(PromotionType.BookmarksScreen)) return@withContext false true } @@ -73,7 +86,17 @@ class SyncPromotionsImpl @Inject constructor( override suspend fun recordBookmarksPromotionDismissed() { withContext(dispatchers.io()) { - dataStore.recordBookmarksPromoDismissed() + dataStore.recordPromoDismissed(PromotionType.BookmarksScreen) + } + } + + override suspend fun canShowBookmarkAddedDialogPromotion(): Boolean { + return bookmarkAddedPromoRules.canShowPromo() + } + + override suspend fun recordBookmarkAddedDialogPromotionDismissed() { + withContext(dispatchers.io()) { + dataStore.recordPromoDismissed(PromotionType.BookmarkAddedDialog) } } @@ -84,7 +107,7 @@ class SyncPromotionsImpl @Inject constructor( if (!isSyncFeatureEnabled() || isUserSyncingAlready()) return@withContext false if (!isPasswordsPromoEnabled()) return@withContext false - if (dataStore.hasPasswordsPromoBeenDismissed()) return@withContext false + if (dataStore.hasPromoBeenDismissed(PromotionType.PasswordsScreen)) return@withContext false true } @@ -92,7 +115,7 @@ class SyncPromotionsImpl @Inject constructor( override suspend fun recordPasswordsPromotionDismissed() { withContext(dispatchers.io()) { - dataStore.recordPasswordsPromoDismissed() + dataStore.recordPromoDismissed(PromotionType.PasswordsScreen) } } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/promotion/bookmarks/addeddialog/SetupSyncBookmarkAddedPromo.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/promotion/bookmarks/addeddialog/SetupSyncBookmarkAddedPromo.kt new file mode 100644 index 000000000000..b33e519bb485 --- /dev/null +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/promotion/bookmarks/addeddialog/SetupSyncBookmarkAddedPromo.kt @@ -0,0 +1,111 @@ +/* + * 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.sync.impl.promotion.bookmarks.addeddialog + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.duckduckgo.anvil.annotations.PriorityKey +import com.duckduckgo.app.bookmarks.BookmarkAddedPromotionPlugin +import com.duckduckgo.common.ui.menu.PopupMenu +import com.duckduckgo.common.ui.view.gone +import com.duckduckgo.common.ui.view.listitem.OneLineListItem +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.duckduckgo.sync.api.SyncActivityWithEmptyParams +import com.duckduckgo.sync.api.SyncState +import com.duckduckgo.sync.api.SyncStateMonitor +import com.duckduckgo.sync.impl.R +import com.duckduckgo.sync.impl.promotion.SyncPromotions +import com.squareup.anvil.annotations.ContributesMultibinding +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ContributesMultibinding(scope = ActivityScope::class) +@PriorityKey(BookmarkAddedPromotionPlugin.PRIORITY_KEY_BOOKMARK_ADDED_PROMOTION) +class SetupSyncBookmarkAddedPromo @Inject constructor( + private val globalActivityStarter: GlobalActivityStarter, + private val dispatchers: DispatcherProvider, + private val activity: AppCompatActivity, + private val syncPromotions: SyncPromotions, + private val syncStateMonitor: SyncStateMonitor, +) : BookmarkAddedPromotionPlugin { + + @SuppressLint("InflateParams") + override suspend fun getView(): View? { + if (!syncPromotions.canShowBookmarkAddedDialogPromotion()) { + return null + } + + val root = LayoutInflater.from(activity).inflate(R.layout.view_sync_setup_bookmark_added_promo, null) as OneLineListItem + root.setOnClickListener { onLaunchSyncFlow(activity) } + root.configureOverflowMenu() + + activity.lifecycleScope.launch { + syncStateMonitor.syncState().collect { syncState -> onSyncStateAvailable(syncState, root) } + } + + return root + } + + private fun onLaunchSyncFlow(activity: AppCompatActivity) { + val intent = globalActivityStarter.startIntent(activity, SyncActivityWithEmptyParams) + activity.startActivity(intent) + } + + private fun onSyncStateAvailable(syncState: SyncState, root: OneLineListItem) { + when (syncState) { + SyncState.READY, SyncState.IN_PROGRESS -> { + root.gone() + } + else -> { + // no-op + } + } + } + + private fun OneLineListItem.configureOverflowMenu() { + showTrailingIcon() + setTrailingIconClickListener { overflowView -> + val layoutInflater = LayoutInflater.from(context) + val popupMenu = buildPopupMenu(this, layoutInflater) + popupMenu.show(this, overflowView) + } + } + + private fun buildPopupMenu( + rootView: View, + layoutInflater: LayoutInflater, + ): PopupMenu { + val popupMenu = PopupMenu(layoutInflater, R.layout.popup_window_hide_sync_suggestion_menu) + val hideButton = popupMenu.contentView.findViewById(R.id.hide) + + popupMenu.apply { + onMenuItemClicked(hideButton) { + activity.lifecycleScope.launch(dispatchers.main()) { + rootView.gone() + syncPromotions.recordBookmarkAddedDialogPromotionDismissed() + } + } + } + + return popupMenu + } +} diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/promotion/bookmarks/addeddialog/SetupSyncBookmarkAddedPromoRules.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/promotion/bookmarks/addeddialog/SetupSyncBookmarkAddedPromoRules.kt new file mode 100644 index 000000000000..acce5bb9b031 --- /dev/null +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/promotion/bookmarks/addeddialog/SetupSyncBookmarkAddedPromoRules.kt @@ -0,0 +1,68 @@ +/* + * 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.sync.impl.promotion.bookmarks.addeddialog + +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.sync.api.DeviceSyncState +import com.duckduckgo.sync.impl.promotion.SyncPromotionDataStore +import com.duckduckgo.sync.impl.promotion.SyncPromotionDataStore.PromotionType +import com.duckduckgo.sync.impl.promotion.SyncPromotionFeature +import com.squareup.anvil.annotations.ContributesBinding +import kotlinx.coroutines.withContext +import logcat.logcat +import javax.inject.Inject + +interface SetupSyncBookmarkAddedPromoRules { + suspend fun canShowPromo(): Boolean +} + +@ContributesBinding(AppScope::class) +class RealSetupSyncBookmarkAddedPromoRules @Inject constructor( + private val syncState: DeviceSyncState, + private val dispatchers: DispatcherProvider, + private val syncPromotionDataStore: SyncPromotionDataStore, + private val syncPromotionFeature: SyncPromotionFeature, +) : SetupSyncBookmarkAddedPromoRules { + + override suspend fun canShowPromo(): Boolean { + return withContext(dispatchers.io()) { + if (!isPromoFeatureEnabled()) { + return@withContext false + } + + if (!isSyncFeatureEnabled()) { + return@withContext false + } + + if (isUserSyncingAlready()) { + return@withContext false + } + + if (syncPromotionDataStore.hasPromoBeenDismissed(PromotionType.BookmarkAddedDialog)) { + return@withContext false + } + + true + }.also { + logcat { "Sync-promo: determined if canShowPromo for ${javaClass.simpleName}: $it" } + } + } + private fun isPromoFeatureEnabled() = syncPromotionFeature.bookmarkAddedDialog().isEnabled() && syncPromotionFeature.self().isEnabled() + private fun isSyncFeatureEnabled() = syncState.isFeatureEnabled() + private fun isUserSyncingAlready() = syncState.isUserSignedInOnDevice() +} diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncInternalSettingsActivity.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncInternalSettingsActivity.kt index 166003b10c3b..74de961dd6b7 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncInternalSettingsActivity.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncInternalSettingsActivity.kt @@ -98,6 +98,9 @@ class SyncInternalSettingsActivity : DuckDuckGoActivity() { binding.syncFaviconsPromptCta.setOnClickListener { viewModel.resetFaviconsPrompt() } + binding.clearHistoryBookmarkAddedDialogPromo.setOnClickListener { viewModel.onClearHistoryBookmarkAddedDialogPromoClicked() } + binding.clearHistoryBookmarkScreenPromo.setOnClickListener { viewModel.onClearHistoryBookmarkScreenPromoClicked() } + binding.clearHistoryPasswordScreenPromo.setOnClickListener { viewModel.onClearHistoryPasswordScreenPromoClicked() } } private fun observeUiEvents() { diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncInternalSettingsViewModel.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncInternalSettingsViewModel.kt index 980b38081991..8f580e2da42a 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncInternalSettingsViewModel.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncInternalSettingsViewModel.kt @@ -29,6 +29,10 @@ import com.duckduckgo.sync.impl.Result.Success import com.duckduckgo.sync.impl.SyncAccountRepository import com.duckduckgo.sync.impl.getOrNull import com.duckduckgo.sync.impl.internal.SyncInternalEnvDataStore +import com.duckduckgo.sync.impl.promotion.SyncPromotionDataStore +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.duckduckgo.sync.impl.ui.SyncInternalSettingsViewModel.Command.ReadConnectQR import com.duckduckgo.sync.impl.ui.SyncInternalSettingsViewModel.Command.ReadQR import com.duckduckgo.sync.impl.ui.SyncInternalSettingsViewModel.Command.ShowMessage @@ -54,6 +58,7 @@ constructor( private val syncEnvDataStore: SyncInternalEnvDataStore, private val syncFaviconFetchingStore: FaviconsFetchingStore, private val dispatchers: DispatcherProvider, + private val syncPromotionDataStore: SyncPromotionDataStore, ) : ViewModel() { private val command = Channel(1, BufferOverflow.DROP_OLDEST) @@ -300,4 +305,25 @@ constructor( } } } + + fun onClearHistoryBookmarkAddedDialogPromoClicked() { + viewModelScope.launch { + syncPromotionDataStore.clearPromoHistory(BookmarkAddedDialog) + command.send(ShowMessage("'Bookmark added' promo history cleared")) + } + } + + fun onClearHistoryBookmarkScreenPromoClicked() { + viewModelScope.launch { + syncPromotionDataStore.clearPromoHistory(BookmarksScreen) + command.send(ShowMessage("'Bookmark screen' promo history cleared")) + } + } + + fun onClearHistoryPasswordScreenPromoClicked() { + viewModelScope.launch { + syncPromotionDataStore.clearPromoHistory(PasswordsScreen) + command.send(ShowMessage("'Password screen' promo history cleared")) + } + } } diff --git a/sync/sync-impl/src/main/res/layout/activity_internal_sync_settings.xml b/sync/sync-impl/src/main/res/layout/activity_internal_sync_settings.xml index a833ddb7d2f2..5496d3e1af55 100644 --- a/sync/sync-impl/src/main/res/layout/activity_internal_sync_settings.xml +++ b/sync/sync-impl/src/main/res/layout/activity_internal_sync_settings.xml @@ -274,5 +274,35 @@ android:layout_height="wrap_content" android:text="Reset" /> + + + + + + + + + + \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/layout/popup_window_hide_sync_suggestion_menu.xml b/sync/sync-impl/src/main/res/layout/popup_window_hide_sync_suggestion_menu.xml new file mode 100644 index 000000000000..f7199d0177af --- /dev/null +++ b/sync/sync-impl/src/main/res/layout/popup_window_hide_sync_suggestion_menu.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/sync/sync-impl/src/main/res/layout/view_sync_setup_bookmark_added_promo.xml b/sync/sync-impl/src/main/res/layout/view_sync_setup_bookmark_added_promo.xml new file mode 100644 index 000000000000..b53c91612b12 --- /dev/null +++ b/sync/sync-impl/src/main/res/layout/view_sync_setup_bookmark_added_promo.xml @@ -0,0 +1,28 @@ + + + + \ No newline at end of file diff --git a/sync/sync-impl/src/main/res/values/donottranslate.xml b/sync/sync-impl/src/main/res/values/donottranslate.xml index ab52625f0197..b06ea900c2ec 100644 --- a/sync/sync-impl/src/main/res/values/donottranslate.xml +++ b/sync/sync-impl/src/main/res/values/donottranslate.xml @@ -29,5 +29,12 @@ User ID Device Id Device Name + Sync Promotions + Clear history (bookmark added dialog) + Clear history (bookmark screen) + Clear history (password screen) + + Sync Bookmarks Across Devices + Hide \ No newline at end of file diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/promotion/SyncPromotionDataStoreImplTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/promotion/SyncPromotionDataStoreImplTest.kt index 730493c092c7..e5ab494cec8b 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/promotion/SyncPromotionDataStoreImplTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/promotion/SyncPromotionDataStoreImplTest.kt @@ -4,6 +4,9 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import com.duckduckgo.common.test.CoroutineTestRule +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 kotlinx.coroutines.test.runTest import org.junit.Assert.* import org.junit.Rule @@ -26,24 +29,35 @@ class SyncPromotionDataStoreImplTest { private val testee = SyncPromotionDataStoreImpl(testDataStore) @Test - fun whenInitializedThenBookmarksPromoHasNotBeenDismissed() = runTest { - assertFalse(testee.hasBookmarksPromoBeenDismissed()) + fun whenInitializedThenBookmarksScreenPromoHasNotBeenDismissed() = runTest { + assertFalse(testee.hasPromoBeenDismissed(BookmarksScreen)) } @Test fun whenBookmarksPromoRecordedThenBookmarksPromoHasBeenDismissed() = runTest { - testee.recordBookmarksPromoDismissed() - assertTrue(testee.hasBookmarksPromoBeenDismissed()) + testee.recordPromoDismissed(BookmarksScreen) + assertTrue(testee.hasPromoBeenDismissed(BookmarksScreen)) } @Test fun whenInitializedThenPasswordsPromoHasNotBeenDismissed() = runTest { - assertFalse(testee.hasPasswordsPromoBeenDismissed()) + assertFalse(testee.hasPromoBeenDismissed(PasswordsScreen)) } @Test fun whenPasswordsPromoRecordedThenPasswordsPromoHasBeenDismissed() = runTest { - testee.recordPasswordsPromoDismissed() - assertTrue(testee.hasPasswordsPromoBeenDismissed()) + testee.recordPromoDismissed(PasswordsScreen) + assertTrue(testee.hasPromoBeenDismissed(PasswordsScreen)) + } + + @Test + fun whenInitializedThenBookmarkAddedPromoHasNotBeenDismissed() = runTest { + assertFalse(testee.hasPromoBeenDismissed(BookmarkAddedDialog)) + } + + @Test + fun whenBookmarkAddedPromoRecordedThenPromoHasBeenDismissed() = runTest { + testee.recordPromoDismissed(BookmarkAddedDialog) + assertTrue(testee.hasPromoBeenDismissed(BookmarkAddedDialog)) } } diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/promotion/SyncPromotionsImplTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/promotion/SyncPromotionsImplTest.kt index 226afffab403..7841f22f8469 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/promotion/SyncPromotionsImplTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/promotion/SyncPromotionsImplTest.kt @@ -4,6 +4,10 @@ import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.sync.api.DeviceSyncState +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.duckduckgo.sync.impl.promotion.bookmarks.addeddialog.SetupSyncBookmarkAddedPromoRules import kotlinx.coroutines.test.runTest import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -21,6 +25,7 @@ class SyncPromotionsImplTest { private val syncPromotionFeature = FakeFeatureToggleFactory.create(SyncPromotionFeature::class.java) + private val bookmarkAddedPromoRules: SetupSyncBookmarkAddedPromoRules = mock() private val dataStore: SyncPromotionDataStore = mock() private val syncState: DeviceSyncState = mock() @@ -29,6 +34,7 @@ class SyncPromotionsImplTest { syncPromotionFeature = syncPromotionFeature, syncState = syncState, dataStore = dataStore, + bookmarkAddedPromoRules = bookmarkAddedPromoRules, ) @Before @@ -107,16 +113,28 @@ class SyncPromotionsImplTest { assertFalse(testee.canShowBookmarksPromotion(savedBookmarks = 5)) } + @Test + fun whenCouldShowBookmarksAddedDialogThenUsePromoRulesToDecide() = runTest { + testee.canShowBookmarkAddedDialogPromotion() + verify(bookmarkAddedPromoRules).canShowPromo() + } + @Test fun whenPasswordPromoDismissedThenEventRecordedInDataStore() = runTest { testee.recordPasswordsPromotionDismissed() - verify(dataStore).recordPasswordsPromoDismissed() + verify(dataStore).recordPromoDismissed(PasswordsScreen) } @Test - fun whenBookmarkPromoDismissedThenEventRecordedInDataStore() = runTest { + fun whenBookmarkScreenPromoDismissedThenEventRecordedInDataStore() = runTest { testee.recordBookmarksPromotionDismissed() - verify(dataStore).recordBookmarksPromoDismissed() + verify(dataStore).recordPromoDismissed(BookmarksScreen) + } + + @Test + fun whenBookmarkAddedDialogPromoDismissedThenEventRecordedInDataStore() = runTest { + testee.recordBookmarkAddedDialogPromotionDismissed() + verify(dataStore).recordPromoDismissed(BookmarkAddedDialog) } private fun configureUserHasEnabledSync(enabled: Boolean) { @@ -128,11 +146,11 @@ class SyncPromotionsImplTest { } private suspend fun configureBookmarksPromoPreviouslyDismissed(previouslyDismissed: Boolean) { - whenever(dataStore.hasBookmarksPromoBeenDismissed()).thenReturn(previouslyDismissed) + whenever(dataStore.hasPromoBeenDismissed(BookmarksScreen)).thenReturn(previouslyDismissed) } private suspend fun configurePasswordsPromoPreviouslyDismissed(previouslyDismissed: Boolean) { - whenever(dataStore.hasPasswordsPromoBeenDismissed()).thenReturn(previouslyDismissed) + whenever(dataStore.hasPromoBeenDismissed(PasswordsScreen)).thenReturn(previouslyDismissed) } private fun configureAllTogglesEnabled() { diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/promotion/bookmarks/addeddialog/RealSetupSyncBookmarkAddedPromoRulesTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/promotion/bookmarks/addeddialog/RealSetupSyncBookmarkAddedPromoRulesTest.kt new file mode 100644 index 000000000000..4435f687cc19 --- /dev/null +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/promotion/bookmarks/addeddialog/RealSetupSyncBookmarkAddedPromoRulesTest.kt @@ -0,0 +1,101 @@ +/* + * 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.sync.impl.promotion.bookmarks.addeddialog + +import android.annotation.SuppressLint +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.sync.api.DeviceSyncState +import com.duckduckgo.sync.impl.promotion.SyncPromotionDataStore +import com.duckduckgo.sync.impl.promotion.SyncPromotionDataStore.PromotionType +import com.duckduckgo.sync.impl.promotion.SyncPromotionFeature +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.whenever + +@SuppressLint("DenyListedApi") +class RealSetupSyncBookmarkAddedPromoRulesTest { + + private val syncPromotionDataStore: SyncPromotionDataStore = mock() + private val syncState: DeviceSyncState = mock() + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val syncPromotionFeature = FakeFeatureToggleFactory.create(SyncPromotionFeature::class.java) + + private val testee = RealSetupSyncBookmarkAddedPromoRules( + syncState = syncState, + dispatchers = coroutineTestRule.testDispatcherProvider, + syncPromotionDataStore = syncPromotionDataStore, + syncPromotionFeature = syncPromotionFeature, + ) + + @Before + fun setup() = runTest { + configureAllCriteriaMet() + } + + @Test + fun whenSyncStateDisabledThenCannotShowPromo() = runTest { + whenever(syncState.isFeatureEnabled()).thenReturn(false) + assertFalse(testee.canShowPromo()) + } + + @Test + fun whenUserAlreadySyncingThisDeviceThenCannotShowPromo() = runTest { + whenever(syncState.isUserSignedInOnDevice()).thenReturn(true) + assertFalse(testee.canShowPromo()) + } + + @Test + fun whenUserHasPreviouslyDismissedPromoThenCannotShowPromo() = runTest { + whenever(syncPromotionDataStore.hasPromoBeenDismissed(PromotionType.BookmarkAddedDialog)).thenReturn(true) + assertFalse(testee.canShowPromo()) + } + + @Test + fun whenGlobalSyncPromotionFeatureDisabledThenCannotShowPromo() = runTest { + syncPromotionFeature.self().setRawStoredState(State(enable = false)) + assertFalse(testee.canShowPromo()) + } + + @Test + fun whenBookmarkAddedDialogPromotionFeatureDisabledThenCannotShowPromo() = runTest { + syncPromotionFeature.bookmarkAddedDialog().setRawStoredState(State(enable = false)) + assertFalse(testee.canShowPromo()) + } + + @Test + fun whenAllCriteriaMetThenCanShowPromo() = runTest { + configureAllCriteriaMet() + assertTrue(testee.canShowPromo()) + } + + private suspend fun configureAllCriteriaMet() { + syncPromotionFeature.self().setRawStoredState(State(enable = true)) + syncPromotionFeature.bookmarkAddedDialog().setRawStoredState(State(enable = true)) + whenever(syncState.isFeatureEnabled()).thenReturn(true) + whenever(syncState.isUserSignedInOnDevice()).thenReturn(false) + whenever(syncPromotionDataStore.hasPromoBeenDismissed(PromotionType.BookmarkAddedDialog)).thenReturn(false) + } +}