merge fx-team to mozilla-central a=merge

This commit is contained in:
Carsten "Tomcat" Book 2014-11-14 13:13:42 +01:00
commit 98164c5baf
55 changed files with 867 additions and 370 deletions

View File

@ -22,4 +22,4 @@
# changes to stick? As of bug 928195, this shouldn't be necessary! Please # changes to stick? As of bug 928195, this shouldn't be necessary! Please
# don't change CLOBBER for WebIDL changes any more. # don't change CLOBBER for WebIDL changes any more.
Bug 1095234 - Bug 1091260 stopped packaging a devtools file with EXTRA_JS_MODULES while making it require pre-processing. Bug 1084498 - Android build tools dependency.

View File

@ -697,6 +697,38 @@ html, .fx-embedded, #main,
height: 100%; height: 100%;
} }
.standalone .room-conversation-wrapper {
height: calc(100% - 50px - 60px);
background: #000;
}
.room-conversation-wrapper header {
background: #000;
height: 50px;
text-align: left;
}
.room-conversation-wrapper header h1 {
font-size: 1.5em;
color: #fff;
line-height: 50px;
text-indent: 50px;
background-image: url("../img/firefox-logo.png");
background-size: 30px;
background-position: 10px;
background-repeat: no-repeat;
}
.room-conversation-wrapper footer {
background: #000;
height: 60px;
margin-top: -12px;
}
.room-conversation-wrapper footer a {
color: #555;
}
/** /**
* Hides the hangup button for room conversations. * Hides the hangup button for room conversations.
*/ */
@ -745,7 +777,7 @@ html, .fx-embedded, #main,
.standalone .room-inner-info-area { .standalone .room-inner-info-area {
position: absolute; position: absolute;
top: 35%; top: 50%;
left: 0; left: 0;
right: 25%; right: 25%;
z-index: 1000; z-index: 1000;
@ -767,6 +799,7 @@ html, .fx-embedded, #main,
padding: .5em 3em .3em 3em; padding: .5em 3em .3em 3em;
border-radius: 3px; border-radius: 3px;
font-weight: normal; font-weight: normal;
max-width: 400px;
} }
.standalone .room-conversation h2.room-name { .standalone .room-conversation h2.room-name {
@ -796,7 +829,7 @@ html, .fx-embedded, #main,
.standalone .room-conversation .conversation-toolbar { .standalone .room-conversation .conversation-toolbar {
background: #000; background: #000;
border-top: none; border: none;
} }
.standalone .room-conversation .conversation-toolbar .btn-hangup-entry { .standalone .room-conversation .conversation-toolbar .btn-hangup-entry {

View File

@ -10,6 +10,15 @@ loop.store.ActiveRoomStore = (function() {
"use strict"; "use strict";
var sharedActions = loop.shared.actions; var sharedActions = loop.shared.actions;
var FAILURE_REASONS = loop.shared.utils.FAILURE_REASONS;
// Error numbers taken from
// https://github.com/mozilla-services/loop-server/blob/master/loop/errno.json
var SERVER_CODES = loop.store.SERVER_CODES = {
INVALID_TOKEN: 105,
EXPIRED: 111,
ROOM_FULL: 202
};
var ROOM_STATES = loop.store.ROOM_STATES = { var ROOM_STATES = loop.store.ROOM_STATES = {
// The initial state of the room // The initial state of the room
@ -84,7 +93,8 @@ loop.store.ActiveRoomStore = (function() {
this._storeState = { this._storeState = {
roomState: ROOM_STATES.INIT, roomState: ROOM_STATES.INIT,
audioMuted: false, audioMuted: false,
videoMuted: false videoMuted: false,
failureReason: undefined
}; };
} }
@ -112,13 +122,24 @@ loop.store.ActiveRoomStore = (function() {
* @param {sharedActions.RoomFailure} actionData * @param {sharedActions.RoomFailure} actionData
*/ */
roomFailure: function(actionData) { roomFailure: function(actionData) {
function getReason(serverCode) {
switch (serverCode) {
case SERVER_CODES.INVALID_TOKEN:
case SERVER_CODES.EXPIRED:
return FAILURE_REASONS.EXPIRED_OR_INVALID;
default:
return FAILURE_REASONS.UNKNOWN;
}
}
console.error("Error in state `" + this._storeState.roomState + "`:", console.error("Error in state `" + this._storeState.roomState + "`:",
actionData.error); actionData.error);
this.setStoreState({ this.setStoreState({
error: actionData.error, error: actionData.error,
roomState: actionData.error.errno === 202 ? ROOM_STATES.FULL failureReason: getReason(actionData.error.errno),
: ROOM_STATES.FAILED roomState: actionData.error.errno === SERVER_CODES.ROOM_FULL ?
ROOM_STATES.FULL : ROOM_STATES.FAILED
}); });
}, },
@ -228,6 +249,11 @@ loop.store.ActiveRoomStore = (function() {
* Handles the action to join to a room. * Handles the action to join to a room.
*/ */
joinRoom: function() { joinRoom: function() {
// Reset the failure reason if necessary.
if (this.getStoreState().failureReason) {
this.setStoreState({failureReason: undefined});
}
this._mozLoop.rooms.join(this._storeState.roomToken, this._mozLoop.rooms.join(this._storeState.roomToken,
function(error, responseData) { function(error, responseData) {
if (error) { if (error) {
@ -275,11 +301,17 @@ loop.store.ActiveRoomStore = (function() {
/** /**
* Handles disconnection of this local client from the sdk servers. * Handles disconnection of this local client from the sdk servers.
*
* @param {sharedActions.ConnectionFailure} actionData
*/ */
connectionFailure: function() { connectionFailure: function(actionData) {
// Treat all reasons as something failed. In theory, clientDisconnected // Treat all reasons as something failed. In theory, clientDisconnected
// could be a success case, but there's no way we should be intentionally // could be a success case, but there's no way we should be intentionally
// sending that and still have the window open. // sending that and still have the window open.
this.setStoreState({
failureReason: actionData.reason
});
this._leaveRoom(ROOM_STATES.FAILED); this._leaveRoom(ROOM_STATES.FAILED);
}, },

View File

@ -8,6 +8,7 @@ var loop = loop || {};
loop.OTSdkDriver = (function() { loop.OTSdkDriver = (function() {
var sharedActions = loop.shared.actions; var sharedActions = loop.shared.actions;
var FAILURE_REASONS = loop.shared.utils.FAILURE_REASONS;
/** /**
* This is a wrapper for the OT sdk. It is used to translate the SDK events into * This is a wrapper for the OT sdk. It is used to translate the SDK events into
@ -47,8 +48,11 @@ loop.OTSdkDriver = (function() {
// the initial connect of the session. This saves time when setting up // the initial connect of the session. This saves time when setting up
// the media. // the media.
this.publisher = this.sdk.initPublisher(this.getLocalElement(), this.publisher = this.sdk.initPublisher(this.getLocalElement(),
this.publisherConfig, this.publisherConfig);
this._onPublishComplete.bind(this)); this.publisher.on("accessAllowed", this._onPublishComplete.bind(this));
this.publisher.on("accessDenied", this._onPublishDenied.bind(this));
this.publisher.on("accessDialogOpened",
this._onAccessDialogOpened.bind(this));
}, },
/** /**
@ -96,16 +100,12 @@ loop.OTSdkDriver = (function() {
*/ */
disconnectSession: function() { disconnectSession: function() {
if (this.session) { if (this.session) {
this.session.off("streamCreated", this._onRemoteStreamCreated.bind(this)); this.session.off("streamCreated connectionDestroyed sessionDisconnected");
this.session.off("connectionDestroyed",
this._onConnectionDestroyed.bind(this));
this.session.off("sessionDisconnected",
this._onSessionDisconnected.bind(this));
this.session.disconnect(); this.session.disconnect();
delete this.session; delete this.session;
} }
if (this.publisher) { if (this.publisher) {
this.publisher.off("accessAllowed accessDenied accessDialogOpened");
this.publisher.destroy(); this.publisher.destroy();
delete this.publisher; delete this.publisher;
} }
@ -126,7 +126,7 @@ loop.OTSdkDriver = (function() {
if (error) { if (error) {
console.error("Failed to complete connection", error); console.error("Failed to complete connection", error);
this.dispatcher.dispatch(new sharedActions.ConnectionFailure({ this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
reason: "couldNotConnect" reason: FAILURE_REASONS.COULD_NOT_CONNECT
})); }));
return; return;
} }
@ -159,7 +159,7 @@ loop.OTSdkDriver = (function() {
// We only need to worry about the network disconnected reason here. // We only need to worry about the network disconnected reason here.
if (event.reason === "networkDisconnected") { if (event.reason === "networkDisconnected") {
this.dispatcher.dispatch(new sharedActions.ConnectionFailure({ this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
reason: "networkDisconnected" reason: FAILURE_REASONS.NETWORK_DISCONNECTED
})); }));
} }
}, },
@ -188,24 +188,42 @@ loop.OTSdkDriver = (function() {
} }
}, },
/**
* Called from the sdk when the media access dialog is opened.
* Prevents the default action, to prevent the SDK's "allow access"
* dialog from being shown.
*
* @param {OT.Event} event
*/
_onAccessDialogOpened: function(event) {
event.preventDefault();
},
/** /**
* Handles the publishing being complete. * Handles the publishing being complete.
* *
* @param {Error} error An OT error object, null if there was no error. * @param {OT.Event} event
*/ */
_onPublishComplete: function(error) { _onPublishComplete: function(event) {
if (error) { event.preventDefault();
console.error("Failed to initialize publisher", error);
this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
reason: "noMedia"
}));
return;
}
this._publisherReady = true; this._publisherReady = true;
this._maybePublishLocalStream(); this._maybePublishLocalStream();
}, },
/**
* Handles publishing of media being denied.
*
* @param {OT.Event} event
*/
_onPublishDenied: function(event) {
// This prevents the SDK's "access denied" dialog showing.
event.preventDefault();
this.dispatcher.dispatch(new sharedActions.ConnectionFailure({
reason: FAILURE_REASONS.MEDIA_DENIED
}));
},
/** /**
* Publishes the local stream if the session is connected * Publishes the local stream if the session is connected
* and the publisher is ready. * and the publisher is ready.

View File

@ -17,6 +17,14 @@ loop.shared.utils = (function(mozL10n) {
AUDIO_ONLY: "audio" AUDIO_ONLY: "audio"
}; };
var FAILURE_REASONS = {
MEDIA_DENIED: "reason-media-denied",
COULD_NOT_CONNECT: "reason-could-not-connect",
NETWORK_DISCONNECTED: "reason-network-disconnected",
EXPIRED_OR_INVALID: "reason-expired-or-invalid",
UNKNOWN: "reason-unknown"
};
/** /**
* Format a given date into an l10n-friendly string. * Format a given date into an l10n-friendly string.
* *
@ -110,6 +118,7 @@ loop.shared.utils = (function(mozL10n) {
return { return {
CALL_TYPES: CALL_TYPES, CALL_TYPES: CALL_TYPES,
FAILURE_REASONS: FAILURE_REASONS,
Helper: Helper, Helper: Helper,
composeCallUrlEmail: composeCallUrlEmail, composeCallUrlEmail: composeCallUrlEmail,
formatDate: formatDate, formatDate: formatDate,

View File

@ -18,6 +18,13 @@ body,
font-family: Open Sans,sans-serif; font-family: Open Sans,sans-serif;
} }
/**
* Note: the is-standalone-room class is dynamically set by the StandaloneRoomView.
*/
.standalone.is-standalone-room {
background-color: #000;
}
.standalone-header { .standalone-header {
border-radius: 4px; border-radius: 4px;
background: #fff; background: #fff;

View File

@ -11,8 +11,10 @@ var loop = loop || {};
loop.standaloneRoomViews = (function(mozL10n) { loop.standaloneRoomViews = (function(mozL10n) {
"use strict"; "use strict";
var FAILURE_REASONS = loop.shared.utils.FAILURE_REASONS;
var ROOM_STATES = loop.store.ROOM_STATES; var ROOM_STATES = loop.store.ROOM_STATES;
var sharedActions = loop.shared.actions; var sharedActions = loop.shared.actions;
var sharedMixins = loop.shared.mixins;
var sharedViews = loop.shared.views; var sharedViews = loop.shared.views;
var StandaloneRoomInfoArea = React.createClass({displayName: 'StandaloneRoomInfoArea', var StandaloneRoomInfoArea = React.createClass({displayName: 'StandaloneRoomInfoArea',
@ -39,6 +41,20 @@ loop.standaloneRoomViews = (function(mozL10n) {
); );
}, },
/**
* @return String An appropriate string according to the failureReason.
*/
_getFailureString: function() {
switch(this.props.failureReason) {
case FAILURE_REASONS.MEDIA_DENIED:
return mozL10n.get("rooms_media_denied_message");
case FAILURE_REASONS.EXPIRED_OR_INVALID:
return mozL10n.get("rooms_unavailable_notification_message");
default:
return mozL10n.get("status_error");
};
},
_renderContent: function() { _renderContent: function() {
switch(this.props.roomState) { switch(this.props.roomState) {
case ROOM_STATES.INIT: case ROOM_STATES.INIT:
@ -67,6 +83,12 @@ loop.standaloneRoomViews = (function(mozL10n) {
React.DOM.p(null, this._renderCallToActionLink()) React.DOM.p(null, this._renderCallToActionLink())
) )
); );
case ROOM_STATES.FAILED:
return (
React.DOM.p({className: "failed-room-message"},
this._getFailureString()
)
);
default: default:
return null; return null;
} }
@ -81,6 +103,43 @@ loop.standaloneRoomViews = (function(mozL10n) {
} }
}); });
var StandaloneRoomHeader = React.createClass({displayName: 'StandaloneRoomHeader',
render: function() {
return (
React.DOM.header(null,
React.DOM.h1(null, mozL10n.get("clientShortname2"))
)
);
}
});
var StandaloneRoomFooter = React.createClass({displayName: 'StandaloneRoomFooter',
_getContent: function() {
return mozL10n.get("legal_text_and_links", {
"clientShortname": mozL10n.get("clientShortname2"),
"terms_of_use_url": React.renderComponentToStaticMarkup(
React.DOM.a({href: loop.config.legalWebsiteUrl, target: "_blank"},
mozL10n.get("terms_of_use_link_text")
)
),
"privacy_notice_url": React.renderComponentToStaticMarkup(
React.DOM.a({href: loop.config.privacyWebsiteUrl, target: "_blank"},
mozL10n.get("privacy_notice_link_text")
)
),
});
},
render: function() {
return (
React.DOM.footer(null,
React.DOM.p({dangerouslySetInnerHTML: {__html: this._getContent()}}),
React.DOM.div({className: "footer-logo"})
)
);
}
});
var StandaloneRoomView = React.createClass({displayName: 'StandaloneRoomView', var StandaloneRoomView = React.createClass({displayName: 'StandaloneRoomView',
mixins: [Backbone.Events], mixins: [Backbone.Events],
@ -144,6 +203,11 @@ loop.standaloneRoomViews = (function(mozL10n) {
}; };
}, },
componentDidMount: function() {
// Adding a class to the document body element from here to ease styling it.
document.body.classList.add("is-standalone-room");
},
componentWillUnmount: function() { componentWillUnmount: function() {
this.stopListening(this.props.activeRoomStore); this.stopListening(this.props.activeRoomStore);
}, },
@ -207,7 +271,9 @@ loop.standaloneRoomViews = (function(mozL10n) {
return ( return (
React.DOM.div({className: "room-conversation-wrapper"}, React.DOM.div({className: "room-conversation-wrapper"},
StandaloneRoomHeader(null),
StandaloneRoomInfoArea({roomState: this.state.roomState, StandaloneRoomInfoArea({roomState: this.state.roomState,
failureReason: this.state.failureReason,
joinRoom: this.joinRoom, joinRoom: this.joinRoom,
helper: this.props.helper}), helper: this.props.helper}),
React.DOM.div({className: "video-layout-wrapper"}, React.DOM.div({className: "video-layout-wrapper"},
@ -229,7 +295,8 @@ loop.standaloneRoomViews = (function(mozL10n) {
hangupButtonLabel: mozL10n.get("rooms_leave_button_label"), hangupButtonLabel: mozL10n.get("rooms_leave_button_label"),
enableHangup: this._roomIsActive()}) enableHangup: this._roomIsActive()})
) )
) ),
StandaloneRoomFooter(null)
) )
); );
} }

View File

@ -11,8 +11,10 @@ var loop = loop || {};
loop.standaloneRoomViews = (function(mozL10n) { loop.standaloneRoomViews = (function(mozL10n) {
"use strict"; "use strict";
var FAILURE_REASONS = loop.shared.utils.FAILURE_REASONS;
var ROOM_STATES = loop.store.ROOM_STATES; var ROOM_STATES = loop.store.ROOM_STATES;
var sharedActions = loop.shared.actions; var sharedActions = loop.shared.actions;
var sharedMixins = loop.shared.mixins;
var sharedViews = loop.shared.views; var sharedViews = loop.shared.views;
var StandaloneRoomInfoArea = React.createClass({ var StandaloneRoomInfoArea = React.createClass({
@ -39,6 +41,20 @@ loop.standaloneRoomViews = (function(mozL10n) {
); );
}, },
/**
* @return String An appropriate string according to the failureReason.
*/
_getFailureString: function() {
switch(this.props.failureReason) {
case FAILURE_REASONS.MEDIA_DENIED:
return mozL10n.get("rooms_media_denied_message");
case FAILURE_REASONS.EXPIRED_OR_INVALID:
return mozL10n.get("rooms_unavailable_notification_message");
default:
return mozL10n.get("status_error");
};
},
_renderContent: function() { _renderContent: function() {
switch(this.props.roomState) { switch(this.props.roomState) {
case ROOM_STATES.INIT: case ROOM_STATES.INIT:
@ -67,6 +83,12 @@ loop.standaloneRoomViews = (function(mozL10n) {
<p>{this._renderCallToActionLink()}</p> <p>{this._renderCallToActionLink()}</p>
</div> </div>
); );
case ROOM_STATES.FAILED:
return (
<p className="failed-room-message">
{this._getFailureString()}
</p>
);
default: default:
return null; return null;
} }
@ -81,6 +103,43 @@ loop.standaloneRoomViews = (function(mozL10n) {
} }
}); });
var StandaloneRoomHeader = React.createClass({
render: function() {
return (
<header>
<h1>{mozL10n.get("clientShortname2")}</h1>
</header>
);
}
});
var StandaloneRoomFooter = React.createClass({
_getContent: function() {
return mozL10n.get("legal_text_and_links", {
"clientShortname": mozL10n.get("clientShortname2"),
"terms_of_use_url": React.renderComponentToStaticMarkup(
<a href={loop.config.legalWebsiteUrl} target="_blank">
{mozL10n.get("terms_of_use_link_text")}
</a>
),
"privacy_notice_url": React.renderComponentToStaticMarkup(
<a href={loop.config.privacyWebsiteUrl} target="_blank">
{mozL10n.get("privacy_notice_link_text")}
</a>
),
});
},
render: function() {
return (
<footer>
<p dangerouslySetInnerHTML={{__html: this._getContent()}}></p>
<div className="footer-logo" />
</footer>
);
}
});
var StandaloneRoomView = React.createClass({ var StandaloneRoomView = React.createClass({
mixins: [Backbone.Events], mixins: [Backbone.Events],
@ -144,6 +203,11 @@ loop.standaloneRoomViews = (function(mozL10n) {
}; };
}, },
componentDidMount: function() {
// Adding a class to the document body element from here to ease styling it.
document.body.classList.add("is-standalone-room");
},
componentWillUnmount: function() { componentWillUnmount: function() {
this.stopListening(this.props.activeRoomStore); this.stopListening(this.props.activeRoomStore);
}, },
@ -207,7 +271,9 @@ loop.standaloneRoomViews = (function(mozL10n) {
return ( return (
<div className="room-conversation-wrapper"> <div className="room-conversation-wrapper">
<StandaloneRoomHeader />
<StandaloneRoomInfoArea roomState={this.state.roomState} <StandaloneRoomInfoArea roomState={this.state.roomState}
failureReason={this.state.failureReason}
joinRoom={this.joinRoom} joinRoom={this.joinRoom}
helper={this.props.helper} /> helper={this.props.helper} />
<div className="video-layout-wrapper"> <div className="video-layout-wrapper">
@ -230,6 +296,7 @@ loop.standaloneRoomViews = (function(mozL10n) {
enableHangup={this._roomIsActive()} /> enableHangup={this._roomIsActive()} />
</div> </div>
</div> </div>
<StandaloneRoomFooter />
</div> </div>
); );
} }

View File

@ -116,6 +116,8 @@ rooms_room_full_call_to_action_label=Learn more about {{clientShortname}} »
rooms_room_joined_label=Someone has joined the conversation! rooms_room_joined_label=Someone has joined the conversation!
rooms_room_join_label=Join the conversation rooms_room_join_label=Join the conversation
rooms_display_name_guest=Guest rooms_display_name_guest=Guest
rooms_unavailable_notification_message=Sorry, you cannot join this conversation. The link may be expired or invalid.
rooms_media_denied_message=We could not get access to your microphone or camera. Please reload the page to try again.
## LOCALIZATION_NOTE(standalone_title_with_status): {{clientShortname}} will be ## LOCALIZATION_NOTE(standalone_title_with_status): {{clientShortname}} will be
## replaced by the brand name and {{currentStatus}} will be replaced ## replaced by the brand name and {{currentStatus}} will be replaced

View File

@ -65,6 +65,7 @@ describe("loop.roomViews", function () {
roomState: ROOM_STATES.INIT, roomState: ROOM_STATES.INIT,
audioMuted: false, audioMuted: false,
videoMuted: false, videoMuted: false,
failureReason: undefined,
foo: "bar" foo: "bar"
}); });
}); });

View File

@ -6,7 +6,9 @@ var sharedActions = loop.shared.actions;
describe("loop.store.ActiveRoomStore", function () { describe("loop.store.ActiveRoomStore", function () {
"use strict"; "use strict";
var SERVER_CODES = loop.store.SERVER_CODES;
var ROOM_STATES = loop.store.ROOM_STATES; var ROOM_STATES = loop.store.ROOM_STATES;
var FAILURE_REASONS = loop.shared.utils.FAILURE_REASONS;
var sandbox, dispatcher, store, fakeMozLoop, fakeSdkDriver; var sandbox, dispatcher, store, fakeMozLoop, fakeSdkDriver;
var fakeMultiplexGum; var fakeMultiplexGum;
@ -91,8 +93,8 @@ describe("loop.store.ActiveRoomStore", function () {
sinon.match(ROOM_STATES.READY), fakeError); sinon.match(ROOM_STATES.READY), fakeError);
}); });
it("should set the state to `FULL` on server errno 202", function() { it("should set the state to `FULL` on server error room full", function() {
fakeError.errno = 202; fakeError.errno = SERVER_CODES.ROOM_FULL;
store.roomFailure({error: fakeError}); store.roomFailure({error: fakeError});
@ -103,7 +105,28 @@ describe("loop.store.ActiveRoomStore", function () {
store.roomFailure({error: fakeError}); store.roomFailure({error: fakeError});
expect(store._storeState.roomState).eql(ROOM_STATES.FAILED); expect(store._storeState.roomState).eql(ROOM_STATES.FAILED);
expect(store._storeState.failureReason).eql(FAILURE_REASONS.UNKNOWN);
}); });
it("should set the failureReason to EXPIRED_OR_INVALID on server error: " +
"invalid token", function() {
fakeError.errno = SERVER_CODES.INVALID_TOKEN;
store.roomFailure({error: fakeError});
expect(store._storeState.roomState).eql(ROOM_STATES.FAILED);
expect(store._storeState.failureReason).eql(FAILURE_REASONS.EXPIRED_OR_INVALID);
});
it("should set the failureReason to EXPIRED_OR_INVALID on server error: " +
"expired", function() {
fakeError.errno = SERVER_CODES.EXPIRED;
store.roomFailure({error: fakeError});
expect(store._storeState.roomState).eql(ROOM_STATES.FAILED);
expect(store._storeState.failureReason).eql(FAILURE_REASONS.EXPIRED_OR_INVALID);
});
}); });
describe("#setupWindowData", function() { describe("#setupWindowData", function() {
@ -244,6 +267,14 @@ describe("loop.store.ActiveRoomStore", function () {
store.setStoreState({roomToken: "tokenFake"}); store.setStoreState({roomToken: "tokenFake"});
}); });
it("should reset failureReason", function() {
store.setStoreState({failureReason: "Test"});
store.joinRoom();
expect(store.getStoreState().failureReason).eql(undefined);
});
it("should call rooms.join on mozLoop", function() { it("should call rooms.join on mozLoop", function() {
store.joinRoom(); store.joinRoom();
@ -380,22 +411,34 @@ describe("loop.store.ActiveRoomStore", function () {
}); });
describe("#connectionFailure", function() { describe("#connectionFailure", function() {
var connectionFailureAction;
beforeEach(function() { beforeEach(function() {
store.setStoreState({ store.setStoreState({
roomState: ROOM_STATES.JOINED, roomState: ROOM_STATES.JOINED,
roomToken: "fakeToken", roomToken: "fakeToken",
sessionToken: "1627384950" sessionToken: "1627384950"
}); });
connectionFailureAction = new sharedActions.ConnectionFailure({
reason: "FAIL"
});
});
it("should store the failure reason", function() {
store.connectionFailure(connectionFailureAction);
expect(store.getStoreState().failureReason).eql("FAIL");
}); });
it("should reset the multiplexGum", function() { it("should reset the multiplexGum", function() {
store.leaveRoom(); store.connectionFailure(connectionFailureAction);
sinon.assert.calledOnce(fakeMultiplexGum.reset); sinon.assert.calledOnce(fakeMultiplexGum.reset);
}); });
it("should disconnect from the servers via the sdk", function() { it("should disconnect from the servers via the sdk", function() {
store.connectionFailure(); store.connectionFailure(connectionFailureAction);
sinon.assert.calledOnce(fakeSdkDriver.disconnectSession); sinon.assert.calledOnce(fakeSdkDriver.disconnectSession);
}); });
@ -404,13 +447,13 @@ describe("loop.store.ActiveRoomStore", function () {
sandbox.stub(window, "clearTimeout"); sandbox.stub(window, "clearTimeout");
store._timeout = {}; store._timeout = {};
store.connectionFailure(); store.connectionFailure(connectionFailureAction);
sinon.assert.calledOnce(clearTimeout); sinon.assert.calledOnce(clearTimeout);
}); });
it("should call mozLoop.rooms.leave", function() { it("should call mozLoop.rooms.leave", function() {
store.connectionFailure(); store.connectionFailure(connectionFailureAction);
sinon.assert.calledOnce(fakeMozLoop.rooms.leave); sinon.assert.calledOnce(fakeMozLoop.rooms.leave);
sinon.assert.calledWithExactly(fakeMozLoop.rooms.leave, sinon.assert.calledWithExactly(fakeMozLoop.rooms.leave,
@ -418,7 +461,7 @@ describe("loop.store.ActiveRoomStore", function () {
}); });
it("should set the state to `FAILED`", function() { it("should set the state to `FAILED`", function() {
store.connectionFailure(); store.connectionFailure(connectionFailureAction);
expect(store.getStoreState().roomState).eql(ROOM_STATES.FAILED); expect(store.getStoreState().roomState).eql(ROOM_STATES.FAILED);
}); });

View File

@ -7,16 +7,19 @@ describe("loop.OTSdkDriver", function () {
"use strict"; "use strict";
var sharedActions = loop.shared.actions; var sharedActions = loop.shared.actions;
var FAILURE_REASONS = loop.shared.utils.FAILURE_REASONS;
var sandbox; var sandbox;
var dispatcher, driver, publisher, sdk, session, sessionData; var dispatcher, driver, publisher, sdk, session, sessionData;
var fakeLocalElement, fakeRemoteElement, publisherConfig; var fakeLocalElement, fakeRemoteElement, publisherConfig, fakeEvent;
beforeEach(function() { beforeEach(function() {
sandbox = sinon.sandbox.create(); sandbox = sinon.sandbox.create();
fakeLocalElement = {fake: 1}; fakeLocalElement = {fake: 1};
fakeRemoteElement = {fake: 2}; fakeRemoteElement = {fake: 2};
fakeEvent = {
preventDefault: sinon.stub()
};
publisherConfig = { publisherConfig = {
fake: "config" fake: "config"
}; };
@ -34,14 +37,14 @@ describe("loop.OTSdkDriver", function () {
subscribe: sinon.stub() subscribe: sinon.stub()
}, Backbone.Events); }, Backbone.Events);
publisher = { publisher = _.extend({
destroy: sinon.stub(), destroy: sinon.stub(),
publishAudio: sinon.stub(), publishAudio: sinon.stub(),
publishVideo: sinon.stub() publishVideo: sinon.stub()
}; }, Backbone.Events);
sdk = { sdk = {
initPublisher: sinon.stub(), initPublisher: sinon.stub().returns(publisher),
initSession: sinon.stub().returns(session) initSession: sinon.stub().returns(session)
}; };
@ -80,51 +83,6 @@ describe("loop.OTSdkDriver", function () {
sinon.assert.calledOnce(sdk.initPublisher); sinon.assert.calledOnce(sdk.initPublisher);
sinon.assert.calledWith(sdk.initPublisher, fakeLocalElement, publisherConfig); sinon.assert.calledWith(sdk.initPublisher, fakeLocalElement, publisherConfig);
}); });
describe("On Publisher Complete", function() {
it("should publish the stream if the connection is ready", function() {
sdk.initPublisher.callsArgWith(2, null);
driver.session = session;
driver._sessionConnected = true;
dispatcher.dispatch(new sharedActions.SetupStreamElements({
getLocalElementFunc: function() {return fakeLocalElement;},
getRemoteElementFunc: function() {return fakeRemoteElement;},
publisherConfig: publisherConfig
}));
sinon.assert.calledOnce(session.publish);
});
it("should dispatch connectionFailure if connecting failed", function() {
sdk.initPublisher.callsArgWith(2, new Error("Failure"));
// Special stub, as we want to use the dispatcher, but also know that
// we've been called correctly for the second dispatch.
var dispatchStub = (function() {
var originalDispatch = dispatcher.dispatch.bind(dispatcher);
return sandbox.stub(dispatcher, "dispatch", function(action) {
originalDispatch(action);
});
}());
driver.session = session;
driver._sessionConnected = true;
dispatcher.dispatch(new sharedActions.SetupStreamElements({
getLocalElementFunc: function() {return fakeLocalElement;},
getRemoteElementFunc: function() {return fakeRemoteElement;},
publisherConfig: publisherConfig
}));
sinon.assert.called(dispatcher.dispatch);
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "connectionFailure"));
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("reason", "noMedia"));
});
});
}); });
describe("#setMute", function() { describe("#setMute", function() {
@ -194,7 +152,7 @@ describe("loop.OTSdkDriver", function () {
sinon.assert.calledWithMatch(dispatcher.dispatch, sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "connectionFailure")); sinon.match.hasOwn("name", "connectionFailure"));
sinon.assert.calledWithMatch(dispatcher.dispatch, sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("reason", "couldNotConnect")); sinon.match.hasOwn("reason", FAILURE_REASONS.COULD_NOT_CONNECT));
}); });
}); });
}); });
@ -269,7 +227,7 @@ describe("loop.OTSdkDriver", function () {
sinon.assert.calledWithMatch(dispatcher.dispatch, sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "connectionFailure")); sinon.match.hasOwn("name", "connectionFailure"));
sinon.assert.calledWithMatch(dispatcher.dispatch, sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("reason", "networkDisconnected")); sinon.match.hasOwn("reason", FAILURE_REASONS.NETWORK_DISCONNECTED));
}); });
}); });
@ -328,5 +286,41 @@ describe("loop.OTSdkDriver", function () {
sinon.assert.notCalled(dispatcher.dispatch); sinon.assert.notCalled(dispatcher.dispatch);
}); });
}); });
describe("accessAllowed", function() {
it("should publish the stream if the connection is ready", function() {
driver._sessionConnected = true;
publisher.trigger("accessAllowed", fakeEvent);
sinon.assert.calledOnce(session.publish);
});
});
describe("accessDenied", function() {
it("should prevent the default event behavior", function() {
publisher.trigger("accessDenied", fakeEvent);
sinon.assert.calledOnce(fakeEvent.preventDefault);
});
it("should dispatch connectionFailure", function() {
publisher.trigger("accessDenied", fakeEvent);
sinon.assert.called(dispatcher.dispatch);
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("name", "connectionFailure"));
sinon.assert.calledWithMatch(dispatcher.dispatch,
sinon.match.hasOwn("reason", FAILURE_REASONS.MEDIA_DENIED));
});
});
describe("accessDialogOpened", function() {
it("should prevent the default event behavior", function() {
publisher.trigger("accessDialogOpened", fakeEvent);
sinon.assert.calledOnce(fakeEvent.preventDefault);
});
});
}); });
}); });

View File

@ -139,6 +139,16 @@ describe("loop.standaloneRoomViews", function() {
}); });
}); });
describe("Failed room message", function() {
it("should display a failed room message on FAILED",
function() {
activeRoomStore.setStoreState({roomState: ROOM_STATES.FAILED});
expect(view.getDOMNode().querySelector(".failed-room-message"))
.not.eql(null);
});
});
describe("Join button", function() { describe("Join button", function() {
function getJoinButton(view) { function getJoinButton(view) {
return view.getDOMNode().querySelector(".btn-join"); return view.getDOMNode().querySelector(".btn-join");

View File

@ -608,6 +608,16 @@
roomState: ROOM_STATES.FULL, roomState: ROOM_STATES.FULL,
helper: {isFirefox: returnFalse}}) helper: {isFirefox: returnFalse}})
) )
),
Example({summary: "Standalone room conversation (failed)"},
React.DOM.div({className: "standalone"},
StandaloneRoomView({
dispatcher: dispatcher,
activeRoomStore: activeRoomStore,
roomState: ROOM_STATES.FAILED,
helper: {isFirefox: returnFalse}})
)
) )
), ),

View File

@ -609,6 +609,16 @@
helper={{isFirefox: returnFalse}} /> helper={{isFirefox: returnFalse}} />
</div> </div>
</Example> </Example>
<Example summary="Standalone room conversation (failed)">
<div className="standalone">
<StandaloneRoomView
dispatcher={dispatcher}
activeRoomStore={activeRoomStore}
roomState={ROOM_STATES.FAILED}
helper={{isFirefox: returnFalse}} />
</div>
</Example>
</Section> </Section>
<Section name="SVG icons preview"> <Section name="SVG icons preview">

View File

@ -9,11 +9,6 @@ function test() {
} }
function runTests() { function runTests() {
// Request a longer timeout because the test takes quite a while
// to complete on slow Windows debug machines and we would otherwise
// see a lot of (not so) intermittent test failures.
requestLongerTimeout(2);
Services.prefs.setBoolPref(PREF_RESTORE_ON_DEMAND, true); Services.prefs.setBoolPref(PREF_RESTORE_ON_DEMAND, true);
registerCleanupFunction(function () { registerCleanupFunction(function () {
Services.prefs.clearUserPref(PREF_RESTORE_ON_DEMAND); Services.prefs.clearUserPref(PREF_RESTORE_ON_DEMAND);
@ -29,112 +24,31 @@ function runTests() {
{ entries: [{ url: "http://example.org/#7" }], extData: { "uniq": r() } }, { entries: [{ url: "http://example.org/#7" }], extData: { "uniq": r() } },
{ entries: [{ url: "http://example.org/#8" }], extData: { "uniq": r() } }, { entries: [{ url: "http://example.org/#8" }], extData: { "uniq": r() } },
{ entries: [{ url: "http://example.org/#9" }], extData: { "uniq": r() } }, { entries: [{ url: "http://example.org/#9" }], extData: { "uniq": r() } },
{ entries: [{ url: "http://example.org/#10" }], extData: { "uniq": r() } },
{ entries: [{ url: "http://example.org/#11" }], extData: { "uniq": r() } },
{ entries: [{ url: "http://example.org/#12" }], extData: { "uniq": r() } },
{ entries: [{ url: "http://example.org/#13" }], extData: { "uniq": r() } },
{ entries: [{ url: "http://example.org/#14" }], extData: { "uniq": r() } },
{ entries: [{ url: "http://example.org/#15" }], extData: { "uniq": r() } },
{ entries: [{ url: "http://example.org/#16" }], extData: { "uniq": r() } },
{ entries: [{ url: "http://example.org/#17" }], extData: { "uniq": r() } },
{ entries: [{ url: "http://example.org/#18" }], extData: { "uniq": r() } }
], selected: 1 }] }; ], selected: 1 }] };
let loadCount = 0; let loadCount = 0;
gProgressListener.setCallback(function (aBrowser, aNeedRestore, aRestoring, aRestored) { gBrowser.tabContainer.addEventListener("SSTabRestored", function onRestored(event) {
loadCount++; let tab = event.target;
is(aBrowser.currentURI.spec, state.windows[0].tabs[loadCount - 1].entries[0].url, let browser = tab.linkedBrowser;
let tabData = state.windows[0].tabs[loadCount++];
// double check that this tab was the right one
is(browser.currentURI.spec, tabData.entries[0].url,
"load " + loadCount + " - browser loaded correct url"); "load " + loadCount + " - browser loaded correct url");
is(ss.getTabValue(tab, "uniq"), tabData.extData.uniq,
"load " + loadCount + " - correct tab was restored");
if (loadCount <= state.windows[0].tabs.length) { if (loadCount == state.windows[0].tabs.length) {
// double check that this tab was the right one gBrowser.tabContainer.removeEventListener("SSTabRestored", onRestored);
let expectedData = state.windows[0].tabs[loadCount - 1].extData.uniq;
let tab;
for (let i = 0; i < window.gBrowser.tabs.length; i++) {
if (!tab && window.gBrowser.tabs[i].linkedBrowser == aBrowser)
tab = window.gBrowser.tabs[i];
}
is(ss.getTabValue(tab, "uniq"), expectedData,
"load " + loadCount + " - correct tab was restored");
if (loadCount == state.windows[0].tabs.length) { executeSoon(function () {
gProgressListener.unsetCallback(); waitForBrowserState(TestRunner.backupState, finish);
executeSoon(function () { });
reloadAllTabs(state, function () { } else {
waitForBrowserState(TestRunner.backupState, testCascade); // reload the next tab
}); gBrowser.browsers[loadCount].reload();
});
} else {
// reload the next tab
window.gBrowser.reloadTab(window.gBrowser.tabs[loadCount]);
}
} }
}); });
yield ss.setBrowserState(JSON.stringify(state)); yield ss.setBrowserState(JSON.stringify(state));
} }
function testCascade() {
Services.prefs.setBoolPref(PREF_RESTORE_ON_DEMAND, false);
let state = { windows: [{ tabs: [
{ entries: [{ url: "http://example.com/#1" }], extData: { "uniq": r() } },
{ entries: [{ url: "http://example.com/#2" }], extData: { "uniq": r() } },
{ entries: [{ url: "http://example.com/#3" }], extData: { "uniq": r() } },
{ entries: [{ url: "http://example.com/#4" }], extData: { "uniq": r() } },
{ entries: [{ url: "http://example.com/#5" }], extData: { "uniq": r() } },
{ entries: [{ url: "http://example.com/#6" }], extData: { "uniq": r() } }
] }] };
let loadCount = 0;
gProgressListener.setCallback(function (aBrowser, aNeedRestore, aRestoring, aRestored) {
if (++loadCount < state.windows[0].tabs.length) {
return;
}
gProgressListener.unsetCallback();
executeSoon(function () {
reloadAllTabs(state, next);
});
});
ss.setBrowserState(JSON.stringify(state));
}
function reloadAllTabs(aState, aCallback) {
// Simulate a left mouse button click with no modifiers, which is what
// Command-R, or clicking reload does.
let fakeEvent = {
button: 0,
metaKey: false,
altKey: false,
ctrlKey: false,
shiftKey: false
};
let loadCount = 0;
gWebProgressListener.setCallback(function (aBrowser) {
if (++loadCount <= aState.windows[0].tabs.length) {
// double check that this tab was the right one
let expectedData = aState.windows[0].tabs[loadCount - 1].extData.uniq;
let tab;
for (let i = 0; i < window.gBrowser.tabs.length; i++) {
if (!tab && window.gBrowser.tabs[i].linkedBrowser == aBrowser)
tab = window.gBrowser.tabs[i];
}
is(ss.getTabValue(tab, "uniq"), expectedData,
"load " + loadCount + " - correct tab was reloaded");
if (loadCount == aState.windows[0].tabs.length) {
gWebProgressListener.unsetCallback();
executeSoon(aCallback);
} else {
// reload the next tab
window.gBrowser.selectTabAtIndex(loadCount);
BrowserReloadOrDuplicate(fakeEvent);
}
}
});
BrowserReloadOrDuplicate(fakeEvent);
}

View File

@ -8,9 +8,8 @@ include $(topsrcdir)/config/rules.mk
addondir = $(srcdir)/test/addons addondir = $(srcdir)/test/addons
testdir = $(abspath $(DEPTH)/_tests/xpcshell/browser/experiments/test/xpcshell) testdir = $(abspath $(DEPTH)/_tests/xpcshell/browser/experiments/test/xpcshell)
libs:: misc:: $(call mkdir_deps,$(testdir))
$(EXIT_ON_ERROR) \ $(EXIT_ON_ERROR) \
$(NSINSTALL) -D $(testdir); \
for dir in $(addondir)/*; do \ for dir in $(addondir)/*; do \
base=`basename $$dir`; \ base=`basename $$dir`; \
(cd $$dir && zip -qr $(testdir)/$$base.xpi *); \ (cd $$dir && zip -qr $(testdir)/$$base.xpi *); \

View File

@ -2,6 +2,8 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this # 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/. # file, You can obtain one at http://mozilla.org/MPL/2.0/.
HAS_MISC_RULE = True
EXTRA_COMPONENTS += [ EXTRA_COMPONENTS += [
'Experiments.manifest', 'Experiments.manifest',
'ExperimentsService.js', 'ExperimentsService.js',

View File

@ -1,2 +1,6 @@
title=You're browsing privately # This Source Code Form is subject to the terms of the Mozilla Public
title.normal=Open a private window? # 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/.
title=You're browsing privately
title.normal=Open a private window?

View File

@ -41,7 +41,7 @@ public class FennecNativeActions implements Actions {
} }
class GeckoEventExpecter implements RepeatedEventExpecter { class GeckoEventExpecter implements RepeatedEventExpecter {
private static final int MAX_WAIT_MS = 90000; private static final int MAX_WAIT_MS = 180000;
private volatile boolean mIsRegistered; private volatile boolean mIsRegistered;

View File

@ -23,5 +23,6 @@ css_properties.js: host_ListCSSProperties$(HOST_BIN_SUFFIX) css_properties_like_
GARBAGE += css_properties.js GARBAGE += css_properties.js
TEST_FILES := css_properties.js TEST_FILES := css_properties.js
TEST_DEST = $(DEPTH)/_tests/testing/mochitest/tests/$(relativesrcdir) TEST_DEST = $(DEPTH)/_tests/testing/mochitest/tests/$(relativesrcdir)
TEST_TARGET := misc
INSTALL_TARGETS += TEST INSTALL_TARGETS += TEST
endif endif

View File

@ -4,6 +4,8 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this # 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/. # file, You can obtain one at http://mozilla.org/MPL/2.0/.
HAS_MISC_RULE = True
HostSimplePrograms([ HostSimplePrograms([
'host_ListCSSProperties', 'host_ListCSSProperties',
]) ])

View File

@ -27,6 +27,10 @@ public final class SharedPreferencesHelper
{ {
public static final String LOGTAG = "GeckoAndSharedPrefs"; public static final String LOGTAG = "GeckoAndSharedPrefs";
// Calculate this once, at initialization. isLoggable is too expensive to
// have in-line in each log call.
private static final boolean logVerbose = Log.isLoggable(LOGTAG, Log.VERBOSE);
private enum Scope { private enum Scope {
APP("app"), APP("app"),
PROFILE("profile"), PROFILE("profile"),
@ -214,7 +218,7 @@ public final class SharedPreferencesHelper
@Override @Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
if (Log.isLoggable(LOGTAG, Log.VERBOSE)) { if (logVerbose) {
Log.v(LOGTAG, "Got onSharedPreferenceChanged"); Log.v(LOGTAG, "Got onSharedPreferenceChanged");
} }
try { try {
@ -279,19 +283,19 @@ public final class SharedPreferencesHelper
// overwriting an in-progress response. // overwriting an in-progress response.
try { try {
if (event.equals("SharedPreferences:Set")) { if (event.equals("SharedPreferences:Set")) {
if (Log.isLoggable(LOGTAG, Log.VERBOSE)) { if (logVerbose) {
Log.v(LOGTAG, "Got SharedPreferences:Set message."); Log.v(LOGTAG, "Got SharedPreferences:Set message.");
} }
handleSet(message); handleSet(message);
} else if (event.equals("SharedPreferences:Get")) { } else if (event.equals("SharedPreferences:Get")) {
if (Log.isLoggable(LOGTAG, Log.VERBOSE)) { if (logVerbose) {
Log.v(LOGTAG, "Got SharedPreferences:Get message."); Log.v(LOGTAG, "Got SharedPreferences:Get message.");
} }
JSONObject obj = new JSONObject(); JSONObject obj = new JSONObject();
obj.put("values", handleGet(message)); obj.put("values", handleGet(message));
EventDispatcher.sendResponse(message, obj); EventDispatcher.sendResponse(message, obj);
} else if (event.equals("SharedPreferences:Observe")) { } else if (event.equals("SharedPreferences:Observe")) {
if (Log.isLoggable(LOGTAG, Log.VERBOSE)) { if (logVerbose) {
Log.v(LOGTAG, "Got SharedPreferences:Observe message."); Log.v(LOGTAG, "Got SharedPreferences:Observe message.");
} }
handleObserve(message); handleObserve(message);

View File

@ -24,7 +24,6 @@ import java.util.HashMap;
import java.util.Iterator; import java.util.Iterator;
import java.util.Map; import java.util.Map;
import java.util.Queue; import java.util.Queue;
import java.util.Scanner;
import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.jar.JarEntry; import java.util.jar.JarEntry;
import java.util.jar.JarInputStream; import java.util.jar.JarInputStream;
@ -42,6 +41,7 @@ import org.mozilla.gecko.GeckoEvent;
import org.mozilla.gecko.GeckoSharedPrefs; import org.mozilla.gecko.GeckoSharedPrefs;
import org.mozilla.gecko.Telemetry; import org.mozilla.gecko.Telemetry;
import org.mozilla.gecko.mozglue.RobocopTarget; import org.mozilla.gecko.mozglue.RobocopTarget;
import org.mozilla.gecko.util.FileUtils;
import org.mozilla.gecko.util.ThreadUtils; import org.mozilla.gecko.util.ThreadUtils;
import android.app.Activity; import android.app.Activity;
@ -286,7 +286,7 @@ public class Distribution {
} }
try { try {
JSONObject all = new JSONObject(getFileContents(descFile)); JSONObject all = new JSONObject(FileUtils.getFileContents(descFile));
if (!all.has("Global")) { if (!all.has("Global")) {
Log.e(LOGTAG, "Distribution preferences.json has no Global entry!"); Log.e(LOGTAG, "Distribution preferences.json has no Global entry!");
@ -314,7 +314,7 @@ public class Distribution {
} }
try { try {
return new JSONArray(getFileContents(bookmarks)); return new JSONArray(FileUtils.getFileContents(bookmarks));
} catch (IOException e) { } catch (IOException e) {
Log.e(LOGTAG, "Error getting bookmarks", e); Log.e(LOGTAG, "Error getting bookmarks", e);
Telemetry.HistogramAdd(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION); Telemetry.HistogramAdd(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_MALFORMED_DISTRIBUTION);
@ -739,19 +739,6 @@ public class Distribution {
return null; return null;
} }
// Shortcut to slurp a file without messing around with streams.
private String getFileContents(File file) throws IOException {
Scanner scanner = null;
try {
scanner = new Scanner(file, "UTF-8");
return scanner.useDelimiter("\\A").next();
} finally {
if (scanner != null) {
scanner.close();
}
}
}
private String getDataDir() { private String getDataDir() {
return context.getApplicationInfo().dataDir; return context.getApplicationInfo().dataDir;
} }

View File

@ -38,6 +38,7 @@ import android.preference.Preference.OnPreferenceClickListener;
import android.preference.PreferenceCategory; import android.preference.PreferenceCategory;
import android.preference.PreferenceScreen; import android.preference.PreferenceScreen;
import android.text.TextUtils; import android.text.TextUtils;
import android.text.format.DateUtils;
/** /**
* A fragment that displays the status of an AndroidFxAccount. * A fragment that displays the status of an AndroidFxAccount.
@ -86,6 +87,7 @@ public class FxAccountStatusFragment
protected EditTextPreference deviceNamePreference; protected EditTextPreference deviceNamePreference;
protected Preference syncServerPreference; protected Preference syncServerPreference;
protected Preference morePreference; protected Preference morePreference;
protected Preference syncNowPreference;
protected volatile AndroidFxAccount fxAccount; protected volatile AndroidFxAccount fxAccount;
// The contract is: when fxAccount is non-null, then clientsDataDelegate is // The contract is: when fxAccount is non-null, then clientsDataDelegate is
@ -167,6 +169,10 @@ public class FxAccountStatusFragment
morePreference = ensureFindPreference("more"); morePreference = ensureFindPreference("more");
morePreference.setOnPreferenceClickListener(this); morePreference.setOnPreferenceClickListener(this);
syncNowPreference = ensureFindPreference("sync_now");
syncNowPreference.setEnabled(true);
syncNowPreference.setOnPreferenceClickListener(this);
if (HardwareUtils.hasMenuButton()) { if (HardwareUtils.hasMenuButton()) {
syncCategory.removePreference(morePreference); syncCategory.removePreference(morePreference);
} }
@ -229,6 +235,13 @@ public class FxAccountStatusFragment
return true; return true;
} }
if (preference == syncNowPreference) {
if (fxAccount != null) {
FirefoxAccounts.requestSync(fxAccount.getAndroidAccount(), FirefoxAccounts.FORCE, null, null);
}
return true;
}
return false; return false;
} }
@ -250,6 +263,7 @@ public class FxAccountStatusFragment
passwordsPreference.setEnabled(enabled); passwordsPreference.setEnabled(enabled);
// Since we can't sync, we can't update our remote client record. // Since we can't sync, we can't update our remote client record.
deviceNamePreference.setEnabled(enabled); deviceNamePreference.setEnabled(enabled);
syncNowPreference.setEnabled(enabled);
} }
/** /**
@ -470,6 +484,26 @@ public class FxAccountStatusFragment
final String clientName = clientsDataDelegate.getClientName(); final String clientName = clientsDataDelegate.getClientName();
deviceNamePreference.setSummary(clientName); deviceNamePreference.setSummary(clientName);
deviceNamePreference.setText(clientName); deviceNamePreference.setText(clientName);
updateSyncNowPreference();
}
// This is a helper function similar to TabsAccessor.getLastSyncedString() to calculate relative "Last synced" time span.
private String getLastSyncedString(final long startTime) {
final CharSequence relativeTimeSpanString = DateUtils.getRelativeTimeSpanString(startTime);
return getActivity().getResources().getString(R.string.fxaccount_status_last_synced, relativeTimeSpanString);
}
protected void updateSyncNowPreference() {
final boolean currentlySyncing = fxAccount.isCurrentlySyncing();
syncNowPreference.setEnabled(!currentlySyncing);
if (currentlySyncing) {
syncNowPreference.setTitle(R.string.fxaccount_status_syncing);
} else {
syncNowPreference.setTitle(R.string.fxaccount_status_sync_now);
}
final String lastSynced = getLastSyncedString(fxAccount.getLastSyncedTimestamp());
syncNowPreference.setSummary(lastSynced);
} }
protected void updateAuthServerPreference() { protected void updateAuthServerPreference() {

View File

@ -65,6 +65,8 @@ public class AndroidFxAccount {
protected static final List<String> ANDROID_AUTHORITIES = Collections.unmodifiableList(Arrays.asList(BrowserContract.AUTHORITY)); protected static final List<String> ANDROID_AUTHORITIES = Collections.unmodifiableList(Arrays.asList(BrowserContract.AUTHORITY));
private static final String PREF_KEY_LAST_SYNCED_TIMESTAMP = "lastSyncedTimestamp";
protected final Context context; protected final Context context;
protected final AccountManager accountManager; protected final AccountManager accountManager;
protected final Account account; protected final Account account;
@ -565,4 +567,22 @@ public class AndroidFxAccount {
intent.putExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_KEY, account.name); intent.putExtra(FxAccountConstants.ACCOUNT_DELETED_INTENT_ACCOUNT_KEY, account.name);
return intent; return intent;
} }
public void setLastSyncedTimestamp(long now) {
try {
getSyncPrefs().edit().putLong(PREF_KEY_LAST_SYNCED_TIMESTAMP, now).commit();
} catch (Exception e) {
Logger.warn(LOG_TAG, "Got exception setting last synced time; ignoring.", e);
}
}
public long getLastSyncedTimestamp() {
final long neverSynced = -1L;
try {
return getSyncPrefs().getLong(PREF_KEY_LAST_SYNCED_TIMESTAMP, neverSynced);
} catch (Exception e) {
Logger.warn(LOG_TAG, "Got exception getting last synced time; ignoring.", e);
return neverSynced;
}
}
} }

View File

@ -105,6 +105,7 @@ public class FxAccountSchedulePolicy implements SchedulePolicy {
@Override @Override
public void onSuccessfulSync(int otherClientsCount) { public void onSuccessfulSync(int otherClientsCount) {
this.account.setLastSyncedTimestamp(System.currentTimeMillis());
// This undoes the change made in observeBackoffMillis -- once we hit backoff we'll // This undoes the change made in observeBackoffMillis -- once we hit backoff we'll
// periodically sync at the backoff duration, but as soon as we succeed we'll switch // periodically sync at the backoff duration, but as soon as we succeed we'll switch
// into the client-count-dependent interval. // into the client-count-dependent interval.

View File

@ -434,11 +434,20 @@ public class FxAccountSyncAdapter extends AbstractThreadedSyncAdapter {
Logger.setThreadLogTag(FxAccountConstants.GLOBAL_LOG_TAG); Logger.setThreadLogTag(FxAccountConstants.GLOBAL_LOG_TAG);
Logger.resetLogging(); Logger.resetLogging();
final Context context = getContext();
final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account);
Logger.info(LOG_TAG, "Syncing FxAccount" + Logger.info(LOG_TAG, "Syncing FxAccount" +
" account named like " + Utils.obfuscateEmail(account.name) + " account named like " + Utils.obfuscateEmail(account.name) +
" for authority " + authority + " for authority " + authority +
" with instance " + this + "."); " with instance " + this + ".");
Logger.info(LOG_TAG, "Account last synced at: " + fxAccount.getLastSyncedTimestamp());
if (FxAccountConstants.LOG_PERSONAL_INFORMATION) {
fxAccount.dump();
}
final EnumSet<FirefoxAccounts.SyncHint> syncHints = FirefoxAccounts.getHintsToSyncFromBundle(extras); final EnumSet<FirefoxAccounts.SyncHint> syncHints = FirefoxAccounts.getHintsToSyncFromBundle(extras);
FirefoxAccounts.logSyncHints(syncHints); FirefoxAccounts.logSyncHints(syncHints);
@ -450,12 +459,6 @@ public class FxAccountSyncAdapter extends AbstractThreadedSyncAdapter {
return; return;
} }
final Context context = getContext();
final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account);
if (FxAccountConstants.LOG_PERSONAL_INFORMATION) {
fxAccount.dump();
}
// Pickle in a background thread to avoid strict mode warnings. // Pickle in a background thread to avoid strict mode warnings.
ThreadPool.run(new Runnable() { ThreadPool.run(new Runnable() {
@Override @Override

View File

@ -187,6 +187,8 @@
<!ENTITY fxaccount_status_header2 'Firefox Account'> <!ENTITY fxaccount_status_header2 'Firefox Account'>
<!ENTITY fxaccount_status_signed_in_as 'Signed in as'> <!ENTITY fxaccount_status_signed_in_as 'Signed in as'>
<!ENTITY fxaccount_status_auth_server 'Account server'> <!ENTITY fxaccount_status_auth_server 'Account server'>
<!ENTITY fxaccount_status_sync_now 'Sync now'>
<!ENTITY fxaccount_status_syncing 'Syncing...'>
<!ENTITY fxaccount_status_device_name 'Device name'> <!ENTITY fxaccount_status_device_name 'Device name'>
<!ENTITY fxaccount_status_sync_server 'Sync server'> <!ENTITY fxaccount_status_sync_server 'Sync server'>
<!ENTITY fxaccount_status_sync '&syncBrand.shortName.label;'> <!ENTITY fxaccount_status_sync '&syncBrand.shortName.label;'>

View File

@ -45,7 +45,7 @@
style="@style/FxAccountLinkItem" style="@style/FxAccountLinkItem"
android:text="@string/fxaccount_confirm_account_resend_email" /> android:text="@string/fxaccount_confirm_account_resend_email" />
<TextView <TextView
android:id="@+id/change_confirmation_email_link" android:id="@+id/change_confirmation_email_link"
style="@style/FxAccountLinkItem" style="@style/FxAccountLinkItem"
android:text="@string/fxaccount_confirm_account_change_email" /> android:text="@string/fxaccount_confirm_account_change_email" />

View File

@ -55,6 +55,14 @@
android:persistent="false" android:persistent="false"
android:title="@string/fxaccount_status_needs_account_enabled" /> android:title="@string/fxaccount_status_needs_account_enabled" />
<Preference
android:editable="false"
android:key="sync_now"
android:defaultValue=""
android:persistent="false"
android:title="Sync now"
android:summary="" />
<CheckBoxPreference <CheckBoxPreference
android:key="bookmarks" android:key="bookmarks"
android:persistent="false" android:persistent="false"

View File

@ -96,6 +96,7 @@
<string name="share_image_failed">&share_image_failed;</string> <string name="share_image_failed">&share_image_failed;</string>
<string name="save_as_pdf">&save_as_pdf;</string> <string name="save_as_pdf">&save_as_pdf;</string>
<string name="find_in_page">&find_in_page;</string> <string name="find_in_page">&find_in_page;</string>
<string name="find_matchcase">&find_matchcase;</string>
<string name="desktop_mode">&desktop_mode;</string> <string name="desktop_mode">&desktop_mode;</string>
<string name="page">&page;</string> <string name="page">&page;</string>
<string name="tools">&tools;</string> <string name="tools">&tools;</string>

View File

@ -37,7 +37,6 @@ public class SyncConfiguration {
this.editor = config.getEditor(); this.editor = config.getEditor();
} }
@Override
public void apply() { public void apply() {
// Android <=r8 SharedPreferences.Editor does not contain apply() for overriding. // Android <=r8 SharedPreferences.Editor does not contain apply() for overriding.
this.editor.commit(); this.editor.commit();
@ -86,7 +85,6 @@ public class SyncConfiguration {
// Not marking as Override, because Android <= 10 doesn't have // Not marking as Override, because Android <= 10 doesn't have
// putStringSet. Neither can we implement it. // putStringSet. Neither can we implement it.
@Override
public Editor putStringSet(String key, Set<String> value) { public Editor putStringSet(String key, Set<String> value) {
throw new RuntimeException("putStringSet not available."); throw new RuntimeException("putStringSet not available.");
} }
@ -162,7 +160,6 @@ public class SyncConfiguration {
// Not marking as Override, because Android <= 10 doesn't have // Not marking as Override, because Android <= 10 doesn't have
// getStringSet. Neither can we implement it. // getStringSet. Neither can we implement it.
@Override
public Set<String> getStringSet(String key, Set<String> defValue) { public Set<String> getStringSet(String key, Set<String> defValue) {
throw new RuntimeException("getStringSet not available."); throw new RuntimeException("getStringSet not available.");
} }

View File

@ -65,7 +65,7 @@ public class BaseResource implements Resource {
private boolean retryOnFailedRequest = true; private boolean retryOnFailedRequest = true;
public static final boolean rewriteLocalhost = true; public static boolean rewriteLocalhost = true;
private static final String LOG_TAG = "BaseResource"; private static final String LOG_TAG = "BaseResource";

View File

@ -11,6 +11,11 @@ public class JavascriptTest extends BaseTest {
private static final String LOGTAG = "JavascriptTest"; private static final String LOGTAG = "JavascriptTest";
private static final String EVENT_TYPE = JavascriptBridge.EVENT_TYPE; private static final String EVENT_TYPE = JavascriptBridge.EVENT_TYPE;
// Calculate these once, at initialization. isLoggable is too expensive to
// have in-line in each log call.
private static final boolean logDebug = Log.isLoggable(LOGTAG, Log.DEBUG);
private static final boolean logVerbose = Log.isLoggable(LOGTAG, Log.VERBOSE);
private final String javascriptUrl; private final String javascriptUrl;
public JavascriptTest(String javascriptUrl) { public JavascriptTest(String javascriptUrl) {
@ -39,11 +44,11 @@ public class JavascriptTest extends BaseTest {
new JavascriptMessageParser(mAsserter, false); new JavascriptMessageParser(mAsserter, false);
try { try {
while (!testMessageParser.isTestFinished()) { while (!testMessageParser.isTestFinished()) {
if (Log.isLoggable(LOGTAG, Log.VERBOSE)) { if (logVerbose) {
Log.v(LOGTAG, "Waiting for " + EVENT_TYPE); Log.v(LOGTAG, "Waiting for " + EVENT_TYPE);
} }
String data = expecter.blockForEventData(); String data = expecter.blockForEventData();
if (Log.isLoggable(LOGTAG, Log.VERBOSE)) { if (logVerbose) {
Log.v(LOGTAG, "Got event with data '" + data + "'"); Log.v(LOGTAG, "Got event with data '" + data + "'");
} }
@ -60,7 +65,7 @@ public class JavascriptTest extends BaseTest {
testMessageParser.logMessage(message); testMessageParser.logMessage(message);
} }
if (Log.isLoggable(LOGTAG, Log.DEBUG)) { if (logDebug) {
Log.d(LOGTAG, "Got test finished message"); Log.d(LOGTAG, "Got test finished message");
} }
} finally { } finally {

View File

@ -9,6 +9,7 @@ import android.util.Log;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.FilenameFilter; import java.io.FilenameFilter;
import java.util.Scanner;
import org.mozilla.gecko.mozglue.RobocopTarget; import org.mozilla.gecko.mozglue.RobocopTarget;
@ -81,4 +82,17 @@ public class FileUtils {
// Even if this is a dir, it should now be empty and delete should work // Even if this is a dir, it should now be empty and delete should work
return file.delete(); return file.delete();
} }
// Shortcut to slurp a file without messing around with streams.
public static String getFileContents(File file) throws IOException {
Scanner scanner = null;
try {
scanner = new Scanner(file, "UTF-8");
return scanner.useDelimiter("\\A").next();
} finally {
if (scanner != null) {
scanner.close();
}
}
}
} }

View File

@ -6907,7 +6907,7 @@ var SearchEngines = {
PREF_SUGGEST_PROMPTED: "browser.search.suggest.prompted", PREF_SUGGEST_PROMPTED: "browser.search.suggest.prompted",
// Shared preference key used for search activity default engine. // Shared preference key used for search activity default engine.
PREF_SEARCH_ACTIVITY_ENGINE_KEY: "search.engines.default", PREF_SEARCH_ACTIVITY_ENGINE_KEY: "search.engines.defaultname",
init: function init() { init: function init() {
Services.obs.addObserver(this, "SearchEngines:Add", false); Services.obs.addObserver(this, "SearchEngines:Add", false);
@ -7047,34 +7047,7 @@ var SearchEngines = {
// Updates the search activity pref when the default engine changes. // Updates the search activity pref when the default engine changes.
_setSearchActivityDefaultPref: function _setSearchActivityDefaultPref(engine) { _setSearchActivityDefaultPref: function _setSearchActivityDefaultPref(engine) {
// Helper function copied from nsSearchService.js. This is the logic that is used SharedPreferences.forApp().setCharPref(this.PREF_SEARCH_ACTIVITY_ENGINE_KEY, engine.name);
// to create file names for search plugin XML serialized to disk.
function sanitizeName(aName) {
const maxLength = 60;
const minLength = 1;
let name = aName.toLowerCase();
name = name.replace(/\s+/g, "-");
name = name.replace(/[^-a-z0-9]/g, "");
if (name.length < minLength) {
// Well, in this case, we're kinda screwed. In this case, the search service
// generates a random file name, so to do this the right way, we'd need
// to open up search.json and see what file name is stored.
Cu.reportError("Couldn't create search plugin file name from engine name: " + aName);
return null;
}
// Force max length.
return name.substring(0, maxLength);
}
let identifier = engine.identifier;
if (identifier === null) {
// The identifier will be null for non-built-in engines. In this case, we need to
// figure out an identifier to store from the engine name.
identifier = sanitizeName(engine.name);
}
SharedPreferences.forApp().setCharPref(this.PREF_SEARCH_ACTIVITY_ENGINE_KEY, identifier);
}, },
// Display context menu listing names of the search engines available to be added. // Display context menu listing names of the search engines available to be added.

View File

@ -15,7 +15,7 @@ ac_add_options --enable-elf-hack
ANDROID_NDK_VERSION="r8e" ANDROID_NDK_VERSION="r8e"
ANDROID_NDK_VERSION_32BIT="r8c" ANDROID_NDK_VERSION_32BIT="r8c"
ANDROID_SDK_VERSION="20" ANDROID_SDK_VERSION="21"
# Build Fennec # Build Fennec
ac_add_options --enable-application=mobile/android ac_add_options --enable-application=mobile/android

View File

@ -6,8 +6,8 @@
"filename": "android-ndk.tar.bz2" "filename": "android-ndk.tar.bz2"
}, },
{ {
"size": 207966812, "size": 227988048,
"digest": "9f6d50e5e67e6a6784dea3b5573178a869b017d2d0c7588af9eb53fdd23c7d1bd6f775f07a9ad1510ba36bf1608d21baa4e26e92afe61e190429870a6371b97d", "digest": "c84db0abd1f4fda1bb38292ef561e211e1e6b99586764fd8cf0829fa4d0c6a605eb21e1eb5462465fcca64749d48e22ac1b13029e2623bbdfe103801f5ef1411",
"algorithm": "sha512", "algorithm": "sha512",
"filename": "android-sdk.tar.xz" "filename": "android-sdk.tar.xz"
}, },

View File

@ -6,8 +6,8 @@
"filename": "android-ndk.tar.bz2" "filename": "android-ndk.tar.bz2"
}, },
{ {
"size": 207966812, "size": 227988048,
"digest": "9f6d50e5e67e6a6784dea3b5573178a869b017d2d0c7588af9eb53fdd23c7d1bd6f775f07a9ad1510ba36bf1608d21baa4e26e92afe61e190429870a6371b97d", "digest": "c84db0abd1f4fda1bb38292ef561e211e1e6b99586764fd8cf0829fa4d0c6a605eb21e1eb5462465fcca64749d48e22ac1b13029e2623bbdfe103801f5ef1411",
"algorithm": "sha512", "algorithm": "sha512",
"filename": "android-sdk.tar.xz" "filename": "android-sdk.tar.xz"
}, },

View File

@ -6,8 +6,8 @@
"filename": "android-ndk.tar.bz2" "filename": "android-ndk.tar.bz2"
}, },
{ {
"size": 207966812, "size": 227988048,
"digest": "9f6d50e5e67e6a6784dea3b5573178a869b017d2d0c7588af9eb53fdd23c7d1bd6f775f07a9ad1510ba36bf1608d21baa4e26e92afe61e190429870a6371b97d", "digest": "c84db0abd1f4fda1bb38292ef561e211e1e6b99586764fd8cf0829fa4d0c6a605eb21e1eb5462465fcca64749d48e22ac1b13029e2623bbdfe103801f5ef1411",
"algorithm": "sha512", "algorithm": "sha512",
"filename": "android-sdk.tar.xz" "filename": "android-sdk.tar.xz"
}, },

View File

@ -3,12 +3,14 @@
# file, You can obtain one at http://mozilla.org/MPL/2.0/. # file, You can obtain one at http://mozilla.org/MPL/2.0/.
gradle := \ gradle := \
local.properties.in \
gradle.properties.in \ gradle.properties.in \
$(NULL) $(NULL)
gradle_PATH := $(CURDIR) gradle_PATH := $(CURDIR)
gradle_FLAGS += -Dtopsrcdir=$(abspath $(topsrcdir)) gradle_FLAGS += -Dtopsrcdir=$(abspath $(topsrcdir))
gradle_FLAGS += -Dtopobjdir=$(abspath $(DEPTH)) gradle_FLAGS += -Dtopobjdir=$(abspath $(DEPTH))
gradle_FLAGS += -DANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)
gradle_KEEP_PATH := 1 gradle_KEEP_PATH := 1
PP_TARGETS += gradle PP_TARGETS += gradle

View File

@ -14,7 +14,7 @@ buildscript {
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:0.12.2' classpath 'com.android.tools.build:gradle:0.14.1'
} }
} }

View File

@ -1,6 +1,6 @@
#Wed Apr 10 15:27:10 PDT 2013 #Thu Nov 13 14:57:51 PST 2014
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=http\://services.gradle.org/distributions/gradle-1.12-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-2.1-bin.zip

View File

@ -0,0 +1,2 @@
#filter substitution
sdk.dir=@ANDROID_SDK_ROOT@

View File

@ -17,9 +17,4 @@ package org.mozilla.search;
public class Constants { public class Constants {
public static final String ABOUT_BLANK = "about:blank"; public static final String ABOUT_BLANK = "about:blank";
// TODO: Localize this with region.properties (or a similar solution). See bug 1065306.
public static final String DEFAULT_ENGINE_IDENTIFIER = "yahoo";
public static final String PREF_SEARCH_ENGINE_KEY = "search.engines.default";
} }

View File

@ -10,6 +10,7 @@ import org.mozilla.gecko.R;
import org.mozilla.gecko.Telemetry; import org.mozilla.gecko.Telemetry;
import org.mozilla.gecko.TelemetryContract; import org.mozilla.gecko.TelemetryContract;
import org.mozilla.gecko.db.BrowserContract.SearchHistory; import org.mozilla.gecko.db.BrowserContract.SearchHistory;
import org.mozilla.gecko.distribution.Distribution;
import org.mozilla.gecko.health.BrowserHealthRecorder; import org.mozilla.gecko.health.BrowserHealthRecorder;
import org.mozilla.search.autocomplete.SearchBar; import org.mozilla.search.autocomplete.SearchBar;
import org.mozilla.search.autocomplete.SuggestionsFragment; import org.mozilla.search.autocomplete.SuggestionsFragment;
@ -101,7 +102,7 @@ public class SearchActivity extends LocaleAware.LocaleAwareFragmentActivity
suggestionsFragment = (SuggestionsFragment) getSupportFragmentManager().findFragmentById(R.id.suggestions); suggestionsFragment = (SuggestionsFragment) getSupportFragmentManager().findFragmentById(R.id.suggestions);
postSearchFragment = (PostSearchFragment) getSupportFragmentManager().findFragmentById(R.id.postsearch); postSearchFragment = (PostSearchFragment) getSupportFragmentManager().findFragmentById(R.id.postsearch);
searchEngineManager = new SearchEngineManager(this); searchEngineManager = new SearchEngineManager(this, Distribution.init(this));
searchEngineManager.setChangeCallback(this); searchEngineManager.setChangeCallback(this);
// Initialize the fragments with the selected search engine. // Initialize the fragments with the selected search engine.
@ -279,7 +280,10 @@ public class SearchActivity extends LocaleAware.LocaleAwareFragmentActivity
searchEngineManager.getEngine(new SearchEngineCallback() { searchEngineManager.getEngine(new SearchEngineCallback() {
@Override @Override
public void execute(SearchEngine engine) { public void execute(SearchEngine engine) {
postSearchFragment.startSearch(engine, query); // TODO: If engine is null, we should show an error message.
if (engine != null) {
postSearchFragment.startSearch(engine, query);
}
} }
}); });
} }
@ -293,6 +297,10 @@ public class SearchActivity extends LocaleAware.LocaleAwareFragmentActivity
*/ */
@Override @Override
public void execute(SearchEngine engine) { public void execute(SearchEngine engine) {
// TODO: If engine is null, we should show an error message.
if (engine == null) {
return;
}
this.engine = engine; this.engine = engine;
suggestionsFragment.setEngine(engine); suggestionsFragment.setEngine(engine);
searchBar.setEngine(engine); searchBar.setEngine(engine);

View File

@ -52,7 +52,9 @@ public class SearchEngine {
"document.getElementsByTagName('head')[0].appendChild(tag);" + "document.getElementsByTagName('head')[0].appendChild(tag);" +
"tag.innerText='%s'})();"; "tag.innerText='%s'})();";
// The Gecko search identifier. This will be null for engines that don't ship with the locale.
private final String identifier; private final String identifier;
private String shortName; private String shortName;
private String iconURL; private String iconURL;
@ -189,7 +191,9 @@ public class SearchEngine {
public String getInjectableJs() { public String getInjectableJs() {
final String css; final String css;
if (identifier.equals("bing")) { if (identifier == null) {
css = "";
} else if (identifier.equals("bing")) {
css = "#mHeader{display:none}#contentWrapper{margin-top:0}"; css = "#mHeader{display:none}#contentWrapper{margin-top:0}";
} else if (identifier.equals("google")) { } else if (identifier.equals("google")) {
css = "#sfcnt,#top_nav{display:none}"; css = "#sfcnt,#top_nav{display:none}";
@ -247,7 +251,7 @@ public class SearchEngine {
public String resultsUriForQuery(String query) { public String resultsUriForQuery(String query) {
final Uri resultsUri = getResultsUri(); final Uri resultsUri = getResultsUri();
if (resultsUri == null) { if (resultsUri == null) {
Log.e(LOG_TAG, "No results URL for search engine: " + identifier); Log.e(LOG_TAG, "No results URL for search engine: " + shortName);
return ""; return "";
} }
final String template = Uri.decode(resultsUri.toString()); final String template = Uri.decode(resultsUri.toString());
@ -261,7 +265,7 @@ public class SearchEngine {
*/ */
public String getSuggestionTemplate(String query) { public String getSuggestionTemplate(String query) {
if (suggestUri == null) { if (suggestUri == null) {
Log.e(LOG_TAG, "No suggestions template for search engine: " + identifier); Log.e(LOG_TAG, "No suggestions template for search engine: " + shortName);
return ""; return "";
} }
final String template = Uri.decode(suggestUri.toString()); final String template = Uri.decode(suggestUri.toString());

View File

@ -6,15 +6,21 @@ package org.mozilla.search.providers;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.os.AsyncTask;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; import android.util.Log;
import org.json.JSONException;
import org.json.JSONObject;
import org.mozilla.gecko.AppConstants; import org.mozilla.gecko.AppConstants;
import org.mozilla.gecko.BrowserLocaleManager; import org.mozilla.gecko.BrowserLocaleManager;
import org.mozilla.gecko.GeckoProfile; import org.mozilla.gecko.GeckoProfile;
import org.mozilla.gecko.GeckoSharedPrefs; import org.mozilla.gecko.GeckoSharedPrefs;
import org.mozilla.gecko.R;
import org.mozilla.gecko.util.FileUtils;
import org.mozilla.gecko.util.GeckoJarReader; import org.mozilla.gecko.util.GeckoJarReader;
import org.mozilla.gecko.util.RawResource;
import org.mozilla.gecko.util.ThreadUtils;
import org.mozilla.gecko.distribution.Distribution;
import org.mozilla.search.Constants; import org.mozilla.search.Constants;
import org.xmlpull.v1.XmlPullParserException; import org.xmlpull.v1.XmlPullParserException;
@ -30,9 +36,16 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
public class SearchEngineManager implements SharedPreferences.OnSharedPreferenceChangeListener { public class SearchEngineManager implements SharedPreferences.OnSharedPreferenceChangeListener {
private static final String LOG_TAG = "SearchEngineManager"; private static final String LOG_TAG = "GeckoSearchEngineManager";
// Gecko pref that defines the name of the default search engine.
private static final String PREF_GECKO_DEFAULT_ENGINE = "browser.search.defaultenginename";
// Key for shared preference that stores default engine name.
private static final String PREF_DEFAULT_ENGINE_KEY = "search.engines.defaultname";
private Context context; private Context context;
private Distribution distribution;
private SearchEngineCallback changeCallback; private SearchEngineCallback changeCallback;
private SearchEngine engine; private SearchEngine engine;
@ -40,11 +53,19 @@ public class SearchEngineManager implements SharedPreferences.OnSharedPreference
public void execute(SearchEngine engine); public void execute(SearchEngine engine);
} }
public SearchEngineManager(Context context) { public SearchEngineManager(Context context, Distribution distribution) {
this.context = context; this.context = context;
this.distribution = distribution;
GeckoSharedPrefs.forApp(context).registerOnSharedPreferenceChangeListener(this); GeckoSharedPrefs.forApp(context).registerOnSharedPreferenceChangeListener(this);
} }
/**
* Sets a callback to be called when the default engine changes.
*
* @param callback SearchEngineCallback to be called after the search engine
* changed. This will run on the UI thread.
* Note: callback may be called with null engine.
*/
public void setChangeCallback(SearchEngineCallback changeCallback) { public void setChangeCallback(SearchEngineCallback changeCallback) {
this.changeCallback = changeCallback; this.changeCallback = changeCallback;
} }
@ -60,80 +81,230 @@ public class SearchEngineManager implements SharedPreferences.OnSharedPreference
if (engine != null) { if (engine != null) {
callback.execute(engine); callback.execute(engine);
} else { } else {
getEngineFromPrefs(callback); getDefaultEngine(callback);
} }
} }
public void destroy() { public void destroy() {
GeckoSharedPrefs.forApp(context).unregisterOnSharedPreferenceChangeListener(this); GeckoSharedPrefs.forApp(context).unregisterOnSharedPreferenceChangeListener(this);
context = null; context = null;
distribution = null;
changeCallback = null; changeCallback = null;
engine = null; engine = null;
} }
private int ignorePreferenceChange = 0;
@Override @Override
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, final String key) { public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, final String key) {
if (!TextUtils.equals(Constants.PREF_SEARCH_ENGINE_KEY, key)) { if (!TextUtils.equals(PREF_DEFAULT_ENGINE_KEY, key)) {
return; return;
} }
getEngineFromPrefs(changeCallback);
if (ignorePreferenceChange > 0) {
ignorePreferenceChange--;
return;
}
getDefaultEngine(changeCallback);
} }
/** /**
* Look up the current search engine in shared preferences. * Runs a SearchEngineCallback on the main thread.
* Creates a SearchEngine instance and caches it for use on the main thread. */
private void runCallback(final SearchEngine engine, final SearchEngineCallback callback) {
ThreadUtils.postToUiThread(new Runnable() {
@Override
public void run() {
// Cache engine for future calls to getEngine.
SearchEngineManager.this.engine = engine;
callback.execute(engine);
}
});
}
/**
* This method finds and creates the default search engine. It will first look for
* the default engine name, then create the engine from that name.
* *
* @param callback a SearchEngineCallback to be called after successfully looking * To find the default engine name, we first look in shared preferences, then
* the distribution (if one exists), and finally fall back to the localized default.
*
* @param callback SearchEngineCallback to be called after successfully looking
* up the search engine. This will run on the UI thread. * up the search engine. This will run on the UI thread.
* Note: callback may be called with null engine.
*/ */
private void getEngineFromPrefs(final SearchEngineCallback callback) { private void getDefaultEngine(final SearchEngineCallback callback) {
final AsyncTask<Void, Void, SearchEngine> task = new AsyncTask<Void, Void, SearchEngine>() { // This runnable is posted to the background thread.
distribution.addOnDistributionReadyCallback(new Runnable() {
@Override @Override
protected SearchEngine doInBackground(Void... params) { public void run() {
String identifier = GeckoSharedPrefs.forApp(context).getString(Constants.PREF_SEARCH_ENGINE_KEY, null); // First look for a default name stored in shared preferences.
if (!TextUtils.isEmpty(identifier)) { String name = GeckoSharedPrefs.forApp(context).getString(PREF_DEFAULT_ENGINE_KEY, null);
try {
return createEngine(identifier); if (name != null) {
} catch (IllegalArgumentException e) { Log.d(LOG_TAG, "Found default engine name in SharedPreferences: " + name);
Log.e(LOG_TAG, "Exception creating search engine from pref. Falling back to default engine.", e); } else {
// First, look for the default search engine in a distribution.
name = getDefaultEngineNameFromDistribution();
if (name == null) {
// Otherwise, get the default engine that we ship.
name = getDefaultEngineNameFromLocale();
} }
// Store the default engine name for the future.
// Increment an 'ignore' counter so that this preference change
// won'tcause getDefaultEngine to be called again.
ignorePreferenceChange++;
GeckoSharedPrefs.forApp(context)
.edit()
.putString(PREF_DEFAULT_ENGINE_KEY, name)
.apply();
} }
try { final SearchEngine engine = createEngineFromName(name);
return createEngine(Constants.DEFAULT_ENGINE_IDENTIFIER); runCallback(engine, callback);
} catch (IllegalArgumentException e) {
Log.e(LOG_TAG, "Exception creating search engine from default identifier. " +
"This will happen if the locale doesn't contain the default search plugin.", e);
}
return null;
} }
});
@Override
protected void onPostExecute(SearchEngine engine) {
if (engine != null) {
// Only touch engine on the main thread.
SearchEngineManager.this.engine = engine;
if (callback != null) {
callback.execute(engine);
}
}
}
};
task.execute();
} }
/** /**
* Creates a list of SearchEngine instances from all available open search plugins. * Looks for a default search engine included in a distribution.
* This method does disk I/O, call it from a background thread. * This method must be called after the distribution is ready.
* *
* @return List of SearchEngine instances * @return search engine name.
*/ */
public List<SearchEngine> getAllEngines() { private String getDefaultEngineNameFromDistribution() {
// First try to read the engine list from the jar. if (!distribution.exists()) {
InputStream in = getInputStreamFromJar("list.txt"); return null;
}
final List<SearchEngine> list = new ArrayList<SearchEngine>(); final File prefFile = distribution.getDistributionFile("preferences.json");
if (prefFile == null) {
return null;
}
try {
final JSONObject all = new JSONObject(FileUtils.getFileContents(prefFile));
// First, check to see if there's a locale-specific override.
final String languageTag = BrowserLocaleManager.getLanguageTag(Locale.getDefault());
final String overridesKey = "LocalizablePreferences." + languageTag;
if (all.has(overridesKey)) {
final JSONObject overridePrefs = all.getJSONObject(overridesKey);
if (overridePrefs.has(PREF_GECKO_DEFAULT_ENGINE)) {
Log.d(LOG_TAG, "Found default engine name in distribution LocalizablePreferences override.");
return overridePrefs.getString(PREF_GECKO_DEFAULT_ENGINE);
}
}
// Next, check to see if there's a non-override default pref.
if (all.has("LocalizablePreferences")) {
final JSONObject localizablePrefs = all.getJSONObject("LocalizablePreferences");
if (localizablePrefs.has(PREF_GECKO_DEFAULT_ENGINE)) {
Log.d(LOG_TAG, "Found default engine name in distribution LocalizablePreferences.");
return localizablePrefs.getString(PREF_GECKO_DEFAULT_ENGINE);
}
}
} catch (IOException e) {
Log.e(LOG_TAG, "Error getting search engine name from preferences.json", e);
} catch (JSONException e) {
Log.e(LOG_TAG, "Error parsing preferences.json", e);
}
return null;
}
/**
* Looks for the default search engine shipped in the locale.
*
* @return search engine name.
*/
private String getDefaultEngineNameFromLocale() {
try {
final JSONObject browsersearch = new JSONObject(RawResource.getAsString(context, R.raw.browsersearch));
if (browsersearch.has("default")) {
Log.d(LOG_TAG, "Found default engine name in browsersearch.json.");
return browsersearch.getString("default");
}
} catch (IOException e) {
Log.e(LOG_TAG, "Error getting search engine name from browsersearch.json", e);
} catch (JSONException e) {
Log.e(LOG_TAG, "Error parsing browsersearch.json", e);
}
return null;
}
/**
* Creates a SearchEngine instance from an engine name.
*
* To create the engine, we first try to find the search plugin in the distribution
* (if one exists), followed by the localized plugins we ship with the browser, and
* then finally third-party plugins that are installed in the profile directory.
*
* This method must be called after the distribution is ready.
*
* @param name The search engine name (e.g. "Google" or "Amazon.com")
* @return SearchEngine instance for name.
*/
private SearchEngine createEngineFromName(String name) {
// First, look in the distribution.
SearchEngine engine = createEngineFromDistribution(name);
// Second, look in the jar for plugins shipped with the locale.
if (engine == null) {
engine = createEngineFromLocale(name);
}
// Finally, look in the profile for third-party plugins.
if (engine == null) {
engine = createEngineFromProfile(name);
}
if (engine == null) {
Log.e(LOG_TAG, "Could not create search engine from name: " + name);
}
return engine;
}
/**
* Creates a SearchEngine instance for a distribution search plugin.
*
* This method iterates through the distribution searchplugins directory,
* creating SearchEngine instances until it finds one with the right name.
*
* This method must be called after the distribution is ready.
*
* @param name Search engine name.
* @return SearchEngine instance for name.
*/
private SearchEngine createEngineFromDistribution(String name) {
if (!distribution.exists()) {
return null;
}
final File pluginsDir = distribution.getDistributionFile("searchplugins");
if (pluginsDir == null) {
return null;
}
final File[] files = (new File(pluginsDir, "common")).listFiles();
return createEngineFromFileList(files, name);
}
/**
* Creates a SearchEngine instance for a search plugin shipped in the locale.
*
* This method reads the list of search plugin file names from list.txt, then
* iterates through the files, creating SearchEngine instances until it finds one
* with the right name. Unfortunately, we need to do this because there is no
* other way to map the search engine "name" to the file for the search plugin.
*
* @param name Search engine name.
* @return SearchEngine instance for name.
*/
private SearchEngine createEngineFromLocale(String name) {
final InputStream in = getInputStreamFromSearchPluginsJar("list.txt");
InputStreamReader isr = null; InputStreamReader isr = null;
try { try {
@ -141,10 +312,14 @@ public class SearchEngineManager implements SharedPreferences.OnSharedPreference
BufferedReader br = new BufferedReader(isr); BufferedReader br = new BufferedReader(isr);
String identifier; String identifier;
while ((identifier = br.readLine()) != null) { while ((identifier = br.readLine()) != null) {
list.add(createEngine(identifier)); final InputStream pluginIn = getInputStreamFromSearchPluginsJar(identifier + ".xml");
final SearchEngine engine = createEngineFromInputStream(identifier, pluginIn);
if (engine != null && engine.getName().equals(name)) {
return engine;
}
} }
} catch (IOException e) { } catch (IOException e) {
throw new IllegalStateException("Error creating all search engines from list.txt"); Log.e(LOG_TAG, "Error creating shipped search engine from name: " + name, e);
} finally { } finally {
if (isr != null) { if (isr != null) {
try { try {
@ -159,27 +334,62 @@ public class SearchEngineManager implements SharedPreferences.OnSharedPreference
// Ignore. // Ignore.
} }
} }
return list; return null;
} }
/** /**
* Creates a SearchEngine instance from an open search plugin. * Creates a SearchEngine instance for a search plugin in the profile directory.
* This method does disk I/O, call it from a background thread.
* *
* @param identifier search engine identifier (e.g. "google") * This method iterates through the profile searchplugins directory, creating
* @return SearchEngine instance for identifier * SearchEngine instances until it finds one with the right name.
*
* @param name Search engine name.
* @return SearchEngine instance for name.
*/ */
private SearchEngine createEngine(String identifier) { private SearchEngine createEngineFromProfile(String name) {
InputStream in = getInputStreamFromJar(identifier + ".xml"); final File pluginsDir = GeckoProfile.get(context).getFile("searchplugins");
if (pluginsDir == null) {
if (in == null) { return null;
in = getEngineFromProfile(identifier);
} }
if (in == null) { final File[] files = pluginsDir.listFiles();
throw new IllegalArgumentException("Couldn't find search engine for identifier: " + identifier); return createEngineFromFileList(files, name);
} }
/**
* This method iterates through an array of search plugin files, creating
* SearchEngine instances until it finds one with the right name.
*
* @param files Array of search plugin files.
* @param name Search engine name.
* @return SearchEngine instance for name.
*/
private SearchEngine createEngineFromFileList(File[] files, String name) {
for (int i = 0; i < files.length; i++) {
try {
final FileInputStream fis = new FileInputStream(files[i]);
final SearchEngine engine = createEngineFromInputStream(null, fis);
if (engine != null && engine.getName().equals(name)) {
return engine;
}
} catch (IOException e) {
Log.e(LOG_TAG, "Error creating earch engine from name: " + name, e);
}
}
return null;
}
/**
* Creates a SearchEngine instance from an InputStream.
*
* This method closes the stream after it is done reading it.
*
* @param identifier Seach engine identifier. This only exists for search engines that
* ship with the default set of engines in the locale.
* @param in InputStream for search plugin XML file.
* @return SearchEngine instance.
*/
private SearchEngine createEngineFromInputStream(String identifier, InputStream in) {
try { try {
try { try {
return new SearchEngine(identifier, in); return new SearchEngine(identifier, in);
@ -194,13 +404,12 @@ public class SearchEngineManager implements SharedPreferences.OnSharedPreference
} }
/** /**
* Reads a file from the searchplugins directory in the Gecko jar. This will only work * Reads a file from the searchplugins directory in the Gecko jar.
* if the search activity is built as part of mozilla-central.
* *
* @param fileName name of the file to read * @param fileName name of the file to read.
* @return InputStream for file * @return InputStream for file.
*/ */
private InputStream getInputStreamFromJar(String fileName) { private InputStream getInputStreamFromSearchPluginsJar(String fileName) {
final Locale locale = Locale.getDefault(); final Locale locale = Locale.getDefault();
// First, try a file path for the full locale. // First, try a file path for the full locale.
@ -228,32 +437,14 @@ public class SearchEngineManager implements SharedPreferences.OnSharedPreference
} }
/** /**
* Gets the jar URL for a file in the searchplugins directory * Gets the jar URL for a file in the searchplugins directory.
* *
* @param locale String representing the Gecko locale (e.g. "en-US") * @param locale String representing the Gecko locale (e.g. "en-US").
* @param fileName name of the file to read * @param fileName The name of the file to read.
* @return URL for jar file * @return URL for jar file.
*/ */
private String getSearchPluginsJarURL(String locale, String fileName) { private String getSearchPluginsJarURL(String locale, String fileName) {
final String path = "!/chrome/" + locale + "/locale/" + locale + "/browser/searchplugins/" + fileName; final String path = "!/chrome/" + locale + "/locale/" + locale + "/browser/searchplugins/" + fileName;
return "jar:jar:file://" + context.getPackageResourcePath() + "!/" + AppConstants.OMNIJAR_NAME + path; return "jar:jar:file://" + context.getPackageResourcePath() + "!/" + AppConstants.OMNIJAR_NAME + path;
} }
/**
* Opens the search plugin XML file from the searchplugins directory in the Gecko profile.
*
* @param identifier
* @return InputStream for search plugin file
*/
private InputStream getEngineFromProfile(String identifier) {
final File f = GeckoProfile.get(context).getFile("searchplugins/" + identifier + ".xml");
if (f.exists()) {
try {
return new FileInputStream(f);
} catch (FileNotFoundException e) {
Log.e(LOG_TAG, "Exception getting search engine from profile", e);
}
}
return null;
}
} }

View File

@ -175,6 +175,9 @@
<string name="fxaccount_status_header">&fxaccount_status_header2;</string> <string name="fxaccount_status_header">&fxaccount_status_header2;</string>
<string name="fxaccount_status_signed_in_as">&fxaccount_status_signed_in_as;</string> <string name="fxaccount_status_signed_in_as">&fxaccount_status_signed_in_as;</string>
<string name="fxaccount_status_auth_server">&fxaccount_status_auth_server;</string> <string name="fxaccount_status_auth_server">&fxaccount_status_auth_server;</string>
<string name="fxaccount_status_sync_now">&fxaccount_status_sync_now;</string>
<string name="fxaccount_status_syncing">&fxaccount_status_syncing;</string>
<string name="fxaccount_status_last_synced">&remote_tabs_last_synced;</string>
<string name="fxaccount_status_device_name">&fxaccount_status_device_name;</string> <string name="fxaccount_status_device_name">&fxaccount_status_device_name;</string>
<string name="fxaccount_status_sync_server">&fxaccount_status_sync_server;</string> <string name="fxaccount_status_sync_server">&fxaccount_status_sync_server;</string>
<string name="fxaccount_status_sync">&fxaccount_status_sync;</string> <string name="fxaccount_status_sync">&fxaccount_status_sync;</string>
@ -216,6 +219,3 @@
<string name="fxaccount_remove_account_dialog_message">&fxaccount_remove_account_dialog_message;</string> <string name="fxaccount_remove_account_dialog_message">&fxaccount_remove_account_dialog_message;</string>
<string name="fxaccount_remove_account_toast">&fxaccount_remove_account_toast;</string> <string name="fxaccount_remove_account_toast">&fxaccount_remove_account_toast;</string>
<string name="fxaccount_remove_account_menu_item">&fxaccount_remove_account_menu_item;</string> <string name="fxaccount_remove_account_menu_item">&fxaccount_remove_account_menu_item;</string>
<!-- Find-In-Page strings -->
<string name="find_matchcase">&find_matchcase;</string>

View File

@ -274,7 +274,9 @@ public class Hex implements BinaryEncoder, BinaryDecoder {
try { try {
byte[] byteArray = object instanceof String ? ((String) object).getBytes(getCharsetName()) : (byte[]) object; byte[] byteArray = object instanceof String ? ((String) object).getBytes(getCharsetName()) : (byte[]) object;
return encodeHex(byteArray); return encodeHex(byteArray);
} catch (ClassCastException | UnsupportedEncodingException e) { } catch (ClassCastException e) {
throw new EncoderException(e.getMessage(), e);
} catch (UnsupportedEncodingException e) {
throw new EncoderException(e.getMessage(), e); throw new EncoderException(e.getMessage(), e);
} }
} }

View File

@ -501,6 +501,22 @@ VARIABLES = {
delimiters. delimiters.
""", None), """, None),
'HAS_MISC_RULE': (bool, bool,
"""Whether this directory should be traversed in the ``misc`` tier.
Many ``libs`` rules still exist in Makefile.in files. We highly prefer
that these rules exist in the ``misc`` tier/target so that they can be
executed concurrently during tier traversal (the ``misc`` tier is
fully concurrent).
Presence of this variable indicates that this directory should be
traversed by the ``misc`` tier.
Please note that converting ``libs`` rules to the ``misc`` tier must
be done with care, as there are many implicit dependencies that can
break the build in subtle ways.
""", 'misc'),
'FINAL_TARGET_FILES': (HierarchicalStringList, list, 'FINAL_TARGET_FILES': (HierarchicalStringList, list,
"""List of files to be installed into the application directory. """List of files to be installed into the application directory.

View File

@ -98,6 +98,8 @@ class TestEmitterBasic(unittest.TestCase):
reldirs = [o.relativedir for o in objs] reldirs = [o.relativedir for o in objs]
self.assertEqual(reldirs, ['', 'foo', 'foo/biz', 'bar']) self.assertEqual(reldirs, ['', 'foo', 'foo/biz', 'bar'])
self.assertEqual(objs[3].affected_tiers, {'misc'})
dirs = [o.dirs for o in objs] dirs = [o.dirs for o in objs]
self.assertEqual(dirs, [ self.assertEqual(dirs, [
[ [