From ed11c6735269fb080f17696ded0f37f88e450073 Mon Sep 17 00:00:00 2001 From: Cristian Monforte Date: Thu, 28 Nov 2024 16:21:54 +0100 Subject: [PATCH 1/5] Autofill system provider --- .../src/main/AndroidManifest.xml | 13 ++ .../impl/service/DDGAutofillService.kt | 213 ++++++++++++++++++ .../main/res/xml/service_configuration.xml | 18 ++ 3 files changed, 244 insertions(+) create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/DDGAutofillService.kt create mode 100644 autofill/autofill-impl/src/main/res/xml/service_configuration.xml diff --git a/autofill/autofill-impl/src/main/AndroidManifest.xml b/autofill/autofill-impl/src/main/AndroidManifest.xml index 01a906eb9004..7eff3ea37395 100644 --- a/autofill/autofill-impl/src/main/AndroidManifest.xml +++ b/autofill/autofill-impl/src/main/AndroidManifest.xml @@ -24,6 +24,19 @@ android:configChanges="orientation|screenSize" android:exported="false" android:windowSoftInputMode="adjustResize" /> + + + + + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/DDGAutofillService.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/DDGAutofillService.kt new file mode 100644 index 000000000000..b948fd34d324 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/DDGAutofillService.kt @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.service + +import android.R +import android.app.assist.AssistStructure +import android.app.assist.AssistStructure.ViewNode +import android.os.CancellationSignal +import android.service.autofill.AutofillService +import android.service.autofill.Dataset +import android.service.autofill.FillCallback +import android.service.autofill.FillRequest +import android.service.autofill.FillResponse +import android.service.autofill.SaveCallback +import android.service.autofill.SaveRequest +import android.service.autofill.SavedDatasetsInfoCallback +import android.view.View +import android.view.autofill.AutofillValue +import android.widget.RemoteViews +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.autofill.api.store.AutofillStore +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.VpnScope +import dagger.android.AndroidInjection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@InjectWith( + scope = VpnScope::class, // we might need to have our own scope to avoid creating the whole app graph +) +class DDGAutofillService : AutofillService() { + + @Inject + @AppCoroutineScope + lateinit var coroutineScope: CoroutineScope + + @Inject lateinit var dispatcherProvider: DispatcherProvider + + @Inject lateinit var autofillStore: AutofillStore + + override fun onFillRequest( + request: FillRequest, + cancellationSignal: CancellationSignal, + callback: FillCallback + ) { + Timber.i("DDGAutofillService onFillRequest") + coroutineScope.launch(dispatcherProvider.io()) { + Timber.i("DDGAutofillService structure: ${request.fillContexts}") + val structure = request.fillContexts.last().structure ?: return@launch + Timber.i("DDGAutofillService structure: $structure") + + // Extract package name + val packageName = structure.activityComponent?.packageName.orEmpty() + Timber.i("DDGAutofillService packageName: $packageName") + + val fields = findFields(packageName, structure) + + if (fields.isNotEmpty()) { + val dataset = createDataset(fields) + + if (dataset == null) { + callback.onFailure("No dataset found.") + return@launch + } + + val response = FillResponse.Builder() + .addDataset(dataset) + .build() + callback.onSuccess(response) + } else { + callback.onFailure("No suitable fields found.") + } + } + } + + private suspend fun createDataset(fieldsRoot: Map>): Dataset? { + Timber.i("DDGAutofillService fieldsRoot keys: ${fieldsRoot.keys}") + val firstNonEmptyOrigin = fieldsRoot.keys.first { it.isNotEmpty() } + val fields = fieldsRoot.values.lastOrNull()?.let { fields -> + Timber.i("DDGAutofillService fields: $fields") + fields + } ?: return null + + val credential = autofillStore.getCredentials(firstNonEmptyOrigin).firstOrNull() ?: return null + + Timber.i("DDGAutofillService we have credentials ${credential.username} to use in -> $fields") + val datasetBuilder = Dataset.Builder() + fields["username"]?.let { usernameNode -> + val username = credential.username // Retrieve from your secure storage + val presentation = RemoteViews(packageName, android.R.layout.simple_list_item_1) + presentation.setTextViewText(android.R.id.text1, username) + datasetBuilder.setValue( + usernameNode.autofillId!!, + AutofillValue.forText(username), + presentation + ) + } + fields["password"]?.let { passwordNode -> + val password = credential.password // Retrieve from your secure storage + val presentation = RemoteViews(packageName, R.layout.simple_list_item_1) + presentation.setTextViewText(android.R.id.text1, "Password for ${credential.username}") + datasetBuilder.setValue( + passwordNode.autofillId!!, + AutofillValue.forText(password), + presentation + ) + } + return datasetBuilder.build() + } + + private fun findFields( + packageName: String, + structure: AssistStructure + ): Map>{ + val fields = mutableMapOf>() + val windowNodeCount = structure.windowNodeCount + Timber.i("DDGAutofillService windowNodeCount: $windowNodeCount") + for (i in 0 until windowNodeCount) { + val windowNode = structure.getWindowNodeAt(i) + val rootViewNode = windowNode.rootViewNode + traverseNode(rootViewNode, packageName, fields) + } + return fields + } + + private fun traverseNode(node: ViewNode, packageName: String, fields: MutableMap>) { + Timber.i("DDGAutofillService node web: ${node.webDomain}") + val domain = node.webDomain ?: packageName + + node.autofillHints?.let { hints -> + Timber.i("DDGAutofillService hints for $node: $hints") + for (hint in hints) { + Timber.i("DDGAutofillService hint: $hint") + when (hint) { + View.AUTOFILL_HINT_USERNAME -> { + Timber.i("DDGAutofillService hint is username for $domain") + fields[domain]?.let { + it["username"] = node + } ?: run { + fields[domain] = mutableMapOf("username" to node) + } + } + View.AUTOFILL_HINT_PASSWORD -> { + Timber.i("DDGAutofillService hint is password for $domain") + fields[domain]?.let { + it["password"] = node + } ?: run { + fields[domain] = mutableMapOf("password" to node) + } + } + View.AUTOFILL_HINT_EMAIL_ADDRESS -> { + Timber.i("DDGAutofillService hint is EMAIL for $domain") + fields[domain]?.let { + it["username"] = node + } ?: run { + fields[domain] = mutableMapOf("username" to node) + } + } + else -> { + Timber.i("DDGAutofillService hint is unknown: $hint") + } + } + } + } + for (i in 0 until node.childCount) { + traverseNode(node.getChildAt(i), packageName, fields) + } + } + + override fun onSaveRequest( + request: SaveRequest, callback: SaveCallback + ) { + Timber.i("DDGAutofillService onSaveRequest") + } + + override fun onCreate() { + super.onCreate() + Timber.i("DDGAutofillService created") + AndroidInjection.inject(this) + } + + override fun onConnected() { + super.onConnected() + Timber.i("DDGAutofillService onConnected") + } + + override fun onSavedDatasetsInfoRequest(callback: SavedDatasetsInfoCallback) { + super.onSavedDatasetsInfoRequest(callback) + Timber.i("DDGAutofillService onSavedDatasetsInfoRequest") + } + + override fun onDisconnected() { + super.onDisconnected() + Timber.i("DDGAutofillService onDisconnected") + } +} diff --git a/autofill/autofill-impl/src/main/res/xml/service_configuration.xml b/autofill/autofill-impl/src/main/res/xml/service_configuration.xml new file mode 100644 index 000000000000..d413efdd006f --- /dev/null +++ b/autofill/autofill-impl/src/main/res/xml/service_configuration.xml @@ -0,0 +1,18 @@ + + + \ No newline at end of file From d70adf0aeb815e9f099e375e87e72355f139abc5 Mon Sep 17 00:00:00 2001 From: Cristian Monforte Date: Wed, 4 Dec 2024 21:30:06 +0100 Subject: [PATCH 2/5] autofill provider with keyboard support --- autofill/autofill-impl/build.gradle | 1 + .../src/main/AndroidManifest.xml | 13 - .../autofill/impl/service/AutofillParser.kt | 149 +++++++++ .../service/AutofillProviderSuggestions.kt | 217 +++++++++++++ ...illServiceSuggestionCredentialFormatter.kt | 81 +++++ .../service/AutofillServiceViewProvider.kt | 143 ++++++++ .../impl/service/DDGAutofillService.kt | 213 ------------ .../impl/service/RealAutofillService.kt | 156 +++++++++ .../impl/service/ViewNodeClassifier.kt | 128 ++++++++ .../drawable/ic_dax_silhouette_primary_24.xml | 10 + .../main/res/layout/autofill_remote_view.xml | 43 +++ .../src/main/res/values/donottranslate.xml | 2 +- .../main/res/xml/service_configuration.xml | 3 +- .../service/AutofillProvideMockBuilder.kt | 118 +++++++ .../AutofillServiceViewNodeClassifierTest.kt | 273 ++++++++++++++++ .../impl/service/RealAutofillParserTest.kt | 207 ++++++++++++ .../RealAutofillProviderSuggestionsTest.kt | 304 ++++++++++++++++++ .../src/main/AndroidManifest.xml | 15 +- 18 files changed, 1846 insertions(+), 230 deletions(-) create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillParser.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillProviderSuggestions.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceSuggestionCredentialFormatter.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceViewProvider.kt delete mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/DDGAutofillService.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/RealAutofillService.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/ViewNodeClassifier.kt create mode 100644 autofill/autofill-impl/src/main/res/drawable/ic_dax_silhouette_primary_24.xml create mode 100644 autofill/autofill-impl/src/main/res/layout/autofill_remote_view.xml create mode 100644 autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/AutofillProvideMockBuilder.kt create mode 100644 autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/AutofillServiceViewNodeClassifierTest.kt create mode 100644 autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/RealAutofillParserTest.kt create mode 100644 autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/RealAutofillProviderSuggestionsTest.kt diff --git a/autofill/autofill-impl/build.gradle b/autofill/autofill-impl/build.gradle index 88287d919531..f8a126ddde92 100644 --- a/autofill/autofill-impl/build.gradle +++ b/autofill/autofill-impl/build.gradle @@ -54,6 +54,7 @@ dependencies { implementation Google.android.material implementation AndroidX.constraintLayout implementation JakeWharton.timber + implementation("androidx.autofill:autofill:1.1.0") implementation KotlinX.coroutines.core implementation AndroidX.fragment.ktx diff --git a/autofill/autofill-impl/src/main/AndroidManifest.xml b/autofill/autofill-impl/src/main/AndroidManifest.xml index 7eff3ea37395..01a906eb9004 100644 --- a/autofill/autofill-impl/src/main/AndroidManifest.xml +++ b/autofill/autofill-impl/src/main/AndroidManifest.xml @@ -24,19 +24,6 @@ android:configChanges="orientation|screenSize" android:exported="false" android:windowSoftInputMode="adjustResize" /> - - - - - - - \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillParser.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillParser.kt new file mode 100644 index 000000000000..f781211a72b6 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillParser.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.service + +import android.annotation.SuppressLint +import android.app.assist.AssistStructure +import android.app.assist.AssistStructure.ViewNode +import android.os.Build +import android.view.autofill.AutofillId +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.autofill.impl.service.AutofillFieldType.UNKNOWN +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import timber.log.Timber + +interface AutofillParser { + // Parses structure, detects autofill fields, and returns a list of root nodes. + // Each root node contains a list of parsed autofill fields. + // We intend that each root node has packageId and/or website, based on child values, but it's not guaranteed. + fun parseStructure(structure: AssistStructure): MutableList +} + +// Parsed root node of the autofill structure +data class AutofillRootNode( + val packageId: String?, + val website: String?, + val parsedAutofillFields: List, // Parsed fields in the structure +) + +// Parsed autofill field +data class ParsedAutofillField( + val autofillId: AutofillId, + val packageId: String?, + val website: String?, + val value: String, + val type: AutofillFieldType = AutofillFieldType.UNKNOWN, + val originalNode: ViewNode, +) + +enum class AutofillFieldType { + USERNAME, + PASSWORD, + UNKNOWN, +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class RealAutofillParser @Inject constructor( + private val appBuildConfig: AppBuildConfig, + private val viewNodeClassifier: ViewNodeClassifier, +) : AutofillParser { + + override fun parseStructure(structure: AssistStructure): MutableList { + val autofillRootNodes = mutableListOf() + val windowNodeCount = structure.windowNodeCount + Timber.i("DDGAutofillService windowNodeCount: $windowNodeCount") + for (i in 0 until windowNodeCount) { + val windowNode = structure.getWindowNodeAt(i) + windowNode.rootViewNode?.let { viewNode -> + autofillRootNodes.add( + traverseViewNode(viewNode).convertIntoAutofillNode(), + ) + } + } + Timber.i("DDGAutofillService convertedNodes: $autofillRootNodes") + autofillRootNodes.forEach { node -> + Timber.i("DDGAutofillService Detected Fields: ${node.parsedAutofillFields.filter { it.type != UNKNOWN }}") + } + return autofillRootNodes + } + + private fun traverseViewNode( + viewNode: ViewNode, + ): MutableList { + val autofillId = viewNode.autofillId ?: return mutableListOf() + Timber.i("DDGAutofillService Parsing NODE: $autofillId") + val traversalDataList = mutableListOf() + val packageId = viewNode.validPackageId() + val website = viewNode.website() + val autofillType = viewNodeClassifier.classify(viewNode) + val value = kotlin.runCatching { viewNode.autofillValue?.textValue?.toString() ?: "" }.getOrDefault("") + val parsedAutofillField = ParsedAutofillField( + autofillId = autofillId, + packageId = packageId, + website = website, + value = value, + type = autofillType, + originalNode = viewNode, + ) + Timber.i("DDGAutofillService Parsed as: $parsedAutofillField") + traversalDataList.add(parsedAutofillField) + + for (i in 0 until viewNode.childCount) { + val childNode = viewNode.getChildAt(i) + traversalDataList.addAll(traverseViewNode(childNode)) + } + + return traversalDataList + } + + private fun List.convertIntoAutofillNode(): AutofillRootNode { + return AutofillRootNode( + packageId = this.firstOrNull { it.packageId != null }?.packageId, + website = this.firstOrNull { it.website != null }?.website, + parsedAutofillFields = this, + ) + } + + private fun ViewNode.validPackageId(): String? { + return this.idPackage + .takeUnless { it.isNullOrBlank() } + ?.takeUnless { it in INVALID_PACKAGE_ID } + } + + @SuppressLint("NewApi") + private fun ViewNode.website(): String? { + return this.webDomain + .takeUnless { it?.isBlank() == true } + ?.let { webDomain -> + val webScheme = if (appBuildConfig.sdkInt >= Build.VERSION_CODES.P) { + this.webScheme.takeUnless { it.isNullOrBlank() } + } else { + null + } ?: "http" + + "$webScheme://$webDomain" + } + } + + companion object { + private val INVALID_PACKAGE_ID = listOf("android") + } +} 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 new file mode 100644 index 000000000000..84e677afcf9a --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillProviderSuggestions.kt @@ -0,0 +1,217 @@ +/* + * 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.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build.VERSION_CODES +import android.service.autofill.Dataset +import android.service.autofill.FillRequest +import android.service.autofill.FillResponse +import android.view.autofill.AutofillValue +import androidx.annotation.RequiresApi +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +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.ui.credential.management.AutofillManagementActivity +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlin.random.Random +import timber.log.Timber + +interface AutofillProviderSuggestions { + suspend fun buildSuggestionsResponse( + context: Context, + nodeToAutofill: AutofillRootNode, + request: FillRequest, + ): FillResponse +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class RealAutofillProviderSuggestions @Inject constructor( + private val appBuildConfig: AppBuildConfig, + private val autofillStore: AutofillStore, + private val viewProvider: AutofillServiceViewProvider, + private val suggestionsFormatter: AutofillServiceSuggestionCredentialFormatter, +) : AutofillProviderSuggestions { + + companion object { + private const val RESERVED_SUGGESTIONS_SIZE = 1 + } + + @SuppressLint("NewApi") + override suspend fun buildSuggestionsResponse( + context: Context, + nodeToAutofill: AutofillRootNode, + request: FillRequest, + ): FillResponse { + Timber.i("DDGAutofillService Fillable Request for rootNode: $nodeToAutofill") + val fillableFields = nodeToAutofill.parsedAutofillFields.filter { it.type != UNKNOWN } + Timber.i("DDGAutofillService Fillable Request for fields: $fillableFields") + + val response = FillResponse.Builder() + // We add credential suggestions + fillableFields.forEach { fieldsToAutofill -> + var inlineSuggestionsToShow = getMaxInlinedSuggestions(request) - RESERVED_SUGGESTIONS_SIZE + val credentials = loginCredentials(nodeToAutofill) + credentials?.forEach { credential -> + val datasetBuilder = Dataset.Builder() + Timber.i("DDGAutofillService suggesting credentials for: $fieldsToAutofill") + val suggestionUISpecs = suggestionsFormatter.getSuggestionSpecs(credential) + + // >= android 11 inline presentations are supported + if (appBuildConfig.sdkInt >= VERSION_CODES.R && inlineSuggestionsToShow > 0) { + datasetBuilder.addInlinePresentationsIfSupported( + context, + request, + suggestionUISpecs.title, + suggestionUISpecs.subtitle, + suggestionUISpecs.icon, + ) + inlineSuggestionsToShow -= 1 + } + val formPresentation = viewProvider.createFormPresentation( + context, + suggestionUISpecs.title, + suggestionUISpecs.subtitle, + suggestionUISpecs.icon, + ) + datasetBuilder.setValue( + fieldsToAutofill.autofillId, + autofillValue(credential, fieldsToAutofill.type), + formPresentation, + ) + val dataset = datasetBuilder.build() + response.addDataset(dataset) + } + } + + // Last suggestion to open DDG App and manually choose a credential + val ddgAppDataSetBuild = createAccessDDGDataSet(context, request, fillableFields) + response.addDataset(ddgAppDataSetBuild) + + // TODO: add ignoredAutofillIds https://app.asana.com/0/1200156640058969/1209226370597334/f + return response.build() + } + + @SuppressLint("NewApi") + private fun createAccessDDGDataSet( + context: Context, + request: FillRequest, + fillableFields: List, + ): Dataset { + // add access passwords + val ddgAppDataSet = Dataset.Builder() + val specs = suggestionsFormatter.getOpenDuckDuckGoSuggestionSpecs() + val pendingIntent = createAutofillSelectionIntent(context) + if (appBuildConfig.sdkInt >= VERSION_CODES.R) { + ddgAppDataSet.addInlinePresentationsIfSupported(context, request, specs.title, specs.subtitle, specs.icon) + } + val formPresentation = viewProvider.createFormPresentation(context, specs.title, specs.subtitle, specs.icon) + fillableFields.forEach { fieldsToAutofill -> + ddgAppDataSet.setValue( + fieldsToAutofill.autofillId, + AutofillValue.forText("placeholder"), + formPresentation, + ) + } + val ddgAppDataSetBuild = ddgAppDataSet + .setAuthentication(pendingIntent.intentSender) + .build() + return ddgAppDataSetBuild + } + + @RequiresApi(VERSION_CODES.R) + private fun Dataset.Builder.addInlinePresentationsIfSupported( + context: Context, + request: FillRequest, + suggestionTitle: String, + suggestionSubtitle: String, + icon: Int, + ) { + val inlinePresentationSpec = request.inlineSuggestionsRequest?.inlinePresentationSpecs?.firstOrNull() ?: return + val pendingIntent = PendingIntent.getService( + context, + 0, + Intent(), + PendingIntent.FLAG_ONE_SHOT or + PendingIntent.FLAG_UPDATE_CURRENT or + PendingIntent.FLAG_IMMUTABLE, + ) + viewProvider.createInlinePresentation( + context, + pendingIntent, + suggestionTitle, + suggestionSubtitle, + icon, + inlinePresentationSpec, + )?.let { inlinePresentation -> + this.setInlinePresentation(inlinePresentation) + } + } + + @SuppressLint("NewApi") + private fun getMaxInlinedSuggestions( + request: FillRequest, + ): Int { + if (appBuildConfig.sdkInt >= VERSION_CODES.R) { + return request.inlineSuggestionsRequest?.maxSuggestionCount ?: 0 + } + return 0 + } + + private fun autofillValue( + credential: LoginCredentials?, + autofillRequestedType: AutofillFieldType, + ): AutofillValue? = if (autofillRequestedType == USERNAME) { + AutofillValue.forText(credential?.username ?: "username") + } else { + AutofillValue.forText(credential?.password ?: "password") + } + + private suspend fun loginCredentials(node: AutofillRootNode): List? { + val crendentialsForDomain = node.website.takeUnless { it.isNullOrBlank() }?.let { + autofillStore.getCredentials(it) + } ?: emptyList() + + val crendentialsForPackage = node.packageId.takeUnless { it.isNullOrBlank() }?.let { + autofillStore.getCredentials(it) + } ?: emptyList() + + Timber.i("DDGAutofillService credentials for domain: $crendentialsForDomain") + Timber.i("DDGAutofillService credentials for package: $crendentialsForPackage") + return crendentialsForDomain.plus(crendentialsForPackage).distinct() + } + + private fun createAutofillSelectionIntent(context: Context): PendingIntent { + val intent = Intent(context, AutofillManagementActivity::class.java) + return PendingIntent + .getActivity( + context, + Random.nextInt(), + intent, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE, + ) + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceSuggestionCredentialFormatter.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceSuggestionCredentialFormatter.kt new file mode 100644 index 000000000000..a11f1c9755a1 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceSuggestionCredentialFormatter.kt @@ -0,0 +1,81 @@ +/* + * 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.Context +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.R +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import javax.inject.Inject + +interface AutofillServiceSuggestionCredentialFormatter { + fun getSuggestionSpecs(credential: LoginCredentials): SuggestionUISpecs + fun getOpenDuckDuckGoSuggestionSpecs(): SuggestionUISpecs +} + +data class SuggestionUISpecs( + val title: String, + val subtitle: String, + val icon: Int, +) + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class RealAutofillCredentialFormatter @Inject constructor( + private val context: Context, +) : AutofillServiceSuggestionCredentialFormatter { + + override fun getSuggestionSpecs(credential: LoginCredentials): SuggestionUISpecs { + val userName = credential.nonEmptyUsername() + val domain = credential.nonEmptyDomain() + val domainTitle = credential.nonEmptyDomainTitle() + + val title = listOfNotNull(userName, domainTitle, domain).first() + val subtitle = if (userName != null) { + domainTitle ?: domain.orEmpty() // domain should exist, otherwise we wouldn't be here + } else { + "" // no subtitle if no username + } + return SuggestionUISpecs( + title = title, + subtitle = subtitle, + icon = R.drawable.ic_dax_silhouette_primary_24, + ) + } + + private fun LoginCredentials.nonEmptyUsername(): String? { + return this.username.takeUnless { it.isNullOrBlank() } + } + + private fun LoginCredentials.nonEmptyDomain(): String? { + return this.domain.takeUnless { it.isNullOrBlank() } + } + + private fun LoginCredentials.nonEmptyDomainTitle(): String? { + return this.domainTitle.takeUnless { it.isNullOrBlank() } + } + + override fun getOpenDuckDuckGoSuggestionSpecs(): SuggestionUISpecs { + return SuggestionUISpecs( + title = context.getString(R.string.autofill_service_suggestion_search_passwords), + subtitle = "", + icon = R.drawable.ic_dax_silhouette_primary_24, + ) + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceViewProvider.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceViewProvider.kt new file mode 100644 index 000000000000..8e7285949ddb --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceViewProvider.kt @@ -0,0 +1,143 @@ +/* + * 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.annotation.SuppressLint +import android.app.PendingIntent +import android.app.slice.Slice +import android.content.Context +import android.graphics.drawable.Icon +import android.os.Build.VERSION_CODES +import android.service.autofill.InlinePresentation +import android.widget.RemoteViews +import android.widget.inline.InlinePresentationSpec +import androidx.annotation.DrawableRes +import androidx.annotation.RequiresApi +import androidx.autofill.inline.UiVersions +import androidx.autofill.inline.v1.InlineSuggestionUi +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.autofill.impl.R +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import javax.inject.Inject + +interface AutofillServiceViewProvider { + fun createFormPresentation( + context: Context, + suggestionTitle: String, + suggestionSubtitle: String, + icon: Int, + ): RemoteViews + + fun createInlinePresentation( + context: Context, + pendingIntent: PendingIntent, + suggestionTitle: String, + suggestionSubtitle: String, + icon: Int, + inlinePresentationSpec: InlinePresentationSpec, + ): InlinePresentation? +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class RealAutofillServiceViewProvider @Inject constructor( + private val appBuildConfig: AppBuildConfig, +) : AutofillServiceViewProvider { + + override fun createFormPresentation( + context: Context, + suggestionTitle: String, + suggestionSubtitle: String, + icon: Int, + ) = buildAutofillRemoteViews( + context = context, + name = suggestionTitle, + subtitle = suggestionSubtitle, + iconRes = icon, + ) + + @RequiresApi(VERSION_CODES.R) + override fun createInlinePresentation( + context: Context, + pendingIntent: PendingIntent, + suggestionTitle: String, + suggestionSubtitle: String, + icon: Int, + inlinePresentationSpec: InlinePresentationSpec, + ): InlinePresentation? { + val isInlineSupported = isInlineSuggestionSupported(inlinePresentationSpec) + if (isInlineSupported) { + val slice = createSlice(context, pendingIntent, suggestionTitle, suggestionSubtitle, icon) + return InlinePresentation(slice, inlinePresentationSpec, false) + } + return null + } + + @RequiresApi(VERSION_CODES.R) + private fun isInlineSuggestionSupported(inlinePresentationSpec: InlinePresentationSpec?): Boolean { + // requires >= android 11 + return if (appBuildConfig.sdkInt >= VERSION_CODES.R && inlinePresentationSpec != null) { + UiVersions.getVersions(inlinePresentationSpec.style).contains(UiVersions.INLINE_UI_VERSION_1) + } else { + false + } + } + + @SuppressLint("RestrictedApi") // because getSlice, but docs clearly indicate you need to use that method. + @RequiresApi(VERSION_CODES.R) + private fun createSlice( + context: Context, + pendingIntent: PendingIntent, + suggestionTitle: String, + suggestionSubtitle: String, + icon: Int, + ): Slice { + val slice = InlineSuggestionUi.newContentBuilder( + pendingIntent, + ).setTitle(suggestionTitle) + .setSubtitle(suggestionSubtitle) + .setStartIcon(Icon.createWithResource(context, icon)) + .build().slice + return slice + } + + private fun buildAutofillRemoteViews( + context: Context, + name: String, + subtitle: String, + @DrawableRes iconRes: Int, + ): RemoteViews = + RemoteViews( + context.packageName, + R.layout.autofill_remote_view, + ).apply { + setTextViewText( + R.id.title, + name, + ) + setTextViewText( + R.id.subtitle, + subtitle, + ) + setImageViewResource( + R.id.ddgIcon, + iconRes, + ) + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/DDGAutofillService.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/DDGAutofillService.kt deleted file mode 100644 index b948fd34d324..000000000000 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/DDGAutofillService.kt +++ /dev/null @@ -1,213 +0,0 @@ -/* - * Copyright (c) 2024 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.autofill.impl.service - -import android.R -import android.app.assist.AssistStructure -import android.app.assist.AssistStructure.ViewNode -import android.os.CancellationSignal -import android.service.autofill.AutofillService -import android.service.autofill.Dataset -import android.service.autofill.FillCallback -import android.service.autofill.FillRequest -import android.service.autofill.FillResponse -import android.service.autofill.SaveCallback -import android.service.autofill.SaveRequest -import android.service.autofill.SavedDatasetsInfoCallback -import android.view.View -import android.view.autofill.AutofillValue -import android.widget.RemoteViews -import com.duckduckgo.anvil.annotations.InjectWith -import com.duckduckgo.app.di.AppCoroutineScope -import com.duckduckgo.autofill.api.store.AutofillStore -import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.di.scopes.VpnScope -import dagger.android.AndroidInjection -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import timber.log.Timber -import javax.inject.Inject - -@InjectWith( - scope = VpnScope::class, // we might need to have our own scope to avoid creating the whole app graph -) -class DDGAutofillService : AutofillService() { - - @Inject - @AppCoroutineScope - lateinit var coroutineScope: CoroutineScope - - @Inject lateinit var dispatcherProvider: DispatcherProvider - - @Inject lateinit var autofillStore: AutofillStore - - override fun onFillRequest( - request: FillRequest, - cancellationSignal: CancellationSignal, - callback: FillCallback - ) { - Timber.i("DDGAutofillService onFillRequest") - coroutineScope.launch(dispatcherProvider.io()) { - Timber.i("DDGAutofillService structure: ${request.fillContexts}") - val structure = request.fillContexts.last().structure ?: return@launch - Timber.i("DDGAutofillService structure: $structure") - - // Extract package name - val packageName = structure.activityComponent?.packageName.orEmpty() - Timber.i("DDGAutofillService packageName: $packageName") - - val fields = findFields(packageName, structure) - - if (fields.isNotEmpty()) { - val dataset = createDataset(fields) - - if (dataset == null) { - callback.onFailure("No dataset found.") - return@launch - } - - val response = FillResponse.Builder() - .addDataset(dataset) - .build() - callback.onSuccess(response) - } else { - callback.onFailure("No suitable fields found.") - } - } - } - - private suspend fun createDataset(fieldsRoot: Map>): Dataset? { - Timber.i("DDGAutofillService fieldsRoot keys: ${fieldsRoot.keys}") - val firstNonEmptyOrigin = fieldsRoot.keys.first { it.isNotEmpty() } - val fields = fieldsRoot.values.lastOrNull()?.let { fields -> - Timber.i("DDGAutofillService fields: $fields") - fields - } ?: return null - - val credential = autofillStore.getCredentials(firstNonEmptyOrigin).firstOrNull() ?: return null - - Timber.i("DDGAutofillService we have credentials ${credential.username} to use in -> $fields") - val datasetBuilder = Dataset.Builder() - fields["username"]?.let { usernameNode -> - val username = credential.username // Retrieve from your secure storage - val presentation = RemoteViews(packageName, android.R.layout.simple_list_item_1) - presentation.setTextViewText(android.R.id.text1, username) - datasetBuilder.setValue( - usernameNode.autofillId!!, - AutofillValue.forText(username), - presentation - ) - } - fields["password"]?.let { passwordNode -> - val password = credential.password // Retrieve from your secure storage - val presentation = RemoteViews(packageName, R.layout.simple_list_item_1) - presentation.setTextViewText(android.R.id.text1, "Password for ${credential.username}") - datasetBuilder.setValue( - passwordNode.autofillId!!, - AutofillValue.forText(password), - presentation - ) - } - return datasetBuilder.build() - } - - private fun findFields( - packageName: String, - structure: AssistStructure - ): Map>{ - val fields = mutableMapOf>() - val windowNodeCount = structure.windowNodeCount - Timber.i("DDGAutofillService windowNodeCount: $windowNodeCount") - for (i in 0 until windowNodeCount) { - val windowNode = structure.getWindowNodeAt(i) - val rootViewNode = windowNode.rootViewNode - traverseNode(rootViewNode, packageName, fields) - } - return fields - } - - private fun traverseNode(node: ViewNode, packageName: String, fields: MutableMap>) { - Timber.i("DDGAutofillService node web: ${node.webDomain}") - val domain = node.webDomain ?: packageName - - node.autofillHints?.let { hints -> - Timber.i("DDGAutofillService hints for $node: $hints") - for (hint in hints) { - Timber.i("DDGAutofillService hint: $hint") - when (hint) { - View.AUTOFILL_HINT_USERNAME -> { - Timber.i("DDGAutofillService hint is username for $domain") - fields[domain]?.let { - it["username"] = node - } ?: run { - fields[domain] = mutableMapOf("username" to node) - } - } - View.AUTOFILL_HINT_PASSWORD -> { - Timber.i("DDGAutofillService hint is password for $domain") - fields[domain]?.let { - it["password"] = node - } ?: run { - fields[domain] = mutableMapOf("password" to node) - } - } - View.AUTOFILL_HINT_EMAIL_ADDRESS -> { - Timber.i("DDGAutofillService hint is EMAIL for $domain") - fields[domain]?.let { - it["username"] = node - } ?: run { - fields[domain] = mutableMapOf("username" to node) - } - } - else -> { - Timber.i("DDGAutofillService hint is unknown: $hint") - } - } - } - } - for (i in 0 until node.childCount) { - traverseNode(node.getChildAt(i), packageName, fields) - } - } - - override fun onSaveRequest( - request: SaveRequest, callback: SaveCallback - ) { - Timber.i("DDGAutofillService onSaveRequest") - } - - override fun onCreate() { - super.onCreate() - Timber.i("DDGAutofillService created") - AndroidInjection.inject(this) - } - - override fun onConnected() { - super.onConnected() - Timber.i("DDGAutofillService onConnected") - } - - override fun onSavedDatasetsInfoRequest(callback: SavedDatasetsInfoCallback) { - super.onSavedDatasetsInfoRequest(callback) - Timber.i("DDGAutofillService onSavedDatasetsInfoRequest") - } - - override fun onDisconnected() { - super.onDisconnected() - Timber.i("DDGAutofillService onDisconnected") - } -} 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 new file mode 100644 index 000000000000..d8961543c2ad --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/RealAutofillService.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.service + +import android.os.Build.VERSION_CODES +import android.os.CancellationSignal +import android.service.autofill.AutofillService +import android.service.autofill.FillCallback +import android.service.autofill.FillRequest +import android.service.autofill.SaveCallback +import android.service.autofill.SaveRequest +import android.service.autofill.SavedDatasetsInfoCallback +import androidx.annotation.RequiresApi +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.autofill.impl.service.AutofillFieldType.UNKNOWN +import com.duckduckgo.common.utils.ConflatedJob +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.ServiceScope +import dagger.android.AndroidInjection +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +@InjectWith(scope = ServiceScope::class) +class RealAutofillService : AutofillService() { + + @Inject + @AppCoroutineScope + lateinit var coroutineScope: CoroutineScope + + @Inject lateinit var dispatcherProvider: DispatcherProvider + + @Inject lateinit var appBuildConfig: AppBuildConfig + + @Inject lateinit var autofillParser: AutofillParser + + @Inject lateinit var autofillProviderSuggestions: AutofillProviderSuggestions + + private val autofillJob = ConflatedJob() + + override fun onCreate() { + super.onCreate() + Timber.i("DDGAutofillService created") + AndroidInjection.inject(this) + } + + @RequiresApi(VERSION_CODES.R) + override fun onFillRequest( + request: FillRequest, + cancellationSignal: CancellationSignal, + callback: FillCallback, + ) { + Timber.i("DDGAutofillService onFillRequest: $request") + cancellationSignal.setOnCancelListener { autofillJob.cancel() } + + autofillJob += coroutineScope.launch(dispatcherProvider.io()) { + val structure = request.fillContexts.lastOrNull()?.structure + if (structure == null) { + callback.onSuccess(null) + return@launch + } + val parsedRootNodes = autofillParser.parseStructure(structure) + val nodeToAutofill = findBestFillableNode(parsedRootNodes) + + if (nodeToAutofill == null || shouldSkipAutofillSuggestions(nodeToAutofill)) { + callback.onSuccess(null) + return@launch + } + + // prepare response + val response = autofillProviderSuggestions.buildSuggestionsResponse( + context = this@RealAutofillService, + nodeToAutofill = nodeToAutofill, + request = request, + ) + + callback.onSuccess(response) + } + } + + private fun shouldSkipAutofillSuggestions(nodeToAutofill: AutofillRootNode): Boolean { + if (nodeToAutofill.packageId.isNullOrBlank() && nodeToAutofill.website.isNullOrBlank()) { + return true + } + + if (nodeToAutofill.packageId.equals("android", ignoreCase = true)) return true + + if (nodeToAutofill.packageId in PACKAGES_TO_EXCLUDE) return true + + return false + } + + private fun findBestFillableNode(rootNodes: List): AutofillRootNode? { + return rootNodes.firstNotNullOfOrNull { rootNode -> + val focusedDetectedField = rootNode.parsedAutofillFields + .firstOrNull { field -> + field.originalNode.isFocused && field.type != UNKNOWN + } + if (focusedDetectedField != null) { + return@firstNotNullOfOrNull rootNode + } + + val firstDetectedField = rootNode.parsedAutofillFields.firstOrNull { field -> field.type != UNKNOWN } + if (firstDetectedField != null) { + return@firstNotNullOfOrNull rootNode + } + return@firstNotNullOfOrNull null + } + } + + override fun onSaveRequest( + request: SaveRequest, + callback: SaveCallback, + ) { + Timber.i("DDGAutofillService onSaveRequest") + } + + override fun onConnected() { + super.onConnected() + Timber.i("DDGAutofillService onConnected") + } + + override fun onSavedDatasetsInfoRequest(callback: SavedDatasetsInfoCallback) { + super.onSavedDatasetsInfoRequest(callback) + Timber.i("DDGAutofillService onSavedDatasetsInfoRequest") + } + + override fun onDisconnected() { + super.onDisconnected() + Timber.i("DDGAutofillService onDisconnected") + } + + companion object { + private val PACKAGES_TO_EXCLUDE = setOf( + "com.duckduckgo.mobile.android", + "com.duckduckgo.mobile.android.debug", + ) + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/ViewNodeClassifier.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/ViewNodeClassifier.kt new file mode 100644 index 000000000000..0ee20079047b --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/ViewNodeClassifier.kt @@ -0,0 +1,128 @@ +/* + * 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.app.assist.AssistStructure.ViewNode +import android.text.InputType +import android.view.View +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import timber.log.Timber + +interface ViewNodeClassifier { + fun classify(viewNode: ViewNode): AutofillFieldType +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class AutofillServiceViewNodeClassifier @Inject constructor() : ViewNodeClassifier { + override fun classify(viewNode: ViewNode): AutofillFieldType { + val autofillId = viewNode.autofillId + Timber.i("DDGAutofillService node $autofillId has autofillHints ${viewNode.autofillHints?.joinToString()}") + Timber.i("DDGAutofillService node $autofillId has options ${viewNode.autofillOptions?.joinToString()}") + Timber.i("DDGAutofillService node $autofillId has idEntry ${viewNode.idEntry}") + Timber.i("DDGAutofillService node $autofillId has hints ${viewNode.hint}") + Timber.i("DDGAutofillService node $autofillId is inputType ${viewNode.inputType and InputType.TYPE_CLASS_TEXT > 0}") + Timber.i("DDGAutofillService node $autofillId has inputType ${viewNode.inputType}") + Timber.i("DDGAutofillService node $autofillId has className ${viewNode.className}") + Timber.i("DDGAutofillService node $autofillId has htmlInfo.attributes ${viewNode.htmlInfo?.attributes?.joinToString()}") + + var autofillType = getType(viewNode.autofillHints) + if (autofillType == AutofillFieldType.UNKNOWN) { + if (isTextField(viewNode.inputType)) { + if (viewNode.idEntry?.containsAny(userNameKeywords) == true || + viewNode.hint?.containsAny(userNameKeywords) == true + ) { + autofillType = AutofillFieldType.USERNAME + } else if (viewNode.idEntry?.containsAny(passwordKeywords) == true || + viewNode.hint?.containsAny(passwordKeywords) == true + ) { + if (isTextPasswordField(viewNode.inputType)) { + autofillType = AutofillFieldType.PASSWORD + } + } + } + + if (autofillType == AutofillFieldType.UNKNOWN) { + val isUsername: Boolean = viewNode.htmlInfo?.attributes?.find { it.first == "type" }?.second?.containsAny(userNameKeywords) == true || + viewNode.htmlInfo?.attributes + ?.firstOrNull { it.first?.containsAny(listOf("autofill")) == true && it.second?.containsAny(userNameKeywords) == true } != null + val isPassword = viewNode.htmlInfo?.attributes?.find { it.first == "type" }?.second?.containsAny(passwordKeywords) == true || + viewNode.htmlInfo?.attributes + ?.firstOrNull { it.first?.containsAny(listOf("autofill")) == true && it.second?.containsAny(passwordKeywords) == true } != null + + if (isUsername) { + autofillType = AutofillFieldType.USERNAME + } else if (isPassword) { + autofillType = AutofillFieldType.PASSWORD + } + } + } + + kotlin.runCatching { viewNode.autofillValue?.textValue?.toString() }.getOrElse { + // Some views will throw an exception when trying to get the autofill value (e.g: CompoundButton) + // never try to autofill them + autofillType = AutofillFieldType.UNKNOWN + } + + return autofillType + } + + private fun isTextField(inputType: Int): Boolean { + return (inputType and InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_TEXT + } + + private fun isTextPasswordField(inputType: Int): Boolean { + return (inputType and InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_TEXT && + (inputType and InputType.TYPE_MASK_VARIATION) == InputType.TYPE_TEXT_VARIATION_PASSWORD + } + + private fun getType(autofillHints: Array?): AutofillFieldType { + if (autofillHints == null) return AutofillFieldType.UNKNOWN + if (autofillHints.any { it in USERNAME_HINTS }) return AutofillFieldType.USERNAME + if (autofillHints.any { it in PASSWORD_HINTS }) return AutofillFieldType.PASSWORD + return AutofillFieldType.UNKNOWN + } + + private fun String.containsAny(words: List): Boolean { + return words.any { this.contains(it, ignoreCase = true) } + } + + private val USERNAME_HINTS: List = listOf( + View.AUTOFILL_HINT_EMAIL_ADDRESS, + View.AUTOFILL_HINT_USERNAME, + ) + + private val PASSWORD_HINTS: List = listOf( + View.AUTOFILL_HINT_PASSWORD, + "passwordAuto", + ) + + private val userNameKeywords = listOf( + "email", + "username", + "user name", + "identifier", + "account_name", + ) + + private val passwordKeywords = listOf( + "password", + ) +} diff --git a/autofill/autofill-impl/src/main/res/drawable/ic_dax_silhouette_primary_24.xml b/autofill/autofill-impl/src/main/res/drawable/ic_dax_silhouette_primary_24.xml new file mode 100644 index 000000000000..ac172d6e060b --- /dev/null +++ b/autofill/autofill-impl/src/main/res/drawable/ic_dax_silhouette_primary_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/autofill/autofill-impl/src/main/res/layout/autofill_remote_view.xml b/autofill/autofill-impl/src/main/res/layout/autofill_remote_view.xml new file mode 100644 index 000000000000..ebe95d776f93 --- /dev/null +++ b/autofill/autofill-impl/src/main/res/layout/autofill_remote_view.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + diff --git a/autofill/autofill-impl/src/main/res/values/donottranslate.xml b/autofill/autofill-impl/src/main/res/values/donottranslate.xml index def6aa352279..6cc828a794d9 100644 --- a/autofill/autofill-impl/src/main/res/values/donottranslate.xml +++ b/autofill/autofill-impl/src/main/res/values/donottranslate.xml @@ -15,5 +15,5 @@ --> - + Search Passwords \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/res/xml/service_configuration.xml b/autofill/autofill-impl/src/main/res/xml/service_configuration.xml index d413efdd006f..2634422894fd 100644 --- a/autofill/autofill-impl/src/main/res/xml/service_configuration.xml +++ b/autofill/autofill-impl/src/main/res/xml/service_configuration.xml @@ -15,4 +15,5 @@ --> \ No newline at end of file + android:settingsActivity="com.duckduckgo.autofill.impl.ui.credential.management.AutofillManagementActivity" + android:supportsInlineSuggestions="true"/> \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/AutofillProvideMockBuilder.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/AutofillProvideMockBuilder.kt new file mode 100644 index 000000000000..6fafe0b1f8ee --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/AutofillProvideMockBuilder.kt @@ -0,0 +1,118 @@ +/* + * 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.app.assist.AssistStructure +import android.app.assist.AssistStructure.ViewNode +import android.app.assist.AssistStructure.WindowNode +import android.service.autofill.FillRequest +import android.util.Pair +import android.view.ViewStructure.HtmlInfo +import android.view.autofill.AutofillId +import android.view.inputmethod.InlineSuggestionsRequest +import android.widget.inline.InlinePresentationSpec +import org.mockito.Mockito.mock +import org.mockito.kotlin.whenever + +fun fillRequest(): FillRequest = mock() + +fun inlineSuggestionsRequest(): InlineSuggestionsRequest = mock() + +fun inlinePresentationSpec(): InlinePresentationSpec = mock() + +fun FillRequest.inlineSuggestionsRequest(suggestionsRequest: InlineSuggestionsRequest): FillRequest { + whenever(this.inlineSuggestionsRequest).thenReturn(suggestionsRequest) + return this +} + +fun InlineSuggestionsRequest.maxSuggestionCount(count: Int): InlineSuggestionsRequest { + whenever(this.maxSuggestionCount).thenReturn(count) + return this +} + +fun InlineSuggestionsRequest.inlinePresentationSpecs(vararg specs: InlinePresentationSpec): InlineSuggestionsRequest { + whenever(this.inlinePresentationSpecs).thenReturn(specs.toMutableList()) + return this +} + +fun assistStructure(): AssistStructure = mock() + +fun AssistStructure.windowNodes(vararg windowNodes: WindowNode): AssistStructure { + whenever(this.windowNodeCount).thenReturn(windowNodes.size) + windowNodes.forEachIndexed { index, windowNode -> + whenever(this.getWindowNodeAt(index)).thenReturn(windowNode) + } + return this +} + +fun windowNode(): WindowNode = mock() + +fun WindowNode.rootViewNode(viewNode: ViewNode): WindowNode { + whenever(this.rootViewNode).thenReturn(viewNode) + return this +} + +fun autofillId(): AutofillId = mock() + +fun viewNode(): ViewNode { + return mock() +} + +fun ViewNode.webDomain(domain: String): ViewNode { + whenever(this.webDomain).thenReturn(domain) + return this +} + +fun ViewNode.autofillId(id: AutofillId): ViewNode { + whenever(this.autofillId).thenReturn(id) + return this +} + +fun ViewNode.packageId(id: String): ViewNode { + whenever(this.idPackage).thenReturn(id) + return this +} + +fun ViewNode.childrenNodes(vararg viewNodes: ViewNode): ViewNode { + whenever(this.childCount).thenReturn(viewNodes.size) + viewNodes.forEachIndexed { index, viewNode -> + whenever(this.getChildAt(index)).thenReturn(viewNode) + } + return this +} + +fun ViewNode.autofillHints(hints: Array?): ViewNode { + whenever(this.autofillHints).thenReturn(hints) + return this +} + +fun ViewNode.idEntry(id: String): ViewNode { + whenever(this.idEntry).thenReturn(id) + return this +} + +fun ViewNode.hint(hint: String): ViewNode { + whenever(this.hint).thenReturn(hint) + return this +} + +fun ViewNode.htmlAttributes(attributes: List>): ViewNode { + val htmlInfoMock = mock() + whenever(this.htmlInfo).thenReturn(htmlInfoMock) + whenever(htmlInfoMock.attributes).thenReturn(attributes) + return this +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/AutofillServiceViewNodeClassifierTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/AutofillServiceViewNodeClassifierTest.kt new file mode 100644 index 000000000000..f753d2222584 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/AutofillServiceViewNodeClassifierTest.kt @@ -0,0 +1,273 @@ +/* + * 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.util.Pair +import android.view.View +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.autofill.impl.service.AutofillFieldType.PASSWORD +import com.duckduckgo.autofill.impl.service.AutofillFieldType.UNKNOWN +import com.duckduckgo.autofill.impl.service.AutofillFieldType.USERNAME +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AutofillServiceViewNodeClassifierTest { + + val testee = AutofillServiceViewNodeClassifier() + + // autofillHints + @Test + fun whenAutofillHintsIsNullThenReturnUnknown() { + val viewNode = viewNode().autofillHints(null) + assertEquals(AutofillFieldType.UNKNOWN, testee.classify(viewNode)) + } + + // autofillHints containsAny(usernameHints) + @Test + fun whenAutofillHintsContainsUsernameViewHintsThenReturnUsername() { + assertEquals(USERNAME, testee.classify(viewNode().autofillHints(arrayOf(View.AUTOFILL_HINT_EMAIL_ADDRESS)))) + assertEquals(USERNAME, testee.classify(viewNode().autofillHints(arrayOf(View.AUTOFILL_HINT_USERNAME)))) + assertEquals( + USERNAME, + testee.classify( + viewNode().autofillHints( + // one autofill hint is username + arrayOf(View.AUTOFILL_HINT_CREDIT_CARD_NUMBER, View.AUTOFILL_HINT_USERNAME), + ), + ), + ) + + assertEquals(UNKNOWN, testee.classify(viewNode().autofillHints(arrayOf(View.AUTOFILL_HINT_PHONE)))) + assertEquals(UNKNOWN, testee.classify(viewNode().autofillHints(arrayOf(View.AUTOFILL_HINT_CREDIT_CARD_NUMBER)))) + assertEquals(UNKNOWN, testee.classify(viewNode().autofillHints(arrayOf("")))) + } + + // autofillHints containsAny(passwordHints) + @Test + fun whenAutofillHintsContainsPasswordViewHintsThenReturnPassword() { + assertEquals(PASSWORD, testee.classify(viewNode().autofillHints(arrayOf(View.AUTOFILL_HINT_PASSWORD)))) + assertEquals(PASSWORD, testee.classify(viewNode().autofillHints(arrayOf("passwordAuto")))) + assertEquals( + PASSWORD, + testee.classify( + viewNode().autofillHints( + // one autofill hint is password + arrayOf(View.AUTOFILL_HINT_CREDIT_CARD_NUMBER, View.AUTOFILL_HINT_PASSWORD), + ), + ), + ) + + assertEquals(UNKNOWN, testee.classify(viewNode().autofillHints(arrayOf(View.AUTOFILL_HINT_PHONE)))) + assertEquals(UNKNOWN, testee.classify(viewNode().autofillHints(arrayOf(View.AUTOFILL_HINT_CREDIT_CARD_NUMBER)))) + assertEquals(UNKNOWN, testee.classify(viewNode().autofillHints(arrayOf("")))) + } + + // idEntry or hint containsAny(usernameKeywords) + @Test + fun whenNodeHintContainsUsernameKeywordsThenReturnUsername() { + validUsernameCombinations.forEach { usernameCombination -> + assertEquals( + "$usernameCombination failed", + USERNAME, + testee.classify(viewNode().hint(usernameCombination)), + ) + } + + assertEquals(UNKNOWN, testee.classify(viewNode().hint("userna"))) // incomplete keyword + assertEquals(UNKNOWN, testee.classify(viewNode().hint(""))) + assertEquals(UNKNOWN, testee.classify(viewNode().hint("randomHint"))) + } + + @Test + fun whenNodeIdEntryContainsUsernameKeywordsThenReturnUsername() { + validUsernameCombinations.forEach { usernameCombination -> + assertEquals( + "$usernameCombination failed", + USERNAME, + testee.classify(viewNode().idEntry(usernameCombination)), + ) + } + + assertEquals(UNKNOWN, testee.classify(viewNode().idEntry("userna"))) // incomplete keyword + assertEquals(UNKNOWN, testee.classify(viewNode().idEntry(""))) + assertEquals(UNKNOWN, testee.classify(viewNode().idEntry("randomHint"))) + } + + // idEntry or hint containsAny(passwordKeywords) + @Test + fun whenNodeHintContainsPasswordKeywordsThenReturnPassword() { + validPasswordCombinations.forEach { passwordCombination -> + assertEquals( + "$passwordCombination failed", + PASSWORD, + testee.classify(viewNode().hint(passwordCombination)), + ) + } + + assertEquals(UNKNOWN, testee.classify(viewNode().hint("pass"))) // incomplete keyword + assertEquals(UNKNOWN, testee.classify(viewNode().hint(""))) + assertEquals(UNKNOWN, testee.classify(viewNode().hint("something"))) + assertEquals(UNKNOWN, testee.classify(viewNode().hint("Passw0rd"))) + } + + @Test + fun whenNodeIdEntryContainsPasswordKeywordsThenReturnPassword() { + validPasswordCombinations.forEach { passwordCombination -> + assertEquals( + "$passwordCombination failed", + PASSWORD, + testee.classify(viewNode().idEntry(passwordCombination)), + ) + } + + assertEquals(UNKNOWN, testee.classify(viewNode().idEntry("pass"))) // incomplete keyword + assertEquals(UNKNOWN, testee.classify(viewNode().idEntry(""))) + assertEquals(UNKNOWN, testee.classify(viewNode().idEntry("something"))) + assertEquals(UNKNOWN, testee.classify(viewNode().idEntry("Passw0rd"))) + } + + // htmlInfo.attributes contains usernameKeywords (in type or autofill attribute) + @Test + fun whenNodeHtmlAttributeTypeContainsUsernameKeywordsThenReturnUsername() { + // username hints + validUsernameCombinations.forEach { usernameCombination -> + assertEquals( + "$usernameCombination failed", + USERNAME, + testee.classify(viewNode().htmlAttributes(listOf(Pair("type", usernameCombination)))), + ) + } + + // non username hints + assertEquals(UNKNOWN, testee.classify(viewNode().htmlAttributes(listOf(Pair("type", "userna"))))) // incomplete keyword + assertEquals(UNKNOWN, testee.classify(viewNode().htmlAttributes(listOf(Pair("type", ""))))) + assertEquals(UNKNOWN, testee.classify(viewNode().htmlAttributes(listOf(Pair("type", "randomHint"))))) + } + + @Test + fun whenNodeHtmlAttributeIsAutofillAndValueContainsUsernameKeywordsThenReturnUsername() { + // username hints + autofillAttributeKeyCombination.forEach { autofillKey -> + validUsernameCombinations.forEach { usernameCombination -> + assertEquals( + "$autofillKey - $usernameCombination failed", + USERNAME, + testee.classify(viewNode().htmlAttributes(listOf(Pair(autofillKey, usernameCombination)))), + ) + } + } + + // if one matches is enough + assertEquals( + USERNAME, + testee.classify( + viewNode().htmlAttributes( + listOf( + Pair("randomKey", "randomValue"), + Pair(autofillAttributeKeyCombination.random(), validUsernameCombinations.random()), + Pair("anotherRandom", "anotherValue"), + ), + ), + ), + ) + + // non username hints + assertEquals( + UNKNOWN, + testee.classify(viewNode().htmlAttributes(listOf(Pair(autofillAttributeKeyCombination.random(), "userna")))), + ) // incomplete keyword + assertEquals(UNKNOWN, testee.classify(viewNode().htmlAttributes(listOf(Pair(autofillAttributeKeyCombination.random(), ""))))) + assertEquals(UNKNOWN, testee.classify(viewNode().htmlAttributes(listOf(Pair(autofillAttributeKeyCombination.random(), "randomHint"))))) + } + + // htmlInfo.attributes contains passwordKeywords (in type or autofill attribute) + @Test + fun whenNodeHtmlAttributeTypeContainsPasswordKeywordsThenReturnPassword() { + // username hints + validPasswordCombinations.forEach { usernameCombination -> + assertEquals( + "$usernameCombination failed", + PASSWORD, + testee.classify(viewNode().htmlAttributes(listOf(Pair("type", usernameCombination)))), + ) + } + + // non username hints + assertEquals(UNKNOWN, testee.classify(viewNode().htmlAttributes(listOf(Pair("type", "userna"))))) // incomplete keyword + assertEquals(UNKNOWN, testee.classify(viewNode().htmlAttributes(listOf(Pair("type", ""))))) + assertEquals(UNKNOWN, testee.classify(viewNode().htmlAttributes(listOf(Pair("type", "randomHint"))))) + } + + @Test + fun whenNodeHtmlAttributeIsAutofillAndValueContainsPasswordKeywordsThenReturnPassword() { + // username hints + autofillAttributeKeyCombination.forEach { autofillKey -> + validPasswordCombinations.forEach { usernameCombination -> + assertEquals( + "$autofillKey - $usernameCombination failed", + PASSWORD, + testee.classify(viewNode().htmlAttributes(listOf(Pair(autofillKey, usernameCombination)))), + ) + } + } + + // if one matches is enough + assertEquals( + PASSWORD, + testee.classify( + viewNode().htmlAttributes( + listOf( + Pair("randomKey", "randomValue"), + Pair(autofillAttributeKeyCombination.random(), validPasswordCombinations.random()), + Pair("anotherRandom", "anotherValue"), + ), + ), + ), + ) + + // non username hints + assertEquals(UNKNOWN, testee.classify(viewNode().htmlAttributes(listOf(Pair(autofillAttributeKeyCombination.random(), "userna"))))) + assertEquals(UNKNOWN, testee.classify(viewNode().htmlAttributes(listOf(Pair(autofillAttributeKeyCombination.random(), ""))))) + assertEquals(UNKNOWN, testee.classify(viewNode().htmlAttributes(listOf(Pair(autofillAttributeKeyCombination.random(), "randomHint"))))) + } + + private val validUsernameCombinations = listOf( + "username", + "aKeywordWithUsernameInside", + "USERNAME", + "a-Username_1", + "user NAME", + "your email", + "user name field", + "account_name", + ) + + private val validPasswordCombinations = listOf( + "password", + "PASSWORD", + "a-Password_1", + "PassworD", + ) + + private val autofillAttributeKeyCombination = listOf( + "autofill", + "ua-autofill-hint", + "autofill_car", + ) +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/RealAutofillParserTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/RealAutofillParserTest.kt new file mode 100644 index 000000000000..15ea3af36e28 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/RealAutofillParserTest.kt @@ -0,0 +1,207 @@ +/* + * 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.app.assist.AssistStructure.ViewNode +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.autofill.impl.service.AutofillFieldType.PASSWORD +import com.duckduckgo.autofill.impl.service.AutofillFieldType.UNKNOWN +import com.duckduckgo.autofill.impl.service.AutofillFieldType.USERNAME +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever + +class RealAutofillParserTest { + + private val appBuildConfig: AppBuildConfig = mock() + private val viewNodeClassifier: ViewNodeClassifier = mock().also { + whenever(it.classify(any())).thenReturn(UNKNOWN) + } + + private val testee = RealAutofillParser(appBuildConfig, viewNodeClassifier) + + @Test + fun whenZeroWindowNodesThenReturnEmptyList() { + val assistStructure = assistStructure() + .windowNodes() + val nodes = testee.parseStructure(assistStructure) + assertTrue(nodes.isEmpty()) + } + + @Test + fun whenWindowNodeHasNoRootNodeThenReturnEmptyList() { + val assistStructure = assistStructure() + .windowNodes(windowNode()) + val nodes = testee.parseStructure(assistStructure) + assertTrue(nodes.isEmpty()) + } + + @Test + fun whenMultipleRootNodesThenReturnMultipleParsedRoots() { + val assistStructure = assistStructure() + .windowNodes( + windowNode().rootViewNode(viewNode()), + windowNode().rootViewNode(viewNode()), + ) + val nodes = testee.parseStructure(assistStructure) + assertEquals(2, nodes.size) + } + + @Test + fun whenViewNodeWithoutAutofillIdThenSkipItAtAnyLevel() { + val assistStructure = assistStructure() + .windowNodes( + windowNode().rootViewNode(viewNode()), + windowNode().rootViewNode( + viewNode().childrenNodes(viewNode(), viewNode()), + ), + ) + val nodes = testee.parseStructure(assistStructure) + assertEquals(2, nodes.size) + nodes.forEach { + assertEquals(0, it.parsedAutofillFields.size) + } + } + + @Test + fun whenRootNodeHasChildNodesThenParseValidOnes() { + val assistStructure = assistStructure().windowNodes( + windowNode().rootViewNode( + viewNode() + .autofillId(autofillId()) + .childrenNodes( + viewNode().autofillId(autofillId()), + viewNode().autofillId(autofillId()), + viewNode(), // this node should be skipped + ), + ), + ) + + val nodes = testee.parseStructure(assistStructure) + + assertEquals(1, nodes.size) + assertEquals(3, nodes.first().parsedAutofillFields.size) + } + + // if child nodes have package or domain, returned autofill node should have them + @Test + fun whenChildNodesHavePackageOrDomainThenReturnedAutofillRootNodeHasThem() { + val assistStructure = assistStructure().windowNodes( + windowNode().rootViewNode( + viewNode() + .autofillId(autofillId()) + .childrenNodes( + viewNode().autofillId(autofillId()).packageId("com.android.package"), + viewNode().autofillId(autofillId()).webDomain("example.com"), + viewNode(), // this node should be skipped + ), + ), + ) + + val nodes = testee.parseStructure(assistStructure) + + assertEquals(1, nodes.size) + assertEquals(3, nodes.first().parsedAutofillFields.size) + assertEquals("com.android.package", nodes.first().packageId) + assertEquals("http://example.com", nodes.first().website) + } + + @Test + fun whenParsingStructureThenAnyValidFieldIsParsedAndClassified() { + val assistStructure = assistStructure().windowNodes( + windowNode().rootViewNode( + viewNode().autofillId(autofillId()) + .autofillType(USERNAME) + .packageId("com.android.package") + .childrenNodes( + viewNode().autofillId(autofillId()) + .packageId("com.android.package").autofillType(USERNAME), + viewNode().autofillId(autofillId()) + .webDomain("example.com"), + viewNode().autofillId(autofillId()) + .packageId("com.android.package").autofillType(PASSWORD), + viewNode().autofillId(autofillId()) + .webDomain("example.com"), + ), + ), + windowNode().rootViewNode( + viewNode() + .autofillId(autofillId()) + .childrenNodes( + viewNode().packageId("com.android.package2").autofillType(USERNAME), // invalid autofill id + viewNode().autofillId(autofillId()).autofillType(USERNAME), + viewNode().autofillId(autofillId()).webDomain("example2.com"), + viewNode().autofillId(autofillId()).packageId("com.android.package2").autofillType(USERNAME), + viewNode().autofillId(autofillId()).autofillType(PASSWORD), + ), + ), + ) + + val nodes = testee.parseStructure(assistStructure) + + assertEquals(2, nodes.size) + val firstNode = nodes[0] + assertEquals(5, firstNode.parsedAutofillFields.size) + assertEquals("com.android.package", firstNode.packageId) + assertEquals("http://example.com", firstNode.website) + assertEquals(2, firstNode.parsedAutofillFields.filter { it.type == USERNAME }.size) + assertEquals(1, firstNode.parsedAutofillFields.filter { it.type == PASSWORD }.size) + assertEquals(2, firstNode.parsedAutofillFields.filter { it.type == UNKNOWN }.size) + + val secondNode = nodes[1] + assertEquals(5, secondNode.parsedAutofillFields.size) + assertEquals("com.android.package2", secondNode.packageId) + assertEquals("http://example2.com", secondNode.website) + assertEquals(2, secondNode.parsedAutofillFields.filter { it.type == USERNAME }.size) + assertEquals(1, secondNode.parsedAutofillFields.filter { it.type == PASSWORD }.size) + assertEquals(2, secondNode.parsedAutofillFields.filter { it.type == UNKNOWN }.size) + } + + @Test + fun whenMultiplePackageIdsThenReturnFirstValidOne() { + val assistStructure = assistStructure().windowNodes( + windowNode().rootViewNode( + viewNode().autofillId(autofillId()) + .packageId("") + .childrenNodes( + viewNode().autofillId(autofillId()) + .packageId("android"), + viewNode() + .packageId("com.android.package"), + viewNode().autofillId(autofillId()) + .packageId("com.android.package"), + viewNode().autofillId(autofillId()) + .packageId("com.android.package2"), + ), + ), + ) + + val nodes = testee.parseStructure(assistStructure) + + assertEquals("com.android.package", nodes.first().packageId) + } + + // if a node doesn't have a text value, skip it + + private fun ViewNode.autofillType(autofillFieldType: AutofillFieldType): ViewNode { + whenever(viewNodeClassifier.classify(this)).thenReturn(autofillFieldType) + return this + } +} 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 new file mode 100644 index 000000000000..263d090b8d57 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/RealAutofillProviderSuggestionsTest.kt @@ -0,0 +1,304 @@ +/* + * 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.os.Build.VERSION_CODES +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +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.common.test.CoroutineTestRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.spy +import org.mockito.kotlin.any +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class RealAutofillProviderSuggestionsTest { + + @get:Rule var coroutineRule = CoroutineTestRule() + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + private val appBuildConfig = mock().apply { + whenever(sdkInt).thenReturn(VERSION_CODES.R) + } + + private val autofillStore = mock() + + private val mockViewProvider = mock() + + private val suggestionFormatter = mock().apply { + whenever(this.getSuggestionSpecs(any())).thenReturn(SuggestionUISpecs("title", "subtitle", 0)) + whenever(this.getOpenDuckDuckGoSuggestionSpecs()).thenReturn(SuggestionUISpecs("Search in DuckDuckGo", "", 0)) + } + + private val testee = RealAutofillProviderSuggestions( + appBuildConfig = appBuildConfig, + autofillStore = autofillStore, + viewProvider = mockViewProvider, + suggestionsFormatter = suggestionFormatter, + ) + + @Test + fun whenRunningOnAndroidVersionWithoutInlineSupportThenDoNotAddInlineSuggestions() = runTest { + val credentials = listOf( + LoginCredentials(1L, "username", "password", "example.com"), + ) + whenever(appBuildConfig.sdkInt).thenReturn(VERSION_CODES.Q) + whenever(autofillStore.getCredentials(any())).thenReturn(credentials) + whenever(mockViewProvider.createFormPresentation(any(), any(), any(), any())).thenReturn(mock()) + whenever(mockViewProvider.createInlinePresentation(any(), any(), any(), any(), any(), any())).thenReturn(null) + + testee.buildSuggestionsResponse( + context = context, + AutofillRootNode( + "com.example.app", + null, + listOf( + ParsedAutofillField(autofillId(), "", "", "", USERNAME, viewNode()), + ), + ), + mock(), + ) + + verify(mockViewProvider, times(0)).createInlinePresentation(any(), any(), any(), any(), any(), any()) + verify(mockViewProvider, times(2)).createFormPresentation(any(), any(), any(), any()) + } + + @Test + fun whenNoInlinePresentationSpecsThenDoNotAddInlineSuggestions() = runTest { + val credentials = listOf( + LoginCredentials(1L, "username", "password", "example.com"), + ) + whenever(autofillStore.getCredentials(any())).thenReturn(credentials) + whenever(mockViewProvider.createFormPresentation(any(), any(), any(), any())).thenReturn(mock()) + whenever(mockViewProvider.createInlinePresentation(any(), any(), any(), any(), any(), any())).thenReturn(null) + + testee.buildSuggestionsResponse( + context = context, + AutofillRootNode( + "com.example.app", + null, + listOf( + ParsedAutofillField(autofillId(), "", "", "", USERNAME, viewNode()), + ), + ), + fillRequest().inlineSuggestionsRequest( + inlineSuggestionsRequest().maxSuggestionCount(3), + ), + ) + + verify(mockViewProvider, times(0)).createInlinePresentation(any(), any(), any(), any(), any(), any()) + verify(mockViewProvider, times(2)).createFormPresentation(any(), any(), any(), any()) + } + + @Test + fun whenManyCredentialSuggestionsAvailableThenShowAsManyAsPossiblePlusDDGSearch() = runTest { + val credentials = listOf( + LoginCredentials(1L, "username", "password", "example.com"), + LoginCredentials(2L, "username2", "password2", "example2.com"), + LoginCredentials(3L, "username3", "password3", "example3.com"), + LoginCredentials(4L, "username4", "password4", "example4.com"), + ) + whenever(autofillStore.getCredentials(any())).thenReturn(credentials) + whenever(mockViewProvider.createFormPresentation(any(), any(), any(), any())).thenReturn(mock()) + whenever(mockViewProvider.createInlinePresentation(any(), any(), any(), any(), any(), any())).thenReturn(mock()) + + testee.buildSuggestionsResponse( + context = context, + AutofillRootNode( + "com.example.app", + null, + listOf( + ParsedAutofillField(autofillId(), "", "", "", USERNAME, viewNode()), + ), + ), + fillRequest().inlineSuggestionsRequest( + inlineSuggestionsRequest() + .maxSuggestionCount(3) + .inlinePresentationSpecs(inlinePresentationSpec()), + ), + ) + + verify(mockViewProvider, times(3)).createInlinePresentation(any(), any(), any(), any(), any(), any()) + verify(mockViewProvider, times(5)).createFormPresentation(any(), any(), any(), any()) + } + + @Test + fun whenSupportedThenInlineSuggestionsAdded() = runTest { + val credentials = listOf( + LoginCredentials(1L, "username", "password", "example.com"), + LoginCredentials(2L, "username2", "password2", "example2.com"), + ) + whenever(autofillStore.getCredentials(any())).thenReturn(credentials) + whenever(mockViewProvider.createFormPresentation(any(), any(), any(), any())).thenReturn(mock()) + whenever(mockViewProvider.createInlinePresentation(any(), any(), any(), any(), any(), any())).thenReturn(mock()) + + testee.buildSuggestionsResponse( + context = context, + AutofillRootNode( + "com.example.app", + null, + listOf( + ParsedAutofillField(autofillId(), "", "", "", USERNAME, viewNode()), + ), + ), + fillRequest().inlineSuggestionsRequest( + inlineSuggestionsRequest() + .maxSuggestionCount(3) + .inlinePresentationSpecs(inlinePresentationSpec()), + ), + ) + + verify(mockViewProvider, times(3)).createInlinePresentation(any(), any(), any(), any(), any(), any()) + verify(mockViewProvider, times(3)).createFormPresentation(any(), any(), any(), any()) + } + + @Test + fun whenCredentialsFoundThenAddSuggestions() = runTest { + val credentials = listOf( + LoginCredentials(1L, "username", "password", "example.com"), + LoginCredentials(2L, "username2", "password2", "example2.com"), + ) + whenever(autofillStore.getCredentials(any())).thenReturn(credentials) + whenever(mockViewProvider.createFormPresentation(any(), any(), any(), any())).thenReturn(mock()) + + testee.buildSuggestionsResponse( + context = context, + AutofillRootNode( + "com.example.app", + null, + listOf( + ParsedAutofillField(autofillId(), "", "", "", USERNAME, viewNode()), + ), + ), + mock(), + ) + + verify(mockViewProvider, times(3)).createFormPresentation(any(), any(), any(), any()) + } + + @Test + fun whenNoCredentialsFoundThenOnlyOpenDDGAppItemIsAdded() = runTest { + whenever(autofillStore.getCredentials(any())).thenReturn(emptyList()) + whenever(mockViewProvider.createFormPresentation(any(), any(), any(), any())).thenReturn(mock()) + + testee.buildSuggestionsResponse( + context = context, + AutofillRootNode( + "com.example.app", + null, + listOf( + ParsedAutofillField(autofillId(), "", "", "", USERNAME, viewNode()), + ), + ), + mock(), + ) + + verify(mockViewProvider, times(1)).createFormPresentation(any(), any(), any(), any()) + } + + @Test + fun whenMultipleFillableFieldsFoundThenAddSuggestionsForEach() = runTest { + val credentials = listOf( + LoginCredentials(1L, "username", "password", "example.com"), + ) + whenever(autofillStore.getCredentials(any())).thenReturn(credentials) + whenever(mockViewProvider.createFormPresentation(any(), any(), any(), any())).thenReturn(mock()) + + testee.buildSuggestionsResponse( + context = context, + AutofillRootNode( + "com.example.app", + null, + listOf( + ParsedAutofillField(autofillId(), "", "", "", USERNAME, viewNode()), + ParsedAutofillField(autofillId(), "", "", "", PASSWORD, viewNode()), + ), + ), + mock(), + ) + + verify(mockViewProvider, times(3)).createFormPresentation(any(), any(), any(), any()) + } + + @Test + fun whenMultipleFillableFieldsFoundThenRespectInlineLimitsPerFillableField() = runTest { + val credentials = listOf( + LoginCredentials(1L, "username", "password", "example.com"), + LoginCredentials(2L, "username2", "password2", "example2.com"), + ) + whenever(autofillStore.getCredentials(any())).thenReturn(credentials) + whenever(mockViewProvider.createFormPresentation(any(), any(), any(), any())).thenReturn(mock()) + whenever(mockViewProvider.createInlinePresentation(any(), any(), any(), any(), any(), any())).thenReturn(mock()) + + testee.buildSuggestionsResponse( + context = context, + AutofillRootNode( + "com.example.app", + null, + listOf( + ParsedAutofillField(autofillId(), "", "", "", USERNAME, viewNode()), + ParsedAutofillField(autofillId(), "", "", "", PASSWORD, viewNode()), + ), + ), + fillRequest().inlineSuggestionsRequest( + inlineSuggestionsRequest() + .maxSuggestionCount(3) + .inlinePresentationSpecs(inlinePresentationSpec()), + ), + ) + + verify(mockViewProvider, times(5)).createInlinePresentation(any(), any(), any(), any(), any(), any()) + verify(mockViewProvider, times(5)).createFormPresentation(any(), any(), any(), any()) + } + + @Test + fun whenMultipleFillableFieldsFoundThenAddTheRightSuggestionValueForEach() = runTest { + val credential = spy(LoginCredentials(1L, "username", "password", "example.com")) + val credentials = listOf(credential) + whenever(autofillStore.getCredentials(any())).thenReturn(credentials) + whenever(mockViewProvider.createFormPresentation(any(), any(), any(), any())).thenReturn(mock()) + + testee.buildSuggestionsResponse( + context = context, + AutofillRootNode( + "com.example.app", + null, + listOf( + ParsedAutofillField(autofillId(), "", "", "", USERNAME, viewNode()), + ParsedAutofillField(autofillId(), "", "", "", PASSWORD, viewNode()), + ), + ), + mock(), + ) + + verify(credential, times(1)).username + verify(credential, times(1)).password + verify(mockViewProvider, times(3)).createFormPresentation(any(), any(), any(), any()) + } +} diff --git a/autofill/autofill-internal/src/main/AndroidManifest.xml b/autofill/autofill-internal/src/main/AndroidManifest.xml index e419b5f250f1..cbf65f598829 100644 --- a/autofill/autofill-internal/src/main/AndroidManifest.xml +++ b/autofill/autofill-internal/src/main/AndroidManifest.xml @@ -21,7 +21,18 @@ android:name="com.duckduckgo.autofill.internal.AutofillInternalSettingsActivity" android:exported="true" android:label="@string/autofillDevSettingsTitle" - android:parentActivityName="com.duckduckgo.app.settings.SettingsActivity" - /> + android:parentActivityName="com.duckduckgo.app.settings.SettingsActivity" /> + + + + + + \ No newline at end of file From 42f098a5453b86fe85be554fec4edca97278b9b2 Mon Sep 17 00:00:00 2001 From: Cristian Monforte Date: Thu, 30 Jan 2025 11:56:57 +0100 Subject: [PATCH 3/5] removes unnecessary annotation --- .../autofill/impl/service/AutofillParser.kt | 17 +++++++---------- .../impl/service/AutofillProviderSuggestions.kt | 6 +++--- .../impl/service/AutofillServiceViewProvider.kt | 2 +- .../impl/service/RealAutofillService.kt | 3 --- 4 files changed, 11 insertions(+), 17 deletions(-) diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillParser.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillParser.kt index f781211a72b6..30325425588f 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillParser.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillParser.kt @@ -19,7 +19,6 @@ package com.duckduckgo.autofill.impl.service import android.annotation.SuppressLint import android.app.assist.AssistStructure import android.app.assist.AssistStructure.ViewNode -import android.os.Build import android.view.autofill.AutofillId import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.autofill.impl.service.AutofillFieldType.UNKNOWN @@ -130,16 +129,14 @@ class RealAutofillParser @Inject constructor( @SuppressLint("NewApi") private fun ViewNode.website(): String? { - return this.webDomain - .takeUnless { it?.isBlank() == true } - ?.let { webDomain -> - val webScheme = if (appBuildConfig.sdkInt >= Build.VERSION_CODES.P) { - this.webScheme.takeUnless { it.isNullOrBlank() } + return this.webDomain?.takeUnless { it.isBlank() } + ?.let { nonEmptyDomain -> + val scheme = if (appBuildConfig.sdkInt >= 28) { + this.webScheme.takeUnless { it.isNullOrBlank() } ?: "http" } else { - null - } ?: "http" - - "$webScheme://$webDomain" + "http" + } + "$scheme://$nonEmptyDomain" } } 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 84e677afcf9a..fe23edf7de65 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 @@ -81,7 +81,7 @@ class RealAutofillProviderSuggestions @Inject constructor( val suggestionUISpecs = suggestionsFormatter.getSuggestionSpecs(credential) // >= android 11 inline presentations are supported - if (appBuildConfig.sdkInt >= VERSION_CODES.R && inlineSuggestionsToShow > 0) { + if (appBuildConfig.sdkInt >= 30 && inlineSuggestionsToShow > 0) { datasetBuilder.addInlinePresentationsIfSupported( context, request, @@ -125,7 +125,7 @@ class RealAutofillProviderSuggestions @Inject constructor( val ddgAppDataSet = Dataset.Builder() val specs = suggestionsFormatter.getOpenDuckDuckGoSuggestionSpecs() val pendingIntent = createAutofillSelectionIntent(context) - if (appBuildConfig.sdkInt >= VERSION_CODES.R) { + if (appBuildConfig.sdkInt >= 30) { ddgAppDataSet.addInlinePresentationsIfSupported(context, request, specs.title, specs.subtitle, specs.icon) } val formPresentation = viewProvider.createFormPresentation(context, specs.title, specs.subtitle, specs.icon) @@ -175,7 +175,7 @@ class RealAutofillProviderSuggestions @Inject constructor( private fun getMaxInlinedSuggestions( request: FillRequest, ): Int { - if (appBuildConfig.sdkInt >= VERSION_CODES.R) { + if (appBuildConfig.sdkInt >= 30) { return request.inlineSuggestionsRequest?.maxSuggestionCount ?: 0 } return 0 diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceViewProvider.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceViewProvider.kt index 8e7285949ddb..89e3e54ca39d 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceViewProvider.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceViewProvider.kt @@ -92,7 +92,7 @@ class RealAutofillServiceViewProvider @Inject constructor( @RequiresApi(VERSION_CODES.R) private fun isInlineSuggestionSupported(inlinePresentationSpec: InlinePresentationSpec?): Boolean { // requires >= android 11 - return if (appBuildConfig.sdkInt >= VERSION_CODES.R && inlinePresentationSpec != null) { + return if (appBuildConfig.sdkInt >= 30 && inlinePresentationSpec != null) { UiVersions.getVersions(inlinePresentationSpec.style).contains(UiVersions.INLINE_UI_VERSION_1) } else { false 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 d8961543c2ad..d20361f16cff 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 @@ -16,7 +16,6 @@ package com.duckduckgo.autofill.impl.service -import android.os.Build.VERSION_CODES import android.os.CancellationSignal import android.service.autofill.AutofillService import android.service.autofill.FillCallback @@ -24,7 +23,6 @@ import android.service.autofill.FillRequest import android.service.autofill.SaveCallback import android.service.autofill.SaveRequest import android.service.autofill.SavedDatasetsInfoCallback -import androidx.annotation.RequiresApi import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.appbuildconfig.api.AppBuildConfig @@ -61,7 +59,6 @@ class RealAutofillService : AutofillService() { AndroidInjection.inject(this) } - @RequiresApi(VERSION_CODES.R) override fun onFillRequest( request: FillRequest, cancellationSignal: CancellationSignal, From fc50c76d9585acfe173657ab8d4e467d3a29b37c Mon Sep 17 00:00:00 2001 From: Cristian Monforte Date: Thu, 30 Jan 2025 12:51:30 +0100 Subject: [PATCH 4/5] fixing lint --- .../autofill/impl/service/ViewNodeClassifier.kt | 6 +++++- .../impl/service/AutofillProvideMockBuilder.kt | 5 +++++ .../service/AutofillServiceViewNodeClassifierTest.kt | 10 ++++++---- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/ViewNodeClassifier.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/ViewNodeClassifier.kt index 0ee20079047b..09756dae8796 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/ViewNodeClassifier.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/ViewNodeClassifier.kt @@ -90,7 +90,11 @@ class AutofillServiceViewNodeClassifier @Inject constructor() : ViewNodeClassifi private fun isTextPasswordField(inputType: Int): Boolean { return (inputType and InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_TEXT && - (inputType and InputType.TYPE_MASK_VARIATION) == InputType.TYPE_TEXT_VARIATION_PASSWORD + ( + (inputType and InputType.TYPE_MASK_VARIATION) == InputType.TYPE_TEXT_VARIATION_PASSWORD || + (inputType and InputType.TYPE_MASK_VARIATION) == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD || + (inputType and InputType.TYPE_MASK_VARIATION) == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD + ) } private fun getType(autofillHints: Array?): AutofillFieldType { diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/AutofillProvideMockBuilder.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/AutofillProvideMockBuilder.kt index 6fafe0b1f8ee..8f27699b67b5 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/AutofillProvideMockBuilder.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/AutofillProvideMockBuilder.kt @@ -72,6 +72,11 @@ fun viewNode(): ViewNode { return mock() } +fun ViewNode.inputType(inputType: Int): ViewNode { + whenever(this.inputType).thenReturn(inputType) + return this +} + fun ViewNode.webDomain(domain: String): ViewNode { whenever(this.webDomain).thenReturn(domain) return this diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/AutofillServiceViewNodeClassifierTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/AutofillServiceViewNodeClassifierTest.kt index f753d2222584..955347b4b631 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/AutofillServiceViewNodeClassifierTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/service/AutofillServiceViewNodeClassifierTest.kt @@ -16,6 +16,8 @@ package com.duckduckgo.autofill.impl.service +import android.text.InputType.TYPE_CLASS_TEXT +import android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD import android.util.Pair import android.view.View import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -85,7 +87,7 @@ class AutofillServiceViewNodeClassifierTest { assertEquals( "$usernameCombination failed", USERNAME, - testee.classify(viewNode().hint(usernameCombination)), + testee.classify(viewNode().inputType(TYPE_CLASS_TEXT).hint(usernameCombination)), ) } @@ -100,7 +102,7 @@ class AutofillServiceViewNodeClassifierTest { assertEquals( "$usernameCombination failed", USERNAME, - testee.classify(viewNode().idEntry(usernameCombination)), + testee.classify(viewNode().inputType(TYPE_CLASS_TEXT).idEntry(usernameCombination)), ) } @@ -116,7 +118,7 @@ class AutofillServiceViewNodeClassifierTest { assertEquals( "$passwordCombination failed", PASSWORD, - testee.classify(viewNode().hint(passwordCombination)), + testee.classify(viewNode().inputType(TYPE_CLASS_TEXT or TYPE_TEXT_VARIATION_PASSWORD).hint(passwordCombination)), ) } @@ -132,7 +134,7 @@ class AutofillServiceViewNodeClassifierTest { assertEquals( "$passwordCombination failed", PASSWORD, - testee.classify(viewNode().idEntry(passwordCombination)), + testee.classify(viewNode().inputType(TYPE_CLASS_TEXT or TYPE_TEXT_VARIATION_PASSWORD).idEntry(passwordCombination)), ) } From 12bf9849a07fe6e6a4469f8fafb802527c932943 Mon Sep 17 00:00:00 2001 From: Cristian Monforte Date: Thu, 30 Jan 2025 13:22:07 +0100 Subject: [PATCH 5/5] fixing lint --- autofill/autofill-impl/lint-baseline.xml | 66 +++++++++++++++++++ .../service/AutofillProviderSuggestions.kt | 3 +- .../service/AutofillServiceViewProvider.kt | 7 +- .../RealAutofillProviderSuggestionsTest.kt | 5 +- 4 files changed, 72 insertions(+), 9 deletions(-) diff --git a/autofill/autofill-impl/lint-baseline.xml b/autofill/autofill-impl/lint-baseline.xml index 855a3d582afa..31a2c68a6859 100644 --- a/autofill/autofill-impl/lint-baseline.xml +++ b/autofill/autofill-impl/lint-baseline.xml @@ -2025,6 +2025,17 @@ column="25"/> + + + + + + + + + + + + + + + + + + + + + + + + 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 fe23edf7de65..eb2dde66a635 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 @@ -20,7 +20,6 @@ import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.os.Build.VERSION_CODES import android.service.autofill.Dataset import android.service.autofill.FillRequest import android.service.autofill.FillResponse @@ -142,7 +141,7 @@ class RealAutofillProviderSuggestions @Inject constructor( return ddgAppDataSetBuild } - @RequiresApi(VERSION_CODES.R) + @RequiresApi(30) private fun Dataset.Builder.addInlinePresentationsIfSupported( context: Context, request: FillRequest, diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceViewProvider.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceViewProvider.kt index 89e3e54ca39d..ebbcd1905bc8 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceViewProvider.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillServiceViewProvider.kt @@ -21,7 +21,6 @@ import android.app.PendingIntent import android.app.slice.Slice import android.content.Context import android.graphics.drawable.Icon -import android.os.Build.VERSION_CODES import android.service.autofill.InlinePresentation import android.widget.RemoteViews import android.widget.inline.InlinePresentationSpec @@ -72,7 +71,7 @@ class RealAutofillServiceViewProvider @Inject constructor( iconRes = icon, ) - @RequiresApi(VERSION_CODES.R) + @RequiresApi(30) override fun createInlinePresentation( context: Context, pendingIntent: PendingIntent, @@ -89,7 +88,7 @@ class RealAutofillServiceViewProvider @Inject constructor( return null } - @RequiresApi(VERSION_CODES.R) + @RequiresApi(30) private fun isInlineSuggestionSupported(inlinePresentationSpec: InlinePresentationSpec?): Boolean { // requires >= android 11 return if (appBuildConfig.sdkInt >= 30 && inlinePresentationSpec != null) { @@ -100,7 +99,7 @@ class RealAutofillServiceViewProvider @Inject constructor( } @SuppressLint("RestrictedApi") // because getSlice, but docs clearly indicate you need to use that method. - @RequiresApi(VERSION_CODES.R) + @RequiresApi(30) private fun createSlice( context: Context, pendingIntent: PendingIntent, 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 263d090b8d57..753049c04fc2 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 @@ -16,7 +16,6 @@ package com.duckduckgo.autofill.impl.service -import android.os.Build.VERSION_CODES import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.appbuildconfig.api.AppBuildConfig @@ -44,7 +43,7 @@ class RealAutofillProviderSuggestionsTest { private val context = InstrumentationRegistry.getInstrumentation().targetContext private val appBuildConfig = mock().apply { - whenever(sdkInt).thenReturn(VERSION_CODES.R) + whenever(sdkInt).thenReturn(30) } private val autofillStore = mock() @@ -68,7 +67,7 @@ class RealAutofillProviderSuggestionsTest { val credentials = listOf( LoginCredentials(1L, "username", "password", "example.com"), ) - whenever(appBuildConfig.sdkInt).thenReturn(VERSION_CODES.Q) + whenever(appBuildConfig.sdkInt).thenReturn(29) whenever(autofillStore.getCredentials(any())).thenReturn(credentials) whenever(mockViewProvider.createFormPresentation(any(), any(), any(), any())).thenReturn(mock()) whenever(mockViewProvider.createInlinePresentation(any(), any(), any(), any(), any(), any())).thenReturn(null)