Skip to content

Commit

Permalink
Support showing autofill surveys based on remote config (#4981)
Browse files Browse the repository at this point in the history
Task/Issue URL:
https://app.asana.com/0/488551667048375/1207935899042228/f

### Description
Adds support for consuming autofill surveys from remote config, and
converts the autofill survey UI to make use of the new
`PasswordsScreenPromotionPlugin` framework.

### Steps to test this PR

**Test nothing has broken when no surveys available**
- [x] Fresh install, visit `Passwords` screen
- [x] Verify there is no survey showing. 
- [x] Verify there is no sync promo showing.
- [x] Add a password (manually or through autofill), then return visit
`Passwords` screen again; verify sync promo shows

**Test survey shows when it is available**
- [x] Apply patch to fake a remote config survey being available (see
patch below)
- [x] Launch the app and visit `Passwords` screen; verify the survey
shows
- [x] Verify `m_autofill_management_screen_visit_survey_available` pixel
shows in logs

**Test dismissing the survey**
- [x] Dismiss the survey prompt; verify it goes away
- [x] Verify you see the sync promo (if you still have >= 1 saved
passwords)

**Test taking the survey**
- [x] Either do a fresh install, or use `Settings` -> `Autofill Dev
Settings` -> `Previously Seen Surveys` to reset state
- [x] Visit `Passwords` screen, and tap `Take Survey` button
- [x] Verify it loads in a tab and the survey is shown (don't worry
about the survey contents itself; still being refined)
- [x] Return to the `Passwords` screen; verify the survey prompt does
**not** show



### UI changes

![combined](https://github.com/user-attachments/assets/3ee76579-4382-41e6-9f40-73ded077a092)


### Patch for testing

Right now, there is no survey active in remote config, so you can use
this patch to see a survey.

```
Index: privacy-config/privacy-config-api/src/main/java/com/duckduckgo/privacy/config/api/PrivacyFeatureName.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/privacy-config/privacy-config-api/src/main/java/com/duckduckgo/privacy/config/api/PrivacyFeatureName.kt b/privacy-config/privacy-config-api/src/main/java/com/duckduckgo/privacy/config/api/PrivacyFeatureName.kt
--- a/privacy-config/privacy-config-api/src/main/java/com/duckduckgo/privacy/config/api/PrivacyFeatureName.kt	(revision Staged)
+++ b/privacy-config/privacy-config-api/src/main/java/com/duckduckgo/privacy/config/api/PrivacyFeatureName.kt	(date 1725364069768)
@@ -27,4 +27,5 @@
     TrackingParametersFeatureName("trackingParameters"),
 }
 
-const val PRIVACY_REMOTE_CONFIG_URL = "https://staticcdn.duckduckgo.com/trackerblocking/config/v4/android-config.json"
+// const val PRIVACY_REMOTE_CONFIG_URL = "https://staticcdn.duckduckgo.com/trackerblocking/config/v4/android-config.json"
+const val PRIVACY_REMOTE_CONFIG_URL = "https://jsonblob.com/api/1280153728681107456"

```
  • Loading branch information
CDRussell authored Sep 9, 2024
1 parent 4d460d2 commit bfc87ca
Show file tree
Hide file tree
Showing 24 changed files with 558 additions and 221 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,9 @@ interface PasswordsScreenPromotionPlugin {
interface Callback {
fun onPromotionDismissed()
}

companion object {
const val PRIORITY_KEY_SURVEY = 100
const val PRIORITY_KEY_SYNC_PROMO = 200
}
}
1 change: 1 addition & 0 deletions autofill/autofill-impl/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ dependencies {
implementation project(':user-agent-api')
implementation project(':new-tab-page-api')
implementation project(':data-store-api')
testImplementation project(':feature-toggles-test')

anvil project(path: ':anvil-compiler')
implementation project(path: ':anvil-annotations')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,6 @@ import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsVie
import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.ListModeCommand.ReevalutePromotions
import com.duckduckgo.autofill.impl.ui.credential.management.neversaved.NeverSavedSitesViewState
import com.duckduckgo.autofill.impl.ui.credential.management.searching.CredentialListFilter
import com.duckduckgo.autofill.impl.ui.credential.management.survey.AutofillSurvey
import com.duckduckgo.autofill.impl.ui.credential.management.survey.SurveyDetails
import com.duckduckgo.autofill.impl.ui.credential.management.viewing.duckaddress.DuckAddressIdentifier
import com.duckduckgo.autofill.impl.ui.credential.repository.DuckAddressStatusRepository
import com.duckduckgo.autofill.impl.ui.credential.repository.DuckAddressStatusRepository.ActivationStatusResult
Expand Down Expand Up @@ -131,7 +129,6 @@ class AutofillSettingsViewModel @Inject constructor(
private val duckAddressIdentifier: DuckAddressIdentifier,
private val syncEngine: SyncEngine,
private val neverSavedSiteRepository: NeverSavedSiteRepository,
private val autofillSurvey: AutofillSurvey,
private val urlMatcher: AutofillUrlMatcher,
private val autofillBreakageReportSender: AutofillBreakageReportSender,
private val autofillBreakageReportDataStore: AutofillSiteBreakageReportingDataStore,
Expand Down Expand Up @@ -177,9 +174,6 @@ class AutofillSettingsViewModel @Inject constructor(

fun onInitialiseListMode() {
onShowListMode()
viewModelScope.launch(dispatchers.io()) {
showSurveyIfAvailable()
}
}

fun onReturnToListModeFromCredentialMode() {
Expand Down Expand Up @@ -287,24 +281,11 @@ class AutofillSettingsViewModel @Inject constructor(
}
}

private suspend fun showSurveyIfAvailable() {
withContext(dispatchers.io()) {
val survey = autofillSurvey.firstUnusedSurvey()
_viewState.value = _viewState.value.copy(survey = survey)

if (survey != null) {
pixel.fire(AutofillPixelNames.AUTOFILL_SURVEY_AVAILABLE_PROMPT_DISPLAYED)
}
}
}

private suspend fun showPromotionIfEligible() {
withContext(dispatchers.io()) {
val surveyShowing = _viewState.value.survey != null
val userIsSearching = _viewState.value.credentialSearchQuery.isNotEmpty()

val canShowPromo = when {
surveyShowing -> false
userIsSearching -> false
else -> true
}
Expand All @@ -314,20 +295,6 @@ class AutofillSettingsViewModel @Inject constructor(
}
}

fun onSurveyShown(surveyId: String) {
viewModelScope.launch(dispatchers.io()) {
_viewState.value = _viewState.value.copy(survey = null)
autofillSurvey.recordSurveyAsUsed(surveyId)
}
}

fun onSurveyPromptDismissed(surveyId: String) {
viewModelScope.launch(dispatchers.io()) {
_viewState.value = _viewState.value.copy(survey = null)
autofillSurvey.recordSurveyAsUsed(surveyId)
}
}

suspend fun launchDeviceAuth() {
if (!autofillStore.autofillAvailable()) {
Timber.d("Can't access secure storage so can't offer autofill functionality")
Expand Down Expand Up @@ -811,7 +778,6 @@ class AutofillSettingsViewModel @Inject constructor(
val credentialMode: CredentialMode? = null,
val credentialSearchQuery: String = "",
val reportBreakageState: ReportBreakageState = ReportBreakageState(),
val survey: SurveyDetails? = null,
val canShowPromo: Boolean = false,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,9 @@ import androidx.core.net.toUri
import com.duckduckgo.app.statistics.store.StatisticsDataStore
import com.duckduckgo.app.usage.app.AppDaysUsedRepository
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
import com.duckduckgo.autofill.impl.engagement.store.AutofillEngagementBucketing
import com.duckduckgo.autofill.impl.store.InternalAutofillStore
import com.duckduckgo.autofill.impl.ui.credential.management.survey.AutofillSurveyImpl.Companion.SurveyParams.IN_APP
import com.duckduckgo.autofill.impl.ui.credential.management.survey.AutofillSurveyImpl.Companion.SurveyParams.NUMBER_PASSWORD_BUCKET_LOTS
import com.duckduckgo.autofill.impl.ui.credential.management.survey.AutofillSurveyImpl.Companion.SurveyParams.NUMBER_PASSWORD_BUCKET_MANY
import com.duckduckgo.autofill.impl.ui.credential.management.survey.AutofillSurveyImpl.Companion.SurveyParams.NUMBER_PASSWORD_BUCKET_NONE
import com.duckduckgo.autofill.impl.ui.credential.management.survey.AutofillSurveyImpl.Companion.SurveyParams.NUMBER_PASSWORD_BUCKET_SOME
import com.duckduckgo.browser.api.UserBrowserProperties
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
Expand All @@ -49,6 +46,8 @@ class AutofillSurveyImpl @Inject constructor(
private val dispatchers: DispatcherProvider,
private val autofillSurveyStore: AutofillSurveyStore,
private val internalAutofillStore: InternalAutofillStore,
private val surveysFeature: AutofillSurveysFeature,
private val passwordBucketing: AutofillEngagementBucketing,
) : AutofillSurvey {

override suspend fun firstUnusedSurvey(): SurveyDetails? {
Expand All @@ -60,7 +59,7 @@ class AutofillSurveyImpl @Inject constructor(
}

private fun canShowSurvey(): Boolean {
return deviceSetToEnglish()
return surveysFeature.self().isEnabled() && deviceSetToEnglish()
}

override suspend fun recordSurveyAsUsed(id: String) {
Expand All @@ -77,6 +76,8 @@ class AutofillSurveyImpl @Inject constructor(

private suspend fun String.addSurveyParameters(): String {
return withContext(dispatchers.io()) {
val passwordsSaved = internalAutofillStore.getCredentialCount().firstOrNull() ?: 0

val urlBuilder = toUri()
.buildUpon()
.appendQueryParameter(SurveyParams.ATB, statisticsStore.atb?.version ?: "")
Expand All @@ -88,22 +89,12 @@ class AutofillSurveyImpl @Inject constructor(
.appendQueryParameter(SurveyParams.MODEL, appBuildConfig.model)
.appendQueryParameter(SurveyParams.SOURCE, IN_APP)
.appendQueryParameter(SurveyParams.LAST_ACTIVE_DATE, appDaysUsedRepository.getLastActiveDay())
.appendQueryParameter(SurveyParams.NUMBER_PASSWORDS, bucketSavedPasswords(internalAutofillStore.getCredentialCount().firstOrNull()))
.appendQueryParameter(SurveyParams.NUMBER_PASSWORDS, passwordBucketing.bucketNumberOfSavedPasswords(passwordsSaved))

urlBuilder.build().toString()
}
}

private fun bucketSavedPasswords(passwordsSaved: Int?): String {
return when {
passwordsSaved == null -> NUMBER_PASSWORD_BUCKET_NONE
passwordsSaved < 3 -> NUMBER_PASSWORD_BUCKET_NONE
passwordsSaved < 10 -> NUMBER_PASSWORD_BUCKET_SOME
passwordsSaved < 50 -> NUMBER_PASSWORD_BUCKET_MANY
else -> NUMBER_PASSWORD_BUCKET_LOTS
}
}

companion object {

private object SurveyParams {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2024 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.ui.credential.management.survey

import com.duckduckgo.anvil.annotations.ContributesRemoteFeature
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.autofill.impl.reporting.remoteconfig.AutofillSiteBreakageReportingRemoteSettingsPersister
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.feature.toggles.api.FeatureSettings
import com.duckduckgo.feature.toggles.api.RemoteFeatureStoreNamed
import com.duckduckgo.feature.toggles.api.Toggle
import com.duckduckgo.feature.toggles.api.Toggle.InternalAlwaysEnabled
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

@ContributesRemoteFeature(
scope = AppScope::class,
boundType = AutofillSurveysFeature::class,
featureName = "autofillSurveys",
settingsStore = AutofillSiteBreakageReportingRemoteSettingsPersister::class,
)
/**
* This is the class that represents the feature flag for showing autofill user-surveys.
*/
interface AutofillSurveysFeature {
/**
* @return `true` when the remote config has the global "autofillSurveys" feature flag enabled
*
* If the remote feature is not present defaults to `false`
*/

@InternalAlwaysEnabled
@Toggle.DefaultValue(false)
fun self(): Toggle
}

@ContributesBinding(AppScope::class)
@RemoteFeatureStoreNamed(AutofillSurveysFeature::class)
class AutofillSurveyFeatureSettingsStore @Inject constructor(
private val autofillSurveyStore: AutofillSurveyStore,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
private val dispatchers: DispatcherProvider,
) : FeatureSettings.Store {

override fun store(jsonString: String) {
appCoroutineScope.launch(dispatchers.io()) {
autofillSurveyStore.updateAvailableSurveys(jsonString)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2024 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.ui.credential.management.survey

import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import javax.inject.Inject

interface AutofillSurveyJsonParser {
suspend fun parseJson(json: String?): List<SurveyDetails>
}

@ContributesBinding(AppScope::class)
class AutofillSurveyJsonParserImpl @Inject constructor() : AutofillSurveyJsonParser {

private val jsonAdapter by lazy { buildJsonAdapter() }

private fun buildJsonAdapter(): JsonAdapter<AutofillSettingsJson> {
val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
return moshi.adapter(AutofillSettingsJson::class.java)
}

override suspend fun parseJson(json: String?): List<SurveyDetails> {
if (json == null) return emptyList()

return kotlin.runCatching {
val surveyDetailsJson = jsonAdapter.fromJson(json)
return surveyDetailsJson?.surveys?.asSurveyDetails() ?: emptyList()
}.getOrDefault(emptyList())
}

private fun List<SurveyDetailsJson>?.asSurveyDetails(): List<SurveyDetails> {
if (this == null) return emptyList()
return this.map { SurveyDetails(id = it.id, url = it.url) }
}

data class AutofillSettingsJson(
val surveys: List<SurveyDetailsJson>,
)

data class SurveyDetailsJson(
val id: String,
val url: String,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@ interface AutofillSurveyStore {
suspend fun recordSurveyWasShown(id: String)
suspend fun resetPreviousSurveys()
suspend fun availableSurveys(): List<SurveyDetails>
suspend fun updateAvailableSurveys(json: String)
}

@ContributesBinding(AppScope::class)
@ContributesBinding(AppScope::class, boundType = AutofillSurveyStore::class)
@SingleInstanceIn(AppScope::class)
class AutofillSurveyStoreImpl @Inject constructor(
private val context: Context,
private val dispatchers: DispatcherProvider,
private val surveyJsonParser: AutofillSurveyJsonParser,
) : AutofillSurveyStore {

private val prefs: SharedPreferences by lazy {
Expand Down Expand Up @@ -71,11 +73,25 @@ class AutofillSurveyStoreImpl @Inject constructor(
}

override suspend fun availableSurveys(): List<SurveyDetails> {
return emptyList()
return withContext(dispatchers.io()) {
kotlin.runCatching {
val availableSurveyJson = prefs.getString(AVAILABLE_SURVEYS, null)
surveyJsonParser.parseJson(availableSurveyJson)
}.getOrElse { emptyList() }
}
}

override suspend fun updateAvailableSurveys(json: String) {
withContext(dispatchers.io()) {
prefs.edit {
putString(AVAILABLE_SURVEYS, json)
}
}
}

companion object {
private const val PREFS_FILE_NAME = "autofill_survey_store"
private const val SURVEY_IDS = "survey_ids"
private const val AVAILABLE_SURVEYS = "available_surveys"
}
}
Loading

0 comments on commit bfc87ca

Please sign in to comment.