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 69f29f556cc6..291e9c21ff0d 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -16,6 +16,7 @@ package com.duckduckgo.app.browser +import android.content.Intent import android.graphics.Bitmap import android.net.Uri import android.view.MenuItem @@ -47,6 +48,7 @@ import com.duckduckgo.app.browser.BrowserTabViewModel.FireButton 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.applinks.AppLinksHandler import com.duckduckgo.app.browser.downloader.FileDownloader import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.browser.favicon.FaviconSource @@ -112,15 +114,11 @@ 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.* import com.nhaarman.mockitokotlin2.any -import com.nhaarman.mockitokotlin2.anyOrNull import com.nhaarman.mockitokotlin2.atLeastOnce -import com.nhaarman.mockitokotlin2.doAnswer import com.nhaarman.mockitokotlin2.doReturn import com.nhaarman.mockitokotlin2.eq -import com.nhaarman.mockitokotlin2.firstValue -import com.nhaarman.mockitokotlin2.lastValue -import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.whenever import dagger.Lazy import io.reactivex.Observable @@ -143,11 +141,13 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.ArgumentCaptor -import org.mockito.ArgumentMatchers.anyString import org.mockito.Captor import org.mockito.Mock import org.mockito.Mockito import org.mockito.Mockito.* +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify import org.mockito.MockitoAnnotations import org.mockito.internal.util.DefaultMockingDetails import java.io.File @@ -264,6 +264,12 @@ class BrowserTabViewModelTest { @Mock private lateinit var mockFavoritesRepository: FavoritesRepository + @Mock + private lateinit var mockSpecialUrlDetector: SpecialUrlDetector + + @Mock + private lateinit var mockAppLinksHandler: AppLinksHandler + private val lazyFaviconManager = Lazy { mockFaviconManager } private lateinit var mockAutoCompleteApi: AutoCompleteApi @@ -273,6 +279,9 @@ class BrowserTabViewModelTest { @Captor private lateinit var commandCaptor: ArgumentCaptor + @Captor + private lateinit var appLinkCaptor: ArgumentCaptor<() -> Unit> + private lateinit var db: AppDatabase private lateinit var testee: BrowserTabViewModel @@ -352,7 +361,7 @@ class BrowserTabViewModelTest { bookmarksDao = mockBookmarksDao, longPressHandler = mockLongPressHandler, webViewSessionStorage = webViewSessionStorage, - specialUrlDetector = SpecialUrlDetectorImpl(), + specialUrlDetector = mockSpecialUrlDetector, faviconManager = mockFaviconManager, addToHomeCapabilityDetector = mockAddToHomeCapabilityDetector, ctaViewModel = ctaViewModel, @@ -375,7 +384,8 @@ class BrowserTabViewModelTest { fireproofDialogsEventHandler = fireproofDialogsEventHandler, emailManager = mockEmailManager, favoritesRepository = mockFavoritesRepository, - appCoroutineScope = TestCoroutineScope() + appCoroutineScope = TestCoroutineScope(), + appLinksHandler = mockAppLinksHandler ) testee.loadData("abc", null, false) @@ -1125,6 +1135,14 @@ class BrowserTabViewModelTest { assertTrue(commandCaptor.allValues.any { it == Command.HideKeyboard }) } + @Test + fun whenEnteringAppLinkQueryThenNavigateInBrowser() { + whenever(mockOmnibarConverter.convertQueryToUrl("foo", null)).thenReturn("foo.com") + testee.onUserSubmittedQuery("foo") + verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) + assertTrue(commandCaptor.allValues.any { it == Command.HideKeyboard }) + } + @Test fun whenNotifiedEnteringFullScreenThenViewStateUpdatedWithFullScreenFlag() { val stubView = View(getInstrumentation().targetContext) @@ -3011,24 +3029,24 @@ class BrowserTabViewModelTest { @Test fun whenExternalAppLinkClickedIfGpcIsEnabledThenAddHeaderToUrl() { whenever(mockSettingsStore.globalPrivacyControlEnabled).thenReturn(true) - val intentType = SpecialUrlDetector.UrlType.IntentType("query", mock(), null) + val intentType = SpecialUrlDetector.UrlType.NonHttpAppLink("query", mock(), null) - testee.externalAppLinkClicked(intentType) + testee.nonHttpAppLinkClicked(intentType) verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) - val command = commandCaptor.lastValue as Command.HandleExternalAppLink + val command = commandCaptor.lastValue as Command.HandleNonHttpAppLink assertEquals(GPC_HEADER_VALUE, command.headers[GPC_HEADER]) } @Test fun whenExternalAppLinkClickedIfGpcIsDisabledThenDoNotAddHeaderToUrl() { whenever(mockSettingsStore.globalPrivacyControlEnabled).thenReturn(false) - val intentType = SpecialUrlDetector.UrlType.IntentType("query", mock(), null) + val intentType = SpecialUrlDetector.UrlType.NonHttpAppLink("query", mock(), null) - testee.externalAppLinkClicked(intentType) + testee.nonHttpAppLinkClicked(intentType) verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) - val command = commandCaptor.lastValue as Command.HandleExternalAppLink + val command = commandCaptor.lastValue as Command.HandleNonHttpAppLink assertTrue(command.headers.isEmpty()) } @@ -3197,6 +3215,39 @@ class BrowserTabViewModelTest { assertCommandNotIssued() } + @Test + fun whenHandleAppLinkCalledThenHandleAppLink() { + val urlType = SpecialUrlDetector.UrlType.AppLink(uriString = "http://example.com") + testee.handleAppLink(urlType, isRedirect = false, isForMainFrame = true) + verify(mockAppLinksHandler).handleAppLink(isRedirect = eq(false), isForMainFrame = eq(true), capture(appLinkCaptor)) + appLinkCaptor.value.invoke() + assertCommandIssued() + } + + @Test + fun whenHandleNonHttpAppLinkCalledThenHandleNonHttpAppLink() { + val urlType = SpecialUrlDetector.UrlType.NonHttpAppLink("market://details?id=com.example", Intent(), "http://example.com") + testee.handleNonHttpAppLink(urlType, false) + verify(mockAppLinksHandler).handleNonHttpAppLink(isRedirect = eq(false), capture(appLinkCaptor)) + appLinkCaptor.value.invoke() + assertCommandIssued() + } + + @Test + fun whenResetAppLinkStateCalledThenResetAppLinkState() { + testee.resetAppLinkState() + verify(mockAppLinksHandler).reset() + } + + @Test + fun whenUserSubmittedQueryIsAppLinkThenOpenAppLinkInBrowser() { + whenever(mockOmnibarConverter.convertQueryToUrl("foo", null)).thenReturn("foo.com") + whenever(mockSpecialUrlDetector.determineType(anyString())).thenReturn(SpecialUrlDetector.UrlType.AppLink(uriString = "http://foo.com")) + testee.onUserSubmittedQuery("foo") + verify(mockAppLinksHandler).enterBrowserState() + assertCommandIssued() + } + private suspend fun givenFireButtonPulsing() { whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.DAX_ONBOARDING) dismissedCtaDaoChannel.send(listOf(DismissedCta(CtaId.DAX_DIALOG_TRACKERS_FOUND))) diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt index 2900cbbe36b1..7f25c7a9b23f 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt @@ -17,6 +17,7 @@ package com.duckduckgo.app.browser import android.content.Context +import android.content.Intent import android.net.Uri import android.os.Build import android.webkit.* @@ -38,12 +39,13 @@ import com.duckduckgo.app.globalprivacycontrol.GlobalPrivacyControl import com.duckduckgo.app.runBlocking import com.duckduckgo.app.statistics.store.OfflinePixelCountDataStore import com.nhaarman.mockitokotlin2.* +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestCoroutineScope import org.junit.Before import org.junit.Rule import org.junit.Test -import java.lang.RuntimeException @ExperimentalCoroutinesApi class BrowserWebViewClientTest { @@ -68,6 +70,7 @@ class BrowserWebViewClientTest { private val webViewHttpAuthStore: WebViewHttpAuthStore = mock() private val thirdPartyCookieManager: ThirdPartyCookieManager = mock() private val emailInjector: EmailInjector = mock() + private val webResourceRequest: WebResourceRequest = mock() @UiThreadTest @Before @@ -91,6 +94,7 @@ class BrowserWebViewClientTest { emailInjector ) testee.webViewClientListener = listener + whenever(webResourceRequest.url).thenReturn(Uri.EMPTY) } @UiThreadTest @@ -261,6 +265,115 @@ class BrowserWebViewClientTest { verify(uncaughtExceptionRepository).recordUncaughtException(exception, UncaughtExceptionSource.SHOULD_OVERRIDE_REQUEST) } + @Test + fun whenAppLinkDetectedAndIsHandledThenReturnTrue() = coroutinesTestRule.runBlocking { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val urlType = SpecialUrlDetector.UrlType.AppLink(uriString = EXAMPLE_URL) + whenever(specialUrlDetector.determineType(any())).thenReturn(urlType) + whenever(webResourceRequest.isRedirect).thenReturn(false) + whenever(webResourceRequest.isForMainFrame).thenReturn(true) + whenever(listener.handleAppLink(any(), any(), any())).thenReturn(true) + assertTrue(testee.shouldOverrideUrlLoading(webView, webResourceRequest)) + verify(listener).handleAppLink(urlType, isRedirect = false, isForMainFrame = true) + } + } + + @Test + fun whenAppLinkDetectedAndIsNotHandledThenReturnFalse() = coroutinesTestRule.runBlocking { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val urlType = SpecialUrlDetector.UrlType.AppLink(uriString = EXAMPLE_URL) + whenever(specialUrlDetector.determineType(any())).thenReturn(urlType) + whenever(webResourceRequest.isRedirect).thenReturn(false) + whenever(webResourceRequest.isForMainFrame).thenReturn(true) + whenever(listener.handleAppLink(any(), any(), any())).thenReturn(false) + assertFalse(testee.shouldOverrideUrlLoading(webView, webResourceRequest)) + verify(listener).handleAppLink(urlType, isRedirect = false, isForMainFrame = true) + } + } + + @Test + fun whenAppLinkDetectedAndListenerIsNullThenReturnFalse() = coroutinesTestRule.runBlocking { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + whenever(specialUrlDetector.determineType(any())).thenReturn(SpecialUrlDetector.UrlType.AppLink(uriString = EXAMPLE_URL)) + testee.webViewClientListener = null + assertFalse(testee.shouldOverrideUrlLoading(webView, webResourceRequest)) + verify(listener, never()).handleAppLink(any(), any(), any()) + } + } + + @Test + fun whenNonHttpAppLinkDetectedAndIsHandledThenReturnTrue() = coroutinesTestRule.runBlocking { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val urlType = SpecialUrlDetector.UrlType.NonHttpAppLink(EXAMPLE_URL, Intent(), EXAMPLE_URL) + whenever(specialUrlDetector.determineType(any())).thenReturn(urlType) + whenever(webResourceRequest.isRedirect).thenReturn(false) + whenever(listener.handleNonHttpAppLink(any(), any())).thenReturn(true) + assertTrue(testee.shouldOverrideUrlLoading(webView, webResourceRequest)) + verify(listener).handleNonHttpAppLink(urlType, isRedirect = false) + } + } + + @Test + fun whenNonHttpAppLinkDetectedAndIsHandledOnApiLessThan24ThenReturnTrue() = coroutinesTestRule.runBlocking { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + val urlType = SpecialUrlDetector.UrlType.NonHttpAppLink(EXAMPLE_URL, Intent(), EXAMPLE_URL) + whenever(specialUrlDetector.determineType(any())).thenReturn(urlType) + whenever(listener.handleNonHttpAppLink(any(), any())).thenReturn(true) + assertTrue(testee.shouldOverrideUrlLoading(webView, EXAMPLE_URL)) + verify(listener).handleNonHttpAppLink(urlType, isRedirect = false) + } + } + + @Test + fun whenNonHttpAppLinkDetectedAndIsNotHandledThenReturnFalse() = coroutinesTestRule.runBlocking { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val urlType = SpecialUrlDetector.UrlType.NonHttpAppLink(EXAMPLE_URL, Intent(), EXAMPLE_URL) + whenever(specialUrlDetector.determineType(any())).thenReturn(urlType) + whenever(webResourceRequest.isRedirect).thenReturn(false) + whenever(listener.handleNonHttpAppLink(any(), any())).thenReturn(false) + assertFalse(testee.shouldOverrideUrlLoading(webView, webResourceRequest)) + verify(listener).handleNonHttpAppLink(urlType, isRedirect = false) + } + } + + @Test + fun whenNonHttpAppLinkDetectedAndIsNotHandledOnApiLessThan24ThenReturnFalse() = coroutinesTestRule.runBlocking { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + val urlType = SpecialUrlDetector.UrlType.NonHttpAppLink(EXAMPLE_URL, Intent(), EXAMPLE_URL) + whenever(specialUrlDetector.determineType(any())).thenReturn(urlType) + whenever(listener.handleNonHttpAppLink(any(), any())).thenReturn(false) + assertFalse(testee.shouldOverrideUrlLoading(webView, EXAMPLE_URL)) + verify(listener).handleNonHttpAppLink(urlType, isRedirect = false) + } + } + + @Test + fun whenNonHttpAppLinkDetectedAndListenerIsNullThenReturnTrue() = coroutinesTestRule.runBlocking { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + whenever(specialUrlDetector.determineType(any())).thenReturn(SpecialUrlDetector.UrlType.NonHttpAppLink(EXAMPLE_URL, Intent(), EXAMPLE_URL)) + testee.webViewClientListener = null + assertTrue(testee.shouldOverrideUrlLoading(webView, webResourceRequest)) + verify(listener, never()).handleNonHttpAppLink(any(), any()) + } + } + + @Test + fun whenNonHttpAppLinkDetectedAndListenerIsNullOnApiLessThan24ThenReturnTrue() = coroutinesTestRule.runBlocking { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + whenever(specialUrlDetector.determineType(any())).thenReturn(SpecialUrlDetector.UrlType.NonHttpAppLink(EXAMPLE_URL, Intent(), EXAMPLE_URL)) + testee.webViewClientListener = null + assertTrue(testee.shouldOverrideUrlLoading(webView, EXAMPLE_URL)) + verify(listener, never()).handleNonHttpAppLink(any(), any()) + } + } + + @UiThreadTest + @Test + fun whenOnPageStartedCalledThenResetAppLinkState() { + testee.onPageStarted(webView, EXAMPLE_URL, null) + verify(listener).resetAppLinkState() + } + private class TestWebView(context: Context) : WebView(context) companion object { diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/SpecialUrlDetectorImplTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/SpecialUrlDetectorImplTest.kt index 82e60b6c29f5..8cf486939549 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/SpecialUrlDetectorImplTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/SpecialUrlDetectorImplTest.kt @@ -16,21 +16,44 @@ package com.duckduckgo.app.browser +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ActivityInfo +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.os.Build import com.duckduckgo.app.browser.SpecialUrlDetector.UrlType.* import com.duckduckgo.app.browser.SpecialUrlDetectorImpl.Companion.EMAIL_MAX_LENGTH import com.duckduckgo.app.browser.SpecialUrlDetectorImpl.Companion.PHONE_MAX_LENGTH import com.duckduckgo.app.browser.SpecialUrlDetectorImpl.Companion.SMS_MAX_LENGTH +import com.duckduckgo.app.settings.db.SettingsDataStore +import com.nhaarman.mockitokotlin2.* +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import java.net.URISyntaxException class SpecialUrlDetectorImplTest { lateinit var testee: SpecialUrlDetector + @Mock + lateinit var mockPackageManager: PackageManager + + @Mock + lateinit var mockSettingsDataStore: SettingsDataStore + @Before fun setup() { - testee = SpecialUrlDetectorImpl() + MockitoAnnotations.openMocks(this) + testee = SpecialUrlDetectorImpl(mockPackageManager, mockSettingsDataStore) + whenever(mockPackageManager.queryIntentActivities(any(), anyInt())).thenReturn(emptyList()) + whenever(mockSettingsDataStore.appLinksEnabled).thenReturn(true) } @Test @@ -59,6 +82,74 @@ class SpecialUrlDetectorImplTest { assertEquals("https://example.com", type.webAddress) } + @Test + fun whenAppLinksEnabledAndNoNonBrowserActivitiesFoundThenReturnWebType() { + whenever(mockSettingsDataStore.appLinksEnabled).thenReturn(true) + whenever(mockPackageManager.queryIntentActivities(any(), anyInt())).thenReturn(listOf(buildBrowserResolveInfo())) + val type = testee.determineType("https://example.com") + assertTrue(type is Web) + } + + @Test + fun whenAppLinksDisabledThenReturnWebType() { + whenever(mockSettingsDataStore.appLinksEnabled).thenReturn(false) + whenever(mockPackageManager.queryIntentActivities(any(), anyInt())).thenReturn(listOf(buildAppResolveInfo())) + val type = testee.determineType("https://example.com") + verifyZeroInteractions(mockPackageManager) + assertTrue(type is Web) + } + + @Test + fun whenAppLinkThrowsURISyntaxExceptionThenReturnWebType() { + whenever(mockSettingsDataStore.appLinksEnabled).thenReturn(true) + given(mockPackageManager.queryIntentActivities(any(), anyInt())).willAnswer { throw URISyntaxException("", "") } + val type = testee.determineType("https://example.com") + assertTrue(type is Web) + } + + @Test + fun whenAppLinksEnabledAndOneNonBrowserActivityFoundThenReturnAppLinkWithIntent() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + whenever(mockSettingsDataStore.appLinksEnabled).thenReturn(true) + whenever(mockPackageManager.queryIntentActivities(any(), anyInt())).thenReturn(listOf(buildAppResolveInfo(), buildBrowserResolveInfo(), ResolveInfo())) + val type = testee.determineType("https://example.com") + verify(mockPackageManager).queryIntentActivities(argThat { hasCategory(Intent.CATEGORY_BROWSABLE) }, eq(PackageManager.GET_RESOLVED_FILTER)) + assertTrue(type is AppLink) + val appLinkType = type as AppLink + assertEquals("https://example.com", appLinkType.uriString) + assertEquals(EXAMPLE_APP_PACKAGE, appLinkType.appIntent!!.component!!.packageName) + assertEquals(EXAMPLE_APP_ACTIVITY_NAME, appLinkType.appIntent!!.component!!.className) + assertNull(appLinkType.excludedComponents) + } + } + + @Test + fun whenAppLinksEnabledAndMultipleNonBrowserActivitiesFoundThenReturnAppLinkWithExcludedComponents() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + whenever(mockSettingsDataStore.appLinksEnabled).thenReturn(true) + whenever(mockPackageManager.queryIntentActivities(any(), anyInt())).thenReturn(listOf(buildAppResolveInfo(), buildAppResolveInfo(), buildBrowserResolveInfo(), ResolveInfo())) + val type = testee.determineType("https://example.com") + verify(mockPackageManager).queryIntentActivities(argThat { hasCategory(Intent.CATEGORY_BROWSABLE) }, eq(PackageManager.GET_RESOLVED_FILTER)) + assertTrue(type is AppLink) + val appLinkType = type as AppLink + assertEquals("https://example.com", appLinkType.uriString) + assertEquals(1, appLinkType.excludedComponents!!.size) + assertEquals(EXAMPLE_BROWSER_PACKAGE, appLinkType.excludedComponents!![0].packageName) + assertEquals(EXAMPLE_BROWSER_ACTIVITY_NAME, appLinkType.excludedComponents!![0].className) + assertNull(appLinkType.appIntent) + } + } + + @Test + fun whenAppLinkCheckedOnApiLessThan24ThenReturnWebType() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + whenever(mockSettingsDataStore.appLinksEnabled).thenReturn(true) + val type = testee.determineType("https://example.com") + verifyZeroInteractions(mockPackageManager) + assertTrue(type is Web) + } + } + @Test fun whenUrlIsTelWithDashesThenTelephoneTypeDetected() { val expected = Telephone::class @@ -132,9 +223,9 @@ class SpecialUrlDetectorImplTest { } @Test - fun whenUrlIsCustomUriSchemeThenIntentTypeDetected() { - val type = testee.determineType("myapp:foo bar") as IntentType - assertEquals("myapp:foo bar", type.url) + fun whenUrlIsCustomUriSchemeThenNonHttpAppLinkTypeDetected() { + val type = testee.determineType("myapp:foo bar") as NonHttpAppLink + assertEquals("myapp:foo bar", type.uriString) } @Test @@ -195,4 +286,31 @@ class SpecialUrlDetectorImplTest { val charList: List = ('a'..'z') + ('0'..'9') return List(length) { charList.random() }.joinToString("") } + + private fun buildAppResolveInfo(): ResolveInfo { + val activity = ResolveInfo() + activity.filter = IntentFilter() + activity.filter.addDataAuthority("host.com", "123") + activity.filter.addDataPath("/path", 0) + activity.activityInfo = ActivityInfo() + activity.activityInfo.packageName = EXAMPLE_APP_PACKAGE + activity.activityInfo.name = EXAMPLE_APP_ACTIVITY_NAME + return activity + } + + private fun buildBrowserResolveInfo(): ResolveInfo { + val activity = ResolveInfo() + activity.filter = IntentFilter() + activity.activityInfo = ActivityInfo() + activity.activityInfo.packageName = EXAMPLE_BROWSER_PACKAGE + activity.activityInfo.name = EXAMPLE_BROWSER_ACTIVITY_NAME + return activity + } + + companion object { + const val EXAMPLE_APP_PACKAGE = "com.test.apppackage" + const val EXAMPLE_APP_ACTIVITY_NAME = "com.test.AppActivity" + const val EXAMPLE_BROWSER_PACKAGE = "com.test.browserpackage" + const val EXAMPLE_BROWSER_ACTIVITY_NAME = "com.test.BrowserActivity" + } } diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/applinks/DuckDuckGoAppLinksHandlerTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/applinks/DuckDuckGoAppLinksHandlerTest.kt new file mode 100644 index 000000000000..a480cbeacbb1 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/applinks/DuckDuckGoAppLinksHandlerTest.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2021 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.applinks + +import android.os.Build +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyZeroInteractions +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import org.junit.Before +import org.junit.Test + +class DuckDuckGoAppLinksHandlerTest { + + private lateinit var testee: DuckDuckGoAppLinksHandler + + private var mockCallback: () -> Unit = mock() + + @Before + fun setup() { + testee = DuckDuckGoAppLinksHandler() + } + + @Test + fun whenAppLinkHandledAndIsRedirectAndAppLinkNotOpenedInBrowserThenReturnTrueAndLaunchAppLink() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + testee.appLinkOpenedInBrowser = false + assertTrue(testee.handleAppLink(isRedirect = true, isForMainFrame = true, launchAppLink = mockCallback)) + verify(mockCallback).invoke() + } + } + + @Test + fun whenAppLinkHandledAndIsNotRedirectAndAppLinkOpenedInBrowserThenReturnTrueAndLaunchAppLink() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + testee.appLinkOpenedInBrowser = true + assertTrue(testee.handleAppLink(isRedirect = false, isForMainFrame = true, launchAppLink = mockCallback)) + verify(mockCallback).invoke() + } + } + + @Test + fun whenAppLinkHandledAndIsRedirectAndAppLinkOpenedInBrowserThenReturnFalse() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + testee.appLinkOpenedInBrowser = true + assertFalse(testee.handleAppLink(isRedirect = true, isForMainFrame = true, launchAppLink = mockCallback)) + verifyZeroInteractions(mockCallback) + } + } + + @Test + fun whenAppLinkHandledAndIsForMainFrameThenReturnTrueAndLaunchAppLink() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + testee.appLinkOpenedInBrowser = false + assertTrue(testee.handleAppLink(isRedirect = false, isForMainFrame = true, launchAppLink = mockCallback)) + verify(mockCallback).invoke() + } + } + + @Test + fun whenAppLinkHandledAndIsNotForMainFrameThenReturnFalse() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + testee.appLinkOpenedInBrowser = false + assertFalse(testee.handleAppLink(isRedirect = false, isForMainFrame = false, launchAppLink = mockCallback)) + verifyZeroInteractions(mockCallback) + } + } + + @Test + fun whenAppLinkHandledOnApiLessThan24ThenReturnFalse() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + testee.appLinkOpenedInBrowser = true + assertFalse(testee.handleAppLink(isRedirect = true, isForMainFrame = false, launchAppLink = mockCallback)) + verifyZeroInteractions(mockCallback) + } + } + + @Test + fun whenNonHttpAppLinkHandledAndIsRedirectAndAppLinkOpenedInBrowserThenReturnTrue() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + testee.appLinkOpenedInBrowser = true + assertTrue(testee.handleNonHttpAppLink(true, mockCallback)) + verifyZeroInteractions(mockCallback) + } + } + + @Test + fun whenNonHttpAppLinkHandledAndIsRedirectAndAppLinkOpenedInBrowserThenReturnTrueAndLaunchNonHttpAppLink() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + testee.appLinkOpenedInBrowser = false + assertTrue(testee.handleNonHttpAppLink(true, mockCallback)) + verify(mockCallback).invoke() + } + } + + @Test + fun whenNonHttpAppLinkHandledAndIsNotRedirectAndAppLinkOpenedInBrowserThenReturnTrueAndLaunchNonHttpAppLink() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + testee.appLinkOpenedInBrowser = true + assertTrue(testee.handleNonHttpAppLink(false, mockCallback)) + verify(mockCallback).invoke() + } + } + + @Test + fun whenNonHttpAppLinkHandledAndIsNotRedirectAndAppLinkNotOpenedInBrowserThenReturnTrueAndLaunchNonHttpAppLink() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + testee.appLinkOpenedInBrowser = false + assertTrue(testee.handleNonHttpAppLink(false, mockCallback)) + verify(mockCallback).invoke() + } + } + + @Test + fun whenNonHttpAppLinkHandledOnApiLessThan24ThenReturnTrueAndLaunchNonHttpAppLink() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + testee.appLinkOpenedInBrowser = true + assertTrue(testee.handleNonHttpAppLink(true, mockCallback)) + verify(mockCallback).invoke() + } + } + + @Test + fun whenEnterBrowserStateCalledThenSetAppLinkOpenedInBrowserToTrue() { + assertFalse(testee.appLinkOpenedInBrowser) + testee.enterBrowserState() + assertTrue(testee.appLinkOpenedInBrowser) + } + + @Test + fun whenResetCalledThenSetAppLinkOpenedInBrowserToFalse() { + testee.appLinkOpenedInBrowser = true + testee.reset() + assertFalse(testee.appLinkOpenedInBrowser) + } +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt index ef25c8a8ee7f..0f349c7c06e0 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt @@ -191,6 +191,18 @@ class SettingsViewModelTest { verify(mockAppSettingsDataStore).autoCompleteSuggestionsEnabled = false } + @Test + fun whenAppLinksSwitchedOnThenDataStoreIsUpdated() { + testee.onAppLinksSettingChanged(true) + verify(mockAppSettingsDataStore).appLinksEnabled = true + } + + @Test + fun whenAppLinksSwitchedOffThenDataStoreIsUpdated() { + testee.onAppLinksSettingChanged(false) + verify(mockAppSettingsDataStore).appLinksEnabled = false + } + @Test fun whenLeaveFeedBackRequestedThenCommandIsLaunchFeedback() = coroutineTestRule.runBlocking { testee.commands().test { 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 1b28cf825d9e..82ababed5579 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -26,10 +26,7 @@ import android.content.pm.PackageManager import android.content.res.Configuration import android.media.MediaScannerConnection import android.net.Uri -import android.os.Bundle -import android.os.Environment -import android.os.Handler -import android.os.Message +import android.os.* import android.provider.Settings import android.text.Editable import android.view.* @@ -46,6 +43,7 @@ import android.widget.EditText import android.widget.TextView import android.widget.Toast import androidx.annotation.AnyThread +import androidx.annotation.RequiresApi import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.core.app.ActivityCompat @@ -629,8 +627,11 @@ class BrowserTabFragment : addHomeShortcut(it, context) } } - is Command.HandleExternalAppLink -> { - openExternalDialog(it.appLink.intent, it.appLink.fallbackUrl, false, it.headers) + is Command.HandleAppLink -> { + openAppLinkDialog(it.appLink.appIntent, it.appLink.excludedComponents, it.appLink.uriString, it.headers) + } + is Command.HandleNonHttpAppLink -> { + openExternalDialog(it.nonHttpAppLink.intent, it.nonHttpAppLink.fallbackUrl, false, it.headers) } is Command.LaunchSurvey -> launchSurvey(it.survey) is Command.LaunchAddWidget -> launchAddWidget() @@ -801,6 +802,29 @@ class BrowserTabFragment : decorator.incrementTabs() } + private fun openAppLinkDialog( + appIntent: Intent?, + excludedComponents: List?, + url: String, + headers: Map = emptyMap() + ) { + if (appIntent != null) { + launchAppLinkDialog(requireContext(), url, headers) { startActivity(appIntent) } + } else if (excludedComponents != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val title = getString(R.string.openExternalApp) + val chooserIntent = getChooserIntent(url, title, excludedComponents) + launchAppLinkDialog(requireContext(), url, headers) { startActivity(chooserIntent) } + } + } + + @RequiresApi(Build.VERSION_CODES.N) + private fun getChooserIntent(url: String?, title: String, excludedComponents: List): Intent { + val urlIntent = Intent.parseUri(url, URI_NO_FLAG) + val chooserIntent = Intent.createChooser(urlIntent, title) + chooserIntent.putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, excludedComponents.toTypedArray()) + return chooserIntent + } + private fun openExternalDialog( intent: Intent, fallbackUrl: String? = null, @@ -893,6 +917,25 @@ class BrowserTabFragment : } } + private fun launchAppLinkDialog(context: Context, url: String, headers: Map, launchApp: () -> Unit) { + val isShowing = alertDialog?.isShowing + + if (isShowing != true) { + alertDialog = AlertDialog.Builder(context) + .setTitle(R.string.appLinkDialogTitle) + .setMessage(getString(R.string.confirmOpenExternalApp)) + .setPositiveButton(R.string.yes) { _, _ -> + launchApp() + viewModel.resetAppLinkState() + } + .setNegativeButton(R.string.no) { _, _ -> + viewModel.openAppLinksInBrowser() + navigate(url, headers) + } + .show() + } + } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode == REQUEST_CODE_CHOOSE_FILE) { handleFileUploadResult(resultCode, data) @@ -1586,6 +1629,8 @@ class BrowserTabFragment : private const val QUICK_ACCESS_GRID_MAX_COLUMNS = 6 + private const val URI_NO_FLAG = 0 + fun newInstance(tabId: String, query: String? = null, skipHome: Boolean): BrowserTabFragment { val fragment = BrowserTabFragment() val args = Bundle() 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 ef23dfeb6752..1f3ac9476514 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -49,9 +49,12 @@ 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.SpecialUrlDetector.UrlType.AppLink +import com.duckduckgo.app.browser.SpecialUrlDetector.UrlType.NonHttpAppLink import com.duckduckgo.app.browser.WebNavigationStateChange.* import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector +import com.duckduckgo.app.browser.applinks.AppLinksHandler +import com.duckduckgo.app.browser.applinks.DuckDuckGoAppLinksHandler import com.duckduckgo.app.browser.downloader.DownloadFailReason import com.duckduckgo.app.browser.downloader.FileDownloader import com.duckduckgo.app.browser.favicon.FaviconManager @@ -155,7 +158,8 @@ class BrowserTabViewModel( private val globalPrivacyControl: GlobalPrivacyControl, private val fireproofDialogsEventHandler: FireproofDialogsEventHandler, private val emailManager: EmailManager, - @AppCoroutineScope private val appCoroutineScope: CoroutineScope + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val appLinksHandler: AppLinksHandler ) : WebViewClientListener, EditSavedSiteListener, HttpAuthenticationListener, SiteLocationPermissionDialog.SiteLocationPermissionDialogListener, SystemLocationPermissionDialog.SystemLocationPermissionDialogListener, ViewModel() { @@ -278,7 +282,8 @@ class BrowserTabViewModel( class BrokenSiteFeedback(val data: BrokenSiteData) : Command() object DismissFindInPage : Command() class ShowFileChooser(val filePathCallback: ValueCallback>, val fileChooserParams: WebChromeClient.FileChooserParams) : Command() - class HandleExternalAppLink(val appLink: IntentType, val headers: Map) : Command() + class HandleNonHttpAppLink(val nonHttpAppLink: NonHttpAppLink, val headers: Map) : Command() + class HandleAppLink(val appLink: AppLink, val headers: Map) : Command() class AddHomeShortcut(val title: String, val url: String, val icon: Bitmap? = null) : Command() class LaunchSurvey(val survey: Survey) : Command() object LaunchAddWidget : Command() @@ -568,14 +573,16 @@ class BrowserTabViewModel( val urlToNavigate = queryUrlConverter.convertQueryToUrl(trimmedInput, verticalParameter, queryOrigin) val type = specialUrlDetector.determineType(trimmedInput) - if (type is IntentType) { - externalAppLinkClicked(type) + if (type is NonHttpAppLink) { + nonHttpAppLinkClicked(type) } else { if (shouldClearHistoryOnNewQuery()) { command.value = ResetHistory } fireQueryChangedPixel(trimmedInput) + + openAppLinksInBrowser() command.value = Navigate(urlToNavigate, getUrlHeaders()) } @@ -1752,8 +1759,28 @@ class BrowserTabViewModel( tabRepository.updateTabPreviewImage(tabId, null) } - override fun externalAppLinkClicked(appLink: IntentType) { - command.value = HandleExternalAppLink(appLink, getUrlHeaders()) + override fun handleAppLink(appLink: AppLink, isRedirect: Boolean, isForMainFrame: Boolean): Boolean { + return appLinksHandler.handleAppLink(isRedirect, isForMainFrame) { appLinkClicked(appLink) } + } + + override fun resetAppLinkState() { + appLinksHandler.reset() + } + + fun openAppLinksInBrowser() { + appLinksHandler.enterBrowserState() + } + + fun appLinkClicked(appLink: AppLink) { + command.value = HandleAppLink(appLink, getUrlHeaders()) + } + + override fun handleNonHttpAppLink(nonHttpAppLink: NonHttpAppLink, isRedirect: Boolean): Boolean { + return appLinksHandler.handleNonHttpAppLink(isRedirect) { nonHttpAppLinkClicked(nonHttpAppLink) } + } + + fun nonHttpAppLinkClicked(appLink: NonHttpAppLink) { + command.value = HandleNonHttpAppLink(appLink, getUrlHeaders()) } override fun openMessageInNewTab(message: Message) { @@ -1986,12 +2013,13 @@ class BrowserTabViewModelFactory @Inject constructor( private val globalPrivacyControl: Provider, private val fireproofDialogsEventHandler: Provider, private val emailManager: Provider, - private val appCoroutineScope: Provider + private val appCoroutineScope: Provider, + private val appLinksHandler: Provider ) : ViewModelFactoryPlugin { override fun create(modelClass: Class): T? { with(modelClass) { return when { - isAssignableFrom(BrowserTabViewModel::class.java) -> BrowserTabViewModel(statisticsUpdater.get(), queryUrlConverter.get(), duckDuckGoUrlDetector.get(), siteFactory.get(), tabRepository.get(), userWhitelistDao.get(), networkLeaderboardDao.get(), bookmarksDao.get(), favoritesRepository.get(), fireproofWebsiteRepository.get(), locationPermissionsRepository.get(), geoLocationPermissions.get(), navigationAwareLoginDetector.get(), autoComplete.get(), appSettingsPreferencesStore.get(), longPressHandler.get(), webViewSessionStorage.get(), specialUrlDetector.get(), faviconManager.get(), addToHomeCapabilityDetector.get(), ctaViewModel.get(), searchCountDao.get(), pixel.get(), dispatchers, userEventsStore.get(), notificationDao.get(), variantManager.get(), fileDownloader.get(), globalPrivacyControl.get(), fireproofDialogsEventHandler.get(), emailManager.get(), appCoroutineScope.get()) as T + isAssignableFrom(BrowserTabViewModel::class.java) -> BrowserTabViewModel(statisticsUpdater.get(), queryUrlConverter.get(), duckDuckGoUrlDetector.get(), siteFactory.get(), tabRepository.get(), userWhitelistDao.get(), networkLeaderboardDao.get(), bookmarksDao.get(), favoritesRepository.get(), fireproofWebsiteRepository.get(), locationPermissionsRepository.get(), geoLocationPermissions.get(), navigationAwareLoginDetector.get(), autoComplete.get(), appSettingsPreferencesStore.get(), longPressHandler.get(), webViewSessionStorage.get(), specialUrlDetector.get(), faviconManager.get(), addToHomeCapabilityDetector.get(), ctaViewModel.get(), searchCountDao.get(), pixel.get(), dispatchers, userEventsStore.get(), notificationDao.get(), variantManager.get(), fileDownloader.get(), globalPrivacyControl.get(), fireproofDialogsEventHandler.get(), emailManager.get(), appCoroutineScope.get(), appLinksHandler.get()) as T else -> null } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt index 64b33fd935ea..4d8db13a0c0e 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt @@ -68,9 +68,10 @@ class BrowserWebViewClient( /** * This is the new method of url overriding available from API 24 onwards */ + @RequiresApi(Build.VERSION_CODES.N) override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { val url = request.url - return shouldOverride(view, url, request.isForMainFrame) + return shouldOverride(view, url, request.isForMainFrame, request.isRedirect) } /** @@ -79,13 +80,13 @@ class BrowserWebViewClient( @Suppress("OverridingDeprecatedMember") override fun shouldOverrideUrlLoading(view: WebView, urlString: String): Boolean { val url = Uri.parse(urlString) - return shouldOverride(view, url, true) + return shouldOverride(view, url, isForMainFrame = true, isRedirect = false) } /** * API-agnostic implementation of deciding whether to override url or not */ - private fun shouldOverride(webView: WebView, url: Uri, isForMainFrame: Boolean): Boolean { + private fun shouldOverride(webView: WebView, url: Uri, isForMainFrame: Boolean, isRedirect: Boolean): Boolean { Timber.v("shouldOverride $url") try { @@ -108,13 +109,22 @@ class BrowserWebViewClient( webViewClientListener?.sendSmsRequested(urlType.telephoneNumber) true } - is SpecialUrlDetector.UrlType.IntentType -> { - Timber.i("Found intent type link for $urlType.url") - launchExternalApp(urlType) + is SpecialUrlDetector.UrlType.AppLink -> { + Timber.i("Found app link for ${urlType.uriString}") + webViewClientListener?.let { listener -> + return listener.handleAppLink(urlType, isRedirect, isForMainFrame) + } + false + } + is SpecialUrlDetector.UrlType.NonHttpAppLink -> { + Timber.i("Found non-http app link for ${urlType.uriString}") + webViewClientListener?.let { listener -> + return listener.handleNonHttpAppLink(urlType, isRedirect) + } true } is SpecialUrlDetector.UrlType.Unknown -> { - Timber.w("Unable to process link type for ${urlType.url}") + Timber.w("Unable to process link type for ${urlType.uriString}") webView.originalUrl?.let { webView.loadUrl(it) } @@ -142,10 +152,6 @@ class BrowserWebViewClient( } } - private fun launchExternalApp(urlType: SpecialUrlDetector.UrlType.IntentType) { - webViewClientListener?.externalAppLinkClicked(urlType) - } - @UiThread override fun onPageStarted(webView: WebView, url: String?, favicon: Bitmap?) { try { @@ -164,6 +170,7 @@ class BrowserWebViewClient( emailInjector.resetInjectedJsFlag() globalPrivacyControl.injectDoNotSellToDom(webView) loginDetector.onEvent(WebNavigationEvent.OnPageStarted(webView)) + webViewClientListener?.resetAppLinkState() } catch (e: Throwable) { appCoroutineScope.launch { uncaughtExceptionRepository.recordUncaughtException(e, ON_PAGE_STARTED) diff --git a/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt b/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt index 64117744fe8e..6a77e4f71897 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/SpecialUrlDetector.kt @@ -16,9 +16,15 @@ package com.duckduckgo.app.browser +import android.content.ComponentName import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo import android.net.Uri +import android.os.Build import com.duckduckgo.app.browser.SpecialUrlDetector.UrlType +import com.duckduckgo.app.settings.db.SettingsDataStore import timber.log.Timber import java.net.URISyntaxException @@ -31,14 +37,17 @@ interface SpecialUrlDetector { class Telephone(val telephoneNumber: String) : UrlType() class Email(val emailAddress: String) : UrlType() class Sms(val telephoneNumber: String) : UrlType() - class IntentType(val url: String, val intent: Intent, val fallbackUrl: String?) : UrlType() + class AppLink(val appIntent: Intent? = null, val excludedComponents: List? = null, val uriString: String) : UrlType() + class NonHttpAppLink(val uriString: String, val intent: Intent, val fallbackUrl: String?) : UrlType() class SearchQuery(val query: String) : UrlType() - class Unknown(val url: String) : UrlType() + class Unknown(val uriString: String) : UrlType() } - } -class SpecialUrlDetectorImpl : SpecialUrlDetector { +class SpecialUrlDetectorImpl( + private val packageManager: PackageManager, + private val settingsDataStore: SettingsDataStore +) : SpecialUrlDetector { override fun determineType(uri: Uri): UrlType { val uriString = uri.toString() @@ -49,7 +58,7 @@ class SpecialUrlDetectorImpl : SpecialUrlDetector { MAILTO_SCHEME -> buildEmail(uriString) SMS_SCHEME -> buildSms(uriString) SMSTO_SCHEME -> buildSmsTo(uriString) - HTTP_SCHEME, HTTPS_SCHEME, DATA_SCHEME -> UrlType.Web(uriString) + HTTP_SCHEME, HTTPS_SCHEME, DATA_SCHEME -> checkForAppLink(uriString) ABOUT_SCHEME -> UrlType.Unknown(uriString) JAVASCRIPT_SCHEME -> UrlType.SearchQuery(uriString) null -> UrlType.SearchQuery(uriString) @@ -68,6 +77,55 @@ class SpecialUrlDetectorImpl : SpecialUrlDetector { private fun buildSmsTo(uriString: String): UrlType = UrlType.Sms(uriString.removePrefix("$SMSTO_SCHEME:").truncate(SMS_MAX_LENGTH)) + private fun checkForAppLink(uriString: String): UrlType { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && settingsDataStore.appLinksEnabled) { + try { + val activities = queryActivities(uriString) + val nonBrowserActivities = keepNonBrowserActivities(activities) + + if (nonBrowserActivities.isNotEmpty()) { + nonBrowserActivities.singleOrNull()?.let { resolveInfo -> + val nonBrowserIntent = buildNonBrowserIntent(resolveInfo, uriString) + return UrlType.AppLink(appIntent = nonBrowserIntent, uriString = uriString) + } + val excludedComponents = getExcludedComponents(activities) + return UrlType.AppLink(excludedComponents = excludedComponents, uriString = uriString) + } + } catch (e: URISyntaxException) { + Timber.w(e, "Failed to parse uri $uriString") + } + } + return UrlType.Web(uriString) + } + + @Throws(URISyntaxException::class) + private fun queryActivities(uriString: String): MutableList { + val browsableIntent = Intent.parseUri(uriString, URI_NO_FLAG) + browsableIntent.addCategory(Intent.CATEGORY_BROWSABLE) + return packageManager.queryIntentActivities(browsableIntent, PackageManager.GET_RESOLVED_FILTER) + } + + private fun keepNonBrowserActivities(activities: List): List { + return activities.filter { resolveInfo -> + resolveInfo.filter != null && !(isBrowserFilter(resolveInfo.filter)) + } + } + @Throws(URISyntaxException::class) + private fun buildNonBrowserIntent(nonBrowserActivity: ResolveInfo, uriString: String): Intent { + val intent = Intent.parseUri(uriString, URI_NO_FLAG) + intent.component = ComponentName(nonBrowserActivity.activityInfo.packageName, nonBrowserActivity.activityInfo.name) + return intent + } + + private fun getExcludedComponents(activities: List): List { + return activities.filter { resolveInfo -> + resolveInfo.filter != null && isBrowserFilter(resolveInfo.filter) + }.map { ComponentName(it.activityInfo.packageName, it.activityInfo.name) } + } + + private fun isBrowserFilter(filter: IntentFilter) = + filter.countDataAuthorities() == 0 && filter.countDataPaths() == 0 + private fun checkForIntent(scheme: String, uriString: String): UrlType { val validUriSchemeRegex = Regex("[a-z][a-zA-Z\\d+.-]+") if (scheme.matches(validUriSchemeRegex)) { @@ -79,9 +137,9 @@ class SpecialUrlDetectorImpl : SpecialUrlDetector { private fun buildIntent(uriString: String): UrlType { return try { - val intent = Intent.parseUri(uriString, 0) + val intent = Intent.parseUri(uriString, URI_NO_FLAG) val fallbackUrl = intent.getStringExtra(EXTRA_FALLBACK_URL) - UrlType.IntentType(url = uriString, intent = intent, fallbackUrl = fallbackUrl) + UrlType.NonHttpAppLink(uriString = uriString, intent = intent, fallbackUrl = fallbackUrl) } catch (e: URISyntaxException) { Timber.w(e, "Failed to parse uri $uriString") return UrlType.Unknown(uriString) @@ -108,6 +166,7 @@ class SpecialUrlDetectorImpl : SpecialUrlDetector { private const val DATA_SCHEME = "data" private const val JAVASCRIPT_SCHEME = "javascript" private const val EXTRA_FALLBACK_URL = "browser_fallback_url" + private const val URI_NO_FLAG = 0 const val SMS_MAX_LENGTH = 400 const val PHONE_MAX_LENGTH = 20 const val EMAIL_MAX_LENGTH = 1000 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 5b6596c3e238..4ebad0599c26 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt @@ -47,7 +47,9 @@ interface WebViewClientListener { fun goFullScreen(view: View) fun exitFullScreen() fun showFileChooser(filePathCallback: ValueCallback>, fileChooserParams: WebChromeClient.FileChooserParams) - fun externalAppLinkClicked(appLink: SpecialUrlDetector.UrlType.IntentType) + fun handleAppLink(appLink: SpecialUrlDetector.UrlType.AppLink, isRedirect: Boolean, isForMainFrame: Boolean): Boolean + fun resetAppLinkState() + fun handleNonHttpAppLink(nonHttpAppLink: SpecialUrlDetector.UrlType.NonHttpAppLink, isRedirect: Boolean): Boolean fun openMessageInNewTab(message: Message) fun recoverFromRenderProcessGone() fun requiresAuthentication(request: BasicAuthenticationRequest) diff --git a/app/src/main/java/com/duckduckgo/app/browser/applinks/AppLinksHandler.kt b/app/src/main/java/com/duckduckgo/app/browser/applinks/AppLinksHandler.kt new file mode 100644 index 000000000000..1bbaebe89679 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/applinks/AppLinksHandler.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2021 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.applinks + +import android.os.Build +import javax.inject.Inject + +interface AppLinksHandler { + fun handleAppLink(isRedirect: Boolean, isForMainFrame: Boolean, launchAppLink: () -> Unit): Boolean + fun handleNonHttpAppLink(isRedirect: Boolean, launchNonHttpAppLink: () -> Unit): Boolean + fun enterBrowserState() + fun reset() +} + +class DuckDuckGoAppLinksHandler @Inject constructor() : AppLinksHandler { + + var appLinkOpenedInBrowser = false + + override fun handleAppLink(isRedirect: Boolean, isForMainFrame: Boolean, launchAppLink: () -> Unit): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || isRedirect && appLinkOpenedInBrowser || !isForMainFrame) { + return false + } + launchAppLink() + return true + } + + override fun handleNonHttpAppLink(isRedirect: Boolean, launchNonHttpAppLink: () -> Unit): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isRedirect && appLinkOpenedInBrowser) { + return true + } + launchNonHttpAppLink() + return true + } + + override fun enterBrowserState() { + appLinkOpenedInBrowser = true + } + + override fun reset() { + appLinkOpenedInBrowser = false + } +} 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 4a9b6800c499..08b578718146 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 @@ -18,6 +18,7 @@ package com.duckduckgo.app.browser.di import android.content.ClipboardManager import android.content.Context +import android.content.pm.PackageManager import android.webkit.CookieManager import android.webkit.WebSettings import androidx.lifecycle.LifecycleObserver @@ -174,7 +175,7 @@ class BrowserModule { } @Provides - fun specialUrlDetector(): SpecialUrlDetector = SpecialUrlDetectorImpl() + fun specialUrlDetector(packageManager: PackageManager, settingsDataStore: SettingsDataStore): SpecialUrlDetector = SpecialUrlDetectorImpl(packageManager, settingsDataStore) @Provides @Singleton 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 4c7bfe9ba50d..51d9fdc0369f 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt @@ -80,12 +80,17 @@ class SettingsActivity : viewModel.onAutocompleteSettingChanged(isChecked) } + private val appLinksToggleListener = OnCheckedChangeListener { _, isChecked -> + viewModel.onAppLinksSettingChanged(isChecked) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_settings) setupToolbar(toolbar) configureUiEventHandlers() + configureAppLinksToggle() observeViewModel() } @@ -112,6 +117,14 @@ class SettingsActivity : emailSetting.setOnClickListener { viewModel.onEmailSettingClicked() } } + private fun configureAppLinksToggle() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + appLinksToggle.setOnCheckedChangeListener(appLinksToggleListener) + } else { + appLinksToggle.visibility = View.GONE + } + } + private fun observeViewModel() { viewModel.viewState() .flowWithLifecycle(lifecycle, Lifecycle.State.CREATED) @@ -126,6 +139,7 @@ class SettingsActivity : changeAppIcon.setImageResource(it.appIcon.icon) updateSelectedFireAnimation(it.selectedFireAnimation) setEmailSetting(it.emailSetting) + appLinksToggle.quietlySetIsChecked(it.appLinksEnabled, appLinksToggleListener) } }.launchIn(lifecycleScope) diff --git a/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt b/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt index 5fba0ec4d381..74704ac2be2e 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt @@ -65,7 +65,8 @@ class SettingsViewModel @Inject constructor( val automaticallyClearData: AutomaticallyClearData = AutomaticallyClearData(ClearWhatOption.CLEAR_NONE, ClearWhenOption.APP_EXIT_ONLY), val appIcon: AppIcon = AppIcon.DEFAULT, val globalPrivacyControlEnabled: Boolean = false, - val emailSetting: EmailSetting = EmailSetting.EmailSettingOff + val emailSetting: EmailSetting = EmailSetting.EmailSettingOff, + val appLinksEnabled: Boolean = true ) sealed class EmailSetting { @@ -122,7 +123,8 @@ class SettingsViewModel @Inject constructor( appIcon = settingsDataStore.appIcon, selectedFireAnimation = settingsDataStore.selectedFireAnimation, globalPrivacyControlEnabled = settingsDataStore.globalPrivacyControlEnabled, - emailSetting = getEmailSetting() + emailSetting = getEmailSetting(), + appLinksEnabled = settingsDataStore.appLinksEnabled ) ) } @@ -211,6 +213,12 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { viewState.emit(currentViewState().copy(autoCompleteSuggestionsEnabled = enabled)) } } + fun onAppLinksSettingChanged(enabled: Boolean) { + Timber.i("User changed app links setting, is now enabled: $enabled") + settingsDataStore.appLinksEnabled = enabled + viewModelScope.launch { viewState.emit(currentViewState().copy(appLinksEnabled = enabled)) } + } + private fun obtainVersion(variantKey: String): String { val formattedVariantKey = if (variantKey.isBlank()) " " else " $variantKey " return "${BuildConfig.VERSION_NAME}$formattedVariantKey(${BuildConfig.VERSION_CODE})" 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 05a8456f6aed..2b6071079472 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 @@ -41,6 +41,7 @@ interface SettingsDataStore { var appLocationPermission: Boolean var appLocationPermissionDeniedForever: Boolean var globalPrivacyControlEnabled: Boolean + var appLinksEnabled: Boolean /** * This will be checked upon app startup and used to decide whether it should perform a clear or not. @@ -146,6 +147,10 @@ class SettingsSharedPreferences constructor(private val context: Context, privat get() = preferences.getBoolean(KEY_DO_NOT_SELL_ENABLED, true) set(enabled) = preferences.edit { putBoolean(KEY_DO_NOT_SELL_ENABLED, enabled) } + override var appLinksEnabled: Boolean + get() = preferences.getBoolean(APP_LINKS_ENABLED, true) + set(enabled) = preferences.edit { putBoolean(APP_LINKS_ENABLED, enabled) } + override fun hasBackgroundTimestampRecorded(): Boolean = preferences.contains(KEY_APP_BACKGROUNDED_TIMESTAMP) override fun clearAppBackgroundTimestamp() = preferences.edit { remove(KEY_APP_BACKGROUNDED_TIMESTAMP) } @@ -199,6 +204,7 @@ class SettingsSharedPreferences constructor(private val context: Context, privat const val KEY_SITE_LOCATION_PERMISSION_ENABLED = "KEY_SITE_LOCATION_PERMISSION_ENABLED" const val KEY_SYSTEM_LOCATION_PERMISSION_DENIED_FOREVER = "KEY_SYSTEM_LOCATION_PERMISSION_DENIED_FOREVER" const val KEY_DO_NOT_SELL_ENABLED = "KEY_DO_NOT_SELL_ENABLED" + const val APP_LINKS_ENABLED = "APP_LINKS_ENABLED" private val DEFAULT_ICON = if (BuildConfig.DEBUG) { AppIcon.BLUE diff --git a/app/src/main/res/layout/content_settings_privacy.xml b/app/src/main/res/layout/content_settings_privacy.xml index 0bbbaf025267..068f8abdd35c 100644 --- a/app/src/main/res/layout/content_settings_privacy.xml +++ b/app/src/main/res/layout/content_settings_privacy.xml @@ -89,4 +89,14 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/whitelist" /> + + \ No newline at end of file diff --git a/app/src/main/res/values/string-untranslated.xml b/app/src/main/res/values/string-untranslated.xml index 9cdb49495e01..e280b604ee4b 100644 --- a/app/src/main/res/values/string-untranslated.xml +++ b/app/src/main/res/values/string-untranslated.xml @@ -47,4 +47,9 @@ Success! %s has been added to your home screen. + + Open Links in Associated Apps + Disable to prevent links from automatically opening in other installed apps. + Open in another app? +