Skip to content

Commit

Permalink
Add developer settings for importing passwords from Google Password M…
Browse files Browse the repository at this point in the history
…anager
  • Loading branch information
CDRussell committed Oct 22, 2024
1 parent 0f14463 commit f7b551c
Show file tree
Hide file tree
Showing 4 changed files with 229 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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<LoginCredentials>): String
fun getImportStatus(jobId: String): Flow<ImportResult>

sealed interface ImportResult : Parcelable {

@Parcelize
data class InProgress(
val savedCredentialIds: List<Long>,
val duplicatedPasswords: List<LoginCredentials>,
val importListSize: Int,
val jobId: String,
) : ImportResult

@Parcelize
data class Finished(
val savedCredentialIds: List<Long>,
val duplicatedPasswords: List<LoginCredentials>,
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<ImportResult>(replay = 1)
private val mutex = Mutex()

override suspend fun importPasswords(importList: List<LoginCredentials>): String {
val jobId = UUID.randomUUID().toString()

mutex.withLock {
appCoroutineScope.launch(dispatchers.io()) {
doImportPasswords(importList, jobId)
}
}

return jobId
}

private suspend fun doImportPasswords(
importList: List<LoginCredentials>,
jobId: String,
) {
val savedCredentialIds = mutableListOf<Long>()
val duplicatedPasswords = mutableListOf<LoginCredentials>()

_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<ImportResult> {
return _importStatus.filter { result ->
when (result) {
is InProgress -> result.jobId == jobId
is Finished -> result.jobId == jobId
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -168,6 +228,7 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() {
configureEngagementEventHandlers()
configureReportBreakagesHandlers()
configureDeclineCounterHandlers()
configureImportPasswordsEventHandlers()
}

private fun configureReportBreakagesHandlers() {
Expand All @@ -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()) {
Expand Down Expand Up @@ -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<Pair<String, Int>> {
return listOf(
Pair(getString(R.string.autofillDevSettingsOverrideMaxInstalledOptionNever), -1),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,27 @@
android:layout_height="wrap_content"
app:primaryText="@string/autofillDevSettingsViewSavedLogins" />

<com.duckduckgo.common.ui.view.listitem.SectionHeaderListItem
android:id="@+id/importPasswordsSectionTitle"
android:layout_width="match_parent"
android:layout_marginTop="20dp"
android:layout_height="wrap_content"
app:primaryText="@string/autofillDevSettingsImportPasswordsTitle" />


<com.duckduckgo.common.ui.view.listitem.OneLineListItem
android:id="@+id/importPasswordsLaunchGooglePasswordWebpage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:primaryText="@string/autofillDevSettingsImportPasswordsExportPasswordsOurAppTitle" />

<com.duckduckgo.common.ui.view.listitem.OneLineListItem
android:id="@+id/importPasswordsImportCsv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:primaryText="@string/autofillDevSettingsImportPasswordsImportPasswordsCsvTitle" />


<com.duckduckgo.common.ui.view.divider.HorizontalDivider
android:layout_width="match_parent"
android:layout_height="wrap_content" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
<string name="autofillDevSettingsNeverSavedSitesCountSubtitle" instruction="%1$d is count of how many websites have been added to a list">Number of sites: %1$d</string>
<string name="autofillDevSettingsNeverSavedSitesAddSampleButton">Add sample site (fill.dev)</string>

<string name="autofillDevSettingsImportPasswordsTitle">Import Passwords</string>
<string name="autofillDevSettingsImportPasswordsExportPasswordsOurAppTitle">Launch Google Passwords (normal tab)</string>
<string name="autofillDevSettingsImportPasswordsImportPasswordsCsvTitle">Import CSV</string>

<string name="autofillDevSettingsOverrideMaxInstallDialogTitle">Maximum number of days since install</string>
<string name="autofillDevSettingsOverrideMaxInstallDialogOkButtonText">OK</string>
<string name="autofillDevSettingsOverrideMaxInstallDialogCancelButtonText">Cancel</string>
Expand Down

0 comments on commit f7b551c

Please sign in to comment.