Skip to content

Commit

Permalink
Adds Feature Flag for Autofill Service
Browse files Browse the repository at this point in the history
  • Loading branch information
cmonfortep committed Jan 31, 2025
1 parent 5d63aa5 commit 1552747
Show file tree
Hide file tree
Showing 8 changed files with 192 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* 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.service

import com.duckduckgo.anvil.annotations.ContributesRemoteFeature
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.feature.toggles.api.Toggle
import com.duckduckgo.feature.toggles.api.Toggle.InternalAlwaysEnabled

@ContributesRemoteFeature(
scope = AppScope::class,
featureName = "autofillService",
)
interface AutofillServiceFeature {

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

@Toggle.DefaultValue(false)
@InternalAlwaysEnabled
fun canUpdateAppToDomainDataset(): Toggle

@Toggle.DefaultValue(false)
@InternalAlwaysEnabled
fun canMapAppToDomain(): Toggle
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* 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.service

import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DEFAULT
import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED
import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED
import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER
import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED
import android.content.pm.PackageManager.DONT_KILL_APP
import androidx.lifecycle.LifecycleOwner
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesMultibinding
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber

@ContributesMultibinding(
scope = AppScope::class,
boundType = MainProcessLifecycleObserver::class,
)
class AutofillServiceLifecycleObserver @Inject constructor(
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
private val context: Context,
private val autofillServiceFeature: AutofillServiceFeature,
) : MainProcessLifecycleObserver {

override fun onCreate(owner: LifecycleOwner) {
super.onCreate(owner)
appCoroutineScope.launch(dispatcherProvider.io()) {
runCatching {
val currentState = getAutofillServiceState(context).toBoolean()
autofillServiceFeature.self().isEnabled().let { remoteState ->
if (currentState != remoteState) {
Timber.d("DDGAutofillService: Updating state to $remoteState")
newState(context, remoteState)
}
}
}.onFailure {
Timber.e("DDGAutofillService: Failed to update Service state: $it")
}
}
}

private fun getAutofillServiceState(context: Context): Int {
val pm = context.packageManager
val autofillServiceComponent = ComponentName(context, RealAutofillService::class.java)
return pm.getComponentEnabledSetting(autofillServiceComponent)
}

private fun newState(
context: Context,
isEnabled: Boolean,
) {
val pm = context.packageManager
val autofillServiceComponent = ComponentName(context, RealAutofillService::class.java)

val value = when (isEnabled) {
true -> COMPONENT_ENABLED_STATE_ENABLED
false -> COMPONENT_ENABLED_STATE_DISABLED
}

pm.setComponentEnabledSetting(autofillServiceComponent, value, DONT_KILL_APP)
}

private fun Int.toBoolean(): Boolean? {
return when (this) {
COMPONENT_ENABLED_STATE_DEFAULT -> false // this is the current value in Manifest
COMPONENT_ENABLED_STATE_ENABLED -> true
COMPONENT_ENABLED_STATE_DISABLED -> false
COMPONENT_ENABLED_STATE_DISABLED_USER -> true
COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED -> true
else -> null
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ class RealAutofillService : AutofillService() {
@AppCoroutineScope
lateinit var coroutineScope: CoroutineScope

@Inject lateinit var autofillServiceFeature: AutofillServiceFeature

@Inject lateinit var dispatcherProvider: DispatcherProvider

@Inject lateinit var appBuildConfig: AppBuildConfig
Expand All @@ -68,6 +70,11 @@ class RealAutofillService : AutofillService() {
cancellationSignal.setOnCancelListener { autofillJob.cancel() }

autofillJob += coroutineScope.launch(dispatcherProvider.io()) {
if (autofillServiceFeature.self().isEnabled().not()) {
callback.onSuccess(null)
return@launch
}

val structure = request.fillContexts.lastOrNull()?.structure
if (structure == null) {
callback.onSuccess(null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package com.duckduckgo.autofill.impl.service.mapper

import com.duckduckgo.autofill.api.domain.app.LoginCredentials
import com.duckduckgo.autofill.api.store.AutofillStore
import com.duckduckgo.autofill.impl.service.AutofillServiceFeature
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
Expand All @@ -39,8 +40,11 @@ class RealAppCredentialProvider @Inject constructor(
private val appToDomainMapper: AppToDomainMapper,
private val dispatcherProvider: DispatcherProvider,
private val autofillStore: AutofillStore,
private val autofillServiceFeature: AutofillServiceFeature,
) : AppCredentialProvider {
override suspend fun getCredentials(appPackage: String): List<LoginCredentials> = withContext(dispatcherProvider.io()) {
if (autofillServiceFeature.canMapAppToDomain().isEnabled().not()) return@withContext emptyList()

Timber.d("Autofill-mapping: Getting credentials for $appPackage")
return@withContext appToDomainMapper.getAssociatedDomains(appPackage).map {
getAllCredentialsFromDomain(it)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package com.duckduckgo.autofill.impl.service.mapper
import androidx.lifecycle.LifecycleOwner
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
import com.duckduckgo.autofill.impl.service.AutofillServiceFeature
import com.duckduckgo.autofill.store.AutofillPrefsStore
import com.duckduckgo.autofill.store.targets.DomainTargetAppDao
import com.duckduckgo.autofill.store.targets.DomainTargetAppEntity
Expand All @@ -43,10 +44,11 @@ class RemoteDomainTargetAppDataDownloader @Inject constructor(
private val autofillPrefsStore: AutofillPrefsStore,
private val domainTargetAppDao: DomainTargetAppDao,
private val currentTimeProvider: CurrentTimeProvider,
private val autofillServiceFeature: AutofillServiceFeature,
) : MainProcessLifecycleObserver {
override fun onCreate(owner: LifecycleOwner) {
super.onCreate(owner)
// TODO: Add check for killswitch
if (autofillServiceFeature.canUpdateAppToDomainDataset().isEnabled().not()) return
appCoroutineScope.launch(dispatcherProvider.io()) {
Timber.d("Autofill-mapping: Attempting to download")
download()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.duckduckgo.autofill.impl.service.mapper

import com.duckduckgo.autofill.api.domain.app.LoginCredentials
import com.duckduckgo.autofill.impl.service.AutofillServiceFeature
import com.duckduckgo.autofill.impl.service.mapper.fakes.FakeAutofillStore
import com.duckduckgo.common.test.CoroutineTestRule
import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory
import com.duckduckgo.feature.toggles.api.Toggle.State
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
Expand All @@ -20,6 +23,9 @@ class RealAppCredentialProviderTest {

@Mock
private lateinit var mapper: AppToDomainMapper

private val autofillServiceFeature = FakeFeatureToggleFactory.create(AutofillServiceFeature::class.java)

private lateinit var toTest: RealAppCredentialProvider

private val store = FakeAutofillStore(
Expand All @@ -39,14 +45,27 @@ class RealAppCredentialProviderTest {

@Before
fun setUp() {
autofillServiceFeature.self().setRawStoredState(State(enable = true))
autofillServiceFeature.canMapAppToDomain().setRawStoredState(State(enable = true))
MockitoAnnotations.openMocks(this)
toTest = RealAppCredentialProvider(
mapper,
coroutineTestRule.testDispatcherProvider,
store,
autofillServiceFeature,
)
}

@Test
fun whenFeatureFlagDisabledThenReturnEmtpyList() = runTest {
autofillServiceFeature.canMapAppToDomain().setRawStoredState(State(enable = false))
whenever(mapper.getAssociatedDomains("com.duplicate.domains")).thenReturn(listOf("package1.com", "package2.com", "package2.com"))

val result = toTest.getCredentials("com.duplicate.domains")

assertTrue(result.isEmpty())
}

@Test
fun whenAppIsMappedToDuplicateDomainsReturnDistinctCredentials() = runTest {
whenever(mapper.getAssociatedDomains("com.duplicate.domains")).thenReturn(listOf("package1.com", "package2.com", "package2.com"))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package com.duckduckgo.autofill.impl.service.mapper

import androidx.lifecycle.LifecycleOwner
import com.duckduckgo.autofill.impl.service.AutofillServiceFeature
import com.duckduckgo.autofill.store.AutofillPrefsStore
import com.duckduckgo.autofill.store.targets.DomainTargetAppDao
import com.duckduckgo.autofill.store.targets.DomainTargetAppEntity
import com.duckduckgo.autofill.store.targets.TargetApp
import com.duckduckgo.common.test.CoroutineTestRule
import com.duckduckgo.common.utils.CurrentTimeProvider
import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory
import com.duckduckgo.feature.toggles.api.Toggle.State
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
Expand Down Expand Up @@ -35,6 +38,8 @@ class RemoteDomainTargetAppDataDownloaderTest {

@Mock
private lateinit var mockOwner: LifecycleOwner

private val autofillServiceFeature = FakeFeatureToggleFactory.create(AutofillServiceFeature::class.java)
private lateinit var toTest: RemoteDomainTargetAppDataDownloader
private val dataset = RemoteDomainTargetDataSet(
version = 1,
Expand Down Expand Up @@ -78,18 +83,33 @@ class RemoteDomainTargetAppDataDownloaderTest {
fun setUp() {
MockitoAnnotations.openMocks(this)

autofillServiceFeature.self().setRawStoredState(State(enable = true))
autofillServiceFeature.canUpdateAppToDomainDataset().setRawStoredState(State(enable = true))

toTest = RemoteDomainTargetAppDataDownloader(
coroutineTestRule.testScope,
coroutineTestRule.testDispatcherProvider,
remoteDomainTargetAppService,
autofillPrefsStore,
domainTargetAppDao,
currentTimeProvider,
autofillServiceFeature,
)

whenever(currentTimeProvider.currentTimeMillis()).thenReturn(1737548373455)
}

@Test
fun whenFeatureFlagDisabledThenNoUpdates() = runTest {
autofillServiceFeature.canUpdateAppToDomainDataset().setRawStoredState(State(enable = false))

toTest.onCreate(mockOwner)

verifyNoMoreInteractions(remoteDomainTargetAppService)
verifyNoMoreInteractions(autofillPrefsStore)
verifyNoMoreInteractions(domainTargetAppDao)
}

@Test
fun whenOnMainLifecycleCreateThenDownloadAndPersistData() = runTest {
whenever(autofillPrefsStore.domainTargetDatasetVersion).thenReturn(0)
Expand Down
1 change: 1 addition & 0 deletions autofill/autofill-internal/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
android:name="com.duckduckgo.autofill.impl.service.RealAutofillService"
android:exported="true"
android:label="@string/appName"
android:enabled="false"
android:permission="android.permission.BIND_AUTOFILL_SERVICE">
<intent-filter>
<action android:name="android.service.autofill.AutofillService" />
Expand Down

0 comments on commit 1552747

Please sign in to comment.