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 00554dcdd603..2beafad20e09 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -75,6 +75,7 @@ import io.reactivex.Single import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestCoroutineScope import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.withContext import org.junit.After @@ -180,6 +181,8 @@ class BrowserTabViewModelTest { private lateinit var testee: BrowserTabViewModel + private val selectedTabLiveData = MutableLiveData() + @Before fun before() { MockitoAnnotations.initMocks(this) @@ -201,11 +204,13 @@ class BrowserTabViewModelTest { ) val siteFactory = SiteFactory(mockPrivacyPractices, mockTrackerNetworks, prevalenceStore = mockPrevalenceStore) - runBlockingTest { - whenever(mockTabsRepository.retrieveSiteData(any())).thenReturn(MutableLiveData()) - whenever(mockPrivacyPractices.privacyPracticesFor(any())).thenReturn(PrivacyPractices.UNKNOWN) - whenever(mockAppInstallStore.installTimestamp).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)) - } + + whenever(mockOmnibarConverter.convertQueryToUrl(any())).thenReturn("duckduckgo.com") + whenever(mockVariantManager.getVariant()).thenReturn(DEFAULT_VARIANT) + whenever(mockTabsRepository.liveSelectedTab).thenReturn(selectedTabLiveData) + whenever(mockTabsRepository.retrieveSiteData(any())).thenReturn(MutableLiveData()) + whenever(mockPrivacyPractices.privacyPracticesFor(any())).thenReturn(PrivacyPractices.UNKNOWN) + whenever(mockAppInstallStore.installTimestamp).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)) testee = BrowserTabViewModel( statisticsUpdater = mockStatisticsUpdater, @@ -232,9 +237,6 @@ class BrowserTabViewModelTest { testee.loadData("abc", null, false) testee.command.observeForever(mockCommandObserver) - - whenever(mockOmnibarConverter.convertQueryToUrl(any())).thenReturn("duckduckgo.com") - whenever(mockVariantManager.getVariant()).thenReturn(DEFAULT_VARIANT) } @ExperimentalCoroutinesApi @@ -269,7 +271,7 @@ class BrowserTabViewModelTest { } @Test - fun whenOpenInNewBackgroundRequestedThenTabRepositoryUpdatedAndCommandIssued() = runBlockingTest { + fun whenOpenInNewBackgroundRequestedThenTabRepositoryUpdatedAndCommandIssued() = ruleRunBlockingTest { val url = "http://www.example.com" testee.openInNewBackgroundTab(url) @@ -295,6 +297,14 @@ class BrowserTabViewModelTest { assertTrue(commandCaptor.allValues.contains(Command.ShowKeyboard)) } + @Test + fun whenInvalidatedGlobalLayoutRestoredThenErrorIsShown() { + givenInvalidatedGlobalLayout() + setBrowserShowing(true) + testee.onViewVisible() + assertCommandIssued() + } + @Test fun whenSubmittedQueryHasWhitespaceItIsTrimmed() { testee.onUserSubmittedQuery(" nytimes.com ") @@ -320,13 +330,13 @@ class BrowserTabViewModelTest { } @Test - fun whenBookmarkEditedThenDaoIsUpdated() = runBlockingTest { + fun whenBookmarkEditedThenDaoIsUpdated() = ruleRunBlockingTest { testee.editBookmark(0, "A title", "www.example.com") verify(mockBookmarksDao).update(BookmarkEntity(title = "A title", url = "www.example.com")) } @Test - fun whenBookmarkAddedThenDaoIsUpdatedAndUserNotified() = runBlockingTest { + fun whenBookmarkAddedThenDaoIsUpdatedAndUserNotified() = ruleRunBlockingTest { loadUrl("www.example.com", "A title") testee.onBookmarkAddRequested() @@ -343,7 +353,7 @@ class BrowserTabViewModelTest { } @Test - fun whenEmptyInputQueryThenQueryNavigateCommandNotSubmittedToActivityActivity() { + fun whenEmptyInputQueryThenQueryNavigateCommandNotSubmittedToActivity() { testee.onUserSubmittedQuery("") verify(mockCommandObserver, never()).onChanged(commandCaptor.capture()) } @@ -362,7 +372,27 @@ class BrowserTabViewModelTest { } @Test - fun whenBrowsingAndUrlLoadedThenSiteVisitedEntryAddedToLeaderboardDao() = runBlockingTest { + fun whenInvalidatedGlobalLayoutAndNonEmptyInputThenOpenInNewTab() { + givenOneActiveTabSelected() + givenInvalidatedGlobalLayout() + testee.onUserSubmittedQuery("foo") + assertCommandIssued() + } + + @Test + fun whenInvalidatedGlobalLayoutAndNonEmptyInputThenCloseCurrentTab() { + givenOneActiveTabSelected() + givenInvalidatedGlobalLayout() + + testee.onUserSubmittedQuery("foo") + + ruleRunBlockingTest { + verify(mockTabsRepository).delete(selectedTabLiveData.value!!) + } + } + + @Test + fun whenBrowsingAndUrlLoadedThenSiteVisitedEntryAddedToLeaderboardDao() = ruleRunBlockingTest { loadUrl("http://example.com/abc", isBrowserShowing = true) verify(mockNetworkLeaderboardDao).incrementSitesVisited() } @@ -431,7 +461,7 @@ class BrowserTabViewModelTest { } @Test - fun whenViewModelNotifiedThatUrlGotFocusThenViewStateIsUpdated() = runBlockingTest { + fun whenViewModelNotifiedThatUrlGotFocusThenViewStateIsUpdated() = ruleRunBlockingTest { withContext(Dispatchers.Main) { testee.onOmnibarInputStateChanged("", true, hasQueryChanged = false) assertTrue(omnibarViewState().isEditing) @@ -499,7 +529,7 @@ class BrowserTabViewModelTest { } @Test - fun whenUrlClearedThenPrivacyGradeIsCleared() = runBlockingTest { + fun whenUrlClearedThenPrivacyGradeIsCleared() = ruleRunBlockingTest { withContext(Dispatchers.Main) { loadUrl("https://duckduckgo.com") assertNotNull(testee.privacyGrade.value) @@ -509,7 +539,7 @@ class BrowserTabViewModelTest { } @Test - fun whenUrlLoadedThenPrivacyGradeIsReset() = runBlockingTest { + fun whenUrlLoadedThenPrivacyGradeIsReset() = ruleRunBlockingTest { withContext(Dispatchers.Main) { loadUrl("https://duckduckgo.com") assertNotNull(testee.privacyGrade.value) @@ -805,6 +835,15 @@ class BrowserTabViewModelTest { assertTrue(browserViewState().canGoForward) } + @Test + fun whenHomeShowingByPressingBackOnInvalidatedBrowserThenForwardButtonInactive() { + setupNavigation(isBrowsing = true) + givenInvalidatedGlobalLayout() + testee.onUserPressedBack() + assertFalse(browserViewState().browserShowing) + assertFalse(browserViewState().canGoForward) + } + @Test fun whenBrowserShowingAndCanGoForwardThenForwardButtonActive() { setupNavigation(isBrowsing = true, canGoForward = true) @@ -859,6 +898,34 @@ class BrowserTabViewModelTest { assertTrue(captureCommands().lastValue == Command.Refresh) } + @Test + fun whenRefreshRequestedWithInvalidatedGlobalLayoutThenOpenCurrentUrlInNewTab() { + givenOneActiveTabSelected() + givenInvalidatedGlobalLayout() + + testee.onRefreshRequested() + + assertCommandIssued() + } + + @Test + fun whenRefreshRequestedWithInvalidatedGlobalLayoutThenCloseCurrentTab() { + givenOneActiveTabSelected() + givenInvalidatedGlobalLayout() + + testee.onRefreshRequested() + + ruleRunBlockingTest { + verify(mockTabsRepository).delete(selectedTabLiveData.value!!) + } + } + + @Test + fun whenRefreshRequestedWithBrowserGlobalLayoutThenRefresh() { + testee.onRefreshRequested() + assertCommandIssued() + } + @Test fun whenUserBrowsingPressesBackAndBrowserCanGoBackThenNavigatesToPreviousPageAndHandledTrue() { setupNavigation(isBrowsing = true, canGoBack = true, stepsToPreviousPage = 2) @@ -936,7 +1003,7 @@ class BrowserTabViewModelTest { } @Test - fun whenSiteLoadedAndUserSelectsToAddBookmarkThenAddBookmarkCommandSentWithUrlAndTitle() = runBlockingTest { + fun whenSiteLoadedAndUserSelectsToAddBookmarkThenAddBookmarkCommandSentWithUrlAndTitle() = ruleRunBlockingTest { loadUrl("foo.com") testee.titleReceived("Foo Title") testee.onBookmarkAddRequested() @@ -946,7 +1013,7 @@ class BrowserTabViewModelTest { } @Test - fun whenNoSiteAndUserSelectsToAddBookmarkThenBookmarkAddedWithBlankTitleAndUrl() = runBlockingTest { + fun whenNoSiteAndUserSelectsToAddBookmarkThenBookmarkAddedWithBlankTitleAndUrl() = ruleRunBlockingTest { whenever(mockBookmarksDao.insert(any())).thenReturn(1) testee.onBookmarkAddRequested() verify(mockBookmarksDao).insert(BookmarkEntity(title = "", url = "")) @@ -965,7 +1032,7 @@ class BrowserTabViewModelTest { } @Test - fun whenOnSiteAndBrokenSiteSelectedThenBrokenSiteFeedbackCommandSentWithUrl() = runBlockingTest { + fun whenOnSiteAndBrokenSiteSelectedThenBrokenSiteFeedbackCommandSentWithUrl() = ruleRunBlockingTest { loadUrl("foo.com", isBrowserShowing = true) testee.onBrokenSiteSelected() val command = captureCommands().value as Command.BrokenSiteFeedback @@ -989,7 +1056,7 @@ class BrowserTabViewModelTest { @Test fun whenWebSessionRestoredThenGlobalLayoutSwitchedToShowingBrowser() { testee.onWebSessionRestored() - assertFalse(globalLayoutViewState().isNewTabState) + assertFalse(browserGlobalLayoutViewState().isNewTabState) } @Test @@ -1020,11 +1087,11 @@ class BrowserTabViewModelTest { fun whenWebViewSessionRestorableThenSessionRestored() { whenever(webViewSessionStorage.restoreSession(anyOrNull(), anyString())).thenReturn(true) testee.restoreWebViewState(null, "") - assertFalse(globalLayoutViewState().isNewTabState) + assertFalse(browserGlobalLayoutViewState().isNewTabState) } @Test - fun whenUrlNullThenSetBrowserNotShowing() = runBlockingTest { + fun whenUrlNullThenSetBrowserNotShowing() = ruleRunBlockingTest { withContext(Dispatchers.Main) { testee.loadData("id", null, false) testee.determineShowBrowser() @@ -1033,7 +1100,7 @@ class BrowserTabViewModelTest { } @Test - fun whenUrlBlankThenSetBrowserNotShowing() = runBlockingTest { + fun whenUrlBlankThenSetBrowserNotShowing() = ruleRunBlockingTest { withContext(Dispatchers.Main) { testee.loadData("id", " ", false) testee.determineShowBrowser() @@ -1042,7 +1109,7 @@ class BrowserTabViewModelTest { } @Test - fun whenUrlPresentThenSetBrowserShowing() = runBlockingTest { + fun whenUrlPresentThenSetBrowserShowing() = ruleRunBlockingTest { withContext(Dispatchers.Main) { testee.loadData("id", "https://example.com", false) testee.determineShowBrowser() @@ -1050,6 +1117,74 @@ class BrowserTabViewModelTest { } } + @Test + fun whenRecoveringFromProcessGoneThenShowErrorWithAction() { + testee.recoverFromRenderProcessGone() + assertCommandIssued() + } + + @Test + fun whenUserClicksOnErrorActionThenOpenCurrentUrlInNewTab() { + givenOneActiveTabSelected() + testee.recoverFromRenderProcessGone() + verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) + val showErrorWithAction = commandCaptor.value as Command.ShowErrorWithAction + + showErrorWithAction.action() + + assertCommandIssued { + assertEquals("https://example.com", query) + } + } + + @Test + fun whenUserClicksOnErrorActionThenOpenCurrentTabIsClosed() { + givenOneActiveTabSelected() + testee.recoverFromRenderProcessGone() + verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) + val showErrorWithAction = commandCaptor.value as Command.ShowErrorWithAction + + showErrorWithAction.action() + + ruleRunBlockingTest { + verify(mockTabsRepository).delete(selectedTabLiveData.value!!) + } + } + + @Test + fun whenRecoveringFromProcessGoneThenGlobalLayoutIsInvalidated() { + testee.recoverFromRenderProcessGone() + + assertTrue(globalLayoutViewState() is BrowserTabViewModel.GlobalLayoutViewState.Invalidated) + } + + @Test + fun whenRecoveringFromProcessGoneThenLoadingIsReset() { + testee.recoverFromRenderProcessGone() + + assertEquals(loadingViewState(), BrowserTabViewModel.LoadingViewState()) + } + + @Test + fun whenRecoveringFromProcessGoneThenFindInPageIsReset() { + testee.recoverFromRenderProcessGone() + + assertEquals(findInPageViewState(), BrowserTabViewModel.FindInPageViewState()) + } + + @Test + fun whenRecoveringFromProcessGoneThenExpectedBrowserOptionsAreDisabled() { + setupNavigation(skipHome = true, isBrowsing = true, canGoForward = true, canGoBack = true, stepsToPreviousPage = 1) + + testee.recoverFromRenderProcessGone() + + assertFalse(browserViewState().canGoBack) + assertFalse(browserViewState().canGoForward) + assertFalse(browserViewState().canReportSite) + assertFalse(browserViewState().canChangeBrowsingMode) + assertFalse(findInPageViewState().canFindInPage) + } + @Test fun whenAuthenticationIsRequiredThenRequiresAuthenticationCommandSent() { val mockHandler = mock() @@ -1105,7 +1240,7 @@ class BrowserTabViewModelTest { } @Test - fun whenUserSelectToEditQueryThenMoveCaretToTheEnd() = runBlockingTest { + fun whenUserSelectToEditQueryThenMoveCaretToTheEnd() = ruleRunBlockingTest { withContext(Dispatchers.Main) { testee.onUserSelectedToEditQuery("foo") assertTrue(omnibarViewState().shouldMoveCaretToEnd) @@ -1136,37 +1271,30 @@ class BrowserTabViewModelTest { @Test fun whenCloseCurrentTabSelectedThenTabDeletedFromRepository() = runBlocking { - val liveData = MutableLiveData() - liveData.value = TabEntity("TAB_ID", "", "", false, true, 0) - whenever(mockTabsRepository.liveSelectedTab).thenReturn(liveData) + givenOneActiveTabSelected() testee.closeCurrentTab() - verify(mockTabsRepository).delete(liveData.value!!) + verify(mockTabsRepository).delete(selectedTabLiveData.value!!) } @Test fun whenUserPressesBackAndSkippingHomeThenWebViewPreviewGenerated() { setupNavigation(isBrowsing = true, canGoBack = false, skipHome = true) testee.onUserPressedBack() - verifyGenerateWebViewPreviewCommandIssued() + assertCommandIssued() } @Test fun whenUserPressesBackAndNotSkippingHomeThenWebViewPreviewNotGenerated() { setupNavigation(isBrowsing = true, canGoBack = false, skipHome = false) testee.onUserPressedBack() - verifyGenerateWebViewPreviewCommandNotIssued() + verify(mockCommandObserver, never()).onChanged(commandCaptor.capture()) } - private fun verifyGenerateWebViewPreviewCommandIssued() { + private inline fun assertCommandIssued(instanceAssertions: T.() -> Unit = {}) { verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) - val generatedPreviewCommand = commandCaptor.allValues.find { it is Command.GenerateWebViewPreviewImage } - assertNotNull(generatedPreviewCommand) - } - - private fun verifyGenerateWebViewPreviewCommandNotIssued() { - verify(mockCommandObserver, atLeast(0)).onChanged(commandCaptor.capture()) - val generatedPreviewCommand = commandCaptor.allValues.find { it is Command.GenerateWebViewPreviewImage } - assertNull(generatedPreviewCommand) + val issuedCommand = commandCaptor.allValues.find { it is T } + assertNotNull(issuedCommand) + (issuedCommand as T).apply { instanceAssertions() } } private fun pixelParams(showedBookmarks: Boolean, bookmarkCapable: Boolean) = mapOf( @@ -1174,6 +1302,15 @@ class BrowserTabViewModelTest { Pixel.PixelParameter.BOOKMARK_CAPABLE to bookmarkCapable.toString() ) + private fun givenInvalidatedGlobalLayout() { + testee.globalLayoutState.value = BrowserTabViewModel.GlobalLayoutViewState.Invalidated + } + + private fun givenOneActiveTabSelected() { + selectedTabLiveData.value = TabEntity("TAB_ID", "https://example.com", "", skipHome = false, viewed = true, position = 0) + testee.loadData("TAB_ID", "https://example.com", false) + } + private fun setBrowserShowing(isBrowsing: Boolean) { testee.browserViewState.value = browserViewState().copy(browserShowing = isBrowsing) } @@ -1183,6 +1320,7 @@ class BrowserTabViewModelTest { testee.navigationStateChanged(buildWebNavigation(originalUrl = url, currentUrl = url, title = title)) } + @Suppress("SameParameterValue") private fun updateUrl(originalUrl: String?, currentUrl: String?, isBrowserShowing: Boolean) { setBrowserShowing(isBrowserShowing) testee.navigationStateChanged(buildWebNavigation(originalUrl = originalUrl, currentUrl = currentUrl)) @@ -1230,4 +1368,8 @@ class BrowserTabViewModelTest { private fun autoCompleteViewState() = testee.autoCompleteViewState.value!! private fun findInPageViewState() = testee.findInPageViewState.value!! private fun globalLayoutViewState() = testee.globalLayoutState.value!! + private fun browserGlobalLayoutViewState() = testee.globalLayoutState.value!! as BrowserTabViewModel.GlobalLayoutViewState.Browser + + private fun ruleRunBlockingTest(block: suspend TestCoroutineScope.() -> Unit) = + coroutineRule.testDispatcher.runBlockingTest(block) } \ No newline at end of file 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 a76c3ce3974a..147973db102c 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt @@ -117,6 +117,15 @@ class BrowserWebViewClientTest { verify(offlinePixelCountDataStore, times(1)).webRendererGoneKilledCount = 1 } + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) + fun whenRenderProcessGoneThenEmitEventIntoListener() { + val detail: RenderProcessGoneDetail = mock() + whenever(detail.didCrash()).thenReturn(true) + testee.onRenderProcessGone(webView, detail) + verify(listener, times(1)).recoverFromRenderProcessGone() + } + private class TestWebView(context: Context) : WebView(context) companion object { diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/EmptyNavigationStateTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/EmptyNavigationStateTest.kt new file mode 100644 index 000000000000..1b8a96a00c40 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/EmptyNavigationStateTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2019 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 + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class EmptyNavigationStateTest { + + @Test + fun whenEmptyNavigationStateFromNavigationStateThenBrowserPropertiesAreTheSame() { + val previousState = buildState("originalUrl", "currentUrl", "titlle") + val emptyNavigationState = EmptyNavigationState(previousState) + + assertEquals(emptyNavigationState.currentUrl, previousState.currentUrl) + assertEquals(emptyNavigationState.originalUrl, previousState.originalUrl) + assertEquals(emptyNavigationState.title, previousState.title) + } + + @Test + fun whenEmptyNavigationStateFromNavigationStateThenNavigationPropertiesAreCleared() { + val emptyNavigationState = EmptyNavigationState(buildState("originalUrl", "currentUrl", "titlle")) + + assertEquals(emptyNavigationState.stepsToPreviousPage, 0) + assertFalse(emptyNavigationState.canGoBack) + assertFalse(emptyNavigationState.canGoForward) + assertFalse(emptyNavigationState.hasNavigationHistory) + } + + private fun buildState(originalUrl: String?, currentUrl: String?, title: String? = null): WebNavigationState { + return TestNavigationState( + originalUrl = originalUrl, + currentUrl = currentUrl, + title = title, + stepsToPreviousPage = 1, + canGoBack = true, + canGoForward = true, + hasNavigationHistory = true + ) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/TestNavigationState.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/TestNavigationState.kt new file mode 100644 index 000000000000..7be977acba77 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/TestNavigationState.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2019 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 + +data class TestNavigationState( + override val originalUrl: String?, + override val currentUrl: String?, + override val title: String?, + override val stepsToPreviousPage: Int, + override val canGoBack: Boolean, + override val canGoForward: Boolean, + override val hasNavigationHistory: Boolean +) : WebNavigationState diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/WebNavigationStateTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/WebNavigationStateTest.kt index dbfa686f67d9..7f052c268871 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/WebNavigationStateTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/WebNavigationStateTest.kt @@ -91,13 +91,6 @@ class WebNavigationStateComparisonTest { assertEquals(UrlUpdated("http://same.com/latest"), latestState.compare(previousState)) } - @Test - fun whenPreviousContainsAnOriginalUrlAndCurrentUrlAndLatestContainsSameOriginalUrlAndNoCurrentUrlThenCompareReturnsOther() { - val previousState = buildState("http://same.com", "http://subdomain.previous.com") - val latestState = buildState("http://same.com", null) - assertEquals(Other, latestState.compare(previousState)) - } - @Test fun whenPreviousContainsAnOriginalUrlAndCurrentUrlAndLatestStateContainsNoOriginalUrlAndNoCurrentUrlThenCompareReturnsPageCleared() { val previousState = buildState("http://previous.com", "http://subdomain.previous.com") @@ -112,6 +105,20 @@ class WebNavigationStateComparisonTest { assertEquals(PageCleared, latestState.compare(previousState)) } + @Test + fun whenLatestStateIsEmptyNavigationCompareReturnsPageNavigationCleared() { + val previousState = buildState("http://previous.com", "http://subdomain.previous.com") + val latestState = EmptyNavigationState(previousState) + assertEquals(PageNavigationCleared, latestState.compare(previousState)) + } + + @Test + fun whenPreviousContainsAnOriginalUrlAndCurrentUrlAndLatestContainsSameOriginalUrlAndNoCurrentUrlThenCompareReturnsOther() { + val previousState = buildState("http://same.com", "http://subdomain.previous.com") + val latestState = buildState("http://same.com", null) + assertEquals(Other, latestState.compare(previousState)) + } + @Test fun whenPreviousContainsAnOriginalUrlAndCurrentUrlAndLatestStateContainsDifferentOriginalUrlAndNoCurrentUrlThenCompareReturnsOther() { val previousState = buildState("http://previous.com", "http://subdomain.previous.com") @@ -131,13 +138,3 @@ class WebNavigationStateComparisonTest { ) } } - -data class TestNavigationState( - override val originalUrl: String?, - override val currentUrl: String?, - override val title: String?, - override val stepsToPreviousPage: Int, - override val canGoBack: Boolean, - override val canGoForward: Boolean, - override val hasNavigationHistory: Boolean -) : WebNavigationState diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index 3b121d575370..a500144805c9 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -244,7 +244,7 @@ class BrowserActivity : DuckDuckGoActivity(), CoroutineScope { Timber.i("Processing command: $command") when (command) { is Query -> currentTab?.submitQuery(command.query) - is Refresh -> currentTab?.refresh() + is Refresh -> currentTab?.onRefreshRequested() is Command.DisplayMessage -> applicationContext?.longToast(command.messageId) is Command.LaunchPlayStore -> launchPlayStore() is Command.ShowAppEnjoymentPrompt -> showAppEnjoymentPrompt(AppEnjoymentDialogFragment.create(command.promptCount, viewModel)) 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 72becfcce787..fa6d819d5401 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -203,6 +203,11 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope { private var webView: WebView? = null + private val errorSnackbar: Snackbar by lazy { + Snackbar.make(browserLayout, R.string.crashedWebViewErrorMessage, Snackbar.LENGTH_INDEFINITE) + .setBehavior(NonDismissibleBehavior()) + } + private val findInPageTextWatcher = object : TextChangedWatcher() { override fun afterTextChanged(editable: Editable) { viewModel.userFindingInPage(findInPageInput.text.toString()) @@ -309,7 +314,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope { popupMenu.apply { onMenuItemClicked(view.forwardPopupMenuItem) { viewModel.onUserPressedForward() } onMenuItemClicked(view.backPopupMenuItem) { activity?.onBackPressed() } - onMenuItemClicked(view.refreshPopupMenuItem) { refresh() } + onMenuItemClicked(view.refreshPopupMenuItem) { viewModel.onRefreshRequested() } onMenuItemClicked(view.newTabPopupMenuItem) { viewModel.userRequestedOpeningNewTab() } onMenuItemClicked(view.bookmarksPopupMenuItem) { browserActivity?.launchBookmarks() } onMenuItemClicked(view.addBookmarksPopupMenuItem) { launch { viewModel.onBookmarkAddRequested() } } @@ -374,6 +379,8 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope { } private fun showHome() { + errorSnackbar.dismiss() + newTabLayout.show() showKeyboardImmediately() appBarLayout.setExpanded(true) webView?.onPause() @@ -383,6 +390,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope { } private fun showBrowser() { + newTabLayout.gone() webView?.show() webView?.onResume() omnibarScrolling.enableOmnibarScrolling(toolbarContainer) @@ -398,13 +406,17 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope { webView?.loadUrl(url) } + fun onRefreshRequested() { + viewModel.onRefreshRequested() + } + fun refresh() { webView?.reload() } private fun processCommand(it: Command?) { when (it) { - Command.Refresh -> refresh() + is Command.Refresh -> refresh() is Command.OpenInNewTab -> { browserActivity?.openInNewTab(it.query) } @@ -419,10 +431,10 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope { is Command.NavigateBack -> { webView?.goBackOrForward(-it.steps) } - Command.NavigateForward -> { + is Command.NavigateForward -> { webView?.goForward() } - Command.ResetHistory -> { + is Command.ResetHistory -> { resetWebView() } is Command.DialNumber -> { @@ -439,10 +451,10 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope { val intent = Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:${it.telephoneNumber}")) openExternalDialog(intent) } - Command.ShowKeyboard -> { + is Command.ShowKeyboard -> { showKeyboard() } - Command.HideKeyboard -> { + is Command.HideKeyboard -> { hideKeyboard() } is Command.BrokenSiteFeedback -> { @@ -458,7 +470,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope { } is Command.DownloadImage -> requestImageDownload(it.url) is Command.FindInPageCommand -> webView?.findAllAsync(it.searchTerm) - Command.DismissFindInPage -> webView?.findAllAsync(null) + is Command.DismissFindInPage -> webView?.findAllAsync(null) is Command.ShareLink -> launchSharePageChooser(it.url) is Command.CopyLink -> { clipboardManager.primaryClip = ClipData.newPlainText(null, it.url) @@ -481,6 +493,14 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope { is Command.SaveCredentials -> saveBasicAuthCredentials(it.request, it.credentials) is Command.GenerateWebViewPreviewImage -> generateWebViewPreviewImage() is Command.LaunchTabSwitcher -> launchTabSwitcher() + is Command.ShowErrorWithAction -> showErrorSnackbar(it) + } + } + + private fun showErrorSnackbar(command: Command.ShowErrorWithAction) { + //Snackbar is global and it should appear only the foreground fragment + if (!errorSnackbar.view.isAttachedToWindow && isVisible) { + errorSnackbar.setAction(R.string.crashedWebViewErrorAction) { command.action() }.show() } } @@ -1124,13 +1144,23 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope { } fun renderGlobalViewState(viewState: GlobalLayoutViewState) { + if (lastSeenGlobalViewState is GlobalLayoutViewState.Invalidated && + viewState is GlobalLayoutViewState.Browser) { + throw IllegalStateException("Invalid state transition") + } + renderIfChanged(viewState, lastSeenGlobalViewState) { lastSeenGlobalViewState = viewState - if (viewState.isNewTabState) { - browserLayout.hide() - } else { - browserLayout.show() + when (viewState) { + is GlobalLayoutViewState.Browser -> { + if (viewState.isNewTabState) { + browserLayout.hide() + } else { + browserLayout.show() + } + } + is GlobalLayoutViewState.Invalidated -> destroyWebView() } } } @@ -1174,6 +1204,8 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope { newTabPopupMenuItem.isEnabled = browserShowing addBookmarksPopupMenuItem?.isEnabled = viewState.canAddBookmarks sharePageMenuItem?.isEnabled = viewState.canSharePage + brokenSitePopupMenuItem?.isEnabled = viewState.canReportSite + requestDesktopSiteCheckMenuItem?.isEnabled = viewState.canChangeBrowsingMode addToHome?.let { it.visibility = if (viewState.addToHomeVisible) VISIBLE else GONE 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 be30e2b2932b..73a8bb68a34f 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -38,6 +38,8 @@ import com.duckduckgo.app.bookmarks.db.BookmarkEntity import com.duckduckgo.app.bookmarks.db.BookmarksDao import com.duckduckgo.app.bookmarks.ui.EditBookmarkDialogFragment.EditBookmarkListener import com.duckduckgo.app.browser.BrowserTabViewModel.Command.* +import com.duckduckgo.app.browser.BrowserTabViewModel.GlobalLayoutViewState.Browser +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.* @@ -104,14 +106,16 @@ class BrowserTabViewModel( private var buildingSiteFactoryJob: Job? = null - data class GlobalLayoutViewState( - val isNewTabState: Boolean = true - ) + sealed class GlobalLayoutViewState { + data class Browser(val isNewTabState: Boolean = true) : GlobalLayoutViewState() + object Invalidated : GlobalLayoutViewState() + } data class BrowserViewState( val browserShowing: Boolean = false, val isFullScreen: Boolean = false, val isDesktopBrowsingMode: Boolean = false, + val canChangeBrowsingMode: Boolean = true, val showPrivacyGrade: Boolean = false, val showClearButton: Boolean = false, val showTabsButton: Boolean = true, @@ -121,6 +125,7 @@ class BrowserTabViewModel( val canAddBookmarks: Boolean = false, val canGoBack: Boolean = false, val canGoForward: Boolean = false, + val canReportSite: Boolean = true, val addToHomeEnabled: Boolean = false, val addToHomeVisible: Boolean = false ) @@ -182,6 +187,7 @@ class BrowserTabViewModel( class SaveCredentials(val request: BasicAuthenticationRequest, val credentials: BasicAuthenticationCredentials) : Command() object GenerateWebViewPreviewImage : Command() object LaunchTabSwitcher : Command() + class ShowErrorWithAction(val action: () -> Unit) : Command() } val autoCompleteViewState: MutableLiveData = MutableLiveData() @@ -294,6 +300,9 @@ class BrowserTabViewModel( fun onViewVisible() { command.value = if (!currentBrowserViewState().browserShowing) ShowKeyboard else HideKeyboard ctaViewModel.refreshCta() + if (currentGlobalLayoutState() is Invalidated && currentBrowserViewState().browserShowing) { + showErrorWithAction() + } } fun onViewHidden() { @@ -317,13 +326,18 @@ class BrowserTabViewModel( pixel.fire(pixelName, params) } - fun onUserSubmittedQuery(input: String) { - if (input.isBlank()) { + fun onUserSubmittedQuery(query: String) { + if (query.isBlank()) { + return + } + + if (currentGlobalLayoutState() is Invalidated) { + recoverTabWithQuery(query) return } command.value = HideKeyboard - val trimmedInput = input.trim() + val trimmedInput = query.trim() viewModelScope.launch(dispatchers.io()) { searchCountDao.incrementSearchCount() @@ -339,7 +353,7 @@ class BrowserTabViewModel( command.value = Navigate(queryUrlConverter.convertQueryToUrl(trimmedInput)) } - globalLayoutState.value = GlobalLayoutViewState(isNewTabState = false) + globalLayoutState.value = Browser(isNewTabState = false) findInPageViewState.value = FindInPageViewState(visible = false, canFindInPage = true) omnibarViewState.value = currentOmnibarViewState().copy(omnibarText = trimmedInput, shouldMoveCaretToEnd = false) browserViewState.value = currentBrowserViewState().copy(browserShowing = true, showClearButton = false) @@ -367,6 +381,14 @@ class BrowserTabViewModel( } } + fun onRefreshRequested() { + if (currentGlobalLayoutState() is Invalidated) { + recoverTabWithQuery(url.orEmpty()) + } else { + command.value = Refresh + } + } + /** * Handles back navigation. Returns false if navigation could not be * handled at this level, giving system an opportunity to handle it @@ -400,7 +422,7 @@ class BrowserTabViewModel( browserViewState.value = currentBrowserViewState().copy( browserShowing = false, canGoBack = false, - canGoForward = true + canGoForward = currentGlobalLayoutState() !is Invalidated ) omnibarViewState.value = currentOmnibarViewState().copy(omnibarText = "", shouldMoveCaretToEnd = false) loadingViewState.value = currentLoadingViewState().copy(isLoading = false) @@ -437,6 +459,7 @@ class BrowserTabViewModel( is NewPage -> pageChanged(stateChange.url, stateChange.title) is PageCleared -> pageCleared() is UrlUpdated -> urlUpdated(stateChange.url) + is PageNavigationCleared -> disableUserNavigation() } } @@ -583,6 +606,7 @@ class BrowserTabViewModel( command.value = ShowFileChooser(filePathCallback, fileChooserParams) } + private fun currentGlobalLayoutState(): GlobalLayoutViewState = globalLayoutState.value!! private fun currentAutoCompleteViewState(): AutoCompleteViewState = autoCompleteViewState.value!! private fun currentBrowserViewState(): BrowserViewState = browserViewState.value!! private fun currentFindInPageViewState(): FindInPageViewState = findInPageViewState.value!! @@ -728,7 +752,7 @@ class BrowserTabViewModel( } fun onWebSessionRestored() { - globalLayoutState.value = GlobalLayoutViewState(isNewTabState = false) + globalLayoutState.value = Browser(isNewTabState = false) } fun onDesktopSiteModeToggled(desktopSiteRequested: Boolean) { @@ -747,7 +771,7 @@ class BrowserTabViewModel( } private fun initializeViewStates() { - globalLayoutState.value = GlobalLayoutViewState() + globalLayoutState.value = Browser() browserViewState.value = BrowserViewState().copy(addToHomeVisible = addToHomeCapabilityDetector.isAddToHomeSupported()) loadingViewState.value = LoadingViewState() autoCompleteViewState.value = AutoCompleteViewState() @@ -856,6 +880,14 @@ class BrowserTabViewModel( command.value = HandleExternalAppLink(appLink) } + override fun recoverFromRenderProcessGone() { + webNavigationState?.let { + navigationStateChanged(EmptyNavigationState(it)) + } + invalidateBrowsingActions() + showErrorWithAction() + } + override fun requiresAuthentication(request: BasicAuthenticationRequest) { command.value = RequiresAuthentication(request) } @@ -872,4 +904,28 @@ class BrowserTabViewModel( fun userLaunchingTabSwitcher() { command.value = LaunchTabSwitcher } + + private fun invalidateBrowsingActions() { + globalLayoutState.value = Invalidated + loadingViewState.value = LoadingViewState() + findInPageViewState.value = FindInPageViewState() + } + + private fun disableUserNavigation() { + browserViewState.value = currentBrowserViewState().copy( + canGoBack = false, + canGoForward = false, + canReportSite = false, + canChangeBrowsingMode = false + ) + } + + private fun showErrorWithAction() { + command.value = ShowErrorWithAction { this.onUserSubmittedQuery(url.orEmpty()) } + } + + private fun recoverTabWithQuery(query: String) { + viewModelScope.launch { closeCurrentTab() } + command.value = OpenInNewTab(query) + } } \ No newline at end of file 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 a86b91361c75..ebcd640df92a 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt @@ -175,7 +175,9 @@ class BrowserWebViewClient( } else { offlinePixelCountDataStore.webRendererGoneKilledCount += 1 } - return super.onRenderProcessGone(view, detail) + + webViewClientListener?.recoverFromRenderProcessGone() + return true } @UiThread diff --git a/app/src/main/java/com/duckduckgo/app/browser/WebNavigationState.kt b/app/src/main/java/com/duckduckgo/app/browser/WebNavigationState.kt index 9838b259892e..f5acd70059df 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/WebNavigationState.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/WebNavigationState.kt @@ -37,15 +37,18 @@ sealed class WebNavigationStateChange { data class UrlUpdated(val url: String) : WebNavigationStateChange() object PageCleared : WebNavigationStateChange() object Unchanged : WebNavigationStateChange() + object PageNavigationCleared : WebNavigationStateChange() object Other : WebNavigationStateChange() } fun WebNavigationState.compare(previous: WebNavigationState?): WebNavigationStateChange { - if (this == previous) { return Unchanged } + if (this is EmptyNavigationState) + return PageNavigationCleared + if (originalUrl == null && previous?.originalUrl != null) { return PageCleared } @@ -122,6 +125,26 @@ data class WebViewNavigationState(private val stack: WebBackForwardList) : WebNa } } +@Suppress("DataClassPrivateConstructor") +data class EmptyNavigationState private constructor(override val originalUrl: String?, + override val currentUrl: String?, + override val title: String?) : WebNavigationState { + companion object { + operator fun invoke(webNavigationState: WebNavigationState): EmptyNavigationState { + return EmptyNavigationState( + webNavigationState.originalUrl, + webNavigationState.currentUrl, + webNavigationState.title + ) + } + } + + override val stepsToPreviousPage: Int = 0 + override val canGoBack: Boolean = false + override val canGoForward: Boolean = false + override val hasNavigationHistory: Boolean = false +} + private val WebBackForwardList.originalUrl: String? get() = currentItem?.originalUrl 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 71bf15a3f9a6..61fc538bfa23 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt @@ -40,6 +40,7 @@ interface WebViewClientListener { fun exitFullScreen() fun showFileChooser(filePathCallback: ValueCallback>, fileChooserParams: WebChromeClient.FileChooserParams) fun externalAppLinkClicked(appLink: SpecialUrlDetector.UrlType.IntentType) + fun recoverFromRenderProcessGone() fun requiresAuthentication(request: BasicAuthenticationRequest) } \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/global/view/NonDismissibleBehavior.kt b/app/src/main/java/com/duckduckgo/app/global/view/NonDismissibleBehavior.kt new file mode 100644 index 000000000000..069909f9e3a0 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/global/view/NonDismissibleBehavior.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2019 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.view.View +import com.google.android.material.snackbar.BaseTransientBottomBar + +class NonDismissibleBehavior : BaseTransientBottomBar.Behavior() { + override fun canSwipeDismissView(child: View) = false +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/survey/ui/SurveyActivity.kt b/app/src/main/java/com/duckduckgo/app/survey/ui/SurveyActivity.kt index cec9087d6b7e..c608d9086566 100644 --- a/app/src/main/java/com/duckduckgo/app/survey/ui/SurveyActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/survey/ui/SurveyActivity.kt @@ -101,8 +101,8 @@ class SurveyActivity : DuckDuckGoActivity() { private fun showError() { progress.gone() - webView.gone() errorView.show() + destroyWebView() } override fun onSaveInstanceState(outState: Bundle) { @@ -128,6 +128,13 @@ class SurveyActivity : DuckDuckGoActivity() { viewModel.onSurveyDismissed() } + private fun destroyWebView() { + webView.gone() + surveyActivityContainerViewGroup.removeView(webView) + webView.destroy() + webView.webViewClient = null + } + companion object { fun intent(context: Context, survey: Survey): Intent { @@ -165,5 +172,10 @@ class SurveyActivity : DuckDuckGoActivity() { viewModel.onSurveyFailedToLoad() } } + + override fun onRenderProcessGone(view: WebView?, detail: RenderProcessGoneDetail?): Boolean { + viewModel.onSurveyFailedToLoad() + return true + } } } \ No newline at end of file diff --git a/app/src/main/res/layout/activity_user_survey.xml b/app/src/main/res/layout/activity_user_survey.xml index 56333bf7cd3e..480980388bdc 100644 --- a/app/src/main/res/layout/activity_user_survey.xml +++ b/app/src/main/res/layout/activity_user_survey.xml @@ -24,6 +24,7 @@ tools:context="com.duckduckgo.app.feedback.ui.common.FeedbackActivity"> diff --git a/app/src/main/res/values/string-untranslated.xml b/app/src/main/res/values/string-untranslated.xml new file mode 100644 index 000000000000..97b4f4238726 --- /dev/null +++ b/app/src/main/res/values/string-untranslated.xml @@ -0,0 +1,20 @@ + + + + "The webpage could not be displayed." + "Reload" + \ No newline at end of file