/* * 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 com.android.settings.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 com.android.settings.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 { public ContributorsAdapter(Context context) { super(context, R.id.contributor_name, new ArrayList()); } @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 { 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 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 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 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 } } } } }