From f0f316fb0484a22f06ce5a63a9d0ce75260b339a Mon Sep 17 00:00:00 2001 From: 0nko Date: Thu, 20 Aug 2026 15:11:49 +0200 Subject: [PATCH 01/13] Add a central DRM policy manager behind the drmPolicy feature flag --- .../config/impl/features/drm/RealDrm.kt | 5 +- .../config/impl/features/drm/RealDrmTest.kt | 36 +++- .../SitePermissionsDialogActivityLauncher.kt | 6 +- .../impl/SitePermissionsManagerImpl.kt | 27 ++- .../impl/SitePermissionsRepository.kt | 28 ++- .../impl/drm/DrmPolicyEvaluator.kt | 46 +++++ .../permissions/impl/drm/DrmPolicyManager.kt | 54 +++++ .../permissions/impl/drm/DrmSessionStore.kt | 37 ++++ .../impl/drm/RealDrmPolicyManager.kt | 55 +++++ .../impl/feature/DrmPolicyFeature.kt | 34 +++ ...tePermissionsDialogActivityLauncherTest.kt | 4 +- .../impl/SitePermissionsManagerTest.kt | 87 ++++++++ .../impl/SitePermissionsRepositoryTest.kt | 65 +++++- .../impl/drm/DrmPolicyEvaluatorTest.kt | 165 +++++++++++++++ .../impl/drm/RealDrmPolicyManagerTest.kt | 195 ++++++++++++++++++ 15 files changed, 818 insertions(+), 26 deletions(-) create mode 100644 site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmPolicyEvaluator.kt create mode 100644 site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmPolicyManager.kt create mode 100644 site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmSessionStore.kt create mode 100644 site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt create mode 100644 site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/feature/DrmPolicyFeature.kt create mode 100644 site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/DrmPolicyEvaluatorTest.kt create mode 100644 site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt diff --git a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrm.kt b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrm.kt index bf44fff9f548..b403d05c6eb2 100644 --- a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrm.kt +++ b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrm.kt @@ -47,6 +47,9 @@ class RealDrm @Inject constructor( } private fun domainsThatAllowDrm(host: String?): Boolean { - return drmRepository.exceptions.firstOrNull { it.domain == host } != null + host ?: return false + return drmRepository.exceptions.any { exception -> + host == exception.domain || host.endsWith(".${exception.domain}") + } } } diff --git a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrmTest.kt b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrmTest.kt index f35f83711efc..7ca7887f86e5 100644 --- a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrmTest.kt +++ b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrmTest.kt @@ -51,6 +51,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() @@ -91,8 +123,8 @@ class RealDrmTest { whenever(mockFeatureToggle.isFeatureEnabled(eq(PrivacyFeatureName.DrmFeatureName.value), any())).thenReturn(true) } - private fun givenUrlIsInExceptionList() { - val exceptions = CopyOnWriteArrayList().apply { add(FeatureException("open.spotify.com", "my reason here")) } + private fun givenUrlIsInExceptionList(domain: String = "open.spotify.com") { + val exceptions = CopyOnWriteArrayList().apply { add(FeatureException(domain, "my reason here")) } whenever(mockDrmRepository.exceptions).thenReturn(exceptions) } diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncher.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncher.kt index 4917d22c543b..68f073a2d872 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncher.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncher.kt @@ -253,7 +253,7 @@ class SitePermissionsDialogActivityLauncher @Inject constructor( val domain = url.extractDomain() ?: url // Check if user allowed or denied per session - val sessionSetting = sitePermissionsRepository.getDrmForSession(domain) + val sessionSetting = sitePermissionsRepository.getDrmForSession(tabId, domain) if (sessionSetting != null) { if (sessionSetting) { grantPermissions() @@ -305,7 +305,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() } @@ -320,7 +320,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) } diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt index c0246cab23eb..ee7d432e10c2 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt @@ -31,6 +31,9 @@ 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.feature.DrmPolicyFeature import com.duckduckgo.site.permissions.impl.feature.MicrophoneSitePermissionsDomainRecoveryFeature import com.squareup.anvil.annotations.ContributesBinding import kotlinx.coroutines.withContext @@ -46,6 +49,8 @@ class SitePermissionsManagerImpl @Inject constructor( private val dispatcherProvider: DispatcherProvider, private val context: Context, private val microphoneSitePermissionsDomainRecoveryFeature: MicrophoneSitePermissionsDomainRecoveryFeature, + private val drmPolicyFeature: DrmPolicyFeature, + private val drmPolicyManager: DrmPolicyManager, duckAiHostProvider: DuckAiHostProvider, ) : SitePermissionsManager { @@ -65,14 +70,25 @@ class SitePermissionsManagerImpl @Inject constructor( val autoAccept = mutableListOf() val url = request.origin.toString() + val drmDecision = if ( + request.resources.contains(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) && + drmPolicyFeature.centralPolicy().isEnabled() + ) { + drmPolicyManager.decide(url, tabId) + } else { + null + } + logcat { "Permissions: drm policy decision for $url is $drmDecision" } + 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 && @@ -85,6 +101,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" } @@ -94,7 +116,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" } diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepository.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepository.kt index 4341694615b1..20ebe7f9c9b2 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepository.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepository.kt @@ -22,7 +22,11 @@ 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.store.SitePermissionsPreferences import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionsDao @@ -30,6 +34,7 @@ import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionsEnti 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 @@ -50,8 +55,8 @@ interface SitePermissionsRepository { fun sitePermissionsWebsitesFlow(): Flow> fun sitePermissionsForAllWebsites(): List fun sitePermissionsAllowedFlow(): Flow> - 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, allowedSites: List) suspend fun deleteAll() @@ -69,6 +74,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, ) : SitePermissionsRepository { override var askCameraEnabled: Boolean @@ -94,12 +102,12 @@ class SitePermissionsRepositoryImpl @Inject constructor( sitePermissionsPreferences.askLocationEnabled = value } - private val drmSessions = mutableMapOf() - override suspend fun isDrmEnabledForSite(url: String): Boolean { - val domain = url.extractDomain() ?: url + if (drmPolicyFeature.centralPolicy().isEnabled()) { + // "Permitted or promptable" rather than "granted" — this feeds the breakage report's drmEnabled field. + return drmPolicyManager.get().decide(url).action != DrmPolicyAction.DENY + } - drmSessions[domain]?.let { return it } if (isDrmBlockedForUrlByConfig(url)) return false return isDomainAllowedToAsk(url, PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) || @@ -201,12 +209,12 @@ 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 drmSessionStore.get(tabId, domain) } - override fun saveDrmForSession(domain: String, allowed: Boolean) { - drmSessions[domain] = allowed + override fun saveDrmForSession(tabId: String, domain: String, allowed: Boolean) { + drmSessionStore.save(tabId, domain, allowed) } override fun isDrmBlockedForUrlByConfig(url: String): Boolean { diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmPolicyEvaluator.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmPolicyEvaluator.kt new file mode 100644 index 000000000000..047a7cc8421d --- /dev/null +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmPolicyEvaluator.kt @@ -0,0 +1,46 @@ +/* + * 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.drm + +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction.DENY +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction.GRANT +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction.PROMPT +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.ALLOW_LIST +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.BLOCK_LIST +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.GLOBAL_OFF +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.NO_RULE +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.PROTECTIONS_OFF +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.SESSION +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.USER_ALLOW_ALWAYS +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.USER_DENY_ALWAYS +import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType.ALLOW_ALWAYS +import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType.DENY_ALWAYS + +/* + * First match wins. isBlockedByBlockList comes from a composite that is already false for + * protections-off sites, so a block-listed domain with protections disabled grants at PROTECTIONS_OFF. + */ +internal fun DrmPolicyContext.evaluate(): DrmPolicyDecision = when { + !isGlobalAskEnabled -> DrmPolicyDecision(DENY, GLOBAL_OFF) + siteSetting == DENY_ALWAYS -> DrmPolicyDecision(DENY, USER_DENY_ALWAYS) + siteSetting == ALLOW_ALWAYS -> DrmPolicyDecision(GRANT, USER_ALLOW_ALWAYS) + sessionChoice != null -> DrmPolicyDecision(if (sessionChoice) GRANT else DENY, SESSION) + isBlockedByBlockList -> DrmPolicyDecision(DENY, BLOCK_LIST) + isSiteUnprotected -> DrmPolicyDecision(GRANT, PROTECTIONS_OFF) + isAllowedByAllowList -> DrmPolicyDecision(GRANT, ALLOW_LIST) + else -> DrmPolicyDecision(PROMPT, NO_RULE) +} diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmPolicyManager.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmPolicyManager.kt new file mode 100644 index 000000000000..034930e937b3 --- /dev/null +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmPolicyManager.kt @@ -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.drm + +import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType + +enum class DrmPolicyAction { + GRANT, + DENY, + PROMPT, +} + +enum class DrmPolicyReason { + GLOBAL_OFF, + USER_DENY_ALWAYS, + USER_ALLOW_ALWAYS, + SESSION, + BLOCK_LIST, + PROTECTIONS_OFF, + ALLOW_LIST, + NO_RULE, +} + +data class DrmPolicyDecision( + val action: DrmPolicyAction, + val reason: DrmPolicyReason, +) + +data class DrmPolicyContext( + val isGlobalAskEnabled: Boolean, + val siteSetting: SitePermissionAskSettingType?, + val sessionChoice: Boolean?, + val isBlockedByBlockList: Boolean, + val isSiteUnprotected: Boolean, + val isAllowedByAllowList: Boolean, +) + +interface DrmPolicyManager { + suspend fun decide(url: String, tabId: String? = null): DrmPolicyDecision +} diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmSessionStore.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmSessionStore.kt new file mode 100644 index 000000000000..5d5394885b9c --- /dev/null +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmSessionStore.kt @@ -0,0 +1,37 @@ +/* + * 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.drm + +import com.duckduckgo.di.scopes.AppScope +import dagger.SingleInstanceIn +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +@SingleInstanceIn(AppScope::class) +class DrmSessionStore @Inject constructor() { + + // Entries for closed tabs are never reclaimed — one Boolean per (tab, domain) that saw a prompt. + private val sessions = ConcurrentHashMap() + + fun get(tabId: String, domain: String): Boolean? = sessions[key(tabId, domain)] + + fun save(tabId: String, domain: String, allowed: Boolean) { + sessions[key(tabId, domain)] = allowed + } + + private fun key(tabId: String, domain: String) = "$tabId/$domain" +} diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt new file mode 100644 index 000000000000..384af6a8caae --- /dev/null +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt @@ -0,0 +1,55 @@ +/* + * 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.drm + +import androidx.core.net.toUri +import com.duckduckgo.app.privacy.db.UserAllowListRepository +import com.duckduckgo.common.utils.extractDomain +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.privacy.config.api.Drm +import com.duckduckgo.privacy.config.api.UnprotectedTemporary +import com.duckduckgo.site.permissions.impl.SitePermissionsRepository +import com.duckduckgo.site.permissions.impl.drmblock.DrmBlock +import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class RealDrmPolicyManager @Inject constructor( + private val sitePermissionsRepository: SitePermissionsRepository, + private val drmSessionStore: DrmSessionStore, + private val drmBlock: DrmBlock, + private val drm: Drm, + private val userAllowListRepository: UserAllowListRepository, + private val unprotectedTemporary: UnprotectedTemporary, +) : DrmPolicyManager { + + override suspend fun decide(url: String, tabId: String?): DrmPolicyDecision { + val domain = url.extractDomain() ?: url + val uri = url.toUri() + val siteSetting = sitePermissionsRepository.getSitePermissionsForWebsite(domain)?.askDrmSetting + ?.let { setting -> SitePermissionAskSettingType.entries.firstOrNull { it.name == setting } } + return DrmPolicyContext( + isGlobalAskEnabled = sitePermissionsRepository.askDrmEnabled, + siteSetting = siteSetting, + sessionChoice = tabId?.let { drmSessionStore.get(it, domain) }, + isBlockedByBlockList = drmBlock.isDrmBlockedForUrl(url), + isSiteUnprotected = userAllowListRepository.isUriInUserAllowList(uri) || unprotectedTemporary.isAnException(url), + isAllowedByAllowList = drm.isDrmAllowedForUrl(url), + ).evaluate() + } +} diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/feature/DrmPolicyFeature.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/feature/DrmPolicyFeature.kt new file mode 100644 index 000000000000..4f94400782f0 --- /dev/null +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/feature/DrmPolicyFeature.kt @@ -0,0 +1,34 @@ +/* + * 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.feature + +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "drmPolicy", +) +interface DrmPolicyFeature { + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun self(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun centralPolicy(): Toggle +} diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncherTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncherTest.kt index 16238e6cf1b8..0b036957dc88 100644 --- a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncherTest.kt +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncherTest.kt @@ -68,7 +68,7 @@ class SitePermissionsDialogActivityLauncherTest { @Test fun whenDrmAlreadyAllowedForSessionThenDialogNotShownAndNoImpressionPixelFired() { - whenever(sitePermissionsRepository.getDrmForSession("example.com")).thenReturn(true) + whenever(sitePermissionsRepository.getDrmForSession("tabId", "example.com")).thenReturn(true) val activity: Activity = mock() val request: PermissionRequest = mock() @@ -91,7 +91,7 @@ class SitePermissionsDialogActivityLauncherTest { @Test fun whenDrmBlockedByConfigThenDialogNotShownAndNoImpressionPixelFired() { - whenever(sitePermissionsRepository.getDrmForSession("example.com")).thenReturn(null) + whenever(sitePermissionsRepository.getDrmForSession("tabId", "example.com")).thenReturn(null) whenever(sitePermissionsRepository.isDrmBlockedForUrlByConfig("https://example.com")).thenReturn(true) val activity: Activity = mock() diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerTest.kt index 12c3e501073a..b24d27a69208 100644 --- a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerTest.kt +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerTest.kt @@ -28,12 +28,18 @@ import com.duckduckgo.duckchat.api.DuckAiHostProvider import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissionQueryResponse +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyDecision +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyManager +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason +import com.duckduckgo.site.permissions.impl.feature.DrmPolicyFeature import com.duckduckgo.site.permissions.impl.feature.MicrophoneSitePermissionsDomainRecoveryFeature import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionsEntity import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.never import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyZeroInteractions import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -57,6 +63,8 @@ class SitePermissionsManagerTest { private val fakeMicrophoneSitePermissionsDomainRecoveryFeature = FakeFeatureToggleFactory.create( MicrophoneSitePermissionsDomainRecoveryFeature::class.java, ) + private val drmPolicyFeature = FakeFeatureToggleFactory.create(DrmPolicyFeature::class.java) + private val mockDrmPolicyManager: DrmPolicyManager = mock() private val testee by lazy { SitePermissionsManagerImpl( @@ -66,6 +74,8 @@ class SitePermissionsManagerTest { coroutineRule.testDispatcherProvider, mockContext, fakeMicrophoneSitePermissionsDomainRecoveryFeature, + drmPolicyFeature, + mockDrmPolicyManager, mockDuckAiHostProvider, ) } @@ -78,6 +88,8 @@ class SitePermissionsManagerTest { whenever(mockDuckAiHostProvider.getHost()).thenReturn("duck.ai") whenever(mockPackageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)).thenReturn(true) fakeMicrophoneSitePermissionsDomainRecoveryFeature.self().setRawStoredState(Toggle.State(false)) + drmPolicyFeature.self().setRawStoredState(Toggle.State(true)) + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(false)) } @Test @@ -117,6 +129,81 @@ class SitePermissionsManagerTest { verify(permissionRequest).grant(arrayOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE, PermissionRequest.RESOURCE_VIDEO_CAPTURE)) } + @Test + fun whenCentralPolicyEnabledAndPolicyGrantsThenDrmAutoAccepted() = runTest { + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(true)) + val resources = arrayOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) + whenever(mockDrmPolicyManager.decide(url, tabId)) + .thenReturn(DrmPolicyDecision(DrmPolicyAction.GRANT, DrmPolicyReason.ALLOW_LIST)) + + val permissionRequest: PermissionRequest = mock() + whenever(permissionRequest.origin).thenReturn(url.toUri()) + whenever(permissionRequest.resources).thenReturn(resources) + + val permissions = testee.getSitePermissions(tabId, permissionRequest) + + assertEquals(0, permissions.userHandled.size) + verify(permissionRequest).grant(resources) + verify(permissionRequest, never()).deny() + verify(mockSitePermissionsRepository, never()).isDomainAllowedToAsk(url, PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) + verify(mockSitePermissionsRepository, never()).isDomainGranted(url, tabId, PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) + } + + @Test + fun whenCentralPolicyEnabledAndPolicyDeniesThenRequestDenied() = runTest { + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(true)) + val resources = arrayOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) + whenever(mockDrmPolicyManager.decide(url, tabId)) + .thenReturn(DrmPolicyDecision(DrmPolicyAction.DENY, DrmPolicyReason.BLOCK_LIST)) + + val permissionRequest: PermissionRequest = mock() + whenever(permissionRequest.origin).thenReturn(url.toUri()) + whenever(permissionRequest.resources).thenReturn(resources) + + val permissions = testee.getSitePermissions(tabId, permissionRequest) + + assertEquals(0, permissions.autoAccept.size) + assertEquals(0, permissions.userHandled.size) + verify(permissionRequest).deny() + verify(permissionRequest, never()).grant(any()) + } + + @Test + fun whenCentralPolicyEnabledAndPolicyPromptsThenDrmUserHandled() = runTest { + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(true)) + val resources = arrayOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) + whenever(mockDrmPolicyManager.decide(url, tabId)) + .thenReturn(DrmPolicyDecision(DrmPolicyAction.PROMPT, DrmPolicyReason.NO_RULE)) + + val permissionRequest: PermissionRequest = mock() + whenever(permissionRequest.origin).thenReturn(url.toUri()) + whenever(permissionRequest.resources).thenReturn(resources) + + val permissions = testee.getSitePermissions(tabId, permissionRequest) + + assertEquals(0, permissions.autoAccept.size) + assertEquals(listOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID), permissions.userHandled) + verify(permissionRequest, never()).grant(any()) + verify(permissionRequest, never()).deny() + } + + @Test + fun whenCentralPolicyDisabledThenDrmFollowsExistingPath() = runTest { + val resources = arrayOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) + whenever(mockSitePermissionsRepository.isDomainAllowedToAsk(url, PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID)).thenReturn(true) + whenever(mockSitePermissionsRepository.isDomainGranted(url, tabId, PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID)).thenReturn(false) + + val permissionRequest: PermissionRequest = mock() + whenever(permissionRequest.origin).thenReturn(url.toUri()) + whenever(permissionRequest.resources).thenReturn(resources) + + val permissions = testee.getSitePermissions(tabId, permissionRequest) + + assertEquals(0, permissions.autoAccept.size) + assertEquals(listOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID), permissions.userHandled) + verifyZeroInteractions(mockDrmPolicyManager) + } + @Test fun givenListOfPermissionsThenFilterNotSupportedAndReturnOnlyPermissionsAllowedToAsk() = runTest { val resources = diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepositoryTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepositoryTest.kt index b5802d8b1db1..e8f0199d1118 100644 --- a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepositoryTest.kt +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepositoryTest.kt @@ -19,8 +19,16 @@ package com.duckduckgo.site.permissions.impl import android.webkit.PermissionRequest import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.site.permissions.api.SitePermissionsManager.LocationPermissionRequest +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyDecision +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyManager +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason +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.store.SitePermissionsPreferences import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType.ALLOW_ALWAYS @@ -35,7 +43,9 @@ import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.test.runTest import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -51,6 +61,8 @@ class SitePermissionsRepositoryTest { private val mockSitePermissionsAllowedDao: SitePermissionsAllowedDao = mock() private val mockSitePermissionsPreferences: SitePermissionsPreferences = mock() private val mockDrmBlock: DrmBlock = mock() + private val mockDrmPolicyManager: DrmPolicyManager = mock() + private val drmPolicyFeature = FakeFeatureToggleFactory.create(DrmPolicyFeature::class.java) private val repository = SitePermissionsRepositoryImpl( mockSitePermissionsDao, @@ -59,10 +71,20 @@ class SitePermissionsRepositoryTest { coroutineRule.testScope, coroutineRule.testDispatcherProvider, mockDrmBlock, + DrmSessionStore(), + drmPolicyFeature, + { mockDrmPolicyManager }, ) private val url = "https://domain.com/whatever" private val domain = "domain.com" + private val tabId = "tabId" + + @Before + fun before() { + drmPolicyFeature.self().setRawStoredState(Toggle.State(true)) + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(false)) + } @Test fun givenPermissionNotSupportedThenDomainIsNotAllowedToAsk() = runTest { @@ -127,17 +149,17 @@ class SitePermissionsRepositoryTest { } @Test - fun whenDrmSessionAllowsThenDrmIsEnabledForSite() = runTest { - repository.saveDrmForSession(domain, true) + fun whenDrmSessionSavedThenGetDrmForSessionReturnsIt() { + repository.saveDrmForSession(tabId, domain, true) - assertTrue(repository.isDrmEnabledForSite(url)) + assertTrue(repository.getDrmForSession(tabId, domain) == true) } @Test - fun whenDrmSessionDeniesThenDrmIsDisabledForSite() = runTest { - repository.saveDrmForSession(domain, false) + fun whenDrmSessionSavedForOneTabThenAnotherTabOnSameDomainHasNoSessionChoice() { + repository.saveDrmForSession(tabId, domain, true) - assertFalse(repository.isDrmEnabledForSite(url)) + assertNull(repository.getDrmForSession("anotherTabId", domain)) } @Test @@ -147,6 +169,37 @@ class SitePermissionsRepositoryTest { assertFalse(repository.isDrmEnabledForSite(url)) } + @Test + fun whenDomainAllowedToAskThenDrmIsEnabledForSite() = runTest { + setInitialSettings() + + assertTrue(repository.isDrmEnabledForSite(url)) + } + + @Test + fun whenCentralPolicyEnabledAndPolicyDeniesThenDrmIsDisabledForSite() = runTest { + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(true)) + whenever(mockDrmPolicyManager.decide(url)).thenReturn(DrmPolicyDecision(DrmPolicyAction.DENY, DrmPolicyReason.BLOCK_LIST)) + + assertFalse(repository.isDrmEnabledForSite(url)) + } + + @Test + fun whenCentralPolicyEnabledAndPolicyPromptsThenDrmIsEnabledForSite() = runTest { + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(true)) + whenever(mockDrmPolicyManager.decide(url)).thenReturn(DrmPolicyDecision(DrmPolicyAction.PROMPT, DrmPolicyReason.NO_RULE)) + + assertTrue(repository.isDrmEnabledForSite(url)) + } + + @Test + fun whenCentralPolicyEnabledAndPolicyGrantsThenDrmIsEnabledForSite() = runTest { + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(true)) + whenever(mockDrmPolicyManager.decide(url)).thenReturn(DrmPolicyDecision(DrmPolicyAction.GRANT, DrmPolicyReason.ALLOW_LIST)) + + assertTrue(repository.isDrmEnabledForSite(url)) + } + @Test fun whenSitePermissionsWasGrantedWithin24hThenReturnPermissionGranted() = runTest { setInitialSettings() diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/DrmPolicyEvaluatorTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/DrmPolicyEvaluatorTest.kt new file mode 100644 index 000000000000..e736e9ecbe5e --- /dev/null +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/DrmPolicyEvaluatorTest.kt @@ -0,0 +1,165 @@ +/* + * 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.drm + +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction.DENY +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction.GRANT +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction.PROMPT +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.ALLOW_LIST +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.BLOCK_LIST +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.GLOBAL_OFF +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.NO_RULE +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.PROTECTIONS_OFF +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.SESSION +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.USER_ALLOW_ALWAYS +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.USER_DENY_ALWAYS +import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType.ALLOW_ALWAYS +import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType.ASK_EVERY_TIME +import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType.DENY_ALWAYS +import org.junit.Assert.assertEquals +import org.junit.Test + +class DrmPolicyEvaluatorTest { + + private val noRuleContext = DrmPolicyContext( + isGlobalAskEnabled = true, + siteSetting = null, + sessionChoice = null, + isBlockedByBlockList = false, + isSiteUnprotected = false, + isAllowedByAllowList = false, + ) + + @Test + fun whenGlobalToggleOffThenDenyWithGlobalOff() { + val decision = noRuleContext.copy(isGlobalAskEnabled = false).evaluate() + + assertEquals(DrmPolicyDecision(DENY, GLOBAL_OFF), decision) + } + + @Test + fun whenSiteSettingDenyAlwaysThenDenyWithUserDenyAlways() { + val decision = noRuleContext.copy(siteSetting = DENY_ALWAYS).evaluate() + + assertEquals(DrmPolicyDecision(DENY, USER_DENY_ALWAYS), decision) + } + + @Test + fun whenSiteSettingAllowAlwaysThenGrantWithUserAllowAlways() { + val decision = noRuleContext.copy(siteSetting = ALLOW_ALWAYS).evaluate() + + assertEquals(DrmPolicyDecision(GRANT, USER_ALLOW_ALWAYS), decision) + } + + @Test + fun whenSiteSettingAskEveryTimeThenPromptWithNoRule() { + val decision = noRuleContext.copy(siteSetting = ASK_EVERY_TIME).evaluate() + + assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), decision) + } + + @Test + fun whenSessionChoiceAllowsThenGrantWithSession() { + val decision = noRuleContext.copy(sessionChoice = true).evaluate() + + assertEquals(DrmPolicyDecision(GRANT, SESSION), decision) + } + + @Test + fun whenSessionChoiceDeniesThenDenyWithSession() { + val decision = noRuleContext.copy(sessionChoice = false).evaluate() + + assertEquals(DrmPolicyDecision(DENY, SESSION), decision) + } + + @Test + fun whenBlockListMatchesThenDenyWithBlockList() { + val decision = noRuleContext.copy(isBlockedByBlockList = true).evaluate() + + assertEquals(DrmPolicyDecision(DENY, BLOCK_LIST), decision) + } + + @Test + fun whenSiteUnprotectedThenGrantWithProtectionsOff() { + val decision = noRuleContext.copy(isSiteUnprotected = true).evaluate() + + assertEquals(DrmPolicyDecision(GRANT, PROTECTIONS_OFF), decision) + } + + @Test + fun whenAllowListMatchesThenGrantWithAllowList() { + val decision = noRuleContext.copy(isAllowedByAllowList = true).evaluate() + + assertEquals(DrmPolicyDecision(GRANT, ALLOW_LIST), decision) + } + + @Test + fun whenNoRuleMatchesThenPromptWithNoRule() { + val decision = noRuleContext.evaluate() + + assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), decision) + } + + @Test + fun whenDenyAlwaysAndAllowListMatchThenDenyWithUserDenyAlways() { + val decision = noRuleContext.copy(siteSetting = DENY_ALWAYS, isAllowedByAllowList = true).evaluate() + + assertEquals(DrmPolicyDecision(DENY, USER_DENY_ALWAYS), decision) + } + + @Test + fun whenAllowAlwaysAndBlockListMatchThenGrantWithUserAllowAlways() { + val decision = noRuleContext.copy(siteSetting = ALLOW_ALWAYS, isBlockedByBlockList = true).evaluate() + + assertEquals(DrmPolicyDecision(GRANT, USER_ALLOW_ALWAYS), decision) + } + + @Test + fun whenGlobalToggleOffAndAllowAlwaysThenDenyWithGlobalOff() { + val decision = noRuleContext.copy(isGlobalAskEnabled = false, siteSetting = ALLOW_ALWAYS).evaluate() + + assertEquals(DrmPolicyDecision(DENY, GLOBAL_OFF), decision) + } + + @Test + fun whenSessionDenyAndAllowListMatchThenDenyWithSession() { + val decision = noRuleContext.copy(sessionChoice = false, isAllowedByAllowList = true).evaluate() + + assertEquals(DrmPolicyDecision(DENY, SESSION), decision) + } + + @Test + fun whenSessionAllowAndBlockListMatchThenGrantWithSession() { + val decision = noRuleContext.copy(sessionChoice = true, isBlockedByBlockList = true).evaluate() + + assertEquals(DrmPolicyDecision(GRANT, SESSION), decision) + } + + @Test + fun whenBlockListAndAllowListMatchThenDenyWithBlockList() { + val decision = noRuleContext.copy(isBlockedByBlockList = true, isAllowedByAllowList = true).evaluate() + + assertEquals(DrmPolicyDecision(DENY, BLOCK_LIST), decision) + } + + @Test + fun whenSiteUnprotectedAndAllowListMatchThenGrantWithProtectionsOff() { + val decision = noRuleContext.copy(isSiteUnprotected = true, isAllowedByAllowList = true).evaluate() + + assertEquals(DrmPolicyDecision(GRANT, PROTECTIONS_OFF), decision) + } +} diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt new file mode 100644 index 000000000000..3b45efcf8fa6 --- /dev/null +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt @@ -0,0 +1,195 @@ +/* + * 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.drm + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.privacy.db.UserAllowListRepository +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.privacy.config.api.Drm +import com.duckduckgo.privacy.config.api.UnprotectedTemporary +import com.duckduckgo.site.permissions.impl.SitePermissionsRepository +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction.DENY +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction.GRANT +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction.PROMPT +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.ALLOW_LIST +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.BLOCK_LIST +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.GLOBAL_OFF +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.NO_RULE +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.PROTECTIONS_OFF +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.SESSION +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.USER_ALLOW_ALWAYS +import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason.USER_DENY_ALWAYS +import com.duckduckgo.site.permissions.impl.drmblock.DrmBlock +import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType +import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionsEntity +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RealDrmPolicyManagerTest { + + @get:Rule + var coroutineRule = CoroutineTestRule() + + private val mockSitePermissionsRepository: SitePermissionsRepository = mock() + private val drmSessionStore = DrmSessionStore() + private val mockDrmBlock: DrmBlock = mock() + private val mockDrm: Drm = mock() + private val mockUserAllowListRepository: UserAllowListRepository = mock() + private val mockUnprotectedTemporary: UnprotectedTemporary = mock() + + private val testee = RealDrmPolicyManager( + mockSitePermissionsRepository, + drmSessionStore, + mockDrmBlock, + mockDrm, + mockUserAllowListRepository, + mockUnprotectedTemporary, + ) + + private val url = "https://www.netflix.com/watch" + private val domain = "www.netflix.com" + private val tabId = "tabId" + + @Before + fun before() { + whenever(mockSitePermissionsRepository.askDrmEnabled).thenReturn(true) + } + + @Test + fun whenGlobalDrmAskDisabledThenDenyWithGlobalOff() = runTest { + whenever(mockSitePermissionsRepository.askDrmEnabled).thenReturn(false) + + assertEquals(DrmPolicyDecision(DENY, GLOBAL_OFF), testee.decide(url, tabId)) + } + + @Test + fun whenSiteSettingIsDenyAlwaysThenDenyWithUserDenyAlways() = runTest { + givenSiteSetting(SitePermissionAskSettingType.DENY_ALWAYS) + + assertEquals(DrmPolicyDecision(DENY, USER_DENY_ALWAYS), testee.decide(url, tabId)) + } + + @Test + fun whenSiteSettingIsAllowAlwaysThenGrantWithUserAllowAlways() = runTest { + givenSiteSetting(SitePermissionAskSettingType.ALLOW_ALWAYS) + + assertEquals(DrmPolicyDecision(GRANT, USER_ALLOW_ALWAYS), testee.decide(url, tabId)) + } + + @Test + fun whenSessionChoiceExistsForTabThenSessionDecides() = runTest { + drmSessionStore.save(tabId, domain, false) + + assertEquals(DrmPolicyDecision(DENY, SESSION), testee.decide(url, tabId)) + } + + @Test + fun whenTabIdIsNullThenSessionChoiceIsNotConsulted() = runTest { + drmSessionStore.save(tabId, domain, false) + + assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide(url, tabId = null)) + } + + @Test + fun whenOtherTabHasSessionChoiceThenSessionIsNotConsulted() = runTest { + drmSessionStore.save("otherTabId", domain, false) + + assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide(url, tabId)) + } + + @Test + fun whenDrmBlockedByBlockListThenDenyWithBlockList() = runTest { + whenever(mockDrmBlock.isDrmBlockedForUrl(url)).thenReturn(true) + + assertEquals(DrmPolicyDecision(DENY, BLOCK_LIST), testee.decide(url, tabId)) + } + + @Test + fun whenUriInUserAllowListThenGrantWithProtectionsOff() = runTest { + whenever(mockUserAllowListRepository.isUriInUserAllowList(any())).thenReturn(true) + + assertEquals(DrmPolicyDecision(GRANT, PROTECTIONS_OFF), testee.decide(url, tabId)) + } + + @Test + fun whenUrlInUnprotectedTemporaryThenGrantWithProtectionsOff() = runTest { + whenever(mockUnprotectedTemporary.isAnException(url)).thenReturn(true) + + assertEquals(DrmPolicyDecision(GRANT, PROTECTIONS_OFF), testee.decide(url, tabId)) + } + + @Test + fun whenBlockListDomainIsAlsoUnprotectedThenGrantWithProtectionsOff() = runTest { + // DrmBlock's composite already returns false for unprotected sites, so the block rule never fires here. + whenever(mockDrmBlock.isDrmBlockedForUrl(url)).thenReturn(false) + whenever(mockUserAllowListRepository.isUriInUserAllowList(any())).thenReturn(true) + whenever(mockDrm.isDrmAllowedForUrl(url)).thenReturn(true) + + assertEquals(DrmPolicyDecision(GRANT, PROTECTIONS_OFF), testee.decide(url, tabId)) + } + + @Test + fun whenUrlInAllowListThenGrantWithAllowList() = runTest { + whenever(mockDrm.isDrmAllowedForUrl(url)).thenReturn(true) + + assertEquals(DrmPolicyDecision(GRANT, ALLOW_LIST), testee.decide(url, tabId)) + } + + @Test + fun whenNoRuleMatchesThenPromptWithNoRule() = runTest { + assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide(url, tabId)) + } + + @Test + fun whenDenyAlwaysStoredOnWwwDomainAndRequestIsForBareDomainThenAllowListGrants() = runTest { + // Settings key on extractDomain() (keeps www.) while the allow list keys on baseHost (strips it), + // so a bare-domain request misses the www-keyed setting. Pins current behaviour until keying is unified. + val entity = SitePermissionsEntity(domain = domain, askDrmSetting = SitePermissionAskSettingType.DENY_ALWAYS.name) + whenever(mockSitePermissionsRepository.getSitePermissionsForWebsite(domain)).thenReturn(entity) + whenever(mockDrm.isDrmAllowedForUrl("https://netflix.com")).thenReturn(true) + + assertEquals(DrmPolicyDecision(GRANT, ALLOW_LIST), testee.decide("https://netflix.com", tabId)) + } + + @Test + fun whenUrlIsMalformedThenPromptWithNoRule() = runTest { + assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide("not a url", tabId)) + } + + @Test + fun whenUrlIsIpAddressThenPromptWithNoRule() = runTest { + assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide("https://192.168.0.1", tabId)) + } + + @Test + fun whenUrlHostIsPublicSuffixThenPromptWithNoRule() = runTest { + assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide("https://co.uk", tabId)) + } + + private suspend fun givenSiteSetting(setting: SitePermissionAskSettingType) { + val entity = SitePermissionsEntity(domain = domain, askDrmSetting = setting.name) + whenever(mockSitePermissionsRepository.getSitePermissionsForWebsite(domain)).thenReturn(entity) + } +} From ea0e1a0005a905ef8b0064451017a792857a78fb Mon Sep 17 00:00:00 2001 From: 0nko Date: Thu, 20 Aug 2026 19:24:44 +0200 Subject: [PATCH 02/13] Keep the existing DRM session map on the flag-off path --- .../impl/SitePermissionsRepository.kt | 17 +++++++++++++++-- .../impl/SitePermissionsRepositoryTest.kt | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepository.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepository.kt index 20ebe7f9c9b2..a0de2d48add2 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepository.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepository.kt @@ -102,12 +102,17 @@ 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() + override suspend fun isDrmEnabledForSite(url: String): Boolean { if (drmPolicyFeature.centralPolicy().isEnabled()) { // "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 return isDomainAllowedToAsk(url, PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) || @@ -210,11 +215,19 @@ class SitePermissionsRepositoryImpl @Inject constructor( } override fun getDrmForSession(tabId: String, domain: String): Boolean? { - return drmSessionStore.get(tabId, domain) + return if (drmPolicyFeature.centralPolicy().isEnabled()) { + drmSessionStore.get(tabId, domain) + } else { + drmSessions[domain] + } } override fun saveDrmForSession(tabId: String, domain: String, allowed: Boolean) { - drmSessionStore.save(tabId, domain, allowed) + if (drmPolicyFeature.centralPolicy().isEnabled()) { + drmSessionStore.save(tabId, domain, allowed) + } else { + drmSessions[domain] = allowed + } } override fun isDrmBlockedForUrlByConfig(url: String): Boolean { diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepositoryTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepositoryTest.kt index e8f0199d1118..1de8cd243f0f 100644 --- a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepositoryTest.kt +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepositoryTest.kt @@ -150,6 +150,8 @@ class SitePermissionsRepositoryTest { @Test fun whenDrmSessionSavedThenGetDrmForSessionReturnsIt() { + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(true)) + repository.saveDrmForSession(tabId, domain, true) assertTrue(repository.getDrmForSession(tabId, domain) == true) @@ -157,11 +159,27 @@ class SitePermissionsRepositoryTest { @Test fun whenDrmSessionSavedForOneTabThenAnotherTabOnSameDomainHasNoSessionChoice() { + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(true)) + repository.saveDrmForSession(tabId, domain, true) assertNull(repository.getDrmForSession("anotherTabId", domain)) } + @Test + fun whenPolicyDisabledAndDrmSessionSavedThenGetDrmForSessionReturnsItForAnyTab() { + repository.saveDrmForSession(tabId, domain, true) + + assertTrue(repository.getDrmForSession("anotherTabId", domain) == true) + } + + @Test + fun whenPolicyDisabledAndDrmSessionSavedThenDrmEnabledForSiteFollowsIt() = runTest { + repository.saveDrmForSession(tabId, domain, false) + + assertFalse(repository.isDrmEnabledForSite(url)) + } + @Test fun whenDrmBlockedByConfigThenDrmIsDisabledForSite() = runTest { whenever(mockDrmBlock.isDrmBlockedForUrl(url)).thenReturn(true) From 0f1cf15ec14444ff18860ebf0abc4047e1f3a971 Mon Sep 17 00:00:00 2001 From: 0nko Date: Thu, 20 Aug 2026 19:42:06 +0200 Subject: [PATCH 03/13] Clean up code --- .../impl/SitePermissionsManagerImpl.kt | 16 +++++++--------- .../site/permissions/impl/drm/DrmSessionStore.kt | 2 -- .../permissions/impl/drm/RealDrmPolicyManager.kt | 1 + 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt index ee7d432e10c2..954fcbee5eaf 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt @@ -70,15 +70,13 @@ class SitePermissionsManagerImpl @Inject constructor( val autoAccept = mutableListOf() val url = request.origin.toString() - val drmDecision = if ( - request.resources.contains(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) && - drmPolicyFeature.centralPolicy().isEnabled() - ) { - drmPolicyManager.decide(url, tabId) - } else { - null - } - logcat { "Permissions: drm policy decision for $url is $drmDecision" } + val drmDecision = drmPolicyManager + .takeIf { + drmPolicyFeature.centralPolicy().isEnabled() && + request.resources.contains(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) + } + ?.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 } diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmSessionStore.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmSessionStore.kt index 5d5394885b9c..c8ed0cde6a39 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmSessionStore.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmSessionStore.kt @@ -23,8 +23,6 @@ import javax.inject.Inject @SingleInstanceIn(AppScope::class) class DrmSessionStore @Inject constructor() { - - // Entries for closed tabs are never reclaimed — one Boolean per (tab, domain) that saw a prompt. private val sessions = ConcurrentHashMap() fun get(tabId: String, domain: String): Boolean? = sessions[key(tabId, domain)] diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt index 384af6a8caae..1687314a4d4d 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt @@ -43,6 +43,7 @@ class RealDrmPolicyManager @Inject constructor( val uri = url.toUri() val siteSetting = sitePermissionsRepository.getSitePermissionsForWebsite(domain)?.askDrmSetting ?.let { setting -> SitePermissionAskSettingType.entries.firstOrNull { it.name == setting } } + return DrmPolicyContext( isGlobalAskEnabled = sitePermissionsRepository.askDrmEnabled, siteSetting = siteSetting, From 8f38ee52a6d56dcb2dfba225e6f85c06f7903b91 Mon Sep 17 00:00:00 2001 From: 0nko Date: Thu, 20 Aug 2026 19:47:16 +0200 Subject: [PATCH 04/13] Move FF access to IO dispatcher --- .../permissions/impl/SitePermissionsManagerImpl.kt | 14 ++++++++------ .../permissions/impl/SitePermissionsRepository.kt | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt index 954fcbee5eaf..0c7f64452b62 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt @@ -70,13 +70,15 @@ class SitePermissionsManagerImpl @Inject constructor( val autoAccept = mutableListOf() val url = request.origin.toString() - val drmDecision = drmPolicyManager - .takeIf { - drmPolicyFeature.centralPolicy().isEnabled() && + val drmDecision = withContext(dispatcherProvider.io()) { + drmPolicyManager + .takeIf { + drmPolicyFeature.centralPolicy().isEnabled() && request.resources.contains(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) - } - ?.decide(url, tabId) - ?.also { logcat { "Permissions: drm policy decision for $url is $it" } } + } + ?.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 } diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepository.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepository.kt index a0de2d48add2..8ab3bedd1069 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepository.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepository.kt @@ -106,7 +106,7 @@ class SitePermissionsRepositoryImpl @Inject constructor( private val drmSessions = mutableMapOf() override suspend fun isDrmEnabledForSite(url: String): Boolean { - if (drmPolicyFeature.centralPolicy().isEnabled()) { + if (withContext(dispatcherProvider.io()) { drmPolicyFeature.centralPolicy().isEnabled() }) { // "Permitted or promptable" rather than "granted" — this feeds the breakage report's drmEnabled field. return drmPolicyManager.get().decide(url).action != DrmPolicyAction.DENY } From 5245685d630722f4dd4dea7e41ed868d459249b8 Mon Sep 17 00:00:00 2001 From: 0nko Date: Thu, 20 Aug 2026 20:56:14 +0200 Subject: [PATCH 05/13] Fix some leftover issues --- .../privacy/config/impl/features/drm/RealDrm.kt | 11 ++++------- .../impl/SitePermissionsManagerImpl.kt | 6 +++++- .../permissions/impl/SitePermissionsRepository.kt | 7 ++++--- .../site/permissions/impl/drm/DrmSessionStore.kt | 4 ++++ .../permissions/impl/drm/RealDrmPolicyManager.kt | 9 +++++++-- .../permissions/impl/feature/DrmPolicyFeature.kt | 2 ++ .../impl/SitePermissionsManagerTest.kt | 14 ++++++++++++++ .../impl/drm/RealDrmPolicyManagerTest.kt | 15 +++++++++++---- 8 files changed, 51 insertions(+), 17 deletions(-) diff --git a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrm.kt b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrm.kt index b403d05c6eb2..eaa6eda367a1 100644 --- a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrm.kt +++ b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrm.kt @@ -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 @@ -41,15 +41,12 @@ 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)) || + return (isFeatureEnabled && domainsThatAllowDrm(url)) || userAllowListRepository.isUriInUserAllowList(uri) || unprotectedTemporary.isAnException(uri.toString()) } - private fun domainsThatAllowDrm(host: String?): Boolean { - host ?: return false - return drmRepository.exceptions.any { exception -> - host == exception.domain || host.endsWith(".${exception.domain}") - } + private fun domainsThatAllowDrm(url: String): Boolean { + return drmRepository.exceptions.any { exception -> sameOrSubdomain(url, exception.domain) } } } diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt index 0c7f64452b62..d970a81d63dd 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt @@ -33,8 +33,10 @@ import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermission 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 @@ -51,6 +53,7 @@ class SitePermissionsManagerImpl @Inject constructor( private val microphoneSitePermissionsDomainRecoveryFeature: MicrophoneSitePermissionsDomainRecoveryFeature, private val drmPolicyFeature: DrmPolicyFeature, private val drmPolicyManager: DrmPolicyManager, + private val drmSessionStore: DrmSessionStore, duckAiHostProvider: DuckAiHostProvider, ) : SitePermissionsManager { @@ -73,7 +76,7 @@ class SitePermissionsManagerImpl @Inject constructor( val drmDecision = withContext(dispatcherProvider.io()) { drmPolicyManager .takeIf { - drmPolicyFeature.centralPolicy().isEnabled() && + drmPolicyFeature.isCentralPolicyEnabled() && request.resources.contains(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) } ?.decide(url, tabId) @@ -138,6 +141,7 @@ class SitePermissionsManagerImpl @Inject constructor( } override suspend fun clearAllButFireproof(fireproofDomains: List) { + drmSessionStore.clear() sitePermissionsRepository.sitePermissionsForAllWebsites().forEach { permission -> if (!fireproofDomains.contains(permission.domain)) { sitePermissionsRepository.deletePermissionsForSite(permission.domain) diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepository.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepository.kt index 8ab3bedd1069..603657d9517b 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepository.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsRepository.kt @@ -27,6 +27,7 @@ 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 @@ -106,7 +107,7 @@ class SitePermissionsRepositoryImpl @Inject constructor( private val drmSessions = mutableMapOf() override suspend fun isDrmEnabledForSite(url: String): Boolean { - if (withContext(dispatcherProvider.io()) { drmPolicyFeature.centralPolicy().isEnabled() }) { + 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 } @@ -215,7 +216,7 @@ class SitePermissionsRepositoryImpl @Inject constructor( } override fun getDrmForSession(tabId: String, domain: String): Boolean? { - return if (drmPolicyFeature.centralPolicy().isEnabled()) { + return if (drmPolicyFeature.isCentralPolicyEnabled()) { drmSessionStore.get(tabId, domain) } else { drmSessions[domain] @@ -223,7 +224,7 @@ class SitePermissionsRepositoryImpl @Inject constructor( } override fun saveDrmForSession(tabId: String, domain: String, allowed: Boolean) { - if (drmPolicyFeature.centralPolicy().isEnabled()) { + if (drmPolicyFeature.isCentralPolicyEnabled()) { drmSessionStore.save(tabId, domain, allowed) } else { drmSessions[domain] = allowed diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmSessionStore.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmSessionStore.kt index c8ed0cde6a39..26102453eaa2 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmSessionStore.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/DrmSessionStore.kt @@ -31,5 +31,9 @@ class DrmSessionStore @Inject constructor() { sessions[key(tabId, domain)] = allowed } + fun clear() { + sessions.clear() + } + private fun key(tabId: String, domain: String) = "$tabId/$domain" } diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt index 1687314a4d4d..73f69b49f55d 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt @@ -18,6 +18,7 @@ package com.duckduckgo.site.permissions.impl.drm import androidx.core.net.toUri import com.duckduckgo.app.privacy.db.UserAllowListRepository +import com.duckduckgo.common.utils.baseHost import com.duckduckgo.common.utils.extractDomain import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.privacy.config.api.Drm @@ -41,8 +42,12 @@ class RealDrmPolicyManager @Inject constructor( override suspend fun decide(url: String, tabId: String?): DrmPolicyDecision { val domain = url.extractDomain() ?: url val uri = url.toUri() - val siteSetting = sitePermissionsRepository.getSitePermissionsForWebsite(domain)?.askDrmSetting - ?.let { setting -> SitePermissionAskSettingType.entries.firstOrNull { it.name == setting } } + // Settings are keyed on the host as typed, so example.com and www.example.com are separate + // rows, while the allow list matches both + val siteSetting = listOfNotNull(domain, uri.baseHost, uri.baseHost?.let { "www.$it" }) + .distinct() + .firstNotNullOfOrNull { sitePermissionsRepository.getSitePermissionsForWebsite(it)?.askDrmSetting } + ?.let { setting -> runCatching { SitePermissionAskSettingType.valueOf(setting) }.getOrNull() } return DrmPolicyContext( isGlobalAskEnabled = sitePermissionsRepository.askDrmEnabled, diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/feature/DrmPolicyFeature.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/feature/DrmPolicyFeature.kt index 4f94400782f0..82fbf54a91ce 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/feature/DrmPolicyFeature.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/feature/DrmPolicyFeature.kt @@ -32,3 +32,5 @@ interface DrmPolicyFeature { @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) fun centralPolicy(): Toggle } + +fun DrmPolicyFeature.isCentralPolicyEnabled(): Boolean = self().isEnabled() && centralPolicy().isEnabled() diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerTest.kt index b24d27a69208..0b52bf2f5956 100644 --- a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerTest.kt +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerTest.kt @@ -32,6 +32,7 @@ import com.duckduckgo.site.permissions.impl.drm.DrmPolicyAction import com.duckduckgo.site.permissions.impl.drm.DrmPolicyDecision import com.duckduckgo.site.permissions.impl.drm.DrmPolicyManager import com.duckduckgo.site.permissions.impl.drm.DrmPolicyReason +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.store.sitepermissions.SitePermissionsEntity @@ -43,6 +44,7 @@ import com.nhaarman.mockitokotlin2.verifyZeroInteractions import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Rule import org.junit.Test @@ -65,6 +67,7 @@ class SitePermissionsManagerTest { ) private val drmPolicyFeature = FakeFeatureToggleFactory.create(DrmPolicyFeature::class.java) private val mockDrmPolicyManager: DrmPolicyManager = mock() + private val drmSessionStore = DrmSessionStore() private val testee by lazy { SitePermissionsManagerImpl( @@ -76,6 +79,7 @@ class SitePermissionsManagerTest { fakeMicrophoneSitePermissionsDomainRecoveryFeature, drmPolicyFeature, mockDrmPolicyManager, + drmSessionStore, mockDuckAiHostProvider, ) } @@ -280,6 +284,16 @@ class SitePermissionsManagerTest { verify(mockSitePermissionsRepository).deletePermissionsForSite(domain) } + @Test + fun whenClearAllButFireproofThenDrmSessionChoicesAreCleared() = runTest { + drmSessionStore.save(tabId, "domain.com", true) + whenever(mockSitePermissionsRepository.sitePermissionsForAllWebsites()).thenReturn(emptyList()) + + testee.clearAllButFireproof(listOf("domain.com")) + + assertNull(drmSessionStore.get(tabId, "domain.com")) + } + @Test fun whenDomainGrantedThenGetPermissionsQueryResponseReturnsGranted() = runTest { whenever(mockSitePermissionsRepository.isDomainGranted(url, tabId, PermissionRequest.RESOURCE_VIDEO_CAPTURE)).thenReturn(true) diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt index 3b45efcf8fa6..ac6f68dfdcaa 100644 --- a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt @@ -163,14 +163,21 @@ class RealDrmPolicyManagerTest { } @Test - fun whenDenyAlwaysStoredOnWwwDomainAndRequestIsForBareDomainThenAllowListGrants() = runTest { - // Settings key on extractDomain() (keeps www.) while the allow list keys on baseHost (strips it), - // so a bare-domain request misses the www-keyed setting. Pins current behaviour until keying is unified. + fun whenDenyAlwaysStoredOnWwwDomainAndRequestIsForBareDomainThenDenyWithUserDenyAlways() = runTest { val entity = SitePermissionsEntity(domain = domain, askDrmSetting = SitePermissionAskSettingType.DENY_ALWAYS.name) whenever(mockSitePermissionsRepository.getSitePermissionsForWebsite(domain)).thenReturn(entity) whenever(mockDrm.isDrmAllowedForUrl("https://netflix.com")).thenReturn(true) - assertEquals(DrmPolicyDecision(GRANT, ALLOW_LIST), testee.decide("https://netflix.com", tabId)) + assertEquals(DrmPolicyDecision(DENY, USER_DENY_ALWAYS), testee.decide("https://netflix.com", tabId)) + } + + @Test + fun whenDenyAlwaysStoredOnBareDomainAndRequestIsForWwwDomainThenDenyWithUserDenyAlways() = runTest { + val entity = SitePermissionsEntity(domain = "netflix.com", askDrmSetting = SitePermissionAskSettingType.DENY_ALWAYS.name) + whenever(mockSitePermissionsRepository.getSitePermissionsForWebsite("netflix.com")).thenReturn(entity) + whenever(mockDrm.isDrmAllowedForUrl(url)).thenReturn(true) + + assertEquals(DrmPolicyDecision(DENY, USER_DENY_ALWAYS), testee.decide(url, tabId)) } @Test From ab213d9467dd4bbd258bc78b2883f5a7d779dfcb Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 21 Aug 2026 00:10:54 +0200 Subject: [PATCH 06/13] Fix the blocklist domain comparison --- .../impl/drm/RealDrmPolicyManager.kt | 7 ++++--- .../permissions/impl/drmblock/RealDrmBlock.kt | 8 ++++---- .../impl/drm/RealDrmPolicyManagerTest.kt | 17 +++++++++++++++++ .../impl/drmblock/RealDrmBlockTest.kt | 16 ++++++++++++++++ 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt index 73f69b49f55d..666c17d76bed 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt @@ -19,6 +19,7 @@ package com.duckduckgo.site.permissions.impl.drm import androidx.core.net.toUri import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.common.utils.baseHost +import com.duckduckgo.common.utils.extensions.toTldPlusOneOrSelf import com.duckduckgo.common.utils.extractDomain import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.privacy.config.api.Drm @@ -42,9 +43,9 @@ class RealDrmPolicyManager @Inject constructor( override suspend fun decide(url: String, tabId: String?): DrmPolicyDecision { val domain = url.extractDomain() ?: url val uri = url.toUri() - // Settings are keyed on the host as typed, so example.com and www.example.com are separate - // rows, while the allow list matches both - val siteSetting = listOfNotNull(domain, uri.baseHost, uri.baseHost?.let { "www.$it" }) + // Settings are keyed on the host as typed, while both config lists match subdomains. Check every + // spelling plus the parent domain: missing the row would let remote config override a user's choice + val siteSetting = listOfNotNull(domain, uri.baseHost, uri.baseHost?.let { "www.$it" }, domain.toTldPlusOneOrSelf()) .distinct() .firstNotNullOfOrNull { sitePermissionsRepository.getSitePermissionsForWebsite(it)?.askDrmSetting } ?.let { setting -> runCatching { SitePermissionAskSettingType.valueOf(setting) }.getOrNull() } diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlock.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlock.kt index 5589f32770a1..469ef5dd28f3 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlock.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlock.kt @@ -17,8 +17,8 @@ package com.duckduckgo.site.permissions.impl.drmblock 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.privacy.config.api.UnprotectedTemporary import com.squareup.anvil.annotations.ContributesBinding @@ -35,12 +35,12 @@ class RealDrmBlock @Inject constructor( override fun isDrmBlockedForUrl(url: String): Boolean { val uri = url.toUri() return drmBlockFeature.self().isEnabled() && - domainsThatBlockDrm(uri.baseHost) && + domainsThatBlockDrm(url) && !userAllowListRepository.isUriInUserAllowList(uri) && !unprotectedTemporary.isAnException(uri.toString()) } - private fun domainsThatBlockDrm(host: String?): Boolean { - return drmBlockRepository.exceptions.firstOrNull { it.domain == host } != null + private fun domainsThatBlockDrm(url: String): Boolean { + return drmBlockRepository.exceptions.any { sameOrSubdomain(url, it.domain) } } } diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt index ac6f68dfdcaa..8d5eb0d6322c 100644 --- a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt @@ -180,6 +180,23 @@ class RealDrmPolicyManagerTest { assertEquals(DrmPolicyDecision(DENY, USER_DENY_ALWAYS), testee.decide(url, tabId)) } + @Test + fun whenDenyAlwaysStoredOnParentDomainAndRequestIsFromSubdomainThenDenyWithUserDenyAlways() = runTest { + val entity = SitePermissionsEntity(domain = "foxnews.com", askDrmSetting = SitePermissionAskSettingType.DENY_ALWAYS.name) + whenever(mockSitePermissionsRepository.getSitePermissionsForWebsite("foxnews.com")).thenReturn(entity) + whenever(mockDrm.isDrmAllowedForUrl("https://static.foxnews.com/")).thenReturn(true) + + assertEquals(DrmPolicyDecision(DENY, USER_DENY_ALWAYS), testee.decide("https://static.foxnews.com/", tabId)) + } + + @Test + fun whenSettingStoredOnSubdomainThenParentDomainRequestIsUnaffected() = runTest { + val entity = SitePermissionsEntity(domain = "static.foxnews.com", askDrmSetting = SitePermissionAskSettingType.DENY_ALWAYS.name) + whenever(mockSitePermissionsRepository.getSitePermissionsForWebsite("static.foxnews.com")).thenReturn(entity) + + assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide("https://foxnews.com/", tabId)) + } + @Test fun whenUrlIsMalformedThenPromptWithNoRule() = runTest { assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide("not a url", tabId)) diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlockTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlockTest.kt index 2c41a8a3e54f..f780080eed35 100644 --- a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlockTest.kt +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlockTest.kt @@ -88,6 +88,22 @@ class RealDrmBlockTest { assertTrue(testee.isDrmBlockedForUrl(url)) } + @Test + fun whenSubdomainOfBlockedDomainThenTrueIsReturned() { + givenFeatureIsEnabled() + givenUrlIsInExceptionList() + + assertTrue(testee.isDrmBlockedForUrl("https://static.open.spotify.com/player")) + } + + @Test + fun whenParentOfBlockedDomainThenFalseIsReturned() { + givenFeatureIsEnabled() + givenUrlIsInExceptionList() + + assertFalse(testee.isDrmBlockedForUrl("https://spotify.com")) + } + private fun givenFeatureIsEnabled() { whenever(mockDrmBlockFeature.self().isEnabled()).thenReturn(true) } From 3537a7227ec8dae8b4d6b7030debab201b3643f2 Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 21 Aug 2026 00:15:40 +0200 Subject: [PATCH 07/13] Fix the default permission value --- .../impl/drm/RealDrmPolicyManager.kt | 9 +++++++-- .../impl/drm/RealDrmPolicyManagerTest.kt | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt index 666c17d76bed..e30243c41a73 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt @@ -47,8 +47,7 @@ class RealDrmPolicyManager @Inject constructor( // spelling plus the parent domain: missing the row would let remote config override a user's choice val siteSetting = listOfNotNull(domain, uri.baseHost, uri.baseHost?.let { "www.$it" }, domain.toTldPlusOneOrSelf()) .distinct() - .firstNotNullOfOrNull { sitePermissionsRepository.getSitePermissionsForWebsite(it)?.askDrmSetting } - ?.let { setting -> runCatching { SitePermissionAskSettingType.valueOf(setting) }.getOrNull() } + .firstNotNullOfOrNull { host -> sitePermissionsRepository.getSitePermissionsForWebsite(host)?.askDrmSetting?.toDrmSetting() } return DrmPolicyContext( isGlobalAskEnabled = sitePermissionsRepository.askDrmEnabled, @@ -59,4 +58,10 @@ class RealDrmPolicyManager @Inject constructor( isAllowedByAllowList = drm.isDrmAllowedForUrl(url), ).evaluate() } + + // Every row carries a DRM value defaulting to ASK_EVERY_TIME, so a row saved for another permission + // must not stop the scan before an explicit choice on one of the other spellings. + private fun String.toDrmSetting(): SitePermissionAskSettingType? = + runCatching { SitePermissionAskSettingType.valueOf(this) }.getOrNull() + ?.takeIf { it != SitePermissionAskSettingType.ASK_EVERY_TIME } } diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt index 8d5eb0d6322c..e8b72c5b0b8c 100644 --- a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt @@ -197,6 +197,25 @@ class RealDrmPolicyManagerTest { assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide("https://foxnews.com/", tabId)) } + @Test + fun whenAnotherPermissionIsSavedOnOneSpellingThenDrmChoiceOnTheOtherStillApplies() = runTest { + val cameraOnly = SitePermissionsEntity(domain = domain, askCameraSetting = SitePermissionAskSettingType.ALLOW_ALWAYS.name) + val drmDenied = SitePermissionsEntity(domain = "netflix.com", askDrmSetting = SitePermissionAskSettingType.DENY_ALWAYS.name) + whenever(mockSitePermissionsRepository.getSitePermissionsForWebsite(domain)).thenReturn(cameraOnly) + whenever(mockSitePermissionsRepository.getSitePermissionsForWebsite("netflix.com")).thenReturn(drmDenied) + whenever(mockDrm.isDrmAllowedForUrl(url)).thenReturn(true) + + assertEquals(DrmPolicyDecision(DENY, USER_DENY_ALWAYS), testee.decide(url, tabId)) + } + + @Test + fun whenOnlyAskEveryTimeIsStoredThenNoUserRuleApplies() = runTest { + val askEveryTime = SitePermissionsEntity(domain = domain) + whenever(mockSitePermissionsRepository.getSitePermissionsForWebsite(domain)).thenReturn(askEveryTime) + + assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide(url, tabId)) + } + @Test fun whenUrlIsMalformedThenPromptWithNoRule() = runTest { assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide("not a url", tabId)) From a649903fb96353134e23a2e604198594388515da Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 21 Aug 2026 01:02:45 +0200 Subject: [PATCH 08/13] Fix the unprotected site check --- .../site/permissions/impl/UserProtections.kt | 34 +++++++++++++++++++ .../impl/drm/RealDrmPolicyManager.kt | 3 +- .../permissions/impl/drmblock/RealDrmBlock.kt | 3 +- .../impl/drmblock/RealDrmBlockTest.kt | 27 +++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/UserProtections.kt diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/UserProtections.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/UserProtections.kt new file mode 100644 index 000000000000..5626c9b159b5 --- /dev/null +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/UserProtections.kt @@ -0,0 +1,34 @@ +/* + * 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 registrableDomain = uri.host?.toTldPlusOneOrSelf() ?: return false + return listOf(registrableDomain, "www.$registrableDomain").any { isDomainInUserAllowList(it) } +} diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt index e30243c41a73..0d23535ebe7b 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt @@ -26,6 +26,7 @@ import com.duckduckgo.privacy.config.api.Drm import com.duckduckgo.privacy.config.api.UnprotectedTemporary import com.duckduckgo.site.permissions.impl.SitePermissionsRepository import com.duckduckgo.site.permissions.impl.drmblock.DrmBlock +import com.duckduckgo.site.permissions.impl.isSiteUnprotectedByUser import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject @@ -54,7 +55,7 @@ class RealDrmPolicyManager @Inject constructor( siteSetting = siteSetting, sessionChoice = tabId?.let { drmSessionStore.get(it, domain) }, isBlockedByBlockList = drmBlock.isDrmBlockedForUrl(url), - isSiteUnprotected = userAllowListRepository.isUriInUserAllowList(uri) || unprotectedTemporary.isAnException(url), + isSiteUnprotected = userAllowListRepository.isSiteUnprotectedByUser(uri) || unprotectedTemporary.isAnException(url), isAllowedByAllowList = drm.isDrmAllowedForUrl(url), ).evaluate() } diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlock.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlock.kt index 469ef5dd28f3..f19ed82bf48a 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlock.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlock.kt @@ -21,6 +21,7 @@ import com.duckduckgo.app.browser.UriString.Companion.sameOrSubdomain import com.duckduckgo.app.privacy.db.UserAllowListRepository import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.privacy.config.api.UnprotectedTemporary +import com.duckduckgo.site.permissions.impl.isSiteUnprotectedByUser import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject @@ -36,7 +37,7 @@ class RealDrmBlock @Inject constructor( val uri = url.toUri() return drmBlockFeature.self().isEnabled() && domainsThatBlockDrm(url) && - !userAllowListRepository.isUriInUserAllowList(uri) && + !userAllowListRepository.isSiteUnprotectedByUser(uri) && !unprotectedTemporary.isAnException(uri.toString()) } diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlockTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlockTest.kt index f780080eed35..94a01d2a4164 100644 --- a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlockTest.kt +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlockTest.kt @@ -104,6 +104,33 @@ class RealDrmBlockTest { assertFalse(testee.isDrmBlockedForUrl("https://spotify.com")) } + @Test + fun whenProtectionsAreOffForThePageHostThenSubresourceOriginIsNotBlocked() { + givenFeatureIsEnabled() + givenUrlIsInExceptionList() + whenever(mockUserAllowListRepository.isDomainInUserAllowList("www.spotify.com")).thenReturn(true) + + assertFalse(testee.isDrmBlockedForUrl("https://static.open.spotify.com/player")) + } + + @Test + fun whenProtectionsAreOffForTheRegistrableDomainThenSubresourceOriginIsNotBlocked() { + givenFeatureIsEnabled() + givenUrlIsInExceptionList() + whenever(mockUserAllowListRepository.isDomainInUserAllowList("spotify.com")).thenReturn(true) + + assertFalse(testee.isDrmBlockedForUrl("https://static.open.spotify.com/player")) + } + + @Test + fun whenProtectionsAreOffForAnUnrelatedDomainThenBlockStands() { + givenFeatureIsEnabled() + givenUrlIsInExceptionList() + whenever(mockUserAllowListRepository.isDomainInUserAllowList("example.com")).thenReturn(true) + + assertTrue(testee.isDrmBlockedForUrl("https://static.open.spotify.com/player")) + } + private fun givenFeatureIsEnabled() { whenever(mockDrmBlockFeature.self().isEnabled()).thenReturn(true) } From ed1c2aa808a900b8ee396b3e8c2ed0b888203076 Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 21 Aug 2026 13:38:36 +0200 Subject: [PATCH 09/13] Fix a case when eme block is completely removed --- .../privacy/config/impl/features/drm/RealDrm.kt | 5 ++++- .../privacy/config/impl/features/drm/RealDrmTest.kt | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrm.kt b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrm.kt index eaa6eda367a1..c26bb0ca4df0 100644 --- a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrm.kt +++ b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrm.kt @@ -40,7 +40,10 @@ class RealDrm @Inject constructor( override fun isDrmAllowedForUrl(url: String): Boolean { val uri = url.toUri() - val isFeatureEnabled = featureToggle.isFeatureEnabled(PrivacyFeatureName.DrmFeatureName.value, defaultValue = true) + // 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()) diff --git a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrmTest.kt b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrmTest.kt index 7ca7887f86e5..812f44180bd4 100644 --- a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrmTest.kt +++ b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/features/drm/RealDrmTest.kt @@ -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 @@ -119,6 +120,16 @@ 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) } From aba4daac3094c88e05c80a0c11b4a4ef29e63689 Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 21 Aug 2026 14:06:26 +0200 Subject: [PATCH 10/13] Check the www parent domain when looking up a DRM site setting --- .../site/permissions/impl/drm/RealDrmPolicyManager.kt | 3 ++- .../permissions/impl/drm/RealDrmPolicyManagerTest.kt | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt index 0d23535ebe7b..dd202cde78c0 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt @@ -46,7 +46,8 @@ class RealDrmPolicyManager @Inject constructor( val uri = url.toUri() // Settings are keyed on the host as typed, while both config lists match subdomains. Check every // spelling plus the parent domain: missing the row would let remote config override a user's choice - val siteSetting = listOfNotNull(domain, uri.baseHost, uri.baseHost?.let { "www.$it" }, domain.toTldPlusOneOrSelf()) + val registrableDomain = domain.toTldPlusOneOrSelf() + val siteSetting = listOfNotNull(domain, uri.baseHost, uri.baseHost?.let { "www.$it" }, registrableDomain, "www.$registrableDomain") .distinct() .firstNotNullOfOrNull { host -> sitePermissionsRepository.getSitePermissionsForWebsite(host)?.askDrmSetting?.toDrmSetting() } diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt index e8b72c5b0b8c..b33fccf2c805 100644 --- a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt @@ -216,6 +216,15 @@ class RealDrmPolicyManagerTest { assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide(url, tabId)) } + @Test + fun whenDenyAlwaysStoredOnWwwParentAndRequestIsFromSubdomainThenDenyWithUserDenyAlways() = runTest { + val entity = SitePermissionsEntity(domain = "www.foxnews.com", askDrmSetting = SitePermissionAskSettingType.DENY_ALWAYS.name) + whenever(mockSitePermissionsRepository.getSitePermissionsForWebsite("www.foxnews.com")).thenReturn(entity) + whenever(mockDrm.isDrmAllowedForUrl("https://static.foxnews.com/")).thenReturn(true) + + assertEquals(DrmPolicyDecision(DENY, USER_DENY_ALWAYS), testee.decide("https://static.foxnews.com/", tabId)) + } + @Test fun whenUrlIsMalformedThenPromptWithNoRule() = runTest { assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide("not a url", tabId)) From afb17d173c23783e7bca5f0beb4f010ee82cc1e8 Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 21 Aug 2026 14:06:36 +0200 Subject: [PATCH 11/13] Skip the launcher DRM pre-checks when the central policy is enabled --- .../SitePermissionsDialogActivityLauncher.kt | 34 ++++++++++------- ...tePermissionsDialogActivityLauncherTest.kt | 38 +++++++++++++++++++ 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncher.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncher.kt index 68f073a2d872..091d159c6a0f 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncher.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncher.kt @@ -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 @@ -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 @@ -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(tabId, 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 diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncherTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncherTest.kt index 0b036957dc88..b87019f98dc3 100644 --- a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncherTest.kt +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/SitePermissionsDialogActivityLauncherTest.kt @@ -25,8 +25,11 @@ import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.browsermode.api.BrowserMode import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.duckchat.api.DuckAiHostProvider +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.site.permissions.api.SitePermissionsGrantedListener import com.duckduckgo.site.permissions.api.SitePermissionsManager.SitePermissions +import com.duckduckgo.site.permissions.impl.feature.DrmPolicyFeature import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.never @@ -53,6 +56,8 @@ class SitePermissionsDialogActivityLauncherTest { whenever(it.getHost()).thenReturn("duck.ai") } + private val drmPolicyFeature = FakeFeatureToggleFactory.create(DrmPolicyFeature::class.java) + private val testee = createLauncher(BrowserMode.REGULAR) private fun createLauncher(browserMode: BrowserMode) = SitePermissionsDialogActivityLauncher( @@ -64,8 +69,41 @@ class SitePermissionsDialogActivityLauncherTest { appCoroutineScope = coroutineRule.testScope, duckAiHostProvider = duckAiHostProvider, browserMode = browserMode, + drmPolicyFeature = drmPolicyFeature, ) + @Test + fun whenCentralPolicyEnabledThenLauncherDoesNotRecheckSessionOrBlockList() { + drmPolicyFeature.self().setRawStoredState(Toggle.State(true)) + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(true)) + val activity: Activity = mock() + val request: PermissionRequest = mock() + whenever(request.resources).thenReturn(arrayOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID)) + whenever(sitePermissionsRepository.getDrmForSession(any(), any())).thenReturn(false) + whenever(sitePermissionsRepository.isDrmBlockedForUrlByConfig(any())).thenReturn(true) + + // Showing the dialog needs a themed Activity, which a mock cannot provide. Only the skipped + // pre-checks are under test here: with the flag on neither early return may fire. + runCatching { + testee.askForSitePermission( + activity = activity, + url = "https://example.com", + tabId = "tabId", + permissionsRequested = SitePermissions( + autoAccept = emptyList(), + userHandled = listOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID), + ), + request = request, + permissionsGrantedListener = permissionsGrantedListener, + ) + } + + verify(request, never()).grant(any()) + verify(request, never()).deny() + verify(sitePermissionsRepository, never()).getDrmForSession(any(), any()) + verify(sitePermissionsRepository, never()).isDrmBlockedForUrlByConfig(any()) + } + @Test fun whenDrmAlreadyAllowedForSessionThenDialogNotShownAndNoImpressionPixelFired() { whenever(sitePermissionsRepository.getDrmForSession("tabId", "example.com")).thenReturn(true) From 425255fd1028582ace69c22ed8684f282d39a9d1 Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 21 Aug 2026 15:55:36 +0200 Subject: [PATCH 12/13] Resolve a user DRM choice against every parent of the request origin --- .../site/permissions/impl/UserProtections.kt | 24 +++++++++++++++++-- .../impl/drm/RealDrmPolicyManager.kt | 11 ++++----- .../impl/drm/RealDrmPolicyManagerTest.kt | 9 +++++++ .../impl/drmblock/RealDrmBlockTest.kt | 9 +++++++ 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/UserProtections.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/UserProtections.kt index 5626c9b159b5..ce1ff8152588 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/UserProtections.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/UserProtections.kt @@ -29,6 +29,26 @@ import com.duckduckgo.common.utils.extensions.toTldPlusOneOrSelf */ internal fun UserAllowListRepository.isSiteUnprotectedByUser(uri: Uri): Boolean { if (isUriInUserAllowList(uri)) return true - val registrableDomain = uri.host?.toTldPlusOneOrSelf() ?: return false - return listOf(registrableDomain, "www.$registrableDomain").any { isDomainInUserAllowList(it) } + 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 { + val registrableDomain = toTldPlusOneOrSelf() + val chain = mutableListOf() + 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() } diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt index dd202cde78c0..e7b561428d3e 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManager.kt @@ -18,14 +18,13 @@ package com.duckduckgo.site.permissions.impl.drm import androidx.core.net.toUri import com.duckduckgo.app.privacy.db.UserAllowListRepository -import com.duckduckgo.common.utils.baseHost -import com.duckduckgo.common.utils.extensions.toTldPlusOneOrSelf import com.duckduckgo.common.utils.extractDomain import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.privacy.config.api.Drm import com.duckduckgo.privacy.config.api.UnprotectedTemporary import com.duckduckgo.site.permissions.impl.SitePermissionsRepository import com.duckduckgo.site.permissions.impl.drmblock.DrmBlock +import com.duckduckgo.site.permissions.impl.hostAndParentCandidates import com.duckduckgo.site.permissions.impl.isSiteUnprotectedByUser import com.duckduckgo.site.permissions.store.sitepermissions.SitePermissionAskSettingType import com.squareup.anvil.annotations.ContributesBinding @@ -44,11 +43,9 @@ class RealDrmPolicyManager @Inject constructor( override suspend fun decide(url: String, tabId: String?): DrmPolicyDecision { val domain = url.extractDomain() ?: url val uri = url.toUri() - // Settings are keyed on the host as typed, while both config lists match subdomains. Check every - // spelling plus the parent domain: missing the row would let remote config override a user's choice - val registrableDomain = domain.toTldPlusOneOrSelf() - val siteSetting = listOfNotNull(domain, uri.baseHost, uri.baseHost?.let { "www.$it" }, registrableDomain, "www.$registrableDomain") - .distinct() + // Settings are keyed on the host as typed, while both config lists match subdomains, so a choice + // stored against any parent of the request origin has to be found too. Most specific wins. + val siteSetting = domain.hostAndParentCandidates() .firstNotNullOfOrNull { host -> sitePermissionsRepository.getSitePermissionsForWebsite(host)?.askDrmSetting?.toDrmSetting() } return DrmPolicyContext( diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt index b33fccf2c805..316e23d281c9 100644 --- a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drm/RealDrmPolicyManagerTest.kt @@ -225,6 +225,15 @@ class RealDrmPolicyManagerTest { assertEquals(DrmPolicyDecision(DENY, USER_DENY_ALWAYS), testee.decide("https://static.foxnews.com/", tabId)) } + @Test + fun whenDenyAlwaysStoredOnIntermediateParentThenSubdomainRequestIsDenied() = runTest { + val entity = SitePermissionsEntity(domain = "open.spotify.com", askDrmSetting = SitePermissionAskSettingType.DENY_ALWAYS.name) + whenever(mockSitePermissionsRepository.getSitePermissionsForWebsite("open.spotify.com")).thenReturn(entity) + whenever(mockDrm.isDrmAllowedForUrl("https://static.open.spotify.com/")).thenReturn(true) + + assertEquals(DrmPolicyDecision(DENY, USER_DENY_ALWAYS), testee.decide("https://static.open.spotify.com/", tabId)) + } + @Test fun whenUrlIsMalformedThenPromptWithNoRule() = runTest { assertEquals(DrmPolicyDecision(PROMPT, NO_RULE), testee.decide("not a url", tabId)) diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlockTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlockTest.kt index 94a01d2a4164..332fe3dd87a0 100644 --- a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlockTest.kt +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlockTest.kt @@ -131,6 +131,15 @@ class RealDrmBlockTest { assertTrue(testee.isDrmBlockedForUrl("https://static.open.spotify.com/player")) } + @Test + fun whenProtectionsAreOffForAnIntermediateParentThenSubdomainIsNotBlocked() { + givenFeatureIsEnabled() + givenUrlIsInExceptionList() + whenever(mockUserAllowListRepository.isDomainInUserAllowList("open.spotify.com")).thenReturn(true) + + assertFalse(testee.isDrmBlockedForUrl("https://static.open.spotify.com/player")) + } + private fun givenFeatureIsEnabled() { whenever(mockDrmBlockFeature.self().isEnabled()).thenReturn(true) } From daaa1fc14fa2268769d7390fbedbb3cdc9e9adf6 Mon Sep 17 00:00:00 2001 From: 0nko Date: Fri, 21 Aug 2026 16:35:29 +0200 Subject: [PATCH 13/13] Gate the DRM block list changes behind the central policy flag --- .../impl/SitePermissionsManagerImpl.kt | 17 ++++---- .../permissions/impl/drmblock/RealDrmBlock.kt | 23 +++++++---- .../impl/drmblock/RealDrmBlockTest.kt | 40 ++++++++++++++++++- 3 files changed, 64 insertions(+), 16 deletions(-) diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt index d970a81d63dd..a8d27af566c9 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/SitePermissionsManagerImpl.kt @@ -73,14 +73,15 @@ class SitePermissionsManagerImpl @Inject constructor( val autoAccept = mutableListOf() val url = request.origin.toString() - val drmDecision = withContext(dispatcherProvider.io()) { - drmPolicyManager - .takeIf { - drmPolicyFeature.isCentralPolicyEnabled() && - request.resources.contains(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID) - } - ?.decide(url, tabId) - ?.also { logcat { "Permissions: drm policy decision for $url is $it" } } + 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 diff --git a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlock.kt b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlock.kt index f19ed82bf48a..cccd53337f2e 100644 --- a/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlock.kt +++ b/site-permissions/site-permissions-impl/src/main/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlock.kt @@ -19,8 +19,11 @@ package com.duckduckgo.site.permissions.impl.drmblock 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.privacy.config.api.UnprotectedTemporary +import com.duckduckgo.site.permissions.impl.feature.DrmPolicyFeature +import com.duckduckgo.site.permissions.impl.feature.isCentralPolicyEnabled import com.duckduckgo.site.permissions.impl.isSiteUnprotectedByUser import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject @@ -31,17 +34,23 @@ class RealDrmBlock @Inject constructor( private val drmBlockRepository: DrmBlockRepository, private val userAllowListRepository: UserAllowListRepository, private val unprotectedTemporary: UnprotectedTemporary, + private val drmPolicyFeature: DrmPolicyFeature, ) : DrmBlock { override fun isDrmBlockedForUrl(url: String): Boolean { val uri = url.toUri() - return drmBlockFeature.self().isEnabled() && - domainsThatBlockDrm(url) && - !userAllowListRepository.isSiteUnprotectedByUser(uri) && - !unprotectedTemporary.isAnException(uri.toString()) - } + if (!drmBlockFeature.self().isEnabled()) return false - private fun domainsThatBlockDrm(url: String): Boolean { - return drmBlockRepository.exceptions.any { sameOrSubdomain(url, it.domain) } + // The central policy widens both the list match and the protections-off check. The legacy path has to + // stay as it is on develop, so that turning the flag off during the rollout restores today's behaviour. + return if (drmPolicyFeature.isCentralPolicyEnabled()) { + drmBlockRepository.exceptions.any { sameOrSubdomain(url, it.domain) } && + !userAllowListRepository.isSiteUnprotectedByUser(uri) && + !unprotectedTemporary.isAnException(uri.toString()) + } else { + drmBlockRepository.exceptions.firstOrNull { it.domain == uri.baseHost } != null && + !userAllowListRepository.isUriInUserAllowList(uri) && + !unprotectedTemporary.isAnException(uri.toString()) + } } } diff --git a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlockTest.kt b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlockTest.kt index 332fe3dd87a0..354bf2d6c829 100644 --- a/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlockTest.kt +++ b/site-permissions/site-permissions-impl/src/test/java/com/duckduckgo/site/permissions/impl/drmblock/RealDrmBlockTest.kt @@ -18,9 +18,11 @@ package com.duckduckgo.site.permissions.impl.drmblock import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.app.privacy.db.UserAllowListRepository +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.FeatureException import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.privacy.config.api.UnprotectedTemporary +import com.duckduckgo.site.permissions.impl.feature.DrmPolicyFeature import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.whenever @@ -39,14 +41,23 @@ class RealDrmBlockTest { private val mockDrmBlockRepository: DrmBlockRepository = mock() private val mockUserAllowListRepository: UserAllowListRepository = mock() private val mockUnprotectedTemporary: UnprotectedTemporary = mock() + private val drmPolicyFeature = FakeFeatureToggleFactory.create(DrmPolicyFeature::class.java) - val testee: RealDrmBlock = RealDrmBlock(mockDrmBlockFeature, mockDrmBlockRepository, mockUserAllowListRepository, mockUnprotectedTemporary) + val testee: RealDrmBlock = RealDrmBlock( + mockDrmBlockFeature, + mockDrmBlockRepository, + mockUserAllowListRepository, + mockUnprotectedTemporary, + drmPolicyFeature, + ) val url = "https://open.spotify.com" @Before fun before() { whenever(mockDrmBlockFeature.self()).thenReturn(mockToggle) + drmPolicyFeature.self().setRawStoredState(Toggle.State(true)) + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(false)) } @Test @@ -90,6 +101,7 @@ class RealDrmBlockTest { @Test fun whenSubdomainOfBlockedDomainThenTrueIsReturned() { + givenCentralPolicyIsEnabled() givenFeatureIsEnabled() givenUrlIsInExceptionList() @@ -98,6 +110,7 @@ class RealDrmBlockTest { @Test fun whenParentOfBlockedDomainThenFalseIsReturned() { + givenCentralPolicyIsEnabled() givenFeatureIsEnabled() givenUrlIsInExceptionList() @@ -106,6 +119,7 @@ class RealDrmBlockTest { @Test fun whenProtectionsAreOffForThePageHostThenSubresourceOriginIsNotBlocked() { + givenCentralPolicyIsEnabled() givenFeatureIsEnabled() givenUrlIsInExceptionList() whenever(mockUserAllowListRepository.isDomainInUserAllowList("www.spotify.com")).thenReturn(true) @@ -115,6 +129,7 @@ class RealDrmBlockTest { @Test fun whenProtectionsAreOffForTheRegistrableDomainThenSubresourceOriginIsNotBlocked() { + givenCentralPolicyIsEnabled() givenFeatureIsEnabled() givenUrlIsInExceptionList() whenever(mockUserAllowListRepository.isDomainInUserAllowList("spotify.com")).thenReturn(true) @@ -124,6 +139,7 @@ class RealDrmBlockTest { @Test fun whenProtectionsAreOffForAnUnrelatedDomainThenBlockStands() { + givenCentralPolicyIsEnabled() givenFeatureIsEnabled() givenUrlIsInExceptionList() whenever(mockUserAllowListRepository.isDomainInUserAllowList("example.com")).thenReturn(true) @@ -133,6 +149,7 @@ class RealDrmBlockTest { @Test fun whenProtectionsAreOffForAnIntermediateParentThenSubdomainIsNotBlocked() { + givenCentralPolicyIsEnabled() givenFeatureIsEnabled() givenUrlIsInExceptionList() whenever(mockUserAllowListRepository.isDomainInUserAllowList("open.spotify.com")).thenReturn(true) @@ -140,6 +157,27 @@ class RealDrmBlockTest { assertFalse(testee.isDrmBlockedForUrl("https://static.open.spotify.com/player")) } + @Test + fun whenCentralPolicyDisabledThenSubdomainOfBlockedDomainIsNotBlocked() { + givenFeatureIsEnabled() + givenUrlIsInExceptionList() + + assertFalse(testee.isDrmBlockedForUrl("https://static.open.spotify.com/player")) + } + + @Test + fun whenCentralPolicyDisabledThenProtectionsOffForAParentDoesNotUnblock() { + givenFeatureIsEnabled() + givenUrlIsInExceptionList() + whenever(mockUserAllowListRepository.isDomainInUserAllowList("spotify.com")).thenReturn(true) + + assertTrue(testee.isDrmBlockedForUrl(url)) + } + + private fun givenCentralPolicyIsEnabled() { + drmPolicyFeature.centralPolicy().setRawStoredState(Toggle.State(true)) + } + private fun givenFeatureIsEnabled() { whenever(mockDrmBlockFeature.self().isEnabled()).thenReturn(true) }