Skip to content

Show import promo in autofill management #6266

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

Merged
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -151,4 +151,7 @@ interface AutofillFeature {

@Toggle.DefaultValue(defaultValue = DefaultFeatureValue.TRUE)
fun createAsyncPreferences(): Toggle

@Toggle.DefaultValue(defaultValue = DefaultFeatureValue.INTERNAL)
fun canPromoteImportPasswords(): Toggle
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ interface PasswordsScreenPromotionPlugin {
}

companion object {
const val PRIORITY_KEY_IMPORT_PROMO = 50
const val PRIORITY_KEY_SURVEY = 100
const val PRIORITY_KEY_SYNC_PROMO = 200
}
Expand Down
2 changes: 2 additions & 0 deletions autofill/autofill-impl/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ dependencies {

implementation "de.siegmar:fastcsv:_"

implementation "com.airbnb.android:lottie:_"

implementation Square.retrofit2.converter.moshi
implementation "com.squareup.moshi:moshi-kotlin:_"
implementation "com.squareup.moshi:moshi-adapters:_"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,21 @@ class SecureStoreBackedAutofillStore @Inject constructor(
autofillPrefsStore.hasEverBeenPromptedToSaveLogin = value
}

override var hasEverImportedPasswords: Boolean
get() = autofillPrefsStore.hasEverImportedPasswords
set(value) {
autofillPrefsStore.hasEverImportedPasswords = value
}

override fun hasEverImportedPasswordsFlow(): Flow<Boolean> {
return autofillPrefsStore.hasEverImportedPasswordsFlow()
}

override var hasDismissedImportedPasswordsPromo: Boolean
get() = autofillPrefsStore.hasDismissedImportedPasswordsPromo
set(value) {
autofillPrefsStore.hasDismissedImportedPasswordsPromo = value
}
override var autofillDeclineCount: Int
get() = autofillPrefsStore.autofillDeclineCount
set(value) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* 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.autofill.impl.importing

enum class AutofillImportLaunchSource(val value: String) {
PasswordManagementPromo("passwords_management_promo"),
AutofillSettings("autofill_settings"),
PasswordManagementEmpty("passwords_management_empty"),
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ class CredentialImporterImpl @Inject constructor(
val insertedIds = autofillStore.bulkInsert(importList)

skippedCredentials += (importList.size - insertedIds.size)

// Set the flag when at least one credential was successfully imported
if (insertedIds.isNotEmpty()) {
autofillStore.hasEverImportedPasswords = true
}

_importStatus.emit(Finished(savedCredentials = insertedIds.size, numberSkipped = skippedCredentials))
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*
* 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.autofill.impl.importing.promo

import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.findViewTreeViewModelStoreOwner
import androidx.lifecycle.lifecycleScope
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.anvil.annotations.PriorityKey
import com.duckduckgo.autofill.api.promotion.PasswordsScreenPromotionPlugin
import com.duckduckgo.autofill.api.promotion.PasswordsScreenPromotionPlugin.Callback
import com.duckduckgo.autofill.api.promotion.PasswordsScreenPromotionPlugin.Companion.PRIORITY_KEY_IMPORT_PROMO
import com.duckduckgo.autofill.impl.R
import com.duckduckgo.autofill.impl.databinding.ViewImportPasswordsPromoBinding
import com.duckduckgo.autofill.impl.importing.promo.ImportInPasswordsPromotionViewModel.Command
import com.duckduckgo.autofill.impl.importing.promo.ImportInPasswordsPromotionViewModel.Command.DismissImport
import com.duckduckgo.common.ui.view.MessageCta.Message
import com.duckduckgo.common.ui.view.MessageCta.MessageType.REMOTE_MESSAGE
import com.duckduckgo.common.ui.view.show
import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.common.utils.ConflatedJob
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.common.utils.ViewViewModelFactory
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.di.scopes.ViewScope
import com.duckduckgo.navigation.api.GlobalActivityStarter
import com.squareup.anvil.annotations.ContributesMultibinding
import dagger.android.support.AndroidSupportInjection
import javax.inject.Inject
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import logcat.logcat

@ContributesMultibinding(scope = AppScope::class)
@PriorityKey(PRIORITY_KEY_IMPORT_PROMO)
class ImportInPasswordsPromotion @Inject constructor(
private val importInPasswordsVisibility: ImportInPasswordsVisibility,
) : PasswordsScreenPromotionPlugin {

override suspend fun getView(
context: Context,
numberSavedPasswords: Int,
): View? {
if (importInPasswordsVisibility.canShowImportInPasswords(numberSavedPasswords).not()) return null
logcat { "Autofill: returning view for ImportInPasswordsPromotion" }
return ImportInPasswordsPromotionView(context)
}
}

@InjectWith(ViewScope::class)
class ImportInPasswordsPromotionView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0,
) : FrameLayout(context, attrs, defStyle) {

@Inject
lateinit var viewModelFactory: ViewViewModelFactory

@Inject
lateinit var globalActivityStarter: GlobalActivityStarter

@Inject
lateinit var dispatchers: DispatcherProvider

private val binding: ViewImportPasswordsPromoBinding by viewBinding()

private val viewModel: ImportInPasswordsPromotionViewModel by lazy {
ViewModelProvider(findViewTreeViewModelStoreOwner()!!, viewModelFactory)[ImportInPasswordsPromotionViewModel::class.java]
}

private var job: ConflatedJob = ConflatedJob()

override fun onAttachedToWindow() {
AndroidSupportInjection.inject(this)
super.onAttachedToWindow()

job += viewModel.commands()
.onEach { processCommand(it) }
.launchIn(findViewTreeLifecycleOwner()?.lifecycleScope!!)

showPromo()
viewModel.onPromoShown()
}

override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
job.cancel()
}

private fun processCommand(command: Command) {
when (command) {
DismissImport -> notifyPromoDismissed()
}
}

private fun notifyPromoDismissed() {
(context as? Callback)?.onPromotionDismissed()
}

private fun showPromo() {
with(binding.importPromo) {
setMessage(
Message(
topAnimation = R.raw.anim_password_keys,
title = context.getString(R.string.passwords_import_promo_title),
subtitle = context.getString(R.string.passwords_import_promo_subtitle),
action = context.getString(R.string.passwords_import_promo_action),
messageType = REMOTE_MESSAGE,
),
)
onTopAnimationConfigured { view ->
view.repeatCount = 1
view.playAnimation()
}
onPrimaryActionClicked {
viewModel.onUserClickedToImport()
}
onCloseButtonClicked {
viewModel.onUserDismissedPromo()
}
show()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* 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.autofill.impl.importing.promo

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.duckduckgo.anvil.annotations.ContributesViewModel
import com.duckduckgo.app.statistics.pixels.Pixel
import com.duckduckgo.autofill.impl.importing.AutofillImportLaunchSource
import com.duckduckgo.autofill.impl.importing.promo.ImportInPasswordsPromotionViewModel.Command.DismissImport
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_IMPORT_GOOGLE_PASSWORDS_EMPTY_STATE_CTA_BUTTON_TAPPED
import com.duckduckgo.autofill.impl.store.AutofillEffect
import com.duckduckgo.autofill.impl.store.AutofillEffectDispatcher
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.ViewScope
import javax.inject.Inject
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch

@ContributesViewModel(ViewScope::class)
class ImportInPasswordsPromotionViewModel @Inject constructor(
private val pixel: Pixel,
private val dispatchers: DispatcherProvider,
private val promoEventDispatcher: AutofillEffectDispatcher,
private val importInPasswordsVisibility: ImportInPasswordsVisibility,
) : ViewModel() {

sealed interface Command {
data object DismissImport : Command
}

private val command = Channel<Command>(1, DROP_OLDEST)
internal fun commands(): Flow<Command> = command.receiveAsFlow()

// want to ensure this pixel doesn't trigger repeatedly as it's scrolled in and out of the list
private var promoDisplayedPixelSent = false

fun onPromoShown() {
if (!promoDisplayedPixelSent) {
promoDisplayedPixelSent = true
val params = mapOf("source" to AutofillImportLaunchSource.PasswordManagementPromo.value)
pixel.fire(AutofillPixelNames.AUTOFILL_IMPORT_GOOGLE_PASSWORDS_EMPTY_STATE_CTA_BUTTON_SHOWN, params)
}
}

fun onUserClickedToImport() {
viewModelScope.launch(dispatchers.io()) {
promoEventDispatcher.emit(
AutofillEffect.LaunchImportPasswords(
source = AutofillImportLaunchSource.PasswordManagementPromo,
),
)
val params = mapOf("source" to AutofillImportLaunchSource.PasswordManagementPromo.value)
pixel.fire(AUTOFILL_IMPORT_GOOGLE_PASSWORDS_EMPTY_STATE_CTA_BUTTON_TAPPED, params)
}
}

fun onUserDismissedPromo() {
viewModelScope.launch(dispatchers.io()) {
importInPasswordsVisibility.onPromoDismissed()
command.send(DismissImport)
}
}
}
Loading
Loading