Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Autofill: Implement mapping for appPackages for autofill provider service #5508

Open
wants to merge 14 commits into
base: feature/cristian/autofill/autofill_provider_poc
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -195,7 +197,7 @@ class RealAutofillProviderSuggestions @Inject constructor(
} ?: emptyList()

val crendentialsForPackage = node.packageId.takeUnless { it.isNullOrBlank() }?.let {
autofillStore.getCredentials(it)
appCredentialProvider.getCredentials(it)
} ?: emptyList()

Timber.i("DDGAutofillService credentials for domain: $crendentialsForDomain")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* 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.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<LoginCredentials>
}

@ContributesBinding(AppScope::class)
@SingleInstanceIn(AppScope::class)
class RealAppCredentialProvider @Inject constructor(
private val appToDomainMapper: AppToDomainMapper,
private val dispatcherProvider: DispatcherProvider,
private val autofillStore: AutofillStore,
) : AppCredentialProvider {
override suspend fun getCredentials(appPackage: String): List<LoginCredentials> = withContext(dispatcherProvider.io()) {
Timber.d("Autofill-mapping: Getting credentials for $appPackage")
karlenDimla marked this conversation as resolved.
Show resolved Hide resolved
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<LoginCredentials> {
return autofillStore.getCredentials(domain)
}
}
Original file line number Diff line number Diff line change
@@ -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<String>
}

@ContributesBinding(AppScope::class)
class RealAppFingerprintProvider @Inject constructor(
private val appBuildConfig: AppBuildConfig,
private val context: Context,
) : AppFingerprintProvider {
override fun getSHA256HexadecimalFingerprint(packageName: String): List<String> =
context.packageManager.getSHA256HexadecimalFingerprintCompat(packageName, appBuildConfig)
karlenDimla marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
@@ -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<String>
}

@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<String> {
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<String>,
): List<String> {
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<String>,
): List<String> {
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<String, List<String>>,
) = runCatching {
val toPersist = mutableListOf<DomainTargetAppEntity>()
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
}
}
Original file line number Diff line number Diff line change
@@ -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<String, List<String>>
}

@ContributesBinding(AppScope::class)
class RealAssetLinksLoader @Inject constructor(
private val assetLinksService: AssetLinksService,
private val dispatcherProvider: DispatcherProvider,
) : AssetLinksLoader {
override suspend fun getValidTargetApps(domain: String): Map<String, List<String>> {
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"
karlenDimla marked this conversation as resolved.
Show resolved Hide resolved
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"
}
}
Original file line number Diff line number Diff line change
@@ -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<AssetLink>
}

data class AssetLink(
val relation: List<String>,
val target: AssetLinkTarget,
)

data class AssetLinkTarget(
val namespace: String,
val package_name: String?,
val sha256_cert_fingerprints: List<String>?,

)
Loading
Loading