/** * Copyright 2010 Google Inc. * * Licensed 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 int DEFAULT_WIDTH_PX = 600; /** * 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; // 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); } } } }); } /** * 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); } 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."); 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()); } 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) { setIframeHeight(String.valueOf(height)); } else { setIframeHeight(String.valueOf(DEFAULT_HEIGHT_PX)); } if (width > 0){ setIframeWidth(String.valueOf(width)); } else { setIframeWidth(String.valueOf(DEFAULT_WIDTH_PX)); } } /** * 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; } @Override public void setIframeHeight(String height) { if (!isActive()) { 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); } } public void setIframeWidth(String width) { if (!isActive()) { return; } log("Set IFrame width ", width); try { int widthValue = parseSizeString(width); 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; } }