From 1bb9c02b2a08aa40b513c3d2d825a7e5f6469686 Mon Sep 17 00:00:00 2001 From: Cristian Monforte Date: Thu, 28 Nov 2024 16:21:54 +0100 Subject: [PATCH 1/2] 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 e70f429900f85241c0ae95bd8f606725eadc3cb5 Mon Sep 17 00:00:00 2001 From: Cristian Monforte Date: Wed, 4 Dec 2024 21:30:06 +0100 Subject: [PATCH 2/2] autofill provider with keyboard support --- autofill/autofill-impl/build.gradle | 1 + .../src/main/AndroidManifest.xml | 2 +- .../autofill/impl/service/AutofillParser.kt | 208 +++++++++++ .../service/AutofillProviderSuggestions.kt | 342 ++++++++++++++++++ .../impl/service/DDGAutofillService.kt | 213 ----------- .../impl/service/RealAutofillService.kt | 149 ++++++++ .../management/AutofillManagementActivity.kt | 165 +++++++++ .../management/AutofillSettingsViewModel.kt | 7 +- .../viewing/AutofillManagementListMode.kt | 5 + .../drawable/ic_dax_silhouette_primary_24.xml | 10 + .../main/res/layout/autofill_remote_view.xml | 43 +++ .../main/res/xml/service_configuration.xml | 3 +- 12 files changed, 931 insertions(+), 217 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 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/res/drawable/ic_dax_silhouette_primary_24.xml create mode 100644 autofill/autofill-impl/src/main/res/layout/autofill_remote_view.xml 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..fd6006250f41 100644 --- a/autofill/autofill-impl/src/main/AndroidManifest.xml +++ b/autofill/autofill-impl/src/main/AndroidManifest.xml @@ -26,7 +26,7 @@ android:windowSoftInputMode="adjustResize" /> 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..9a795fd306a9 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillParser.kt @@ -0,0 +1,208 @@ +/* + * 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.os.Build.VERSION_CODES +import android.text.InputType +import android.view.View +import android.view.autofill.AutofillId +import androidx.annotation.RequiresApi +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +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 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, +) : AutofillParser { + + private val classifier = ViewNodeClassifier() + 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") + 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.idPackage + val website = viewNode.website() + val autofillType = classifier.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, + ) + } + + @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" + } + } +} + +class ViewNodeClassifier { + 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 (viewNode.inputType and InputType.TYPE_CLASS_TEXT > 0) { + if (viewNode.idEntry?.containsAny(userNameKeywords) == true || + viewNode.hint?.containsAny(userNameKeywords) == true + ) { + autofillType = AutofillFieldType.USERNAME + } else if (viewNode.idEntry?.containsAny(passwordKeywords) == true || + viewNode.hint?.containsAny(userNameKeywords) == true + ) { + autofillType = AutofillFieldType.PASSWORD + } + } + } + + kotlin.runCatching { viewNode.autofillValue?.textValue?.toString() }.getOrElse { + // If land here, it means we are not a text node, then assign Unknown type (logins are text fields) + autofillType = AutofillFieldType.UNKNOWN + } + + return autofillType + } + + 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/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..98c7aaaaf064 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillProviderSuggestions.kt @@ -0,0 +1,342 @@ +/* + * 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.content.Intent +import android.graphics.Color +import android.graphics.drawable.Icon +import android.os.Build.VERSION_CODES +import android.service.autofill.Dataset +import android.service.autofill.FillRequest +import android.service.autofill.FillResponse +import android.service.autofill.InlinePresentation +import android.view.autofill.AutofillValue +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.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.api.store.AutofillStore +import com.duckduckgo.autofill.impl.R +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, + autofillRequest: AutofillParsedRequest, + request: FillRequest, + ): FillResponse +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class RealAutofillProviderSuggestions @Inject constructor( + private val appBuildConfig: AppBuildConfig, + private val autofillStore: AutofillStore, +) : AutofillProviderSuggestions { + + @SuppressLint("NewApi") + override suspend fun buildSuggestionsResponse( + context: Context, + autofillRequest: AutofillParsedRequest, + request: FillRequest, + ): FillResponse { + var inlineSuggestionsToShow = getMaxInlinedSuggestions(request) - 1 + val node: AutofillRootNode = autofillRequest.rootNode + Timber.i("DDGAutofillService Fillable Request for rootNode: $node") + val fillableFields = node.parsedAutofillFields.filter { it.type != UNKNOWN } + Timber.i("DDGAutofillService Fillable Request for fields: $fillableFields") + // ensure fields is not empty + // add entries + val response = FillResponse.Builder() + fillableFields.forEach { fieldsToAutofill -> + val credentials = loginCredentials(node) ?: listOf(LoginCredentials(12L, "domain", "username", "password")) + credentials?.forEach { credential -> + val datasetBuilder = Dataset.Builder() + Timber.i("DDGAutofillService Fillable Request for fields: $fieldsToAutofill") + // TODO: what if we don't have username/domain/others + val suggestionTitle = credential.username ?: "not found" + val suggestionSubtitle = credential.domain ?: "not found" + val icon = R.drawable.ic_dax_silhouette_primary_24 + // >= android 11 + if (appBuildConfig.sdkInt >= VERSION_CODES.R && inlineSuggestionsToShow > 0) { + datasetBuilder.addInlinePresentationsIfSupported(context, request, suggestionTitle, suggestionSubtitle, icon) + inlineSuggestionsToShow -= 1 + } + // Supported in all android apis + val formPresentation = createFormPresentation(context, suggestionTitle, suggestionSubtitle, icon) + + datasetBuilder.setValue( + fieldsToAutofill.autofillId, + autofillValue(credential, fieldsToAutofill.type), + formPresentation, + ) + val pendingIntent = createAutofillSelectionIntent(context) + val dataset = datasetBuilder + .setAuthentication(pendingIntent.intentSender) // TODO: this is how we should request auth + .build() + response.addDataset(dataset) + } + } + + // adding access to ddg app + val ddgAppDataSetBuild = createAccessDDGDataSet(context, request, fillableFields) + response.addDataset(ddgAppDataSetBuild) + + // add ignored ids + return response.build() + } + + @SuppressLint("NewApi") + private fun createAccessDDGDataSet( + context: Context, + request: FillRequest, + fillableFields: List, + ): Dataset { + // add access passwords + val ddgAppDataSet = Dataset.Builder() + val suggestionTitle = "Search in DuckDuckGo" + val suggestionSubtitle = "" + val icon = R.drawable.ic_dax_silhouette_primary_24 + val pendingIntent = createAutofillSelectionIntent(context) + if (appBuildConfig.sdkInt >= VERSION_CODES.R) { + ddgAppDataSet.addInlinePresentationsIfSupported(context, request, suggestionTitle, suggestionSubtitle, icon) + } + val formPresentation = createFormPresentation(context, suggestionTitle, suggestionSubtitle, icon) + // TODO: why we add one per fillable field, is this not adding multiple entries? + // seems in >30 we will only how 1 presentation, but what happens <30? + // I think nothing because they are separate fields, and suggestions will only appear when field focused + fillableFields.forEach { fieldsToAutofill -> + ddgAppDataSet.setValue( + fieldsToAutofill.autofillId, + if (fieldsToAutofill.type == USERNAME) { + AutofillValue.forText("username") + } else { + AutofillValue.forText("password") + }, + formPresentation, + ) + } + // TODO: how can we require auth for <30 + 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 isInlineSupported = isInlineSuggestionSupported(inlinePresentationSpec) + if (isInlineSupported) { + val pendingIntent = PendingIntent.getService( + context, + 0, + Intent(), + PendingIntent.FLAG_ONE_SHOT or + PendingIntent.FLAG_UPDATE_CURRENT or + PendingIntent.FLAG_IMMUTABLE, + ) + val inlinePresentation = createInlinePresentation( + context, + pendingIntent, + suggestionTitle, + suggestionSubtitle, + icon, + inlinePresentationSpec, + ) + 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") + } + + @RequiresApi(VERSION_CODES.R) + private fun createInlinePresentation( + context: Context, + pendingIntent: PendingIntent, + suggestionTitle: String, + suggestionSubtitle: String, + icon: Int, + inlinePresentationSpec: InlinePresentationSpec, + ): InlinePresentation { + val slice = createSlice(context, pendingIntent, suggestionTitle, suggestionSubtitle, icon) + val inlinePresentation = InlinePresentation(slice, inlinePresentationSpec!!, false) + return inlinePresentation + } + + @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 suspend fun loginCredentials(node: AutofillRootNode): List? { + var crendentials = node.website.takeUnless { it.isNullOrBlank() }?.let { + autofillStore.getCredentials(it) + } + if (crendentials == null) { + crendentials = node.packageId.takeUnless { it.isNullOrBlank() }?.let { + autofillStore.getCredentials(it) + } + } + return crendentials + } + + @RequiresApi(VERSION_CODES.R) + 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 + } + } + + private fun createFormPresentation( + context: Context, + suggestionTitle: String, + suggestionSubtitle: String, + icon: Int, + ) = buildAutofillRemoteViews( + context = context, + name = suggestionTitle, + subtitle = suggestionSubtitle, + iconRes = icon, + shouldTintIcon = false, + ) + + private fun createAutofillSelectionIntent( + context: Context, + // framework: AutofillSelectionData.Framework, + // type: AutofillSelectionData.Type, + // uri: String?, + ): PendingIntent { + val intent = Intent(context, AutofillManagementActivity::class.java) + return PendingIntent + .getActivity( + context, + Random.nextInt(), + intent, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE, + ) + } + + private fun buildAutofillRemoteViews( + // autofillContentDescription: String?, + context: Context, + name: String, + subtitle: String, + @DrawableRes iconRes: Int, + shouldTintIcon: Boolean, + ): RemoteViews = + RemoteViews( + context.packageName, + R.layout.autofill_remote_view, + ).apply { + /*autofillContentDescription?.let { + setContentDescription( + R.id.container, + it, + ) + }*/ + setTextViewText( + R.id.title, + name, + ) + setTextViewText( + R.id.subtitle, + subtitle, + ) + setImageViewResource( + R.id.icon, + iconRes, + ) + /*setInt( + R.id.container, + "setBackgroundColor", + Color.CYAN, + )*/ + setInt( + R.id.title, + "setTextColor", + Color.BLACK, + ) + setInt( + R.id.subtitle, + "setTextColor", + Color.BLACK, + ) + if (shouldTintIcon) { + setInt( + R.id.icon, + "setColorFilter", + Color.BLACK, + ) + } + } +} 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..60f7c3dbfd00 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/RealAutofillService.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.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.R +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.VpnScope +import dagger.android.AndroidInjection +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +@InjectWith( + scope = VpnScope::class, // we might need to have our own scope to avoid creating the whole app graph +) +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 + } + // TODO: return if it's ddg app? + + val parsedRootNodes = autofillParser.parseStructure(structure) + val autofillParsedRequest: AutofillParsedRequest? = findFillableNode(parsedRootNodes) + if (autofillParsedRequest == null) { + callback.onSuccess(null) + return@launch + } + val logSpecs = request.inlineSuggestionsRequest?.inlinePresentationSpecs?.firstOrNull() + Timber.i("DDGAutofillService onFillRequest logSpecs: $logSpecs") + Timber.i("DDGAutofillService onFillRequest maxSuggestionCount: ${request.inlineSuggestionsRequest?.maxSuggestionCount}") + + // prepare response + val response = autofillProviderSuggestions.buildSuggestionsResponse( + context = this@RealAutofillService, + autofillRequest = autofillParsedRequest, + request = request, + ) + + callback.onSuccess(response) + } + } + + private fun findFillableNode(rootNodes: List): AutofillParsedRequest? { + return rootNodes.firstNotNullOfOrNull { rootNode -> + val focusedDetectedField = rootNode.parsedAutofillFields + .firstOrNull { field -> + field.originalNode.isFocused && field.type != UNKNOWN + } + if (focusedDetectedField != null) { + return@firstNotNullOfOrNull AutofillParsedRequest(rootNode, focusedDetectedField) + } + + val firstDetectedField = rootNode.parsedAutofillFields.firstOrNull { field -> field.type != UNKNOWN } + if (firstDetectedField != null) { + return@firstNotNullOfOrNull AutofillParsedRequest(rootNode, firstDetectedField) + } + return@firstNotNullOfOrNull null + } + } + + override fun onSaveRequest( + request: SaveRequest, + callback: SaveCallback, + ) { + TODO("Not yet implemented") + } + + 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") + } +} + +class AutofillParsedRequest( + val rootNode: AutofillRootNode, + val fillRequestNode: ParsedAutofillField, +) diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillManagementActivity.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillManagementActivity.kt index 6db0d81b1444..4e27bd3af3d5 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillManagementActivity.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillManagementActivity.kt @@ -16,8 +16,18 @@ package com.duckduckgo.autofill.impl.ui.credential.management +import android.app.Activity +import android.app.assist.AssistStructure +import android.content.Intent +import android.graphics.Color import android.os.Bundle +import android.service.autofill.Dataset import android.view.WindowManager +import android.view.autofill.AutofillManager +import android.view.autofill.AutofillValue +import android.widget.RemoteViews +import androidx.annotation.DrawableRes +import androidx.core.content.IntentCompat import androidx.core.view.isVisible import androidx.fragment.app.commit import androidx.fragment.app.commitNow @@ -39,6 +49,12 @@ import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator.AuthResult.Error import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator.AuthResult.Success import com.duckduckgo.autofill.impl.deviceauth.DeviceAuthenticator.AuthResult.UserCancelled +import com.duckduckgo.autofill.impl.service.AutofillFieldType +import com.duckduckgo.autofill.impl.service.AutofillFieldType.UNKNOWN +import com.duckduckgo.autofill.impl.service.AutofillParser +import com.duckduckgo.autofill.impl.service.AutofillRootNode +import com.duckduckgo.autofill.impl.service.ParsedAutofillField +import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.Command.AutofillLogin import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.Command.ExitCredentialMode import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.Command.ExitDisabledMode import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.Command.ExitListMode @@ -98,6 +114,11 @@ class AutofillManagementActivity : DuckDuckGoActivity(), PasswordsScreenPromotio @Inject lateinit var newSettingsFeature: NewSettingsFeature + @Inject + lateinit var autofillParser: AutofillParser + + private var assistStructure: AssistStructure? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -105,6 +126,8 @@ class AutofillManagementActivity : DuckDuckGoActivity(), PasswordsScreenPromotio window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) } + assistStructure = IntentCompat.getParcelableExtra(intent, AutofillManager.EXTRA_ASSIST_STRUCTURE, AssistStructure::class.java) + setContentView(binding.root) setupToolbar(binding.toolbar) observeViewModel() @@ -209,6 +232,7 @@ class AutofillManagementActivity : DuckDuckGoActivity(), PasswordsScreenPromotio is ExitLockedMode -> exitLockedMode() is ExitDisabledMode -> exitDisabledMode() is ExitListMode -> exitListMode() + is AutofillLogin -> autofillLogin(command) else -> processed = false } if (processed) { @@ -217,6 +241,147 @@ class AutofillManagementActivity : DuckDuckGoActivity(), PasswordsScreenPromotio } } + private fun autofillLogin(command: AutofillLogin) { + val structure = assistStructure ?: return + val parsedNodes = autofillParser.parseStructure(structure) + val detectedNode: Pair? = parsedNodes.firstNotNullOfOrNull { node -> + val focusedDetectedField = node.parsedAutofillFields + .firstOrNull { field -> + field.originalNode.isFocused && field.type != UNKNOWN + } + if (focusedDetectedField != null) { + return@firstNotNullOfOrNull Pair(node, focusedDetectedField) + } + val firstDetectedField = node.parsedAutofillFields.firstOrNull { field -> field.type != UNKNOWN } + if (firstDetectedField != null) { + return@firstNotNullOfOrNull Pair(node, firstDetectedField) + } + return@firstNotNullOfOrNull null + } + + if (detectedNode == null) { + return + } + + val fields = detectedNode.first.parsedAutofillFields.filter { it.type != UNKNOWN } + + val dataset = buildDataset(fields, command) + + val resultIntent = Intent().apply { + putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset) + } + this.setResult(Activity.RESULT_OK, resultIntent) + this.finish() + } + + private fun buildDataset( + fields: List, + command: AutofillLogin, + ): Dataset { + val datasetBuilder = Dataset.Builder() + fields.forEach { fieldsToAutofill -> + val suggestionTitle = "name ${fieldsToAutofill.autofillId}" + val suggestionSubtitle = "subtitle" + val icon = R.drawable.ic_autofill_color_24 + // >= android 11 + /*val isInlineSupported = if (inlinePresentationSpec != null ) { + UiVersions.getVersions(inlinePresentationSpec.style).contains(UiVersions.INLINE_UI_VERSION_1) + } else { + false + } + if (isInlineSupported) { + val slice = InlineSuggestionUi.newContentBuilder( + PendingIntent.getService( + this, + 0, + Intent(), + PendingIntent.FLAG_ONE_SHOT or + PendingIntent.FLAG_UPDATE_CURRENT or + PendingIntent.FLAG_IMMUTABLE, + ), + ).setTitle(suggestionTitle) + .setSubtitle(suggestionSubtitle) + .setStartIcon(Icon.createWithResource(this, icon)) + .build().slice + val inlinePresentation = InlinePresentation(slice, inlinePresentationSpec!!, false) + datasetBuilder.setInlinePresentation(inlinePresentation) + }*/ + + // Supported in all android apis + val remoteView = buildAutofillRemoteViews( + name = suggestionTitle, + subtitle = suggestionSubtitle, + iconRes = icon, + shouldTintIcon = false, + ) + datasetBuilder.setValue( + fieldsToAutofill.autofillId, + if (fieldsToAutofill.type == AutofillFieldType.USERNAME) { + AutofillValue.forText(command.credentials.username) + } else { + AutofillValue.forText(command.credentials.password) + }, + remoteView, + ) + + fieldsToAutofill.autofillId + } + return datasetBuilder.build() + } + + private fun buildAutofillRemoteViews( + // autofillContentDescription: String?, + name: String, + subtitle: String, + @DrawableRes iconRes: Int, + shouldTintIcon: Boolean, + ): RemoteViews = + RemoteViews( + packageName, + R.layout.autofill_remote_view, + ).apply { + /*autofillContentDescription?.let { + setContentDescription( + R.id.container, + it, + ) + }*/ + setTextViewText( + R.id.title, + name, + ) + setTextViewText( + R.id.subtitle, + subtitle, + ) + setImageViewResource( + R.id.icon, + iconRes, + ) + /*setInt( + R.id.container, + "setBackgroundColor", + Color.CYAN, + )*/ + setInt( + R.id.title, + "setTextColor", + Color.BLACK, + ) + setInt( + R.id.subtitle, + "setTextColor", + Color.BLACK, + ) + if (shouldTintIcon) { + setInt( + R.id.icon, + "setColorFilter", + Color.BLACK, + ) + } + } + private fun showCopiedToClipboardSnackbar(dataType: CopiedToClipboardDataType) { val stringResourceId = when (dataType) { is CopiedToClipboardDataType.Username -> R.string.autofillManagementUsernameCopied diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt index 4f7466709cd6..38768011d9a6 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillSettingsViewModel.kt @@ -58,6 +58,7 @@ import com.duckduckgo.autofill.impl.reporting.AutofillBreakageReportSender import com.duckduckgo.autofill.impl.reporting.AutofillSiteBreakageReportingDataStore import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository +import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.Command.AutofillLogin import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.Command.ExitCredentialMode import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.Command.ExitDisabledMode import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.Command.ExitListMode @@ -198,12 +199,13 @@ class AutofillSettingsViewModel @Inject constructor( fun onViewCredentials( credentials: LoginCredentials, ) { - _viewState.value = viewState.value.copy( + addCommand(AutofillLogin(credentials)) + /*_viewState.value = viewState.value.copy( credentialMode = Viewing(credentialsViewed = credentials, showLinkButton = credentials.shouldShowLinkButton()), ) addCommand(ShowCredentialMode) - updateDuckAddressStatus(credentials.username) + updateDuckAddressStatus(credentials.username)*/ } fun onCreateNewCredentials() { @@ -863,6 +865,7 @@ class AutofillSettingsViewModel @Inject constructor( class OfferUserUndoDeletion(val credentials: LoginCredentials?) : Command() class OfferUserUndoMassDeletion(val credentials: List) : Command() + class AutofillLogin(val credentials: LoginCredentials) : Command() object ShowListMode : Command() object ShowCredentialMode : Command() object ShowDisabledMode : Command() diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt index 3f4469ae074b..e2abec9faa26 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt @@ -338,6 +338,7 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill private fun getCurrentSiteUrl() = arguments?.getString(ARG_CURRENT_URL, null) private fun getPrivacyProtectionEnabled() = arguments?.getBoolean(ARG_PRIVACY_PROTECTION_STATUS) + private fun isLaunchedFromAutofill() = arguments?.getBoolean(ARG_AUTOFILL_MODE_LAUNCH, false) private fun getAutofillSettingsLaunchSource(): AutofillSettingsLaunchSource? = arguments?.getSerializable(ARG_AUTOFILL_SETTINGS_LAUNCH_SOURCE) as AutofillSettingsLaunchSource? @@ -654,6 +655,7 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill currentUrl: String? = null, privacyProtectionEnabled: Boolean?, source: AutofillSettingsLaunchSource? = null, + autofillMode: Boolean = true, ) = AutofillManagementListMode().apply { arguments = Bundle().apply { @@ -666,12 +668,15 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill if (source != null) { putSerializable(ARG_AUTOFILL_SETTINGS_LAUNCH_SOURCE, source) } + + putBoolean(ARG_AUTOFILL_MODE_LAUNCH, autofillMode) } } private const val ARG_CURRENT_URL = "ARG_CURRENT_URL" private const val ARG_PRIVACY_PROTECTION_STATUS = "ARG_PRIVACY_PROTECTION_STATUS" private const val ARG_AUTOFILL_SETTINGS_LAUNCH_SOURCE = "ARG_AUTOFILL_SETTINGS_LAUNCH_SOURCE" + private const val ARG_AUTOFILL_MODE_LAUNCH = "ARG_AUTOFILL_MODE_LAUNCH" private const val LEARN_MORE_LINK = "https://duckduckgo.com/duckduckgo-help-pages/sync-and-backup/password-manager-security/" private const val IMPORT_FROM_GPM_DIALOG_TAG = "IMPORT_FROM_GPM_DIALOG_TAG" } 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..da2d11635c2a --- /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/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