diff --git a/app/build.gradle b/app/build.gradle index d5f75aff4631..85f6db8d25cb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -192,6 +192,9 @@ dependencies { // Lottie implementation "com.airbnb.android:lottie:_" + // Security crypto + implementation AndroidX.security.crypto + // Play Store referrer library playImplementation("com.android.installreferrer:installreferrer:_") 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 f6434a8f71a0..2c5f345717bf 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -60,6 +60,7 @@ import com.duckduckgo.app.cta.db.DismissedCtaDao import com.duckduckgo.app.cta.model.CtaId import com.duckduckgo.app.cta.model.DismissedCta import com.duckduckgo.app.cta.ui.* +import com.duckduckgo.app.email.EmailManager import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository @@ -113,9 +114,12 @@ import dagger.Lazy import io.reactivex.Observable import io.reactivex.Single import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking @@ -134,6 +138,7 @@ import java.io.File import java.util.* import java.util.concurrent.TimeUnit +@FlowPreview @ExperimentalCoroutinesApi class BrowserTabViewModelTest { @@ -237,6 +242,9 @@ class BrowserTabViewModelTest { @Mock private lateinit var fireproofDialogsEventHandler: FireproofDialogsEventHandler + @Mock + private lateinit var mockEmailManager: EmailManager + private val lazyFaviconManager = Lazy { mockFaviconManager } private lateinit var mockAutoCompleteApi: AutoCompleteApi @@ -266,6 +274,8 @@ class BrowserTabViewModelTest { private val childClosedTabsFlow = childClosedTabsSharedFlow.asSharedFlow() + private val emailStateFlow = MutableStateFlow(false) + @Before fun before() { MockitoAnnotations.openMocks(this) @@ -280,6 +290,7 @@ class BrowserTabViewModelTest { whenever(mockDismissedCtaDao.dismissedCtas()).thenReturn(dismissedCtaDaoChannel.consumeAsFlow()) whenever(mockTabRepository.flowTabs).thenReturn(flowOf(emptyList())) + whenever(mockEmailManager.signedInFlow()).thenReturn(emailStateFlow.asStateFlow()) ctaViewModel = CtaViewModel( mockAppInstallStore, @@ -345,7 +356,8 @@ class BrowserTabViewModelTest { variantManager = mockVariantManager, fileDownloader = mockFileDownloader, globalPrivacyControl = GlobalPrivacyControlManager(mockSettingsStore), - fireproofDialogsEventHandler = fireproofDialogsEventHandler + fireproofDialogsEventHandler = fireproofDialogsEventHandler, + emailManager = mockEmailManager ) testee.loadData("abc", null, false) @@ -3150,6 +3162,98 @@ class BrowserTabViewModelTest { assertCommandNotIssued() } + @Test + fun whenConsumeAliasAndCopyToClipboardThenCopyAliasToClipboardCommandSent() { + whenever(mockEmailManager.getAlias()).thenReturn("alias") + + testee.consumeAliasAndCopyToClipboard() + + assertCommandIssued() + } + + @Test + fun whenEmailIsSignedOutThenIsEmailSignedInReturnsFalse() = coroutineRule.runBlocking { + emailStateFlow.emit(false) + + assertFalse(browserViewState().isEmailSignedIn) + } + + @Test + fun whenEmailIsSignedInThenIsEmailSignedInReturnsTrue() = coroutineRule.runBlocking { + emailStateFlow.emit(true) + + assertTrue(browserViewState().isEmailSignedIn) + } + + @Test + fun whenConsumeAliasThenInjectAddressCommandSent() { + whenever(mockEmailManager.getAlias()).thenReturn("alias") + + testee.consumeAlias() + + assertCommandIssued { + assertEquals("alias", this.address) + } + } + + @Test + fun whenConsumeAliasThenPixelSent() { + whenever(mockEmailManager.getAlias()).thenReturn("alias") + + testee.consumeAlias() + + verify(mockPixel).enqueueFire(AppPixelName.EMAIL_USE_ALIAS) + } + + @Test + fun whenCancelAutofillTooltipThenPixelSent() { + whenever(mockEmailManager.getAlias()).thenReturn("alias") + + testee.cancelAutofillTooltip() + + verify(mockPixel).enqueueFire(AppPixelName.EMAIL_TOOLTIP_DISMISSED) + } + + @Test + fun whenUseAddressThenInjectAddressCommandSent() { + whenever(mockEmailManager.getEmailAddress()).thenReturn("address") + + testee.useAddress() + + assertCommandIssued { + assertEquals("address", this.address) + } + } + + @Test + fun whenUseAddressThenPixelSent() { + whenever(mockEmailManager.getEmailAddress()).thenReturn("address") + + testee.useAddress() + + verify(mockPixel).enqueueFire(AppPixelName.EMAIL_USE_ADDRESS) + } + + @Test + fun whenShowEmailTooltipIfAddressExistsThenShowEmailTooltipCommandSent() { + whenever(mockEmailManager.getEmailAddress()).thenReturn("address") + + testee.showEmailTooltip() + + assertCommandIssued { + assertEquals("address", this.address) + } + } + + @Test + fun whenShowEmailTooltipIfAddressDoesNotExistThenCommandNotSent() { + whenever(mockEmailManager.getEmailAddress()).thenReturn(null) + + testee.showEmailTooltip() + + assertCommandNotIssued() + } + private suspend fun givenFireButtonPulsing() { whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.DAX_ONBOARDING) dismissedCtaDaoChannel.send(listOf(DismissedCta(CtaId.DAX_DIALOG_TRACKERS_FOUND))) 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 ce790866f642..9dc3d8916caa 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt @@ -30,6 +30,7 @@ import com.duckduckgo.app.browser.httpauth.WebViewHttpAuthStore 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.email.EmailInjector import com.duckduckgo.app.global.exception.UncaughtExceptionRepository import com.duckduckgo.app.globalprivacycontrol.GlobalPrivacyControl import com.duckduckgo.app.runBlocking @@ -64,6 +65,7 @@ class BrowserWebViewClientTest { private val trustedCertificateStore: TrustedCertificateStore = mock() private val webViewHttpAuthStore: WebViewHttpAuthStore = mock() private val thirdPartyCookieManager: ThirdPartyCookieManager = mock() + private val emailInjector: EmailInjector = mock() @UiThreadTest @Before @@ -83,7 +85,8 @@ class BrowserWebViewClientTest { globalPrivacyControl, thirdPartyCookieManager, GlobalScope, - coroutinesTestRule.testDispatcherProvider + coroutinesTestRule.testDispatcherProvider, + emailInjector ) testee.webViewClientListener = listener } @@ -197,6 +200,20 @@ class BrowserWebViewClientTest { verify(listener, never()).prefetchFavicon(any()) } + @UiThreadTest + @Test + fun whenOnPageFinishedCalledThenInjectEmailAutofillJsCalled() { + testee.onPageFinished(webView, null) + verify(emailInjector).injectEmailAutofillJs(webView, null) + } + + @UiThreadTest + @Test + fun whenOnPageStartedCalledThenResetInjectedJsFlagCalled() { + testee.onPageStarted(webView, null, null) + verify(emailInjector).resetInjectedJsFlag() + } + private class TestWebView(context: Context) : WebView(context) companion object { diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetectorTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetectorTest.kt index 11a8223189b6..160d357492b9 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetectorTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetectorTest.kt @@ -118,4 +118,23 @@ class DuckDuckGoUrlDetectorTest { assertFalse(testee.isDuckDuckGoStaticUrl("https://example.com/settings")) } + @Test + fun whenDomainIsNotDuckDuckGoThenReturnFalse() { + assertFalse(testee.isDuckDuckGoDomain("https://example.com")) + } + + @Test + fun whenDomainIsDuckDuckGoThenReturnTrue() { + assertTrue(testee.isDuckDuckGoDomain("https://duckduckgo.com")) + } + + @Test + fun whenUrlContainsSubdomainAndIsFromDuckDuckGoDomainThenReturnTrue() { + assertTrue(testee.isDuckDuckGoDomain("https://test.duckduckgo.com")) + } + + @Test + fun whenUrlHasNoSchemeAndIsFromDuckDuckGoDomainThenReturnsTrue() { + assertTrue(testee.isDuckDuckGoDomain("duckduckgo.com")) + } } diff --git a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt index 638c05ef33fb..5f5b145920cd 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt @@ -143,7 +143,7 @@ class CtaViewModelTest { @Before fun before() { - MockitoAnnotations.initMocks(this) + MockitoAnnotations.openMocks(this) db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) .allowMainThreadQueries() .build() diff --git a/app/src/androidTest/java/com/duckduckgo/app/email/AppEmailManagerTest.kt b/app/src/androidTest/java/com/duckduckgo/app/email/AppEmailManagerTest.kt new file mode 100644 index 000000000000..9c19ce82a59b --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/email/AppEmailManagerTest.kt @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2020 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.email + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.duckduckgo.app.CoroutineTestRule +import com.duckduckgo.app.email.AppEmailManager.Companion.DUCK_EMAIL_DOMAIN +import com.duckduckgo.app.email.api.EmailAlias +import com.duckduckgo.app.email.api.EmailService +import com.duckduckgo.app.email.db.EmailDataStore +import com.duckduckgo.app.runBlocking +import com.nhaarman.mockitokotlin2.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestCoroutineScope +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@FlowPreview +@ExperimentalCoroutinesApi +class AppEmailManagerTest { + + @get:Rule + var coroutineRule = CoroutineTestRule() + + @get:Rule + var instantTaskExecutorRule = InstantTaskExecutorRule() + + private val mockEmailService: EmailService = mock() + private val mockEmailDataStore: EmailDataStore = mock() + private val aliasSharedFlow = MutableStateFlow(null) + lateinit var testee: AppEmailManager + + @Before + fun setup() { + whenever(mockEmailDataStore.nextAliasFlow()).thenReturn(aliasSharedFlow.asStateFlow()) + testee = AppEmailManager(mockEmailService, mockEmailDataStore, coroutineRule.testDispatcherProvider, TestCoroutineScope()) + } + + @Test + fun whenFetchAliasFromServiceThenStoreAliasAddingDuckDomain() = coroutineRule.runBlocking { + whenever(mockEmailDataStore.emailToken).thenReturn("token") + whenever(mockEmailService.newAlias(any())).thenReturn(EmailAlias("test")) + testee.getAlias() + + verify(mockEmailDataStore).nextAlias = "test$DUCK_EMAIL_DOMAIN" + } + + @Test + fun whenFetchAliasFromServiceAndTokenDoesNotExistThenDoNothing() = coroutineRule.runBlocking { + whenever(mockEmailDataStore.emailToken).thenReturn(null) + testee.getAlias() + + verify(mockEmailService, never()).newAlias(any()) + } + + @Test + fun whenFetchAliasFromServiceAndAddressIsBlankThenStoreNullTwice() = coroutineRule.runBlocking { + whenever(mockEmailDataStore.emailToken).thenReturn("token") + whenever(mockEmailService.newAlias(any())).thenReturn(EmailAlias("")) + testee.getAlias() + + verify(mockEmailDataStore, times(2)).nextAlias = null + } + + @Test + fun whenGetAliasThenReturnNextAlias() = coroutineRule.runBlocking { + givenNextAliasExists() + + assertEquals("alias", testee.getAlias()) + } + + @Test + fun whenGetAliasIfNextAliasDoesNotExistThenReturnNull() { + assertNull(testee.getAlias()) + } + + @Test + fun whenGetAliasThenClearNextAlias() { + testee.getAlias() + + verify(mockEmailDataStore).nextAlias = null + } + + @Test + fun whenIsSignedInAndTokenDoesNotExistThenReturnFalse() { + whenever(mockEmailDataStore.emailUsername).thenReturn("username") + whenever(mockEmailDataStore.nextAlias).thenReturn("alias") + + assertFalse(testee.isSignedIn()) + } + + @Test + fun whenIsSignedInAndUsernameDoesNotExistThenReturnFalse() { + whenever(mockEmailDataStore.emailToken).thenReturn("token") + whenever(mockEmailDataStore.nextAlias).thenReturn("alias") + + assertFalse(testee.isSignedIn()) + } + + @Test + fun whenIsSignedInAndTokenAndUsernameExistThenReturnTrue() { + whenever(mockEmailDataStore.emailToken).thenReturn("token") + whenever(mockEmailDataStore.emailUsername).thenReturn("username") + + assertTrue(testee.isSignedIn()) + } + + @Test + fun whenStoreCredentialsThenGenerateNewAlias() = coroutineRule.runBlocking { + whenever(mockEmailDataStore.emailToken).thenReturn("token") + whenever(mockEmailService.newAlias(any())).thenReturn(EmailAlias("")) + + testee.storeCredentials("token", "username") + + verify(mockEmailService).newAlias(any()) + } + + @Test + fun whenStoreCredentialsThenCredentialsAreStoredInDataStore() { + testee.storeCredentials("token", "username") + + verify(mockEmailDataStore).emailUsername = "username" + verify(mockEmailDataStore).emailToken = "token" + } + + @Test + fun whenStoreCredentialsThenIsSignedInChannelSendsTrue() = coroutineRule.runBlocking { + testee.storeCredentials("token", "username") + + assertTrue(testee.signedInFlow().first()) + } + + @Test + fun whenSignedOutThenClearEmailDataAndAliasIsNull() { + testee.signOut() + + verify(mockEmailDataStore).emailUsername = null + verify(mockEmailDataStore).emailToken = null + verify(mockEmailDataStore).nextAlias = null + assertNull(testee.getAlias()) + } + + @Test + fun whenSignedOutThenIsSignedInChannelSendsFalse() = coroutineRule.runBlocking { + testee.signOut() + + assertFalse(testee.signedInFlow().first()) + } + + @Test + fun whenGetEmailAddressThenDuckEmailDomainIsAppended() { + whenever(mockEmailDataStore.emailUsername).thenReturn("username") + + assertEquals("username$DUCK_EMAIL_DOMAIN", testee.getEmailAddress()) + } + + private suspend fun givenNextAliasExists() { + aliasSharedFlow.emit("alias") + } +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/email/EmailInjectorJsTest.kt b/app/src/androidTest/java/com/duckduckgo/app/email/EmailInjectorJsTest.kt new file mode 100644 index 000000000000..729089d678a4 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/email/EmailInjectorJsTest.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2020 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.email + +import android.webkit.WebView +import androidx.test.annotation.UiThreadTest +import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.app.browser.DuckDuckGoUrlDetector +import com.duckduckgo.app.browser.R +import com.nhaarman.mockitokotlin2.* +import org.junit.Before +import org.junit.Test + +class EmailInjectorJsTest { + + private val mockEmailManager: EmailManager = mock() + lateinit var testee: EmailInjectorJs + + @Before + fun setup() { + testee = EmailInjectorJs(mockEmailManager, DuckDuckGoUrlDetector()) + } + + @UiThreadTest + @Test + fun whenInjectEmailAutofillJsAndUrlIsFromDuckDuckGoDomainThenInjectJsCode() { + val jsToEvaluate = getJsToEvaluate() + val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) + + testee.injectEmailAutofillJs(webView, "https://duckduckgo.com") + + verify(webView).evaluateJavascript(jsToEvaluate, null) + } + + @UiThreadTest + @Test + fun whenInjectEmailAutofillJsAndUrlIsFromDuckDuckGoSubdomainThenInjectJsCode() { + val jsToEvaluate = getJsToEvaluate() + val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) + + testee.injectEmailAutofillJs(webView, "https://test.duckduckgo.com") + + verify(webView).evaluateJavascript(jsToEvaluate, null) + } + + @UiThreadTest + @Test + 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 + 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 + fun whenInjectEmailAutofillJsTwiceThenDoNotInjectJsCodeTwice() { + whenever(mockEmailManager.isSignedIn()).thenReturn(true) + val jsToEvaluate = getJsToEvaluate() + val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) + + testee.injectEmailAutofillJs(webView, "https://example.com") + testee.injectEmailAutofillJs(webView, "https://example.com") + + verify(webView, times(1)).evaluateJavascript(jsToEvaluate, null) + } + + @UiThreadTest + @Test + fun whenResetInjectedFlagCalledBetweenTwoInjectEmailJsCallsThenInjectJsCodeTwice() { + whenever(mockEmailManager.isSignedIn()).thenReturn(true) + val jsToEvaluate = getJsToEvaluate() + val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) + + testee.injectEmailAutofillJs(webView, "https://example.com") + testee.resetInjectedJsFlag() + testee.injectEmailAutofillJs(webView, "https://example.com") + + verify(webView, times(2)).evaluateJavascript(jsToEvaluate, null) + } + + @UiThreadTest + @Test + fun whenInjectAddressThenInjectJsCodeReplacingTheAlias() { + val address = "address" + val jsToEvaluate = getAliasJsToEvaluate().replace("%s", address) + val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) + + testee.injectAddressInEmailField(webView, address) + + verify(webView).evaluateJavascript(jsToEvaluate, null) + } + + private fun getJsToEvaluate(): String { + val js = InstrumentationRegistry.getInstrumentation().targetContext.resources.openRawResource(R.raw.autofill) + .bufferedReader() + .use { it.readText() } + return "javascript:$js" + } + + private fun getAliasJsToEvaluate(): String { + val js = InstrumentationRegistry.getInstrumentation().targetContext.resources.openRawResource(R.raw.inject_alias) + .bufferedReader() + .use { it.readText() } + return "javascript:$js" + } +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/email/EmailJavascriptInterfaceTest.kt b/app/src/androidTest/java/com/duckduckgo/app/email/EmailJavascriptInterfaceTest.kt new file mode 100644 index 000000000000..5353479f609b --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/email/EmailJavascriptInterfaceTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2020 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.email + +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class EmailJavascriptInterfaceTest { + + private val mockEmailManager: EmailManager = mock() + lateinit var testee: EmailJavascriptInterface + private var counter = 0 + + @Before + fun setup() { + testee = EmailJavascriptInterface(mockEmailManager) { counter++ } + } + + @Test + fun whenGetAliasThenGetAliasCalled() { + givenAliasExists() + + testee.getAlias() + + verify(mockEmailManager).getAlias() + } + + @Test + fun whenGetAliasThenReturnNextAliasWrappedInJsonObject() { + givenAliasExists() + + assertEquals("{\"nextAlias\": \"alias\"}", testee.getAlias()) + } + + @Test + fun whenGetAliasIfNextAliasDoesNotExistThenReturnEmpty() { + givenAliasDoesNotExist() + + assertEquals("", testee.getAlias()) + } + + @Test + fun whenIsSignedInThenIsSignedInCalled() { + testee.isSignedIn() + + verify(mockEmailManager).isSignedIn() + } + + @Test + fun whenStoreCredentialsThenStoreCredentialsCalledWithCorrectParameters() { + testee.storeCredentials("token", "username") + + verify(mockEmailManager).storeCredentials("token", "username") + } + + @Test + fun whenShowTooltipThenLambdaCalled() { + testee.showTooltip() + + assertEquals(1, counter) + } + + private fun givenAliasExists() { + whenever(mockEmailManager.getAlias()).thenReturn("alias") + } + + private fun givenAliasDoesNotExist() { + whenever(mockEmailManager.getAlias()).thenReturn("") + } +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/email/db/EmailEncryptedSharedPreferencesTest.kt b/app/src/androidTest/java/com/duckduckgo/app/email/db/EmailEncryptedSharedPreferencesTest.kt new file mode 100644 index 000000000000..0d1b19fe7183 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/email/db/EmailEncryptedSharedPreferencesTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2020 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.email.db + +import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.app.CoroutineTestRule +import com.duckduckgo.app.runBlocking +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.first +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@FlowPreview +@ExperimentalCoroutinesApi +class EmailEncryptedSharedPreferencesTest { + + @get:Rule + var coroutineRule = CoroutineTestRule() + + lateinit var testee: EmailEncryptedSharedPreferences + + @Before + fun before() { + testee = EmailEncryptedSharedPreferences(InstrumentationRegistry.getInstrumentation().targetContext) + } + + @Test + fun whenNextAliasEqualsValueThenValueIsSentToNextAliasChannel() = coroutineRule.runBlocking { + testee.nextAlias = "test" + + assertEquals("test", testee.nextAliasFlow().first()) + } + + @Test + fun whenNextAliasEqualsNullThenNullIsSentToNextAliasChannel() = coroutineRule.runBlocking { + testee.nextAlias = null + + assertNull(testee.nextAliasFlow().first()) + } +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt index b2fdbd720aad..e3596410416c 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt @@ -24,9 +24,11 @@ import com.duckduckgo.app.blockingObserve import com.duckduckgo.app.browser.BuildConfig import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector import com.duckduckgo.app.fire.FireAnimationLoader +import com.duckduckgo.app.email.EmailManager import com.duckduckgo.app.global.DuckDuckGoTheme import com.duckduckgo.app.icon.api.AppIcon import com.duckduckgo.app.pixels.AppPixelName +import com.duckduckgo.app.settings.SettingsViewModel.EmailSetting import com.duckduckgo.app.settings.SettingsViewModel.Command import com.duckduckgo.app.settings.clear.ClearWhatOption.CLEAR_NONE import com.duckduckgo.app.settings.clear.ClearWhenOption.APP_EXIT_ONLY @@ -41,6 +43,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.Mock +import org.mockito.Mockito import org.mockito.MockitoAnnotations class SettingsViewModelTest { @@ -71,16 +74,19 @@ class SettingsViewModelTest { @Mock private lateinit var mockFireAnimationLoader: FireAnimationLoader + @Mock + private lateinit var mockEmailManager: EmailManager + private lateinit var commandCaptor: KArgumentCaptor @Before fun before() { - MockitoAnnotations.initMocks(this) + MockitoAnnotations.openMocks(this) context = InstrumentationRegistry.getInstrumentation().targetContext commandCaptor = argumentCaptor() - testee = SettingsViewModel(mockAppSettingsDataStore, mockDefaultBrowserDetector, mockVariantManager, mockFireAnimationLoader, mockPixel) + testee = SettingsViewModel(mockAppSettingsDataStore, mockDefaultBrowserDetector, mockVariantManager, mockEmailManager, mockFireAnimationLoader, mockPixel) testee.command.observeForever(commandObserver) whenever(mockAppSettingsDataStore.automaticallyClearWhenOption).thenReturn(APP_EXIT_ONLY) @@ -302,8 +308,75 @@ class SettingsViewModelTest { assertEquals(Command.LaunchGlobalPrivacyControl, commandCaptor.firstValue) } + @Test + fun whenUserNotSignedInOnEmailThenEmailSettingIsOff() { + givenUserIsNotSignedIn() + + testee.start() + + assert(latestViewState().emailSetting is EmailSetting.EmailSettingOff) + } + + @Test + fun whenUserSignedInOnEmailAndEmailAddressIsNotNullThenEmailSettingIsOn() { + givenUserIsSignedInAndHasAliasAvailable() + + testee.start() + + assertTrue(latestViewState().emailSetting is EmailSetting.EmailSettingOn) + } + + @Test + fun whenUserSignedInOnEmailAndEmailAddressIsNullThenEmailSettingIsOff() { + whenever(mockEmailManager.getEmailAddress()).thenReturn(null) + whenever(mockEmailManager.isSignedIn()).thenReturn(true) + + testee.start() + + assert(latestViewState().emailSetting is EmailSetting.EmailSettingOff) + } + + @Test + fun whenOnEmailLogoutThenSignOutIsCalled() { + testee.onEmailLogout() + + verify(mockEmailManager).signOut() + } + + @Test + fun whenOnEmailLogoutThenEmailSettingIsOff() { + testee.onEmailLogout() + + assert(latestViewState().emailSetting is EmailSetting.EmailSettingOff) + } + + @Test + fun whenOnEmailSettingClickedAndUserIsSignedInThenLaunchEmailDialogCommandSent() { + givenUserIsSignedInAndHasAliasAvailable() + + testee.onEmailSettingClicked() + + assertCommandIssued() + } + private fun latestViewState() = testee.viewState.value!! + private inline fun assertCommandIssued(instanceAssertions: T.() -> Unit = {}) { + Mockito.verify(commandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) + val issuedCommand = commandCaptor.allValues.find { it is T } + assertNotNull(issuedCommand) + (issuedCommand as T).apply { instanceAssertions() } + } + + private fun givenUserIsSignedInAndHasAliasAvailable() { + whenever(mockEmailManager.getEmailAddress()).thenReturn("test@duck.com") + whenever(mockEmailManager.isSignedIn()).thenReturn(true) + } + + private fun givenUserIsNotSignedIn() { + whenever(mockEmailManager.isSignedIn()).thenReturn(false) + } + private fun givenSelectedFireAnimation(fireAnimation: FireAnimation) { whenever(mockAppSettingsDataStore.selectedFireAnimation).thenReturn(fireAnimation) whenever(mockAppSettingsDataStore.isCurrentlySelected(fireAnimation)).thenReturn(true) 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 a8e8b67986b8..fbf66003d818 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -86,6 +86,8 @@ import com.duckduckgo.app.browser.tabpreview.WebViewPreviewPersister import com.duckduckgo.app.browser.ui.HttpAuthenticationDialogFragment import com.duckduckgo.app.browser.useragent.UserAgentProvider import com.duckduckgo.app.cta.ui.* +import com.duckduckgo.app.email.EmailInjector +import com.duckduckgo.app.email.EmailAutofillTooltipFragment import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.app.fire.fireproofwebsite.data.website import com.duckduckgo.app.global.ViewModelFactory @@ -201,6 +203,9 @@ class BrowserTabFragment : @Inject lateinit var thirdPartyCookieManager: ThirdPartyCookieManager + @Inject + lateinit var emailInjector: EmailInjector + var messageFromPreviousTab: Message? = null private val initialUrl get() = requireArguments().getString(URL_EXTRA_ARG) @@ -274,6 +279,8 @@ class BrowserTabFragment : private var loginDetectionDialog: AlertDialog? = null + private var emailAutofillTooltipDialog: EmailAutofillTooltipFragment? = null + private val pulseAnimation: PulseAnimation = PulseAnimation(this) override fun onAttach(context: Context) { @@ -618,6 +625,24 @@ class BrowserTabFragment : is Command.ConvertBlobToDataUri -> convertBlobToDataUri(it) is Command.RequestFileDownload -> requestFileDownload(it.url, it.contentDisposition, it.mimeType, it.requestUserConfirmation) is Command.ChildTabClosed -> processUriForThirdPartyCookies() + is Command.CopyAliasToClipboard -> copyAliasToClipboard(it.alias) + is Command.InjectEmailAddress -> injectEmailAddress(it.address) + is Command.ShowEmailTooltip -> showEmailTooltip(it.address) + } + } + + private fun injectEmailAddress(alias: String) { + webView?.let { + emailInjector.injectAddressInEmailField(it, alias) + } + } + + private fun copyAliasToClipboard(alias: String) { + context?.let { + val clipboard: ClipboardManager? = ContextCompat.getSystemService(it, ClipboardManager::class.java) + val clip: ClipData = ClipData.newPlainText("Alias", alias) + clipboard?.setPrimaryClip(clip) + showToast(R.string.aliasToClipboardMessage) } } @@ -1000,6 +1025,7 @@ class BrowserTabFragment : it.setFindListener(this) loginDetector.addLoginDetection(it) { viewModel.loginDetected() } blobConverterInjector.addJsInterface(it) { url, mimeType -> viewModel.requestFileDownload(url, null, mimeType, true) } + emailInjector.addJsInterface(it) { viewModel.showEmailTooltip() } } if (BuildConfig.DEBUG) { @@ -1216,6 +1242,7 @@ class BrowserTabFragment : supervisorJob.cancel() popupMenu.dismiss() loginDetectionDialog?.dismiss() + emailAutofillTooltipDialog?.dismiss() destroyWebView() super.onDestroy() } @@ -1393,6 +1420,19 @@ class BrowserTabFragment : viewModel.stopShowingEmptyGrade() } + private fun showEmailTooltip(address: String) { + context?.let { + val isShowing: Boolean? = emailAutofillTooltipDialog?.isShowing + if (isShowing != true) { + emailAutofillTooltipDialog = EmailAutofillTooltipFragment(it, address) + emailAutofillTooltipDialog?.show() + emailAutofillTooltipDialog?.setOnCancelListener { viewModel.cancelAutofillTooltip() } + emailAutofillTooltipDialog?.useAddress = { viewModel.useAddress() } + emailAutofillTooltipDialog?.usePrivateAlias = { viewModel.consumeAlias() } + } + } + } + companion object { private const val TAB_ID_ARG = "TAB_ID_ARG" private const val URL_EXTRA_ARG = "URL_EXTRA_ARG" @@ -1531,6 +1571,7 @@ class BrowserTabFragment : pixel.fire(AppPixelName.MENU_ACTION_ADD_TO_HOME_PRESSED) viewModel.onPinPageToHomeSelected() } + onMenuItemClicked(view.newEmailAliasMenuItem) { viewModel.consumeAliasAndCopyToClipboard() } } browserMenu.setOnClickListener { hideKeyboardImmediately() @@ -1769,6 +1810,10 @@ class BrowserTabFragment : requestDesktopSiteCheckMenuItem?.isEnabled = viewState.canChangeBrowsingMode requestDesktopSiteCheckMenuItem?.isChecked = viewState.isDesktopBrowsingMode + newEmailAliasMenuItem?.let { + it.visibility = if (viewState.isEmailSignedIn) VISIBLE else GONE + } + addToHome?.let { it.visibility = if (viewState.addToHomeVisible) VISIBLE else GONE it.isEnabled = viewState.addToHomeEnabled 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 0d5857506f18..510d0e353539 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -66,6 +66,7 @@ import com.duckduckgo.app.browser.omnibar.QueryUrlConverter import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.browser.ui.HttpAuthenticationDialogFragment.HttpAuthenticationListener import com.duckduckgo.app.cta.ui.* +import com.duckduckgo.app.email.EmailManager import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository import com.duckduckgo.app.global.* @@ -110,7 +111,9 @@ import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.* -import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext import timber.log.Timber import java.io.File import java.util.* @@ -148,7 +151,8 @@ class BrowserTabViewModel( private val variantManager: VariantManager, private val fileDownloader: FileDownloader, private val globalPrivacyControl: GlobalPrivacyControl, - private val fireproofDialogsEventHandler: FireproofDialogsEventHandler + private val fireproofDialogsEventHandler: FireproofDialogsEventHandler, + private val emailManager: EmailManager ) : WebViewClientListener, EditBookmarkListener, HttpAuthenticationListener, SiteLocationPermissionDialog.SiteLocationPermissionDialogListener, SystemLocationPermissionDialog.SystemLocationPermissionDialogListener, ViewModel() { @@ -185,7 +189,8 @@ class BrowserTabViewModel( val canReportSite: Boolean = false, val addToHomeEnabled: Boolean = false, val addToHomeVisible: Boolean = false, - val showDaxIcon: Boolean = false + val showDaxIcon: Boolean = false, + val isEmailSignedIn: Boolean = false ) sealed class FireButton { @@ -283,6 +288,9 @@ class BrowserTabViewModel( class ConvertBlobToDataUri(val url: String, val mimeType: String) : Command() class RequestFileDownload(val url: String, val contentDisposition: String?, val mimeType: String, val requestUserConfirmation: Boolean) : Command() object ChildTabClosed : Command() + class CopyAliasToClipboard(val alias: String) : Command() + class InjectEmailAddress(val address: String) : Command() + class ShowEmailTooltip(val address: String) : Command() sealed class DaxCommand : Command() { object FinishTrackerAnimation : DaxCommand() class HideDaxDialog(val cta: Cta) : DaxCommand() @@ -350,6 +358,7 @@ class BrowserTabViewModel( } } + @ExperimentalCoroutinesApi private val fireButtonAnimation = Observer { shouldShowAnimation -> Timber.i("shouldShowAnimation $shouldShowAnimation") if (currentBrowserViewState().fireButton is FireButton.Visible) { @@ -394,13 +403,16 @@ class BrowserTabViewModel( fireproofDialogsEventHandler.event.observeForever(fireproofDialogEventObserver) navigationAwareLoginDetector.loginEventLiveData.observeForever(loginDetectionObserver) showPulseAnimation.observeForever(fireButtonAnimation) - viewModelScope.launch { - tabRepository.childClosedTabs.collect { closedTab -> - if (this@BrowserTabViewModel::tabId.isInitialized && tabId == closedTab) { - command.value = ChildTabClosed - } + + tabRepository.childClosedTabs.onEach { closedTab -> + if (this@BrowserTabViewModel::tabId.isInitialized && tabId == closedTab) { + command.value = ChildTabClosed } - } + }.launchIn(viewModelScope) + + emailManager.signedInFlow().onEach { isSignedIn -> + browserViewState.value = currentBrowserViewState().copy(isEmailSignedIn = isSignedIn) + }.launchIn(viewModelScope) } fun loadData(tabId: String, initialUrl: String?, skipHome: Boolean) { @@ -1783,6 +1795,36 @@ class BrowserTabViewModel( command.postValue(RequestFileDownload(url, contentDisposition, mimeType, requestUserConfirmation)) } + fun showEmailTooltip() { + emailManager.getEmailAddress()?.let { + command.postValue(ShowEmailTooltip(it)) + } + } + + fun consumeAliasAndCopyToClipboard() { + emailManager.getAlias()?.let { + command.value = CopyAliasToClipboard(it) + } + } + + fun consumeAlias() { + emailManager.getAlias()?.let { + command.postValue(InjectEmailAddress(it)) + pixel.enqueueFire(AppPixelName.EMAIL_USE_ALIAS) + } + } + + fun useAddress() { + emailManager.getEmailAddress()?.let { + command.postValue(InjectEmailAddress(it)) + pixel.enqueueFire(AppPixelName.EMAIL_USE_ADDRESS) + } + } + + fun cancelAutofillTooltip() { + pixel.enqueueFire(AppPixelName.EMAIL_TOOLTIP_DISMISSED) + } + fun download(pendingFileDownload: FileDownloader.PendingFileDownload) { viewModelScope.launch(dispatchers.io()) { fileDownloader.download( @@ -1870,12 +1912,13 @@ class BrowserTabViewModelFactory @Inject constructor( private val variantManager: Provider, private val fileDownloader: Provider, private val globalPrivacyControl: Provider, - private val fireproofDialogsEventHandler: Provider + private val fireproofDialogsEventHandler: Provider, + private val emailManager: Provider ) : ViewModelFactoryPlugin { override fun create(modelClass: Class): T? { with(modelClass) { return when { - isAssignableFrom(BrowserTabViewModel::class.java) -> BrowserTabViewModel(statisticsUpdater.get(), queryUrlConverter.get(), duckDuckGoUrlDetector.get(), siteFactory.get(), tabRepository.get(), userWhitelistDao.get(), networkLeaderboardDao.get(), bookmarksDao.get(), fireproofWebsiteRepository.get(), locationPermissionsRepository.get(), geoLocationPermissions.get(), navigationAwareLoginDetector.get(), autoComplete.get(), appSettingsPreferencesStore.get(), longPressHandler.get(), webViewSessionStorage.get(), specialUrlDetector.get(), faviconManager.get(), addToHomeCapabilityDetector.get(), ctaViewModel.get(), searchCountDao.get(), pixel.get(), dispatchers, userEventsStore.get(), notificationDao.get(), useOurAppDetector.get(), variantManager.get(), fileDownloader.get(), globalPrivacyControl.get(), fireproofDialogsEventHandler.get()) as T + isAssignableFrom(BrowserTabViewModel::class.java) -> BrowserTabViewModel(statisticsUpdater.get(), queryUrlConverter.get(), duckDuckGoUrlDetector.get(), siteFactory.get(), tabRepository.get(), userWhitelistDao.get(), networkLeaderboardDao.get(), bookmarksDao.get(), fireproofWebsiteRepository.get(), locationPermissionsRepository.get(), geoLocationPermissions.get(), navigationAwareLoginDetector.get(), autoComplete.get(), appSettingsPreferencesStore.get(), longPressHandler.get(), webViewSessionStorage.get(), specialUrlDetector.get(), faviconManager.get(), addToHomeCapabilityDetector.get(), ctaViewModel.get(), searchCountDao.get(), pixel.get(), dispatchers, userEventsStore.get(), notificationDao.get(), useOurAppDetector.get(), variantManager.get(), fileDownloader.get(), globalPrivacyControl.get(), fireproofDialogsEventHandler.get(), emailManager.get()) as T else -> null } } 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 1a5c7b19baee..749f40702471 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt @@ -34,6 +34,7 @@ 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.navigation.safeCopyBackForwardList +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.* @@ -57,7 +58,8 @@ class BrowserWebViewClient( private val globalPrivacyControl: GlobalPrivacyControl, private val thirdPartyCookieManager: ThirdPartyCookieManager, private val appCoroutineScope: CoroutineScope, - private val dispatcherProvider: DispatcherProvider + private val dispatcherProvider: DispatcherProvider, + private val emailInjector: EmailInjector ) : WebViewClient() { var webViewClientListener: WebViewClientListener? = null @@ -157,6 +159,7 @@ class BrowserWebViewClient( webViewClientListener?.pageRefreshed(url) } lastPageStarted = url + emailInjector.resetInjectedJsFlag() globalPrivacyControl.injectDoNotSellToDom(webView) loginDetector.onEvent(WebNavigationEvent.OnPageStarted(webView)) } catch (e: Throwable) { @@ -172,6 +175,7 @@ class BrowserWebViewClient( try { Timber.v("onPageFinished webViewUrl: ${webView.url} URL: $url") val navigationList = webView.safeCopyBackForwardList() ?: return + emailInjector.injectEmailAutofillJs(webView, url) // Needs to be injected onPageFinished webViewClientListener?.run { navigationStateChanged(WebViewNavigationState(navigationList)) url?.let { prefetchFavicon(url) } diff --git a/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt b/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt index 271516d392c4..08b501cb4b28 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt @@ -19,10 +19,15 @@ package com.duckduckgo.app.browser import android.net.Uri import com.duckduckgo.app.global.AppUrl import com.duckduckgo.app.global.AppUrl.ParamKey +import com.duckduckgo.app.global.baseHost import javax.inject.Inject class DuckDuckGoUrlDetector @Inject constructor() { + fun isDuckDuckGoDomain(uri: String): Boolean { + return uri.toUri().baseHost?.contains(AppUrl.Url.HOST) ?: false + } + fun isDuckDuckGoUrl(uri: String): Boolean { return AppUrl.Url.HOST == uri.toUri().host } 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 0f3c4ab02d7e..3c7d78f77bcb 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 @@ -43,6 +43,7 @@ import com.duckduckgo.app.browser.tabpreview.WebViewPreviewGenerator import com.duckduckgo.app.browser.tabpreview.WebViewPreviewPersister 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 @@ -102,7 +103,8 @@ class BrowserModule { globalPrivacyControl: GlobalPrivacyControl, thirdPartyCookieManager: ThirdPartyCookieManager, @AppCoroutineScope appCoroutineScope: CoroutineScope, - dispatcherProvider: DispatcherProvider + dispatcherProvider: DispatcherProvider, + emailInjector: EmailInjector ): BrowserWebViewClient { return BrowserWebViewClient( webViewHttpAuthStore, @@ -118,7 +120,8 @@ class BrowserModule { globalPrivacyControl, thirdPartyCookieManager, appCoroutineScope, - dispatcherProvider + dispatcherProvider, + emailInjector ) } diff --git a/app/src/main/java/com/duckduckgo/app/di/AppComponent.kt b/app/src/main/java/com/duckduckgo/app/di/AppComponent.kt index 99018d70ed1e..79b0625646b2 100644 --- a/app/src/main/java/com/duckduckgo/app/di/AppComponent.kt +++ b/app/src/main/java/com/duckduckgo/app/di/AppComponent.kt @@ -22,6 +22,7 @@ import com.duckduckgo.app.browser.certificates.CertificateTrustedStoreModule import com.duckduckgo.app.browser.di.BrowserModule import com.duckduckgo.app.browser.favicon.FaviconModule import com.duckduckgo.app.browser.rating.di.RatingModule +import com.duckduckgo.app.email.di.EmailModule import com.duckduckgo.app.global.DuckDuckGoApplication import com.duckduckgo.app.global.exception.UncaughtExceptionModule import com.duckduckgo.app.httpsupgrade.di.HttpsUpgraderModule @@ -77,7 +78,8 @@ import javax.inject.Singleton CoroutinesModule::class, CertificateTrustedStoreModule::class, WelcomePageModule::class, - HttpsPersisterModule::class + HttpsPersisterModule::class, + EmailModule::class ] ) interface AppComponent : AndroidInjector { diff --git a/app/src/main/java/com/duckduckgo/app/di/NetworkModule.kt b/app/src/main/java/com/duckduckgo/app/di/NetworkModule.kt index 667cb1c074e8..960953e3903e 100644 --- a/app/src/main/java/com/duckduckgo/app/di/NetworkModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/NetworkModule.kt @@ -21,6 +21,7 @@ import androidx.work.WorkManager import com.duckduckgo.app.autocomplete.api.AutoCompleteService import com.duckduckgo.app.brokensite.api.BrokenSiteSender import com.duckduckgo.app.brokensite.api.BrokenSiteSubmitter +import com.duckduckgo.app.email.api.EmailService import com.duckduckgo.app.browser.useragent.UserAgentProvider import com.duckduckgo.app.feedback.api.FeedbackService import com.duckduckgo.app.feedback.api.FeedbackSubmitter @@ -131,6 +132,10 @@ class NetworkModule { fun autoCompleteService(@Named("nonCaching") retrofit: Retrofit): AutoCompleteService = retrofit.create(AutoCompleteService::class.java) + @Provides + fun emailService(@Named("nonCaching") retrofit: Retrofit): EmailService = + retrofit.create(EmailService::class.java) + @Provides fun surrogatesService(@Named("api") retrofit: Retrofit): ResourceSurrogateListService = retrofit.create(ResourceSurrogateListService::class.java) diff --git a/app/src/main/java/com/duckduckgo/app/di/component/SettingsEmailLogoutDialogComponent.kt b/app/src/main/java/com/duckduckgo/app/di/component/SettingsEmailLogoutDialogComponent.kt new file mode 100644 index 000000000000..f6e99a415eef --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/di/component/SettingsEmailLogoutDialogComponent.kt @@ -0,0 +1,53 @@ +/* + * 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. + */ + +package com.duckduckgo.app.di.component + +import com.duckduckgo.app.di.ActivityScoped +import com.duckduckgo.app.settings.SettingsEmailLogoutDialog +import com.duckduckgo.di.scopes.ActivityObjectGraph +import com.duckduckgo.di.scopes.AppObjectGraph +import com.squareup.anvil.annotations.ContributesTo +import com.squareup.anvil.annotations.MergeSubcomponent +import dagger.Binds +import dagger.Module +import dagger.Subcomponent +import dagger.android.AndroidInjector +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@ActivityScoped +@MergeSubcomponent( + scope = ActivityObjectGraph::class +) +interface SettingsEmailLogoutDialogComponent : AndroidInjector { + @Subcomponent.Factory + interface Factory : AndroidInjector.Factory +} + +@ContributesTo(AppObjectGraph::class) +interface SettingsEmailLogoutDialogComponentProvider { + fun provideSettingsEmailLogoutDialogComponentFactory(): SettingsEmailLogoutDialogComponent.Factory +} + +@Module +@ContributesTo(AppObjectGraph::class) +abstract class SettingsEmailLogoutDialogBindingModule { + @Binds + @IntoMap + @ClassKey(SettingsEmailLogoutDialog::class) + abstract fun bindSettingsEmailLogoutDialogComponentFactory(factory: SettingsEmailLogoutDialogComponent.Factory): AndroidInjector.Factory<*> +} diff --git a/app/src/main/java/com/duckduckgo/app/email/EmailAutofillTooltipFragment.kt b/app/src/main/java/com/duckduckgo/app/email/EmailAutofillTooltipFragment.kt new file mode 100644 index 000000000000..f03740eed6d3 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/email/EmailAutofillTooltipFragment.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2020 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.email + +import android.content.Context +import android.os.Bundle +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.global.view.html +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import kotlinx.android.synthetic.main.content_autofill_tooltip.* + +class EmailAutofillTooltipFragment( + context: Context, + val address: String +) : BottomSheetDialog(context, R.style.EmailTooltip) { + + var useAddress: (() -> Unit) = {} + var usePrivateAlias: (() -> Unit) = {} + + init { + setContentView(R.layout.content_autofill_tooltip) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setDialog() + } + + private fun setDialog() { + behavior.state = BottomSheetBehavior.STATE_EXPANDED + val addressFormatted = context.getString(R.string.autofillTooltipUseYourAlias, address) + tooltipPrimaryCtaTitle.text = addressFormatted.html(context) + + secondaryCta.setOnClickListener { + usePrivateAlias() + dismiss() + } + + primaryCta.setOnClickListener { + useAddress() + dismiss() + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/email/EmailInjector.kt b/app/src/main/java/com/duckduckgo/app/email/EmailInjector.kt new file mode 100644 index 000000000000..5b7f2f2d7643 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/email/EmailInjector.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2020 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.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 + +interface EmailInjector { + fun injectEmailAutofillJs(webView: WebView, url: String?) + fun addJsInterface(webView: WebView, onTooltipShown: () -> Unit) + fun injectAddressInEmailField(webView: WebView, alias: String?) + fun resetInjectedJsFlag() +} + +class EmailInjectorJs(private val emailManager: EmailManager, private val urlDetector: DuckDuckGoUrlDetector) : EmailInjector { + private val javaScriptInjector = JavaScriptInjector() + private var hasJsBeenInjected = false + + override fun addJsInterface(webView: WebView, onTooltipShown: () -> Unit) { + webView.addJavascriptInterface(EmailJavascriptInterface(emailManager, onTooltipShown), JAVASCRIPT_INTERFACE_NAME) + } + + @UiThread + override fun injectEmailAutofillJs(webView: WebView, url: String?) { + if (!hasJsBeenInjected && (isDuckDuckGoUrl(url) || emailManager.isSignedIn())) { + hasJsBeenInjected = true + webView.evaluateJavascript("javascript:${javaScriptInjector.getFunctionsJS(webView.context)}", null) + } + } + + @UiThread + override fun injectAddressInEmailField(webView: WebView, alias: String?) { + webView.evaluateJavascript("javascript:${javaScriptInjector.getAliasFunctions(webView.context, alias)}", null) + } + + override fun resetInjectedJsFlag() { + hasJsBeenInjected = false + } + + private fun isDuckDuckGoUrl(url: String?): Boolean = (url != null && urlDetector.isDuckDuckGoDomain(url)) + + private class JavaScriptInjector { + private lateinit var functions: String + private lateinit var aliasFunctions: String + + fun getFunctionsJS(context: Context): String { + if (!this::functions.isInitialized) { + functions = context.resources.openRawResource(R.raw.autofill).bufferedReader().use { it.readText() } + } + 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()) + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/email/EmailJavascriptInterface.kt b/app/src/main/java/com/duckduckgo/app/email/EmailJavascriptInterface.kt new file mode 100644 index 000000000000..b22c590fff0e --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/email/EmailJavascriptInterface.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2020 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.email + +import android.webkit.JavascriptInterface +import timber.log.Timber + +class EmailJavascriptInterface( + private val emailManager: EmailManager, + private val showNativeTooltip: () -> Unit +) { + + @JavascriptInterface + fun log(message: String) { + Timber.i("EmailInterface $message") + } + + @JavascriptInterface + fun getAlias(): String { + val nextAlias = emailManager.getAlias() + + return if (nextAlias.isNullOrBlank()) { + "" + } else { + "{\"nextAlias\": \"$nextAlias\"}" + } + } + + @JavascriptInterface + fun isSignedIn(): String = emailManager.isSignedIn().toString() + + @JavascriptInterface + fun storeCredentials(token: String, username: String) { + emailManager.storeCredentials(token, username) + } + + @JavascriptInterface + fun showTooltip() { + showNativeTooltip() + } + + companion object { + const val JAVASCRIPT_INTERFACE_NAME = "EmailInterface" + } +} diff --git a/app/src/main/java/com/duckduckgo/app/email/EmailManager.kt b/app/src/main/java/com/duckduckgo/app/email/EmailManager.kt new file mode 100644 index 000000000000..e3c2c5804d0a --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/email/EmailManager.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2020 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.email + +import androidx.lifecycle.LifecycleObserver +import com.duckduckgo.app.email.api.EmailService +import com.duckduckgo.app.email.db.EmailDataStore +import com.duckduckgo.app.global.DispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber + +interface EmailManager : LifecycleObserver { + fun signedInFlow(): StateFlow + fun getAlias(): String? + fun isSignedIn(): Boolean + fun storeCredentials(token: String, username: String) + fun signOut() + fun getEmailAddress(): String? +} + +@FlowPreview +@ExperimentalCoroutinesApi +class AppEmailManager( + private val emailService: EmailService, + private val emailDataStore: EmailDataStore, + private val dispatcherProvider: DispatcherProvider, + private val appCoroutineScope: CoroutineScope +) : EmailManager { + + private val nextAliasFlow = emailDataStore.nextAliasFlow() + + private val isSignedInStateFlow = MutableStateFlow(isSignedIn()) + override fun signedInFlow(): StateFlow = isSignedInStateFlow.asStateFlow() + + override fun getAlias(): String? = consumeAlias() + + override fun isSignedIn(): Boolean { + return !emailDataStore.emailToken.isNullOrBlank() && !emailDataStore.emailUsername.isNullOrBlank() + } + + override fun storeCredentials(token: String, username: String) { + emailDataStore.emailToken = token + emailDataStore.emailUsername = username + appCoroutineScope.launch(dispatcherProvider.io()) { + generateNewAlias() + isSignedInStateFlow.emit(true) + } + } + + override fun signOut() { + appCoroutineScope.launch(dispatcherProvider.io()) { + emailDataStore.clearEmailData() + isSignedInStateFlow.emit(false) + } + } + + override fun getEmailAddress(): String? { + return emailDataStore.emailUsername?.let { + "$it$DUCK_EMAIL_DOMAIN" + } + } + + private fun consumeAlias(): String? { + val alias = nextAliasFlow.value + emailDataStore.clearNextAlias() + appCoroutineScope.launch(dispatcherProvider.io()) { + generateNewAlias() + } + return alias + } + + private suspend fun generateNewAlias() { + fetchAliasFromService() + } + + private suspend fun fetchAliasFromService() { + emailDataStore.emailToken?.let { token -> + runCatching { + emailService.newAlias("Bearer $token") + }.onSuccess { alias -> + emailDataStore.nextAlias = if (alias.address.isBlank()) { + null + } else { + "${alias.address}$DUCK_EMAIL_DOMAIN" + } + }.onFailure { + Timber.w(it, "Failed to fetch alias") + } + } + } + + companion object { + const val DUCK_EMAIL_DOMAIN = "@duck.com" + } + + private fun EmailDataStore.clearEmailData() { + emailToken = null + emailUsername = null + nextAlias = null + } + + private fun EmailDataStore.clearNextAlias() { + nextAlias = null + } +} diff --git a/app/src/main/java/com/duckduckgo/app/email/api/EmailService.kt b/app/src/main/java/com/duckduckgo/app/email/api/EmailService.kt new file mode 100644 index 000000000000..0b14f1edcab9 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/email/api/EmailService.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2020 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.email.api + +import retrofit2.http.Header +import retrofit2.http.POST + +interface EmailService { + @POST("https://quack.duckduckgo.com/api/email/addresses") + suspend fun newAlias(@Header("Authorization") authorization: String): EmailAlias +} + +data class EmailAlias(val address: String) diff --git a/app/src/main/java/com/duckduckgo/app/email/db/EmailDataStore.kt b/app/src/main/java/com/duckduckgo/app/email/db/EmailDataStore.kt new file mode 100644 index 000000000000..0145dee43e4e --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/email/db/EmailDataStore.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2020 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.email.db + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +interface EmailDataStore { + var emailToken: String? + var nextAlias: String? + var emailUsername: String? + fun nextAliasFlow(): StateFlow +} + +@FlowPreview +@ExperimentalCoroutinesApi +class EmailEncryptedSharedPreferences(private val context: Context) : EmailDataStore { + + private val nextAliasSharedFlow: MutableStateFlow = MutableStateFlow(nextAlias) + override fun nextAliasFlow(): StateFlow = nextAliasSharedFlow.asStateFlow() + + private val encryptedPreferences: SharedPreferences + get() = EncryptedSharedPreferences.create( + context, + FILENAME, + MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build(), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + override var emailToken: String? + get() = encryptedPreferences.getString(KEY_EMAIL_TOKEN, null) + set(value) { + encryptedPreferences.edit(commit = true) { + if (value == null) remove(KEY_EMAIL_TOKEN) + else putString(KEY_EMAIL_TOKEN, value) + } + } + + override var nextAlias: String? + get() = encryptedPreferences.getString(KEY_NEXT_ALIAS, null) + set(value) { + encryptedPreferences.edit(commit = true) { + if (value == null) remove(KEY_NEXT_ALIAS) + else putString(KEY_NEXT_ALIAS, value) + GlobalScope.launch { + nextAliasSharedFlow.emit(value) + } + } + } + + override var emailUsername: String? + get() = encryptedPreferences.getString(KEY_EMAIL_USERNAME, null) + set(value) { + encryptedPreferences.edit(commit = true) { + if (value == null) remove(KEY_EMAIL_USERNAME) + else putString(KEY_EMAIL_USERNAME, value) + } + } + + companion object { + const val FILENAME = "com.duckduckgo.app.email.settings" + const val KEY_EMAIL_TOKEN = "KEY_EMAIL_TOKEN" + const val KEY_EMAIL_USERNAME = "KEY_EMAIL_USERNAME" + const val KEY_NEXT_ALIAS = "KEY_NEXT_ALIAS" + } +} 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 new file mode 100644 index 000000000000..8f856ff820a3 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/email/di/EmailModule.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2020 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.email.di + +import android.content.Context +import com.duckduckgo.app.browser.DuckDuckGoUrlDetector +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.email.AppEmailManager +import com.duckduckgo.app.email.EmailInjector +import com.duckduckgo.app.email.EmailInjectorJs +import com.duckduckgo.app.email.EmailManager +import com.duckduckgo.app.email.api.EmailService +import com.duckduckgo.app.email.db.EmailDataStore +import com.duckduckgo.app.email.db.EmailEncryptedSharedPreferences +import com.duckduckgo.app.global.DispatcherProvider +import dagger.Module +import dagger.Provides +import kotlinx.coroutines.CoroutineScope +import javax.inject.Singleton + +@Module +class EmailModule { + + @Singleton + @Provides + fun providesEmailManager(emailService: EmailService, emailDataStore: EmailDataStore, dispatcherProvider: DispatcherProvider, @AppCoroutineScope appCoroutineScope: CoroutineScope): EmailManager { + return AppEmailManager(emailService, emailDataStore, dispatcherProvider, appCoroutineScope) + } + + @Provides + fun providesEmailInjector(emailManager: EmailManager, duckDuckGoUrlDetector: DuckDuckGoUrlDetector): EmailInjector { + return EmailInjectorJs(emailManager, duckDuckGoUrlDetector) + } + + @Provides + fun providesEmailDataStore(context: Context): EmailDataStore { + return EmailEncryptedSharedPreferences(context) + } +} diff --git a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt index 5fb8e8228df9..7076a4e9411a 100644 --- a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt +++ b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt @@ -209,4 +209,8 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName { FIRE_ANIMATION_SETTINGS_OPENED("m_fas_o"), FIRE_ANIMATION_NEW_SELECTED("m_fas_s"), + + EMAIL_TOOLTIP_DISMISSED("m_e_t_d"), + EMAIL_USE_ALIAS("m_e_ua"), + EMAIL_USE_ADDRESS("m_e_uad") } 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 60423cc1bf0e..87abbdf65d9e 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt @@ -33,13 +33,16 @@ import com.duckduckgo.app.feedback.ui.common.FeedbackActivity import com.duckduckgo.app.fire.fireproofwebsite.ui.FireproofWebsitesActivity import com.duckduckgo.app.global.DuckDuckGoActivity import com.duckduckgo.app.global.sendThemeChangedBroadcast +import com.duckduckgo.app.global.view.gone import com.duckduckgo.app.global.view.launchDefaultAppActivity import com.duckduckgo.app.global.view.quietlySetIsChecked +import com.duckduckgo.app.global.view.show import com.duckduckgo.app.globalprivacycontrol.ui.GlobalPrivacyControlActivity import com.duckduckgo.app.icon.ui.ChangeIconActivity import com.duckduckgo.app.location.ui.LocationPermissionsActivity import com.duckduckgo.app.privacy.ui.WhitelistActivity import com.duckduckgo.app.settings.SettingsViewModel.AutomaticallyClearData +import com.duckduckgo.app.settings.SettingsViewModel.EmailSetting import com.duckduckgo.app.settings.SettingsViewModel.Command import com.duckduckgo.app.settings.clear.ClearWhatOption import com.duckduckgo.app.settings.clear.ClearWhenOption @@ -102,6 +105,7 @@ class SettingsActivity : automaticallyClearWhatSetting.setOnClickListener { launchAutomaticallyClearWhatDialog() } automaticallyClearWhenSetting.setOnClickListener { launchAutomaticallyClearWhenDialog() } whitelist.setOnClickListener { viewModel.onManageWhitelistSelected() } + emailSetting.setOnClickListener { viewModel.onEmailSettingClicked() } } private fun observeViewModel() { @@ -117,6 +121,7 @@ class SettingsActivity : setGlobalPrivacyControlSetting(it.globalPrivacyControlEnabled) changeAppIcon.setImageResource(it.appIcon.icon) updateSelectedFireAnimation(it.selectedFireAnimation) + setEmailSetting(it.emailSetting) } } ) @@ -129,6 +134,18 @@ class SettingsActivity : ) } + private fun setEmailSetting(emailData: EmailSetting) { + when (emailData) { + is EmailSetting.EmailSettingOff -> { + emailSetting.gone() + } + is EmailSetting.EmailSettingOn -> { + emailSetting.show() + emailSetting.setSubtitle(getString(R.string.settingsEmailAutofillEnabledFor, emailData.emailAddress)) + } + } + } + private fun setGlobalPrivacyControlSetting(enabled: Boolean) { val stateText = if (enabled) { getString(R.string.enabled) @@ -154,6 +171,12 @@ class SettingsActivity : automaticallyClearWhenSetting.isEnabled = whenOptionEnabled } + private fun launchEmailDialog() { + val dialog = SettingsEmailLogoutDialog.create() + dialog.show(supportFragmentManager, EMAIL_DIALOG_TAG) + dialog.onLogout = { viewModel.onEmailLogout() } + } + private fun launchAutomaticallyClearWhatDialog() { val dialog = SettingsAutomaticallyClearWhatFragment.create(viewModel.viewState.value?.automaticallyClearData?.clearWhatOption) dialog.show(supportFragmentManager, CLEAR_WHAT_DIALOG_TAG) @@ -176,6 +199,7 @@ class SettingsActivity : is Command.LaunchGlobalPrivacyControl -> launchGlobalPrivacyControl() is Command.UpdateTheme -> sendThemeChangedBroadcast() is Command.LaunchFireAnimationSettings -> launchFireAnimationSelector() + is Command.LaunchEmailDialog -> launchEmailDialog() } } @@ -281,6 +305,7 @@ class SettingsActivity : private const val FIRE_ANIMATION_SELECTOR_TAG = "FIRE_ANIMATION_SELECTOR_DIALOG_FRAGMENT" private const val CLEAR_WHAT_DIALOG_TAG = "CLEAR_WHAT_DIALOG_FRAGMENT" private const val CLEAR_WHEN_DIALOG_TAG = "CLEAR_WHEN_DIALOG_FRAGMENT" + private const val EMAIL_DIALOG_TAG = "EMAIL_DIALOG_FRAGMENT" private const val FEEDBACK_REQUEST_CODE = 100 private const val CHANGE_APP_ICON_REQUEST_CODE = 101 diff --git a/app/src/main/java/com/duckduckgo/app/settings/SettingsEmailLogoutDialog.kt b/app/src/main/java/com/duckduckgo/app/settings/SettingsEmailLogoutDialog.kt new file mode 100644 index 000000000000..a828bf75e896 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsEmailLogoutDialog.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2018 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.settings + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.email.EmailManager +import com.google.android.material.textview.MaterialTextView +import dagger.android.support.AndroidSupportInjection +import javax.inject.Inject + +class SettingsEmailLogoutDialog : DialogFragment() { + + @Inject + lateinit var emailManager: EmailManager + + var onLogout: (() -> Unit) = {} + + override fun onAttach(context: Context) { + AndroidSupportInjection.inject(this) + super.onAttach(context) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val rootView = View.inflate(activity, R.layout.settings_email_logout_fragment, null) + val message = rootView.findViewById(R.id.emailDialogText) + message.text = getString(R.string.settingsEmailAutofillEnabledFor, emailManager.getEmailAddress().orEmpty()) + + val alertBuilder = AlertDialog.Builder(requireActivity()) + .setView(rootView) + .setTitle(getString(R.string.settingsEmailAutofill)) + .setNegativeButton(R.string.autofillSettingCancel) { _, _ -> + dismiss() + } + .setPositiveButton(R.string.autofillSettingDisable) { _, _ -> + dialog?.let { + onLogout() + } + } + .setNegativeButton(android.R.string.cancel) { _, _ -> } + + return alertBuilder.create() + } + + companion object { + fun create(): SettingsEmailLogoutDialog = SettingsEmailLogoutDialog() + } + +} 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 b5d901ceb825..3d0e547e8e7d 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.ViewModel import com.duckduckgo.app.browser.BuildConfig import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector import com.duckduckgo.app.fire.FireAnimationLoader +import com.duckduckgo.app.email.EmailManager import com.duckduckgo.app.global.DuckDuckGoTheme import com.duckduckgo.app.global.SingleLiveEvent import com.duckduckgo.app.global.plugins.view_model.ViewModelFactoryPlugin @@ -45,6 +46,7 @@ class SettingsViewModel @Inject constructor( private val settingsDataStore: SettingsDataStore, private val defaultWebBrowserCapability: DefaultBrowserDetector, private val variantManager: VariantManager, + private val emailManager: EmailManager, private val fireAnimationLoader: FireAnimationLoader, private val pixel: Pixel ) : ViewModel() { @@ -59,9 +61,15 @@ class SettingsViewModel @Inject constructor( val selectedFireAnimation: FireAnimation = FireAnimation.HeroFire, val automaticallyClearData: AutomaticallyClearData = AutomaticallyClearData(ClearWhatOption.CLEAR_NONE, ClearWhenOption.APP_EXIT_ONLY), val appIcon: AppIcon = AppIcon.DEFAULT, - val globalPrivacyControlEnabled: Boolean = false + val globalPrivacyControlEnabled: Boolean = false, + val emailSetting: EmailSetting = EmailSetting.EmailSettingOff ) + sealed class EmailSetting { + object EmailSettingOff : EmailSetting() + data class EmailSettingOn(val emailAddress: String) : EmailSetting() + } + data class AutomaticallyClearData( val clearWhatOption: ClearWhatOption, val clearWhenOption: ClearWhenOption, @@ -77,6 +85,7 @@ class SettingsViewModel @Inject constructor( object LaunchFireAnimationSettings : Command() object LaunchGlobalPrivacyControl : Command() object UpdateTheme : Command() + object LaunchEmailDialog : Command() } val viewState: MutableLiveData = MutableLiveData().apply { @@ -107,10 +116,30 @@ class SettingsViewModel @Inject constructor( automaticallyClearData = AutomaticallyClearData(automaticallyClearWhat, automaticallyClearWhen, automaticallyClearWhenEnabled), appIcon = settingsDataStore.appIcon, selectedFireAnimation = settingsDataStore.selectedFireAnimation, - globalPrivacyControlEnabled = settingsDataStore.globalPrivacyControlEnabled + globalPrivacyControlEnabled = settingsDataStore.globalPrivacyControlEnabled, + emailSetting = getEmailSetting() ) } + private fun getEmailSetting(): EmailSetting { + val emailAddress = emailManager.getEmailAddress() + + return if (emailManager.isSignedIn()) { + when (emailAddress) { + null -> EmailSetting.EmailSettingOff + else -> EmailSetting.EmailSettingOn(emailAddress) + } + } else { + EmailSetting.EmailSettingOff + } + } + + fun onEmailSettingClicked() { + if (getEmailSetting() is EmailSetting.EmailSettingOn) { + command.value = Command.LaunchEmailDialog + } + } + fun userRequestedToSendFeedback() { command.value = Command.LaunchFeedback } @@ -136,6 +165,11 @@ class SettingsViewModel @Inject constructor( command.value = Command.LaunchGlobalPrivacyControl } + fun onEmailLogout() { + emailManager.signOut() + viewState.value = currentViewState().copy(emailSetting = EmailSetting.EmailSettingOff) + } + fun onLightThemeToggled(enabled: Boolean) { Timber.i("User toggled light theme, is now enabled: $enabled") settingsDataStore.theme = if (enabled) DuckDuckGoTheme.LIGHT else DuckDuckGoTheme.DARK @@ -246,13 +280,14 @@ class SettingsViewModelFactory @Inject constructor( private val settingsDataStore: Provider, private val defaultWebBrowserCapability: Provider, private val variantManager: Provider, + private val emailManager: Provider, private val fireAnimationLoader: Provider, private val pixel: Provider ) : ViewModelFactoryPlugin { override fun create(modelClass: Class): T? { with(modelClass) { return when { - isAssignableFrom(SettingsViewModel::class.java) -> (SettingsViewModel(settingsDataStore.get(), defaultWebBrowserCapability.get(), variantManager.get(), fireAnimationLoader.get(), pixel.get()) as T) + isAssignableFrom(SettingsViewModel::class.java) -> (SettingsViewModel(settingsDataStore.get(), defaultWebBrowserCapability.get(), variantManager.get(), emailManager.get(), fireAnimationLoader.get(), pixel.get()) as T) else -> null } } diff --git a/app/src/main/res/layout/content_autofill_tooltip.xml b/app/src/main/res/layout/content_autofill_tooltip.xml new file mode 100644 index 000000000000..22063ce891c7 --- /dev/null +++ b/app/src/main/res/layout/content_autofill_tooltip.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/content_settings_privacy.xml b/app/src/main/res/layout/content_settings_privacy.xml index 82abceee533b..0bbbaf025267 100644 --- a/app/src/main/res/layout/content_settings_privacy.xml +++ b/app/src/main/res/layout/content_settings_privacy.xml @@ -80,4 +80,13 @@ android:text="@string/settingsPrivacyProtectionWhitelist" app:layout_constraintTop_toBottomOf="@id/automaticallyClearWhenSetting" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/popup_window_browser_menu.xml b/app/src/main/res/layout/popup_window_browser_menu.xml index b7e0959d5027..3106584e876e 100644 --- a/app/src/main/res/layout/popup_window_browser_menu.xml +++ b/app/src/main/res/layout/popup_window_browser_menu.xml @@ -101,6 +101,11 @@ style="@style/BrowserTextMenuItem" android:text="@string/shareMenuTitle" /> + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/raw/autofill.js b/app/src/main/res/raw/autofill.js new file mode 100644 index 000000000000..9865e6723ad6 --- /dev/null +++ b/app/src/main/res/raw/autofill.js @@ -0,0 +1,2211 @@ +(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i 0; + } + }); + } + return; +} + +/** + * Returns the embedding frame element, if any. + * @param {!Document} doc + * @return {!Element} + */ +function getFrameElement(doc) { + try { + return doc.defaultView && doc.defaultView.frameElement || null; + } catch (e) { + // Ignore the error. + return null; + } +} + +/** + * A local reference to the root document. + */ +var document = (function(startDoc) { + var doc = startDoc; + var frame = getFrameElement(doc); + while (frame) { + doc = frame.ownerDocument; + frame = getFrameElement(doc); + } + return doc; +})(window.document); + +/** + * An IntersectionObserver registry. This registry exists to hold a strong + * reference to IntersectionObserver instances currently observing a target + * element. Without this registry, instances without another reference may be + * garbage collected. + */ +var registry = []; + +/** + * The signal updater for cross-origin intersection. When not null, it means + * that the polyfill is configured to work in a cross-origin mode. + * @type {function(DOMRect|ClientRect, DOMRect|ClientRect)} + */ +var crossOriginUpdater = null; + +/** + * The current cross-origin intersection. Only used in the cross-origin mode. + * @type {DOMRect|ClientRect} + */ +var crossOriginRect = null; + + +/** + * Creates the global IntersectionObserverEntry constructor. + * https://w3c.github.io/IntersectionObserver/#intersection-observer-entry + * @param {Object} entry A dictionary of instance properties. + * @constructor + */ +function IntersectionObserverEntry(entry) { + this.time = entry.time; + this.target = entry.target; + this.rootBounds = ensureDOMRect(entry.rootBounds); + this.boundingClientRect = ensureDOMRect(entry.boundingClientRect); + this.intersectionRect = ensureDOMRect(entry.intersectionRect || getEmptyRect()); + this.isIntersecting = !!entry.intersectionRect; + + // Calculates the intersection ratio. + var targetRect = this.boundingClientRect; + var targetArea = targetRect.width * targetRect.height; + var intersectionRect = this.intersectionRect; + var intersectionArea = intersectionRect.width * intersectionRect.height; + + // Sets intersection ratio. + if (targetArea) { + // Round the intersection ratio to avoid floating point math issues: + // https://github.com/w3c/IntersectionObserver/issues/324 + this.intersectionRatio = Number((intersectionArea / targetArea).toFixed(4)); + } else { + // If area is zero and is intersecting, sets to 1, otherwise to 0 + this.intersectionRatio = this.isIntersecting ? 1 : 0; + } +} + + +/** + * Creates the global IntersectionObserver constructor. + * https://w3c.github.io/IntersectionObserver/#intersection-observer-interface + * @param {Function} callback The function to be invoked after intersection + * changes have queued. The function is not invoked if the queue has + * been emptied by calling the `takeRecords` method. + * @param {Object=} opt_options Optional configuration options. + * @constructor + */ +function IntersectionObserver(callback, opt_options) { + + var options = opt_options || {}; + + if (typeof callback != 'function') { + throw new Error('callback must be a function'); + } + + if (options.root && options.root.nodeType != 1) { + throw new Error('root must be an Element'); + } + + // Binds and throttles `this._checkForIntersections`. + this._checkForIntersections = throttle( + this._checkForIntersections.bind(this), this.THROTTLE_TIMEOUT); + + // Private properties. + this._callback = callback; + this._observationTargets = []; + this._queuedEntries = []; + this._rootMarginValues = this._parseRootMargin(options.rootMargin); + + // Public properties. + this.thresholds = this._initThresholds(options.threshold); + this.root = options.root || null; + this.rootMargin = this._rootMarginValues.map(function(margin) { + return margin.value + margin.unit; + }).join(' '); + + /** @private @const {!Array} */ + this._monitoringDocuments = []; + /** @private @const {!Array} */ + this._monitoringUnsubscribes = []; +} + + +/** + * The minimum interval within which the document will be checked for + * intersection changes. + */ +IntersectionObserver.prototype.THROTTLE_TIMEOUT = 100; + + +/** + * The frequency in which the polyfill polls for intersection changes. + * this can be updated on a per instance basis and must be set prior to + * calling `observe` on the first target. + */ +IntersectionObserver.prototype.POLL_INTERVAL = null; + +/** + * Use a mutation observer on the root element + * to detect intersection changes. + */ +IntersectionObserver.prototype.USE_MUTATION_OBSERVER = true; + + +/** + * Sets up the polyfill in the cross-origin mode. The result is the + * updater function that accepts two arguments: `boundingClientRect` and + * `intersectionRect` - just as these fields would be available to the + * parent via `IntersectionObserverEntry`. This function should be called + * each time the iframe receives intersection information from the parent + * window, e.g. via messaging. + * @return {function(DOMRect|ClientRect, DOMRect|ClientRect)} + */ +IntersectionObserver._setupCrossOriginUpdater = function() { + if (!crossOriginUpdater) { + /** + * @param {DOMRect|ClientRect} boundingClientRect + * @param {DOMRect|ClientRect} intersectionRect + */ + crossOriginUpdater = function(boundingClientRect, intersectionRect) { + if (!boundingClientRect || !intersectionRect) { + crossOriginRect = getEmptyRect(); + } else { + crossOriginRect = convertFromParentRect(boundingClientRect, intersectionRect); + } + registry.forEach(function(observer) { + observer._checkForIntersections(); + }); + }; + } + return crossOriginUpdater; +}; + + +/** + * Resets the cross-origin mode. + */ +IntersectionObserver._resetCrossOriginUpdater = function() { + crossOriginUpdater = null; + crossOriginRect = null; +}; + + +/** + * Starts observing a target element for intersection changes based on + * the thresholds values. + * @param {Element} target The DOM element to observe. + */ +IntersectionObserver.prototype.observe = function(target) { + var isTargetAlreadyObserved = this._observationTargets.some(function(item) { + return item.element == target; + }); + + if (isTargetAlreadyObserved) { + return; + } + + if (!(target && target.nodeType == 1)) { + throw new Error('target must be an Element'); + } + + this._registerInstance(); + this._observationTargets.push({element: target, entry: null}); + this._monitorIntersections(target.ownerDocument); + this._checkForIntersections(); +}; + + +/** + * Stops observing a target element for intersection changes. + * @param {Element} target The DOM element to observe. + */ +IntersectionObserver.prototype.unobserve = function(target) { + this._observationTargets = + this._observationTargets.filter(function(item) { + return item.element != target; + }); + this._unmonitorIntersections(target.ownerDocument); + if (this._observationTargets.length == 0) { + this._unregisterInstance(); + } +}; + + +/** + * Stops observing all target elements for intersection changes. + */ +IntersectionObserver.prototype.disconnect = function() { + this._observationTargets = []; + this._unmonitorAllIntersections(); + this._unregisterInstance(); +}; + + +/** + * Returns any queue entries that have not yet been reported to the + * callback and clears the queue. This can be used in conjunction with the + * callback to obtain the absolute most up-to-date intersection information. + * @return {Array} The currently queued entries. + */ +IntersectionObserver.prototype.takeRecords = function() { + var records = this._queuedEntries.slice(); + this._queuedEntries = []; + return records; +}; + + +/** + * Accepts the threshold value from the user configuration object and + * returns a sorted array of unique threshold values. If a value is not + * between 0 and 1 and error is thrown. + * @private + * @param {Array|number=} opt_threshold An optional threshold value or + * a list of threshold values, defaulting to [0]. + * @return {Array} A sorted list of unique and valid threshold values. + */ +IntersectionObserver.prototype._initThresholds = function(opt_threshold) { + var threshold = opt_threshold || [0]; + if (!Array.isArray(threshold)) threshold = [threshold]; + + return threshold.sort().filter(function(t, i, a) { + if (typeof t != 'number' || isNaN(t) || t < 0 || t > 1) { + throw new Error('threshold must be a number between 0 and 1 inclusively'); + } + return t !== a[i - 1]; + }); +}; + + +/** + * Accepts the rootMargin value from the user configuration object + * and returns an array of the four margin values as an object containing + * the value and unit properties. If any of the values are not properly + * formatted or use a unit other than px or %, and error is thrown. + * @private + * @param {string=} opt_rootMargin An optional rootMargin value, + * defaulting to '0px'. + * @return {Array} An array of margin objects with the keys + * value and unit. + */ +IntersectionObserver.prototype._parseRootMargin = function(opt_rootMargin) { + var marginString = opt_rootMargin || '0px'; + var margins = marginString.split(/\s+/).map(function(margin) { + var parts = /^(-?\d*\.?\d+)(px|%)$/.exec(margin); + if (!parts) { + throw new Error('rootMargin must be specified in pixels or percent'); + } + return {value: parseFloat(parts[1]), unit: parts[2]}; + }); + + // Handles shorthand. + margins[1] = margins[1] || margins[0]; + margins[2] = margins[2] || margins[0]; + margins[3] = margins[3] || margins[1]; + + return margins; +}; + + +/** + * Starts polling for intersection changes if the polling is not already + * happening, and if the page's visibility state is visible. + * @param {!Document} doc + * @private + */ +IntersectionObserver.prototype._monitorIntersections = function(doc) { + var win = doc.defaultView; + if (!win) { + // Already destroyed. + return; + } + if (this._monitoringDocuments.indexOf(doc) != -1) { + // Already monitoring. + return; + } + + // Private state for monitoring. + var callback = this._checkForIntersections; + var monitoringInterval = null; + var domObserver = null; + + // If a poll interval is set, use polling instead of listening to + // resize and scroll events or DOM mutations. + if (this.POLL_INTERVAL) { + monitoringInterval = win.setInterval(callback, this.POLL_INTERVAL); + } else { + addEvent(win, 'resize', callback, true); + addEvent(doc, 'scroll', callback, true); + if (this.USE_MUTATION_OBSERVER && 'MutationObserver' in win) { + domObserver = new win.MutationObserver(callback); + domObserver.observe(doc, { + attributes: true, + childList: true, + characterData: true, + subtree: true + }); + } + } + + this._monitoringDocuments.push(doc); + this._monitoringUnsubscribes.push(function() { + // Get the window object again. When a friendly iframe is destroyed, it + // will be null. + var win = doc.defaultView; + + if (win) { + if (monitoringInterval) { + win.clearInterval(monitoringInterval); + } + removeEvent(win, 'resize', callback, true); + } + + removeEvent(doc, 'scroll', callback, true); + if (domObserver) { + domObserver.disconnect(); + } + }); + + // Also monitor the parent. + if (doc != (this.root && this.root.ownerDocument || document)) { + var frame = getFrameElement(doc); + if (frame) { + this._monitorIntersections(frame.ownerDocument); + } + } +}; + + +/** + * Stops polling for intersection changes. + * @param {!Document} doc + * @private + */ +IntersectionObserver.prototype._unmonitorIntersections = function(doc) { + var index = this._monitoringDocuments.indexOf(doc); + if (index == -1) { + return; + } + + var rootDoc = (this.root && this.root.ownerDocument || document); + + // Check if any dependent targets are still remaining. + var hasDependentTargets = + this._observationTargets.some(function(item) { + var itemDoc = item.element.ownerDocument; + // Target is in this context. + if (itemDoc == doc) { + return true; + } + // Target is nested in this context. + while (itemDoc && itemDoc != rootDoc) { + var frame = getFrameElement(itemDoc); + itemDoc = frame && frame.ownerDocument; + if (itemDoc == doc) { + return true; + } + } + return false; + }); + if (hasDependentTargets) { + return; + } + + // Unsubscribe. + var unsubscribe = this._monitoringUnsubscribes[index]; + this._monitoringDocuments.splice(index, 1); + this._monitoringUnsubscribes.splice(index, 1); + unsubscribe(); + + // Also unmonitor the parent. + if (doc != rootDoc) { + var frame = getFrameElement(doc); + if (frame) { + this._unmonitorIntersections(frame.ownerDocument); + } + } +}; + + +/** + * Stops polling for intersection changes. + * @param {!Document} doc + * @private + */ +IntersectionObserver.prototype._unmonitorAllIntersections = function() { + var unsubscribes = this._monitoringUnsubscribes.slice(0); + this._monitoringDocuments.length = 0; + this._monitoringUnsubscribes.length = 0; + for (var i = 0; i < unsubscribes.length; i++) { + unsubscribes[i](); + } +}; + + +/** + * Scans each observation target for intersection changes and adds them + * to the internal entries queue. If new entries are found, it + * schedules the callback to be invoked. + * @private + */ +IntersectionObserver.prototype._checkForIntersections = function() { + if (!this.root && crossOriginUpdater && !crossOriginRect) { + // Cross origin monitoring, but no initial data available yet. + return; + } + + var rootIsInDom = this._rootIsInDom(); + var rootRect = rootIsInDom ? this._getRootRect() : getEmptyRect(); + + this._observationTargets.forEach(function(item) { + var target = item.element; + var targetRect = getBoundingClientRect(target); + var rootContainsTarget = this._rootContainsTarget(target); + var oldEntry = item.entry; + var intersectionRect = rootIsInDom && rootContainsTarget && + this._computeTargetAndRootIntersection(target, targetRect, rootRect); + + var newEntry = item.entry = new IntersectionObserverEntry({ + time: now(), + target: target, + boundingClientRect: targetRect, + rootBounds: crossOriginUpdater && !this.root ? null : rootRect, + intersectionRect: intersectionRect + }); + + if (!oldEntry) { + this._queuedEntries.push(newEntry); + } else if (rootIsInDom && rootContainsTarget) { + // If the new entry intersection ratio has crossed any of the + // thresholds, add a new entry. + if (this._hasCrossedThreshold(oldEntry, newEntry)) { + this._queuedEntries.push(newEntry); + } + } else { + // If the root is not in the DOM or target is not contained within + // root but the previous entry for this target had an intersection, + // add a new record indicating removal. + if (oldEntry && oldEntry.isIntersecting) { + this._queuedEntries.push(newEntry); + } + } + }, this); + + if (this._queuedEntries.length) { + this._callback(this.takeRecords(), this); + } +}; + + +/** + * Accepts a target and root rect computes the intersection between then + * following the algorithm in the spec. + * TODO(philipwalton): at this time clip-path is not considered. + * https://w3c.github.io/IntersectionObserver/#calculate-intersection-rect-algo + * @param {Element} target The target DOM element + * @param {Object} targetRect The bounding rect of the target. + * @param {Object} rootRect The bounding rect of the root after being + * expanded by the rootMargin value. + * @return {?Object} The final intersection rect object or undefined if no + * intersection is found. + * @private + */ +IntersectionObserver.prototype._computeTargetAndRootIntersection = + function(target, targetRect, rootRect) { + // If the element isn't displayed, an intersection can't happen. + if (window.getComputedStyle(target).display == 'none') return; + + var intersectionRect = targetRect; + var parent = getParentNode(target); + var atRoot = false; + + while (!atRoot && parent) { + var parentRect = null; + var parentComputedStyle = parent.nodeType == 1 ? + window.getComputedStyle(parent) : {}; + + // If the parent isn't displayed, an intersection can't happen. + if (parentComputedStyle.display == 'none') return null; + + if (parent == this.root || parent.nodeType == /* DOCUMENT */ 9) { + atRoot = true; + if (parent == this.root || parent == document) { + if (crossOriginUpdater && !this.root) { + if (!crossOriginRect || + crossOriginRect.width == 0 && crossOriginRect.height == 0) { + // A 0-size cross-origin intersection means no-intersection. + parent = null; + parentRect = null; + intersectionRect = null; + } else { + parentRect = crossOriginRect; + } + } else { + parentRect = rootRect; + } + } else { + // Check if there's a frame that can be navigated to. + var frame = getParentNode(parent); + var frameRect = frame && getBoundingClientRect(frame); + var frameIntersect = + frame && + this._computeTargetAndRootIntersection(frame, frameRect, rootRect); + if (frameRect && frameIntersect) { + parent = frame; + parentRect = convertFromParentRect(frameRect, frameIntersect); + } else { + parent = null; + intersectionRect = null; + } + } + } else { + // If the element has a non-visible overflow, and it's not the + // or element, update the intersection rect. + // Note: and cannot be clipped to a rect that's not also + // the document rect, so no need to compute a new intersection. + var doc = parent.ownerDocument; + if (parent != doc.body && + parent != doc.documentElement && + parentComputedStyle.overflow != 'visible') { + parentRect = getBoundingClientRect(parent); + } + } + + // If either of the above conditionals set a new parentRect, + // calculate new intersection data. + if (parentRect) { + intersectionRect = computeRectIntersection(parentRect, intersectionRect); + } + if (!intersectionRect) break; + parent = parent && getParentNode(parent); + } + return intersectionRect; +}; + + +/** + * Returns the root rect after being expanded by the rootMargin value. + * @return {ClientRect} The expanded root rect. + * @private + */ +IntersectionObserver.prototype._getRootRect = function() { + var rootRect; + if (this.root) { + rootRect = getBoundingClientRect(this.root); + } else { + // Use / instead of window since scroll bars affect size. + var html = document.documentElement; + var body = document.body; + rootRect = { + top: 0, + left: 0, + right: html.clientWidth || body.clientWidth, + width: html.clientWidth || body.clientWidth, + bottom: html.clientHeight || body.clientHeight, + height: html.clientHeight || body.clientHeight + }; + } + return this._expandRectByRootMargin(rootRect); +}; + + +/** + * Accepts a rect and expands it by the rootMargin value. + * @param {DOMRect|ClientRect} rect The rect object to expand. + * @return {ClientRect} The expanded rect. + * @private + */ +IntersectionObserver.prototype._expandRectByRootMargin = function(rect) { + var margins = this._rootMarginValues.map(function(margin, i) { + return margin.unit == 'px' ? margin.value : + margin.value * (i % 2 ? rect.width : rect.height) / 100; + }); + var newRect = { + top: rect.top - margins[0], + right: rect.right + margins[1], + bottom: rect.bottom + margins[2], + left: rect.left - margins[3] + }; + newRect.width = newRect.right - newRect.left; + newRect.height = newRect.bottom - newRect.top; + + return newRect; +}; + + +/** + * Accepts an old and new entry and returns true if at least one of the + * threshold values has been crossed. + * @param {?IntersectionObserverEntry} oldEntry The previous entry for a + * particular target element or null if no previous entry exists. + * @param {IntersectionObserverEntry} newEntry The current entry for a + * particular target element. + * @return {boolean} Returns true if a any threshold has been crossed. + * @private + */ +IntersectionObserver.prototype._hasCrossedThreshold = + function(oldEntry, newEntry) { + + // To make comparing easier, an entry that has a ratio of 0 + // but does not actually intersect is given a value of -1 + var oldRatio = oldEntry && oldEntry.isIntersecting ? + oldEntry.intersectionRatio || 0 : -1; + var newRatio = newEntry.isIntersecting ? + newEntry.intersectionRatio || 0 : -1; + + // Ignore unchanged ratios + if (oldRatio === newRatio) return; + + for (var i = 0; i < this.thresholds.length; i++) { + var threshold = this.thresholds[i]; + + // Return true if an entry matches a threshold or if the new ratio + // and the old ratio are on the opposite sides of a threshold. + if (threshold == oldRatio || threshold == newRatio || + threshold < oldRatio !== threshold < newRatio) { + return true; + } + } +}; + + +/** + * Returns whether or not the root element is an element and is in the DOM. + * @return {boolean} True if the root element is an element and is in the DOM. + * @private + */ +IntersectionObserver.prototype._rootIsInDom = function() { + return !this.root || containsDeep(document, this.root); +}; + + +/** + * Returns whether or not the target element is a child of root. + * @param {Element} target The target element to check. + * @return {boolean} True if the target element is a child of root. + * @private + */ +IntersectionObserver.prototype._rootContainsTarget = function(target) { + return containsDeep(this.root || document, target) && + (!this.root || this.root.ownerDocument == target.ownerDocument); +}; + + +/** + * Adds the instance to the global IntersectionObserver registry if it isn't + * already present. + * @private + */ +IntersectionObserver.prototype._registerInstance = function() { + if (registry.indexOf(this) < 0) { + registry.push(this); + } +}; + + +/** + * Removes the instance from the global IntersectionObserver registry. + * @private + */ +IntersectionObserver.prototype._unregisterInstance = function() { + var index = registry.indexOf(this); + if (index != -1) registry.splice(index, 1); +}; + + +/** + * Returns the result of the performance.now() method or null in browsers + * that don't support the API. + * @return {number} The elapsed time since the page was requested. + */ +function now() { + return window.performance && performance.now && performance.now(); +} + + +/** + * Throttles a function and delays its execution, so it's only called at most + * once within a given time period. + * @param {Function} fn The function to throttle. + * @param {number} timeout The amount of time that must pass before the + * function can be called again. + * @return {Function} The throttled function. + */ +function throttle(fn, timeout) { + var timer = null; + return function () { + if (!timer) { + timer = setTimeout(function() { + fn(); + timer = null; + }, timeout); + } + }; +} + + +/** + * Adds an event handler to a DOM node ensuring cross-browser compatibility. + * @param {Node} node The DOM node to add the event handler to. + * @param {string} event The event name. + * @param {Function} fn The event handler to add. + * @param {boolean} opt_useCapture Optionally adds the even to the capture + * phase. Note: this only works in modern browsers. + */ +function addEvent(node, event, fn, opt_useCapture) { + if (typeof node.addEventListener == 'function') { + node.addEventListener(event, fn, opt_useCapture || false); + } + else if (typeof node.attachEvent == 'function') { + node.attachEvent('on' + event, fn); + } +} + + +/** + * Removes a previously added event handler from a DOM node. + * @param {Node} node The DOM node to remove the event handler from. + * @param {string} event The event name. + * @param {Function} fn The event handler to remove. + * @param {boolean} opt_useCapture If the event handler was added with this + * flag set to true, it should be set to true here in order to remove it. + */ +function removeEvent(node, event, fn, opt_useCapture) { + if (typeof node.removeEventListener == 'function') { + node.removeEventListener(event, fn, opt_useCapture || false); + } + else if (typeof node.detatchEvent == 'function') { + node.detatchEvent('on' + event, fn); + } +} + + +/** + * Returns the intersection between two rect objects. + * @param {Object} rect1 The first rect. + * @param {Object} rect2 The second rect. + * @return {?Object|?ClientRect} The intersection rect or undefined if no + * intersection is found. + */ +function computeRectIntersection(rect1, rect2) { + var top = Math.max(rect1.top, rect2.top); + var bottom = Math.min(rect1.bottom, rect2.bottom); + var left = Math.max(rect1.left, rect2.left); + var right = Math.min(rect1.right, rect2.right); + var width = right - left; + var height = bottom - top; + + return (width >= 0 && height >= 0) && { + top: top, + bottom: bottom, + left: left, + right: right, + width: width, + height: height + } || null; +} + + +/** + * Shims the native getBoundingClientRect for compatibility with older IE. + * @param {Element} el The element whose bounding rect to get. + * @return {DOMRect|ClientRect} The (possibly shimmed) rect of the element. + */ +function getBoundingClientRect(el) { + var rect; + + try { + rect = el.getBoundingClientRect(); + } catch (err) { + // Ignore Windows 7 IE11 "Unspecified error" + // https://github.com/w3c/IntersectionObserver/pull/205 + } + + if (!rect) return getEmptyRect(); + + // Older IE + if (!(rect.width && rect.height)) { + rect = { + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + width: rect.right - rect.left, + height: rect.bottom - rect.top + }; + } + return rect; +} + + +/** + * Returns an empty rect object. An empty rect is returned when an element + * is not in the DOM. + * @return {ClientRect} The empty rect. + */ +function getEmptyRect() { + return { + top: 0, + bottom: 0, + left: 0, + right: 0, + width: 0, + height: 0 + }; +} + + +/** + * Ensure that the result has all of the necessary fields of the DOMRect. + * Specifically this ensures that `x` and `y` fields are set. + * + * @param {?DOMRect|?ClientRect} rect + * @return {?DOMRect} + */ +function ensureDOMRect(rect) { + // A `DOMRect` object has `x` and `y` fields. + if (!rect || 'x' in rect) { + return rect; + } + // A IE's `ClientRect` type does not have `x` and `y`. The same is the case + // for internally calculated Rect objects. For the purposes of + // `IntersectionObserver`, it's sufficient to simply mirror `left` and `top` + // for these fields. + return { + top: rect.top, + y: rect.top, + bottom: rect.bottom, + left: rect.left, + x: rect.left, + right: rect.right, + width: rect.width, + height: rect.height + }; +} + + +/** + * Inverts the intersection and bounding rect from the parent (frame) BCR to + * the local BCR space. + * @param {DOMRect|ClientRect} parentBoundingRect The parent's bound client rect. + * @param {DOMRect|ClientRect} parentIntersectionRect The parent's own intersection rect. + * @return {ClientRect} The local root bounding rect for the parent's children. + */ +function convertFromParentRect(parentBoundingRect, parentIntersectionRect) { + var top = parentIntersectionRect.top - parentBoundingRect.top; + var left = parentIntersectionRect.left - parentBoundingRect.left; + return { + top: top, + left: left, + height: parentIntersectionRect.height, + width: parentIntersectionRect.width, + bottom: top + parentIntersectionRect.height, + right: left + parentIntersectionRect.width + }; +} + + +/** + * Checks to see if a parent element contains a child element (including inside + * shadow DOM). + * @param {Node} parent The parent element. + * @param {Node} child The child element. + * @return {boolean} True if the parent node contains the child node. + */ +function containsDeep(parent, child) { + var node = child; + while (node) { + if (node == parent) return true; + + node = getParentNode(node); + } + return false; +} + + +/** + * Gets the parent node of an element or its host element if the parent node + * is a shadow root. + * @param {Node} node The node whose parent to get. + * @return {Node|null} The parent node or null if no parent exists. + */ +function getParentNode(node) { + var parent = node.parentNode; + + if (node.nodeType == /* DOCUMENT */ 9 && node != document) { + // If this node is a document node, look for the embedding frame. + return getFrameElement(node); + } + + if (parent && parent.nodeType == 11 && parent.host) { + // If the parent is a shadow root, return the host element. + return parent.host; + } + + if (parent && parent.assignedSlot) { + // If the parent is distributed in a , return the parent of a slot. + return parent.assignedSlot.parentNode; + } + + return parent; +} + + +// Exposes the constructors globally. +window.IntersectionObserver = IntersectionObserver; +window.IntersectionObserverEntry = IntersectionObserverEntry; + +}()); + +},{}],2:[function(require,module,exports){ +"use strict"; + +module.exports = "\n.wrapper *, .wrapper *::before, .wrapper *::after {\n box-sizing: border-box;\n}\n.wrapper {\n position: fixed;\n top: 0;\n left: 0;\n padding: 0;\n font-family: 'DDG_ProximaNova', 'Proxima Nova', -apple-system,\n BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',\n 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;\n -webkit-font-smoothing: antialiased;\n z-index: 2147483647;\n}\n.tooltip {\n position: absolute;\n bottom: calc(100% + 15px);\n right: calc(100% - 60px);\n width: 350px;\n max-width: calc(100vw - 25px);\n padding: 25px;\n border: 1px solid #D0D0D0;\n border-radius: 20px;\n background-color: #FFFFFF;\n font-size: 14px;\n color: #333333;\n line-height: 1.4;\n box-shadow: 0 10px 20px rgba(0, 0, 0, 0.15);\n z-index: 2147483647;\n}\n.tooltip::before {\n content: \"\";\n width: 0;\n height: 0;\n border-left: 10px solid transparent;\n border-right: 10px solid transparent;\n display: block;\n border-top: 12px solid #D0D0D0;\n position: absolute;\n right: 34px;\n bottom: -12px;\n}\n.tooltip::after {\n content: \"\";\n width: 0;\n height: 0;\n border-left: 10px solid transparent;\n border-right: 10px solid transparent;\n display: block;\n border-top: 12px solid #FFFFFF;\n position: absolute;\n right: 34px;\n bottom: -10px;\n}\n.tooltip__title {\n margin: -4px 0 4px;\n font-size: 16px;\n font-weight: bold;\n line-height: 1.3;\n}\n.tooltip p {\n margin: 4px 0 12px;\n}\n.tooltip strong {\n font-weight: bold;\n}\n.tooltip__alias-container {\n display: flex;\n justify-content: center;\n align-items: center;\n margin: 10px auto 15px;\n font-size: 16px;\n}\n.alias {\n padding-left: 4px;\n}\n.tooltip__button-container {\n display: flex;\n}\n.tooltip__button {\n flex: 1;\n height: 40px;\n padding: 0 8px;\n background-color: #332FF3;\n color: #FFFFFF;\n font-family: inherit;\n font-size: 16px;\n font-weight: bold;\n border: none;\n border-radius: 10px;\n}\n.tooltip__button:last-child {\n margin-left: 12px;\n}\n.tooltip__button--secondary {\n background-color: #EEEEEE;\n color: #332FF3;\n}\n"; + +},{}],3:[function(require,module,exports){ +const {daxSvg} = require('./logo-svg') +const { isApp, getDaxBoundingBox, safeExecute } = require('./autofill-utils') + +class DDGAutofill { + constructor (input, associatedForm, getAlias, refreshAlias) { + const shadow = document.createElement('ddg-autofill').attachShadow({mode: 'closed'}) + this.host = shadow.host + this.input = input + this.associatedForm = associatedForm + this.animationFrame = null + + const includeStyles = isApp + ? `` + : `` + + shadow.innerHTML = ` +${includeStyles} +
+ +
` + this.wrapper = shadow.querySelector('.wrapper') + this.tooltip = shadow.querySelector('.tooltip') + this.dismissButton = shadow.querySelector('.js-dismiss') + this.confirmButton = shadow.querySelector('.js-confirm') + this.aliasEl = shadow.querySelector('.alias') + this.stylesheet = shadow.querySelector('link, style') + // Un-hide once the style is loaded, to avoid flashing unstyled content + this.stylesheet.addEventListener('load', () => + this.tooltip.removeAttribute('hidden')) + + this.updateAliasInTooltip = () => { + const [alias] = this.nextAlias.split('@') + this.aliasEl.textContent = alias + } + + // Get the alias from the extension + getAlias().then((alias) => { + if (alias) { + this.nextAlias = alias + this.updateAliasInTooltip() + } + }) + + this.top = 0 + this.left = 0 + this.transformRuleIndex = null + this.updatePosition = ({left, top}) => { + // If the stylesheet is not loaded wait for load (Chrome bug) + if (!shadow.styleSheets.length) return this.stylesheet.addEventListener('load', this.checkPosition) + + this.left = left + this.top = top + + if (this.transformRuleIndex && shadow.styleSheets[this.transformRuleIndex]) { + // If we have already set the rule, remove it… + shadow.styleSheets[0].deleteRule(this.transformRuleIndex) + } else { + // …otherwise, set the index as the very last rule + this.transformRuleIndex = shadow.styleSheets[0].rules.length + } + + const newRule = `.wrapper {transform: translate(${left}px, ${top}px);}` + shadow.styleSheets[0].insertRule(newRule, this.transformRuleIndex) + } + + this.append = () => document.body.appendChild(shadow.host) + this.append() + this.lift = () => { + this.left = null + this.top = null + document.body.removeChild(this.host) + } + + this.remove = () => { + window.removeEventListener('scroll', this.checkPosition, {passive: true, capture: true}) + this.resObs.disconnect() + this.mutObs.disconnect() + this.lift() + } + + this.checkPosition = () => { + if (this.animationFrame) { + window.cancelAnimationFrame(this.animationFrame) + } + + this.animationFrame = window.requestAnimationFrame(() => { + const {left, top} = getDaxBoundingBox(this.input) + + if (left !== this.left || top !== this.top) { + this.updatePosition({left, top}) + } + + this.animationFrame = null + }) + } + this.resObs = new ResizeObserver(entries => entries.forEach(this.checkPosition)) + this.resObs.observe(document.body) + this.count = 0 + this.ensureIsLastInDOM = () => { + // If DDG el is not the last in the doc, move them there + if (document.body.lastElementChild !== this.host) { + this.lift() + + // Try up to 5 times to avoid infinite loop in case someone is doing the same + if (this.count < 15) { + this.append() + this.checkPosition() + this.count++ + } else { + // Reset count so we can resume normal flow + this.count = 0 + console.info(`DDG autofill bailing out`) + } + } + } + this.mutObs = new MutationObserver((mutationList) => { + for (const mutationRecord of mutationList) { + if (mutationRecord.type === 'childList') { + // Only check added nodes + mutationRecord.addedNodes.forEach(el => { + if (el.nodeName === 'DDG-AUTOFILL') return + + this.ensureIsLastInDOM() + }) + } + } + this.checkPosition() + }) + this.mutObs.observe(document.body, {childList: true, subtree: true, attributes: true}) + window.addEventListener('scroll', this.checkPosition, {passive: true, capture: true}) + + this.dismissButton.addEventListener('click', (e) => { + if (!e.isTrusted) return + + e.stopImmediatePropagation() + this.associatedForm.dismissTooltip() + }) + this.confirmButton.addEventListener('click', (e) => { + if (!e.isTrusted) return + e.stopImmediatePropagation() + + safeExecute(this.confirmButton, () => { + this.associatedForm.autofill(this.nextAlias) + refreshAlias() + }) + }) + } +} + +module.exports = DDGAutofill + +},{"./DDGAutofill-styles.js":2,"./autofill-utils":7,"./logo-svg":9}],4:[function(require,module,exports){ +"use strict"; + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +var DDGAutofill = require('./DDGAutofill'); + +var _require = require('./autofill-utils'), + isApp = _require.isApp, + notifyWebApp = _require.notifyWebApp, + isDDGApp = _require.isDDGApp, + isAndroid = _require.isAndroid, + isDDGDomain = _require.isDDGDomain, + sendAndWaitForAnswer = _require.sendAndWaitForAnswer, + setValue = _require.setValue; + +var scanForInputs = require('./scanForInputs.js'); + +var SIGN_IN_MSG = { + signMeIn: true +}; + +var createAttachTooltip = function createAttachTooltip(getAlias, refreshAlias) { + return function (form, input) { + if (isDDGApp && !isApp) { + form.activeInput = input; + getAlias().then(function (alias) { + if (alias) form.autofill(alias);else form.activeInput.focus(); + }); + } else { + if (form.tooltip) return; + form.tooltip = new DDGAutofill(input, form, getAlias, refreshAlias); + form.intObs.observe(input); + window.addEventListener('mousedown', form.removeTooltip, { + capture: true + }); + } + }; +}; + +var ExtensionInterface = function ExtensionInterface() { + var _this = this; + + _classCallCheck(this, ExtensionInterface); + + this.getAlias = function () { + return new Promise(function (resolve) { + return chrome.runtime.sendMessage({ + getAlias: true + }, function (_ref) { + var alias = _ref.alias; + return resolve(alias); + }); + }); + }; + + this.refreshAlias = function () { + return chrome.runtime.sendMessage({ + refreshAlias: true + }); + }; + + this.isDeviceSignedIn = function () { + return _this.getAlias(); + }; + + this.trySigningIn = function () { + if (isDDGDomain()) { + sendAndWaitForAnswer(SIGN_IN_MSG, 'addUserData').then(function (data) { + return _this.storeUserData(data); + }); + } + }; + + this.storeUserData = function (data) { + return chrome.runtime.sendMessage(data); + }; + + this.addDeviceListeners = function () { + // Add contextual menu listeners + var activeEl = null; + document.addEventListener('contextmenu', function (e) { + activeEl = e.target; + }); + chrome.runtime.onMessage.addListener(function (message, sender) { + if (sender.id !== chrome.runtime.id) return; + + switch (message.type) { + case 'ddgUserReady': + scanForInputs(_this); + break; + + case 'contextualAutofill': + setValue(activeEl, message.alias); + activeEl.classList.add('ddg-autofilled'); + + _this.refreshAlias(); // If the user changes the alias, remove the decoration + + + activeEl.addEventListener('input', function (e) { + return e.target.classList.remove('ddg-autofilled'); + }, { + once: true + }); + break; + + default: + break; + } + }); + }; + + this.addLogoutListener = function (handler) { + // Cleanup on logout events + chrome.runtime.onMessage.addListener(function (message, sender) { + if (sender.id === chrome.runtime.id && message.type === 'logout') { + handler(); + } + }); + }; + + this.attachTooltip = createAttachTooltip(this.getAlias, this.refreshAlias); +}; + +var AndroidInterface = function AndroidInterface() { + var _this2 = this; + + _classCallCheck(this, AndroidInterface); + + this.getAlias = function () { + return sendAndWaitForAnswer(function () { + return window.EmailInterface.showTooltip(); + }, 'getAliasResponse').then(function (_ref2) { + var alias = _ref2.alias; + return alias; + }); + }; + + this.refreshAlias = function () {}; + + this.isDeviceSignedIn = function () { + return new Promise(function (resolve) { + return resolve(window.EmailInterface.isSignedIn() === 'true'); + }); + }; + + this.trySigningIn = function () { + if (isDDGDomain()) { + sendAndWaitForAnswer(SIGN_IN_MSG, 'addUserData').then(function (data) { + // This call doesn't send a response, so we can't know if it succeded + _this2.storeUserData(data); + + scanForInputs(_this2); + }); + } + }; + + this.storeUserData = function (_ref3) { + var _ref3$addUserData = _ref3.addUserData, + token = _ref3$addUserData.token, + userName = _ref3$addUserData.userName; + return window.EmailInterface.storeCredentials(token, userName); + }; + + this.addDeviceListeners = function () {}; + + this.addLogoutListener = function () {}; + + this.attachTooltip = createAttachTooltip(this.getAlias); +}; + +var AppleDeviceInterface = function AppleDeviceInterface() { + var _this3 = this; + + _classCallCheck(this, AppleDeviceInterface); + + if (isDDGDomain()) { + // Tell the web app whether we're in the app + notifyWebApp({ + isApp: isApp + }); + } + + this.getAlias = function () { + return sendAndWaitForAnswer(function () { + return window.webkit.messageHandlers['emailHandlerGetAlias'].postMessage({ + requiresUserPermission: !isApp, + shouldConsumeAliasIfProvided: !isApp + }); + }, 'getAliasResponse').then(function (_ref4) { + var alias = _ref4.alias; + return alias; + }); + }; + + this.refreshAlias = function () { + return window.webkit.messageHandlers['emailHandlerRefreshAlias'].postMessage({}); + }; + + this.isDeviceSignedIn = function () { + return sendAndWaitForAnswer(function () { + return window.webkit.messageHandlers['emailHandlerCheckAppSignedInStatus'].postMessage({}); + }, 'checkExtensionSignedInCallback').then(function (data) { + return data.isAppSignedIn; + }); + }; + + this.trySigningIn = function () { + if (isDDGDomain()) { + sendAndWaitForAnswer(SIGN_IN_MSG, 'addUserData').then(function (data) { + // This call doesn't send a response, so we can't know if it succeded + _this3.storeUserData(data); + + scanForInputs(_this3); + }); + } + }; + + this.storeUserData = function (_ref5) { + var _ref5$addUserData = _ref5.addUserData, + token = _ref5$addUserData.token, + userName = _ref5$addUserData.userName; + return window.webkit.messageHandlers['emailHandlerStoreToken'].postMessage({ + token: token, + username: userName + }); + }; + + this.addDeviceListeners = function () { + window.addEventListener('message', function (e) { + if (e.origin !== window.origin) return; + + if (e.data.ddgUserReady) { + scanForInputs(_this3); + } + }); + }; + + this.addLogoutListener = function () {}; + + this.attachTooltip = createAttachTooltip(this.getAlias, this.refreshAlias); +}; + +var DeviceInterface = function () { + if (isDDGApp) { + return isAndroid ? new AndroidInterface() : new AppleDeviceInterface(); + } + + return new ExtensionInterface(); +}(); + +module.exports = DeviceInterface; + +},{"./DDGAutofill":3,"./autofill-utils":7,"./scanForInputs.js":11}],5:[function(require,module,exports){ +"use strict"; + +function _createForOfIteratorHelper(o, allowArrayLike) { var it; if (typeof Symbol === "undefined" || o[Symbol.iterator] == null) { if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e) { throw _e; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function s() { it = o[Symbol.iterator](); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e2) { didErr = true; err = _e2; }, f: function f() { try { if (!normalCompletion && it["return"] != null) it["return"](); } finally { if (didErr) throw err; } } }; } + +function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); } + +function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; } + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var FormAnalyzer = require('./FormAnalyzer'); + +var _require = require('./autofill-utils'), + addInlineStyles = _require.addInlineStyles, + removeInlineStyles = _require.removeInlineStyles; + +var _require2 = require('./logo-svg'), + daxBase64 = _require2.daxBase64; + +var _require3 = require('./autofill-utils'), + isDDGApp = _require3.isDDGApp, + setValue = _require3.setValue, + isEventWithinDax = _require3.isEventWithinDax; + +var getDaxImg = isDDGApp ? daxBase64 : chrome.runtime.getURL('img/logo-small.svg'); + +var getDaxStyles = function getDaxStyles(input) { + return { + // Height must be > 0 to account for fields initially hidden + 'background-size': "auto ".concat(input.offsetHeight <= 30 && input.offsetHeight > 0 ? '100%' : '24px'), + 'background-position': 'center right', + 'background-repeat': 'no-repeat', + 'background-origin': 'content-box', + 'background-image': "url(".concat(getDaxImg, ")") + }; +}; + +var INLINE_AUTOFILLED_STYLES = { + 'background-color': '#F8F498', + 'color': '#333333' +}; + +var Form = /*#__PURE__*/function () { + function Form(form, input, attachTooltip) { + var _this = this; + + _classCallCheck(this, Form); + + this.form = form; + this.formAnalyzer = new FormAnalyzer(form, input); + this.attachTooltip = attachTooltip; + this.relevantInputs = new Set(); + this.touched = new Set(); + this.listeners = new Set(); + this.addInput(input); + this.tooltip = null; + this.activeInput = null; + this.intObs = new IntersectionObserver(function (entries) { + var _iterator = _createForOfIteratorHelper(entries), + _step; + + try { + for (_iterator.s(); !(_step = _iterator.n()).done;) { + var entry = _step.value; + if (!entry.isIntersecting) _this.removeTooltip(); + } + } catch (err) { + _iterator.e(err); + } finally { + _iterator.f(); + } + }); + + this.removeTooltip = function (e) { + if (e && (e.target === _this.activeInput || e.target === _this.tooltip.host)) { + return; + } + + _this.tooltip.remove(); + + _this.tooltip = null; + + _this.intObs.disconnect(); + + window.removeEventListener('mousedown', _this.removeTooltip, { + capture: true + }); + }; + + this.removeInputHighlight = function (input) { + removeInlineStyles(input, INLINE_AUTOFILLED_STYLES); + input.classList.remove('ddg-autofilled'); + }; + + this.removeAllHighlights = function (e) { + // This ensures we are not removing the highlight ourselves when autofilling more than once + if (e && !e.isTrusted) return; + + _this.execOnInputs(_this.removeInputHighlight); + }; + + this.removeInputDecoration = function (input) { + removeInlineStyles(input, getDaxStyles(input)); + input.removeAttribute('data-ddg-autofill'); + }; + + this.removeAllDecorations = function () { + _this.execOnInputs(_this.removeInputDecoration); + + _this.listeners.forEach(function (_ref) { + var el = _ref.el, + type = _ref.type, + fn = _ref.fn; + return el.removeEventListener(type, fn); + }); + }; + + this.resetAllInputs = function () { + _this.execOnInputs(function (input) { + setValue(input, ''); + + _this.removeInputHighlight(input); + }); + + if (_this.activeInput) _this.activeInput.focus(); + }; + + this.dismissTooltip = function () { + _this.resetAllInputs(); + + _this.removeTooltip(); + }; + + return this; + } + + _createClass(Form, [{ + key: "execOnInputs", + value: function execOnInputs(fn) { + this.relevantInputs.forEach(fn); + } + }, { + key: "addInput", + value: function addInput(input) { + this.relevantInputs.add(input); + if (this.formAnalyzer.autofillSignal > 0) this.decorateInput(input); + return this; + } + }, { + key: "areAllInputsEmpty", + value: function areAllInputsEmpty() { + var allEmpty = true; + this.execOnInputs(function (input) { + if (input.value) allEmpty = false; + }); + return allEmpty; + } + }, { + key: "addListener", + value: function addListener(el, type, fn) { + el.addEventListener(type, fn); + this.listeners.add({ + el: el, + type: type, + fn: fn + }); + } + }, { + key: "decorateInput", + value: function decorateInput(input) { + var _this2 = this; + + input.setAttribute('data-ddg-autofill', 'true'); + addInlineStyles(input, getDaxStyles(input)); + this.addListener(input, 'mousemove', function (e) { + if (isEventWithinDax(e, e.target)) { + e.target.style.setProperty('cursor', 'pointer', 'important'); + } else { + e.target.style.removeProperty('cursor'); + } + }); + this.addListener(input, 'mousedown', function (e) { + if (!e.isTrusted) return; + if (e.button !== 0) return; + + if (_this2.shouldOpenTooltip(e, e.target)) { + e.preventDefault(); + e.stopImmediatePropagation(); + + _this2.touched.add(e.target); + + _this2.attachTooltip(_this2, e.target); + } + }); + return this; + } + }, { + key: "shouldOpenTooltip", + value: function shouldOpenTooltip(e, input) { + return !this.touched.has(input) && this.areAllInputsEmpty() || isEventWithinDax(e, input); + } + }, { + key: "autofill", + value: function autofill(alias) { + var _this3 = this; + + this.execOnInputs(function (input) { + setValue(input, alias); + input.classList.add('ddg-autofilled'); + addInlineStyles(input, INLINE_AUTOFILLED_STYLES); // If the user changes the alias, remove the decoration + + input.addEventListener('input', _this3.removeAllHighlights, { + once: true + }); + }); + + if (this.tooltip) { + this.removeTooltip(); + } + } + }]); + + return Form; +}(); + +module.exports = Form; + +},{"./FormAnalyzer":6,"./autofill-utils":7,"./logo-svg":9}],6:[function(require,module,exports){ +"use strict"; + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } + +function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } + +var FormAnalyzer = /*#__PURE__*/function () { + function FormAnalyzer(form, input) { + _classCallCheck(this, FormAnalyzer); + + this.form = form; + this.autofillSignal = 0; + this.signals = []; // Avoid autofill on our signup page + + if (window.location.href.match(/^https:\/\/.+\.duckduckgo\.com\/email\/signup/i)) return this; + this.evaluateElAttributes(input, 3, true); + form ? this.evaluateForm() : this.evaluatePage(); + console.log(this.autofillSignal, this, this.signals); + return this; + } + + _createClass(FormAnalyzer, [{ + key: "increaseSignalBy", + value: function increaseSignalBy(strength, signal) { + this.autofillSignal += strength; + this.signals.push("".concat(signal, ": +").concat(strength)); + return this; + } + }, { + key: "decreaseSignalBy", + value: function decreaseSignalBy(strength, signal) { + this.autofillSignal -= strength; + this.signals.push("".concat(signal, ": -").concat(strength)); + return this; + } + }, { + key: "updateSignal", + value: function updateSignal(_ref) { + var string = _ref.string, + strength = _ref.strength, + _ref$signalType = _ref.signalType, + signalType = _ref$signalType === void 0 ? 'generic' : _ref$signalType, + _ref$shouldFlip = _ref.shouldFlip, + shouldFlip = _ref$shouldFlip === void 0 ? false : _ref$shouldFlip, + _ref$shouldCheckUnifi = _ref.shouldCheckUnifiedForm, + shouldCheckUnifiedForm = _ref$shouldCheckUnifi === void 0 ? false : _ref$shouldCheckUnifi, + _ref$shouldBeConserva = _ref.shouldBeConservative, + shouldBeConservative = _ref$shouldBeConserva === void 0 ? false : _ref$shouldBeConserva; + var negativeRegex = new RegExp(/sign(ing)?.?in(?!g)|log.?in/i); + var positiveRegex = new RegExp(/sign(ing)?.?up|join|regist(er|ration)|newsletter|subscri(be|ption)|contact|create|start|settings|preferences|profile|update|checkout|guest|purchase|buy|order/i); + var conservativePositiveRegex = new RegExp(/sign.?up|join|register|newsletter|subscri(be|ption)|settings|preferences|profile|update/i); + var strictPositiveRegex = new RegExp(/sign.?up|join|register|settings|preferences|profile|update/i); + var matchesNegative = string.match(negativeRegex); // Check explicitly for unified login/signup forms. They should always be negative, so we increase signal + + if (shouldCheckUnifiedForm && matchesNegative && string.match(strictPositiveRegex)) { + this.decreaseSignalBy(strength + 2, "Unified detected ".concat(signalType)); + return this; + } + + var matchesPositive = string.match(shouldBeConservative ? conservativePositiveRegex : positiveRegex); // In some cases a login match means the login is somewhere else, i.e. when a link points outside + + if (shouldFlip) { + if (matchesNegative) this.increaseSignalBy(strength, signalType); + if (matchesPositive) this.decreaseSignalBy(strength, signalType); + } else { + if (matchesNegative) this.decreaseSignalBy(strength, signalType); + if (matchesPositive) this.increaseSignalBy(strength, signalType); + } + + return this; + } + }, { + key: "evaluateElAttributes", + value: function evaluateElAttributes(el) { + var _this = this; + + var signalStrength = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 3; + var isInput = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; + Array.from(el.attributes).forEach(function (attr) { + var attributeString = "".concat(attr.nodeName, "=").concat(attr.nodeValue); + + _this.updateSignal({ + string: attributeString, + strength: signalStrength, + signalType: "".concat(el.nodeName, " attr: ").concat(attributeString), + shouldCheckUnifiedForm: isInput + }); + }); + } + }, { + key: "evaluatePageTitle", + value: function evaluatePageTitle() { + var pageTitle = document.title; + this.updateSignal({ + string: pageTitle, + strength: 2, + signalType: "page title: ".concat(pageTitle) + }); + } + }, { + key: "evaluatePageHeadings", + value: function evaluatePageHeadings() { + var _this2 = this; + + var headings = document.querySelectorAll('h1, h2, h3, [class*="title"], [id*="title"]'); + + if (headings) { + headings.forEach(function (_ref2) { + var innerText = _ref2.innerText; + + _this2.updateSignal({ + string: innerText, + strength: 0.5, + signalType: "heading: ".concat(innerText), + shouldCheckUnifiedForm: true, + shouldBeConservative: true + }); + }); + } + } + }, { + key: "evaluatePage", + value: function evaluatePage() { + var _this3 = this; + + this.evaluatePageTitle(); + this.evaluatePageHeadings(); // Check for submit buttons + + var buttons = document.querySelectorAll("\n button[type=submit],\n button:not([type]),\n [role=button]\n "); + buttons.forEach(function (button) { + // if the button has a form, it's not related to our input, because our input has no form here + if (!button.form && !button.closest('form')) { + _this3.evaluateElement(button); + + _this3.evaluateElAttributes(button, 0.5); + } + }); + } + }, { + key: "elementIs", + value: function elementIs(el, type) { + return el.nodeName.toLowerCase() === type.toLowerCase(); + } + }, { + key: "getText", + value: function getText(el) { + var _this4 = this; + + // for buttons, we don't care about descendants, just get the whole text as is + // this is important in order to give proper attribution of the text to the button + if (this.elementIs(el, 'BUTTON')) return el.innerText; + if (this.elementIs(el, 'INPUT') && ['submit', 'button'].includes(el.type)) return el.value; + return Array.from(el.childNodes).reduce(function (text, child) { + return _this4.elementIs(child, '#text') ? text + ' ' + child.textContent : text; + }, ''); + } + }, { + key: "evaluateElement", + value: function evaluateElement(el) { + var string = this.getText(el); // check button contents + + if (this.elementIs(el, 'INPUT') && ['submit', 'button'].includes(el.type) || this.elementIs(el, 'BUTTON') && el.type === 'submit' || (el.getAttribute('role') || '').toUpperCase() === 'BUTTON') { + this.updateSignal({ + string: string, + strength: 2, + signalType: "submit: ".concat(string) + }); + } // if a link points to relevant urls or contain contents outside the page… + + + if (this.elementIs(el, 'A') && el.href && el.href !== '#' || (el.getAttribute('role') || '').toUpperCase() === 'LINK') { + // …and matches one of the regexes, we assume the match is not pertinent to the current form + this.updateSignal({ + string: string, + strength: 1, + signalType: "external link: ".concat(string), + shouldFlip: true + }); + } else { + // any other case + this.updateSignal({ + string: string, + strength: 1, + signalType: "generic: ".concat(string), + shouldCheckUnifiedForm: true + }); + } + } + }, { + key: "evaluateForm", + value: function evaluateForm() { + var _this5 = this; + + // Check page title + this.evaluatePageTitle(); // Check form attributes + + this.evaluateElAttributes(this.form); // Check form contents (skip select and option because they contain too much noise) + + this.form.querySelectorAll('*:not(select):not(option)').forEach(function (el) { + return _this5.evaluateElement(el); + }); // If we can't decide at this point, try reading page headings + + if (this.autofillSignal === 0) { + this.evaluatePageHeadings(); + } + + return this; + } + }]); + + return FormAnalyzer; +}(); + +module.exports = FormAnalyzer; + +},{}],7:[function(require,module,exports){ +"use strict"; + +function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest(); } + +function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } + +function _iterableToArrayLimit(arr, i) { if (typeof Symbol === "undefined" || !(Symbol.iterator in Object(arr))) return; var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } + +function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; } + +function _createForOfIteratorHelper(o, allowArrayLike) { var it; if (typeof Symbol === "undefined" || o[Symbol.iterator] == null) { if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e2) { throw _e2; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function s() { it = o[Symbol.iterator](); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e3) { didErr = true; err = _e3; }, f: function f() { try { if (!normalCompletion && it["return"] != null) it["return"](); } finally { if (didErr) throw err; } } }; } + +function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); } + +function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; } + +var isApp = false; // Do not modify or remove the next line -- the app code will replace it with `isApp = true;` +// INJECT isApp HERE + +var isDDGApp = /(iPhone|iPad|Android).*DuckDuckGo\/[0-9]/i.test(window.navigator.userAgent) || isApp; +var isAndroid = isDDGApp && /Android/i.test(window.navigator.userAgent); +var DDG_DOMAIN_REGEX = new RegExp(/^https:\/\/(([a-z0-9-_]+?)\.)?duckduckgo\.com/); + +var isDDGDomain = function isDDGDomain() { + return window.origin.match(DDG_DOMAIN_REGEX); +}; // Send a message to the web app (only on DDG domains) + + +var notifyWebApp = function notifyWebApp(message) { + if (isDDGDomain()) { + window.postMessage(message, window.origin); + } +}; +/** + * Sends a message and returns a Promise that resolves with the response + * @param {{} | Function} msgOrFn - a fn to call or an object to send via postMessage + * @param {String} expectedResponse - the name of the response + * @returns {Promise} + */ + + +var sendAndWaitForAnswer = function sendAndWaitForAnswer(msgOrFn, expectedResponse) { + if (typeof msgOrFn === 'function') { + msgOrFn(); + } else { + window.postMessage(msgOrFn, window.origin); + } + + return new Promise(function (resolve) { + var handler = function handler(e) { + if (e.origin !== window.origin) return; + if (!e.data || e.data && !(e.data[expectedResponse] || e.data.type === expectedResponse)) return; + resolve(e.data); + window.removeEventListener('message', handler); + }; + + window.addEventListener('message', handler); + }); +}; // Access the original setter (needed to bypass React's implementation on mobile) + + +var originalSet = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; // This ensures that the value is set properly and dispatches events to simulate a real user action + +var setValue = function setValue(el, val) { + // Avoid keyboard flashing on Android + if (!isAndroid) { + el.focus(); + } + + originalSet.call(el, val); + var ev = new Event('input', { + bubbles: true + }); + el.dispatchEvent(ev); + el.blur(); +}; +/** + * Use IntersectionObserver v2 to make sure the element is visible when clicked + * https://developers.google.com/web/updates/2019/02/intersectionobserver-v2 + */ + + +var safeExecute = function safeExecute(el, fn) { + var intObs = new IntersectionObserver(function (changes) { + var _iterator = _createForOfIteratorHelper(changes), + _step; + + try { + for (_iterator.s(); !(_step = _iterator.n()).done;) { + var change = _step.value; + + // Feature detection + if (typeof change.isVisible === 'undefined') { + // The browser doesn't support Intersection Observer v2, falling back to v1 behavior. + change.isVisible = true; + } + + if (change.isIntersecting && change.isVisible) { + fn(); + } + } + } catch (err) { + _iterator.e(err); + } finally { + _iterator.f(); + } + + intObs.disconnect(); + }, { + trackVisibility: true, + delay: 100 + }); + intObs.observe(el); +}; + +var getDaxBoundingBox = function getDaxBoundingBox(input) { + var _input$getBoundingCli = input.getBoundingClientRect(), + inputRight = _input$getBoundingCli.right, + inputTop = _input$getBoundingCli.top, + inputHeight = _input$getBoundingCli.height; + + var inputRightPadding = parseInt(getComputedStyle(input).paddingRight); + var width = 30; + var height = 30; + var top = inputTop + (inputHeight - height) / 2; + var right = inputRight - inputRightPadding; + var left = right - width; + var bottom = top + height; + return { + bottom: bottom, + height: height, + left: left, + right: right, + top: top, + width: width, + x: left, + y: top + }; +}; + +var isEventWithinDax = function isEventWithinDax(e, input) { + var _getDaxBoundingBox = getDaxBoundingBox(input), + left = _getDaxBoundingBox.left, + right = _getDaxBoundingBox.right, + top = _getDaxBoundingBox.top, + bottom = _getDaxBoundingBox.bottom; + + var withinX = e.clientX >= left && e.clientX <= right; + var withinY = e.clientY >= top && e.clientY <= bottom; + return withinX && withinY; +}; + +var addInlineStyles = function addInlineStyles(el, styles) { + return Object.entries(styles).forEach(function (_ref) { + var _ref2 = _slicedToArray(_ref, 2), + property = _ref2[0], + val = _ref2[1]; + + return el.style.setProperty(property, val, 'important'); + }); +}; + +var removeInlineStyles = function removeInlineStyles(el, styles) { + return Object.keys(styles).forEach(function (property) { + return el.style.removeProperty(property); + }); +}; + +module.exports = { + isApp: isApp, + isDDGApp: isDDGApp, + isAndroid: isAndroid, + DDG_DOMAIN_REGEX: DDG_DOMAIN_REGEX, + isDDGDomain: isDDGDomain, + notifyWebApp: notifyWebApp, + sendAndWaitForAnswer: sendAndWaitForAnswer, + setValue: setValue, + safeExecute: safeExecute, + getDaxBoundingBox: getDaxBoundingBox, + isEventWithinDax: isEventWithinDax, + addInlineStyles: addInlineStyles, + removeInlineStyles: removeInlineStyles +}; + +},{}],8:[function(require,module,exports){ +"use strict"; + +(function () { + // Polyfills/shims + require('intersection-observer'); + + require('./requestIdleCallback'); + + var DeviceInterface = require('./DeviceInterface'); + + var scanForInputs = require('./scanForInputs.js'); + + DeviceInterface.addDeviceListeners(); + DeviceInterface.isDeviceSignedIn().then(function (deviceSignedIn) { + if (deviceSignedIn) { + scanForInputs(DeviceInterface); + } else { + DeviceInterface.trySigningIn(); + } + }); +})(); + +},{"./DeviceInterface":4,"./requestIdleCallback":10,"./scanForInputs.js":11,"intersection-observer":1}],9:[function(require,module,exports){ +"use strict"; + +var daxSvg = ''; +var daxBase64 = 'data:image/svg+xml;base64,PHN2ZyBmaWxsPSJub25lIiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgNDQgNDQiIHdpZHRoPSIyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+PGxpbmVhckdyYWRpZW50IGlkPSJhIj48c3RvcCBvZmZzZXQ9Ii4wMSIgc3RvcC1jb2xvcj0iIzYxNzZiOSIvPjxzdG9wIG9mZnNldD0iLjY5IiBzdG9wLWNvbG9yPSIjMzk0YTlmIi8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9ImIiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4MT0iMTMuOTI5NyIgeDI9IjE3LjA3MiIgeGxpbms6aHJlZj0iI2EiIHkxPSIxNi4zOTgiIHkyPSIxNi4zOTgiLz48bGluZWFyR3JhZGllbnQgaWQ9ImMiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4MT0iMjMuODExNSIgeDI9IjI2LjY3NTIiIHhsaW5rOmhyZWY9IiNhIiB5MT0iMTQuOTY3OSIgeTI9IjE0Ljk2NzkiLz48bWFzayBpZD0iZCIgaGVpZ2h0PSI0MCIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgd2lkdGg9IjQwIiB4PSIyIiB5PSIyIj48cGF0aCBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Im0yMi4wMDAzIDQxLjA2NjljMTAuNTMwMiAwIDE5LjA2NjYtOC41MzY0IDE5LjA2NjYtMTkuMDY2NiAwLTEwLjUzMDMtOC41MzY0LTE5LjA2NjcxLTE5LjA2NjYtMTkuMDY2NzEtMTAuNTMwMyAwLTE5LjA2NjcxIDguNTM2NDEtMTkuMDY2NzEgMTkuMDY2NzEgMCAxMC41MzAyIDguNTM2NDEgMTkuMDY2NiAxOS4wNjY3MSAxOS4wNjY2eiIgZmlsbD0iI2ZmZiIgZmlsbC1ydWxlPSJldmVub2RkIi8+PC9tYXNrPjxwYXRoIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0ibTIyIDQ0YzEyLjE1MDMgMCAyMi05Ljg0OTcgMjItMjIgMC0xMi4xNTAyNi05Ljg0OTctMjItMjItMjItMTIuMTUwMjYgMC0yMiA5Ljg0OTc0LTIyIDIyIDAgMTIuMTUwMyA5Ljg0OTc0IDIyIDIyIDIyeiIgZmlsbD0iI2RlNTgzMyIgZmlsbC1ydWxlPSJldmVub2RkIi8+PGcgbWFzaz0idXJsKCNkKSI+PHBhdGggY2xpcC1ydWxlPSJldmVub2RkIiBkPSJtMjYuMDgxMyA0MS42Mzg2Yy0uOTIwMy0xLjc4OTMtMS44MDAzLTMuNDM1Ni0yLjM0NjYtNC41MjQ2LTEuNDUyLTIuOTA3Ny0yLjkxMTQtNy4wMDctMi4yNDc3LTkuNjUwNy4xMjEtLjQ4MDMtMS4zNjc3LTE3Ljc4Njk5LTIuNDItMTguMzQ0MzItMS4xNjk3LS42MjMzMy0zLjcxMDctMS40NDQ2Ny01LjAyNy0xLjY2NDY3LS45MTY3LS4xNDY2Ni0xLjEyNTcuMTEtMS41MTA3LjE2ODY3LjM2My4wMzY2NyAyLjA5Ljg4NzMzIDIuNDIzNy45MzUtLjMzMzcuMjI3MzMtMS4zMi0uMDA3MzMtMS45NTA3LjI3MTMzLS4zMTkuMTQ2NjctLjU1NzMuNjg5MzQtLjU1Ljk0NiAxLjc5NjctLjE4MzMzIDQuNjA1NC0uMDAzNjYgNi4yNy43MzMyOS0xLjMyMzYuMTUwNC0zLjMzMy4zMTktNC4xOTgzLjc3MzctMi41MDggMS4zMi0zLjYxNTMgNC40MTEtMi45NTUzIDguMTE0My42NTYzIDMuNjk2IDMuNTY0IDE3LjE3ODQgNC40OTE2IDIxLjY4MS45MjQgNC40OTkgMTEuNTUzNyAzLjU1NjcgMTAuMDE3NC41NjF6IiBmaWxsPSIjZDVkN2Q4IiBmaWxsLXJ1bGU9ImV2ZW5vZGQiLz48cGF0aCBkPSJtMjIuMjg2NSAyNi44NDM5Yy0uNjYgMi42NDM2Ljc5MiA2LjczOTMgMi4yNDc2IDkuNjUwNi40ODkxLjk3MjcgMS4yNDM4IDIuMzkyMSAyLjA1NTggMy45NjM3LTEuODk0LjQ2OTMtNi40ODk1IDEuMTI2NC05LjcxOTEgMC0uOTI0LTQuNDkxNy0zLjgzMTctMTcuOTc3Ny00LjQ5NTMtMjEuNjgxLS42Ni0zLjcwMzMgMC02LjM0NyAyLjUxNTMtNy42NjcuODYxNy0uNDU0NyAyLjA5MzctLjc4NDcgMy40MTM3LS45MzEzLTEuNjY0Ny0uNzQwNy0zLjYzNzQtMS4wMjY3LTUuNDQxNC0uODQzMzYtLjAwNzMtLjc2MjY3IDEuMzM4NC0uNzE4NjcgMS44NDQ0LTEuMDYzMzQtLjMzMzctLjA0NzY2LTEuMTYyNC0uNzk1NjYtMS41MjktLjgzMjMzIDIuMjg4My0uMzkyNDQgNC42NDIzLS4wMjEzOCA2LjY5OSAxLjA1NiAxLjA0ODYuNTYxIDEuNzg5MyAxLjE2MjMzIDIuMjQ3NiAxLjc5MzAzIDEuMTk1NC4yMjczIDIuMjUxNC42NiAyLjk0MDcgMS4zNDkzIDIuMTE5MyAyLjExNTcgNC4wMTEzIDYuOTUyIDMuMjE5MyA5LjczMTMtLjIyMzYuNzctLjczMzMgMS4zMzEtMS4zNzEzIDEuNzk2Ny0xLjIzOTMuOTAyLTEuMDE5My0xLjA0NS00LjEwMy45NzE3LS4zOTk3LjI2MDMtLjM5OTcgMi4yMjU2LS41MjQzIDIuNzA2eiIgZmlsbD0iI2ZmZiIvPjwvZz48ZyBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGZpbGwtcnVsZT0iZXZlbm9kZCI+PHBhdGggZD0ibTE2LjY3MjQgMjAuMzU0Yy43Njc1IDAgMS4zODk2LS42MjIxIDEuMzg5Ni0xLjM4OTZzLS42MjIxLTEuMzg5Ny0xLjM4OTYtMS4zODk3LTEuMzg5Ny42MjIyLTEuMzg5NyAxLjM4OTcuNjIyMiAxLjM4OTYgMS4zODk3IDEuMzg5NnoiIGZpbGw9IiMyZDRmOGUiLz48cGF0aCBkPSJtMTcuMjkyNCAxOC44NjE3Yy4xOTg1IDAgLjM1OTQtLjE2MDguMzU5NC0uMzU5M3MtLjE2MDktLjM1OTMtLjM1OTQtLjM1OTNjLS4xOTg0IDAtLjM1OTMuMTYwOC0uMzU5My4zNTkzcy4xNjA5LjM1OTMuMzU5My4zNTkzeiIgZmlsbD0iI2ZmZiIvPjxwYXRoIGQ9Im0yNS45NTY4IDE5LjMzMTFjLjY1ODEgMCAxLjE5MTctLjUzMzUgMS4xOTE3LTEuMTkxNyAwLS42NTgxLS41MzM2LTEuMTkxNi0xLjE5MTctMS4xOTE2cy0xLjE5MTcuNTMzNS0xLjE5MTcgMS4xOTE2YzAgLjY1ODIuNTMzNiAxLjE5MTcgMS4xOTE3IDEuMTkxN3oiIGZpbGw9IiMyZDRmOGUiLz48cGF0aCBkPSJtMjYuNDg4MiAxOC4wNTExYy4xNzAxIDAgLjMwOC0uMTM3OS4zMDgtLjMwOHMtLjEzNzktLjMwOC0uMzA4LS4zMDgtLjMwOC4xMzc5LS4zMDguMzA4LjEzNzkuMzA4LjMwOC4zMDh6IiBmaWxsPSIjZmZmIi8+PHBhdGggZD0ibTE3LjA3MiAxNC45NDJzLTEuMDQ4Ni0uNDc2Ni0yLjA2NDMuMTY1Yy0xLjAxNTcuNjM4LS45NzkgMS4yOTA3LS45NzkgMS4yOTA3cy0uNTM5LTEuMjAyNy44OTgzLTEuNzkzYzEuNDQxLS41ODY3IDIuMTQ1LjMzNzMgMi4xNDUuMzM3M3oiIGZpbGw9InVybCgjYikiLz48cGF0aCBkPSJtMjYuNjc1MiAxNC44NDY3cy0uNzUxNy0uNDI5LTEuMzM4My0uNDIxN2MtMS4xOTkuMDE0Ny0xLjUyNTQuNTQyNy0xLjUyNTQuNTQyN3MuMjAxNy0xLjI2MTQgMS43MzQ0LTEuMDA4NGMuNDk5Ny4wOTE0LjkyMjMuNDIzNCAxLjEyOTMuODg3NHoiIGZpbGw9InVybCgjYykiLz48cGF0aCBkPSJtMjAuOTI1OCAyNC4zMjFjLjEzOTMtLjg0MzMgMi4zMS0yLjQzMSAzLjg1LTIuNTMgMS41NC0uMDk1MyAyLjAxNjctLjA3MzMgMy4zLS4zODEzIDEuMjg3LS4zMDQzIDQuNTk4LTEuMTI5MyA1LjUxMS0xLjU1NDcuOTE2Ny0uNDIxNiA0LjgwMzMuMjA5IDIuMDY0MyAxLjczOC0xLjE4NDMuNjYzNy00LjM3OCAxLjg4MS02LjY2MjMgMi41NjMtMi4yODA3LjY4Mi0zLjY2My0uNjUyNi00LjQyMi40Njk0LS42MDEzLjg5MS0uMTIxIDIuMTEyIDIuNjAzMyAyLjM2NSAzLjY4MTQuMzQxIDcuMjA4Ny0xLjY1NzQgNy41OTc0LS41OTQuMzg4NiAxLjA2MzMtMy4xNjA3IDIuMzgzMy01LjMyNCAyLjQyNzMtMi4xNjM0LjA0MDMtNi41MTk0LTEuNDMtNy4xNzItMS44ODQ3LS42NTY0LS40NTEtMS41MjU0LTEuNTE0My0xLjM0NTctMi42MTh6IiBmaWxsPSIjZmRkMjBhIi8+PHBhdGggZD0ibTI4Ljg4MjUgMzEuODM4NmMtLjc3NzMtLjE3MjQtNC4zMTIgMi41MDA2LTQuMzEyIDIuNTAwNmguMDAzN2wtLjE2NSAyLjA1MzRzNC4wNDA2IDEuNjUzNiA0LjczIDEuMzk3Yy42ODkzLS4yNjQuNTE3LTUuNzc1LS4yNTY3LTUuOTUxem0tMTEuNTQ2MyAxLjAzNGMuMDg0My0xLjExODQgNS4yNTQzIDEuNjQyNiA1LjI1NDMgMS42NDI2bC4wMDM3LS4wMDM2LjI1NjYgMi4xNTZzLTQuMzA4MyAyLjU4MTMtNC45MTMzIDIuMjM2NmMtLjYwMTMtLjM0NDYtLjY4OTMtNC45MDk2LS42MDEzLTYuMDMxNnoiIGZpbGw9IiM2NWJjNDYiLz48cGF0aCBkPSJtMjEuMzQgMzQuODA0OWMwIDEuODA3Ny0uMjYwNCAyLjU4NS41MTMzIDIuNzU3NC43NzczLjE3MjMgMi4yNDAzIDAgMi43NjEtLjM0NDcuNTEzMy0uMzQ0Ny4wODQzLTIuNjY5My0uMDg4LTMuMTAycy0zLjE5LS4wODgtMy4xOS42ODkzeiIgZmlsbD0iIzQzYTI0NCIvPjxwYXRoIGQ9Im0yMS42NzAxIDM0LjQwNTFjMCAxLjgwNzYtLjI2MDQgMi41ODEzLjUxMzMgMi43NTM2Ljc3MzcuMTc2IDIuMjM2NyAwIDIuNzU3My0uMzQ0Ni41MTctLjM0NDcuMDg4LTIuNjY5NC0uMDg0My0zLjEwMi0uMTcyMy0uNDMyNy0zLjE5LS4wODQ0LTMuMTkuNjg5M3oiIGZpbGw9IiM2NWJjNDYiLz48cGF0aCBkPSJtMjIuMDAwMiA0MC40NDgxYzEwLjE4ODUgMCAxOC40NDc5LTguMjU5NCAxOC40NDc5LTE4LjQ0NzlzLTguMjU5NC0xOC40NDc5NS0xOC40NDc5LTE4LjQ0Nzk1LTE4LjQ0Nzk1IDguMjU5NDUtMTguNDQ3OTUgMTguNDQ3OTUgOC4yNTk0NSAxOC40NDc5IDE4LjQ0Nzk1IDE4LjQ0Nzl6bTAgMS43MTg3YzExLjEzNzcgMCAyMC4xNjY2LTkuMDI4OSAyMC4xNjY2LTIwLjE2NjYgMC0xMS4xMzc4LTkuMDI4OS0yMC4xNjY3LTIwLjE2NjYtMjAuMTY2Ny0xMS4xMzc4IDAtMjAuMTY2NyA5LjAyODktMjAuMTY2NyAyMC4xNjY3IDAgMTEuMTM3NyA5LjAyODkgMjAuMTY2NiAyMC4xNjY3IDIwLjE2NjZ6IiBmaWxsPSIjZmZmIi8+PC9nPjwvc3ZnPg=='; +module.exports = { + daxSvg: daxSvg, + daxBase64: daxBase64 +}; + +},{}],10:[function(require,module,exports){ +"use strict"; + +/*! + * Copyright 2015 Google Inc. All rights reserved. + * + * 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. + */ + +/* + * @see https://developers.google.com/web/updates/2015/08/using-requestidlecallback + */ +window.requestIdleCallback = window.requestIdleCallback || function (cb) { + return setTimeout(function () { + var start = Date.now(); // eslint-disable-next-line standard/no-callback-literal + + cb({ + didTimeout: false, + timeRemaining: function timeRemaining() { + return Math.max(0, 50 - (Date.now() - start)); + } + }); + }, 1); +}; + +window.cancelIdleCallback = window.cancelIdleCallback || function (id) { + clearTimeout(id); +}; + +},{}],11:[function(require,module,exports){ +"use strict"; + +function _createForOfIteratorHelper(o, allowArrayLike) { var it; if (typeof Symbol === "undefined" || o[Symbol.iterator] == null) { if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e) { throw _e; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function s() { it = o[Symbol.iterator](); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e2) { didErr = true; err = _e2; }, f: function f() { try { if (!normalCompletion && it["return"] != null) it["return"](); } finally { if (didErr) throw err; } } }; } + +function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); } + +function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; } + +var Form = require('./Form'); + +var _require = require('./autofill-utils'), + notifyWebApp = _require.notifyWebApp; // Accepts the DeviceInterface as an explicit dependency + + +var scanForInputs = function scanForInputs(DeviceInterface) { + notifyWebApp({ + deviceSignedIn: { + value: true + } + }); + var forms = new Map(); + var EMAIL_SELECTOR = "\n input:not([type])[name*=mail i]:not([readonly]):not([disabled]):not([hidden]):not([aria-hidden=true]),\n input[type=\"\"][name*=mail i]:not([readonly]):not([disabled]):not([hidden]):not([aria-hidden=true]),\n input[type=text][name*=mail i]:not([readonly]):not([disabled]):not([hidden]):not([aria-hidden=true]),\n input:not([type])[id*=mail i]:not([readonly]):not([disabled]):not([hidden]):not([aria-hidden=true]),\n input:not([type])[placeholder*=mail i]:not([readonly]):not([disabled]):not([hidden]):not([aria-hidden=true]),\n input[type=\"\"][id*=mail i]:not([readonly]):not([disabled]):not([hidden]):not([aria-hidden=true]),\n input[type=text][placeholder*=mail i]:not([readonly]):not([disabled]):not([hidden]):not([aria-hidden=true]),\n input[type=\"\"][placeholder*=mail i]:not([readonly]):not([disabled]):not([hidden]):not([aria-hidden=true]),\n input:not([type])[placeholder*=mail i]:not([readonly]):not([disabled]):not([hidden]):not([aria-hidden=true]),\n input[type=email]:not([readonly]):not([disabled]):not([hidden]):not([aria-hidden=true]),\n input[type=text][aria-label*=mail i],\n input:not([type])[aria-label*=mail i],\n input[type=text][placeholder*=mail i]:not([readonly])\n "; + + var addInput = function addInput(input) { + var parentForm = input.form; + + if (forms.has(parentForm)) { + // If we've already met the form, add the input + forms.get(parentForm).addInput(input); + } else { + forms.set(parentForm || input, new Form(parentForm, input, DeviceInterface.attachTooltip)); + } + }; + + var findEligibleInput = function findEligibleInput(context) { + if (context.nodeName === 'INPUT' && context.matches(EMAIL_SELECTOR)) { + addInput(context); + } else { + context.querySelectorAll(EMAIL_SELECTOR).forEach(addInput); + } + }; + + findEligibleInput(document); // For all DOM mutations, search for new eligible inputs and update existing inputs positions + + var mutObs = new MutationObserver(function (mutationList) { + var _iterator = _createForOfIteratorHelper(mutationList), + _step; + + try { + for (_iterator.s(); !(_step = _iterator.n()).done;) { + var mutationRecord = _step.value; + + if (mutationRecord.type === 'childList') { + // We query only within the context of added/removed nodes + mutationRecord.addedNodes.forEach(function (el) { + if (el.nodeName === 'DDG-AUTOFILL') return; + + if (el instanceof HTMLElement) { + window.requestIdleCallback(function () { + findEligibleInput(el); + }); + } + }); + } + } + } catch (err) { + _iterator.e(err); + } finally { + _iterator.f(); + } + }); + mutObs.observe(document.body, { + childList: true, + subtree: true + }); + + var logoutHandler = function logoutHandler() { + // remove Dax, listeners, and observers + mutObs.disconnect(); + forms.forEach(function (form) { + form.resetAllInputs(); + form.removeAllDecorations(); + }); + forms.clear(); + notifyWebApp({ + deviceSignedIn: { + value: false + } + }); + }; + + DeviceInterface.addLogoutListener(logoutHandler); +}; + +module.exports = scanForInputs; + +},{"./Form":5,"./autofill-utils":7}]},{},[8]); diff --git a/app/src/main/res/raw/inject_alias.js b/app/src/main/res/raw/inject_alias.js new file mode 100644 index 000000000000..4b938bc50230 --- /dev/null +++ b/app/src/main/res/raw/inject_alias.js @@ -0,0 +1,21 @@ +// +// DuckDuckGo +// +// Copyright © 2020 DuckDuckGo. All rights reserved. +// +// 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. +// + +(function() { + window.postMessage({type: 'getAliasResponse', alias: '%s'}, window.origin); +})(); \ No newline at end of file diff --git a/app/src/main/res/values-v27/themes.xml b/app/src/main/res/values-v27/themes.xml index 77083e37b4ff..e24690734dae 100644 --- a/app/src/main/res/values-v27/themes.xml +++ b/app/src/main/res/values-v27/themes.xml @@ -42,4 +42,11 @@ @style/FireDialogStyle + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index c34be2ea3e79..bdc93c46f7dc 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -66,6 +66,8 @@ #d8d8d8 #e0e0e0 #c0c0c0 + #EEEEEE + #332FF3 #d03a10 diff --git a/app/src/main/res/values/string-untranslated.xml b/app/src/main/res/values/string-untranslated.xml index 80260c9e6c33..175ab687a059 100644 --- a/app/src/main/res/values/string-untranslated.xml +++ b/app/src/main/res/values/string-untranslated.xml @@ -25,4 +25,15 @@ Success! %s has been added to your home screen. Checking your feed in DuckDuckGo is a great alternative to using the Facebook app!<br/><br/>But if the Facebook app is on your phone, it can make requests for data even when you\'re not using it.<br/><br/>Prevent this by deleting it now! + + Email Autofill + Enabled for %1$s + Cancel + Disable + New address copied to your clipboard + Use %1$s + Blocks email trackers + Generate a Private Address + Blocks email trackers and hides your address + Create Duck Address diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index dd69cac6cc8f..cc18525a511f 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -361,6 +361,16 @@ @android:color/transparent + + + +