diff --git a/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/SitePermissionsFeature.kt b/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/SitePermissionsFeature.kt index ddeb8a62a65..b0a71be74d9 100644 --- a/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/SitePermissionsFeature.kt +++ b/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/SitePermissionsFeature.kt @@ -54,6 +54,7 @@ typealias OnNeedToRequestPermissions = (permissions: Array) -> Unit * @property sessionManager the [SessionManager] instance in order to subscribe * to the selected [Session]. * @property storage the object in charge of persisting all the [SitePermissions] objects. + * @property sitePermissionsRules indicates how permissions should behave per permission category. * @property onNeedToRequestPermissions a callback invoked when permissions * need to be requested. Once the request is completed, [onPermissionsResult] needs to be invoked. **/ @@ -63,6 +64,7 @@ class SitePermissionsFeature( private val anchorView: View, private val sessionManager: SessionManager, private val storage: SitePermissionsStorage = SitePermissionsStorage(anchorView.context), + var sitePermissionsRules: SitePermissionsRules? = null, private val onNeedToRequestPermissions: OnNeedToRequestPermissions ) : LifecycleAwareFeature { @@ -166,9 +168,27 @@ class SitePermissionsFeature( internal suspend fun onContentPermissionRequested( session: Session, - permissionRequest: PermissionRequest + request: PermissionRequest ): DoorhangerPrompt? { + return if (shouldApplyRules(request.host)) { + handleRuledFlow(request, session) + } else { + handleNoRuledFlow(request, session) + } + } + + @VisibleForTesting + internal fun findDoNotAskAgainCheckBox(controls: List?): CheckBox? { + return controls?.find { + (it is CheckBox) + } as CheckBox? + } + + private suspend fun handleNoRuledFlow( + permissionRequest: PermissionRequest, + session: Session + ): DoorhangerPrompt? { val permissionFromStorage = withContext(ioCoroutineScope.coroutineContext) { storage.findSitePermissionsBy(permissionRequest.host) } @@ -181,17 +201,11 @@ class SitePermissionsFeature( } else { permissionRequest.reject() } + session.contentPermissionRequest.consume { true } null } } - @VisibleForTesting - internal fun findDoNotAskAgainCheckBox(controls: List?): CheckBox? { - return controls?.find { - (it is CheckBox) - } as CheckBox? - } - private fun shouldShowPrompt( permissionRequest: PermissionRequest, permissionFromStorage: SitePermissions? @@ -201,6 +215,23 @@ class SitePermissionsFeature( !permissionRequest.doNotAskAgain(permissionFromStorage)) } + private fun handleRuledFlow(permissionRequest: PermissionRequest, session: Session): DoorhangerPrompt? { + val action = requireNotNull(sitePermissionsRules).getActionFrom(permissionRequest) + return when (action) { + SitePermissionsRules.Action.BLOCKED -> { + permissionRequest.reject() + session.contentPermissionRequest.consume { true } + null + } + SitePermissionsRules.Action.ASK_TO_ALLOW -> { + createPrompt(permissionRequest, session) + } + } + } + + private fun shouldApplyRules(host: String) = + sitePermissionsRules != null && !requireNotNull(sitePermissionsRules).isHostInExceptions(host) + private fun PermissionRequest.doNotAskAgain(permissionFromStore: SitePermissions): Boolean { return permissions.any { permission -> when (permission) { diff --git a/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/SitePermissionsRules.kt b/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/SitePermissionsRules.kt new file mode 100644 index 00000000000..6a7857ce41d --- /dev/null +++ b/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/SitePermissionsRules.kt @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.feature.sitepermissions + +import android.net.Uri +import mozilla.components.concept.engine.permission.Permission +import mozilla.components.concept.engine.permission.PermissionRequest +import mozilla.components.feature.sitepermissions.SitePermissionsRules.Action.ASK_TO_ALLOW +import mozilla.components.feature.sitepermissions.SitePermissionsRules.Action.BLOCKED + +/** + * Indicate how site permissions must behave by permission category. + */ +data class SitePermissionsRules( + val camera: Action, + val location: Action, + val notification: Action, + val microphone: Action, + val exceptions: List? = null +) { + enum class Action { + BLOCKED, ASK_TO_ALLOW; + } + + internal fun getActionFrom(request: PermissionRequest): Action { + return if (request.containsVideoAndAudioSources()) { + getActionForCombinedPermission() + } else { + getActionForSinglePermission(request.permissions.first()) + } + } + + internal fun isHostInExceptions(host: String): Boolean { + if (exceptions == null || exceptions.isEmpty()) { + return false + } + + return exceptions.any { + it.host == host + } + } + + private fun getActionForSinglePermission(permission: Permission): Action { + return when (permission) { + is Permission.ContentGeoLocation -> { + location + } + is Permission.ContentNotification -> { + notification + } + is Permission.ContentAudioCapture, is Permission.ContentAudioMicrophone -> { + microphone + } + is Permission.ContentVideoCamera, is Permission.ContentVideoCapture -> { + camera + } + else -> ASK_TO_ALLOW + } + } + + private fun getActionForCombinedPermission(): Action { + return if (camera == BLOCKED || microphone == BLOCKED) { + BLOCKED + } else { + ASK_TO_ALLOW + } + } +} diff --git a/components/feature/sitepermissions/src/test/java/mozilla/components/feature/sitepermissions/SitePermissionsFeatureTest.kt b/components/feature/sitepermissions/src/test/java/mozilla/components/feature/sitepermissions/SitePermissionsFeatureTest.kt index 01de3332bc2..599bbf9714d 100644 --- a/components/feature/sitepermissions/src/test/java/mozilla/components/feature/sitepermissions/SitePermissionsFeatureTest.kt +++ b/components/feature/sitepermissions/src/test/java/mozilla/components/feature/sitepermissions/SitePermissionsFeatureTest.kt @@ -185,6 +185,69 @@ class SitePermissionsFeatureTest { } } + @Test + fun `onContentPermissionRequested with rules must behave according to the rules object`() { + val permissions = listOf( + ContentGeoLocation(), + ContentNotification(), + ContentAudioCapture(), + ContentAudioMicrophone() + ) + + val rules = SitePermissionsRules( + location = SitePermissionsRules.Action.BLOCKED, + camera = SitePermissionsRules.Action.ASK_TO_ALLOW, + notification = SitePermissionsRules.Action.ASK_TO_ALLOW, + microphone = SitePermissionsRules.Action.BLOCKED + ) + + sitePermissionFeature.sitePermissionsRules = rules + + permissions.forEach { permission -> + val session = getSelectedSession() + var grantWasCalled = false + var rejectWasCalled = false + + val permissionRequest: PermissionRequest = object : PermissionRequest { + override val uri: String? + get() = "http://www.mozilla.org" + override val permissions: List + get() = listOf(permission) + + override fun grant(permissions: List) { + grantWasCalled = true + } + + override fun reject() { + rejectWasCalled = true + } + } + + mockStorage = mock() + session.contentPermissionRequest = Consumable.from(permissionRequest) + + runBlocking { + val prompt = sitePermissionFeature.onContentPermissionRequested(session, permissionRequest) + + when (permission) { + is ContentGeoLocation, is ContentAudioCapture, is ContentAudioMicrophone -> { + assertTrue(rejectWasCalled) + assertFalse(grantWasCalled) + assertNull(prompt) + assertTrue(session.contentPermissionRequest.isConsumed()) + } + is ContentVideoCamera, is ContentVideoCapture, is ContentNotification -> { + assertFalse(rejectWasCalled) + assertFalse(grantWasCalled) + assertNotNull(prompt) + } + + else -> throw InvalidParameterException() + } + } + } + } + @Test fun `storing a new SitePermissions must call save on the store`() { val sitePermissionsList = listOf(ContentGeoLocation()) @@ -226,17 +289,21 @@ class SitePermissionsFeatureTest { } @Test - fun `requesting a content permissions with an already stored allowed permission will auto granted it and not show a prompt`() { + fun `requesting a content permissions with an already stored allowed permissions will auto granted it and not show a prompt`() { val request: PermissionRequest = mock() + val mockSession: Session = mock() + val mockConsumable: Consumable = mock() val sitePermissionFromStorage: SitePermissions = mock() val permissionList = listOf(ContentGeoLocation()) doReturn(permissionList).`when`(request).permissions doReturn(sitePermissionFromStorage).`when`(mockStorage).findSitePermissionsBy(anyString()) doReturn(ALLOWED).`when`(sitePermissionFromStorage).location + doReturn(mockConsumable).`when`(mockSession).contentPermissionRequest + doReturn(true).`when`(mockConsumable).consume { true } runBlocking { - val prompt = sitePermissionFeature.onContentPermissionRequested(mock(), request) + val prompt = sitePermissionFeature.onContentPermissionRequested(mockSession, request) verify(mockStorage).findSitePermissionsBy(anyString()) verify(request).grant(permissionList) assertNull(prompt) @@ -246,15 +313,19 @@ class SitePermissionsFeatureTest { @Test fun `requesting a content permissions with an already stored blocked permission will auto block it and not show a prompt`() { val request: PermissionRequest = mock() + val mockSession: Session = mock() + val mockConsumable: Consumable = mock() val sitePermissionFromStorage: SitePermissions = mock() val permissionList = listOf(ContentGeoLocation()) doReturn(permissionList).`when`(request).permissions doReturn(sitePermissionFromStorage).`when`(mockStorage).findSitePermissionsBy(anyString()) doReturn(BLOCKED).`when`(sitePermissionFromStorage).location + doReturn(mockConsumable).`when`(mockSession).contentPermissionRequest + doReturn(true).`when`(mockConsumable).consume { true } runBlocking { - val prompt = sitePermissionFeature.onContentPermissionRequested(mock(), request) + val prompt = sitePermissionFeature.onContentPermissionRequested(mockSession, request) verify(mockStorage).findSitePermissionsBy(anyString()) verify(request).reject() assertNull(prompt) diff --git a/components/feature/sitepermissions/src/test/java/mozilla/components/feature/sitepermissions/SitePermissionsRulesTest.kt b/components/feature/sitepermissions/src/test/java/mozilla/components/feature/sitepermissions/SitePermissionsRulesTest.kt new file mode 100644 index 00000000000..d3ae7be767b --- /dev/null +++ b/components/feature/sitepermissions/src/test/java/mozilla/components/feature/sitepermissions/SitePermissionsRulesTest.kt @@ -0,0 +1,155 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.feature.sitepermissions + +import android.view.View +import androidx.test.core.app.ApplicationProvider +import mozilla.components.browser.session.SessionManager +import mozilla.components.concept.engine.Engine +import mozilla.components.concept.engine.permission.Permission.ContentAudioCapture +import mozilla.components.concept.engine.permission.Permission.ContentNotification +import mozilla.components.concept.engine.permission.Permission.ContentVideoCapture +import mozilla.components.concept.engine.permission.Permission.Generic +import mozilla.components.concept.engine.permission.Permission.ContentGeoLocation +import mozilla.components.concept.engine.permission.PermissionRequest +import mozilla.components.feature.sitepermissions.SitePermissionsRules.Action.ASK_TO_ALLOW +import mozilla.components.feature.sitepermissions.SitePermissionsRules.Action.BLOCKED +import mozilla.components.support.ktx.kotlin.toUri +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.Mockito.doReturn +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SitePermissionsRulesTest { + + private lateinit var anchorView: View + private lateinit var mockSessionManager: SessionManager + private lateinit var rules: SitePermissionsFeature + private lateinit var mockOnNeedToRequestPermissions: OnNeedToRequestPermissions + private lateinit var mockStorage: SitePermissionsStorage + + @Before + fun setup() { + val engine = Mockito.mock(Engine::class.java) + anchorView = View(ApplicationProvider.getApplicationContext()) + mockSessionManager = Mockito.spy(SessionManager(engine)) + mockOnNeedToRequestPermissions = mock() + mockStorage = mock() + + rules = SitePermissionsFeature( + anchorView = anchorView, + sessionManager = mockSessionManager, + onNeedToRequestPermissions = mockOnNeedToRequestPermissions, + storage = mockStorage + ) + } + + @Test + fun `getActionFrom must return the right action per permission`() { + val rules = SitePermissionsRules( + camera = ASK_TO_ALLOW, + location = BLOCKED, + notification = ASK_TO_ALLOW, + microphone = BLOCKED + ) + + val mockRequest: PermissionRequest = mock() + + doReturn(listOf(ContentGeoLocation())).`when`(mockRequest).permissions + var action = rules.getActionFrom(mockRequest) + assertEquals(action, rules.location) + + doReturn(listOf(ContentNotification())).`when`(mockRequest).permissions + action = rules.getActionFrom(mockRequest) + assertEquals(action, rules.notification) + + doReturn(listOf(ContentAudioCapture())).`when`(mockRequest).permissions + action = rules.getActionFrom(mockRequest) + assertEquals(action, rules.microphone) + + doReturn(listOf(ContentVideoCapture())).`when`(mockRequest).permissions + action = rules.getActionFrom(mockRequest) + assertEquals(action, rules.camera) + + doReturn(listOf(Generic("", ""))).`when`(mockRequest).permissions + action = rules.getActionFrom(mockRequest) + assertEquals(action, rules.camera) + } + + @Test + fun `isHostInExceptions must return true for a host included in the exception list`() { + val rules = SitePermissionsRules( + camera = ASK_TO_ALLOW, + location = BLOCKED, + notification = ASK_TO_ALLOW, + microphone = BLOCKED, + exceptions = listOf("https://www.mozilla.org/".toUri()) + ) + + var isInExceptions = rules.isHostInExceptions("www.mozilla.org") + assertTrue(isInExceptions) + + isInExceptions = rules.isHostInExceptions("google.com") + assertFalse(isInExceptions) + } + + @Test + fun `isHostInExceptions must return false for an empty or null exception list`() { + var rules = SitePermissionsRules( + camera = ASK_TO_ALLOW, + location = BLOCKED, + notification = ASK_TO_ALLOW, + microphone = BLOCKED, + exceptions = null + ) + + var isInExceptions = rules.isHostInExceptions("www.mozilla.org") + assertFalse(isInExceptions) + + rules = SitePermissionsRules( + camera = ASK_TO_ALLOW, + location = BLOCKED, + notification = ASK_TO_ALLOW, + microphone = BLOCKED, + exceptions = emptyList() + ) + + isInExceptions = rules.isHostInExceptions("www.mozilla.org") + assertFalse(isInExceptions) + } + + @Test + fun `getActionFrom must return the right action for a Camera + Microphone permission`() { + var rules = SitePermissionsRules( + camera = ASK_TO_ALLOW, + location = BLOCKED, + notification = ASK_TO_ALLOW, + microphone = BLOCKED + ) + + val mockRequest: PermissionRequest = mock() + doReturn(true).`when`(mockRequest).containsVideoAndAudioSources() + + var action = rules.getActionFrom(mockRequest) + assertEquals(action, BLOCKED) + + rules = SitePermissionsRules( + camera = ASK_TO_ALLOW, + location = BLOCKED, + notification = ASK_TO_ALLOW, + microphone = ASK_TO_ALLOW + ) + + action = rules.getActionFrom(mockRequest) + assertEquals(action, ASK_TO_ALLOW) + } +}