Commit b242ad2d authored by Arturo Mejia's avatar Arturo Mejia
Browse files

Closes #2656 #2657 Remove multiple hardware from camera and microphone

prompts.
parent cd2ff8fc
......@@ -12,6 +12,7 @@ import android.util.AttributeSet
import android.widget.FrameLayout
import mozilla.components.concept.engine.EngineSession
import mozilla.components.concept.engine.EngineView
import mozilla.components.concept.engine.permission.PermissionRequest
import org.mozilla.geckoview.GeckoResult
......@@ -47,6 +48,8 @@ class GeckoEngineView @JvmOverloads constructor(
currentSession?.let { currentGeckoView.setSession(it.geckoSession) }
}
}
override fun onAppPermissionRequest(permissionRequest: PermissionRequest) = Unit
override fun onContentPermissionRequest(permissionRequest: PermissionRequest) = Unit
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
......
......@@ -12,6 +12,7 @@ android {
defaultConfig {
minSdkVersion config.minSdkVersion
targetSdkVersion config.targetSdkVersion
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
kapt {
arguments {
......@@ -26,6 +27,14 @@ android {
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
packagingOptions {
exclude 'META-INF/proguard/androidx-annotations.pro'
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
}
dependencies {
......@@ -48,6 +57,13 @@ dependencies {
testImplementation Dependencies.testing_robolectric
testImplementation Dependencies.testing_mockito
testImplementation project(':support-test')
androidTestImplementation project(':support-android-test')
androidTestImplementation Dependencies.room_testing
androidTestImplementation Dependencies.androidx_test_core
androidTestImplementation Dependencies.androidx_test_runner
androidTestImplementation Dependencies.androidx_test_rules
}
apply from: '../../../publish.gradle'
......
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "279719818fbc84cac905ddf942282eae",
"entities": [
{
"tableName": "site_permissions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `location` INTEGER NOT NULL, `notification` INTEGER NOT NULL, `microphone` INTEGER NOT NULL, `camera` INTEGER NOT NULL, `bluetooth` INTEGER NOT NULL, `local_storage` INTEGER NOT NULL, `saved_at` INTEGER NOT NULL, PRIMARY KEY(`origin`))",
"fields": [
{
"fieldPath": "origin",
"columnName": "origin",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "location",
"columnName": "location",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notification",
"columnName": "notification",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "microphone",
"columnName": "microphone",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "camera",
"columnName": "camera",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bluetooth",
"columnName": "bluetooth",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localStorage",
"columnName": "local_storage",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "savedAt",
"columnName": "saved_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"origin"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"279719818fbc84cac905ddf942282eae\")"
]
}
}
\ No newline at end of file
/* 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.db
import android.arch.persistence.db.framework.FrameworkSQLiteOpenHelperFactory
import android.arch.persistence.room.Room
import android.arch.persistence.room.testing.MigrationTestHelper
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.platform.app.InstrumentationRegistry
import mozilla.components.feature.sitepermissions.SitePermissions
import mozilla.components.feature.sitepermissions.SitePermissionsStorage
import mozilla.components.support.ktx.kotlin.toUri
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
private const val MIGRATION_TEST_DB = "migration-test"
class OnDeviceSitePermissionsStorageTest {
private val context: Context
get() = ApplicationProvider.getApplicationContext()
private lateinit var storage: SitePermissionsStorage
private lateinit var database: SitePermissionsDatabase
@get:Rule
val helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
SitePermissionsDatabase::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
)
@Before
fun setUp() {
database = Room.inMemoryDatabaseBuilder(context, SitePermissionsDatabase::class.java).build()
storage = SitePermissionsStorage(context)
storage.databaseInitializer = {
database
}
}
@After
fun tearDown() {
database.close()
}
@Test
fun testStorageInteraction() {
val origin = "https://www.mozilla.org".toUri().host!!
val sitePermissions = SitePermissions(
origin = origin,
camera = SitePermissions.Status.BLOCKED,
savedAt = System.currentTimeMillis()
)
storage.save(sitePermissions)
val sitePermissionsFromStorage = storage.findSitePermissionsBy(origin)!!
assertEquals(origin, sitePermissionsFromStorage.origin)
assertEquals(SitePermissions.Status.BLOCKED, sitePermissionsFromStorage.camera)
}
@Test
fun migrate1to2() {
val dbVersion1 = helper.createDatabase(MIGRATION_TEST_DB, 1).apply {
execSQL(
"INSERT INTO " +
"site_permissions " +
"(origin, location, notification, microphone,camera_front,camera_back,bluetooth,local_storage,saved_at) " +
"VALUES " +
"('mozilla.org',1,1,1,1,1,1,1,1)"
)
}
dbVersion1.query("SELECT * FROM site_permissions").use { cursor ->
assertEquals(9, cursor.columnCount)
}
val dbVersion2 = helper.runMigrationsAndValidate(
MIGRATION_TEST_DB, 2, true, Migrations.migration_1_2
).apply {
execSQL(
"INSERT INTO " +
"site_permissions " +
"(origin, location, notification, microphone,camera,bluetooth,local_storage,saved_at) " +
"VALUES " +
"('mozilla.org',1,1,1,1,1,1,1)"
)
}
dbVersion2.query("SELECT * FROM site_permissions").use { cursor ->
assertEquals(8, cursor.columnCount)
cursor.moveToFirst()
assertEquals(1, cursor.getInt(cursor.getColumnIndexOrThrow("camera")))
}
}
}
/* 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.db
import android.arch.persistence.room.Room
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import mozilla.components.feature.sitepermissions.SitePermissions
import mozilla.components.feature.sitepermissions.SitePermissions.Status.ALLOWED
import mozilla.components.feature.sitepermissions.SitePermissions.Status.BLOCKED
import mozilla.components.support.ktx.kotlin.toUri
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
class SitePermissionsDaoTest {
private val context: Context
get() = ApplicationProvider.getApplicationContext()
private lateinit var database: SitePermissionsDatabase
private lateinit var dao: SitePermissionsDao
@Before
fun setUp() {
database = Room.inMemoryDatabaseBuilder(context, SitePermissionsDatabase::class.java).build()
dao = database.sitePermissionsDao()
}
@After
fun tearDown() {
database.close()
}
@Test
fun testInsertingAndReadingSitePermissions() {
val origin = insertMockSitePermissions("https://www.mozilla.org")
val siteFromDb = dao.getSitePermissionsBy(origin)!!.toSitePermission()
assertEquals(origin, siteFromDb.origin)
assertEquals(BLOCKED, siteFromDb.camera)
}
@Test
fun testUpdateAndDeleteSitePermissions() {
val origin = insertMockSitePermissions("https://www.mozilla.org")
var siteFromDb = dao.getSitePermissionsBy(origin)!!.toSitePermission()
assertEquals(BLOCKED, siteFromDb.camera)
dao.update(siteFromDb.copy(camera = ALLOWED).toSitePermissionsEntity())
siteFromDb = dao.getSitePermissionsBy(origin)!!.toSitePermission()
assertEquals(ALLOWED, siteFromDb.camera)
dao.deleteSitePermissions(siteFromDb.toSitePermissionsEntity())
val notFoundSitePermissions = dao.getSitePermissionsBy(origin)?.toSitePermission()
assertNull(notFoundSitePermissions)
}
private fun insertMockSitePermissions(url: String): String {
val origin = url.toUri().host!!
val sitePermissions = SitePermissions(
origin = origin,
camera = BLOCKED,
savedAt = System.currentTimeMillis()
)
dao.insert(
sitePermissions.toSitePermissionsEntity()
)
return origin
}
}
......@@ -4,7 +4,10 @@
package mozilla.components.feature.sitepermissions
import android.os.Parcel
import android.os.Parcelable
import mozilla.components.feature.sitepermissions.SitePermissions.Status.NO_DECISION
import mozilla.components.feature.sitepermissions.db.StatusConverter
/**
* A site permissions and its state.
......@@ -14,12 +17,24 @@ data class SitePermissions(
val location: Status = NO_DECISION,
val notification: Status = NO_DECISION,
val microphone: Status = NO_DECISION,
val cameraBack: Status = NO_DECISION,
val cameraFront: Status = NO_DECISION,
val camera: Status = NO_DECISION,
val bluetooth: Status = NO_DECISION,
val localStorage: Status = NO_DECISION,
val savedAt: Long
) {
) : Parcelable {
constructor(parcel: Parcel) :
this(
requireNotNull(parcel.readString()),
requireNotNull(converter.toStatus(parcel.readInt())),
requireNotNull(converter.toStatus(parcel.readInt())),
requireNotNull(converter.toStatus(parcel.readInt())),
requireNotNull(converter.toStatus(parcel.readInt())),
requireNotNull(converter.toStatus(parcel.readInt())),
requireNotNull(converter.toStatus(parcel.readInt())),
parcel.readLong()
)
enum class Status(
internal val id: Int
) {
......@@ -29,4 +44,31 @@ data class SitePermissions(
fun doNotAskAgain() = this == ALLOWED || this == BLOCKED
}
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeString(origin)
parcel.writeInt(converter.toInt(location))
parcel.writeInt(converter.toInt(notification))
parcel.writeInt(converter.toInt(microphone))
parcel.writeInt(converter.toInt(camera))
parcel.writeInt(converter.toInt(bluetooth))
parcel.writeInt(converter.toInt(localStorage))
parcel.writeLong(savedAt)
}
override fun describeContents(): Int {
return 0
}
companion object CREATOR : Parcelable.Creator<SitePermissions> {
override fun createFromParcel(parcel: Parcel): SitePermissions {
return SitePermissions(parcel)
}
override fun newArray(size: Int): Array<SitePermissions?> {
return arrayOfNulls(size)
}
private val converter = StatusConverter()
}
}
......@@ -4,14 +4,13 @@
package mozilla.components.feature.sitepermissions
import android.Manifest.permission.RECORD_AUDIO
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.support.annotation.DrawableRes
import android.support.annotation.StringRes
import android.support.annotation.VisibleForTesting
import android.support.annotation.VisibleForTesting.PRIVATE
import android.support.v4.content.ContextCompat
import android.view.View
import kotlinx.coroutines.CoroutineScope
......@@ -33,12 +32,12 @@ import mozilla.components.concept.engine.permission.PermissionRequest
import mozilla.components.feature.sitepermissions.SitePermissions.Status.ALLOWED
import mozilla.components.feature.sitepermissions.SitePermissions.Status.BLOCKED
import mozilla.components.support.base.feature.LifecycleAwareFeature
import mozilla.components.support.ktx.android.content.isPermissionGranted
import mozilla.components.support.ktx.kotlin.toUri
import mozilla.components.ui.doorhanger.DoorhangerPrompt
import mozilla.components.ui.doorhanger.DoorhangerPrompt.Button
import mozilla.components.ui.doorhanger.DoorhangerPrompt.Control
import mozilla.components.ui.doorhanger.DoorhangerPrompt.Control.CheckBox
import mozilla.components.ui.doorhanger.DoorhangerPrompt.Control.RadioButton
import mozilla.components.ui.doorhanger.DoorhangerPrompt.ControlGroup
import java.security.InvalidParameterException
......@@ -171,10 +170,20 @@ class SitePermissionsFeature(
request: PermissionRequest
): DoorhangerPrompt? {
return if (shouldApplyRules(request.host)) {
// Preventing this behavior https://github.com/mozilla-mobile/android-components/issues/2668
if (request.isMicrophone && isMicrophoneAndroidPermissionNotGranted) {
request.reject()
return null
}
val permissionFromStorage = withContext(ioCoroutineScope.coroutineContext) {
storage.findSitePermissionsBy(request.host)
}
return if (shouldApplyRules(permissionFromStorage)) {
handleRuledFlow(request, session)
} else {
handleNoRuledFlow(request, session)
handleNoRuledFlow(permissionFromStorage, request, session)
}
}
......@@ -185,14 +194,11 @@ class SitePermissionsFeature(
} as CheckBox?
}
private suspend fun handleNoRuledFlow(
private fun handleNoRuledFlow(
permissionFromStorage: SitePermissions?,
permissionRequest: PermissionRequest,
session: Session
): DoorhangerPrompt? {
val permissionFromStorage = withContext(ioCoroutineScope.coroutineContext) {
storage.findSitePermissionsBy(permissionRequest.host)
}
return if (shouldShowPrompt(permissionRequest, permissionFromStorage)) {
createPrompt(permissionRequest, session)
} else {
......@@ -210,9 +216,7 @@ class SitePermissionsFeature(
permissionRequest: PermissionRequest,
permissionFromStorage: SitePermissions?
): Boolean {
return (permissionRequest.containsVideoAndAudioSources() ||
permissionFromStorage == null ||
!permissionRequest.doNotAskAgain(permissionFromStorage))
return (permissionFromStorage == null || !permissionRequest.doNotAskAgain(permissionFromStorage))
}
private fun handleRuledFlow(permissionRequest: PermissionRequest, session: Session): DoorhangerPrompt? {
......@@ -229,8 +233,8 @@ class SitePermissionsFeature(
}
}
private fun shouldApplyRules(host: String) =
sitePermissionsRules != null && !requireNotNull(sitePermissionsRules).isHostInExceptions(host)
private fun shouldApplyRules(permissionFromStorage: SitePermissions?) =
sitePermissionsRules != null && permissionFromStorage == null
private fun PermissionRequest.doNotAskAgain(permissionFromStore: SitePermissions): Boolean {
return permissions.any { permission ->
......@@ -238,12 +242,14 @@ class SitePermissionsFeature(
is ContentGeoLocation -> {
permissionFromStore.location.doNotAskAgain()
}
is ContentNotification -> {
permissionFromStore.notification.doNotAskAgain()
}
is ContentAudioCapture, is ContentAudioMicrophone -> {
permissionFromStore.microphone.doNotAskAgain()
}
is ContentVideoCamera, is ContentVideoCapture -> {
permissionFromStore.cameraFront.doNotAskAgain() &&
permissionFromStore.cameraBack.doNotAskAgain()
permissionFromStore.camera.doNotAskAgain()
}
else -> false
}
......@@ -281,11 +287,7 @@ class SitePermissionsFeature(
sitePermissions.copy(microphone = status)
}
is ContentVideoCamera, is ContentVideoCapture -> {
if (permission.isFrontCamera) {
sitePermissions.copy(cameraFront = status)
} else {
sitePermissions.copy(cameraBack = status)
}
sitePermissions.copy(camera = status)
}
else ->
throw InvalidParameterException("$permission is not a valid permission.")
......@@ -314,9 +316,10 @@ class SitePermissionsFeature(
}
prompt = if (!permissionRequest.containsVideoAndAudioSources()) {
handlingSingleContentPermissions(session, permissionRequest, host, allowButton, denyButton)
val permission = permissionRequest.permissions.first()
handlingSingleContentPermissions(permission, host, allowButton, denyButton)
} else {
createVideoAndAudioPrompt(session, permissionRequest, host, allowButtonTitle, denyButton)
createVideoAndAudioPrompt(host, allowButton, denyButton)
}
prompt.createDoorhanger(context).show(anchorView)
......@@ -347,56 +350,29 @@ class SitePermissionsFeature(
}
}
@SuppressLint("VisibleForTests")
private fun createVideoAndAudioPrompt(
session: Session,
permissionRequest: PermissionRequest,
host: String,
allowButtonTitle: String,
allowButton: Button,
denyButton: Button
): DoorhangerPrompt {
val context = anchorView.context
val title = context.getString(R.string.mozac_feature_sitepermissions_microfone_title, host)
val microphoneIcon = ContextCompat.getDrawable(context, R.drawable.mozac_ic_microphone)
val microphoneControlGroups = createControlGroupForMicrophonePermission(
icon = microphoneIcon,
shouldIncludeDoNotAskAgainCheckBox = false
)
val cameraIcon = ContextCompat.getDrawable(context, R.drawable.mozac_ic_video)
val cameraControlGroup = createControlGroupForCameraPermission(
icon = cameraIcon,
cameraPermissions = permissionRequest.cameraPermissions,
shouldIncludeDoNotAskAgainCheckBox = false
)
val allowButton = Button(allowButtonTitle, true) {
val selectedCameraPermission: Permission =
findSelectedPermission(cameraControlGroup, permissionRequest.cameraPermissions)
val selectedMicrophonePermission: Permission =