Skip to content

Commit

Permalink
Allow users to select password inside DDG from other apps (#5618)
Browse files Browse the repository at this point in the history
Task/Issue URL:
https://app.asana.com/0/1200156640058969/1209114881668819

### Description
Introduces:
- Asking the user for authentication, if required, after interacting
with any suggestion.
- Allowing user to manually select a credential inside our app from a
simplified list.

### Steps to test this PR

(heuristics have not changed, so we are good just testing the
autofilling part of a few scenarios).

Add some credentials for apps you will test from this task
https://app.asana.com/0/1149059203486286/1209279712661812

_Feature 1_
- [ ] install the app
- [ ] Select DuckDuckGo as System Level Autofill
- [ ] Test anything from this task
https://app.asana.com/0/1149059203486286/1209279712661812
- [ ] When selecting the credential suggestion, it should request you to
authenticate.
- [ ] After authentication, it should autofill

_Feature 1_
- [ ] Test another from this task
https://app.asana.com/0/1149059203486286/1209279712661812
- [ ] Instead click on "Search Password"
- [ ] It should open automatically without authentication since you
recently did previous step
- [ ] try searching to validate it works
- [ ] now select a credential from the list
- [ ] ensure it autofills correctly


### UI changes
| Before  | After |
| ------ | ----- |
!(Upload before screenshot)|(Upload after screenshot)|

---------

Co-authored-by: Karl Dimla <[email protected]>
  • Loading branch information
cmonfortep and karlenDimla authored Feb 12, 2025
1 parent 9aa386f commit 3c125b5
Show file tree
Hide file tree
Showing 22 changed files with 1,468 additions and 41 deletions.
11 changes: 11 additions & 0 deletions autofill/autofill-impl/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@
android:configChanges="orientation|screenSize"
android:exported="false"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".service.AutofillProviderFillSuggestionActivity"
android:configChanges="orientation|screenSize"
android:exported="false"
android:theme="@style/Theme.AppCompat.Transparent.NoActionBar"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".service.AutofillProviderChooseActivity"
android:configChanges="orientation|screenSize"
android:exported="false"
android:windowSoftInputMode="adjustResize" />
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* 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.deviceauth

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import dagger.SingleInstanceIn
import javax.inject.Qualifier

@Module
@ContributesTo(AppScope::class)
class AutofillDeviceAuthModule {

private val Context.autofillDeviceAuthDataStore: DataStore<Preferences> by preferencesDataStore(
name = "autofill_device_auth_store",
)

@Provides
@SingleInstanceIn(AppScope::class)
@AutofillDeviceAuthStore
fun providesDeviceAuthDataStore(context: Context): DataStore<Preferences> {
return context.autofillDeviceAuthDataStore
}
}

@Qualifier
annotation class AutofillDeviceAuthStore
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/*
* 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.os.Bundle
import android.view.autofill.AutofillManager
import androidx.core.content.IntentCompat
import androidx.core.view.isVisible
import androidx.fragment.app.commitNow
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
import com.duckduckgo.autofill.api.domain.app.LoginCredentials
import com.duckduckgo.autofill.impl.R
import com.duckduckgo.autofill.impl.databinding.ActivityCustomAutofillProviderBinding
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.AutofillProviderChooseViewModel.Command
import com.duckduckgo.autofill.impl.service.AutofillProviderChooseViewModel.Command.AutofillLogin
import com.duckduckgo.autofill.impl.service.AutofillProviderChooseViewModel.Command.ContinueWithoutAuthentication
import com.duckduckgo.autofill.impl.service.AutofillProviderChooseViewModel.Command.ForceFinish
import com.duckduckgo.autofill.impl.service.AutofillProviderChooseViewModel.Command.RequestAuthentication
import com.duckduckgo.common.ui.DuckDuckGoActivity
import com.duckduckgo.common.ui.view.SearchBar
import com.duckduckgo.common.ui.view.gone
import com.duckduckgo.common.ui.view.hideKeyboard
import com.duckduckgo.common.ui.view.show
import com.duckduckgo.common.ui.view.showKeyboard
import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.di.scopes.ActivityScope
import javax.inject.Inject
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import timber.log.Timber

@InjectWith(ActivityScope::class)
class AutofillProviderChooseActivity : DuckDuckGoActivity() {

val binding: ActivityCustomAutofillProviderBinding by viewBinding()
private val viewModel: AutofillProviderChooseViewModel by bindViewModel()

@Inject
lateinit var deviceAuthenticator: DeviceAuthenticator

@Inject
lateinit var appBuildConfig: AppBuildConfig

@Inject
lateinit var autofillServiceActivityHandler: AutofillServiceActivityHandler

private var assistStructure: AssistStructure? = null

private val credentialId: Long?
get() = intent.getLongExtra(FILL_REQUEST_AUTOFILL_CREDENTIAL_ID_EXTRAS, -1L).takeIf { it != -1L }

private val urlRequest: String
get() = intent.getStringExtra(FILL_REQUEST_URL_EXTRAS) ?: ""

private val packageRequest: String
get() = intent.getStringExtra(FILL_REQUEST_PACKAGE_ID_EXTRAS) ?: ""

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Timber.i("DDGAutofillService onCreate!")

assistStructure = IntentCompat.getParcelableExtra(intent, AutofillManager.EXTRA_ASSIST_STRUCTURE, AssistStructure::class.java)

setContentView(binding.root)
setupToolbar(binding.toolbar)
setTitle(R.string.autofill_service_select_password_activity)
observeViewModel()
}

private fun observeViewModel() {
viewModel.commands()
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.onEach { processCommand(it) }
.launchIn(lifecycleScope)
}

private fun processCommand(command: Command) {
when (command) {
is RequestAuthentication -> {
Timber.i("DDGAutofillService auth IS REQUIRED!")
deviceAuthenticator.authenticate(this) {
when (it) {
Success -> {
viewModel.onUserAuthenticatedSuccessfully()
}

UserCancelled -> {
finish()
}

is Error -> {
finish()
}
}
}
}

ContinueWithoutAuthentication -> {
Timber.i("DDGAutofillService ContinueWithoutAuthentication credentialId: $credentialId")
credentialId?.let { nonNullId ->
viewModel.continueAfterAuthentication(nonNullId)
} ?: run {
showListMode()
}
}

is AutofillLogin -> {
autofillLogin(command.credentials)
}

ForceFinish -> finish()
}
}

private fun showListMode() {
supportFragmentManager.commitNow {
val fragment = AutofillSimpleCredentialsListFragment.instance(urlRequest, packageRequest)
replace(R.id.fragment_container_view, fragment, TAG_CREDENTIALS_LIST)
}
}

fun showSearchBar() {
with(binding) {
toolbar.gone()
searchBar.handle(SearchBar.Event.ShowSearchBar)
searchBar.showKeyboard()
}
}

fun hideSearchBar() {
with(binding) {
toolbar.show()
searchBar.handle(SearchBar.Event.DismissSearchBar)
searchBar.hideKeyboard()
}
}

private fun isSearchBarVisible(): Boolean = binding.searchBar.isVisible

override fun onBackPressed() {
if (isSearchBarVisible()) {
hideSearchBar()
} else {
super.onBackPressed()
}
}

fun autofillLogin(credential: LoginCredentials) {
val structure = assistStructure ?: return
autofillServiceActivityHandler.onFillRequest(this, credential, structure)
}

companion object {
const val TAG_CREDENTIALS_LIST = "tag_fragment_credentials_list"
const val FILL_REQUEST_URL_EXTRAS = "FILL_REQUEST_URL"
const val FILL_REQUEST_PACKAGE_ID_EXTRAS = "FILL_REQUEST_PACKAGE_ID"
const val FILL_REQUEST_AUTOFILL_ID_EXTRAS = "AUTOFILL_ID"
const val FILL_REQUEST_AUTOFILL_CREDENTIAL_ID_EXTRAS = "USER_CREDENTIAL_ID"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* 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 androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.duckduckgo.anvil.annotations.ContributesViewModel
import com.duckduckgo.autofill.api.domain.app.LoginCredentials
import com.duckduckgo.autofill.impl.securestorage.SecureStorage
import com.duckduckgo.autofill.impl.securestorage.WebsiteLoginDetailsWithCredentials
import com.duckduckgo.autofill.impl.service.AutofillProviderChooseViewModel.Command.AutofillLogin
import com.duckduckgo.autofill.impl.service.AutofillProviderChooseViewModel.Command.ContinueWithoutAuthentication
import com.duckduckgo.autofill.impl.service.AutofillProviderChooseViewModel.Command.ForceFinish
import com.duckduckgo.autofill.impl.service.AutofillProviderChooseViewModel.Command.RequestAuthentication
import com.duckduckgo.autofill.impl.ui.credential.management.AutofillSettingsViewModel.Command
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.ActivityScope
import javax.inject.Inject
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import timber.log.Timber

@ContributesViewModel(ActivityScope::class)
class AutofillProviderChooseViewModel @Inject constructor(
private val autofillProviderDeviceAuth: AutofillProviderDeviceAuth,
private val dispatchers: DispatcherProvider,
private val secureStorage: SecureStorage,
) : ViewModel() {

private val command = Channel<Command>(1, BufferOverflow.DROP_OLDEST)

fun commands(): Flow<Command> = command.receiveAsFlow().onStart {
if (autofillProviderDeviceAuth.isAuthRequired()) {
command.send(RequestAuthentication)
} else {
command.send(ContinueWithoutAuthentication)
}
}

sealed class Command {
data object RequestAuthentication : Command()
data object ContinueWithoutAuthentication : Command()
data class AutofillLogin(val credentials: LoginCredentials) : Command()
data object ForceFinish : Command()
}

fun onUserAuthenticatedSuccessfully() {
viewModelScope.launch(dispatchers.io()) {
autofillProviderDeviceAuth.recordSuccessfulAuthorization()
command.send(ContinueWithoutAuthentication)
}
}

fun continueAfterAuthentication(credentialId: Long) {
Timber.i("DDGAutofillService request to autofill login with credentialId: $credentialId")
viewModelScope.launch(dispatchers.io()) {
secureStorage.getWebsiteLoginDetailsWithCredentials(credentialId)?.toLoginCredentials()?.let {
Timber.i("DDGAutofillService $credentialId found, autofilling")
command.send(AutofillLogin(it))
} ?: run {
command.send(ForceFinish)
}
}
}

private fun WebsiteLoginDetailsWithCredentials.toLoginCredentials(): LoginCredentials {
return LoginCredentials(
id = details.id,
domain = details.domain,
username = details.username,
password = password,
domainTitle = details.domainTitle,
notes = notes,
lastUpdatedMillis = details.lastUpdatedMillis,
lastUsedMillis = details.lastUsedInMillis,
)
}
}
Loading

0 comments on commit 3c125b5

Please sign in to comment.