Skip to content

Commit

Permalink
Suppress report autofill breakage prompt after previous submission
Browse files Browse the repository at this point in the history
  • Loading branch information
CDRussell committed Jul 19, 2024
1 parent 5c08489 commit 0b72da0
Show file tree
Hide file tree
Showing 12 changed files with 350 additions and 34 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
package com.duckduckgo.app.browser.autofill

import com.duckduckgo.autofill.impl.RealAutofillFireproofDialogSuppressor
import com.duckduckgo.autofill.impl.TimeProvider
import com.duckduckgo.autofill.impl.time.TimeProvider
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package com.duckduckgo.autofill.impl

import com.duckduckgo.autofill.impl.time.TimeProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import dagger.SingleInstanceIn
Expand Down Expand Up @@ -60,12 +61,3 @@ class RealAutofillFireproofDialogSuppressor @Inject constructor(private val time
private val TIME_PERIOD_TO_SUPPRESS_FIREPROOF_PROMPT = TimeUnit.SECONDS.toMillis(10)
}
}

interface TimeProvider {
fun currentTimeMillis(): Long
}

@ContributesBinding(AppScope::class)
class SystemCurrentTimeProvider @Inject constructor() : TimeProvider {
override fun currentTimeMillis(): Long = System.currentTimeMillis()
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package com.duckduckgo.autofill.impl.deviceauth

import com.duckduckgo.autofill.impl.time.TimeProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import dagger.SingleInstanceIn
Expand Down Expand Up @@ -80,12 +81,3 @@ class AutofillTimeBasedAuthorizationGracePeriod @Inject constructor(
private const val AUTH_GRACE_PERIOD_MS = 15_000
}
}

interface TimeProvider {
fun currentTimeMillis(): Long
}

@ContributesBinding(AppScope::class)
class SystemCurrentTimeProvider @Inject constructor() : TimeProvider {
override fun currentTimeMillis(): Long = System.currentTimeMillis()
}
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.reporting

import com.duckduckgo.autofill.impl.reporting.remoteconfig.AutofillSiteBreakageReportingFeature
import com.duckduckgo.autofill.impl.time.TimeProvider
import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher
import com.duckduckgo.autofill.store.reporting.AutofillSiteBreakageReportingFeatureRepository
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlinx.coroutines.withContext

interface AutofillBreakageReportCanShowRules {
suspend fun canShowForSite(url: String): Boolean
}

@ContributesBinding(AppScope::class)
class AutofillBreakageReportCanShowRulesImpl @Inject constructor(
private val reportBreakageFeature: AutofillSiteBreakageReportingFeature,
private val exceptionsRepository: AutofillSiteBreakageReportingFeatureRepository,
private val dispatchers: DispatcherProvider,
private val urlMatcher: AutofillUrlMatcher,
private val dataStore: AutofillSiteBreakageReportingDataStore,
private val timeProvider: TimeProvider,

) : AutofillBreakageReportCanShowRules {

override suspend fun canShowForSite(url: String): Boolean {
return withContext(dispatchers.io()) {
if (!featureEnabledInRemoteConfig()) return@withContext false

val eTldPlusOne = urlMatcher.extractUrlPartsForAutofill(url).eTldPlus1 ?: return@withContext false

if (eTldPlusOne.isInExceptionList()) return@withContext false
if (eTldPlusOne.siteReportSentRecently()) return@withContext false

true
}
}

private fun String.isInExceptionList() = exceptionsRepository.exceptions.contains(this)
private fun featureEnabledInRemoteConfig() = reportBreakageFeature.self().isEnabled()
private suspend fun String.siteReportSentRecently(): Boolean {
val lastSubmissionForSite = dataStore.getTimestampLastFeedbackSent(this) ?: return false
val minDaysBeforeResubmissionAllowed = dataStore.getMinimumNumberOfDaysBeforeReportPromptReshown()

val timeSinceReportMs = timeProvider.currentTimeMillis() - lastSubmissionForSite
return timeSinceReportMs <= TimeUnit.DAYS.toMillis(minDaysBeforeResubmissionAllowed.toLong())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import com.duckduckgo.autofill.impl.reporting.remoteconfig.AutofillSiteBreakageReporting
import com.duckduckgo.autofill.impl.time.TimeProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import dagger.SingleInstanceIn
Expand All @@ -31,12 +33,15 @@ interface AutofillSiteBreakageReportingDataStore {

suspend fun getMinimumNumberOfDaysBeforeReportPromptReshown(): Int
suspend fun updateMinimumNumberOfDaysBeforeReportPromptReshown(newValue: Int)
suspend fun recordFeedbackSent(eTldPlusOne: String)
suspend fun getTimestampLastFeedbackSent(eTldPlusOne: String): Long?
}

@SingleInstanceIn(AppScope::class)
@ContributesBinding(AppScope::class)
class AutofillSiteBreakageReportingDataStoreImpl @Inject constructor(
@AutofillSiteBreakageReporting private val store: DataStore<Preferences>,
private val timeProvider: TimeProvider,
) : AutofillSiteBreakageReportingDataStore {

private val daysBeforePromptShownAgainKey = intPreferencesKey("days_before_prompt_shown_again")
Expand All @@ -47,10 +52,22 @@ class AutofillSiteBreakageReportingDataStoreImpl @Inject constructor(
}
}

override suspend fun recordFeedbackSent(eTldPlusOne: String) {
store.edit {
it[timestampLastReportedKey(eTldPlusOne)] = timeProvider.currentTimeMillis()
}
}

override suspend fun getTimestampLastFeedbackSent(eTldPlusOne: String): Long? {
return store.data.firstOrNull()?.get(timestampLastReportedKey(eTldPlusOne))
}

override suspend fun getMinimumNumberOfDaysBeforeReportPromptReshown(): Int {
return store.data.firstOrNull()?.get(daysBeforePromptShownAgainKey) ?: DEFAULT_NUMBER_OF_DAYS_BEFORE_REPORT_PROMPT_RESHOWN
}

private fun timestampLastReportedKey(eTldPlusOne: String) = longPreferencesKey("timestamp_last_reported_$eTldPlusOne")

companion object {
private const val DEFAULT_NUMBER_OF_DAYS_BEFORE_REPORT_PROMPT_RESHOWN = 42
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* 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.time

import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject

interface TimeProvider {
fun currentTimeMillis(): Long
}

@ContributesBinding(AppScope::class)
class SystemCurrentTimeProvider @Inject constructor() : TimeProvider {
override fun currentTimeMillis(): Long = System.currentTimeMillis()
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_MANUALLY_S
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_NEVER_SAVE_FOR_THIS_SITE_CONFIRMATION_PROMPT_CONFIRMED
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_NEVER_SAVE_FOR_THIS_SITE_CONFIRMATION_PROMPT_DISMISSED
import com.duckduckgo.autofill.impl.pixel.AutofillPixelNames.AUTOFILL_NEVER_SAVE_FOR_THIS_SITE_CONFIRMATION_PROMPT_DISPLAYED
import com.duckduckgo.autofill.impl.reporting.AutofillBreakageReportCanShowRules
import com.duckduckgo.autofill.impl.reporting.AutofillBreakageReportSender
import com.duckduckgo.autofill.impl.reporting.remoteconfig.AutofillSiteBreakageReportingFeature
import com.duckduckgo.autofill.impl.reporting.AutofillSiteBreakageReportingDataStore
import com.duckduckgo.autofill.impl.store.InternalAutofillStore
import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository
import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.Command.ExitCredentialMode
Expand Down Expand Up @@ -87,7 +88,6 @@ import com.duckduckgo.autofill.impl.ui.credential.management.viewing.duckaddress
import com.duckduckgo.autofill.impl.ui.credential.repository.DuckAddressStatusRepository
import com.duckduckgo.autofill.impl.ui.credential.repository.DuckAddressStatusRepository.ActivationStatusResult
import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher
import com.duckduckgo.autofill.store.reporting.AutofillSiteBreakageReportingFeatureRepository
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.ActivityScope
import com.duckduckgo.sync.api.engine.SyncEngine
Expand Down Expand Up @@ -122,10 +122,10 @@ class AutofillSettingsViewModel @Inject constructor(
private val syncEngine: SyncEngine,
private val neverSavedSiteRepository: NeverSavedSiteRepository,
private val autofillSurvey: AutofillSurvey,
private val reportBreakageFeature: AutofillSiteBreakageReportingFeature,
private val reportBreakageFeatureExceptions: AutofillSiteBreakageReportingFeatureRepository,
private val urlMatcher: AutofillUrlMatcher,
private val autofillBreakageReportSender: AutofillBreakageReportSender,
private val autofillBreakageReportDataStore: AutofillSiteBreakageReportingDataStore,
private val autofillBreakageReportCanShowRules: AutofillBreakageReportCanShowRules,
) : ViewModel() {

private val _viewState = MutableStateFlow(ViewState())
Expand Down Expand Up @@ -437,13 +437,9 @@ class AutofillSettingsViewModel @Inject constructor(
}
}

private fun isBreakageReportingAllowed(): Boolean {
private suspend fun isBreakageReportingAllowed(): Boolean {
val url = _viewState.value.reportBreakageState.currentUrl ?: return false
val urlParts = urlMatcher.extractUrlPartsForAutofill(url)

return (reportBreakageFeature.self().isEnabled() && !reportBreakageFeatureExceptions.exceptions.contains(urlParts.eTldPlus1)).also {
Timber.v("Allow breakage reporting for [%s]: %s", urlParts, it)
}
return autofillBreakageReportCanShowRules.canShowForSite(url)
}

fun onDeleteCurrentCredentials() {
Expand Down Expand Up @@ -733,7 +729,11 @@ class AutofillSettingsViewModel @Inject constructor(
autofillBreakageReportSender.sendBreakageReport(it, privacyProtectionEnabled)
}

// todo record feedback sent timestamp for this domain. todo - work out where to record this
viewModelScope.launch(dispatchers.io()) {
urlMatcher.extractUrlPartsForAutofill(currentUrl).eTldPlus1?.let {
autofillBreakageReportDataStore.recordFeedbackSent(it)
}
}

val updatedReportBreakageState = _viewState.value.reportBreakageState.copy(allowBreakageReporting = false)
_viewState.value = _viewState.value.copy(reportBreakageState = updatedReportBreakageState)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package com.duckduckgo.autofill.impl.deviceauth

import com.duckduckgo.autofill.impl.time.TimeProvider
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package com.duckduckgo.autofill.impl.reporting

import androidx.test.ext.junit.runners.AndroidJUnit4
import com.duckduckgo.autofill.impl.encoding.UrlUnicodeNormalizerImpl
import com.duckduckgo.autofill.impl.time.TimeProvider
import com.duckduckgo.autofill.impl.urlmatcher.AutofillDomainNameUrlMatcher
import com.duckduckgo.autofill.store.reporting.AutofillSiteBreakageReportingFeatureRepository
import com.duckduckgo.common.test.CoroutineTestRule
import com.duckduckgo.feature.toggles.api.toggle.AutofillReportBreakageTestFeature
import java.util.concurrent.TimeUnit.DAYS
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever

@RunWith(AndroidJUnit4::class)
class AutofillBreakageReportCanShowRulesImplTest {

@get:Rule
val coroutineTestRule: CoroutineTestRule = CoroutineTestRule()

private val urlMatcher = AutofillDomainNameUrlMatcher(UrlUnicodeNormalizerImpl())
private val dataStore: AutofillSiteBreakageReportingDataStore = mock()
private val remoteFeature = AutofillReportBreakageTestFeature()
private val exceptionsRepository: AutofillSiteBreakageReportingFeatureRepository = mock()
private val timeProvider: TimeProvider = mock()

private val testee = AutofillBreakageReportCanShowRulesImpl(
reportBreakageFeature = remoteFeature,
dispatchers = coroutineTestRule.testDispatcherProvider,
urlMatcher = urlMatcher,
dataStore = dataStore,
exceptionsRepository = exceptionsRepository,
timeProvider = timeProvider,
)

@Before
fun setup() = runTest {
remoteFeature.topLevelFeatureEnabled = true
whenever(exceptionsRepository.exceptions).thenReturn(emptyList())
whenever(dataStore.getMinimumNumberOfDaysBeforeReportPromptReshown()).thenReturn(10)
whenever(timeProvider.currentTimeMillis()).thenReturn(System.currentTimeMillis())
}

@Test
fun whenFeatureIsDisabledThenCannotShowPrompt() = runTest {
remoteFeature.topLevelFeatureEnabled = false
assertFalse(testee.canShowForSite(aSite()))
}

@Test
fun whenETldPlusOneNotExtractableThenCannotShowPrompt() = runTest {
assertFalse(testee.canShowForSite(""))
}

@Test
fun whenSiteInExceptionsListThenCannotShowPrompt() = runTest {
whenever(exceptionsRepository.exceptions).thenReturn(listOf("example.com"))
assertFalse(testee.canShowForSite("example.com"))
}

@Test
fun whenSiteWasRecentlySubmittedAlreadyThenCannotShowPrompt() = runTest {
whenever(dataStore.getTimestampLastFeedbackSent(any())).thenReturn(10L)
whenever(timeProvider.currentTimeMillis()).thenReturn(20L)
assertFalse(testee.canShowForSite("example.com"))
}

@Test
fun whenSiteWasSubmittedAtThresholdThenCannotShowPrompt() = runTest {
whenever(dataStore.getTimestampLastFeedbackSent(any())).thenReturn(0L)
whenever(dataStore.getMinimumNumberOfDaysBeforeReportPromptReshown()).thenReturn(42)
whenever(timeProvider.currentTimeMillis()).thenReturn(DAYS.toMillis(42))
assertFalse(testee.canShowForSite("example.com"))
}

@Test
fun whenSiteWasSubmittedAtOneMsBeyondThresholdThenCanShowPrompt() = runTest {
whenever(dataStore.getTimestampLastFeedbackSent(any())).thenReturn(0L)
whenever(dataStore.getMinimumNumberOfDaysBeforeReportPromptReshown()).thenReturn(42)
whenever(timeProvider.currentTimeMillis()).thenReturn(DAYS.toMillis(42) + 1)
assertTrue(testee.canShowForSite("example.com"))
}

@Test
fun whenAnotherSiteWasRecentlySubmittedThenCanShowPrompt() = runTest {
whenever(dataStore.getTimestampLastFeedbackSent("foo.com")).thenReturn(10L)
whenever(timeProvider.currentTimeMillis()).thenReturn(20L)
assertTrue(testee.canShowForSite("example.com"))
}

@Test
fun whenSiteWasSubmittedBeforeButNotRecentlyThenCanShowPrompt() = runTest {
whenever(dataStore.getTimestampLastFeedbackSent(any())).thenReturn(0L)
assertTrue(testee.canShowForSite("example.com"))
}

@Test
fun whenNoGoodReasonNotToThenCanShowPrompt() = runTest {
assertTrue(testee.canShowForSite("example.com"))
}

private fun aSite(): String = "example.com"
}
Loading

0 comments on commit 0b72da0

Please sign in to comment.