Bug 1012462 - Part 11: Support image loading for distribution files (r=rnewman)
authorLucas Rocha <lucasr@mozilla.com>
Tue, 15 Jul 2014 20:56:48 +0100
changeset 194326 f574d2d9ef70cf1e83167e1788a18f9d6da767f2
parent 194325 b9cd95b9e5a0e4ae782a94ccc9b4bd418dea99ac
child 194327 bf9c873c60f16cadd842d1bccc50a53c4badd47f
push id27143
push usercbook@mozilla.com
push dateWed, 16 Jul 2014 13:54:56 +0000
treeherdermozilla-central@f6e46d1fc903 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrnewman
bugs1012462
milestone33.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1012462 - Part 11: Support image loading for distribution files (r=rnewman)
mobile/android/base/home/ImageLoader.java
mobile/android/base/home/PanelAuthLayout.java
mobile/android/base/home/PanelBackItemView.java
mobile/android/base/home/PanelItemView.java
mobile/android/base/home/PanelLayout.java
mobile/android/base/home/TopSitesGridItemView.java
mobile/android/base/moz.build
mobile/android/tests/browser/junit3/moz.build
mobile/android/tests/browser/junit3/src/tests/TestImageDownloader.java
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/home/ImageLoader.java
@@ -0,0 +1,154 @@
+/* -*- 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.home;
+
+import android.content.Context;
+import android.net.Uri;
+import android.util.DisplayMetrics;
+import android.util.Log;
+
+import com.squareup.picasso.Picasso;
+import com.squareup.picasso.Downloader.Response;
+import com.squareup.picasso.UrlConnectionDownloader;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.EnumSet;
+import java.util.Set;
+
+import org.mozilla.gecko.distribution.Distribution;
+
+public class ImageLoader {
+    private static final String LOGTAG = "GeckoImageLoader";
+
+    private static final String DISTRIBUTION_SCHEME = "gecko.distribution";
+    private static final String SUGGESTED_SITES_AUTHORITY = "suggestedsites";
+
+    // The order of density factors to try when looking for an image resource
+    // in the distribution directory. It looks for an exact match first (1.0) then
+    // tries to find images with higher density (2.0 and 1.5). If no image is found,
+    // try a lower density (0.5). See loadDistributionImage().
+    private static final float[] densityFactors = new float[] { 1.0f, 2.0f, 1.5f, 0.5f };
+
+    private static enum Density {
+        MDPI,
+        HDPI,
+        XHDPI,
+        XXHDPI;
+
+        @Override
+        public String toString() {
+            return super.toString().toLowerCase();
+        }
+    }
+
+    private static Picasso instance;
+
+    public static synchronized Picasso with(Context context) {
+        if (instance == null) {
+            Picasso.Builder builder = new Picasso.Builder(context);
+
+            final Distribution distribution = Distribution.getInstance(context);
+            builder.downloader(new ImageDownloader(context, distribution));
+            instance = builder.build();
+        }
+
+        return instance;
+    }
+
+    /**
+     * Custom Downloader built on top of Picasso's UrlConnectionDownloader
+     * that supports loading images from custom URIs.
+     */
+    public static class ImageDownloader extends UrlConnectionDownloader {
+        private final Context context;
+        private final Distribution distribution;
+
+        public ImageDownloader(Context context, Distribution distribution) {
+            super(context);
+            this.context = context;
+            this.distribution = distribution;
+        }
+
+        private Density getDensity(float factor) {
+            final DisplayMetrics dm = context.getResources().getDisplayMetrics();
+            final float densityDpi = dm.densityDpi * factor;
+
+            if (densityDpi >= DisplayMetrics.DENSITY_XXHIGH) {
+                return Density.XXHDPI;
+            } else if (densityDpi >= DisplayMetrics.DENSITY_XHIGH) {
+                return Density.XHDPI;
+            } else if (densityDpi >= DisplayMetrics.DENSITY_HIGH) {
+                return Density.HDPI;
+            }
+
+            // Fallback to mdpi, no need to handle ldpi.
+            return Density.MDPI;
+        }
+
+        @Override
+        public Response load(Uri uri, boolean localCacheOnly) throws IOException {
+            final String scheme = uri.getScheme();
+            if (DISTRIBUTION_SCHEME.equals(scheme)) {
+                return loadDistributionImage(uri);
+            }
+
+            return super.load(uri, localCacheOnly);
+        }
+
+        private static String getPathForDensity(String basePath, Density density,
+                                                String filename) {
+            final File dir = new File(basePath, density.toString());
+            return String.format("%s/%s.png", dir.toString(), filename);
+        }
+
+        /**
+         * Handle distribution URIs in Picasso. The expected format is:
+         *
+         *   gecko.distribution://<basepath>/<imagename>
+         *
+         * Which will look for the following file in the distribution:
+         *
+         *   <distribution-root-dir>/<basepath>/<device-density>/<imagename>.png
+         */
+        private Response loadDistributionImage(Uri uri) throws IOException {
+            // Eliminate the leading '//'
+            final String ssp = uri.getSchemeSpecificPart().substring(2);
+
+            final String filename;
+            final String basePath;
+
+            final int slashIndex = ssp.lastIndexOf('/');
+            if (slashIndex == -1) {
+                filename = ssp;
+                basePath = "";
+            } else {
+                filename = ssp.substring(slashIndex + 1);
+                basePath = ssp.substring(0, slashIndex);
+            }
+
+            Set<Density> triedDensities = EnumSet.noneOf(Density.class);
+
+            for (int i = 0; i < densityFactors.length; i++) {
+                final Density density = getDensity(densityFactors[i]);
+                if (!triedDensities.add(density)) {
+                    continue;
+                }
+
+                final String path = getPathForDensity(basePath, density, filename);
+                Log.d(LOGTAG, "Trying to load image from distribution " + path);
+
+                final File f = distribution.getDistributionFile(path);
+                if (f != null) {
+                    return new Response(new FileInputStream(f), true);
+                }
+            }
+
+            throw new ResponseException("Couldn't find suggested site image in distribution");
+        }
+    }
+}
--- a/mobile/android/base/home/PanelAuthLayout.java
+++ b/mobile/android/base/home/PanelAuthLayout.java
@@ -51,14 +51,14 @@ class PanelAuthLayout extends LinearLayo
 
         final ImageView imageView = (ImageView) findViewById(R.id.image);
         final String imageUrl = authConfig.getImageUrl();
 
         if (TextUtils.isEmpty(imageUrl)) {
             // Use a default image if an image URL isn't specified.
             imageView.setImageResource(R.drawable.icon_home_empty_firefox);
         } else {
-            Picasso.with(getContext())
-                   .load(imageUrl)
-                   .into(imageView);
+            ImageLoader.with(getContext())
+                       .load(imageUrl)
+                       .into(imageView);
         }
     }
 }
--- a/mobile/android/base/home/PanelBackItemView.java
+++ b/mobile/android/base/home/PanelBackItemView.java
@@ -28,20 +28,20 @@ class PanelBackItemView extends LinearLa
 
         title = (TextView) findViewById(R.id.title);
 
         final ImageView image = (ImageView) findViewById(R.id.image);
 
         if (TextUtils.isEmpty(backImageUrl)) {
             image.setImageResource(R.drawable.folder_up);
         } else {
-            Picasso.with(getContext())
-                   .load(backImageUrl)
-                   .placeholder(R.drawable.folder_up)
-                   .into(image);
+            ImageLoader.with(getContext())
+                       .load(backImageUrl)
+                       .placeholder(R.drawable.folder_up)
+                       .into(image);
         }
     }
 
     public void updateFromFilter(FilterDetail filter) {
         final String backText = getResources()
             .getString(R.string.home_move_up_to_filter, filter.title);
         title.setText(backText);
     }
--- a/mobile/android/base/home/PanelItemView.java
+++ b/mobile/android/base/home/PanelItemView.java
@@ -63,19 +63,19 @@ class PanelItemView extends LinearLayout
         int imageIndex = cursor.getColumnIndexOrThrow(HomeItems.IMAGE_URL);
         final String imageUrl = cursor.getString(imageIndex);
 
         // Only try to load the image if the item has define image URL
         final boolean hasImageUrl = !TextUtils.isEmpty(imageUrl);
         image.setVisibility(hasImageUrl ? View.VISIBLE : View.GONE);
 
         if (hasImageUrl) {
-            Picasso.with(getContext())
-                   .load(imageUrl)
-                   .into(image);
+            ImageLoader.with(getContext())
+                       .load(imageUrl)
+                       .into(image);
         }
     }
 
     private static class ArticleItemView extends PanelItemView {
         private ArticleItemView(Context context) {
             super(context, R.layout.panel_article_item);
             setOrientation(LinearLayout.HORIZONTAL);
         }
--- a/mobile/android/base/home/PanelLayout.java
+++ b/mobile/android/base/home/PanelLayout.java
@@ -455,20 +455,20 @@ abstract class PanelLayout extends Frame
             }
 
             final String imageUrl = (emptyViewConfig == null) ? null : emptyViewConfig.getImageUrl();
             final ImageView imageView = (ImageView) view.findViewById(R.id.home_empty_image);
 
             if (TextUtils.isEmpty(imageUrl)) {
                 imageView.setImageResource(R.drawable.icon_home_empty_firefox);
             } else {
-                Picasso.with(getContext())
-                       .load(imageUrl)
-                       .error(R.drawable.icon_home_empty_firefox)
-                       .into(imageView);
+                ImageLoader.with(getContext())
+                           .load(imageUrl)
+                           .error(R.drawable.icon_home_empty_firefox)
+                           .into(imageView);
             }
 
             viewState.setEmptyView(view);
         }
 
         return view;
     }
 
--- a/mobile/android/base/home/TopSitesGridItemView.java
+++ b/mobile/android/base/home/TopSitesGridItemView.java
@@ -144,17 +144,17 @@ public class TopSitesGridItemView extend
     }
 
     public void blankOut() {
         mUrl = "";
         mTitle = "";
         updateType(TopSites.TYPE_BLANK);
         updateTitleView();
         setLoadId(Favicons.NOT_LOADING);
-        Picasso.with(getContext()).cancelRequest(mThumbnailView);
+        ImageLoader.with(getContext()).cancelRequest(mThumbnailView);
         displayThumbnail(R.drawable.top_site_add);
 
     }
 
     public void markAsDirty() {
         mIsDirty = true;
     }
 
@@ -187,17 +187,17 @@ public class TopSitesGridItemView extend
             // Because we'll have a new favicon or thumbnail arriving shortly, and
             // we need to not reject it because we already had a thumbnail.
             mThumbnailSet = false;
         }
 
         if (changed) {
             updateTitleView();
             setLoadId(Favicons.NOT_LOADING);
-            Picasso.with(getContext()).cancelRequest(mThumbnailView);
+            ImageLoader.with(getContext()).cancelRequest(mThumbnailView);
         }
 
         if (updateType(type)) {
             changed = true;
         }
 
         // The dirty state forces the state update to return true
         // so that the adapter loads favicons once the thumbnails
@@ -228,17 +228,17 @@ public class TopSitesGridItemView extend
     public void displayThumbnail(Bitmap thumbnail) {
         if (thumbnail == null) {
             // Show a favicon based view instead.
             displayThumbnail(R.drawable.favicon);
             return;
         }
         mThumbnailSet = true;
         Favicons.cancelFaviconLoad(mLoadId);
-        Picasso.with(getContext()).cancelRequest(mThumbnailView);
+        ImageLoader.with(getContext()).cancelRequest(mThumbnailView);
 
         mThumbnailView.setScaleType(SCALE_TYPE_THUMBNAIL);
         mThumbnailView.setImageBitmap(thumbnail);
         mThumbnailView.setBackgroundDrawable(null);
     }
 
     /**
      * Display the thumbnail from a URL.
@@ -246,21 +246,21 @@ public class TopSitesGridItemView extend
      * @param imageUrl URL of the image to show.
      * @param bgColor background color to use in the view.
      */
     public void displayThumbnail(final String imageUrl, final int bgColor) {
         mThumbnailView.setScaleType(SCALE_TYPE_URL);
         mThumbnailView.setBackgroundColor(bgColor);
         mThumbnailSet = true;
 
-        Picasso.with(getContext())
-               .load(imageUrl)
-               .noFade()
-               .error(R.drawable.favicon)
-               .into(mThumbnailView);
+        ImageLoader.with(getContext())
+                   .load(imageUrl)
+                   .noFade()
+                   .error(R.drawable.favicon)
+                   .into(mThumbnailView);
     }
 
     public void displayFavicon(Bitmap favicon, String faviconURL, int expectedLoadId) {
         if (mLoadId != Favicons.NOT_LOADING &&
             mLoadId != expectedLoadId) {
             // View recycled.
             return;
         }
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -273,16 +273,17 @@ gbjar.sources += [
     'home/HomeConfigPrefsBackend.java',
     'home/HomeContextMenuInfo.java',
     'home/HomeFragment.java',
     'home/HomeListView.java',
     'home/HomePager.java',
     'home/HomePagerTabStrip.java',
     'home/HomePanelPicker.java',
     'home/HomePanelsManager.java',
+    'home/ImageLoader.java',
     'home/MultiTypeCursorAdapter.java',
     'home/PanelAuthCache.java',
     'home/PanelAuthLayout.java',
     'home/PanelBackItemView.java',
     'home/PanelGridView.java',
     'home/PanelInfoManager.java',
     'home/PanelItemView.java',
     'home/PanelLayout.java',
--- a/mobile/android/tests/browser/junit3/moz.build
+++ b/mobile/android/tests/browser/junit3/moz.build
@@ -8,16 +8,17 @@ DEFINES['ANDROID_PACKAGE_NAME'] = CONFIG
 
 jar = add_java_jar('browser-junit3')
 jar.sources += [
     'src/harness/BrowserInstrumentationTestRunner.java',
     'src/harness/BrowserTestListener.java',
     'src/tests/BrowserTestCase.java',
     'src/tests/TestDistribution.java',
     'src/tests/TestGeckoSharedPrefs.java',
+    'src/tests/TestImageDownloader.java',
     'src/tests/TestJarReader.java',
     'src/tests/TestRawResource.java',
     'src/tests/TestSuggestedSites.java',
     'src/tests/TestTopSitesCursorWrapper.java',
 ]
 jar.generated_sources = [] # None yet -- try to keep it this way.
 jar.javac_flags += ['-Xlint:all,-unchecked']
 
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/browser/junit3/src/tests/TestImageDownloader.java
@@ -0,0 +1,205 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.browser.tests;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.test.mock.MockResources;
+import android.test.RenamingDelegatingContext;
+import android.util.DisplayMetrics;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.mozilla.gecko.distribution.Distribution;
+import org.mozilla.gecko.home.ImageLoader.ImageDownloader;
+
+public class TestImageDownloader extends BrowserTestCase {
+    private static class TestContext extends RenamingDelegatingContext {
+        private static final String PREFIX = "TestImageDownloader-";
+
+        private final Resources resources;
+        private final Set<String> usedPrefs;
+
+        public TestContext(Context context) {
+            super(context, PREFIX);
+            resources = new TestResources();
+            usedPrefs = Collections.synchronizedSet(new HashSet<String>());
+        }
+
+        @Override
+        public Resources getResources() {
+            return resources;
+        }
+
+        @Override
+        public SharedPreferences getSharedPreferences(String name, int mode) {
+            usedPrefs.add(name);
+            return super.getSharedPreferences(PREFIX + name, mode);
+        }
+
+        public void clearUsedPrefs() {
+            for (String prefsName : usedPrefs) {
+                getSharedPreferences(prefsName, 0).edit().clear().commit();
+            }
+
+            usedPrefs.clear();
+        }
+    }
+
+    private static class TestResources extends MockResources {
+        private final DisplayMetrics metrics;
+
+        public TestResources() {
+            metrics = new DisplayMetrics();
+        }
+
+        @Override
+        public DisplayMetrics getDisplayMetrics() {
+            return metrics;
+        }
+
+        public void setDensityDpi(int densityDpi) {
+            metrics.densityDpi = densityDpi;
+        }
+    }
+
+    private static class TestDistribution extends Distribution {
+        final List<String> accessedFiles;
+
+        public TestDistribution(Context context) {
+            super(context);
+            accessedFiles = new ArrayList<String>();
+        }
+
+        @Override
+        public File getDistributionFile(String name) {
+            accessedFiles.add(name);
+
+            // Return null to ensure the ImageDownloader will go
+            // through a complete density lookup for each filename.
+            return null;
+        }
+
+        public List<String> getAccessedFiles() {
+            return Collections.unmodifiableList(accessedFiles);
+        }
+
+        public void resetAccessedFiles() {
+            accessedFiles.clear();
+        }
+    }
+
+    private TestContext context;
+    private TestResources resources;
+    private TestDistribution distribution;
+    private ImageDownloader downloader;
+
+    protected void setUp() {
+        context = new TestContext(getApplicationContext());
+        resources = (TestResources) context.getResources();
+        distribution = new TestDistribution(context);
+        downloader = new ImageDownloader(context, distribution);
+    }
+
+    protected void tearDown() {
+        context.clearUsedPrefs();
+    }
+
+    private void triggerLoad(Uri uri) {
+        try {
+            downloader.load(uri, false);
+        } catch (IOException e) {
+            // Ignore any IO exceptions.
+        }
+    }
+
+    private void checkAccessedFiles(String[] filenames) {
+        List<String> accessedFiles = distribution.getAccessedFiles();
+
+        for (int i = 0; i < filenames.length; i++) {
+            assertEquals(filenames[i], accessedFiles.get(i));
+        }
+    }
+
+    private void checkAccessedFilesForUri(Uri uri, int densityDpi, String[] filenames) {
+        resources.setDensityDpi(densityDpi);
+        triggerLoad(uri);
+        checkAccessedFiles(filenames);
+        distribution.resetAccessedFiles();
+    }
+
+    public void testAccessedFiles() {
+        // Filename only.
+        checkAccessedFilesForUri(Uri.parse("gecko.distribution://file"),
+                                 DisplayMetrics.DENSITY_MEDIUM,
+                                 new String[] {
+                                    "mdpi/file.png",
+                                    "xhdpi/file.png",
+                                    "hdpi/file.png"
+                                 });
+
+        // Directory and filename.
+        checkAccessedFilesForUri(Uri.parse("gecko.distribution://dir/file"),
+                                 DisplayMetrics.DENSITY_MEDIUM,
+                                 new String[] {
+                                    "dir/mdpi/file.png",
+                                    "dir/xhdpi/file.png",
+                                    "dir/hdpi/file.png"
+                                 });
+
+        // Sub-directories and filename.
+        checkAccessedFilesForUri(Uri.parse("gecko.distribution://dir/subdir/file"),
+                                 DisplayMetrics.DENSITY_MEDIUM,
+                                 new String[] {
+                                    "dir/subdir/mdpi/file.png",
+                                    "dir/subdir/xhdpi/file.png",
+                                    "dir/subdir/hdpi/file.png"
+                                 });
+    }
+
+    public void testDensityLookup() {
+        Uri uri = Uri.parse("gecko.distribution://file");
+
+        // Medium density
+        checkAccessedFilesForUri(uri,
+                                 DisplayMetrics.DENSITY_MEDIUM,
+                                 new String[] {
+                                    "mdpi/file.png",
+                                    "xhdpi/file.png",
+                                    "hdpi/file.png"
+                                 });
+
+        checkAccessedFilesForUri(uri,
+                                 DisplayMetrics.DENSITY_HIGH,
+                                 new String[] {
+                                    "hdpi/file.png",
+                                    "xxhdpi/file.png",
+                                    "xhdpi/file.png"
+                                 });
+
+        checkAccessedFilesForUri(uri,
+                                 DisplayMetrics.DENSITY_XHIGH,
+                                 new String[] {
+                                    "xhdpi/file.png",
+                                    "xxhdpi/file.png",
+                                    "mdpi/file.png"
+                                 });
+
+
+        checkAccessedFilesForUri(uri,
+                                 DisplayMetrics.DENSITY_XXHIGH,
+                                 new String[] {
+                                    "xxhdpi/file.png",
+                                    "hdpi/file.png"
+                                 });
+    }
+}