From 8789ab04e73d31fc71ed5501a257f6e8ef88e256 Mon Sep 17 00:00:00 2001 From: Cris Barreiro Date: Fri, 2 Aug 2024 18:35:44 +0200 Subject: [PATCH] Code cleanup --- .../browser/WebViewRequestInterceptorTest.kt | 23 ++- .../referencetests/DomainsReferenceTest.kt | 6 +- .../referencetests/SurrogatesReferenceTest.kt | 6 +- .../WebViewDuckPlayerRequestInterceptor.kt | 129 ---------------- .../app/browser/WebViewRequestInterceptor.kt | 5 +- .../app/browser/di/BrowserModule.kt | 5 +- duckplayer/duckplayer-api/build.gradle | 1 + .../duckduckgo/duckplayer/api/DuckPlayer.kt | 66 ++++---- .../duckplayer/impl/RealDuckPlayer.kt | 143 +++++++++++++++--- 9 files changed, 179 insertions(+), 205 deletions(-) delete mode 100644 app/src/main/java/com/duckduckgo/app/browser/WebViewDuckPlayerRequestInterceptor.kt diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/WebViewRequestInterceptorTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/WebViewRequestInterceptorTest.kt index d6734c52621a..f73ebc8f7634 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/WebViewRequestInterceptorTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/WebViewRequestInterceptorTest.kt @@ -19,7 +19,12 @@ package com.duckduckgo.app.browser import android.net.Uri -import android.webkit.* +import android.webkit.WebBackForwardList +import android.webkit.WebHistoryItem +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView import androidx.core.net.toUri import androidx.test.annotation.UiThreadTest import com.duckduckgo.adclick.api.AdClickManager @@ -38,6 +43,7 @@ import com.duckduckgo.app.trackerdetection.model.TrackerStatus import com.duckduckgo.app.trackerdetection.model.TrackerType import com.duckduckgo.app.trackerdetection.model.TrackingEvent import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.feature.toggles.api.FeatureToggle import com.duckduckgo.httpsupgrade.api.HttpsUpgrader import com.duckduckgo.privacy.config.api.Gpc @@ -47,13 +53,20 @@ import com.duckduckgo.user.agent.api.UserAgentProvider import com.duckduckgo.user.agent.impl.RealUserAgentProvider import com.duckduckgo.user.agent.impl.UserAgent import kotlinx.coroutines.test.runTest -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.ArgumentMatchers.anyMap import org.mockito.ArgumentMatchers.anyString -import org.mockito.kotlin.* +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever class WebViewRequestInterceptorTest { @@ -72,7 +85,7 @@ class WebViewRequestInterceptorTest { private val mockAdClickManager: AdClickManager = mock() private val mockCloakedCnameDetector: CloakedCnameDetector = mock() private val mockRequestFilterer: RequestFilterer = mock() - private val mockDuckPlayerInterceptor: WebViewDuckPlayerRequestInterceptor = mock() + private val mockDuckPlayer: DuckPlayer = mock() private val fakeUserAgent: UserAgent = UserAgentFake() private val fakeToggle: FeatureToggle = FeatureToggleFake() private val fakeUserAllowListRepository = UserAllowListRepositoryFake() @@ -103,7 +116,7 @@ class WebViewRequestInterceptorTest { adClickManager = mockAdClickManager, cloakedCnameDetector = mockCloakedCnameDetector, requestFilterer = mockRequestFilterer, - duckPlayerRequestInterceptor = mockDuckPlayerInterceptor, + duckPlayer = mockDuckPlayer, ) } diff --git a/app/src/androidTest/java/com/duckduckgo/app/referencetests/DomainsReferenceTest.kt b/app/src/androidTest/java/com/duckduckgo/app/referencetests/DomainsReferenceTest.kt index d7fd925db2bf..9bc809b2efdf 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/referencetests/DomainsReferenceTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/referencetests/DomainsReferenceTest.kt @@ -26,7 +26,6 @@ import androidx.room.Room import androidx.test.annotation.UiThreadTest import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.adclick.api.AdClickManager -import com.duckduckgo.app.browser.WebViewDuckPlayerRequestInterceptor import com.duckduckgo.app.browser.WebViewRequestInterceptor import com.duckduckgo.app.browser.useragent.provideUserAgentOverridePluginPoint import com.duckduckgo.app.fakes.FeatureToggleFake @@ -55,6 +54,7 @@ import com.duckduckgo.app.trackerdetection.db.TdsEntityDao import com.duckduckgo.app.trackerdetection.db.WebTrackersBlockedDao import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.FileUtilities +import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.feature.toggles.api.FeatureToggle import com.duckduckgo.httpsupgrade.api.HttpsUpgrader import com.duckduckgo.privacy.config.api.ContentBlocking @@ -104,7 +104,7 @@ class DomainsReferenceTest(private val testCase: TestCase) { private var mockRequest: WebResourceRequest = mock() private val mockPrivacyProtectionCountDao: PrivacyProtectionCountDao = mock() private val mockRequestFilterer: RequestFilterer = mock() - private val mockDuckPlayerInterceptor: WebViewDuckPlayerRequestInterceptor = mock() + private val mockDuckPlayer: DuckPlayer = mock() private val mockUserAllowListRepository: UserAllowListRepository = mock() private val fakeUserAgent: UserAgent = UserAgentFake() private val fakeToggle: FeatureToggle = FeatureToggleFake() @@ -174,7 +174,7 @@ class DomainsReferenceTest(private val testCase: TestCase) { adClickManager = mockAdClickManager, cloakedCnameDetector = CloakedCnameDetectorImpl(tdsCnameEntityDao, mockTrackerAllowlist, mockUserAllowListRepository), requestFilterer = mockRequestFilterer, - duckPlayerRequestInterceptor = mockDuckPlayerInterceptor, + duckPlayer = mockDuckPlayer, ) } diff --git a/app/src/androidTest/java/com/duckduckgo/app/referencetests/SurrogatesReferenceTest.kt b/app/src/androidTest/java/com/duckduckgo/app/referencetests/SurrogatesReferenceTest.kt index 17d4c5c91c7e..83fb7607d59f 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/referencetests/SurrogatesReferenceTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/referencetests/SurrogatesReferenceTest.kt @@ -25,7 +25,6 @@ import androidx.room.Room import androidx.test.annotation.UiThreadTest import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.adclick.api.AdClickManager -import com.duckduckgo.app.browser.WebViewDuckPlayerRequestInterceptor import com.duckduckgo.app.browser.WebViewRequestInterceptor import com.duckduckgo.app.browser.useragent.provideUserAgentOverridePluginPoint import com.duckduckgo.app.fakes.FeatureToggleFake @@ -53,6 +52,7 @@ import com.duckduckgo.app.trackerdetection.db.TdsEntityDao import com.duckduckgo.app.trackerdetection.db.WebTrackersBlockedDao import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.FileUtilities +import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.feature.toggles.api.FeatureToggle import com.duckduckgo.httpsupgrade.api.HttpsUpgrader import com.duckduckgo.privacy.config.api.ContentBlocking @@ -100,7 +100,7 @@ class SurrogatesReferenceTest(private val testCase: TestCase) { private var mockRequest: WebResourceRequest = mock() private val mockPrivacyProtectionCountDao: PrivacyProtectionCountDao = mock() private val mockRequestFilterer: RequestFilterer = mock() - private val mockDuckPlayerInterceptor: WebViewDuckPlayerRequestInterceptor = mock() + private val mockDuckPlayer: DuckPlayer = mock() private val fakeUserAgent: UserAgent = UserAgentFake() private val fakeToggle: FeatureToggle = FeatureToggleFake() private val fakeUserAllowListRepository = UserAllowListRepositoryFake() @@ -170,7 +170,7 @@ class SurrogatesReferenceTest(private val testCase: TestCase) { adClickManager = mockAdClickManager, cloakedCnameDetector = mockCloakedCnameDetector, requestFilterer = mockRequestFilterer, - duckPlayerRequestInterceptor = mockDuckPlayerInterceptor, + duckPlayer = mockDuckPlayer, ) } diff --git a/app/src/main/java/com/duckduckgo/app/browser/WebViewDuckPlayerRequestInterceptor.kt b/app/src/main/java/com/duckduckgo/app/browser/WebViewDuckPlayerRequestInterceptor.kt deleted file mode 100644 index 35f98e971266..000000000000 --- a/app/src/main/java/com/duckduckgo/app/browser/WebViewDuckPlayerRequestInterceptor.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright (c) 2024 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 android.net.Uri -import android.webkit.MimeTypeMap -import android.webkit.WebResourceRequest -import android.webkit.WebResourceResponse -import android.webkit.WebView -import androidx.core.net.toUri -import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.duckplayer.api.DUCK_PLAYER_ASSETS_PATH -import com.duckduckgo.duckplayer.api.DuckPlayer -import com.squareup.anvil.annotations.ContributesBinding -import java.io.InputStream -import javax.inject.Inject -import kotlinx.coroutines.withContext - -interface WebViewDuckPlayerRequestInterceptor { - suspend fun intercept( - request: WebResourceRequest, - url: Uri, - webView: WebView, - ): WebResourceResponse? -} - -@ContributesBinding(AppScope::class) -class RealWebViewDuckPlayerRequestInterceptor @Inject constructor( - private val duckPlayer: DuckPlayer, - private val mimeTypeMap: MimeTypeMap, - private val dispatchers: DispatcherProvider, -) : WebViewDuckPlayerRequestInterceptor { - - override suspend fun intercept( - request: WebResourceRequest, - url: Uri, - webView: WebView, - ): WebResourceResponse? { - if (duckPlayer.isDuckPlayerUri(url)) { - return processDuckPlayerUri(url, webView) - } else if (duckPlayer.isYoutubeWatchUrl(url)) { - return processYouTubeWatchUri(request, url, webView) - } else if (duckPlayer.isSimulatedYoutubeNoCookie(url)) { - return processSimulatedYouTubeNoCookieUri(url, webView) - } - - return null - } - private fun processSimulatedYouTubeNoCookieUri( - url: Uri, - webView: WebView, - ): WebResourceResponse { - val path = duckPlayer.getDuckPlayerAssetsPath(url) - val mimeType = mimeTypeMap.getMimeTypeFromExtension(path?.substringAfterLast(".")) - - if (path != null && mimeType != null) { - try { - val inputStream: InputStream = webView.context.assets.open(path) - return WebResourceResponse(mimeType, "UTF-8", inputStream) - } catch (e: Exception) { - return WebResourceResponse(null, null, null) - } - } else { - val inputStream: InputStream = webView.context.assets.open(DUCK_PLAYER_ASSETS_PATH) - return WebResourceResponse("text/html", "UTF-8", inputStream) - } - } - - private suspend fun processYouTubeWatchUri( - request: WebResourceRequest, - url: Uri, - webView: WebView, - ): WebResourceResponse? { - val referer = request.requestHeaders["Referer"] - val previousUrl = url.getQueryParameter("embeds_referring_euri") - if ((referer != null && duckPlayer.isSimulatedYoutubeNoCookie(referer.toUri())) || - (previousUrl != null && duckPlayer.isSimulatedYoutubeNoCookie(previousUrl)) - ) { - withContext(dispatchers.main()) { - url.getQueryParameter("v")?.let { - webView.loadUrl("duck://player/openInYoutube?v=$it") - } - } - return WebResourceResponse(null, null, null) - } else if (duckPlayer.shouldNavigateToDuckPlayer()) { - withContext(dispatchers.main()) { - webView.loadUrl(duckPlayer.createDuckPlayerUriFromYoutube(url)) - } - return WebResourceResponse(null, null, null) - } - return null - } - - private suspend fun processDuckPlayerUri( - url: Uri, - webView: WebView, - ): WebResourceResponse { - if (url.pathSegments?.firstOrNull()?.equals("openInYoutube", ignoreCase = true) == true) { - duckPlayer.createYoutubeWatchUrlFromDuckPlayer(url)?.let { youtubeUrl -> - duckPlayer.youTubeRequestedFromDuckPlayer() - withContext(dispatchers.main()) { - webView.loadUrl(youtubeUrl) - } - } - } else { - duckPlayer.createYoutubeNoCookieFromDuckPlayer(url)?.let { youtubeUrl -> - withContext(dispatchers.main()) { - webView.loadUrl(youtubeUrl) - } - } - } - return WebResourceResponse(null, null, null) - } -} diff --git a/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt b/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt index 53dffa0b76cf..5ca57ebb0029 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt @@ -33,6 +33,7 @@ import com.duckduckgo.common.utils.AppUrl import com.duckduckgo.common.utils.DefaultDispatcherProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.isHttp +import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.httpsupgrade.api.HttpsUpgrader import com.duckduckgo.privacy.config.api.Gpc import com.duckduckgo.request.filterer.api.RequestFilterer @@ -69,7 +70,7 @@ class WebViewRequestInterceptor( private val adClickManager: AdClickManager, private val cloakedCnameDetector: CloakedCnameDetector, private val requestFilterer: RequestFilterer, - private val duckPlayerRequestInterceptor: WebViewDuckPlayerRequestInterceptor, + private val duckPlayer: DuckPlayer, private val dispatchers: DispatcherProvider = DefaultDispatcherProvider(), ) : RequestInterceptor { @@ -122,7 +123,7 @@ class WebViewRequestInterceptor( } if (url != null) { - duckPlayerRequestInterceptor.intercept(request, url, webView)?.let { return it } + duckPlayer.intercept(request, url, webView)?.let { return it } } if (url != null && shouldAddGcpHeaders(request) && !requestWasInTheStack(url, webView)) { 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 cbc500454e6f..87c736a25b1f 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 @@ -78,6 +78,7 @@ import com.duckduckgo.downloads.api.FileDownloader import com.duckduckgo.downloads.impl.AndroidFileDownloader import com.duckduckgo.downloads.impl.DataUriDownloader import com.duckduckgo.downloads.impl.FileDownloadCallback +import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.experiments.api.VariantManager import com.duckduckgo.httpsupgrade.api.HttpsUpgrader import com.duckduckgo.privacy.config.api.AmpLinks @@ -194,7 +195,7 @@ class BrowserModule { adClickManager: AdClickManager, cloakedCnameDetector: CloakedCnameDetector, requestFilterer: RequestFilterer, - duckPlayerRequestInterceptor: WebViewDuckPlayerRequestInterceptor, + duckPlayer: DuckPlayer, ): RequestInterceptor = WebViewRequestInterceptor( resourceSurrogates, @@ -206,7 +207,7 @@ class BrowserModule { adClickManager, cloakedCnameDetector, requestFilterer, - duckPlayerRequestInterceptor, + duckPlayer, ) @Provides diff --git a/duckplayer/duckplayer-api/build.gradle b/duckplayer/duckplayer-api/build.gradle index eecb928b27cd..e44fb4abf6b5 100644 --- a/duckplayer/duckplayer-api/build.gradle +++ b/duckplayer/duckplayer-api/build.gradle @@ -34,6 +34,7 @@ dependencies { implementation Kotlin.stdlib.jdk7 implementation AndroidX.core.ktx implementation KotlinX.coroutines.core + implementation 'androidx.legacy:legacy-support-v4:1.0.0' coreLibraryDesugaring Android.tools.desugarJdkLibs implementation project(path: ':common-utils') implementation project(':navigation-api') diff --git a/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayer.kt b/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayer.kt index 08a90eee56d6..1de7788d491a 100644 --- a/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayer.kt +++ b/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayer.kt @@ -17,18 +17,24 @@ package com.duckduckgo.duckplayer.api import android.net.Uri +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Disabled import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled import kotlinx.coroutines.flow.Flow -const val DUCK_PLAYER_ASSETS_PATH = "duckplayer/index.html" - /** * DuckPlayer interface provides a set of methods for interacting with the DuckPlayer. */ interface DuckPlayer { + /** + * Checks if the DuckPlayer is available through remote config + * + * @return True if the DuckPlayer is available, false otherwise. + */ fun isDuckPlayerAvailable(): Boolean /** @@ -46,14 +52,23 @@ interface DuckPlayer { */ suspend fun getUserPreferences(): UserPreferences + /** + * Checks if the DuckPlayer overlay should be hidden after navigating back from Duck Player + * + * @return True if the overlay should be hidden, false otherwise. + */ fun shouldHideDuckPlayerOverlay(): Boolean + /** + * Notifies the DuckPlayer that the overlay was hidden after navigating back from Duck Player + */ fun duckPlayerOverlayHidden() + /** + * Notifies the DuckPlayer that the user navigated to YouTube successfully, so subsequent requests would redirect to Duck Player + */ fun duckPlayerNavigatedToYoutube() - suspend fun shouldNavigateToDuckPlayer(): Boolean - /** * Retrieves a flow of user preferences. * @@ -78,39 +93,13 @@ interface DuckPlayer { fun createDuckPlayerUriFromYoutubeNoCookie(uri: Uri): String /** - * Creates a DuckPlayer URI from a YouTube URI. - * - * @param uri The YouTube URI. - * @return The DuckPlayer URI. - */ - fun createDuckPlayerUriFromYoutube(uri: Uri): String - - /** - * Creates a YouTube no-cookie URI from a DuckPlayer URI. + * Creates a YouTube URI from a DuckPlayer URI. * * @param uri The DuckPlayer URI. - * @return The YouTube no-cookie URI. + * @return The YouTube URI. */ - fun createYoutubeNoCookieFromDuckPlayer(uri: Uri): String? - fun createYoutubeWatchUrlFromDuckPlayer(uri: Uri): String? - /** - * Checks if a URI is a DuckPlayer URI. - * - * @param uri The URI to check. - * @return True if the URI is a DuckPlayer URI, false otherwise. - */ - fun isDuckPlayerUri(uri: Uri): Boolean - - /** - * Checks if a URI is a DuckPlayer settings URI. - * - * @param uri The URI to check. - * @return True if the URI is a DuckPlayer settings URI, false otherwise. - */ - fun isDuckPlayerSettingsUri(uri: Uri?): Boolean - /** * Checks if a string is a DuckPlayer URI. * @@ -144,13 +133,16 @@ interface DuckPlayer { fun isSimulatedYoutubeNoCookie(uri: String): Boolean /** - * Retrieves the duck player assets path from a URI. + * Notify Duck Player of a resource request and allow Duck Player to return the data. * - * @param url The URI to retrieve the path from. - * @return The path of the URI. + * If the return value is null, it means Duck Player won't add any response data. + * Otherwise, the return response and data will be used. */ - fun getDuckPlayerAssetsPath(url: Uri): String? - suspend fun youTubeRequestedFromDuckPlayer() + suspend fun intercept( + request: WebResourceRequest, + url: Uri, + webView: WebView, + ): WebResourceResponse? /** * Data class representing user preferences for Duck Player. diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/RealDuckPlayer.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/RealDuckPlayer.kt index 4fc3c586d5d7..27b38f473512 100644 --- a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/RealDuckPlayer.kt +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/RealDuckPlayer.kt @@ -17,9 +17,15 @@ package com.duckduckgo.duckplayer.impl import android.net.Uri +import android.webkit.MimeTypeMap +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView import androidx.core.net.toUri import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.common.utils.UrlScheme +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.UrlScheme.Companion.duck +import com.duckduckgo.common.utils.UrlScheme.Companion.https import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.duckplayer.api.DuckPlayer.UserPreferences @@ -28,13 +34,25 @@ import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Disabled import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled import com.squareup.anvil.annotations.ContributesBinding import dagger.SingleInstanceIn +import java.io.InputStream import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext private const val YOUTUBE_NO_COOKIE_HOST = "youtube-nocookie.com" private const val YOUTUBE_HOST = "youtube.com" private const val YOUTUBE_MOBILE_HOST = "m.youtube.com" +private const val YOUTUBE_WATCH_PATH = "watch" +private const val DUCK_PLAYER_VIDEO_ID_QUERY_PARAM = "videoID" +private const val YOUTUBE_VIDEO_ID_QUERY_PARAM = "v" +private const val DUCK_PLAYER_OPEN_IN_YOUTUBE_PATH = "openInYoutube" +private const val DUCK_PLAYER_DOMAIN = "player" +private const val DUCK_PLAYER_URL_BASE = "$duck://$DUCK_PLAYER_DOMAIN/" +private const val DUCK_PLAYER_ASSETS_PATH = "duckplayer/" +private const val DUCK_PLAYER_ASSETS_INDEX_PATH = "${DUCK_PLAYER_ASSETS_PATH}index.html" +private const val REFERER_HEADER = "Referer" +private const val REFERRING_QUERY_PARAM = "embeds_referring_euri" @SingleInstanceIn(AppScope::class) @ContributesBinding(AppScope::class) @@ -43,6 +61,8 @@ class RealDuckPlayer @Inject constructor( private val duckPlayerFeature: DuckPlayerFeature, private val pixel: Pixel, private val duckPlayerLocalFilesPath: DuckPlayerLocalFilesPath, + private val mimeTypeMap: MimeTypeMap, + private val dispatchers: DispatcherProvider, ) : DuckPlayer { private var shouldForceYTNavigation = false @@ -78,7 +98,7 @@ class RealDuckPlayer @Inject constructor( shouldHideOverlay = false } - override suspend fun shouldNavigateToDuckPlayer(): Boolean { + private suspend fun shouldNavigateToDuckPlayer(): Boolean { val result = getUserPreferences().privatePlayerMode == Enabled && !shouldForceYTNavigation return result } @@ -101,33 +121,33 @@ class RealDuckPlayer @Inject constructor( pixel.fire(androidPixelName, pixelData) } - override fun createYoutubeNoCookieFromDuckPlayer(uri: Uri): String? { + private fun createYoutubeNoCookieFromDuckPlayer(uri: Uri): String? { uri.pathSegments?.firstOrNull()?.let { videoID -> - return "https://www.$YOUTUBE_NO_COOKIE_HOST?videoID=$videoID" + return "$https://www.$YOUTUBE_NO_COOKIE_HOST?$DUCK_PLAYER_VIDEO_ID_QUERY_PARAM=$videoID" } return null } override fun createYoutubeWatchUrlFromDuckPlayer(uri: Uri): String? { - uri.getQueryParameter("v")?.let { videoID -> - return "https://$YOUTUBE_HOST/watch?v=$videoID" + uri.getQueryParameter(YOUTUBE_VIDEO_ID_QUERY_PARAM)?.let { videoID -> + return "$https://$YOUTUBE_HOST/$YOUTUBE_WATCH_PATH?$YOUTUBE_VIDEO_ID_QUERY_PARAM=$videoID" } ?: uri.pathSegments.firstOrNull()?.let { videoID -> - return "https://$YOUTUBE_HOST/watch?v=$videoID" + return "$https://$YOUTUBE_HOST/$YOUTUBE_WATCH_PATH?$YOUTUBE_VIDEO_ID_QUERY_PARAM=$videoID" } return null } - override suspend fun youTubeRequestedFromDuckPlayer() { + private suspend fun youTubeRequestedFromDuckPlayer() { shouldForceYTNavigation = true if (getUserPreferences().privatePlayerMode == AlwaysAsk) { shouldHideOverlay = true } } - override fun isDuckPlayerUri(uri: Uri): Boolean { - if (uri.normalizeScheme()?.scheme != UrlScheme.duck) return false + private fun isDuckPlayerUri(uri: Uri): Boolean { + if (uri.normalizeScheme()?.scheme != duck) return false if (uri.userInfo != null) return false uri.host?.let { host -> - if (!host.contains("player")) return false + if (!host.contains(DUCK_PLAYER_DOMAIN)) return false return !host.contains("!") } return false @@ -137,12 +157,6 @@ class RealDuckPlayer @Inject constructor( return isDuckPlayerUri(uri.toUri()) } - override fun isDuckPlayerSettingsUri(uri: Uri?): Boolean { - if (uri?.normalizeScheme()?.scheme != UrlScheme.duck) return false - if (uri.userInfo != null) return false - return uri.host == "settings" && uri.pathSegments.firstOrNull() == "duckplayer" - } - override fun isSimulatedYoutubeNoCookie(uri: Uri): Boolean { val validPaths = duckPlayerLocalFilesPath.assetsPath return ( @@ -150,7 +164,7 @@ class RealDuckPlayer @Inject constructor( YOUTUBE_NO_COOKIE_HOST && ( uri.pathSegments.firstOrNull() == null || validPaths.any { uri.path?.contains(it) == true } || - (uri.pathSegments.firstOrNull() != "embed" && uri.getQueryParameter("videoID") != null) + (uri.pathSegments.firstOrNull() != "embed" && uri.getQueryParameter(DUCK_PLAYER_VIDEO_ID_QUERY_PARAM) != null) ) ) } @@ -159,20 +173,101 @@ class RealDuckPlayer @Inject constructor( return isSimulatedYoutubeNoCookie(uri.toUri()) } - override fun getDuckPlayerAssetsPath(url: Uri): String? { - return url.path?.takeIf { it.isNotBlank() }?.removePrefix("/")?.let { "duckplayer/$it" } + private fun getDuckPlayerAssetsPath(url: Uri): String? { + return url.path?.takeIf { it.isNotBlank() }?.removePrefix("/")?.let { "$DUCK_PLAYER_ASSETS_PATH$it" } } override fun isYoutubeWatchUrl(uri: Uri): Boolean { val host = uri.host?.removePrefix("www.") - return (host == YOUTUBE_HOST || host == YOUTUBE_MOBILE_HOST) && uri.pathSegments.firstOrNull() == "watch" + return (host == YOUTUBE_HOST || host == YOUTUBE_MOBILE_HOST) && uri.pathSegments.firstOrNull() == YOUTUBE_WATCH_PATH } override fun createDuckPlayerUriFromYoutubeNoCookie(uri: Uri): String { - return "${UrlScheme.duck}://player/${uri.getQueryParameter("videoID")}" + return "$DUCK_PLAYER_URL_BASE${uri.getQueryParameter(DUCK_PLAYER_VIDEO_ID_QUERY_PARAM)}" + } + + private fun createDuckPlayerUriFromYoutube(uri: Uri): String { + return "$DUCK_PLAYER_URL_BASE${uri.getQueryParameter(YOUTUBE_VIDEO_ID_QUERY_PARAM)}" + } + + override suspend fun intercept( + request: WebResourceRequest, + url: Uri, + webView: WebView, + ): WebResourceResponse? { + if (isDuckPlayerUri(url)) { + return processDuckPlayerUri(url, webView) + } else if (isYoutubeWatchUrl(url)) { + return processYouTubeWatchUri(request, url, webView) + } else if (isSimulatedYoutubeNoCookie(url)) { + return processSimulatedYouTubeNoCookieUri(url, webView) + } + + return null } + private fun processSimulatedYouTubeNoCookieUri( + url: Uri, + webView: WebView, + ): WebResourceResponse { + val path = getDuckPlayerAssetsPath(url) + val mimeType = mimeTypeMap.getMimeTypeFromExtension(path?.substringAfterLast(".")) - override fun createDuckPlayerUriFromYoutube(uri: Uri): String { - return "${UrlScheme.duck}://player/${uri.getQueryParameter("v")}" + if (path != null && mimeType != null) { + try { + val inputStream: InputStream = webView.context.assets.open(path) + return WebResourceResponse(mimeType, "UTF-8", inputStream) + } catch (e: Exception) { + return WebResourceResponse(null, null, null) + } + } else { + val inputStream: InputStream = webView.context.assets.open(DUCK_PLAYER_ASSETS_INDEX_PATH) + return WebResourceResponse("text/html", "UTF-8", inputStream) + } + } + + private suspend fun processYouTubeWatchUri( + request: WebResourceRequest, + url: Uri, + webView: WebView, + ): WebResourceResponse? { + val referer = request.requestHeaders[REFERER_HEADER] + val previousUrl = url.getQueryParameter(REFERRING_QUERY_PARAM) + if ((referer != null && isSimulatedYoutubeNoCookie(referer.toUri())) || + (previousUrl != null && isSimulatedYoutubeNoCookie(previousUrl)) + ) { + withContext(dispatchers.main()) { + url.getQueryParameter(YOUTUBE_VIDEO_ID_QUERY_PARAM)?.let { + webView.loadUrl("$DUCK_PLAYER_URL_BASE$DUCK_PLAYER_OPEN_IN_YOUTUBE_PATH?$YOUTUBE_VIDEO_ID_QUERY_PARAM=$it") + } + } + return WebResourceResponse(null, null, null) + } else if (shouldNavigateToDuckPlayer()) { + withContext(dispatchers.main()) { + webView.loadUrl(createDuckPlayerUriFromYoutube(url)) + } + return WebResourceResponse(null, null, null) + } + return null + } + + private suspend fun processDuckPlayerUri( + url: Uri, + webView: WebView, + ): WebResourceResponse { + if (url.pathSegments?.firstOrNull()?.equals(DUCK_PLAYER_OPEN_IN_YOUTUBE_PATH, ignoreCase = true) == true) { + createYoutubeWatchUrlFromDuckPlayer(url)?.let { youtubeUrl -> + youTubeRequestedFromDuckPlayer() + withContext(dispatchers.main()) { + webView.loadUrl(youtubeUrl) + } + } + } else { + createYoutubeNoCookieFromDuckPlayer(url)?.let { youtubeUrl -> + withContext(dispatchers.main()) { + webView.loadUrl(youtubeUrl) + } + } + } + return WebResourceResponse(null, null, null) } }