diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeature.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeature.kt index 299c163a081a..e6dc5e5a2f4e 100644 --- a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeature.kt +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeature.kt @@ -57,4 +57,11 @@ interface DuckPlayerFeature { */ @Toggle.DefaultValue(DefaultFeatureValue.FALSE) fun customError(): Toggle + + /** + * @return `true` when the remote config has the "addCustomEmbedReferer" feature flag enabled + * If the remote feature is not present defaults to `false` + */ + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun addCustomEmbedReferer(): Toggle } diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeatureRepository.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeatureRepository.kt index aa0fe1ce2724..199e70c463d1 100644 --- a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeatureRepository.kt +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeatureRepository.kt @@ -24,13 +24,21 @@ import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Disabled import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled import com.squareup.anvil.annotations.ContributesBinding +import dagger.Lazy import dagger.SingleInstanceIn +import java.io.IOException +import java.io.InputStream import javax.inject.Inject +import javax.inject.Named import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import logcat.logcat +import okhttp3.Headers.Companion.toHeaders +import okhttp3.OkHttpClient +import okhttp3.Request interface DuckPlayerFeatureRepository { fun getDuckPlayerRemoteConfigJson(): String @@ -73,6 +81,11 @@ interface DuckPlayerFeatureRepository { suspend fun wasUsedBefore(): Boolean suspend fun setUsed() + + suspend fun requestEmbed( + url: String, + headers: Map, + ): InputStream? } @SingleInstanceIn(AppScope::class) @@ -81,6 +94,7 @@ class RealDuckPlayerFeatureRepository @Inject constructor( private val duckPlayerDataStore: DuckPlayerDataStore, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, + @Named("api") private val okHttpClient: Lazy, ) : DuckPlayerFeatureRepository { override fun getDuckPlayerRemoteConfigJson(): String { @@ -212,4 +226,17 @@ class RealDuckPlayerFeatureRepository @Inject constructor( override suspend fun setUsed() { duckPlayerDataStore.setUsed() } + + override suspend fun requestEmbed( + url: String, + headers: Map, + ): InputStream? { + return try { + val okHttpRequest = Request.Builder().url(url).headers(headers.toHeaders()).build() + withContext(dispatcherProvider.io()) { okHttpClient.get().newCall(okHttpRequest).execute().body?.byteStream() } + } catch (e: IOException) { + logcat { "Request failed: ${e.message}" } + null + } + } } 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 40fe0f73909e..794280edd9af 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 @@ -80,6 +80,8 @@ 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 EMBED_REFERER_VALUE = "http://android.mobile.duckduckgo.com" interface DuckPlayerInternal : DuckPlayer { /** @@ -254,12 +256,24 @@ class RealDuckPlayer @Inject constructor( return isDuckPlayerUri(uri.toUri()) } + private fun isYouTubeNoCookieUri(uri: Uri): Boolean { + val embedUrl = duckPlayerFeatureRepository.getYouTubeEmbedUrl() + return uri.host?.removePrefix("www.") == embedUrl + } + + private fun isYouTubeNoCookieEmbedUri( + uri: Uri, + webViewUrl: String?, + ): Boolean { + webViewUrl ?: return false + if (!isYouTubeNoCookieUri(uri) || uri.pathSegments.firstOrNull() != "embed") return false + return webViewUrl.toUri().getQueryParameter(DUCK_PLAYER_VIDEO_ID_QUERY_PARAM) == uri.pathSegments.getOrNull(1) + } + override fun isSimulatedYoutubeNoCookie(uri: Uri): Boolean { val validPaths = duckPlayerLocalFilesPath.assetsPath() - val embedUrl = duckPlayerFeatureRepository.getYouTubeEmbedUrl() return ( - uri.host?.removePrefix("www.") == - embedUrl && ( + isYouTubeNoCookieUri(uri) && ( uri.pathSegments.firstOrNull() == null || validPaths.any { uri.path?.contains(it) == true } || (uri.pathSegments.firstOrNull() != "embed" && uri.getQueryParameter(DUCK_PLAYER_VIDEO_ID_QUERY_PARAM) != null) @@ -305,15 +319,28 @@ class RealDuckPlayer @Inject constructor( return processDuckPlayerUri(url, webView) } else { if (!isFeatureEnabled) return null + val webViewUrl = withContext(dispatchers.main()) { webView.url } if (isYoutubeWatchUrl(url)) { return processYouTubeWatchUri(request, url, webView) } else if (isSimulatedYoutubeNoCookie(url)) { return processSimulatedYouTubeNoCookieUri(url, webView) + } else if (duckPlayerFeature.addCustomEmbedReferer().isEnabled() && isYouTubeNoCookieEmbedUri(url, webViewUrl)) { + return getEmbedWithReferer(request)?.let { inputStream -> + WebResourceResponse("text/html", "UTF-8", inputStream) + } } } return null } + private suspend fun getEmbedWithReferer(request: WebResourceRequest): InputStream? { + val headers = request.requestHeaders + .filterNot { it.key.lowercase() == REFERER_HEADER } + .plus(REFERER_HEADER to EMBED_REFERER_VALUE) + + return duckPlayerFeatureRepository.requestEmbed(request.url.toString(), headers) + } + private fun processSimulatedYouTubeNoCookieUri( url: Uri, webView: WebView, diff --git a/duckplayer/duckplayer-impl/src/test/kotlin/com/duckduckgo/duckplayer/impl/RealDuckPlayerTest.kt b/duckplayer/duckplayer-impl/src/test/kotlin/com/duckduckgo/duckplayer/impl/RealDuckPlayerTest.kt index 7ba07ba07616..705cbe3734f3 100644 --- a/duckplayer/duckplayer-impl/src/test/kotlin/com/duckduckgo/duckplayer/impl/RealDuckPlayerTest.kt +++ b/duckplayer/duckplayer-impl/src/test/kotlin/com/duckduckgo/duckplayer/impl/RealDuckPlayerTest.kt @@ -16,6 +16,7 @@ package com.duckduckgo.duckplayer.impl +import android.annotation.SuppressLint import android.content.Context import android.content.res.AssetManager import android.net.Uri @@ -48,6 +49,7 @@ import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_VIEW_FROM_ import com.duckduckgo.duckplayer.impl.DuckPlayerPixelName.DUCK_PLAYER_WATCH_ON_YOUTUBE import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.Toggle.State +import java.io.InputStream import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.take @@ -67,6 +69,7 @@ import org.mockito.Mockito.verify import org.mockito.kotlin.never import org.mockito.kotlin.whenever +@SuppressLint("DenyListedApi") @RunWith(AndroidJUnit4::class) class RealDuckPlayerTest { @@ -757,6 +760,93 @@ class RealDuckPlayerTest { assertNull(result) } + @Test + fun whenIsYouTubeNoCookieEmbedUriForCurrentDuckPlayerVideoAndAddCustomEmbedReferer_thenRequestEmbedWithCustomHeaders() = runTest { + val mockRequest: WebResourceRequest = mock() + whenever(mockRequest.isForMainFrame).thenReturn(true) + val headers = mapOf("header" to "value", "referer" to "value") + val expectedHeaders = headers.plus("referer" to "http://android.mobile.duckduckgo.com") + whenever(mockRequest.requestHeaders).thenReturn(headers) + val url: Uri = Uri.parse("https://www.youtube-nocookie.com/embed/12345") + whenever(mockRequest.url).thenReturn(url) + duckPlayerFeature.addCustomEmbedReferer().setRawStoredState(State(true)) + val webView: WebView = mock() + whenever(webView.url).thenReturn("https://www.youtube-nocookie.com?videoID=12345") + whenever(mockDuckPlayerFeatureRepository.getUserPreferences()).thenReturn(UserPreferences(true, AlwaysAsk)) + val mockInputStream: InputStream = mock() + whenever(mockDuckPlayerFeatureRepository.requestEmbed(url.toString(), expectedHeaders)).thenReturn(mockInputStream) + + val result = testee.intercept(mockRequest, url, webView) + + verify(mockDuckPlayerFeatureRepository).requestEmbed(url.toString(), expectedHeaders) + assertNotNull(result) + } + + @Test + fun whenIsYouTubeNoCookieEmbedUriForCurrentDuckPlayerVideoAndAddCustomEmbedRefererAndRequestEmbedReturnsNull_thenReturnNull() = runTest { + val mockRequest: WebResourceRequest = mock() + whenever(mockRequest.isForMainFrame).thenReturn(true) + val headers = mapOf("header" to "value", "referer" to "value") + val expectedHeaders = headers.plus("referer" to "http://android.mobile.duckduckgo.com") + whenever(mockRequest.requestHeaders).thenReturn(headers) + val url: Uri = Uri.parse("https://www.youtube-nocookie.com/embed/12345") + whenever(mockRequest.url).thenReturn(url) + duckPlayerFeature.addCustomEmbedReferer().setRawStoredState(State(true)) + val webView: WebView = mock() + whenever(webView.url).thenReturn("https://www.youtube-nocookie.com?videoID=12345") + whenever(mockDuckPlayerFeatureRepository.getUserPreferences()).thenReturn(UserPreferences(true, AlwaysAsk)) + whenever(mockDuckPlayerFeatureRepository.requestEmbed(url.toString(), expectedHeaders)).thenReturn(null) + + val result = testee.intercept(mockRequest, url, webView) + + verify(mockDuckPlayerFeatureRepository).requestEmbed(url.toString(), expectedHeaders) + assertNull(result) + } + + @Test + fun whenIsYouTubeNoCookieEmbedUriForCurrentDuckPlayerVideoAndAddCustomEmbedRefererDisabled_thenReturnNull() = runTest { + val mockRequest: WebResourceRequest = mock() + whenever(mockRequest.isForMainFrame).thenReturn(true) + val headers = mapOf("header" to "value") + val expectedHeaders = headers.plus("referer" to "http://android.mobile.duckduckgo.com") + whenever(mockRequest.requestHeaders).thenReturn(headers) + val url: Uri = Uri.parse("https://www.youtube-nocookie.com/embed/12345") + whenever(mockRequest.url).thenReturn(url) + duckPlayerFeature.addCustomEmbedReferer().setRawStoredState(State(false)) + val webView: WebView = mock() + whenever(webView.url).thenReturn("https://www.youtube-nocookie.com?videoID=12345") + whenever(mockDuckPlayerFeatureRepository.getUserPreferences()).thenReturn(UserPreferences(true, AlwaysAsk)) + val mockInputStream: InputStream = mock() + whenever(mockDuckPlayerFeatureRepository.requestEmbed(url.toString(), expectedHeaders)).thenReturn(mockInputStream) + + val result = testee.intercept(mockRequest, url, webView) + + verify(mockDuckPlayerFeatureRepository, never()).requestEmbed(url.toString(), expectedHeaders) + assertNull(result) + } + + @Test + fun whenIsYouTubeNoCookieEmbedUriNotForCurrentDuckPlayerVideoAndAddCustomEmbedReferer_thenReturnNull() = runTest { + val mockRequest: WebResourceRequest = mock() + whenever(mockRequest.isForMainFrame).thenReturn(true) + val headers = mapOf("header" to "value") + val expectedHeaders = headers.plus("referer" to "http://android.mobile.duckduckgo.com") + whenever(mockRequest.requestHeaders).thenReturn(headers) + val url: Uri = Uri.parse("https://www.youtube-nocookie.com/embed/0000") + whenever(mockRequest.url).thenReturn(url) + duckPlayerFeature.addCustomEmbedReferer().setRawStoredState(State(true)) + val webView: WebView = mock() + whenever(webView.url).thenReturn("https://www.youtube-nocookie.com?videoID=12345") + whenever(mockDuckPlayerFeatureRepository.getUserPreferences()).thenReturn(UserPreferences(true, AlwaysAsk)) + val mockInputStream: InputStream = mock() + whenever(mockDuckPlayerFeatureRepository.requestEmbed(url.toString(), expectedHeaders)).thenReturn(mockInputStream) + + val result = testee.intercept(mockRequest, url, webView) + + verify(mockDuckPlayerFeatureRepository, never()).requestEmbed(url.toString(), expectedHeaders) + assertNull(result) + } + // endregion // region willNavigateToDuckPlayer