From 0b216e16ae642ce16f2a0834bcd45342921a192a Mon Sep 17 00:00:00 2001 From: Lucas Rocha Date: Tue, 15 Jul 2014 20:56:48 +0100 Subject: [PATCH] Bug 1012462 - Part 11: Support image loading for distribution files (r=rnewman) --- mobile/android/base/home/ImageLoader.java | 154 +++++++++++++ mobile/android/base/home/PanelAuthLayout.java | 6 +- .../android/base/home/PanelBackItemView.java | 8 +- mobile/android/base/home/PanelItemView.java | 6 +- mobile/android/base/home/PanelLayout.java | 8 +- .../base/home/TopSitesGridItemView.java | 16 +- mobile/android/base/moz.build | 1 + mobile/android/tests/browser/junit3/moz.build | 1 + .../junit3/src/tests/TestImageDownloader.java | 205 ++++++++++++++++++ 9 files changed, 383 insertions(+), 22 deletions(-) create mode 100644 mobile/android/base/home/ImageLoader.java create mode 100644 mobile/android/tests/browser/junit3/src/tests/TestImageDownloader.java diff --git a/mobile/android/base/home/ImageLoader.java b/mobile/android/base/home/ImageLoader.java new file mode 100644 index 000000000000..48cebb947ae5 --- /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:/// + * + * Which will look for the following file in the distribution: + * + * ///.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 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"); + } + } +} diff --git a/mobile/android/base/home/PanelAuthLayout.java b/mobile/android/base/home/PanelAuthLayout.java index 870939a72cf5..761b1380e332 100644 --- a/mobile/android/base/home/PanelAuthLayout.java +++ b/mobile/android/base/home/PanelAuthLayout.java @@ -56,9 +56,9 @@ class PanelAuthLayout extends LinearLayout { // 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); } } } diff --git a/mobile/android/base/home/PanelBackItemView.java b/mobile/android/base/home/PanelBackItemView.java index 1d9ffa40548c..e82b3fcd8426 100644 --- a/mobile/android/base/home/PanelBackItemView.java +++ b/mobile/android/base/home/PanelBackItemView.java @@ -33,10 +33,10 @@ class PanelBackItemView extends LinearLayout { 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); } } diff --git a/mobile/android/base/home/PanelItemView.java b/mobile/android/base/home/PanelItemView.java index fb58254cecc0..6dc39645d414 100644 --- a/mobile/android/base/home/PanelItemView.java +++ b/mobile/android/base/home/PanelItemView.java @@ -68,9 +68,9 @@ class PanelItemView extends LinearLayout { image.setVisibility(hasImageUrl ? View.VISIBLE : View.GONE); if (hasImageUrl) { - Picasso.with(getContext()) - .load(imageUrl) - .into(image); + ImageLoader.with(getContext()) + .load(imageUrl) + .into(image); } } diff --git a/mobile/android/base/home/PanelLayout.java b/mobile/android/base/home/PanelLayout.java index b90700865c8b..99fc2542e725 100644 --- a/mobile/android/base/home/PanelLayout.java +++ b/mobile/android/base/home/PanelLayout.java @@ -460,10 +460,10 @@ abstract class PanelLayout extends FrameLayout { 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); diff --git a/mobile/android/base/home/TopSitesGridItemView.java b/mobile/android/base/home/TopSitesGridItemView.java index daa21e8af0af..288646928c23 100644 --- a/mobile/android/base/home/TopSitesGridItemView.java +++ b/mobile/android/base/home/TopSitesGridItemView.java @@ -149,7 +149,7 @@ public class TopSitesGridItemView extends RelativeLayout { 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); } @@ -192,7 +192,7 @@ public class TopSitesGridItemView extends RelativeLayout { if (changed) { updateTitleView(); setLoadId(Favicons.NOT_LOADING); - Picasso.with(getContext()).cancelRequest(mThumbnailView); + ImageLoader.with(getContext()).cancelRequest(mThumbnailView); } if (updateType(type)) { @@ -233,7 +233,7 @@ public class TopSitesGridItemView extends RelativeLayout { } mThumbnailSet = true; Favicons.cancelFaviconLoad(mLoadId); - Picasso.with(getContext()).cancelRequest(mThumbnailView); + ImageLoader.with(getContext()).cancelRequest(mThumbnailView); mThumbnailView.setScaleType(SCALE_TYPE_THUMBNAIL); mThumbnailView.setImageBitmap(thumbnail); @@ -251,11 +251,11 @@ public class TopSitesGridItemView extends RelativeLayout { 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) { diff --git a/mobile/android/base/moz.build b/mobile/android/base/moz.build index 70d60bb642d5..8be7114c281e 100644 --- a/mobile/android/base/moz.build +++ b/mobile/android/base/moz.build @@ -278,6 +278,7 @@ gbjar.sources += [ 'home/HomePagerTabStrip.java', 'home/HomePanelPicker.java', 'home/HomePanelsManager.java', + 'home/ImageLoader.java', 'home/MultiTypeCursorAdapter.java', 'home/PanelAuthCache.java', 'home/PanelAuthLayout.java', diff --git a/mobile/android/tests/browser/junit3/moz.build b/mobile/android/tests/browser/junit3/moz.build index c620954755e8..631a58e13d4e 100644 --- a/mobile/android/tests/browser/junit3/moz.build +++ b/mobile/android/tests/browser/junit3/moz.build @@ -13,6 +13,7 @@ jar.sources += [ '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', diff --git a/mobile/android/tests/browser/junit3/src/tests/TestImageDownloader.java b/mobile/android/tests/browser/junit3/src/tests/TestImageDownloader.java new file mode 100644 index 000000000000..dfa3162ec354 --- /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 usedPrefs; + + public TestContext(Context context) { + super(context, PREFIX); + resources = new TestResources(); + usedPrefs = Collections.synchronizedSet(new HashSet()); + } + + @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 accessedFiles; + + public TestDistribution(Context context) { + super(context); + accessedFiles = new ArrayList(); + } + + @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 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 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" + }); + } +}