/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.waveprotocol.wave.client.gadget.renderer;
import static org.waveprotocol.wave.model.gadget.GadgetConstants.AUTHOR_ATTRIBUTE;
import static org.waveprotocol.wave.model.gadget.GadgetConstants.ID_ATTRIBUTE;
import static org.waveprotocol.wave.model.gadget.GadgetConstants.IFRAME_URL_ATTRIBUTE;
import static org.waveprotocol.wave.model.gadget.GadgetConstants.LAST_KNOWN_HEIGHT_ATTRIBUTE;
import static org.waveprotocol.wave.model.gadget.GadgetConstants.LAST_KNOWN_WIDTH_ATTRIBUTE;
import static org.waveprotocol.wave.model.gadget.GadgetConstants.PREFS_ATTRIBUTE;
import static org.waveprotocol.wave.model.gadget.GadgetConstants.SNIPPET_ATTRIBUTE;
import static org.waveprotocol.wave.model.gadget.GadgetConstants.STATE_ATTRIBUTE;
import static org.waveprotocol.wave.model.gadget.GadgetConstants.TITLE_ATTRIBUTE;
import static org.waveprotocol.wave.model.gadget.GadgetConstants.URL_ATTRIBUTE;
import com.google.common.annotations.VisibleForTesting;
import com.google.gwt.core.client.Duration;
import com.google.gwt.core.client.GWT;
import com.google.gwt.core.client.JavaScriptObject;
import com.google.gwt.dom.client.Document;
import com.google.gwt.dom.client.ScriptElement;
import com.google.gwt.http.client.URL;
import com.google.gwt.user.client.Element;
import com.google.gwt.user.client.Random;
import com.google.gwt.user.client.Window.Location;
import org.waveprotocol.wave.client.account.ProfileManager;
import org.waveprotocol.wave.client.common.util.UserAgent;
import org.waveprotocol.wave.client.editor.content.AnnotationPainter;
import org.waveprotocol.wave.client.editor.content.CMutableDocument;
import org.waveprotocol.wave.client.editor.content.ContentElement;
import org.waveprotocol.wave.client.editor.content.ContentNode;
import org.waveprotocol.wave.client.gadget.GadgetLog;
import org.waveprotocol.wave.client.gadget.StateMap;
import org.waveprotocol.wave.client.gadget.StateMap.Each;
import org.waveprotocol.wave.client.scheduler.ScheduleCommand;
import org.waveprotocol.wave.client.scheduler.ScheduleTimer;
import org.waveprotocol.wave.client.scheduler.Scheduler;
import org.waveprotocol.wave.client.scheduler.Scheduler.Task;
import org.waveprotocol.wave.model.conversation.ConversationBlip;
import org.waveprotocol.wave.model.conversation.ObservableConversation;
import org.waveprotocol.wave.model.document.util.Point;
import org.waveprotocol.wave.model.document.util.XmlStringBuilder;
import org.waveprotocol.wave.model.gadget.GadgetXmlUtil;
import org.waveprotocol.wave.model.id.ModernIdSerialiser;
import org.waveprotocol.wave.model.id.WaveletName;
import org.waveprotocol.wave.model.supplement.ObservableSupplementedWave;
import org.waveprotocol.wave.model.util.CollectionUtils;
import org.waveprotocol.wave.model.util.ReadableStringMap.ProcV;
import org.waveprotocol.wave.model.util.ReadableStringSet;
import org.waveprotocol.wave.model.util.StringMap;
import org.waveprotocol.wave.model.wave.ParticipantId;
import java.util.Collection;
import java.util.Date;
import java.util.List;
/**
* Class to implement gadget widgets rendered in the client.
*
*
* TODO(user): Modularize the gadget APIs (base, Podium, Wave, etc).
*
* TODO(user): Refactor the common RPC call code.
*/
public class GadgetWidget extends ObservableSupplementedWave.ListenerImpl
implements GadgetRpcListener, GadgetWaveletListener, GadgetUiListener {
private static final String GADGET_RELAY_PATH = "gadgets/files/container/rpc_relay.html";
private static final int DEFAULT_HEIGHT_PX = 100;
private static final String DEFAULT_WIDTH = "99%";
/**
* Helper class to analyze element changes in the gadget state and prefs.
*/
private abstract class ElementChangeTask {
/**
* Runs processChange() wrapped in code that detects and submits changes in
* the gadget state and prefs.
*
* @param node The node being processed or null if not defined.
*/
void run(ContentNode node) {
if (!isActive()) {
log("Element change event in removed node: ignoring.");
return;
}
StateMap oldState = StateMap.create();
oldState.copyFrom(state);
final StateMap oldPrefs = StateMap.create();
oldPrefs.copyFrom(userPrefs);
processChange(node);
if (!state.compare(oldState)) {
gadgetStateSubmitter.submit();
}
// TODO(user): Optimize prefs updates.
if (!userPrefs.compare(oldPrefs)) {
userPrefs.each(new StateMap.Each() {
@Override
public void apply(String key, String value) {
if (!oldPrefs.has(key) || !value.equals(oldPrefs.get(key))) {
setGadgetPref(key, value);
}
}
});
}
}
/**
* Processes the changes in the elements.
*
* @param node The node being processed or null if not defined.
*/
abstract void processChange(ContentNode node);
}
/**
* Podium state is stored as a part of the wave gadget state and can be
* visible to the Gadget via both Wave and Podium RPC interfaces.
*/
private static final String PODIUM_STATE_NAME = "podiumState";
/**
* Gadget RPC path: location of the RPC JavaScript code to be loaded into the
* client code. This is the standard Gadget library to support RPCs.
*/
static final String GADGET_RPC_PATH = "/gadgets/js/core:rpc.js";
/**
* Gadget name prefix: the common part of the gadget IFrame ID and name. The
* numeric gadget ID is appended to this prefix.
*/
static final String GADGET_NAME_PREFIX = "wgadget_iframe_";
/** Primary view for gadgets. */
static final String GADGET_PRIMARY_VIEW = "canvas";
/** Default view for gadgets. */
static final String GADGET_DEFAULT_VIEW = "default";
/**
* Time in milliseconds to wait for the RPC script to load before logging a
* warning.
*/
private static final int GADGET_RPC_LOAD_WARNING_TIMEOUT_MS = 30000;
/** Time granularity to check for the Gadget RPC library load state. */
private static final int GADGET_RPC_LOAD_TIMER_MS = 250;
/** Editing mode polling timer. */
private static final int EDITING_POLLING_TIMER_MS = 200;
/** Blip submit delay in milliseconds. */
private static final int BLIP_SUBMIT_TIMEOUT_MS = 30;
/** Gadget state send delay in milliseconds. */
private static final int STATE_SEND_TIMEOUT_MS = 30;
/** The Wave API version supported by the gadget container. */
private static final String WAVE_API_VERSION = "1";
/** The key for the playback state in the wave gadget state map. */
private static final String PLAYBACK_MODE_KEY = "${playback}";
/** The key for the edit state in the wave gadget state map. */
private static final String EDIT_MODE_KEY = "${edit}";
/** Gadget-loading frame border removal delay in ms. */
private static final int FRAME_BORDER_REMOVE_DELAY_MS = 3000;
/** Delay before sending one more participant information update in ms. */
private static final int REPEAT_PARTICIPANT_INFORMATION_SEND_DELAY_MS = 5000;
/** Object that manages Gadget UI HTML elements. */
private GadgetWidgetUi ui;
/** Gadget title element. */
private GadgetElementChild titleElement;
/** The gadget spec URL. */
private String source;
/** Gadget instance ID counter (local for each client). */
private static int nextClientInstanceId = 0;
/** Gadget instance ID. Non-final for testing. */
private int clientInstanceId;
/** Gadget iframe URL. */
private String iframeUrl;
/** Gadget RPC token.*/
private final String rpcToken;
/** Gadget security token. */
private String securityToken;
/** Gadget user preferences. */
private GadgetUserPrefs userPrefs;
/**
* Gadget state element map. Maps state keys to the corresponding elements.
*/
private final StringMap<GadgetElementChild> prefElements;
/**
* Widget active flag: true after the widget is created, false after it is
* destroyed.
*/
private boolean active = false;
/** ID of the gadget's wave/let. */
private WaveletName waveletName;
/** Host blip of this gadget. */
private ConversationBlip blip;
/** Blip submitter. */
private Submitter blipSubmitter;
/** Gadget state submitter. */
private Submitter gadgetStateSubmitter;
/** Private gadget state submitter. */
private Submitter privateGadgetStateSubmitter;
/** ContentElement in the wave that corresponds to this gadget. */
private ContentElement element;
/** Indicator for gadget's blip editing state. */
private EditingIndicator editingIndicator;
/** Participant information. */
private ParticipantInformation participants;
/** Gadget state. */
private StateMap state;
/** User id of the current logged in user. */
private String loginName;
/**
* Gadget state element map. Maps state keys to the corresponding elements.
*/
private final StringMap<GadgetElementChild> stateElements;
/** Indicates whether the gadget is known to support the Wave API. */
private boolean waveEnabled = false;
/** Version of Wave API that is used by the gadget-side code. */
private String waveApiVersion = "";
/** Per-user wavelet to store private gadget data. */
private ObservableSupplementedWave supplement;
/** Provides profile information. */
private ProfileManager profileManager;
/** Wave client locale. */
private Locale locale;
/** Gadget library initialization flag. */
private static boolean initialized = false;
/**
* Gadget element child that defines what nodes to check for redundancy in the
* removeRedundantNodeTask. Only a single task can be scheduled at a time.
*/
private GadgetElementChild redundantNodeCheckChild = null;
/**
* Indicates whether the gadget has performed a document mutation on behalf of
* the user. This flag is checked when the gadget tries to perform
* non-essential modifications of the document such as duplicate node cleanup
* or height attribute update. Performing such operations may generate
* unnecessary playback frames and attribute modifications to a user who did
* not use the gadget. The flag is set when the gadget modifies state, prefs,
* title, or any other elements that normally are linked to user actions in
* the gadget.
*/
private boolean documentModified = false;
/**
* Indicates that the iframe URL attribute should be updated when the gadget
* modifies the document in response to a user action.
*/
private boolean toUpdateIframeUrl = false;
private final String clientInstanceLogLabel;
private boolean isSavedHeightSet = false;
// Note that the following regex expressions are strings rather than compiled patterns because GWT
// does not (yet) support those. Consider using the new GWT RegExp class in the future.
/**
* Pattern to match rpc token, security token, and user preference parameters
* in a URL fragment. Used to remove all these parameters.
*/
private final static String FRAGMENT_CLEANING_PATTERN = "(^|&)(rpctoken=|st=|up_)[^&]*";
/**
* Pattern to match module ID and security token parameters a URL. Used to
* remove all these parameters.
*/
private final static String URL_CLEANING_PATTERN = "&(mid=|st=|lang=|country=|debug=)[^&]*";
/**
* Pattern to match and remove URL fragment including the #.
*/
private final static String FRAGMENT_PATTERN = "#.*";
/**
* Pattern to match and remove URL part before fragment including the #.
*/
private final static String BEFORE_FRAGMENT_PATTERN = "[^#]*#";
/**
* Pattern to validate URL fragment.
*/
private final static String FRAGMENT_VALIDATION_PATTERN =
"([\\w~!&@\\$\\-\\.\\'\\(\\)\\*\\+\\,\\;\\=\\?\\:]|%[0-9a-fA-F]{2})+";
/**
* Pattern to match iframe host in the beginning of a URL. This is not a
* validation check. The user can choose their own host. This simply serves
* to extract the iframe segment of the URL
*/
private final static String IFRAME_HOST_PATTERN =
"^\\/\\/(https?:\\/\\/)?[^\\/]+\\/";
/**
* Pattern to remove XML-unsafe characters. Snippeting fails on some of those
* symbol combinations due to a potential bug in XML attribute processing.
* Theoretically all those symbols should be tolerated and displayed in
* snippets without any special processing in this class.
*
* TODO(user): Investigate/test this later to remove sanitization.
*/
private final static String SNIPPET_SANITIZER_PATTERN = "[<>\\\"\\'\\&]";
/**
* Constructs GadgetWidget for testing.
*/
private GadgetWidget() {
clientInstanceId = nextClientInstanceId++;
clientInstanceLogLabel = "[" + clientInstanceId + "]";
prefElements = CollectionUtils.createStringMap();
stateElements = CollectionUtils.createStringMap();
rpcToken = "" +
((Long.valueOf(Random.nextInt()) << 32) | (Long.valueOf(Random.nextInt()) & 0xFFFFFFFFL));
}
private static native boolean gadgetLibraryLoaded() /*-{
return ($wnd.gadgets && $wnd.gadgets.rpc) ? true : false;
}-*/;
/**
* Preloads the libraries and initializes them on the first use.
*/
private static void initializeGadgets() {
if (!initialized && !gadgetLibraryLoaded()) {
GadgetLog.log("Initializing Gadget RPC script tag.");
loadGadgetRpcScript();
initialized = true;
GadgetLog.log("Gadgets RPC script tag initialized.");
}
// TODO(user): Remove the css hacks once CAJA is fixed.
if (!initialized && !gadgetLibraryLoaded()) {
// HACK(user): NOT reachable, but GWT thinks it is.
excludeCssName();
}
}
/**
* Utility function to convert a Gadget StateMap to a string to be stored as
* an attribute value.
*
* @param state JSON object to be converted to string.
* @return string to be saved as an attribute value.
*/
private static String stateToAttribute(StateMap state) {
if (state == null) {
return URL.encodeComponent("{}");
}
return URL.encodeComponent(state.toJson());
}
/**
* Utility function to convert an attribute string to a Gadget StateMap.
*
* @param attribute attribute value string.
* @return StateMap constructed from the attribute value.
*/
private StateMap attributeToState(String attribute) {
StateMap result = StateMap.create();
if ((attribute != null) && !attribute.equals("")) {
log("Unescaped attribute: ", URL.decodeComponent(attribute));
result.fromJson(URL.decodeComponent(attribute));
log("State map: ", result.toJson());
}
return result;
}
/**
* Returns the gadget name that identifies the gadget and its frame.
*
* @return gadget name.
*/
private String getGadgetName() {
return GADGET_NAME_PREFIX + clientInstanceId;
}
private void updatePrefsFromAttribute(String prefAttribute) {
if (!stateToAttribute(userPrefs).equals(prefAttribute)) {
StateMap prefState = attributeToState(prefAttribute);
userPrefs.parse(prefState, true);
log("Updating user prefs: ", userPrefs.toJson());
prefState.each(new StateMap.Each() {
@Override
public void apply(String key, String value) {
setGadgetPref(key, value);
}
});
}
}
/**
* Processes changes in the gadget element attributes.
* TODO(user): move some of this code to the handler.
*
* @param name attribute name.
* @param value new attribute value.
*/
public void onAttributeModified(String name, String value) {
log("Attribute '", name, "' changed to '", value, "'");
if (userPrefs == null) {
log("Attribute changed before the gadget is initialized.");
return;
}
if (name.equals(URL_ATTRIBUTE)) {
source = (value == null) ? "" : value;
} else if (name.equals(TITLE_ATTRIBUTE)) {
String title = (value == null) ? "" : URL.decodeComponent(value);
if (!title.equals(ui.getTitleLabelText())) {
log("Updating title: ", title);
ui.setTitleLabelText(title);
}
} else if (name.equals(PREFS_ATTRIBUTE)) {
updatePrefsFromAttribute(value);
} else if (name.equals(STATE_ATTRIBUTE)) {
StateMap newState = attributeToState(value);
if (!state.compare(newState)) {
String podiumState = newState.get(PODIUM_STATE_NAME);
if ((podiumState != null) && (!podiumState.equals(state.get(PODIUM_STATE_NAME)))) {
sendPodiumOnStateChangedRpc(getGadgetName(), podiumState);
}
state.clear();
state.copyFrom(newState);
log("Updating gadget state: ", state.toJson());
gadgetStateSubmitter.submit();
}
}
}
/**
* Loads Gadget RPC library script.
*/
private static void loadGadgetRpcScript() {
ScriptElement script = Document.get().createScriptElement();
script.setType("text/javascript");
script.setSrc(GADGET_RPC_PATH);
Document.get().getBody().appendChild(script);
}
/**
* Appends tokens to the iframe URI fragment.
*
* @param fragment Original parameter fragment of the gadget URI.
* @return Updated parameter fragment with new RPC and security tokens.
*/
private String updateGadgetUriFragment(String fragment) {
fragment = "rpctoken=" + rpcToken +
(fragment.isEmpty() || (fragment.charAt(0) == '&') ? "" : "&") + fragment;
if ((securityToken != null) && !securityToken.isEmpty()) {
fragment += "&st=" + URL.encodeComponent(securityToken);
}
return fragment;
}
@VisibleForTesting
static String cleanUrl(String url) {
String baseUrl = url;
String fragment = "";
int fragmentIndex = url.indexOf("#");
if (fragmentIndex >= 0) {
fragment = (url.substring(fragmentIndex + 1)).replaceAll(FRAGMENT_CLEANING_PATTERN, "");
if (fragment.startsWith("&")) {
fragment = fragment.substring(1);
}
baseUrl = url.substring(0, fragmentIndex);
}
baseUrl = baseUrl.replaceAll(URL_CLEANING_PATTERN, "");
return baseUrl + (fragment.isEmpty() ? "" : "#" + fragment);
}
/**
* Constructs IFrame URI of this gadget.
*
* @param instanceId instance to encode in the URI.
* @param url URL template.
* @return IFrame URI of this gadget.
*/
String buildIframeUrl(int instanceId, String url) {
final StringBuilder builder = new StringBuilder();
String fragment = "";
int fragmentIndex = url.indexOf("#");
if (fragmentIndex >= 0) {
fragment = url.substring(fragmentIndex + 1);
url = url.substring(0, fragmentIndex);
}
builder.append(url);
boolean enableGadgetCache = false;
builder.append("&nocache=" + (enableGadgetCache ? "0" : "1"));
builder.append("&mid=" + instanceId);
builder.append("&lang=" + locale.getLanguage());
builder.append("&country=" + locale.getCountry());
String href = getUrlPrefix();
// TODO(user): Parent is normally the last non-hash parameter. It is moved
// as a temp fix for kitchensinky. Move it back when the kitchensinky is
// working wihout this workaround.
builder.append("&parent=" + URL.encode(href));
builder.append("&wave=" + WAVE_API_VERSION);
builder.append("&waveId=" + URL.encodeQueryString(
ModernIdSerialiser.INSTANCE.serialiseWaveId(waveletName.waveId)));
fragment = updateGadgetUriFragment(fragment);
if (!fragment.isEmpty()) {
builder.append("#" + fragment);
log("Appended fragment: ", fragment);
}
if (userPrefs != null) {
userPrefs.each(new StateMap.Each() {
@Override
public void apply(String key, String value) {
if (value != null) {
builder.append("&up_");
builder.append(URL.encodeQueryString(key));
builder.append('=');
builder.append(URL.encodeQueryString(value));
}
}
});
}
return builder.toString();
}
/**
* Verifies that the gadget has non-empty attribute.
*
* @param name attribute name.
* @return true if non-empty height attribute exists, flase otherwise.
*/
private boolean hasAttribute(String name) {
if (element.hasAttribute(name)) {
String value = element.getAttribute(name);
if (!"".equals(value)) {
return true;
}
}
return false;
}
/**
* Updates the gadget attribute in a deferred command if the panel is
* editable.
*
* @param attributeName attribute name.
* @param value new attribute value.
*/
private void scheduleGadgetAttributeUpdate(final String attributeName, final String value) {
ScheduleCommand.addCommand(new Scheduler.Task() {
@Override
public void execute() {
if (canModifyDocument() && documentModified) {
String oldValue = element.getAttribute(attributeName);
if (!value.equals(oldValue)) {
element.getMutableDoc().setElementAttribute(element, attributeName, value);
}
}
}
});
}
/**
* Update the gadget iframe height in a deferred command if the panel is
* editable
*
* @param height the new height of the gadget iframe
*/
private void scheduleGadgetHeightUpdate(final String height) {
ScheduleCommand.addCommand(new Scheduler.Task() {
@Override
public void execute() {
if (canModifyDocument()) {
updateIframeHeight(height);
}
}
});
}
/**
* Updates gadget IFrame attributes.
*
* @param url URL template for the iframe.
* @param width preferred width of the iframe.
* @param height preferred height of the iframe.
*/
private void updateGadgetIframe(String url, long width, long height) {
if (!isActive()) {
return;
}
iframeUrl = url;
if (hasAttribute(LAST_KNOWN_WIDTH_ATTRIBUTE)) {
setSavedIframeWidth();
} else if (width != 0) {
ui.setIframeWidth(width + "px");
ui.makeInline();
scheduleGadgetAttributeUpdate(LAST_KNOWN_WIDTH_ATTRIBUTE, Long.toString(width));
}
if (!hasAttribute(LAST_KNOWN_HEIGHT_ATTRIBUTE) && (height != 0)) {
ui.setIframeHeight(height);
scheduleGadgetAttributeUpdate(LAST_KNOWN_HEIGHT_ATTRIBUTE, Long.toString(height));
}
String ifr = buildIframeUrl(getInstanceId(), url);
log("ifr: ", ifr);
ui.setIframeSource(ifr);
}
private int parseSizeString(String heightString) throws NumberFormatException {
if (heightString.endsWith("px")) {
return Integer.parseInt(heightString.substring(0, heightString.length() - 2));
} else {
return Integer.parseInt(heightString);
}
}
/**
* Updates gadget iframe height if the gadget has the height attribute.
*/
private void setSavedIframeHeight() {
if (hasAttribute(LAST_KNOWN_HEIGHT_ATTRIBUTE)) {
String savedHeight = element.getAttribute(LAST_KNOWN_HEIGHT_ATTRIBUTE);
try {
int height = parseSizeString(savedHeight);
ui.setIframeHeight(height);
isSavedHeightSet = true;
} catch (NumberFormatException e) {
log("Invalid saved height attribute (ignored): ", savedHeight);
}
}
}
/**
* Updates gadget iframe height if the gadget has the height attribute.
*/
private void setSavedIframeWidth() {
if (hasAttribute(LAST_KNOWN_WIDTH_ATTRIBUTE)) {
String savedWidth = element.getAttribute(LAST_KNOWN_WIDTH_ATTRIBUTE);
try {
int width = parseSizeString(savedWidth);
ui.setIframeWidth(width + "px");
ui.makeInline();
} catch (NumberFormatException e) {
log("Invalid saved width attribute (ignored): ", savedWidth);
}
}
}
/**
* Creates a display widget for the gadget.
*
* @param element ContentElement from the wave.
* @param blip gadget blip.
* @return display widget for the gadget.
*/
public static GadgetWidget createGadgetWidget(ContentElement element, WaveletName waveletName,
ConversationBlip blip, ObservableSupplementedWave supplement,
ProfileManager profileManager, Locale locale, String loginName) {
final GadgetWidget widget = GWT.create(GadgetWidget.class);
widget.element = element;
widget.editingIndicator =
new BlipEditingIndicator(element.getRenderedContentView().getDocumentElement());
widget.ui = new GadgetWidgetUi(widget.getGadgetName(), widget.editingIndicator);
widget.state = StateMap.create();
initializeGadgets();
widget.blip = blip;
widget.initializeGadgetContainer();
widget.ui.setGadgetUiListener(widget);
widget.waveletName = waveletName;
widget.supplement = supplement;
widget.profileManager = profileManager;
widget.locale = locale;
widget.loginName = loginName;
supplement.addListener(widget);
return widget;
}
/**
* @return the actual GWT widget
*/
public GadgetWidgetUi getWidget() {
return ui;
}
@Override
public void setTitle(String title) {
if (!isActive()) {
return;
}
final String newTitle = (title == null) ? "" : title;
log("Set title '", XmlStringBuilder.createText(newTitle), "'");
if (titleElement == null) {
onModifyingDocument();
GadgetElementChild.create(element.getMutableDoc().insertXml(
Point.end((ContentNode) element), GadgetXmlUtil.constructTitleXml(newTitle)));
blipSubmitter.submit();
} else {
if (!title.equals(titleElement.getValue())) {
onModifyingDocument();
titleElement.setValue(newTitle);
blipSubmitter.submit();
}
}
}
@Override
public void logMessage(String message) {
GadgetLog.developerLog(message);
}
private String sanitizeSnippet(String snippet) {
return snippet.replaceAll(SNIPPET_SANITIZER_PATTERN, " ");
}
@Override
public void setSnippet(String snippet) {
if (!canModifyDocument()) {
return;
}
String safeSnippet = sanitizeSnippet(snippet);
log("Snippet changed: " + safeSnippet);
scheduleGadgetAttributeUpdate(SNIPPET_ATTRIBUTE, safeSnippet);
}
/**
* Gets the attribute value from the mutable document associated with the
* gadget.
*
* @param attributeName name of the attribute
* @return attribute value or empty string if attribute is missing
*/
private String getAttribute(String attributeName) {
return element.hasAttribute(attributeName) ? element.getAttribute(attributeName) : "";
}
@VisibleForTesting
static String getIframeHost(String url) {
// Ideally this should be done with regex matcher which is not supported in GWT.
String iframeHostMatcher = url.replaceFirst(IFRAME_HOST_PATTERN, "");
if (iframeHostMatcher.length() != url.length()) {
return url.substring(0, url.length() - iframeHostMatcher.length());
} else {
return "";
}
}
/**
* Controller registration task.
*
* @param url URL template of the gadget iframe.
* @param width preferred iframe width.
* @param height preferred iframe height.
*/
private void controllerRegistration(String url, long width, long height) {
Controller controller = Controller.getInstance();
String iframeHost = getIframeHost(url);
String relayUrl = iframeHost + GADGET_RELAY_PATH;
controller.setRelayUrl(getGadgetName(), relayUrl);
controller.registerGadgetListener(getGadgetName(), GadgetWidget.this);
controller.setRpcToken(getGadgetName(), rpcToken);
updateGadgetIframe(url, width, height);
removeFrameBorder();
delayedPodiumInitialization();
log("Gadget ", getGadgetName(), " is registered, relayUrl=", relayUrl,
", RPC token=", rpcToken);
}
private void registerWithController(String url, long width, long height) {
if (gadgetLibraryLoaded()) {
controllerRegistration(url, width, height);
} else {
scheduleControllerRegistration(url, width, height);
}
}
/**
* Registers the Gadget object as RPC event listener with the Gadget RPC
* Controller after waiting for the Gadget RPC library to load.
*/
private void scheduleControllerRegistration(
final String url, final long width, final long height) {
new ScheduleTimer() {
private double loadWarningTime =
Duration.currentTimeMillis() + GADGET_RPC_LOAD_WARNING_TIMEOUT_MS;
@Override
public void run() {
if (!isActive()) {
cancel();
log("Not active.");
return;
} else if (gadgetLibraryLoaded()) {
cancel();
controllerRegistration(url, width, height);
} else {
if (Duration.currentTimeMillis() > loadWarningTime) {
log("Gadget RPC script failed to load on time.");
loadWarningTime += GADGET_RPC_LOAD_WARNING_TIMEOUT_MS;
}
}
}
}.scheduleRepeating(GADGET_RPC_LOAD_TIMER_MS);
}
private void initializeGadgetContainer() {
userPrefs = GadgetUserPrefs.create();
blipSubmitter = new Submitter(BLIP_SUBMIT_TIMEOUT_MS, new Submitter.SubmitTask() {
@Override public void doSubmit() {
// TODO: send a playback frame signal.
log("Blip submitted.");
}
});
gadgetStateSubmitter = new Submitter(STATE_SEND_TIMEOUT_MS, new Submitter.SubmitTask() {
@Override public void doSubmit() {
sendGadgetState();
log("Gadget state sent.");
}
});
privateGadgetStateSubmitter = new Submitter(STATE_SEND_TIMEOUT_MS, new Submitter.SubmitTask() {
@Override public void doSubmit() {
sendPrivateGadgetState();
log("Private gadget state sent.");
}
});
}
private void initializePodium() {
if (!isActive()) {
// If the widget does not exist, exit.
return;
}
for (ParticipantId participant : blip.getConversation().getParticipantIds()) {
String myId = participants.getMyId();
if ((myId != null) && !participant.getAddress().equals(myId)) {
String opponentId = participant.getAddress();
try {
sendPodiumOnInitializedRpc(getGadgetName(), myId, opponentId);
log("Sent Podium initialization: " + myId + " " + opponentId);
String podiumState = state.get(PODIUM_STATE_NAME);
if (podiumState != null) {
sendPodiumOnStateChangedRpc(getGadgetName(), podiumState);
log("Sent Podium state update.");
}
} catch (Exception e) {
// This is a catch to avoid sending RPCs to deleted gadgets.
log("Podium initialization failure");
}
return;
}
}
log("Podium is not initialized: less than two participants.");
}
private void delayedPodiumInitialization() {
// TODO(user): This is a hack to delay Podium initialization.
// Define an initialization protocol for Podium to avoid this.
new ScheduleTimer() {
@Override
public void run() {
initializePodium();
}
}.schedule(3000);
}
private void removeFrameBorder() {
new ScheduleTimer() {
@Override
public void run() {
ui.removeThrobber();
}
}.schedule(FRAME_BORDER_REMOVE_DELAY_MS);
}
private void constructGadgetFromMetadata(GadgetMetadata metadata, String view, String token) {
log("Received metadata: ", metadata.getIframeUrl(view));
String url = cleanUrl(metadata.getIframeUrl(view));
if (url.equals(iframeUrl) && ((token == null) || token.isEmpty())) {
log("Received metadata matches the cached information.");
constructGadgetSizeFromMetadata(metadata, view, url);
return;
}
// NOTE(user): Technically we should not save iframe URLs for gadgets with security tokens,
// but some gadgets, such as YNM, that depend on opensocial libraries get security tokens they
// never use. Also to enable gadgets in Ripple and other light Wave clients it's desirable to
// to always have the iframe URL at least for rudimentary rendering.
if (canModifyDocument() && documentModified) {
scheduleGadgetAttributeUpdate(IFRAME_URL_ATTRIBUTE, url);
} else {
toUpdateIframeUrl = true;
}
securityToken = token;
if ("".equals(ui.getTitleLabelText()) && metadata.hasTitle()) {
ui.setTitleLabelText(metadata.getTitle());
}
constructGadgetSizeFromMetadata(metadata, view, url);
}
private void constructGadgetSizeFromMetadata(GadgetMetadata metadata, String view, String url) {
int height =
(int) (metadata.hasHeight() ? metadata.getHeight() : metadata.getPreferredHeight(view));
int width =
(int) (metadata.hasWidth() ? metadata.getWidth() : metadata.getPreferredWidth(view));
registerWithController(url, width, height);
if (height > 0) {
updateIframeHeight(String.valueOf(height));
} else {
updateIframeHeight(String.valueOf(DEFAULT_HEIGHT_PX));
}
if (width > 0){
setIframeWidth(String.valueOf(width));
} else {
setIframeWidth(DEFAULT_WIDTH);
}
}
/**
* This function generates a gadget instance ID for generating gadget metadata
* and security tokens. The ID should be 1. hard to guess; 2. same for the
* same gadget element for the same participant in the same wave every time
* the wave is rendered in the same client; 3. preferably, but not necessarily
* different for different gadget elements and different participants.
*
* Condition 2 is needed to achieve consistent behavior in gadgets that, for
* example, request special permissions using OAuth/OpenSocial.
*
* This function satisfies those conditions, except the ID is going to be
* always the same for the same type of the gadget in the same wavelet for the
* same participant. This poses minimal risk (in terms of matching domains and
* security tokens) because the gadgets with matching IDs would be rendered
* for the same person in the same wave.
*
* NOTE(user): Instance ID should be non-negative number to work around a
* bug in GGS and/or Linux libraries that produces non-renderable iframe URLs
* for negative instance IDs. The domain name starts with dash "-". Browsers
* in Windows and Mac OS tolerate this, but browsers in Linux fail to render
* such URLs.
*
* @return instance ID for the gadget.
*/
private int getInstanceId() {
String name = ModernIdSerialiser.INSTANCE.serialiseWaveletName(waveletName);
String instanceDescriptor = name + loginName + source;
int hash = instanceDescriptor.hashCode();
return (hash < 0) ? ~hash : hash;
}
private void showBrokenGadget(String message) {
ui.showBrokenGadget(message);
log("Broken gadget: ", message);
}
private boolean validIframeUrl(String url) {
return (url != null) && !url.isEmpty() && !getIframeHost(url).isEmpty();
}
private void scheduleGadgetIdUpdate() {
ScheduleCommand.addCommand(new Scheduler.Task() {
@Override
public void execute() {
generateAndSetGadgetId();
}
});
}
private void allowModificationOfNewlyCreatedGadget() {
// Missing height attribute indicates freshly added gadget. Assume that the
// document is modified for the purpose of updating attributes.
if (!hasAttribute(LAST_KNOWN_HEIGHT_ATTRIBUTE) && editingIndicator.isEditing()) {
scheduleGadgetIdUpdate();
onModifyingDocument();
}
}
/**
* Creates a widget to render the gadget.
*/
public void createWidget() {
if (isActive()) {
log("Repeated attempt to create gadget widget.");
return;
}
active = true;
log("Creating Gadget Widget ", getGadgetName());
ui.enableMenu();
allowModificationOfNewlyCreatedGadget();
setSavedIframeHeight();
setSavedIframeWidth();
source = getAttribute(URL_ATTRIBUTE);
String title = getAttribute(TITLE_ATTRIBUTE);
ui.setTitleLabelText((title == null) ? "" : URL.decodeComponent(title));
updatePrefsFromAttribute(getAttribute(PREFS_ATTRIBUTE));
refreshParticipantInformation();
// HACK(anorth): This event routing should happen outside the widget.
ObservableConversation conv = (ObservableConversation) blip.getConversation();
conv.addListener(new WaveletListenerAdapter(blip, this));
log("Requesting Gadget metadata: ", source);
String cachedIframeUrl = getAttribute(IFRAME_URL_ATTRIBUTE);
if (validIframeUrl(cachedIframeUrl)) {
registerWithController(cleanUrl(cachedIframeUrl), 0, 0);
}
GadgetDataStoreImpl.getInstance().getGadgetData(source, waveletName, getInstanceId(),
new GadgetDataStore.DataCallback() {
@Override
public void onError(String message, Throwable t) {
if ((t != null) && (t.getMessage() != null)) {
message += " " + t.getMessage();
}
showBrokenGadget(message);
}
@Override
public void onDataReady(GadgetMetadata metadata, String securityToken) {
if (isActive()) {
ReadableStringSet views = metadata.getViewSet();
String view = null;
if (views.contains(GADGET_PRIMARY_VIEW)) {
view = GADGET_PRIMARY_VIEW;
} else if (views.contains(GADGET_DEFAULT_VIEW)) {
view = GADGET_DEFAULT_VIEW;
} else if (!views.isEmpty()) {
view = views.someElement();
} else {
showBrokenGadget("Gadget has no view to render.");
return;
}
String url = metadata.getIframeUrl(view);
if (validIframeUrl(url)) {
constructGadgetFromMetadata(metadata, view, securityToken);
} else {
showBrokenGadget("Invalid IFrame URL " + url);
}
}
}
});
}
/**
* Utility function to send setPref RPC to the gadget.
*
* @param target the gadget frame ID.
* @param name name of the preference to set.
* @param value value of the preference.
*/
public native void sendGadgetPrefRpc(String target, String name, String value) /*-{
try {
$wnd.gadgets.rpc.call(target, 'set_pref', null, 0, name, value);
} catch (e) {
// HACK(user): Ignoring any failure for now.
@org.waveprotocol.wave.client.gadget.GadgetLog::log(Ljava/lang/String;)
('set_pref RPC failed');
}
}-*/;
/**
* Utility function to send initialization RPC to Podium gadget.
*
* @param target the gadget frame ID.
* @param id Podium ID of this client.
* @param otherId Podium ID of the opponent client.
*/
public native void sendPodiumOnInitializedRpc(String target, String id, String otherId) /*-{
try {
$wnd.gadgets.rpc.call(target, 'onInitialized', null, id, otherId);
} catch (e) {
// HACK(user): Ignoring any failure for now.
@org.waveprotocol.wave.client.gadget.GadgetLog::log(Ljava/lang/String;)
('onInitialized RPC failed');
}
}-*/;
/**
* Utility function to send state change RPC to Podium gadget.
*
* @param target the gadget frame ID.
* @param state Podium gadget state.
*/
public native void sendPodiumOnStateChangedRpc(String target, String state) /*-{
try {
$wnd.gadgets.rpc.call(target, 'onStateChanged', null, state);
} catch (e) {
// HACK(user): Ignoring any failure for now.
@org.waveprotocol.wave.client.gadget.GadgetLog::log(Ljava/lang/String;)
('onStateChanged RPC failed');
}
}-*/;
/**
* Utility function to send title to the embedding container.
*
* @param title the title value for the container.
*/
public native void sendEmbeddedRpc(String title) /*-{
try {
$wnd.gadgets.rpc.call(null, 'set_title', null, title);
} catch (e) {
// HACK(user): Ignoring any failure for now.
@org.waveprotocol.wave.client.gadget.GadgetLog::log(Ljava/lang/String;)
('set_title RPC failed');
}
}-*/;
/**
* Utility function to send participant information to Wave gadget.
*
* @param target the gadget frame ID.
* @param participants JSON string of Wavelet participants.
*/
public native void sendParticipantsRpc(String target, JavaScriptObject participants) /*-{
try {
$wnd.gadgets.rpc.call(target, 'wave_participants', null, participants);
} catch (e) {
// HACK(user): Ignoring any failure for now.
@org.waveprotocol.wave.client.gadget.GadgetLog::log(Ljava/lang/String;)
('wave_participants RPC failed');
}
}-*/;
/**
* Utility function to send Gadget state to Wave gadget.
*
* @param target the gadget frame ID.
* @param state JSON string of Gadget state.
*/
public native void sendGadgetStateRpc(String target, JavaScriptObject state) /*-{
try {
$wnd.gadgets.rpc.call(target, 'wave_gadget_state', null, state);
} catch (e) {
// HACK(user): Ignoring any failure for now.
@org.waveprotocol.wave.client.gadget.GadgetLog::log(Ljava/lang/String;)
('wave_gadget_state RPC failed');
}
}-*/;
/**
* Utility function to send private Gadget state to Wave gadget.
*
* @param target the gadget frame ID.
* @param state JSON string of Gadget state.
*/
public native void sendPrivateGadgetStateRpc(String target, JavaScriptObject state) /*-{
try {
$wnd.gadgets.rpc.call(target, 'wave_private_gadget_state', null, state);
} catch (e) {
// HACK(user): Ignoring any failure for now.
@org.waveprotocol.wave.client.gadget.GadgetLog::log(Ljava/lang/String;)
('wave_private_gadget_state RPC failed');
}
}-*/;
/**
* Utility function to send Gadget mode to Wave gadget.
*
* @param target the gadget frame ID.
* @param mode JSON string of Gadget state.
*/
public native void sendModeRpc(String target, JavaScriptObject mode) /*-{
try {
$wnd.gadgets.rpc.call(target, 'wave_gadget_mode', null, mode);
} catch (e) {
// HACK(user): Ignoring any failure for now.
@org.waveprotocol.wave.client.gadget.GadgetLog::log(Ljava/lang/String;)
('wave_gadget_mode RPC failed');
}
}-*/;
/**
* Sends the gadget state to the wave gadget. Injects the playback state value
* into the state.
*/
public void sendGadgetState() {
if (waveEnabled) {
log("Sending gadget state: ", state.toJson());
sendGadgetStateRpc(getGadgetName(), state.asJavaScriptObject());
}
}
/**
* Sends the private gadget state to the wave gadget.
*/
public void sendPrivateGadgetState() {
if (waveEnabled) {
String gadgetId = getGadgetId();
StateMap privateState = StateMap.createFromStringMap(gadgetId != null ?
supplement.getGadgetState(gadgetId) : CollectionUtils.<String> emptyMap());
log("Sending private gadget state: ", privateState.toJson());
sendPrivateGadgetStateRpc(getGadgetName(), privateState.asJavaScriptObject());
}
}
/**
* Sends the gadget mode to the wave gadget.
*/
public void sendMode() {
if (waveEnabled) {
StateMap mode = StateMap.create();
mode.put(PLAYBACK_MODE_KEY, "0");
mode.put(EDIT_MODE_KEY, editingIndicator.isEditing() ? "1" : "0");
log("Sending gadget mode: ", mode.toJson());
sendModeRpc(getGadgetName(), mode.asJavaScriptObject());
}
}
/**
* Returns the ID of the user who added the gadget as defined in the author
* attribute. If the attribute is not defined returns the blip author instead
* (as the best guess for the author for backward compatibility).
*
* @return author ID of the user who added the gadget to the wave
*/
private String getAuthor() {
String author = element.getAttribute(AUTHOR_ATTRIBUTE);
return (author != null) ? author : blip.getAuthorId().getAddress();
}
/**
* Builds a map of participants from two lists of participant ids.
*/
private StringMap<ParticipantId> getParticipantsForIds(
Collection<ParticipantId> list1, Collection<ParticipantId> list2) {
StringMap<ParticipantId> mergedMap = CollectionUtils.createStringMap();
for (ParticipantId p : list1) {
mergedMap.put(p.getAddress(), p);
}
for (ParticipantId p : list2) {
mergedMap.put(p.getAddress(), p);
}
return mergedMap;
}
/**
* Refreshes the participant information.
*/
private void refreshParticipantInformation() {
StringMap<ParticipantId> waveletParticipants = getParticipantsForIds(
blip.getConversation().getParticipantIds(), blip.getContributorIds());
ParticipantId viewerId = new ParticipantId(loginName);
waveletParticipants.put(viewerId.getAddress(), viewerId);
List<ParticipantId> participantList = CollectionUtils.newJavaList(waveletParticipants);
participants = ParticipantInformation.create(
viewerId.getAddress(), getAuthor(), participantList, getUrlPrefix(), profileManager);
final StringBuilder builder = new StringBuilder();
builder.append("Participants: ");
builder.append("I am " + participants.getMyId());
for (ParticipantId participant : participantList) {
builder.append("; " + participant);
}
log(builder.toString());
}
/**
* Refreshes and sends participant information to wave-enabled gadget.
*/
private void sendCurrentParticipantInformation() {
if (waveEnabled) {
refreshParticipantInformation();
sendParticipantsRpc(getGadgetName(), participants);
log("Sent participants: ", participants);
}
}
/**
* Utility function to perform setPref RPC to the gadget.
*
* @param name name of the preference to set.
* @param value value of the preference.
*/
public void setGadgetPref(final String name, final String value) {
ScheduleCommand.addCommand(new Task() {
@Override
public void execute() {
if (isActive()) {
sendGadgetPrefRpc(getGadgetName(), name, value);
}
}
});
}
/**
* Marks the Widget as inactive after the gadget node is removed from the
* parent.
*/
public void setInactive() {
log("Gadget node removed.");
supplement.removeListener(this);
active = false;
}
private void updateIframeHeight(String height) {
if (!isActive() || (isSavedHeightSet && !documentModified)) {
return;
}
log("Set IFrame height ", height);
try {
int heightValue = parseSizeString(height);
ui.setIframeHeight(heightValue);
scheduleGadgetAttributeUpdate(LAST_KNOWN_HEIGHT_ATTRIBUTE, Long.toString(heightValue));
} catch (NumberFormatException e) {
log("Invalid height (ignored): ", height);
}
}
@Override
public void setIframeHeight(String height) {
scheduleGadgetHeightUpdate(height);
}
public void setIframeWidth(String width) {
if (!isActive()) {
return;
}
log("Set IFrame width ", width);
if (width.contains("%")) {
ui.setIframeWidth(width);
ui.makeInline();
scheduleGadgetAttributeUpdate(LAST_KNOWN_WIDTH_ATTRIBUTE, width);
} else {
try {
int widthValue = parseSizeString(width);
if (widthValue > 0) {
ui.setIframeWidth(widthValue + "px");
}
ui.makeInline();
scheduleGadgetAttributeUpdate(LAST_KNOWN_WIDTH_ATTRIBUTE, Long.toString(widthValue));
} catch (NumberFormatException e) {
log("Invalid width (ignored): ", width);
}
}
}
@Override
public void requestNavigateTo(String url) {
log("Requested navigate to: ", url);
// NOTE(user): Currently only allow the gadgets to change the fragment part of the URL.
String newFragment = url.replaceFirst(BEFORE_FRAGMENT_PATTERN, "");
if (newFragment.matches(FRAGMENT_VALIDATION_PATTERN)) {
Location.replace(Location.getHref().replaceFirst(FRAGMENT_PATTERN, "") + "#" + newFragment);
} else {
log("Navigate request denied.");
}
}
@Override
public void updatePodiumState(String podiumState) {
if (isActive()) {
modifyState(PODIUM_STATE_NAME, podiumState);
blipSubmitter.submit();
}
}
private void setPref(String key, String value) {
if (!canModifyDocument() || (key == null) || (value == null)) {
return;
}
userPrefs.put(key, value);
if (prefElements.containsKey(key)) {
if (!prefElements.get(key).getValue().equals(value)) {
log("Updating preference '", key, "'='", value, "'");
onModifyingDocument();
prefElements.get(key).setValue(value);
blipSubmitter.submit();
}
} else {
log("New preference '", key, "'='", value, "'");
onModifyingDocument();
element.getMutableDoc().insertXml(
Point.end((ContentNode)element), GadgetXmlUtil.constructPrefXml(key, value));
blipSubmitter.submit();
}
}
@Override
public void setPrefs(String ... keyValue) {
// Ignore callbacks from the gadget in playback mode.
if (!canModifyDocument()) {
return;
}
// Ignore the last key if its value is missing.
for (int i = 0; i < keyValue.length - 1; i+=2) {
setPref(keyValue[i], keyValue[i + 1]);
}
}
/**
* Sets up a polling loop to check the edit mode state and send it to the
* gadget.
*
* TODO(user): Add edit mode change events to the client and find a way to
* relay them to the gadget containers.
*/
private void setupModePolling() {
new ScheduleTimer() {
private boolean wasEditing = editingIndicator.isEditing();
@Override
public void run() {
if (!isActive()) {
cancel();
return;
} else {
boolean newEditing = editingIndicator.isEditing();
if (wasEditing != newEditing) {
sendMode();
wasEditing = newEditing;
}
}
}
}.scheduleRepeating(EDITING_POLLING_TIMER_MS);
}
/**
* HACK: This is a workaround for Firefox bug
* https://bugzilla.mozilla.org/show_bug.cgi?id=498904 Due to this bug the
* gadget RPCs may be sent to a dead iframe. Changing the iframe ID fixes
* container-to-gadget communication. Non-wave gadgets may have other issues
* associated with this bug. But most wave-enabled gadgets should work when
* the iframe ID is updated in the waveEnable call.
*/
private void substituteIframeId() {
clientInstanceId = nextClientInstanceId++;
ui.setIframeId(getGadgetName());
controllerRegistration(iframeUrl, 0, 0);
}
@Override
public void waveEnable(String waveApiVersion) {
if (!isActive()) {
return;
}
// HACK: See substituteIframeId() description.
// TODO(user): Remove when the Firefox bug is fixed.
if (UserAgent.isFirefox()) {
substituteIframeId();
}
waveEnabled = true;
this.waveApiVersion = waveApiVersion;
log("Wave-enabled gadget registered with API version ", waveApiVersion);
sendWaveGadgetInitialization();
setupModePolling();
}
@Override
public void waveGadgetStateUpdate(final JavaScriptObject delta) {
// Return if in playback mode. isEditable indicates playback.
if (!canModifyDocument()) {
return;
}
final StateMap deltaState = StateMap.create();
deltaState.fromJsonObject(delta);
// Defer state modifications to avoid RPC failure in Safari 3. The
// intermittent failure is caused by RPC called from received RPC
// callback.
// TODO(user): Remove this workaround once this is fixed in GGS.
ScheduleCommand.addCommand(new Task() {
@Override
public void execute() {
deltaState.each(new Each() {
@Override
public void apply(final String key, final String value) {
if (value != null) {
modifyState(key, value);
} else {
deleteState(key);
}
}
});
log("Applied delta ", delta.toString(), " new state ", state.toJson());
gadgetStateSubmitter.triggerScheduledSubmit();
blipSubmitter.submitImmediately();
}
});
}
/**
* Generates a unique gadget ID.
* TODO(user): Replace with proper MD5-based UUID.
*
* @return a unique gadget ID.
*/
private String generateGadgetId() {
String name = ModernIdSerialiser.INSTANCE.serialiseWaveletName(waveletName);
String instanceDescriptor = name + getAuthor() + source;
String prefix = Integer.toHexString(instanceDescriptor.hashCode());
String time = Integer.toHexString(new Date().hashCode());
String version = Long.toHexString(blip.getLastModifiedVersion());
return prefix + time + version;
}
private String generateAndSetGadgetId() {
if (!canModifyDocument()) {
return null;
}
String id = generateGadgetId();
element.getMutableDoc().setElementAttribute(element, ID_ATTRIBUTE, id);
return id;
}
private String getGadgetId() {
return element.getAttribute(ID_ATTRIBUTE);
}
private String getOrGenerateGadgetId() {
String id = getGadgetId();
if ((id == null) || id.isEmpty()) {
id = generateAndSetGadgetId();
}
return id;
}
@Override
public void wavePrivateGadgetStateUpdate(JavaScriptObject delta) {
// Return if in playback mode. isEditable indicates playback.
if (!canModifyDocument()) {
return;
}
StateMap deltaState = StateMap.create();
deltaState.fromJsonObject(delta);
final String gadgetId = getOrGenerateGadgetId();
if (gadgetId != null) {
deltaState.each(new Each() {
@Override
public void apply(final String key, final String value) {
supplement.setGadgetState(gadgetId, key, value);
}
});
log("Applied private delta ", deltaState.toJson());
privateGadgetStateSubmitter.triggerScheduledSubmit();
} else {
log("Unable to get gadget ID to update private state. Delta ", deltaState.toJson());
}
}
private void modifyState(String key, String value) {
if (!canModifyDocument()) {
log("Unable to modify state ", key, " ", value);
} else {
log("Modifying state ", key, " ", value);
if (stateElements.containsKey(key)) {
if (!stateElements.get(key).getValue().equals(value)) {
onModifyingDocument();
stateElements.get(key).setValue(value);
}
} else {
onModifyingDocument();
element.getMutableDoc().insertXml(
Point.end((ContentNode)element), GadgetXmlUtil.constructStateXml(key, value));
}
}
}
private void deleteState(String key) {
if (!canModifyDocument()) {
log("Unable to remove state ", key);
} else {
log("Removing state ", key);
if (stateElements.containsKey(key)) {
onModifyingDocument();
element.getMutableDoc().deleteNode(stateElements.get(key).getElement());
}
}
}
private void sendWaveGadgetInitialization() {
sendMode();
sendCurrentParticipantInformation();
gadgetStateSubmitter.submitImmediately();
privateGadgetStateSubmitter.submitImmediately();
// Send participant information one more time as participant pictures may be
// loaded with a delay. There is no callback to get the picture update
// event.
new ScheduleTimer() {
@Override
public void run() {
if (isActive()) {
sendCurrentParticipantInformation();
}
}
}.schedule(REPEAT_PARTICIPANT_INFORMATION_SEND_DELAY_MS);
}
private void updateElementMaps(
GadgetElementChild child, StringMap<GadgetElementChild> childMap, StateMap stateMap) {
if (child.getKey() == null) {
log("Missing key attribute: element ignored.");
return;
}
if (childMap.containsKey(child.getKey())) {
logFine("Old value: ", childMap.get(child.getKey()));
}
childMap.put(child.getKey(), child);
stateMap.put(child.getKey(), child.getValue());
logFine("Updated element ", child.getKey(), " : ", child.getValue());
}
private void processTitleChild(GadgetElementChild child) {
titleElement = child;
String newTitleValue = child.getValue();
if (newTitleValue == null) {
newTitleValue = "";
}
if (!newTitleValue.equals(ui.getTitleLabelText())) {
ui.setTitleLabelText(newTitleValue);
}
}
private void removeChildFromMaps(
GadgetElementChild child, StringMap<GadgetElementChild> childMap, StateMap stateMap) {
String key = child.getKey();
if (childMap.containsKey(key)) {
stateMap.remove(key);
childMap.remove(key);
logFine("Removed element ", key);
}
}
private void processChild(GadgetElementChild child) {
if (child == null) {
return;
}
logFine("Processing: ", child);
switch (child.getType()) {
case STATE:
updateElementMaps(child, stateElements, state);
break;
case PREF:
updateElementMaps(child, prefElements, userPrefs);
break;
case TITLE:
processTitleChild(child);
break;
case CATEGORIES:
logFine("Categories element ignored.");
break;
default:
// Note(user): editor may add/remove selection and cursor nodes.
logFine("Unexpected gadget node ", child.getTag());
}
}
/**
* Finds the first copy of the given child in the sibling sequence starting at
* the given node.
*
* @param child Child to find next copy of.
* @param node Node to scan from.
* @return Next copy of the child or null if not found.
*/
private static GadgetElementChild findNextChildCopy(GadgetElementChild child, ContentNode node) {
if (child == null) {
return null;
}
while (node != null) {
GadgetElementChild gadgetChild = GadgetElementChild.create(node);
if (child.isDuplicate(gadgetChild)) {
return gadgetChild;
}
node = node.getNextSibling();
}
return null;
}
/**
* Task removes redundant nodes that match redundantNodeCheckChild.
*/
private final Scheduler.Task removeRedundantNodesTask = new Scheduler.Task() {
@Override
public void execute() {
if (!canModifyDocument()) {
return;
}
if (redundantNodeCheckChild != null) {
GadgetElementChild firstMatchingNode = findNextChildCopy(
redundantNodeCheckChild, element.getFirstChild());
GadgetElementChild lastSeenNode = firstMatchingNode;
while (lastSeenNode != null) {
lastSeenNode = findNextChildCopy(
redundantNodeCheckChild, firstMatchingNode.getElement().getNextSibling());
if (lastSeenNode != null) {
log("Removing: ", lastSeenNode);
element.getMutableDoc().deleteNode(lastSeenNode.getElement());
}
}
} else {
log("Undefined redundant node check child.");
}
redundantNodeCheckChild = null;
}
};
/**
* Scans nodes and removes duplicate copies of the given child leaving only
* the first copy.
* TODO(user): Unit test for node manipulations.
*
* @param child Child to delete the duplicates of.
*/
private void removeRedundantNodes(final GadgetElementChild child) {
if (!documentModified || (child == null)) {
return;
}
if (redundantNodeCheckChild == null) {
redundantNodeCheckChild = child;
ScheduleCommand.addCommand(removeRedundantNodesTask);
} else {
log("Overlapping redundant node check requests.");
}
}
private final ElementChangeTask childAddedTask = new ElementChangeTask() {
@Override
void processChange(ContentNode node) {
GadgetElementChild child = GadgetElementChild.create(node);
log("Added: ", child);
if (child != null) {
removeRedundantNodes(child);
processChild(child);
}
}
};
/**
* Processes an add child event.
*
* @param node the child added to the gadget node.
*/
public void onChildAdded(ContentNode node) {
childAddedTask.run(node);
}
private final ElementChangeTask childRemovedTask = new ElementChangeTask() {
@Override
void processChange(ContentNode node) {
GadgetElementChild child = GadgetElementChild.create(node);
log("Removed: ", child);
switch (child.getType()) {
case STATE:
removeChildFromMaps(child, stateElements, state);
break;
case PREF:
removeChildFromMaps(child, prefElements, userPrefs);
break;
case TITLE:
log("Removing title is not supported");
break;
case CATEGORIES:
log("Removing categories is not supported");
break;
default:
// Note(user): editor may add/remove selection and cursor nodes.
log("Unexpected gadget node removed ", child.getTag());
}
}
};
/**
* Processes a remove child event.
*
* @param node
*/
public void onRemovingChild(ContentNode node) {
childRemovedTask.run(node);
}
/**
* Rescans all gadget children to update the values stored in the gadget
* object.
*/
private void rescanGadgetXmlElements() {
log("Rescanning elements");
ContentNode childNode = element.getFirstChild();
while (childNode != null) {
processChild(GadgetElementChild.create(childNode));
childNode = childNode.getNextSibling();
}
}
private final ElementChangeTask descendantsMutatedTask = new ElementChangeTask() {
@Override
void processChange(ContentNode node) {
rescanGadgetXmlElements();
}
};
private final Scheduler.Task schedulableMutationTask = new Scheduler.Task() {
@Override
public void execute() {
descendantsMutatedTask.run(null);
}
};
/**
* Processes a mutation event.
*/
public void onDescendantsMutated() {
log("Descendants mutated.");
ScheduleCommand.addCommand(schedulableMutationTask);
}
@Override
public void onBlipContributorAdded(ParticipantId contributor) {
if (isActive()) {
log("Contributor added ", contributor);
sendCurrentParticipantInformation();
} else {
log("Contributor added event in deleted node.");
}
}
@Override
public void onBlipContributorRemoved(ParticipantId contributor) {
if (isActive()) {
log("Contributor removed ", contributor);
sendCurrentParticipantInformation();
} else {
log("Contributor removed event in deleted node.");
}
}
@Override
public void onParticipantAdded(ParticipantId participant) {
if (isActive()) {
log("Participant added ", participant);
sendCurrentParticipantInformation();
} else {
log("Participant added event in deleted node.");
}
}
@Override
public void onParticipantRemoved(ParticipantId participant) {
if (isActive()) {
log("Participant removed ", participant);
sendCurrentParticipantInformation();
} else {
log("Participant removed event in deleted node.");
}
}
private Object[] expandArgs(Object object, Object ... objects) {
Object[] args = new Object[objects.length + 1];
args[0] = object;
System.arraycopy(objects, 0, args, 1, objects.length);
return args;
}
private void log(Object ... objects) {
if (GadgetLog.shouldLog()) {
GadgetLog.logLazy(expandArgs(clientInstanceLogLabel, objects));
}
}
private void logFine(Object ... objects) {
if (GadgetLog.shouldLogFine()) {
GadgetLog.logFineLazy(expandArgs(clientInstanceLogLabel, objects));
}
}
/**
* Returns the URL of the client including protocol and host.
*
* @return URL of the client.
*/
private String getUrlPrefix() {
return Location.getProtocol() + "//" + Location.getHost();
}
/**
* Returns the UI element.
*
* @return UI element.
*/
Element getElement() {
return ui.getElement();
}
private boolean isActive() {
return active;
}
private boolean canModifyDocument() {
return isActive();
}
@Override
public void deleteGadget() {
if (canModifyDocument()) {
element.getMutableDoc().deleteNode(element);
}
}
@Override
public void selectGadget() {
if (isActive()) {
CMutableDocument doc = element.getMutableDoc();
element.getSelectionHelper().setSelectionPoints(
Point.before(doc, element), Point.after(doc, element));
}
}
@Override
public void resetGadget() {
if (canModifyDocument()) {
state.each(new Each() {
@Override
public void apply(String key, String value) {
deleteState(key);
}
});
gadgetStateSubmitter.submit();
final String gadgetId = getGadgetId();
if (gadgetId != null) {
supplement.getGadgetState(gadgetId).each(new ProcV<String>() {
@Override
public void apply(String key, String value) {
supplement.setGadgetState(gadgetId, key, null);
}
});
privateGadgetStateSubmitter.submit();
}
}
}
private static native void excludeCssName() /*-{
css();
}-*/;
private static class BlipEditingIndicator implements EditingIndicator {
private final ContentElement element;
/**
* Constructs editing indicator for the gadget's blip.
*/
BlipEditingIndicator(ContentElement element) {
this.element = element;
}
/**
* Returns the current edit state of the blip.
* TODO(user): add event-driven update of the edit state.
*
* @return whether the blip is in edit state.
*/
@Override
public boolean isEditing() {
return (element != null)
? AnnotationPainter.isInEditingDocument(ContentElement.ELEMENT_MANAGER, element) : false;
}
}
@Override
public void onMaybeGadgetStateChanged(String gadgetId) {
if (gadgetId != null) {
String myId = getGadgetId();
if (gadgetId.equals(myId)) {
privateGadgetStateSubmitter.submitImmediately();
}
}
}
/**
* Executes when the document is being modified in response to a user action.
*/
private void onModifyingDocument() {
documentModified = true;
if (toUpdateIframeUrl) {
scheduleGadgetAttributeUpdate(IFRAME_URL_ATTRIBUTE, iframeUrl);
toUpdateIframeUrl = false;
}
}
/**
* Creates GadgetWidget instance with preset fields for testing.
*
* TODO(user): Refactor to remove test code.
*
* @param id client instance ID
* @param userPrefs user prederences
* @param waveletName wavelet name
* @param securityToken security token
* @param locale locale
* @return test instance of the widget
*/
@VisibleForTesting
static GadgetWidget createForTesting(int id, GadgetUserPrefs userPrefs, WaveletName waveletName,
String securityToken, Locale locale) {
GadgetWidget widget = new GadgetWidget();
widget.clientInstanceId = id;
widget.userPrefs = userPrefs;
widget.waveletName = waveletName;
widget.securityToken = securityToken;
widget.locale = locale;
return widget;
}
/**
* @return RPC token for testing
*/
@VisibleForTesting
String getRpcToken() {
return rpcToken;
}
}