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 @@ -17,8 +17,8 @@
package com.duckduckgo.privacy.config.impl.features.drm

import androidx.core.net.toUri
import com.duckduckgo.app.browser.UriString.Companion.sameOrSubdomain
import com.duckduckgo.app.privacy.db.UserAllowListRepository
import com.duckduckgo.common.utils.baseHost
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.feature.toggles.api.FeatureToggle
import com.duckduckgo.privacy.config.api.Drm
Expand All @@ -40,13 +40,16 @@ class RealDrm @Inject constructor(

override fun isDrmAllowedForUrl(url: String): Boolean {
val uri = url.toUri()
val isFeatureEnabled = featureToggle.isFeatureEnabled(PrivacyFeatureName.DrmFeatureName.value, defaultValue = true)
return (isFeatureEnabled && domainsThatAllowDrm(uri.baseHost)) ||
// Defaults to false: the persister clears every privacy config toggle before applying a new config and
// only reinserts the features it contained, so an unset value means eme was dropped from the config. The
// stored exceptions outlive it, and they must not keep granting DRM once the feature is gone.
val isFeatureEnabled = featureToggle.isFeatureEnabled(PrivacyFeatureName.DrmFeatureName.value, defaultValue = false)
return (isFeatureEnabled && domainsThatAllowDrm(url)) ||
userAllowListRepository.isUriInUserAllowList(uri) ||
unprotectedTemporary.isAnException(uri.toString())
}

private fun domainsThatAllowDrm(host: String?): Boolean {
return drmRepository.exceptions.firstOrNull { it.domain == host } != null
private fun domainsThatAllowDrm(url: String): Boolean {
return drmRepository.exceptions.any { exception -> sameOrSubdomain(url, exception.domain) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import java.util.concurrent.CopyOnWriteArrayList

Expand All @@ -51,6 +52,38 @@ class RealDrmTest {
assertTrue(testee.isDrmAllowedForUrl(url))
}

@Test
fun whenIsDrmAllowedForUrlIfSubdomainOfExceptionThenTrueIsReturned() {
giveFeatureIsEnabled()
givenUrlIsInExceptionList("foxnews.com")

assertTrue(testee.isDrmAllowedForUrl("https://static.foxnews.com"))
}

@Test
fun whenIsDrmAllowedForUrlIfNestedSubdomainOfExceptionThenTrueIsReturned() {
giveFeatureIsEnabled()
givenUrlIsInExceptionList("foxnews.com")

assertTrue(testee.isDrmAllowedForUrl("https://a.b.foxnews.com"))
}

@Test
fun whenIsDrmAllowedForUrlIfParentDomainOfExceptionThenFalseIsReturned() {
giveFeatureIsEnabled()
givenUrlIsInExceptionList("open.spotify.com")

assertFalse(testee.isDrmAllowedForUrl("https://spotify.com"))
}

@Test
fun whenIsDrmAllowedForUrlIfUnrelatedDomainSharesSuffixThenFalseIsReturned() {
giveFeatureIsEnabled()
givenUrlIsInExceptionList("foxnews.com")

assertFalse(testee.isDrmAllowedForUrl("https://notfoxnews.com"))
}

@Test
fun whenIsDrmAllowedForUrlIfFeatureIsEnabledAndDomainIsNotInExceptionsListThenFalseIsReturned() {
giveFeatureIsEnabled()
Expand Down Expand Up @@ -87,12 +120,22 @@ class RealDrmTest {
assertTrue(testee.isDrmAllowedForUrl(url))
}

@Test
fun whenIsDrmAllowedForUrlThenEmeFeatureDefaultsToDisabled() {
givenUrlIsInExceptionList()

testee.isDrmAllowedForUrl("https://open.spotify.com")

// An unset toggle means eme was dropped from the config, so the stored exceptions must stop granting
verify(mockFeatureToggle).isFeatureEnabled(PrivacyFeatureName.DrmFeatureName.value, false)
}

private fun giveFeatureIsEnabled() {
whenever(mockFeatureToggle.isFeatureEnabled(eq(PrivacyFeatureName.DrmFeatureName.value), any())).thenReturn(true)
}

private fun givenUrlIsInExceptionList() {
val exceptions = CopyOnWriteArrayList<FeatureException>().apply { add(FeatureException("open.spotify.com", "my reason here")) }
private fun givenUrlIsInExceptionList(domain: String = "open.spotify.com") {
val exceptions = CopyOnWriteArrayList<FeatureException>().apply { add(FeatureException(domain, "my reason here")) }
whenever(mockDrmRepository.exceptions).thenReturn(exceptions)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import com.duckduckgo.site.permissions.api.SitePermissionsDialogLauncher
import com.duckduckgo.site.permissions.api.SitePermissionsGrantedListener
import com.duckduckgo.site.permissions.api.SitePermissionsManager.LocationPermissionRequest
import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissions
import com.duckduckgo.site.permissions.impl.feature.DrmPolicyFeature
import com.duckduckgo.site.permissions.impl.feature.isCentralPolicyEnabled
import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType
import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType.ALLOW_ALWAYS
import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType.DENY_ALWAYS
Expand All @@ -68,6 +70,7 @@ class SitePermissionsDialogActivityLauncher @Inject constructor(
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
private val duckAiHostProvider: DuckAiHostProvider,
private val browserMode: BrowserMode,
private val drmPolicyFeature: DrmPolicyFeature,
) : SitePermissionsDialogLauncher {

private lateinit var sitePermissionRequest: PermissionRequest
Expand Down Expand Up @@ -252,21 +255,26 @@ class SitePermissionsDialogActivityLauncher @Inject constructor(
) {
val domain = url.extractDomain() ?: url

// Check if user allowed or denied per session
val sessionSetting = sitePermissionsRepository.getDrmForSession(domain)
if (sessionSetting != null) {
if (sessionSetting) {
grantPermissions()
} else {
denyPermissions()
// Session state and the block list are rules in the central policy, which has already decided PROMPT
// by the time we get here. Re-checking them would be a second evaluator, and its outcome would not
// appear in the policy decision or the auto-grant pixel.
if (!drmPolicyFeature.isCentralPolicyEnabled()) {
// Check if user allowed or denied per session
val sessionSetting = sitePermissionsRepository.getDrmForSession(tabId, domain)
if (sessionSetting != null) {
if (sessionSetting) {
grantPermissions()
} else {
denyPermissions()
}
return
}
return
}

// No session-based setting --> check if DRM blocked by config
if (sitePermissionsRepository.isDrmBlockedForUrlByConfig(url)) {
denyPermissions()
return
// No session-based setting --> check if DRM blocked by config
if (sitePermissionsRepository.isDrmBlockedForUrlByConfig(url)) {
denyPermissions()
return
}
}

// No session-based setting and no config --> proceed to show dialog
Expand Down Expand Up @@ -305,7 +313,7 @@ class SitePermissionsDialogActivityLauncher @Inject constructor(
// Fire mode grants the in-session WebView permission below but must not write the
// choice into the shared (app-wide, in-memory) DRM session map that regular tabs read.
if (browserMode != BrowserMode.FIRE) {
sitePermissionsRepository.saveDrmForSession(domain, true)
sitePermissionsRepository.saveDrmForSession(tabId, domain, true)
}
grantPermissions()
}
Expand All @@ -320,7 +328,7 @@ class SitePermissionsDialogActivityLauncher @Inject constructor(
} else if (browserMode != BrowserMode.FIRE) {
// Fire mode denied the in-session permission above but must not write the
// choice into the shared (app-wide, in-memory) DRM session map that regular tabs read.
sitePermissionsRepository.saveDrmForSession(domain, false)
sitePermissionsRepository.saveDrmForSession(tabId, domain, false)
}
sendNegativeDialogClickPixel(SitePermissionsPixelValues.DRM, rememberChoice)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ import com.duckduckgo.site.permissions.api.SitePermissionsManager
import com.duckduckgo.site.permissions.api.SitePermissionsManager.LocationPermissionRequest
import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissionQueryResponse
import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissions
import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction
import com.duckduckgo.site.permissions.impl.drm.DrmPolicyManager
import com.duckduckgo.site.permissions.impl.drm.DrmSessionStore
import com.duckduckgo.site.permissions.impl.feature.DrmPolicyFeature
import com.duckduckgo.site.permissions.impl.feature.MicrophoneSitePermissionsDomainRecoveryFeature
import com.duckduckgo.site.permissions.impl.feature.isCentralPolicyEnabled
import com.squareup.anvil.annotations.ContributesBinding
import kotlinx.coroutines.withContext
import logcat.logcat
Expand All @@ -46,6 +51,9 @@ class SitePermissionsManagerImpl @Inject constructor(
private val dispatcherProvider: DispatcherProvider,
private val context: Context,
private val microphoneSitePermissionsDomainRecoveryFeature: MicrophoneSitePermissionsDomainRecoveryFeature,
private val drmPolicyFeature: DrmPolicyFeature,
private val drmPolicyManager: DrmPolicyManager,
private val drmSessionStore: DrmSessionStore,
duckAiHostProvider: DuckAiHostProvider,
) : SitePermissionsManager {

Expand All @@ -65,14 +73,26 @@ class SitePermissionsManagerImpl @Inject constructor(
val autoAccept = mutableListOf<String>()
val url = request.origin.toString()

val drmDecision = if (!request.resources.contains(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID)) {
null
} else {
withContext(dispatcherProvider.io()) {
drmPolicyManager
.takeIf { drmPolicyFeature.isCentralPolicyEnabled() }
?.decide(url, tabId)
?.also { logcat { "Permissions: drm policy decision for $url is $it" } }
}
}

val sitePermissionsAllowedToAsk = request.resources
.filter { drmDecision == null || it != PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID }
.filter { isPermissionSupported(it) && isHardwareSupported(it) }
.filter { sitePermissionsRepository.isDomainAllowedToAsk(url, it) }
.toTypedArray()

logcat { "Permissions: sitePermissionsAllowedToAsk in $url ${sitePermissionsAllowedToAsk.asList()}" }

val sitePermissionsGranted = if (microphoneSitePermissionsDomainRecoveryFeature.self().isEnabled()) {
val filteredPermissionsGranted = if (microphoneSitePermissionsDomainRecoveryFeature.self().isEnabled()) {
getSitePermissionsGranted(url, tabId, sitePermissionsAllowedToAsk).filter { permission ->
if (permission == PermissionRequest.RESOURCE_AUDIO_CAPTURE && audioCapturePermissionDomains.contains(url.extractDomain())) {
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED &&
Expand All @@ -85,6 +105,12 @@ class SitePermissionsManagerImpl @Inject constructor(
getSitePermissionsGranted(url, tabId, sitePermissionsAllowedToAsk)
}

val sitePermissionsGranted = if (drmDecision?.action == DrmPolicyAction.GRANT) {
filteredPermissionsGranted + PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID
} else {
filteredPermissionsGranted
}

if (sitePermissionsGranted.isNotEmpty()) {
withContext(dispatcherProvider.main()) {
logcat { "Permissions: site permission granted" }
Expand All @@ -94,7 +120,8 @@ class SitePermissionsManagerImpl @Inject constructor(

logcat { "Permissions: sitePermissionsGranted for $url are ${sitePermissionsGranted.asList()}" }

val userList = sitePermissionsAllowedToAsk.filter { !sitePermissionsGranted.contains(it) }
val userList = sitePermissionsAllowedToAsk.filter { !sitePermissionsGranted.contains(it) } +
listOfNotNull(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID.takeIf { drmDecision?.action == DrmPolicyAction.PROMPT })
if (userList.isEmpty() && sitePermissionsGranted.isEmpty()) {
withContext(dispatcherProvider.main()) {
logcat { "Permissions: site permission not granted, deny" }
Expand All @@ -115,6 +142,7 @@ class SitePermissionsManagerImpl @Inject constructor(
}

override suspend fun clearAllButFireproof(fireproofDomains: List<String>) {
drmSessionStore.clear()
sitePermissionsRepository.sitePermissionsForAllWebsites().forEach { permission ->
if (!fireproofDomains.contains(permission.domain)) {
sitePermissionsRepository.deletePermissionsForSite(permission.domain)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,20 @@ import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.common.utils.extractDomain
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.site.permissions.api.SitePermissionsManager.LocationPermissionRequest
import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction
import com.duckduckgo.site.permissions.impl.drm.DrmPolicyManager
import com.duckduckgo.site.permissions.impl.drm.DrmSessionStore
import com.duckduckgo.site.permissions.impl.drmblock.DrmBlock
import com.duckduckgo.site.permissions.impl.feature.DrmPolicyFeature
import com.duckduckgo.site.permissions.impl.feature.isCentralPolicyEnabled
import com.duckduckgo.site.permissions.store.SitePermissionsPreferences
import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType
import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionsDao
import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionsEntity
import com.duckduckgo.site.permissions.store.sitepermissionsallowed.SitePermissionAllowedEntity
import com.duckduckgo.site.permissions.store.sitepermissionsallowed.SitePermissionsAllowedDao
import com.squareup.anvil.annotations.ContributesBinding
import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
Expand All @@ -50,8 +56,8 @@ interface SitePermissionsRepository {
fun sitePermissionsWebsitesFlow(): Flow<List<SitePermissionsEntity>>
fun sitePermissionsForAllWebsites(): List<SitePermissionsEntity>
fun sitePermissionsAllowedFlow(): Flow<List<SitePermissionAllowedEntity>>
fun getDrmForSession(domain: String): Boolean?
fun saveDrmForSession(domain: String, allowed: Boolean)
fun getDrmForSession(tabId: String, domain: String): Boolean?
fun saveDrmForSession(tabId: String, domain: String, allowed: Boolean)
fun isDrmBlockedForUrlByConfig(url: String): Boolean
suspend fun undoDeleteAll(sitePermissions: List<SitePermissionsEntity>, allowedSites: List<SitePermissionAllowedEntity>)
suspend fun deleteAll()
Expand All @@ -69,6 +75,9 @@ class SitePermissionsRepositoryImpl @Inject constructor(
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
private val drmBlock: DrmBlock,
private val drmSessionStore: DrmSessionStore,
private val drmPolicyFeature: DrmPolicyFeature,
private val drmPolicyManager: Lazy<DrmPolicyManager>,
) : SitePermissionsRepository {

override var askCameraEnabled: Boolean
Expand All @@ -94,11 +103,16 @@ class SitePermissionsRepositoryImpl @Inject constructor(
sitePermissionsPreferences.askLocationEnabled = value
}

// Kept for the flag-off path only; the shared DrmSessionStore replaces it once drmPolicy is fully rolled out.
private val drmSessions = mutableMapOf<String, Boolean>()

override suspend fun isDrmEnabledForSite(url: String): Boolean {
val domain = url.extractDomain() ?: url
if (withContext(dispatcherProvider.io()) { drmPolicyFeature.isCentralPolicyEnabled() }) {
// "Permitted or promptable" rather than "granted" — this feeds the breakage report's drmEnabled field.
return drmPolicyManager.get().decide(url).action != DrmPolicyAction.DENY
}

val domain = url.extractDomain() ?: url
drmSessions[domain]?.let { return it }
if (isDrmBlockedForUrlByConfig(url)) return false

Expand Down Expand Up @@ -201,12 +215,20 @@ class SitePermissionsRepositoryImpl @Inject constructor(
return sitePermissionsAllowedDao.getAllSitesPermissionsAllowedAsFlow()
}

override fun getDrmForSession(domain: String): Boolean? {
return drmSessions[domain]
override fun getDrmForSession(tabId: String, domain: String): Boolean? {
return if (drmPolicyFeature.isCentralPolicyEnabled()) {
drmSessionStore.get(tabId, domain)
} else {
drmSessions[domain]
}
}

override fun saveDrmForSession(domain: String, allowed: Boolean) {
drmSessions[domain] = allowed
override fun saveDrmForSession(tabId: String, domain: String, allowed: Boolean) {
if (drmPolicyFeature.isCentralPolicyEnabled()) {
drmSessionStore.save(tabId, domain, allowed)
} else {
drmSessions[domain] = allowed
}
}

override fun isDrmBlockedForUrlByConfig(url: String): Boolean {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2026 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.site.permissions.impl

import android.net.Uri
import com.duckduckgo.app.privacy.db.UserAllowListRepository
import com.duckduckgo.common.utils.extensions.toTldPlusOneOrSelf

/**
* Whether the user has turned protections off for this URI.
*
* Protections are stored for the host of the page the user was looking at, while a DRM request usually
* arrives from a subresource origin, so the registrable domain is checked too. Shared by the block list
* and the policy so both answer the question the same way.
*/
internal fun UserAllowListRepository.isSiteUnprotectedByUser(uri: Uri): Boolean {
if (isUriInUserAllowList(uri)) return true
val host = uri.host ?: return false
return host.hostAndParentCandidates().any { isDomainInUserAllowList(it) }
}

/**
* This host and every parent up to its registrable domain, each with its `www.` spelling.
*
* The eme and emeBlock lists match subdomains, so a value the user stored against any parent of the
* request origin has to be found as well, or remote config would override an explicit choice.
*/
internal fun String.hostAndParentCandidates(): List<String> {
val registrableDomain = toTldPlusOneOrSelf()
val chain = mutableListOf<String>()
var current = this
while (true) {
chain.add(current)
if (current == registrableDomain) break
val parent = current.substringAfter('.', "")
if (parent.isEmpty() || parent.length < registrableDomain.length) break
current = parent
}
return chain.flatMap { listOf(it, "www.$it") }.distinct()
}
Loading
Loading