cmparts: Add contributors cloud

 * Original work by:
   - Jorge Ruesga
   - Danesh Mondegarian
   - Matt Mower
   - Michael Bestas

Change-Id: Iddb528af063e75fdea58627903bed9a8f18fed29
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index d08e3cb..078135e 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -33,6 +33,7 @@
     <uses-permission android:name="android.permission.DEVICE_POWER" />
     <uses-permission android:name="android.permission.CHANGE_CONFIGURATION" />
     <uses-permission android:name="android.permission.BIND_DEVICE_ADMIN" />
+    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
 
     <uses-permission android:name="cyanogenmod.permission.BIND_CORE_SERVICE" />
 
@@ -62,6 +63,11 @@
             </intent-filter>
         </service>
 
+        <receiver android:name="BootReceiver" android:enabled="true">
+            <intent-filter android:priority="2147483647">
+                <action android:name="android.intent.action.BOOT_COMPLETED" />
+            </intent-filter>
+        </receiver>
 
         <!-- Privacy settings (dashboard) -->
         <activity-alias
diff --git a/assets/contributors.db b/assets/contributors.db
new file mode 100644
index 0000000..0bda60d
--- /dev/null
+++ b/assets/contributors.db
Binary files differ
diff --git a/proguard.flags b/proguard.flags
index 04f1111..653ffff 100644
--- a/proguard.flags
+++ b/proguard.flags
@@ -2,10 +2,12 @@
 -keep class org.cyanogenmod.cmparts.*Fragment
 -keep class org.cyanogenmod.cmparts.*Picker
 -keep class org.cyanogenmod.cmparts.*Settings
--keep class org.cyanogenmod.cmparts.notificationlight.*
--keep class org.cyanogenmod.cmparts.livedisplay.*
--keep class org.cyanogenmod.cmparts.privacyguard.*
+
+-keep class org.cyanogenmod.cmparts.contributors.*
 -keep class org.cyanogenmod.cmparts.input.*
+-keep class org.cyanogenmod.cmparts.livedisplay.*
+-keep class org.cyanogenmod.cmparts.notificationlight.*
+-keep class org.cyanogenmod.cmparts.privacyguard.*
 -keep class org.cyanogenmod.cmparts.profiles.*
 
 # Keep click responders
diff --git a/res/drawable/ic_person.xml b/res/drawable/ic_person.xml
new file mode 100644
index 0000000..9a211b4
--- /dev/null
+++ b/res/drawable/ic_person.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2013 The CyanogenMod Project
+
+     Licensed under the Apache License, Version 2.0 (the "License");
+     you may not use this file except in compliance with the License.
+     You may obtain a copy of the License at
+
+          http://www.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+
+    <path
+        android:fillColor="@color/theme_accent"
+        android:pathData="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8
+4v2h16v-2c0-2.66-5.33-4-8-4z" />
+</vector>
diff --git a/res/drawable/ic_warning.xml b/res/drawable/ic_warning.xml
new file mode 100644
index 0000000..4001b74
--- /dev/null
+++ b/res/drawable/ic_warning.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2011 The Android Open Source Project
+
+     Licensed under the Apache License, Version 2.0 (the "License");
+     you may not use this file except in compliance with the License.
+     You may obtain a copy of the License at
+
+          http://www.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="512dp"
+    android:height="442.182dp"
+    android:viewportWidth="512"
+    android:viewportHeight="442.182">
+
+    <path
+        android:fillColor="#000"
+        android:pathData="M0,442.182h512L256,0L0,442.182z M279.272,372.363h-46.545v-46.545h46.545V372.363z
+M279.272,279.272h-46.545v-93.091 h46.545V279.272z" />
+</vector>
diff --git a/res/layout/contributors_search_result.xml b/res/layout/contributors_search_result.xml
new file mode 100644
index 0000000..c5607da
--- /dev/null
+++ b/res/layout/contributors_search_result.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2011 The Android Open Source Project
+
+     Licensed under the Apache License, Version 2.0 (the "License");
+     you may not use this file except in compliance with the License.
+     You may obtain a copy of the License at
+
+          http://www.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:minHeight="48dp"
+    android:orientation="vertical"
+    android:layout_margin="16dp"
+    android:gravity="center_vertical">
+
+    <TextView android:id="@+id/contributor_name"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:drawableStart="@drawable/ic_person"
+        android:drawablePadding="8dp"
+        android:singleLine="true"
+        android:maxLines="1"
+        android:ellipsize="end"
+        android:gravity="center_vertical" />
+</LinearLayout>
diff --git a/res/layout/contributors_view.xml b/res/layout/contributors_view.xml
new file mode 100644
index 0000000..1c6a1ee
--- /dev/null
+++ b/res/layout/contributors_view.xml
@@ -0,0 +1,80 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2015 The CyanogenMod Project
+
+     Licensed under the Apache License, Version 2.0 (the "License");
+     you may not use this file except in compliance with the License.
+     You may obtain a copy of the License at
+
+          http://www.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <ImageView
+        android:id="@+id/contributors_cloud_image"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:contentDescription="@string/contributors_cloud_fragment_title"
+        android:visibility="gone"/>
+
+    <LinearLayout
+        android:id="@+id/contributors_cloud_loading"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:orientation="vertical"
+        android:layout_gravity="center"
+        android:visibility="visible">
+
+        <ProgressBar
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:indeterminateOnly="true"
+            android:layout_gravity="center_horizontal"/>
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="8dp"
+            android:layout_gravity="center_horizontal"
+            android:text="@string/contributors_cloud_loading_message">
+        </TextView>
+    </LinearLayout>
+
+    <LinearLayout
+        android:id="@+id/contributors_cloud_failed"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:orientation="vertical"
+        android:layout_gravity="center"
+        android:visibility="gone">
+
+        <ImageView
+            android:layout_width="64dp"
+            android:layout_height="64dp"
+            android:src="@drawable/ic_warning"
+            android:contentDescription="@string/contributors_cloud_failed_message"
+            android:layout_gravity="center_horizontal"/>
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="8dp"
+            android:layout_gravity="center_horizontal"
+            android:text="@string/contributors_cloud_failed_message">
+        </TextView>
+    </LinearLayout>
+
+    <ListView
+        android:id="@+id/contributors_cloud_search_results"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:visibility="gone" />
+</FrameLayout>
diff --git a/res/menu/contributors_menu.xml b/res/menu/contributors_menu.xml
new file mode 100644
index 0000000..5089cda
--- /dev/null
+++ b/res/menu/contributors_menu.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2015 The CyanogenMod Project
+
+     Licensed under the Apache License, Version 2.0 (the "License");
+     you may not use this file except in compliance with the License.
+     You may obtain a copy of the License at
+
+          http://www.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+    <item
+        android:id="@+id/contributors_search"
+        android:title="@string/search"
+        android:icon="@*android:drawable/ic_search_api_material"
+        android:showAsAction="collapseActionView|ifRoom"
+        android:actionViewClass="android.widget.SearchView" />
+    <item
+        android:id="@+id/contributor_info"
+        android:title="@string/contributor_info_menu"
+        android:showAsAction="never" />
+    <item
+        android:id="@+id/contributions_info"
+        android:title="@string/contributions_info_menu"
+        android:showAsAction="never" />
+</menu>
diff --git a/res/values/colors.xml b/res/values/colors.xml
index 135843c..0f97ed0 100644
--- a/res/values/colors.xml
+++ b/res/values/colors.xml
@@ -28,5 +28,9 @@
 
     <color name="fab_ripple">#1fffffff</color><!-- 12% white -->
     <color name="fab_shape">?android:attr/colorAccent</color>
+
+    <!-- Contributors -->
+    <color name="contributors_cloud_fg_color">@color/theme_accent</color>
+    <color name="contributors_cloud_selected_color">#ff5252</color>
 </resources>
 
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 169e873..a69e663 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -17,6 +17,8 @@
 <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
 
     <string name="cmparts_title">CyanogenMod Settings</string>
+
+    <!-- Generic stuff used everywhere -->
     <string name="loading">Loading\u2026</string>
     <string name="dlg_ok">OK</string>
     <string name="cancel">Cancel</string>
@@ -33,6 +35,7 @@
     <string name="off">Off</string>
     <string name="yes">Yes</string>
     <string name="no">No</string>
+    <string name="search">Search</string>
 
     <!-- Privacy Settings Header item -->
     <string name="privacy_settings_title">Privacy</string>
@@ -451,4 +454,19 @@
     <string name="protected_apps_manager_title">Protected apps</string>
     <string name="protected_apps_manager_summary">Manage which apps are hidden behind a secure lock</string>
 
+    <!-- Contributors cloud activity -->
+    <string name="contributors_cloud_fragment_title">Contributors</string>
+    <string name="contributors_cloud_loading_message">Loading contributors data\u2026</string>
+    <string name="contributors_cloud_failed_message">Cannot load contributors data</string>
+    <string name="contributor_info_menu">Contributor info</string>
+    <string name="contributor_info_msg">
+        <![CDATA[<b>Name:</b> <xliff:g id="name">%1$s</xliff:g><br/><br/>
+                 <b>Nick:</b> <xliff:g id="nick">%2$s</xliff:g><br/><br/>
+                 <b>Commits:</b> <xliff:g id="commits">%3$s</xliff:g>]]></string>
+    <string name="contributions_info_menu">Contributions info</string>
+    <string name="contributions_info_msg">
+        <![CDATA[<b>Total contributors:</b> <xliff:g id="total_contributors">%1$s</xliff:g><br/><br/>
+                 <b>Total commits:</b> <xliff:g id="total_commits">%2$s</xliff:g><br/><br/>
+                 <b>Last update:</b> <xliff:g id="date">%3$s</xliff:g>]]></string>
+
 </resources>
diff --git a/res/xml/parts_catalog.xml b/res/xml/parts_catalog.xml
index e9a133c..404d93f 100644
--- a/res/xml/parts_catalog.xml
+++ b/res/xml/parts_catalog.xml
@@ -46,4 +46,8 @@
           android:title="@string/status_bar_title"
           android:fragment="org.cyanogenmod.cmparts.statusbar.StatusBarSettings" />
 
+    <part android:key="contributors"
+          android:title="@string/contributors_cloud_fragment_title"
+          android:fragment="org.cyanogenmod.cmparts.contributors.ContributorsCloudFragment" />
+
 </parts-catalog>
diff --git a/src/org/cyanogenmod/cmparts/BootReceiver.java b/src/org/cyanogenmod/cmparts/BootReceiver.java
new file mode 100644
index 0000000..8968156
--- /dev/null
+++ b/src/org/cyanogenmod/cmparts/BootReceiver.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2012 The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.cyanogenmod.cmparts;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.support.v7.preference.PreferenceManager;
+
+import org.cyanogenmod.cmparts.contributors.ContributorsCloudFragment;
+import org.cyanogenmod.cmparts.input.ButtonSettings;
+
+public class BootReceiver extends BroadcastReceiver {
+
+    private static final String TAG = "BootReceiver";
+    private static final String ONE_TIME_TUNABLE_RESTORE = "hardware_tunable_restored";
+
+    @Override
+    public void onReceive(Context ctx, Intent intent) {
+        if (!hasRestoredTunable(ctx)) {
+            /* Restore the hardware tunable values */
+            ButtonSettings.restoreKeyDisabler(ctx);
+            setRestoredTunable(ctx);
+        }
+
+        // Extract the contributors database
+        ContributorsCloudFragment.extractContributorsCloudDatabase(ctx);
+    }
+
+    private boolean hasRestoredTunable(Context context) {
+        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
+        return preferences.getBoolean(ONE_TIME_TUNABLE_RESTORE, false);
+    }
+
+    private void setRestoredTunable(Context context) {
+        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
+        preferences.edit().putBoolean(ONE_TIME_TUNABLE_RESTORE, true).apply();
+    }
+}
diff --git a/src/org/cyanogenmod/cmparts/contributors/ContributorsCloudFragment.java b/src/org/cyanogenmod/cmparts/contributors/ContributorsCloudFragment.java
new file mode 100644
index 0000000..21864ba
--- /dev/null
+++ b/src/org/cyanogenmod/cmparts/contributors/ContributorsCloudFragment.java
@@ -0,0 +1,770 @@
+/*
+ * Copyright (C) 2015 The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.cyanogenmod.cmparts.contributors;
+
+import android.animation.Animator;
+import android.animation.Animator.AnimatorListener;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.app.Activity;
+import android.app.ActivityManager;
+import android.app.AlertDialog;
+import android.app.Fragment;
+import android.content.Context;
+import android.content.res.AssetManager;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.database.SQLException;
+import android.database.sqlite.SQLiteDatabase;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.Handler;
+import android.text.Html;
+import android.text.TextUtils;
+import android.text.format.DateFormat;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.view.animation.LinearInterpolator;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.ImageView;
+import android.widget.ListView;
+import android.widget.SearchView;
+import android.widget.TextView;
+import android.widget.AdapterView.OnItemClickListener;
+
+import org.cyanogenmod.cmparts.R;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.text.NumberFormat;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+public class ContributorsCloudFragment extends Fragment implements SearchView.OnQueryTextListener,
+        SearchView.OnCloseListener, MenuItem.OnActionExpandListener {
+
+    private static final String TAG = "ContributorsCloud";
+
+    private static final String DB_NAME = "contributors.db";
+
+    private static final String STATE_SELECTED_CONTRIBUTOR = "state_selected_contributor";
+
+    private ContributorsCloudViewController mViewController;
+    private ImageView mImageView;
+    private View mLoadingView;
+    private View mFailedView;
+    private ListView mSearchResults;
+    private ContributorsAdapter mSearchAdapter;
+
+    private SQLiteDatabase mDatabase;
+
+    private int mTotalContributors;
+    private int mTotalCommits;
+    private long mLastUpdate;
+
+    private int mSelectedContributor = -1;
+    private String mContributorName;
+    private String mContributorNick;
+    private int mContributorCommits;
+
+    private MenuItem mSearchMenuItem;
+    private MenuItem mContributorInfoMenuItem;
+    private MenuItem mContributionsInfoMenuItem;
+    private SearchView mSearchView;
+
+    private Handler mHandler;
+
+    private static class ViewInfo {
+        Bitmap mBitmap;
+        float mFocusX;
+        float mFocusY;
+    }
+
+    private static class ContributorsDataHolder {
+        int mId;
+        String mLabel;
+    }
+
+    private static class ContributorsViewHolder {
+        TextView mLabel;
+    }
+
+    private static class ContributorsAdapter extends ArrayAdapter<ContributorsDataHolder> {
+
+        public ContributorsAdapter(Context context) {
+            super(context, R.id.contributor_name, new ArrayList<ContributorsDataHolder>());
+        }
+
+        @Override
+        public View getView(int position, View convertView, ViewGroup parent) {
+            if (convertView == null) {
+                LayoutInflater li = LayoutInflater.from(getContext());
+                convertView = li.inflate(R.layout.contributors_search_result, null);
+                ContributorsViewHolder viewHolder = new ContributorsViewHolder();
+                viewHolder.mLabel = (TextView) convertView.findViewById(R.id.contributor_name);
+                convertView.setTag(viewHolder);
+            }
+
+            ContributorsDataHolder dataHolder = getItem(position);
+
+            ContributorsViewHolder viewHolder = (ContributorsViewHolder) convertView.getTag();
+            viewHolder.mLabel.setText(dataHolder.mLabel);
+
+            return convertView;
+        }
+
+        @Override
+        public boolean hasStableIds() {
+            return true;
+        }
+    }
+
+    private class ContributorCloudLoaderTask extends AsyncTask<Void, Void, Boolean> {
+        private ViewInfo mViewInfo;
+        private final boolean mNotify;
+        private final boolean mNavigate;
+
+        public ContributorCloudLoaderTask(boolean notify, boolean navigate) {
+            mNotify = notify;
+            mNavigate = navigate;
+        }
+
+        @Override
+        protected void onPreExecute() {
+            mLoadingView.setAlpha(1f);
+        }
+
+        @Override
+        protected Boolean doInBackground(Void... params) {
+            try {
+                loadContributorsInfo(getActivity());
+                loadUserInfo(getActivity());
+                mViewInfo = generateViewInfo(getActivity(), mSelectedContributor);
+                if (mViewInfo != null && mViewInfo.mBitmap != null) {
+                    return Boolean.TRUE;
+                }
+
+            } catch (Exception ex) {
+                Log.e(TAG, "Failed to generate cloud bitmap", ex);
+            }
+            return Boolean.FALSE;
+        }
+
+        @Override
+        protected void onPostExecute(Boolean result) {
+            if (result == true) {
+                mImageView.setImageBitmap(mViewInfo.mBitmap);
+                mViewController.update();
+                if (mNotify) {
+                    if (mNavigate) {
+                        onLoadCloudDataSuccess(mViewInfo.mFocusX, mViewInfo.mFocusY);
+                    } else {
+                        onLoadCloudDataSuccess(-1, -1);
+                    }
+                }
+            } else {
+                mImageView.setImageBitmap(null);
+                mViewController.update();
+                if (mViewInfo != null && mViewInfo.mBitmap != null) {
+                    mViewInfo.mBitmap.recycle();
+                }
+                if (mNotify) {
+                    onLoadCloudDataFailed();
+                }
+            }
+        }
+
+        @Override
+        protected void onCancelled() {
+            onLoadCloudDataFailed();
+        }
+    }
+
+    public ContributorsCloudFragment() {
+    }
+
+    @Override
+    public void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setHasOptionsMenu(true);
+
+        if (savedInstanceState != null) {
+            mSelectedContributor = savedInstanceState.getInt(STATE_SELECTED_CONTRIBUTOR, -1);
+        }
+    }
+
+    @Override
+    public void onDestroy() {
+        super.onDestroy();
+        if (mDatabase != null && mDatabase.isOpen()) {
+            try {
+                mDatabase.close();
+            } catch (SQLException ex) {
+                // Ignore
+            }
+        }
+    }
+
+    @Override
+    public void onSaveInstanceState(Bundle outState) {
+        super.onSaveInstanceState(outState);
+        outState.putInt(STATE_SELECTED_CONTRIBUTOR, mSelectedContributor);
+    }
+
+    @Override
+    public void onAttach(Activity activity) {
+        super.onAttach(activity);
+        activity.getWindow().setSoftInputMode(
+                WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN
+                | WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING);
+        mHandler = new Handler();
+    }
+
+    @Override
+    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+        super.onCreateOptionsMenu(menu, inflater);
+
+        // Remove all previous menus
+        int count = menu.size();
+        for (int i = 0; i < count; i++) {
+            menu.removeItem(menu.getItem(i).getItemId());
+        }
+
+        inflater.inflate(R.menu.contributors_menu, menu);
+
+        mSearchMenuItem = menu.findItem(R.id.contributors_search);
+        mContributorInfoMenuItem = menu.findItem(R.id.contributor_info);
+        mContributionsInfoMenuItem = menu.findItem(R.id.contributions_info);
+        mSearchView = (SearchView) mSearchMenuItem.getActionView();
+        mSearchMenuItem.setOnActionExpandListener(this);
+        mSearchView.setOnQueryTextListener(this);
+        mSearchView.setOnCloseListener(this);
+
+        showMenuItems(false);
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        switch (item.getItemId()) {
+            case R.id.contributors_search:
+                mSearchView.setQuery("", false);
+                mSelectedContributor = -1;
+
+                // Load the data from the database and fill the image
+                ContributorCloudLoaderTask task = new ContributorCloudLoaderTask(false, false);
+                task.execute();
+                break;
+
+            case R.id.contributor_info:
+                showUserInfo(getActivity());
+                break;
+
+            case R.id.contributions_info:
+                showContributorsInfo(getActivity());
+                break;
+
+            default:
+                break;
+        }
+        return super.onContextItemSelected(item);
+    }
+
+    @Override
+    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle state) {
+        View v = inflater.inflate(R.layout.contributors_view, container, false);
+
+        mLoadingView= v.findViewById(R.id.contributors_cloud_loading);
+        mFailedView= v.findViewById(R.id.contributors_cloud_failed);
+        mImageView = (ImageView) v.findViewById(R.id.contributors_cloud_image);
+        mViewController = new ContributorsCloudViewController(mImageView);
+        mViewController.setMaximumScale(20f);
+        mViewController.setMediumScale(7f);
+
+        mSearchResults = (ListView) v.findViewById(R.id.contributors_cloud_search_results);
+        mSearchAdapter = new ContributorsAdapter(getActivity());
+        mSearchResults.setAdapter(mSearchAdapter);
+        mSearchResults.setOnItemClickListener(new OnItemClickListener() {
+            @Override
+            public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+                ContributorsDataHolder contributor =
+                        (ContributorsDataHolder) parent.getItemAtPosition(position);
+                onContributorSelected(contributor);
+            }
+        });
+
+        // Load the data from the database and fill the image
+        ContributorCloudLoaderTask task = new ContributorCloudLoaderTask(true, false);
+        task.execute();
+
+        return v;
+    }
+
+    @Override
+    public boolean onMenuItemActionExpand(MenuItem item) {
+        if (item.getItemId() == mSearchMenuItem.getItemId()) {
+            animateFadeOutFadeIn(mImageView, mSearchResults);
+            mContributorInfoMenuItem.setVisible(false);
+            mContributionsInfoMenuItem.setVisible(false);
+        }
+        return true;
+    }
+
+    @Override
+    public boolean onMenuItemActionCollapse(MenuItem item) {
+        if (item.getItemId() == mSearchMenuItem.getItemId()) {
+            animateFadeOutFadeIn(mSearchResults, mImageView);
+            if (mSelectedContributor != -1) {
+                mContributorInfoMenuItem.setVisible(true);
+            }
+            mContributionsInfoMenuItem.setVisible(true);
+        }
+        return true;
+    }
+
+    @Override
+    public boolean onClose() {
+        animateFadeOutFadeIn(mSearchResults, mImageView);
+        return true;
+    }
+
+    @Override
+    public boolean onQueryTextSubmit(String query) {
+        return false;
+    }
+
+    @Override
+    public boolean onQueryTextChange(String newText) {
+        List<ContributorsDataHolder> contributors = new ArrayList<>();
+        if (!TextUtils.isEmpty(newText) || newText.length() >= 3) {
+            contributors.addAll(performFilter(getActivity(), newText));
+        }
+        mSearchAdapter.clear();
+        mSearchAdapter.addAll(contributors);
+        mSearchAdapter.notifyDataSetChanged();
+        return true;
+    }
+
+    private void showMenuItems(boolean visible) {
+        mSearchMenuItem.setVisible(visible);
+        mContributorInfoMenuItem.setVisible(mSelectedContributor != -1 && visible);
+        mContributionsInfoMenuItem.setVisible(visible);
+        if (!visible) {
+            mSearchView.setQuery("", false);
+            mSearchMenuItem.collapseActionView();
+        }
+    }
+
+    private void onLoadCloudDataSuccess(float focusX, float focusY) {
+        animateFadeOutFadeIn(mLoadingView.getVisibility() == View.VISIBLE
+                ? mLoadingView : mSearchResults, mImageView);
+        showMenuItems(true);
+
+        // Navigate to contributor?
+        if (focusX != -1 && focusY != -1) {
+            mViewController.setZoomTransitionDuration(2500);
+            mViewController.setScale(10, focusX, focusY, true);
+            mHandler.postDelayed(new Runnable() {
+                @Override
+                public void run() {
+                    mViewController.setZoomTransitionDuration(-1);
+                }
+            }, 2500);
+        }
+    }
+
+    private void onLoadCloudDataFailed() {
+        // Show the cloud not loaded message
+        animateFadeOutFadeIn(mLoadingView.getVisibility() == View.VISIBLE
+                ? mLoadingView : (mImageView.getVisibility() == View.VISIBLE)
+                        ? mImageView : mSearchResults, mFailedView);
+        showMenuItems(false);
+    }
+
+    private void animateFadeOutFadeIn(final View src, final View dst) {
+        if (dst.getVisibility() != View.VISIBLE || dst.getAlpha() != 1f) {
+            AnimatorSet set = new AnimatorSet();
+            set.playSequentially(
+                    ObjectAnimator.ofFloat(src, "alpha", 0f),
+                    ObjectAnimator.ofFloat(dst, "alpha", 1f));
+            set.setInterpolator(new LinearInterpolator());
+            set.addListener(new AnimatorListener() {
+                @Override
+                public void onAnimationStart(Animator animation) {
+                    src.setAlpha(1f);
+                    dst.setAlpha(0f);
+                    src.setVisibility(View.VISIBLE);
+                    dst.setVisibility(View.VISIBLE);
+                }
+
+                @Override
+                public void onAnimationRepeat(Animator animation) {
+                }
+
+                @Override
+                public void onAnimationEnd(Animator animation) {
+                    src.setVisibility(View.GONE);
+                }
+
+                @Override
+                public void onAnimationCancel(Animator animation) {
+                }
+            });
+            set.setDuration(250);
+            set.start();
+        } else {
+            src.setAlpha(1f);
+            src.setVisibility(View.GONE);
+        }
+    }
+
+    private ViewInfo generateViewInfo(Context context, int selectedId) {
+        Bitmap bitmap = null;
+        float focusX = -1, focusY = -1;
+        final Resources res = context.getResources();
+
+        // Open the database
+        SQLiteDatabase db = getDatabase(context, true);
+        if (db == null) {
+            // We don't have a valid database reference
+            return null;
+        }
+
+        // Extract original image size
+        Cursor c = db.rawQuery("select value from info where key = ?;", new String[]{"orig_size"});
+        if (c == null || !c.moveToFirst()) {
+            // We don't have a valid cursor reference
+            return null;
+        }
+        int osize = c.getInt(0);
+        c.close();
+
+        // Query the metadata table to extract all the commits information
+        c = db.rawQuery("select id, name, x, y, r, fs from metadata;", null);
+        if (c == null) {
+            // We don't have a valid cursor reference
+            return null;
+        }
+        try {
+            int colorForeground = res.getColor(R.color.contributors_cloud_fg_color);
+            int colorSelected = res.getColor(R.color.contributors_cloud_selected_color);
+            Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
+
+            // Create a bitmap large enough to hold the cloud (use large bitmap when available)
+            int bsize = hasLargeHeap() ? 2048 : 1024;
+            bitmap = Bitmap.createBitmap(bsize, bsize, Bitmap.Config.ARGB_8888);
+            Canvas canvas = new Canvas(bitmap);
+
+            // Draw every contributor name
+            while (c.moveToNext()) {
+                int id = c.getInt(c.getColumnIndexOrThrow("id"));
+
+                String name = c.getString(c.getColumnIndexOrThrow("name"));
+                float x = translate(c.getFloat(c.getColumnIndexOrThrow("x")), osize, bsize);
+                float y = translate(c.getFloat(c.getColumnIndexOrThrow("y")), osize, bsize);
+                int r = c.getInt(c.getColumnIndexOrThrow("r"));
+                float fs = translate(c.getFloat(c.getColumnIndexOrThrow("fs")), osize, bsize);
+                if (id < 0) {
+                    y -= translate(fs, osize, bsize);
+                }
+
+                // Choose the correct paint
+                paint.setColor(selectedId == id ? colorSelected : colorForeground);
+                paint.setTextSize(fs);
+
+                // Check text rotation
+                float w = 0f, h = 0f;
+                if (selectedId == id || r != 0) {
+                    Rect bounds = new Rect();
+                    paint.getTextBounds(name, 0, name.length(), bounds);
+                    h = bounds.height();
+                }
+                if (selectedId == id || r == -1) {
+                    w = paint.measureText(name);
+                }
+                if (r == 0) {
+                    // Horizontal
+                    canvas.drawText(name, x, y, paint);
+                } else {
+                    if (r == -1) {
+                        // Vertical (-90 rotation)
+                        canvas.save();
+                        canvas.translate(h, w - h);
+                        canvas.rotate(-90, x, y);
+                        canvas.drawText(name, x, y, paint);
+                        canvas.restore();
+                    } else {
+                        // Vertical (+90 rotation)
+                        canvas.save();
+                        canvas.translate(h/2, -h);
+                        canvas.rotate(90, x, y);
+                        canvas.drawText(name, x, y, paint);
+                        canvas.restore();
+                    }
+                }
+
+                // Calculate focus
+                if (selectedId == id) {
+                    int iw = mImageView.getWidth();
+                    int ih = mImageView.getHeight();
+                    int cx = iw / 2;
+                    int cy = ih / 2;
+                    int cbx = bsize / 2;
+                    int cby = bsize / 2;
+                    float cw = 0f;
+                    float ch = 0f;
+                    if (r == 0) {
+                        cw = translate(w, bsize, Math.min(iw, ih)) / 2;
+                        ch = translate(h, bsize, Math.min(iw, ih)) / 2;
+                    } else {
+                        cw = translate(h, bsize, Math.min(iw, ih)) / 2;
+                        ch = translate(w, bsize, Math.min(iw, ih)) / 2;
+                    }
+
+                    focusX = cx + translate(x - cbx, bsize, iw) + cw;
+                    focusY = cy + translate(y - cby, bsize, ih) + ch;
+                }
+            }
+
+        } finally {
+            c.close();
+        }
+
+        // Return the bitmap
+        ViewInfo viewInfo = new ViewInfo();
+        viewInfo.mBitmap = bitmap;
+        viewInfo.mFocusX = focusX;
+        viewInfo.mFocusY = focusY;
+        return viewInfo;
+    }
+
+    private synchronized SQLiteDatabase getDatabase(Context context, boolean retryCopyIfOpenFails) {
+        if (mDatabase == null) {
+            File dbPath = context.getDatabasePath(DB_NAME);
+            try {
+                mDatabase = SQLiteDatabase.openDatabase(dbPath.getAbsolutePath(),
+                        null, SQLiteDatabase.OPEN_READONLY);
+                if (mDatabase == null) {
+                    Log.e(TAG, "Cannot open cloud database: " + DB_NAME + ". db == null");
+                    return null;
+                }
+                return mDatabase;
+
+            } catch (SQLException ex) {
+                Log.e(TAG, "Cannot open cloud database: " + DB_NAME, ex);
+                if (mDatabase != null && mDatabase.isOpen()) {
+                    try {
+                        mDatabase.close();
+                    } catch (SQLException ex2) {
+                        // Ignore
+                    }
+                }
+
+                if (retryCopyIfOpenFails) {
+                    extractContributorsCloudDatabase(context);
+                    mDatabase = getDatabase(context, false);
+                }
+            }
+
+            // We don't have a valid connection
+            return null;
+        }
+        return mDatabase;
+    }
+
+    private void loadContributorsInfo(Context context) {
+        mTotalContributors = -1;
+        mTotalCommits = -1;
+        mLastUpdate = -1;
+
+        // Open the database
+        SQLiteDatabase db = getDatabase(context, true);
+        if (db == null) {
+            // We don't have a valid database reference
+            return;
+        }
+
+        // Total contributors
+        Cursor c = db.rawQuery("select count(*) from metadata where id > 0;", null);
+        if (c == null || !c.moveToFirst()) {
+            // We don't have a valid cursor reference
+            return;
+        }
+        mTotalContributors = c.getInt(0);
+        c.close();
+
+        // Total commits
+        c = db.rawQuery("select sum(commits) from metadata where id > 0;", null);
+        if (c == null || !c.moveToFirst()) {
+            // We don't have a valid cursor reference
+            return;
+        }
+        mTotalCommits = c.getInt(0);
+        c.close();
+
+        // Last update
+        c = db.rawQuery("select value from info where key = ?;", new String[]{"date"});
+        if (c == null || !c.moveToFirst()) {
+            // We don't have a valid cursor reference
+            return;
+        }
+        mLastUpdate = c.getLong(0);
+        c.close();
+    }
+
+    private void loadUserInfo(Context context) {
+        // Open the database
+        SQLiteDatabase db = getDatabase(context, true);
+        if (db == null) {
+            // We don't have a valid database reference
+            return;
+        }
+
+        // Total contributors
+        String[] args = new String[]{String.valueOf(mSelectedContributor)};
+        Cursor c = db.rawQuery("select m1.name, m1.username, m1.commits " +
+                               "from metadata as m1 where m1.id = ?;", args);
+        if (c == null || !c.moveToFirst()) {
+            // We don't have a valid cursor reference
+            return;
+        }
+        mContributorName = c.getString(0);
+        mContributorNick = c.getString(1);
+        mContributorCommits = c.getInt(2);
+    }
+
+    private void showUserInfo(Context context) {
+        NumberFormat nf = NumberFormat.getNumberInstance(Locale.getDefault());
+        String name = mContributorName != null ? mContributorName : "-";
+        String nick = mContributorNick != null ? mContributorNick : "-";
+        String commits = mContributorName != null ? nf.format(mContributorCommits) : "-";
+
+        AlertDialog.Builder builder = new AlertDialog.Builder(context);
+        builder.setTitle(R.string.contributor_info_menu);
+        builder.setMessage(Html.fromHtml(getString(R.string.contributor_info_msg,
+                name, nick, commits)));
+        builder.setPositiveButton(android.R.string.ok, null);
+        AlertDialog dialog = builder.create();
+        dialog.show();
+    }
+
+    private void showContributorsInfo(Context context) {
+        NumberFormat nf = NumberFormat.getNumberInstance(Locale.getDefault());
+        java.text.DateFormat df = DateFormat.getLongDateFormat(context);
+        java.text.DateFormat tf = DateFormat.getTimeFormat(context);
+        String totalContributors = mTotalContributors != -1
+                ? nf.format(mTotalContributors) : "-";
+        String totalCommits = mTotalCommits != -1
+                ? nf.format(mTotalCommits) : "-";
+        String lastUpdate = mLastUpdate != -1
+                ? df.format(mLastUpdate) + " " + tf.format(mLastUpdate) : "-";
+
+        AlertDialog.Builder builder = new AlertDialog.Builder(context);
+        builder.setTitle(R.string.contributions_info_menu);
+        builder.setMessage(Html.fromHtml(getString(R.string.contributions_info_msg,
+                totalContributors, totalCommits, lastUpdate)));
+        builder.setPositiveButton(android.R.string.ok, null);
+        AlertDialog dialog = builder.create();
+        dialog.show();
+    }
+
+    private List<ContributorsDataHolder> performFilter(Context context, String query) {
+        // Open the database
+        SQLiteDatabase db = getDatabase(context, false);
+        if (db == null) {
+            // We don't have a valid database reference
+            return new ArrayList<>();
+        }
+
+        // Total contributors
+        String[] args = new String[]{String.valueOf(query.replaceAll("\\|", ""))};
+        Cursor c = db.rawQuery(
+                "select id, name || case when username is null then '' else ' <'||username||'>' end contributor " +
+                "from metadata where lower(filter) like lower('%' || ? || '%') and id > 0 " +
+                "order by commits desc", args);
+        if (c == null) {
+            // We don't have a valid cursor reference
+            return new ArrayList<>();
+        }
+        List<ContributorsDataHolder> results = new ArrayList<>();
+        while (c.moveToNext()) {
+            ContributorsDataHolder result = new ContributorsDataHolder();
+            result.mId = c.getInt(0);
+            result.mLabel = c.getString(1);
+            results.add(result);
+        }
+        return results;
+    }
+
+    private void onContributorSelected(ContributorsDataHolder contributor) {
+        mSelectedContributor = contributor.mId;
+        ContributorCloudLoaderTask task = new ContributorCloudLoaderTask(true, true);
+        task.execute();
+        mSearchMenuItem.collapseActionView();
+    }
+
+    private boolean hasLargeHeap() {
+        ActivityManager am = (ActivityManager) getActivity().getSystemService(Context.ACTIVITY_SERVICE);
+        return am.getMemoryClass() >= 96;
+    }
+
+    private float translate(float v, int ssize, int dsize) {
+        return (v * dsize) / ssize;
+    }
+
+
+    public static void extractContributorsCloudDatabase(Context context) {
+        final int BUFFER = 1024;
+        InputStream is = null;
+        OutputStream os = null;
+        File databasePath = context.getDatabasePath(DB_NAME);
+        try {
+            databasePath.getParentFile().mkdir();
+            is = context.getResources().getAssets().open(DB_NAME, AssetManager.ACCESS_BUFFER);
+            os = new FileOutputStream(databasePath);
+            int read = -1;
+            byte[] data = new byte[BUFFER];
+            while ((read = is.read(data, 0, BUFFER)) != -1) {
+                os.write(data, 0, read);
+            }
+        } catch (IOException ex) {
+            Log.e(TAG, "Failed to extract contributors database");
+        } finally {
+            if (is != null) {
+                try {
+                    is.close();
+                } catch (IOException ex) {
+                    // Ignore
+                }
+            }
+        }
+    }
+}
diff --git a/src/org/cyanogenmod/cmparts/contributors/ContributorsCloudViewController.java b/src/org/cyanogenmod/cmparts/contributors/ContributorsCloudViewController.java
new file mode 100644
index 0000000..2237164
--- /dev/null
+++ b/src/org/cyanogenmod/cmparts/contributors/ContributorsCloudViewController.java
@@ -0,0 +1,1304 @@
+/*******************************************************************************
+ * Copyright 2011, 2012 Chris Banes.
+ * Copyright (C) 2015 The CyanogenMod Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *******************************************************************************/
+
+package org.cyanogenmod.cmparts.contributors;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Matrix;
+import android.graphics.Matrix.ScaleToFit;
+import android.graphics.RectF;
+import android.graphics.drawable.Drawable;
+import android.util.Log;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+import android.view.ScaleGestureDetector;
+import android.view.VelocityTracker;
+import android.view.View;
+import android.view.ScaleGestureDetector.OnScaleGestureListener;
+import android.view.View.OnLongClickListener;
+import android.view.ViewConfiguration;
+import android.view.ViewParent;
+import android.view.ViewTreeObserver;
+import android.view.animation.AccelerateDecelerateInterpolator;
+import android.view.animation.Interpolator;
+import android.widget.ImageView;
+import android.widget.ImageView.ScaleType;
+import android.widget.OverScroller;
+
+import java.lang.ref.WeakReference;
+
+import static android.view.MotionEvent.ACTION_CANCEL;
+import static android.view.MotionEvent.ACTION_DOWN;
+import static android.view.MotionEvent.ACTION_UP;
+
+public class ContributorsCloudViewController implements View.OnTouchListener,
+        ViewTreeObserver.OnGlobalLayoutListener {
+
+    private static final String LOG_TAG = "ContributorsCloud";
+
+    public static final float DEFAULT_MAX_SCALE = 3.0f;
+    public static final float DEFAULT_MID_SCALE = 1.75f;
+    public static final float DEFAULT_MIN_SCALE = 1.0f;
+    public static final int DEFAULT_ZOOM_DURATION = 200;
+
+    // let debug flag be dynamic, but still Proguard can be used to remove from
+    // release builds
+    private static final boolean DEBUG = Log.isLoggable(LOG_TAG, Log.DEBUG);
+
+    static final Interpolator sInterpolator = new AccelerateDecelerateInterpolator();
+    int ZOOM_DURATION = DEFAULT_ZOOM_DURATION;
+
+    static final int EDGE_NONE = -1;
+    static final int EDGE_LEFT = 0;
+    static final int EDGE_RIGHT = 1;
+    static final int EDGE_BOTH = 2;
+
+    private float mMinScale = DEFAULT_MIN_SCALE;
+    private float mMidScale = DEFAULT_MID_SCALE;
+    private float mMaxScale = DEFAULT_MAX_SCALE;
+
+    private boolean mAllowParentInterceptOnEdge = true;
+    private boolean mBlockParentIntercept = false;
+
+    private static final int INVALID_POINTER_ID = -1;
+    private int mActivePointerId = INVALID_POINTER_ID;
+    private int mActivePointerIndex = 0;
+    private float mLastTouchX;
+    private float mLastTouchY;
+    private final float mTouchSlop;
+    private final float mMinimumVelocity;
+    private VelocityTracker mVelocityTracker;
+    private boolean mIsDragging;
+    private boolean mIgnoreDoubleTapScale;
+
+    private static void checkZoomLevels(float minZoom, float midZoom,
+                                        float maxZoom) {
+        if (minZoom >= midZoom) {
+            throw new IllegalArgumentException(
+                    "MinZoom has to be less than MidZoom");
+        } else if (midZoom >= maxZoom) {
+            throw new IllegalArgumentException(
+                    "MidZoom has to be less than MaxZoom");
+        }
+    }
+
+    /**
+     * @return true if the ImageView exists, and it's Drawable existss
+     */
+    private static boolean hasDrawable(ImageView imageView) {
+        return null != imageView && null != imageView.getDrawable();
+    }
+
+    /**
+     * @return true if the ScaleType is supported.
+     */
+    private static boolean isSupportedScaleType(final ScaleType scaleType) {
+        if (null == scaleType) {
+            return false;
+        }
+
+        switch (scaleType) {
+            case MATRIX:
+                throw new IllegalArgumentException(scaleType.name()
+                        + " is not supported in PhotoView");
+
+            default:
+                return true;
+        }
+    }
+
+    /**
+     * Set's the ImageView's ScaleType to Matrix.
+     */
+    private static void setImageViewScaleTypeMatrix(ImageView imageView) {
+        /**
+         * PhotoView sets it's own ScaleType to Matrix, then diverts all calls
+         * setScaleType to this.setScaleType automatically.
+         */
+        if (null != imageView /*&& !(imageView instanceof IPhotoView)*/) {
+            if (!ScaleType.MATRIX.equals(imageView.getScaleType())) {
+                imageView.setScaleType(ScaleType.MATRIX);
+            }
+        }
+    }
+
+    public class DefaultOnDoubleTapListener implements GestureDetector.OnDoubleTapListener {
+
+        private ContributorsCloudViewController controller;
+
+        public DefaultOnDoubleTapListener(ContributorsCloudViewController controller) {
+            setController(controller);
+        }
+
+        public void setController(ContributorsCloudViewController controller) {
+            this.controller = controller;
+        }
+
+        @Override
+        public boolean onSingleTapConfirmed(MotionEvent e) {
+            if (controller == null)
+                return false;
+
+            ImageView imageView = controller.getImageView();
+
+            if (null != controller.getOnPhotoTapListener()) {
+                final RectF displayRect = controller.getDisplayRect();
+
+                if (null != displayRect) {
+                    final float x = e.getX(), y = e.getY();
+
+                    // Check to see if the user tapped on the photo
+                    if (displayRect.contains(x, y)) {
+
+                        float xResult = (x - displayRect.left)
+                                / displayRect.width();
+                        float yResult = (y - displayRect.top)
+                                / displayRect.height();
+
+                        controller.getOnPhotoTapListener().onPhotoTap(imageView, xResult, yResult);
+                        return true;
+                    }
+                }
+            }
+            if (null != controller.getOnViewTapListener()) {
+                controller.getOnViewTapListener().onViewTap(imageView, e.getX(), e.getY());
+            }
+
+            return false;
+        }
+
+        @Override
+        public boolean onDoubleTap(MotionEvent ev) {
+            if (controller == null)
+                return false;
+            try {
+                float scale = controller.getScale();
+                float x = ev.getX();
+                float y = ev.getY();
+
+                if (!mIgnoreDoubleTapScale && scale < controller.getMediumScale()) {
+                    controller.setScale(controller.getMediumScale(), x, y, true);
+                } else if (!mIgnoreDoubleTapScale && scale >= controller.getMediumScale()
+                        && scale < controller.getMaximumScale()) {
+                    controller.setScale(controller.getMaximumScale(), x, y, true);
+                } else {
+                    controller.setScale(controller.getMinimumScale(), x, y, true);
+                }
+                mIgnoreDoubleTapScale = false;
+            } catch (ArrayIndexOutOfBoundsException e) {
+                // Can sometimes happen when getX() and getY() is called
+            }
+            return true;
+        }
+
+        @Override
+        public boolean onDoubleTapEvent(MotionEvent e) {
+            // Wait for the confirmed onDoubleTap() instead
+            return false;
+        }
+
+    }
+
+    private WeakReference<ImageView> mImageView;
+
+    // Gesture Detectors
+    private GestureDetector mGestureDetector;
+    private ScaleGestureDetector mScaleDragDetector;
+
+    // These are set so we don't keep allocating them on the heap
+    private final Matrix mBaseMatrix = new Matrix();
+    private final Matrix mDrawMatrix = new Matrix();
+    private final Matrix mSuppMatrix = new Matrix();
+    private final RectF mDisplayRect = new RectF();
+    private final float[] mMatrixValues = new float[9];
+
+    // Listeners
+    private OnMatrixChangedListener mMatrixChangeListener;
+    private OnPhotoTapListener mPhotoTapListener;
+    private OnViewTapListener mViewTapListener;
+    private OnLongClickListener mLongClickListener;
+    private OnScaleChangeListener mScaleChangeListener;
+
+    private int mIvTop, mIvRight, mIvBottom, mIvLeft;
+    private FlingRunnable mCurrentFlingRunnable;
+    private int mScrollEdge = EDGE_BOTH;
+
+    private boolean mZoomEnabled;
+    private ScaleType mScaleType = ScaleType.FIT_CENTER;
+
+    public ContributorsCloudViewController(ImageView imageView) {
+        this(imageView, true);
+    }
+
+    public ContributorsCloudViewController(ImageView imageView, boolean zoomable) {
+        final ViewConfiguration configuration = ViewConfiguration.get(imageView.getContext());
+        mMinimumVelocity = configuration.getScaledMinimumFlingVelocity();
+        mTouchSlop = configuration.getScaledTouchSlop();
+
+        mImageView = new WeakReference<>(imageView);
+
+        imageView.setDrawingCacheEnabled(true);
+        imageView.setOnTouchListener(this);
+
+        ViewTreeObserver observer = imageView.getViewTreeObserver();
+        if (null != observer)
+            observer.addOnGlobalLayoutListener(this);
+
+        // Make sure we using MATRIX Scale Type
+        setImageViewScaleTypeMatrix(imageView);
+
+        if (imageView.isInEditMode()) {
+            return;
+        }
+
+        // Create Gesture Detectors...
+        OnScaleGestureListener mScaleListener = new ScaleGestureDetector.OnScaleGestureListener() {
+            @Override
+            public boolean onScale(ScaleGestureDetector detector) {
+                float scaleFactor = detector.getScaleFactor();
+
+                if (Float.isNaN(scaleFactor) || Float.isInfinite(scaleFactor))
+                    return false;
+
+                ContributorsCloudViewController.this.onScale(scaleFactor,
+                        detector.getFocusX(), detector.getFocusY());
+                return true;
+            }
+
+            @Override
+            public boolean onScaleBegin(ScaleGestureDetector detector) {
+                return true;
+            }
+
+            @Override
+            public void onScaleEnd(ScaleGestureDetector detector) {
+                // NO-OP
+            }
+        };
+        mScaleDragDetector = new ScaleGestureDetector(imageView.getContext(), mScaleListener);
+
+        mGestureDetector = new GestureDetector(imageView.getContext(),
+                new GestureDetector.SimpleOnGestureListener() {
+
+                    // forward long click listener
+                    @Override
+                    public void onLongPress(MotionEvent e) {
+                        if (null != mLongClickListener) {
+                            mLongClickListener.onLongClick(getImageView());
+                        }
+                    }
+                });
+        mGestureDetector.setOnDoubleTapListener(new DefaultOnDoubleTapListener(this));
+
+        // Finally, update the UI so that we're zoomable
+        setZoomable(zoomable);
+    }
+
+    public void setOnDoubleTapListener(GestureDetector.OnDoubleTapListener newOnDoubleTapListener) {
+        if (newOnDoubleTapListener != null) {
+            this.mGestureDetector.setOnDoubleTapListener(newOnDoubleTapListener);
+        } else {
+            this.mGestureDetector.setOnDoubleTapListener(new DefaultOnDoubleTapListener(this));
+        }
+    }
+
+    public void setOnScaleChangeListener(OnScaleChangeListener onScaleChangeListener) {
+        this.mScaleChangeListener = onScaleChangeListener;
+    }
+
+    public boolean canZoom() {
+        return mZoomEnabled;
+    }
+
+    /**
+     * Clean-up the resources attached to this object. This needs to be called when the ImageView is
+     * no longer used. A good example is from {@link android.view.View#onDetachedFromWindow()} or
+     * from {@link android.app.Activity#onDestroy()}. This is automatically called if you are using
+     * {@link uk.co.senab.photoview.PhotoView}.
+     */
+    @SuppressWarnings("deprecation")
+    public void cleanup() {
+        if (null == mImageView) {
+            return; // cleanup already done
+        }
+
+        final ImageView imageView = mImageView.get();
+
+        if (null != imageView) {
+            // Remove this as a global layout listener
+            ViewTreeObserver observer = imageView.getViewTreeObserver();
+            if (null != observer && observer.isAlive()) {
+                observer.removeGlobalOnLayoutListener(this);
+            }
+
+            // Remove the ImageView's reference to this
+            imageView.setOnTouchListener(null);
+
+            // make sure a pending fling runnable won't be run
+            cancelFling();
+        }
+
+        if (null != mGestureDetector) {
+            mGestureDetector.setOnDoubleTapListener(null);
+        }
+
+        // Clear listeners too
+        mMatrixChangeListener = null;
+        mPhotoTapListener = null;
+        mViewTapListener = null;
+
+        // Finally, clear ImageView
+        mImageView = null;
+    }
+
+    public RectF getDisplayRect() {
+        checkMatrixBounds();
+        return getDisplayRect(getDrawMatrix());
+    }
+
+    public boolean setDisplayMatrix(Matrix finalMatrix) {
+        if (finalMatrix == null)
+            throw new IllegalArgumentException("Matrix cannot be null");
+
+        ImageView imageView = getImageView();
+        if (null == imageView)
+            return false;
+
+        if (null == imageView.getDrawable())
+            return false;
+
+        mSuppMatrix.set(finalMatrix);
+        setImageViewMatrix(getDrawMatrix());
+        checkMatrixBounds();
+
+        return true;
+    }
+
+    public void setRotationTo(float degrees) {
+        mSuppMatrix.setRotate(degrees % 360);
+        checkAndDisplayMatrix();
+    }
+
+    public void setRotationBy(float degrees) {
+        mSuppMatrix.postRotate(degrees % 360);
+        checkAndDisplayMatrix();
+    }
+
+    public ImageView getImageView() {
+        ImageView imageView = null;
+
+        if (null != mImageView) {
+            imageView = mImageView.get();
+        }
+
+        // If we don't have an ImageView, call cleanup()
+        if (null == imageView) {
+            cleanup();
+            Log.i(LOG_TAG, "ImageView no longer exists. You should " +
+                    "not use this reference any more.");
+        }
+
+        return imageView;
+    }
+
+    public float getMinimumScale() {
+        return mMinScale;
+    }
+
+    public float getMediumScale() {
+        return mMidScale;
+    }
+
+    public float getMaximumScale() {
+        return mMaxScale;
+    }
+
+    public float getScale() {
+        return (float) Math.sqrt((float) Math.pow(getValue(mSuppMatrix, Matrix.MSCALE_X), 2)
+                + (float) Math.pow(getValue(mSuppMatrix, Matrix.MSKEW_Y), 2));
+    }
+
+    public ScaleType getScaleType() {
+        return mScaleType;
+    }
+
+    public void onDrag(float dx, float dy) {
+        if (mScaleDragDetector.isInProgress()) {
+            return; // Do not drag if we are already scaling
+        }
+
+        if (DEBUG) {
+            Log.d(LOG_TAG, String.format("onDrag: dx: %.2f. dy: %.2f", dx, dy));
+        }
+
+        ImageView imageView = getImageView();
+        mSuppMatrix.postTranslate(dx, dy);
+        checkAndDisplayMatrix();
+
+        /**
+         * Here we decide whether to let the ImageView's parent to start taking
+         * over the touch event.
+         *
+         * First we check whether this function is enabled. We never want the
+         * parent to take over if we're scaling. We then check the edge we're
+         * on, and the direction of the scroll (i.e. if we're pulling against
+         * the edge, aka 'overscrolling', let the parent take over).
+         */
+        ViewParent parent = imageView.getParent();
+        if (mAllowParentInterceptOnEdge && !mScaleDragDetector.isInProgress()
+                && !mBlockParentIntercept) {
+            if (mScrollEdge == EDGE_BOTH
+                    || (mScrollEdge == EDGE_LEFT && dx >= 1f)
+                    || (mScrollEdge == EDGE_RIGHT && dx <= -1f)) {
+                if (null != parent)
+                    parent.requestDisallowInterceptTouchEvent(false);
+            }
+        } else {
+            if (null != parent) {
+                parent.requestDisallowInterceptTouchEvent(true);
+            }
+        }
+        mIgnoreDoubleTapScale = false;
+    }
+
+    public void onFling(float startX, float startY, float velocityX, float velocityY) {
+        if (DEBUG) {
+            Log.d(LOG_TAG, "onFling. sX: " + startX + " sY: " + startY + " Vx: "
+                    + velocityX + " Vy: " + velocityY);
+        }
+        ImageView imageView = getImageView();
+        mCurrentFlingRunnable = new FlingRunnable(imageView.getContext());
+        mCurrentFlingRunnable.fling(getImageViewWidth(imageView),
+                getImageViewHeight(imageView), (int) velocityX, (int) velocityY);
+        imageView.post(mCurrentFlingRunnable);
+        mIgnoreDoubleTapScale = false;
+    }
+
+    @Override
+    public void onGlobalLayout() {
+        ImageView imageView = getImageView();
+
+        if (null != imageView) {
+            if (mZoomEnabled) {
+                final int top = imageView.getTop();
+                final int right = imageView.getRight();
+                final int bottom = imageView.getBottom();
+                final int left = imageView.getLeft();
+
+                /**
+                 * We need to check whether the ImageView's bounds have changed.
+                 * This would be easier if we targeted API 11+ as we could just use
+                 * View.OnLayoutChangeListener. Instead we have to replicate the
+                 * work, keeping track of the ImageView's bounds and then checking
+                 * if the values change.
+                 */
+                if (top != mIvTop || bottom != mIvBottom || left != mIvLeft
+                        || right != mIvRight) {
+                    // Update our base matrix, as the bounds have changed
+                    updateBaseMatrix(imageView.getDrawable());
+
+                    // Update values as something has changed
+                    mIvTop = top;
+                    mIvRight = right;
+                    mIvBottom = bottom;
+                    mIvLeft = left;
+                }
+            } else {
+                updateBaseMatrix(imageView.getDrawable());
+            }
+        }
+    }
+
+    public void onScale(float scaleFactor, float focusX, float focusY) {
+        if (DEBUG) {
+            Log.d(LOG_TAG,String.format("onScale: scale: %.2f. fX: %.2f. fY: %.2f",
+                    scaleFactor, focusX, focusY));
+        }
+
+        if (getScale() < mMaxScale || scaleFactor < 1f) {
+            if (null != mScaleChangeListener) {
+                mScaleChangeListener.onScaleChange(scaleFactor, focusX, focusY);
+            }
+            mSuppMatrix.postScale(scaleFactor, scaleFactor, focusX, focusY);
+            checkAndDisplayMatrix();
+        }
+    }
+
+    @SuppressLint("ClickableViewAccessibility")
+    @Override
+    public boolean onTouch(View v, MotionEvent ev) {
+        boolean handled = false;
+
+        if (mZoomEnabled && hasDrawable((ImageView) v)) {
+            ViewParent parent = v.getParent();
+            switch (ev.getAction()) {
+                case ACTION_DOWN:
+                    // First, disable the Parent from intercepting the touch
+                    // event
+                    if (null != parent) {
+                        parent.requestDisallowInterceptTouchEvent(true);
+                    } else {
+                        Log.i(LOG_TAG, "onTouch getParent() returned null");
+                    }
+
+                    // If we're flinging, and the user presses down, cancel
+                    // fling
+                    cancelFling();
+                    break;
+
+                case ACTION_CANCEL:
+                case ACTION_UP:
+                    // If the user has zoomed less than min scale, zoom back
+                    // to min scale
+                    if (getScale() < mMinScale) {
+                        RectF rect = getDisplayRect();
+                        if (null != rect) {
+                            v.post(new AnimatedZoomRunnable(getScale(), mMinScale,
+                                    rect.centerX(), rect.centerY()));
+                            handled = true;
+                        }
+                    }
+                    break;
+            }
+
+            // Try the Scale/Drag detector
+            if (null != mScaleDragDetector) {
+                boolean wasScaling = mScaleDragDetector.isInProgress();
+                boolean wasDragging = mIsDragging;
+
+                handled = onTouchEvent(ev);
+
+                boolean didntScale = !wasScaling && !mScaleDragDetector.isInProgress();
+                boolean didntDrag = !wasDragging && !mIsDragging;
+
+                mBlockParentIntercept = didntScale && didntDrag;
+            }
+
+            // Check to see if the user double tapped
+            if (null != mGestureDetector && mGestureDetector.onTouchEvent(ev)) {
+                handled = true;
+            }
+
+        }
+
+        return handled;
+    }
+
+    public void setAllowParentInterceptOnEdge(boolean allow) {
+        mAllowParentInterceptOnEdge = allow;
+    }
+
+    public void setMinimumScale(float minimumScale) {
+        checkZoomLevels(minimumScale, mMidScale, mMaxScale);
+        mMinScale = minimumScale;
+    }
+
+    public void setMediumScale(float mediumScale) {
+        checkZoomLevels(mMinScale, mediumScale, mMaxScale);
+        mMidScale = mediumScale;
+    }
+
+    public void setMaximumScale(float maximumScale) {
+        checkZoomLevels(mMinScale, mMidScale, maximumScale);
+        mMaxScale = maximumScale;
+    }
+
+    public void setScaleLevels(float minimumScale, float mediumScale, float maximumScale) {
+        checkZoomLevels(minimumScale, mediumScale, maximumScale);
+        mMinScale = minimumScale;
+        mMidScale = mediumScale;
+        mMaxScale = maximumScale;
+    }
+
+    public void setOnLongClickListener(OnLongClickListener listener) {
+        mLongClickListener = listener;
+    }
+
+    public void setOnMatrixChangeListener(OnMatrixChangedListener listener) {
+        mMatrixChangeListener = listener;
+    }
+
+    public void setOnPhotoTapListener(OnPhotoTapListener listener) {
+        mPhotoTapListener = listener;
+    }
+
+    public OnPhotoTapListener getOnPhotoTapListener() {
+        return mPhotoTapListener;
+    }
+
+    public void setOnViewTapListener(OnViewTapListener listener) {
+        mViewTapListener = listener;
+    }
+
+    public OnViewTapListener getOnViewTapListener() {
+        return mViewTapListener;
+    }
+
+    public void setScale(float scale) {
+        setScale(scale, false);
+    }
+
+    public void setScale(float scale, boolean animate) {
+        ImageView imageView = getImageView();
+
+        if (null != imageView) {
+            setScale(scale,
+                    (imageView.getRight()) / 2,
+                    (imageView.getBottom()) / 2,
+                    animate);
+        }
+    }
+
+    public void setScale(float scale, float focalX, float focalY, boolean animate) {
+        ImageView imageView = getImageView();
+
+        if (null != imageView) {
+            // Check to see if the scale is within bounds
+            if (scale < mMinScale || scale > mMaxScale) {
+                Log.i(LOG_TAG, "Scale must be within the range of minScale and maxScale");
+                return;
+            }
+
+            if (animate) {
+                imageView.post(new AnimatedZoomRunnable(getScale(), scale,
+                        focalX, focalY));
+            } else {
+                mSuppMatrix.setScale(scale, scale, focalX, focalY);
+                checkAndDisplayMatrix();
+            }
+
+            // This focuses to some point of the view, so treat it as a return point to
+            // minimum zoom
+            if (scale < getMediumScale()) {
+                mIgnoreDoubleTapScale = true;
+            }
+        }
+    }
+
+    public void setScaleType(ScaleType scaleType) {
+        if (isSupportedScaleType(scaleType) && scaleType != mScaleType) {
+            mScaleType = scaleType;
+
+            // Finally update
+            update();
+        }
+    }
+
+    public void setZoomable(boolean zoomable) {
+        mZoomEnabled = zoomable;
+        update();
+    }
+
+    public void update() {
+        ImageView imageView = getImageView();
+
+        if (null != imageView) {
+            if (mZoomEnabled) {
+                // Make sure we using MATRIX Scale Type
+                setImageViewScaleTypeMatrix(imageView);
+
+                // Update the base matrix using the current drawable
+                updateBaseMatrix(imageView.getDrawable());
+            } else {
+                // Reset the Matrix...
+                resetMatrix();
+            }
+        }
+    }
+
+    public Matrix getDisplayMatrix() {
+        return new Matrix(getDrawMatrix());
+    }
+
+    public Matrix getDrawMatrix() {
+        mDrawMatrix.set(mBaseMatrix);
+        mDrawMatrix.postConcat(mSuppMatrix);
+        return mDrawMatrix;
+    }
+
+    private void cancelFling() {
+        if (null != mCurrentFlingRunnable) {
+            mCurrentFlingRunnable.cancelFling();
+            mCurrentFlingRunnable = null;
+        }
+    }
+
+    /**
+     * Helper method that simply checks the Matrix, and then displays the result
+     */
+    private void checkAndDisplayMatrix() {
+        if (checkMatrixBounds()) {
+            setImageViewMatrix(getDrawMatrix());
+        }
+    }
+
+    private void checkImageViewScaleType() {
+        ImageView imageView = getImageView();
+
+        /**
+         * PhotoView's getScaleType() will just divert to this.getScaleType() so
+         * only call if we're not attached to a PhotoView.
+         */
+        if (null != imageView/* && !(imageView instanceof IPhotoView)*/) {
+            if (!ScaleType.MATRIX.equals(imageView.getScaleType())) {
+                throw new IllegalStateException("The ImageView's ScaleType has been " +
+                        "changed since attaching to this controller");
+            }
+        }
+    }
+
+    private boolean checkMatrixBounds() {
+        final ImageView imageView = getImageView();
+        if (null == imageView) {
+            return false;
+        }
+
+        final RectF rect = getDisplayRect(getDrawMatrix());
+        if (null == rect) {
+            return false;
+        }
+
+        final float height = rect.height(), width = rect.width();
+        float deltaX = 0, deltaY = 0;
+
+        final int viewHeight = getImageViewHeight(imageView);
+        if (height <= viewHeight) {
+            switch (mScaleType) {
+                case FIT_START:
+                    deltaY = -rect.top;
+                    break;
+                case FIT_END:
+                    deltaY = viewHeight - height - rect.top;
+                    break;
+                default:
+                    deltaY = (viewHeight - height) / 2 - rect.top;
+                    break;
+            }
+        } else if (rect.top > 0) {
+            deltaY = -rect.top;
+        } else if (rect.bottom < viewHeight) {
+            deltaY = viewHeight - rect.bottom;
+        }
+
+        final int viewWidth = getImageViewWidth(imageView);
+        if (width <= viewWidth) {
+            switch (mScaleType) {
+                case FIT_START:
+                    deltaX = -rect.left;
+                    break;
+                case FIT_END:
+                    deltaX = viewWidth - width - rect.left;
+                    break;
+                default:
+                    deltaX = (viewWidth - width) / 2 - rect.left;
+                    break;
+            }
+            mScrollEdge = EDGE_BOTH;
+        } else if (rect.left > 0) {
+            mScrollEdge = EDGE_LEFT;
+            deltaX = -rect.left;
+        } else if (rect.right < viewWidth) {
+            deltaX = viewWidth - rect.right;
+            mScrollEdge = EDGE_RIGHT;
+        } else {
+            mScrollEdge = EDGE_NONE;
+        }
+
+        // Finally actually translate the matrix
+        mSuppMatrix.postTranslate(deltaX, deltaY);
+        return true;
+    }
+
+    /**
+     * Helper method that maps the supplied Matrix to the current Drawable
+     *
+     * @param matrix - Matrix to map Drawable against
+     * @return RectF - Displayed Rectangle
+     */
+    private RectF getDisplayRect(Matrix matrix) {
+        ImageView imageView = getImageView();
+
+        if (null != imageView) {
+            Drawable d = imageView.getDrawable();
+            if (null != d) {
+                mDisplayRect.set(0, 0, d.getIntrinsicWidth(),
+                        d.getIntrinsicHeight());
+                matrix.mapRect(mDisplayRect);
+                return mDisplayRect;
+            }
+        }
+        return null;
+    }
+
+    public Bitmap getVisibleRectangleBitmap() {
+        ImageView imageView = getImageView();
+        return imageView == null ? null : imageView.getDrawingCache();
+    }
+
+    public void setZoomTransitionDuration(int milliseconds) {
+        if (milliseconds < 0)
+            milliseconds = DEFAULT_ZOOM_DURATION;
+        this.ZOOM_DURATION = milliseconds;
+    }
+
+    /**
+     * Helper method that 'unpacks' a Matrix and returns the required value
+     *
+     * @param matrix     - Matrix to unpack
+     * @param whichValue - Which value from Matrix.M* to return
+     * @return float - returned value
+     */
+    private float getValue(Matrix matrix, int whichValue) {
+        matrix.getValues(mMatrixValues);
+        return mMatrixValues[whichValue];
+    }
+
+    /**
+     * Resets the Matrix back to FIT_CENTER, and then displays it.s
+     */
+    private void resetMatrix() {
+        mSuppMatrix.reset();
+        setImageViewMatrix(getDrawMatrix());
+        checkMatrixBounds();
+    }
+
+    private void setImageViewMatrix(Matrix matrix) {
+        ImageView imageView = getImageView();
+        if (null != imageView) {
+
+            checkImageViewScaleType();
+            imageView.setImageMatrix(matrix);
+
+            // Call MatrixChangedListener if needed
+            if (null != mMatrixChangeListener) {
+                RectF displayRect = getDisplayRect(matrix);
+                if (null != displayRect) {
+                    mMatrixChangeListener.onMatrixChanged(displayRect);
+                }
+            }
+        }
+    }
+
+    /**
+     * Calculate Matrix for FIT_CENTER
+     *
+     * @param d - Drawable being displayed
+     */
+    private void updateBaseMatrix(Drawable d) {
+        ImageView imageView = getImageView();
+        if (null == imageView || null == d) {
+            return;
+        }
+
+        final float viewWidth = getImageViewWidth(imageView);
+        final float viewHeight = getImageViewHeight(imageView);
+        final int drawableWidth = d.getIntrinsicWidth();
+        final int drawableHeight = d.getIntrinsicHeight();
+
+        mBaseMatrix.reset();
+
+        final float widthScale = viewWidth / drawableWidth;
+        final float heightScale = viewHeight / drawableHeight;
+
+        if (mScaleType == ScaleType.CENTER) {
+            mBaseMatrix.postTranslate((viewWidth - drawableWidth) / 2F,
+                    (viewHeight - drawableHeight) / 2F);
+
+        } else if (mScaleType == ScaleType.CENTER_CROP) {
+            float scale = Math.max(widthScale, heightScale);
+            mBaseMatrix.postScale(scale, scale);
+            mBaseMatrix.postTranslate((viewWidth - drawableWidth * scale) / 2F,
+                    (viewHeight - drawableHeight * scale) / 2F);
+
+        } else if (mScaleType == ScaleType.CENTER_INSIDE) {
+            float scale = Math.min(1.0f, Math.min(widthScale, heightScale));
+            mBaseMatrix.postScale(scale, scale);
+            mBaseMatrix.postTranslate((viewWidth - drawableWidth * scale) / 2F,
+                    (viewHeight - drawableHeight * scale) / 2F);
+
+        } else {
+            RectF mTempSrc = new RectF(0, 0, drawableWidth, drawableHeight);
+            RectF mTempDst = new RectF(0, 0, viewWidth, viewHeight);
+
+            switch (mScaleType) {
+                case FIT_CENTER:
+                    mBaseMatrix
+                            .setRectToRect(mTempSrc, mTempDst, ScaleToFit.CENTER);
+                    break;
+
+                case FIT_START:
+                    mBaseMatrix.setRectToRect(mTempSrc, mTempDst, ScaleToFit.START);
+                    break;
+
+                case FIT_END:
+                    mBaseMatrix.setRectToRect(mTempSrc, mTempDst, ScaleToFit.END);
+                    break;
+
+                case FIT_XY:
+                    mBaseMatrix.setRectToRect(mTempSrc, mTempDst, ScaleToFit.FILL);
+                    break;
+
+                default:
+                    break;
+            }
+        }
+
+        resetMatrix();
+    }
+
+    private int getImageViewWidth(ImageView imageView) {
+        if (null == imageView)
+            return 0;
+        return imageView.getWidth() - imageView.getPaddingLeft() - imageView.getPaddingRight();
+    }
+
+    private int getImageViewHeight(ImageView imageView) {
+        if (null == imageView)
+            return 0;
+        return imageView.getHeight() - imageView.getPaddingTop() - imageView.getPaddingBottom();
+    }
+
+    private float getActiveX(MotionEvent ev) {
+        try {
+            return ev.getX(mActivePointerIndex);
+        } catch (Exception e) {
+            return ev.getX();
+        }
+    }
+
+    private float getActiveY(MotionEvent ev) {
+        try {
+            return ev.getY(mActivePointerIndex);
+        } catch (Exception e) {
+            return ev.getY();
+        }
+    }
+
+    public boolean onTouchEvent(MotionEvent ev) {
+        mScaleDragDetector.onTouchEvent(ev);
+
+        final int action = ev.getAction();
+        switch (action & MotionEvent.ACTION_MASK) {
+            case MotionEvent.ACTION_DOWN:
+                mActivePointerId = ev.getPointerId(0);
+                break;
+            case MotionEvent.ACTION_CANCEL:
+            case MotionEvent.ACTION_UP:
+                mActivePointerId = INVALID_POINTER_ID;
+                break;
+            case MotionEvent.ACTION_POINTER_UP:
+                // Ignore deprecation, ACTION_POINTER_ID_MASK and
+                // ACTION_POINTER_ID_SHIFT has same value and are deprecated
+                // You can have either deprecation or lint target api warning
+                final int pointerIndex = (ev.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK)
+                        >> MotionEvent.ACTION_POINTER_INDEX_SHIFT;
+                final int pointerId = ev.getPointerId(pointerIndex);
+                if (pointerId == mActivePointerId) {
+                    // This was our active pointer going up. Choose a new
+                    // active pointer and adjust accordingly.
+                    final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
+                    mActivePointerId = ev.getPointerId(newPointerIndex);
+                    mLastTouchX = ev.getX(newPointerIndex);
+                    mLastTouchY = ev.getY(newPointerIndex);
+                }
+                break;
+        }
+
+        mActivePointerIndex = ev.findPointerIndex(
+                mActivePointerId != INVALID_POINTER_ID ? mActivePointerId : 0);
+
+        switch (ev.getAction()) {
+            case MotionEvent.ACTION_DOWN: {
+                mVelocityTracker = VelocityTracker.obtain();
+                if (null != mVelocityTracker) {
+                    mVelocityTracker.addMovement(ev);
+                } else {
+                    Log.i(LOG_TAG, "Velocity tracker is null");
+                }
+
+                mLastTouchX = getActiveX(ev);
+                mLastTouchY = getActiveY(ev);
+                mIsDragging = false;
+                break;
+            }
+
+            case MotionEvent.ACTION_MOVE: {
+                final float x = getActiveX(ev);
+                final float y = getActiveY(ev);
+                final float dx = x - mLastTouchX, dy = y - mLastTouchY;
+
+                if (!mIsDragging) {
+                    // Use Pythagoras to see if drag length is larger than
+                    // touch slop
+                    mIsDragging = Math.sqrt((dx * dx) + (dy * dy)) >= mTouchSlop;
+                }
+
+                if (mIsDragging) {
+                    onDrag(dx, dy);
+                    mLastTouchX = x;
+                    mLastTouchY = y;
+
+                    if (null != mVelocityTracker) {
+                        mVelocityTracker.addMovement(ev);
+                    }
+                }
+                break;
+            }
+
+            case MotionEvent.ACTION_CANCEL: {
+                // Recycle Velocity Tracker
+                if (null != mVelocityTracker) {
+                    mVelocityTracker.recycle();
+                    mVelocityTracker = null;
+                }
+                break;
+            }
+
+            case MotionEvent.ACTION_UP: {
+                if (mIsDragging) {
+                    if (null != mVelocityTracker) {
+                        mLastTouchX = getActiveX(ev);
+                        mLastTouchY = getActiveY(ev);
+
+                        // Compute velocity within the last 1000ms
+                        mVelocityTracker.addMovement(ev);
+                        mVelocityTracker.computeCurrentVelocity(1000);
+
+                        final float vX = mVelocityTracker.getXVelocity(), vY = mVelocityTracker
+                                .getYVelocity();
+
+                        // If the velocity is greater than minVelocity, call
+                        // listener
+                        if (Math.max(Math.abs(vX), Math.abs(vY)) >= mMinimumVelocity) {
+                            onFling(mLastTouchX, mLastTouchY, -vX, -vY);
+                        }
+                    }
+                }
+
+                // Recycle Velocity Tracker
+                if (null != mVelocityTracker) {
+                    mVelocityTracker.recycle();
+                    mVelocityTracker = null;
+                }
+                break;
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * Interface definition for a callback to be invoked when the internal Matrix has changed for
+     * this View.
+     *
+     * @author Chris Banes
+     */
+    public static interface OnMatrixChangedListener {
+        /**
+         * Callback for when the Matrix displaying the Drawable has changed. This could be because
+         * the View's bounds have changed, or the user has zoomed.
+         *
+         * @param rect - Rectangle displaying the Drawable's new bounds.
+         */
+        void onMatrixChanged(RectF rect);
+    }
+
+    /**
+     * Interface definition for callback to be invoked when attached ImageView scale changes
+     *
+     * @author Marek Sebera
+     */
+    public static interface OnScaleChangeListener {
+        /**
+         * Callback for when the scale changes
+         *
+         * @param scaleFactor the scale factor (<1 for zoom out, >1 for zoom in)
+         * @param focusX      focal point X position
+         * @param focusY      focal point Y position
+         */
+        void onScaleChange(float scaleFactor, float focusX, float focusY);
+    }
+
+    /**
+     * Interface definition for a callback to be invoked when the Photo is tapped with a single
+     * tap.
+     *
+     * @author Chris Banes
+     */
+    public static interface OnPhotoTapListener {
+
+        /**
+         * A callback to receive where the user taps on a photo. You will only receive a callback if
+         * the user taps on the actual photo, tapping on 'whitespace' will be ignored.
+         *
+         * @param view - View the user tapped.
+         * @param x    - where the user tapped from the of the Drawable, as percentage of the
+         *             Drawable width.
+         * @param y    - where the user tapped from the top of the Drawable, as percentage of the
+         *             Drawable height.
+         */
+        void onPhotoTap(View view, float x, float y);
+    }
+
+    /**
+     * Interface definition for a callback to be invoked when the ImageView is tapped with a single
+     * tap.
+     *
+     * @author Chris Banes
+     */
+    public static interface OnViewTapListener {
+
+        /**
+         * A callback to receive where the user taps on a ImageView. You will receive a callback if
+         * the user taps anywhere on the view, tapping on 'whitespace' will not be ignored.
+         *
+         * @param view - View the user tapped.
+         * @param x    - where the user tapped from the left of the View.
+         * @param y    - where the user tapped from the top of the View.
+         */
+        void onViewTap(View view, float x, float y);
+    }
+
+    private class AnimatedZoomRunnable implements Runnable {
+
+        private final float mFocalX, mFocalY;
+        private final long mStartTime;
+        private final float mZoomStart, mZoomEnd;
+
+        public AnimatedZoomRunnable(final float currentZoom, final float targetZoom,
+                                    final float focalX, final float focalY) {
+            mFocalX = focalX;
+            mFocalY = focalY;
+            mStartTime = System.currentTimeMillis();
+            mZoomStart = currentZoom;
+            mZoomEnd = targetZoom;
+        }
+
+        @Override
+        public void run() {
+            ImageView imageView = getImageView();
+            if (imageView == null) {
+                return;
+            }
+
+            float t = interpolate();
+            float scale = mZoomStart + t * (mZoomEnd - mZoomStart);
+            float deltaScale = scale / getScale();
+
+            onScale(deltaScale, mFocalX, mFocalY);
+
+            // We haven't hit our target scale yet, so post ourselves again
+            if (t < 1f) {
+                imageView.postOnAnimation(this);
+            }
+        }
+
+        private float interpolate() {
+            float t = 1f * (System.currentTimeMillis() - mStartTime) / ZOOM_DURATION;
+            t = Math.min(1f, t);
+            t = sInterpolator.getInterpolation(t);
+            return t;
+        }
+    }
+
+    private class FlingRunnable implements Runnable {
+
+        protected final OverScroller mScroller;
+        private int mCurrentX, mCurrentY;
+
+        public FlingRunnable(Context context) {
+            mScroller = new OverScroller(context);
+        }
+
+        public void cancelFling() {
+            if (DEBUG) {
+                Log.d(LOG_TAG, "Cancel Fling");
+            }
+            mScroller.forceFinished(true);
+        }
+
+        public void fling(int viewWidth, int viewHeight, int velocityX,
+                          int velocityY) {
+            final RectF rect = getDisplayRect();
+            if (null == rect) {
+                return;
+            }
+
+            final int startX = Math.round(-rect.left);
+            final int minX, maxX, minY, maxY;
+
+            if (viewWidth < rect.width()) {
+                minX = 0;
+                maxX = Math.round(rect.width() - viewWidth);
+            } else {
+                minX = maxX = startX;
+            }
+
+            final int startY = Math.round(-rect.top);
+            if (viewHeight < rect.height()) {
+                minY = 0;
+                maxY = Math.round(rect.height() - viewHeight);
+            } else {
+                minY = maxY = startY;
+            }
+
+            mCurrentX = startX;
+            mCurrentY = startY;
+
+            if (DEBUG) {
+                Log.d(LOG_TAG, "fling. StartX:" + startX + " StartY:" + startY
+                        + " MaxX:" + maxX + " MaxY:" + maxY);
+            }
+
+            // If we actually can move, fling the scroller
+            if (startX != maxX || startY != maxY) {
+                mScroller.fling(startX, startY, velocityX, velocityY, minX,
+                        maxX, minY, maxY, 0, 0);
+            }
+        }
+
+        @Override
+        public void run() {
+            if (mScroller.isFinished()) {
+                return; // remaining post that should not be handled
+            }
+
+            ImageView imageView = getImageView();
+            if (null != imageView && mScroller.computeScrollOffset()) {
+
+                final int newX = mScroller.getCurrX();
+                final int newY = mScroller.getCurrY();
+
+                if (DEBUG) {
+                    Log.d(LOG_TAG, "fling run(). CurrentX:" + mCurrentX + " CurrentY:"
+                            + mCurrentY + " NewX:" + newX + " NewY:" + newY);
+                }
+
+                mSuppMatrix.postTranslate(mCurrentX - newX, mCurrentY - newY);
+                setImageViewMatrix(getDrawMatrix());
+
+                mCurrentX = newX;
+                mCurrentY = newY;
+
+                // Post On animation
+                imageView.postOnAnimation(this);
+            }
+        }
+    }
+}