Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -73,6 +81,11 @@ interface DuckPlayerFeatureRepository {

suspend fun wasUsedBefore(): Boolean
suspend fun setUsed()

suspend fun requestEmbed(
url: String,
headers: Map<String, String>,
): InputStream?
}

@SingleInstanceIn(AppScope::class)
Expand All @@ -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<OkHttpClient>,
) : DuckPlayerFeatureRepository {

override fun getDuckPlayerRemoteConfigJson(): String {
Expand Down Expand Up @@ -212,4 +226,17 @@ class RealDuckPlayerFeatureRepository @Inject constructor(
override suspend fun setUsed() {
duckPlayerDataStore.setUsed()
}

override suspend fun requestEmbed(
url: String,
headers: Map<String, String>,
): 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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 {

Expand Down Expand Up @@ -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
Expand Down
Loading