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 d2ffb1accc24..47563ed50e3d 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -41,6 +41,9 @@ import com.duckduckgo.app.browser.LongPressHandler.RequiredAction.DownloadFile import com.duckduckgo.app.browser.LongPressHandler.RequiredAction.OpenInNewTab import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector import com.duckduckgo.app.browser.favicon.FaviconDownloader +import com.duckduckgo.app.browser.logindetection.LoginDetected +import com.duckduckgo.app.browser.logindetection.NavigationAwareLoginDetector +import com.duckduckgo.app.browser.logindetection.NavigationEvent.LoginAttempt import com.duckduckgo.app.browser.model.BasicAuthenticationCredentials import com.duckduckgo.app.browser.model.BasicAuthenticationRequest import com.duckduckgo.app.browser.model.LongPressTarget @@ -49,13 +52,10 @@ import com.duckduckgo.app.browser.session.WebViewSessionStorage 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.fire.fireproofwebsite.data.FireproofWebsiteDao import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity -import com.duckduckgo.app.cta.ui.Cta -import com.duckduckgo.app.cta.ui.CtaViewModel -import com.duckduckgo.app.cta.ui.DaxBubbleCta -import com.duckduckgo.app.cta.ui.DaxDialogCta -import com.duckduckgo.app.cta.ui.HomePanelCta +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository import com.duckduckgo.app.global.db.AppDatabase import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.global.model.SiteFactory @@ -83,14 +83,7 @@ import com.duckduckgo.app.trackerdetection.EntityLookup import com.duckduckgo.app.trackerdetection.model.TrackingEvent import com.duckduckgo.app.usage.search.SearchCountDao import com.duckduckgo.app.widget.ui.WidgetCapabilities -import com.nhaarman.mockitokotlin2.any -import com.nhaarman.mockitokotlin2.anyOrNull -import com.nhaarman.mockitokotlin2.atLeastOnce -import com.nhaarman.mockitokotlin2.doReturn -import com.nhaarman.mockitokotlin2.firstValue -import com.nhaarman.mockitokotlin2.lastValue -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.whenever +import com.nhaarman.mockitokotlin2.* import io.reactivex.Observable import io.reactivex.Single import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -101,14 +94,10 @@ import org.junit.Assert.* import org.junit.Before import org.junit.Rule import org.junit.Test -import org.mockito.ArgumentCaptor +import org.mockito.* import org.mockito.ArgumentMatchers.anyString -import org.mockito.Captor -import org.mockito.Mock -import org.mockito.Mockito import org.mockito.Mockito.never import org.mockito.Mockito.verify -import org.mockito.MockitoAnnotations import java.util.concurrent.TimeUnit @ExperimentalCoroutinesApi @@ -196,6 +185,9 @@ class BrowserTabViewModelTest { @Mock private lateinit var mockUserWhitelistDao: UserWhitelistDao + @Mock + private lateinit var mockNavigationAwareLoginDetector: NavigationAwareLoginDetector + private lateinit var mockAutoCompleteApi: AutoCompleteApi private lateinit var ctaViewModel: CtaViewModel @@ -211,6 +203,8 @@ class BrowserTabViewModelTest { private val selectedTabLiveData = MutableLiveData() + private val loginEventLiveData = MutableLiveData() + @Before fun before() { MockitoAnnotations.initMocks(this) @@ -241,6 +235,7 @@ class BrowserTabViewModelTest { whenever(mockOmnibarConverter.convertQueryToUrl(any(), any())).thenReturn("duckduckgo.com") whenever(mockVariantManager.getVariant()).thenReturn(DEFAULT_VARIANT) whenever(mockTabsRepository.liveSelectedTab).thenReturn(selectedTabLiveData) + whenever(mockNavigationAwareLoginDetector.loginEventLiveData).thenReturn(loginEventLiveData) whenever(mockTabsRepository.retrieveSiteData(any())).thenReturn(MutableLiveData()) whenever(mockPrivacyPractices.privacyPracticesFor(any())).thenReturn(PrivacyPractices.UNKNOWN) whenever(mockAppInstallStore.installTimestamp).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)) @@ -266,7 +261,8 @@ class BrowserTabViewModelTest { searchCountDao = mockSearchCountDao, pixel = mockPixel, dispatchers = coroutineRule.testDispatcherProvider, - fireproofWebsiteDao = fireproofWebsiteDao, + fireproofWebsiteRepository = FireproofWebsiteRepository(fireproofWebsiteDao, coroutineRule.testDispatcherProvider), + navigationAwareLoginDetector = mockNavigationAwareLoginDetector, variantManager = mockVariantManager ) @@ -1768,23 +1764,25 @@ class BrowserTabViewModelTest { } @Test - fun whenUserLoadsNotFireproofWebsiteThenFireproofWebsiteOptionMenuEnabled() { + fun whenUserLoadsNotFireproofWebsiteThenFireproofWebsiteBrowserStateUpdated() { loadUrl("http://www.example.com/path", isBrowserShowing = true) assertTrue(browserViewState().canFireproofSite) + assertFalse(browserViewState().isFireproofWebsite) } @Test - fun whenUserLoadsFireproofWebsiteThenFireproofWebsiteOptionMenuDisabled() { + fun whenUserLoadsFireproofWebsiteThenFireproofWebsiteBrowserStateUpdated() { givenFireproofWebsiteDomain("www.example.com") loadUrl("http://www.example.com/path", isBrowserShowing = true) - assertFalse(browserViewState().canFireproofSite) + assertTrue(browserViewState().isFireproofWebsite) } @Test - fun whenUserLoadsFireproofWebsiteSubDomainThenFireproofWebsiteOptionMenuEnabled() { + fun whenUserLoadsFireproofWebsiteSubDomainThenFireproofWebsiteBrowserStateUpdated() { givenFireproofWebsiteDomain("example.com") loadUrl("http://mobile.example.com/path", isBrowserShowing = true) assertTrue(browserViewState().canFireproofSite) + assertFalse(browserViewState().isFireproofWebsite) } @Test @@ -1796,64 +1794,127 @@ class BrowserTabViewModelTest { } @Test - fun whenUrlIsUpdatedWithNonFireproofWebsiteThenFireproofWebsiteOptionMenuEnabled() { + fun whenUrlIsUpdatedWithNonFireproofWebsiteThenFireproofWebsiteBrowserStateUpdated() { givenFireproofWebsiteDomain("www.example.com") loadUrl("http://www.example.com/", isBrowserShowing = true) updateUrl("http://www.example.com/", "http://twitter.com/explore", true) assertTrue(browserViewState().canFireproofSite) + assertFalse(browserViewState().isFireproofWebsite) } @Test - fun whenUrlIsUpdatedWithFireproofWebsiteThenFireproofWebsiteOptionMenuDisabled() { + fun whenUrlIsUpdatedWithFireproofWebsiteThenFireproofWebsiteBrowserStateUpdated() { givenFireproofWebsiteDomain("twitter.com") loadUrl("http://example.com/", isBrowserShowing = true) updateUrl("http://example.com/", "http://twitter.com/explore", true) - assertFalse(browserViewState().canFireproofSite) + assertTrue(browserViewState().isFireproofWebsite) } @Test fun whenUserClicksFireproofWebsiteOptionMenuThenShowConfirmationIsIssued() { loadUrl("http://mobile.example.com/", isBrowserShowing = true) - testee.onFireproofWebsiteClicked() + testee.onFireproofWebsiteMenuClicked() assertCommandIssued { assertEquals("mobile.example.com", this.fireproofWebsiteEntity.domain) } } @Test - fun whenUserClicksFireproofWebsiteOptionMenuThenFireproofWebsiteOptionMenuDisabled() { + fun whenUserClicksFireproofWebsiteOptionMenuThenFireproofWebsiteBrowserStateUpdated() { loadUrl("http://example.com/", isBrowserShowing = true) - testee.onFireproofWebsiteClicked() - assertFalse(browserViewState().canFireproofSite) + testee.onFireproofWebsiteMenuClicked() + assertTrue(browserViewState().isFireproofWebsite) } @Test fun whenFireproofWebsiteAddedThenPixelSent() { loadUrl("http://example.com/", isBrowserShowing = true) - testee.onFireproofWebsiteClicked() + testee.onFireproofWebsiteMenuClicked() verify(mockPixel).fire(Pixel.PixelName.FIREPROOF_WEBSITE_ADDED) } + @Test + fun whenUserRemovesFireproofWebsiteFromOptionMenuThenFireproofWebsiteBrowserStateUpdated() { + givenFireproofWebsiteDomain("mobile.example.com") + loadUrl("http://mobile.example.com/", isBrowserShowing = true) + testee.onFireproofWebsiteMenuClicked() + assertFalse(browserViewState().isFireproofWebsite) + } + + @Test + fun whenUserRemovesFireproofWebsiteFromOptionMenuThenPixelSent() { + givenFireproofWebsiteDomain("mobile.example.com") + loadUrl("http://mobile.example.com/", isBrowserShowing = true) + testee.onFireproofWebsiteMenuClicked() + verify(mockPixel).fire(Pixel.PixelName.FIREPROOF_WEBSITE_REMOVE) + } + @Test fun whenUserClicksOnFireproofWebsiteSnackbarUndoActionThenFireproofWebsiteIsRemoved() { loadUrl("http://example.com/", isBrowserShowing = true) - testee.onFireproofWebsiteClicked() + testee.onFireproofWebsiteMenuClicked() assertCommandIssued { testee.onFireproofWebsiteSnackbarUndoClicked(this.fireproofWebsiteEntity) } assertTrue(browserViewState().canFireproofSite) + assertFalse(browserViewState().isFireproofWebsite) } @Test fun whenUserClicksOnFireproofWebsiteSnackbarUndoActionThenPixelSent() { loadUrl("http://example.com/", isBrowserShowing = true) - testee.onFireproofWebsiteClicked() + testee.onFireproofWebsiteMenuClicked() assertCommandIssued { testee.onFireproofWebsiteSnackbarUndoClicked(this.fireproofWebsiteEntity) } verify(mockPixel).fire(Pixel.PixelName.FIREPROOF_WEBSITE_UNDO) } + @Test + fun whenUserFireproofsWebsiteFromLoginDialogThenShowConfirmationIsIssuedWithExpectedDomain() { + loadUrl("http://mobile.example.com/", isBrowserShowing = true) + testee.onUserConfirmedFireproofDialog("login.example.com") + assertCommandIssued { + assertEquals("login.example.com", this.fireproofWebsiteEntity.domain) + } + } + + @Test + fun whenUserFireproofsWebsiteFromLoginDialogThenPixelSent() { + testee.onUserConfirmedFireproofDialog("login.example.com") + verify(mockPixel).fire(Pixel.PixelName.FIREPROOF_WEBSITE_LOGIN_ADDED) + } + + @Test + fun whenUserDismissesFireproofWebsiteLoginDialogThenPixelSent() { + testee.onUserDismissedFireproofLoginDialog() + verify(mockPixel).fire(Pixel.PixelName.FIREPROOF_WEBSITE_LOGIN_DISMISS) + } + + @Test + fun whenLoginAttempDetectedThenNotifyNavigationAwareLoginDetector() { + loadUrl("http://example.com/", isBrowserShowing = true) + + testee.loginDetected() + + verify(mockNavigationAwareLoginDetector).onEvent(LoginAttempt("http://example.com/")) + } + + @Test + fun whenLoginDetectedOnAFireproofedWebsiteThenDoNotAskToFireproofWebsite() { + givenFireproofWebsiteDomain("example.com") + loginEventLiveData.value = givenLoginDetected("example.com") + assertCommandNotIssued() + } + + @Test + fun whenLoginDetectedThenAskToFireproofWebsite() { + loginEventLiveData.value = givenLoginDetected("example.com") + assertCommandIssued { + assertEquals(FireproofWebsiteEntity("example.com"), this.fireproofWebsite) + } + } + @Test fun whenUserBrowsingPressesBackThenCannotAddBookmark() { setupNavigation(skipHome = false, isBrowsing = true, canGoBack = false) @@ -2041,6 +2102,8 @@ class BrowserTabViewModelTest { } } + private fun givenLoginDetected(domain: String) = LoginDetected(authLoginDomain = "", forwardedToDomain = domain) + private fun setBrowserShowing(isBrowsing: Boolean) { testee.browserViewState.value = browserViewState().copy(browserShowing = isBrowsing) } 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 81d528dfa42d..e0c82fd29b65 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt @@ -18,22 +18,29 @@ package com.duckduckgo.app.browser import android.content.Context import android.os.Build -import android.webkit.CookieManager -import android.webkit.HttpAuthHandler -import android.webkit.RenderProcessGoneDetail -import android.webkit.WebView +import android.webkit.* import androidx.test.annotation.UiThreadTest import androidx.test.filters.SdkSuppress import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.app.CoroutineTestRule +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.global.exception.UncaughtExceptionRepository +import com.duckduckgo.app.runBlocking import com.duckduckgo.app.statistics.store.OfflinePixelCountDataStore import com.nhaarman.mockitokotlin2.* +import kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.Before +import org.junit.Rule import org.junit.Test +@ExperimentalCoroutinesApi class BrowserWebViewClientTest { + @get:Rule + var coroutinesTestRule = CoroutineTestRule() + private lateinit var testee: BrowserWebViewClient private lateinit var webView: WebView @@ -42,6 +49,7 @@ class BrowserWebViewClientTest { private val requestInterceptor: RequestInterceptor = mock() private val listener: WebViewClientListener = mock() private val cookieManager: CookieManager = mock() + private val loginDetector: DOMLoginDetector = mock() private val offlinePixelCountDataStore: OfflinePixelCountDataStore = mock() private val uncaughtExceptionRepository: UncaughtExceptionRepository = mock() @@ -55,7 +63,8 @@ class BrowserWebViewClientTest { requestInterceptor, offlinePixelCountDataStore, uncaughtExceptionRepository, - cookieManager + cookieManager, + loginDetector ) testee.webViewClientListener = listener } @@ -83,6 +92,13 @@ class BrowserWebViewClientTest { verify(listener, never()).pageRefreshed(any()) } + @UiThreadTest + @Test + fun whenOnPageStartedCalledThenEventSentToLoginDetector() = coroutinesTestRule.runBlocking { + testee.onPageStarted(webView, EXAMPLE_URL, null) + verify(loginDetector).onEvent(WebNavigationEvent.OnPageStarted(webView)) + } + @UiThreadTest @Test fun whenOnPageFinishedCalledThenListenerInstructedToUpdateNavigationState() { @@ -99,6 +115,14 @@ class BrowserWebViewClientTest { verify(listener).requiresAuthentication(authenticationRequest) } + @UiThreadTest + @Test + fun whenShouldInterceptRequestThenEventSentToLoginDetector() = coroutinesTestRule.runBlocking { + val webResourceRequest = mock() + testee.shouldInterceptRequest(webView, webResourceRequest) + verify(loginDetector).onEvent(WebNavigationEvent.ShouldInterceptRequest(webView, webResourceRequest)) + } + @Test @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) fun whenRenderProcessGoneDueToCrashThenCrashDataStoreEntryIsIncremented() { diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/logindetection/JsLoginDetectorTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/logindetection/JsLoginDetectorTest.kt new file mode 100644 index 000000000000..8b6ee33ae6b0 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/logindetection/JsLoginDetectorTest.kt @@ -0,0 +1,128 @@ +/* + * 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.browser.logindetection + +import android.net.Uri +import android.webkit.WebResourceRequest +import android.webkit.WebView +import androidx.test.annotation.UiThreadTest +import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.app.CoroutineTestRule +import com.duckduckgo.app.browser.logindetection.LoginDetectionJavascriptInterface.Companion.JAVASCRIPT_INTERFACE_NAME +import com.duckduckgo.app.runBlocking +import com.duckduckgo.app.settings.db.SettingsDataStore +import com.nhaarman.mockitokotlin2.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class JsLoginDetectorTest { + + @ExperimentalCoroutinesApi + @get:Rule + var coroutinesTestRule = CoroutineTestRule() + + private val settingsDataStore: SettingsDataStore = mock() + + private val testee = JsLoginDetector(settingsDataStore) + + @UiThreadTest + @Test + fun whenAddLoginDetectionThenJSInterfaceAdded() = coroutinesTestRule.runBlocking { + val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) + testee.addLoginDetection(webView) {} + verify(webView).addJavascriptInterface(any(), eq(JAVASCRIPT_INTERFACE_NAME)) + } + + @UiThreadTest + @Test + fun whenLoginDetectionDisabledAndPageStartedEventThenNoWebViewInteractions() = coroutinesTestRule.runBlocking { + whenever(settingsDataStore.appLoginDetection).thenReturn(false) + val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) + testee.onEvent(WebNavigationEvent.OnPageStarted(webView)) + verifyZeroInteractions(webView) + } + + @UiThreadTest + @Test + fun whenLoginDetectionDisabledAndInterceptRequestEventThenNoWebViewInteractions() = coroutinesTestRule.runBlocking { + whenever(settingsDataStore.appLoginDetection).thenReturn(false) + val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) + val webResourceRequest = aWebResourceRequest() + testee.onEvent(WebNavigationEvent.ShouldInterceptRequest(webView, webResourceRequest)) + verifyZeroInteractions(webView) + } + + @UiThreadTest + @Test + fun whenLoginDetectionEnabledAndPageStartedEventThenJSLoginDetectionInjected() = coroutinesTestRule.runBlocking { + whenever(settingsDataStore.appLoginDetection).thenReturn(true) + val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) + testee.onEvent(WebNavigationEvent.OnPageStarted(webView)) + verify(webView).evaluateJavascript(any(), anyOrNull()) + } + + @UiThreadTest + @Test + fun whenLoginDetectionEnabledAndLoginPostRequestCapturedThenJSLoginDetectionInjected() = coroutinesTestRule.runBlocking { + whenever(settingsDataStore.appLoginDetection).thenReturn(true) + val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) + val webResourceRequest = aWebResourceRequest("POST", "login") + testee.onEvent(WebNavigationEvent.ShouldInterceptRequest(webView, webResourceRequest)) + verify(webView).evaluateJavascript(any(), anyOrNull()) + } + + @UiThreadTest + @Test + fun whenLoginDetectionEnabledAndNoLoginPostRequestCapturedThenNoWebViewInteractions() = coroutinesTestRule.runBlocking { + whenever(settingsDataStore.appLoginDetection).thenReturn(true) + val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) + val webResourceRequest = aWebResourceRequest("POST", "") + testee.onEvent(WebNavigationEvent.ShouldInterceptRequest(webView, webResourceRequest)) + verifyZeroInteractions(webView) + } + + @UiThreadTest + @Test + fun whenLoginDetectionEnabledAndGetRequestCapturedThenNoWebViewInteractions() = coroutinesTestRule.runBlocking { + whenever(settingsDataStore.appLoginDetection).thenReturn(true) + val webView = spy(WebView(InstrumentationRegistry.getInstrumentation().targetContext)) + val webResourceRequest = aWebResourceRequest("GET") + testee.onEvent(WebNavigationEvent.ShouldInterceptRequest(webView, webResourceRequest)) + verifyZeroInteractions(webView) + } + + private fun aWebResourceRequest( + httpMethod: String = "POST", + path: String = "login" + ): WebResourceRequest { + return object : WebResourceRequest { + override fun getUrl(): Uri = Uri.parse("https://example.com/$path") + + override fun isRedirect(): Boolean = false + + override fun getMethod(): String = httpMethod + + override fun getRequestHeaders(): MutableMap = mutableMapOf() + + override fun hasGesture(): Boolean = false + + override fun isForMainFrame(): Boolean = false + } + } +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/logindetection/LoginDetectionJavascriptInterfaceTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/logindetection/LoginDetectionJavascriptInterfaceTest.kt new file mode 100644 index 000000000000..d1b3d49f53f5 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/logindetection/LoginDetectionJavascriptInterfaceTest.kt @@ -0,0 +1,34 @@ +/* + * 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.browser.logindetection + +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import org.junit.Test + +class LoginDetectionJavascriptInterfaceTest { + + @Test + fun whenLoginDetectedThenNotifyCallback() { + val loginDetected = mock<() -> Unit>() + val loginDetectionInterface = LoginDetectionJavascriptInterface(loginDetected) + + loginDetectionInterface.loginDetected() + + verify(loginDetected).invoke() + } +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/logindetection/NextPageLoginDetectionTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/logindetection/NextPageLoginDetectionTest.kt new file mode 100644 index 000000000000..1820ac4bad59 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/logindetection/NextPageLoginDetectionTest.kt @@ -0,0 +1,160 @@ +/* + * 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.browser.logindetection + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Observer +import com.duckduckgo.app.browser.WebNavigationStateChange +import com.duckduckgo.app.browser.WebNavigationStateChange.NewPage +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.atLeastOnce +import com.nhaarman.mockitokotlin2.mock +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito.verify + +class NextPageLoginDetectionTest { + + @get:Rule + @Suppress("unused") + var instantTaskExecutorRule = InstantTaskExecutorRule() + + private val loginDetector = NextPageLoginDetection() + + private val loginObserver = mock>() + private val loginEventCaptor = argumentCaptor() + + @Before + fun setup() { + loginDetector.loginEventLiveData.observeForever(loginObserver) + } + + @Test + fun whenLoginAttemptedAndUserForwardedToNewPageThenLoginDetected() { + loginDetector.onEvent(NavigationEvent.LoginAttempt("http://example.com/login")) + + loginDetector.onEvent(NavigationEvent.WebNavigationEvent(NewPage(url = "http://example.com", title = ""))) + + assertEvent() + } + + @Test + fun whenLoginAttemptedAndUserForwardedToSamePageThenLoginNotDetected() { + loginDetector.onEvent(NavigationEvent.LoginAttempt("http://example.com/login")) + + loginDetector.onEvent(NavigationEvent.WebNavigationEvent(NewPage(url = "http://example.com/login", title = ""))) + + assertEventNotIssued() + } + + @Test + fun whenNotDetectedLoginAttemptAndForwardedToNewPageThenLoginNotDetected() { + loginDetector.onEvent(NavigationEvent.WebNavigationEvent(NewPage(url = "http://example.com", title = ""))) + + assertEventNotIssued() + } + + @Test + fun whenLoginAttemptedAndUserForwardedToNewUrlThenLoginDetected() { + loginDetector.onEvent(NavigationEvent.LoginAttempt("http://example.com/login")) + + loginDetector.onEvent(NavigationEvent.WebNavigationEvent(WebNavigationStateChange.UrlUpdated(url = "http://example.com"))) + + assertEvent() + } + + @Test + fun whenLoginAttemptedAndUserForwardedToSameUrlThenLoginNotDetected() { + loginDetector.onEvent(NavigationEvent.LoginAttempt("http://example.com/login")) + + loginDetector.onEvent(NavigationEvent.WebNavigationEvent(WebNavigationStateChange.UrlUpdated(url = "http://example.com/login"))) + + assertEventNotIssued() + } + + @Test + fun whenNotDetectedLoginAttemptAndForwardedToNewUrlThenLoginNotDetected() { + loginDetector.onEvent(NavigationEvent.WebNavigationEvent(NewPage(url = "http://example.com", title = ""))) + + assertEventNotIssued() + } + + @Test + fun whenLoginAttemptedAndNextPageFinishedThenLoadingNewPageDoesNotDetectLogin() { + loginDetector.onEvent(NavigationEvent.LoginAttempt("http://example.com/login")) + loginDetector.onEvent(NavigationEvent.PageFinished) + + loginDetector.onEvent(NavigationEvent.WebNavigationEvent(NewPage(url = "http://another.example.com", title = ""))) + + assertEventNotIssued() + } + + @Test + fun whenLoginAttemptedAndUserNavigatesBackThenNewPageDoesNotDetectLogin() { + loginDetector.onEvent(NavigationEvent.LoginAttempt("http://example.com/login")) + loginDetector.onEvent(NavigationEvent.UserAction.NavigateBack) + + loginDetector.onEvent(NavigationEvent.WebNavigationEvent(NewPage(url = "http://another.example.com", title = ""))) + + assertEventNotIssued() + } + + @Test + fun whenLoginAttemptedAndUserNavigatesForwardThenNewPageDoesNotDetectLogin() { + loginDetector.onEvent(NavigationEvent.LoginAttempt("http://example.com/login")) + loginDetector.onEvent(NavigationEvent.UserAction.NavigateForward) + + loginDetector.onEvent(NavigationEvent.WebNavigationEvent(NewPage(url = "http://another.example.com", title = ""))) + + assertEventNotIssued() + } + + @Test + fun whenLoginAttemptedAndUserReloadsWebsiteThenNewPageDoesNotDetectLogin() { + loginDetector.onEvent(NavigationEvent.LoginAttempt("http://example.com/login")) + loginDetector.onEvent(NavigationEvent.UserAction.Refresh) + + loginDetector.onEvent(NavigationEvent.WebNavigationEvent(NewPage(url = "http://another.example.com", title = ""))) + + assertEventNotIssued() + } + + @Test + fun whenLoginAttemptedAndUserSubmitsNewQueryThenNewPageDoesNotDetectLogin() { + loginDetector.onEvent(NavigationEvent.LoginAttempt("http://example.com/login")) + loginDetector.onEvent(NavigationEvent.UserAction.NewQuerySubmitted) + + loginDetector.onEvent(NavigationEvent.WebNavigationEvent(NewPage(url = "http://another.example.com", title = ""))) + + assertEventNotIssued() + } + + private inline fun assertEvent(instanceAssertions: T.() -> Unit = {}) { + verify(loginObserver, atLeastOnce()).onChanged(loginEventCaptor.capture()) + val issuedCommand = loginEventCaptor.allValues.find { it is T } + assertNotNull(issuedCommand) + (issuedCommand as T).apply { instanceAssertions() } + } + + private inline fun assertEventNotIssued() { + val issuedCommand = loginEventCaptor.allValues.find { it is T } + assertNull(issuedCommand) + } +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteRepositoryTest.kt b/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteRepositoryTest.kt new file mode 100644 index 000000000000..5455a565f791 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteRepositoryTest.kt @@ -0,0 +1,111 @@ +/* + * 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.fire.fireproofwebsite.data + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.app.CoroutineTestRule +import com.duckduckgo.app.blockingObserve +import com.duckduckgo.app.global.db.AppDatabase +import com.duckduckgo.app.runBlocking +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.MockitoAnnotations + +@ExperimentalCoroutinesApi +class FireproofWebsiteRepositoryTest { + + @get:Rule + @Suppress("unused") + val coroutineRule = CoroutineTestRule() + + @get:Rule + @Suppress("unused") + var instantTaskExecutorRule = InstantTaskExecutorRule() + + private lateinit var db: AppDatabase + private lateinit var fireproofWebsiteDao: FireproofWebsiteDao + private lateinit var fireproofWebsiteRepository: FireproofWebsiteRepository + + @Before + fun before() { + MockitoAnnotations.initMocks(this) + db = Room.inMemoryDatabaseBuilder(InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + fireproofWebsiteDao = db.fireproofWebsiteDao() + fireproofWebsiteRepository = FireproofWebsiteRepository(db.fireproofWebsiteDao(), coroutineRule.testDispatcherProvider) + } + + @After + fun after() { + db.close() + } + + @Test + fun whenFireproofWebsiteEmptyThenNoEntityIsCreated() = coroutineRule.runBlocking { + val fireproofWebsiteEntity = fireproofWebsiteRepository.fireproofWebsite("") + assertNull(fireproofWebsiteEntity) + } + + @Test + fun whenFireproofWebsiteInvalidThenNoEntityIsCreated() = coroutineRule.runBlocking { + val fireproofWebsiteEntity = fireproofWebsiteRepository.fireproofWebsite("aa1111") + assertNull(fireproofWebsiteEntity) + } + + @Test + fun whenFireproofWebsiteWithSchemaThenNoEntityIsCreated() = coroutineRule.runBlocking { + val fireproofWebsiteEntity = fireproofWebsiteRepository.fireproofWebsite("https://aa1111.com") + assertNull(fireproofWebsiteEntity) + } + + @Test + fun whenFireproofWebsiteIsValidThenEntityCreated() = coroutineRule.runBlocking { + val fireproofWebsiteEntity = fireproofWebsiteRepository.fireproofWebsite("example.com") + assertEquals(FireproofWebsiteEntity("example.com"), fireproofWebsiteEntity) + } + + @Test + fun whenGetAllFireproofWebsitesThenReturnLiveDataWithAllItemsFromDatabase() = coroutineRule.runBlocking { + givenFireproofWebsiteDomain("example.com", "example2.com") + val fireproofWebsiteEntities = fireproofWebsiteRepository.getFireproofWebsites().blockingObserve()!! + assertEquals(fireproofWebsiteEntities.size, 2) + } + + @Test + fun whenRemoveFireproofWebsiteThenItemRemovedFromDatabase() = coroutineRule.runBlocking { + givenFireproofWebsiteDomain("example.com", "example2.com") + + fireproofWebsiteRepository.removeFireproofWebsite(FireproofWebsiteEntity("example.com")) + + val fireproofWebsiteEntities = fireproofWebsiteDao.fireproofWebsitesSync() + assertEquals(fireproofWebsiteEntities.size, 1) + } + + private fun givenFireproofWebsiteDomain(vararg fireproofWebsitesDomain: String) { + fireproofWebsitesDomain.forEach { + fireproofWebsiteDao.insert(FireproofWebsiteEntity(domain = it)) + } + } +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModelTest.kt index 13cb15068311..5c3da01e8ebc 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModelTest.kt @@ -24,9 +24,12 @@ import com.duckduckgo.app.CoroutineTestRule import com.duckduckgo.app.InstantSchedulersRule import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository import com.duckduckgo.app.fire.fireproofwebsite.ui.FireproofWebsitesViewModel.Command.ConfirmDeleteFireproofWebsite import com.duckduckgo.app.global.db.AppDatabase +import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelName.FIREPROOF_LOGIN_TOGGLE_ENABLED import com.nhaarman.mockitokotlin2.atLeastOnce import com.nhaarman.mockitokotlin2.mock import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -37,7 +40,9 @@ import org.junit.Rule import org.junit.Test import org.mockito.ArgumentCaptor import org.mockito.Mockito.verify +import java.util.* +@Suppress("EXPERIMENTAL_API_USAGE") class FireproofWebsitesViewModelTest { @get:Rule @@ -66,13 +71,20 @@ class FireproofWebsitesViewModelTest { private val mockPixel: Pixel = mock() + private val settingsDataStore: SettingsDataStore = mock() + @Before fun before() { db = Room.inMemoryDatabaseBuilder(InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java) .allowMainThreadQueries() .build() fireproofWebsiteDao = db.fireproofWebsiteDao() - viewModel = FireproofWebsitesViewModel(fireproofWebsiteDao, coroutineRule.testDispatcherProvider, mockPixel) + viewModel = FireproofWebsitesViewModel( + FireproofWebsiteRepository(fireproofWebsiteDao, coroutineRule.testDispatcherProvider), + coroutineRule.testDispatcherProvider, + mockPixel, + settingsDataStore + ) viewModel.command.observeForever(mockCommandObserver) viewModel.viewState.observeForever(mockViewStateObserver) } @@ -84,6 +96,13 @@ class FireproofWebsitesViewModelTest { viewModel.viewState.removeObserver(mockViewStateObserver) } + @Test + fun whenViewModelCreateThenInitialisedWithDefaultViewState() { + val defaultViewState = FireproofWebsitesViewModel.ViewState(false, emptyList()) + verify(mockViewStateObserver, atLeastOnce()).onChanged(viewStateCaptor.capture()) + assertEquals(defaultViewState, viewStateCaptor.value) + } + @Test fun whenUserDeletesFireProofWebsiteThenConfirmDeleteCommandIssued() { val fireproofWebsiteEntity = FireproofWebsiteEntity("domain.com") @@ -121,6 +140,28 @@ class FireproofWebsitesViewModelTest { assertTrue(viewStateCaptor.value.fireproofWebsitesEntities.size == 1) } + @Test + fun whenUserTogglesLoginDetectionThenFirePixel() { + viewModel.onUserToggleLoginDetection(true) + + verify(mockPixel).fire(FIREPROOF_LOGIN_TOGGLE_ENABLED) + } + + @Test + fun whenUserTogglesLoginDetectionThenUpdateViewState() { + viewModel.onUserToggleLoginDetection(true) + + verify(mockViewStateObserver, atLeastOnce()).onChanged(viewStateCaptor.capture()) + assertTrue(viewStateCaptor.value.loginDetectionEnabled) + } + + @Test + fun whenUserTogglesLoginDetectionThenUpdateSettingsDataStore() { + viewModel.onUserToggleLoginDetection(true) + + verify(settingsDataStore).appLoginDetection = true + } + private inline fun assertCommandIssued(instanceAssertions: T.() -> Unit = {}) { verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) val issuedCommand = commandCaptor.allValues.find { it is T } 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 6c364aec6386..6d0314188731 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -50,11 +50,7 @@ import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY import androidx.core.view.* import androidx.fragment.app.Fragment import androidx.fragment.app.transaction -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleObserver -import androidx.lifecycle.Observer -import androidx.lifecycle.OnLifecycleEvent -import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.* import androidx.recyclerview.widget.LinearLayoutManager import com.duckduckgo.app.autocomplete.api.AutoComplete.AutoCompleteSuggestion import com.duckduckgo.app.bookmarks.ui.EditBookmarkDialogFragment @@ -68,6 +64,7 @@ import com.duckduckgo.app.browser.downloader.FileDownloader import com.duckduckgo.app.browser.downloader.FileDownloader.FileDownloadListener import com.duckduckgo.app.browser.downloader.FileDownloader.PendingFileDownload import com.duckduckgo.app.browser.filechooser.FileChooserIntentBuilder +import com.duckduckgo.app.browser.logindetection.DOMLoginDetector import com.duckduckgo.app.browser.model.BasicAuthenticationCredentials import com.duckduckgo.app.browser.model.BasicAuthenticationRequest import com.duckduckgo.app.browser.model.LongPressTarget @@ -103,24 +100,8 @@ import kotlinx.android.synthetic.main.include_dax_dialog_cta.* import kotlinx.android.synthetic.main.include_find_in_page.* import kotlinx.android.synthetic.main.include_new_browser_tab.* import kotlinx.android.synthetic.main.include_omnibar_toolbar.* -import kotlinx.android.synthetic.main.include_omnibar_toolbar.omnibarTextInput -import kotlinx.android.synthetic.main.include_omnibar_toolbar.view.browserMenu -import kotlinx.android.synthetic.main.include_omnibar_toolbar.view.fireIconMenu -import kotlinx.android.synthetic.main.include_omnibar_toolbar.view.privacyGradeButton -import kotlinx.android.synthetic.main.include_omnibar_toolbar.view.tabsMenu +import kotlinx.android.synthetic.main.include_omnibar_toolbar.view.* import kotlinx.android.synthetic.main.popup_window_browser_menu.view.* -import kotlinx.android.synthetic.main.popup_window_browser_menu.view.addBookmarksPopupMenuItem -import kotlinx.android.synthetic.main.popup_window_browser_menu.view.addToHome -import kotlinx.android.synthetic.main.popup_window_browser_menu.view.backPopupMenuItem -import kotlinx.android.synthetic.main.popup_window_browser_menu.view.brokenSitePopupMenuItem -import kotlinx.android.synthetic.main.popup_window_browser_menu.view.findInPageMenuItem -import kotlinx.android.synthetic.main.popup_window_browser_menu.view.fireproofWebsitePopupMenuItem -import kotlinx.android.synthetic.main.popup_window_browser_menu.view.forwardPopupMenuItem -import kotlinx.android.synthetic.main.popup_window_browser_menu.view.newTabPopupMenuItem -import kotlinx.android.synthetic.main.popup_window_browser_menu.view.refreshPopupMenuItem -import kotlinx.android.synthetic.main.popup_window_browser_menu.view.requestDesktopSiteCheckMenuItem -import kotlinx.android.synthetic.main.popup_window_browser_menu.view.settingsPopupMenuItem -import kotlinx.android.synthetic.main.popup_window_browser_menu.view.whitelistPopupMenuItem import kotlinx.coroutines.* import org.jetbrains.anko.longToast import org.jetbrains.anko.share @@ -182,6 +163,9 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi @Inject lateinit var variantManager: VariantManager + @Inject + lateinit var loginDetector: DOMLoginDetector + val tabId get() = requireArguments()[TAB_ID_ARG] as String @Inject @@ -254,6 +238,8 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi private var alertDialog: AlertDialog? = null + private var loginDetectionDialog: AlertDialog? = null + override fun onAttach(context: Context) { AndroidSupportInjection.inject(this) super.onAttach(context) @@ -571,6 +557,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi is Command.HideWebContent -> webView?.hide() is Command.ShowWebContent -> webView?.show() is Command.RefreshUserAgent -> refreshUserAgent(it.host, it.isDesktop) + is Command.AskToFireproofWebsite -> askToFireproofWebsite(requireContext(), it.fireproofWebsite) } } @@ -640,6 +627,23 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi } } + private fun askToFireproofWebsite(context: Context, fireproofWebsite: FireproofWebsiteEntity) { + val isShowing = loginDetectionDialog?.isShowing + + if (isShowing != true) { + loginDetectionDialog = AlertDialog.Builder(context) + .setTitle(getString(R.string.fireproofWebsiteLoginDialogTitle, fireproofWebsite.website())) + .setMessage(R.string.fireproofWebsiteLoginDialogDescription) + .setPositiveButton(R.string.fireproofWebsiteLoginDialogPositive) { _, _ -> + viewModel.onUserConfirmedFireproofDialog(fireproofWebsite.domain) + } + .setNegativeButton(R.string.fireproofWebsiteLoginDialogNegative) { dialog, _ -> + dialog.dismiss() + viewModel.onUserDismissedFireproofLoginDialog() + }.show() + } + } + private fun launchExternalAppDialog(context: Context, onClick: () -> Unit) { val isShowing = alertDialog?.isShowing @@ -831,6 +835,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi registerForContextMenu(it) it.setFindListener(this) + loginDetector.addLoginDetection(it) { viewModel.loginDetected() } } if (BuildConfig.DEBUG) { @@ -1027,6 +1032,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi animatorHelper.removeListener() supervisorJob.cancel() popupMenu.dismiss() + loginDetectionDialog?.dismiss() destroyWebView() super.onDestroy() } @@ -1306,7 +1312,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi browserActivity?.launchBookmarks() pixel.fire(Pixel.PixelName.MENU_ACTION_BOOKMARKS_PRESSED.pixelName) } - onMenuItemClicked(view.fireproofWebsitePopupMenuItem) { launch { viewModel.onFireproofWebsiteClicked() } } + onMenuItemClicked(view.fireproofWebsitePopupMenuItem) { launch { viewModel.onFireproofWebsiteMenuClicked() } } onMenuItemClicked(view.addBookmarksPopupMenuItem) { launch { viewModel.onBookmarkAddRequested() } } onMenuItemClicked(view.findInPageMenuItem) { viewModel.onFindInPageSelected() } onMenuItemClicked(view.whitelistPopupMenuItem) { viewModel.onWhitelistSelected() } @@ -1540,6 +1546,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi newTabPopupMenuItem.isEnabled = browserShowing addBookmarksPopupMenuItem?.isEnabled = viewState.canAddBookmarks fireproofWebsitePopupMenuItem?.isEnabled = viewState.canFireproofSite + fireproofWebsitePopupMenuItem?.isChecked = viewState.canFireproofSite && viewState.isFireproofWebsite sharePageMenuItem?.isEnabled = viewState.canSharePage whitelistPopupMenuItem?.isEnabled = viewState.canWhitelist whitelistPopupMenuItem?.text = getText(if (viewState.isWhitelisted) R.string.whitelistRemove else R.string.whitelistAdd) 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 f53adb940aeb..53b745c19786 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -48,37 +48,26 @@ import com.duckduckgo.app.browser.BrowserTabViewModel.GlobalLayoutViewState.Brow import com.duckduckgo.app.browser.BrowserTabViewModel.GlobalLayoutViewState.Invalidated import com.duckduckgo.app.browser.LongPressHandler.RequiredAction import com.duckduckgo.app.browser.SpecialUrlDetector.UrlType.IntentType -import com.duckduckgo.app.browser.WebNavigationStateChange.NewPage -import com.duckduckgo.app.browser.WebNavigationStateChange.PageCleared -import com.duckduckgo.app.browser.WebNavigationStateChange.PageNavigationCleared -import com.duckduckgo.app.browser.WebNavigationStateChange.UrlUpdated +import com.duckduckgo.app.browser.WebNavigationStateChange.* import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector import com.duckduckgo.app.browser.favicon.FaviconDownloader +import com.duckduckgo.app.browser.logindetection.NavigationEvent +import com.duckduckgo.app.browser.logindetection.LoginDetected +import com.duckduckgo.app.browser.logindetection.NavigationAwareLoginDetector import com.duckduckgo.app.browser.model.BasicAuthenticationCredentials import com.duckduckgo.app.browser.model.BasicAuthenticationRequest import com.duckduckgo.app.browser.model.LongPressTarget import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.browser.ui.HttpAuthenticationDialogFragment.HttpAuthenticationListener -import com.duckduckgo.app.cta.ui.Cta -import com.duckduckgo.app.cta.ui.CtaViewModel -import com.duckduckgo.app.cta.ui.DaxDialogCta -import com.duckduckgo.app.cta.ui.HomePanelCta -import com.duckduckgo.app.cta.ui.HomeTopPanelCta -import com.duckduckgo.app.cta.ui.SecondaryButtonCta -import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao +import com.duckduckgo.app.cta.ui.* import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity -import com.duckduckgo.app.global.AppUrl -import com.duckduckgo.app.global.DefaultDispatcherProvider -import com.duckduckgo.app.global.DispatcherProvider -import com.duckduckgo.app.global.SingleLiveEvent -import com.duckduckgo.app.global.baseHost -import com.duckduckgo.app.global.isMobileSite +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository +import com.duckduckgo.app.global.* import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.global.model.SiteFactory import com.duckduckgo.app.global.model.domain import com.duckduckgo.app.global.model.domainMatchesUrl -import com.duckduckgo.app.global.toDesktopUri import com.duckduckgo.app.privacy.db.NetworkLeaderboardDao import com.duckduckgo.app.privacy.db.UserWhitelistDao import com.duckduckgo.app.privacy.model.PrivacyGrade @@ -116,7 +105,8 @@ class BrowserTabViewModel( private val userWhitelistDao: UserWhitelistDao, private val networkLeaderboardDao: NetworkLeaderboardDao, private val bookmarksDao: BookmarksDao, - private val fireproofWebsiteDao: FireproofWebsiteDao, + private val fireproofWebsiteRepository: FireproofWebsiteRepository, + private val navigationAwareLoginDetector: NavigationAwareLoginDetector, private val autoComplete: AutoComplete, private val appSettingsPreferencesStore: SettingsDataStore, private val longPressHandler: LongPressHandler, @@ -156,6 +146,7 @@ class BrowserTabViewModel( val canSharePage: Boolean = false, val canAddBookmarks: Boolean = false, val canFireproofSite: Boolean = false, + val isFireproofWebsite: Boolean = false, val canGoBack: Boolean = false, val canGoForward: Boolean = false, val canWhitelist: Boolean = false, @@ -219,6 +210,7 @@ class BrowserTabViewModel( class DownloadImage(val url: String, val requestUserConfirmation: Boolean) : Command() class ShowBookmarkAddedConfirmation(val bookmarkId: Long, val title: String?, val url: String?) : Command() class ShowFireproofWebSiteConfirmation(val fireproofWebsiteEntity: FireproofWebsiteEntity) : Command() + class AskToFireproofWebsite(val fireproofWebsite: FireproofWebsiteEntity) : Command() class ShareLink(val url: String) : Command() class CopyLink(val url: String) : Command() class FindInPageCommand(val searchTerm: String) : Command() @@ -267,21 +259,31 @@ class BrowserTabViewModel( get() = site?.title private val autoCompletePublishSubject = PublishRelay.create() - private val fireproofWebsiteState: LiveData> = fireproofWebsiteDao.fireproofWebsitesEntities() + private val fireproofWebsiteState: LiveData> = fireproofWebsiteRepository.getFireproofWebsites() private var autoCompleteDisposable: Disposable? = null private var site: Site? = null private lateinit var tabId: String private var webNavigationState: WebNavigationState? = null private var httpsUpgraded = false private val browserStateModifier = BrowserStateModifier() + private val fireproofWebsitesObserver = Observer> { - browserViewState.value = currentBrowserViewState().copy(canFireproofSite = canFireproofWebsite()) + browserViewState.value = currentBrowserViewState().copy(isFireproofWebsite = isFireproofWebsite()) + } + + private val loginDetectionObserver = Observer { loginEvent -> + Timber.i("LoginDetection for $loginEvent") + if (!isFireproofWebsite(loginEvent.forwardedToDomain)) { + pixel.fire(PixelName.FIREPROOF_LOGIN_DIALOG_SHOWN) + command.value = AskToFireproofWebsite(FireproofWebsiteEntity(loginEvent.forwardedToDomain)) + } } init { initializeViewStates() configureAutoComplete() fireproofWebsiteState.observeForever(fireproofWebsitesObserver) + navigationAwareLoginDetector.loginEventLiveData.observeForever(loginDetectionObserver) } fun loadData(tabId: String, initialUrl: String?, skipHome: Boolean) { @@ -346,6 +348,7 @@ class BrowserTabViewModel( autoCompleteDisposable?.dispose() autoCompleteDisposable = null fireproofWebsiteState.removeObserver(fireproofWebsitesObserver) + navigationAwareLoginDetector.loginEventLiveData.removeObserver(loginDetectionObserver) super.onCleared() } @@ -391,6 +394,8 @@ class BrowserTabViewModel( } fun onUserSubmittedQuery(query: String) { + navigationAwareLoginDetector.onEvent(NavigationEvent.UserAction.NewQuerySubmitted) + if (query.isBlank()) { return } @@ -484,6 +489,7 @@ class BrowserTabViewModel( } fun onUserPressedForward() { + navigationAwareLoginDetector.onEvent(NavigationEvent.UserAction.NavigateForward) if (!currentBrowserViewState().browserShowing) { browserViewState.value = browserStateModifier.copyForBrowserShowing(currentBrowserViewState()) findInPageViewState.value = currentFindInPageViewState().copy(canFindInPage = true) @@ -494,6 +500,7 @@ class BrowserTabViewModel( } fun onRefreshRequested() { + navigationAwareLoginDetector.onEvent(NavigationEvent.UserAction.Refresh) if (currentGlobalLayoutState() is Invalidated) { recoverTabWithQuery(url.orEmpty()) } else { @@ -508,6 +515,7 @@ class BrowserTabViewModel( * @return true if navigation handled, otherwise false */ fun onUserPressedBack(): Boolean { + navigationAwareLoginDetector.onEvent(NavigationEvent.UserAction.NavigateBack) val navigation = webNavigationState ?: return false if (currentFindInPageViewState().visible) { @@ -579,6 +587,7 @@ class BrowserTabViewModel( is UrlUpdated -> urlUpdated(stateChange.url) is PageNavigationCleared -> disableUserNavigation() } + navigationAwareLoginDetector.onEvent(NavigationEvent.WebNavigationEvent(stateChange)) } private fun pageChanged(url: String, title: String?) { @@ -591,6 +600,7 @@ class BrowserTabViewModel( val currentBrowserViewState = currentBrowserViewState() val domain = site?.domain val canWhitelist = domain != null + val canFireproofSite = domain != null findInPageViewState.value = FindInPageViewState(visible = false, canFindInPage = true) browserViewState.value = currentBrowserViewState.copy( @@ -605,7 +615,8 @@ class BrowserTabViewModel( isWhitelisted = false, showSearchIcon = false, showClearButton = false, - canFireproofSite = canFireproofWebsite(), + canFireproofSite = canFireproofSite, + isFireproofWebsite = isFireproofWebsite(), showDaxIcon = shouldShowDaxIcon(url, true) ) @@ -654,11 +665,7 @@ class BrowserTabViewModel( onSiteChanged() val currentOmnibarViewState = currentOmnibarViewState() omnibarViewState.postValue(currentOmnibarViewState.copy(omnibarText = omnibarTextForUrl(url), shouldMoveCaretToEnd = false)) - browserViewState.postValue( - currentBrowserViewState().copy( - canFireproofSite = canFireproofWebsite() - ) - ) + browserViewState.postValue(currentBrowserViewState().copy(isFireproofWebsite = isFireproofWebsite())) } private fun omnibarTextForUrl(url: String?): String { @@ -718,6 +725,9 @@ class BrowserTabViewModel( val showLoadingGrade = progress.privacyOn || isLoading privacyGradeViewState.value = currentPrivacyGradeState().copy(shouldAnimate = isLoading, showEmptyGrade = showLoadingGrade) + if (newProgress == 100) { + navigationAwareLoginDetector.onEvent(NavigationEvent.PageFinished) + } } private fun registerSiteVisit() { @@ -868,24 +878,37 @@ class BrowserTabViewModel( } } - fun onFireproofWebsiteClicked() { + fun onFireproofWebsiteMenuClicked() { + val domain = site?.domain ?: return viewModelScope.launch { - val url = url ?: return@launch - val urlDomain = Uri.parse(url).host ?: return@launch - val fireproofWebsiteEntity = FireproofWebsiteEntity(domain = urlDomain) - val id = withContext(dispatchers.io()) { - fireproofWebsiteDao.insert(fireproofWebsiteEntity) + if (currentBrowserViewState().isFireproofWebsite) { + fireproofWebsiteRepository.removeFireproofWebsite(FireproofWebsiteEntity(domain)) + pixel.fire(PixelName.FIREPROOF_WEBSITE_REMOVE) + } else { + fireproofWebsiteRepository.fireproofWebsite(domain)?.let { + pixel.fire(PixelName.FIREPROOF_WEBSITE_ADDED) + command.value = ShowFireproofWebSiteConfirmation(fireproofWebsiteEntity = it) + } } - if (id >= 0) { - pixel.fire(PixelName.FIREPROOF_WEBSITE_ADDED) - command.value = ShowFireproofWebSiteConfirmation(fireproofWebsiteEntity = fireproofWebsiteEntity) + } + } + + fun onUserConfirmedFireproofDialog(domain: String) { + viewModelScope.launch { + fireproofWebsiteRepository.fireproofWebsite(domain)?.let { + pixel.fire(PixelName.FIREPROOF_WEBSITE_LOGIN_ADDED) + command.value = ShowFireproofWebSiteConfirmation(fireproofWebsiteEntity = it) } } } + fun onUserDismissedFireproofLoginDialog() { + pixel.fire(PixelName.FIREPROOF_WEBSITE_LOGIN_DISMISS) + } + fun onFireproofWebsiteSnackbarUndoClicked(fireproofWebsiteEntity: FireproofWebsiteEntity) { viewModelScope.launch(dispatchers.io()) { - fireproofWebsiteDao.delete(fireproofWebsiteEntity) + fireproofWebsiteRepository.removeFireproofWebsite(fireproofWebsiteEntity) pixel.fire(PixelName.FIREPROOF_WEBSITE_UNDO) } } @@ -1255,10 +1278,10 @@ class BrowserTabViewModel( command.value = LaunchTabSwitcher } - private fun canFireproofWebsite(): Boolean { - val domain = site?.uri?.host ?: return false + private fun isFireproofWebsite(domain: String? = site?.domain): Boolean { + if (domain == null) return false val fireproofWebsites = fireproofWebsiteState.value - return fireproofWebsites?.all { it.domain != domain } ?: true + return fireproofWebsites?.any { it.domain == domain } ?: false } private fun invalidateBrowsingActions() { @@ -1286,6 +1309,11 @@ class BrowserTabViewModel( command.value = OpenInNewTab(query) } + override fun loginDetected() { + val currentUrl = site?.url ?: return + navigationAwareLoginDetector.onEvent(NavigationEvent.LoginAttempt(currentUrl)) + } + companion object { private const val FIXED_PROGRESS = 50 } 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 d3197171463d..bcc61f97f5a0 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt @@ -23,6 +23,8 @@ import android.webkit.* import androidx.annotation.RequiresApi import androidx.annotation.UiThread import androidx.annotation.WorkerThread +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.global.exception.UncaughtExceptionRepository @@ -38,7 +40,8 @@ class BrowserWebViewClient( private val requestInterceptor: RequestInterceptor, private val offlinePixelCountDataStore: OfflinePixelCountDataStore, private val uncaughtExceptionRepository: UncaughtExceptionRepository, - private val cookieManager: CookieManager + private val cookieManager: CookieManager, + private val loginDetector: DOMLoginDetector ) : WebViewClient() { var webViewClientListener: WebViewClientListener? = null @@ -117,12 +120,14 @@ class BrowserWebViewClient( @UiThread override fun onPageStarted(webView: WebView, url: String?, favicon: Bitmap?) { try { + Timber.v("onPageStarted webViewUrl: ${webView.url} URL: $url") val navigationList = webView.safeCopyBackForwardList() ?: return webViewClientListener?.navigationStateChanged(WebViewNavigationState(navigationList)) if (url != null && url == lastPageStarted) { webViewClientListener?.pageRefreshed(url) } lastPageStarted = url + loginDetector.onEvent(WebNavigationEvent.OnPageStarted(webView)) } catch (e: Throwable) { GlobalScope.launch { uncaughtExceptionRepository.recordUncaughtException(e, ON_PAGE_STARTED) @@ -134,6 +139,7 @@ class BrowserWebViewClient( @UiThread override fun onPageFinished(webView: WebView, url: String?) { try { + Timber.v("onPageFinished webViewUrl: ${webView.url} URL: $url") val navigationList = webView.safeCopyBackForwardList() ?: return webViewClientListener?.navigationStateChanged(WebViewNavigationState(navigationList)) flushCookies() @@ -156,7 +162,10 @@ class BrowserWebViewClient( return runBlocking { try { val documentUrl = withContext(Dispatchers.Main) { webView.url } - Timber.v("Intercepting resource ${request.url} on page $documentUrl") + withContext(Dispatchers.Main) { + loginDetector.onEvent(WebNavigationEvent.ShouldInterceptRequest(webView, request)) + } + Timber.v("Intercepting resource ${request.url} type:${request.method} on page $documentUrl") requestInterceptor.shouldIntercept(request, webView, documentUrl, webViewClientListener) } catch (e: Throwable) { uncaughtExceptionRepository.recordUncaughtException(e, SHOULD_INTERCEPT_REQUEST) diff --git a/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt b/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt index 914d28db5bec..00d872c40b94 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt @@ -49,4 +49,6 @@ interface WebViewClientListener { fun closeCurrentTab() fun upgradedToHttps() fun surrogateDetected(surrogate: SurrogateResponse) + + fun loginDetected() } 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 3c27928b6601..7cbcdec8d694 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 @@ -26,6 +26,10 @@ import com.duckduckgo.app.browser.addtohome.AddToHomeSystemCapabilityDetector import com.duckduckgo.app.browser.defaultbrowsing.AndroidDefaultBrowserDetector import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserObserver +import com.duckduckgo.app.browser.logindetection.JsLoginDetector +import com.duckduckgo.app.browser.logindetection.DOMLoginDetector +import com.duckduckgo.app.browser.logindetection.NextPageLoginDetection +import com.duckduckgo.app.browser.logindetection.NavigationAwareLoginDetector import com.duckduckgo.app.browser.session.WebViewSessionInMemoryStorage import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.browser.tabpreview.FileBasedWebViewPreviewGenerator @@ -44,6 +48,7 @@ import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.httpsupgrade.HttpsUpgrader import com.duckduckgo.app.privacy.db.PrivacyProtectionCountDao import com.duckduckgo.app.referral.AppReferrerDataStore +import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.pixels.ExceptionPixel import com.duckduckgo.app.statistics.pixels.Pixel @@ -76,7 +81,8 @@ class BrowserModule { requestInterceptor: RequestInterceptor, offlinePixelCountDataStore: OfflinePixelCountDataStore, uncaughtExceptionRepository: UncaughtExceptionRepository, - cookieManager: CookieManager + cookieManager: CookieManager, + loginDetector: DOMLoginDetector ): BrowserWebViewClient { return BrowserWebViewClient( requestRewriter, @@ -84,7 +90,8 @@ class BrowserModule { requestInterceptor, offlinePixelCountDataStore, uncaughtExceptionRepository, - cookieManager + cookieManager, + loginDetector ) } @@ -211,4 +218,14 @@ class BrowserModule { fun webViewPreviewGenerator(): WebViewPreviewGenerator { return FileBasedWebViewPreviewGenerator() } + + @Provides + fun domLoginDetector(settingsDataStore: SettingsDataStore): DOMLoginDetector { + return JsLoginDetector(settingsDataStore) + } + + @Provides + fun navigationAwareLoginDetector(): NavigationAwareLoginDetector { + return NextPageLoginDetection() + } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/logindetection/DOMLoginDetector.kt b/app/src/main/java/com/duckduckgo/app/browser/logindetection/DOMLoginDetector.kt new file mode 100644 index 000000000000..7e1f52553e6e --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/logindetection/DOMLoginDetector.kt @@ -0,0 +1,116 @@ +/* + * 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.browser.logindetection + +import android.content.Context +import android.webkit.WebResourceRequest +import android.webkit.WebView +import androidx.annotation.UiThread +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.browser.logindetection.LoginDetectionJavascriptInterface.Companion.JAVASCRIPT_INTERFACE_NAME +import com.duckduckgo.app.settings.db.SettingsDataStore +import timber.log.Timber +import javax.inject.Inject + +interface DOMLoginDetector { + fun addLoginDetection(webView: WebView, onLoginDetected: () -> Unit) + fun onEvent(event: WebNavigationEvent) +} + +sealed class WebNavigationEvent { + data class OnPageStarted(val webView: WebView) : WebNavigationEvent() + data class ShouldInterceptRequest(val webView: WebView, val request: WebResourceRequest) : WebNavigationEvent() +} + +class JsLoginDetector @Inject constructor(private val settingsDataStore: SettingsDataStore) : DOMLoginDetector { + private val javaScriptDetector = JavaScriptDetector() + private val loginPathRegex = Regex("login|sign-in|signin|sessions") + + override fun addLoginDetection(webView: WebView, onLoginDetected: () -> Unit) { + webView.addJavascriptInterface(LoginDetectionJavascriptInterface { onLoginDetected() }, JAVASCRIPT_INTERFACE_NAME) + } + + @UiThread + override fun onEvent(event: WebNavigationEvent) { + if (settingsDataStore.appLoginDetection) { + when (event) { + is WebNavigationEvent.OnPageStarted -> injectLoginFormDetectionJS(event.webView) + is WebNavigationEvent.ShouldInterceptRequest -> { + if (evaluateIfLoginPostRequest(event.request)) { + scanForPasswordFields(event.webView) + } + } + } + } + } + + private fun evaluateIfLoginPostRequest(request: WebResourceRequest): Boolean { + if (request.method == HTTP_POST) { + Timber.i("LoginDetector: evaluate ${request.url}") + if (request.url?.path?.contains(loginPathRegex) == true) { + Timber.v("LoginDetector: post login DETECTED") + return true + } + } + return false + } + + @UiThread + private fun scanForPasswordFields(webView: WebView) { + webView.evaluateJavascript("javascript:${javaScriptDetector.loginFormDetector(webView.context)}", null) + } + + @UiThread + private fun injectLoginFormDetectionJS(webView: WebView) { + webView.evaluateJavascript("javascript:${javaScriptDetector.loginFormEventsDetector(webView.context)}", null) + } + + private class JavaScriptDetector { + private lateinit var functions: String + private lateinit var handlers: String + + private fun getFunctionsJS(context: Context): String { + if (!this::functions.isInitialized) { + functions = context.resources.openRawResource(R.raw.login_form_detection_functions).bufferedReader().use { it.readText() } + } + return functions + } + + private fun getHandlersJS(context: Context): String { + if (!this::handlers.isInitialized) { + handlers = context.resources.openRawResource(R.raw.login_form_detection_handlers).bufferedReader().use { it.readText() } + } + return handlers + } + + fun loginFormEventsDetector(context: Context): String { + return wrapIntoAnonymousFunction(getFunctionsJS(context) + getHandlersJS(context)) + } + + fun loginFormDetector(context: Context): String { + return wrapIntoAnonymousFunction(getFunctionsJS(context) + "scanForPasswordField();") + } + + private fun wrapIntoAnonymousFunction(rawJavaScript: String): String { + return "(function() { $rawJavaScript })();" + } + } + + companion object { + const val HTTP_POST = "POST" + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/logindetection/LoginDetectionJavascriptInterface.kt b/app/src/main/java/com/duckduckgo/app/browser/logindetection/LoginDetectionJavascriptInterface.kt new file mode 100644 index 000000000000..5ad705f0e661 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/logindetection/LoginDetectionJavascriptInterface.kt @@ -0,0 +1,39 @@ +/* + * 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.browser.logindetection + +import android.webkit.JavascriptInterface +import timber.log.Timber + +@Suppress("unused") +class LoginDetectionJavascriptInterface(private val onLoginDetected: () -> Unit) { + + @JavascriptInterface + fun log(message: String) { + Timber.i("LoginDetectionInterface $message") + } + + @JavascriptInterface + fun loginDetected() { + onLoginDetected() + } + + companion object { + // Interface name used inside login_form_detection.js + const val JAVASCRIPT_INTERFACE_NAME = "LoginDetection" + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/logindetection/NavigationAwareLoginDetector.kt b/app/src/main/java/com/duckduckgo/app/browser/logindetection/NavigationAwareLoginDetector.kt new file mode 100644 index 000000000000..3589e8fe8deb --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/logindetection/NavigationAwareLoginDetector.kt @@ -0,0 +1,106 @@ +/* + * 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.browser.logindetection + +import android.net.Uri +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.duckduckgo.app.browser.WebNavigationStateChange +import timber.log.Timber +import javax.inject.Inject + +interface NavigationAwareLoginDetector { + val loginEventLiveData: LiveData + fun onEvent(navigationEvent: NavigationEvent) +} + +data class LoginDetected(val authLoginDomain: String, val forwardedToDomain: String) + +sealed class NavigationEvent { + sealed class UserAction : NavigationEvent() { + object NavigateForward : UserAction() + object NavigateBack : UserAction() + object NewQuerySubmitted : UserAction() + object Refresh : UserAction() + } + + data class WebNavigationEvent(val navigationStateChange: WebNavigationStateChange) : NavigationEvent() + object PageFinished : NavigationEvent() + data class LoginAttempt(val url: String) : NavigationEvent() +} + +class NextPageLoginDetection @Inject constructor() : NavigationAwareLoginDetector { + + private var loginAttempt: NavigationEvent.LoginAttempt? = null + override val loginEventLiveData = MutableLiveData() + + override fun onEvent(navigationEvent: NavigationEvent) { + Timber.i("LoginDetectionDelegate $navigationEvent") + return when (navigationEvent) { + is NavigationEvent.PageFinished -> { + discardLoginAttempt() + } + is NavigationEvent.WebNavigationEvent -> { + handleNavigationEvent(navigationEvent) + } + is NavigationEvent.LoginAttempt -> { + saveLoginAttempt(navigationEvent) + } + is NavigationEvent.UserAction -> { + discardLoginAttempt() + } + } + } + + private fun saveLoginAttempt(navigationEvent: NavigationEvent.LoginAttempt) { + loginAttempt = navigationEvent + } + + private fun discardLoginAttempt() { + loginAttempt = null + } + + private fun handleNavigationEvent(navigationEvent: NavigationEvent.WebNavigationEvent) { + return when (val navigationStateChange = navigationEvent.navigationStateChange) { + is WebNavigationStateChange.NewPage -> { + detectLogin(navigationStateChange.url) + } + is WebNavigationStateChange.PageCleared -> discardLoginAttempt() + is WebNavigationStateChange.UrlUpdated -> { + detectLogin(navigationStateChange.url) + } + is WebNavigationStateChange.PageNavigationCleared -> discardLoginAttempt() + is WebNavigationStateChange.Unchanged -> { + } + is WebNavigationStateChange.Other -> { + } + } + } + + @Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS") + private fun detectLogin(forwardedToUrl: String) { + val loginAttemptEvent = loginAttempt ?: return + val loginURI = Uri.parse(loginAttemptEvent.url).takeUnless { it.host.isNullOrBlank() } ?: return + val forwardedToUri = Uri.parse(forwardedToUrl).takeUnless { it.host.isNullOrBlank() } ?: return + + Timber.i("LoginDetectionDelegate ${loginAttemptEvent.url} vs $forwardedToUrl") + if (loginURI.host != forwardedToUri.host || loginURI.path != forwardedToUri.path) { + loginEventLiveData.value = LoginDetected(loginURI.host, forwardedToUri.host) + loginAttempt = null + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteRepository.kt b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteRepository.kt new file mode 100644 index 000000000000..c1ab12258a34 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteRepository.kt @@ -0,0 +1,51 @@ +/* + * 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.fire.fireproofwebsite.data + +import androidx.lifecycle.LiveData +import com.duckduckgo.app.global.DispatcherProvider +import com.duckduckgo.app.global.UriString +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class FireproofWebsiteRepository @Inject constructor( + private val fireproofWebsiteDao: FireproofWebsiteDao, + private val dispatchers: DispatcherProvider +) { + suspend fun fireproofWebsite(domain: String): FireproofWebsiteEntity? { + if (!UriString.isValidDomain(domain)) return null + + val fireproofWebsiteEntity = FireproofWebsiteEntity(domain = domain) + val id = withContext(dispatchers.io()) { + fireproofWebsiteDao.insert(fireproofWebsiteEntity) + } + + return if (id >= 0) { + fireproofWebsiteEntity + } else { + null + } + } + + fun getFireproofWebsites(): LiveData> = fireproofWebsiteDao.fireproofWebsitesEntities() + + suspend fun removeFireproofWebsite(fireproofWebsiteEntity: FireproofWebsiteEntity) { + withContext(dispatchers.io()) { + fireproofWebsiteDao.delete(fireproofWebsiteEntity) + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsiteAdapter.kt b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsiteAdapter.kt index 05719bdf8c71..30cdc45168d3 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsiteAdapter.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsiteAdapter.kt @@ -20,6 +20,7 @@ import android.net.Uri import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.CompoundButton import android.widget.ImageView import android.widget.PopupMenu import androidx.recyclerview.widget.RecyclerView @@ -28,7 +29,10 @@ import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.app.fire.fireproofwebsite.data.website import com.duckduckgo.app.global.faviconLocation import com.duckduckgo.app.global.image.GlideApp +import com.duckduckgo.app.global.view.quietlySetIsChecked +import kotlinx.android.synthetic.main.view_fireproof_title.view.* import kotlinx.android.synthetic.main.view_fireproof_website_entry.view.* +import kotlinx.android.synthetic.main.view_fireproof_website_toggle.view.* import timber.log.Timber class FireproofWebsiteAdapter( @@ -39,39 +43,61 @@ class FireproofWebsiteAdapter( const val FIREPROOF_WEBSITE_TYPE = 0 const val DESCRIPTION_TYPE = 1 const val EMPTY_STATE_TYPE = 2 + const val TOGGLE_TYPE = 3 + const val DIVIDER_TYPE = 4 + const val SECTION_TITLE_TYPE = 5 - const val DESCRIPTION_ITEM_SIZE = 1 const val EMPTY_HINT_ITEM_SIZE = 1 } + private val sortedHeaderElements = listOf(DESCRIPTION_TYPE, TOGGLE_TYPE, DIVIDER_TYPE, SECTION_TITLE_TYPE) + var fireproofWebsites: List = emptyList() set(value) { - field = value - notifyDataSetChanged() + if (field != value) { + field = value + notifyDataSetChanged() + } } + var loginDetectionEnabled: Boolean = false + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FireproofWebSiteViewHolder { val inflater = LayoutInflater.from(parent.context) return when (viewType) { + DESCRIPTION_TYPE -> { + val view = inflater.inflate(R.layout.view_fireproof_website_description, parent, false) + FireproofWebSiteViewHolder.FireproofWebsiteSimpleViewViewHolder(view) + } + TOGGLE_TYPE -> { + val view = inflater.inflate(R.layout.view_fireproof_website_toggle, parent, false) + FireproofWebSiteViewHolder.FireproofWebsiteToggleViewHolder(view, + CompoundButton.OnCheckedChangeListener { _, isChecked -> viewModel.onUserToggleLoginDetection(isChecked) }) + } + DIVIDER_TYPE -> { + val view = inflater.inflate(R.layout.view_fireproof_divider, parent, false) + FireproofWebSiteViewHolder.FireproofWebsiteSimpleViewViewHolder(view) + } + SECTION_TITLE_TYPE -> { + val view = inflater.inflate(R.layout.view_fireproof_title, parent, false) + view.fireproofWebsiteSectionTitle.setText(R.string.fireproofWebsiteItemsSectionTitle) + FireproofWebSiteViewHolder.FireproofWebsiteSimpleViewViewHolder(view) + } FIREPROOF_WEBSITE_TYPE -> { val view = inflater.inflate(R.layout.view_fireproof_website_entry, parent, false) FireproofWebSiteViewHolder.FireproofWebsiteItemViewHolder(view, viewModel) } EMPTY_STATE_TYPE -> { val view = inflater.inflate(R.layout.view_fireproof_website_empty_hint, parent, false) - FireproofWebSiteViewHolder.FireproofWebsiteEmptyHintViewHolder(view) - } - DESCRIPTION_TYPE -> { - val view = inflater.inflate(R.layout.view_fireproof_website_description, parent, false) - FireproofWebSiteViewHolder.FireproofWebsiteDescriptionViewHolder(view) + FireproofWebSiteViewHolder.FireproofWebsiteSimpleViewViewHolder(view) } else -> throw IllegalArgumentException("viewType not found") } } override fun getItemViewType(position: Int): Int { - return if (position == 0) { - DESCRIPTION_TYPE + return if (position < sortedHeaderElements.size) { + sortedHeaderElements[position] } else { getListItemType() } @@ -79,12 +105,15 @@ class FireproofWebsiteAdapter( override fun onBindViewHolder(holder: FireproofWebSiteViewHolder, position: Int) { when (holder) { + is FireproofWebSiteViewHolder.FireproofWebsiteToggleViewHolder -> { + holder.bind(loginDetectionEnabled) + } is FireproofWebSiteViewHolder.FireproofWebsiteItemViewHolder -> holder.bind(fireproofWebsites[getWebsiteItemPosition(position)]) } } override fun getItemCount(): Int { - return getItemsSize() + DESCRIPTION_ITEM_SIZE + return getItemsSize() + itemsOnTopOfList() } private fun getItemsSize() = if (fireproofWebsites.isEmpty()) { @@ -93,7 +122,9 @@ class FireproofWebsiteAdapter( fireproofWebsites.size } - private fun getWebsiteItemPosition(position: Int) = position - DESCRIPTION_ITEM_SIZE + private fun itemsOnTopOfList() = sortedHeaderElements.size + + private fun getWebsiteItemPosition(position: Int) = position - itemsOnTopOfList() private fun getListItemType(): Int { return if (fireproofWebsites.isEmpty()) { @@ -106,9 +137,14 @@ class FireproofWebsiteAdapter( sealed class FireproofWebSiteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - class FireproofWebsiteDescriptionViewHolder(itemView: View) : FireproofWebSiteViewHolder(itemView) + class FireproofWebsiteToggleViewHolder(itemView: View, private val listener: CompoundButton.OnCheckedChangeListener) : + FireproofWebSiteViewHolder(itemView) { + fun bind(loginDetectionEnabled: Boolean) { + itemView.fireproofWebsiteToggle.quietlySetIsChecked(loginDetectionEnabled, listener) + } + } - class FireproofWebsiteEmptyHintViewHolder(itemView: View) : FireproofWebSiteViewHolder(itemView) + class FireproofWebsiteSimpleViewViewHolder(itemView: View) : FireproofWebSiteViewHolder(itemView) class FireproofWebsiteItemViewHolder(itemView: View, private val viewModel: FireproofWebsitesViewModel) : FireproofWebSiteViewHolder(itemView) { diff --git a/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesActivity.kt b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesActivity.kt index 0fba754e4dfc..bcdf8168f0fe 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesActivity.kt @@ -51,6 +51,7 @@ class FireproofWebsitesActivity : DuckDuckGoActivity() { private fun observeViewModel() { viewModel.viewState.observe(this, Observer { viewState -> viewState?.let { + adapter.loginDetectionEnabled = it.loginDetectionEnabled adapter.fireproofWebsites = it.fireproofWebsitesEntities } }) diff --git a/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModel.kt b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModel.kt index 8e5654755cee..02f6f4f486a0 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModel.kt @@ -17,22 +17,26 @@ package com.duckduckgo.app.fire.fireproofwebsite.ui import androidx.lifecycle.* -import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao +import androidx.lifecycle.Observer import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository import com.duckduckgo.app.fire.fireproofwebsite.ui.FireproofWebsitesViewModel.Command.ConfirmDeleteFireproofWebsite import com.duckduckgo.app.global.DispatcherProvider import com.duckduckgo.app.global.SingleLiveEvent +import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelName.FIREPROOF_WEBSITE_DELETED +import com.duckduckgo.app.statistics.pixels.Pixel.PixelName.* import kotlinx.coroutines.launch class FireproofWebsitesViewModel( - private val dao: FireproofWebsiteDao, + private val fireproofWebsiteRepository: FireproofWebsiteRepository, private val dispatcherProvider: DispatcherProvider, - private val pixel: Pixel + private val pixel: Pixel, + private val settingsDataStore: SettingsDataStore ) : ViewModel() { data class ViewState( + val loginDetectionEnabled: Boolean = false, val fireproofWebsitesEntities: List = emptyList() ) @@ -40,14 +44,17 @@ class FireproofWebsitesViewModel( class ConfirmDeleteFireproofWebsite(val entity: FireproofWebsiteEntity) : Command() } - val viewState: MutableLiveData = MutableLiveData() + private val _viewState: MutableLiveData = MutableLiveData() + val viewState: LiveData = _viewState val command: SingleLiveEvent = SingleLiveEvent() - private val fireproofWebsites: LiveData> = dao.fireproofWebsitesEntities() + private val fireproofWebsites: LiveData> = fireproofWebsiteRepository.getFireproofWebsites() private val fireproofWebsitesObserver = Observer> { onPreservedCookiesEntitiesChanged(it!!) } init { - viewState.value = ViewState() + _viewState.value = ViewState( + loginDetectionEnabled = settingsDataStore.appLoginDetection + ) fireproofWebsites.observeForever(fireproofWebsitesObserver) } @@ -57,7 +64,7 @@ class FireproofWebsitesViewModel( } private fun onPreservedCookiesEntitiesChanged(entities: List) { - viewState.value = viewState.value?.copy( + _viewState.value = _viewState.value?.copy( fireproofWebsitesEntities = entities ) } @@ -68,8 +75,15 @@ class FireproofWebsitesViewModel( fun delete(entity: FireproofWebsiteEntity) { viewModelScope.launch(dispatcherProvider.io()) { - dao.delete(entity) + fireproofWebsiteRepository.removeFireproofWebsite(entity) pixel.fire(FIREPROOF_WEBSITE_DELETED) } } + + fun onUserToggleLoginDetection(enabled: Boolean) { + val pixelName = if (enabled) FIREPROOF_LOGIN_TOGGLE_ENABLED else FIREPROOF_LOGIN_TOGGLE_DISABLED + pixel.fire(pixelName) + settingsDataStore.appLoginDetection = enabled + _viewState.value = _viewState.value?.copy(loginDetectionEnabled = enabled) + } } diff --git a/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt b/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt index 3d9b7a5b540c..5231b7cff2b3 100644 --- a/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt +++ b/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt @@ -27,6 +27,7 @@ import com.duckduckgo.app.browser.* import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector import com.duckduckgo.app.browser.favicon.FaviconDownloader +import com.duckduckgo.app.browser.logindetection.NavigationAwareLoginDetector import com.duckduckgo.app.browser.omnibar.QueryUrlConverter import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.cta.ui.CtaViewModel @@ -37,7 +38,7 @@ import com.duckduckgo.app.feedback.ui.negative.brokensite.BrokenSiteNegativeFeed import com.duckduckgo.app.feedback.ui.negative.openended.ShareOpenEndedNegativeFeedbackViewModel import com.duckduckgo.app.feedback.ui.positive.initial.PositiveFeedbackLandingViewModel import com.duckduckgo.app.fire.DataClearer -import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository import com.duckduckgo.app.fire.fireproofwebsite.ui.FireproofWebsitesViewModel import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.global.model.SiteFactory @@ -85,7 +86,8 @@ class ViewModelFactory @Inject constructor( private val userWhitelistDao: UserWhitelistDao, private val networkLeaderboardDao: NetworkLeaderboardDao, private val bookmarksDao: BookmarksDao, - private val fireproofWebsiteDao: FireproofWebsiteDao, + private val fireproofWebsiteRepository: FireproofWebsiteRepository, + private val navigationAwareLoginDetector: NavigationAwareLoginDetector, private val surveyDao: SurveyDao, private val autoCompleteApi: AutoCompleteApi, private val deviceAppLookup: DeviceAppLookup, @@ -185,7 +187,8 @@ class ViewModelFactory @Inject constructor( userWhitelistDao = userWhitelistDao, networkLeaderboardDao = networkLeaderboardDao, bookmarksDao = bookmarksDao, - fireproofWebsiteDao = fireproofWebsiteDao, + fireproofWebsiteRepository = fireproofWebsiteRepository, + navigationAwareLoginDetector = navigationAwareLoginDetector, autoComplete = autoCompleteApi, appSettingsPreferencesStore = appSettingsPreferencesStore, longPressHandler = webViewLongPressHandler, @@ -204,8 +207,9 @@ class ViewModelFactory @Inject constructor( private fun fireproofWebsiteViewModel() = FireproofWebsitesViewModel( - dao = fireproofWebsiteDao, + fireproofWebsiteRepository = fireproofWebsiteRepository, dispatcherProvider = dispatcherProvider, - pixel = pixel + pixel = pixel, + settingsDataStore = appSettingsPreferencesStore ) } diff --git a/app/src/main/java/com/duckduckgo/app/global/view/SwitchExtension.kt b/app/src/main/java/com/duckduckgo/app/global/view/SwitchExtension.kt new file mode 100644 index 000000000000..ac82eb475a7f --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/global/view/SwitchExtension.kt @@ -0,0 +1,34 @@ +/* + * 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.global.view + +import android.widget.CompoundButton +import androidx.appcompat.widget.SwitchCompat + +/** + * Utility method to toggle a switch without broadcasting to its change listener + * + * This is useful for when setting the checked state from the view model where we want the switch state to match some value, but this act itself + * should not result in the checked change event handler being fired + * + * Requires the change listener to be provided explicitly as it is held privately in the super class and cannot be accessed automatically. + */ +fun SwitchCompat.quietlySetIsChecked(newCheckedState: Boolean, changeListener: CompoundButton.OnCheckedChangeListener?) { + setOnCheckedChangeListener(null) + isChecked = newCheckedState + setOnCheckedChangeListener(changeListener) +} 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 cb43eadc24a1..660c324e085f 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt @@ -26,7 +26,6 @@ import android.view.View import android.widget.CompoundButton.OnCheckedChangeListener import android.widget.Toast import androidx.annotation.StringRes -import androidx.appcompat.widget.SwitchCompat import androidx.lifecycle.Observer import com.duckduckgo.app.about.AboutDuckDuckGoActivity import com.duckduckgo.app.browser.R @@ -35,6 +34,7 @@ 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.launchDefaultAppActivity +import com.duckduckgo.app.global.view.quietlySetIsChecked import com.duckduckgo.app.icon.ui.ChangeIconActivity import com.duckduckgo.app.privacy.ui.WhitelistActivity import com.duckduckgo.app.settings.SettingsViewModel.AutomaticallyClearData @@ -234,17 +234,3 @@ class SettingsActivity : DuckDuckGoActivity(), SettingsAutomaticallyClearWhatFra } } } - -/** - * Utility method to toggle a switch without broadcasting to its change listener - * - * This is useful for when setting the checked state from the view model where we want the switch state to match some value, but this act itself - * should not result in the checked change event handler being fired - * - * Requires the change listener to be provided explicitly as it is held privately in the super class and cannot be accessed automatically. - */ -private fun SwitchCompat.quietlySetIsChecked(newCheckedState: Boolean, changeListener: OnCheckedChangeListener?) { - setOnCheckedChangeListener(null) - isChecked = newCheckedState - setOnCheckedChangeListener(changeListener) -} diff --git a/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt b/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt index db16a62507e7..34ea47cd0339 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt @@ -34,6 +34,7 @@ interface SettingsDataStore { var autoCompleteSuggestionsEnabled: Boolean var appIcon: AppIcon var appIconChanged: Boolean + var appLoginDetection: Boolean /** * This will be checked upon app startup and used to decide whether it should perform a clear or not. @@ -79,6 +80,10 @@ class SettingsSharedPreferences @Inject constructor(private val context: Context get() = preferences.getBoolean(KEY_AUTOCOMPLETE_ENABLED, true) set(enabled) = preferences.edit { putBoolean(KEY_AUTOCOMPLETE_ENABLED, enabled) } + override var appLoginDetection: Boolean + get() = preferences.getBoolean(KEY_LOGIN_DETECTION_ENABLED, false) + set(enabled) = preferences.edit { putBoolean(KEY_LOGIN_DETECTION_ENABLED, enabled) } + override var appIcon: AppIcon get() { val componentName = preferences.getString(KEY_APP_ICON, DEFAULT_ICON.componentName) ?: return DEFAULT_ICON @@ -144,6 +149,7 @@ class SettingsSharedPreferences @Inject constructor(private val context: Context const val KEY_BACKGROUND_JOB_ID = "BACKGROUND_JOB_ID" const val KEY_THEME = "THEME" const val KEY_AUTOCOMPLETE_ENABLED = "AUTOCOMPLETE_ENABLED" + const val KEY_LOGIN_DETECTION_ENABLED = "KEY_LOGIN_DETECTION_ENABLED" const val KEY_AUTOMATICALLY_CLEAR_WHAT_OPTION = "AUTOMATICALLY_CLEAR_WHAT_OPTION" const val KEY_AUTOMATICALLY_CLEAR_WHEN_OPTION = "AUTOMATICALLY_CLEAR_WHEN_OPTION" const val KEY_APP_BACKGROUNDED_TIMESTAMP = "APP_BACKGROUNDED_TIMESTAMP" diff --git a/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt b/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt index 57bfd2d8fb67..be03083f1f04 100644 --- a/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt +++ b/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt @@ -186,7 +186,13 @@ interface Pixel { COOKIE_DATABASE_EXCEPTION_OPEN_ERROR("m_cdb_e_oe"), COOKIE_DATABASE_EXCEPTION_DELETE_ERROR("m_cdb_e_de"), FIREPROOF_WEBSITE_ADDED("m_fw_a"), + FIREPROOF_WEBSITE_REMOVE("m_fw_r"), + FIREPROOF_LOGIN_DIALOG_SHOWN("m_fw_ld_s"), + FIREPROOF_WEBSITE_LOGIN_ADDED("m_fw_l_a"), + FIREPROOF_WEBSITE_LOGIN_DISMISS("m_fw_l_d"), FIREPROOF_WEBSITE_DELETED("m_fw_d"), + FIREPROOF_LOGIN_TOGGLE_ENABLED("m_fw_d_e"), + FIREPROOF_LOGIN_TOGGLE_DISABLED("m_fw_d_d"), FIREPROOF_WEBSITE_UNDO("m_fw_u") } 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 22d7cc98a4c8..e51bcf908d69 100644 --- a/app/src/main/res/layout/popup_window_browser_menu.xml +++ b/app/src/main/res/layout/popup_window_browser_menu.xml @@ -94,10 +94,19 @@ style="@style/BrowserTextMenuItem" android:text="@string/addBookmarkMenuTitle" /> - + style="@style/BrowserCheckBoxMenuItem" + android:theme="@style/PopUpMenuSwitchTheme" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginEnd="20dp" + android:button="@null" + android:drawableEnd="?android:attr/listChoiceIndicatorMultiple" + android:drawablePadding="24dp" + android:paddingStart="24dp" + android:paddingEnd="0dp" + android:text="@string/fireproofWebsiteMenuTitleAdd" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_fireproof_title.xml b/app/src/main/res/layout/view_fireproof_title.xml new file mode 100644 index 000000000000..bbb21f893c4a --- /dev/null +++ b/app/src/main/res/layout/view_fireproof_title.xml @@ -0,0 +1,21 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_fireproof_website_description.xml b/app/src/main/res/layout/view_fireproof_website_description.xml index d71b535a5153..921807bb2f77 100644 --- a/app/src/main/res/layout/view_fireproof_website_description.xml +++ b/app/src/main/res/layout/view_fireproof_website_description.xml @@ -14,22 +14,20 @@ ~ limitations under the License. --> - - - - \ No newline at end of file + android:fontFamily="sans-serif" + android:justificationMode="inter_word" + android:lineSpacingExtra="4sp" + android:paddingStart="16dp" + android:paddingTop="12dp" + android:paddingEnd="16dp" + android:paddingBottom="12dp" + android:text="@string/fireproofWebsiteFeatureDescription" + android:textColor="?attr/settingsMinorTextColor" + android:textSize="14sp" + android:textStyle="normal" + tools:text="Lorem ipsum dolor sit amet" /> \ No newline at end of file diff --git a/app/src/main/res/layout/view_fireproof_website_toggle.xml b/app/src/main/res/layout/view_fireproof_website_toggle.xml new file mode 100644 index 000000000000..8bca394d676e --- /dev/null +++ b/app/src/main/res/layout/view_fireproof_website_toggle.xml @@ -0,0 +1,23 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/raw/login_form_detection_functions.js b/app/src/main/res/raw/login_form_detection_functions.js new file mode 100644 index 000000000000..dac148997d4d --- /dev/null +++ b/app/src/main/res/raw/login_form_detection_functions.js @@ -0,0 +1,71 @@ +// This code must be wrapped in an anonymous function which is done in JavaScriptDetector.kt to allow for dynamic changes before wrapping. + +function loginFormDetected() { + try { + LoginDetection.loginDetected(); + } catch (error) {} +} + +function inputVisible(input) { + return !(input.offsetWidth === 0 && input.offsetHeight === 0) && !input.ariaHidden && !input.hidden && input.value !== ""; +} + +function checkIsLoginForm(form) { + LoginDetection.log("checking form " + form); + + var inputs = form.getElementsByTagName("input"); + if (!inputs) { + return; + } + + for (var i = 0; i < inputs.length; i++) { + var input = inputs.item(i); + if (input.type == "password" && inputVisible(input)) { + LoginDetection.log("found password in form " + form); + loginFormDetected(); + return true; + } + } + + LoginDetection.log("no password field in form " + form); + return false; +} + +function submitHandler(event) { + checkIsLoginForm(event.target); +} + +function scanForForms() { + LoginDetection.log("Scanning for forms"); + + var forms = document.forms; + if (!forms || forms.length === 0) { + LoginDetection.log("No forms found"); + return; + } + + for (var i = 0; i < forms.length; i++) { + var form = forms[i]; + form.removeEventListener("submit", submitHandler); + form.addEventListener("submit", submitHandler); + LoginDetection.log("adding form handler " + i); + } +} + +function scanForPasswordField() { + LoginDetection.log("Scanning for password"); + + var forms = document.forms; + if (!forms || forms.length === 0) { + LoginDetection.log("No forms found"); + return; + } + + for (var i = 0; i < forms.length; i++) { + var form = forms[i]; + var found = checkIsLoginForm(form); + if (found) { + return found; + } + } +} diff --git a/app/src/main/res/raw/login_form_detection_handlers.js b/app/src/main/res/raw/login_form_detection_handlers.js new file mode 100644 index 000000000000..56d76ac7abd7 --- /dev/null +++ b/app/src/main/res/raw/login_form_detection_handlers.js @@ -0,0 +1,13 @@ +// This code must be wrapped in an anonymous function which is done in JavaScriptDetector.kt to allow for dynamic changes before wrapping. + +window.addEventListener("DOMContentLoaded", function(event) { + LoginDetection.log("Adding to DOM"); + setTimeout(scanForForms, 1000); +}); + +window.addEventListener("click", scanForForms); +window.addEventListener("beforeunload", scanForForms); + +window.addEventListener("submit", submitHandler); + +LoginDetection.log("installing loginDetection.js - OUT"); \ No newline at end of file diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index ee6f2e49a6c8..c6607edaba2c 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -439,7 +439,7 @@ Огнеустойчиви уебсайтове Огнеустойчиви уебсайтове - Огнеустойчив уебсайт + Огнеустойчив уебсайт <b>%s</b> вече е огнеустойчив! Посетете Настройки, за да научите повече. Отмяна Сигурни ли сте, че искате да изтриете <b>%s</b>? diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 41321f799d0b..3d77366a6055 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -446,7 +446,7 @@ Webové stránky pro ochranu souborů Webové stránky pro ochranu souborů - Webová stránka pro ochranu souborů + Webová stránka pro ochranu souborů <b>%s</b> je nyní chráněný! Další informace naleznete v části Nastavení. Vrátit Opravdu chcete smazat <b>%s</b>? diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 1616aec23ec7..9951ada02eab 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -440,7 +440,7 @@ Brandsikre websteder Brandsikre websteder - Brandsikkert websted + Brandsikkert websted <b>%s</b> er nu brandsikkert! Besøg Indstillinger for at lære mere. Fortryd Er du sikker på, at du vil slette <b>%s</b>? diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a247ee2f9800..725ab5a3e697 100755 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -439,7 +439,7 @@ Feuersichere Websites Feuersichere Websites - Feuersichere Website + Feuersichere Website <b>%s</b> ist jetzt feuersicher! Besuchen Sie die Einstellungen, um mehr zu erfahren. Rückgängig machen Möchten Sie <b>%s</b> wirklich löschen? diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 1c28ace90b9e..5e5e299a8359 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -439,7 +439,7 @@ Ασφαλείς ιστότοποι Ασφαλείς ιστότοποι - Ασφαλής ιστότοπος + Ασφαλής ιστότοπος Το <b>%s</b> είναι πλέον ασφαλές! Επισκεφτείτε τις Ρυθμίσεις για να μάθετε περισσότερα. Αναίρεση Είστε βέβαιοι ότι θέλετε να διαγράψετε το <b>%s</b>; diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0a1f46810187..aee74eab1560 100755 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -440,7 +440,7 @@ Sitios web a prueba de fuego Sitios web a prueba de fuego - Sitio web a prueba de fuego + Sitio web a prueba de fuego ¡<b>%s</b> es ahora a prueba de fuego! Visita Ajustes para obtener más información. Deshacer ¿Está seguro de que desea eliminar <b>%s</b>? diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 4d9aa7358fb7..ed72dccf5c63 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -460,7 +460,7 @@ Tulekindlad veebisaidid Tulekindlad veebisaidid - Tulekindel veebisait + Tulekindel veebisait <b>%s</b> on nüüd tulekindel! Lisateabe saamiseks vaata üle seaded. Võta tagasi Kas soovite kindlasti kustutada saidi <b>%s</b>? diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 091735342195..67f3150fb69c 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -440,7 +440,7 @@ Palonkestävät verkkosivustot Palonkestävät verkkosivustot - Palonkestävä verkkosivusto + Palonkestävä verkkosivusto <b>%s</b> on nyt palonkestävä! Siirry asetuksiin oppiaksesi lisää. Kumoa Haluatko varmasti poistaa <b>%s</b>? diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 3e71d5e0cc59..93372ac8c27f 100755 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -440,7 +440,7 @@ Sites Web coupe-feu Sites Web coupe-feu - Site Web coupe-feu + Site Web coupe-feu <b>%s</b> est maintenant coupe-feu ! Visitez Paramètres pour en savoir plus. Annuler Voulez-vous vraiment supprimer <b>%s</b> ? diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 5d8d1deabe49..bc304e700439 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -446,7 +446,7 @@ Vatrootporne web stranice Vatrootporne web stranice - Vatrootporna web stranica + Vatrootporna web stranica <b>%s</b> je sada vatrootporan! Posjetite Postavke da biste saznali više. Poništi Jeste li sigurni da želite izbrisati <b>%s</b>? diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 9b5b2f3b09a1..b26bb2db6b98 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -440,7 +440,7 @@ Tűzálló weboldalak Tűzálló weboldalak - Tűzálló weboldal + Tűzálló weboldal A <b>%s</b> most már tűzálló! További információkért nézd meg a Beállításokat. Visszavonás Biztos, hogy törölni akarod a (z) <b>%s</b> fájlt? diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 37c190ba2639..4b1892a4e8d2 100755 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -456,7 +456,7 @@ Siti web a prova di bomba Siti web a prova di bomba - Sito web a prova di bomba + Sito web a prova di bomba <b>%s</b> ora è a prova di bomba! Visita le Impostazioni per saperne di più. Annulla Sei sicuro di voler eliminare <b>%s</b>? diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index 6fadee3ea1fb..c7d9f6f0df6a 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -446,7 +446,7 @@ Patikrinti svetaines Patikrinti svetaines - Patikrinti svetainę + Patikrinti svetainę <b>%s</b> dabar yra patikrinta! Apsilankykite „Nustatymuose“ ir sužinokite daugiau. Anuliuoti Ar tikrai norite ištrinti <b>%s</b>? diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index 10cd676e3b30..d7221c2da38a 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -464,7 +464,7 @@ Ugunsdrošas tīmekļa vietnes Ugunsdrošas tīmekļa vietnes - Ugunsdrošā tīmekļa vietne + Ugunsdrošā tīmekļa vietne <b>%s</b> tagad ir ugunsdrošs! Lai uzzinātu vairāk, apmeklējiet iestatījumus. Atsaukt Vai tiešām vēlaties izdzēst <b>%s</b>? diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index bf5e5b6d4cad..7d2300bcd1cb 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -440,7 +440,7 @@ Brannsikre nettsteder Brannsikre nettsteder - Brannsikkert nettsted + Brannsikkert nettsted <b>%s</b> er nå brannsikkert! Besøk Innstillinger for å lære mer. Angre Er du sikker på at du vil slette <b>%s</b>? diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index fafb4b159ea2..dc3230894d20 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -440,7 +440,7 @@ Brandveilige websites Brandveilige websites - Brandveilige website + Brandveilige website <b>%s</b> is nu brandveilig! Ga naar Instellingen voor meer informatie. Ongedaan maken Weet je zeker dat je <b>%s</b> wilt verwijderen? diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index f6ecab80590e..614ab21ea906 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -452,7 +452,7 @@ Bezpieczne strony internetowe Bezpieczne strony internetowe - Bezpieczna strona internetowa + Bezpieczna strona internetowa <b>%s</b> jest teraz bezpieczna! Odwiedź Ustawienia, aby dowiedzieć się więcej. Cofnij Czy na pewno chcesz usunąć <b>%s</b>? diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 8db7edd71828..983b8eab3210 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -440,7 +440,7 @@ Sites à prova de fogo Sites à prova de fogo - Site à prova de fogo + Site à prova de fogo <b>%s</b> já é à prova de fogo! Aceda a Configurações para saber mais. Anular Tem a certeza de que deseja eliminar <b>%s</b>? diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index d066b5482b6b..21e047065ba5 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -446,7 +446,7 @@ Site-uri ignifuge Site-uri ignifuge - Website ignifug + Website ignifug <b>%s</b> este acum ignifugat! Accesați Setări pentru a afla mai multe. Anulați Sigur doriți să ștergeți <b>%s</b>? diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 93b078430360..7bbc2e33c814 100755 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -467,7 +467,7 @@ Обезопасить сайты Обезопасить сайты - Обезопасить сайт + Обезопасить сайт <b>%s</b> теперь безопасен! Зайдите в Настройки, чтобы узнать больше. Отменить действие Вы уверены, что хотите удалить <b>%s</b>? diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 3a1040c7b569..917ea5b01fc4 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -446,7 +446,7 @@ Zabezpečené webové stránky Zabezpečené webové stránky - Zabezpečiť webovú stránku + Zabezpečiť webovú stránku <b>%s</b> je teraz zabezpečená! Viac informácií nájdete na stránke Nastavenia. Vrátenie späť Naozaj chcete odstrániť <b>%s</b>? diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 6ce949e673b1..a483452fa577 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -452,7 +452,7 @@ Spletne stran s požarno zaščito Spletna stran s požarno zaščito - Spletna stran s požarno zaščito + Spletna stran s požarno zaščito <b>%s</b> ima sedaj požarno zaščito! Za več informacij obiščite Nastavitve. Prekliči spremembe Ste prepričani, da želite izbrisati <b>%s</b>? diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 0b737021be53..73221f636349 100755 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -456,7 +456,7 @@ Brandsäkra webbplatser Brandsäkra webbplatser - Brandsäker webbplats + Brandsäker webbplats <b>%s</b> är nu brandsäker! Besök Inställningar för att lära dig mer. Återställ Är du säker på att du vill ta bort <b>%s</b>? diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 98dcbf1b2869..18d29cc61b53 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -461,7 +461,7 @@ Korumalı Web Siteleri Korumalı Web Siteleri - Korumalı Web Sitesi + Korumalı Web Sitesi <b>%s</b> artık koruma altında! Daha fazla bilgi edinmek için Ayarlar\'a gidin. Geri al <b>%s</b> \'ı silmek istediğinizden emin misiniz? diff --git a/app/src/main/res/values/string-untranslated.xml b/app/src/main/res/values/string-untranslated.xml index a8c6a7a9ad56..15cd5cc99222 100644 --- a/app/src/main/res/values/string-untranslated.xml +++ b/app/src/main/res/values/string-untranslated.xml @@ -32,4 +32,13 @@ Show Me Try It Out + + Would you like to Fireproof %s? + Fireproofing this site will keep you signed in after using the Fire Button. + Fireproof + Not now + Websites + Ask When Signing In + + Websites rely on cookies to keep you signed in. When you Fireproof a site, cookies won\'t be erased and you\'ll stay signed in, even after using the Fire Button. We still block third-party trackers found on Fireproof websites. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4563e84fb6c5..cfcbb30c4388 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -449,11 +449,10 @@ Fireproof Websites Fireproof Websites - Fireproof Website + Fireproof Website <b>%s</b> is now fireproof! Visit Settings to learn more. Undo Are you sure you want to delete <b>%s</b>? - No websites fireproofed yet - Websites rely on cookies to keep you signed in. When you Fireproof a site, cookies won\'t be erased and you\'ll stay signed in, even after using the Fire Button. + No websites Fireproofed yet More options for fireproof website %s diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 08d31407672e..8d44cd84916d 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -310,6 +310,40 @@ ?attr/colorAccent + + + + + +