diff options
Diffstat (limited to 'mobile/android/base/java/org/mozilla/gecko/lwt/LightweightTheme.java')
-rw-r--r-- | mobile/android/base/java/org/mozilla/gecko/lwt/LightweightTheme.java | 455 |
1 files changed, 455 insertions, 0 deletions
diff --git a/mobile/android/base/java/org/mozilla/gecko/lwt/LightweightTheme.java b/mobile/android/base/java/org/mozilla/gecko/lwt/LightweightTheme.java new file mode 100644 index 000000000..0f27c1feb --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/lwt/LightweightTheme.java @@ -0,0 +1,455 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.lwt; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import org.json.JSONObject; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoSharedPrefs; +import org.mozilla.gecko.gfx.BitmapUtils; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.WindowUtils; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.ThreadUtils.AssertBehavior; + +import android.app.Application; +import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.Shader; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.text.TextUtils; +import android.util.Log; +import android.view.Gravity; +import android.view.View; +import android.view.ViewParent; + +public class LightweightTheme implements GeckoEventListener { + private static final String LOGTAG = "GeckoLightweightTheme"; + + private static final String PREFS_URL = "lightweightTheme.headerURL"; + private static final String PREFS_COLOR = "lightweightTheme.color"; + + private static final String ASSETS_PREFIX = "resource://android/assets/"; + + private final Application mApplication; + + private Bitmap mBitmap; + private int mColor; + private boolean mIsLight; + + public static interface OnChangeListener { + // The View should change its background/text color. + public void onLightweightThemeChanged(); + + // The View should reset to its default background/text color. + public void onLightweightThemeReset(); + } + + private final List<OnChangeListener> mListeners; + + class LightweightThemeRunnable implements Runnable { + private String mHeaderURL; + private String mColor; + + private String mSavedURL; + private String mSavedColor; + + LightweightThemeRunnable() { + } + + LightweightThemeRunnable(final String headerURL, final String color) { + mHeaderURL = headerURL; + mColor = color; + } + + private void loadFromPrefs() { + SharedPreferences prefs = GeckoSharedPrefs.forProfile(mApplication); + mSavedURL = prefs.getString(PREFS_URL, null); + mSavedColor = prefs.getString(PREFS_COLOR, null); + } + + private void saveToPrefs() { + GeckoSharedPrefs.forProfile(mApplication) + .edit() + .putString(PREFS_URL, mHeaderURL) + .putString(PREFS_COLOR, mColor) + .apply(); + + // Let's keep the saved data in sync. + mSavedURL = mHeaderURL; + mSavedColor = mColor; + } + + @Override + public void run() { + // Load the data from preferences, if it exists. + loadFromPrefs(); + + if (TextUtils.isEmpty(mHeaderURL)) { + // mHeaderURL is null is this is the early startup path. Use + // the saved values, if we have any. + mHeaderURL = mSavedURL; + mColor = mSavedColor; + if (TextUtils.isEmpty(mHeaderURL)) { + // We don't have any saved values, so we probably don't have + // any lightweight theme set yet. + return; + } + } else if (TextUtils.equals(mHeaderURL, mSavedURL)) { + // If we are already using the given header, just return + // without doing any work. + return; + } else { + // mHeaderURL and mColor probably need to be saved if we get here. + saveToPrefs(); + } + + String croppedURL = mHeaderURL; + int mark = croppedURL.indexOf('?'); + if (mark != -1) { + croppedURL = croppedURL.substring(0, mark); + } + + if (croppedURL.startsWith(ASSETS_PREFIX)) { + onBitmapLoaded(loadFromAssets(croppedURL)); + } else { + onBitmapLoaded(BitmapUtils.decodeUrl(croppedURL)); + } + } + + private Bitmap loadFromAssets(String url) { + InputStream stream = null; + + try { + stream = mApplication.getAssets().open(url.substring(ASSETS_PREFIX.length())); + return BitmapFactory.decodeStream(stream); + } catch (IOException e) { + return null; + } finally { + if (stream != null) { + try { + stream.close(); + } catch (IOException e) { } + } + } + } + + private void onBitmapLoaded(final Bitmap bitmap) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + setLightweightTheme(bitmap, mColor); + } + }); + } + } + + public LightweightTheme(Application application) { + mApplication = application; + mListeners = new ArrayList<OnChangeListener>(); + + // unregister isn't needed as the lifetime is same as the application. + EventDispatcher.getInstance().registerGeckoThreadListener(this, + "LightweightTheme:Update", + "LightweightTheme:Disable"); + + ThreadUtils.postToBackgroundThread(new LightweightThemeRunnable()); + } + + public void addListener(final OnChangeListener listener) { + // Don't inform the listeners that attached late. + // Their onLayout() will take care of them before their onDraw(); + mListeners.add(listener); + } + + public void removeListener(OnChangeListener listener) { + mListeners.remove(listener); + } + + @Override + public void handleMessage(String event, JSONObject message) { + try { + if (event.equals("LightweightTheme:Update")) { + JSONObject lightweightTheme = message.getJSONObject("data"); + final String headerURL = lightweightTheme.getString("headerURL"); + final String color = lightweightTheme.optString("accentcolor"); + + ThreadUtils.postToBackgroundThread(new LightweightThemeRunnable(headerURL, color)); + } else if (event.equals("LightweightTheme:Disable")) { + // Clear the saved data when a theme is disabled. + // Called on the Gecko thread, but should be very lightweight. + clearPrefs(); + + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + resetLightweightTheme(); + } + }); + } + } catch (Exception e) { + Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e); + } + } + + /** + * Clear the data stored in preferences for fast path loading during startup + */ + private void clearPrefs() { + GeckoSharedPrefs.forProfile(mApplication) + .edit() + .remove(PREFS_URL) + .remove(PREFS_COLOR) + .apply(); + } + + /** + * Set a new lightweight theme with the given bitmap. + * Note: This should be called on the UI thread to restrict accessing the + * bitmap to a single thread. + * + * @param bitmap The bitmap used for the lightweight theme. + * @param color The background/accent color used for the lightweight theme. + */ + private void setLightweightTheme(Bitmap bitmap, String color) { + if (bitmap == null || bitmap.getWidth() == 0 || bitmap.getHeight() == 0) { + mBitmap = null; + return; + } + + // Get the max display dimension so we can crop or expand the theme. + final int maxWidth = WindowUtils.getLargestDimension(mApplication); + + // The lightweight theme image's width and height. + final int bitmapWidth = bitmap.getWidth(); + final int bitmapHeight = bitmap.getHeight(); + + try { + mColor = Color.parseColor(color); + } catch (Exception e) { + // Malformed or missing color. + // Default to TRANSPARENT. + mColor = Color.TRANSPARENT; + } + + // Calculate the luminance to determine if it's a light or a dark theme. + double luminance = (0.2125 * ((mColor & 0x00FF0000) >> 16)) + + (0.7154 * ((mColor & 0x0000FF00) >> 8)) + + (0.0721 * (mColor & 0x000000FF)); + mIsLight = luminance > 110; + + // The bitmap image might be smaller than the device's width. + // If it's smaller, fill the extra space on the left with the dominant color. + if (bitmapWidth >= maxWidth) { + mBitmap = Bitmap.createBitmap(bitmap, bitmapWidth - maxWidth, 0, maxWidth, bitmapHeight); + } else { + Paint paint = new Paint(); + paint.setAntiAlias(true); + + // Create a bigger image that can fill the device width. + // By creating a canvas for the bitmap, anything drawn on the canvas + // will be drawn on the bitmap. + mBitmap = Bitmap.createBitmap(maxWidth, bitmapHeight, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(mBitmap); + + // Fill the canvas with dominant color. + canvas.drawColor(mColor); + + // The image should be top-right aligned. + Rect rect = new Rect(); + Gravity.apply(Gravity.TOP | Gravity.RIGHT, + bitmapWidth, + bitmapHeight, + new Rect(0, 0, maxWidth, bitmapHeight), + rect); + + // Draw the bitmap. + canvas.drawBitmap(bitmap, null, rect, paint); + } + + for (OnChangeListener listener : mListeners) { + listener.onLightweightThemeChanged(); + } + } + + /** + * Reset the lightweight theme. + * Note: This should be called on the UI thread to restrict accessing the + * bitmap to a single thread. + */ + private void resetLightweightTheme() { + ThreadUtils.assertOnUiThread(AssertBehavior.NONE); + if (mBitmap == null) { + return; + } + + // Reset the bitmap. + mBitmap = null; + + for (OnChangeListener listener : mListeners) { + listener.onLightweightThemeReset(); + } + } + + /** + * A lightweight theme is enabled only if there is an active bitmap. + * + * @return True if the theme is enabled. + */ + public boolean isEnabled() { + return (mBitmap != null); + } + + /** + * Based on the luminance of the domanint color, a theme is classified as light or dark. + * + * @return True if the theme is light. + */ + public boolean isLightTheme() { + return mIsLight; + } + + /** + * Crop the image based on the position of the view on the window. + * Either the View or one of its ancestors might have scrolled or translated. + * This value should be taken into account while mapping the View to the Bitmap. + * + * @param view The view requesting a cropped bitmap. + */ + private Bitmap getCroppedBitmap(View view) { + if (mBitmap == null || view == null) { + return null; + } + + // Get the global position of the view on the entire screen. + Rect rect = new Rect(); + view.getGlobalVisibleRect(rect); + + // Get the activity's window position. This does an IPC call, may be expensive. + Rect window = new Rect(); + view.getWindowVisibleDisplayFrame(window); + + // Calculate the coordinates for the cropped bitmap. + int screenWidth = view.getContext().getResources().getDisplayMetrics().widthPixels; + int left = mBitmap.getWidth() - screenWidth + rect.left; + int right = mBitmap.getWidth() - screenWidth + rect.right; + int top = rect.top - window.top; + int bottom = rect.bottom - window.top; + + int offsetX = 0; + int offsetY = 0; + + // Find if this view or any of its ancestors has been translated or scrolled. + ViewParent parent; + View curView = view; + do { + offsetX += (int) curView.getTranslationX() - curView.getScrollX(); + offsetY += (int) curView.getTranslationY() - curView.getScrollY(); + + parent = curView.getParent(); + + if (parent instanceof View) { + curView = (View) parent; + } + + } while (parent instanceof View); + + // Adjust the coordinates for the offset. + left -= offsetX; + right -= offsetX; + top -= offsetY; + bottom -= offsetY; + + // The either the required height may be less than the available image height or more than it. + // If the height required is more, crop only the available portion on the image. + int width = right - left; + int height = (bottom > mBitmap.getHeight() ? mBitmap.getHeight() - top : bottom - top); + + // There is a chance that the view is not visible or doesn't fall within the phone's size. + // In this case, 'rect' will have all values as '0'. Hence 'top' and 'bottom' may be negative, + // and createBitmap() will fail. + // The view will get a background in its next layout pass. + try { + return Bitmap.createBitmap(mBitmap, left, top, width, height); + } catch (Exception e) { + return null; + } + } + + /** + * Converts the cropped bitmap to a BitmapDrawable and returns the same. + * + * @param view The view for which a background drawable is required. + * @return Either the cropped bitmap as a Drawable or null. + */ + public Drawable getDrawable(View view) { + Bitmap bitmap = getCroppedBitmap(view); + if (bitmap == null) { + return null; + } + + BitmapDrawable drawable = new BitmapDrawable(view.getContext().getResources(), bitmap); + drawable.setGravity(Gravity.TOP | Gravity.RIGHT); + drawable.setTileModeXY(Shader.TileMode.CLAMP, Shader.TileMode.CLAMP); + return drawable; + } + + /** + * Converts the cropped bitmap to a LightweightThemeDrawable, placing it over the dominant color. + * + * @param view The view for which a background drawable is required. + * @return Either the cropped bitmap as a Drawable or null. + */ + public LightweightThemeDrawable getColorDrawable(View view) { + return getColorDrawable(view, mColor, false); + } + + /** + * Converts the cropped bitmap to a LightweightThemeDrawable, placing it over the required color. + * + * @param view The view for which a background drawable is required. + * @param color The color over which the drawable should be drawn. + * @return Either the cropped bitmap as a Drawable or null. + */ + public LightweightThemeDrawable getColorDrawable(View view, int color) { + return getColorDrawable(view, color, false); + } + + /** + * Converts the cropped bitmap to a LightweightThemeDrawable, placing it over the required color. + * + * @param view The view for which a background drawable is required. + * @param color The color over which the drawable should be drawn. + * @param needsDominantColor A layer of dominant color is needed or not. + * @return Either the cropped bitmap as a Drawable or null. + */ + public LightweightThemeDrawable getColorDrawable(View view, int color, boolean needsDominantColor) { + Bitmap bitmap = getCroppedBitmap(view); + if (bitmap == null) { + return null; + } + + LightweightThemeDrawable drawable = new LightweightThemeDrawable(view.getContext().getResources(), bitmap); + if (needsDominantColor) { + drawable.setColorWithFilter(color, (mColor & 0x22FFFFFF)); + } else { + drawable.setColor(color); + } + + return drawable; + } +} |