From f7b551cc4f8ac1573fbb32cf9da9e7c89e677d75 Mon Sep 17 00:00:00 2001 From: Craig Russell Date: Wed, 29 May 2024 12:01:10 +0100 Subject: [PATCH] Add developer settings for importing passwords from Google Password Manager --- .../impl/importing/PasswordImporter.kt | 123 ++++++++++++++++++ .../AutofillInternalSettingsActivity.kt | 81 ++++++++++++ .../activity_autofill_internal_settings.xml | 21 +++ .../src/main/res/values/donottranslate.xml | 4 + 4 files changed, 229 insertions(+) create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/PasswordImporter.kt diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/PasswordImporter.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/PasswordImporter.kt new file mode 100644 index 000000000000..8046847db765 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/PasswordImporter.kt @@ -0,0 +1,123 @@ +/* + * 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.importing + +import android.os.Parcelable +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.importing.PasswordImporter.ImportResult +import com.duckduckgo.autofill.impl.importing.PasswordImporter.ImportResult.Finished +import com.duckduckgo.autofill.impl.importing.PasswordImporter.ImportResult.InProgress +import com.duckduckgo.autofill.impl.store.InternalAutofillStore +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import java.util.UUID +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.parcelize.Parcelize + +interface PasswordImporter { + suspend fun importPasswords(importList: List): String + fun getImportStatus(jobId: String): Flow + + sealed interface ImportResult : Parcelable { + + @Parcelize + data class InProgress( + val savedCredentialIds: List, + val duplicatedPasswords: List, + val importListSize: Int, + val jobId: String, + ) : ImportResult + + @Parcelize + data class Finished( + val savedCredentialIds: List, + val duplicatedPasswords: List, + val importListSize: Int, + val jobId: String, + ) : ImportResult + } +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class PasswordImporterImpl @Inject constructor( + private val existingPasswordMatchDetector: ExistingPasswordMatchDetector, + private val autofillStore: InternalAutofillStore, + private val dispatchers: DispatcherProvider, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : PasswordImporter { + + private val _importStatus = MutableSharedFlow(replay = 1) + private val mutex = Mutex() + + override suspend fun importPasswords(importList: List): String { + val jobId = UUID.randomUUID().toString() + + mutex.withLock { + appCoroutineScope.launch(dispatchers.io()) { + doImportPasswords(importList, jobId) + } + } + + return jobId + } + + private suspend fun doImportPasswords( + importList: List, + jobId: String, + ) { + val savedCredentialIds = mutableListOf() + val duplicatedPasswords = mutableListOf() + + _importStatus.emit(InProgress(savedCredentialIds, duplicatedPasswords, importList.size, jobId)) + + importList.forEach { + if (!existingPasswordMatchDetector.alreadyExists(it)) { + val insertedId = autofillStore.saveCredentials(it.domain!!, it)?.id + + if (insertedId != null) { + savedCredentialIds.add(insertedId) + } + } else { + duplicatedPasswords.add(it) + } + + _importStatus.emit(InProgress(savedCredentialIds, duplicatedPasswords, importList.size, jobId)) + } + + _importStatus.emit(Finished(savedCredentialIds, duplicatedPasswords, importList.size, jobId)) + } + + override fun getImportStatus(jobId: String): Flow { + return _importStatus.filter { result -> + when (result) { + is InProgress -> result.jobId == jobId + is Finished -> result.jobId == jobId + } + } + } +} diff --git a/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt b/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt index 308f6531e0a5..82982c450721 100644 --- a/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt +++ b/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt @@ -16,15 +16,19 @@ package com.duckduckgo.autofill.internal +import android.annotation.SuppressLint +import android.app.Activity import android.content.Context import android.content.Intent import android.os.Bundle import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle.State.STARTED import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.tabs.BrowserNav import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.api.AutofillScreens.AutofillSettingsScreen import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource.InternalDevSettings @@ -33,6 +37,11 @@ import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.autofill.impl.configuration.AutofillJavascriptEnvironmentConfiguration import com.duckduckgo.autofill.impl.email.incontext.store.EmailProtectionInContextDataStore import com.duckduckgo.autofill.impl.engagement.store.AutofillEngagementRepository +import com.duckduckgo.autofill.impl.importing.CsvPasswordImporter +import com.duckduckgo.autofill.impl.importing.CsvPasswordImporter.ParseResult +import com.duckduckgo.autofill.impl.importing.PasswordImporter +import com.duckduckgo.autofill.impl.importing.PasswordImporter.ImportResult.Finished +import com.duckduckgo.autofill.impl.importing.PasswordImporter.ImportResult.InProgress import com.duckduckgo.autofill.impl.reporting.AutofillSiteBreakageReportingDataStore import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository @@ -48,6 +57,7 @@ import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.google.android.material.snackbar.Snackbar import java.text.SimpleDateFormat import javax.inject.Inject import kotlinx.coroutines.flow.first @@ -75,6 +85,12 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { @Inject lateinit var autofillStore: InternalAutofillStore + @Inject + lateinit var passwordImporter: PasswordImporter + + @Inject + lateinit var browserNav: BrowserNav + @Inject lateinit var autofillPrefsStore: AutofillPrefsStore @@ -101,6 +117,50 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { @Inject lateinit var reportBreakageDataStore: AutofillSiteBreakageReportingDataStore + @Inject + lateinit var csvPasswordImporter: CsvPasswordImporter + + private val importCsvLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val data: Intent? = result.data + val fileUrl = data?.data + + logcat { "cdr onActivityResult for CSV file request. resultCode=${result.resultCode}. uri=$fileUrl" } + if (fileUrl != null) { + lifecycleScope.launch { + when (val parseResult = csvPasswordImporter.readCsv(fileUrl)) { + is ParseResult.Success -> { + val jobId = passwordImporter.importPasswords(parseResult.loginCredentialsToImport) + observePasswordInputUpdates(jobId) + } + is ParseResult.Error -> { + "Failed to import passwords due to an error".showSnackbar() + } + } + } + } + } + } + + private fun observePasswordInputUpdates(jobId: String) { + lifecycleScope.launch { + repeatOnLifecycle(STARTED) { + passwordImporter.getImportStatus(jobId).collect { + when (it) { + is InProgress -> { + logcat { "cdr import status: $it" } + } + + is Finished -> { + logcat { "cdr Imported ${it.savedCredentialIds.size} passwords" } + "Imported ${it.savedCredentialIds.size} passwords".showSnackbar() + } + } + } + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) @@ -168,6 +228,7 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { configureEngagementEventHandlers() configureReportBreakagesHandlers() configureDeclineCounterHandlers() + configureImportPasswordsEventHandlers() } private fun configureReportBreakagesHandlers() { @@ -179,6 +240,22 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } } + @SuppressLint("QueryPermissionsNeeded") + private fun configureImportPasswordsEventHandlers() { + binding.importPasswordsLaunchGooglePasswordWebpage.setClickListener { + val googlePasswordsUrl = "https://passwords.google.com/options?ep=1" + startActivity(browserNav.openInNewTab(this, googlePasswordsUrl)) + } + + binding.importPasswordsImportCsv.setClickListener { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + } + importCsvLauncher.launch(intent) + } + } + private fun configureEngagementEventHandlers() { binding.engagementClearEngagementHistoryButton.setOnClickListener { lifecycleScope.launch(dispatchers.io()) { @@ -443,6 +520,10 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } } + private fun String.showSnackbar(duration: Int = Snackbar.LENGTH_LONG) { + Snackbar.make(binding.root, this, duration).show() + } + private fun Context.daysInstalledOverrideOptions(): List> { return listOf( Pair(getString(R.string.autofillDevSettingsOverrideMaxInstalledOptionNever), -1), diff --git a/autofill/autofill-internal/src/main/res/layout/activity_autofill_internal_settings.xml b/autofill/autofill-internal/src/main/res/layout/activity_autofill_internal_settings.xml index 9018dec4ab59..1646042b7d70 100644 --- a/autofill/autofill-internal/src/main/res/layout/activity_autofill_internal_settings.xml +++ b/autofill/autofill-internal/src/main/res/layout/activity_autofill_internal_settings.xml @@ -85,6 +85,27 @@ android:layout_height="wrap_content" app:primaryText="@string/autofillDevSettingsViewSavedLogins" /> + + + + + + + + diff --git a/autofill/autofill-internal/src/main/res/values/donottranslate.xml b/autofill/autofill-internal/src/main/res/values/donottranslate.xml index 247348c0325d..fc2841729d65 100644 --- a/autofill/autofill-internal/src/main/res/values/donottranslate.xml +++ b/autofill/autofill-internal/src/main/res/values/donottranslate.xml @@ -37,6 +37,10 @@ Number of sites: %1$d Add sample site (fill.dev) + Import Passwords + Launch Google Passwords (normal tab) + Import CSV + Maximum number of days since install OK Cancel