package org.vaadin.touchkit.gwt.client.offlinemode; import static org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason.APP_STARTED; import static org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason.APP_STARTING; import static org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason.BAD_RESPONSE; import static org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason.FORCE_OFFLINE; import static org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason.FORCE_ONLINE; import static org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason.NETWORK_ONLINE; import static org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason.NO_NETWORK; import static org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason.ONLINE_APP_NOT_STARTED; import static org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason.RESPONSE_TIMEOUT; import static org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason.SERVER_AVAILABLE; import java.util.logging.Logger; import org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason; import org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.OfflineEvent; import org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.OnlineEvent; import org.vaadin.touchkit.gwt.client.vcom.OfflineModeConnector; import com.google.gwt.core.client.Callback; import com.google.gwt.core.client.EntryPoint; import com.google.gwt.core.client.GWT; import com.google.gwt.core.client.JavaScriptObject; import com.google.gwt.core.client.Scheduler; import com.google.gwt.core.client.Scheduler.RepeatingCommand; import com.google.gwt.http.client.Request; import com.google.gwt.http.client.RequestBuilder; import com.google.gwt.http.client.RequestCallback; import com.google.gwt.http.client.RequestTimeoutException; import com.google.gwt.http.client.Response; import com.google.gwt.user.client.Timer; import com.vaadin.client.ApplicationConfiguration; import com.vaadin.client.ApplicationConnection; import com.vaadin.client.ApplicationConnection.CommunicationHandler; import com.vaadin.client.ApplicationConnection.RequestStartingEvent; import com.vaadin.client.ApplicationConnection.ResponseHandlingEndedEvent; import com.vaadin.client.ApplicationConnection.ResponseHandlingStartedEvent; /** * When this entry point starts an OfflineMode application is started. * * When the online application goes available, it deactivates the offline * application. * * It listen for HTML5 and Cordova online/off-line events * activating/deactivating the offline app. * * It also observes any request to check whether the server goes unreachable, * and reconfigures heartbeat intervals depending on the connection status. */ public class OfflineModeEntrypoint implements EntryPoint, CommunicationHandler, RequestCallback { /** * We maintain three flags for defining application statuses. * * To know whether the app is online anywhere, use: * <code> * OfflineModeEntrypoint.get().getNetworkStatus().isAppOnline() * </code> */ public class NetworkStatus { private boolean forcedOffline = false; private boolean serverReachable = false; private boolean networkOnline = true; private boolean paused = false; public boolean isAppOnline() { return !forcedOffline && networkOnline && serverReachable; } public boolean isNetworkOnline() { return !forcedOffline && networkOnline; } public boolean isServerReachable() { return !forcedOffline && serverReachable; } public boolean isAppRunning() { return isAppOnline() && !paused; } } private static OfflineModeEntrypoint instance = null; private static OfflineMode offlineModeApp; private static JavaScriptObject appConf = null; private NetworkStatus status = new NetworkStatus(); private OfflineModeConnector offlineModeConnector = null; private final Logger logger = Logger.getLogger(this.getClass().getName()); private ActivationReason lastReason = null; private ApplicationConnection applicationConnection = null; // TODO: make more parameters configurable from server-side. private int onlinePingInterval = 60000; private int offlinePingInterval = 10000; /* * We use a ping request instead of core heartbeat when the app is not * available. Normally when starting it with network off-line. */ private final Timer pingToServer = new Timer() { String url; @Override public void run() { RequestBuilder rq = new RequestBuilder(RequestBuilder.POST, getPingUrl()); rq.setTimeoutMillis(offlinePingInterval); rq.setCallback(OfflineModeEntrypoint.this); try { logger.info("Sending a ping request to the server."); rq.send(); } catch (Exception e) { onError(null, e); } } private String getPingUrl() { if (url == null) { // Try to find the serviceUrl. // Only needed when widgetset is local or it is in a CDN. url = getVaadinConfValue("serviceUrl"); if (url == null) { url = GWT.getHostPageBaseURL(); } url += "/PING"; logger.info("Ping URL " + url); } return url; } }; /** * @return the singletone instance of the OfflineModeEntrypoint */ public static OfflineModeEntrypoint get() { // Shouldn't happen unless someone does not inherits TK module if (instance == null) { new OfflineModeEntrypoint().onModuleLoad(); } return instance; } @Override public void onModuleLoad() { // Do not run twice. if (instance != null) { return; } instance = this; // Read the Javascript object with the vaadin root configuration appConf = getVaadinConf(); // If application is not extending TK servlet offline-mode is not reliable if (!isTouchKitServlet()) { logger.severe("OfflineMode disabled because Servlet is not extending TouchKitServlet."); return; } // Application can disable offline. if (!isOfflineModeEnabled()) { logger.info("OfflineMode disabled because of server configuration."); return; } // We always go off-line at the beginning until we receive // a Vaadin online response dispatch(APP_STARTING); // Configure HTML5 and Cordova off-line listeners configureApplicationOfflineEvents(); // Connection takes a while to be available waitForConnectionAvailable(); // Realize soon that server is offline. pingToServer.schedule(2000); } /* * Loop until vaadin application connection is loaded. * Normally it should be done when the OfflineModeConnector is * instantiated, but there could be applications not using it * in server side. It seems there is not other way to do this. */ private void waitForConnectionAvailable() { Scheduler.get().scheduleFixedDelay(new RepeatingCommand() { int counter = 0; @Override public boolean execute() { if (!ApplicationConfiguration.getRunningApplications() .isEmpty()) { configureHandlers(ApplicationConfiguration .getRunningApplications().iterator().next()); } return counter++ < 50 && applicationConnection == null; } }, 100); } /** * Return a network status object, so as other part of the app could have * info about whether the device network is online and the Vaadin server is * reachable. */ public NetworkStatus getNetworkStatus() { return status; } /** * Set the offlineModeConnector. It's optional to use it in server side. */ public void setOfflineModeConnector(OfflineModeConnector oc) { logger.info("Vaadin OfflineModeConnector has been started."); offlineModeConnector = oc; configureHandlers(oc.getConnection()); onResponseHandlingEnded(null); } /* * When applicationConnection is available we listen to certain handlers. * This never happens if the app starts in offline-mode. */ private void configureHandlers(ApplicationConnection conn) { if (applicationConnection == null) { logger.info("Vaadin ApplicationConnection has been started."); applicationConnection = conn; applicationConnection.addHandler(RequestStartingEvent.TYPE, this); applicationConnection.addHandler(ResponseHandlingStartedEvent.TYPE, this); applicationConnection.addHandler(ResponseHandlingEndedEvent.TYPE, this); dispatch(APP_STARTED); } } /** * Receive any activation or deactivation reason, setting the appropriate * flags and going Off-line or On-line in case. */ public void dispatch(ActivationReason reason) { // If server failed when returning the widgetset configuration, we do nothing if (getRootResponseStatus() >= 400) { logger.severe("OfflineMode disabled because a bad response when fetching root configuration."); return; } // Only dispatch events when something changes if (lastReason != reason) { if (reason == NETWORK_ONLINE && status.isNetworkOnline()) { // Avoid logging a frequent case. return; } logger.info("Dispatching: " + lastReason + " -> " + reason + " flags=" + status.forcedOffline + " " + status.networkOnline + " " + status.serverReachable); if (reason == NETWORK_ONLINE) { status.networkOnline = true; configureHeartBeat(); ping(); } else if (reason == NO_NETWORK) { status.networkOnline = false; if (status.isServerReachable() || lastReason == APP_STARTING) { goOffline(reason); } status.serverReachable = false; } else if (reason == SERVER_AVAILABLE || reason == APP_STARTED) { status.serverReachable = true; status.networkOnline = true; goOnline(reason); } else if (reason == FORCE_OFFLINE) { status.forcedOffline = true; goOffline(reason); } else if (reason == FORCE_ONLINE) { status.forcedOffline = false; ping(); } else if (CacheManifestStatusIndicator.isUpdating()) { // When network is slow and a new version of the app is being downloaded // we could have unreachable responses, hence it's better to ignore it. } else { // Offline cases status.serverReachable = false; goOffline(reason); } lastReason = reason; } } /* * Configure application heartbeat depending on the status. If application * is not ready we use a timer instead. */ private void configureHeartBeat() { if (status.isAppOnline() || !isOfflineModeEnabled()) { setHeartBeatInterval(onlinePingInterval); } else if (!status.isNetworkOnline()) { stopHeartBeat(); } else { if (offlineModeConnector != null && offlineModeConnector.getOfflineModeTimeout() > -1) { // This parameter is configurable from server via connector offlinePingInterval = offlineModeConnector .getOfflineModeTimeout(); } setHeartBeatInterval(offlinePingInterval); } } private void stopHeartBeat() { setHeartBeatInterval(-1); } private void resume() { status.paused = false; configureHeartBeat(); } private void pause() { status.paused = true; stopHeartBeat(); } /* * Set application heartbeat, When application is not ready we use a timer * instead. */ private void setHeartBeatInterval(int ms) { pingToServer.cancel(); if (applicationConnection != null) { applicationConnection.getHeartbeat().setInterval(ms > 0 ? ms / 1000 : -1); } else if (ms > 0) { pingToServer.scheduleRepeating(ms); } } /** * @return the OfflineMode application. */ public static OfflineMode getOfflineMode() { if (offlineModeApp == null) { offlineModeApp = GWT.create(OfflineMode.class); } return offlineModeApp; } /* * Go online if we were not, deactivating off-line UI and reactivating the * online one. */ private void goOnline(ActivationReason reason) { if (status.isAppOnline()) { logger.info("Network Back ONLINE (" + reason + ")"); if (applicationConnection != null) { if (getOfflineMode().isActive()) { getOfflineMode().deactivate(); } configureHeartBeat(); applicationConnection.fireEvent(new OnlineEvent()); } else { retryApplicationConnection(); } } } /* * This method is called when the device becomes online but the * app was started from cache. We rerun vaadinBootstrap in order to * fetch root configuration. */ private void retryApplicationConnection() { fetchRootConfiguration(new Callback<JavaScriptObject, JavaScriptObject>() { @Override public void onSuccess(JavaScriptObject result) { waitForConnectionAvailable(); } @Override public void onFailure(JavaScriptObject reason) { goOffline(ONLINE_APP_NOT_STARTED); } }); } /* * Go off-line showing the off-line UI, or notify it with the last off-line * reason. */ private void goOffline(ActivationReason reason) { logger.info("Network OFFLINE (" + reason + ")"); if (!isOfflineModeEnabled()) { return; } getOfflineMode().activate(reason); if (applicationConnection != null) { applicationConnection.getLoadingIndicator().hide(); applicationConnection.fireEvent(new OfflineEvent(reason)); } configureHeartBeat(); } @Override public void onResponseReceived(Request request, Response response) { if (response != null && response.getStatusCode() == Response.SC_OK) { dispatch(SERVER_AVAILABLE); } else { dispatch(RESPONSE_TIMEOUT); } } @Override public void onError(Request request, Throwable exception) { dispatch(exception instanceof RequestTimeoutException ? RESPONSE_TIMEOUT : BAD_RESPONSE); } @Override public void onRequestStarting(RequestStartingEvent e) { } @Override public void onResponseHandlingStarted(ResponseHandlingStartedEvent e) { dispatch(SERVER_AVAILABLE); } @Override public void onResponseHandlingEnded(ResponseHandlingEndedEvent e) { if (lastReason == APP_STARTING) { dispatch(SERVER_AVAILABLE); } } /** * Check whether the server is reachable setting the status on the response. */ public void ping() { if (applicationConnection != null) { applicationConnection.getHeartbeat().send(); } else { pingToServer.run(); } } /* * Using this JSNI block in order to listen to certain DOM events not * available in GWT: HTML-5 and Cordova online/offline. * * We also listen to hash fragment changes and window post-messages, so as * the app is notified with offline events from the parent when it is * embedded in an iframe. * * This block has a couple of hacks to make the app or network go off-line: * tkGoOffline() tkGoOnline() tkServerDown() tkServerUp() * * NOTE: Most code here is for fixing android bugs firing wrong events and * setting erroneously online flags when it is inside webview. */ private native void configureApplicationOfflineEvents() /*-{ var _this = this; // Cordova installed in current window var hasCordovaEvents = $wnd.navigator.network && $wnd.navigator.network.connection && $wnd.Connection; // Cordova installed in parent window sending network events via postMessage var maybeCordova = !hasCordovaEvents && $wnd.parent !== $wnd && $wnd.postMessage; // Use html5 online events as the last resource because of bugs in // android webview which sends unexpected online/offline events when actually // there weren't real network changes we have detected these issues when rotating // the screen, hiding the keyboard, focusing an input text, etc. var useHtml5Events = !hasCordovaEvents && $wnd.navigator.onLine != undefined; function offline() { var ev = @org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason::NO_NETWORK; _this.@org.vaadin.touchkit.gwt.client.offlinemode.OfflineModeEntrypoint::dispatch(*)(ev); } function online() { var ev = @org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason::NETWORK_ONLINE; _this.@org.vaadin.touchkit.gwt.client.offlinemode.OfflineModeEntrypoint::dispatch(*)(ev); } function pause() { _this.@org.vaadin.touchkit.gwt.client.offlinemode.OfflineModeEntrypoint::pause()(); } function resume() { _this.@org.vaadin.touchkit.gwt.client.offlinemode.OfflineModeEntrypoint::resume()(); } // Export some functions for allowing developer to switch network and server on/off from JS console var forceFailure = false; $wnd.tkServerDown = function() { forceFailure = true; } $wnd.tkServerUp = function() { forceFailure = false; } $wnd.tkGoOffline = function() { // We only set the server Down if we force off-line from console, because setting off-line from // server needs connection for setting on-line back. $wnd.tkServerDown(); var ev = @org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason::FORCE_OFFLINE; _this.@org.vaadin.touchkit.gwt.client.offlinemode.OfflineModeEntrypoint::dispatch(*)(ev); } $wnd.tkGoOnline = function() { $wnd.tkServerUp(); var ev = @org.vaadin.touchkit.gwt.client.offlinemode.OfflineMode.ActivationReason::FORCE_ONLINE; _this.@org.vaadin.touchkit.gwt.client.offlinemode.OfflineModeEntrypoint::dispatch(*)(ev); } // When offline is forced make any XHR fail var realSend = $wnd.XMLHttpRequest.prototype.send; $wnd.XMLHttpRequest.prototype.send = function() { if (forceFailure) { throw "NETWORK_FAILURE_FORCED"; } else { realSend.apply(this, arguments); } } // Listen to HTML5 offline-online events. if (useHtml5Events) { $wnd.addEventListener("offline", offline, false); $wnd.addEventListener("online", online, false); // use HTML5 to test whether connection is available when the app starts if (!$wnd.navigator.onLine) { offline(); } // Redefine the HTML-5 onLine indicator. // This fixes the issue of android inside phonegap returning erroneus values. // It allows old vaadin apps based on testing 'onLine' flag continuing working. // Note: Safari disallows changing the 'online' property of the $wnd. if (!$wnd.navigator.hasOwnProperty('onLine') || $wnd.Object.getOwnPropertyDescriptor($wnd.navigator, 'onLine').configurable) { Object.defineProperty($wnd.navigator, 'onLine', { set: function() {}, get: function() { var sts = _this.@org.vaadin.touchkit.gwt.client.offlinemode.OfflineModeEntrypoint::getNetworkStatus()(); return sts.@org.vaadin.touchkit.gwt.client.offlinemode.OfflineModeEntrypoint.NetworkStatus::isAppOnline()(); } }); } } // Listen to Cordova specific online/off-line stuff // this needs cordova.js to be loaded in the current page. if (hasCordovaEvents) { $doc.addEventListener("offline", offline, false); $doc.addEventListener("online", online, false); $doc.addEventListener("pause", pause, false); $doc.addEventListener("resume", resume, false); // use Cordova to test whether connection is available when the app starts if ($wnd.navigator.network.connection.type == $wnd.Connection.NONE) { offline(); } } // Use postMessage approach to go online-offline, useful when the // application is embedded in a Cordova iframe. if (maybeCordova) { $wnd.addEventListener("message", function(ev) { var msg = ev.data; console.log(">>> received window message " + msg); if (/^(cordova-.+)$/.test(msg)) { // Remove HTML5 events to avoid android devices sending erroneous events if (!hasCordovaEvents) { console.log(">>> Cordova is present, removing HTML5 events."); $wnd.removeEventListener("offline", offline, false); $wnd.removeEventListener("online", online, false); } hasCordovaEvents = true; // Take an action depending on the message if (msg == 'cordova-offline') { offline(); } else if (msg == 'cordova-online') { online(); } else if (msg == 'cordova-pause') { pause(); } else if (msg == 'cordova-resume') { resume(); } } }, false); // Notify parent cordova container about the app was loaded. $wnd.parent.window.postMessage("touchkit-ready", "*"); } }-*/; // Try to find the vaadin config js-object when the app is // off-line and it has not been initialized yet private static native JavaScriptObject getVaadinConf() /*-{ return $wnd.vaadin.getApp($wnd.vaadin.getAppIds()[0]); }-*/; // Get a vaadin config value private static native String getVaadinConfValue(String key) /*-{ var app = @org.vaadin.touchkit.gwt.client.offlinemode.OfflineModeEntrypoint::appConf; var r = app && app.getConfig(key); // return null only in the case the value does not exist // otherwise return the string representation of the value. return r == null ? null : ('' + r); }-*/; // Make vaadinBootstrap rerun the fetchRootConfiguration request. private static native void fetchRootConfiguration(Callback<JavaScriptObject, JavaScriptObject> callback) /*-{ var app = @org.vaadin.touchkit.gwt.client.offlinemode.OfflineModeEntrypoint::appConf; app && app.fetchRootConfig(function(r) { if (callback) { if (r && r.status == 200) callback.@com.google.gwt.core.client.Callback::onSuccess(*)(r); else callback.@com.google.gwt.core.client.Callback::onFailure(*)(r); } }); }-*/; // Return true if offline mode is enabled in this app. // When true we never show the offline UI when the server is unreachable. public boolean isOfflineModeEnabled() { return Boolean.valueOf(getVaadinConfValue("offlineEnabled")); } // Return true if servlet is extending TouchKitServlet. // the 'widgetsetUrl' attribute is set by TK servlet. private static boolean isTouchKitServlet() { return getVaadinConfValue("widgetsetUrl") != null; } // Return the http status of the fetchRootConfiguration call private static int getRootResponseStatus() { String code = getVaadinConfValue("rootResponseStatus"); return code == null ? -1 : Integer.valueOf(code); } }