Merge lp:~uriboni/webbrowser-app/media-access into lp:webbrowser-app

Proposed by Ugo Riboni
Status: Merged
Approved by: Olivier Tilloy
Approved revision: 1222
Merged at revision: 1246
Proposed branch: lp:~uriboni/webbrowser-app/media-access
Merge into: lp:webbrowser-app
Diff against target: 709 lines (+508/-13)
9 files modified
debian/webbrowser-app-apparmor.manifest (+2/-0)
src/app/webbrowser/Browser.qml (+30/-0)
src/app/webbrowser/MediaAccessDialog.qml (+69/-0)
src/app/webbrowser/SettingsDeviceSelector.qml (+71/-0)
src/app/webbrowser/SettingsPage.qml (+97/-13)
tests/autopilot/webbrowser_app/emulators/browser.py (+18/-0)
tests/autopilot/webbrowser_app/tests/http_server.py (+10/-0)
tests/autopilot/webbrowser_app/tests/test_media_access_permission.py (+98/-0)
tests/unittests/qml/tst_SettingsPage.qml (+113/-0)
To merge this branch: bzr merge lp:~uriboni/webbrowser-app/media-access
Reviewer Review Type Date Requested Status
PS Jenkins bot continuous-integration Approve
Olivier Tilloy Approve
Review via email: mp+272919@code.launchpad.net

Commit message

Implement support for allowing or denying access to media input devices and for setting default media input devices.

Description of the change

Implement media access permissions support, prompting the user when the page requests access to the microphone or camera, and allowing the user to change their choices via new settings pages

Additionally allow the user to select the default audio and video input devices in the settings.

To test this on desktop if you don't have multiple webcams:

- sudo apt-get install v4l2loopback-dkms v4l2loopback-source
- sudo modprobe v4l2loopback
- gst-launch-0.10 gst-launch-0.10 videotestsrc ! v4l2sink device=/dev/video1

Replace video1 with the highest numbered video device in /dev/video*

Then in the settings you can switch between the sources. However to make the change effective you will need to cause the webpage to call again navigator.getUserMedia to pickup the new default stream(s).

To post a comment you must log in.
Revision history for this message
Olivier Tilloy (osomon) wrote :

Thanks for your work on this. I haven’t had a chance to test/review yet, but here is a preliminary comment: if/when https://code.launchpad.net/~osomon/webbrowser-app/apparmor-profile/+merge/272850 lands, we will need to add the 'microphone' and 'camera' policy groups to debian/webbrowser-app-apparmor.manifest.

Revision history for this message
Olivier Tilloy (osomon) wrote :

You’re connecting to onMediaAccessPermissionRequested for the current webview. What happens if another tab requests media access? Is the request lost?

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Ugo Riboni (uriboni) wrote :

> You’re connecting to onMediaAccessPermissionRequested for the current webview.
> What happens if another tab requests media access? Is the request lost?

If there is no handler connected to mediaAccessPermissionRequested then oxide will reject the request, but it will also not remember that choice internally for the session.
So when you go back to the page and cause navigator.getUserMedia to be called again by the page, a new onMediaAccessPermissionRequested call will be made, and webbrowser-app will be able to handle it.

What I can try to do if this is not the desired behavior is to connect mediaAccessPermissionRequested to all open webviews, and if the user has already granted/denied permission then go ahead and respond with that information to the request. Otherwise I can try to "hold" the request pending and display a dialog with the permission request as soon as the user switches back to the page. Though I am not sure yet if this is possible, because as soon as the mediaAccessPermissionRequested handler returns, oxide will temporarily deny the request.

Revision history for this message
Olivier Tilloy (osomon) wrote :

I don’t think holding up the request until the tab is focused is a good idea. Can you check how other mobile browsers behave in that regard? I would expect they either discard the request right away, or focus the tab that issues the request to make the user aware of the request.

Revision history for this message
Ugo Riboni (uriboni) wrote :

> I don’t think holding up the request until the tab is focused is a good idea.
> Can you check how other mobile browsers behave in that regard? I would expect
> they either discard the request right away, or focus the tab that issues the
> request to make the user aware of the request.

Both firefox and chrome do something similar to what I suggested: they do nothing, and when you switch to the tab that has requested permission they pop up an authorization request. So it seems that internally, they are actually "holding" on to the request until the user has a chance to decide. However I don't think we can do that with the current implementation of Oxide.

Revision history for this message
Olivier Tilloy (osomon) wrote :

> Both firefox and chrome do something similar to what I suggested: they do
> nothing, and when you switch to the tab that has requested permission they pop
> up an authorization request. So it seems that internally, they are actually
> "holding" on to the request until the user has a chance to decide. However I
> don't think we can do that with the current implementation of Oxide.

In that case discarding the request right away (by not connecting to the signal as you’re doing it) is probably the most sensible option. Can you please add a comment though, to make that explicit?

Revision history for this message
Olivier Tilloy (osomon) wrote :

Can the "media access" entry in the settings go before the "reset browser settings" one?

"Media Access" should probably be spelled "Media access" to be consistent with other entries in the settings menu.

What about the informative message about needing to restart the browser for a domain permission to take effect, do we still want to display something like that?

Browsing to https://opentokrtc.com/foobarbaz, allowing both camera and microphone access, then I go to settings and disallow microphone access for that domain, restart the browser and I’m prompted for both permissions again. I would expect not to be prompted at all.

Similarly, if I do the above but instead of disallowing microphone access I forget the permission for this domain (by swiping to delete the entry), and after restarting the browser, I expect that the prompt would request microphone permission only (as camera was already granted). Instead, it prompts for both camera and microphone.

If I click anywhere outside the permission prompt, it is dismissed, but I have no idea whether the permission was granted or not. I think that dialog should be modal (or at least tab-modal) so it can’t be dismissed without explicitly choosing to allow or forbid media access.

review: Needs Fixing (functional)
Revision history for this message
Olivier Tilloy (osomon) wrote :

I haven’t done a complete code review yet, but here’s a first round of comments:

For completeness, can you add to the class documentation of MediaAccessModel that its entries are not sorted in any particular order? (thanks for the detailed documentation btw, this is much appreciated)

In MediaAccessModel::data(), is the explicit cast to bool really needed?

isNullOrUndefined() should probably be enclosed in an anonymous namespace.

In MediaAccessModel::set(), writes to the DB could be done after signals (dataChanged, rowsInserted, rowCountChanged) have been emitted. This would have the potential to make the UI more responsive by not tying it to disk access (and would be consistent with how other SQLite-DB-backed models behave).
The same remark applies to ::unset().

The docstring for MediaAccessModel::unset() starts with "\qmlmethod void set", it should be "\qmlmethod void unset".

In MediaAccessModel::unset(), if an origin has e.g. only audio set, and I request unsetting only video, it looks to me like a dataChanged signal will be emitted (and the database will be written to), while in fact nothing should happen.

Revision history for this message
Chris Coulson (chrisccoulson) wrote :

I've had a brief look and left a comment inline.

The application shouldn't really be storing these decisions - it doesn't work properly for media access in some circumstances anyway because Chromium needs access to these decisions for navigator.mediaDevices.enumerateDevices to work properly (the device names are scrubbed unless permission has already been granted to a domain).

(It's even worse for notifications because a site has to explicitly request permission before sending a notification. This is how we ended up with a hack in Oxide to remember these decisions for the rest of a session).

Site-specific settings really belong in Oxide (see https://blueprints.launchpad.net/oxide/+spec/site-settings, and https://docs.google.com/document/d/1EZV4KzrH9eEUMjh3G8Cdykw3JTBtqOlunEEZd1kothA/edit)

Revision history for this message
Olivier Tilloy (osomon) wrote :

Although this is functionally fine, I’d prefer the databasePath of MediaAccessModel to be set declaratively rather than imperatively in Component.onCompleted. You could do something like that:

    Binding {
        target: MediaAccessModel
        property: "databasePath"
        value: dataLocation + "/mediaAccess.sqlite"
    }

A media permission request’s 'origin' parameter includes the scheme, and I think this is important information that would should not be trimming when remembering permissions for an origin. A user might want to allow use of the camera for a given domain over an encrypted connection (e.g. origin='https://webrtc.foobar.org/baz') but not over an unencrypted connection (e.g. origin='http://webrtc.foobar.org/bleh').

In MediaAccessDialog.qml, there are extraneous whitespaces between the end of a sentence and the question mark that closes them, in user-facing strings.

In SettingsPage.qml, the import of Qt.labs.settings can now be removed.

review: Needs Fixing
Revision history for this message
Olivier Tilloy (osomon) wrote :

> The application shouldn't really be storing these decisions […]

Thanks for the review and pointers Chris.

So until this API is available in oxide, I guess the way to go for the browser is to pop up a permission request for every emission of mediaAccessPermissionRequested, right?
This would be consistent with the way geolocation permission requests currently work in the browser. Slightly annoying for a user, but it will at least allow using camera and microphone, so definitely an improvement.

Ugo, can you rework that branch (or create a new one) to implement that?

Revision history for this message
Olivier Tilloy (osomon) wrote :

The browser is now running under apparmor confinement, so we need to add the 'microphone' and 'camera' policy groups to debian/webbrowser-app-apparmor.manifest.

Revision history for this message
Olivier Tilloy (osomon) wrote :

And one additional point that Chris raised during our oxide weekly call: remembering permissions should only be done when browsing in public mode, incognito mode should not leave a trace of the user’s actions. No big deal now that this logic is moving to oxide, the incognito check will be handled there.

1195. By Ugo Riboni

Add support for selecting the default audio input device for the web context

1196. By Ugo Riboni

Move the media access settings within the privacy section and rename settings items according to design spec

1197. By Ugo Riboni

Add comment to explain why we are connecting the mediaAccessPermissionRequested only on the current webview

1198. By Ugo Riboni

Add a note warning the user that they will need to restart the application for some changes to the media access permissions to be effective.

1199. By Ugo Riboni

Make the dialog an actual dialog, and modal

1200. By Ugo Riboni

Clarify and fix some MediaAccessModel documentation

1201. By Ugo Riboni

Update text of the restart note to match design

1202. By Ugo Riboni

Don't store permissions locally. Ask the user every time oxide sends a request

1203. By Ugo Riboni

Pop up a modal dialog when the active tab requests media access.

1204. By Ugo Riboni

Clarify via comments why we are doing things this way and what is the plan for the future

1205. By Ugo Riboni

Remove no longer used properties. Fix english question mark style

1206. By Ugo Riboni

Display information about the embedder (if any)

1207. By Ugo Riboni

Merge changes from trunk

1208. By Ugo Riboni

Fix text and positioning+size of buttons

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
1209. By Ugo Riboni

Merge changes from trunk

1210. By Ugo Riboni

Add microphone and camera apparmor policy groups

Revision history for this message
Olivier Tilloy (osomon) wrote :

In InputDevicesModel.qml, to avoid the double "Oxide." prefix, you can simply import com.canonical.Oxide 1.9 as an unnamed import.

What’s the use of InputDevicesModel.qml? Oxide.available{Audio,Video}CaptureDevices is a list of variants, do we really need to convert that to an array of string ids? Can’t we have direct references to Oxide.available{Audio,Video}CaptureDevices in SettingsDeviceSelector.qml ?

When running the autopilot tests, I’m seeing the following warning in the JS console:
    [JS] (:0) getUserMedia() is deprecated on insecure origins, and support will be removed in the future. You should consider switching your application to a secure origin, such as HTTPS. See https://goo.gl/rStTGz for more details.
This is out of scope for this MR, but we need to think about supporting https in our autopilot tests.

webbrowser_app.tests.test_media_access_permission.TestMediaAccessPermission.test_allow fails locally with the following traceback:

Traceback (most recent call last):
  File "/home/osomon/dev/phablet/browser/webbrowser-app/tests/autopilot/webbrowser_app/tests/test_media_access_permission.py", line 52, in test_allow
    self.main_window.wait_until_page_loaded(url)
  File "/home/osomon/dev/phablet/browser/webbrowser-app/tests/autopilot/webbrowser_app/emulators/browser.py", line 45, in wait_until_page_loaded
    webview.url.wait_for(url)
  File "/usr/lib/python3/dist-packages/autopilot/introspection/types.py", line 180, in wait_for
    failure_msg))
AssertionError: After 10.0 seconds test on WebViewImpl.url failed: 'http://test/media/v' != dbus.String('http://test/test2', variant_level=1)

Could it be because I don’t have a webcam plugged into my laptop?

review: Needs Fixing
1211. By Ugo Riboni

Cleaner imports

1212. By Ugo Riboni

Remove InputDevicesModel.qml as it is not really useful

1213. By Ugo Riboni

Alphabetically order apparmor policies

1214. By Ugo Riboni

Skip AP test as we can't guarantee a/v devices are present or test for their presence easily

1215. By Ugo Riboni

Use decorator to skip tests

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Olivier Tilloy (osomon) wrote :

There’s now one minor conflict when merging this branch into the latest trunk, can you please resolve it?

review: Needs Fixing
Revision history for this message
Olivier Tilloy (osomon) wrote :

Also note that after merging the latest trunk QmlTests::tst_SettingsPage will need to be updated, it fails with the following error:

2: QWARN : QmlTests::UnknownTestFunc() file:///home/osomon/dev/phablet/browser/webbrowser-app/tests/unittests/qml/tst_SettingsPage.qml:45:27: HistoryModelMock is not a type
2: historyModel: HistoryModelMock {
2: ^

1216. By Ugo Riboni

Merge changes from trunk

Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Approve (continuous-integration)
Revision history for this message
Olivier Tilloy (osomon) wrote :

For consistency with the rest of the codebase, can you please remove the trailing semi-colons in JS code embedded in QML?

get_deny_button and get_allow_button are used only to have the tests click those buttons, maybe the methods could be transformed into click_*_button (with a corresponding autopilot.logging.log_action decorator, see other similar methods in browser.py).

While testing this branch on desktop, I realized that having a modal dialog displayed doesn’t inhibit keyboard shortcuts. As this isn’t specific to this branch, I filed bug #1507468 to track the issue, and we can address it separately. Just a FYI.

Revision history for this message
Olivier Tilloy (osomon) wrote :

The rest of the changes look good. I’ve just tested on arale, and I’m not seeing any video devices listed in the settings (even with oxide 1.10.2 installed from the phablet-team PPA). Have you managed to validate that camera devices are listed on another device?

1217. By Ugo Riboni

Remove semicolons in JS

1218. By Ugo Riboni

Refactor AP tests to click the button directly from the emulator

Revision history for this message
Olivier Tilloy (osomon) wrote :

There’s still one remaining trailing semi-colon in JS code (in Browser.qml).

The import of "../UrlUtils.js" in MediaAccessDialog.qml is unused. So is "id: dialog".

In MediaAccessDialog.qml, the comment for translators could be more specific (e.g. "requesting access *to the microphone/camera*"). Keep in mind that translators are not expected to go read the code to figure out the context of a string.

In SettingsDeviceSelector.qml, shouldn’t updateDefaultDevice() be called when isAudio changes, too?

review: Needs Fixing
Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Approve (continuous-integration)
1219. By Ugo Riboni

Kill all the semicolons

1220. By Ugo Riboni

Remove unused imports and ids

1221. By Ugo Riboni

Clarify explanation for translators

1222. By Ugo Riboni

Update the default device in one more where it is necessary

Revision history for this message
Olivier Tilloy (osomon) wrote :

LGTM.

review: Approve
Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Approve (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'debian/webbrowser-app-apparmor.manifest'
2--- debian/webbrowser-app-apparmor.manifest 2015-09-29 20:14:26 +0000
3+++ debian/webbrowser-app-apparmor.manifest 2015-10-19 12:13:40 +0000
4@@ -9,10 +9,12 @@
5 "policy_groups": [
6 "accounts",
7 "audio",
8+ "camera",
9 "content_exchange",
10 "content_exchange_source",
11 "keep-display-on",
12 "location",
13+ "microphone",
14 "networking",
15 "push-notification-client",
16 "video",
17
18=== modified file 'src/app/webbrowser/Browser.qml'
19--- src/app/webbrowser/Browser.qml 2015-10-13 12:57:33 +0000
20+++ src/app/webbrowser/Browser.qml 2015-10-19 12:13:40 +0000
21@@ -75,6 +75,30 @@
22 }
23 }
24
25+ Connections {
26+ target: currentWebview
27+
28+ /* Note that we are connecting the mediaAccessPermissionRequested signal
29+ on the current webview only because we want all the tabs that are not
30+ visible to automatically deny the request but emit the signal again
31+ if the same origin requests permissions (which is the default
32+ behavior in oxide if we don't connect a signal handler), so that we
33+ can pop-up a dialog asking the user for permission.
34+
35+ Design is working on a new component that allows per-tab non-modal
36+ dialogs that will allow asking permission to the user without blocking
37+ interaction with the rest of the page or the window. When ready all
38+ tabs will have their mediaAccessPermissionRequested signal handled by
39+ creating one of these new dialogs.
40+ */
41+ onMediaAccessPermissionRequested: PopupUtils.open(mediaAccessDialogComponent, null, { request: request })
42+ }
43+
44+ Component {
45+ id: mediaAccessDialogComponent
46+ MediaAccessDialog { }
47+ }
48+
49 actions: [
50 Actions.GoTo {
51 onTriggered: currentWebview.url = value
52@@ -118,6 +142,8 @@
53 property string allowOpenInBackgroundTab: settingsDefaults.allowOpenInBackgroundTab
54 property bool restoreSession: settingsDefaults.restoreSession
55 property int newTabDefaultSection: settingsDefaults.newTabDefaultSection
56+ property string defaultAudioDevice
57+ property string defaultVideoDevice
58
59 function restoreDefaults() {
60 homepage = settingsDefaults.homepage
61@@ -125,6 +151,8 @@
62 allowOpenInBackgroundTab = settingsDefaults.allowOpenInBackgroundTab
63 restoreSession = settingsDefaults.restoreSession
64 newTabDefaultSection = settingsDefaults.newTabDefaultSection
65+ defaultAudioDevice = settingsDefaults.defaultAudioDevice
66+ defaultVideoDevice = settingsDefaults.defaultVideoDevice
67 }
68 }
69
70@@ -136,6 +164,8 @@
71 readonly property string allowOpenInBackgroundTab: "default"
72 readonly property bool restoreSession: true
73 readonly property int newTabDefaultSection: 0
74+ readonly property string defaultAudioDevice: ""
75+ readonly property string defaultVideoDevice: ""
76 }
77
78 FocusScope {
79
80=== added file 'src/app/webbrowser/MediaAccessDialog.qml'
81--- src/app/webbrowser/MediaAccessDialog.qml 1970-01-01 00:00:00 +0000
82+++ src/app/webbrowser/MediaAccessDialog.qml 2015-10-19 12:13:40 +0000
83@@ -0,0 +1,69 @@
84+/*
85+ * Copyright 2015 Canonical Ltd.
86+ *
87+ * This file is part of webbrowser-app.
88+ *
89+ * webbrowser-app is free software; you can redistribute it and/or modify
90+ * it under the terms of the GNU General Public License as published by
91+ * the Free Software Foundation; version 3.
92+ *
93+ * webbrowser-app is distributed in the hope that it will be useful,
94+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
95+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
96+ * GNU General Public License for more details.
97+ *
98+ * You should have received a copy of the GNU General Public License
99+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
100+ */
101+
102+import QtQuick 2.4
103+import Ubuntu.Components 1.3
104+import Ubuntu.Components.Popups 1.3
105+
106+Dialog {
107+ property var request
108+ modal: true
109+
110+ title: request.isForAudio && request.isForVideo ?
111+ i18n.tr("Allow this domain to access your camera and microphone?") :
112+ (request.isForVideo ? i18n.tr("Allow this domain to access your camera?")
113+ : i18n.tr("Allow this domain to access your microphone?"))
114+
115+ text: request.embedder.toString() !== request.origin.toString() ?
116+ internal.textWhenEmbedded : request.origin
117+
118+ Row {
119+ id: internal
120+
121+ // TRANSLATORS: %1 is the URL of the site requesting access to camera and/or microphone and %2 is the URL of the site that embeds it
122+ readonly property string textWhenEmbedded: i18n.tr("%1 (embedded in %2)")
123+ .arg(request.origin).arg(request.embedder)
124+ height: units.gu(4)
125+ spacing: units.gu(2)
126+ anchors.horizontalCenter: parent.horizontalCenter
127+
128+ Button {
129+ id: allowButton
130+ objectName: "mediaAccessDialog.allowButton"
131+ text: i18n.tr("Yes")
132+ color: UbuntuColors.green
133+ width: units.gu(14)
134+ onClicked: {
135+ request.allow()
136+ hide()
137+ }
138+ }
139+
140+ Button {
141+ id: denyButton
142+ objectName: "mediaAccessDialog.denyButton"
143+ text: i18n.tr("No")
144+ color: UbuntuColors.red
145+ width: units.gu(14)
146+ onClicked: {
147+ request.deny()
148+ hide()
149+ }
150+ }
151+ }
152+}
153
154=== added file 'src/app/webbrowser/SettingsDeviceSelector.qml'
155--- src/app/webbrowser/SettingsDeviceSelector.qml 1970-01-01 00:00:00 +0000
156+++ src/app/webbrowser/SettingsDeviceSelector.qml 2015-10-19 12:13:40 +0000
157@@ -0,0 +1,71 @@
158+/*
159+ * Copyright 2015 Canonical Ltd.
160+ *
161+ * This file is part of webbrowser-app.
162+ *
163+ * webbrowser-app is free software; you can redistribute it and/or modify
164+ * it under the terms of the GNU General Public License as published by
165+ * the Free Software Foundation; version 3.
166+ *
167+ * webbrowser-app is distributed in the hope that it will be useful,
168+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
169+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
170+ * GNU General Public License for more details.
171+ *
172+ * You should have received a copy of the GNU General Public License
173+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
174+ */
175+
176+import QtQuick 2.4
177+import Ubuntu.Components 1.3
178+import com.canonical.Oxide 1.9
179+
180+Item {
181+ property bool isAudio
182+ readonly property int devicesCount: internal.devices.length
183+ property alias enabled: selector.enabled
184+ property string defaultDevice
185+ signal deviceSelected(string id)
186+
187+ implicitHeight: selector.height + units.gu(1)
188+
189+ OptionSelector {
190+ id: selector
191+
192+ anchors.top: parent.top
193+ anchors.left: parent.left
194+ anchors.right: parent.right
195+ anchors.margins: units.gu(1)
196+ containerHeight: itemHeight * model.length
197+
198+ model: internal.devices
199+ delegate: OptionSelectorDelegate { text: modelData.id }
200+ onDelegateClicked: deviceSelected(model[index].id)
201+ }
202+
203+ QtObject {
204+ id: internal
205+
206+ property var devices: isAudio ? Oxide.availableAudioCaptureDevices :
207+ Oxide.availableVideoCaptureDevices
208+
209+ function updateDefaultDevice() {
210+ for (var i = 0; i < devices.length; i++) {
211+ if (defaultDevice === devices[i].id) {
212+ selector.selectedIndex = i
213+ return
214+ }
215+ }
216+ }
217+ }
218+
219+ onDefaultDeviceChanged: internal.updateDefaultDevice()
220+ Connections {
221+ target: Oxide
222+ onAvailableAudioCaptureDevicesChanged: if (isAudio) internal.updateDefaultDevice()
223+ onAvailableVideoCaptureDevicesChanged: if (!isAudio) internal.updateDefaultDevice()
224+ }
225+
226+ onIsAudioChanged: internal.updateDefaultDevice()
227+}
228+
229
230=== modified file 'src/app/webbrowser/SettingsPage.qml'
231--- src/app/webbrowser/SettingsPage.qml 2015-09-01 15:35:40 +0000
232+++ src/app/webbrowser/SettingsPage.qml 2015-10-19 12:13:40 +0000
233@@ -20,7 +20,7 @@
234 import Qt.labs.settings 1.0
235 import Ubuntu.Components 1.3
236 import Ubuntu.Components.Popups 1.3
237-import Ubuntu.Components.ListItems 1.3 as ListItem
238+import Ubuntu.Components.ListItems 1.3 as ListItems
239 import Ubuntu.Web 0.2
240 import webbrowserapp.private 0.1
241
242@@ -29,7 +29,7 @@
243 Item {
244 id: settingsItem
245
246- property Settings settingsObject
247+ property QtObject settingsObject
248
249 signal done()
250
251@@ -68,7 +68,7 @@
252
253 width: parent.width
254
255- ListItem.Subtitled {
256+ ListItems.Subtitled {
257 objectName: "searchengine"
258
259 SearchEngine {
260@@ -85,7 +85,7 @@
261 onClicked: searchEngineComponent.createObject(subpageContainer)
262 }
263
264- ListItem.Subtitled {
265+ ListItems.Subtitled {
266 objectName: "homepage"
267
268 text: i18n.tr("Homepage")
269@@ -94,7 +94,7 @@
270 onClicked: PopupUtils.open(homepageDialog)
271 }
272
273- ListItem.Standard {
274+ ListItems.Standard {
275 objectName: "restoreSession"
276
277 text: i18n.tr("Restore previous session at startup")
278@@ -112,7 +112,7 @@
279 }
280 }
281
282- ListItem.Standard {
283+ ListItems.Standard {
284 objectName: "backgroundTabs"
285
286 text: i18n.tr("Allow opening new tabs in background")
287@@ -131,15 +131,15 @@
288 }
289 }
290
291- ListItem.Standard {
292+ ListItems.Standard {
293 objectName: "privacy"
294
295- text: i18n.tr("Privacy")
296+ text: i18n.tr("Privacy & permissions")
297
298 onClicked: privacyComponent.createObject(subpageContainer)
299 }
300
301- ListItem.Standard {
302+ ListItems.Standard {
303 objectName: "reset"
304
305 text: i18n.tr("Reset browser settings")
306@@ -186,7 +186,7 @@
307
308 model: searchEngines.engines
309
310- delegate: ListItem.Standard {
311+ delegate: ListItems.Standard {
312 objectName: "searchEngineDelegate_" + index
313 SearchEngine {
314 id: searchEngineDelegate
315@@ -224,7 +224,7 @@
316 SettingsPageHeader {
317 id: privacyTitle
318 onBack: privacyItem.destroy()
319- text: i18n.tr("Privacy")
320+ text: i18n.tr("Privacy & permissions")
321 }
322
323 Flickable {
324@@ -243,7 +243,13 @@
325 id: privacyCol
326 width: parent.width
327
328- ListItem.Standard {
329+ ListItems.Standard {
330+ objectName: "privacy.mediaAccess"
331+ text: i18n.tr("Camera & microphone")
332+ onClicked: mediaAccessComponent.createObject(subpageContainer)
333+ }
334+
335+ ListItems.Standard {
336 objectName: "privacy.clearHistory"
337 text: i18n.tr("Clear Browsing History")
338 enabled: HistoryModel.count > 0
339@@ -253,7 +259,7 @@
340 }
341 }
342
343- ListItem.Standard {
344+ ListItems.Standard {
345 objectName: "privacy.clearCache"
346 text: i18n.tr("Clear Cache")
347 onClicked: {
348@@ -359,5 +365,83 @@
349 }
350 }
351 }
352+
353+ Component {
354+ id: mediaAccessComponent
355+
356+ Item {
357+ id: mediaAccessItem
358+ objectName: "mediaAccessSettings"
359+ anchors.fill: parent
360+
361+ Rectangle {
362+ anchors.fill: parent
363+ color: "#f6f6f6"
364+ }
365+
366+ SettingsPageHeader {
367+ id: mediaAccessTitle
368+
369+ onBack: mediaAccessItem.destroy()
370+ text: i18n.tr("Camera & microphone")
371+ }
372+
373+ Flickable {
374+ anchors {
375+ top: mediaAccessTitle.bottom
376+ left: parent.left
377+ right: parent.right
378+ bottom: parent.bottom
379+ }
380+
381+ clip: true
382+
383+ contentHeight: mediaAccessCol.height
384+
385+ Column {
386+ id: mediaAccessCol
387+ width: parent.width
388+
389+ ListItems.Standard {
390+ text: i18n.tr("Microphone")
391+ }
392+
393+ SettingsDeviceSelector {
394+ anchors.left: parent.left
395+ anchors.right: parent.right
396+
397+ isAudio: true
398+ visible: devicesCount > 0
399+ enabled: devicesCount > 1
400+
401+ defaultDevice: settings.defaultAudioDevice
402+ onDeviceSelected: {
403+ SharedWebContext.sharedContext.defaultAudioCaptureDeviceId = id
404+ settings.defaultAudioDevice = id
405+ }
406+ }
407+
408+ ListItems.Standard {
409+ text: i18n.tr("Camera")
410+ }
411+
412+ SettingsDeviceSelector {
413+ anchors.left: parent.left
414+ anchors.right: parent.right
415+
416+ isAudio: false
417+ visible: devicesCount > 0
418+ enabled: devicesCount > 1
419+
420+ defaultDevice: settings.defaultVideoDevice
421+ onDeviceSelected: {
422+ SharedWebContext.sharedContext.defaultVideoCaptureDeviceId = id
423+ settings.defaultVideoDevice = id
424+ }
425+ }
426+ }
427+ }
428+ }
429+ }
430 }
431
432
433=== modified file 'tests/autopilot/webbrowser_app/emulators/browser.py'
434--- tests/autopilot/webbrowser_app/emulators/browser.py 2015-10-08 15:26:18 +0000
435+++ tests/autopilot/webbrowser_app/emulators/browser.py 2015-10-19 12:13:40 +0000
436@@ -123,6 +123,9 @@
437 def get_http_auth_dialog(self):
438 return self.wait_select_single(HttpAuthenticationDialog)
439
440+ def get_media_access_dialog(self):
441+ return self.wait_select_single(MediaAccessDialog)
442+
443 def get_tabs_view(self):
444 return self.wait_select_single(TabsList, visible=True)
445
446@@ -402,6 +405,21 @@
447 return self.select_single("TextField", objectName="password")
448
449
450+class MediaAccessDialog(uitk.UbuntuUIToolkitCustomProxyObjectBase):
451+
452+ @autopilot.logging.log_action(logger.info)
453+ def click_deny_button(self):
454+ button = self.select_single("Button",
455+ objectName="mediaAccessDialog.denyButton")
456+ self.pointing_device.click_object(button)
457+
458+ @autopilot.logging.log_action(logger.info)
459+ def click_allow_button(self):
460+ button = self.select_single("Button",
461+ objectName="mediaAccessDialog.allowButton")
462+ self.pointing_device.click_object(button)
463+
464+
465 class TabPreview(uitk.UbuntuUIToolkitCustomProxyObjectBase):
466
467 @autopilot.logging.log_action(logger.info)
468
469=== modified file 'tests/autopilot/webbrowser_app/tests/http_server.py'
470--- tests/autopilot/webbrowser_app/tests/http_server.py 2015-09-15 15:21:15 +0000
471+++ tests/autopilot/webbrowser_app/tests/http_server.py 2015-10-19 12:13:40 +0000
472@@ -193,6 +193,16 @@
473 self.send_auth_request()
474 else:
475 self.send_auth_request()
476+ elif self.path.startswith("/media/"):
477+ self.send_response(200)
478+ permissions = self.path[len("/media/"):]
479+ self.send_html(
480+ "<script>navigator.webkitGetUserMedia("
481+ "{video: " + ("true" if "v" in permissions else "false") +
482+ ", audio: " + ("true" if "a" in permissions else "false") +
483+ "}, function() { location.href = '/test1' } " +
484+ ", function() { location.href = '/test2' })</script>"
485+ )
486 else:
487 self.send_error(404)
488
489
490=== added file 'tests/autopilot/webbrowser_app/tests/test_media_access_permission.py'
491--- tests/autopilot/webbrowser_app/tests/test_media_access_permission.py 1970-01-01 00:00:00 +0000
492+++ tests/autopilot/webbrowser_app/tests/test_media_access_permission.py 2015-10-19 12:13:40 +0000
493@@ -0,0 +1,98 @@
494+# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
495+#
496+# Copyright 2015 Canonical
497+#
498+# This program is free software: you can redistribute it and/or modify it
499+# under the terms of the GNU General Public License version 3, as published
500+# by the Free Software Foundation.
501+#
502+# This program is distributed in the hope that it will be useful,
503+# but WITHOUT ANY WARRANTY; without even the implied warranty of
504+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
505+# GNU General Public License for more details.
506+#
507+# You should have received a copy of the GNU General Public License
508+# along with this program. If not, see <http://www.gnu.org/licenses/>.
509+
510+import testtools
511+from webbrowser_app.tests import StartOpenRemotePageTestCaseBase
512+
513+
514+class TestMediaAccessPermission(StartOpenRemotePageTestCaseBase):
515+
516+ def setUp(self):
517+ super(TestMediaAccessPermission, self).setUp()
518+ self.url = self.base_url + "/media/"
519+ self.allowed_url = self.base_url + "/test1"
520+ self.denied_url = self.base_url + "/test2"
521+
522+ @testtools.skip("We can't guarantee/test that audio/video devices exist")
523+ def test_allow(self):
524+ # verify that trying to access any media raises an authorization dialog
525+ url = self.url + "a"
526+ self.main_window.go_to_url(url)
527+ self.main_window.wait_until_page_loaded(url)
528+ dialog = self.main_window.get_media_access_dialog()
529+
530+ # note that we have no easy way to verify that the browser actually
531+ # grants or denied permission based on our choice, because we can't
532+ # easily inspect the contents of the page from AP tests. the simplest
533+ # workaround I could find was to redirect the user to two different
534+ # pages upon permission granted or denied, and detect that instead
535+ dialog.click_allow_button()
536+ dialog.wait_until_destroyed()
537+ self.main_window.wait_until_page_loaded(self.allowed_url)
538+
539+ # verify that trying to access the same media for the same origin in
540+ # the same session will not ask for permission again...
541+ self.main_window.go_to_url(url)
542+ self.main_window.wait_until_page_loaded(self.allowed_url)
543+
544+ # ...but it will ask if we try to access other media
545+ url = self.url + "v"
546+ self.main_window.go_to_url(url)
547+ self.main_window.wait_until_page_loaded(url)
548+ dialog = self.main_window.get_media_access_dialog()
549+ dialog.click_allow_button()
550+ dialog.wait_until_destroyed()
551+ self.main_window.wait_until_page_loaded(self.allowed_url)
552+
553+ # now that we granted both permissions, verify that asking for both
554+ # together will also not raise the dialog
555+ url = self.url + "av"
556+ self.main_window.go_to_url(url)
557+ self.main_window.wait_until_page_loaded(self.allowed_url)
558+
559+ def test_deny(self):
560+ # verify that trying to access any media raises an authorization dialog
561+ # and we get redirected to the denial page in case we refuse to give
562+ # permission
563+ url = self.url + "a"
564+ self.main_window.go_to_url(url)
565+ self.main_window.wait_until_page_loaded(url)
566+ dialog = self.main_window.get_media_access_dialog()
567+ dialog.click_deny_button()
568+ dialog.wait_until_destroyed()
569+ self.main_window.wait_until_page_loaded(self.denied_url)
570+
571+ # verify that trying to access the same media for the same origin in
572+ # the same session will not ask for permission again...
573+ self.main_window.go_to_url(url)
574+ self.main_window.wait_until_page_loaded(self.denied_url)
575+
576+ @testtools.skip("Skipping due to oxide bug, see http://pad.lv/1501017")
577+ def test_deny_combined(self):
578+ # deny first one input type, then try to ask both and verify that a
579+ # request is made for the media that was not asked for the fist time
580+ url = self.url + "a"
581+ self.main_window.go_to_url(url)
582+ self.main_window.wait_until_page_loaded(url)
583+ dialog = self.main_window.get_media_access_dialog()
584+ dialog.click_deny_button()
585+ dialog.wait_until_destroyed()
586+ self.main_window.wait_until_page_loaded(self.denied_url)
587+
588+ url = self.url + "av"
589+ self.main_window.go_to_url(url)
590+ self.main_window.wait_until_page_loaded(url)
591+ dialog = self.main_window.get_media_access_dialog()
592
593=== added file 'tests/unittests/qml/tst_SettingsPage.qml'
594--- tests/unittests/qml/tst_SettingsPage.qml 1970-01-01 00:00:00 +0000
595+++ tests/unittests/qml/tst_SettingsPage.qml 2015-10-19 12:13:40 +0000
596@@ -0,0 +1,113 @@
597+/*
598+ * Copyright 2015 Canonical Ltd.
599+ *
600+ * This file is part of webbrowser-app.
601+ *
602+ * webbrowser-app is free software; you can redistribute it and/or modify
603+ * it under the terms of the GNU General Public License as published by
604+ * the Free Software Foundation; version 3.
605+ *
606+ * webbrowser-app is distributed in the hope that it will be useful,
607+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
608+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
609+ * GNU General Public License for more details.
610+ *
611+ * You should have received a copy of the GNU General Public License
612+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
613+ */
614+
615+import QtQuick 2.4
616+import QtTest 1.0
617+import Ubuntu.Test 1.0
618+import webbrowserapp.private 0.1
619+import webbrowsertest.private 0.1
620+import "../../../src/app/webbrowser"
621+
622+Item {
623+ width: 400
624+ height: 600
625+
626+ property var settingsPage: settingsPageLoader.item
627+
628+ Loader {
629+ id: settingsPageLoader
630+ anchors.fill: parent
631+ active: false
632+ sourceComponent: SettingsPage {
633+ anchors.fill: parent
634+
635+ // NOTE: the following properties are not necessary for the tests
636+ // currently in this file, but if we don't provide them a lot of
637+ // warnings will be generated.
638+ // Ideally either more tests that use them will be added or the code
639+ // in SettingsPage will be refactored to cope with the missing
640+ // settings.
641+ settingsObject: QtObject {
642+ property url homepage
643+ property string searchEngine
644+ property int newTabDefaultSection: 0
645+ }
646+ }
647+ }
648+
649+ UbuntuTestCase {
650+ name: "TestSettingsPage"
651+ when: windowShown
652+
653+ function clickItem(item) {
654+ var center = centerOf(item)
655+ mouseClick(item, center.x, center.y)
656+ }
657+
658+ function swipeItemRight(item) {
659+ var center = centerOf(item)
660+ var dx = item.width * 0.5
661+ mousePress(item, center.x, center.y)
662+ mouseMoveSlowly(item, center.x, center.y, dx, 0, 10, 0.01)
663+ mouseRelease(item, center.x + dx, center.y)
664+ }
665+
666+ function init() {
667+ settingsPageLoader.active = true
668+ waitForRendering(settingsPageLoader.item)
669+ }
670+
671+ function cleanup() {
672+ settingsPageLoader.active = false
673+ }
674+
675+ function getListItems(name, itemName) {
676+ var list = findChild(settingsPage, name)
677+ var items = []
678+ if (list) {
679+ // ensure all the delegates are created
680+ list.cacheBuffer = list.count * 1000
681+
682+ // In some cases the ListView might add other children to the
683+ // contentItem, so we filter the list of children to include
684+ // only actual delegates (names for delegates in this case
685+ // follow the pattern "name_index")
686+ var children = list.contentItem.children
687+ for (var i = 0; i < children.length; i++) {
688+ if (children[i].objectName.indexOf(itemName) == 0) {
689+ items.push(children[i])
690+ }
691+ }
692+ }
693+ return items
694+ }
695+
696+ function activateSettingsItem(itemName, pageName) {
697+ var item = findChild(settingsPage, itemName)
698+ clickItem(item)
699+ var page = findChild(settingsPage, pageName)
700+ waitForRendering(page)
701+ return page
702+ }
703+
704+ function goToMediaAccessPage() {
705+ activateSettingsItem("privacy", "privacySettings")
706+ return activateSettingsItem("privacy.mediaAccess", "mediaAccessSettings")
707+ }
708+ }
709+}

Subscribers

People subscribed via source and target branches

to status/vote changes: