diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/di/AutofillModule.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/di/AutofillModule.kt index de1f320f8e45..7259323e5520 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/di/AutofillModule.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/di/AutofillModule.kt @@ -46,6 +46,8 @@ import com.duckduckgo.autofill.store.feature.email.incontext.ALL_MIGRATIONS as E import com.duckduckgo.autofill.store.feature.email.incontext.EmailProtectionInContextDatabase import com.duckduckgo.autofill.store.feature.email.incontext.EmailProtectionInContextFeatureRepository import com.duckduckgo.autofill.store.feature.email.incontext.RealEmailProtectionInContextFeatureRepository +import com.duckduckgo.autofill.store.targets.DomainTargetAppDao +import com.duckduckgo.autofill.store.targets.DomainTargetAppsDatabase import com.duckduckgo.browser.api.UserBrowserProperties import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope @@ -149,6 +151,24 @@ class AutofillModule { .addMigrations(*AutofillEngagementDatabase.ALL_MIGRATIONS) .build() } + + @Provides + @SingleInstanceIn(AppScope::class) + fun providesDomainTargetAppsDatabase( + context: Context, + ): DomainTargetAppsDatabase { + return Room.databaseBuilder(context, DomainTargetAppsDatabase::class.java, "autofill_domain_target_apps.db") + .addMigrations(*DomainTargetAppsDatabase.ALL_MIGRATIONS) + .build() + } + + @Provides + @SingleInstanceIn(AppScope::class) + fun providesDomainTargetAppsDao( + database: DomainTargetAppsDatabase, + ): DomainTargetAppDao { + return database.domainTargetAppDao() + } } /** diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillProviderSuggestions.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillProviderSuggestions.kt index b024b0e6260f..596b7aa2695c 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillProviderSuggestions.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillProviderSuggestions.kt @@ -30,6 +30,7 @@ import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.service.AutofillFieldType.UNKNOWN import com.duckduckgo.autofill.impl.service.AutofillFieldType.USERNAME +import com.duckduckgo.autofill.impl.service.mapper.AppCredentialProvider import com.duckduckgo.autofill.impl.ui.credential.management.AutofillManagementActivity import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding @@ -53,6 +54,7 @@ class RealAutofillProviderSuggestions @Inject constructor( private val autofillStore: AutofillStore, private val viewProvider: AutofillServiceViewProvider, private val suggestionsFormatter: AutofillServiceSuggestionCredentialFormatter, + private val appCredentialProvider: AppCredentialProvider, ) : AutofillProviderSuggestions { companion object { @@ -205,7 +207,7 @@ class RealAutofillProviderSuggestions @Inject constructor( } ?: emptyList() val crendentialsForPackage = node.packageId.takeUnless { it.isNullOrBlank() }?.let { - autofillStore.getCredentials(it) + appCredentialProvider.getCredentials(it) } ?: emptyList() Timber.v("DDGAutofillService credentials for domain: $crendentialsForDomain") diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceFeature.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceFeature.kt new file mode 100644 index 000000000000..8a0b16ab10da --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceFeature.kt @@ -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 +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceLifecycleObserver.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceLifecycleObserver.kt new file mode 100644 index 000000000000..8ac119b682f1 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceLifecycleObserver.kt @@ -0,0 +1,98 @@ +/* + * 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() ?: return@launch + + 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 -> null + COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED -> null + else -> null + } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/RealAutofillService.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/RealAutofillService.kt index f3fc4c289e72..b35e4ae8ecb8 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/RealAutofillService.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/RealAutofillService.kt @@ -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 @@ -69,6 +71,10 @@ class RealAutofillService : AutofillService() { autofillJob += coroutineScope.launch(dispatcherProvider.io()) { runCatching { + if (autofillServiceFeature.self().isEnabled().not()) { + callback.onSuccess(null) + return@launch + } val structure = request.fillContexts.lastOrNull()?.structure if (structure == null) { callback.onSuccess(null) diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/AppCredentialProvider.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/AppCredentialProvider.kt new file mode 100644 index 000000000000..8d0ac1108ac1 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/AppCredentialProvider.kt @@ -0,0 +1,59 @@ +/* + * 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.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 +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.withContext +import timber.log.Timber + +interface AppCredentialProvider { + /** + * Provide a list of unique credentials that can be associated with the given [appPackage] + */ + suspend fun getCredentials(appPackage: String): List +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +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 = 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) + }.flatten().distinct().also { + Timber.d("Autofill-mapping: Total credentials for $appPackage : ${it.size}") + } + } + + private suspend fun getAllCredentialsFromDomain(domain: String): List { + return autofillStore.getCredentials(domain) + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/AppFingerprintProvider.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/AppFingerprintProvider.kt new file mode 100644 index 000000000000..d832b27758f3 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/AppFingerprintProvider.kt @@ -0,0 +1,36 @@ +/* + * 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.mapper + +import android.content.Context +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface AppFingerprintProvider { + fun getSHA256HexadecimalFingerprint(packageName: String): List +} + +@ContributesBinding(AppScope::class) +class RealAppFingerprintProvider @Inject constructor( + private val appBuildConfig: AppBuildConfig, + private val context: Context, +) : AppFingerprintProvider { + override fun getSHA256HexadecimalFingerprint(packageName: String): List = + context.packageManager.getSHA256HexadecimalFingerprintCompat(packageName, appBuildConfig) +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/AppToDomainMapper.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/AppToDomainMapper.kt new file mode 100644 index 000000000000..cc7f2693f69e --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/AppToDomainMapper.kt @@ -0,0 +1,125 @@ +/* + * 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.mapper + +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.utils.CurrentTimeProvider +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.normalizeScheme +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 +import okhttp3.HttpUrl.Companion.toHttpUrl +import timber.log.Timber + +interface AppToDomainMapper { + /** + * Returns a list of domains whose credentials can be associated to the [appPackage] + */ + suspend fun getAssociatedDomains(appPackage: String): List +} + +@ContributesBinding(AppScope::class) +class RealAppToDomainMapper @Inject constructor( + private val domainTargetAppDao: DomainTargetAppDao, + private val appFingerprintProvider: AppFingerprintProvider, + private val assetLinksLoader: AssetLinksLoader, + private val dispatcherProvider: DispatcherProvider, + private val currentTimeProvider: CurrentTimeProvider, +) : AppToDomainMapper { + override suspend fun getAssociatedDomains(appPackage: String): List { + return withContext(dispatcherProvider.io()) { + Timber.d("Autofill-mapping: Getting domains for $appPackage") + val fingerprints = appFingerprintProvider.getSHA256HexadecimalFingerprint(appPackage) + if (fingerprints.isNotEmpty()) { + attemptToGetFromDataset(appPackage, fingerprints).run { + this.ifEmpty { + attemptToGetFromAssetLinks(appPackage, fingerprints) + } + } + } else { + emptyList() + }.distinct() + } + } + + private fun attemptToGetFromDataset( + appPackage: String, + fingerprints: List, + ): List { + Timber.d("Autofill-mapping: Attempting to get domains from dataset") + return domainTargetAppDao.getDomainsForApp(packageName = appPackage, fingerprints = fingerprints).also { + Timber.d("Autofill-mapping: domains from dataset for $appPackage: ${it.size}") + } + } + + private suspend fun attemptToGetFromAssetLinks( + appPackage: String, + fingerprints: List, + ): List { + val domain = kotlin.runCatching { + appPackage.split('.').asReversed().joinToString(".").normalizeScheme().toHttpUrl().topPrivateDomain() + }.getOrNull() + return domain?.run { + Timber.d("Autofill-mapping: Attempting to get asset links for: $domain") + val validTargetApp = assetLinksLoader.getValidTargetApps(this).filter { target -> + target.key == appPackage && target.value.any { it in fingerprints } + } + if (validTargetApp.isNotEmpty()) { + Timber.d("Autofill-mapping: Valid asset links targets found for $appPackage in $domain") + persistMatch(domain, validTargetApp) + listOf(domain) + } else { + Timber.d("Autofill-mapping: No valid asset links target found for $appPackage in $domain") + emptyList() + } + } ?: emptyList() + } + + private fun persistMatch( + domain: String, + validTargets: Map>, + ) = runCatching { + val toPersist = mutableListOf() + validTargets.forEach { (packageName, fingerprints) -> + fingerprints.forEach { fingerprint -> + toPersist.add( + DomainTargetAppEntity( + domain = domain, + targetApp = TargetApp( + packageName = packageName, + sha256CertFingerprints = fingerprint, + ), + dataExpiryInMillis = currentTimeProvider.currentTimeMillis() + TimeUnit.DAYS.toMillis(EXPIRY_IN_DAYS), + ), + ) + } + } + domainTargetAppDao.insertAllMapping(toPersist) + }.onFailure { + // IF it fails for any reason, caching fails but the app should not. + Timber.e("Autofill-mapping: Failed to persist data for $domain") + } + + companion object { + private const val EXPIRY_IN_DAYS = 30L + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/AssetLinksLoader.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/AssetLinksLoader.kt new file mode 100644 index 000000000000..d43ad572ce8c --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/AssetLinksLoader.kt @@ -0,0 +1,66 @@ +/* + * 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.mapper + +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.normalizeScheme +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.withContext +import timber.log.Timber + +interface AssetLinksLoader { + /** + * Return a map of app packages to associated valid fingerprints from the [domain]'s assetlinks + */ + suspend fun getValidTargetApps(domain: String): Map> +} + +@ContributesBinding(AppScope::class) +class RealAssetLinksLoader @Inject constructor( + private val assetLinksService: AssetLinksService, + private val dispatcherProvider: DispatcherProvider, +) : AssetLinksLoader { + override suspend fun getValidTargetApps(domain: String): Map> { + return withContext(dispatcherProvider.io()) { + kotlin.runCatching { + assetLinksService.getAssetLinks("${domain.normalizeScheme()}$ASSET_LINKS_PATH").also { + Timber.d("Autofill-mapping: Assetlinks of $domain: ${it.size}") + }.filter { + it.relation.any { relation -> relation in supportedRelations } && + !it.target.package_name.isNullOrEmpty() && + !it.target.sha256_cert_fingerprints.isNullOrEmpty() && + it.target.namespace == APP_NAMESPACE + }.associate { it.target.package_name!! to it.target.sha256_cert_fingerprints!! } + }.getOrElse { + // This can fail for a lot of reasons: invalid url from package name, absence of assetlinks, malformed assetlinks + // If it does, we don't want to crash the app. We only want to return empty + Timber.e(it, "Autofill-mapping: Failed to obtain assetlinks for: $domain") + emptyMap() + } + } + } + + companion object { + private const val ASSET_LINKS_PATH = "/.well-known/assetlinks.json" + private const val LOGIN_CREDENTIALS_RELATION = "delegate_permission/common.get_login_creds" + private const val HANDLE_ALL_RELATION = "delegate_permission/common.handle_all_urls" + private val supportedRelations = listOf(HANDLE_ALL_RELATION, LOGIN_CREDENTIALS_RELATION) + private const val APP_NAMESPACE = "android_app" + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/AssetLinksService.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/AssetLinksService.kt new file mode 100644 index 000000000000..b1edd9930b6d --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/AssetLinksService.kt @@ -0,0 +1,42 @@ +/* + * 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.mapper + +import com.duckduckgo.anvil.annotations.ContributesServiceApi +import com.duckduckgo.di.scopes.AppScope +import retrofit2.http.GET +import retrofit2.http.Url + +@ContributesServiceApi(AppScope::class) +interface AssetLinksService { + @GET + suspend fun getAssetLinks( + @Url assetLinkUrl: String, + ): List +} + +data class AssetLink( + val relation: List, + val target: AssetLinkTarget, +) + +data class AssetLinkTarget( + val namespace: String, + val package_name: String?, + val sha256_cert_fingerprints: List?, + +) diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/PackageManagerExtension.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/PackageManagerExtension.kt new file mode 100644 index 000000000000..2f688a9a6698 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/PackageManagerExtension.kt @@ -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.mapper + +import android.annotation.SuppressLint +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.Signature +import androidx.annotation.RequiresApi +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import java.security.MessageDigest + +@SuppressLint("NewApi") +internal fun PackageManager.getSHA256HexadecimalFingerprintCompat( + packageName: String, + appBuildConfig: AppBuildConfig, +): List { + return kotlin.runCatching { + if (appBuildConfig.sdkInt >= 28) { + getSHA256Fingerprint(packageName, this) + } else { + getSHA256FingerprintLegacy(packageName, this) + } + }.getOrElse { emptyList() } +} + +@RequiresApi(28) +private fun getSHA256Fingerprint( + packageName: String, + packageManager: PackageManager, +): List { + return try { + val packageInfo: PackageInfo = packageManager.getPackageInfo( + packageName, + PackageManager.GET_SIGNING_CERTIFICATES, // Use GET_SIGNING_CERTIFICATES for API 28+ + ) + + // Get the signing certificates + val signatures = packageInfo.signingInfo?.let { + if (it.hasMultipleSigners()) { + it.apkContentsSigners + } else { + it.signingCertificateHistory + } + } + + signatures?.map { + it.sha256() + } ?: emptyList() + } catch (e: Exception) { + e.printStackTrace() + emptyList() + } +} + +@Suppress("DEPRECATION") +private fun getSHA256FingerprintLegacy( + packageName: String, + packageManager: PackageManager, +): List { + val packageInfo: PackageInfo = packageManager.getPackageInfo( + packageName, + PackageManager.GET_SIGNATURES, + ) + + val signatures = packageInfo.signatures ?: return emptyList() + + if (signatures.size != 1) { + return emptyList() + } + + return signatures.map { + it.sha256() + } +} + +private fun Signature.sha256(): String { + val md = MessageDigest.getInstance("SHA-256") + val bytes = md.digest(this.toByteArray()) + + // convert byte array to a hexadecimal string representation + return bytes.joinToString(":") { byte -> "%02X".format(byte) } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/RemoteDomainTargetAppDataDownloader.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/RemoteDomainTargetAppDataDownloader.kt new file mode 100644 index 000000000000..9a73d8404d86 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/RemoteDomainTargetAppDataDownloader.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.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 +import com.duckduckgo.autofill.store.targets.TargetApp +import com.duckduckgo.common.utils.CurrentTimeProvider +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 RemoteDomainTargetAppDataDownloader @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + private val remoteDomainTargetAppService: RemoteDomainTargetAppService, + 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) + if (autofillServiceFeature.canUpdateAppToDomainDataset().isEnabled().not()) return + appCoroutineScope.launch(dispatcherProvider.io()) { + Timber.d("Autofill-mapping: Attempting to download") + download() + removeExpiredCachedData() + } + } + + private suspend fun download() { + runCatching { + remoteDomainTargetAppService.fetchDataset().run { + Timber.d("Autofill-mapping: Downloaded targets dataset version: ${this.version}") + if (autofillPrefsStore.domainTargetDatasetVersion != this.version) { + persistData(this) + autofillPrefsStore.domainTargetDatasetVersion = this.version + } + } + }.onFailure { + Timber.e(it, "Autofill-mapping: Dataset download failed") + } + } + + private fun persistData(dataset: RemoteDomainTargetDataSet) { + Timber.d("Autofill-mapping: Persisting targets dataset") + val toPersist = mutableListOf() + dataset.targets.forEach { target -> + target.apps.forEach { app -> + app.sha256_cert_fingerprints.forEach { + toPersist.add( + DomainTargetAppEntity( + domain = target.url, + targetApp = TargetApp( + packageName = app.package_name, + sha256CertFingerprints = it, + ), + dataExpiryInMillis = 0L, + ), + ) + } + } + } + Timber.d("Autofill-mapping: Attempting to persist ${toPersist.size} entries") + domainTargetAppDao.updateRemote(toPersist) + Timber.d("Autofill-mapping: Persist complete") + } + + private fun removeExpiredCachedData() { + Timber.d("Autofill-mapping: Removing expired cached data") + domainTargetAppDao.deleteAllExpired(currentTimeProvider.currentTimeMillis()) + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/RemoteDomainTargetAppService.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/RemoteDomainTargetAppService.kt new file mode 100644 index 000000000000..3a4ceb22306e --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/mapper/RemoteDomainTargetAppService.kt @@ -0,0 +1,42 @@ +/* + * 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.mapper + +import com.duckduckgo.anvil.annotations.ContributesServiceApi +import com.duckduckgo.di.scopes.AppScope +import retrofit2.http.GET + +@ContributesServiceApi(AppScope::class) +interface RemoteDomainTargetAppService { + @GET("https://staticcdn.duckduckgo.com/android/domain-app-mapping.json") + suspend fun fetchDataset(): RemoteDomainTargetDataSet +} + +data class RemoteDomainTargetDataSet( + val version: Long, + val targets: List, +) + +data class RemoteDomainTarget( + val url: String, + val apps: List, +) + +data class RemoteTargetApp( + val package_name: String, + val sha256_cert_fingerprints: List, +) diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/RealAutofillProviderSuggestionsTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/RealAutofillProviderSuggestionsTest.kt index 753049c04fc2..086fd096838d 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/RealAutofillProviderSuggestionsTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/RealAutofillProviderSuggestionsTest.kt @@ -23,6 +23,7 @@ import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.store.AutofillStore import com.duckduckgo.autofill.impl.service.AutofillFieldType.PASSWORD import com.duckduckgo.autofill.impl.service.AutofillFieldType.USERNAME +import com.duckduckgo.autofill.impl.service.mapper.AppCredentialProvider import com.duckduckgo.common.test.CoroutineTestRule import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -47,6 +48,7 @@ class RealAutofillProviderSuggestionsTest { } private val autofillStore = mock() + private val appCredentialProvider = mock() private val mockViewProvider = mock() @@ -60,6 +62,7 @@ class RealAutofillProviderSuggestionsTest { autofillStore = autofillStore, viewProvider = mockViewProvider, suggestionsFormatter = suggestionFormatter, + appCredentialProvider = appCredentialProvider, ) @Test @@ -75,8 +78,8 @@ class RealAutofillProviderSuggestionsTest { testee.buildSuggestionsResponse( context = context, AutofillRootNode( - "com.example.app", null, + "example.com", listOf( ParsedAutofillField(autofillId(), "", "", "", USERNAME, viewNode()), ), @@ -100,8 +103,8 @@ class RealAutofillProviderSuggestionsTest { testee.buildSuggestionsResponse( context = context, AutofillRootNode( - "com.example.app", null, + "example.com", listOf( ParsedAutofillField(autofillId(), "", "", "", USERNAME, viewNode()), ), @@ -130,8 +133,8 @@ class RealAutofillProviderSuggestionsTest { testee.buildSuggestionsResponse( context = context, AutofillRootNode( - "com.example.app", null, + "example.com", listOf( ParsedAutofillField(autofillId(), "", "", "", USERNAME, viewNode()), ), @@ -160,8 +163,8 @@ class RealAutofillProviderSuggestionsTest { testee.buildSuggestionsResponse( context = context, AutofillRootNode( - "com.example.app", null, + "example.com", listOf( ParsedAutofillField(autofillId(), "", "", "", USERNAME, viewNode()), ), @@ -189,8 +192,35 @@ class RealAutofillProviderSuggestionsTest { testee.buildSuggestionsResponse( context = context, AutofillRootNode( - "com.example.app", null, + "example.com", + listOf( + ParsedAutofillField(autofillId(), "", "", "", USERNAME, viewNode()), + ), + ), + mock(), + ) + + verify(mockViewProvider, times(3)).createFormPresentation(any(), any(), any(), any()) + } + + @Test + fun whenCredentialsForPackageAndDomainFoundThenAddBothTypeSuggestions() = runTest { + val credentialsDomain = listOf( + LoginCredentials(1L, "username", "password", "example.com"), + ) + val credentialsPackage = listOf( + LoginCredentials(2L, "username2", "password2", "example2.com"), + ) + whenever(autofillStore.getCredentials(any())).thenReturn(credentialsDomain) + whenever(appCredentialProvider.getCredentials(any())).thenReturn(credentialsPackage) + whenever(mockViewProvider.createFormPresentation(any(), any(), any(), any())).thenReturn(mock()) + + testee.buildSuggestionsResponse( + context = context, + AutofillRootNode( + "com.example.app", + "example.com", listOf( ParsedAutofillField(autofillId(), "", "", "", USERNAME, viewNode()), ), @@ -201,6 +231,33 @@ class RealAutofillProviderSuggestionsTest { verify(mockViewProvider, times(3)).createFormPresentation(any(), any(), any(), any()) } + @Test + fun whenCredentialsForPackageAndDomainAreSameThenDedupSuggestions() = runTest { + val credentialsDomain = listOf( + LoginCredentials(1L, "username", "password", "example.com"), + ) + val credentialsPackage = listOf( + LoginCredentials(1L, "username", "password", "example.com"), + ) + whenever(autofillStore.getCredentials(any())).thenReturn(credentialsDomain) + whenever(appCredentialProvider.getCredentials(any())).thenReturn(credentialsPackage) + whenever(mockViewProvider.createFormPresentation(any(), any(), any(), any())).thenReturn(mock()) + + testee.buildSuggestionsResponse( + context = context, + AutofillRootNode( + "com.example.app", + "example.com", + listOf( + ParsedAutofillField(autofillId(), "", "", "", USERNAME, viewNode()), + ), + ), + mock(), + ) + + verify(mockViewProvider, times(2)).createFormPresentation(any(), any(), any(), any()) + } + @Test fun whenNoCredentialsFoundThenOnlyOpenDDGAppItemIsAdded() = runTest { whenever(autofillStore.getCredentials(any())).thenReturn(emptyList()) @@ -209,8 +266,8 @@ class RealAutofillProviderSuggestionsTest { testee.buildSuggestionsResponse( context = context, AutofillRootNode( - "com.example.app", null, + "example.com", listOf( ParsedAutofillField(autofillId(), "", "", "", USERNAME, viewNode()), ), @@ -232,8 +289,8 @@ class RealAutofillProviderSuggestionsTest { testee.buildSuggestionsResponse( context = context, AutofillRootNode( - "com.example.app", null, + "example.com", listOf( ParsedAutofillField(autofillId(), "", "", "", USERNAME, viewNode()), ParsedAutofillField(autofillId(), "", "", "", PASSWORD, viewNode()), @@ -258,8 +315,8 @@ class RealAutofillProviderSuggestionsTest { testee.buildSuggestionsResponse( context = context, AutofillRootNode( - "com.example.app", null, + "example.com", listOf( ParsedAutofillField(autofillId(), "", "", "", USERNAME, viewNode()), ParsedAutofillField(autofillId(), "", "", "", PASSWORD, viewNode()), @@ -286,8 +343,8 @@ class RealAutofillProviderSuggestionsTest { testee.buildSuggestionsResponse( context = context, AutofillRootNode( - "com.example.app", null, + "example.com", listOf( ParsedAutofillField(autofillId(), "", "", "", USERNAME, viewNode()), ParsedAutofillField(autofillId(), "", "", "", PASSWORD, viewNode()), diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/mapper/RealAppCredentialProviderTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/mapper/RealAppCredentialProviderTest.kt new file mode 100644 index 000000000000..6ef659bf13f2 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/mapper/RealAppCredentialProviderTest.kt @@ -0,0 +1,110 @@ +package com.duckduckgo.autofill.impl.service.mapper + +import android.annotation.SuppressLint +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 +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.whenever + +@SuppressLint("DenyListedApi") +class RealAppCredentialProviderTest { + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + @Mock + private lateinit var mapper: AppToDomainMapper + + private val autofillServiceFeature = FakeFeatureToggleFactory.create(AutofillServiceFeature::class.java) + + private lateinit var toTest: RealAppCredentialProvider + + private val store = FakeAutofillStore( + listOf( + LoginCredentials( + domain = "package1.com", + username = "username1", + password = "password1", + ), + LoginCredentials( + domain = "package2.com", + username = "username2", + password = "password2", + ), + ), + ) + + @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")) + + val result = toTest.getCredentials("com.duplicate.domains") + + assertNotNull(result) + assertEquals(2, result.count()) + assertNotNull(result.find { it.domain == "package1.com" }) + assertNotNull(result.find { it.domain == "package2.com" }) + } + + @Test + fun whenAppHasUniqueAssociatedDomainAndCredentialReturnDistinctCredentials() = runTest { + whenever(mapper.getAssociatedDomains("com.distinct.domain")).thenReturn(listOf("package2.com")) + + val result = toTest.getCredentials("com.distinct.domain") + + assertEquals(1, result.count()) + assertNotNull(result.find { it.domain == "package2.com" }) + } + + @Test + fun whenAppHasNoAssociatedDomainsReturnEmptyList() = runTest { + whenever(mapper.getAssociatedDomains("com.no.domain")).thenReturn(emptyList()) + + val result = toTest.getCredentials("com.no.domain") + + assertTrue(result.isEmpty()) + } + + @Test + fun whenAssociatedDomainHasNoCredentialReturnEmptyList() = runTest { + whenever(mapper.getAssociatedDomains("com.no.credential")).thenReturn(listOf("package4.com")) + + val result = toTest.getCredentials("com.no.credential") + + assertTrue(result.isEmpty()) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/mapper/RealAppToDomainMapperTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/mapper/RealAppToDomainMapperTest.kt new file mode 100644 index 000000000000..71526a3fa651 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/mapper/RealAppToDomainMapperTest.kt @@ -0,0 +1,160 @@ +package com.duckduckgo.autofill.impl.service.mapper + +import androidx.test.ext.junit.runners.AndroidJUnit4 +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 java.util.concurrent.TimeUnit +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +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.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class RealAppToDomainMapperTest { + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + @Mock + private lateinit var dao: DomainTargetAppDao + + @Mock + private lateinit var fingerprintProvider: AppFingerprintProvider + + @Mock + private lateinit var currentTimeProvider: CurrentTimeProvider + + @Mock + private lateinit var assetLinksLoader: AssetLinksLoader + + private lateinit var toTest: RealAppToDomainMapper + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + toTest = RealAppToDomainMapper( + dao, + fingerprintProvider, + assetLinksLoader, + coroutineTestRule.testDispatcherProvider, + currentTimeProvider, + ) + + whenever(currentTimeProvider.currentTimeMillis()).thenReturn(1737464996667) + } + + @Test + fun whenAppWithValidCredentialsExistInDatasetThenReturnAllMatchingDomains() = runTest { + whenever(fingerprintProvider.getSHA256HexadecimalFingerprint("com.real.app")).thenReturn(listOf("realfingerprint")) + whenever(dao.getDomainsForApp("com.real.app", listOf("realfingerprint"))).thenReturn( + listOf( + "dataset-domain-2.com", + "dataset-domain.com", + ), + ) + + val result = toTest.getAssociatedDomains("com.real.app") + + assertTrue(result.isNotEmpty()) + assertEquals(2, result.size) + assertTrue(result.contains("dataset-domain-2.com")) + assertTrue(result.contains("dataset-domain.com")) + } + + @Test + fun whenMaliciousAppWithInvalidFingerprintThenReturnNoDomain() = runTest { + whenever(fingerprintProvider.getSHA256HexadecimalFingerprint("com.potentially.malicious.app")).thenReturn(listOf("fakefingerprint")) + whenever(dao.getDomainsForApp("com.potentially.malicious.app", listOf("realfingerprint"))).thenReturn(listOf("dataset-domain.com")) + whenever(assetLinksLoader.getValidTargetApps("potentially.com")).thenReturn( + mapOf("com.potentially.malicious.app" to listOf("realfingerprint")), + ) + + val result = toTest.getAssociatedDomains("com.potentially.malicious.app") + + // Doesn't match dataset and assetlinks, despite package being present in both due to invalid fingerprint + assertTrue(result.isEmpty()) + } + + @Test + fun whenValidAppIsNotInDatasetButHasValidReversedPackageDomainThenReturnAllMatchingDomains() = runTest { + whenever(fingerprintProvider.getSHA256HexadecimalFingerprint("com.website.app")).thenReturn(listOf("realfingerprint")) + whenever(dao.getDomainsForApp("com.website.app", listOf("fakefingerprint"))).thenReturn(emptyList()) + whenever(assetLinksLoader.getValidTargetApps("website.com")).thenReturn( + mapOf( + "com.website.app" to listOf( + "realfingerprint", + "realfingerprint2", + ), + ), + ) + + val result = toTest.getAssociatedDomains("com.website.app") + + assertTrue(result.isNotEmpty()) + assertEquals(1, result.size) + assertTrue(result.contains("website.com")) + // Check data is persisted + verify(dao).insertAllMapping( + listOf( + DomainTargetAppEntity( + domain = "website.com", + targetApp = TargetApp( + packageName = "com.website.app", + sha256CertFingerprints = "realfingerprint", + ), + dataExpiryInMillis = 1737464996667 + TimeUnit.DAYS.toMillis(30), + ), + DomainTargetAppEntity( + domain = "website.com", + targetApp = TargetApp( + packageName = "com.website.app", + sha256CertFingerprints = "realfingerprint2", + ), + dataExpiryInMillis = 1737464996667 + TimeUnit.DAYS.toMillis(30), + ), + ), + ) + } + + @Test + fun whenAppIsNotInDatasetAndHasNoAssetlinksThenReturnNoDomain() = runTest { + whenever(fingerprintProvider.getSHA256HexadecimalFingerprint("com.website-invalid.app")).thenReturn(listOf("realfingerprint")) + whenever(dao.getDomainsForApp("com.website-invalid.app", listOf("fakefingerprint"))).thenReturn(emptyList()) + whenever(assetLinksLoader.getValidTargetApps("website-invalid.com")).thenReturn(emptyMap()) + + val result = toTest.getAssociatedDomains("com.website-invalid.app") + + // Doesn't match dataset but matches assetlinks + assertTrue(result.isEmpty()) + } + + @Test + fun whenUnableToGetFingerprintThenReturnEmpty() = runTest { + whenever(fingerprintProvider.getSHA256HexadecimalFingerprint("com.no.app")).thenReturn(emptyList()) + + val result = toTest.getAssociatedDomains("com.no.fingerprint") + + assertTrue(result.isEmpty()) + verifyNoInteractions(dao) + verifyNoInteractions(assetLinksLoader) + } + + @Test + fun whenAppPackageYieldsNullDomainWhenReversedThenReturnEmpty() = runTest { + whenever(fingerprintProvider.getSHA256HexadecimalFingerprint("com")).thenReturn(listOf("realfingerprint")) + + val result = toTest.getAssociatedDomains("com") + + assertTrue(result.isEmpty()) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/mapper/RealAssetLinksLoaderTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/mapper/RealAssetLinksLoaderTest.kt new file mode 100644 index 000000000000..17e989ba582b --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/mapper/RealAssetLinksLoaderTest.kt @@ -0,0 +1,114 @@ +package com.duckduckgo.autofill.impl.service.mapper + +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.whenever + +class RealAssetLinksLoaderTest { + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + @Mock + private lateinit var service: AssetLinksService + private lateinit var toTest: RealAssetLinksLoader + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + toTest = RealAssetLinksLoader(service, coroutineTestRule.testDispatcherProvider) + } + + @Test + fun whenServiceThrowsAnExceptionTheReturnEmptyMap() = runTest { + whenever(service.getAssetLinks("not-supported.com")).thenThrow(RuntimeException()) + + assertTrue(toTest.getValidTargetApps("not-supported.com").isEmpty()) + } + + @Test + fun whenServiceReturnsAssetlinksTheReturnMapWithValidTargetsOnnly() = runTest { + whenever(service.getAssetLinks("https://supported.com/.well-known/assetlinks.json")).thenReturn( + listOf( + AssetLink( + relation = listOf("delegate_permission/invalid"), + target = AssetLinkTarget( + namespace = "android_app", + package_name = "app.no.valid.relation", + sha256_cert_fingerprints = listOf("fingerprint"), + ), + ), + AssetLink( + relation = listOf("delegate_permission/common.get_login_creds"), + target = AssetLinkTarget( + namespace = "web", + package_name = null, + sha256_cert_fingerprints = null, + ), + ), + AssetLink( + relation = listOf("delegate_permission/common.get_login_creds"), + target = AssetLinkTarget( + namespace = "android_app", + package_name = "app.valid", + sha256_cert_fingerprints = listOf("fingerprint", "fingerprint2"), + ), + ), + AssetLink( + relation = listOf("delegate_permission/common.handle_all_urls", "delegate_permission/common.get_login_creds"), + target = AssetLinkTarget( + namespace = "android_app", + package_name = "app.valid.2", + sha256_cert_fingerprints = listOf("fingerprint"), + ), + ), + AssetLink( + relation = listOf("delegate_permission/common.handle_all_urls"), + target = AssetLinkTarget( + namespace = "android_app", + package_name = "app.valid.3", + sha256_cert_fingerprints = listOf("fingerprint"), + ), + ), + AssetLink( + relation = listOf("delegate_permission/common.handle_all_urls", "delegate_permission/common.get_login_creds"), + target = AssetLinkTarget( + namespace = "android_app", + package_name = "app.no.fingerprint", + sha256_cert_fingerprints = null, + ), + ), + AssetLink( + relation = listOf("delegate_permission/common.handle_all_urls", "delegate_permission/common.get_login_creds"), + target = AssetLinkTarget( + namespace = "android_app", + package_name = "app.empty.fingerprint", + sha256_cert_fingerprints = emptyList(), + ), + ), + AssetLink( + relation = listOf("delegate_permission/common.handle_all_urls", "delegate_permission/common.get_login_creds"), + target = AssetLinkTarget( + namespace = "android_app", + package_name = null, + sha256_cert_fingerprints = listOf("fingerprint"), + ), + ), + ), + ) + + toTest.getValidTargetApps("supported.com").let { + assertTrue(it.isNotEmpty()) + assertEquals(3, it.size) + assertEquals(listOf("fingerprint", "fingerprint2"), it["app.valid"]) + assertEquals(listOf("fingerprint"), it["app.valid.2"]) + assertEquals(listOf("fingerprint"), it["app.valid.3"]) + } + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/mapper/RemoteDomainTargetAppDataDownloaderTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/mapper/RemoteDomainTargetAppDataDownloaderTest.kt new file mode 100644 index 000000000000..c6f2696c0ba1 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/mapper/RemoteDomainTargetAppDataDownloaderTest.kt @@ -0,0 +1,182 @@ +package com.duckduckgo.autofill.impl.service.mapper + +import android.annotation.SuppressLint +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 +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +@SuppressLint("DenyListedApi") +class RemoteDomainTargetAppDataDownloaderTest { + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + @Mock + private lateinit var remoteDomainTargetAppService: RemoteDomainTargetAppService + + @Mock + private lateinit var autofillPrefsStore: AutofillPrefsStore + + @Mock + private lateinit var domainTargetAppDao: DomainTargetAppDao + + @Mock + private lateinit var currentTimeProvider: CurrentTimeProvider + + @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, + targets = listOf( + RemoteDomainTarget( + url = "hello.com", + apps = listOf( + RemoteTargetApp( + package_name = "app.valid", + sha256_cert_fingerprints = listOf("fingerprint"), + ), + ), + ), + + RemoteDomainTarget( + url = "shared.com", + apps = listOf( + RemoteTargetApp( + package_name = "app.valid", + sha256_cert_fingerprints = listOf("fingerprint"), + ), + ), + ), + RemoteDomainTarget( + url = "hello2.com", + apps = listOf( + RemoteTargetApp( + package_name = "app.valid2", + sha256_cert_fingerprints = listOf("fingerprint", "fingerprint2"), + ), + RemoteTargetApp( + package_name = "app.valid3", + sha256_cert_fingerprints = listOf("fingerprint3"), + ), + ), + ), + ), + ) + + @Before + 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) + whenever(remoteDomainTargetAppService.fetchDataset()).thenReturn(dataset) + + toTest.onCreate(mockOwner) + + verify(remoteDomainTargetAppService).fetchDataset() + verify(autofillPrefsStore).domainTargetDatasetVersion = 1 + verify(domainTargetAppDao).deleteAllExpired(1737548373455) + verify(domainTargetAppDao).updateRemote( + listOf( + DomainTargetAppEntity( + domain = "hello.com", + targetApp = TargetApp( + packageName = "app.valid", + sha256CertFingerprints = "fingerprint", + ), + dataExpiryInMillis = 0L, + ), + DomainTargetAppEntity( + domain = "shared.com", + targetApp = TargetApp( + packageName = "app.valid", + sha256CertFingerprints = "fingerprint", + ), + dataExpiryInMillis = 0L, + ), + DomainTargetAppEntity( + domain = "hello2.com", + targetApp = TargetApp( + packageName = "app.valid2", + sha256CertFingerprints = "fingerprint", + ), + dataExpiryInMillis = 0L, + ), + DomainTargetAppEntity( + domain = "hello2.com", + targetApp = TargetApp( + packageName = "app.valid2", + sha256CertFingerprints = "fingerprint2", + ), + dataExpiryInMillis = 0L, + ), + DomainTargetAppEntity( + domain = "hello2.com", + targetApp = TargetApp( + packageName = "app.valid3", + sha256CertFingerprints = "fingerprint3", + ), + dataExpiryInMillis = 0L, + ), + ), + ) + } + + @Test + fun whenVersionIsTheSameThenDontPersistData() = runTest { + whenever(autofillPrefsStore.domainTargetDatasetVersion).thenReturn(1) + whenever(remoteDomainTargetAppService.fetchDataset()).thenReturn(dataset) + + toTest.onCreate(mockOwner) + + verify(remoteDomainTargetAppService).fetchDataset() + verify(domainTargetAppDao).deleteAllExpired(1737548373455) + verifyNoMoreInteractions(domainTargetAppDao) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/mapper/fakes/FakeAutofillStore.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/mapper/fakes/FakeAutofillStore.kt new file mode 100644 index 000000000000..5ae29349352a --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/mapper/fakes/FakeAutofillStore.kt @@ -0,0 +1,28 @@ +/* + * 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.mapper.fakes + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.api.store.AutofillStore + +class FakeAutofillStore( + private val credentials: List, +) : AutofillStore { + override suspend fun getCredentials(rawUrl: String): List { + return credentials.filter { it.domain == rawUrl } + } +} diff --git a/autofill/autofill-internal/src/main/AndroidManifest.xml b/autofill/autofill-internal/src/main/AndroidManifest.xml index cbf65f598829..d06161ef7e98 100644 --- a/autofill/autofill-internal/src/main/AndroidManifest.xml +++ b/autofill/autofill-internal/src/main/AndroidManifest.xml @@ -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"> diff --git a/autofill/autofill-store/src/main/java/com/duckduckgo/autofill/store/AutofillPrefsStore.kt b/autofill/autofill-store/src/main/java/com/duckduckgo/autofill/store/AutofillPrefsStore.kt index ce0b79eb5852..df09788a7577 100644 --- a/autofill/autofill-store/src/main/java/com/duckduckgo/autofill/store/AutofillPrefsStore.kt +++ b/autofill/autofill-store/src/main/java/com/duckduckgo/autofill/store/AutofillPrefsStore.kt @@ -29,6 +29,7 @@ interface AutofillPrefsStore { var hasEverBeenPromptedToSaveLogin: Boolean val autofillStateSetByUser: Boolean var timestampUserLastPromptedToDisableAutofill: Long? + var domainTargetDatasetVersion: Long /** * Returns if Autofill was enabled by default. @@ -92,6 +93,14 @@ class RealAutofillPrefsStore( } set(value) = prefs.edit { putLong(TIMESTAMP_WHEN_USER_LAST_PROMPTED_TO_DISABLE_AUTOFILL, value ?: -1) } + override var domainTargetDatasetVersion: Long + get() = prefs.getLong(DOMAIN_TARGET_DATASET_VERSION, 0L) + set(value) { + prefs.edit { + putLong(DOMAIN_TARGET_DATASET_VERSION, value) + } + } + /** * Returns if Autofill was enabled by default. Note, this is not necessarily the same as the current state of Autofill. */ @@ -144,5 +153,6 @@ class RealAutofillPrefsStore( const val AUTOFILL_DECLINE_COUNT = "autofill_decline_count" const val MONITOR_AUTOFILL_DECLINES = "monitor_autofill_declines" const val ORIGINAL_AUTOFILL_DEFAULT_STATE_ENABLED = "original_autofill_default_state_enabled" + const val DOMAIN_TARGET_DATASET_VERSION = "domain_target_dataset_version" } } diff --git a/autofill/autofill-store/src/main/java/com/duckduckgo/autofill/store/targets/DomainTargetAppDao.kt b/autofill/autofill-store/src/main/java/com/duckduckgo/autofill/store/targets/DomainTargetAppDao.kt new file mode 100644 index 000000000000..d99f078ffa76 --- /dev/null +++ b/autofill/autofill-store/src/main/java/com/duckduckgo/autofill/store/targets/DomainTargetAppDao.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.autofill.store.targets + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction + +@Dao +abstract class DomainTargetAppDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract fun insertAllMapping(mapping: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract fun insert(mapping: DomainTargetAppEntity) + + @Transaction + open fun updateAll(mapping: List) { + deleteAllMapping() + insertAllMapping(mapping) + } + + @Transaction + open fun updateRemote(mapping: List) { + deleteAllRemote() + insertAllMapping(mapping) + } + + @Query("delete from autofill_domain_target_apps_mapping") + abstract fun deleteAllMapping() + + @Query("delete from autofill_domain_target_apps_mapping WHERE dataExpiryInMillis = 0") + abstract fun deleteAllRemote() + + @Query("delete from autofill_domain_target_apps_mapping WHERE dataExpiryInMillis > 0 AND dataExpiryInMillis < :currentTimeMillis") + abstract fun deleteAllExpired(currentTimeMillis: Long) + + @Query("select app_package, app_fingerprint from autofill_domain_target_apps_mapping where domain = :domain") + abstract fun getTargetAppsForDomain(domain: String): List + + @Query("select domain from autofill_domain_target_apps_mapping where app_package = :packageName AND app_fingerprint = :fingerprint") + abstract fun getDomainsForApp( + packageName: String, + fingerprint: String, + ): List + + @Query("select domain from autofill_domain_target_apps_mapping where app_package = :packageName AND app_fingerprint IN (:fingerprints)") + abstract fun getDomainsForApp( + packageName: String, + fingerprints: List, + ): List +} diff --git a/autofill/autofill-store/src/main/java/com/duckduckgo/autofill/store/targets/DomainTargetAppEntity.kt b/autofill/autofill-store/src/main/java/com/duckduckgo/autofill/store/targets/DomainTargetAppEntity.kt new file mode 100644 index 000000000000..164f7fc2bec4 --- /dev/null +++ b/autofill/autofill-store/src/main/java/com/duckduckgo/autofill/store/targets/DomainTargetAppEntity.kt @@ -0,0 +1,35 @@ +/* + * 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.store.targets + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "autofill_domain_target_apps_mapping") +data class DomainTargetAppEntity( + @PrimaryKey(autoGenerate = true) val id: Int = 0, + val domain: String, + @Embedded val targetApp: TargetApp, + val dataExpiryInMillis: Long, // If from our dataset this will be 0L, else if part of user's cache, then a value greater than 0L. +) + +data class TargetApp( + @ColumnInfo(name = "app_package") val packageName: String, + @ColumnInfo(name = "app_fingerprint") val sha256CertFingerprints: String, +) diff --git a/autofill/autofill-store/src/main/java/com/duckduckgo/autofill/store/targets/DomainTargetAppsDatabase.kt b/autofill/autofill-store/src/main/java/com/duckduckgo/autofill/store/targets/DomainTargetAppsDatabase.kt new file mode 100644 index 000000000000..21ecd60e256b --- /dev/null +++ b/autofill/autofill-store/src/main/java/com/duckduckgo/autofill/store/targets/DomainTargetAppsDatabase.kt @@ -0,0 +1,36 @@ +/* + * 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.store.targets + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.migration.Migration + +@Database( + exportSchema = true, + version = 1, + entities = [ + DomainTargetAppEntity::class, + ], +) +abstract class DomainTargetAppsDatabase : RoomDatabase() { + abstract fun domainTargetAppDao(): DomainTargetAppDao + + companion object { + val ALL_MIGRATIONS = emptyArray() + } +}