diff --git a/.idea/modules.xml b/.idea/modules.xml index 3643776..d99f86a 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -3,8 +3,8 @@ - + \ No newline at end of file diff --git a/demo-app/proguard-rules.pro b/demo-app/proguard-rules.pro deleted file mode 100644 index f1b4245..0000000 --- a/demo-app/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile diff --git a/settings.gradle b/settings.gradle index 9686794..f8637a0 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -include ':demo-app', ':zapic' +include ':zapic', ':zapic-demo' diff --git a/demo-app/.gitignore b/zapic-demo/.gitignore similarity index 100% rename from demo-app/.gitignore rename to zapic-demo/.gitignore diff --git a/demo-app/build.gradle b/zapic-demo/build.gradle similarity index 89% rename from demo-app/build.gradle rename to zapic-demo/build.gradle index 02db3cd..9958065 100644 --- a/demo-app/build.gradle +++ b/zapic-demo/build.gradle @@ -9,7 +9,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 - versionName '1.0' + versionName '1.0.0' } buildTypes { @@ -28,5 +28,5 @@ android { dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation project(':zapic') - implementation 'com.android.support:support-annotations:27.0.2' + implementation 'com.android.support:support-annotations:27.1.0' } diff --git a/zapic-demo/proguard-rules.pro b/zapic-demo/proguard-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/demo-app/src/main/AndroidManifest.xml b/zapic-demo/src/main/AndroidManifest.xml similarity index 72% rename from demo-app/src/main/AndroidManifest.xml rename to zapic-demo/src/main/AndroidManifest.xml index 7ec9d99..d6bb923 100644 --- a/demo-app/src/main/AndroidManifest.xml +++ b/zapic-demo/src/main/AndroidManifest.xml @@ -19,8 +19,12 @@ - - + + diff --git a/demo-app/src/main/java/com/zapic/androiddemo/MainActivity.java b/zapic-demo/src/main/java/com/zapic/androiddemo/MainActivity.java similarity index 81% rename from demo-app/src/main/java/com/zapic/androiddemo/MainActivity.java rename to zapic-demo/src/main/java/com/zapic/androiddemo/MainActivity.java index 3dd919d..8348a1c 100644 --- a/demo-app/src/main/java/com/zapic/androiddemo/MainActivity.java +++ b/zapic-demo/src/main/java/com/zapic/androiddemo/MainActivity.java @@ -1,15 +1,15 @@ package com.zapic.androiddemo; +import android.annotation.SuppressLint; import android.app.Activity; import android.graphics.PointF; -import android.support.annotation.MainThread; import android.support.annotation.NonNull; import android.os.Bundle; import android.view.MotionEvent; import android.view.View; import android.widget.RelativeLayout; -import com.zapic.android.sdk.Zapic; +import com.zapic.sdk.android.Zapic; public class MainActivity extends Activity { /** @@ -36,7 +36,6 @@ public MainActivity() { * 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 @@ -48,15 +47,21 @@ private void enableImmersiveFullScreenMode() { | View.SYSTEM_UI_FLAG_LOW_PROFILE); } - @MainThread @Override protected void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - // Hide the system status and navigation bars. this.enableImmersiveFullScreenMode(); + this.getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(new View.OnSystemUiVisibilityChangeListener() { + @Override + public void onSystemUiVisibilityChange(final int visibility) { + if ((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) { + MainActivity.this.enableImmersiveFullScreenMode(); + } + } + }); // Render the page. + super.onCreate(savedInstanceState); this.setContentView(R.layout.activity_main); final RelativeLayout zapicButton = this.findViewById(R.id.activity_main_zapic_button); @@ -77,6 +82,7 @@ public void onClick(View v) { this.findViewById(R.id.activity_main_container).setOnTouchListener(new View.OnTouchListener() { @Override + @SuppressLint("ClickableViewAccessibility") public boolean onTouch(View view, MotionEvent motionEvent) { return MainActivity.this.onTouch(motionEvent); } @@ -86,7 +92,22 @@ public boolean onTouch(View view, MotionEvent motionEvent) { Zapic.attachFragment(this); } - @MainThread + @Override + protected void onResume() { + // Hide the system status and navigation bars. + this.enableImmersiveFullScreenMode(); + this.getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(new View.OnSystemUiVisibilityChangeListener() { + @Override + public void onSystemUiVisibilityChange(final int visibility) { + if ((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) { + MainActivity.this.enableImmersiveFullScreenMode(); + } + } + }); + + super.onResume(); + } + private boolean onTouch(@NonNull final MotionEvent motionEvent) { switch (motionEvent.getActionMasked()) { case MotionEvent.ACTION_DOWN: @@ -129,7 +150,6 @@ private boolean onTouch(@NonNull final MotionEvent motionEvent) { return true; } - @MainThread @Override public void onWindowFocusChanged(final boolean hasFocus) { super.onWindowFocusChanged(hasFocus); diff --git a/demo-app/src/main/res/drawable-hdpi/background.jpg b/zapic-demo/src/main/res/drawable-hdpi/background.jpg similarity index 100% rename from demo-app/src/main/res/drawable-hdpi/background.jpg rename to zapic-demo/src/main/res/drawable-hdpi/background.jpg diff --git a/demo-app/src/main/res/drawable-land-hdpi/background.jpg b/zapic-demo/src/main/res/drawable-land-hdpi/background.jpg similarity index 100% rename from demo-app/src/main/res/drawable-land-hdpi/background.jpg rename to zapic-demo/src/main/res/drawable-land-hdpi/background.jpg diff --git a/demo-app/src/main/res/drawable-land-ldpi/background.jpg b/zapic-demo/src/main/res/drawable-land-ldpi/background.jpg similarity index 100% rename from demo-app/src/main/res/drawable-land-ldpi/background.jpg rename to zapic-demo/src/main/res/drawable-land-ldpi/background.jpg diff --git a/demo-app/src/main/res/drawable-land-mdpi/background.jpg b/zapic-demo/src/main/res/drawable-land-mdpi/background.jpg similarity index 100% rename from demo-app/src/main/res/drawable-land-mdpi/background.jpg rename to zapic-demo/src/main/res/drawable-land-mdpi/background.jpg diff --git a/demo-app/src/main/res/drawable-land-xhdpi/background.jpg b/zapic-demo/src/main/res/drawable-land-xhdpi/background.jpg similarity index 100% rename from demo-app/src/main/res/drawable-land-xhdpi/background.jpg rename to zapic-demo/src/main/res/drawable-land-xhdpi/background.jpg diff --git a/demo-app/src/main/res/drawable-land-xxhdpi/background.jpg b/zapic-demo/src/main/res/drawable-land-xxhdpi/background.jpg similarity index 100% rename from demo-app/src/main/res/drawable-land-xxhdpi/background.jpg rename to zapic-demo/src/main/res/drawable-land-xxhdpi/background.jpg diff --git a/demo-app/src/main/res/drawable-ldpi/background.jpg b/zapic-demo/src/main/res/drawable-ldpi/background.jpg similarity index 100% rename from demo-app/src/main/res/drawable-ldpi/background.jpg rename to zapic-demo/src/main/res/drawable-ldpi/background.jpg diff --git a/demo-app/src/main/res/drawable-mdpi/background.jpg b/zapic-demo/src/main/res/drawable-mdpi/background.jpg similarity index 100% rename from demo-app/src/main/res/drawable-mdpi/background.jpg rename to zapic-demo/src/main/res/drawable-mdpi/background.jpg diff --git a/demo-app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/zapic-demo/src/main/res/drawable-v24/ic_launcher_foreground.xml similarity index 100% rename from demo-app/src/main/res/drawable-v24/ic_launcher_foreground.xml rename to zapic-demo/src/main/res/drawable-v24/ic_launcher_foreground.xml diff --git a/demo-app/src/main/res/drawable-xhdpi/background.jpg b/zapic-demo/src/main/res/drawable-xhdpi/background.jpg similarity index 100% rename from demo-app/src/main/res/drawable-xhdpi/background.jpg rename to zapic-demo/src/main/res/drawable-xhdpi/background.jpg diff --git a/demo-app/src/main/res/drawable-xxhdpi/background.jpg b/zapic-demo/src/main/res/drawable-xxhdpi/background.jpg similarity index 100% rename from demo-app/src/main/res/drawable-xxhdpi/background.jpg rename to zapic-demo/src/main/res/drawable-xxhdpi/background.jpg diff --git a/demo-app/src/main/res/drawable/challenges_button_background.xml b/zapic-demo/src/main/res/drawable/challenges_button_background.xml similarity index 66% rename from demo-app/src/main/res/drawable/challenges_button_background.xml rename to zapic-demo/src/main/res/drawable/challenges_button_background.xml index 9874e6b..217bb44 100644 --- a/demo-app/src/main/res/drawable/challenges_button_background.xml +++ b/zapic-demo/src/main/res/drawable/challenges_button_background.xml @@ -1,5 +1,7 @@ - + diff --git a/demo-app/src/main/res/drawable/ic_launcher_background.xml b/zapic-demo/src/main/res/drawable/ic_launcher_background.xml similarity index 100% rename from demo-app/src/main/res/drawable/ic_launcher_background.xml rename to zapic-demo/src/main/res/drawable/ic_launcher_background.xml diff --git a/demo-app/src/main/res/drawable/zapic_button_background.xml b/zapic-demo/src/main/res/drawable/zapic_button_background.xml similarity index 65% rename from demo-app/src/main/res/drawable/zapic_button_background.xml rename to zapic-demo/src/main/res/drawable/zapic_button_background.xml index 3d94db0..9ca85cf 100644 --- a/demo-app/src/main/res/drawable/zapic_button_background.xml +++ b/zapic-demo/src/main/res/drawable/zapic_button_background.xml @@ -1,5 +1,7 @@ - + diff --git a/demo-app/src/main/res/layout/activity_main.xml b/zapic-demo/src/main/res/layout/activity_main.xml similarity index 94% rename from demo-app/src/main/res/layout/activity_main.xml rename to zapic-demo/src/main/res/layout/activity_main.xml index c87a095..79405de 100644 --- a/demo-app/src/main/res/layout/activity_main.xml +++ b/zapic-demo/src/main/res/layout/activity_main.xml @@ -4,7 +4,8 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/background" - tools:context="com.zapic.androiddemo.MainActivity"> + tools:context=".MainActivity"> + + + + android:src="@drawable/zapic_logo_64dp" /> + + + package="com.zapic.sdk.android"> + - - + + + + diff --git a/zapic/src/main/java/com/zapic/android/sdk/App.java b/zapic/src/main/java/com/zapic/android/sdk/App.java deleted file mode 100644 index 2afb344..0000000 --- a/zapic/src/main/java/com/zapic/android/sdk/App.java +++ /dev/null @@ -1,587 +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.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/AppPageFragment.java b/zapic/src/main/java/com/zapic/android/sdk/AppPageFragment.java deleted file mode 100644 index 5b5e8f0..0000000 --- a/zapic/src/main/java/com/zapic/android/sdk/AppPageFragment.java +++ /dev/null @@ -1,144 +0,0 @@ -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 presents the Zapic JavaScript application page. - *

- * Use the {@link #createInstance()} factory method to create instances of this fragment. - * - * @author Kyle Dodson - * @since 1.0.0 - */ -public final class AppPageFragment extends Fragment { - /** - * The tag used to identify log messages. - */ - @NonNull - private static final String TAG = "AppPageFragment"; - - /** - * The {@link WebView}. - */ - @Nullable - private WebView mWebView; - - /** - * Creates a new {@link AppPageFragment} instance. - */ - @MainThread - public AppPageFragment() { - this.mWebView = null; - } - - /** - * Creates a new {@link LoadingPageFragment} instance. - * - * @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) { - 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"); - } - - 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(this.mWebView); - } - - return view; - } - - @MainThread - @Override - public void 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); - } - - this.mWebView = null; - } - - super.onDestroyView(); - } -} diff --git a/zapic/src/main/java/com/zapic/android/sdk/ConnectivityBroadcastReceiver.java b/zapic/src/main/java/com/zapic/android/sdk/ConnectivityBroadcastReceiver.java deleted file mode 100644 index b9ce5d4..0000000 --- a/zapic/src/main/java/com/zapic/android/sdk/ConnectivityBroadcastReceiver.java +++ /dev/null @@ -1,63 +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.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 deleted file mode 100644 index 5cd8634..0000000 --- a/zapic/src/main/java/com/zapic/android/sdk/ConnectivityListener.java +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 6906625..0000000 --- a/zapic/src/main/java/com/zapic/android/sdk/ConnectivityManagerUtilities.java +++ /dev/null @@ -1,73 +0,0 @@ -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/LoadingPageFragment.java b/zapic/src/main/java/com/zapic/android/sdk/LoadingPageFragment.java deleted file mode 100644 index 2df44f6..0000000 --- a/zapic/src/main/java/com/zapic/android/sdk/LoadingPageFragment.java +++ /dev/null @@ -1,148 +0,0 @@ -package com.zapic.android.sdk; - -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 presents a loading page. - *

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

- * 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. - */ - @Nullable - private InteractionListener mListener; - - /** - * Creates a new {@link LoadingPageFragment} instance. - */ - @MainThread - public LoadingPageFragment() { - } - - /** - * Creates a new {@link LoadingPageFragment} instance. - * - * @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; - } 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); - closeButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - InteractionListener listener = LoadingPageFragment.this.mListener; - if (listener != null) { - listener.close(); - } - } - }); - } -} diff --git a/zapic/src/main/java/com/zapic/android/sdk/OfflinePageFragment.java b/zapic/src/main/java/com/zapic/android/sdk/OfflinePageFragment.java deleted file mode 100644 index c112997..0000000 --- a/zapic/src/main/java/com/zapic/android/sdk/OfflinePageFragment.java +++ /dev/null @@ -1,148 +0,0 @@ -package com.zapic.android.sdk; - -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 presents an offline page. - *

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

- * 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. - */ - @Nullable - private InteractionListener mListener; - - /** - * Creates a new {@link OfflinePageFragment} instance. - */ - @MainThread - public OfflinePageFragment() { - } - - /** - * Creates a new {@link OfflinePageFragment} instance. - * - * @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; - } 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); - closeButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - InteractionListener listener = OfflinePageFragment.this.mListener; - if (listener != null) { - listener.close(); - } - } - }); - } -} diff --git a/zapic/src/main/java/com/zapic/android/sdk/WatchdogTimer.java b/zapic/src/main/java/com/zapic/android/sdk/WatchdogTimer.java deleted file mode 100644 index a2e1533..0000000 --- a/zapic/src/main/java/com/zapic/android/sdk/WatchdogTimer.java +++ /dev/null @@ -1,141 +0,0 @@ -package com.zapic.android.sdk; - -import android.os.Handler; -import android.os.HandlerThread; -import android.os.Message; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.util.Log; - -/** - * A watchdog timer that handles timeout events on a background thread. - * - * @author Kyle Dodson - * @since 1.0.0 - */ -final class WatchdogTimer { - /** - * The tag used to identify log entries. - */ - @NonNull - private static final String TAG = "WatchdogTimer"; - - /** - * The background thread name. - */ - @NonNull - private static final String THREAD_NAME = "ZapicWatchdogTimerThread"; - - /** - * The callback that handles timeout events on the background thread when the watchdog timer has - * elapsed. - */ - @NonNull - private final WatchdogTimer.Callback callback; - - /** - * The handler that sends timeout events on the background thread when the watchdog timer has - * elapsed. - */ - @Nullable - private Handler handler; - - /** - * The background thread. - */ - @Nullable - private HandlerThread handlerThread; - - /** - * The synchronization lock for {@see handler}, {@see handlerThread}, and {@see messageId}. - */ - @NonNull - private final Object lock; - - /** - * The message sequence identifier. - */ - private int messageSequenceId; - - /** - * Creates a new instance. - * - * @param callback The callback that handles timeout events on the background thread when the - * watchdog timer has elapsed. - */ - WatchdogTimer(@NonNull final WatchdogTimer.Callback callback) { - this.callback = callback; - this.handler = null; - this.handlerThread = null; - this.lock = new Object(); - this.messageSequenceId = 0; - } - - /** - * Starts (or resets) the watchdog timer. - * - * @param timeout The timeout (in milliseconds). - */ - void start(final long timeout) { - synchronized (this.lock) { - if (this.handlerThread == null) { - this.handlerThread = new HandlerThread(WatchdogTimer.THREAD_NAME); - this.handlerThread.start(); - } - - if (this.handler == null) { - this.handler = new Handler(this.handlerThread.getLooper(), new Handler.Callback() { - @Override - public boolean handleMessage(Message msg) { - boolean elapsed; - synchronized (WatchdogTimer.this.lock) { - elapsed = msg.arg1 == WatchdogTimer.this.messageSequenceId; - } - - if (elapsed) { - Log.d(TAG, "Firing timeout event; the watchdog timer has elapsed"); - WatchdogTimer.this.callback.handleTimeout(); - } - - return true; - } - }); - } - - if (this.messageSequenceId == Integer.MAX_VALUE) { - this.messageSequenceId = 0; - } - - this.handler.removeCallbacksAndMessages(null); - this.handler.sendMessageDelayed(this.handler.obtainMessage(0, ++this.messageSequenceId, 0), timeout); - } - } - - /** - * Stops the watchdog timer. - */ - void stop() { - synchronized (this.lock) { - if (this.handlerThread != null) { - this.handlerThread.quit(); - this.handlerThread = null; - } - - if (this.handler != null) { - this.handler = null; - } - - this.messageSequenceId = 0; - } - } - - /** - * A callback interface. - */ - interface Callback { - /** - * Handles timeout events on the background thread when the watchdog timer has elapsed. - */ - void handleTimeout(); - } -} diff --git a/zapic/src/main/java/com/zapic/android/sdk/ZapicActivity.java b/zapic/src/main/java/com/zapic/android/sdk/ZapicActivity.java deleted file mode 100644 index 19e53c5..0000000 --- a/zapic/src/main/java/com/zapic/android/sdk/ZapicActivity.java +++ /dev/null @@ -1,309 +0,0 @@ -package com.zapic.android.sdk; - -import android.app.Activity; -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.webkit.WebView; - -/** - * An {@link Activity} that presents the Zapic JavaScript application in the foreground. - *

- * 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 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 - LoadingPageFragment.InteractionListener, - OfflinePageFragment.InteractionListener { - /** - * The {@link Intent} parameter that identifies the Zapic JavaScript application page to open. - */ - @NonNull - private static final String PAGE_PARAM = "page"; - - /** - * The tag used to identify log messages. - */ - @NonNull - private static final String TAG = "ZapicActivity"; - - /** - * The {@link WebView}. - */ - @Nullable - private WebView mWebView; - - /** - * Creates a new {@link ZapicActivity} instance - */ - public ZapicActivity() { - this.mWebView = null; - } - - /** - * Creates an {@link Intent} that starts a {@link ZapicActivity} and opens the default Zapic - * JavaScript application page. - * - * @param gameActivity The game's activity. - * @return The {@link Intent}. - */ - @MainThread - @CheckResult - @NonNull - public static Intent createIntent(@NonNull final Activity gameActivity) { - return ZapicActivity.createIntent(gameActivity, "default"); - } - - /** - * Creates an {@link Intent} that starts a {@link ZapicActivity} and opens the specified Zapic - * JavaScript application page. - * - * @param gameActivity The game's activity. - * @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); -// } - - /** - * Gets the {@link WebView}. - * - * @return The {@link WebView}. - */ - @CheckResult - @MainThread - @Nullable - WebView getWebView() { - return this.mWebView; - } - - @MainThread - @Override - protected void onCreate(final Bundle savedInstanceState) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "onCreate"); - } - - super.onCreate(savedInstanceState); - -// TODO: Determine if the following is required when we use a fullscreen theme. -// // Hide the system status and navigation bars. -// this.enableImmersiveFullScreenMode(); - - 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); - } - - // 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 - protected void onNewIntent(final Intent intent) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "onNewIntent"); - } - - super.onNewIntent(intent); - this.setIntent(intent); - } - -// 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(); -// } -// } - - /** - * 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 - 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 - 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(); - } - } - - //region Lifecycle Events - - // onCreate - - @MainThread - @Override - protected void onStart() { - if (BuildConfig.DEBUG) { - Log.d(TAG, "onStart"); - } - - super.onStart(); - } - - @MainThread - @Override - protected void onResume() { - if (BuildConfig.DEBUG) { - Log.d(TAG, "onResume"); - } - - super.onResume(); - } - - @MainThread - @Override - protected void onPause() { - if (BuildConfig.DEBUG) { - Log.d(TAG, "onPause"); - } - - super.onPause(); - } - - @MainThread - @Override - protected void onStop() { - if (BuildConfig.DEBUG) { - Log.d(TAG, "onStop"); - } - - super.onStop(); - } - - @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 deleted file mode 100644 index 0350115..0000000 --- a/zapic/src/main/java/com/zapic/android/sdk/ZapicFragment.java +++ /dev/null @@ -1,736 +0,0 @@ -package com.zapic.android.sdk; - -import android.app.Activity; -import android.app.Fragment; -import android.content.Context; -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; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.util.Log; -import android.view.LayoutInflater; -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 JavaScript application in the background. - *

- * 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 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. - */ - @NonNull - private static final String TAG = "ZapicFragment"; - - /** - * 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 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 view manager. - */ - @Nullable - private ZapicViewManager mViewManager; - - /** - * Creates a new {@link ZapicFragment} instance. - */ - @MainThread - public ZapicFragment() { - this.mConnectivityBroadcastReceiver = null; - this.mGoogleSignInClient = null; - this.mGoogleSignInRequested = false; - this.mViewManager = null; - } - - /** - * Creates a new {@link ZapicFragment} instance. - * - * @return The new {@link ZapicFragment} instance. - */ - @CheckResult - @MainThread - @NonNull - 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) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "onCreate"); - } - - super.onCreate(savedInstanceState); - - // 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); - - assert this.mViewManager == null : "mViewManager != null"; - this.mViewManager = ZapicViewManager.getInstance(); - this.mViewManager.onCreated(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 onDestroy() { - if (BuildConfig.DEBUG) { - Log.d(TAG, "onDestroy"); - } - - super.onDestroy(); - - assert this.mGoogleSignInClient != null : "mGoogleSignInClient == null"; - this.mGoogleSignInClient = null; - - assert this.mConnectivityBroadcastReceiver != null : "mConnectivityBroadcastReceiver == null"; - ConnectivityManagerUtilities.unregisterConnectivityBroadcastReceiver(this.getActivity(), this.mConnectivityBroadcastReceiver); - this.mConnectivityBroadcastReceiver = 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 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; - } - - final App app = viewManager.mApp; - final WebView webView = app.getWebView(); - if (webView == null) { - return; - } - - String payload; - if (exception instanceof ApiException) { - ApiException apiException = (ApiException) exception; - payload = "'" + String.valueOf(apiException.getStatusCode()) + "'"; - } else { - 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); - } - - @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 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 - @MainThread - @Nullable - @Override - public View onCreateView(final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "onCreateView"); - } - - return super.onCreateView(inflater, container, savedInstanceState); - } - - @MainThread - @Override - public void onActivityCreated(final Bundle savedInstanceState) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "onActivityCreated"); - } - - super.onActivityCreated(savedInstanceState); - } - - @MainThread - @Override - public void onStart() { - if (BuildConfig.DEBUG) { - Log.d(TAG, "onStart"); - } - - super.onStart(); - } - - // onResume - - @MainThread - @Override - public void onPause() { - if (BuildConfig.DEBUG) { - Log.d(TAG, "onPause"); - } - - super.onPause(); - } - - @MainThread - @Override - public void onStop() { - if (BuildConfig.DEBUG) { - Log.d(TAG, "onStop"); - } - - super.onStop(); - } - - @MainThread - @Override - public void onDestroyView() { - if (BuildConfig.DEBUG) { - Log.d(TAG, "onDestroyView"); - } - - super.onDestroyView(); - } - - // onDestroy - - // onDetach - - //endregion -} diff --git a/zapic/src/main/java/com/zapic/android/sdk/AppSource.java b/zapic/src/main/java/com/zapic/sdk/android/AppSource.java similarity index 52% rename from zapic/src/main/java/com/zapic/android/sdk/AppSource.java rename to zapic/src/main/java/com/zapic/sdk/android/AppSource.java index 37a1d33..2696184 100644 --- a/zapic/src/main/java/com/zapic/android/sdk/AppSource.java +++ b/zapic/src/main/java/com/zapic/sdk/android/AppSource.java @@ -1,9 +1,9 @@ -package com.zapic.android.sdk; +package com.zapic.sdk.android; import android.support.annotation.NonNull; /** - * A representation of the web client application source and version. + * A representation of the Zapic JavaScript application source and version. * * @author Kyle Dodson * @since 1.0.0 @@ -26,18 +26,27 @@ final class AppSource { */ private final long lastModified; + /** + * The last cache validation date and time. + */ + private final long lastValidated; + /** * Creates a new instance. * - * @param html The HTML source. - * @param eTag The ETag or an empty string if one was not returned in the HTTP response. - * @param lastModified The Last-Modified date and time or {@code 0} if one was not returned in - * the HTTP response. + * @param html The HTML source. + * @param eTag The ETag or an empty string if one was not returned in the HTTP + * response. + * @param lastModified The Last-Modified date and time or {@code 0} if one was not returned in + * the HTTP response. + * @param lastValidated The last cache validation date and time or {@code 0} if it has not been + * validated. */ - AppSource(@NonNull final String html, @NonNull final String eTag, long lastModified) { + AppSource(@NonNull final String html, @NonNull final String eTag, long lastModified, long lastValidated) { this.eTag = eTag; this.html = html; this.lastModified = lastModified; + this.lastValidated = lastValidated; } /** @@ -69,4 +78,15 @@ String getHtml() { long getLastModified() { return this.lastModified; } + + /** + * Gets the last cache validation date and time in milliseconds since January 1, 1970 or + * {@code 0} if it has not been validated. + * + * @return The last cache validation date and time in milliseconds since January 1, 1970 or + * {@code 0} if it has not been validated. + */ + long getLastValidated() { + return this.lastValidated; + } } diff --git a/zapic/src/main/java/com/zapic/android/sdk/AppSourceAsyncTask.java b/zapic/src/main/java/com/zapic/sdk/android/AppSourceAsyncTask.java similarity index 81% rename from zapic/src/main/java/com/zapic/android/sdk/AppSourceAsyncTask.java rename to zapic/src/main/java/com/zapic/sdk/android/AppSourceAsyncTask.java index 9a4dd3d..b7d590a 100644 --- a/zapic/src/main/java/com/zapic/android/sdk/AppSourceAsyncTask.java +++ b/zapic/src/main/java/com/zapic/sdk/android/AppSourceAsyncTask.java @@ -1,4 +1,4 @@ -package com.zapic.android.sdk; +package com.zapic.sdk.android; import android.os.AsyncTask; import android.os.Build; @@ -15,7 +15,6 @@ import java.io.File; import java.io.IOException; import java.io.InputStreamReader; -import java.lang.ref.WeakReference; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URL; @@ -23,16 +22,16 @@ final class AppSourceAsyncTask extends AsyncTask implements CancellationToken { /** - * The tag used to identify log entries. + * The cache file name. */ @NonNull - private static final String TAG = "AppSourceAsyncTask"; + private static final String APP_SOURCE_FILE_NAME = "zapic.index.html.cacheEntry"; /** - * The web client application manager. + * The tag used to identify log entries. */ @NonNull - private final WeakReference mApp; + private static final String TAG = "AppSourceAsyncTask"; /** * The web client application URL. @@ -49,12 +48,11 @@ final class AppSourceAsyncTask extends AsyncTask imple /** * Creates a new instance. * - * @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 App app, @NonNull final String url, @NonNull final File cacheDir) { + AppSourceAsyncTask(@NonNull final String url, @NonNull final File cacheDir) { URL parsedUrl; try { parsedUrl = new URL(url); @@ -62,7 +60,6 @@ final class AppSourceAsyncTask extends AsyncTask imple throw new IllegalArgumentException("The web client application URL is invalid"); } - this.mApp = new WeakReference<>(app); this.mAppUrl = parsedUrl; this.mCacheDir = cacheDir; } @@ -73,7 +70,19 @@ final class AppSourceAsyncTask extends AsyncTask imple protected AppSource doInBackground(final Void... voids) { final AppSource cachedAppSource = this.getFromCache(); if (cachedAppSource != null) { - return this.injectInitializationScript(cachedAppSource); + final long lastValidated = cachedAppSource.getLastValidated(); + final long now = System.currentTimeMillis(); + long staleness; + if (lastValidated <= 0) { + final long lastModified = cachedAppSource.getLastModified(); + staleness = now - lastModified; + } else { + staleness = now - lastValidated; + } + + if (staleness < 86400000) { + return this.injectInitializationScript(cachedAppSource); + } } if (this.isCancelled()) { @@ -124,11 +133,16 @@ private AppSource download() { // Parse response headers. final int statusCode = connection.getResponseCode(); if (statusCode == HttpURLConnection.HTTP_OK) { - final String eTag = connection.getHeaderField("ETag"); final long lastModified = connection.getLastModified(); - final StringBuilder htmlBuilder = new StringBuilder(); + String eTag = connection.getHeaderField("ETag"); + if (eTag == null) { + eTag = ""; + } else { + eTag = eTag.trim(); + } // Parse response body. + final StringBuilder htmlBuilder = new StringBuilder(); reader = new BufferedReader(new InputStreamReader(connection.getInputStream())); final char[] buffer = new char[1024 * 4]; int n; @@ -144,7 +158,7 @@ private AppSource download() { Log.d(TAG, "Downloaded web client application"); Log.d(TAG, html); - return new AppSource(html, eTag, lastModified); + return new AppSource(html, eTag, lastModified, System.currentTimeMillis()); } Log.e(TAG, String.format("Failed to download web client application with HTTP status code %d", statusCode)); @@ -202,17 +216,16 @@ private AppSource download() { @Nullable @WorkerThread private AppSource getFromCache() { - File file = new File(this.mCacheDir.getAbsolutePath() + File.separator + "Zapic" + File.separator + "index.html.cacheEntry"); String contents; try { - contents = CacheFileUtilities.readFile(file, this); + contents = CacheFileUtilities.readFile(this.mCacheDir, APP_SOURCE_FILE_NAME, this); if (this.isCancelled()) { return null; } if (contents == null) { try { - CacheFileUtilities.deleteFile(file, this); + CacheFileUtilities.deleteFile(this.mCacheDir, APP_SOURCE_FILE_NAME, this); } catch (IOException ignored) { } @@ -225,12 +238,12 @@ private AppSource getFromCache() { try { final JSONObject json = new JSONObject(contents); - return new AppSource(json.getString("HTML"), json.getString("ETag"), json.getLong("LastModified")); + return new AppSource(json.getString("HTML"), json.getString("ETag"), json.getLong("LastModified"), json.optLong("LastValidated", 0)); } catch (JSONException e) { Log.e(TAG, "Failed to parse web client application source and version from cache", e); try { - CacheFileUtilities.deleteFile(file, this); + CacheFileUtilities.deleteFile(this.mCacheDir, APP_SOURCE_FILE_NAME, this); } catch (IOException ignored) { } @@ -258,12 +271,12 @@ private AppSource injectInitializationScript(@NonNull final AppSource appSource) "window.zapic = {" + " environment: 'webview'," + " version: 1," + - " androidVersion: '" + String.valueOf(Build.VERSION.SDK_INT) + "'," + - " onLoaded: (action$, publishAction) => {" + - " window.zapic.dispatch = (action) => {" + + " androidVersion: '" + String.valueOf(Build.VERSION.SDK_INT).replace("'", "\\'") + "'," + + " onLoaded: function (action$, publishAction) {" + + " window.zapic.dispatch = function (action) {" + " publishAction(action)" + " };" + - " action$.subscribe(action => {" + + " action$.subscribe(function (action) {" + " window.androidWebView.dispatch(JSON.stringify(action))" + " });" + " }" + @@ -273,31 +286,24 @@ private AppSource injectInitializationScript(@NonNull final AppSource appSource) final StringBuilder htmlBuilder = new StringBuilder(html); htmlBuilder.insert(endOfHead, script); - return new AppSource(htmlBuilder.toString(), appSource.getETag(), appSource.getLastModified()); + return new AppSource(htmlBuilder.toString(), appSource.getETag(), appSource.getLastModified(), appSource.getLastValidated()); } @MainThread @Override protected void onPostExecute(@Nullable AppSource appSource) { - final App app = this.mApp.get(); - if (app == null) { - return; - } - if (appSource == null) { - app.loadWebViewCancelled(); + WebViewManager.getInstance().cancelLoadApp(); } else { - app.loadWebView(appSource); + WebViewManager.getInstance().submitLoadApp(appSource); } } @MainThread @Override protected void onProgressUpdate(final Integer... values) { - final App app = this.mApp.get(); - if (app == null || !app.getConnected()) { - this.cancel(true); - } + // TODO: Bail early if it makes sense. + // this.cancel(true); } /** @@ -307,18 +313,20 @@ protected void onProgressUpdate(final Integer... values) { */ @WorkerThread private void putInCache(@NonNull final AppSource appSource) { - File file = new File(this.mCacheDir.getAbsolutePath() + File.separator + "Zapic" + File.separator + "index.html.cacheEntry"); final JSONObject json = new JSONObject(); try { json.put("HTML", appSource.getHtml()); json.put("ETag", appSource.getETag()); json.put("LastModified", appSource.getLastModified()); + if (appSource.getLastValidated() > 0) { + json.put("LastValidated", appSource.getLastValidated()); + } } catch (JSONException ignored) { // It is not clear from the JSONObject documentation why this would ever occur. } try { - CacheFileUtilities.writeFile(file, json.toString(), this); + CacheFileUtilities.writeFile(this.mCacheDir, APP_SOURCE_FILE_NAME, json.toString(), this); } catch (IOException e) { Log.i(TAG, "Failed to write web client application source and version to cache"); } diff --git a/zapic/src/main/java/com/zapic/android/sdk/CacheFileUtilities.java b/zapic/src/main/java/com/zapic/sdk/android/CacheFileUtilities.java similarity index 82% rename from zapic/src/main/java/com/zapic/android/sdk/CacheFileUtilities.java rename to zapic/src/main/java/com/zapic/sdk/android/CacheFileUtilities.java index 7086645..30c0200 100644 --- a/zapic/src/main/java/com/zapic/android/sdk/CacheFileUtilities.java +++ b/zapic/src/main/java/com/zapic/sdk/android/CacheFileUtilities.java @@ -1,4 +1,4 @@ -package com.zapic.android.sdk; +package com.zapic.sdk.android; import android.support.annotation.NonNull; import android.support.annotation.Nullable; @@ -38,12 +38,14 @@ final class CacheFileUtilities { * This is a potentially long-running, blocking task and should be invoked on a background * thread. * - * @param file The file to delete. + * @param cacheDir The cache directory. + * @param fileName The file name. * @param cancellationToken The cancellation token. * @throws IOException If repeated errors occur deleting the file. */ @WorkerThread - static void deleteFile(@NonNull final File file, @NonNull final CancellationToken cancellationToken) throws IOException { + static void deleteFile(@NonNull final File cacheDir, @NonNull final String fileName, @NonNull final CancellationToken cancellationToken) throws IOException { + final File file = new File(cacheDir, fileName); int failures = 0; while (!file.delete()) { if (cancellationToken.isCancelled()) { @@ -71,7 +73,8 @@ static void deleteFile(@NonNull final File file, @NonNull final CancellationToke * This is a potentially long-running, blocking task and should be invoked on a background * thread. * - * @param file The file to read. + * @param cacheDir The cache directory. + * @param fileName The file name. * @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. @@ -79,7 +82,8 @@ static void deleteFile(@NonNull final File file, @NonNull final CancellationToke */ @Nullable @WorkerThread - static String readFile(@NonNull final File file, @NonNull final CancellationToken cancellationToken) throws IOException { + static String readFile(@NonNull final File cacheDir, @NonNull final String fileName, @NonNull final CancellationToken cancellationToken) throws IOException { + final File file = new File(cacheDir, fileName); int failures = 0; while (true) { Reader reader = null; @@ -130,14 +134,15 @@ static String readFile(@NonNull final File file, @NonNull final CancellationToke * This is a potentially long-running, blocking task and should be invoked on a background * thread. * - * @param file The file to write. + * @param cacheDir The cache directory. + * @param fileName The file name. * @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 repeated errors creating or writing the file. */ @WorkerThread - static void writeFile(@NonNull final File file, @NonNull final String content, @NonNull final CancellationToken cancellationToken) throws IOException { + static void writeFile(@NonNull final File cacheDir, @NonNull final String fileName, @NonNull final String content, @NonNull final CancellationToken cancellationToken) throws IOException { + final File file = new File(cacheDir, fileName); int failures = 0; while (true) { Writer writer = null; diff --git a/zapic/src/main/java/com/zapic/android/sdk/CancellationToken.java b/zapic/src/main/java/com/zapic/sdk/android/CancellationToken.java similarity index 92% rename from zapic/src/main/java/com/zapic/android/sdk/CancellationToken.java rename to zapic/src/main/java/com/zapic/sdk/android/CancellationToken.java index 3997003..4b6f3d3 100644 --- a/zapic/src/main/java/com/zapic/android/sdk/CancellationToken.java +++ b/zapic/src/main/java/com/zapic/sdk/android/CancellationToken.java @@ -1,4 +1,4 @@ -package com.zapic.android.sdk; +package com.zapic.sdk.android; /** * Represents a token that can signal cancellation of a long-running task. diff --git a/zapic/src/main/java/com/zapic/sdk/android/NotificationUtilities.java b/zapic/src/main/java/com/zapic/sdk/android/NotificationUtilities.java new file mode 100644 index 0000000..00e8224 --- /dev/null +++ b/zapic/src/main/java/com/zapic/sdk/android/NotificationUtilities.java @@ -0,0 +1,49 @@ +package com.zapic.sdk.android; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Typeface; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.view.ViewCompat; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +final class NotificationUtilities { + static void showBanner(@NonNull final Context context, @NonNull final String title, @Nullable final String subtitle, @Nullable final Bitmap icon) { + final LayoutInflater layoutInflater = LayoutInflater.from(context.getApplicationContext()); + @SuppressLint("InflateParams") final View layout = layoutInflater.inflate(R.layout.component_zapic_toast, null); + + TextView titleView = ViewCompat.requireViewById(layout, R.id.component_zapic_toast_title); + titleView.setText(title); + + TextView subtitleView = ViewCompat.requireViewById(layout, R.id.component_zapic_toast_subtitle); + if (subtitle == null) { + final ViewGroup parent = (ViewGroup) subtitleView.getParent(); + parent.removeView(subtitleView); + } else { + titleView.setTypeface(titleView.getTypeface(), Typeface.BOLD); + subtitleView.setText(subtitle); + } + + ImageView imageView = ViewCompat.requireViewById(layout, R.id.component_zapic_toast_icon); + if (icon == null) { + imageView.setImageResource(R.drawable.zapic_logo_64dp); + } else { + imageView.setImageBitmap(icon); + } + + final Toast toast = new Toast(context.getApplicationContext()); + toast.setDuration(Toast.LENGTH_LONG); + toast.setGravity(Gravity.FILL_HORIZONTAL | Gravity.TOP, 0, 0); + toast.setMargin(0, 0); + toast.setView(layout); + toast.show(); + } +} diff --git a/zapic/src/main/java/com/zapic/sdk/android/WebViewJavascriptInterface.java b/zapic/src/main/java/com/zapic/sdk/android/WebViewJavascriptInterface.java new file mode 100644 index 0000000..7d7e408 --- /dev/null +++ b/zapic/src/main/java/com/zapic/sdk/android/WebViewJavascriptInterface.java @@ -0,0 +1,338 @@ +package com.zapic.sdk.android; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.Handler; +import android.os.Handler.Callback; +import android.os.Message; +import android.support.annotation.MainThread; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.WorkerThread; +import android.util.Base64; +import android.util.Log; +import android.webkit.JavascriptInterface; +import android.webkit.WebView; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.HashMap; +import java.util.Map; + +final class WebViewJavascriptInterface implements Callback { + /** + * Identifies the "APP_LOADED" action type. + */ + private static final int APP_LOADED = 1000; + + /** + * Identifies the "APP_STARTED" action type. + */ + private static final int APP_STARTED = 1001; + + /** + * Identifies the "CLOSE_PAGE_REQUESTED" action type. + */ + private static final int CLOSE_PAGE_REQUESTED = 1002; + + /** + * Identifies the "LOGIN" action type. + */ + private static final int LOGIN = 1003; + + /** + * Identifies the "LOGOUT" action type. + */ + private static final int LOGOUT = 1004; + + /** + * Identifies the "PAGE_READY" action type. + */ + private static final int PAGE_READY = 1005; + + /** + * Identifies the "SHOW_BANNER" action type. + */ + private static final int SHOW_BANNER = 1006; + + /** + * The tag used to identify log messages. + */ + @NonNull + private static final String TAG = "WebViewJavascriptInterf"; + + /** + * The application context. + */ + @NonNull + private final Context mContext; + + /** + * The handler used to dispatch messages to the main thread. + */ + @NonNull + private final Handler mHandler; + + /** + * Creates a new {@link WebViewJavascriptInterface} instance. + * + * @param context The context from which to obtain the main looper. + */ + WebViewJavascriptInterface(@NonNull final Context context) { + this.mContext = context.getApplicationContext(); + this.mHandler = new Handler(context.getApplicationContext().getMainLooper(), this); + } + + /** + * 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(TAG, "Failed to parse serialized action", e); + } + + return; + } + + String type; + try { + type = action.getString("type"); + } catch (JSONException e) { + if (BuildConfig.DEBUG) { + Log.e(TAG, "The action does not have a type", e); + } + + return; + } + + switch (type) { + case "APP_LOADED": + this.onAppLoadedDispatched(); + break; + case "APP_STARTED": + this.onAppStartedDispatched(); + break; + case "CLOSE_PAGE_REQUESTED": + this.onClosePageRequestedDispatched(); + break; + case "LOGGED_IN": + this.onLoggedInDispatched(action); + break; + case "LOGGED_OUT": + this.onLoggedOutDispatched(); + break; + case "LOGIN": + this.onLoginDispatched(); + break; + case "LOGOUT": + this.onLogoutDispatched(); + break; + case "PAGE_READY": + this.onPageReadyDispatched(); + break; + case "SHOW_BANNER": + this.onShowBannerDispatched(action); + break; + default: + break; + } + } + + @WorkerThread + private void onAppLoadedDispatched() { + this.mHandler.obtainMessage(APP_LOADED).sendToTarget(); + } + + @MainThread + private void onAppLoadedHandled() { + WebViewManager.getInstance().setLoaded(); + } + + @WorkerThread + private void onAppStartedDispatched() { + this.mHandler.obtainMessage(APP_STARTED).sendToTarget(); + } + + @MainThread + private void onAppStartedHandled() { + final WebViewManager webViewManager = WebViewManager.getInstance(); + webViewManager.setStarted(); + + final ZapicActivity activity = webViewManager.getActivity(); + if (activity == null) { + return; + } + + final WebView webView = webViewManager.getWebView(); + webView.evaluateJavascript("window.zapic.dispatch({ type: 'OPEN_PAGE', payload: '" + activity.getPageParameter() + "' })", null); + } + + @WorkerThread + private void onClosePageRequestedDispatched() { + this.mHandler.obtainMessage(CLOSE_PAGE_REQUESTED).sendToTarget(); + } + + @MainThread + private void onClosePageRequestedHandled() { + WebViewManager.getInstance().finishActivity(); + } + + @WorkerThread + private void onLoggedInDispatched(@NonNull final JSONObject action) { + String userId; + try { + final JSONObject payload = action.getJSONObject("payload"); + userId = payload.getString("userId"); + } catch (JSONException ignored) { + // TODO: Send an error to the JavaScript application. + return; + } + + if (userId.equals("")) { + // TODO: Send an error to the JavaScript application. + return; + } + + Zapic.setPlayerId(userId); + } + + @WorkerThread + private void onLoggedOutDispatched() { + Zapic.setPlayerId(null); + } + + @WorkerThread + private void onLoginDispatched() { + this.mHandler.obtainMessage(LOGIN).sendToTarget(); + } + + @MainThread + private void onLoginHandled() { + WebViewManager.getInstance().login(); + } + + @WorkerThread + private void onLogoutDispatched() { + this.mHandler.obtainMessage(LOGOUT).sendToTarget(); + } + + @MainThread + private void onLogoutHandled() { + WebViewManager.getInstance().logout(); + } + + @WorkerThread + private void onPageReadyDispatched() { + this.mHandler.obtainMessage(PAGE_READY).sendToTarget(); + } + + @MainThread + private void onPageReadyHandled() { + WebViewManager.getInstance().showAppFragment(); + } + + @WorkerThread + private void onShowBannerDispatched(@NonNull final JSONObject action) { + String title; + String subtitle; + String encodedIcon; + try { + final JSONObject payload = action.getJSONObject("payload"); + title = payload.getString("title"); + subtitle = payload.optString("subtitle"); + encodedIcon = payload.optString("icon"); + } catch (JSONException e) { + // TODO: Send an error to the JavaScript application. + return; + } + + if (title.equals("")) { + // TODO: Send an error to the JavaScript application. + return; + } + + if (subtitle.equals("")) { + subtitle = null; + } + + Bitmap icon; + if (encodedIcon.equals("")) { + icon = null; + } else { + try { + byte[] imageBytes = Base64.decode(encodedIcon, Base64.DEFAULT); + icon = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.length); + } catch (IllegalArgumentException e) { + if (BuildConfig.DEBUG) { + Log.e(TAG, "Failed to parse icon", e); + } + + icon = null; + } + + if (icon != null) { + final int size = this.mContext.getResources().getDimensionPixelSize(R.dimen.component_zapic_toast_icon_size); + icon = Bitmap.createScaledBitmap(icon, size, size, false); + } + } + + Map args = new HashMap<>(); + args.put("title", title); + args.put("subtitle", subtitle); + args.put("icon", icon); + this.mHandler.obtainMessage(SHOW_BANNER, args).sendToTarget(); + } + + @MainThread + private void onShowBannerHandled(Map args) { + NotificationUtilities.showBanner(this.mContext, (String) args.get("title"), (String) args.get("subtitle"), (Bitmap) args.get("icon")); + } + + @Override + public boolean handleMessage(@Nullable final Message msg) { + if (msg == null) { + return false; + } + + @SuppressWarnings("unchecked") final Map args = (Map) msg.obj; + switch (msg.what) { + case APP_LOADED: + this.onAppLoadedHandled(); + break; + case APP_STARTED: + this.onAppStartedHandled(); + break; + case CLOSE_PAGE_REQUESTED: + this.onClosePageRequestedHandled(); + break; + case LOGIN: + this.onLoginHandled(); + break; + case LOGOUT: + this.onLogoutHandled(); + break; + case PAGE_READY: + this.onPageReadyHandled(); + break; + case SHOW_BANNER: + this.onShowBannerHandled(args); + break; + default: + break; + } + + return true; + } +} diff --git a/zapic/src/main/java/com/zapic/sdk/android/WebViewManager.java b/zapic/src/main/java/com/zapic/sdk/android/WebViewManager.java new file mode 100644 index 0000000..08048ed --- /dev/null +++ b/zapic/src/main/java/com/zapic/sdk/android/WebViewManager.java @@ -0,0 +1,641 @@ +package com.zapic.sdk.android; + +import android.annotation.SuppressLint; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.content.MutableContextWrapper; +import android.net.Uri; +import android.net.http.SslError; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Handler; +import android.os.Handler.Callback; +import android.os.Message; +import android.support.annotation.AnyThread; +import android.support.annotation.MainThread; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.RequiresApi; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.util.Log; +import android.view.View; +import android.webkit.RenderProcessGoneDetail; +import android.webkit.SslErrorHandler; +import android.webkit.ValueCallback; +import android.webkit.WebChromeClient; +import android.webkit.WebResourceError; +import android.webkit.WebResourceRequest; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import org.json.JSONObject; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.LinkedList; + +final class WebViewManager { + /** + * The tag used to identify log messages. + */ + @NonNull + private static final String TAG = "WebViewManager"; + + /** + * The Zapic JavaScript application source URL. + */ + @NonNull + private static final String APP_SOURCE_URL = "https://app.zapic.net"; + + /** + * A weak reference to the {@link WebViewManager} instance. + *

+ * A strong reference to the {@link WebViewManager} instance is kept by {@link ZapicActivity} + * and {@link ZapicFragment}. + */ + @NonNull + private static WeakReference INSTANCE = new WeakReference<>(null); + + /** + * The JavaScript variable name of the {@link WebViewJavascriptInterface} instance. + */ + @NonNull + private static final String VARIABLE_NAME = "androidWebView"; + + @AnyThread + static WebViewManager getInstance() { + WebViewManager instance = WebViewManager.INSTANCE.get(); + if (instance == null) { + synchronized (WebViewManager.class) { + instance = WebViewManager.INSTANCE.get(); + if (instance == null) { + instance = new WebViewManager(); + WebViewManager.INSTANCE = new WeakReference<>(instance); + } + } + } + + return instance; + } + + /** + * The {@link ZapicActivity} instance. + */ + @Nullable + private ZapicActivity mActivity; + + @Nullable + private AsyncTask mAsyncTask; + + /** + * The list of {@link ZapicFragment} instances. + */ + @NonNull + private final ArrayList mFragments; + + /** + * The handler used to dispatch messages to the main thread. + */ + @Nullable + private Handler mHandler; + + @Nullable + private ValueCallback mImageUploadCallback; + + private boolean mLoaded; + + @NonNull + private final LinkedList mPendingEvents; + + @NonNull + private final Object mPendingEventsLock; + + private boolean mStarted; + + @Nullable + private WebView mWebView; + + @AnyThread + private WebViewManager() { + this.mActivity = null; + this.mAsyncTask = null; + this.mFragments = new ArrayList<>(); + this.mHandler = null; + this.mImageUploadCallback = null; + this.mLoaded = false; + this.mPendingEvents = new LinkedList<>(); + this.mPendingEventsLock = new Object(); + this.mStarted = false; + this.mWebView = null; + } + + @MainThread + void cancelImageUpload() { + if (this.mImageUploadCallback != null) { + this.mImageUploadCallback.onReceiveValue(null); + this.mImageUploadCallback = null; + } + } + + @MainThread + void cancelLoadApp() { + this.showOfflineFragment(); + } + + @MainThread + @SuppressLint("SetJavaScriptEnabled") + private void createWebView(@NonNull final Context context) { + if (BuildConfig.DEBUG) { + // 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); + } + + this.mWebView = new WebView(new MutableContextWrapper(context.getApplicationContext())); + this.mWebView.addJavascriptInterface(new WebViewJavascriptInterface(context), VARIABLE_NAME); + this.mWebView.setLayerType(View.LAYER_TYPE_HARDWARE, null); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + this.mWebView.setRendererPriorityPolicy(WebView.RENDERER_PRIORITY_BOUND, true); + } + + WebSettings webSettings = this.mWebView.getSettings(); + webSettings.setDomStorageEnabled(true); + webSettings.setJavaScriptEnabled(true); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + webSettings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); + } + +// These were from the old project: +// webSettings.setAllowContentAccess(false); +// webSettings.setAllowFileAccess(false); +// webSettings.setAllowFileAccessFromFileURLs(false); +// webSettings.setAllowUniversalAccessFromFileURLs(false); +// webSettings.setGeolocationEnabled(false); +// webSettings.setSaveFormData(false); +// webSettings.setSupportZoom(false); + +// These are in the Android-SmartWebView: +// webSettings.setSaveFormData(ASWP_SFORM); +// webSettings.setSupportZoom(ASWP_ZOOM); +// webSettings.setGeolocationEnabled(ASWP_LOCATION); +// webSettings.setAllowFileAccess(true); +// webSettings.setAllowFileAccessFromFileURLs(true); +// webSettings.setAllowUniversalAccessFromFileURLs(true); +// webSettings.setUseWideViewPort(true); +// webSettings.setDomStorageEnabled(true); +// webSettings.setLayoutAlgorithm(WebSettings.LayoutAlgorithm.NARROW_COLUMNS); + + this.mWebView.setWebChromeClient(new WebChromeClient() { + @Override + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + public boolean onShowFileChooser(@NonNull final WebView webView, @NonNull final ValueCallback filePathCallback, @NonNull final FileChooserParams fileChooserParams) { + if (WebViewManager.this.mActivity == null) { + return false; + } + + String[] acceptTypes = fileChooserParams.getAcceptTypes(); + if (acceptTypes == null || acceptTypes.length != 1 || !acceptTypes[0].equalsIgnoreCase("image/*") || fileChooserParams.getMode() != FileChooserParams.MODE_OPEN) { + return false; + } + + WebViewManager.this.mImageUploadCallback = filePathCallback; + WebViewManager.this.mActivity.showImagePrompt(); + return true; + } + }); + + this.mWebView.setWebViewClient(new WebViewClient() { + @Override + @RequiresApi(Build.VERSION_CODES.M) + public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { + // TODO: Show an error to the user. + } + + @Override + public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { + // TODO: Show an error to the user. + } + + @Override + public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) { + // TODO: Show an error to the user. + } + + @Override + @RequiresApi(Build.VERSION_CODES.O) + public boolean onRenderProcessGone(WebView view, RenderProcessGoneDetail detail) { + if (WebViewManager.this.mWebView != view) { + return false; + } + + if (BuildConfig.DEBUG) { + final 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"); + } + } + + if (WebViewManager.this.mActivity != null) { + WebViewManager.this.finishActivity(); + } + + WebViewManager.this.mImageUploadCallback = null; + + if (WebViewManager.this.mAsyncTask != null) { + WebViewManager.this.mAsyncTask.cancel(true); + WebViewManager.this.mAsyncTask = null; + } + + if (WebViewManager.this.mWebView != null) { + WebViewManager.this.mWebView.destroy(); + WebViewManager.this.mWebView = null; + WebViewManager.this.mLoaded = false; + WebViewManager.this.mStarted = false; + } + + return true; + } + + @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 && WebViewManager.this.overrideUrl(view, request.getUrl()); + } + + @Override + public boolean shouldOverrideUrlLoading(@Nullable final WebView view, @Nullable final String url) { + return !(view == null || url == null) && WebViewManager.this.overrideUrl(view, Uri.parse(url)); + } + }); + } + + @MainThread + private void createEventHandler(@NonNull final Context context) { + if (this.mHandler == null) { + this.mHandler = new Handler(context.getApplicationContext().getMainLooper(), new Callback() { + @Override + public boolean handleMessage(@Nullable final Message msg) { + if (msg == null || msg.what != 0) { + return false; + } + + final JSONObject event = (JSONObject) msg.obj; + if (WebViewManager.this.mWebView == null) { + synchronized (WebViewManager.this.mPendingEventsLock) { + if (WebViewManager.this.mPendingEvents.size() == 1000) { + WebViewManager.this.mPendingEvents.poll(); + } + + WebViewManager.this.mPendingEvents.add(event); + } + } else { + WebViewManager.this.mWebView.evaluateJavascript("window.zapic.dispatch({ type: 'SUBMIT_EVENT', payload: " + event.toString() + " })", null); + } + + return true; + } + }); + } + } + + @MainThread + void finishActivity() { + if (this.mActivity == null) { + throw new IllegalStateException("A ZapicActivity has not been created"); + } + + this.mActivity.finish(); + } + + @Nullable + ZapicActivity getActivity() { + return this.mActivity; + } + + @NonNull + WebView getWebView() { + WebView webView = this.mWebView; + assert webView != null : "mWebView is null"; + return webView; + } + + void login() { + final int size = this.mFragments.size(); + if (size == 0) { + return; + } + + final ZapicFragment fragment = this.mFragments.get(size - 1); + fragment.login(); + } + + @MainThread + void loginFailed(@NonNull final String message) { + if (this.mWebView != null) { + this.mWebView.evaluateJavascript("window.zapic.dispatch({ type: 'LOGIN_WITH_PLAY_GAME_SERVICES', error: true, payload: '" + message.replace("'", "\\'") + "' })", null); + } + } + + @MainThread + void loginSucceeded(@NonNull final String packageName, @NonNull final String serverAuthCode) { + if (this.mWebView != null) { + this.mWebView.evaluateJavascript("window.zapic.dispatch({ type: 'LOGIN_WITH_PLAY_GAME_SERVICES', payload: { authCode: '" + serverAuthCode.replace("'", "\\'") + "', packageName: '" + packageName.replace("'", "\\'") + "' } })", null); + } + } + + void logout() { + final int size = this.mFragments.size(); + if (size == 0) { + return; + } + + final ZapicFragment fragment = this.mFragments.get(size - 1); + fragment.logout(); + } + + @MainThread + void onActivityCreated(@NonNull final ZapicActivity activity) { + this.mActivity = activity; + if (this.mWebView == null) { + if (this.mAsyncTask != null) { + this.mAsyncTask.cancel(true); + } + + this.createEventHandler(activity); + this.createWebView(activity); + this.mAsyncTask = new AppSourceAsyncTask(APP_SOURCE_URL, activity.getApplicationContext().getCacheDir()).execute(); + } else if (this.mStarted) { + this.mWebView.evaluateJavascript("window.zapic.dispatch({ type: 'OPEN_PAGE', payload: '" + activity.getPageParameter() + "' })", null); + } + + this.showLoadingFragment(); + } + + @MainThread + void onActivityDestroyed(@NonNull final ZapicActivity activity) { + if (this.mActivity == activity) { + this.mActivity = null; + if (this.mFragments.size() == 0) { + this.mImageUploadCallback = null; + + if (this.mAsyncTask != null) { + this.mAsyncTask.cancel(true); + this.mAsyncTask = null; + } + + if (this.mWebView != null) { + this.mWebView.destroy(); + this.mWebView = null; + this.mLoaded = false; + this.mStarted = false; + } + } else if (this.mWebView != null) { + this.mWebView.evaluateJavascript("window.zapic.dispatch({ type: 'CLOSE_PAGE' })", null); + } + } + } + + @MainThread + void onActivityStarted() { + if (this.mWebView == null) { + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + this.mWebView.getSettings().setOffscreenPreRaster(true); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + this.mWebView.setRendererPriorityPolicy(WebView.RENDERER_PRIORITY_IMPORTANT, false); + } + } + + @MainThread + void onActivityStopped() { + if (this.mWebView == null) { + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + this.mWebView.getSettings().setOffscreenPreRaster(false); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + this.mWebView.setRendererPriorityPolicy(WebView.RENDERER_PRIORITY_BOUND, true); + } + } + + @MainThread + void onFragmentCreated(@NonNull final ZapicFragment fragment) { + this.mFragments.add(fragment); + if (this.mWebView == null) { + if (this.mAsyncTask != null) { + this.mAsyncTask.cancel(true); + } + + this.createEventHandler(fragment.getActivity()); + this.createWebView(fragment.getActivity()); + this.mAsyncTask = new AppSourceAsyncTask(APP_SOURCE_URL, fragment.getActivity().getApplicationContext().getCacheDir()).execute(); + } + } + + @MainThread + void onFragmentDestroyed(@NonNull final ZapicFragment fragment) { + this.mFragments.remove(fragment); + if (this.mActivity == null && this.mFragments.size() == 0) { + this.mImageUploadCallback = null; + + if (this.mAsyncTask != null) { + this.mAsyncTask.cancel(true); + this.mAsyncTask = null; + } + + if (this.mWebView != null) { + this.mWebView.destroy(); + this.mWebView = null; + this.mLoaded = false; + this.mStarted = false; + } + } + } + + @MainThread + private boolean overrideUrl(@NonNull final WebView view, @NonNull final Uri url) { + final String scheme = url.getScheme(); + final String host = url.getHost(); + + 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 e) { + // TODO: Send an error to the JavaScript application. + } + + // Prevent the WebView from navigating to an invalid URL. + return true; + } + + if (scheme != null && scheme.equalsIgnoreCase("tel")) { + try { + // Create a phone app intent. + Intent intent = new Intent(Intent.ACTION_DIAL); + intent.setData(url); + + // Open the phone app. + final Context context = view.getContext(); + context.startActivity(intent); + } catch (ActivityNotFoundException e) { + // TODO: Send an error to the JavaScript application. + } + } + + if (host != null && (host.equalsIgnoreCase("itunes.apple.com") || host.toLowerCase().endsWith(".itunes.apple.com"))) { + if (scheme != null && (scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https"))) { + try { + // 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); + } catch (ActivityNotFoundException e) { + // TODO: Send an error to the JavaScript application. + } + } + + // 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"))) { + try { + // 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); + } catch (ActivityNotFoundException e) { + // TODO: Send an error to the JavaScript application. + } + } + + // Prevent the WebView from navigating to an external URL. + return true; + } + + return false; + } + + @MainThread + void setLoaded() { + this.mLoaded = true; + } + + @MainThread + void setStarted() { + assert this.mWebView != null : "mWebView is null"; + this.mStarted = true; + + synchronized (this.mPendingEventsLock) { + JSONObject event; + while ((event = this.mPendingEvents.poll()) != null) { + this.mWebView.evaluateJavascript("window.zapic.dispatch({ type: 'SUBMIT_EVENT', payload: " + event.toString() + " })", null); + } + } + } + + @MainThread + void showAppFragment() { + if (this.mActivity == null) { + throw new IllegalStateException("A ZapicActivity has not been created"); + } + + final FragmentManager fragmentManager = this.mActivity.getSupportFragmentManager(); + final Fragment currentFragment = fragmentManager.findFragmentById(R.id.activity_zapic_container); + if (currentFragment == null) { + fragmentManager.beginTransaction().add(R.id.activity_zapic_container, new ZapicAppFragment()).commit(); + } else if (!(currentFragment instanceof ZapicAppFragment)) { + fragmentManager.beginTransaction().replace(R.id.activity_zapic_container, new ZapicAppFragment()).commit(); + } + } + + @MainThread + private void showLoadingFragment() { + if (this.mActivity == null) { + throw new IllegalStateException("A ZapicActivity has not been created"); + } + + final FragmentManager fragmentManager = this.mActivity.getSupportFragmentManager(); + final Fragment currentFragment = fragmentManager.findFragmentById(R.id.activity_zapic_container); + if (currentFragment == null) { + fragmentManager.beginTransaction().add(R.id.activity_zapic_container, new ZapicLoadingFragment()).commit(); + } else if (!(currentFragment instanceof ZapicLoadingFragment)) { + fragmentManager.beginTransaction().replace(R.id.activity_zapic_container, new ZapicLoadingFragment()).commit(); + } + } + + @MainThread + private void showOfflineFragment() { + if (this.mActivity == null) { + throw new IllegalStateException("A ZapicActivity has not been created"); + } + + final FragmentManager fragmentManager = this.mActivity.getSupportFragmentManager(); + final Fragment currentFragment = fragmentManager.findFragmentById(R.id.activity_zapic_container); + if (currentFragment == null) { + fragmentManager.beginTransaction().add(R.id.activity_zapic_container, new ZapicOfflineFragment()).commit(); + } else if (!(currentFragment instanceof ZapicOfflineFragment)) { + fragmentManager.beginTransaction().replace(R.id.activity_zapic_container, new ZapicOfflineFragment()).commit(); + } + } + + @MainThread + void submitImageUpload(@NonNull final Uri[] files) { + if (this.mImageUploadCallback != null) { + this.mImageUploadCallback.onReceiveValue(files); + this.mImageUploadCallback = null; + } + } + + @AnyThread + void submitEvent(@NonNull final JSONObject event) { + if (this.mHandler == null || this.mWebView == null || !this.mStarted) { + synchronized (this.mPendingEventsLock) { + if (this.mPendingEvents.size() == 1000) { + this.mPendingEvents.poll(); + } + + this.mPendingEvents.add(event); + } + } else { + this.mHandler.obtainMessage(0, event).sendToTarget(); + } + } + + @MainThread + void submitLoadApp(@NonNull final AppSource appSource) { + if (this.mWebView != null) { + this.mWebView.loadDataWithBaseURL(APP_SOURCE_URL, appSource.getHtml(), "text/html", "utf-8", APP_SOURCE_URL); + } + } +} diff --git a/zapic/src/main/java/com/zapic/android/sdk/Zapic.java b/zapic/src/main/java/com/zapic/sdk/android/Zapic.java similarity index 83% rename from zapic/src/main/java/com/zapic/android/sdk/Zapic.java rename to zapic/src/main/java/com/zapic/sdk/android/Zapic.java index 8104dde..e5ae292 100644 --- a/zapic/src/main/java/com/zapic/android/sdk/Zapic.java +++ b/zapic/src/main/java/com/zapic/sdk/android/Zapic.java @@ -1,4 +1,4 @@ -package com.zapic.android.sdk; +package com.zapic.sdk.android; import android.app.Activity; import android.app.Fragment; @@ -20,12 +20,12 @@ * 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). + * {@link ZapicFragment} downloads and runs the Zapic JavaScript application in the background. 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). *

* 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 @@ -76,7 +76,7 @@ public static void attachFragment(@Nullable final Activity gameActivity) { final FragmentManager manager = gameActivity.getFragmentManager(); final Fragment fragment = manager.findFragmentByTag(Zapic.FRAGMENT_TAG); if (fragment == null) { - manager.beginTransaction().add(ZapicFragment.createInstance(), Zapic.FRAGMENT_TAG).commit(); + manager.beginTransaction().add(new ZapicFragment(), Zapic.FRAGMENT_TAG).commit(); } } @@ -103,21 +103,6 @@ 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. * @@ -205,7 +190,7 @@ public static void show(@Nullable final Activity gameActivity, @SuppressWarnings * {@link #attachFragment(Activity)}; if {@code parameters} is * not a valid JSON object. */ - @MainThread + @AnyThread @SuppressWarnings({"UnusedDeclaration", "WeakerAccess"}) // documented as public API public static void submitEvent(@Nullable final Activity gameActivity, @Nullable final String parameters) { if (gameActivity == null) { @@ -216,16 +201,6 @@ public static void submitEvent(@Nullable final Activity gameActivity, @Nullable 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); @@ -240,6 +215,6 @@ public static void submitEvent(@Nullable final Activity gameActivity, @Nullable return; } - app.submitEvent(gameplayEvent); + WebViewManager.getInstance().submitEvent(gameplayEvent); } } diff --git a/zapic/src/main/java/com/zapic/sdk/android/ZapicActivity.java b/zapic/src/main/java/com/zapic/sdk/android/ZapicActivity.java new file mode 100644 index 0000000..25726a7 --- /dev/null +++ b/zapic/src/main/java/com/zapic/sdk/android/ZapicActivity.java @@ -0,0 +1,519 @@ +package com.zapic.sdk.android; + +import android.Manifest; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.ActivityNotFoundException; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.provider.MediaStore; +import android.provider.Settings; +import android.support.annotation.CheckResult; +import android.support.annotation.MainThread; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.ActivityCompat; +import android.support.v4.app.FragmentActivity; +import android.support.v4.content.ContextCompat; +import android.support.v4.content.FileProvider; +import android.transition.Slide; +import android.util.Log; +import android.view.View; +import android.view.View.OnSystemUiVisibilityChangeListener; +import android.widget.Toast; + +import java.io.File; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class ZapicActivity extends FragmentActivity { + /** + * Identifies an action request to capture an image from the device camera. + */ + private static final int IMAGE_CAMERA_REQUEST = 1000; + + /** + * Identifies a permission request to capture an image from the device camera. + */ + private static final int IMAGE_CAMERA_PERMISSION_REQUEST = 1001; + + /** + * Identifies an action request to import an image from the media library. + */ + private static final int IMAGE_LIBRARY_REQUEST = 1002; + + /** + * Identifies a permission request to import an image from the media library. + */ + private static final int IMAGE_LIBRARY_PERMISSION_REQUEST = 1003; + + /** + * The {@link Intent} parameter that identifies the Zapic JavaScript application page to open. + */ + @NonNull + private static final String PAGE_PARAMETER = "page"; + + /** + * The tag used to identify log messages. + */ + @NonNull + private static final String TAG = "ZapicActivity"; + + /** + * The file to use to capture an image from the device camera. + */ + @Nullable + private File mImageCameraFile; + + /** + * A strong reference to the {@link WebViewManager} instance. + */ + @Nullable + private WebViewManager mWebViewManager; + + /** + * Creates a new {@link ZapicActivity} instance. + */ + public ZapicActivity() { + this.mImageCameraFile = null; + this.mWebViewManager = null; + } + + /** + * Creates an {@link Intent} that starts a {@link ZapicActivity} and opens the default Zapic + * JavaScript application page. + * + * @param gameActivity The game's activity. + * @return The {@link Intent}. + */ + @MainThread + @CheckResult + @NonNull + public static Intent createIntent(@NonNull final Activity gameActivity) { + return ZapicActivity.createIntent(gameActivity, "default"); + } + + /** + * Creates an {@link Intent} that starts a {@link ZapicActivity} and opens the specified Zapic + * JavaScript application page. + * + * @param gameActivity The game's activity. + * @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(PAGE_PARAMETER, page); + return intent; + } + + /** + * 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. + */ + 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); + } + + @NonNull + String getPageParameter() { + final Bundle parameters = this.getIntent().getExtras(); + if (parameters == null) { + return "default"; + } + + final String page = parameters.getString(PAGE_PARAMETER); + if (page == null || page.equals("")) { + return "default"; + } + + return page; + } + + @Override + protected void onActivityResult(final int requestCode, final int resultCode, @NonNull final Intent data) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onActivityResult"); + } + + super.onActivityResult(requestCode, resultCode, data); + switch (requestCode) { + case IMAGE_CAMERA_REQUEST: { + final Uri[] files = resultCode == RESULT_OK && this.mImageCameraFile != null + ? new Uri[]{FileProvider.getUriForFile(this.getApplicationContext(), this.getApplicationContext().getPackageName() + ".zapic", this.mImageCameraFile)} + : null; + if (files == null) { + assert this.mWebViewManager != null : "mWebViewManager is null"; + this.mWebViewManager.cancelImageUpload(); + } else { + assert this.mWebViewManager != null : "mWebViewManager is null"; + this.mWebViewManager.submitImageUpload(files); + } + + this.mImageCameraFile = null; + break; + } + case IMAGE_LIBRARY_REQUEST: { + final Uri[] files = resultCode == RESULT_OK && data.getData() != null + ? new Uri[]{data.getData()} + : null; + if (files == null) { + assert this.mWebViewManager != null : "mWebViewManager is null"; + this.mWebViewManager.cancelImageUpload(); + } else { + assert this.mWebViewManager != null : "mWebViewManager is null"; + this.mWebViewManager.submitImageUpload(files); + } + + break; + } + default: + break; + } + } + + @Override + protected void onCreate(@Nullable final Bundle savedInstanceState) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onCreate"); + } + + this.enableImmersiveFullScreenMode(); + this.getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(new OnSystemUiVisibilityChangeListener() { + @Override + public void onSystemUiVisibilityChange(final int visibility) { + if ((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) { + ZapicActivity.this.enableImmersiveFullScreenMode(); + } + } + }); + + super.onCreate(savedInstanceState); + this.setContentView(R.layout.activity_zapic); + if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + final Slide slide = new Slide(); + slide.setDuration(150); + this.getWindow().setEnterTransition(slide); + } + + Zapic.attachFragment(this); + + assert this.mWebViewManager == null : "mWebViewManager is not null"; + this.mWebViewManager = WebViewManager.getInstance(); + this.mWebViewManager.onActivityCreated(this); + } + + @Override + public void onDestroy() { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onDestroy"); + } + + super.onDestroy(); + + Zapic.detachFragment(this); + + assert this.mWebViewManager != null : "mWebViewManager is null"; + this.mWebViewManager.onActivityDestroyed(this); + this.mWebViewManager = null; + } + + @Override + protected void onNewIntent(@NonNull final Intent intent) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onNewIntent"); + } + + super.onNewIntent(intent); + this.setIntent(intent); + } + + @Override + protected void onPause() { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onPause"); + } + + super.onPause(); + } + + @Override + public void onRequestPermissionsResult(final int requestCode, @NonNull final String[] permissions, @NonNull final int[] grantResults) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onRequestPermissionsResult"); + } + + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + switch (requestCode) { + case IMAGE_CAMERA_PERMISSION_REQUEST: + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + this.startImageCameraActivity(); + } else { + assert this.mWebViewManager != null : "mWebViewManager is null"; + this.mWebViewManager.cancelImageUpload(); + } + + break; + case IMAGE_LIBRARY_PERMISSION_REQUEST: + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + this.startImageLibraryActivity(); + } else { + assert this.mWebViewManager != null : "mWebViewManager is null"; + this.mWebViewManager.cancelImageUpload(); + } + + break; + default: + break; + } + } + + @Override + protected void onRestart() { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onRestart"); + } + + super.onRestart(); + } + + @Override + protected void onResume() { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onResume"); + } + + this.enableImmersiveFullScreenMode(); + this.getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(new OnSystemUiVisibilityChangeListener() { + @Override + public void onSystemUiVisibilityChange(final int visibility) { + if ((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) { + ZapicActivity.this.enableImmersiveFullScreenMode(); + } + } + }); + + super.onResume(); + } + + @Override + protected void onStart() { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onStart"); + } + + super.onStart(); + + assert this.mWebViewManager != null : "mWebViewManager is null"; + this.mWebViewManager.onActivityStarted(); + } + + @Override + protected void onStop() { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onStop"); + } + + super.onStop(); + + assert this.mWebViewManager != null : "mWebViewManager is null"; + this.mWebViewManager.onActivityStopped(); + } + + @Override + public void onWindowFocusChanged(final boolean hasFocus) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onWindowFocusChanged"); + } + + super.onWindowFocusChanged(hasFocus); + if (hasFocus) { + this.enableImmersiveFullScreenMode(); + } + } + + private void showImagePermissionPrompt(final int permission) { + int message; + switch (permission) { + case IMAGE_CAMERA_PERMISSION_REQUEST: + message = R.string.zapic_activity_image_permission_camera_message; + break; + case IMAGE_LIBRARY_PERMISSION_REQUEST: + message = R.string.zapic_activity_image_permission_library_message; + break; + default: + return; + } + + final DialogInterface.OnClickListener dialogClickListener = new DialogInterface.OnClickListener() { + @Override + public void onClick(@NonNull final DialogInterface dialog, final int which) { + if (which == AlertDialog.BUTTON_POSITIVE) { + ZapicActivity.this.startSettingsActivity(); + } + + dialog.dismiss(); + } + }; + + new AlertDialog.Builder(this) + .setTitle(R.string.zapic_activity_image_permission_title) + .setMessage(message) + .setPositiveButton(R.string.zapic_activity_image_permission_submit, dialogClickListener) + .create() + .show(); + } + + void showImagePrompt() { + final AtomicBoolean handled = new AtomicBoolean(false); + + final DialogInterface.OnDismissListener dialogDismissListener = new DialogInterface.OnDismissListener() { + @Override + public void onDismiss(@NonNull final DialogInterface dialog) { + if (handled.getAndSet(true)) { + return; + } + + assert ZapicActivity.this.mWebViewManager != null : "mWebViewManager is null"; + ZapicActivity.this.mWebViewManager.cancelImageUpload(); + } + }; + + final DialogInterface.OnClickListener dialogClickListener = new DialogInterface.OnClickListener() { + @Override + public void onClick(@NonNull final DialogInterface dialog, final int which) { + if (handled.getAndSet(true)) { + return; + } + + switch (which) { + case 0: { + // Check if the camera permission is declared in the manifest. + boolean hasDeclaredCameraPermission = false; + try { + final PackageInfo packageInfo = ZapicActivity.this.getApplicationContext().getPackageManager().getPackageInfo(ZapicActivity.this.getApplicationContext().getPackageName(), PackageManager.GET_PERMISSIONS); + final String[] permissions = packageInfo.requestedPermissions; + if (permissions != null) { + for (String permission : permissions) { + if (permission.equals(Manifest.permission.CAMERA)) { + hasDeclaredCameraPermission = true; + break; + } + } + } + } catch (NameNotFoundException ignored) { + } + + // The camera permission is automatically granted if the camera permission is *not* + // declared in the manifest. + if (!hasDeclaredCameraPermission || ContextCompat.checkSelfPermission(ZapicActivity.this.getApplicationContext(), Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + ZapicActivity.this.startImageCameraActivity(); + } else if (ActivityCompat.shouldShowRequestPermissionRationale(ZapicActivity.this, Manifest.permission.CAMERA)) { + assert ZapicActivity.this.mWebViewManager != null : "mWebViewManager is null"; + ZapicActivity.this.mWebViewManager.cancelImageUpload(); + ZapicActivity.this.showImagePermissionPrompt(IMAGE_CAMERA_PERMISSION_REQUEST); + } else { + ActivityCompat.requestPermissions(ZapicActivity.this, new String[]{Manifest.permission.CAMERA}, IMAGE_CAMERA_PERMISSION_REQUEST); + } + + break; + } + case 1: + if (ContextCompat.checkSelfPermission(ZapicActivity.this.getApplicationContext(), Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) { + ZapicActivity.this.startImageLibraryActivity(); + } else if (ActivityCompat.shouldShowRequestPermissionRationale(ZapicActivity.this, Manifest.permission.READ_EXTERNAL_STORAGE)) { + assert ZapicActivity.this.mWebViewManager != null : "mWebViewManager is null"; + ZapicActivity.this.mWebViewManager.cancelImageUpload(); + ZapicActivity.this.showImagePermissionPrompt(IMAGE_LIBRARY_PERMISSION_REQUEST); + } else { + ActivityCompat.requestPermissions(ZapicActivity.this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, IMAGE_LIBRARY_PERMISSION_REQUEST); + } + + break; + default: + assert ZapicActivity.this.mWebViewManager != null : "mWebViewManager is null"; + ZapicActivity.this.mWebViewManager.cancelImageUpload(); + break; + } + + dialog.dismiss(); + } + }; + + new AlertDialog + .Builder(this) + .setTitle(R.string.zapic_activity_image_source_title) + .setItems(R.array.zapic_activity_image_source_items, dialogClickListener) + .setNegativeButton(R.string.zapic_activity_image_source_cancel, dialogClickListener) + .setOnDismissListener(dialogDismissListener) + .create() + .show(); + } + + private void startImageCameraActivity() { + final File filesDir = this.getApplicationContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES); + if (filesDir == null) { + assert this.mWebViewManager != null : "mWebViewManager is null"; + this.mWebViewManager.cancelImageUpload(); + + Toast.makeText(this.getApplicationContext(), R.string.zapic_activity_folder_error, Toast.LENGTH_SHORT).show(); + } else { + this.mImageCameraFile = new File(filesDir.getAbsolutePath() + File.separator + "IMG_" + new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()) + ".jpg"); + final Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); + intent.putExtra(MediaStore.EXTRA_OUTPUT, FileProvider.getUriForFile(this.getApplicationContext(), this.getApplicationContext().getPackageName() + ".zapic", this.mImageCameraFile)); + try { + this.startActivityForResult(intent, IMAGE_CAMERA_REQUEST); + } catch (ActivityNotFoundException e) { + assert this.mWebViewManager != null : "mWebViewManager is null"; + this.mWebViewManager.cancelImageUpload(); + + Toast.makeText(this.getApplicationContext(), R.string.zapic_activity_camera_error, Toast.LENGTH_SHORT).show(); + } + } + } + + private void startImageLibraryActivity() { + final Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("image/*"); + try { + this.startActivityForResult(intent, IMAGE_LIBRARY_REQUEST); + } catch (ActivityNotFoundException e) { + assert this.mWebViewManager != null : "mWebViewManager is null"; + this.mWebViewManager.cancelImageUpload(); + + Toast.makeText(this.getApplicationContext(), R.string.zapic_activity_library_error, Toast.LENGTH_SHORT).show(); + } + } + + private void startSettingsActivity() { + final Intent intent = new Intent(); + intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + intent.setData(Uri.fromParts("package", this.getApplicationContext().getPackageName(), null)); + try { + this.startActivity(intent); + } catch (ActivityNotFoundException e) { + Toast.makeText(this.getApplicationContext(), R.string.zapic_activity_settings_error, Toast.LENGTH_SHORT).show(); + } + } +} diff --git a/zapic/src/main/java/com/zapic/sdk/android/ZapicAppFragment.java b/zapic/src/main/java/com/zapic/sdk/android/ZapicAppFragment.java new file mode 100644 index 0000000..16031c4 --- /dev/null +++ b/zapic/src/main/java/com/zapic/sdk/android/ZapicAppFragment.java @@ -0,0 +1,83 @@ +package com.zapic.sdk.android; + +import android.content.MutableContextWrapper; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentActivity; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.webkit.WebView; +import android.widget.FrameLayout; + +public final class ZapicAppFragment extends Fragment { + /** + * The tag used to identify log messages. + */ + @NonNull + private static final String TAG = "ZapicAppFragment"; + + /** + * The {@link WebView}. + */ + @Nullable + private WebView mWebView; + + /** + * Creates a new {@link ZapicAppFragment} instance. + */ + public ZapicAppFragment() { + this.mWebView = null; + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onCreateView"); + } + + final WebViewManager webViewManager = WebViewManager.getInstance(); + + assert this.mWebView == null : "mWebView is not null"; + this.mWebView = webViewManager.getWebView(); + final FrameLayout.LayoutParams webViewLayoutParams = new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT); + this.mWebView.setLayoutParams(webViewLayoutParams); + + final FragmentActivity activity = this.getActivity(); + assert activity != null : "activity is null"; + + final MutableContextWrapper webViewContext = (MutableContextWrapper) this.mWebView.getContext(); + webViewContext.setBaseContext(activity); + + final View view = inflater.inflate(R.layout.fragment_zapic_app, container, false); + + final FrameLayout frameLayout = view.findViewById(R.id.fragment_zapic_app_container); + frameLayout.addView(this.mWebView); + + return view; + } + + @Override + public void onDestroyView() { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onCreateView"); + } + + assert this.mWebView != null : "mWebView is null"; + final FrameLayout frameLayout = (FrameLayout) this.mWebView.getParent(); + frameLayout.removeView(this.mWebView); + + final FragmentActivity activity = this.getActivity(); + assert activity != null : "activity is null"; + + final MutableContextWrapper webViewContext = (MutableContextWrapper) this.mWebView.getContext(); + webViewContext.setBaseContext(activity.getApplicationContext()); + + this.mWebView = null; + + super.onDestroyView(); + } +} diff --git a/zapic/src/main/java/com/zapic/sdk/android/ZapicFragment.java b/zapic/src/main/java/com/zapic/sdk/android/ZapicFragment.java new file mode 100644 index 0000000..03128ba --- /dev/null +++ b/zapic/src/main/java/com/zapic/sdk/android/ZapicFragment.java @@ -0,0 +1,284 @@ +package com.zapic.sdk.android; + +import android.app.Activity; +import android.app.Fragment; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +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.util.Log; + +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; + +public final class ZapicFragment extends Fragment { + /** + * Identifies an action request to sign-in using Google Play Games Services. + */ + private static final int GOOGLE_SIGN_IN_REQUEST = 1000; + + /** + * The tag used to identify log messages. + */ + @NonNull + private static final String TAG = "ZapicFragment"; + + /** + * 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; + + /** + * A strong reference to the {@link WebViewManager} instance. + */ + @Nullable + private WebViewManager mWebViewManager; + + /** + * Creates a new {@link ZapicFragment} instance. + */ + public ZapicFragment() { + this.mGoogleSignInClient = null; + this.mGoogleSignInRequested = false; + this.mWebViewManager = null; + } + + /** + * 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); + } + + void login() { + this.mGoogleSignInRequested = true; + + assert this.mGoogleSignInClient != null : "mGoogleSignInClient is null"; + this.startActivityForResult(this.mGoogleSignInClient.getSignInIntent(), GOOGLE_SIGN_IN_REQUEST); + } + + void logout() { + this.mGoogleSignInRequested = false; + + assert this.mGoogleSignInClient != null : "mGoogleSignInClient is null"; + this.mGoogleSignInClient.signOut(); + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, @Nullable final Intent intent) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onActivityResult"); + } + + if (requestCode == GOOGLE_SIGN_IN_REQUEST) { + 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); + } + } + } + + @Override + @SuppressWarnings("deprecation") + public void onAttach(Activity activity) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onAttach"); + } + + super.onAttach(activity); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + assert this.mGoogleSignInClient == null : "mGoogleSignInClient is not null"; + this.mGoogleSignInClient = ZapicFragment.createGoogleSignInClient(this.getActivity()); + } + } + + @Override + public void onAttach(Context context) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onAttach"); + } + + super.onAttach(context); + + assert this.mGoogleSignInClient == null : "mGoogleSignInClient is not null"; + this.mGoogleSignInClient = ZapicFragment.createGoogleSignInClient(this.getActivity()); + } + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onCreate"); + } + + super.onCreate(savedInstanceState); + + // 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); + + assert this.mWebViewManager == null : "mWebViewManager is not null"; + this.mWebViewManager = WebViewManager.getInstance(); + this.mWebViewManager.onFragmentCreated(this); + } + + @Override + public void onDestroy() { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onDestroy"); + } + + super.onDestroy(); + + assert this.mGoogleSignInClient != null : "mGoogleSignInClient is null"; + this.mGoogleSignInClient = null; + + assert this.mWebViewManager != null : "mWebViewManager is null"; + this.mWebViewManager.onFragmentDestroyed(this); + + if (this.mGoogleSignInRequested) { + // Transfer the sign-in request to another fragment. + this.mWebViewManager.login(); + } + + this.mWebViewManager = null; + } + + @Override + public void onDetach() { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onDetach"); + } + + super.onDetach(); + + this.mGoogleSignInClient = null; + } + + private void onLoginFailed(@NonNull final Exception exception) { + this.mGoogleSignInRequested = false; + + String message; + if (exception instanceof ApiException) { + ApiException apiException = (ApiException) exception; + message = String.valueOf(apiException.getStatusCode()); + } else { + message = "Failed to sign-in to Play Games"; + } + + assert this.mWebViewManager != null : "mWebViewManager is null"; + this.mWebViewManager.loginFailed(message); + } + + private void onLoginSucceeded(@NonNull final String serverAuthCode) { + this.mGoogleSignInRequested = false; + + final String packageName = this.getActivity().getApplicationContext().getPackageName(); + + assert this.mWebViewManager != null : "mWebViewManager is null"; + this.mWebViewManager.loginSucceeded(packageName, serverAuthCode); + } + + @Override + public void onResume() { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onResume"); + } + + super.onResume(); + + assert this.mGoogleSignInClient != null : "mGoogleSignInClient is 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) { + 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(), GOOGLE_SIGN_IN_REQUEST); + } + } + } + }); + } else if (this.mGoogleSignInRequested) { + final String serverAuthCode = account.getServerAuthCode(); + this.onLoginSucceeded(serverAuthCode == null ? "" : serverAuthCode); + } + } +} diff --git a/zapic/src/main/java/com/zapic/sdk/android/ZapicLoadingFragment.java b/zapic/src/main/java/com/zapic/sdk/android/ZapicLoadingFragment.java new file mode 100644 index 0000000..48dbb9a --- /dev/null +++ b/zapic/src/main/java/com/zapic/sdk/android/ZapicLoadingFragment.java @@ -0,0 +1,54 @@ +package com.zapic.sdk.android; + +import android.graphics.drawable.AnimationDrawable; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentActivity; +import android.support.v4.view.ViewCompat; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageButton; + +public final class ZapicLoadingFragment extends Fragment { + /** + * The tag used to identify log messages. + */ + @NonNull + private static final String TAG = "ZapicLoadingFragment"; + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onCreateView"); + } + + View view = inflater.inflate(R.layout.fragment_zapic_loading, container, false); + + View appBar = ViewCompat.requireViewById(view, R.id.component_zapic_app_bar); + + FrameLayout gradient = ViewCompat.requireViewById(appBar, R.id.component_zapic_app_bar_gradient); + AnimationDrawable gradientAnimation = (AnimationDrawable) gradient.getBackground(); + gradientAnimation.setEnterFadeDuration(750); + gradientAnimation.setExitFadeDuration(750); + gradientAnimation.start(); + + ImageButton closeButton = ViewCompat.requireViewById(appBar, R.id.component_zapic_app_bar_close); + closeButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + final FragmentActivity activity = ZapicLoadingFragment.this.getActivity(); + if (activity != null) { + activity.finish(); + } + } + }); + + return view; + } +} diff --git a/zapic/src/main/java/com/zapic/sdk/android/ZapicOfflineFragment.java b/zapic/src/main/java/com/zapic/sdk/android/ZapicOfflineFragment.java new file mode 100644 index 0000000..9a9db33 --- /dev/null +++ b/zapic/src/main/java/com/zapic/sdk/android/ZapicOfflineFragment.java @@ -0,0 +1,53 @@ +package com.zapic.sdk.android; + +import android.graphics.drawable.AnimationDrawable; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentActivity; +import android.support.v4.view.ViewCompat; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageButton; + +public final class ZapicOfflineFragment extends Fragment { + /** + * The tag used to identify log messages. + */ + @NonNull + private static final String TAG = "ZapicOfflineFragment"; + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onCreateView"); + } + + View view = inflater.inflate(R.layout.fragment_zapic_offline, container, false); + + View appBar = ViewCompat.requireViewById(view, R.id.component_zapic_app_bar); + + FrameLayout gradient = ViewCompat.requireViewById(appBar, R.id.component_zapic_app_bar_gradient); + AnimationDrawable gradientAnimation = (AnimationDrawable) gradient.getBackground(); + gradientAnimation.setEnterFadeDuration(750); + gradientAnimation.setExitFadeDuration(750); + gradientAnimation.start(); + + ImageButton closeButton = ViewCompat.requireViewById(appBar, R.id.component_zapic_app_bar_close); + closeButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + final FragmentActivity activity = ZapicOfflineFragment.this.getActivity(); + if (activity != null) { + activity.finish(); + } + } + }); + + return view; + } +} diff --git a/zapic/src/main/res/drawable-hdpi/chevron_double_left.png b/zapic/src/main/res/drawable-hdpi/zapic_close_24dp.png similarity index 100% rename from zapic/src/main/res/drawable-hdpi/chevron_double_left.png rename to zapic/src/main/res/drawable-hdpi/zapic_close_24dp.png diff --git a/zapic/src/main/res/drawable-hdpi/zapic_error_48dp.png b/zapic/src/main/res/drawable-hdpi/zapic_error_48dp.png new file mode 100644 index 0000000..9329ca2 Binary files /dev/null and b/zapic/src/main/res/drawable-hdpi/zapic_error_48dp.png differ diff --git a/zapic/src/main/res/drawable-hdpi/logo.png b/zapic/src/main/res/drawable-hdpi/zapic_logo_64dp.png similarity index 100% rename from zapic/src/main/res/drawable-hdpi/logo.png rename to zapic/src/main/res/drawable-hdpi/zapic_logo_64dp.png diff --git a/zapic/src/main/res/drawable-ldpi/logo.png b/zapic/src/main/res/drawable-ldpi/zapic_logo_64dp.png similarity index 100% rename from zapic/src/main/res/drawable-ldpi/logo.png rename to zapic/src/main/res/drawable-ldpi/zapic_logo_64dp.png diff --git a/zapic/src/main/res/drawable-mdpi/chevron_double_left.png b/zapic/src/main/res/drawable-mdpi/zapic_close_24dp.png similarity index 100% rename from zapic/src/main/res/drawable-mdpi/chevron_double_left.png rename to zapic/src/main/res/drawable-mdpi/zapic_close_24dp.png diff --git a/zapic/src/main/res/drawable-mdpi/zapic_error_48dp.png b/zapic/src/main/res/drawable-mdpi/zapic_error_48dp.png new file mode 100644 index 0000000..fb719fc Binary files /dev/null and b/zapic/src/main/res/drawable-mdpi/zapic_error_48dp.png differ diff --git a/zapic/src/main/res/drawable-mdpi/logo.png b/zapic/src/main/res/drawable-mdpi/zapic_logo_64dp.png similarity index 100% rename from zapic/src/main/res/drawable-mdpi/logo.png rename to zapic/src/main/res/drawable-mdpi/zapic_logo_64dp.png diff --git a/zapic/src/main/res/drawable-xhdpi/chevron_double_left.png b/zapic/src/main/res/drawable-xhdpi/zapic_close_24dp.png similarity index 100% rename from zapic/src/main/res/drawable-xhdpi/chevron_double_left.png rename to zapic/src/main/res/drawable-xhdpi/zapic_close_24dp.png diff --git a/zapic/src/main/res/drawable-xhdpi/zapic_error_48dp.png b/zapic/src/main/res/drawable-xhdpi/zapic_error_48dp.png new file mode 100644 index 0000000..5c71685 Binary files /dev/null and b/zapic/src/main/res/drawable-xhdpi/zapic_error_48dp.png differ diff --git a/zapic/src/main/res/drawable-xhdpi/logo.png b/zapic/src/main/res/drawable-xhdpi/zapic_logo_64dp.png similarity index 100% rename from zapic/src/main/res/drawable-xhdpi/logo.png rename to zapic/src/main/res/drawable-xhdpi/zapic_logo_64dp.png diff --git a/zapic/src/main/res/drawable-xxhdpi/chevron_double_left.png b/zapic/src/main/res/drawable-xxhdpi/zapic_close_24dp.png similarity index 100% rename from zapic/src/main/res/drawable-xxhdpi/chevron_double_left.png rename to zapic/src/main/res/drawable-xxhdpi/zapic_close_24dp.png diff --git a/zapic/src/main/res/drawable-xxhdpi/zapic_error_48dp.png b/zapic/src/main/res/drawable-xxhdpi/zapic_error_48dp.png new file mode 100644 index 0000000..9091e30 Binary files /dev/null and b/zapic/src/main/res/drawable-xxhdpi/zapic_error_48dp.png differ diff --git a/zapic/src/main/res/drawable-xxhdpi/logo.png b/zapic/src/main/res/drawable-xxhdpi/zapic_logo_64dp.png similarity index 100% rename from zapic/src/main/res/drawable-xxhdpi/logo.png rename to zapic/src/main/res/drawable-xxhdpi/zapic_logo_64dp.png diff --git a/zapic/src/main/res/drawable-xxxhdpi/chevron_double_left.png b/zapic/src/main/res/drawable-xxxhdpi/zapic_close_24dp.png similarity index 100% rename from zapic/src/main/res/drawable-xxxhdpi/chevron_double_left.png rename to zapic/src/main/res/drawable-xxxhdpi/zapic_close_24dp.png diff --git a/zapic/src/main/res/drawable-xxxhdpi/zapic_error_48dp.png b/zapic/src/main/res/drawable-xxxhdpi/zapic_error_48dp.png new file mode 100644 index 0000000..7ca93b3 Binary files /dev/null and b/zapic/src/main/res/drawable-xxxhdpi/zapic_error_48dp.png differ diff --git a/zapic/src/main/res/drawable-xxxhdpi/logo.png b/zapic/src/main/res/drawable-xxxhdpi/zapic_logo_64dp.png similarity index 100% rename from zapic/src/main/res/drawable-xxxhdpi/logo.png rename to zapic/src/main/res/drawable-xxxhdpi/zapic_logo_64dp.png diff --git a/zapic/src/main/res/drawable/zapic_gradient_animation.xml b/zapic/src/main/res/drawable/zapic_gradient_animation.xml new file mode 100644 index 0000000..47bbde4 --- /dev/null +++ b/zapic/src/main/res/drawable/zapic_gradient_animation.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/zapic/src/main/res/drawable/zapic_gradient_end.xml b/zapic/src/main/res/drawable/zapic_gradient_end.xml new file mode 100644 index 0000000..47c7f98 --- /dev/null +++ b/zapic/src/main/res/drawable/zapic_gradient_end.xml @@ -0,0 +1,6 @@ + + + + diff --git a/zapic/src/main/res/drawable/zapic_gradient_start.xml b/zapic/src/main/res/drawable/zapic_gradient_start.xml new file mode 100644 index 0000000..7c58377 --- /dev/null +++ b/zapic/src/main/res/drawable/zapic_gradient_start.xml @@ -0,0 +1,6 @@ + + + + diff --git a/zapic/src/main/res/layout-land/fragment_page_offline.xml b/zapic/src/main/res/layout-land/fragment_page_offline.xml deleted file mode 100644 index 6154b1e..0000000 --- a/zapic/src/main/res/layout-land/fragment_page_offline.xml +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - diff --git a/zapic/src/main/res/layout-land/fragment_zapic_offline.xml b/zapic/src/main/res/layout-land/fragment_zapic_offline.xml new file mode 100644 index 0000000..f9b3ab2 --- /dev/null +++ b/zapic/src/main/res/layout-land/fragment_zapic_offline.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + diff --git a/zapic/src/main/res/layout-v21/fragment_page_loading.xml b/zapic/src/main/res/layout-v21/fragment_page_loading.xml deleted file mode 100644 index 1750a65..0000000 --- a/zapic/src/main/res/layout-v21/fragment_page_loading.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - diff --git a/zapic/src/main/res/layout-v21/fragment_zapic_loading.xml b/zapic/src/main/res/layout-v21/fragment_zapic_loading.xml new file mode 100644 index 0000000..32bd2e4 --- /dev/null +++ b/zapic/src/main/res/layout-v21/fragment_zapic_loading.xml @@ -0,0 +1,28 @@ + + + + + + + + + + diff --git a/zapic/src/main/res/layout/activity_zapic.xml b/zapic/src/main/res/layout/activity_zapic.xml index 5cfc989..0be67e7 100644 --- a/zapic/src/main/res/layout/activity_zapic.xml +++ b/zapic/src/main/res/layout/activity_zapic.xml @@ -4,5 +4,5 @@ android:id="@+id/activity_zapic_container" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context="com.zapic.android.sdk.ZapicActivity"> - + android:background="@android:color/white" + tools:context=".ZapicActivity" /> diff --git a/zapic/src/main/res/layout/component_zapic_app_bar.xml b/zapic/src/main/res/layout/component_zapic_app_bar.xml new file mode 100644 index 0000000..6f6093d --- /dev/null +++ b/zapic/src/main/res/layout/component_zapic_app_bar.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + diff --git a/zapic/src/main/res/layout/component_zapic_toast.xml b/zapic/src/main/res/layout/component_zapic_toast.xml new file mode 100644 index 0000000..64520e3 --- /dev/null +++ b/zapic/src/main/res/layout/component_zapic_toast.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + diff --git a/zapic/src/main/res/layout/fragment_page_loading.xml b/zapic/src/main/res/layout/fragment_page_loading.xml deleted file mode 100644 index fdba0da..0000000 --- a/zapic/src/main/res/layout/fragment_page_loading.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - diff --git a/zapic/src/main/res/layout/fragment_page_offline.xml b/zapic/src/main/res/layout/fragment_page_offline.xml deleted file mode 100644 index feb030c..0000000 --- a/zapic/src/main/res/layout/fragment_page_offline.xml +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - diff --git a/zapic/src/main/res/layout/fragment_page_app.xml b/zapic/src/main/res/layout/fragment_zapic_app.xml similarity index 63% rename from zapic/src/main/res/layout/fragment_page_app.xml rename to zapic/src/main/res/layout/fragment_zapic_app.xml index ba2f0ff..83341a4 100644 --- a/zapic/src/main/res/layout/fragment_page_app.xml +++ b/zapic/src/main/res/layout/fragment_zapic_app.xml @@ -1,8 +1,8 @@ - + android:background="@android:color/white" + tools:context=".ZapicAppFragment" /> diff --git a/zapic/src/main/res/layout/fragment_zapic_loading.xml b/zapic/src/main/res/layout/fragment_zapic_loading.xml new file mode 100644 index 0000000..522c9e2 --- /dev/null +++ b/zapic/src/main/res/layout/fragment_zapic_loading.xml @@ -0,0 +1,29 @@ + + + + + + + + + + diff --git a/zapic/src/main/res/layout/fragment_zapic_offline.xml b/zapic/src/main/res/layout/fragment_zapic_offline.xml new file mode 100644 index 0000000..5a9f2d5 --- /dev/null +++ b/zapic/src/main/res/layout/fragment_zapic_offline.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + diff --git a/zapic/src/main/res/values-v21/styles.xml b/zapic/src/main/res/values-v21/styles.xml index c838630..74ffee5 100644 --- a/zapic/src/main/res/values-v21/styles.xml +++ b/zapic/src/main/res/values-v21/styles.xml @@ -1,6 +1,6 @@ - diff --git a/zapic/src/main/res/values/dimens.xml b/zapic/src/main/res/values/dimens.xml index 48e3a27..13498f8 100644 --- a/zapic/src/main/res/values/dimens.xml +++ b/zapic/src/main/res/values/dimens.xml @@ -1,6 +1,16 @@ - 64dp - 16dp - 32dp + 8dp + 6dp + 6dp + 48dp + 36dp + + 6dp + 36dp + 16dp + + 32dp + + 16dp diff --git a/zapic/src/main/res/values/strings.xml b/zapic/src/main/res/values/strings.xml index 54ec0c9..6c0f875 100644 --- a/zapic/src/main/res/values/strings.xml +++ b/zapic/src/main/res/values/strings.xml @@ -1,9 +1,24 @@ - Close - Zapic - Close - Zapic - Zapic could not be loaded. Please check your network connection and try again. - Retry + Done + Zapic + + Zapic + + Zapic could not be loaded. Please check your network connection and try again. + + A camera app could not be found. + A photos folder could not be found. + You have not granted permission to use the camera. Please change your device\'s settings and try again. + You have not granted permission to view photos in storage. Please change your device\'s settings and try again. + Open Settings + Select Photo + Cancel + + Take New Photo + Choose Existing Photo + + Select Photo + A media library app could not be found. + A settings app could not be found. diff --git a/zapic/src/main/res/xml/provider_paths.xml b/zapic/src/main/res/xml/provider_paths.xml new file mode 100644 index 0000000..a075ef9 --- /dev/null +++ b/zapic/src/main/res/xml/provider_paths.xml @@ -0,0 +1,6 @@ + + + +