diff --git a/mobile/android/base/CanvasDelegate.java b/mobile/android/base/CanvasDelegate.java index 7c454ebb23ec..168d13496ba6 100644 --- a/mobile/android/base/CanvasDelegate.java +++ b/mobile/android/base/CanvasDelegate.java @@ -10,6 +10,7 @@ import android.graphics.Paint; import android.graphics.Path; import android.graphics.PorterDuffXfermode; import android.graphics.PorterDuff.Mode; +import android.graphics.Shader; import android.os.Build; public class CanvasDelegate { @@ -68,4 +69,8 @@ public class CanvasDelegate { // Restore the canvas. canvas.restoreToCount(count); } + + public void setShader(Shader shader) { + mPaint.setShader(shader); + } } diff --git a/mobile/android/base/GeckoActivity.java.in b/mobile/android/base/GeckoActivity.java.in index ae1e8f586e80..1b8c2d9b0fa1 100644 --- a/mobile/android/base/GeckoActivity.java.in +++ b/mobile/android/base/GeckoActivity.java.in @@ -40,4 +40,8 @@ public class GeckoActivity extends Activity { MemoryMonitor.getInstance().onTrimMemory(level); super.onTrimMemory(level); } + + public LightweightTheme getLightweightTheme() { + return ((GeckoApplication) getApplication()).getLightweightTheme(); + } } diff --git a/mobile/android/base/GeckoApplication.java b/mobile/android/base/GeckoApplication.java index bdf23d2bfe04..e10954e4856a 100644 --- a/mobile/android/base/GeckoApplication.java +++ b/mobile/android/base/GeckoApplication.java @@ -13,6 +13,8 @@ public class GeckoApplication extends Application { private boolean mInited; private boolean mInBackground; + private LightweightTheme mLightweightTheme; + protected void initialize() { if (mInited) return; @@ -22,6 +24,8 @@ public class GeckoApplication extends Application { Class.forName("android.os.AsyncTask"); } catch (ClassNotFoundException e) {} + mLightweightTheme = new LightweightTheme(this); + GeckoConnectivityReceiver.getInstance().init(getApplicationContext()); GeckoBatteryManager.getInstance().init(getApplicationContext()); GeckoBatteryManager.getInstance().start(); @@ -50,4 +54,8 @@ public class GeckoApplication extends Application { public boolean isApplicationInBackground() { return mInBackground; } + + public LightweightTheme getLightweightTheme() { + return mLightweightTheme; + } } diff --git a/mobile/android/base/LightweightTheme.java b/mobile/android/base/LightweightTheme.java new file mode 100644 index 000000000000..4fae7ae45ba7 --- /dev/null +++ b/mobile/android/base/LightweightTheme.java @@ -0,0 +1,285 @@ +/* -*- 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; + +import org.mozilla.gecko.gfx.BitmapUtils; +import org.mozilla.gecko.util.GeckoEventListener; + +import org.json.JSONException; +import org.json.JSONObject; + +import android.app.Application; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.LayerDrawable; +import android.graphics.Rect; +import android.graphics.Shader; +import android.os.Build; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.View; +import android.view.ViewParent; + +import java.net.URL; +import java.net.URLConnection; +import java.io.InputStream; +import java.util.List; +import java.util.ArrayList; + +import android.util.Log; + +public class LightweightTheme implements GeckoEventListener { + private static final String LOGTAG = "GeckoLightweightTheme"; + + private Application mApplication; + private Bitmap mBitmap; + private int mColor; + + public static interface OnChangeListener { + // This is the View's default post. + // This is required to post the change/rest on UI thread. + public boolean post(Runnable action); + + // 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 List mListeners; + + public LightweightTheme(Application application) { + mApplication = application; + mListeners = new ArrayList(); + + GeckoAppShell.getEventDispatcher().registerEventListener("LightweightTheme:Update", this); + GeckoAppShell.getEventDispatcher().registerEventListener("LightweightTheme:Disable", this); + } + + 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); + } + + public void setLightweightTheme(String headerURL) { + try { + // Wait till gecko downloads and gives us the file, don't download. + if (headerURL.indexOf("http") != -1) + return; + + // Get the image and convert it to a bitmap. + URL url = new URL(headerURL); + InputStream stream = url.openStream(); + mBitmap = BitmapFactory.decodeStream(stream); + stream.close(); + + // To find the dominant color only once, take the bottom 25% of pixels. + DisplayMetrics dm = mApplication.getResources().getDisplayMetrics(); + int maxWidth = Math.max(dm.widthPixels, dm.heightPixels); + int height = (int) (mBitmap.getHeight() * 0.25); + Bitmap cropped = Bitmap.createBitmap(mBitmap, mBitmap.getWidth() - maxWidth, + mBitmap.getHeight() - height, + maxWidth, height); + mColor = BitmapUtils.getDominantColor(cropped, false); + cropped.recycle(); + + notifyListeners(); + } catch(java.net.MalformedURLException e) { + mBitmap = null; + } catch(java.io.IOException e) { + mBitmap = null; + } + } + + public void resetLightweightTheme() { + // Reset the bitmap. + mBitmap = null; + + // Post the reset on the UI thread. + for (OnChangeListener listener : mListeners) { + final OnChangeListener oneListener = listener; + oneListener.post(new Runnable() { + @Override + public void run() { + oneListener.onLightweightThemeReset(); + } + }); + } + } + + public void notifyListeners() { + if (mBitmap == null) + return; + + // Post the change on the UI thread. + for (OnChangeListener listener : mListeners) { + final OnChangeListener oneListener = listener; + oneListener.post(new Runnable() { + @Override + public void run() { + oneListener.onLightweightThemeChanged(); + } + }); + } + } + + @Override + public void handleMessage(String event, JSONObject message) { + try { + if (event.equals("LightweightTheme:Update")) { + JSONObject lightweightTheme = message.getJSONObject("data"); + String headerURL = lightweightTheme.getString("headerURL"); + int mark = headerURL.indexOf('?'); + if (mark != -1) + headerURL = headerURL.substring(0, mark); + setLightweightTheme(headerURL); + } else if (event.equals("LightweightTheme:Disable")) { + resetLightweightTheme(); + } + } catch (Exception e) { + Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e); + } + } + + /** + * 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 { + if (Build.VERSION.SDK_INT >= 11) { + offsetX += (int) curView.getTranslationX() - curView.getScrollX(); + offsetY += (int) curView.getTranslationY() - curView.getScrollY(); + } else { + offsetX -= curView.getScrollX(); + offsetY -= curView.getScrollY(); + } + + parent = curView.getParent(); + + if (parent instanceof View) + curView = (View) parent; + + } while(parent instanceof View && parent != null); + + // 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, with the required alpha. + * LightweightThemeDrawable is optionally placed over a ColorDrawable (of dominant color), + * if the cropped bitmap cannot fill the entire view. + * + * @param view The view for which a background drawable is required. + * @param alpha The alpha (0..255) value to be applied to the Drawable. + * @return Either the cropped bitmap as a Drawable or null. + */ + public Drawable getDrawableWithAlpha(View view, int alpha) { + return getDrawableWithAlpha(view, alpha, alpha); + } + + /** + * Converts the cropped bitmap to a LightweightThemeDrawable, with the required alpha applied as + * a LinearGradient. LightweightThemeDrawable is optionally placed over a ColorDrawable + * (of dominant color), if the cropped bitmap cannot fill the entire view. + * + * @param view The view for which a background drawable is required. + * @param startAlpha The top alpha (0..255) of the linear gradient to be applied to the Drawable. + * @param endAlpha The bottom alpha (0..255) of the linear gradient to be applied to the Drawable. + * @return Either the cropped bitmap as a Drawable or null. + */ + public Drawable getDrawableWithAlpha(View view, int startAlpha, int endAlpha) { + Bitmap bitmap = getCroppedBitmap(view); + if (bitmap == null) + return null; + + LightweightThemeDrawable drawable = new LightweightThemeDrawable(view.getContext().getResources(), bitmap); + drawable.setAlpha(startAlpha, endAlpha); + drawable.setGravity(Gravity.TOP|Gravity.RIGHT|Gravity.FILL_HORIZONTAL); + + if (bitmap.getHeight() != view.getHeight()) { + ColorDrawable colorDrawable = new ColorDrawable(mColor); + LayerDrawable layerDrawable = new LayerDrawable(new Drawable[]{ colorDrawable, drawable }); + return layerDrawable; + } else { + return drawable; + } + } +} diff --git a/mobile/android/base/LightweightThemeDrawable.java b/mobile/android/base/LightweightThemeDrawable.java new file mode 100644 index 000000000000..555617335d26 --- /dev/null +++ b/mobile/android/base/LightweightThemeDrawable.java @@ -0,0 +1,59 @@ +/* -*- 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; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.LinearGradient; +import android.graphics.Path; +import android.graphics.PorterDuff.Mode; +import android.graphics.Rect; +import android.graphics.Shader; + +public class LightweightThemeDrawable extends BitmapDrawable + implements CanvasDelegate.DrawManager { + private static final String LOGTAG = "GeckoLightweightThemeDrawable"; + private Path mPath; + private CanvasDelegate mCanvasDelegate; + private Bitmap mBitmap; + private int mStartColor; + private int mEndColor; + + public LightweightThemeDrawable(Resources resources, Bitmap bitmap) { + super(resources, bitmap); + mBitmap = bitmap; + + mPath = new Path(); + mCanvasDelegate = new CanvasDelegate(this, Mode.DST_IN); + } + + public void setAlpha(int startAlpha, int endAlpha) { + mStartColor = startAlpha << 24; + mEndColor = endAlpha << 24; + } + + @Override + protected void onBoundsChange(Rect bounds) { + mCanvasDelegate.setShader(new LinearGradient(0, 0, + 0, mBitmap.getHeight(), + mStartColor, mEndColor, + Shader.TileMode.CLAMP)); + + mPath.addRect(0, 0, mBitmap.getWidth(), mBitmap.getHeight(), Path.Direction.CW); + } + + @Override + public void draw(Canvas canvas) { + mCanvasDelegate.draw(canvas, mPath, canvas.getWidth(), canvas.getHeight()); + } + + @Override + public void defaultDraw(Canvas canvas) { + super.draw(canvas); + } +} diff --git a/mobile/android/base/Makefile.in b/mobile/android/base/Makefile.in index 72b65889d66a..4f883d3078c8 100644 --- a/mobile/android/base/Makefile.in +++ b/mobile/android/base/Makefile.in @@ -92,6 +92,8 @@ FENNEC_JAVA_FILES = \ GeckoViewsFactory.java \ HeightChangeAnimation.java \ InputMethods.java \ + LightweightTheme.java \ + LightweightThemeDrawable.java \ LinkPreference.java \ LinkTextView.java \ MemoryMonitor.java \ diff --git a/mobile/android/base/gfx/BitmapUtils.java b/mobile/android/base/gfx/BitmapUtils.java index 4540160c35b6..8bb1b79d9d99 100644 --- a/mobile/android/base/gfx/BitmapUtils.java +++ b/mobile/android/base/gfx/BitmapUtils.java @@ -10,6 +10,10 @@ import android.graphics.Color; public final class BitmapUtils { public static int getDominantColor(Bitmap source) { + return getDominantColor(source, true); + } + + public static int getDominantColor(Bitmap source, boolean applyThreshold) { int[] colors = new int[37]; int[] sat = new int[11]; int[] val = new int[11]; @@ -29,20 +33,22 @@ public final class BitmapUtils { Color.colorToHSV(c, hsv); // arbitrarily chosen values for "white" and "black" - if (hsv[1] > 0.35f && hsv[2] > 0.35f) { - int h = Math.round(hsv[0] / 10.0f); - int s = Math.round(hsv[1] * 10.0f); - int v = Math.round(hsv[2] * 10.0f); - colors[h]++; - sat[s]++; - val[v]++; - // we only care about the most unique non white or black hue, but also - // store its saturation and value params to match the color better - if (colors[h] > colors[maxH]) { - maxH = h; - maxS = s; - maxV = v; - } + if (applyThreshold && hsv[1] <= 0.35f && hsv[2] <= 0.35f) + continue; + + int h = Math.round(hsv[0] / 10.0f); + int s = Math.round(hsv[1] * 10.0f); + int v = Math.round(hsv[2] * 10.0f); + colors[h]++; + sat[s]++; + val[v]++; + + // we only care about the most unique non white or black hue - if threshold is applied + // we also store its saturation and value params to match the color better + if (colors[h] > colors[maxH]) { + maxH = h; + maxS = s; + maxV = v; } } }