diff --git a/app/build.gradle b/app/build.gradle index ad158d418de5..2593ff00cf20 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -150,6 +150,10 @@ fladle { } dependencies { + implementation project(path: ':autofill-api') + implementation project(path: ':autofill-impl') + implementation project(path: ':autofill-store') + anvil project(path: ':anvil-compiler') implementation project(path: ':anvil-annotations') @@ -187,6 +191,13 @@ dependencies { implementation project(path: ':bandwidth-impl') + implementation project(path: ':secure-storage-api') + implementation project(path: ':secure-storage-impl') + implementation project(path: ':secure-storage-store') + + implementation project(path: ':device-auth-api') + implementation project(path: ':device-auth-impl') + // Deprecated. TODO: Stop using this artifact. implementation "androidx.legacy:legacy-support-v4:_" debugImplementation Square.leakCanary.android diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index 99a8b3f15844..bbc6573f34e7 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -118,6 +118,7 @@ import com.duckduckgo.app.trackerdetection.EntityLookup import com.duckduckgo.app.trackerdetection.model.TrackingEvent import com.duckduckgo.app.usage.search.SearchCountDao import com.duckduckgo.app.widget.ui.WidgetCapabilities +import com.duckduckgo.autofill.store.AutofillStore import com.duckduckgo.downloads.api.DownloadCallback import com.duckduckgo.downloads.api.FileDownloader import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload @@ -363,6 +364,8 @@ class BrowserTabViewModelTest { private val favoriteListFlow = Channel>() + private val mockAutofillStore: AutofillStore = mock() + @Before fun before() { MockitoAnnotations.openMocks(this) @@ -459,7 +462,8 @@ class BrowserTabViewModelTest { trackingParameters = mockTrackingParameters, voiceSearchAvailability = voiceSearchAvailability, voiceSearchPixelLogger = voiceSearchPixelLogger, - settingsDataStore = mockSettingsDataStore + settingsDataStore = mockSettingsDataStore, + autofillStore = mockAutofillStore ) testee.loadData("abc", null, false, false) diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt index fcce4ab8d57b..1582c69c111f 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt @@ -39,10 +39,11 @@ import com.duckduckgo.app.browser.logindetection.DOMLoginDetector import com.duckduckgo.app.browser.logindetection.WebNavigationEvent import com.duckduckgo.app.browser.model.BasicAuthenticationRequest import com.duckduckgo.app.browser.print.PrintInjector -import com.duckduckgo.app.email.EmailInjector import com.duckduckgo.app.global.exception.UncaughtExceptionRepository import com.duckduckgo.app.global.exception.UncaughtExceptionSource import com.duckduckgo.app.statistics.store.OfflinePixelCountDataStore +import com.duckduckgo.autofill.BrowserAutofill +import com.duckduckgo.autofill.InternalTestUserChecker import com.duckduckgo.privacy.config.api.Gpc import com.duckduckgo.privacy.config.api.AmpLinks import org.mockito.kotlin.any @@ -86,10 +87,11 @@ class BrowserWebViewClientTest { private val trustedCertificateStore: TrustedCertificateStore = mock() private val webViewHttpAuthStore: WebViewHttpAuthStore = mock() private val thirdPartyCookieManager: ThirdPartyCookieManager = mock() - private val emailInjector: EmailInjector = mock() + private val browserAutofill: BrowserAutofill = mock() private val webResourceRequest: WebResourceRequest = mock() private val ampLinks: AmpLinks = mock() private val printInjector: PrintInjector = mock() + private val internalTestUserChecker: InternalTestUserChecker = mock() @UiThreadTest @Before @@ -110,10 +112,11 @@ class BrowserWebViewClientTest { thirdPartyCookieManager, TestScope(), coroutinesTestRule.testDispatcherProvider, - emailInjector, + browserAutofill, accessibilitySettings, ampLinks, - printInjector + printInjector, + internalTestUserChecker ) testee.webViewClientListener = listener whenever(webResourceRequest.url).thenReturn(Uri.EMPTY) @@ -289,7 +292,7 @@ class BrowserWebViewClientTest { @Test fun whenOnPageStartedCalledThenInjectEmailAutofillJsCalled() { testee.onPageStarted(webView, null, null) - verify(emailInjector).injectEmailAutofillJs(webView, null) + verify(browserAutofill).configureAutofillForCurrentPage(webView, null) } @Test @@ -571,6 +574,22 @@ class BrowserWebViewClientTest { assertFalse(testee.shouldOverrideUrlLoading(mockWebView, webResourceRequest)) } + @Test + fun whenOnPageFinishedThenCallVerifyVerificationCompleted() { + testee.onPageFinished(webView, EXAMPLE_URL) + + verify(internalTestUserChecker).verifyVerificationCompleted(EXAMPLE_URL) + } + + @Test + fun whenOnReceivedHttpErrorThenCallVerifyVerificationErrorReceived() { + val mockWebView = getImmediatelyInvokedMockWebView() + whenever(mockWebView.url).thenReturn(EXAMPLE_URL) + testee.onReceivedHttpError(mockWebView, null, null) + + verify(internalTestUserChecker).verifyVerificationErrorReceived(EXAMPLE_URL) + } + private fun getImmediatelyInvokedMockWebView(): WebView { val mockWebView = mock() whenever(mockWebView.originalUrl).thenReturn(EXAMPLE_URL) diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/favicon/DuckDuckGoFaviconManagerTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/favicon/DuckDuckGoFaviconManagerTest.kt index 8ba4326e0e49..a9c75fc80d72 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/favicon/DuckDuckGoFaviconManagerTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/favicon/DuckDuckGoFaviconManagerTest.kt @@ -35,6 +35,7 @@ import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository import com.duckduckgo.app.global.faviconLocation import com.duckduckgo.app.location.data.LocationPermissionsDao import com.duckduckgo.app.location.data.LocationPermissionsRepository +import com.duckduckgo.autofill.store.AutofillStore import org.mockito.kotlin.* import kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.Assert.assertNull @@ -59,6 +60,7 @@ class DuckDuckGoFaviconManagerTest { private val mockFireproofWebsiteDao: FireproofWebsiteDao = mock() private val mockLocationPermissionsDao: LocationPermissionsDao = mock() private val mockFaviconDownloader: FaviconDownloader = mock() + private val mockAutofillStore: AutofillStore = mock() private val mockFile: File = File("test") private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext @@ -67,6 +69,7 @@ class DuckDuckGoFaviconManagerTest { @Before fun setup() { whenever(mockFavoriteRepository.favoritesCountByDomain(any())).thenReturn(0) + mockAutofillStore.stub { onBlocking { getCredentials(any()) }.thenReturn(emptyList()) } testee = DuckDuckGoFaviconManager( faviconPersister = mockFaviconPersister, @@ -75,7 +78,8 @@ class DuckDuckGoFaviconManagerTest { locationPermissionsRepository = LocationPermissionsRepository(mockLocationPermissionsDao, mock(), coroutineRule.testDispatcherProvider), favoritesRepository = mockFavoriteRepository, faviconDownloader = mockFaviconDownloader, - dispatcherProvider = coroutineRule.testDispatcherProvider + dispatcherProvider = coroutineRule.testDispatcherProvider, + autofillStore = mockAutofillStore ) } diff --git a/app/src/androidTest/java/com/duckduckgo/app/email/EmailInjectorJsTest.kt b/app/src/androidTest/java/com/duckduckgo/app/email/EmailInjectorJsTest.kt index 5c38af2baa4e..8bafd38a27ac 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/email/EmailInjectorJsTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/email/EmailInjectorJsTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020 DuckDuckGo + * Copyright (c) 2022 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,8 @@ import android.webkit.WebView import androidx.test.annotation.UiThreadTest import androidx.test.filters.SdkSuppress import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.app.autofill.FileBasedJavascriptInjector +import com.duckduckgo.app.autofill.JavascriptInjector import com.duckduckgo.app.browser.DuckDuckGoUrlDetector import com.duckduckgo.app.browser.R import com.duckduckgo.app.global.DispatcherProvider @@ -37,96 +39,18 @@ class EmailInjectorJsTest { private val mockDispatcherProvider: DispatcherProvider = mock() private val mockFeatureToggle: FeatureToggle = mock() private val mockAutofill: Autofill = mock() + private val javascriptInjector: JavascriptInjector = FileBasedJavascriptInjector() lateinit var testee: EmailInjectorJs @Before fun setup() { - testee = EmailInjectorJs(mockEmailManager, DuckDuckGoUrlDetector(), mockDispatcherProvider, mockFeatureToggle, mockAutofill) + testee = + EmailInjectorJs(mockEmailManager, DuckDuckGoUrlDetector(), mockDispatcherProvider, mockFeatureToggle, javascriptInjector, mockAutofill) whenever(mockFeatureToggle.isFeatureEnabled(AutofillFeatureName)).thenReturn(true) whenever(mockAutofill.isAnException(any())).thenReturn(false) } - @UiThreadTest - @Test - @SdkSuppress(minSdkVersion = 24) - fun whenInjectEmailAutofillJsAndUrlIsFromDuckDuckGoDomainThenInjectJsCode() { - val jsToEvaluate = getJsToEvaluate() - val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) - - testee.injectEmailAutofillJs(webView, "https://duckduckgo.com/email") - - verify(webView).evaluateJavascript(jsToEvaluate, null) - } - - @UiThreadTest - @Test - @SdkSuppress(minSdkVersion = 24) - fun whenInjectEmailAutofillJsAndUrlIsFromDuckDuckGoSubdomainThenDoNotInjectJsCode() { - val jsToEvaluate = getJsToEvaluate() - val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) - - testee.injectEmailAutofillJs(webView, "https://test.duckduckgo.com/email") - - verify(webView, never()).evaluateJavascript(jsToEvaluate, null) - } - - @UiThreadTest - @Test - @SdkSuppress(minSdkVersion = 24) - fun whenInjectEmailAutofillJsAndUrlIsNotFromDuckDuckGoAndEmailIsSignedInThenInjectJsCode() { - whenever(mockEmailManager.isSignedIn()).thenReturn(true) - val jsToEvaluate = getJsToEvaluate() - val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) - - testee.injectEmailAutofillJs(webView, "https://example.com") - - verify(webView).evaluateJavascript(jsToEvaluate, null) - } - - @UiThreadTest - @Test - @SdkSuppress(minSdkVersion = 24) - fun whenInjectEmailAutofillJsAndUrlIsNotFromDuckDuckGoAndEmailIsNotSignedInThenDoNotInjectJsCode() { - whenever(mockEmailManager.isSignedIn()).thenReturn(false) - val jsToEvaluate = getJsToEvaluate() - val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) - - testee.injectEmailAutofillJs(webView, "https://example.com") - - verify(webView, never()).evaluateJavascript(jsToEvaluate, null) - } - - @UiThreadTest - @Test - @SdkSuppress(minSdkVersion = 24) - fun whenInjectEmailAutofillJsAndUrlIsFromDuckDuckGoAndFeatureIsDisabledThenInjectJsCode() { - whenever(mockEmailManager.isSignedIn()).thenReturn(true) - whenever(mockFeatureToggle.isFeatureEnabled(AutofillFeatureName)).thenReturn(false) - - val jsToEvaluate = getJsToEvaluate() - val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) - - testee.injectEmailAutofillJs(webView, "https://duckduckgo.com/email") - - verify(webView).evaluateJavascript(jsToEvaluate, null) - } - - @UiThreadTest - @Test - @SdkSuppress(minSdkVersion = 24) - fun whenInjectEmailAutofillJsAndUrlIsFromDuckDuckGoAndUrlIsInExceptionsThenInjectJsCode() { - whenever(mockEmailManager.isSignedIn()).thenReturn(true) - whenever(mockAutofill.isAnException(any())).thenReturn(true) - - val jsToEvaluate = getJsToEvaluate() - val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) - - testee.injectEmailAutofillJs(webView, "https://duckduckgo.com/email") - - verify(webView).evaluateJavascript(jsToEvaluate, null) - } - @UiThreadTest @Test @SdkSuppress(minSdkVersion = 24) @@ -224,26 +148,6 @@ class EmailInjectorJsTest { verify(webView).evaluateJavascript(jsToEvaluate, null) } - @UiThreadTest - @Test - @SdkSuppress(minSdkVersion = 24) - fun whenNotifyWebAppSignEventAndUrlIsFromDuckDuckGoAndFeatureIsEnabledAndEmailIsSignedInThenDoNotEvaluateJsCode() { - whenever(mockEmailManager.isSignedIn()).thenReturn(true) - whenever(mockFeatureToggle.isFeatureEnabled(AutofillFeatureName)).thenReturn(true) - - val jsToEvaluate = getNotifySignOutJsToEvaluate() - val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) - - testee.notifyWebAppSignEvent(webView, "https://duckduckgo.com/email") - - verify(webView, never()).evaluateJavascript(jsToEvaluate, null) - } - - private fun getJsToEvaluate(): String { - val js = readResource().use { it?.readText() }.orEmpty() - return "javascript:$js" - } - private fun getAliasJsToEvaluate(): String { val js = InstrumentationRegistry.getInstrumentation().targetContext.resources.openRawResource(R.raw.inject_alias) .bufferedReader() @@ -252,13 +156,14 @@ class EmailInjectorJsTest { } private fun getNotifySignOutJsToEvaluate(): String { - val js = InstrumentationRegistry.getInstrumentation().targetContext.resources.openRawResource(R.raw.signout_autofill) - .bufferedReader() - .use { it.readText() } + val js = + InstrumentationRegistry.getInstrumentation().targetContext.resources.openRawResource(R.raw.signout_autofill) + .bufferedReader() + .use { it.readText() } return "javascript:$js" } - private fun readResource(): BufferedReader? { - return javaClass.classLoader?.getResource("autofill.js")?.openStream()?.bufferedReader() + private fun readResource(resourceName: String): BufferedReader? { + return javaClass.classLoader?.getResource(resourceName)?.openStream()?.bufferedReader() } } diff --git a/app/src/androidTest/java/com/duckduckgo/app/email/ui/EmailProtectionSignInViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/email/ui/EmailProtectionSignInViewModelTest.kt index e09a4dc3ae7e..a16ff935ab85 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/email/ui/EmailProtectionSignInViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/email/ui/EmailProtectionSignInViewModelTest.kt @@ -25,8 +25,8 @@ import androidx.work.impl.utils.SynchronousExecutor import androidx.work.testing.WorkManagerTestInitHelper import app.cash.turbine.test import com.duckduckgo.app.CoroutineTestRule -import com.duckduckgo.app.email.AppEmailManager.WaitlistState.JoinedQueue -import com.duckduckgo.app.email.AppEmailManager.WaitlistState.NotJoinedQueue +import com.duckduckgo.app.email.EmailManager.WaitlistState.JoinedQueue +import com.duckduckgo.app.email.EmailManager.WaitlistState.NotJoinedQueue import com.duckduckgo.app.email.EmailManager import com.duckduckgo.app.email.api.EmailAlias import com.duckduckgo.app.email.api.EmailInviteCodeResponse diff --git a/app/src/androidTest/java/com/duckduckgo/app/email/waitlist/AppEmailWaitlistCodeFetcherTest.kt b/app/src/androidTest/java/com/duckduckgo/app/email/waitlist/AppEmailWaitlistCodeFetcherTest.kt index 59b6f380ec8d..f5528fc1a30e 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/email/waitlist/AppEmailWaitlistCodeFetcherTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/email/waitlist/AppEmailWaitlistCodeFetcherTest.kt @@ -25,8 +25,6 @@ import androidx.work.WorkManager import androidx.work.impl.utils.SynchronousExecutor import androidx.work.testing.WorkManagerTestInitHelper import com.duckduckgo.app.CoroutineTestRule -import kotlinx.coroutines.test.runTest -import com.duckduckgo.app.email.AppEmailManager import com.duckduckgo.app.email.EmailManager import com.duckduckgo.app.job.TestWorker import com.duckduckgo.app.notification.NotificationSender @@ -34,17 +32,18 @@ import com.duckduckgo.app.notification.model.SchedulableNotification import com.duckduckgo.app.waitlist.email.AppEmailWaitlistCodeFetcher import com.duckduckgo.app.waitlist.email.EmailWaitlistCodeFetcher import com.duckduckgo.app.waitlist.email.EmailWaitlistWorkRequestBuilder -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import java.util.concurrent.TimeUnit @ExperimentalCoroutinesApi @@ -117,7 +116,7 @@ class AppEmailWaitlistCodeFetcherTest { @Test fun whenExecuteWaitlistCodeFetcherIfUserInNotInQueueThenDoNothing() = runTest { - whenever(mockEmailManager.waitlistState()).thenReturn(AppEmailManager.WaitlistState.NotJoinedQueue) + whenever(mockEmailManager.waitlistState()).thenReturn(EmailManager.WaitlistState.NotJoinedQueue) (testee as AppEmailWaitlistCodeFetcher).executeWaitlistCodeFetcher() @@ -126,7 +125,7 @@ class AppEmailWaitlistCodeFetcherTest { @Test fun whenExecuteWaitlistCodeFetcherIfUserIsInBetaThenDoNothing() = runTest { - whenever(mockEmailManager.waitlistState()).thenReturn(AppEmailManager.WaitlistState.InBeta) + whenever(mockEmailManager.waitlistState()).thenReturn(EmailManager.WaitlistState.InBeta) (testee as AppEmailWaitlistCodeFetcher).executeWaitlistCodeFetcher() @@ -134,18 +133,18 @@ class AppEmailWaitlistCodeFetcherTest { } private fun givenUserIsInTheQueueAndCodeAlreadyExists() = runTest { - whenever(mockEmailManager.waitlistState()).thenReturn(AppEmailManager.WaitlistState.JoinedQueue()) - whenever(mockEmailManager.fetchInviteCode()).thenReturn(AppEmailManager.FetchCodeResult.CodeExisted) + whenever(mockEmailManager.waitlistState()).thenReturn(EmailManager.WaitlistState.JoinedQueue()) + whenever(mockEmailManager.fetchInviteCode()).thenReturn(EmailManager.FetchCodeResult.CodeExisted) } private fun givenUserIsInTheQueueAndCodeReturned() = runTest { - whenever(mockEmailManager.waitlistState()).thenReturn(AppEmailManager.WaitlistState.JoinedQueue()) - whenever(mockEmailManager.fetchInviteCode()).thenReturn(AppEmailManager.FetchCodeResult.Code) + whenever(mockEmailManager.waitlistState()).thenReturn(EmailManager.WaitlistState.JoinedQueue()) + whenever(mockEmailManager.fetchInviteCode()).thenReturn(EmailManager.FetchCodeResult.Code) } private fun givenUserIsInTheQueueAndNoCodeReturned() = runTest { - whenever(mockEmailManager.waitlistState()).thenReturn(AppEmailManager.WaitlistState.JoinedQueue()) - whenever(mockEmailManager.fetchInviteCode()).thenReturn(AppEmailManager.FetchCodeResult.NoCode) + whenever(mockEmailManager.waitlistState()).thenReturn(EmailManager.WaitlistState.JoinedQueue()) + whenever(mockEmailManager.fetchInviteCode()).thenReturn(EmailManager.FetchCodeResult.NoCode) } private fun initializeWorkManager() { diff --git a/app/src/main/java/com/duckduckgo/app/autofill/FileBasedJavascriptInjector.kt b/app/src/main/java/com/duckduckgo/app/autofill/FileBasedJavascriptInjector.kt new file mode 100644 index 000000000000..2adbbd2f9b0f --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/autofill/FileBasedJavascriptInjector.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022 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.app.autofill + +import android.content.Context +import com.duckduckgo.app.browser.R +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import java.io.BufferedReader +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class FileBasedJavascriptInjector @Inject constructor() : JavascriptInjector { + private lateinit var functions: String + private lateinit var aliasFunctions: String + private lateinit var signOutFunctions: String + + override fun getFunctionsJS(): String { + if (!this::functions.isInitialized) { + // this can be enabled to see more verbose output from the autofill JS; useful for debugging autofill-related issues + val debugMode = false + + functions = loadJs(if (debugMode) "autofill-debug.js" else "autofill.js") + } + return functions + } + + override fun getAliasFunctions( + context: Context, + alias: String? + ): String { + if (!this::aliasFunctions.isInitialized) { + aliasFunctions = context.resources.openRawResource(R.raw.inject_alias).bufferedReader().use { it.readText() } + } + return aliasFunctions.replace("%s", alias.orEmpty()) + } + + override fun getSignOutFunctions( + context: Context + ): String { + if (!this::signOutFunctions.isInitialized) { + signOutFunctions = context.resources.openRawResource(R.raw.signout_autofill).bufferedReader().use { it.readText() } + } + return signOutFunctions + } + + fun loadJs(resourceName: String): String = readResource(resourceName).use { it?.readText() }.orEmpty() + + private fun readResource(resourceName: String): BufferedReader? { + return javaClass.classLoader?.getResource(resourceName)?.openStream()?.bufferedReader() + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index 3acc38ffa579..68467976762e 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -139,6 +139,11 @@ import kotlinx.android.synthetic.main.include_omnibar_toolbar.* import kotlinx.android.synthetic.main.include_omnibar_toolbar.view.* import kotlinx.android.synthetic.main.include_quick_access_items.* import kotlinx.android.synthetic.main.popup_window_browser_menu.view.* +import com.duckduckgo.autofill.* +import com.duckduckgo.autofill.CredentialAutofillPickerDialog.Companion.RESULT_KEY_CREDENTIAL_PICKER +import com.duckduckgo.autofill.CredentialSavePickerDialog.Companion.RESULT_KEY_CREDENTIAL_RESULT_SAVE +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.setFragmentResultListener import kotlinx.coroutines.* import timber.log.Timber import java.io.File @@ -169,6 +174,7 @@ import com.duckduckgo.app.browser.BrowserTabViewModel.LoadingViewState import com.duckduckgo.app.browser.BrowserTabViewModel.OmnibarViewState import com.duckduckgo.app.browser.BrowserTabViewModel.PrivacyGradeViewState import com.duckduckgo.app.browser.BrowserTabViewModel.SavedSiteChangedViewState +import com.duckduckgo.app.browser.autofill.AutofillCredentialsSelectionResultHandler import com.duckduckgo.app.browser.history.NavigationHistorySheet import com.duckduckgo.app.browser.history.NavigationHistorySheet.NavigationHistorySheetListener import com.duckduckgo.app.downloads.DownloadsFileActions @@ -181,6 +187,13 @@ import com.duckduckgo.app.playstore.PlayStoreUtils import com.duckduckgo.app.utils.ConflatedJob import com.duckduckgo.app.widget.AddWidgetLauncher import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.autofill.BrowserAutofill +import com.duckduckgo.autofill.Callback +import com.duckduckgo.autofill.CredentialUpdateExistingCredentialsDialog.Companion.RESULT_KEY_CREDENTIAL_RESULT_UPDATE +import com.duckduckgo.autofill.domain.app.LoginCredentials +import com.duckduckgo.autofill.store.AutofillStore.ContainsCredentialsResult.* +import com.duckduckgo.autofill.ui.ExistingCredentialMatchDetector +import com.duckduckgo.deviceauth.api.DeviceAuthenticator import com.duckduckgo.di.scopes.FragmentScope import com.duckduckgo.voice.api.VoiceSearchLauncher import com.duckduckgo.voice.api.VoiceSearchLauncher.Source.BROWSER @@ -273,6 +286,9 @@ class BrowserTabFragment : @Inject lateinit var emailInjector: EmailInjector + @Inject + lateinit var browserAutofill: BrowserAutofill + @Inject lateinit var faviconManager: FaviconManager @@ -316,6 +332,18 @@ class BrowserTabFragment : @Inject lateinit var printInjector: PrintInjector + @Inject + lateinit var deviceAuthenticator: DeviceAuthenticator + + @Inject + lateinit var credentialAutofillDialogFactory: CredentialAutofillDialogFactory + + @Inject + lateinit var autofillCredentialsSelectionResultHandler: AutofillCredentialsSelectionResultHandler + + @Inject + lateinit var existingCredentialMatchDetector: ExistingCredentialMatchDetector + private var urlExtractingWebView: UrlExtractingWebView? = null var messageFromPreviousTab: Message? = null @@ -392,6 +420,40 @@ class BrowserTabFragment : } } + private val autofillCallback = object : Callback { + override fun onCredentialsAvailableToInject(credentials: List) { + showAutofillDialogChooseCredentials(credentials) + } + + override fun noCredentialsAvailable(originalUrl: String) { + viewModel.returnNoCredentialsWithPage(originalUrl) + } + + override fun onCredentialsAvailableToSave(currentUrl: String, credentials: LoginCredentials) { + launch { + val username = credentials.username + val password = credentials.password + + if (username == null) { + Timber.w("Not saving credentials with null username") + return@launch + } + + if (password == null) { + Timber.w("Not saving credentials with null password") + return@launch + } + + when (existingCredentialMatchDetector.determine(currentUrl, username, password)) { + ExactMatch -> Timber.w("Credentials already exist for %s", currentUrl) + UsernameMatch -> showAutofillDialogUpdateCredentials(currentUrl, credentials) + NoMatch -> showAutofillDialogSaveCredentials(currentUrl, credentials) + UrlOnlyMatch -> showAutofillDialogSaveCredentials(currentUrl, credentials) + } + } + } + } + private val homeBackgroundLogo by lazy { HomeBackgroundLogo(ddgLogo) } private val ctaViewStateObserver = Observer { @@ -871,6 +933,8 @@ class BrowserTabFragment : is Command.CopyAliasToClipboard -> copyAliasToClipboard(it.alias) is Command.InjectEmailAddress -> injectEmailAddress(it.address) is Command.ShowEmailTooltip -> showEmailTooltip(it.address) + is Command.InjectCredentials -> injectAutofillCredentials(it.url, it.credentials) + is Command.CancelIncomingAutofillRequest -> injectAutofillCredentials(it.url, null) is Command.EditWithSelectedQuery -> { omnibarTextInput.setText(it.query) omnibarTextInput.setSelection(it.query.length) @@ -1505,6 +1569,7 @@ class BrowserTabFragment : loginDetector.addLoginDetection(it) { viewModel.loginDetected() } blobConverterInjector.addJsInterface(it) { url, mimeType -> viewModel.requestFileDownload(url, null, mimeType, true) } emailInjector.addJsInterface(it) { viewModel.showEmailTooltip() } + configureWebViewForAutofill(it) printInjector.addJsInterface(it) { viewModel.printFromWebView() } } @@ -1513,6 +1578,65 @@ class BrowserTabFragment : } } + private fun configureWebViewForAutofill(it: DuckDuckGoWebView) { + if (deviceAuthenticator.hasValidDeviceAuthentication()) { + browserAutofill.addJsInterface(it, autofillCallback) + + setFragmentResultListener(RESULT_KEY_CREDENTIAL_PICKER) { _, result -> + autofillCredentialsSelectionResultHandler.processAutofillCredentialSelectionResult(result, this, viewModel) + } + + setFragmentResultListener(RESULT_KEY_CREDENTIAL_RESULT_SAVE) { _, result -> + autofillCredentialsSelectionResultHandler.processSaveCredentialsResult(result, viewModel) + } + + setFragmentResultListener(RESULT_KEY_CREDENTIAL_RESULT_UPDATE) { _, result -> + autofillCredentialsSelectionResultHandler.processUpdateCredentialsResult(result, viewModel) + } + } + } + + private fun injectAutofillCredentials(url: String, credentials: LoginCredentials?) { + webView?.let { + if (it.url != url) { + Timber.w("WebView url has changed since autofill request; bailing") + return + } + browserAutofill.injectCredentials(credentials) + } + } + + private fun showAutofillDialogChooseCredentials(credentials: List) { + Timber.v("onCredentialsAvailable. %d creds to choose from", credentials.size) + val url = webView?.url ?: return + val dialog = credentialAutofillDialogFactory.autofillSelectCredentialsDialog(url, credentials) + showDialogHidingPrevious(dialog.asDialogFragment(), CredentialAutofillPickerDialog.TAG) + } + + private fun showAutofillDialogSaveCredentials(currentUrl: String, credentials: LoginCredentials) { + val url = webView?.url ?: return + if (url != currentUrl) return + + val dialog = credentialAutofillDialogFactory.autofillSavingCredentialsDialog(url, credentials) + showDialogHidingPrevious(dialog.asDialogFragment(), CredentialSavePickerDialog.TAG) + } + + private fun showAutofillDialogUpdateCredentials(currentUrl: String, credentials: LoginCredentials) { + val url = webView?.url ?: return + if (url != currentUrl) return + + val dialog = credentialAutofillDialogFactory.autofillSavingUpdateCredentialsDialog(url, credentials) + showDialogHidingPrevious(dialog.asDialogFragment(), CredentialUpdateExistingCredentialsDialog.TAG) + } + + private fun showDialogHidingPrevious(dialog: DialogFragment, tag: String) { + childFragmentManager.findFragmentByTag(tag)?.let { + Timber.i("Found existing dialog for %s; removing it now", tag) + childFragmentManager.commitNow(allowStateLoss = true) { remove(it) } + } + dialog.show(childFragmentManager, tag) + } + private fun configureDarkThemeSupport(webSettings: WebSettings) { when (themingDataStore.theme) { DuckDuckGoTheme.LIGHT -> webSettings.enableLightMode() @@ -1839,6 +1963,7 @@ class BrowserTabFragment : loginDetectionDialog?.dismiss() automaticFireproofDialog?.dismiss() emailAutofillTooltipDialog?.dismiss() + browserAutofill.removeJsInterface() destroyWebView() super.onDestroy() } @@ -1910,11 +2035,7 @@ class BrowserTabFragment : if (isStateSaved) return val downloadConfirmationFragment = DownloadConfirmationFragment.instance(pendingDownload) - childFragmentManager.findFragmentByTag(DOWNLOAD_CONFIRMATION_TAG)?.let { - Timber.i("Found existing dialog; removing it now") - childFragmentManager.commitNow(allowStateLoss = true) { remove(it) } - } - downloadConfirmationFragment.show(childFragmentManager, DOWNLOAD_CONFIRMATION_TAG) + showDialogHidingPrevious(downloadConfirmationFragment, DOWNLOAD_CONFIRMATION_TAG) } private fun launchFilePicker(command: Command.ShowFileChooser) { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index 32fe43b1f9be..16f4332489b4 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -54,6 +54,7 @@ import com.duckduckgo.app.browser.SpecialUrlDetector.UrlType.AppLink import com.duckduckgo.app.browser.SpecialUrlDetector.UrlType.NonHttpAppLink import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector import com.duckduckgo.app.browser.applinks.AppLinksHandler +import com.duckduckgo.app.browser.autofill.AutofillCredentialsSelectionResultHandler import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.browser.favicon.FaviconSource.ImageFavicon import com.duckduckgo.app.browser.favicon.FaviconSource.UrlFavicon @@ -110,19 +111,17 @@ import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.app.trackerdetection.model.TrackingEvent import com.duckduckgo.app.usage.search.SearchCountDao +import com.duckduckgo.autofill.domain.app.LoginCredentials +import com.duckduckgo.autofill.store.AutofillStore import com.duckduckgo.di.scopes.FragmentScope -import com.duckduckgo.voice.api.VoiceSearchAvailability -import com.duckduckgo.voice.api.VoiceSearchAvailabilityPixelLogger import com.duckduckgo.downloads.api.DownloadCallback import com.duckduckgo.downloads.api.DownloadCommand import com.duckduckgo.downloads.api.FileDownloader import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload -import com.duckduckgo.privacy.config.api.ContentBlocking -import com.duckduckgo.privacy.config.api.Gpc -import com.duckduckgo.privacy.config.api.AmpLinks -import com.duckduckgo.privacy.config.api.AmpLinkInfo +import com.duckduckgo.privacy.config.api.* import com.duckduckgo.remote.messaging.api.RemoteMessage -import com.duckduckgo.privacy.config.api.TrackingParameters +import com.duckduckgo.voice.api.VoiceSearchAvailability +import com.duckduckgo.voice.api.VoiceSearchAvailabilityPixelLogger import com.jakewharton.rxrelay2.PublishRelay import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -175,13 +174,16 @@ class BrowserTabViewModel @Inject constructor( private val downloadCallback: DownloadCallback, private val voiceSearchAvailability: VoiceSearchAvailability, private val voiceSearchPixelLogger: VoiceSearchAvailabilityPixelLogger, - private val settingsDataStore: SettingsDataStore + private val settingsDataStore: SettingsDataStore, + private val autofillStore: AutofillStore, ) : WebViewClientListener, EditSavedSiteListener, HttpAuthenticationListener, SiteLocationPermissionDialog.SiteLocationPermissionDialogListener, SystemLocationPermissionDialog.SystemLocationPermissionDialogListener, UrlExtractionListener, + AutofillCredentialsSelectionResultHandler.AutofillCredentialSaver, + AutofillCredentialsSelectionResultHandler.CredentialInjector, ViewModel(), NavigationHistoryListener { @@ -429,7 +431,8 @@ class BrowserTabViewModel @Inject constructor( object FinishTrackerAnimation : DaxCommand() class HideDaxDialog(val cta: Cta) : DaxCommand() } - + class InjectCredentials(val url: String, val credentials: LoginCredentials) : Command() + class CancelIncomingAutofillRequest(val url: String) : Command() class EditWithSelectedQuery(val query: String) : Command() class ShowBackNavigationHistory(val history: List) : Command() class NavigateToHistory(val historyStackIndex: Int) : Command() @@ -2656,6 +2659,26 @@ class BrowserTabViewModel @Inject constructor( command.postValue(LoadExtractedUrl(extractedUrl = destinationUrl)) } + override fun shareCredentialsWithPage(originalUrl: String, credentials: LoginCredentials) { + command.postValue(InjectCredentials(originalUrl, credentials)) + } + + override fun returnNoCredentialsWithPage(originalUrl: String) { + command.postValue(CancelIncomingAutofillRequest(originalUrl)) + } + + override fun saveCredentials(url: String, credentials: LoginCredentials) { + viewModelScope.launch { + autofillStore.saveCredentials(url, credentials) + } + } + + override fun updateCredentials(url: String, credentials: LoginCredentials) { + viewModelScope.launch { + autofillStore.updateCredentials(url, credentials) + } + } + fun onConfigurationChanged() { browserViewState.value = currentBrowserViewState().copy( forceRenderingTicker = System.currentTimeMillis() diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt index 5e032f59b6e6..bae986adff95 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt @@ -37,11 +37,12 @@ import com.duckduckgo.app.browser.logindetection.WebNavigationEvent import com.duckduckgo.app.browser.model.BasicAuthenticationRequest import com.duckduckgo.app.browser.navigation.safeCopyBackForwardList import com.duckduckgo.app.browser.print.PrintInjector -import com.duckduckgo.app.email.EmailInjector import com.duckduckgo.app.global.DispatcherProvider import com.duckduckgo.app.global.exception.UncaughtExceptionRepository import com.duckduckgo.app.global.exception.UncaughtExceptionSource.* import com.duckduckgo.app.statistics.store.OfflinePixelCountDataStore +import com.duckduckgo.autofill.BrowserAutofill +import com.duckduckgo.autofill.InternalTestUserChecker import com.duckduckgo.privacy.config.api.Gpc import com.duckduckgo.privacy.config.api.AmpLinks import kotlinx.coroutines.* @@ -63,10 +64,11 @@ class BrowserWebViewClient( private val thirdPartyCookieManager: ThirdPartyCookieManager, private val appCoroutineScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, - private val emailInjector: EmailInjector, + private val browserAutofill: BrowserAutofill, private val accessibilityManager: AccessibilityManager, private val ampLinks: AmpLinks, - private val printInjector: PrintInjector + private val printInjector: PrintInjector, + private val internalTestUserChecker: InternalTestUserChecker ) : WebViewClient() { var webViewClientListener: WebViewClientListener? = null @@ -251,7 +253,7 @@ class BrowserWebViewClient( webViewClientListener?.pageRefreshed(url) } lastPageStarted = url - emailInjector.injectEmailAutofillJs(webView, url) // Needs to be injected onPageStarted + browserAutofill.configureAutofillForCurrentPage(webView, url) injectGpcToDom(webView, url) loginDetector.onEvent(WebNavigationEvent.OnPageStarted(webView)) } catch (e: Throwable) { @@ -269,6 +271,10 @@ class BrowserWebViewClient( ) { try { accessibilityManager.onPageFinished(webView, url) + url?.let { + // We call this for any url but it will only be processed for an internal tester verification url + internalTestUserChecker.verifyVerificationCompleted(it) + } Timber.v("onPageFinished webViewUrl: ${webView.url} URL: $url") val navigationList = webView.safeCopyBackForwardList() ?: return webViewClientListener?.run { @@ -412,4 +418,16 @@ class BrowserWebViewClient( it.requiresAuthentication(request) } } + + override fun onReceivedHttpError( + view: WebView?, + request: WebResourceRequest?, + errorResponse: WebResourceResponse? + ) { + super.onReceivedHttpError(view, request, errorResponse) + view?.url?.let { + // We call this for any url but it will only be processed for an internal tester verification url + internalTestUserChecker.verifyVerificationErrorReceived(it) + } + } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/autofill/AutofillCredentialsSelectionResultHandler.kt b/app/src/main/java/com/duckduckgo/app/browser/autofill/AutofillCredentialsSelectionResultHandler.kt new file mode 100644 index 000000000000..5fcfb7276117 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/autofill/AutofillCredentialsSelectionResultHandler.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2022 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.app.browser.autofill + +import android.os.Bundle +import androidx.fragment.app.Fragment +import com.duckduckgo.autofill.CredentialAutofillPickerDialog +import com.duckduckgo.autofill.CredentialSavePickerDialog +import com.duckduckgo.autofill.CredentialUpdateExistingCredentialsDialog +import com.duckduckgo.autofill.domain.app.LoginCredentials +import com.duckduckgo.deviceauth.api.DeviceAuthenticator +import com.duckduckgo.deviceauth.api.DeviceAuthenticator.AuthResult.Success +import com.duckduckgo.deviceauth.api.DeviceAuthenticator.Features.AUTOFILL +import timber.log.Timber +import javax.inject.Inject + +class AutofillCredentialsSelectionResultHandler @Inject constructor(private val deviceAuthenticator: DeviceAuthenticator) { + + fun processAutofillCredentialSelectionResult( + result: Bundle, + browserTabFragment: Fragment, + credentialInjector: CredentialInjector, + ) { + val originalUrl = result.getString(CredentialAutofillPickerDialog.KEY_URL) ?: return + val selectedCredentials = result.getParcelable(CredentialAutofillPickerDialog.KEY_CREDENTIALS) ?: return + + if (result.getBoolean(CredentialAutofillPickerDialog.KEY_CANCELLED)) { + Timber.v("Autofill: User cancelled credential selection") + credentialInjector.returnNoCredentialsWithPage(originalUrl) + return + } + + deviceAuthenticator.authenticate(AUTOFILL, browserTabFragment) { + val successfullyAuthenticated = it is Success + Timber.v("Autofill: user selected credential to use. Successfully authenticated: %s", successfullyAuthenticated) + + if (successfullyAuthenticated) { + credentialInjector.shareCredentialsWithPage(originalUrl, selectedCredentials) + } else { + credentialInjector.returnNoCredentialsWithPage(originalUrl) + } + } + } + + fun processSaveCredentialsResult(result: Bundle, credentialSaver: AutofillCredentialSaver) { + val selectedCredentials = result.getParcelable(CredentialSavePickerDialog.KEY_CREDENTIALS) ?: return + val originalUrl = result.getString(CredentialSavePickerDialog.KEY_URL) ?: return + credentialSaver.saveCredentials(originalUrl, selectedCredentials) + } + + fun processUpdateCredentialsResult(result: Bundle, credentialSaver: AutofillCredentialSaver) { + val selectedCredentials = result.getParcelable(CredentialUpdateExistingCredentialsDialog.KEY_CREDENTIALS) ?: return + val originalUrl = result.getString(CredentialUpdateExistingCredentialsDialog.KEY_URL) ?: return + credentialSaver.updateCredentials(originalUrl, selectedCredentials) + } + + interface CredentialInjector { + fun shareCredentialsWithPage(originalUrl: String, credentials: LoginCredentials) + fun returnNoCredentialsWithPage(originalUrl: String) + } + + interface AutofillCredentialSaver { + fun saveCredentials(url: String, credentials: LoginCredentials) + fun updateCredentials(url: String, credentials: LoginCredentials) + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt index 0495cfe0c6b7..6973655b3688 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt @@ -52,7 +52,6 @@ import com.duckduckgo.app.browser.urlextraction.JsUrlExtractor import com.duckduckgo.app.browser.urlextraction.UrlExtractingWebViewClient import com.duckduckgo.app.browser.useragent.UserAgentProvider import com.duckduckgo.app.di.AppCoroutineScope -import com.duckduckgo.app.email.EmailInjector import com.duckduckgo.app.fire.* import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository @@ -78,6 +77,8 @@ import com.duckduckgo.app.surrogates.ResourceSurrogates import com.duckduckgo.app.tabs.ui.GridViewColumnCalculator import com.duckduckgo.app.trackerdetection.TrackerDetector import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.autofill.BrowserAutofill +import com.duckduckgo.autofill.InternalTestUserChecker import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.downloads.api.FileDownloader import com.duckduckgo.downloads.impl.AndroidFileDownloader @@ -127,10 +128,11 @@ class BrowserModule { thirdPartyCookieManager: ThirdPartyCookieManager, @AppCoroutineScope appCoroutineScope: CoroutineScope, dispatcherProvider: DispatcherProvider, - emailInjector: EmailInjector, accessibilityManager: AccessibilityManager, + browserAutofill: BrowserAutofill, ampLinks: AmpLinks, - printInjector: PrintInjector + printInjector: PrintInjector, + internalTestUserChecker: InternalTestUserChecker ): BrowserWebViewClient { return BrowserWebViewClient( webViewHttpAuthStore, @@ -147,10 +149,11 @@ class BrowserModule { thirdPartyCookieManager, appCoroutineScope, dispatcherProvider, - emailInjector, + browserAutofill, accessibilityManager, ampLinks, - printInjector + printInjector, + internalTestUserChecker ) } diff --git a/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconManager.kt b/app/src/main/java/com/duckduckgo/app/browser/favicon/DuckDuckGoFaviconManager.kt similarity index 88% rename from app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconManager.kt rename to app/src/main/java/com/duckduckgo/app/browser/favicon/DuckDuckGoFaviconManager.kt index 9f94c7357967..af7309483968 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconManager.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/favicon/DuckDuckGoFaviconManager.kt @@ -32,65 +32,10 @@ import com.duckduckgo.app.global.faviconLocation import com.duckduckgo.app.global.touchFaviconLocation import com.duckduckgo.app.global.view.loadFavicon import com.duckduckgo.app.location.data.LocationPermissionsRepository +import com.duckduckgo.autofill.store.AutofillStore import kotlinx.coroutines.withContext import java.io.File -interface FaviconManager { - suspend fun storeFavicon( - tabId: String, - faviconSource: FaviconSource - ): File? - - suspend fun tryFetchFaviconForUrl( - tabId: String, - url: String - ): File? - - suspend fun persistCachedFavicon( - tabId: String, - url: String - ) - - suspend fun loadToViewFromLocalOrFallback( - tabId: String? = null, - url: String, - view: ImageView - ) - - suspend fun loadFromDisk( - tabId: String?, - url: String - ): Bitmap? - - suspend fun loadFromDiskWithParams( - tabId: String? = null, - url: String, - cornerRadius: Int, - width: Int, - height: Int - ): Bitmap? - - suspend fun deletePersistedFavicon(url: String) - suspend fun deleteOldTempFavicon( - tabId: String, - path: String? - ) - - suspend fun deleteAllTemp() -} - -sealed class FaviconSource { - data class ImageFavicon( - val icon: Bitmap, - val url: String - ) : FaviconSource() - - data class UrlFavicon( - val faviconUrl: String, - val url: String - ) : FaviconSource() -} - class DuckDuckGoFaviconManager constructor( private val faviconPersister: FaviconPersister, private val bookmarksDao: BookmarksDao, @@ -98,7 +43,8 @@ class DuckDuckGoFaviconManager constructor( private val locationPermissionsRepository: LocationPermissionsRepository, private val favoritesRepository: FavoritesRepository, private val faviconDownloader: FaviconDownloader, - private val dispatcherProvider: DispatcherProvider + private val dispatcherProvider: DispatcherProvider, + private val autofillStore: AutofillStore ) : FaviconManager { private val tempFaviconCache: HashMap>> = hashMapOf() @@ -285,11 +231,13 @@ class DuckDuckGoFaviconManager constructor( private suspend fun persistedFaviconsForDomain(domain: String): Int { val query = "%$domain%" + return withContext(dispatcherProvider.io()) { bookmarksDao.bookmarksCountByUrl(query) + locationPermissionsRepository.permissionEntitiesCountByDomain(query) + fireproofWebsiteRepository.fireproofWebsitesCountByDomain(domain) + - favoritesRepository.favoritesCountByDomain(query) + favoritesRepository.favoritesCountByDomain(query) + + autofillStore.getCredentials(domain).size } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconModule.kt b/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconModule.kt index 651f3587a4c0..5f4ff0debd32 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconModule.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconModule.kt @@ -22,6 +22,7 @@ import com.duckduckgo.app.bookmarks.model.FavoritesRepository import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository import com.duckduckgo.app.global.DispatcherProvider import com.duckduckgo.app.location.data.LocationPermissionsRepository +import com.duckduckgo.autofill.store.AutofillStore import com.duckduckgo.di.scopes.AppScope import dagger.Module import dagger.Provides @@ -39,7 +40,8 @@ class FaviconModule { locationPermissionsRepository: LocationPermissionsRepository, favoritesRepository: FavoritesRepository, faviconDownloader: FaviconDownloader, - dispatcherProvider: DispatcherProvider + dispatcherProvider: DispatcherProvider, + autofillStore: AutofillStore ): FaviconManager { return DuckDuckGoFaviconManager( faviconPersister, @@ -48,7 +50,8 @@ class FaviconModule { locationPermissionsRepository, favoritesRepository, faviconDownloader, - dispatcherProvider + dispatcherProvider, + autofillStore ) } diff --git a/app/src/main/java/com/duckduckgo/app/email/EmailManager.kt b/app/src/main/java/com/duckduckgo/app/email/AppEmailManager.kt similarity index 85% rename from app/src/main/java/com/duckduckgo/app/email/EmailManager.kt rename to app/src/main/java/com/duckduckgo/app/email/AppEmailManager.kt index f3439d50ecb8..c856544a1005 100644 --- a/app/src/main/java/com/duckduckgo/app/email/EmailManager.kt +++ b/app/src/main/java/com/duckduckgo/app/email/AppEmailManager.kt @@ -16,6 +16,8 @@ package com.duckduckgo.app.email +import com.duckduckgo.app.email.EmailManager.FetchCodeResult +import com.duckduckgo.app.email.EmailManager.WaitlistState import com.duckduckgo.app.email.api.EmailService import com.duckduckgo.app.email.db.EmailDataStore import com.duckduckgo.app.global.DispatcherProvider @@ -30,35 +32,6 @@ import timber.log.Timber import java.text.SimpleDateFormat import java.util.* -interface EmailManager { - fun signedInFlow(): StateFlow - fun getAlias(): String? - fun isSignedIn(): Boolean - fun storeCredentials( - token: String, - username: String, - cohort: String - ) - - fun signOut() - fun getEmailAddress(): String? - fun getUserData(): String - fun waitlistState(): AppEmailManager.WaitlistState - fun joinWaitlist( - timestamp: Int, - token: String - ) - - fun getInviteCode(): String - fun doesCodeAlreadyExist(): Boolean - suspend fun fetchInviteCode(): AppEmailManager.FetchCodeResult - fun notifyOnJoinedWaitlist() - fun getCohort(): String - fun isEmailFeatureSupported(): Boolean - fun getLastUsedDate(): String - fun setNewLastUsedDate() -} - class AppEmailManager( private val emailService: EmailService, private val emailDataStore: EmailDataStore, @@ -209,18 +182,6 @@ class AppEmailManager( } } - sealed class FetchCodeResult { - object Code : FetchCodeResult() - object NoCode : FetchCodeResult() - object CodeExisted : FetchCodeResult() - } - - sealed class WaitlistState { - object NotJoinedQueue : WaitlistState() - data class JoinedQueue(val notify: Boolean = false) : WaitlistState() - object InBeta : WaitlistState() - } - companion object { const val DUCK_EMAIL_DOMAIN = "@duck.com" const val UNKNOWN_COHORT = "unknown" diff --git a/app/src/main/java/com/duckduckgo/app/email/EmailInjector.kt b/app/src/main/java/com/duckduckgo/app/email/EmailInjector.kt index c06129253e87..c9fc5757669b 100644 --- a/app/src/main/java/com/duckduckgo/app/email/EmailInjector.kt +++ b/app/src/main/java/com/duckduckgo/app/email/EmailInjector.kt @@ -16,23 +16,17 @@ package com.duckduckgo.app.email -import android.content.Context import android.webkit.WebView import androidx.annotation.UiThread import com.duckduckgo.app.browser.DuckDuckGoUrlDetector -import com.duckduckgo.app.browser.R import com.duckduckgo.app.email.EmailJavascriptInterface.Companion.JAVASCRIPT_INTERFACE_NAME import com.duckduckgo.app.global.DispatcherProvider +import com.duckduckgo.app.autofill.JavascriptInjector import com.duckduckgo.feature.toggles.api.FeatureToggle import com.duckduckgo.privacy.config.api.Autofill import com.duckduckgo.privacy.config.api.PrivacyFeatureName -import java.io.BufferedReader interface EmailInjector { - fun injectEmailAutofillJs( - webView: WebView, - url: String? - ) fun addJsInterface( webView: WebView, @@ -56,9 +50,9 @@ class EmailInjectorJs( private val urlDetector: DuckDuckGoUrlDetector, private val dispatcherProvider: DispatcherProvider, private val featureToggle: FeatureToggle, + private val javaScriptInjector: JavascriptInjector, private val autofill: Autofill, ) : EmailInjector { - private val javaScriptInjector = JavaScriptInjector() override fun addJsInterface( webView: WebView, @@ -71,18 +65,6 @@ class EmailInjectorJs( ) } - @UiThread - override fun injectEmailAutofillJs( - webView: WebView, - url: String? - ) { - url?.let { - if (isDuckDuckGoUrl(url) || (isFeatureEnabled() && !autofill.isAnException(url) && emailManager.isSignedIn())) { - webView.evaluateJavascript("javascript:${javaScriptInjector.getFunctionsJS()}", null) - } - } - } - @UiThread override fun injectAddressInEmailField( webView: WebView, @@ -112,41 +94,4 @@ class EmailInjectorJs( private fun isDuckDuckGoUrl(url: String?): Boolean = (url != null && urlDetector.isDuckDuckGoEmailUrl(url)) - private class JavaScriptInjector { - private lateinit var functions: String - private lateinit var aliasFunctions: String - private lateinit var signOutFunctions: String - - fun getFunctionsJS(): String { - if (!this::functions.isInitialized) { - functions = loadJs("autofill.js") - } - return functions - } - - fun getAliasFunctions( - context: Context, - alias: String? - ): String { - if (!this::aliasFunctions.isInitialized) { - aliasFunctions = context.resources.openRawResource(R.raw.inject_alias).bufferedReader().use { it.readText() } - } - return aliasFunctions.replace("%s", alias.orEmpty()) - } - - fun getSignOutFunctions( - context: Context - ): String { - if (!this::signOutFunctions.isInitialized) { - signOutFunctions = context.resources.openRawResource(R.raw.signout_autofill).bufferedReader().use { it.readText() } - } - return signOutFunctions - } - - fun loadJs(resourceName: String): String = readResource(resourceName).use { it?.readText() }.orEmpty() - - private fun readResource(resourceName: String): BufferedReader? { - return javaClass.classLoader?.getResource(resourceName)?.openStream()?.bufferedReader() - } - } } diff --git a/app/src/main/java/com/duckduckgo/app/email/di/EmailModule.kt b/app/src/main/java/com/duckduckgo/app/email/di/EmailModule.kt index 951561e09067..698b79e17225 100644 --- a/app/src/main/java/com/duckduckgo/app/email/di/EmailModule.kt +++ b/app/src/main/java/com/duckduckgo/app/email/di/EmailModule.kt @@ -19,6 +19,7 @@ package com.duckduckgo.app.email.di import android.content.Context import androidx.lifecycle.LifecycleObserver import androidx.work.WorkManager +import com.duckduckgo.app.autofill.JavascriptInjector import com.duckduckgo.app.browser.DuckDuckGoUrlDetector import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.email.AppEmailManager @@ -63,9 +64,10 @@ class EmailModule { duckDuckGoUrlDetector: DuckDuckGoUrlDetector, dispatcherProvider: DispatcherProvider, featureToggle: FeatureToggle, + javascriptInjector: JavascriptInjector, autofill: Autofill ): EmailInjector { - return EmailInjectorJs(emailManager, duckDuckGoUrlDetector, dispatcherProvider, featureToggle, autofill) + return EmailInjectorJs(emailManager, duckDuckGoUrlDetector, dispatcherProvider, featureToggle, javascriptInjector, autofill) } @Provides diff --git a/app/src/main/java/com/duckduckgo/app/email/ui/EmailProtectionSignInFragment.kt b/app/src/main/java/com/duckduckgo/app/email/ui/EmailProtectionSignInFragment.kt index 9b6fb1f1d3c8..6bb10490fa5e 100644 --- a/app/src/main/java/com/duckduckgo/app/email/ui/EmailProtectionSignInFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/email/ui/EmailProtectionSignInFragment.kt @@ -29,7 +29,7 @@ import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.browser.BrowserActivity import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.databinding.FragmentEmailProtectionSignInBinding -import com.duckduckgo.app.email.AppEmailManager +import com.duckduckgo.app.email.EmailManager.WaitlistState import com.duckduckgo.app.waitlist.email.WaitlistNotificationDialog import com.duckduckgo.app.global.view.html import com.duckduckgo.di.scopes.FragmentScope @@ -80,9 +80,9 @@ class EmailProtectionSignInFragment : EmailProtectionFragment(R.layout.fragment_ private fun render(signInViewState: EmailProtectionSignInViewModel.ViewState) { when (val state = signInViewState.waitlistState) { - is AppEmailManager.WaitlistState.JoinedQueue -> renderJoinedQueue(state.notify) - is AppEmailManager.WaitlistState.InBeta -> renderInBeta() - is AppEmailManager.WaitlistState.NotJoinedQueue -> renderNotJoinedQueue() + is WaitlistState.JoinedQueue -> renderJoinedQueue(state.notify) + is WaitlistState.InBeta -> renderInBeta() + is WaitlistState.NotJoinedQueue -> renderNotJoinedQueue() } } diff --git a/app/src/main/java/com/duckduckgo/app/email/ui/EmailProtectionSignInViewModel.kt b/app/src/main/java/com/duckduckgo/app/email/ui/EmailProtectionSignInViewModel.kt index 4ce790a9fd98..485d22298879 100644 --- a/app/src/main/java/com/duckduckgo/app/email/ui/EmailProtectionSignInViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/email/ui/EmailProtectionSignInViewModel.kt @@ -20,8 +20,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.work.WorkManager import com.duckduckgo.anvil.annotations.ContributesViewModel -import com.duckduckgo.app.email.AppEmailManager import com.duckduckgo.app.email.EmailManager +import com.duckduckgo.app.email.EmailManager.* import com.duckduckgo.app.email.api.EmailService import com.duckduckgo.app.waitlist.email.EmailWaitlistWorkRequestBuilder import com.duckduckgo.app.pixels.AppPixelName @@ -57,7 +57,7 @@ class EmailProtectionSignInViewModel @Inject constructor( object ShowNotificationDialog : Command() } - data class ViewState(val waitlistState: AppEmailManager.WaitlistState) + data class ViewState(val waitlistState: WaitlistState) fun haveADuckAddress() { viewModelScope.launch { diff --git a/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt index f195b4653b82..fd8f102e6ff4 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt @@ -58,12 +58,11 @@ import com.duckduckgo.app.settings.extension.InternalFeaturePlugin import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.waitlist.trackerprotection.ui.AppTPWaitlistActivity import com.duckduckgo.app.widget.AddWidgetLauncher +import com.duckduckgo.autofill.ui.AutofillSettingsActivityLauncher import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.macos_api.MacWaitlistState -import com.duckduckgo.macos_api.MacWaitlistState.InBeta -import com.duckduckgo.macos_api.MacWaitlistState.JoinedWaitlist -import com.duckduckgo.macos_api.MacWaitlistState.NotJoinedQueue +import com.duckduckgo.macos_api.MacWaitlistState.* import com.duckduckgo.macos_impl.waitlist.ui.MacOsWaitlistActivity import com.duckduckgo.mobile.android.ui.DuckDuckGoTheme import com.duckduckgo.mobile.android.ui.sendThemeChangedBroadcast @@ -101,6 +100,9 @@ class SettingsActivity : @Inject lateinit var appBuildConfig: AppBuildConfig + @Inject + lateinit var autofillSettingsActivityLauncher: AutofillSettingsActivityLauncher + private val defaultBrowserChangeListener = OnCheckedChangeListener { _, isChecked -> viewModel.onDefaultBrowserToggled(isChecked) } @@ -219,6 +221,7 @@ class SettingsActivity : updateDeviceShieldSettings(it.appTrackingProtectionEnabled, it.appTrackingProtectionWaitlistState) updateEmailSubtitle(it.emailAddress) updateMacOsSettings(it.macOsWaitlistState) + updateAutofill(it.showAutofill) } }.launchIn(lifecycleScope) @@ -228,6 +231,15 @@ class SettingsActivity : .launchIn(lifecycleScope) } + private fun updateAutofill(autofillEnabled: Boolean) { + if (autofillEnabled) { + viewsPrivacy.autofill.visibility = View.VISIBLE + viewsPrivacy.autofill.setOnClickListener { viewModel.onAutofillSettingsClick() } + } else { + viewsPrivacy.autofill.visibility = View.GONE + } + } + private fun updateEmailSubtitle(emailAddress: String?) { val subtitle = emailAddress ?: getString(R.string.settingsEmailProtectionSubtitle) viewsMore.emailSetting.setSubtitle(subtitle) @@ -297,6 +309,7 @@ class SettingsActivity : is Command.LaunchDefaultBrowser -> launchDefaultAppScreen() is Command.LaunchFeedback -> launchFeedback() is Command.LaunchFireproofWebsites -> launchFireproofWebsites() + is Command.LaunchAutofillSettings -> launchAutofillSettings() is Command.LaunchAccessibilitySettings -> launchAccessibilitySettings() is Command.LaunchLocation -> launchLocation() is Command.LaunchWhitelist -> launchWhitelist() @@ -375,6 +388,11 @@ class SettingsActivity : startActivity(FireproofWebsitesActivity.intent(this), options) } + private fun launchAutofillSettings() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + startActivity(autofillSettingsActivityLauncher.intent(this), options) + } + private fun launchAccessibilitySettings() { val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() startActivity(AccessibilityActivity.intent(this), options) diff --git a/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt b/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt index 03c14fe5f283..f14a7a5b778e 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt @@ -36,6 +36,7 @@ import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelName import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.FIRE_ANIMATION import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.autofill.InternalTestUserChecker import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.feature.toggles.api.FeatureToggle import com.duckduckgo.macos_api.MacOsWaitlist @@ -76,7 +77,8 @@ class SettingsViewModel @Inject constructor( private val pixel: Pixel, private val appBuildConfig: AppBuildConfig, private val emailManager: EmailManager, - private val macOsWaitlist: MacOsWaitlist + private val macOsWaitlist: MacOsWaitlist, + private val internalTestUserChecker: InternalTestUserChecker ) : ViewModel(), LifecycleObserver { private var deviceShieldStatePollingJob: Job? = null @@ -96,7 +98,8 @@ class SettingsViewModel @Inject constructor( val appTrackingProtectionWaitlistState: WaitlistState = WaitlistState.NotJoinedQueue, val appTrackingProtectionEnabled: Boolean = false, val emailAddress: String? = null, - val macOsWaitlistState: MacWaitlistState = MacWaitlistState.NotJoinedQueue + val macOsWaitlistState: MacWaitlistState = MacWaitlistState.NotJoinedQueue, + val showAutofill: Boolean = false ) data class AutomaticallyClearData( @@ -110,6 +113,7 @@ class SettingsViewModel @Inject constructor( object LaunchEmailProtection : Command() object LaunchFeedback : Command() object LaunchFireproofWebsites : Command() + object LaunchAutofillSettings : Command() object LaunchAccessibilitySettings : Command() object LaunchLocation : Command() object LaunchWhitelist : Command() @@ -161,7 +165,8 @@ class SettingsViewModel @Inject constructor( appTrackingProtectionEnabled = TrackerBlockingVpnService.isServiceRunning(appContext), appTrackingProtectionWaitlistState = atpRepository.getState(), emailAddress = emailManager.getEmailAddress(), - macOsWaitlistState = macOsWaitlist.getWaitlistState() + macOsWaitlistState = macOsWaitlist.getWaitlistState(), + showAutofill = internalTestUserChecker.isInternalTestUser ) ) } @@ -233,6 +238,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { command.send(Command.LaunchFireproofWebsites) } } + fun onAutofillSettingsClick() { + viewModelScope.launch { command.send(Command.LaunchAutofillSettings) } + } + fun onLocationClicked() { viewModelScope.launch { command.send(Command.LaunchLocation) } } diff --git a/app/src/main/java/com/duckduckgo/app/waitlist/email/EmailWaitlistCodeFetcher.kt b/app/src/main/java/com/duckduckgo/app/waitlist/email/EmailWaitlistCodeFetcher.kt index b9cc960703d8..5021d703cdca 100644 --- a/app/src/main/java/com/duckduckgo/app/waitlist/email/EmailWaitlistCodeFetcher.kt +++ b/app/src/main/java/com/duckduckgo/app/waitlist/email/EmailWaitlistCodeFetcher.kt @@ -20,9 +20,11 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.OnLifecycleEvent import androidx.work.WorkManager -import com.duckduckgo.app.email.AppEmailManager -import com.duckduckgo.app.email.AppEmailManager.FetchCodeResult.* import com.duckduckgo.app.email.EmailManager +import com.duckduckgo.app.email.EmailManager.FetchCodeResult.CodeExisted +import com.duckduckgo.app.email.EmailManager.FetchCodeResult.Code +import com.duckduckgo.app.email.EmailManager.FetchCodeResult.NoCode +import com.duckduckgo.app.email.EmailManager.WaitlistState import com.duckduckgo.app.global.DispatcherProvider import com.duckduckgo.app.notification.NotificationSender import com.duckduckgo.app.notification.model.SchedulableNotification @@ -46,7 +48,7 @@ class AppEmailWaitlistCodeFetcher( @OnLifecycleEvent(Lifecycle.Event.ON_START) fun executeWaitlistCodeFetcher() { appCoroutineScope.launch { - if (emailManager.waitlistState() is AppEmailManager.WaitlistState.JoinedQueue) { + if (emailManager.waitlistState() is WaitlistState.JoinedQueue) { fetchInviteCode() } } diff --git a/app/src/main/java/com/duckduckgo/app/waitlist/email/EmailWaitlistWorkRequestBuilder.kt b/app/src/main/java/com/duckduckgo/app/waitlist/email/EmailWaitlistWorkRequestBuilder.kt index 352e39c655c2..cc9312d54d37 100644 --- a/app/src/main/java/com/duckduckgo/app/waitlist/email/EmailWaitlistWorkRequestBuilder.kt +++ b/app/src/main/java/com/duckduckgo/app/waitlist/email/EmailWaitlistWorkRequestBuilder.kt @@ -19,8 +19,8 @@ package com.duckduckgo.app.waitlist.email import android.content.Context import androidx.work.* import com.duckduckgo.anvil.annotations.ContributesWorker -import com.duckduckgo.app.email.AppEmailManager import com.duckduckgo.app.email.EmailManager +import com.duckduckgo.app.email.EmailManager.FetchCodeResult import com.duckduckgo.app.notification.NotificationSender import com.duckduckgo.app.notification.model.EmailWaitlistCodeNotification import com.duckduckgo.di.scopes.AppScope @@ -71,9 +71,9 @@ class EmailWaitlistWorker( override suspend fun doWork(): Result { when (emailManager.fetchInviteCode()) { - AppEmailManager.FetchCodeResult.CodeExisted -> Result.success() - AppEmailManager.FetchCodeResult.Code -> notificationSender.sendNotification(notification) - AppEmailManager.FetchCodeResult.NoCode -> WorkManager.getInstance(context).enqueue(emailWaitlistWorkRequestBuilder.waitlistRequestWork()) + FetchCodeResult.CodeExisted -> Result.success() + FetchCodeResult.Code -> notificationSender.sendNotification(notification) + FetchCodeResult.NoCode -> WorkManager.getInstance(context).enqueue(emailWaitlistWorkRequestBuilder.waitlistRequestWork()) } return Result.success() diff --git a/app/src/main/res/layout/content_settings_privacy.xml b/app/src/main/res/layout/content_settings_privacy.xml index 2dfe07763f2e..60bb021a009a 100644 --- a/app/src/main/res/layout/content_settings_privacy.xml +++ b/app/src/main/res/layout/content_settings_privacy.xml @@ -48,13 +48,21 @@ android:text="@string/settingsFireproofWebsites" app:layout_constraintTop_toBottomOf="@id/globalPrivacyControlSetting" /> + + + app:layout_constraintTop_toBottomOf="@id/autofill" /> + + Autofill + diff --git a/app/src/test/java/com/duckduckgo/app/browser/autofill/AutofillCredentialsSelectionResultHandlerTest.kt b/app/src/test/java/com/duckduckgo/app/browser/autofill/AutofillCredentialsSelectionResultHandlerTest.kt new file mode 100644 index 000000000000..600bfd7acd70 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/browser/autofill/AutofillCredentialsSelectionResultHandlerTest.kt @@ -0,0 +1,252 @@ +/* + * Copyright (c) 2022 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.app.browser.autofill + +import android.os.Bundle +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import com.duckduckgo.app.browser.BrowserTabFragment +import com.duckduckgo.app.browser.autofill.AutofillCredentialsSelectionResultHandler.AutofillCredentialSaver +import com.duckduckgo.app.browser.autofill.AutofillCredentialsSelectionResultHandler.CredentialInjector +import com.duckduckgo.app.browser.autofill.AutofillCredentialsSelectionResultHandlerTest.FakeAuthenticator.AuthorizeEverything +import com.duckduckgo.app.browser.autofill.AutofillCredentialsSelectionResultHandlerTest.FakeAuthenticator.DenyEverything +import com.duckduckgo.autofill.CredentialAutofillPickerDialog +import com.duckduckgo.autofill.CredentialSavePickerDialog +import com.duckduckgo.autofill.CredentialUpdateExistingCredentialsDialog +import com.duckduckgo.autofill.domain.app.LoginCredentials +import com.duckduckgo.deviceauth.api.DeviceAuthenticator +import com.duckduckgo.deviceauth.api.DeviceAuthenticator.AuthResult.Failed +import com.duckduckgo.deviceauth.api.DeviceAuthenticator.AuthResult.Success +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.* +import org.robolectric.RobolectricTestRunner + +@ExperimentalCoroutinesApi +@RunWith(RobolectricTestRunner::class) +class AutofillCredentialsSelectionResultHandlerTest { + + private val credentialsSaver: AutofillCredentialSaver = mock() + private val credentialsInjector: CredentialInjector = mock() + private val dummyFragment = Fragment() + private lateinit var deviceAuthenticator: FakeAuthenticator + private lateinit var testee: AutofillCredentialsSelectionResultHandler + + @Before + fun setup() { + setupAuthenticatorAlwaysAuth() + } + + @Test + fun whenSaveBundleMissingUrlThenNoAttemptToSaveMade() = runTest { + val bundle = bundleForSaveDialog(url = null, credentials = someLoginCredentials()) + testee.processSaveCredentialsResult(bundle, credentialsSaver) + verifySaveNeverCalled() + } + + @Test + fun whenSaveBundleMissingCredentialsThenNoAttemptToSaveMade() = runTest { + val bundle = bundleForSaveDialog(url = "example.com", credentials = null) + testee.processSaveCredentialsResult(bundle, credentialsSaver) + verifySaveNeverCalled() + } + + @Test + fun whenSaveBundleWellFormedThenCredentialsAreSaved() = runTest { + val loginCredentials = LoginCredentials(domain = "example.com", username = "foo", password = "bar") + val bundle = bundleForSaveDialog("example.com", loginCredentials) + testee.processSaveCredentialsResult(bundle, credentialsSaver) + verify(credentialsSaver).saveCredentials(eq("example.com"), eq(loginCredentials)) + } + + @Test + fun whenUpdateBundleMissingUrlThenNoAttemptToUpdateMade() = runTest { + val bundle = bundleForUpdateDialog(url = null, credentials = someLoginCredentials()) + testee.processUpdateCredentialsResult(bundle, credentialsSaver) + verifyUpdateNeverCalled() + } + + @Test + fun whenUpdateBundleMissingCredentialsThenNoAttemptToSaveMade() = runTest { + val bundle = bundleForUpdateDialog(url = "example.com", credentials = null) + testee.processUpdateCredentialsResult(bundle, credentialsSaver) + verifyUpdateNeverCalled() + } + + @Test + fun whenUpdateBundleWellFormedThenCredentialsAreUpdated() = runTest { + val loginCredentials = LoginCredentials(domain = "example.com", username = "foo", password = "bar") + val bundle = bundleForUpdateDialog("example.com", loginCredentials) + testee.processUpdateCredentialsResult(bundle, credentialsSaver) + verify(credentialsSaver).updateCredentials(eq("example.com"), eq(loginCredentials)) + verifySaveNeverCalled() + } + + @Test + fun whenCredentialsSelectionBundleEmptyThenAuthenticatorNotCalled() { + testee.processAutofillCredentialSelectionResult(Bundle(), dummyFragment, credentialsInjector) + verifyAuthenticatorNeverCalled() + } + + @Test + fun whenCredentialsSelectionBundleEmptyThenNoAutofillResponseGiven() { + testee.processAutofillCredentialSelectionResult(Bundle(), dummyFragment, credentialsInjector) + verifyNoAutofillResponseGiven() + } + + @Test + fun whenCredentialsSelectionBundleMissingUrlThenNoAutofillResponseGiven() { + val bundle = bundleForSelectionDialog(url = null, cancelled = false, credentials = someLoginCredentials()) + testee.processAutofillCredentialSelectionResult(bundle, dummyFragment, credentialsInjector) + verifyNoAutofillResponseGiven() + } + + @Test + fun whenCredentialsSelectionIsCancelledThenAutofillRequestCancelled() { + val bundle = bundleForSelectionDialog(url = "example.com", cancelled = true, credentials = someLoginCredentials()) + testee.processAutofillCredentialSelectionResult(bundle, dummyFragment, credentialsInjector) + verifyAutofillResponseCancelled("example.com") + } + + @Test + fun whenCredentialsSelectionMadeAndAuthorizedThenCredentialsSharedWithPage() { + setupAuthenticatorAlwaysAuth() + val bundle = bundleForSelectionDialog(url = "example.com", cancelled = false, credentials = someLoginCredentials()) + testee.processAutofillCredentialSelectionResult(bundle, dummyFragment, credentialsInjector) + verifyAuthenticatorIsCalled() + verifyCredentialsSharedWithPage("example.com", someLoginCredentials()) + } + + @Test + fun whenCredentialsSelectionMadeButNotAuthorizedThenAutofillRequestCancelled() { + setupAuthenticatorAlwaysDeny() + val bundle = bundleForSelectionDialog(url = "example.com", cancelled = false, credentials = someLoginCredentials()) + testee.processAutofillCredentialSelectionResult(bundle, dummyFragment, credentialsInjector) + verifyAuthenticatorIsCalled() + verifyAutofillResponseCancelled("example.com") + } + + @Test + fun whenCredentialsSelectionIsCancelledThenAuthenticatorNotCalled() { + val bundle = bundleForSelectionDialog("example.com", cancelled = true, someLoginCredentials()) + testee.processAutofillCredentialSelectionResult(bundle, BrowserTabFragment(), credentialsInjector) + verifyAuthenticatorNeverCalled() + } + + private fun verifySaveNeverCalled() { + verify(credentialsSaver, never()).saveCredentials(any(), any()) + } + + private fun verifyUpdateNeverCalled() { + verify(credentialsSaver, never()).updateCredentials(any(), any()) + } + + private fun verifyCredentialsSharedWithPage(url: String, credentials: LoginCredentials) { + verify(credentialsInjector).shareCredentialsWithPage(url, credentials) + } + + private fun verifyAutofillResponseCancelled(url: String) { + verify(credentialsInjector).returnNoCredentialsWithPage(url) + } + + private fun verifyAuthenticatorNeverCalled() { + assertFalse(deviceAuthenticator.authenticateCalled) + } + + private fun verifyAuthenticatorIsCalled() { + assertTrue(deviceAuthenticator.authenticateCalled) + } + + private fun verifyNoAutofillResponseGiven() { + verify(credentialsInjector, never()).returnNoCredentialsWithPage(any()) + verify(credentialsInjector, never()).shareCredentialsWithPage(any(), any()) + } + + private fun someLoginCredentials() = LoginCredentials(domain = "example.com", username = "foo", password = "bar") + + private fun bundleForSaveDialog(url: String?, credentials: LoginCredentials?): Bundle { + return Bundle().also { + if (url != null) it.putString(CredentialSavePickerDialog.KEY_URL, url) + if (credentials != null) it.putParcelable(CredentialSavePickerDialog.KEY_CREDENTIALS, credentials) + } + } + + private fun bundleForUpdateDialog(url: String?, credentials: LoginCredentials?): Bundle { + return Bundle().also { + if (url != null) it.putString(CredentialUpdateExistingCredentialsDialog.KEY_URL, url) + if (credentials != null) it.putParcelable(CredentialUpdateExistingCredentialsDialog.KEY_CREDENTIALS, credentials) + } + } + + private fun bundleForSelectionDialog(url: String?, cancelled: Boolean?, credentials: LoginCredentials?): Bundle { + return Bundle().also { + if (url != null) it.putString(CredentialAutofillPickerDialog.KEY_URL, url) + if (cancelled != null) it.putBoolean(CredentialAutofillPickerDialog.KEY_CANCELLED, cancelled) + if (credentials != null) it.putParcelable(CredentialSavePickerDialog.KEY_CREDENTIALS, credentials) + } + } + + private fun setupAuthenticatorAlwaysAuth() { + deviceAuthenticator = AuthorizeEverything() + testee = AutofillCredentialsSelectionResultHandler(deviceAuthenticator) + } + + private fun setupAuthenticatorAlwaysDeny() { + deviceAuthenticator = DenyEverything() + testee = AutofillCredentialsSelectionResultHandler(deviceAuthenticator) + } + + private abstract class FakeAuthenticator : DeviceAuthenticator { + + var authenticateCalled: Boolean = false + abstract val authenticationWillSucceed: Boolean + + private fun authenticationCalled(onResult: (DeviceAuthenticator.AuthResult) -> Unit) { + authenticateCalled = true + if (authenticationWillSucceed) { + onResult(Success) + } else { + onResult(Failed) + } + } + + override fun hasValidDeviceAuthentication(): Boolean = true + + override fun authenticate( + featureToAuth: DeviceAuthenticator.Features, + fragment: Fragment, + onResult: (DeviceAuthenticator.AuthResult) -> Unit + ) { + authenticationCalled(onResult) + } + + override fun authenticate( + featureToAuth: DeviceAuthenticator.Features, + fragmentActivity: FragmentActivity, + onResult: (DeviceAuthenticator.AuthResult) -> Unit + ) { + authenticationCalled(onResult) + } + + class AuthorizeEverything(override val authenticationWillSucceed: Boolean = true) : FakeAuthenticator() + class DenyEverything(override val authenticationWillSucceed: Boolean = false) : FakeAuthenticator() + } +} diff --git a/app/src/test/java/com/duckduckgo/app/email/AppEmailManagerTest.kt b/app/src/test/java/com/duckduckgo/app/email/AppEmailManagerTest.kt index 1d28677dc5b0..102bca2c7cc7 100644 --- a/app/src/test/java/com/duckduckgo/app/email/AppEmailManagerTest.kt +++ b/app/src/test/java/com/duckduckgo/app/email/AppEmailManagerTest.kt @@ -21,9 +21,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.app.CoroutineTestRule import com.duckduckgo.app.email.AppEmailManager.Companion.DUCK_EMAIL_DOMAIN import com.duckduckgo.app.email.AppEmailManager.Companion.UNKNOWN_COHORT -import com.duckduckgo.app.email.AppEmailManager.WaitlistState.InBeta -import com.duckduckgo.app.email.AppEmailManager.WaitlistState.JoinedQueue -import com.duckduckgo.app.email.AppEmailManager.WaitlistState.NotJoinedQueue +import com.duckduckgo.app.email.EmailManager.FetchCodeResult +import com.duckduckgo.app.email.EmailManager.WaitlistState.* import com.duckduckgo.app.email.api.EmailAlias import com.duckduckgo.app.email.api.EmailInviteCodeResponse import com.duckduckgo.app.email.api.EmailService @@ -281,7 +280,7 @@ class AppEmailManagerTest { fun whenFetchInviteCodeIfCodeAlreadyExistsThenReturnCodeExisted() = runTest { mockEmailDataStore.inviteCode = "inviteCode" - assertEquals(AppEmailManager.FetchCodeResult.CodeExisted, testee.fetchInviteCode()) + assertEquals(FetchCodeResult.CodeExisted, testee.fetchInviteCode()) } @Test @@ -309,7 +308,7 @@ class AppEmailManagerTest { givenUserIsTopOfTheQueue() whenever(mockEmailService.getCode(any())).thenReturn(EmailInviteCodeResponse("code")) - assertEquals(AppEmailManager.FetchCodeResult.Code, testee.fetchInviteCode()) + assertEquals(FetchCodeResult.Code, testee.fetchInviteCode()) } @Test @@ -317,7 +316,7 @@ class AppEmailManagerTest { givenUserIsTopOfTheQueue() whenever(mockEmailService.getCode(any())).thenReturn(EmailInviteCodeResponse("")) - assertEquals(AppEmailManager.FetchCodeResult.NoCode, testee.fetchInviteCode()) + assertEquals(FetchCodeResult.NoCode, testee.fetchInviteCode()) } @Test @@ -325,7 +324,7 @@ class AppEmailManagerTest { testee = AppEmailManager(TestEmailService(), mockEmailDataStore, coroutineRule.testDispatcherProvider, TestScope()) givenUserIsTopOfTheQueue() - assertEquals(AppEmailManager.FetchCodeResult.NoCode, testee.fetchInviteCode()) + assertEquals(FetchCodeResult.NoCode, testee.fetchInviteCode()) } @Test @@ -333,7 +332,7 @@ class AppEmailManagerTest { testee = AppEmailManager(TestEmailService(), mockEmailDataStore, coroutineRule.testDispatcherProvider, TestScope()) givenUserIsInWaitlist() - assertEquals(AppEmailManager.FetchCodeResult.NoCode, testee.fetchInviteCode()) + assertEquals(FetchCodeResult.NoCode, testee.fetchInviteCode()) } @Test diff --git a/app/src/test/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt index 32fb84e42da4..e0a6e848483a 100644 --- a/app/src/test/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt @@ -35,6 +35,7 @@ import com.duckduckgo.app.statistics.Variant import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.autofill.InternalTestUserChecker import com.duckduckgo.feature.toggles.api.FeatureToggle import com.duckduckgo.macos_api.MacOsWaitlist import com.duckduckgo.macos_api.MacWaitlistState @@ -107,6 +108,9 @@ class SettingsViewModelTest { @Mock private lateinit var mockMacOsWaitlist: MacOsWaitlist + @Mock + private lateinit var internalTestUserChecker: InternalTestUserChecker + private lateinit var appTrackingProtectionWaitlistDataStore: FakeAppTrackingProtectionWaitlistDataStore @get:Rule @@ -143,7 +147,8 @@ class SettingsViewModelTest { mockPixel, mockAppBuildConfig, mockEmailManager, - mockMacOsWaitlist + mockMacOsWaitlist, + internalTestUserChecker ) } @@ -627,6 +632,26 @@ class SettingsViewModelTest { } } + @Test + fun whenIsInternalTestUserTheShowAutofillTrue() = runTest { + whenever(internalTestUserChecker.isInternalTestUser).thenReturn(true) + testee.start() + + testee.viewState().test { + assertTrue(awaitItem().showAutofill) + } + } + + @Test + fun whenIsInternalTestUserTheShowAutofillFalse() = runTest { + whenever(internalTestUserChecker.isInternalTestUser).thenReturn(false) + testee.start() + + testee.viewState().test { + assertFalse(awaitItem().showAutofill) + } + } + private fun givenSelectedFireAnimation(fireAnimation: FireAnimation) { whenever(mockAppSettingsDataStore.selectedFireAnimation).thenReturn(fireAnimation) whenever(mockAppSettingsDataStore.isCurrentlySelected(fireAnimation)).thenReturn(true) diff --git a/autofill/autofill-api/.gitignore b/autofill/autofill-api/.gitignore new file mode 100644 index 000000000000..42afabfd2abe --- /dev/null +++ b/autofill/autofill-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/autofill/autofill-api/build.gradle b/autofill/autofill-api/build.gradle new file mode 100644 index 000000000000..72f480e8dfb8 --- /dev/null +++ b/autofill/autofill-api/build.gradle @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2021 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. + */ + +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'kotlin-android-extensions' +} + +apply from: "$rootProject.projectDir/gradle/android-library.gradle" + +dependencies { + implementation project(path: ':di') + implementation project(path: ':common') + implementation project(path: ':common-ui') + + implementation KotlinX.coroutines.core + implementation Google.android.material + implementation AndroidX.appCompat + implementation AndroidX.constraintLayout +} + diff --git a/autofill/autofill-api/lint-baseline.xml b/autofill/autofill-api/lint-baseline.xml new file mode 100644 index 000000000000..2578bb1afdb4 --- /dev/null +++ b/autofill/autofill-api/lint-baseline.xml @@ -0,0 +1,4 @@ + + + + diff --git a/autofill/autofill-api/src/main/AndroidManifest.xml b/autofill/autofill-api/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..33b19c379dbb --- /dev/null +++ b/autofill/autofill-api/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/AutofillCredentialDialogs.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/AutofillCredentialDialogs.kt new file mode 100644 index 000000000000..eb00187be18b --- /dev/null +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/AutofillCredentialDialogs.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2022 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 + +import androidx.fragment.app.DialogFragment +import com.duckduckgo.autofill.domain.app.LoginCredentials + +/** + * Dialog which can be shown when user is required to select which saved credential to autofill + */ +interface CredentialAutofillPickerDialog : DialogFragmentType { + + companion object { + const val TAG = "CredentialAutofillPickerDialog" + const val RESULT_KEY_CREDENTIAL_PICKER = "CredentialAutofillPickerDialogResult" + const val KEY_CANCELLED = "cancelled" + const val KEY_URL = "url" + const val KEY_CREDENTIALS = "credentials" + } +} + +/** + * Dialog which can be shown to prompt user to save credentials or not + */ +interface CredentialSavePickerDialog : DialogFragmentType { + + companion object { + const val TAG = "CredentialSavePickerDialog" + const val RESULT_KEY_CREDENTIAL_RESULT_SAVE = "CredentialSavePickerDialogResultSave" + const val KEY_URL = "url" + const val KEY_CREDENTIALS = "credentials" + } +} + +/** + * Dialog which can be shown to prompt user to update existing saved credentials or not + */ +interface CredentialUpdateExistingCredentialsDialog : DialogFragmentType { + + companion object { + const val TAG = "CredentialUpdateExistingCredentialsDialog" + const val KEY_URL = "url" + const val KEY_CREDENTIALS = "credentials" + const val RESULT_KEY_CREDENTIAL_RESULT_UPDATE = "CredentialUpdateExistingCredentialsResult" + } +} + +/** + * A workaround caused by modularization: + * clients using these dialogs will know them by their interface but will also need to know they are DialogFragments to show them + */ +interface DialogFragmentType { + fun asDialogFragment(): DialogFragment +} + +/** + * Factory used to get instances of the various autofill dialogs + */ +interface CredentialAutofillDialogFactory { + + fun autofillSelectCredentialsDialog(url: String, credentials: List): CredentialAutofillPickerDialog + + fun autofillSavingCredentialsDialog(url: String, credentials: LoginCredentials): CredentialSavePickerDialog + + fun autofillSavingUpdateCredentialsDialog(url: String, credentials: LoginCredentials): CredentialUpdateExistingCredentialsDialog + +} diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/BrowserAutofill.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/BrowserAutofill.kt new file mode 100644 index 000000000000..04f744d2d85f --- /dev/null +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/BrowserAutofill.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2022 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 + +import android.webkit.WebView +import com.duckduckgo.autofill.domain.app.LoginCredentials + +/** + * Public interface for accessing and configuring browser autofill functionality for a WebView instance + */ +interface BrowserAutofill { + + /** + * Adds the native->JS interface to the given WebView + * This should be called once per WebView where autofill is to be available in it + */ + fun addJsInterface(webView: WebView, callback: Callback) + + /** + * Removes the JS interface as a clean-up. Recommended to call from onDestroy() of Fragment/Activity containing the WebView + */ + fun removeJsInterface() + + /** + * Configures autofill for the current webpage. + * This should be called once per page load (e.g., onPageStarted()) + * + * Responsible for injecting the required autofill configuration to the JS layer + */ + fun configureAutofillForCurrentPage(webView: WebView, url: String?) + + /** + * Communicates with the JS layer to pass the given credentials + * + * @param credentials The credentials to be passed to the JS layer. Can be null to indicate credentials won't be autofilled. + */ + fun injectCredentials(credentials: LoginCredentials?) + +} + +/** + * Browser Autofill callbacks + */ +interface Callback { + fun onCredentialsAvailableToInject(credentials: List) + fun onCredentialsAvailableToSave(currentUrl: String, credentials: LoginCredentials) + fun noCredentialsAvailable(originalUrl: String) +} diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/InternalTestUserChecker.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/InternalTestUserChecker.kt new file mode 100644 index 000000000000..1fd5d9388331 --- /dev/null +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/InternalTestUserChecker.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2022 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 + +/** + * Public API that could be used if the user is a verified internal tester. + * This class could potentially be moved to a different module once there's a need for it in the future. + */ +interface InternalTestUserChecker { + /** + * This checks if the user has went through the process of becoming a verified test user + */ + val isInternalTestUser: Boolean + + /** + * This method should be called if an error is received when loading a [url]. + * This will only be processed if the [url] passed is a valid internal tester success verification url + * else it will just be ignored. + */ + fun verifyVerificationErrorReceived(url: String) + + /** + * This method should be called if the [url] is completely loaded. + * This will only be processed if the [url] passed is a valid internal tester success verification url + * else it will just be ignored. + */ + fun verifyVerificationCompleted(url: String) +} diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/domain/app/LoginCredentials.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/domain/app/LoginCredentials.kt new file mode 100644 index 000000000000..6b773d62ec56 --- /dev/null +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/domain/app/LoginCredentials.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 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.domain.app + +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize + +/** + * Representation of login credentials used for autofilling into the browser. + */ +@Parcelize +data class LoginCredentials( + val id: Int? = null, + val domain: String?, + val username: String?, + val password: String? +) : Parcelable { + override fun toString(): String { + return "LoginCredentials(id=$id, domain=$domain, username=$username, password=********" + } +} diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/store/AutofillStore.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/store/AutofillStore.kt new file mode 100644 index 000000000000..f70b12b59b22 --- /dev/null +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/store/AutofillStore.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2022 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.store + +import com.duckduckgo.autofill.domain.app.LoginCredentials +import kotlinx.coroutines.flow.Flow + +/** + * APIs for accessing and updating saved autofill data + */ +interface AutofillStore { + + /** + * Global toggle for determining / setting if autofill is enabled + */ + var autofillEnabled: Boolean + + /** + * Find saved credentials for the given URL, returning an empty list where no matches are found + * @param rawUrl Can be a full, unmodified URL taken from the URL bar (containing subdomains, query params etc...) + */ + suspend fun getCredentials(rawUrl: String): List + + /** + * Save the given credentials for the given URL + * @param rawUrl Can be a full, unmodified URL taken from the URL bar (containing subdomains, query params etc...) + * @param credentials The credentials to be saved. The ID can be null. + */ + suspend fun saveCredentials(rawUrl: String, credentials: LoginCredentials) + + /** + * Updates the credentials saved for the given URL + * @param rawUrl Can be a full, unmodified URL taken from the URL bar (containing subdomains, query params etc...) + * @param credentials The credentials to be updated. The ID can be null. + */ + suspend fun updateCredentials(rawUrl: String, credentials: LoginCredentials) + + /** + * Returns the full list of stored login credentials + */ + suspend fun getAllCredentials(): Flow> + + /** + * Deletes the credential with the given ID + */ + suspend fun deleteCredentials(id: Int) + + /** + * Updates the given login credentials, replacing what was saved before for the credentials with the specified ID + * @param credentials The ID of the given credentials must match a saved credential for it to be updated. + */ + suspend fun updateCredentials(credentials: LoginCredentials) + + /** + * Searches the saved login credentials for a match to the given URL, username and password + * This can be used to determine if we need to prompt the user to update a saved credential + * + * @return The match type, which might indicate there was an exact match, a partial match etc... + */ + suspend fun containsCredentials(rawUrl: String, username: String, password: String): ContainsCredentialsResult + + /** + * Possible match types returned when searching for the presence of credentials + */ + sealed interface ContainsCredentialsResult { + object ExactMatch : ContainsCredentialsResult + object UsernameMatch : ContainsCredentialsResult + object UrlOnlyMatch : ContainsCredentialsResult + object NoMatch : ContainsCredentialsResult + } +} diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/ui/AutofillSettingsActivityLauncher.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/ui/AutofillSettingsActivityLauncher.kt new file mode 100644 index 000000000000..4cc30c7a5c78 --- /dev/null +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/ui/AutofillSettingsActivityLauncher.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 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.ui + +import android.content.Context +import android.content.Intent + +/** + * Used to access an Intent which will launch the autofill settings activity + * The activity is implemented in the impl module and is otherwise inaccessible from outside this module. + */ +interface AutofillSettingsActivityLauncher { + fun intent(context: Context): Intent +} diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/ui/ExistingCredentialMatchDetector.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/ui/ExistingCredentialMatchDetector.kt new file mode 100644 index 000000000000..ad7ce275d975 --- /dev/null +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/ui/ExistingCredentialMatchDetector.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 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.ui + +import com.duckduckgo.autofill.store.AutofillStore + +/** + * Used to determine if the given credential details exist in the autofill storage + * + * There are times when the UI from the main app will need to prompt the user if they want to update saved details. + * We can only show that prompt if we've first determined there is an existing partial match in need of an update. + */ +interface ExistingCredentialMatchDetector { + suspend fun determine(currentUrl: String, username: String, password: String): AutofillStore.ContainsCredentialsResult +} diff --git a/autofill/autofill-impl/build.gradle b/autofill/autofill-impl/build.gradle new file mode 100644 index 000000000000..ffbf5076b5dc --- /dev/null +++ b/autofill/autofill-impl/build.gradle @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2021 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. + */ + +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'com.squareup.anvil' + id 'kotlin-android-extensions' +} + +apply from: "$rootProject.projectDir/gradle/android-library.gradle" + +dependencies { + implementation project(path: ':app-build-config-api') + implementation project(path: ':di') + implementation project(path: ':common') + implementation project(path: ':common-ui') + implementation project(path: ':autofill-api') + implementation project(path: ':secure-storage-api') + implementation project(path: ':device-auth-api') + implementation project(path: ':browser-api') + implementation project(path: ':autofill-store') + + anvil project(path: ':anvil-compiler') + implementation project(path: ':anvil-annotations') + + implementation AndroidX.appCompat + implementation Google.android.material + implementation AndroidX.constraintLayout + implementation JakeWharton.timber + + implementation KotlinX.coroutines.core + implementation AndroidX.fragment.ktx + implementation "androidx.webkit:webkit:_" + + implementation Square.retrofit2.converter.moshi + implementation Google.dagger + implementation AndroidX.core.ktx + implementation AndroidX.work.runtimeKtx + + // Testing dependencies + testImplementation "org.mockito.kotlin:mockito-kotlin:_" + testImplementation Testing.junit4 + testImplementation AndroidX.core + testImplementation AndroidX.test.ext.junit + testImplementation "androidx.test:runner:_" + testImplementation Testing.robolectric + testImplementation CashApp.turbine + testImplementation "org.jetbrains.kotlin:kotlin-reflect:_" + + testImplementation project(path: ':common-test') + + testImplementation (KotlinX.coroutines.test) { + // https://github.com/Kotlin/kotlinx.coroutines/issues/2023 + // conflicts with mockito due to direct inclusion of byte buddy + exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-debug" + } + +} + +android { + anvil { + generateDaggerFactories = true // default is false + } + testOptions { + unitTests { + includeAndroidResources = true + } + } +} + diff --git a/autofill/autofill-impl/lint-baseline.xml b/autofill/autofill-impl/lint-baseline.xml new file mode 100644 index 000000000000..3f2a6199800e --- /dev/null +++ b/autofill/autofill-impl/lint-baseline.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/autofill/autofill-impl/src/main/AndroidManifest.xml b/autofill/autofill-impl/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..d2a667798a19 --- /dev/null +++ b/autofill/autofill-impl/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/AutofillJavascriptInterface.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/AutofillJavascriptInterface.kt new file mode 100644 index 000000000000..378b98a1c58e --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/AutofillJavascriptInterface.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2022 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 + +import android.webkit.JavascriptInterface +import android.webkit.WebView +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.email.EmailManager +import com.duckduckgo.app.global.DefaultDispatcherProvider +import com.duckduckgo.app.global.DispatcherProvider +import com.duckduckgo.app.utils.ConflatedJob +import com.duckduckgo.autofill.domain.app.LoginCredentials +import com.duckduckgo.autofill.domain.javascript.JavascriptCredentials +import com.duckduckgo.autofill.jsbridge.AutofillMessagePoster +import com.duckduckgo.autofill.jsbridge.request.AutofillRequestParser +import com.duckduckgo.autofill.jsbridge.request.SupportedAutofillInputMainType.CREDENTIALS +import com.duckduckgo.autofill.jsbridge.response.AutofillResponseWriter +import com.duckduckgo.autofill.store.AutofillStore +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject + +interface AutofillJavascriptInterface { + + @JavascriptInterface + fun getAutofillData(requestString: String) + + suspend fun getRuntimeConfiguration(rawJs: String, url: String?): String + fun injectCredentials(credentials: LoginCredentials) + fun injectNoCredentials() + + var callback: Callback? + var webView: WebView? + + companion object { + const val INTERFACE_NAME = "BrowserAutofill" + } + +} + +@ContributesBinding(AppScope::class) +class AutofillStoredBackJavascriptInterface @Inject constructor( + private val requestParser: AutofillRequestParser, + private val autofillStore: AutofillStore, + private val autofillMessagePoster: AutofillMessagePoster, + private val autofillResponseWriter: AutofillResponseWriter, + private val emailManager: EmailManager, + @AppCoroutineScope private val coroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider = DefaultDispatcherProvider(), + private val currentUrlProvider: UrlProvider = WebViewUrlProvider(dispatcherProvider) +) : AutofillJavascriptInterface { + + override var callback: Callback? = null + override var webView: WebView? = null + private val getAutofillDataJob = ConflatedJob() + + @JavascriptInterface + override fun getAutofillData(requestString: String) { + Timber.v("BrowserAutofill: getAutofillData called:\n%s", requestString) + getAutofillDataJob += coroutineScope.launch(dispatcherProvider.default()) { + val request = requestParser.parseAutofillDataRequest(requestString) + + if (request.mainType != CREDENTIALS) { + Timber.w("Autofill type %s unsupported", request.mainType) + return@launch + } + + val url = currentUrlProvider.currentUrl(webView) + if (url == null) { + Timber.w("Can't autofill as can't retrieve current URL") + return@launch + } + + val credentials = autofillStore.getCredentials(url) + + withContext(dispatcherProvider.main()) { + if (credentials.isEmpty()) { + callback?.noCredentialsAvailable(url) + } else { + callback?.onCredentialsAvailableToInject(credentials) + } + } + } + } + + override suspend fun getRuntimeConfiguration(rawJs: String, url: String?): String { + Timber.v("BrowserAutofill: getRuntimeConfiguration called") + + val contentScope = autofillResponseWriter.generateContentScope() + val userUnprotectedDomains = autofillResponseWriter.generateUserUnprotectedDomains() + val userPreferences = autofillResponseWriter.generateUserPreferences(autofillCredentials = determineIfAutofillEnabled()) + val availableInputTypes = generateAvailableInputTypes(url) + + return rawJs + .replace("// INJECT contentScope HERE", contentScope) + .replace("// INJECT userUnprotectedDomains HERE", userUnprotectedDomains) + .replace("// INJECT userPreferences HERE", userPreferences) + .replace("// INJECT availableInputTypes HERE", availableInputTypes) + } + + private suspend fun generateAvailableInputTypes(url: String?): String { + val credentialsAvailable = determineIfCredentialsAvailable(url) + val emailAvailable = determineIfEmailAvailable() + + val json = autofillResponseWriter.generateResponseGetAvailableInputTypes(credentialsAvailable, emailAvailable).also { + Timber.v("availableInputTypes for %s: \n%s", url, it) + } + return "availableInputTypes = $json" + } + + private fun determineIfEmailAvailable(): Boolean = emailManager.isSignedIn() + + // in the future, we'll also tie this into feature toggles and remote config + private fun determineIfAutofillEnabled(): Boolean = autofillStore.autofillEnabled + + private suspend fun determineIfCredentialsAvailable(url: String?): Boolean { + return if (url == null) { + false + } else { + val savedCredentials = autofillStore.getCredentials(url) + savedCredentials.isNotEmpty() + } + } + + @JavascriptInterface + fun storeFormData(data: String) { + Timber.i("storeFormData called, credentials provided to be persisted") + + getAutofillDataJob += coroutineScope.launch { + val currentUrl = currentUrlProvider.currentUrl(webView) ?: return@launch + + val request = requestParser.parseStoreFormDataRequest(data).credentials + val jsCredentials = JavascriptCredentials(request.username, request.password) + val credentials = jsCredentials.asLoginCredentials(currentUrl) + + withContext(dispatcherProvider.main()) { + callback?.onCredentialsAvailableToSave(currentUrl, credentials) + } + } + } + + override fun injectCredentials(credentials: LoginCredentials) { + getAutofillDataJob += coroutineScope.launch(dispatcherProvider.default()) { + val jsCredentials = credentials.asJsCredentials() + autofillMessagePoster.postMessage(webView, autofillResponseWriter.generateResponseGetAutofillData(jsCredentials)) + } + } + + override fun injectNoCredentials() { + getAutofillDataJob += coroutineScope.launch(dispatcherProvider.default()) { + autofillMessagePoster.postMessage(webView, autofillResponseWriter.generateEmptyResponseGetAutofillData()) + } + } + + private fun LoginCredentials.asJsCredentials(): JavascriptCredentials { + return JavascriptCredentials( + username = username, + password = password + ) + } + + private fun JavascriptCredentials.asLoginCredentials(url: String): LoginCredentials { + return LoginCredentials( + id = null, + domain = url, + username = username, + password = password + ) + } + + interface UrlProvider { + suspend fun currentUrl(webView: WebView?): String? + } + + @ContributesBinding(AppScope::class) + class WebViewUrlProvider @Inject constructor(val dispatcherProvider: DispatcherProvider) : UrlProvider { + override suspend fun currentUrl(webView: WebView?): String? { + return withContext(dispatcherProvider.main()) { + webView?.url + } + } + } + +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/InlineBrowserAutofill.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/InlineBrowserAutofill.kt new file mode 100644 index 000000000000..acd5b1a49562 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/InlineBrowserAutofill.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2022 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 + +import android.webkit.WebView +import com.duckduckgo.app.autofill.JavascriptInjector +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.autofill.domain.app.LoginCredentials +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class InlineBrowserAutofill @Inject constructor( + private val autofillInterface: AutofillJavascriptInterface, + private val javascriptInjector: JavascriptInjector, + @AppCoroutineScope private val coroutineScope: CoroutineScope, +) : BrowserAutofill { + + override fun addJsInterface(webView: WebView, callback: Callback) { + Timber.v("Injecting BrowserAutofill interface") + webView.addJavascriptInterface(autofillInterface, AutofillJavascriptInterface.INTERFACE_NAME) + autofillInterface.webView = webView + autofillInterface.callback = callback + } + + override fun removeJsInterface() { + autofillInterface.webView = null + } + + override fun configureAutofillForCurrentPage(webView: WebView, url: String?) { + coroutineScope.launch { + val rawJs = javascriptInjector.getFunctionsJS() + val formatted = autofillInterface.getRuntimeConfiguration(rawJs, url) + + withContext(Dispatchers.Main) { + webView.evaluateJavascript("javascript:$formatted", null) + } + } + } + + override fun injectCredentials(credentials: LoginCredentials?) { + if (credentials == null) { + autofillInterface.injectNoCredentials() + } else { + autofillInterface.injectCredentials(credentials) + } + + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/di/AutofillModule.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/di/AutofillModule.kt new file mode 100644 index 000000000000..51a0e4cbcf71 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/di/AutofillModule.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 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.di + +import android.content.Context +import com.duckduckgo.autofill.store.InternalTestUserStore +import com.duckduckgo.autofill.store.RealInternalTestUserStore +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides + +@Module +@ContributesTo(AppScope::class) +class AutofillModule { + + @Provides + fun provideInternalTestUserStore(applicationContext: Context): InternalTestUserStore = RealInternalTestUserStore(applicationContext) +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/domain/javascript/JavascriptCredentials.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/domain/javascript/JavascriptCredentials.kt new file mode 100644 index 000000000000..c01209793c3e --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/domain/javascript/JavascriptCredentials.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2022 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.domain.javascript + +data class JavascriptCredentials( + val username: String?, + val password: String? +) diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/internal/RealInternalTestUserChecker.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/internal/RealInternalTestUserChecker.kt new file mode 100644 index 000000000000..d329b3a99b1b --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/internal/RealInternalTestUserChecker.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2022 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.internal + +import androidx.core.net.toUri +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.appbuildconfig.api.isInternalBuild +import com.duckduckgo.autofill.InternalTestUserChecker +import com.duckduckgo.autofill.store.InternalTestUserStore +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import javax.inject.Inject + +/** + * The logic for this implementation of [InternalTestUserChecker] relies on the fact that + * a user loads a verification url and goes through auth and completes the process to be + * an internal test user. + */ +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class RealInternalTestUserChecker @Inject constructor( + private val internalTestUserStore: InternalTestUserStore, + private val appBuildConfig: AppBuildConfig +) : InternalTestUserChecker { + + private var verificationErrorDetected = false + + override val isInternalTestUser: Boolean + get() = appBuildConfig.isInternalBuild() || internalTestUserStore.isVerifiedInternalTestUser + + override fun verifyVerificationErrorReceived(url: String) { + /** + * When the page is loaded for [LOGIN_URL_SUCCESS] and the user hasn't completed auth, + * the page could successfully complete loading BUT will emit an http error [WebViewClient.onReceivedHttpError]. + * This http error will be our signal that the user should NOT be an internal test user. + */ + if (url.internalTestUrlSuccessUrl()) { + verificationErrorDetected = true + } + } + + override fun verifyVerificationCompleted(url: String) { + /** + * This method is processed when [LOGIN_URL_SUCCESS] has been successfully loaded. If no http error + * has been emitted / [verifyVerificationErrorReceived] not called, This means that the user + * has completed auth and the user should be set as an internal tester. + */ + if (url.internalTestUrlSuccessUrl()) { + internalTestUserStore.isVerifiedInternalTestUser = !verificationErrorDetected + verificationErrorDetected = false + } + } + + private fun String.internalTestUrlSuccessUrl(): Boolean = this.toUri().schemeSpecificPart == LOGIN_URL_SUCCESS + + companion object { + private const val LOGIN_URL_SUCCESS = "//use-login.duckduckgo.com/patestsucceeded" + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/jsbridge/AutofillMessagePoster.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/jsbridge/AutofillMessagePoster.kt new file mode 100644 index 000000000000..ad393417ae43 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/jsbridge/AutofillMessagePoster.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2022 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.jsbridge + +import android.annotation.SuppressLint +import android.webkit.WebView +import androidx.core.net.toUri +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject + +interface AutofillMessagePoster { + suspend fun postMessage(webView: WebView?, message: String) +} + +@ContributesBinding(AppScope::class) +class AutofillWebViewMessagePoster @Inject constructor() : AutofillMessagePoster { + + @SuppressLint("RequiresFeature") + override suspend fun postMessage(webView: WebView?, message: String) { + + webView?.let { wv -> + withContext(Dispatchers.Main) { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.POST_WEB_MESSAGE)) { + Timber.e("Unable to post web message") + return@withContext + } + + WebViewCompat.postWebMessage(wv, WebMessageCompat(message), WILDCARD_ORIGIN_URL) + } + } + } + + companion object { + private val WILDCARD_ORIGIN_URL = "*".toUri() + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/jsbridge/request/AutofillDataRequest.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/jsbridge/request/AutofillDataRequest.kt new file mode 100644 index 000000000000..09d5ebbaea84 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/jsbridge/request/AutofillDataRequest.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2022 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.jsbridge.request + +import com.squareup.moshi.Json + +data class AutofillDataRequest( + val mainType: SupportedAutofillInputMainType, + val subType: SupportedAutofillInputSubType +) { + + data class InputType( + val title: String, + val description: String, + val type: String, + ) +} + +enum class SupportedAutofillInputMainType { + @Json(name = "credentials") CREDENTIALS, + @Json(name = "identities") IDENTITIES, + @Json(name = "creditCards") CREDIT_CARDS, +} + +enum class SupportedAutofillInputSubType { + @Json(name = "username") USERNAME, + @Json(name = "password") PASSWORD, +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/jsbridge/request/AutofillRequestParser.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/jsbridge/request/AutofillRequestParser.kt new file mode 100644 index 000000000000..fef9f9901700 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/jsbridge/request/AutofillRequestParser.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022 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.jsbridge.request + +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.moshi.Moshi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +interface AutofillRequestParser { + suspend fun parseAutofillDataRequest(request: String): AutofillDataRequest + suspend fun parseStoreFormDataRequest(request: String): AutofillStoreFormDataRequest +} + +@ContributesBinding(AppScope::class) +class AutofillJsonRequestParser @Inject constructor(val moshi: Moshi) : AutofillRequestParser { + + private val autofillDataRequestParser by lazy { moshi.adapter(AutofillDataRequest::class.java) } + private val autofillStoreFormDataRequestParser by lazy { moshi.adapter(AutofillStoreFormDataRequest::class.java) } + + override suspend fun parseAutofillDataRequest(request: String): AutofillDataRequest { + return withContext(Dispatchers.Default) { + autofillDataRequestParser.fromJson(request) ?: throw IllegalArgumentException("Failed to parse autofill request") + } + } + + override suspend fun parseStoreFormDataRequest(request: String): AutofillStoreFormDataRequest { + return withContext(Dispatchers.Default) { + autofillStoreFormDataRequestParser.fromJson(request) ?: throw IllegalArgumentException("Failed to parse autofill request") + } + } + +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/jsbridge/request/AutofillStoreFormDataRequest.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/jsbridge/request/AutofillStoreFormDataRequest.kt new file mode 100644 index 000000000000..ce5dc36b682a --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/jsbridge/request/AutofillStoreFormDataRequest.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 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.jsbridge.request + +data class AutofillStoreFormDataRequest( + val credentials: AutofillStoreFormDataCredentialsRequest +) + +data class AutofillStoreFormDataCredentialsRequest( + val username: String, + val password: String +) diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/jsbridge/response/AutofillDataResponse.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/jsbridge/response/AutofillDataResponse.kt new file mode 100644 index 000000000000..c7f78225ffdb --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/jsbridge/response/AutofillDataResponse.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 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.jsbridge.response + +data class AutofillDataResponse( + val type: String = "getAutofillDataResponse", + val success: CredentialSuccessResponse +) { + + data class CredentialSuccessResponse( + val username: String? = "", + val password: String? = null + ) +} + +data class AutofillAvailableInputTypesResponse( + val type: String = "getAvailableInputTypesResponse", + val success: AvailableInputSuccessResponse +) { + + data class AvailableInputSuccessResponse( + val credentials: Boolean + ) +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/jsbridge/response/AutofillDataResponses.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/jsbridge/response/AutofillDataResponses.kt new file mode 100644 index 000000000000..3a223d96e605 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/jsbridge/response/AutofillDataResponses.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 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.jsbridge.response + +import com.duckduckgo.autofill.domain.javascript.JavascriptCredentials + +data class ContainingCredentials( + val type: String = "getAutofillDataResponse", + val success: CredentialSuccessResponse +) { + + data class CredentialSuccessResponse( + val credentials: JavascriptCredentials, + val action: String = "fill", + ) +} + +data class EmptyResponse( + val type: String = "getAutofillDataResponse", + val success: EmptyCredentialResponse +) { + + data class EmptyCredentialResponse( + val action: String = "non" + ) +} + +data class AvailableInputSuccessResponse( + val credentials: Boolean, + val email: Boolean +) diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/jsbridge/response/AutofillResponseWriter.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/jsbridge/response/AutofillResponseWriter.kt new file mode 100644 index 000000000000..bbcc1cd7e744 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/jsbridge/response/AutofillResponseWriter.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2022 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.jsbridge.response + +import com.duckduckgo.autofill.domain.javascript.JavascriptCredentials +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.moshi.Moshi +import javax.inject.Inject + +interface AutofillResponseWriter { + fun generateResponseGetAutofillData(credentials: JavascriptCredentials): String + fun generateEmptyResponseGetAutofillData(): String + fun generateResponseGetAvailableInputTypes(credentialsAvailable: Boolean, emailAvailable: Boolean): String + fun generateContentScope(): String + fun generateUserUnprotectedDomains(): String + fun generateUserPreferences(autofillCredentials: Boolean, showInlineKeyIcon: Boolean = false): String +} + +@ContributesBinding(AppScope::class) +class AutofillJsonResponseWriter @Inject constructor(val moshi: Moshi) : AutofillResponseWriter { + + private val availableInputTypesAdapter = moshi.adapter(AvailableInputSuccessResponse::class.java).indent(" ") + private val autofillDataAdapterCredentialsAvailable = moshi.adapter(ContainingCredentials::class.java).indent(" ") + private val autofillDataAdapterCredentialsUnavailable = moshi.adapter(EmptyResponse::class.java).indent(" ") + + override fun generateResponseGetAutofillData(credentials: JavascriptCredentials): String { + val credentialsResponse = ContainingCredentials.CredentialSuccessResponse(credentials) + val topLevelResponse = ContainingCredentials(success = credentialsResponse) + return autofillDataAdapterCredentialsAvailable.toJson(topLevelResponse) + } + + override fun generateEmptyResponseGetAutofillData(): String { + val credentialsResponse = EmptyResponse.EmptyCredentialResponse() + val topLevelResponse = EmptyResponse(success = credentialsResponse) + return autofillDataAdapterCredentialsUnavailable.toJson(topLevelResponse) + } + + override fun generateResponseGetAvailableInputTypes(credentialsAvailable: Boolean, emailAvailable: Boolean): String { + val availableInputTypes = AvailableInputSuccessResponse(credentialsAvailable, emailAvailable) + return availableInputTypesAdapter.toJson(availableInputTypes) + } + + /* + * hardcoded for now, but eventually will be a dump of the most up-to-date privacy remote config, untouched by us + */ + override fun generateContentScope(): String { + return """ + contentScope = { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [] + } + }, + "unprotectedTemporary": [] + }; + """.trimIndent() + } + + /* + * userUnprotectedDomains: any sites for which the user has chosen to disable privacy protections (leave empty for now) + */ + override fun generateUserUnprotectedDomains(): String { + return """ + userUnprotectedDomains = []; + """.trimIndent() + } + + override fun generateUserPreferences( + autofillCredentials: Boolean, + showInlineKeyIcon: Boolean + ): String { + return """ + userPreferences = { + "debug": false, + "platform": { + "name": "android" + }, + "features": { + "autofill": { + "settings": { + "featureToggles": { + "inputType_credentials": $autofillCredentials, + "inputType_identities": false, + "inputType_creditCards": false, + "emailProtection": true, + "password_generation": false, + "credentials_saving": $autofillCredentials, + "inlineIcon_credentials": $showInlineKeyIcon + } + } + } + } + }; + """.trimIndent() + } + +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/CredentialAutofillDialogAndroidFactory.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/CredentialAutofillDialogAndroidFactory.kt new file mode 100644 index 000000000000..e3f03c6f94f9 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/CredentialAutofillDialogAndroidFactory.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 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.ui + +import com.duckduckgo.autofill.CredentialAutofillDialogFactory +import com.duckduckgo.autofill.CredentialAutofillPickerDialog +import com.duckduckgo.autofill.CredentialSavePickerDialog +import com.duckduckgo.autofill.CredentialUpdateExistingCredentialsDialog +import com.duckduckgo.autofill.domain.app.LoginCredentials +import com.duckduckgo.autofill.ui.credential.saving.AutofillSavingCredentialsDialogFragment +import com.duckduckgo.autofill.ui.credential.saving.AutofillSavingUpdatingExistingCredentialsDialogFragment +import com.duckduckgo.autofill.ui.credential.selecting.AutofillSelectCredentialsDialogFragment +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class CredentialAutofillDialogAndroidFactory @Inject constructor() : CredentialAutofillDialogFactory { + + override fun autofillSelectCredentialsDialog(url: String, credentials: List): CredentialAutofillPickerDialog { + return AutofillSelectCredentialsDialogFragment.instance(url, credentials) + } + + override fun autofillSavingCredentialsDialog(url: String, credentials: LoginCredentials): CredentialSavePickerDialog { + return AutofillSavingCredentialsDialogFragment.instance(url, credentials) + } + + override fun autofillSavingUpdateCredentialsDialog(url: String, credentials: LoginCredentials): CredentialUpdateExistingCredentialsDialog { + return AutofillSavingUpdatingExistingCredentialsDialogFragment.instance(url, credentials) + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/ExistingCredentialStoreInterrogatingMatchDetector.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/ExistingCredentialStoreInterrogatingMatchDetector.kt new file mode 100644 index 000000000000..05b47f1ac3c2 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/ExistingCredentialStoreInterrogatingMatchDetector.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 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.ui + +import com.duckduckgo.autofill.store.AutofillStore +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class ExistingCredentialStoreInterrogatingMatchDetector @Inject constructor(private val autofillStore: AutofillStore) : + ExistingCredentialMatchDetector { + + override suspend fun determine(currentUrl: String, username: String, password: String): AutofillStore.ContainsCredentialsResult { + return autofillStore.containsCredentials(currentUrl, username, password) + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/management/AutofillManagementActivity.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/management/AutofillManagementActivity.kt new file mode 100644 index 000000000000..2d0d4e4a1fe9 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/management/AutofillManagementActivity.kt @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2022 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.ui.credential.management + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.WindowManager +import androidx.fragment.app.commit +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.global.DuckDuckGoActivity +import com.duckduckgo.autofill.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.R +import com.duckduckgo.autofill.impl.databinding.ActivityAutofillSettingsBinding +import com.duckduckgo.autofill.ui.AutofillSettingsActivityLauncher +import com.duckduckgo.autofill.ui.credential.management.AutofillSettingsViewModel.Command.* +import com.duckduckgo.autofill.ui.credential.management.viewing.AutofillManagementDisabledMode +import com.duckduckgo.autofill.ui.credential.management.viewing.AutofillManagementEditMode +import com.duckduckgo.autofill.ui.credential.management.viewing.AutofillManagementLockedMode +import com.duckduckgo.autofill.ui.credential.management.viewing.AutofillManagementListMode +import com.duckduckgo.deviceauth.api.DeviceAuthenticator +import com.duckduckgo.deviceauth.api.DeviceAuthenticator.AuthResult +import com.duckduckgo.deviceauth.api.DeviceAuthenticator.Features.AUTOFILL +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.mobile.android.ui.viewbinding.viewBinding +import com.google.android.material.snackbar.Snackbar +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@InjectWith(ActivityScope::class) +class AutofillManagementActivity : DuckDuckGoActivity() { + + private val binding: ActivityAutofillSettingsBinding by viewBinding() + private val viewModel: AutofillSettingsViewModel by bindViewModel() + + private var inEditMode: Boolean = false + + @Inject + lateinit var deviceAuthenticator: DeviceAuthenticator + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + setContentView(binding.root) + setupToolbar(binding.includeToolbar.toolbar) + observeViewModel() + setTitle(R.string.managementScreenTitle) + } + + override fun onStart() { + super.onStart() + viewModel.launchDeviceAuth() + } + + override fun onStop() { + super.onStop() + viewModel.lock() + } + + private fun launchDeviceAuth() { + if (deviceAuthenticator.hasValidDeviceAuthentication()) { + deviceAuthenticator.authenticate(AUTOFILL, this) { + if (it == AuthResult.Success) { + viewModel.unlock() + showListMode() + } else { + finish() + } + } + } else { + viewModel.disabled() + } + } + + private fun observeViewModel() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.viewState.collect { state -> + processState(state) + } + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.commands.collect { commands -> + commands.forEach { processCommand(it) } + } + } + } + } + + private fun processState(state: AutofillSettingsViewModel.ViewState) { + if (state.isLocked) { + showLockMode() + } + } + + private fun processCommand(command: AutofillSettingsViewModel.Command) { + var processed = true + when (command) { + is ShowListMode -> showListMode() + is ShowEditMode -> showEditMode(command.credentials) + is ShowUserUsernameCopied -> showCopiedToClipboardSnackbar("Username") + is ShowUserPasswordCopied -> showCopiedToClipboardSnackbar("Password") + is ShowDisabledMode -> showDisabledMode() + is LaunchDeviceAuth -> launchDeviceAuth() + else -> processed = false + } + if (processed) { + Timber.v("Processed command $command") + viewModel.commandProcessed(command) + } + } + + private fun showCopiedToClipboardSnackbar(type: String) { + Snackbar.make(binding.root, "$type copied to clipboard", Snackbar.LENGTH_SHORT).show() + } + + private fun showListMode() { + Timber.e("Show view mode") + supportFragmentManager.commit { + setReorderingAllowed(true) + replace(R.id.fragment_container_view, AutofillManagementListMode.instance()) + } + inEditMode = false + } + + private fun showEditMode(credentials: LoginCredentials) { + Timber.e("Show edit mode") + supportFragmentManager.commit { + setReorderingAllowed(true) + replace(R.id.fragment_container_view, AutofillManagementEditMode.instance(credentials)) + } + + inEditMode = true + } + + private fun showLockMode() { + supportFragmentManager.commit { + setReorderingAllowed(true) + replace( + R.id.fragment_container_view, + AutofillManagementLockedMode.instance() + ) + } + inEditMode = false + } + + private fun showDisabledMode() { + supportFragmentManager.commit { + setReorderingAllowed(true) + replace(R.id.fragment_container_view, AutofillManagementDisabledMode.instance()) + } + inEditMode = false + } + + override fun onBackPressed() { + if (inEditMode) { + showListMode() + } else { + super.onBackPressed() + } + } + + companion object { + fun intent(context: Context): Intent { + return Intent(context, AutofillManagementActivity::class.java) + } + } +} + +@ContributesTo(AppScope::class) +@Module +class AutofillSettingsModule { + + @Provides + fun activityLauncher(): AutofillSettingsActivityLauncher { + return object : AutofillSettingsActivityLauncher { + override fun intent(context: Context): Intent { + return AutofillManagementActivity.intent(context) + } + } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/management/AutofillManagementRecyclerAdapter.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/management/AutofillManagementRecyclerAdapter.kt new file mode 100644 index 000000000000..760ed3e5856d --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/management/AutofillManagementRecyclerAdapter.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2022 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.ui.credential.management + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.Adapter +import com.duckduckgo.app.browser.favicon.FaviconManager +import com.duckduckgo.autofill.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.databinding.ItemRowAutofillCredentialsManagementScreenBinding +import kotlinx.coroutines.launch + +class AutofillManagementRecyclerAdapter( + val lifecycleOwner: LifecycleOwner, + val faviconManager: FaviconManager, + val onCredentialSelected: (credentials: LoginCredentials) -> Unit, + val onCopyUsername: (credentials: LoginCredentials) -> Unit, + val onCopyPassword: (credentials: LoginCredentials) -> Unit +) : Adapter() { + + private var credentials = listOf() + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): CredentialsViewHolder { + val binding = ItemRowAutofillCredentialsManagementScreenBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return CredentialsViewHolder(binding) + } + + override fun onBindViewHolder( + viewHolder: CredentialsViewHolder, + position: Int + ) { + val credentials = credentials[position] + + with(viewHolder.binding) { + username.text = credentials.username + domain.text = credentials.domain + + root.setOnClickListener { onCredentialSelected(credentials) } + + updateFavicon(credentials) + } + + } + + private fun ItemRowAutofillCredentialsManagementScreenBinding.updateFavicon(credentials: LoginCredentials) { + val domain = credentials.domain + if (domain == null) { + favicon.setImageBitmap(null) + } else { + lifecycleOwner.lifecycleScope.launch { + faviconManager.loadToViewFromLocalOrFallback(url = domain, view = favicon) + } + } + } + + fun updateLogins(list: List) { + credentials = list + notifyDataSetChanged() + } + + override fun getItemCount(): Int = credentials.size + + class CredentialsViewHolder(val binding: ItemRowAutofillCredentialsManagementScreenBinding) : RecyclerView.ViewHolder(binding.root) +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/management/AutofillSettingsViewModel.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/management/AutofillSettingsViewModel.kt new file mode 100644 index 000000000000..5e24f0f97b42 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/management/AutofillSettingsViewModel.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2022 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.ui.credential.management + +import android.annotation.SuppressLint +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.autofill.domain.app.LoginCredentials +import com.duckduckgo.autofill.store.AutofillStore +import com.duckduckgo.autofill.ui.credential.management.AutofillSettingsViewModel.Command.* +import com.duckduckgo.di.scopes.ActivityScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import java.util.* +import javax.inject.Inject + +@SuppressLint("StaticFieldLeak") +@ContributesViewModel(ActivityScope::class) +class AutofillSettingsViewModel @Inject constructor( + private val applicationContext: Context, + private val autofillStore: AutofillStore, +) : ViewModel() { + + private val _viewState = MutableStateFlow(ViewState()) + val viewState: StateFlow = _viewState + + private val _commands = MutableStateFlow>(emptyList()) + val commands: StateFlow> = _commands + + fun onCopyUsername(username: String?) { + username?.copyToClipboard() + addCommand(ShowUserUsernameCopied()) + } + + fun onCopyPassword(password: String?) { + password?.copyToClipboard() + addCommand(ShowUserPasswordCopied()) + } + + fun onEditCredentials(credentials: LoginCredentials) { + addCommand(ShowEditMode(credentials)) + } + + fun launchDeviceAuth() { + addCommand(LaunchDeviceAuth) + } + + fun lock() { + if (!viewState.value.isLocked) { + _viewState.value = viewState.value.copy(isLocked = true) + } + } + + fun unlock() { + _viewState.value = viewState.value.copy(isLocked = false) + } + + fun disabled() { + addCommand(ShowDisabledMode) + } + + private fun addCommand(command: Command) { + Timber.v("Adding command %s", command) + commands.value.let { commands -> + val updatedList = commands + command + _commands.value = updatedList + } + } + + fun commandProcessed(command: Command) { + commands.value.let { currentCommands -> + val updatedList = currentCommands.filterNot { it.id == command.id } + _commands.value = updatedList + } + } + + private fun String.copyToClipboard() { + clipboardManager().setPrimaryClip(ClipData.newPlainText("", this)) + } + + private fun clipboardManager(): ClipboardManager { + return applicationContext.getSystemService(ClipboardManager::class.java) + } + + fun observeCredentials() { + viewModelScope.launch { + _viewState.value = _viewState.value.copy(autofillEnabled = autofillStore.autofillEnabled) + + autofillStore.getAllCredentials().collect { credentials -> + _viewState.value = _viewState.value.copy( + logins = credentials + ) + } + } + } + + fun onDeleteCredentials(credentials: LoginCredentials) { + val credentialsId = credentials.id ?: return + + viewModelScope.launch { + autofillStore.deleteCredentials(credentialsId) + } + + addCommand(ShowListMode) + } + + fun updateCredentials(updatedCredentials: LoginCredentials) { + viewModelScope.launch { + autofillStore.updateCredentials(updatedCredentials) + } + } + + fun onEnableAutofill() { + autofillStore.autofillEnabled = true + _viewState.value = viewState.value.copy(autofillEnabled = true) + } + + fun onDisableAutofill() { + autofillStore.autofillEnabled = false + _viewState.value = viewState.value.copy(autofillEnabled = false) + } + + data class ViewState( + val autofillEnabled: Boolean = true, + val logins: List = emptyList(), + val isLocked: Boolean = true + ) + + sealed class Command(val id: String = UUID.randomUUID().toString()) { + class ShowUserUsernameCopied : Command() + class ShowUserPasswordCopied : Command() + data class ShowEditMode(val credentials: LoginCredentials) : Command() + object ShowListMode : Command() + object ShowDisabledMode : Command() + object LaunchDeviceAuth : Command() + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/management/viewing/AutofillManagementDisabledMode.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/management/viewing/AutofillManagementDisabledMode.kt new file mode 100644 index 000000000000..aa425300f719 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/management/viewing/AutofillManagementDisabledMode.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2022 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.ui.credential.management.viewing + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.autofill.impl.databinding.FragmentAutofillManagementDisabledBinding +import com.duckduckgo.di.scopes.FragmentScope +import dagger.android.support.AndroidSupportInjection +import javax.inject.Inject + +@InjectWith(FragmentScope::class) +class AutofillManagementDisabledMode : Fragment() { + + @Inject + lateinit var appBuildConfig: AppBuildConfig + + private lateinit var binding: FragmentAutofillManagementDisabledBinding + + override fun onAttach(context: Context) { + AndroidSupportInjection.inject(this) + super.onAttach(context) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentAutofillManagementDisabledBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle? + ) { + super.onViewCreated(view, savedInstanceState) + binding.disabledCta.setOnClickListener { + launchDeviceAuthEnrollment() + } + } + + @SuppressLint("InlinedApi", "DEPRECATION") + private fun launchDeviceAuthEnrollment() { + when { + appBuildConfig.manufacturer == "Xiaomi" -> + // Issue on Xiaomi: https://stackoverflow.com/questions/68484485/intent-action-fingerprint-enroll-on-redmi-results-in-exception + requireActivity().startActivity(Intent(android.provider.Settings.ACTION_SETTINGS)) + appBuildConfig.sdkInt >= Build.VERSION_CODES.R -> + requireActivity().startActivity(Intent(android.provider.Settings.ACTION_BIOMETRIC_ENROLL)) + appBuildConfig.sdkInt >= Build.VERSION_CODES.P -> + requireActivity().startActivity(Intent(android.provider.Settings.ACTION_FINGERPRINT_ENROLL)) + else -> + requireActivity().startActivity(Intent(android.provider.Settings.ACTION_SECURITY_SETTINGS)) + } + + requireActivity().finish() + } + + companion object { + fun instance() = AutofillManagementDisabledMode() + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/management/viewing/AutofillManagementEditMode.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/management/viewing/AutofillManagementEditMode.kt new file mode 100644 index 000000000000..086fc086d81f --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/management/viewing/AutofillManagementEditMode.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2022 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.ui.credential.management.viewing + +import android.content.Context +import android.os.Bundle +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.browser.favicon.FaviconManager +import com.duckduckgo.app.global.FragmentViewModelFactory +import com.duckduckgo.autofill.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.databinding.FragmentAutofillManagementEditModeBinding +import com.duckduckgo.autofill.ui.credential.management.AutofillSettingsViewModel +import com.duckduckgo.autofill.ui.credential.management.AutofillSettingsViewModel.Command +import com.duckduckgo.autofill.ui.credential.management.AutofillSettingsViewModel.Command.* +import com.duckduckgo.di.scopes.FragmentScope +import dagger.android.support.AndroidSupportInjection +import kotlinx.android.synthetic.main.fragment_autofill_management_edit_mode.* +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@InjectWith(FragmentScope::class) +class AutofillManagementEditMode : Fragment() { + + @Inject + lateinit var faviconManager: FaviconManager + + @Inject + lateinit var viewModelFactory: FragmentViewModelFactory + + val viewModel by lazy { + ViewModelProvider(requireActivity(), viewModelFactory)[AutofillSettingsViewModel::class.java] + } + + private lateinit var binding: FragmentAutofillManagementEditModeBinding + + override fun onAttach(context: Context) { + AndroidSupportInjection.inject(this) + super.onAttach(context) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + binding = FragmentAutofillManagementEditModeBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + observeViewModel() + populateFields(getCredentials()) + configureUiEventHandlers() + } + + private fun configureUiEventHandlers() { + binding.saveButton.setOnClickListener { saveCredentials() } + binding.deleteButton.setOnClickListener { deleteCredentials() } + binding.copyUsernameButton.setOnClickListener { copyUsername() } + binding.copyPasswordButton.setOnClickListener { copyPassword() } + } + + private fun copyUsername() { + viewModel.onCopyUsername(binding.usernameEditText.text.toString()) + } + + private fun copyPassword() { + viewModel.onCopyPassword(binding.passwordEditText.text.toString()) + } + + private fun saveCredentials() { + val updatedCredentials = getCredentials().copy( + username = binding.usernameEditText.text.toString(), + password = binding.passwordEditText.text.toString(), + ) + viewModel.updateCredentials(updatedCredentials) + } + + private fun deleteCredentials() { + viewModel.onDeleteCredentials(getCredentials()) + } + + private fun populateFields(credentials: LoginCredentials) { + binding.usernameEditText.setText(credentials.username) + binding.passwordEditText.setText(credentials.password) + binding.domainEditText.setText(credentials.domain) + } + + private fun getCredentials(): LoginCredentials { + return requireArguments().getParcelable(EXTRA_KEY_CREDENTIALS)!! + } + + private fun observeViewModel() { + lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.viewState.collect { state -> + } + } + } + } + + private fun processCommand(command: Command) { + var processed = true + when (command) { + else -> processed = false + } + if (processed) { + Timber.v("Processed command $command") + viewModel.commandProcessed(command) + } + } + + companion object { + + private const val EXTRA_KEY_CREDENTIALS = "credentials" + + fun instance(credentials: LoginCredentials) = + AutofillManagementEditMode().apply { + arguments = Bundle().apply { + putParcelable(EXTRA_KEY_CREDENTIALS, credentials) + } + } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/management/viewing/AutofillManagementListMode.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/management/viewing/AutofillManagementListMode.kt new file mode 100644 index 000000000000..9c118d435a7b --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/management/viewing/AutofillManagementListMode.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2022 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.ui.credential.management.viewing + +import android.content.Context +import android.os.Bundle +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.CompoundButton +import android.widget.RadioGroup.OnCheckedChangeListener +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.browser.favicon.FaviconManager +import com.duckduckgo.app.global.FragmentViewModelFactory +import com.duckduckgo.autofill.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.databinding.FragmentAutofillManagementListModeBinding +import com.duckduckgo.autofill.ui.credential.management.AutofillManagementRecyclerAdapter +import com.duckduckgo.autofill.ui.credential.management.AutofillSettingsViewModel +import com.duckduckgo.autofill.ui.credential.management.AutofillSettingsViewModel.Command.* +import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.mobile.android.ui.view.quietlySetIsChecked +import dagger.android.support.AndroidSupportInjection +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@InjectWith(FragmentScope::class) +class AutofillManagementListMode : Fragment() { + + @Inject + lateinit var faviconManager: FaviconManager + + @Inject + lateinit var viewModelFactory: FragmentViewModelFactory + + val viewModel by lazy { + ViewModelProvider(requireActivity(), viewModelFactory)[AutofillSettingsViewModel::class.java] + } + + private lateinit var binding: FragmentAutofillManagementListModeBinding + private lateinit var adapter: AutofillManagementRecyclerAdapter + + private val globalAutofillToggleListener = object : CompoundButton.OnCheckedChangeListener { + + override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) { + if (isChecked) viewModel.onEnableAutofill() else viewModel.onDisableAutofill() + } + } + + override fun onAttach(context: Context) { + AndroidSupportInjection.inject(this) + super.onAttach(context) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + binding = FragmentAutofillManagementListModeBinding.inflate(inflater, container, false) + configureToggle() + configureRecyclerView() + return binding.root + } + + private fun configureToggle() { + binding.enabledToggle.setOnCheckedChangeListener(globalAutofillToggleListener) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + observeViewModel() + } + + private fun observeViewModel() { + lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.viewState.collect { state -> + binding.enabledToggle.quietlySetIsChecked(state.autofillEnabled, globalAutofillToggleListener) + credentialsListUpdated(state.logins) + } + } + } + lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.commands.collect { commands -> + commands.forEach { processCommand(it) } + } + } + } + + viewModel.observeCredentials() + } + + private fun processCommand(command: AutofillSettingsViewModel.Command) { + var processed = true + when (command) { + else -> processed = false + } + if (processed) { + Timber.v("Processed command $command") + viewModel.commandProcessed(command) + } + } + + private fun credentialsListUpdated(credentials: List) { + adapter.updateLogins(credentials) + } + + private fun configureRecyclerView() { + adapter = AutofillManagementRecyclerAdapter( + this, faviconManager, + onCredentialSelected = this::onCredentialsSelected, + onCopyUsername = this::onCopyUsername, + onCopyPassword = this::onCopyPassword, + ) + + binding.logins.adapter = adapter + } + + private fun onCredentialsSelected(credentials: LoginCredentials) { + viewModel.onEditCredentials(credentials) + } + + private fun onCopyUsername(credentials: LoginCredentials) { + viewModel.onCopyUsername(credentials.username) + } + + private fun onCopyPassword(credentials: LoginCredentials) { + viewModel.onCopyPassword(credentials.password) + } + + companion object { + fun instance() = + AutofillManagementListMode().apply { + arguments = Bundle().apply { + } + } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/management/viewing/AutofillManagementLockedMode.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/management/viewing/AutofillManagementLockedMode.kt new file mode 100644 index 000000000000..a0ac15d52fec --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/management/viewing/AutofillManagementLockedMode.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022 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.ui.credential.management.viewing + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.duckduckgo.autofill.impl.databinding.FragmentAutofillManagementLockedBinding + +class AutofillManagementLockedMode : Fragment() { + private lateinit var binding: FragmentAutofillManagementLockedBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentAutofillManagementLockedBinding.inflate(inflater, container, false) + return binding.root + } + + companion object { + fun instance() = AutofillManagementLockedMode() + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/saving/AutofillSavingCredentialsDialogFragment.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/saving/AutofillSavingCredentialsDialogFragment.kt new file mode 100644 index 000000000000..4dcaec738acd --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/ui/credential/saving/AutofillSavingCredentialsDialogFragment.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2022 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.ui.credential.saving + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.setFragmentResult +import androidx.lifecycle.lifecycleScope +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.browser.favicon.FaviconManager +import com.duckduckgo.app.global.extractDomain +import com.duckduckgo.autofill.CredentialSavePickerDialog +import com.duckduckgo.autofill.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.R +import com.duckduckgo.autofill.impl.databinding.ContentAutofillSaveNewCredentialsBinding +import com.duckduckgo.di.scopes.FragmentScope +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dagger.android.support.AndroidSupportInjection +import kotlinx.coroutines.launch +import javax.inject.Inject + +@InjectWith(FragmentScope::class) +class AutofillSavingCredentialsDialogFragment : BottomSheetDialogFragment(), CredentialSavePickerDialog { + + @Inject + lateinit var faviconManager: FaviconManager + + override fun onAttach(context: Context) { + AndroidSupportInjection.inject(this) + super.onAttach(context) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val binding = ContentAutofillSaveNewCredentialsBinding.inflate(inflater, container, false) + configureViews(binding) + return binding.root + } + + private fun configureViews(binding: ContentAutofillSaveNewCredentialsBinding) { + configureSiteDetails(binding) + } + + private fun configureSiteDetails(binding: ContentAutofillSaveNewCredentialsBinding) { + val originalUrl = getOriginalUrl() + val url = originalUrl.extractDomain() ?: originalUrl + + binding.siteName.text = url + + lifecycleScope.launch { + faviconManager.loadToViewFromLocalOrFallback(url = url, view = binding.favicon) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + view.findViewById