diff --git a/demo-app/src/main/AndroidManifest.xml b/demo-app/src/main/AndroidManifest.xml index 456316a..7ec9d99 100644 --- a/demo-app/src/main/AndroidManifest.xml +++ b/demo-app/src/main/AndroidManifest.xml @@ -19,7 +19,8 @@ - + + diff --git a/demo-app/src/main/res/values/strings.xml b/demo-app/src/main/res/values/strings.xml index 9779aca..2062f63 100644 --- a/demo-app/src/main/res/values/strings.xml +++ b/demo-app/src/main/res/values/strings.xml @@ -2,4 +2,7 @@ Zapic Android Demo Challenges Zapic + + 853726039702 + 853726039702-d1er1k1d6bvjg6lf9fg5rnja64scrk9s.apps.googleusercontent.com diff --git a/zapic/src/main/java/com/zapic/android/sdk/App.java b/zapic/src/main/java/com/zapic/android/sdk/App.java new file mode 100644 index 0000000..2afb344 --- /dev/null +++ b/zapic/src/main/java/com/zapic/android/sdk/App.java @@ -0,0 +1,587 @@ +package com.zapic.android.sdk; + +import android.annotation.SuppressLint; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.support.annotation.CheckResult; +import android.support.annotation.MainThread; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.RequiresApi; +import android.support.annotation.WorkerThread; +import android.util.Log; +import android.view.ViewManager; +import android.view.ViewParent; +import android.webkit.JavascriptInterface; +import android.webkit.RenderProcessGoneDetail; +import android.webkit.WebResourceRequest; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.File; + +final class App implements ConnectivityListener { + interface InteractionListener { + @MainThread + void login(); + + @MainThread + void logout(); + + @MainThread + void onStateChanged(final State state); + + @MainThread + void toast(@NonNull final String message); + } + + enum State { + LOADED, + STARTED, + READY, + NOT_LOADED, + NOT_READY, + } + + /** + * A communication bridge that dispatches messages from the Zapic JavaScript application. + * + * @author Kyle Dodson + * @since 1.0.0 + */ + final class AppJavascriptInterface { + /** + * Identifies an action that indicates the Zapic JavaScript application has loaded. + */ + private static final int APP_LOADED = 1; + + /** + * Identifies an action that indicates the Zapic JavaScript application has started. + */ + private static final int APP_STARTED = 2; + + /** + * Identifies an action that indicates the Zapic JavaScript application has requested + * closing the page. + */ + private static final int CLOSE_PAGE_REQUESTED = 3; + + /** + * Identifies an action that indicates the Zapic JavaScript application has requested + * logging in the player. + */ + private static final int LOGIN = 4; + + /** + * Identifies an action that indicates the Zapic JavaScript application has requested + * logging out the player. + */ + private static final int LOGOUT = 5; + + /** + * Identifies an action that indicates the Zapic JavaScript application has requested + * showing a banner to the player. + */ + private static final int SHOW_BANNER = 6; + + /** + * Identifies an action that indicates the Zapic JavaScript application is ready to show a + * page to the player. + */ + private static final int SHOW_PAGE = 7; + + /** + * The handler used to run tasks on the main thread. + */ + @NonNull + private final Handler mHandler; + + /** + * Creates a new {@link AppJavascriptInterface} instance. + */ + private AppJavascriptInterface() { + this.mHandler = new Handler(Looper.getMainLooper(), new Handler.Callback() { + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case APP_LOADED: { + App.this.mLoaded = true; + App.this.mInteractionListener.onStateChanged(State.LOADED); + break; + } + case APP_STARTED: { + App.this.mStarted = true; + App.this.mInteractionListener.onStateChanged(State.STARTED); + break; + } + case CLOSE_PAGE_REQUESTED: { + App.this.mReady = false; + App.this.mInteractionListener.onStateChanged(State.NOT_READY); + break; + } + case LOGIN: { + App.this.mInteractionListener.login(); + break; + } + case LOGOUT: { + App.this.mInteractionListener.logout(); + break; + } + case SHOW_BANNER: { + App.this.mInteractionListener.toast((String)msg.obj); + break; + } + case SHOW_PAGE: { + App.this.mReady = true; + App.this.mInteractionListener.onStateChanged(State.READY); + break; + } + default: { + break; + } + } + + return true; + } + }); + } + + /** + * Dispatches a message from the Zapic JavaScript application. + *

+ * This is not invoked on the UI thread. + * + * @param message The message. + */ + @JavascriptInterface + @WorkerThread + public void dispatch(@Nullable final String message) { + JSONObject action; + try { + action = new JSONObject(message); + } catch (JSONException e) { + if (BuildConfig.DEBUG) { + Log.e(App.TAG, "Failed to parse serialized action", e); + } + + return; + } + + String type; + try { + type = action.getString("type"); + } catch (JSONException e) { + if (BuildConfig.DEBUG) { + Log.e(App.TAG, "The action does not have a type", e); + } + + return; + } + + switch (type) { + case "APP_LOADED": { + this.mHandler.sendMessage(this.mHandler.obtainMessage(APP_LOADED)); + break; + } + case "APP_STARTED": { + this.mHandler.sendMessage(this.mHandler.obtainMessage(APP_STARTED)); + break; + } + case "CLOSE_PAGE_REQUESTED": { + this.mHandler.sendMessage(this.mHandler.obtainMessage(CLOSE_PAGE_REQUESTED)); + break; + } + case "LOGGED_IN": { + try { + String payload = action.getString("payload"); + Zapic.setPlayerId(payload); + } catch (JSONException ignored) { + Zapic.setPlayerId(null); + } + + break; + } + case "LOGIN": { + this.mHandler.sendMessage(this.mHandler.obtainMessage(LOGIN)); + break; + } + case "LOGOUT": { + Zapic.setPlayerId(null); + this.mHandler.sendMessage(this.mHandler.obtainMessage(LOGOUT)); + break; + } + case "PAGE_READY": { + this.mHandler.sendMessage(this.mHandler.obtainMessage(SHOW_PAGE)); + break; + } + case "SHOW_BANNER": { + this.mHandler.sendMessage(this.mHandler.obtainMessage(SHOW_BANNER)); + break; + } + default: { + break; + } + } + } + } + + /** + * A utility that overrides the default {@link WebView} behaviors. + * + * @author Kyle Dodson + * @since 1.0.0 + */ + final class AppWebViewClient extends WebViewClient { +// TODO: Handle errors loading the initial HTTP content. +// @MainThread +// @Override +// @RequiresApi(Build.VERSION_CODES.M) +// public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { +// // TODO: Transition to an offline page. +// } + +// TODO: Handle errors loading the initial HTTP content. +// @MainThread +// @Override +// public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { +// // TODO: Transition to an offline page. +// } + + @MainThread + @Override + @RequiresApi(Build.VERSION_CODES.O) + public boolean onRenderProcessGone(@NonNull final WebView view, @NonNull final RenderProcessGoneDetail detail) { + if (BuildConfig.DEBUG) { + final boolean crashed = detail.didCrash(); + if (crashed) { + Log.e(App.TAG, "The WebView has crashed"); + } else { + Log.e(App.TAG, "The WebView has been stopped to reclaim memory"); + } + } + + App.this.mLoaded = false; + App.this.mReady = false; + App.this.mStarted = false; + App.this.mInteractionListener.onStateChanged(State.NOT_LOADED); + return true; + } + + @MainThread + @Override + @RequiresApi(Build.VERSION_CODES.N) + public boolean shouldOverrideUrlLoading(@Nullable final WebView view, @Nullable final WebResourceRequest request) { + if (view == null || request == null) { + return false; + } + + Uri url = request.getUrl(); + return url != null && this.overrideUrlLoading(view, request.getUrl()); + } + + @MainThread + @Override + public boolean shouldOverrideUrlLoading(@Nullable final WebView view, @Nullable final String url) { + return !(view == null || url == null) && this.overrideUrlLoading(view, Uri.parse(url)); + } + + /** + * Intercepts {@link WebView} navigation events and, optionally, overrides the navigation + * behavior. + *

+ * This currently overrides navigation events to {@code market://*} URLs by opening the Google + * Play Store app. + * + * @param view The {@link WebView}. + * @param url The target {@link Uri} of the navigation event. + * @return {@code true} if the navigation behavior was overridden; {@code false} if the + * navigation behavior was not overridden. + */ + @MainThread + private boolean overrideUrlLoading(@NonNull final WebView view, @NonNull final Uri url) { + final String scheme = url.getScheme(); + if (scheme != null && scheme.equalsIgnoreCase("market")) { + try { + // Create a Google Play Store app intent. + final Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(url); + + // Open the Google Play Store app. + final Context context = view.getContext(); + context.startActivity(intent); + } catch (ActivityNotFoundException ignored) { + } + + // Prevent the WebView from navigating to an invalid URL. + return true; + } + + final String host = url.getHost(); + if (host != null && (host.equalsIgnoreCase("itunes.apple.com") || host.toLowerCase().endsWith(".itunes.apple.com"))) { + if (scheme != null && (scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https"))) { + // Create a web browser app intent. + final Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(url); + + // Open the web browser app. + final Context context = view.getContext(); + context.startActivity(intent); + } + + // Prevent the WebView from navigating to an external URL. + return true; + } + + if (host != null && (host.equalsIgnoreCase("play.google.com") || host.toLowerCase().endsWith(".play.google.com"))) { + if (scheme != null && (scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https"))) { + // Create a web browser app intent. + final Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(url); + + // Open the web browser app. + final Context context = view.getContext(); + context.startActivity(intent); + } + + // Prevent the WebView from navigating to an external URL. + return true; + } + + return false; + } + } + + /** + * The Zapic JavaScript application URL. + */ + @NonNull + private static final String APP_URL = "https://app.zapic.net"; + + /** + * The tag used to identify log messages. + */ + @NonNull + private static final String TAG = "App"; + + /** + * The variable name of the {@link AppJavascriptInterface} instance. + */ + @NonNull + private static final String VARIABLE_NAME = "androidWebView"; + + /** + * The currently executing asynchronous task. + */ + @Nullable + private AsyncTask mAsyncTask; + + /** + * The Android application's cache directory. + */ + @Nullable + private File mCacheDir; + + /** + * A value that indicates whether the Android device has network connectivity. + */ + private boolean mConnected; + + /** + * The interaction events listener. + */ + @NonNull + private final InteractionListener mInteractionListener; + + /** + * A value that indicates whether the Zapic JavaScript application has been loaded. + */ + private boolean mLoaded; + + /** + * A value that indicates whether the Zapic JavaScript application page is ready. + */ + private boolean mReady; + + /** + * A value that indicates whether the Zapic JavaScript application has been started. + */ + private boolean mStarted; + + /** + * The {@link WebView}. + */ + @Nullable + private WebView mWebView; + + /** + * Creates a new {@link App} instance + * + * @param interactionListener The interaction event listener. + */ + App(@NonNull final InteractionListener interactionListener) { + this.mAsyncTask = null; + this.mCacheDir = null; + this.mConnected = false; + this.mInteractionListener = interactionListener; + this.mLoaded = false; + this.mReady = false; + this.mStarted = false; + this.mWebView = null; + } + + /** + * Gets a value that indicates whether the Android device has network connectivity. + * + * @return {@code true} if the Android device has network connectivity; {@code false} if the + * Android device does not have network connectivity. + */ + @CheckResult + @MainThread + boolean getConnected() { + return this.mConnected; + } + + /** + * Gets the current state. + * + * @return The current state. + */ + @CheckResult + @MainThread + State getState() { + if (this.mReady) { + return State.READY; + } else if (this.mStarted) { + return State.STARTED; + } else if (this.mLoaded) { + return State.LOADED; + } else { + return State.NOT_LOADED; + } + } + + /** + * Gets the {@link WebView}. + * + * @return The {@link WebView}. + */ + @CheckResult + @MainThread + @Nullable + WebView getWebView() { + return this.mWebView; + } + + @MainThread + void loadWebView(@NonNull final AppSource appSource) { + this.mAsyncTask = null; + + if (this.mWebView != null) { + this.mWebView.loadDataWithBaseURL(App.APP_URL, appSource.getHtml(), "text/html", "utf-8", App.APP_URL); + } + } + + @MainThread + void loadWebViewCancelled() { + this.mAsyncTask = null; + } + + @Override + public void onConnected() { + if (this.mConnected) { + return; + } + + this.mConnected = true; + if (this.mAsyncTask == null && this.mCacheDir != null && this.getState() == State.NOT_LOADED) { + // Start an asynchronous task to get the web client application. + AppSourceAsyncTask asyncTask = new AppSourceAsyncTask(this, App.APP_URL, this.mCacheDir); + this.mAsyncTask = asyncTask; + asyncTask.execute(); + } + } + + @Override + public void onDisconnected() { + if (!this.mConnected) { + return; + } + + this.mConnected = false; + } + + @MainThread + @SuppressLint("SetJavaScriptEnabled") + void start(@NonNull final Context context) { + if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + // This enables WebView debugging for all WebViews in the current process. Changes to + // this value are accepted only before the WebView process is created. + WebView.setWebContentsDebuggingEnabled(true); + } + + // We use the application context to ensure we don't leak an activity. + this.mWebView = new WebView(context.getApplicationContext()); + this.mWebView.addJavascriptInterface(this.new AppJavascriptInterface(), App.VARIABLE_NAME); + this.mWebView.getSettings().setAllowContentAccess(false); + this.mWebView.getSettings().setAllowFileAccess(false); + this.mWebView.getSettings().setAllowFileAccessFromFileURLs(false); + this.mWebView.getSettings().setAllowUniversalAccessFromFileURLs(false); + this.mWebView.getSettings().setDomStorageEnabled(true); + this.mWebView.getSettings().setGeolocationEnabled(false); + this.mWebView.getSettings().setJavaScriptEnabled(true); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + this.mWebView.getSettings().setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); + } + this.mWebView.getSettings().setSaveFormData(false); + this.mWebView.getSettings().setSupportZoom(false); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + this.mWebView.setRendererPriorityPolicy(WebView.RENDERER_PRIORITY_WAIVED, false); + } + this.mWebView.setWebViewClient(this.new AppWebViewClient()); + + // Get the Android application's cache directory. + this.mCacheDir = context.getCacheDir(); + + // Start an asynchronous task to get the web client application. + AppSourceAsyncTask asyncTask = new AppSourceAsyncTask(this, App.APP_URL, this.mCacheDir); + this.mAsyncTask = asyncTask; + asyncTask.execute(); + } + + @MainThread + void stop() { + if (this.mAsyncTask != null) { + this.mAsyncTask.cancel(true); + this.mAsyncTask = null; + } + + this.mLoaded = false; + this.mReady = false; + this.mStarted = false; + this.mInteractionListener.onStateChanged(State.NOT_LOADED); + + if (this.mWebView != null) { + final ViewParent parent = this.mWebView.getParent(); + if (parent instanceof ViewManager) { + ((ViewManager)parent).removeView(this.mWebView); + } + + this.mWebView.destroy(); + this.mWebView = null; + } + } + + @MainThread + void submitEvent(@NonNull final JSONObject event) {} +} diff --git a/zapic/src/main/java/com/zapic/android/sdk/AppJavaBridge.java b/zapic/src/main/java/com/zapic/android/sdk/AppJavaBridge.java deleted file mode 100644 index 694f4b9..0000000 --- a/zapic/src/main/java/com/zapic/android/sdk/AppJavaBridge.java +++ /dev/null @@ -1,148 +0,0 @@ -package com.zapic.android.sdk; - -import android.os.Handler; -import android.os.Looper; -import android.os.Message; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.text.TextUtils; -import android.webkit.WebView; - -import org.json.JSONException; -import org.json.JSONObject; - -import java.util.ArrayList; - -/** - * A communication bridge that dispatches messages from the Android application's Java context to - * the web client application's JavaScript context (running in an Android - * {@see android.webkit.WebView}). - * - * @author Kyle Dodson - * @since 1.0.0 - */ -final class AppJavaBridge { - /** - * The tag used to identify log entries. - */ - @NonNull - private static final String TAG = "AppJavaBridge"; - - /** - * The handler that sends messages on the UI thread. - */ - @NonNull - private final Handler handler; - - /** - * The synchronization lock for {@see queue}, {@see queueMessageScheduled}, and {@see webView}. - */ - @NonNull - private final Object lock; - - /** - * The queue of actions. - */ - @NonNull - private final ArrayList queue; - - /** - * A value indicating whether a message has been scheduled. - */ - private boolean queueMessageScheduled; - - /** - * The Android {@see android.webkit.WebView} running the web client application. - */ - @Nullable - private WebView webView; - - /** - * Creates a new instance. - * - * @param webView The Android {@see android.webkit.WebView} running the web client application. - */ - AppJavaBridge(@NonNull final WebView webView) { - this.handler = new Handler(Looper.getMainLooper(), new Handler.Callback() { - @Override - public boolean handleMessage(Message msg) { - String[] actions; - WebView webView; - synchronized (AppJavaBridge.this.lock) { - if (AppJavaBridge.this.webView == null) { - return true; - } - - actions = AppJavaBridge.this.queue.toArray(new String[AppJavaBridge.this.queue.size()]); - webView = AppJavaBridge.this.webView; - - AppJavaBridge.this.queue.clear(); - AppJavaBridge.this.queueMessageScheduled = false; - } - - if (actions.length == 1) { - webView.evaluateJavascript(actions[0], null); - } else if (actions.length > 1) { - webView.evaluateJavascript(TextUtils.join(";", actions), null); - } - - return true; - } - }); - this.lock = new Object(); - this.queue = new ArrayList<>(); - this.queueMessageScheduled = false; - this.webView = webView; - } - - void dispatch(@NonNull final String action) { - try { - this.send(new JSONObject() - .put("type", action)); - } catch (JSONException ignored) { - // JSONException is only thrown if the value is a non-finite number. - } - } - - void dispatch(String action, JSONObject payload) { - try { - this.send(new JSONObject() - .put("type", action) - .put("payload", payload)); - } catch (JSONException ignored) { - // JSONException is only thrown if the value is a non-finite number. - } - } - - void dispatchError(String action) { - try { - this.send(new JSONObject() - .put("type", action) - .put("error", true)); - } catch (JSONException ignored) { - // JSONException is only thrown if the value is a non-finite number. - } - } - - void dispatchError(String action, String message) { - try { - this.send(new JSONObject() - .put("type", action) - .put("error", true) - .put("payload", message)); - } catch (JSONException ignored) { - // JSONException is only thrown if the value is a non-finite number. - } - } - - private void send(JSONObject action) { - synchronized (this.lock) { - this.queue.add(String.format("window.zapic.dispatch(%s)", action)); - - if (!this.queueMessageScheduled && this.webView != null) { - this.handler.sendEmptyMessageDelayed(0, 50); - this.queueMessageScheduled = true; - } - } - } -} diff --git a/zapic/src/main/java/com/zapic/android/sdk/AppJavaScriptBridge.java b/zapic/src/main/java/com/zapic/android/sdk/AppJavaScriptBridge.java deleted file mode 100644 index ff53083..0000000 --- a/zapic/src/main/java/com/zapic/android/sdk/AppJavaScriptBridge.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.zapic.android.sdk; - -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.util.Log; -import android.webkit.JavascriptInterface; - -import org.json.JSONException; -import org.json.JSONObject; - -/** - * A communication bridge that dispatches messages from the web client application's JavaScript - * context (running in an Android {@see android.webkit.WebView}) to the Android application's Java - * context. - * - * @author Kyle Dodson - * @since 1.0.0 - */ -final class AppJavaScriptBridge { - /** - * The tag used to identify log entries. - */ - @NonNull - private static final String TAG = "AppJavaScriptBridge"; - - /** - * Dispatches a message from the web client application's JavaScript context to the Android - * application's Java context. - *

- * This is not invoked on the UI thread. - * - * @param message The message. - */ - @JavascriptInterface - public void dispatch(@Nullable final String message) { - JSONObject action; - try { - action = new JSONObject(message); - } catch (JSONException e) { - Log.e(TAG, "Failed to parse serialized action", e); - return; - } - - String type; - try { - type = action.getString("type"); - } catch (JSONException e) { - Log.e(TAG, "The action does not have a type", e); - return; - } - - switch (type) { - case "APP_LOADED": - case "LOGIN": - case "LOGGED_IN": - case "SHOW_BANNER": - case "APP_STARTED": - case "CLOSE_PAGE_REQUESTED": - Log.d(TAG, message); - break; - default: - Log.e(TAG, String.format("The action type is invalid: %s", type)); - break; - } - } -} diff --git a/zapic/src/main/java/com/zapic/android/sdk/AppManager.java b/zapic/src/main/java/com/zapic/android/sdk/AppManager.java deleted file mode 100644 index 622d197..0000000 --- a/zapic/src/main/java/com/zapic/android/sdk/AppManager.java +++ /dev/null @@ -1,340 +0,0 @@ -package com.zapic.android.sdk; - -import android.annotation.SuppressLint; -import android.content.ActivityNotFoundException; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.AsyncTask; -import android.os.Build; -import android.support.annotation.AnyThread; -import android.support.annotation.CheckResult; -import android.support.annotation.MainThread; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.support.annotation.RequiresApi; -import android.util.Log; -import android.webkit.RenderProcessGoneDetail; -import android.webkit.WebResourceRequest; -import android.webkit.WebSettings; -import android.webkit.WebView; -import android.webkit.WebViewClient; - -import java.io.File; -import java.lang.ref.WeakReference; -import java.util.ArrayList; - -final class AppManager { - private enum State { - UNLOADED, - LOADED - } - - @NonNull - private static final String JAVASCRIPT_VARIABLE_NAME = "androidWebView"; - - @NonNull - private static final String TAG = "AppManager"; - - /** - * The web client URL. - */ - @NonNull - private static final String WEB_CLIENT_URL = "https://app.zapic.net"; - - private static final Object instanceLock = new Object(); - - private static WeakReference instance = new WeakReference<>(null); - - @NonNull - static AppManager getInstance() { - synchronized (AppManager.instanceLock) { - AppManager appManager = AppManager.instance.get(); - if (appManager == null) { - Log.i(TAG, "Creating a new instance of AppManager"); - appManager = new AppManager(); - AppManager.instance = new WeakReference<>(appManager); - } - - return appManager; - } - } - - @Nullable - private AppJavaBridge mAppJavaBridge; - - @Nullable - private AppJavaScriptBridge mAppJavaScriptBridge; - - @Nullable - private AsyncTask mAsyncTask; - - @Nullable - private File mCacheDir; - - @NonNull - private final ArrayList mListeners; - - private volatile boolean mOffline; - - private volatile State mState; - - private int mViewCount; - - @Nullable - private WebView mWebView; - - private AppManager() { - this.mAppJavaBridge = null; - this.mAppJavaScriptBridge = null; - this.mAsyncTask = null; - this.mCacheDir = null; - this.mListeners = new ArrayList<>(); - this.mOffline = false; - this.mState = State.UNLOADED; - this.mViewCount = 0; - this.mWebView = null; - } - - @MainThread - @Nullable - WebView getWebView() { - return this.mWebView; - } - - @AnyThread - @CheckResult - boolean isAppStarted() { - return this.mState == State.LOADED; - } - - @AnyThread - @CheckResult - boolean isOffline() { return this.mOffline; } - - /** - * Creates the {@link WebView} if it has not already been created and asynchronously loads the - * web client application. - * - * @param context A context belonging to the Android application. - */ - @MainThread - void onViewCreated(@NonNull final Context context) { - ++this.mViewCount; - if (this.mViewCount == 1) { - // Create the WebView. - assert this.mWebView == null : "mWebView != null"; - this.mWebView = this.createWebView(context); - - // Get the Android application's cache directory. - this.mCacheDir = context.getCacheDir(); - - if (!this.mOffline) { - // Start an asynchronous task to get the web client application. - AppSourceAsyncTask asyncTask = new AppSourceAsyncTask(this, AppManager.WEB_CLIENT_URL, this.mCacheDir); - this.mAsyncTask = asyncTask; - asyncTask.execute(); - } - } - } - - @MainThread - void onViewDestroyed() { - --this.mViewCount; - if (this.mViewCount == 0) { - // Cancel any asynchronous task. - if (this.mAsyncTask != null) { - this.mAsyncTask.cancel(true); - this.mAsyncTask = null; - } - - // Destroy the WebView. - if (this.mWebView != null) { - this.mWebView.destroy(); - this.mWebView = null; - } - } - } - - @SuppressLint("SetJavaScriptEnabled") - private WebView createWebView(@NonNull final Context context) { - if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { - // This enables WebView debugging for all WebViews in the current process. Changes - // to this value are accepted only before the WebView process is created. - WebView.setWebContentsDebuggingEnabled(true); - } - - // The WebView is tied to the application's context to ensure we don't create an - // activity memory leak. - Context applicationContext = context.getApplicationContext(); - WebView webView = new WebView(applicationContext); - - if (this.mAppJavaBridge == null) { - this.mAppJavaBridge = new AppJavaBridge(webView); - } - - if (this.mAppJavaScriptBridge == null) { - this.mAppJavaScriptBridge = new AppJavaScriptBridge(); - } - - webView.addJavascriptInterface(this.mAppJavaScriptBridge, AppManager.JAVASCRIPT_VARIABLE_NAME); - webView.getSettings().setAllowContentAccess(false); - webView.getSettings().setAllowFileAccess(false); - webView.getSettings().setAllowFileAccessFromFileURLs(false); - webView.getSettings().setAllowUniversalAccessFromFileURLs(false); - webView.getSettings().setDisplayZoomControls(false); - webView.getSettings().setDomStorageEnabled(true); - webView.getSettings().setGeolocationEnabled(false); - webView.getSettings().setJavaScriptEnabled(true); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - webView.getSettings().setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); - } - webView.getSettings().setSaveFormData(false); - webView.getSettings().setSupportZoom(false); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - webView.setRendererPriorityPolicy(WebView.RENDERER_PRIORITY_BOUND, true); - } - webView.setWebViewClient(new WebViewClient() { - @Override - @RequiresApi(Build.VERSION_CODES.O) - public boolean onRenderProcessGone(WebView view, RenderProcessGoneDetail detail) { - boolean crashed = detail.didCrash(); - if (crashed) { - Log.e(TAG, "The WebView has crashed"); - } else { - Log.e(TAG, "The WebView has been stopped to reclaim memory"); - } - - return true; - } - - @Override - @RequiresApi(Build.VERSION_CODES.N) - public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { - if (view == null || request == null) { - return false; - } - - Uri url = request.getUrl(); - return url != null && AppManager.overrideUrlLoading(view, request.getUrl()); - } - - @Override - public boolean shouldOverrideUrlLoading(WebView view, String url) { - return !(view == null || url == null) && AppManager.overrideUrlLoading(view, Uri.parse(url)); - } - }); - - return webView; - } - - @MainThread - void loadWebView(@NonNull final AppSource appSource) { - this.mAsyncTask = null; - - if (this.mWebView != null) { - this.mWebView.loadDataWithBaseURL(AppManager.WEB_CLIENT_URL, appSource.getHtml(), "text/html", "utf-8", AppManager.WEB_CLIENT_URL); - - // Notify listeners. - this.mState = State.LOADED; - for (StateChangedListener listener : this.mListeners) { - listener.onAppStarted(); - } - } - } - - /** - * Intercepts {@see android.webkit.WebView} navigation events and, optionally, overrides the - * navigation behavior. - *

- * This currently only overrides navigation events to {@code market://*} URLs by opening the - * the Google Play Store app. - * - * @param view The {@see android.webkit.WebView}. - * @param url The {@see Uri} of the navigation event. - * @return {@code true} if the navigation behavior was overridden; {@code false} if the - * navigation behavior was not overridden. - */ - private static boolean overrideUrlLoading(@NonNull final WebView view, @NonNull final Uri url) { - final String scheme = url.getScheme(); - if (scheme != null && scheme.equalsIgnoreCase("market")) { - try { - // Create a Google Play Store app intent. - Intent intent = new Intent(Intent.ACTION_VIEW); - intent.setData(url); - - // Open the Google Play Store app. - Context context = view.getContext(); - context.startActivity(intent); - } catch (ActivityNotFoundException ignored) { - } - - // Prevent the WebView from navigating to an invalid URL. - return true; - } - - return false; - } - - @MainThread - void setOffline() { - if (this.mOffline) { - return; - } - - // Cancel any asynchronous task. - if (this.mAsyncTask != null) { - this.mAsyncTask.cancel(true); - this.mAsyncTask = null; - } - - // Notify listeners. - this.mOffline = true; - for (StateChangedListener listener : this.mListeners) { - listener.onOffline(); - } - } - - @MainThread - void setOnline() { - if (!this.mOffline) { - return; - } - - // Notify listeners. - this.mOffline = false; - for (StateChangedListener listener : this.mListeners) { - listener.onOnline(); - } - - // Start an asynchronous task to get the web client application. - if (this.mState == State.UNLOADED && this.mCacheDir != null) { - AppSourceAsyncTask asyncTask = new AppSourceAsyncTask(this, AppManager.WEB_CLIENT_URL, this.mCacheDir); - this.mAsyncTask = asyncTask; - asyncTask.execute(); - } - } - - interface StateChangedListener { - @MainThread - void onAppStarted(); - - @MainThread - void onOffline(); - - @MainThread - void onOnline(); - } - - @MainThread - void addStateChangedListener(@NonNull final StateChangedListener listener) { - if (!this.mListeners.contains(listener)) { - this.mListeners.add(listener); - } - } - - @MainThread - void removeStateChangedListener(@NonNull final StateChangedListener listener) { - this.mListeners.remove(listener); - } -} diff --git a/zapic/src/main/java/com/zapic/android/sdk/AppPageFragment.java b/zapic/src/main/java/com/zapic/android/sdk/AppPageFragment.java index 8094331..5b5e8f0 100644 --- a/zapic/src/main/java/com/zapic/android/sdk/AppPageFragment.java +++ b/zapic/src/main/java/com/zapic/android/sdk/AppPageFragment.java @@ -1,22 +1,27 @@ package com.zapic.android.sdk; +import android.app.Activity; import android.app.Fragment; +import android.os.Build; import android.os.Bundle; +import android.support.annotation.CheckResult; import android.support.annotation.MainThread; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.transition.Slide; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewManager; +import android.view.ViewParent; import android.webkit.WebView; import android.widget.FrameLayout; /** - * A {@link Fragment} that renders the web client application page. + * A {@link Fragment} that presents the Zapic JavaScript application page. *

- * Use the {@link AppPageFragment#createInstance} factory method to create instances of this - * fragment. + * Use the {@link #createInstance()} factory method to create instances of this fragment. * * @author Kyle Dodson * @since 1.0.0 @@ -29,50 +34,91 @@ public final class AppPageFragment extends Fragment { private static final String TAG = "AppPageFragment"; /** - * Creates a new instance. + * The {@link WebView}. */ + @Nullable + private WebView mWebView; + + /** + * Creates a new {@link AppPageFragment} instance. + */ + @MainThread public AppPageFragment() { + this.mWebView = null; } /** - * Creates a new instance of the {@link AppPageFragment} class. + * Creates a new {@link LoadingPageFragment} instance. * - * @return The new instance of the {@link AppPageFragment} class. + * @return The new {@link LoadingPageFragment} instance. */ + @CheckResult + @MainThread @NonNull public static AppPageFragment createInstance() { return new AppPageFragment(); } + @MainThread + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onActivityCreated"); + } + + super.onActivityCreated(savedInstanceState); + + if (this.mWebView == null) { + final Activity activity = this.getActivity(); + if (activity instanceof ZapicActivity) { + final ZapicActivity zapicActivity = (ZapicActivity) activity; + this.mWebView = zapicActivity.getWebView(); + assert this.mWebView != null : "mWebView == null"; + + final FrameLayout.LayoutParams webViewLayoutParams = new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT); + this.mWebView.setLayoutParams(webViewLayoutParams); + + View view = this.getView(); + assert view != null : "view == null"; + + final FrameLayout frameLayout = view.findViewById(R.id.fragment_page_app_container); + frameLayout.addView(this.mWebView); + } + } + } + @MainThread @Override public void onCreate(final Bundle savedInstanceState) { - Log.d(TAG, "onCreate"); + if (BuildConfig.DEBUG) { + Log.d(TAG, "onCreate"); + } + super.onCreate(savedInstanceState); - // This "recycles" the fragment instance when configuration changes occur allowing instance - // variables to be retained. Note that the fragment is detached from the old activity and - // then attached to the new activity. - this.setRetainInstance(true); + if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + final Slide slide = new Slide(); + slide.setDuration(500); + this.setEnterTransition(slide); + } } @MainThread @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { - Log.d(TAG, "onCreateView"); - final View view = inflater.inflate(R.layout.fragment_page_app, container, false); - - final AppManager appManager = AppManager.getInstance(); - final WebView webView = appManager.getWebView(); - if (webView == null) { - return view; + if (BuildConfig.DEBUG) { + Log.d(TAG, "onCreateView"); } - final FrameLayout.LayoutParams webViewLayoutParams = new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT); - webView.setLayoutParams(webViewLayoutParams); + View view = inflater.inflate(R.layout.fragment_page_app, container, false); + + if (this.mWebView != null) { + final FrameLayout.LayoutParams webViewLayoutParams = new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT); + this.mWebView.setLayoutParams(webViewLayoutParams); - final FrameLayout frameLayout = view.findViewById(R.id.fragment_page_app_container); - frameLayout.addView(webView); + final FrameLayout frameLayout = view.findViewById(R.id.fragment_page_app_container); + frameLayout.addView(this.mWebView); + } return view; } @@ -80,12 +126,17 @@ public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup @MainThread @Override public void onDestroyView() { - Log.d(TAG, "onDestroyView"); + if (BuildConfig.DEBUG) { + Log.d(TAG, "onDestroyView"); + } + + if (this.mWebView != null) { + ViewParent parent = this.mWebView.getParent(); + if (parent instanceof ViewManager) { + ((ViewManager) parent).removeView(this.mWebView); + } - final AppManager appManager = AppManager.getInstance(); - final WebView webView = appManager.getWebView(); - if (webView != null) { - ((ViewManager)webView.getParent()).removeView(webView); + this.mWebView = null; } super.onDestroyView(); diff --git a/zapic/src/main/java/com/zapic/android/sdk/AppSource.java b/zapic/src/main/java/com/zapic/android/sdk/AppSource.java index a42a3f2..37a1d33 100644 --- a/zapic/src/main/java/com/zapic/android/sdk/AppSource.java +++ b/zapic/src/main/java/com/zapic/android/sdk/AppSource.java @@ -64,7 +64,7 @@ String getHtml() { * Gets the Last-Modified date and time in milliseconds since January 1, 1970. * * @return The Last-Modified date and time in milliseconds since January 1, 1970 or {@code 0} - * if one was not returned in the HTTP response. + * if one was not returned in the HTTP response. */ long getLastModified() { return this.lastModified; diff --git a/zapic/src/main/java/com/zapic/android/sdk/AppSourceAsyncTask.java b/zapic/src/main/java/com/zapic/android/sdk/AppSourceAsyncTask.java index 5349308..9a4dd3d 100644 --- a/zapic/src/main/java/com/zapic/android/sdk/AppSourceAsyncTask.java +++ b/zapic/src/main/java/com/zapic/android/sdk/AppSourceAsyncTask.java @@ -32,7 +32,7 @@ final class AppSourceAsyncTask extends AsyncTask imple * The web client application manager. */ @NonNull - private final WeakReference mAppManager; + private final WeakReference mApp; /** * The web client application URL. @@ -49,12 +49,12 @@ final class AppSourceAsyncTask extends AsyncTask imple /** * Creates a new instance. * - * @param appManager The web client application manager. - * @param url The web client application URL. - * @param cacheDir The Android application's cache directory. + * @param app The Zapic JavaScript application. + * @param url The web client application URL. + * @param cacheDir The Android application's cache directory. * @throws IllegalArgumentException If {@code url} is invalid. */ - AppSourceAsyncTask(@NonNull final AppManager appManager, @NonNull final String url, @NonNull final File cacheDir) { + AppSourceAsyncTask(@NonNull final App app, @NonNull final String url, @NonNull final File cacheDir) { URL parsedUrl; try { parsedUrl = new URL(url); @@ -62,13 +62,14 @@ final class AppSourceAsyncTask extends AsyncTask imple throw new IllegalArgumentException("The web client application URL is invalid"); } - this.mAppManager = new WeakReference<>(appManager); + this.mApp = new WeakReference<>(app); this.mAppUrl = parsedUrl; this.mCacheDir = cacheDir; } @Nullable @Override + @WorkerThread protected AppSource doInBackground(final Void... voids) { final AppSource cachedAppSource = this.getFromCache(); if (cachedAppSource != null) { @@ -96,7 +97,7 @@ protected AppSource doInBackground(final Void... voids) { * Downloads the web client application. * * @return The web client application source and version or {@code null} if the task was - * cancelled. + * cancelled. */ @Nullable @WorkerThread @@ -109,7 +110,7 @@ private AppSource download() { try { // Connect to web server. Log.d(TAG, String.format("Downloading web client application from %s", this.mAppUrl)); - connection = (HttpURLConnection)this.mAppUrl.openConnection(); + connection = (HttpURLConnection) this.mAppUrl.openConnection(); connection.setAllowUserInteraction(false); connection.setConnectTimeout(30000); connection.setInstanceFollowRedirects(false); @@ -196,7 +197,7 @@ private AppSource download() { * Gets the web client application source and version from the cache. * * @return The web client application source and version or {@code null} if the task was - * cancelled or if the web client application source and version has not been cached. + * cancelled or if the web client application source and version has not been cached. */ @Nullable @WorkerThread @@ -241,7 +242,7 @@ private AppSource getFromCache() { * Injects the initialization script into the web client application HTML. * * @param appSource The original web client application source and version. - * @return The modified web client application source and version. + * @return The modified web client application source and version. */ @NonNull @WorkerThread @@ -257,7 +258,7 @@ private AppSource injectInitializationScript(@NonNull final AppSource appSource) "window.zapic = {" + " environment: 'webview'," + " version: 1," + - " androidVersion: " + String.valueOf(Build.VERSION.SDK_INT) + "," + + " androidVersion: '" + String.valueOf(Build.VERSION.SDK_INT) + "'," + " onLoaded: (action$, publishAction) => {" + " window.zapic.dispatch = (action) => {" + " publishAction(action)" + @@ -278,20 +279,31 @@ private AppSource injectInitializationScript(@NonNull final AppSource appSource) @MainThread @Override protected void onPostExecute(@Nullable AppSource appSource) { - if (appSource == null) { + final App app = this.mApp.get(); + if (app == null) { return; } - AppManager appManager = this.mAppManager.get(); - if (appManager != null) { - appManager.loadWebView(appSource); + if (appSource == null) { + app.loadWebViewCancelled(); + } else { + app.loadWebView(appSource); + } + } + + @MainThread + @Override + protected void onProgressUpdate(final Integer... values) { + final App app = this.mApp.get(); + if (app == null || !app.getConnected()) { + this.cancel(true); } } /** * Puts the web client application source and version in the cache. * - * @param appSource The web client application source and version. + * @param appSource The web client application source and version. */ @WorkerThread private void putInCache(@NonNull final AppSource appSource) { diff --git a/zapic/src/main/java/com/zapic/android/sdk/CacheFileUtilities.java b/zapic/src/main/java/com/zapic/android/sdk/CacheFileUtilities.java index 32a48b6..7086645 100644 --- a/zapic/src/main/java/com/zapic/android/sdk/CacheFileUtilities.java +++ b/zapic/src/main/java/com/zapic/android/sdk/CacheFileUtilities.java @@ -40,7 +40,7 @@ final class CacheFileUtilities { * * @param file The file to delete. * @param cancellationToken The cancellation token. - * @throws IOException If repeated errors occur deleting the file. + * @throws IOException If repeated errors occur deleting the file. */ @WorkerThread static void deleteFile(@NonNull final File file, @NonNull final CancellationToken cancellationToken) throws IOException { @@ -73,9 +73,9 @@ static void deleteFile(@NonNull final File file, @NonNull final CancellationToke * * @param file The file to read. * @param cancellationToken The cancellation token. - * @return The contents of the file or {@code null} if the task was cancelled - * or if the file does not exist. - * @throws IOException If repeated errors occur reading the file. + * @return The contents of the file or {@code null} if the task was cancelled or if the file + * does not exist. + * @throws IOException If repeated errors occur reading the file. */ @Nullable @WorkerThread @@ -133,8 +133,8 @@ static String readFile(@NonNull final File file, @NonNull final CancellationToke * @param file The file to write. * @param content The contents of the file. * @param cancellationToken The cancellation token. - * @throws IOException If the file cannot be created or if repeated errors occur writing - * the file. + * @throws IOException If the file cannot be created or if repeated errors occur writing the + * file. */ @WorkerThread static void writeFile(@NonNull final File file, @NonNull final String content, @NonNull final CancellationToken cancellationToken) throws IOException { diff --git a/zapic/src/main/java/com/zapic/android/sdk/CancellationToken.java b/zapic/src/main/java/com/zapic/android/sdk/CancellationToken.java index 660f4af..3997003 100644 --- a/zapic/src/main/java/com/zapic/android/sdk/CancellationToken.java +++ b/zapic/src/main/java/com/zapic/android/sdk/CancellationToken.java @@ -11,7 +11,7 @@ interface CancellationToken { * Gets a value indicating whether the signal has been set. * * @return {@code true} if the signal has been set; {@code false} if the signal has not been - * set. + * set. */ boolean isCancelled(); } diff --git a/zapic/src/main/java/com/zapic/android/sdk/ConnectivityBroadcastReceiver.java b/zapic/src/main/java/com/zapic/android/sdk/ConnectivityBroadcastReceiver.java new file mode 100644 index 0000000..b9ce5d4 --- /dev/null +++ b/zapic/src/main/java/com/zapic/android/sdk/ConnectivityBroadcastReceiver.java @@ -0,0 +1,63 @@ +package com.zapic.android.sdk; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.net.ConnectivityManager; +import android.support.annotation.AnyThread; +import android.support.annotation.MainThread; +import android.support.annotation.NonNull; + +/** + * A {@link ConnectivityManager} broadcast receiver that relays network connectivity changes. + * + * @author Kyle Dodson + * @since 1.0.0 + */ +final class ConnectivityBroadcastReceiver extends BroadcastReceiver { + /** + * The {@link ConnectivityListener} to notify on network connectivity changes. + */ + @NonNull + private final ConnectivityListener mConnectivityListener; + + /** + * Creates a new {@link ConnectivityBroadcastReceiver} instance. + * + * @param connectivityListener The {@link ConnectivityListener} to notify on network + * connectivity changes. + */ + @AnyThread + ConnectivityBroadcastReceiver(@NonNull final ConnectivityListener connectivityListener) { + this.mConnectivityListener = connectivityListener; + } + + /** + * Notifies the listener of connectivity changes. + * + * @param context The application context or an activity context. + */ + @MainThread + void notify(@NonNull final Context context) { + final ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (connectivityManager == null) { + return; + } + + if (ConnectivityManagerUtilities.isConnected(connectivityManager)) { + this.mConnectivityListener.onConnected(); + } else { + this.mConnectivityListener.onDisconnected(); + } + } + + @MainThread + @Override + public void onReceive(Context context, Intent intent) { + if (context == null) { + throw new IllegalArgumentException("context must not be null"); + } + + this.notify(context); + } +} diff --git a/zapic/src/main/java/com/zapic/android/sdk/ConnectivityListener.java b/zapic/src/main/java/com/zapic/android/sdk/ConnectivityListener.java new file mode 100644 index 0000000..5cd8634 --- /dev/null +++ b/zapic/src/main/java/com/zapic/android/sdk/ConnectivityListener.java @@ -0,0 +1,29 @@ +package com.zapic.android.sdk; + +import android.support.annotation.MainThread; + +/** + * Represents a network connectivity listener. + * + * @author Kyle Dodson + * @since 1.0.0 + */ +interface ConnectivityListener { + /** + * Called when the application gains network connectivity. + *

+ * This normally indicates the device gained network connectivity. However, it may also indicate + * data saver has been disabled. + */ + @MainThread + void onConnected(); + + /** + * Called when the application loses network connectivity. + *

+ * This normally indicates the device lost network connectivity. However, it may also indicate + * data saver has been enabled. + */ + @MainThread + void onDisconnected(); +} diff --git a/zapic/src/main/java/com/zapic/android/sdk/ConnectivityManagerUtilities.java b/zapic/src/main/java/com/zapic/android/sdk/ConnectivityManagerUtilities.java new file mode 100644 index 0000000..6906625 --- /dev/null +++ b/zapic/src/main/java/com/zapic/android/sdk/ConnectivityManagerUtilities.java @@ -0,0 +1,73 @@ +package com.zapic.android.sdk; + +import android.app.Activity; +import android.content.IntentFilter; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.support.annotation.AnyThread; +import android.support.annotation.CheckResult; +import android.support.annotation.NonNull; + +/** + * Provides {@link ConnectivityManager} utility methods. + * + * @author Kyle Dodson + * @since 1.0.0 + */ +final class ConnectivityManagerUtilities { + /** + * Prevents creating a new {@link ConnectivityManagerUtilities} instance. + */ + @AnyThread + private ConnectivityManagerUtilities() { + } + + /** + * Gets a value indicating whether the application has network connectivity. + *

+ * This normally indicates whether the device has network connectivity. However, it may also + * indicate whether data saver has been enabled. + * + * @param connectivityManager The Android device's connectivity manager. + * @return {@code true} if the Android device is connected to a network; {@code false} if the + * Android device is not connected to a network. + */ + @AnyThread + @CheckResult + static boolean isConnected(@NonNull ConnectivityManager connectivityManager) { + final NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo(); + return networkInfo != null && networkInfo.isConnected(); + } + + /** + * Registers and returns a {@link ConnectivityBroadcastReceiver} using the specified activity's + * context. This should be unregistered using {@link #unregisterConnectivityBroadcastReceiver}. + * + * @param activity The activity. + * @param listener The network connectivity listener. + * @return The {@link ConnectivityBroadcastReceiver}. + * @see #unregisterConnectivityBroadcastReceiver + */ + @AnyThread + @NonNull + static ConnectivityBroadcastReceiver registerConnectivityBroadcastReceiver(@NonNull final Activity activity, @NonNull final ConnectivityListener listener) { + final IntentFilter connectivityFilter = new IntentFilter(); + connectivityFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION); + + final ConnectivityBroadcastReceiver broadcastReceiver = new ConnectivityBroadcastReceiver(listener); + activity.registerReceiver(broadcastReceiver, connectivityFilter); + + return broadcastReceiver; + } + + /** + * Unregisters a {@link ConnectivityBroadcastReceiver} using the specified activity's context. + * + * @param activity The activity. + * @param broadcastReceiver The {@link ConnectivityBroadcastReceiver}. + */ + @AnyThread + static void unregisterConnectivityBroadcastReceiver(@NonNull final Activity activity, @NonNull final ConnectivityBroadcastReceiver broadcastReceiver) { + activity.unregisterReceiver(broadcastReceiver); + } +} diff --git a/zapic/src/main/java/com/zapic/android/sdk/ConnectivityReceiver.java b/zapic/src/main/java/com/zapic/android/sdk/ConnectivityReceiver.java deleted file mode 100644 index 496f737..0000000 --- a/zapic/src/main/java/com/zapic/android/sdk/ConnectivityReceiver.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.zapic.android.sdk; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; -import android.support.annotation.NonNull; - -final class ConnectivityReceiver extends BroadcastReceiver { - @NonNull - private final AppManager mAppManager; - - ConnectivityReceiver(@NonNull final AppManager appManager) { - this.mAppManager = appManager; - } - - @Override - public void onReceive(Context context, Intent intent) { - final ConnectivityManager connectivityManager = (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE); - if (connectivityManager == null) { - return; - } - - final NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo(); - if (networkInfo != null && networkInfo.isConnected()) { - this.mAppManager.setOnline(); - } else { - this.mAppManager.setOffline(); - } - } -} diff --git a/zapic/src/main/java/com/zapic/android/sdk/LoadingPageFragment.java b/zapic/src/main/java/com/zapic/android/sdk/LoadingPageFragment.java index 41c65b8..2df44f6 100644 --- a/zapic/src/main/java/com/zapic/android/sdk/LoadingPageFragment.java +++ b/zapic/src/main/java/com/zapic/android/sdk/LoadingPageFragment.java @@ -2,27 +2,50 @@ import android.app.Fragment; import android.content.Context; +import android.os.Build; import android.os.Bundle; +import android.support.annotation.CheckResult; +import android.support.annotation.MainThread; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.transition.Slide; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageButton; /** - * A {@link Fragment} that renders a loading page. + * A {@link Fragment} that presents a loading page. *

- * Use the {@link LoadingPageFragment#createInstance} factory method to create instances of this - * fragment. + * Use the {@link #createInstance()} factory method to create instances of this fragment. *

- * Activities that include this fragment must implement the - * {@link LoadingPageFragment.InteractionListener} interface. + * Activities that present this fragment must implement the {@link InteractionListener} interface. * * @author Kyle Dodson * @since 1.0.0 */ public final class LoadingPageFragment extends Fragment { + /** + * Handles {@link LoadingPageFragment} interaction events. + * + * @author Kyle Dodson + * @since 1.0.0 + */ + public interface InteractionListener { + /** + * Closes the page. + */ + @MainThread + void close(); + } + + /** + * The tag used to identify log messages. + */ + @NonNull + private static final String TAG = "LoadingPageFragment"; + /** * The interaction listener. */ @@ -30,39 +53,85 @@ public final class LoadingPageFragment extends Fragment { private InteractionListener mListener; /** - * Creates a new instance. + * Creates a new {@link LoadingPageFragment} instance. */ + @MainThread public LoadingPageFragment() { } /** - * Creates a new instance of the {@link LoadingPageFragment} class. + * Creates a new {@link LoadingPageFragment} instance. * - * @return The new instance of the {@link LoadingPageFragment} class. + * @return The new {@link LoadingPageFragment} instance. */ + @CheckResult + @MainThread @NonNull public static LoadingPageFragment createInstance() { return new LoadingPageFragment(); } + @MainThread @Override public void onAttach(final Context context) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onAttach"); + } + super.onAttach(context); if (context instanceof InteractionListener) { - this.mListener = (InteractionListener)context; + this.mListener = (InteractionListener) context; } else { throw new RuntimeException(String.format("%s must implement InteractionListener", context.toString())); } } + @MainThread + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onCreate"); + } + + super.onCreate(savedInstanceState); + + if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + final Slide slide = new Slide(); + slide.setDuration(500); + this.setEnterTransition(slide); + } + } + + @MainThread @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onCreateView"); + } + return inflater.inflate(R.layout.fragment_page_loading, container, false); } + @MainThread + @Override + public void onDetach() { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onDetach"); + } + + super.onDetach(); + + this.mListener = null; + } + + @MainThread @Override public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onViewCreated"); + } + super.onViewCreated(view, savedInstanceState); ImageButton closeButton = view.findViewById(R.id.fragment_page_loading_close); @@ -71,27 +140,9 @@ public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { public void onClick(View v) { InteractionListener listener = LoadingPageFragment.this.mListener; if (listener != null) { - listener.onClose(); + listener.close(); } } }); } - - @Override - public void onDetach() { - super.onDetach(); - - this.mListener = null; - } - - /** - * Activities that include {@link LoadingPageFragment} must implement this interface to handle - * events. - */ - public interface InteractionListener { - /** - * Called when the user requests that the page be closed. - */ - void onClose(); - } } diff --git a/zapic/src/main/java/com/zapic/android/sdk/OfflinePageFragment.java b/zapic/src/main/java/com/zapic/android/sdk/OfflinePageFragment.java index a1724c4..c112997 100644 --- a/zapic/src/main/java/com/zapic/android/sdk/OfflinePageFragment.java +++ b/zapic/src/main/java/com/zapic/android/sdk/OfflinePageFragment.java @@ -2,27 +2,50 @@ import android.app.Fragment; import android.content.Context; +import android.os.Build; import android.os.Bundle; +import android.support.annotation.CheckResult; +import android.support.annotation.MainThread; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.transition.Slide; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageButton; /** - * A {@link Fragment} that renders an offline page. + * A {@link Fragment} that presents an offline page. *

- * Use the {@link OfflinePageFragment#createInstance} factory method to create instances of this - * fragment. + * Use the {@link #createInstance()} factory method to create instances of this fragment. *

- * Activities that include this fragment must implement the - * {@link OfflinePageFragment.InteractionListener} interface. + * Activities that present this fragment must implement the {@link InteractionListener} interface. * * @author Kyle Dodson * @since 1.0.0 */ public final class OfflinePageFragment extends Fragment { + /** + * Handles {@link OfflinePageFragment} interaction events. + * + * @author Kyle Dodson + * @since 1.0.0 + */ + public interface InteractionListener { + /** + * Closes the page. + */ + @MainThread + void close(); + } + + /** + * The tag used to identify log messages. + */ + @NonNull + private static final String TAG = "OfflinePageFragment"; + /** * The interaction listener. */ @@ -30,39 +53,85 @@ public final class OfflinePageFragment extends Fragment { private InteractionListener mListener; /** - * Creates a new instance. + * Creates a new {@link OfflinePageFragment} instance. */ + @MainThread public OfflinePageFragment() { } /** - * Creates a new instance of the {@link OfflinePageFragment} class. + * Creates a new {@link OfflinePageFragment} instance. * - * @return The new instance of the {@link OfflinePageFragment} class. + * @return The new {@link OfflinePageFragment} instance. */ + @CheckResult + @MainThread @NonNull public static OfflinePageFragment createInstance() { return new OfflinePageFragment(); } + @MainThread @Override public void onAttach(final Context context) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onAttach"); + } + super.onAttach(context); if (context instanceof InteractionListener) { - this.mListener = (InteractionListener)context; + this.mListener = (InteractionListener) context; } else { throw new RuntimeException(String.format("%s must implement InteractionListener", context.toString())); } } + @MainThread + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onCreate"); + } + + super.onCreate(savedInstanceState); + + if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + final Slide slide = new Slide(); + slide.setDuration(500); + this.setEnterTransition(slide); + } + } + + @MainThread @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onCreateView"); + } + return inflater.inflate(R.layout.fragment_page_offline, container, false); } + @MainThread + @Override + public void onDetach() { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onDetach"); + } + + super.onDetach(); + + this.mListener = null; + } + + @MainThread @Override public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onViewCreated"); + } + super.onViewCreated(view, savedInstanceState); ImageButton closeButton = view.findViewById(R.id.fragment_page_offline_close); @@ -71,27 +140,9 @@ public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { public void onClick(View v) { InteractionListener listener = OfflinePageFragment.this.mListener; if (listener != null) { - listener.onClose(); + listener.close(); } } }); } - - @Override - public void onDetach() { - super.onDetach(); - - this.mListener = null; - } - - /** - * Activities that include {@link OfflinePageFragment} must implement this interface to handle - * events. - */ - public interface InteractionListener { - /** - * Called when the user requests that the page be closed. - */ - void onClose(); - } } diff --git a/zapic/src/main/java/com/zapic/android/sdk/Zapic.java b/zapic/src/main/java/com/zapic/android/sdk/Zapic.java index fd4a1fc..8104dde 100644 --- a/zapic/src/main/java/com/zapic/android/sdk/Zapic.java +++ b/zapic/src/main/java/com/zapic/android/sdk/Zapic.java @@ -4,33 +4,37 @@ import android.app.Fragment; import android.app.FragmentManager; import android.content.Intent; +import android.support.annotation.AnyThread; import android.support.annotation.CheckResult; import android.support.annotation.MainThread; import android.support.annotation.NonNull; import android.support.annotation.Nullable; -import android.util.Log; import android.webkit.WebView; import org.json.JSONException; import org.json.JSONObject; /** - * Provides static methods to manage the Zapic application. + * Provides static methods to manage and interact with Zapic. *

- * The Zapic application runs in a {@link WebView}. Generally, the {@link WebView} runs for the - * lifetime of the Android application. While the game is in focus, the {@link WebView} runs in - * the background (managed by a {@link ZapicFragment} attached to the game's activity) and the Zapic - * application processes events and receives notifications. When the player shifts focus to the - * Zapic application, the {@link WebView} moves to the foreground and is presented by a - * {@link ZapicActivity}. When the player shifts focus back to the game, the {@link ZapicActivity} - * finishes and the {@link WebView} again runs in the background (again managed by a - * {@link ZapicFragment} attached to the game's activity). + * It is the responsibility of the game's activity (or game's activities if there are multiple) to + * call the {@link #attachFragment(Activity)} method during its {@code onCreate} lifecycle method. + * This method creates and attaches a non-UI fragment, {@link ZapicFragment}, to the activity. The + * {@link ZapicFragment} downloads and runs the Zapic JavaScript application. If a game's activity + * does not call {@link #attachFragment(Activity)}, the Zapic JavaScript application may be + * garbage collected and the current player's session reset. It is also the responsibility of the + * game's activity to call the {@link #show(Activity)} method after the player interacts with a + * Zapic-branded button visible from the game's main menu (requirements are outlined in the + * Terms of Use). *

- * It is the responsibility of the game's activity (or activities if there are multiple) to call - * {@link Zapic#attachFragment(Activity)} during its {@code onCreate} lifecycle callback. This will - * create and attach a {@link ZapicFragment} to the game's activity. If the game's activity does - * not call {@link Zapic#attachFragment(Activity)}, the {@link WebView} may be garbage - * collected by the system. + * The Zapic JavaScript application runs in a {@link WebView}. Generally, the {@link WebView} runs + * for the lifetime of the Android application. While the game is in focus, the {@link WebView} runs + * in the background (managed by one or more {@link ZapicFragment}s attached to the game's + * activities). The Zapic JavaScript application processes events and receives notifications while + * running in the background. When the player shifts focus to Zapic, the {@link WebView} moves to + * the foreground and is presented by a {@link ZapicActivity}. When the player shifts focus back to + * the game, the {@link ZapicActivity} finishes and the {@link WebView} returns to the background + * (again managed by one or more {@link ZapicFragment}s attached to the game's activities). * * @author Kyle Dodson * @since 1.0.0 @@ -46,10 +50,10 @@ public final class Zapic { * The player's unique identifier. */ @Nullable - private static String playerId; + private static volatile String playerId; /** - * Prevents creating a new instance. + * Prevents creating a new {@link Zapic} instance. */ private Zapic() { } @@ -59,11 +63,11 @@ private Zapic() { *

* This must be called from the Android application's main thread. * - * @param gameActivity The game's activity. + * @param gameActivity The game's activity. * @throws IllegalArgumentException If {@code gameActivity} is {@code null}. */ @MainThread - @SuppressWarnings("UnusedDeclaration") // documented as public API + @SuppressWarnings({"UnusedDeclaration", "WeakerAccess"}) // documented as public API public static void attachFragment(@Nullable final Activity gameActivity) { if (gameActivity == null) { throw new IllegalArgumentException("gameActivity must not be null"); @@ -80,14 +84,13 @@ public static void attachFragment(@Nullable final Activity gameActivity) { * Detaches a {@link ZapicFragment} from the specified game's activity. *

* This must be called from the Android application's main thread. Generally, this should not be - * called. The fragment is automatically destroyed and garbage collected when the game's - * activity finishes. + * called. The fragment is automatically destroyed when the game's activity finishes. * - * @param gameActivity The game's activity. + * @param gameActivity The game's activity. * @throws IllegalArgumentException If {@code gameActivity} is {@code null}. */ @MainThread - @SuppressWarnings("UnusedDeclaration") // documented as public API + @SuppressWarnings({"UnusedDeclaration", "WeakerAccess"}) // documented as public API public static void detachFragment(@Nullable final Activity gameActivity) { if (gameActivity == null) { throw new IllegalArgumentException("gameActivity must not be null"); @@ -100,14 +103,30 @@ public static void detachFragment(@Nullable final Activity gameActivity) { } } + /** + * Gets a {@link ZapicFragment} from the specified game's activity. + * + * @param gameActivity The game's activity. + * @return The {@link ZapicFragment} or {@code null} if one has not been attached. + */ + @CheckResult + @MainThread + @Nullable + static ZapicFragment getFragment(@NonNull final Activity gameActivity) { + final FragmentManager manager = gameActivity.getFragmentManager(); + final Fragment fragment = manager.findFragmentByTag(Zapic.FRAGMENT_TAG); + return fragment instanceof ZapicFragment ? (ZapicFragment) fragment : null; + } + /** * Gets the player's unique identifier. * * @return The player's unique identifier or {@code null} if the player has not logged in. */ + @AnyThread @CheckResult @Nullable - @SuppressWarnings("UnusedDeclaration") // documented as public API + @SuppressWarnings({"UnusedDeclaration", "WeakerAccess"}) // documented as public API public static String getPlayerId() { return Zapic.playerId; } @@ -117,25 +136,26 @@ public static String getPlayerId() { * * @param playerId The player's unique identifier or {@code null} if the player has logged out. */ + @AnyThread static void setPlayerId(@Nullable final String playerId) { Zapic.playerId = playerId; } /** - * Shows the Zapic application. + * Shows the Zapic JavaScript application and opens the default page. *

* This must be called from the Android application's main thread. This starts a - * {@link ZapicActivity} and opens the default Zapic application page. + * {@link ZapicActivity} and opens the default Zapic JavaScript application page. *

* This must be called from the Zapic-branded button visible from the game's main menu * (requirements are outlined in the Terms of Use). - * This may be called from other game elements or events. + * This may be called from other game elements or user interactions. * - * @param gameActivity The game's activity. + * @param gameActivity The game's activity. * @throws IllegalArgumentException If {@code gameActivity} is {@code null}. */ @MainThread - @SuppressWarnings("UnusedDeclaration") // documented as public API + @SuppressWarnings({"UnusedDeclaration", "WeakerAccess"}) // documented as public API public static void show(@Nullable final Activity gameActivity) { if (gameActivity == null) { throw new IllegalArgumentException("gameActivity must not be null"); @@ -146,22 +166,22 @@ public static void show(@Nullable final Activity gameActivity) { } /** - * Shows the Zapic application and opens the specified Zapic application page. + * Shows the Zapic JavaScript application and opens the specified page. *

* This must be called from the Android application's main thread. This starts a - * {@link ZapicActivity} and opens the default Zapic application page. + * {@link ZapicActivity} and opens the specified Zapic JavaScript application page. *

* This must not be called from the Zapic-branded button visible from the game's main - * menu (see {@link Zapic#show(Activity)}. This may be called from other game elements or - * events to refer players to more specific pages. + * menu (see {@link #show(Activity)}. This may be called from other game elements or + * user interactions to navigate players to specific pages. * - * @param gameActivity The game's activity. - * @param page The Zapic application page to open. + * @param gameActivity The game's activity. + * @param page The Zapic JavaScript application page to open. * @throws IllegalArgumentException If {@code gameActivity} or {@code page} are {@code null}. */ @MainThread - @SuppressWarnings("UnusedDeclaration") // documented as public API - public static void show(@Nullable final Activity gameActivity, @Nullable final String page) { + @SuppressWarnings({"UnusedDeclaration", "WeakerAccess"}) // documented as public API + public static void show(@Nullable final Activity gameActivity, @SuppressWarnings("SameParameterValue") @Nullable final String page) { if (gameActivity == null) { throw new IllegalArgumentException("gameActivity must not be null"); } @@ -177,15 +197,17 @@ public static void show(@Nullable final Activity gameActivity, @Nullable final S /** * Submits a gameplay event to Zapic. * - * @param gameActivity The game's activity. - * @param parameters The JSON-encoded object of gameplay event parameters. + * @param gameActivity The game's activity. + * @param parameters The JSON-encoded object of gameplay event parameters. * @throws IllegalArgumentException If {@code gameActivity} or {@code parameters} are - * {@code null} or if {@code parameters} is not a valid JSON - * object. + * {@code null}; if {@code gameActivity} does not have a + * {@link ZapicFragment} attached (see + * {@link #attachFragment(Activity)}; if {@code parameters} is + * not a valid JSON object. */ @MainThread - @SuppressWarnings("UnusedDeclaration") // documented as public API - public static void submitEvent(@Nullable Activity gameActivity, @Nullable String parameters) { + @SuppressWarnings({"UnusedDeclaration", "WeakerAccess"}) // documented as public API + public static void submitEvent(@Nullable final Activity gameActivity, @Nullable final String parameters) { if (gameActivity == null) { throw new IllegalArgumentException("gameActivity must not be null"); } @@ -194,6 +216,16 @@ public static void submitEvent(@Nullable Activity gameActivity, @Nullable String throw new IllegalArgumentException("parameters must not be null"); } + final ZapicFragment fragment = Zapic.getFragment(gameActivity); + if (fragment == null) { + throw new IllegalArgumentException("gameActivity must have a ZapicFragment attached"); + } + + final App app = fragment.getApp(); + if (app == null) { + throw new IllegalArgumentException("gameActivity must have a ZapicFragment attached"); + } + JSONObject payload; try { payload = new JSONObject(parameters); @@ -204,11 +236,10 @@ public static void submitEvent(@Nullable Activity gameActivity, @Nullable String JSONObject gameplayEvent; try { gameplayEvent = new JSONObject().put("type", "gameplay").put("payload", payload); - } catch (JSONException e) { - throw new IllegalArgumentException("payload must be a valid JSON object"); + } catch (JSONException ignored) { + return; } - // TODO: Submit event. - Log.d("Zapic", String.format("Gameplay Event: %s", gameplayEvent)); + app.submitEvent(gameplayEvent); } } diff --git a/zapic/src/main/java/com/zapic/android/sdk/ZapicActivity.java b/zapic/src/main/java/com/zapic/android/sdk/ZapicActivity.java index 9724625..19e53c5 100644 --- a/zapic/src/main/java/com/zapic/android/sdk/ZapicActivity.java +++ b/zapic/src/main/java/com/zapic/android/sdk/ZapicActivity.java @@ -4,45 +4,41 @@ import android.app.Fragment; import android.app.FragmentManager; import android.content.Intent; +import android.os.Build; import android.os.Bundle; import android.support.annotation.CheckResult; import android.support.annotation.MainThread; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.transition.Slide; import android.util.Log; -import android.view.View; import android.webkit.WebView; /** - * An {@link Activity} that presents the Zapic application. + * An {@link Activity} that presents the Zapic JavaScript application in the foreground. *

- * Use the {@link ZapicActivity#createIntent} factory method to create an intent that starts this - * activity and opens a specific Zapic application page. + * Use {@link Zapic#show(Activity)} or {@link Zapic#show(Activity, String)} to start instances of + * this activity. Alternatively, use the {@link #createIntent(Activity)} or + * {@link #createIntent(Activity, String)} factory methods to create an intent that starts instances + * of this activity. *

- * The Zapic application runs in a {@link WebView}. Generally, the {@link WebView} runs for the - * lifetime of the Android application. While the game is in focus, the {@link WebView} runs in - * the background (managed by a {@link ZapicFragment} attached to the game's activity) and the Zapic - * application processes events and receives notifications. When the player shifts focus to the - * Zapic application, the {@link WebView} moves to the foreground and is presented by a - * {@link ZapicActivity}. When the player shifts focus back to the game, the {@link ZapicActivity} - * finishes and the {@link WebView} again runs in the background (again managed by a - * {@link ZapicFragment} attached to the game's activity). + * The Zapic JavaScript application runs in a {@link WebView}. Generally, the {@link WebView} runs + * for the lifetime of the Android application. While the game is in focus, the {@link WebView} runs + * in the background (managed by one or more {@link ZapicFragment}s attached to the game's + * activities). The Zapic JavaScript application processes events and receives notifications while + * running in the background. When the player shifts focus to Zapic, the {@link WebView} moves to + * the foreground and is presented by a {@link ZapicActivity}. When the player shifts focus back to + * the game, the {@link ZapicActivity} finishes and the {@link WebView} returns to the background + * (again managed by one or more {@link ZapicFragment}s attached to the game's activities). * * @author Kyle Dodson * @since 1.0.0 */ public final class ZapicActivity extends Activity implements - AppManager.StateChangedListener, LoadingPageFragment.InteractionListener, OfflinePageFragment.InteractionListener { /** - * The fragment tag used to identify {@link ZapicFragment} instances. - */ - @NonNull - private static final String FRAGMENT_TAG = "Zapic"; - - /** - * The {@link Intent} parameter that identifies the Zapic application page to open. + * The {@link Intent} parameter that identifies the Zapic JavaScript application page to open. */ @NonNull private static final String PAGE_PARAM = "page"; @@ -51,28 +47,29 @@ public final class ZapicActivity extends Activity implements * The tag used to identify log messages. */ @NonNull - private static final String TAG = "ZapicFragment"; + private static final String TAG = "ZapicActivity"; /** - * The Zapic application manager. + * The {@link WebView}. */ @Nullable - private AppManager mAppManager; + private WebView mWebView; /** - * Creates a new instance. + * Creates a new {@link ZapicActivity} instance */ public ZapicActivity() { - this.mAppManager = null; + this.mWebView = null; } /** * Creates an {@link Intent} that starts a {@link ZapicActivity} and opens the default Zapic - * application page. + * JavaScript application page. * * @param gameActivity The game's activity. - * @return The {@link Intent}. + * @return The {@link Intent}. */ + @MainThread @CheckResult @NonNull public static Intent createIntent(@NonNull final Activity gameActivity) { @@ -81,149 +78,176 @@ public static Intent createIntent(@NonNull final Activity gameActivity) { /** * Creates an {@link Intent} that starts a {@link ZapicActivity} and opens the specified Zapic - * application page. + * JavaScript application page. * * @param gameActivity The game's activity. - * @param page The Zapic application page to open. - * @return The {@link Intent}. + * @param page The Zapic JavaScript application page to open. + * @return The {@link Intent}. */ + @MainThread @CheckResult @NonNull public static Intent createIntent(@NonNull final Activity gameActivity, @NonNull final String page) { final Intent intent = new Intent(gameActivity, ZapicActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); intent.putExtra(ZapicActivity.PAGE_PARAM, page); return intent; } + @Override + public void close() { + this.finish(); + } + +// TODO: Determine if the following is required when we use a fullscreen theme. +// /** +// * Enables an immersive full-screen mode. This hides the system status and navigation bars until +// * the user swipes in from the edges of the screen. +// */ +// @MainThread +// private void enableImmersiveFullScreenMode() { +// this.getWindow().getDecorView().setSystemUiVisibility( +// View.SYSTEM_UI_FLAG_FULLSCREEN +// | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION +// | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY +// | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN +// | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION +// | View.SYSTEM_UI_FLAG_LAYOUT_STABLE +// | View.SYSTEM_UI_FLAG_LOW_PROFILE); +// } + /** - * Enables an immersive full-screen mode. This hides the system status and navigation bars until - * the user swipes in from the edges of the screen. + * Gets the {@link WebView}. + * + * @return The {@link WebView}. */ + @CheckResult @MainThread - private void enableImmersiveFullScreenMode() { - this.getWindow().getDecorView().setSystemUiVisibility( - View.SYSTEM_UI_FLAG_FULLSCREEN - | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_LAYOUT_STABLE - | View.SYSTEM_UI_FLAG_LOW_PROFILE); + @Nullable + WebView getWebView() { + return this.mWebView; } @MainThread @Override - public void onAppStarted() { - assert this.mAppManager != null : "mAppManager == null"; - - // TODO: Show loading page, dispatch OPEN_PAGE, wait for PAGE_READY. - final FragmentManager fragmentManager = this.getFragmentManager(); - final Fragment currentFragment = fragmentManager.findFragmentById(R.id.activity_zapic_container); - if (!(currentFragment instanceof AppPageFragment)) { - fragmentManager.beginTransaction().add(R.id.activity_zapic_container, AppPageFragment.createInstance()).commit(); + protected void onCreate(final Bundle savedInstanceState) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onCreate"); } - } - @Override - public void onOffline() { - assert this.mAppManager != null : "mAppManager == null"; - if (this.mAppManager.isAppStarted()) { - return; - } + super.onCreate(savedInstanceState); - final FragmentManager fragmentManager = this.getFragmentManager(); - final Fragment currentFragment = fragmentManager.findFragmentById(R.id.activity_zapic_container); - if (!(currentFragment instanceof OfflinePageFragment)) { - fragmentManager.beginTransaction().add(R.id.activity_zapic_container, OfflinePageFragment.createInstance()).commit(); - } - } +// TODO: Determine if the following is required when we use a fullscreen theme. +// // Hide the system status and navigation bars. +// this.enableImmersiveFullScreenMode(); - @Override - public void onOnline() { - assert this.mAppManager != null : "mAppManager == null"; - if (this.mAppManager.isAppStarted()) { - return; + this.setContentView(R.layout.activity_zapic); + if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + final Slide slide = new Slide(); + slide.setDuration(500); + this.getWindow().setEnterTransition(slide); } - final FragmentManager fragmentManager = this.getFragmentManager(); - final Fragment currentFragment = fragmentManager.findFragmentById(R.id.activity_zapic_container); - if (!(currentFragment instanceof LoadingPageFragment)) { - fragmentManager.beginTransaction().add(R.id.activity_zapic_container, LoadingPageFragment.createInstance()).commit(); + // Attach Zapic. + Zapic.attachFragment(this); + this.getFragmentManager().executePendingTransactions(); + + ZapicFragment fragment = Zapic.getFragment(this); + assert fragment != null : "fragment == null"; + App app = fragment.getApp(); + assert app != null : "app == null"; + this.mWebView = app.getWebView(); + assert this.mWebView != null : "mWebView == null"; + + switch (app.getState()) { + case READY: + this.openAppPage(); + break; + case STARTED: + this.openLoadingPage(); + String page = this.getIntent().getStringExtra("page"); + if (page == null) { + page = "default"; + } + + final String escapedPage = page.replace("'", "\\'"); + this.mWebView.evaluateJavascript("window.zapic.dispatch({ type: 'OPEN_PAGE', payload: '" + escapedPage + "' })", null); + break; + default: + if (app.getConnected()) { + this.openLoadingPage(); + } else { + this.openOfflinePage(); + } } } @MainThread @Override - public void onClose() { - this.finish(); - } - - @MainThread - @Override - protected void onCreate(final Bundle savedInstanceState) { - Log.d(TAG, "onCreate"); - super.onCreate(savedInstanceState); - - // Hide the system status and navigation bars. - this.enableImmersiveFullScreenMode(); - - // Get a reference to the AppManager (to keep it from being garbage collected). - if (this.mAppManager == null) { - this.mAppManager = AppManager.getInstance(); - } - - // Listen to application state changes. - this.mAppManager.addStateChangedListener(this); - - // Render the page. - this.setContentView(R.layout.activity_zapic); - final Fragment fragment; - if (this.mAppManager.isAppStarted()) { - // TODO: Show loading page, dispatch OPEN_PAGE, wait for PAGE_READY. - fragment = AppPageFragment.createInstance(); - } else if (this.mAppManager.isOffline()) { - fragment = OfflinePageFragment.createInstance(); - } else { - fragment = LoadingPageFragment.createInstance(); + protected void onNewIntent(final Intent intent) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onNewIntent"); } - final FragmentManager fragmentManager = this.getFragmentManager(); - fragmentManager.beginTransaction().add(R.id.activity_zapic_container, fragment).commit(); - fragmentManager.beginTransaction().add(ZapicFragment.createInstance(), ZapicActivity.FRAGMENT_TAG).commit(); + super.onNewIntent(intent); + this.setIntent(intent); } - @MainThread - @Override - public void onDestroy() { - Log.d(TAG, "onDestroy"); - super.onDestroy(); +// TODO: Determine if the following is required when we use a fullscreen theme. +// @MainThread +// @Override +// public void onWindowFocusChanged(final boolean hasFocus) { +// if (BuildConfig.DEBUG) { +// Log.d(TAG, "onWindowFocusChanged"); +// } +// +// super.onWindowFocusChanged(hasFocus); +// +// // Hide the system status and navigation bars. +// if (hasFocus) { +// this.enableImmersiveFullScreenMode(); +// } +// } - // Destroy the WebView (if this is the last view). - assert this.mAppManager != null : "mAppManager == null"; - this.mAppManager.removeStateChangedListener(this); - this.mAppManager = null; + /** + * Opens the Zapic JavaScript application page. + */ + @MainThread + void openAppPage() { + final FragmentManager fragmentManager = this.getFragmentManager(); + final Fragment currentFragment = fragmentManager.findFragmentById(R.id.activity_zapic_container); + if (currentFragment == null) { + fragmentManager.beginTransaction().add(R.id.activity_zapic_container, AppPageFragment.createInstance()).commit(); + } else if (!(currentFragment instanceof AppPageFragment)) { + fragmentManager.beginTransaction().replace(R.id.activity_zapic_container, AppPageFragment.createInstance()).commit(); + } } + /** + * Opens the loading page. + */ @MainThread - @Override - protected void onNewIntent(final Intent intent) { - Log.d(TAG, "onNewIntent"); - super.onNewIntent(intent); - this.setIntent(intent); - - // TODO: If the app is loaded, navigate to the requested page. If the app is not loaded, - // ensure we navigate to the newly requested page. + void openLoadingPage() { + final FragmentManager fragmentManager = this.getFragmentManager(); + final Fragment currentFragment = fragmentManager.findFragmentById(R.id.activity_zapic_container); + if (currentFragment == null) { + fragmentManager.beginTransaction().add(R.id.activity_zapic_container, LoadingPageFragment.createInstance()).commit(); + } else if (!(currentFragment instanceof LoadingPageFragment)) { + fragmentManager.beginTransaction().replace(R.id.activity_zapic_container, LoadingPageFragment.createInstance()).commit(); + } } + /** + * Opens the offline page. + */ @MainThread - @Override - public void onWindowFocusChanged(final boolean hasFocus) { - Log.d(TAG, "onWindowFocusChanged"); - super.onWindowFocusChanged(hasFocus); - - // Hide the system status and navigation bars. - if (hasFocus) { - this.enableImmersiveFullScreenMode(); + void openOfflinePage() { + final FragmentManager fragmentManager = this.getFragmentManager(); + final Fragment currentFragment = fragmentManager.findFragmentById(R.id.activity_zapic_container); + if (currentFragment == null) { + fragmentManager.beginTransaction().add(R.id.activity_zapic_container, OfflinePageFragment.createInstance()).commit(); + } else if (!(currentFragment instanceof OfflinePageFragment)) { + fragmentManager.beginTransaction().replace(R.id.activity_zapic_container, OfflinePageFragment.createInstance()).commit(); } } @@ -234,32 +258,52 @@ public void onWindowFocusChanged(final boolean hasFocus) { @MainThread @Override protected void onStart() { - Log.d(TAG, "onStart"); + if (BuildConfig.DEBUG) { + Log.d(TAG, "onStart"); + } + super.onStart(); } @MainThread @Override protected void onResume() { - Log.d(TAG, "onResume"); + if (BuildConfig.DEBUG) { + Log.d(TAG, "onResume"); + } + super.onResume(); } @MainThread @Override protected void onPause() { - Log.d(TAG, "onPause"); + if (BuildConfig.DEBUG) { + Log.d(TAG, "onPause"); + } + super.onPause(); } @MainThread @Override protected void onStop() { - Log.d(TAG, "onStop"); + if (BuildConfig.DEBUG) { + Log.d(TAG, "onStop"); + } + super.onStop(); } - // onDestroy + @MainThread + @Override + public void onDestroy() { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onDestroy"); + } + + super.onDestroy(); + } //endregion } diff --git a/zapic/src/main/java/com/zapic/android/sdk/ZapicFragment.java b/zapic/src/main/java/com/zapic/android/sdk/ZapicFragment.java index 09ab5aa..0350115 100644 --- a/zapic/src/main/java/com/zapic/android/sdk/ZapicFragment.java +++ b/zapic/src/main/java/com/zapic/android/sdk/ZapicFragment.java @@ -1,10 +1,11 @@ package com.zapic.android.sdk; +import android.app.Activity; import android.app.Fragment; import android.content.Context; -import android.content.IntentFilter; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; import android.os.Bundle; import android.support.annotation.CheckResult; import android.support.annotation.MainThread; @@ -15,25 +16,330 @@ import android.view.View; import android.view.ViewGroup; import android.webkit.WebView; +import android.widget.Toast; + +import com.google.android.gms.auth.api.signin.GoogleSignIn; +import com.google.android.gms.auth.api.signin.GoogleSignInAccount; +import com.google.android.gms.auth.api.signin.GoogleSignInClient; +import com.google.android.gms.auth.api.signin.GoogleSignInOptions; +import com.google.android.gms.common.api.ApiException; +import com.google.android.gms.tasks.OnCompleteListener; +import com.google.android.gms.tasks.Task; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; /** - * A {@link Fragment} that runs the Zapic application in the background. + * A {@link Fragment} that runs the Zapic JavaScript application in the background. *

- * Use the {@link ZapicFragment#createInstance} factory method to create instances of this fragment. + * Use the {@link Zapic#attachFragment(Activity)} method to create and attach instances of this + * fragment to your game's activity. Alternatively, use the {@link #createInstance()} factory method + * to create instances of this fragment. *

- * The Zapic application runs in a {@link WebView}. Generally, the {@link WebView} runs for the - * lifetime of the Android application. While the game is in focus, the {@link WebView} runs in - * the background (managed by a {@link ZapicFragment} attached to the game's activity) and the Zapic - * application processes events and receives notifications. When the player shifts focus to the - * Zapic application, the {@link WebView} moves to the foreground and is presented by a - * {@link ZapicActivity}. When the player shifts focus back to the game, the {@link ZapicActivity} - * finishes and the {@link WebView} again runs in the background (again managed by a - * {@link ZapicFragment} attached to the game's activity). + * The Zapic JavaScript application runs in a {@link WebView}. Generally, the {@link WebView} runs + * for the lifetime of the Android application. While the game is in focus, the {@link WebView} runs + * in the background (managed by one or more {@link ZapicFragment}s attached to the game's + * activities). The Zapic JavaScript application processes events and receives notifications while + * running in the background. When the player shifts focus to Zapic, the {@link WebView} moves to + * the foreground and is presented by a {@link ZapicActivity}. When the player shifts focus back to + * the game, the {@link ZapicActivity} finishes and the {@link WebView} returns to the background + * (again managed by one or more {@link ZapicFragment}s attached to the game's activities). * * @author Kyle Dodson * @since 1.0.0 */ public final class ZapicFragment extends Fragment { + /** + * A utility class that coordinates the Zapic JavaScript application with the various views + * ({@link ZapicActivity}, {@link ZapicFragment}, and {@link AppPageFragment} instances). + *

+ * This starts and stops the Zapic JavaScript application when the first {@link ZapicFragment} + * is created and the last {@link ZapicFragment} is destroyed, respectively. + *

+ * This ensures at most one {@link ZapicActivity} is running to prevent adding the + * {@link WebView} to multiple view hierarchies. + *

+ * This adds and removes the {@link WebView} to and from the {@link AppPageFragment}. + *

+ * Use the {@link #getInstance()} method to get the singleton instance. + * + * @author Kyle Dodson + * @see App + * @see AppPageFragment + * @see ZapicActivity + * @see ZapicFragment + * @since 1.0.0 + */ + private static final class ZapicViewManager implements App.InteractionListener { + /** + * A weak reference to the {@link ZapicViewManager} "singleton instance". Technically, as a + * weak reference, this is not a singleton instance as defined by the software design + * pattern. The weak reference is required, though. It ensures we don't leak the Android + * application's context. In practice, we cannot discern this implementation difference. + * {@link ZapicFragment} instances keep a strong reference to this {@link ZapicViewManager} + * instance. As such, this {@link ZapicViewManager} instance can only be garbage collected + * after all of our {@link ZapicFragment} instances have been garbage collected. This is no + * different from the state at initial launch. + */ + @NonNull + private static WeakReference INSTANCE = new WeakReference<>(null); + + /** + * Gets the {@link ZapicViewManager} singleton instance. + * + * @return The {@link ZapicViewManager} singleton instance. + */ + @MainThread + @NonNull + static ZapicViewManager getInstance() { + ZapicViewManager instance = ZapicViewManager.INSTANCE.get(); + if (instance == null) { + instance = new ZapicViewManager(); + ZapicViewManager.INSTANCE = new WeakReference<>(instance); + } + + return instance; + } + + /** + * The {@link ZapicActivity} instance. + */ + @Nullable + private ZapicActivity mActivity; + + /** + * The Zapic JavaScript application. + */ + @NonNull + private final App mApp; + + /** + * The list of {@link ZapicFragment} instances. + */ + @NonNull + private final ArrayList mFragments; + + /** + * Creates a new {@link ZapicViewManager} instance. + */ + @MainThread + private ZapicViewManager() { + this.mActivity = null; + this.mApp = new App(this); + this.mFragments = new ArrayList<>(); + } + + @Override + public void login() { + final int size = this.mFragments.size(); + if (size == 0) { + return; + } + + for (int i = size - 1; i >= 0; --i) { + final ZapicFragment fragment = this.mFragments.get(i); + if (fragment.mGoogleSignInClient != null) { + fragment.mGoogleSignInRequested = true; + fragment.startActivityForResult(fragment.mGoogleSignInClient.getSignInIntent(), ZapicFragment.RC_GOOGLE_SIGN_IN); + return; + } + } + + final ZapicFragment fragment = this.mFragments.get(size - 1); + fragment.mGoogleSignInRequested = true; + } + + @Override + public void logout() { + final int size = this.mFragments.size(); + if (size == 0) { + return; + } + + GoogleSignInClient signInClient = null; + for (int i = 0; i < size; ++i) { + final ZapicFragment fragment = this.mFragments.get(i); + if (fragment.mGoogleSignInClient != null && signInClient == null) { + signInClient = fragment.mGoogleSignInClient; + } + + fragment.mGoogleSignInRequested = false; + } + + if (signInClient != null) { + signInClient.signOut(); + } + } + + /** + * Called when a {@link ZapicFragment} is attached to an {@link Activity}. This checks to + * see if the {@link Activity} is a {@link ZapicActivity}. The previous + * {@link ZapicActivity}, if any, is closed to prevent adding the {@link WebView} to + * multiple view hierarchies. + * + * @param fragment The {@link ZapicFragment} instance. + */ + @MainThread + private void onAttached(@NonNull final ZapicFragment fragment) { + final Activity activity = fragment.getActivity(); + if (!(activity instanceof ZapicActivity)) { + return; + } + + final ZapicActivity zapicActivity = (ZapicActivity) activity; + if (this.mActivity == zapicActivity) { + return; + } + + if (this.mActivity != null) { + this.mActivity.close(); + } + + this.mActivity = (ZapicActivity) activity; + } + + /** + * Called when a {@link ZapicFragment} is created. This adds the {@link ZapicFragment} to + * {@link #mFragments} and, if this is the first fragment, starts the Zapic JavaScript + * application. + * + * @param fragment The {@link ZapicFragment} instance. + */ + @MainThread + private void onCreated(@NonNull final ZapicFragment fragment) { + this.onAttached(fragment); + + this.mFragments.add(fragment); + if (this.mFragments.size() == 1) { + // Start the application if this is the first fragment. + this.mApp.start(fragment.getActivity()); + } + } + + /** + * Called when a {@link ZapicFragment} is destroyed. This removes the {@link ZapicFragment} + * from {@link #mFragments} and, if this is the last fragment, stops the Zapic JavaScript + * application. + * + * @param fragment The {@link ZapicFragment} instance. + */ + @MainThread + private void onDestroyed(@NonNull final ZapicFragment fragment) { + this.onDetached(fragment); + + this.mFragments.remove(fragment); + if (this.mFragments.size() == 0) { + // Stop the application if this is the last fragment. + this.mApp.stop(); + } else { + final Activity activity = fragment.getActivity(); + if (!(activity instanceof ZapicActivity)) { + return; + } + + if (this.mActivity != null) { + return; + } + + final WebView webView = this.mApp.getWebView(); + if (webView == null) { + return; + } + + webView.evaluateJavascript("window.zapic.dispatch({ type: 'CLOSE_PAGE' })", null); + } + } + + /** + * Called when a {@link ZapicFragment} is detached from an {@link Activity}. This checks to + * see if the {@link Activity} is a {@link ZapicActivity}. + * + * @param fragment The {@link ZapicFragment} instance. + */ + @MainThread + private void onDetached(@NonNull final ZapicFragment fragment) { + final Activity activity = fragment.getActivity(); + if (!(activity instanceof ZapicActivity)) { + return; + } + + final ZapicActivity zapicActivity = (ZapicActivity) activity; + if (this.mActivity != zapicActivity) { + return; + } + + this.mActivity = null; + } + + @Override + public void onStateChanged(App.State state) { + switch (state) { + case LOADED: { + break; + } + case NOT_LOADED: { + if (this.mActivity != null && this.mFragments.size() > 0) { + if (this.mApp.getConnected()) { + this.mActivity.openLoadingPage(); + this.mApp.start(this.mActivity.getApplicationContext()); + } else { + this.mActivity.openOfflinePage(); + } + } + + break; + } + case NOT_READY: { + if (this.mActivity != null) { + this.mActivity.close(); + } + + break; + } + case READY: { + if (this.mActivity != null) { + this.mActivity.openAppPage(); + } + + break; + } + case STARTED: { + if (this.mActivity != null) { + final WebView webView = this.mApp.getWebView(); + if (webView == null) { + return; + } + + String page = this.mActivity.getIntent().getStringExtra("page"); + if (page == null) { + page = "default"; + } + + final String escapedPage = page.replace("'", "\\'"); + webView.evaluateJavascript("window.zapic.dispatch({ type: 'OPEN_PAGE', payload: '" + escapedPage + "' })", null); + } + + break; + } + default: { + break; + } + } + } + + @Override + public void toast(@NonNull final String message) { + if (this.mActivity != null) { + Toast.makeText(this.mActivity.getApplicationContext(), message, Toast.LENGTH_SHORT).show(); + } + } + } + + /** + * The request code that identifies an intent to login using the Google Sign-In client. + */ + private static final int RC_GOOGLE_SIGN_IN = 9001; + /** * The tag used to identify log messages. */ @@ -41,29 +347,95 @@ public final class ZapicFragment extends Fragment { private static final String TAG = "ZapicFragment"; /** - * The web client application manager. + * Creates the Google Sign-In client. + * + * @param activity The activity to which the Google Sign-In client is bound. + * @return The Google Sign-In client. + * @throws RuntimeException If the Android application's manifest does not have the APP_ID and + * WEB_CLIENT_ID meta-data tags. + */ + @CheckResult + @MainThread + @NonNull + private static GoogleSignInClient createGoogleSignInClient(@NonNull final Activity activity) { + final String packageName = activity.getPackageName(); + Bundle metaData; + try { + final ApplicationInfo info = activity.getPackageManager().getApplicationInfo(packageName, PackageManager.GET_META_DATA); + metaData = info.metaData; + } catch (PackageManager.NameNotFoundException e) { + throw new RuntimeException("Using Zapic requires a metadata tag with the name \"com.google.android.gms.games.APP_ID\" in the application tag of the manifest for " + packageName); + } + + String appId = metaData.getString("com.google.android.gms.games.APP_ID"); + if (appId != null) { + appId = appId.trim(); + if (appId.length() == 0) { + appId = null; + } + } + + if (appId == null) { + throw new RuntimeException("Using Zapic requires a metadata tag with the name \"com.google.android.gms.games.APP_ID\" in the application tag of the manifest for " + packageName); + } + + String webClientId = metaData.getString("com.google.android.gms.games.WEB_CLIENT_ID"); + if (webClientId != null) { + webClientId = webClientId.trim(); + if (webClientId.length() == 0) { + webClientId = null; + } + } + + if (webClientId == null) { + throw new RuntimeException("Using Zapic requires a metadata tag with the name \"com.google.android.gms.games.WEB_CLIENT_ID\" in the application tag of the manifest for " + packageName); + } + + GoogleSignInOptions options = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_GAMES_SIGN_IN) + .requestServerAuthCode(webClientId) + .build(); + return GoogleSignIn.getClient(activity, options); + } + + /** + * The broadcast receiver that relays network connectivity changes. */ @Nullable - private AppManager mAppManager; + private ConnectivityBroadcastReceiver mConnectivityBroadcastReceiver; + + /** + * The Google Sign-In client. + */ + @Nullable + private GoogleSignInClient mGoogleSignInClient; + + /** + * A value that indicates whether the Zapic JavaScript application has requested to login using + * the Google Sign-In client. + */ + private boolean mGoogleSignInRequested; /** - * The connectivity change event receiver. + * The view manager. */ @Nullable - private ConnectivityReceiver mConnectivityReceiver; + private ZapicViewManager mViewManager; /** - * Creates a new instance. + * Creates a new {@link ZapicFragment} instance. */ + @MainThread public ZapicFragment() { - this.mAppManager = null; - this.mConnectivityReceiver = null; + this.mConnectivityBroadcastReceiver = null; + this.mGoogleSignInClient = null; + this.mGoogleSignInRequested = false; + this.mViewManager = null; } /** - * Creates a new instance of the {@link ZapicFragment} class. + * Creates a new {@link ZapicFragment} instance. * - * @return The new instance of the {@link ZapicFragment} class. + * @return The new {@link ZapicFragment} instance. */ @CheckResult @MainThread @@ -72,79 +444,224 @@ public static ZapicFragment createInstance() { return new ZapicFragment(); } + /** + * Gets the Zapic JavaScript application. + * + * @return The Zapic JavaScript application or {@code null} if called before the + * {@link #onCreate(Bundle)} lifecycle method or after the {@link #onDestroy()} lifecycle + * method. + */ + @CheckResult + @MainThread + @Nullable + App getApp() { + return this.mViewManager == null ? null : this.mViewManager.mApp; + } + + @MainThread + @Override + public void onActivityResult(final int requestCode, final int resultCode, @Nullable final Intent intent) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onAttach"); + } + + super.onActivityResult(requestCode, resultCode, intent); + + if (ZapicFragment.this.mViewManager != null && requestCode == ZapicFragment.RC_GOOGLE_SIGN_IN) { + ZapicFragment.this.mGoogleSignInRequested = false; + + Task task = GoogleSignIn.getSignedInAccountFromIntent(intent); + try { + // Interactive sign-in succeeded. + final String serverAuthCode = task.getResult(ApiException.class).getServerAuthCode(); + this.onLoginSucceeded(serverAuthCode == null ? "" : serverAuthCode); + } catch (ApiException e) { + // Interactive sign-in failed; return error. + this.onLoginFailed(e); + } + } + } + + @MainThread + @Override + public void onAttach(final Context context) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onAttach"); + } + + super.onAttach(context); + + if (this.mViewManager != null) { + this.mViewManager.onAttached(this); + + assert this.mConnectivityBroadcastReceiver == null : "mConnectivityBroadcastReceiver != null"; + this.mConnectivityBroadcastReceiver = ConnectivityManagerUtilities.registerConnectivityBroadcastReceiver(this.getActivity(), this.mViewManager.mApp); + this.mConnectivityBroadcastReceiver.notify(this.getActivity()); + + assert this.mGoogleSignInClient == null : "mGoogleSignInClient != null"; + this.mGoogleSignInClient = ZapicFragment.createGoogleSignInClient(this.getActivity()); + } + } + @MainThread @Override public void onCreate(final Bundle savedInstanceState) { - Log.d(TAG, "onCreate"); + if (BuildConfig.DEBUG) { + Log.d(TAG, "onCreate"); + } + super.onCreate(savedInstanceState); - // This "recycles" the fragment instance when configuration changes occur allowing instance - // variables to be retained. Note that the fragment is detached from the old activity and - // then attached to the new activity. + // This retains the fragment instance when configuration changes occur. This effectively + // keeps instance variables from being garbage collected. Note that the fragment is still + // detached from the old activity and attached to the new activity. this.setRetainInstance(true); - // Get a reference to the AppManager (to keep it from being garbage collected). - if (this.mAppManager == null) { - this.mAppManager = AppManager.getInstance(); - } - - // Create the WebView (if this is the first view). - this.mAppManager.onViewCreated(this.getActivity()); + assert this.mViewManager == null : "mViewManager != null"; + this.mViewManager = ZapicViewManager.getInstance(); + this.mViewManager.onCreated(this); - // Register to start receiving connectivity change events. - assert this.mConnectivityReceiver == null : "mConnectivityReceiver != null"; - IntentFilter connectivityFilter = new IntentFilter(); - connectivityFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION); + assert this.mConnectivityBroadcastReceiver == null : "mConnectivityBroadcastReceiver != null"; + this.mConnectivityBroadcastReceiver = ConnectivityManagerUtilities.registerConnectivityBroadcastReceiver(this.getActivity(), this.mViewManager.mApp); + this.mConnectivityBroadcastReceiver.notify(this.getActivity()); - this.mConnectivityReceiver = new ConnectivityReceiver(this.mAppManager); - this.getActivity().registerReceiver(this.mConnectivityReceiver, connectivityFilter); + assert this.mGoogleSignInClient == null : "mGoogleSignInClient != null"; + this.mGoogleSignInClient = ZapicFragment.createGoogleSignInClient(this.getActivity()); } @MainThread @Override public void onDestroy() { - Log.d(TAG, "onDestroy"); + if (BuildConfig.DEBUG) { + Log.d(TAG, "onDestroy"); + } + super.onDestroy(); - // Unregister to stop receiving connectivity change events. - assert this.mConnectivityReceiver != null : "mConnectivityReceiver == null"; - this.getActivity().unregisterReceiver(this.mConnectivityReceiver); - this.mConnectivityReceiver = null; + assert this.mGoogleSignInClient != null : "mGoogleSignInClient == null"; + this.mGoogleSignInClient = null; + + assert this.mConnectivityBroadcastReceiver != null : "mConnectivityBroadcastReceiver == null"; + ConnectivityManagerUtilities.unregisterConnectivityBroadcastReceiver(this.getActivity(), this.mConnectivityBroadcastReceiver); + this.mConnectivityBroadcastReceiver = null; - // Destroy the WebView (if this is the last view). - assert this.mAppManager != null : "mAppManager == null"; - this.mAppManager.onViewDestroyed(); - this.mAppManager = null; + assert this.mViewManager != null : "mViewManager == null"; + ZapicViewManager viewManager = this.mViewManager; + this.mViewManager.onDestroyed(this); + this.mViewManager = null; + + if (this.mGoogleSignInRequested) { + // Transfer the sign-in request to another fragment. + viewManager.login(); + } } @MainThread @Override - public void onStart() { - Log.d(TAG, "onStart"); - super.onStart(); + public void onDetach() { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onDetach"); + } + + super.onDetach(); + + if (this.mViewManager != null) { + assert this.mGoogleSignInClient != null; + this.mGoogleSignInClient = null; + + assert this.mConnectivityBroadcastReceiver != null : "mConnectivityBroadcastReceiver == null"; + ConnectivityManagerUtilities.unregisterConnectivityBroadcastReceiver(this.getActivity(), this.mConnectivityBroadcastReceiver); + this.mConnectivityBroadcastReceiver = null; + + this.mViewManager.onDetached(this); + } + } + + @MainThread + private void onLoginFailed(@NonNull final Exception exception) { + final ZapicViewManager viewManager = ZapicFragment.this.mViewManager; + if (viewManager == null) { + return; + } - // Check initial connectivity state. - final ConnectivityManager connectivityManager = (ConnectivityManager)this.getActivity().getSystemService(Context.CONNECTIVITY_SERVICE); - assert connectivityManager != null : "connectivityManager == null"; + final App app = viewManager.mApp; + final WebView webView = app.getWebView(); + if (webView == null) { + return; + } - final NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo(); - assert this.mAppManager != null : "mAppManager == null"; - if (networkInfo != null && networkInfo.isConnected()) { - this.mAppManager.setOnline(); + String payload; + if (exception instanceof ApiException) { + ApiException apiException = (ApiException) exception; + payload = "'" + String.valueOf(apiException.getStatusCode()) + "'"; } else { - this.mAppManager.setOffline(); + payload = "'Failed to sign-in to Play Games'"; } + + this.mGoogleSignInRequested = false; + webView.evaluateJavascript("window.zapic.dispatch({ type: 'LOGIN_WITH_PLAY_GAME_SERVICES', payload: " + payload + " })", null); } - //region Lifecycle Events + @MainThread + private void onLoginSucceeded(@NonNull final String serverAuthCode) { + final ZapicViewManager viewManager = ZapicFragment.this.mViewManager; + if (viewManager == null) { + return; + } + + final App app = viewManager.mApp; + final WebView webView = app.getWebView(); + if (webView == null) { + return; + } + + final String packageName = this.getActivity().getPackageName(); + final String payload = "{ authCode: '" + serverAuthCode.replace("'", "\\'") + "', packageName: '" + packageName.replace("'", "\\'") + "' }"; + + this.mGoogleSignInRequested = false; + webView.evaluateJavascript("window.zapic.dispatch({ type: 'LOGIN_WITH_PLAY_GAME_SERVICES', payload: " + payload + " })", null); + } @MainThread @Override - public void onAttach(final Context context) { - Log.d(TAG, "onAttach"); - super.onAttach(context); + public void onResume() { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onResume"); + } + + super.onResume(); + + assert this.mGoogleSignInClient != null : "mGoogleSignInClient == null"; + final GoogleSignInAccount account = GoogleSignIn.getLastSignedInAccount(this.getActivity()); + if (account == null) { + this.mGoogleSignInClient.silentSignIn().addOnCompleteListener(this.getActivity(), new OnCompleteListener() { + @MainThread + @Override + public void onComplete(@NonNull final Task task) { + if (ZapicFragment.this.mViewManager != null) { + try { + // Silent sign-in succeeded. + final String serverAuthCode = task.getResult(ApiException.class).getServerAuthCode(); + ZapicFragment.this.onLoginSucceeded(serverAuthCode == null ? "" : serverAuthCode); + } catch (ApiException e) { + if (ZapicFragment.this.mGoogleSignInRequested && ZapicFragment.this.mGoogleSignInClient != null) { + // Silent sign-in failed; try interactive sign-in. + ZapicFragment.this.startActivityForResult(ZapicFragment.this.mGoogleSignInClient.getSignInIntent(), ZapicFragment.RC_GOOGLE_SIGN_IN); + } + } + } + } + }); + } else if (this.mGoogleSignInRequested) { + final String serverAuthCode = account.getServerAuthCode(); + this.onLoginSucceeded(serverAuthCode == null ? "" : serverAuthCode); + } } + //region Lifecycle Events + + // onAttach + // onCreate @CheckResult @@ -152,55 +669,68 @@ public void onAttach(final Context context) { @Nullable @Override public View onCreateView(final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { - Log.d(TAG, "onCreateView"); + if (BuildConfig.DEBUG) { + Log.d(TAG, "onCreateView"); + } + return super.onCreateView(inflater, container, savedInstanceState); } @MainThread @Override public void onActivityCreated(final Bundle savedInstanceState) { - Log.d(TAG, "onActivityCreated"); + if (BuildConfig.DEBUG) { + Log.d(TAG, "onActivityCreated"); + } + super.onActivityCreated(savedInstanceState); } - // onStart - @MainThread @Override - public void onResume() { - Log.d(TAG, "onResume"); - super.onResume(); + public void onStart() { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onStart"); + } + + super.onStart(); } + // onResume + @MainThread @Override public void onPause() { - Log.d(TAG, "onPause"); + if (BuildConfig.DEBUG) { + Log.d(TAG, "onPause"); + } + super.onPause(); } @MainThread @Override public void onStop() { - Log.d(TAG, "onStop"); + if (BuildConfig.DEBUG) { + Log.d(TAG, "onStop"); + } + super.onStop(); } @MainThread @Override public void onDestroyView() { - Log.d(TAG, "onDestroyView"); + if (BuildConfig.DEBUG) { + Log.d(TAG, "onDestroyView"); + } + super.onDestroyView(); } // onDestroy - @MainThread - @Override - public void onDetach() { - Log.d(TAG, "onDetach"); - super.onDetach(); - } + // onDetach //endregion } diff --git a/zapic/src/main/res/layout-v21/fragment_page_loading.xml b/zapic/src/main/res/layout-v21/fragment_page_loading.xml index 9694ebf..1750a65 100644 --- a/zapic/src/main/res/layout-v21/fragment_page_loading.xml +++ b/zapic/src/main/res/layout-v21/fragment_page_loading.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:background="#ffffffff" tools:context="com.zapic.android.sdk.LoadingPageFragment">