diff options
Diffstat (limited to 'src/com/android/photos')
39 files changed, 9083 insertions, 0 deletions
diff --git a/src/com/android/photos/AlbumActivity.java b/src/com/android/photos/AlbumActivity.java new file mode 100644 index 000000000..c616b998b --- /dev/null +++ b/src/com/android/photos/AlbumActivity.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos; + +import android.app.Activity; +import android.os.Bundle; + +public class AlbumActivity extends Activity implements MultiChoiceManager.Provider { + + public static final String KEY_ALBUM_URI = AlbumFragment.KEY_ALBUM_URI; + public static final String KEY_ALBUM_TITLE = AlbumFragment.KEY_ALBUM_TITLE; + + private MultiChoiceManager mMultiChoiceManager; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Bundle intentExtras = getIntent().getExtras(); + mMultiChoiceManager = new MultiChoiceManager(this); + if (savedInstanceState == null) { + AlbumFragment albumFragment = new AlbumFragment(); + mMultiChoiceManager.setDelegate(albumFragment); + albumFragment.setArguments(intentExtras); + getFragmentManager().beginTransaction().add(android.R.id.content, + albumFragment).commit(); + } + getActionBar().setTitle(intentExtras.getString(KEY_ALBUM_TITLE)); + } + + @Override + public MultiChoiceManager getMultiChoiceManager() { + return mMultiChoiceManager; + } +} diff --git a/src/com/android/photos/AlbumFragment.java b/src/com/android/photos/AlbumFragment.java new file mode 100644 index 000000000..406fd2a29 --- /dev/null +++ b/src/com/android/photos/AlbumFragment.java @@ -0,0 +1,168 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos; + +import android.app.LoaderManager.LoaderCallbacks; +import android.content.Context; +import android.content.Intent; +import android.content.Loader; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.GridView; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.gallery3d.R; +import com.android.gallery3d.app.Gallery; +import com.android.photos.adapters.PhotoThumbnailAdapter; +import com.android.photos.data.PhotoSetLoader; +import com.android.photos.shims.LoaderCompatShim; +import com.android.photos.shims.MediaItemsLoader; +import com.android.photos.views.HeaderGridView; + +import java.util.ArrayList; + +public class AlbumFragment extends MultiSelectGridFragment implements LoaderCallbacks<Cursor> { + + protected static final String KEY_ALBUM_URI = "AlbumUri"; + protected static final String KEY_ALBUM_TITLE = "AlbumTitle"; + private static final int LOADER_ALBUM = 1; + + private LoaderCompatShim<Cursor> mLoaderCompatShim; + private PhotoThumbnailAdapter mAdapter; + private String mAlbumPath; + private String mAlbumTitle; + private View mHeaderView; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Context context = getActivity(); + mAdapter = new PhotoThumbnailAdapter(context); + Bundle args = getArguments(); + if (args != null) { + mAlbumPath = args.getString(KEY_ALBUM_URI, null); + mAlbumTitle = args.getString(KEY_ALBUM_TITLE, null); + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + getLoaderManager().initLoader(LOADER_ALBUM, null, this); + return inflater.inflate(R.layout.album_content, container, false); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + // TODO: Remove once UI stabilizes + getGridView().setColumnWidth(MediaItemsLoader.getThumbnailSize()); + } + + private void updateHeaderView() { + if (mHeaderView == null) { + mHeaderView = LayoutInflater.from(getActivity()) + .inflate(R.layout.album_header, getGridView(), false); + ((HeaderGridView) getGridView()).addHeaderView(mHeaderView, null, false); + + // TODO remove this when the data model stabilizes + mHeaderView.setMinimumHeight(200); + } + ImageView iv = (ImageView) mHeaderView.findViewById(R.id.album_header_image); + TextView title = (TextView) mHeaderView.findViewById(R.id.album_header_title); + TextView subtitle = (TextView) mHeaderView.findViewById(R.id.album_header_subtitle); + title.setText(mAlbumTitle); + int count = mAdapter.getCount(); + subtitle.setText(getActivity().getResources().getQuantityString( + R.plurals.number_of_photos, count, count)); + if (count > 0) { + iv.setImageDrawable(mLoaderCompatShim.drawableForItem(mAdapter.getItem(0), null)); + } + } + + @Override + public void onGridItemClick(GridView g, View v, int position, long id) { + if (mLoaderCompatShim == null) { + // Not fully initialized yet, discard + return; + } + Cursor item = (Cursor) getItemAtPosition(position); + Uri uri = mLoaderCompatShim.uriForItem(item); + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + intent.setClass(getActivity(), Gallery.class); + startActivity(intent); + } + + @Override + public Loader<Cursor> onCreateLoader(int id, Bundle args) { + // TODO: Switch to PhotoSetLoader + MediaItemsLoader loader = new MediaItemsLoader(getActivity(), mAlbumPath); + mLoaderCompatShim = loader; + mAdapter.setDrawableFactory(mLoaderCompatShim); + return loader; + } + + @Override + public void onLoadFinished(Loader<Cursor> loader, + Cursor data) { + mAdapter.swapCursor(data); + updateHeaderView(); + setAdapter(mAdapter); + } + + @Override + public void onLoaderReset(Loader<Cursor> loader) { + } + + @Override + public int getItemMediaType(Object item) { + return ((Cursor) item).getInt(PhotoSetLoader.INDEX_MEDIA_TYPE); + } + + @Override + public int getItemSupportedOperations(Object item) { + return ((Cursor) item).getInt(PhotoSetLoader.INDEX_SUPPORTED_OPERATIONS); + } + + private ArrayList<Uri> mSubItemUriTemp = new ArrayList<Uri>(1); + @Override + public ArrayList<Uri> getSubItemUrisForItem(Object item) { + mSubItemUriTemp.clear(); + mSubItemUriTemp.add(mLoaderCompatShim.uriForItem((Cursor) item)); + return mSubItemUriTemp; + } + + @Override + public void deleteItemWithPath(Object itemPath) { + mLoaderCompatShim.deleteItemWithPath(itemPath); + } + + @Override + public Uri getItemUri(Object item) { + return mLoaderCompatShim.uriForItem((Cursor) item); + } + + @Override + public Object getPathForItem(Object item) { + return mLoaderCompatShim.getPathForItem((Cursor) item); + } +} diff --git a/src/com/android/photos/AlbumSetFragment.java b/src/com/android/photos/AlbumSetFragment.java new file mode 100644 index 000000000..bc5289ee1 --- /dev/null +++ b/src/com/android/photos/AlbumSetFragment.java @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos; + +import android.app.LoaderManager.LoaderCallbacks; +import android.content.Context; +import android.content.Intent; +import android.content.Loader; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.provider.MediaStore.Files.FileColumns; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.GridView; + +import com.android.gallery3d.R; +import com.android.photos.adapters.AlbumSetCursorAdapter; +import com.android.photos.data.AlbumSetLoader; +import com.android.photos.shims.LoaderCompatShim; +import com.android.photos.shims.MediaSetLoader; + +import java.util.ArrayList; + + +public class AlbumSetFragment extends MultiSelectGridFragment implements LoaderCallbacks<Cursor> { + + private AlbumSetCursorAdapter mAdapter; + private LoaderCompatShim<Cursor> mLoaderCompatShim; + + private static final int LOADER_ALBUMSET = 1; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Context context = getActivity(); + mAdapter = new AlbumSetCursorAdapter(context); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View root = super.onCreateView(inflater, container, savedInstanceState); + getLoaderManager().initLoader(LOADER_ALBUMSET, null, this); + return root; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + getGridView().setColumnWidth(getActivity().getResources() + .getDimensionPixelSize(R.dimen.album_set_item_width)); + } + + @Override + public Loader<Cursor> onCreateLoader(int id, Bundle args) { + // TODO: Switch to AlbumSetLoader + MediaSetLoader loader = new MediaSetLoader(getActivity()); + mAdapter.setDrawableFactory(loader); + mLoaderCompatShim = loader; + return loader; + } + + @Override + public void onLoadFinished(Loader<Cursor> loader, + Cursor data) { + mAdapter.swapCursor(data); + setAdapter(mAdapter); + } + + @Override + public void onLoaderReset(Loader<Cursor> loader) { + } + + @Override + public void onGridItemClick(GridView g, View v, int position, long id) { + if (mLoaderCompatShim == null) { + // Not fully initialized yet, discard + return; + } + Cursor item = (Cursor) getItemAtPosition(position); + Context context = getActivity(); + Intent intent = new Intent(context, AlbumActivity.class); + intent.putExtra(AlbumActivity.KEY_ALBUM_URI, + mLoaderCompatShim.getPathForItem(item).toString()); + intent.putExtra(AlbumActivity.KEY_ALBUM_TITLE, + item.getString(AlbumSetLoader.INDEX_TITLE)); + context.startActivity(intent); + } + + @Override + public int getItemMediaType(Object item) { + return FileColumns.MEDIA_TYPE_NONE; + } + + @Override + public int getItemSupportedOperations(Object item) { + return ((Cursor) item).getInt(AlbumSetLoader.INDEX_SUPPORTED_OPERATIONS); + } + + @Override + public ArrayList<Uri> getSubItemUrisForItem(Object item) { + return mLoaderCompatShim.urisForSubItems((Cursor) item); + } + + @Override + public void deleteItemWithPath(Object itemPath) { + mLoaderCompatShim.deleteItemWithPath(itemPath); + } + + @Override + public Uri getItemUri(Object item) { + return mLoaderCompatShim.uriForItem((Cursor) item); + } + + @Override + public Object getPathForItem(Object item) { + return mLoaderCompatShim.getPathForItem((Cursor) item); + } +} diff --git a/src/com/android/photos/BitmapRegionTileSource.java b/src/com/android/photos/BitmapRegionTileSource.java new file mode 100644 index 000000000..d7d52f67a --- /dev/null +++ b/src/com/android/photos/BitmapRegionTileSource.java @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; +import android.graphics.BitmapFactory; +import android.graphics.BitmapRegionDecoder; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.os.Build; +import android.os.Build.VERSION_CODES; +import android.util.Log; + +import com.android.gallery3d.glrenderer.BasicTexture; +import com.android.gallery3d.glrenderer.BitmapTexture; +import com.android.photos.views.TiledImageRenderer; + +import java.io.IOException; + +/** + * A {@link com.android.photos.views.TiledImageRenderer.TileSource} using + * {@link BitmapRegionDecoder} to wrap a local file + */ +public class BitmapRegionTileSource implements TiledImageRenderer.TileSource { + + private static final String TAG = "BitmapRegionTileSource"; + + private static final boolean REUSE_BITMAP = + Build.VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN; + private static final int MAX_PREVIEW_SIZE = 1024; + + BitmapRegionDecoder mDecoder; + int mWidth; + int mHeight; + int mTileSize; + private BasicTexture mPreview; + private final int mRotation; + + // For use only by getTile + private Rect mWantRegion = new Rect(); + private Rect mOverlapRegion = new Rect(); + private BitmapFactory.Options mOptions; + private Canvas mCanvas; + + public BitmapRegionTileSource(Context context, String path, int previewSize, int rotation) { + mTileSize = TiledImageRenderer.suggestedTileSize(context); + mRotation = rotation; + try { + mDecoder = BitmapRegionDecoder.newInstance(path, true); + mWidth = mDecoder.getWidth(); + mHeight = mDecoder.getHeight(); + } catch (IOException e) { + Log.w("BitmapRegionTileSource", "ctor failed", e); + } + mOptions = new BitmapFactory.Options(); + mOptions.inPreferredConfig = Bitmap.Config.ARGB_8888; + mOptions.inPreferQualityOverSpeed = true; + mOptions.inTempStorage = new byte[16 * 1024]; + if (previewSize != 0) { + previewSize = Math.min(previewSize, MAX_PREVIEW_SIZE); + // Although this is the same size as the Bitmap that is likely already + // loaded, the lifecycle is different and interactions are on a different + // thread. Thus to simplify, this source will decode its own bitmap. + int sampleSize = (int) Math.ceil(Math.max( + mWidth / (float) previewSize, mHeight / (float) previewSize)); + mOptions.inSampleSize = Math.max(sampleSize, 1); + Bitmap preview = mDecoder.decodeRegion( + new Rect(0, 0, mWidth, mHeight), mOptions); + if (preview.getWidth() <= MAX_PREVIEW_SIZE && preview.getHeight() <= MAX_PREVIEW_SIZE) { + mPreview = new BitmapTexture(preview); + } else { + Log.w(TAG, String.format( + "Failed to create preview of apropriate size! " + + " in: %dx%d, sample: %d, out: %dx%d", + mWidth, mHeight, sampleSize, + preview.getWidth(), preview.getHeight())); + } + } + } + + @Override + public int getTileSize() { + return mTileSize; + } + + @Override + public int getImageWidth() { + return mWidth; + } + + @Override + public int getImageHeight() { + return mHeight; + } + + @Override + public BasicTexture getPreview() { + return mPreview; + } + + @Override + public int getRotation() { + return mRotation; + } + + @Override + public Bitmap getTile(int level, int x, int y, Bitmap bitmap) { + int tileSize = getTileSize(); + if (!REUSE_BITMAP) { + return getTileWithoutReusingBitmap(level, x, y, tileSize); + } + + int t = tileSize << level; + mWantRegion.set(x, y, x + t, y + t); + + if (bitmap == null) { + bitmap = Bitmap.createBitmap(tileSize, tileSize, Bitmap.Config.ARGB_8888); + } + + mOptions.inSampleSize = (1 << level); + mOptions.inBitmap = bitmap; + + try { + bitmap = mDecoder.decodeRegion(mWantRegion, mOptions); + } finally { + if (mOptions.inBitmap != bitmap && mOptions.inBitmap != null) { + mOptions.inBitmap = null; + } + } + + if (bitmap == null) { + Log.w("BitmapRegionTileSource", "fail in decoding region"); + } + return bitmap; + } + + private Bitmap getTileWithoutReusingBitmap( + int level, int x, int y, int tileSize) { + + int t = tileSize << level; + mWantRegion.set(x, y, x + t, y + t); + + mOverlapRegion.set(0, 0, mWidth, mHeight); + + mOptions.inSampleSize = (1 << level); + Bitmap bitmap = mDecoder.decodeRegion(mOverlapRegion, mOptions); + + if (bitmap == null) { + Log.w(TAG, "fail in decoding region"); + } + + if (mWantRegion.equals(mOverlapRegion)) { + return bitmap; + } + + Bitmap result = Bitmap.createBitmap(tileSize, tileSize, Config.ARGB_8888); + if (mCanvas == null) { + mCanvas = new Canvas(); + } + mCanvas.setBitmap(result); + mCanvas.drawBitmap(bitmap, + (mOverlapRegion.left - mWantRegion.left) >> level, + (mOverlapRegion.top - mWantRegion.top) >> level, null); + mCanvas.setBitmap(null); + return result; + } +} diff --git a/src/com/android/photos/FullscreenViewer.java b/src/com/android/photos/FullscreenViewer.java new file mode 100644 index 000000000..a3761395e --- /dev/null +++ b/src/com/android/photos/FullscreenViewer.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos; + +import android.app.Activity; +import android.os.Bundle; +import com.android.photos.views.TiledImageView; + + +public class FullscreenViewer extends Activity { + + private TiledImageView mTextureView; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + String path = getIntent().getData().toString(); + mTextureView = new TiledImageView(this); + mTextureView.setTileSource(new BitmapRegionTileSource(this, path, 0, 0), null); + setContentView(mTextureView); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + mTextureView.destroy(); + } + +} diff --git a/src/com/android/photos/GalleryActivity.java b/src/com/android/photos/GalleryActivity.java new file mode 100644 index 000000000..710767d77 --- /dev/null +++ b/src/com/android/photos/GalleryActivity.java @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos; + +import android.app.ActionBar; +import android.app.ActionBar.Tab; +import android.app.Activity; +import android.app.Fragment; +import android.app.FragmentTransaction; +import android.content.Intent; +import android.os.Bundle; +import android.support.v13.app.FragmentPagerAdapter; +import android.support.v4.view.ViewPager; +import android.view.Menu; +import android.view.MenuItem; +import android.view.ViewGroup; + +import com.android.camera.CameraActivity; +import com.android.gallery3d.R; + +import java.util.ArrayList; + +public class GalleryActivity extends Activity implements MultiChoiceManager.Provider { + + private MultiChoiceManager mMultiChoiceManager; + private ViewPager mViewPager; + private TabsAdapter mTabsAdapter; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mMultiChoiceManager = new MultiChoiceManager(this); + mViewPager = new ViewPager(this); + mViewPager.setId(R.id.viewpager); + setContentView(mViewPager); + + ActionBar ab = getActionBar(); + ab.setNavigationMode(ActionBar.NAVIGATION_MODE_TABS); + ab.setDisplayShowHomeEnabled(false); + ab.setDisplayShowTitleEnabled(false); + + mTabsAdapter = new TabsAdapter(this, mViewPager); + mTabsAdapter.addTab(ab.newTab().setText(R.string.tab_photos), + PhotoSetFragment.class, null); + mTabsAdapter.addTab(ab.newTab().setText(R.string.tab_albums), + AlbumSetFragment.class, null); + + if (savedInstanceState != null) { + ab.setSelectedNavigationItem(savedInstanceState.getInt("tab", 0)); + } + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putInt("tab", getActionBar().getSelectedNavigationIndex()); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.gallery, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_camera: + Intent intent = new Intent(this, CameraActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + return true; + default: + return super.onOptionsItemSelected(item); + } + } + + public static class TabsAdapter extends FragmentPagerAdapter implements + ActionBar.TabListener, ViewPager.OnPageChangeListener { + + private final GalleryActivity mActivity; + private final ActionBar mActionBar; + private final ViewPager mViewPager; + private final ArrayList<TabInfo> mTabs = new ArrayList<TabInfo>(); + + static final class TabInfo { + + private final Class<?> clss; + private final Bundle args; + + TabInfo(Class<?> _class, Bundle _args) { + clss = _class; + args = _args; + } + } + + public TabsAdapter(GalleryActivity activity, ViewPager pager) { + super(activity.getFragmentManager()); + mActivity = activity; + mActionBar = activity.getActionBar(); + mViewPager = pager; + mViewPager.setAdapter(this); + mViewPager.setOnPageChangeListener(this); + } + + public void addTab(ActionBar.Tab tab, Class<?> clss, Bundle args) { + TabInfo info = new TabInfo(clss, args); + tab.setTag(info); + tab.setTabListener(this); + mTabs.add(info); + mActionBar.addTab(tab); + notifyDataSetChanged(); + } + + @Override + public int getCount() { + return mTabs.size(); + } + + @Override + public Fragment getItem(int position) { + TabInfo info = mTabs.get(position); + return Fragment.instantiate(mActivity, info.clss.getName(), + info.args); + } + + @Override + public void onPageScrolled(int position, float positionOffset, + int positionOffsetPixels) { + } + + @Override + public void onPageSelected(int position) { + mActionBar.setSelectedNavigationItem(position); + } + + @Override + public void setPrimaryItem(ViewGroup container, int position, Object object) { + super.setPrimaryItem(container, position, object); + mActivity.mMultiChoiceManager.setDelegate((MultiChoiceManager.Delegate) object); + } + + @Override + public void onPageScrollStateChanged(int state) { + } + + @Override + public void onTabSelected(Tab tab, FragmentTransaction ft) { + Object tag = tab.getTag(); + for (int i = 0; i < mTabs.size(); i++) { + if (mTabs.get(i) == tag) { + mViewPager.setCurrentItem(i); + } + } + } + + @Override + public void onTabUnselected(Tab tab, FragmentTransaction ft) { + } + + @Override + public void onTabReselected(Tab tab, FragmentTransaction ft) { + } + } + + @Override + public MultiChoiceManager getMultiChoiceManager() { + return mMultiChoiceManager; + } +} diff --git a/src/com/android/photos/MultiChoiceManager.java b/src/com/android/photos/MultiChoiceManager.java new file mode 100644 index 000000000..49519ca63 --- /dev/null +++ b/src/com/android/photos/MultiChoiceManager.java @@ -0,0 +1,295 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.AsyncTask; +import android.provider.MediaStore.Files.FileColumns; +import android.util.SparseBooleanArray; +import android.view.ActionMode; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.widget.AbsListView.MultiChoiceModeListener; +import android.widget.ShareActionProvider; +import android.widget.ShareActionProvider.OnShareTargetSelectedListener; + +import com.android.gallery3d.R; +import com.android.gallery3d.app.TrimVideo; +import com.android.gallery3d.data.MediaObject; +import com.android.gallery3d.filtershow.FilterShowActivity; +import com.android.gallery3d.filtershow.crop.CropActivity; +import com.android.gallery3d.util.GalleryUtils; + +import java.util.ArrayList; +import java.util.List; + +public class MultiChoiceManager implements MultiChoiceModeListener, + OnShareTargetSelectedListener, SelectionManager.SelectedUriSource { + + public interface Provider { + public MultiChoiceManager getMultiChoiceManager(); + } + + public interface Delegate { + public SparseBooleanArray getSelectedItemPositions(); + public int getSelectedItemCount(); + public int getItemMediaType(Object item); + public int getItemSupportedOperations(Object item); + public ArrayList<Uri> getSubItemUrisForItem(Object item); + public Uri getItemUri(Object item); + public Object getItemAtPosition(int position); + public Object getPathForItemAtPosition(int position); + public void deleteItemWithPath(Object itemPath); + } + + private SelectionManager mSelectionManager; + private ShareActionProvider mShareActionProvider; + private ActionMode mActionMode; + private Context mContext; + private Delegate mDelegate; + + private ArrayList<Uri> mSelectedShareableUrisArray = new ArrayList<Uri>(); + + public MultiChoiceManager(Activity activity) { + mContext = activity; + mSelectionManager = new SelectionManager(activity); + } + + public void setDelegate(Delegate delegate) { + if (mDelegate == delegate) { + return; + } + if (mActionMode != null) { + mActionMode.finish(); + } + mDelegate = delegate; + } + + @Override + public ArrayList<Uri> getSelectedShareableUris() { + return mSelectedShareableUrisArray; + } + + private void updateSelectedTitle(ActionMode mode) { + int count = mDelegate.getSelectedItemCount(); + mode.setTitle(mContext.getResources().getQuantityString( + R.plurals.number_of_items_selected, count, count)); + } + + private String getItemMimetype(Object item) { + int type = mDelegate.getItemMediaType(item); + if (type == FileColumns.MEDIA_TYPE_IMAGE) { + return GalleryUtils.MIME_TYPE_IMAGE; + } else if (type == FileColumns.MEDIA_TYPE_VIDEO) { + return GalleryUtils.MIME_TYPE_VIDEO; + } else { + return GalleryUtils.MIME_TYPE_ALL; + } + } + + @Override + public void onItemCheckedStateChanged(ActionMode mode, int position, long id, + boolean checked) { + updateSelectedTitle(mode); + Object item = mDelegate.getItemAtPosition(position); + + int supported = mDelegate.getItemSupportedOperations(item); + + if ((supported & MediaObject.SUPPORT_SHARE) > 0) { + ArrayList<Uri> subItems = mDelegate.getSubItemUrisForItem(item); + if (checked) { + mSelectedShareableUrisArray.addAll(subItems); + } else { + mSelectedShareableUrisArray.removeAll(subItems); + } + } + + mSelectionManager.onItemSelectedStateChanged(mShareActionProvider, + mDelegate.getItemMediaType(item), + supported, + checked); + updateActionItemVisibilities(mode.getMenu(), + mSelectionManager.getSupportedOperations()); + } + + private void updateActionItemVisibilities(Menu menu, int supportedOperations) { + MenuItem editItem = menu.findItem(R.id.menu_edit); + MenuItem deleteItem = menu.findItem(R.id.menu_delete); + MenuItem shareItem = menu.findItem(R.id.menu_share); + MenuItem cropItem = menu.findItem(R.id.menu_crop); + MenuItem trimItem = menu.findItem(R.id.menu_trim); + MenuItem muteItem = menu.findItem(R.id.menu_mute); + MenuItem setAsItem = menu.findItem(R.id.menu_set_as); + + editItem.setVisible((supportedOperations & MediaObject.SUPPORT_EDIT) > 0); + deleteItem.setVisible((supportedOperations & MediaObject.SUPPORT_DELETE) > 0); + shareItem.setVisible((supportedOperations & MediaObject.SUPPORT_SHARE) > 0); + cropItem.setVisible((supportedOperations & MediaObject.SUPPORT_CROP) > 0); + trimItem.setVisible((supportedOperations & MediaObject.SUPPORT_TRIM) > 0); + muteItem.setVisible((supportedOperations & MediaObject.SUPPORT_MUTE) > 0); + setAsItem.setVisible((supportedOperations & MediaObject.SUPPORT_SETAS) > 0); + } + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + mSelectionManager.setSelectedUriSource(this); + mActionMode = mode; + MenuInflater inflater = mode.getMenuInflater(); + inflater.inflate(R.menu.gallery_multiselect, menu); + MenuItem menuItem = menu.findItem(R.id.menu_share); + mShareActionProvider = (ShareActionProvider) menuItem.getActionProvider(); + mShareActionProvider.setOnShareTargetSelectedListener(this); + updateSelectedTitle(mode); + return true; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + // onDestroyActionMode gets called when the share target was selected, + // but apparently before the ArrayList is serialized in the intent + // so we can't clear the old one here. + mSelectedShareableUrisArray = new ArrayList<Uri>(); + mSelectionManager.onClearSelection(); + mSelectionManager.setSelectedUriSource(null); + mShareActionProvider = null; + mActionMode = null; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + updateSelectedTitle(mode); + return false; + } + + @Override + public boolean onShareTargetSelected(ShareActionProvider provider, Intent intent) { + mActionMode.finish(); + return false; + } + + private static class BulkDeleteTask extends AsyncTask<Void, Void, Void> { + private Delegate mDelegate; + private List<Object> mPaths; + + public BulkDeleteTask(Delegate delegate, List<Object> paths) { + mDelegate = delegate; + mPaths = paths; + } + + @Override + protected Void doInBackground(Void... ignored) { + for (Object path : mPaths) { + mDelegate.deleteItemWithPath(path); + } + return null; + } + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + int actionItemId = item.getItemId(); + switch (actionItemId) { + case R.id.menu_delete: + BulkDeleteTask deleteTask = new BulkDeleteTask(mDelegate, + getPathsForSelectedItems()); + deleteTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + mode.finish(); + return true; + case R.id.menu_edit: + case R.id.menu_crop: + case R.id.menu_trim: + case R.id.menu_mute: + case R.id.menu_set_as: + singleItemAction(getSelectedItem(), actionItemId); + mode.finish(); + return true; + default: + return false; + } + } + + private void singleItemAction(Object item, int actionItemId) { + Intent intent = new Intent(); + String mime = getItemMimetype(item); + Uri uri = mDelegate.getItemUri(item); + switch (actionItemId) { + case R.id.menu_edit: + intent.setDataAndType(uri, mime) + .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .setAction(Intent.ACTION_EDIT); + mContext.startActivity(Intent.createChooser(intent, null)); + return; + case R.id.menu_crop: + intent.setDataAndType(uri, mime) + .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .setAction(CropActivity.CROP_ACTION) + .setClass(mContext, FilterShowActivity.class); + mContext.startActivity(intent); + return; + case R.id.menu_trim: + intent.setData(uri) + .setClass(mContext, TrimVideo.class); + mContext.startActivity(intent); + return; + case R.id.menu_mute: + /* TODO need a way to get the file path of an item + MuteVideo muteVideo = new MuteVideo(filePath, + uri, (Activity) mContext); + muteVideo.muteInBackground(); + */ + return; + case R.id.menu_set_as: + intent.setDataAndType(uri, mime) + .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .setAction(Intent.ACTION_ATTACH_DATA) + .putExtra("mimeType", mime); + mContext.startActivity(Intent.createChooser( + intent, mContext.getString(R.string.set_as))); + return; + default: + return; + } + } + + private List<Object> getPathsForSelectedItems() { + List<Object> paths = new ArrayList<Object>(); + SparseBooleanArray selected = mDelegate.getSelectedItemPositions(); + for (int i = 0; i < selected.size(); i++) { + if (selected.valueAt(i)) { + paths.add(mDelegate.getPathForItemAtPosition(i)); + } + } + return paths; + } + + public Object getSelectedItem() { + if (mDelegate.getSelectedItemCount() != 1) { + return null; + } + SparseBooleanArray selected = mDelegate.getSelectedItemPositions(); + for (int i = 0; i < selected.size(); i++) { + if (selected.valueAt(i)) { + return mDelegate.getItemAtPosition(selected.keyAt(i)); + } + } + return null; + } +} diff --git a/src/com/android/photos/MultiSelectGridFragment.java b/src/com/android/photos/MultiSelectGridFragment.java new file mode 100644 index 000000000..dda9fe443 --- /dev/null +++ b/src/com/android/photos/MultiSelectGridFragment.java @@ -0,0 +1,348 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos; + +import android.app.Activity; +import android.app.Fragment; +import android.os.Bundle; +import android.os.Handler; +import android.util.SparseBooleanArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AnimationUtils; +import android.widget.AdapterView; +import android.widget.GridView; +import android.widget.ListAdapter; +import android.widget.TextView; + +import com.android.gallery3d.R; + +public abstract class MultiSelectGridFragment extends Fragment + implements MultiChoiceManager.Delegate, AdapterView.OnItemClickListener { + + final private Handler mHandler = new Handler(); + + final private Runnable mRequestFocus = new Runnable() { + @Override + public void run() { + mGrid.focusableViewAvailable(mGrid); + } + }; + + ListAdapter mAdapter; + GridView mGrid; + TextView mEmptyView; + View mProgressContainer; + View mGridContainer; + CharSequence mEmptyText; + boolean mGridShown; + MultiChoiceManager.Provider mHost; + + public MultiSelectGridFragment() { + } + + /** + * Provide default implementation to return a simple grid view. Subclasses + * can override to replace with their own layout. If doing so, the returned + * view hierarchy <em>must</em> have a GridView whose id is + * {@link android.R.id#grid android.R.id.list} and can optionally have a + * sibling text view id {@link android.R.id#empty android.R.id.empty} that + * is to be shown when the grid is empty. + */ + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + return inflater.inflate(R.layout.multigrid_content, container, false); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + mHost = (MultiChoiceManager.Provider) activity; + if (mGrid != null) { + mGrid.setMultiChoiceModeListener(mHost.getMultiChoiceManager()); + } + } + + @Override + public void onDetach() { + super.onDetach(); + mHost = null; + } + + /** + * Attach to grid view once the view hierarchy has been created. + */ + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + ensureGrid(); + } + + /** + * Detach from grid view. + */ + @Override + public void onDestroyView() { + mHandler.removeCallbacks(mRequestFocus); + mGrid = null; + mGridShown = false; + mEmptyView = null; + mProgressContainer = mGridContainer = null; + super.onDestroyView(); + } + + /** + * This method will be called when an item in the grid is selected. + * Subclasses should override. Subclasses can call + * getGridView().getItemAtPosition(position) if they need to access the data + * associated with the selected item. + * + * @param g The GridView where the click happened + * @param v The view that was clicked within the GridView + * @param position The position of the view in the grid + * @param id The id of the item that was clicked + */ + public void onGridItemClick(GridView g, View v, int position, long id) { + } + + /** + * Provide the cursor for the grid view. + */ + public void setAdapter(ListAdapter adapter) { + boolean hadAdapter = mAdapter != null; + mAdapter = adapter; + if (mGrid != null) { + mGrid.setAdapter(adapter); + if (!mGridShown && !hadAdapter) { + // The grid was hidden, and previously didn't have an + // adapter. It is now time to show it. + setGridShown(true, getView().getWindowToken() != null); + } + } + } + + /** + * Set the currently selected grid item to the specified position with the + * adapter's data + * + * @param position + */ + public void setSelection(int position) { + ensureGrid(); + mGrid.setSelection(position); + } + + /** + * Get the position of the currently selected grid item. + */ + public int getSelectedItemPosition() { + ensureGrid(); + return mGrid.getSelectedItemPosition(); + } + + /** + * Get the cursor row ID of the currently selected grid item. + */ + public long getSelectedItemId() { + ensureGrid(); + return mGrid.getSelectedItemId(); + } + + /** + * Get the activity's grid view widget. + */ + public GridView getGridView() { + ensureGrid(); + return mGrid; + } + + /** + * The default content for a MultiSelectGridFragment has a TextView that can + * be shown when the grid is empty. If you would like to have it shown, call + * this method to supply the text it should use. + */ + public void setEmptyText(CharSequence text) { + ensureGrid(); + if (mEmptyView == null) { + return; + } + mEmptyView.setText(text); + if (mEmptyText == null) { + mGrid.setEmptyView(mEmptyView); + } + mEmptyText = text; + } + + /** + * Control whether the grid is being displayed. You can make it not + * displayed if you are waiting for the initial data to show in it. During + * this time an indeterminate progress indicator will be shown instead. + * <p> + * Applications do not normally need to use this themselves. The default + * behavior of MultiSelectGridFragment is to start with the grid not being + * shown, only showing it once an adapter is given with + * {@link #setAdapter(ListAdapter)}. If the grid at that point had not been + * shown, when it does get shown it will be do without the user ever seeing + * the hidden state. + * + * @param shown If true, the grid view is shown; if false, the progress + * indicator. The initial value is true. + */ + public void setGridShown(boolean shown) { + setGridShown(shown, true); + } + + /** + * Like {@link #setGridShown(boolean)}, but no animation is used when + * transitioning from the previous state. + */ + public void setGridShownNoAnimation(boolean shown) { + setGridShown(shown, false); + } + + /** + * Control whether the grid is being displayed. You can make it not + * displayed if you are waiting for the initial data to show in it. During + * this time an indeterminate progress indicator will be shown instead. + * + * @param shown If true, the grid view is shown; if false, the progress + * indicator. The initial value is true. + * @param animate If true, an animation will be used to transition to the + * new state. + */ + private void setGridShown(boolean shown, boolean animate) { + ensureGrid(); + if (mProgressContainer == null) { + throw new IllegalStateException("Can't be used with a custom content view"); + } + if (mGridShown == shown) { + return; + } + mGridShown = shown; + if (shown) { + if (animate) { + mProgressContainer.startAnimation(AnimationUtils.loadAnimation( + getActivity(), android.R.anim.fade_out)); + mGridContainer.startAnimation(AnimationUtils.loadAnimation( + getActivity(), android.R.anim.fade_in)); + } else { + mProgressContainer.clearAnimation(); + mGridContainer.clearAnimation(); + } + mProgressContainer.setVisibility(View.GONE); + mGridContainer.setVisibility(View.VISIBLE); + } else { + if (animate) { + mProgressContainer.startAnimation(AnimationUtils.loadAnimation( + getActivity(), android.R.anim.fade_in)); + mGridContainer.startAnimation(AnimationUtils.loadAnimation( + getActivity(), android.R.anim.fade_out)); + } else { + mProgressContainer.clearAnimation(); + mGridContainer.clearAnimation(); + } + mProgressContainer.setVisibility(View.VISIBLE); + mGridContainer.setVisibility(View.GONE); + } + } + + /** + * Get the ListAdapter associated with this activity's GridView. + */ + public ListAdapter getAdapter() { + return mGrid.getAdapter(); + } + + private void ensureGrid() { + if (mGrid != null) { + return; + } + View root = getView(); + if (root == null) { + throw new IllegalStateException("Content view not yet created"); + } + if (root instanceof GridView) { + mGrid = (GridView) root; + } else { + View empty = root.findViewById(android.R.id.empty); + if (empty != null && empty instanceof TextView) { + mEmptyView = (TextView) empty; + } + mProgressContainer = root.findViewById(R.id.progressContainer); + mGridContainer = root.findViewById(R.id.gridContainer); + View rawGridView = root.findViewById(android.R.id.list); + if (!(rawGridView instanceof GridView)) { + throw new RuntimeException( + "Content has view with id attribute 'android.R.id.list' " + + "that is not a GridView class"); + } + mGrid = (GridView) rawGridView; + if (mGrid == null) { + throw new RuntimeException( + "Your content must have a GridView whose id attribute is " + + "'android.R.id.list'"); + } + if (mEmptyView != null) { + mGrid.setEmptyView(mEmptyView); + } + } + mGridShown = true; + mGrid.setOnItemClickListener(this); + mGrid.setMultiChoiceModeListener(mHost.getMultiChoiceManager()); + if (mAdapter != null) { + ListAdapter adapter = mAdapter; + mAdapter = null; + setAdapter(adapter); + } else { + // We are starting without an adapter, so assume we won't + // have our data right away and start with the progress indicator. + if (mProgressContainer != null) { + setGridShown(false, false); + } + } + mHandler.post(mRequestFocus); + } + + @Override + public Object getItemAtPosition(int position) { + return getAdapter().getItem(position); + } + + @Override + public Object getPathForItemAtPosition(int position) { + return getPathForItem(getItemAtPosition(position)); + } + + @Override + public SparseBooleanArray getSelectedItemPositions() { + return mGrid.getCheckedItemPositions(); + } + + @Override + public int getSelectedItemCount() { + return mGrid.getCheckedItemCount(); + } + + public abstract Object getPathForItem(Object item); + + @Override + public void onItemClick(AdapterView<?> parent, View v, int position, long id) { + onGridItemClick((GridView) parent, v, position, id); + } +} diff --git a/src/com/android/photos/PhotoFragment.java b/src/com/android/photos/PhotoFragment.java new file mode 100644 index 000000000..3be6313f2 --- /dev/null +++ b/src/com/android/photos/PhotoFragment.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2013 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. + */ + + +package com.android.photos; + +import android.app.Fragment; + + +public class PhotoFragment extends Fragment { + +} diff --git a/src/com/android/photos/PhotoSetFragment.java b/src/com/android/photos/PhotoSetFragment.java new file mode 100644 index 000000000..961fd0bf2 --- /dev/null +++ b/src/com/android/photos/PhotoSetFragment.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos; + +import android.app.LoaderManager.LoaderCallbacks; +import android.content.Context; +import android.content.Intent; +import android.content.Loader; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.GridView; + +import com.android.gallery3d.app.Gallery; +import com.android.photos.adapters.PhotoThumbnailAdapter; +import com.android.photos.data.PhotoSetLoader; +import com.android.photos.shims.LoaderCompatShim; +import com.android.photos.shims.MediaItemsLoader; + +import java.util.ArrayList; + +public class PhotoSetFragment extends MultiSelectGridFragment implements LoaderCallbacks<Cursor> { + + private static final int LOADER_PHOTOSET = 1; + + private LoaderCompatShim<Cursor> mLoaderCompatShim; + private PhotoThumbnailAdapter mAdapter; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Context context = getActivity(); + mAdapter = new PhotoThumbnailAdapter(context); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View root = super.onCreateView(inflater, container, savedInstanceState); + getLoaderManager().initLoader(LOADER_PHOTOSET, null, this); + return root; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + // TODO: Remove once UI stabilizes + getGridView().setColumnWidth(MediaItemsLoader.getThumbnailSize()); + } + + @Override + public void onGridItemClick(GridView g, View v, int position, long id) { + if (mLoaderCompatShim == null) { + // Not fully initialized yet, discard + return; + } + Cursor item = (Cursor) getItemAtPosition(position); + Uri uri = mLoaderCompatShim.uriForItem(item); + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + intent.setClass(getActivity(), Gallery.class); + startActivity(intent); + } + + @Override + public Loader<Cursor> onCreateLoader(int id, Bundle args) { + // TODO: Switch to PhotoSetLoader + MediaItemsLoader loader = new MediaItemsLoader(getActivity()); + mLoaderCompatShim = loader; + mAdapter.setDrawableFactory(mLoaderCompatShim); + return loader; + } + + @Override + public void onLoadFinished(Loader<Cursor> loader, + Cursor data) { + mAdapter.swapCursor(data); + setAdapter(mAdapter); + } + + @Override + public void onLoaderReset(Loader<Cursor> loader) { + } + + @Override + public int getItemMediaType(Object item) { + return ((Cursor) item).getInt(PhotoSetLoader.INDEX_MEDIA_TYPE); + } + + @Override + public int getItemSupportedOperations(Object item) { + return ((Cursor) item).getInt(PhotoSetLoader.INDEX_SUPPORTED_OPERATIONS); + } + + private ArrayList<Uri> mSubItemUriTemp = new ArrayList<Uri>(1); + @Override + public ArrayList<Uri> getSubItemUrisForItem(Object item) { + mSubItemUriTemp.clear(); + mSubItemUriTemp.add(mLoaderCompatShim.uriForItem((Cursor) item)); + return mSubItemUriTemp; + } + + @Override + public void deleteItemWithPath(Object itemPath) { + mLoaderCompatShim.deleteItemWithPath(itemPath); + } + + @Override + public Uri getItemUri(Object item) { + return mLoaderCompatShim.uriForItem((Cursor) item); + } + + @Override + public Object getPathForItem(Object item) { + return mLoaderCompatShim.getPathForItem((Cursor) item); + } +} diff --git a/src/com/android/photos/SelectionManager.java b/src/com/android/photos/SelectionManager.java new file mode 100644 index 000000000..9bfb9be75 --- /dev/null +++ b/src/com/android/photos/SelectionManager.java @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.nfc.NfcAdapter; +import android.nfc.NfcAdapter.CreateBeamUrisCallback; +import android.nfc.NfcEvent; +import android.provider.MediaStore.Files.FileColumns; +import android.widget.ShareActionProvider; + +import com.android.gallery3d.common.ApiHelper; +import com.android.gallery3d.data.MediaObject; +import com.android.gallery3d.util.GalleryUtils; + +import java.util.ArrayList; + +public class SelectionManager { + private Activity mActivity; + private NfcAdapter mNfcAdapter; + private SelectedUriSource mUriSource; + private Intent mShareIntent = new Intent(); + + public interface SelectedUriSource { + public ArrayList<Uri> getSelectedShareableUris(); + } + + public SelectionManager(Activity activity) { + mActivity = activity; + if (ApiHelper.AT_LEAST_16) { + mNfcAdapter = NfcAdapter.getDefaultAdapter(mActivity); + mNfcAdapter.setBeamPushUrisCallback(new CreateBeamUrisCallback() { + @Override + public Uri[] createBeamUris(NfcEvent arg0) { + // This will have been preceded by a call to onItemSelectedStateChange + if (mCachedShareableUris == null) return null; + return mCachedShareableUris.toArray( + new Uri[mCachedShareableUris.size()]); + } + }, mActivity); + } + } + + public void setSelectedUriSource(SelectedUriSource source) { + mUriSource = source; + } + + private int mSelectedTotalCount = 0; + private int mSelectedShareableCount = 0; + private int mSelectedShareableImageCount = 0; + private int mSelectedShareableVideoCount = 0; + private int mSelectedDeletableCount = 0; + private int mSelectedEditableCount = 0; + private int mSelectedCroppableCount = 0; + private int mSelectedSetableCount = 0; + private int mSelectedTrimmableCount = 0; + private int mSelectedMuteableCount = 0; + + private ArrayList<Uri> mCachedShareableUris = null; + + public void onItemSelectedStateChanged(ShareActionProvider share, + int itemType, int itemSupportedOperations, boolean selected) { + int increment = selected ? 1 : -1; + + mSelectedTotalCount += increment; + mCachedShareableUris = null; + + if ((itemSupportedOperations & MediaObject.SUPPORT_DELETE) > 0) { + mSelectedDeletableCount += increment; + } + if ((itemSupportedOperations & MediaObject.SUPPORT_EDIT) > 0) { + mSelectedEditableCount += increment; + } + if ((itemSupportedOperations & MediaObject.SUPPORT_CROP) > 0) { + mSelectedCroppableCount += increment; + } + if ((itemSupportedOperations & MediaObject.SUPPORT_SETAS) > 0) { + mSelectedSetableCount += increment; + } + if ((itemSupportedOperations & MediaObject.SUPPORT_TRIM) > 0) { + mSelectedTrimmableCount += increment; + } + if ((itemSupportedOperations & MediaObject.SUPPORT_MUTE) > 0) { + mSelectedMuteableCount += increment; + } + if ((itemSupportedOperations & MediaObject.SUPPORT_SHARE) > 0) { + mSelectedShareableCount += increment; + if (itemType == FileColumns.MEDIA_TYPE_IMAGE) { + mSelectedShareableImageCount += increment; + } else if (itemType == FileColumns.MEDIA_TYPE_VIDEO) { + mSelectedShareableVideoCount += increment; + } + } + + mShareIntent.removeExtra(Intent.EXTRA_STREAM); + if (mSelectedShareableCount == 0) { + mShareIntent.setAction(null).setType(null); + } else if (mSelectedShareableCount >= 1) { + mCachedShareableUris = mUriSource.getSelectedShareableUris(); + if (mCachedShareableUris.size() == 0) { + mShareIntent.setAction(null).setType(null); + } else { + if (mSelectedShareableImageCount == mSelectedShareableCount) { + mShareIntent.setType(GalleryUtils.MIME_TYPE_IMAGE); + } else if (mSelectedShareableVideoCount == mSelectedShareableCount) { + mShareIntent.setType(GalleryUtils.MIME_TYPE_VIDEO); + } else { + mShareIntent.setType(GalleryUtils.MIME_TYPE_ALL); + } + if (mCachedShareableUris.size() == 1) { + mShareIntent.setAction(Intent.ACTION_SEND); + mShareIntent.putExtra(Intent.EXTRA_STREAM, mCachedShareableUris.get(0)); + } else { + mShareIntent.setAction(Intent.ACTION_SEND_MULTIPLE); + mShareIntent.putExtra(Intent.EXTRA_STREAM, mCachedShareableUris); + } + } + } + share.setShareIntent(mShareIntent); + } + + public int getSupportedOperations() { + if (mSelectedTotalCount == 0) { + return 0; + } + int supported = 0; + if (mSelectedTotalCount == 1) { + if (mSelectedCroppableCount == 1) { + supported |= MediaObject.SUPPORT_CROP; + } + if (mSelectedEditableCount == 1) { + supported |= MediaObject.SUPPORT_EDIT; + } + if (mSelectedSetableCount == 1) { + supported |= MediaObject.SUPPORT_SETAS; + } + if (mSelectedTrimmableCount == 1) { + supported |= MediaObject.SUPPORT_TRIM; + } + if (mSelectedMuteableCount == 1) { + supported |= MediaObject.SUPPORT_MUTE; + } + } + if (mSelectedDeletableCount == mSelectedTotalCount) { + supported |= MediaObject.SUPPORT_DELETE; + } + if (mSelectedShareableCount > 0) { + supported |= MediaObject.SUPPORT_SHARE; + } + return supported; + } + + public void onClearSelection() { + mSelectedTotalCount = 0; + mSelectedShareableCount = 0; + mSelectedShareableImageCount = 0; + mSelectedShareableVideoCount = 0; + mSelectedDeletableCount = 0; + mSelectedEditableCount = 0; + mSelectedCroppableCount = 0; + mSelectedSetableCount = 0; + mSelectedTrimmableCount = 0; + mSelectedMuteableCount = 0; + mCachedShareableUris = null; + mShareIntent.removeExtra(Intent.EXTRA_STREAM); + mShareIntent.setAction(null).setType(null); + } +} diff --git a/src/com/android/photos/adapters/AlbumSetCursorAdapter.java b/src/com/android/photos/adapters/AlbumSetCursorAdapter.java new file mode 100644 index 000000000..ab99cde70 --- /dev/null +++ b/src/com/android/photos/adapters/AlbumSetCursorAdapter.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos.adapters; + +import android.content.Context; +import android.database.Cursor; +import android.graphics.drawable.Drawable; +import android.text.format.DateFormat; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CursorAdapter; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import com.android.gallery3d.R; +import com.android.photos.data.AlbumSetLoader; +import com.android.photos.shims.LoaderCompatShim; + +import java.util.Date; + +public class AlbumSetCursorAdapter extends CursorAdapter { + + private LoaderCompatShim<Cursor> mDrawableFactory; + + public void setDrawableFactory(LoaderCompatShim<Cursor> factory) { + mDrawableFactory = factory; + } + + public AlbumSetCursorAdapter(Context context) { + super(context, null, false); + } + + @Override + public void bindView(View v, Context context, Cursor cursor) { + TextView titleTextView = (TextView) v.findViewById( + R.id.album_set_item_title); + titleTextView.setText(cursor.getString(AlbumSetLoader.INDEX_TITLE)); + + TextView countTextView = (TextView) v.findViewById( + R.id.album_set_item_count); + int count = cursor.getInt(AlbumSetLoader.INDEX_COUNT); + countTextView.setText(context.getResources().getQuantityString( + R.plurals.number_of_photos, count, count)); + + ImageView thumbImageView = (ImageView) v.findViewById( + R.id.album_set_item_image); + Drawable recycle = thumbImageView.getDrawable(); + Drawable drawable = mDrawableFactory.drawableForItem(cursor, recycle); + if (recycle != drawable) { + thumbImageView.setImageDrawable(drawable); + } + } + + @Override + public View newView(Context context, Cursor cursor, ViewGroup parent) { + return LayoutInflater.from(context).inflate( + R.layout.album_set_item, parent, false); + } +} diff --git a/src/com/android/photos/adapters/PhotoThumbnailAdapter.java b/src/com/android/photos/adapters/PhotoThumbnailAdapter.java new file mode 100644 index 000000000..1190b8c85 --- /dev/null +++ b/src/com/android/photos/adapters/PhotoThumbnailAdapter.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos.adapters; + +import android.content.Context; +import android.database.Cursor; +import android.graphics.drawable.Drawable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CursorAdapter; +import android.widget.ImageView; + +import com.android.gallery3d.R; +import com.android.photos.data.PhotoSetLoader; +import com.android.photos.shims.LoaderCompatShim; +import com.android.photos.views.GalleryThumbnailView.GalleryThumbnailAdapter; + + +public class PhotoThumbnailAdapter extends CursorAdapter implements GalleryThumbnailAdapter { + private LayoutInflater mInflater; + private LoaderCompatShim<Cursor> mDrawableFactory; + + public PhotoThumbnailAdapter(Context context) { + super(context, null, false); + mInflater = LayoutInflater.from(context); + } + + public void setDrawableFactory(LoaderCompatShim<Cursor> factory) { + mDrawableFactory = factory; + } + + @Override + public void bindView(View view, Context context, Cursor cursor) { + ImageView iv = (ImageView) view.findViewById(R.id.thumbnail); + Drawable recycle = iv.getDrawable(); + Drawable drawable = mDrawableFactory.drawableForItem(cursor, recycle); + if (recycle != drawable) { + iv.setImageDrawable(drawable); + } + } + + @Override + public View newView(Context context, Cursor cursor, ViewGroup parent) { + View view = mInflater.inflate(R.layout.photo_set_item, parent, false); + return view; + } + + @Override + public float getIntrinsicAspectRatio(int position) { + Cursor cursor = getItem(position); + float width = cursor.getInt(PhotoSetLoader.INDEX_WIDTH); + float height = cursor.getInt(PhotoSetLoader.INDEX_HEIGHT); + return width / height; + } + + @Override + public Cursor getItem(int position) { + return (Cursor) super.getItem(position); + } +}
\ No newline at end of file diff --git a/src/com/android/photos/data/AlbumSetLoader.java b/src/com/android/photos/data/AlbumSetLoader.java new file mode 100644 index 000000000..940473255 --- /dev/null +++ b/src/com/android/photos/data/AlbumSetLoader.java @@ -0,0 +1,54 @@ +package com.android.photos.data; + +import android.database.MatrixCursor; + + +public class AlbumSetLoader { + public static final int INDEX_ID = 0; + public static final int INDEX_TITLE = 1; + public static final int INDEX_TIMESTAMP = 2; + public static final int INDEX_THUMBNAIL_URI = 3; + public static final int INDEX_THUMBNAIL_WIDTH = 4; + public static final int INDEX_THUMBNAIL_HEIGHT = 5; + public static final int INDEX_COUNT_PENDING_UPLOAD = 6; + public static final int INDEX_COUNT = 7; + public static final int INDEX_SUPPORTED_OPERATIONS = 8; + + public static final String[] PROJECTION = { + "_id", + "title", + "timestamp", + "thumb_uri", + "thumb_width", + "thumb_height", + "count_pending_upload", + "_count", + "supported_operations" + }; + public static final MatrixCursor MOCK = createRandomCursor(30); + + private static MatrixCursor createRandomCursor(int count) { + MatrixCursor c = new MatrixCursor(PROJECTION, count); + for (int i = 0; i < count; i++) { + c.addRow(createRandomRow()); + } + return c; + } + + private static Object[] createRandomRow() { + double random = Math.random(); + int id = (int) (500 * random); + Object[] row = { + id, + "Fun times " + id, + (long) (System.currentTimeMillis() * random), + null, + 0, + 0, + (random < .3 ? 1 : 0), + 1, + 0 + }; + return row; + } +}
\ No newline at end of file diff --git a/src/com/android/photos/data/BitmapDecoder.java b/src/com/android/photos/data/BitmapDecoder.java new file mode 100644 index 000000000..0671e73ca --- /dev/null +++ b/src/com/android/photos/data/BitmapDecoder.java @@ -0,0 +1,224 @@ +/* + * Copyright (C) 2013 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. + */ +package com.android.photos.data; + +import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; +import android.graphics.BitmapFactory; +import android.graphics.BitmapFactory.Options; +import android.util.Log; +import android.util.Pools.Pool; +import android.util.Pools.SynchronizedPool; + +import com.android.gallery3d.common.BitmapUtils; +import com.android.gallery3d.common.Utils; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +/** + * BitmapDecoder keeps a pool of temporary storage to reuse for decoding + * bitmaps. It also simplifies the multi-stage decoding required to efficiently + * use GalleryBitmapPool. The static methods decode and decodeFile can be used + * to decode a bitmap from GalleryBitmapPool. The bitmap may be returned + * directly to GalleryBitmapPool or use the put method here when the bitmap is + * ready to be recycled. + */ +public class BitmapDecoder { + private static final String TAG = BitmapDecoder.class.getSimpleName(); + private static final int POOL_SIZE = 4; + private static final int TEMP_STORAGE_SIZE_BYTES = 16 * 1024; + private static final int HEADER_MAX_SIZE = 128 * 1024; + private static final int NO_SCALING = -1; + + private static final Pool<BitmapFactory.Options> sOptions = + new SynchronizedPool<BitmapFactory.Options>(POOL_SIZE); + + private interface Decoder<T> { + Bitmap decode(T input, BitmapFactory.Options options); + + boolean decodeBounds(T input, BitmapFactory.Options options); + } + + private static abstract class OnlyDecode<T> implements Decoder<T> { + @Override + public boolean decodeBounds(T input, BitmapFactory.Options options) { + decode(input, options); + return true; + } + } + + private static final Decoder<InputStream> sStreamDecoder = new Decoder<InputStream>() { + @Override + public Bitmap decode(InputStream is, Options options) { + return BitmapFactory.decodeStream(is, null, options); + } + + @Override + public boolean decodeBounds(InputStream is, Options options) { + is.mark(HEADER_MAX_SIZE); + BitmapFactory.decodeStream(is, null, options); + try { + is.reset(); + return true; + } catch (IOException e) { + Log.e(TAG, "Could not decode stream to bitmap", e); + return false; + } + } + }; + + private static final Decoder<String> sFileDecoder = new OnlyDecode<String>() { + @Override + public Bitmap decode(String filePath, Options options) { + return BitmapFactory.decodeFile(filePath, options); + } + }; + + private static final Decoder<byte[]> sByteArrayDecoder = new OnlyDecode<byte[]>() { + @Override + public Bitmap decode(byte[] data, Options options) { + return BitmapFactory.decodeByteArray(data, 0, data.length, options); + } + }; + + private static <T> Bitmap delegateDecode(Decoder<T> decoder, T input, int width, int height) { + BitmapFactory.Options options = getOptions(); + GalleryBitmapPool pool = GalleryBitmapPool.getInstance(); + try { + options.inJustDecodeBounds = true; + if (!decoder.decodeBounds(input, options)) { + return null; + } + options.inJustDecodeBounds = false; + Bitmap reuseBitmap = null; + if (width != NO_SCALING && options.outWidth >= width && options.outHeight >= height) { + setScaling(options, width, height); + } else { + reuseBitmap = pool.get(options.outWidth, options.outHeight); + } + options.inBitmap = reuseBitmap; + Bitmap decodedBitmap = decoder.decode(input, options); + if (reuseBitmap != null && decodedBitmap != reuseBitmap) { + pool.put(reuseBitmap); + } + return decodedBitmap; + } catch (IllegalArgumentException e) { + if (options.inBitmap == null) { + throw e; + } + pool.put(options.inBitmap); + options.inBitmap = null; + return decoder.decode(input, options); + } finally { + options.inBitmap = null; + options.inJustDecodeBounds = false; + sOptions.release(options); + } + } + + public static Bitmap decode(InputStream in) { + try { + if (!in.markSupported()) { + in = new BufferedInputStream(in); + } + return delegateDecode(sStreamDecoder, in, NO_SCALING, NO_SCALING); + } finally { + Utils.closeSilently(in); + } + } + + public static Bitmap decode(File file) { + return decodeFile(file.getPath()); + } + + public static Bitmap decodeFile(String path) { + return delegateDecode(sFileDecoder, path, NO_SCALING, NO_SCALING); + } + + public static Bitmap decodeByteArray(byte[] data) { + return delegateDecode(sByteArrayDecoder, data, NO_SCALING, NO_SCALING); + } + + public static void put(Bitmap bitmap) { + GalleryBitmapPool.getInstance().put(bitmap); + } + + /** + * Decodes to a specific size. If the dimensions of the image don't match + * width x height, the resulting image will be in the proportions of the + * decoded image, but will be scaled to fill the dimensions. For example, if + * width and height are 10x10 and the image is 200x100, the resulting image + * will be scaled/sampled to 20x10. + */ + public static Bitmap decodeFile(String path, int width, int height) { + return delegateDecode(sFileDecoder, path, width, height); + } + + /** @see #decodeFile(String, int, int) */ + public static Bitmap decodeByteArray(byte[] data, int width, int height) { + return delegateDecode(sByteArrayDecoder, data, width, height); + } + + /** @see #decodeFile(String, int, int) */ + public static Bitmap decode(InputStream in, int width, int height) { + try { + if (!in.markSupported()) { + in = new BufferedInputStream(in); + } + return delegateDecode(sStreamDecoder, in, width, height); + } finally { + Utils.closeSilently(in); + } + } + + private static BitmapFactory.Options getOptions() { + BitmapFactory.Options opts = sOptions.acquire(); + if (opts == null) { + opts = new BitmapFactory.Options(); + opts.inMutable = true; + opts.inPreferredConfig = Config.ARGB_8888; + opts.inTempStorage = new byte[TEMP_STORAGE_SIZE_BYTES]; + } + opts.inSampleSize = 1; + opts.inDensity = 1; + opts.inTargetDensity = 1; + + return opts; + } + + // Sets the options to sample then scale the image so that the image's + // minimum dimension will match side. + private static void setScaling(BitmapFactory.Options options, int width, int height) { + float widthScale = ((float)options.outWidth)/ width; + float heightScale = ((float) options.outHeight)/height; + int side = (widthScale < heightScale) ? width : height; + options.inSampleSize = BitmapUtils.computeSampleSize(options.outWidth, options.outHeight, + side, BitmapUtils.UNCONSTRAINED); + int constraint; + if (options.outWidth < options.outHeight) { + // Width is the constraint. Scale so that width = side. + constraint = options.outWidth; + } else { + // Height is the constraint. Scale so that height = side. + constraint = options.outHeight; + } + options.inDensity = constraint / options.inSampleSize; + options.inTargetDensity = side; + } +} diff --git a/src/com/android/photos/data/FileRetriever.java b/src/com/android/photos/data/FileRetriever.java new file mode 100644 index 000000000..eb7686ef6 --- /dev/null +++ b/src/com/android/photos/data/FileRetriever.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2013 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. + */ +package com.android.photos.data; + +import android.graphics.Bitmap; +import android.media.ExifInterface; +import android.net.Uri; +import android.util.Log; +import android.webkit.MimeTypeMap; + +import com.android.gallery3d.common.BitmapUtils; + +import java.io.File; +import java.io.IOException; + +public class FileRetriever implements MediaRetriever { + private static final String TAG = FileRetriever.class.getSimpleName(); + + @Override + public File getLocalFile(Uri contentUri) { + return new File(contentUri.getPath()); + } + + @Override + public MediaSize getFastImageSize(Uri contentUri, MediaSize size) { + if (isVideo(contentUri)) { + return null; + } + return MediaSize.TemporaryThumbnail; + } + + @Override + public byte[] getTemporaryImage(Uri contentUri, MediaSize fastImageSize) { + + try { + ExifInterface exif = new ExifInterface(contentUri.getPath()); + if (exif.hasThumbnail()) { + return exif.getThumbnail(); + } + } catch (IOException e) { + Log.w(TAG, "Unable to load exif for " + contentUri); + } + return null; + } + + @Override + public boolean getMedia(Uri contentUri, MediaSize imageSize, File tempFile) { + if (imageSize == MediaSize.Original) { + return false; // getLocalFile should always return the original. + } + if (imageSize == MediaSize.Thumbnail) { + File preview = MediaCache.getInstance().getCachedFile(contentUri, MediaSize.Preview); + if (preview != null) { + // Just downsample the preview, it is faster. + return MediaCacheUtils.downsample(preview, imageSize, tempFile); + } + } + File highRes = new File(contentUri.getPath()); + boolean success; + if (!isVideo(contentUri)) { + success = MediaCacheUtils.downsample(highRes, imageSize, tempFile); + } else { + // Video needs to extract the bitmap. + Bitmap bitmap = BitmapUtils.createVideoThumbnail(highRes.getPath()); + if (bitmap == null) { + return false; + } else if (imageSize == MediaSize.Thumbnail + && !MediaCacheUtils.needsDownsample(bitmap, MediaSize.Preview) + && MediaCacheUtils.writeToFile(bitmap, tempFile)) { + // Opportunistically save preview + MediaCache mediaCache = MediaCache.getInstance(); + mediaCache.insertIntoCache(contentUri, MediaSize.Preview, tempFile); + } + // Now scale the image + success = MediaCacheUtils.downsample(bitmap, imageSize, tempFile); + } + return success; + } + + @Override + public Uri normalizeUri(Uri contentUri, MediaSize size) { + return contentUri; + } + + @Override + public MediaSize normalizeMediaSize(Uri contentUri, MediaSize size) { + return size; + } + + private static boolean isVideo(Uri uri) { + MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton(); + String extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString()); + String mimeType = mimeTypeMap.getMimeTypeFromExtension(extension); + return (mimeType != null && mimeType.startsWith("video/")); + } +} diff --git a/src/com/android/photos/data/GalleryBitmapPool.java b/src/com/android/photos/data/GalleryBitmapPool.java new file mode 100644 index 000000000..390a0d42f --- /dev/null +++ b/src/com/android/photos/data/GalleryBitmapPool.java @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos.data; + +import android.graphics.Bitmap; +import android.graphics.Point; +import android.util.Pools.Pool; +import android.util.Pools.SynchronizedPool; + +import com.android.photos.data.SparseArrayBitmapPool.Node; + +/** + * Pool allowing the efficient reuse of bitmaps in order to avoid long + * garbage collection pauses. + */ +public class GalleryBitmapPool { + + private static final int CAPACITY_BYTES = 20971520; + + // We found that Gallery uses bitmaps that are either square (for example, + // tiles of large images or square thumbnails), match one of the common + // photo aspect ratios (4x3, 3x2, or 16x9), or, less commonly, are of some + // other aspect ratio. Taking advantage of this information, we use 3 + // SparseArrayBitmapPool instances to back the GalleryBitmapPool, which affords + // O(1) lookups for square bitmaps, and average-case - but *not* asymptotically - + // O(1) lookups for common photo aspect ratios and other miscellaneous aspect + // ratios. Beware of the pathological case where there are many bitmaps added + // to the pool with different non-square aspect ratios but the same width, as + // performance will degrade and the average case lookup will approach + // O(# of different aspect ratios). + private static final int POOL_INDEX_NONE = -1; + private static final int POOL_INDEX_SQUARE = 0; + private static final int POOL_INDEX_PHOTO = 1; + private static final int POOL_INDEX_MISC = 2; + + private static final Point[] COMMON_PHOTO_ASPECT_RATIOS = + { new Point(4, 3), new Point(3, 2), new Point(16, 9) }; + + private int mCapacityBytes; + private SparseArrayBitmapPool [] mPools; + private Pool<Node> mSharedNodePool = new SynchronizedPool<Node>(128); + + private GalleryBitmapPool(int capacityBytes) { + mPools = new SparseArrayBitmapPool[3]; + mPools[POOL_INDEX_SQUARE] = new SparseArrayBitmapPool(capacityBytes / 3, mSharedNodePool); + mPools[POOL_INDEX_PHOTO] = new SparseArrayBitmapPool(capacityBytes / 3, mSharedNodePool); + mPools[POOL_INDEX_MISC] = new SparseArrayBitmapPool(capacityBytes / 3, mSharedNodePool); + mCapacityBytes = capacityBytes; + } + + private static GalleryBitmapPool sInstance = new GalleryBitmapPool(CAPACITY_BYTES); + + public static GalleryBitmapPool getInstance() { + return sInstance; + } + + private SparseArrayBitmapPool getPoolForDimensions(int width, int height) { + int index = getPoolIndexForDimensions(width, height); + if (index == POOL_INDEX_NONE) { + return null; + } else { + return mPools[index]; + } + } + + private int getPoolIndexForDimensions(int width, int height) { + if (width <= 0 || height <= 0) { + return POOL_INDEX_NONE; + } + if (width == height) { + return POOL_INDEX_SQUARE; + } + int min, max; + if (width > height) { + min = height; + max = width; + } else { + min = width; + max = height; + } + for (Point ar : COMMON_PHOTO_ASPECT_RATIOS) { + if (min * ar.x == max * ar.y) { + return POOL_INDEX_PHOTO; + } + } + return POOL_INDEX_MISC; + } + + /** + * @return Capacity of the pool in bytes. + */ + public synchronized int getCapacity() { + return mCapacityBytes; + } + + /** + * @return Approximate total size in bytes of the bitmaps stored in the pool. + */ + public int getSize() { + // Note that this only returns an approximate size, since multiple threads + // might be getting and putting Bitmaps from the pool and we lock at the + // sub-pool level to avoid unnecessary blocking. + int total = 0; + for (SparseArrayBitmapPool p : mPools) { + total += p.getSize(); + } + return total; + } + + /** + * @return Bitmap from the pool with the desired height/width or null if none available. + */ + public Bitmap get(int width, int height) { + SparseArrayBitmapPool pool = getPoolForDimensions(width, height); + if (pool == null) { + return null; + } else { + return pool.get(width, height); + } + } + + /** + * Adds the given bitmap to the pool. + * @return Whether the bitmap was added to the pool. + */ + public boolean put(Bitmap b) { + if (b == null || b.getConfig() != Bitmap.Config.ARGB_8888) { + return false; + } + SparseArrayBitmapPool pool = getPoolForDimensions(b.getWidth(), b.getHeight()); + if (pool == null) { + b.recycle(); + return false; + } else { + return pool.put(b); + } + } + + /** + * Empty the pool, recycling all the bitmaps currently in it. + */ + public void clear() { + for (SparseArrayBitmapPool p : mPools) { + p.clear(); + } + } +} diff --git a/src/com/android/photos/data/MediaCache.java b/src/com/android/photos/data/MediaCache.java new file mode 100644 index 000000000..0952a4017 --- /dev/null +++ b/src/com/android/photos/data/MediaCache.java @@ -0,0 +1,676 @@ +/* + * Copyright (C) 2013 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. + */ +package com.android.photos.data; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.os.Environment; +import android.util.Log; + +import com.android.photos.data.MediaCacheDatabase.Action; +import com.android.photos.data.MediaRetriever.MediaSize; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Queue; + +/** + * MediaCache keeps a cache of images, videos, thumbnails and previews. Calls to + * retrieve a specific media item are executed asynchronously. The caller has an + * option to receive a notification for lower resolution images that happen to + * be available prior to the one requested. + * <p> + * When an media item has been retrieved, the notification for it is called on a + * separate notifier thread. This thread should not be held for a long time so + * that other notifications may happen. + * </p> + * <p> + * Media items are uniquely identified by their content URIs. Each + * scheme/authority can offer its own MediaRetriever, running in its own thread. + * </p> + * <p> + * The MediaCache is an LRU cache, but does not allow the thumbnail cache to + * drop below a minimum size. This prevents browsing through original images to + * wipe out the thumbnails. + * </p> + */ +public class MediaCache { + static final String TAG = MediaCache.class.getSimpleName(); + /** Subdirectory containing the image cache. */ + static final String IMAGE_CACHE_SUBDIR = "image_cache"; + /** File name extension to use for cached images. */ + static final String IMAGE_EXTENSION = ".cache"; + /** File name extension to use for temporary cached images while retrieving. */ + static final String TEMP_IMAGE_EXTENSION = ".temp"; + + public static interface ImageReady { + void imageReady(InputStream bitmapInputStream); + } + + public static interface OriginalReady { + void originalReady(File originalFile); + } + + /** A Thread for each MediaRetriever */ + private class ProcessQueue extends Thread { + private Queue<ProcessingJob> mQueue; + + public ProcessQueue(Queue<ProcessingJob> queue) { + mQueue = queue; + } + + @Override + public void run() { + while (mRunning) { + ProcessingJob status; + synchronized (mQueue) { + while (mQueue.isEmpty()) { + try { + mQueue.wait(); + } catch (InterruptedException e) { + if (!mRunning) { + return; + } + Log.w(TAG, "Unexpected interruption", e); + } + } + status = mQueue.remove(); + } + processTask(status); + } + } + }; + + private interface NotifyReady { + void notifyReady(); + + void setFile(File file) throws FileNotFoundException; + + boolean isPrefetch(); + } + + private static class NotifyOriginalReady implements NotifyReady { + private final OriginalReady mCallback; + private File mFile; + + public NotifyOriginalReady(OriginalReady callback) { + mCallback = callback; + } + + @Override + public void notifyReady() { + if (mCallback != null) { + mCallback.originalReady(mFile); + } + } + + @Override + public void setFile(File file) { + mFile = file; + } + + @Override + public boolean isPrefetch() { + return mCallback == null; + } + } + + private static class NotifyImageReady implements NotifyReady { + private final ImageReady mCallback; + private InputStream mInputStream; + + public NotifyImageReady(ImageReady callback) { + mCallback = callback; + } + + @Override + public void notifyReady() { + if (mCallback != null) { + mCallback.imageReady(mInputStream); + } + } + + @Override + public void setFile(File file) throws FileNotFoundException { + mInputStream = new FileInputStream(file); + } + + public void setBytes(byte[] bytes) { + mInputStream = new ByteArrayInputStream(bytes); + } + + @Override + public boolean isPrefetch() { + return mCallback == null; + } + } + + /** A media item to be retrieved and its notifications. */ + private static class ProcessingJob { + public ProcessingJob(Uri uri, MediaSize size, NotifyReady complete, + NotifyImageReady lowResolution) { + this.contentUri = uri; + this.size = size; + this.complete = complete; + this.lowResolution = lowResolution; + } + public Uri contentUri; + public MediaSize size; + public NotifyImageReady lowResolution; + public NotifyReady complete; + } + + private boolean mRunning = true; + private static MediaCache sInstance; + private File mCacheDir; + private Context mContext; + private Queue<NotifyReady> mCallbacks = new LinkedList<NotifyReady>(); + private Map<String, MediaRetriever> mRetrievers = new HashMap<String, MediaRetriever>(); + private Map<String, List<ProcessingJob>> mTasks = new HashMap<String, List<ProcessingJob>>(); + private List<ProcessQueue> mProcessingThreads = new ArrayList<ProcessQueue>(); + private MediaCacheDatabase mDatabaseHelper; + private long mTempImageNumber = 1; + private Object mTempImageNumberLock = new Object(); + + private long mMaxCacheSize = 40 * 1024 * 1024; // 40 MB + private long mMinThumbCacheSize = 4 * 1024 * 1024; // 4 MB + private long mCacheSize = -1; + private long mThumbCacheSize = -1; + private Object mCacheSizeLock = new Object(); + + private Action mNotifyCachedLowResolution = new Action() { + @Override + public void execute(Uri uri, long id, MediaSize size, Object parameter) { + ProcessingJob job = (ProcessingJob) parameter; + File file = createCacheImagePath(id); + addNotification(job.lowResolution, file); + } + }; + + private Action mMoveTempToCache = new Action() { + @Override + public void execute(Uri uri, long id, MediaSize size, Object parameter) { + File tempFile = (File) parameter; + File cacheFile = createCacheImagePath(id); + tempFile.renameTo(cacheFile); + } + }; + + private Action mDeleteFile = new Action() { + @Override + public void execute(Uri uri, long id, MediaSize size, Object parameter) { + File file = createCacheImagePath(id); + file.delete(); + synchronized (mCacheSizeLock) { + if (mCacheSize != -1) { + long length = (Long) parameter; + mCacheSize -= length; + if (size == MediaSize.Thumbnail) { + mThumbCacheSize -= length; + } + } + } + } + }; + + /** The thread used to make ImageReady and OriginalReady callbacks. */ + private Thread mProcessNotifications = new Thread() { + @Override + public void run() { + while (mRunning) { + NotifyReady notifyImage; + synchronized (mCallbacks) { + while (mCallbacks.isEmpty()) { + try { + mCallbacks.wait(); + } catch (InterruptedException e) { + if (!mRunning) { + return; + } + Log.w(TAG, "Unexpected Interruption, continuing"); + } + } + notifyImage = mCallbacks.remove(); + } + + notifyImage.notifyReady(); + } + } + }; + + public static synchronized void initialize(Context context) { + if (sInstance == null) { + sInstance = new MediaCache(context); + MediaCacheUtils.initialize(context); + } + } + + public static MediaCache getInstance() { + return sInstance; + } + + public static synchronized void shutdown() { + sInstance.mRunning = false; + sInstance.mProcessNotifications.interrupt(); + for (ProcessQueue processingThread : sInstance.mProcessingThreads) { + processingThread.interrupt(); + } + sInstance = null; + } + + private MediaCache(Context context) { + mDatabaseHelper = new MediaCacheDatabase(context); + mProcessNotifications.start(); + mContext = context; + } + + // This is used for testing. + public void setCacheDir(File cacheDir) { + cacheDir.mkdirs(); + mCacheDir = cacheDir; + } + + public File getCacheDir() { + synchronized (mContext) { + if (mCacheDir == null) { + String state = Environment.getExternalStorageState(); + File baseDir; + if (Environment.MEDIA_MOUNTED.equals(state)) { + baseDir = mContext.getExternalCacheDir(); + } else { + // Stored in internal cache + baseDir = mContext.getCacheDir(); + } + mCacheDir = new File(baseDir, IMAGE_CACHE_SUBDIR); + mCacheDir.mkdirs(); + } + return mCacheDir; + } + } + + /** + * Invalidates all cached images related to a given contentUri. This call + * doesn't complete until the images have been removed from the cache. + */ + public void invalidate(Uri contentUri) { + mDatabaseHelper.delete(contentUri, mDeleteFile); + } + + public void clearCacheDir() { + File[] cachedFiles = getCacheDir().listFiles(); + if (cachedFiles != null) { + for (File cachedFile : cachedFiles) { + cachedFile.delete(); + } + } + } + + /** + * Add a MediaRetriever for a Uri scheme and authority. This MediaRetriever + * will be granted its own thread for retrieving images. + */ + public void addRetriever(String scheme, String authority, MediaRetriever retriever) { + String differentiator = getDifferentiator(scheme, authority); + synchronized (mRetrievers) { + mRetrievers.put(differentiator, retriever); + } + synchronized (mTasks) { + LinkedList<ProcessingJob> queue = new LinkedList<ProcessingJob>(); + mTasks.put(differentiator, queue); + new ProcessQueue(queue).start(); + } + } + + /** + * Retrieves a thumbnail. complete will be called when the thumbnail is + * available. If lowResolution is not null and a lower resolution thumbnail + * is available before the thumbnail, lowResolution will be called prior to + * complete. All callbacks will be made on a thread other than the calling + * thread. + * + * @param contentUri The URI for the full resolution image to search for. + * @param complete Callback for when the image has been retrieved. + * @param lowResolution If not null and a lower resolution image is + * available prior to retrieving the thumbnail, this will be + * called with the low resolution bitmap. + */ + public void retrieveThumbnail(Uri contentUri, ImageReady complete, ImageReady lowResolution) { + addTask(contentUri, complete, lowResolution, MediaSize.Thumbnail); + } + + /** + * Retrieves a preview. complete will be called when the preview is + * available. If lowResolution is not null and a lower resolution preview is + * available before the preview, lowResolution will be called prior to + * complete. All callbacks will be made on a thread other than the calling + * thread. + * + * @param contentUri The URI for the full resolution image to search for. + * @param complete Callback for when the image has been retrieved. + * @param lowResolution If not null and a lower resolution image is + * available prior to retrieving the preview, this will be called + * with the low resolution bitmap. + */ + public void retrievePreview(Uri contentUri, ImageReady complete, ImageReady lowResolution) { + addTask(contentUri, complete, lowResolution, MediaSize.Preview); + } + + /** + * Retrieves the original image or video. complete will be called when the + * media is available on the local file system. If lowResolution is not null + * and a lower resolution preview is available before the original, + * lowResolution will be called prior to complete. All callbacks will be + * made on a thread other than the calling thread. + * + * @param contentUri The URI for the full resolution image to search for. + * @param complete Callback for when the image has been retrieved. + * @param lowResolution If not null and a lower resolution image is + * available prior to retrieving the preview, this will be called + * with the low resolution bitmap. + */ + public void retrieveOriginal(Uri contentUri, OriginalReady complete, ImageReady lowResolution) { + File localFile = getLocalFile(contentUri); + if (localFile != null) { + addNotification(new NotifyOriginalReady(complete), localFile); + } else { + NotifyImageReady notifyLowResolution = (lowResolution == null) ? null + : new NotifyImageReady(lowResolution); + addTask(contentUri, new NotifyOriginalReady(complete), notifyLowResolution, + MediaSize.Original); + } + } + + /** + * Looks for an already cached media at a specific size. + * + * @param contentUri The original media item content URI + * @param size The target size to search for in the cache + * @return The cached file location or null if it is not cached. + */ + public File getCachedFile(Uri contentUri, MediaSize size) { + Long cachedId = mDatabaseHelper.getCached(contentUri, size); + File file = null; + if (cachedId != null) { + file = createCacheImagePath(cachedId); + if (!file.exists()) { + mDatabaseHelper.delete(contentUri, size, mDeleteFile); + file = null; + } + } + return file; + } + + /** + * Inserts a media item into the cache. + * + * @param contentUri The original media item URI. + * @param size The size of the media item to store in the cache. + * @param tempFile The temporary file where the image is stored. This file + * will no longer exist after executing this method. + * @return The new location, in the cache, of the media item or null if it + * wasn't possible to move into the cache. + */ + public File insertIntoCache(Uri contentUri, MediaSize size, File tempFile) { + long fileSize = tempFile.length(); + if (fileSize == 0) { + return null; + } + File cacheFile = null; + SQLiteDatabase db = mDatabaseHelper.getWritableDatabase(); + // Ensure that this step is atomic + db.beginTransaction(); + try { + Long id = mDatabaseHelper.getCached(contentUri, size); + if (id != null) { + cacheFile = createCacheImagePath(id); + if (tempFile.renameTo(cacheFile)) { + mDatabaseHelper.updateLength(id, fileSize); + } else { + Log.w(TAG, "Could not update cached file with " + tempFile); + tempFile.delete(); + cacheFile = null; + } + } else { + ensureFreeCacheSpace(tempFile.length(), size); + id = mDatabaseHelper.insert(contentUri, size, mMoveTempToCache, tempFile); + cacheFile = createCacheImagePath(id); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + return cacheFile; + } + + /** + * For testing purposes. + */ + public void setMaxCacheSize(long maxCacheSize) { + synchronized (mCacheSizeLock) { + mMaxCacheSize = maxCacheSize; + mMinThumbCacheSize = mMaxCacheSize / 10; + mCacheSize = -1; + mThumbCacheSize = -1; + } + } + + private File createCacheImagePath(long id) { + return new File(getCacheDir(), String.valueOf(id) + IMAGE_EXTENSION); + } + + private void addTask(Uri contentUri, ImageReady complete, ImageReady lowResolution, + MediaSize size) { + NotifyReady notifyComplete = new NotifyImageReady(complete); + NotifyImageReady notifyLowResolution = null; + if (lowResolution != null) { + notifyLowResolution = new NotifyImageReady(lowResolution); + } + addTask(contentUri, notifyComplete, notifyLowResolution, size); + } + + private void addTask(Uri contentUri, NotifyReady complete, NotifyImageReady lowResolution, + MediaSize size) { + MediaRetriever retriever = getMediaRetriever(contentUri); + Uri uri = retriever.normalizeUri(contentUri, size); + if (uri == null) { + throw new IllegalArgumentException("No MediaRetriever for " + contentUri); + } + size = retriever.normalizeMediaSize(uri, size); + + File cachedFile = getCachedFile(uri, size); + if (cachedFile != null) { + addNotification(complete, cachedFile); + return; + } + String differentiator = getDifferentiator(uri.getScheme(), uri.getAuthority()); + synchronized (mTasks) { + List<ProcessingJob> tasks = mTasks.get(differentiator); + if (tasks == null) { + throw new IllegalArgumentException("Cannot find retriever for: " + uri); + } + synchronized (tasks) { + ProcessingJob job = new ProcessingJob(uri, size, complete, lowResolution); + if (complete.isPrefetch()) { + tasks.add(job); + } else { + int index = tasks.size() - 1; + while (index >= 0 && tasks.get(index).complete.isPrefetch()) { + index--; + } + tasks.add(index + 1, job); + } + tasks.notifyAll(); + } + } + } + + private MediaRetriever getMediaRetriever(Uri uri) { + String differentiator = getDifferentiator(uri.getScheme(), uri.getAuthority()); + MediaRetriever retriever; + synchronized (mRetrievers) { + retriever = mRetrievers.get(differentiator); + } + if (retriever == null) { + throw new IllegalArgumentException("No MediaRetriever for " + uri); + } + return retriever; + } + + private File getLocalFile(Uri uri) { + MediaRetriever retriever = getMediaRetriever(uri); + File localFile = null; + if (retriever != null) { + localFile = retriever.getLocalFile(uri); + } + return localFile; + } + + private MediaSize getFastImageSize(Uri uri, MediaSize size) { + MediaRetriever retriever = getMediaRetriever(uri); + return retriever.getFastImageSize(uri, size); + } + + private boolean isFastImageBetter(MediaSize fastImageType, MediaSize size) { + if (fastImageType == null) { + return false; + } + if (size == null) { + return true; + } + return fastImageType.isBetterThan(size); + } + + private byte[] getTemporaryImage(Uri uri, MediaSize fastImageType) { + MediaRetriever retriever = getMediaRetriever(uri); + return retriever.getTemporaryImage(uri, fastImageType); + } + + private void processTask(ProcessingJob job) { + File cachedFile = getCachedFile(job.contentUri, job.size); + if (cachedFile != null) { + addNotification(job.complete, cachedFile); + return; + } + + boolean hasLowResolution = job.lowResolution != null; + if (hasLowResolution) { + MediaSize cachedSize = mDatabaseHelper.executeOnBestCached(job.contentUri, job.size, + mNotifyCachedLowResolution); + MediaSize fastImageSize = getFastImageSize(job.contentUri, job.size); + if (isFastImageBetter(fastImageSize, cachedSize)) { + if (fastImageSize.isTemporary()) { + byte[] bytes = getTemporaryImage(job.contentUri, fastImageSize); + if (bytes != null) { + addNotification(job.lowResolution, bytes); + } + } else { + File lowFile = getMedia(job.contentUri, fastImageSize); + if (lowFile != null) { + addNotification(job.lowResolution, lowFile); + } + } + } + } + + // Now get the full size desired + File fullSizeFile = getMedia(job.contentUri, job.size); + if (fullSizeFile != null) { + addNotification(job.complete, fullSizeFile); + } + } + + private void addNotification(NotifyReady callback, File file) { + try { + callback.setFile(file); + synchronized (mCallbacks) { + mCallbacks.add(callback); + mCallbacks.notifyAll(); + } + } catch (FileNotFoundException e) { + Log.e(TAG, "Unable to read file " + file, e); + } + } + + private void addNotification(NotifyImageReady callback, byte[] bytes) { + callback.setBytes(bytes); + synchronized (mCallbacks) { + mCallbacks.add(callback); + mCallbacks.notifyAll(); + } + } + + private File getMedia(Uri uri, MediaSize size) { + long imageNumber; + synchronized (mTempImageNumberLock) { + imageNumber = mTempImageNumber++; + } + File tempFile = new File(getCacheDir(), String.valueOf(imageNumber) + TEMP_IMAGE_EXTENSION); + MediaRetriever retriever = getMediaRetriever(uri); + boolean retrieved = retriever.getMedia(uri, size, tempFile); + File cachedFile = null; + if (retrieved) { + ensureFreeCacheSpace(tempFile.length(), size); + long id = mDatabaseHelper.insert(uri, size, mMoveTempToCache, tempFile); + cachedFile = createCacheImagePath(id); + } + return cachedFile; + } + + private static String getDifferentiator(String scheme, String authority) { + if (authority == null) { + return scheme; + } + StringBuilder differentiator = new StringBuilder(scheme); + differentiator.append(':'); + differentiator.append(authority); + return differentiator.toString(); + } + + private void ensureFreeCacheSpace(long size, MediaSize mediaSize) { + synchronized (mCacheSizeLock) { + if (mCacheSize == -1 || mThumbCacheSize == -1) { + mCacheSize = mDatabaseHelper.getCacheSize(); + mThumbCacheSize = mDatabaseHelper.getThumbnailCacheSize(); + if (mCacheSize == -1 || mThumbCacheSize == -1) { + Log.e(TAG, "Can't determine size of the image cache"); + return; + } + } + mCacheSize += size; + if (mediaSize == MediaSize.Thumbnail) { + mThumbCacheSize += size; + } + if (mCacheSize > mMaxCacheSize) { + shrinkCacheLocked(); + } + } + } + + private void shrinkCacheLocked() { + long deleteSize = mMinThumbCacheSize; + boolean includeThumbnails = (mThumbCacheSize - deleteSize) > mMinThumbCacheSize; + mDatabaseHelper.deleteOldCached(includeThumbnails, deleteSize, mDeleteFile); + } +} diff --git a/src/com/android/photos/data/MediaCacheDatabase.java b/src/com/android/photos/data/MediaCacheDatabase.java new file mode 100644 index 000000000..c92ac0fdf --- /dev/null +++ b/src/com/android/photos/data/MediaCacheDatabase.java @@ -0,0 +1,286 @@ +/* + * Copyright (C) 2013 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. + */ +package com.android.photos.data; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; +import android.provider.BaseColumns; + +import com.android.photos.data.MediaRetriever.MediaSize; + +import java.io.File; + +class MediaCacheDatabase extends SQLiteOpenHelper { + public static final int DB_VERSION = 1; + public static final String DB_NAME = "mediacache.db"; + + /** Internal database table used for the media cache */ + public static final String TABLE = "media_cache"; + + private static interface Columns extends BaseColumns { + /** The Content URI of the original image. */ + public static final String URI = "uri"; + /** MediaSize.getValue() values. */ + public static final String MEDIA_SIZE = "media_size"; + /** The last time this image was queried. */ + public static final String LAST_ACCESS = "last_access"; + /** The image size in bytes. */ + public static final String SIZE_IN_BYTES = "size"; + } + + static interface Action { + void execute(Uri uri, long id, MediaSize size, Object parameter); + } + + private static final String[] PROJECTION_ID = { + Columns._ID, + }; + + private static final String[] PROJECTION_CACHED = { + Columns._ID, Columns.MEDIA_SIZE, Columns.SIZE_IN_BYTES, + }; + + private static final String[] PROJECTION_CACHE_SIZE = { + "SUM(" + Columns.SIZE_IN_BYTES + ")" + }; + + private static final String[] PROJECTION_DELETE_OLD = { + Columns._ID, Columns.URI, Columns.MEDIA_SIZE, Columns.SIZE_IN_BYTES, Columns.LAST_ACCESS, + }; + + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE + "(" + + Columns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + Columns.URI + " TEXT NOT NULL," + + Columns.MEDIA_SIZE + " INTEGER NOT NULL," + + Columns.LAST_ACCESS + " INTEGER NOT NULL," + + Columns.SIZE_IN_BYTES + " INTEGER NOT NULL," + + "UNIQUE(" + Columns.URI + ", " + Columns.MEDIA_SIZE + "))"; + + public static final String DROP_TABLE = "DROP TABLE IF EXISTS " + TABLE; + + public static final String WHERE_THUMBNAIL = Columns.MEDIA_SIZE + " = " + + MediaSize.Thumbnail.getValue(); + + public static final String WHERE_NOT_THUMBNAIL = Columns.MEDIA_SIZE + " <> " + + MediaSize.Thumbnail.getValue(); + + public static final String WHERE_CLEAR_CACHE = Columns.LAST_ACCESS + " <= ?"; + + public static final String WHERE_CLEAR_CACHE_LARGE = WHERE_CLEAR_CACHE + " AND " + + WHERE_NOT_THUMBNAIL; + + static class QueryCacheResults { + public QueryCacheResults(long id, int sizeVal) { + this.id = id; + this.size = MediaSize.fromInteger(sizeVal); + } + public long id; + public MediaSize size; + } + + public MediaCacheDatabase(Context context) { + super(context, DB_NAME, null, DB_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL(CREATE_TABLE); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + db.execSQL(DROP_TABLE); + onCreate(db); + MediaCache.getInstance().clearCacheDir(); + } + + public Long getCached(Uri uri, MediaSize size) { + String where = Columns.URI + " = ? AND " + Columns.MEDIA_SIZE + " = ?"; + SQLiteDatabase db = getWritableDatabase(); + String[] whereArgs = { + uri.toString(), String.valueOf(size.getValue()), + }; + Cursor cursor = db.query(TABLE, PROJECTION_ID, where, whereArgs, null, null, null); + Long id = null; + if (cursor.moveToNext()) { + id = cursor.getLong(0); + } + cursor.close(); + if (id != null) { + String[] updateArgs = { + id.toString() + }; + ContentValues values = new ContentValues(); + values.put(Columns.LAST_ACCESS, System.currentTimeMillis()); + db.beginTransaction(); + try { + db.update(TABLE, values, Columns._ID + " = ?", updateArgs); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + return id; + } + + public MediaSize executeOnBestCached(Uri uri, MediaSize size, Action action) { + String where = Columns.URI + " = ? AND " + Columns.MEDIA_SIZE + " < ?"; + String orderBy = Columns.MEDIA_SIZE + " DESC"; + SQLiteDatabase db = getReadableDatabase(); + String[] whereArgs = { + uri.toString(), String.valueOf(size.getValue()), + }; + Cursor cursor = db.query(TABLE, PROJECTION_CACHED, where, whereArgs, null, null, orderBy); + MediaSize bestSize = null; + if (cursor.moveToNext()) { + long id = cursor.getLong(0); + bestSize = MediaSize.fromInteger(cursor.getInt(1)); + long fileSize = cursor.getLong(2); + action.execute(uri, id, bestSize, fileSize); + } + cursor.close(); + return bestSize; + } + + public long insert(Uri uri, MediaSize size, Action action, File tempFile) { + SQLiteDatabase db = getWritableDatabase(); + db.beginTransaction(); + try { + ContentValues values = new ContentValues(); + values.put(Columns.LAST_ACCESS, System.currentTimeMillis()); + values.put(Columns.MEDIA_SIZE, size.getValue()); + values.put(Columns.URI, uri.toString()); + values.put(Columns.SIZE_IN_BYTES, tempFile.length()); + long id = db.insert(TABLE, null, values); + if (id != -1) { + action.execute(uri, id, size, tempFile); + db.setTransactionSuccessful(); + } + return id; + } finally { + db.endTransaction(); + } + } + + public void updateLength(long id, long fileSize) { + ContentValues values = new ContentValues(); + values.put(Columns.SIZE_IN_BYTES, fileSize); + String[] whereArgs = { + String.valueOf(id) + }; + SQLiteDatabase db = getWritableDatabase(); + db.beginTransaction(); + try { + db.update(TABLE, values, Columns._ID + " = ?", whereArgs); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + public void delete(Uri uri, MediaSize size, Action action) { + String where = Columns.URI + " = ? AND " + Columns.MEDIA_SIZE + " = ?"; + String[] whereArgs = { + uri.toString(), String.valueOf(size.getValue()), + }; + deleteRows(uri, where, whereArgs, action); + } + + public void delete(Uri uri, Action action) { + String where = Columns.URI + " = ?"; + String[] whereArgs = { + uri.toString() + }; + deleteRows(uri, where, whereArgs, action); + } + + private void deleteRows(Uri uri, String where, String[] whereArgs, Action action) { + SQLiteDatabase db = getWritableDatabase(); + // Make this an atomic operation + db.beginTransaction(); + Cursor cursor = db.query(TABLE, PROJECTION_CACHED, where, whereArgs, null, null, null); + while (cursor.moveToNext()) { + long id = cursor.getLong(0); + MediaSize size = MediaSize.fromInteger(cursor.getInt(1)); + long length = cursor.getLong(2); + action.execute(uri, id, size, length); + } + cursor.close(); + try { + db.delete(TABLE, where, whereArgs); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + public void deleteOldCached(boolean includeThumbnails, long deleteSize, Action action) { + String where = includeThumbnails ? null : WHERE_NOT_THUMBNAIL; + long lastAccess = 0; + SQLiteDatabase db = getWritableDatabase(); + db.beginTransaction(); + try { + Cursor cursor = db.query(TABLE, PROJECTION_DELETE_OLD, where, null, null, null, + Columns.LAST_ACCESS); + while (cursor.moveToNext()) { + long id = cursor.getLong(0); + String uri = cursor.getString(1); + MediaSize size = MediaSize.fromInteger(cursor.getInt(2)); + long length = cursor.getLong(3); + long imageLastAccess = cursor.getLong(4); + + if (imageLastAccess != lastAccess && deleteSize < 0) { + break; // We've deleted enough. + } + lastAccess = imageLastAccess; + action.execute(Uri.parse(uri), id, size, length); + deleteSize -= length; + } + cursor.close(); + String[] whereArgs = { + String.valueOf(lastAccess), + }; + String whereDelete = includeThumbnails ? WHERE_CLEAR_CACHE : WHERE_CLEAR_CACHE_LARGE; + db.delete(TABLE, whereDelete, whereArgs); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + public long getCacheSize() { + return getCacheSize(null); + } + + public long getThumbnailCacheSize() { + return getCacheSize(WHERE_THUMBNAIL); + } + + private long getCacheSize(String where) { + SQLiteDatabase db = getReadableDatabase(); + Cursor cursor = db.query(TABLE, PROJECTION_CACHE_SIZE, where, null, null, null, null); + long size = -1; + if (cursor.moveToNext()) { + size = cursor.getLong(0); + } + cursor.close(); + return size; + } +} diff --git a/src/com/android/photos/data/MediaCacheUtils.java b/src/com/android/photos/data/MediaCacheUtils.java new file mode 100644 index 000000000..e3ccd1402 --- /dev/null +++ b/src/com/android/photos/data/MediaCacheUtils.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2013 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. + */ +package com.android.photos.data; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Bitmap.CompressFormat; +import android.graphics.BitmapFactory; +import android.util.Log; +import android.util.Pools.SimplePool; +import android.util.Pools.SynchronizedPool; + +import com.android.gallery3d.R; +import com.android.gallery3d.common.BitmapUtils; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.data.DecodeUtils; +import com.android.gallery3d.data.MediaItem; +import com.android.gallery3d.util.ThreadPool.CancelListener; +import com.android.gallery3d.util.ThreadPool.JobContext; +import com.android.photos.data.MediaRetriever.MediaSize; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class MediaCacheUtils { + private static final String TAG = MediaCacheUtils.class.getSimpleName(); + private static int QUALITY = 80; + private static final int BUFFER_SIZE = 4096; + private static final SimplePool<byte[]> mBufferPool = new SynchronizedPool<byte[]>(5); + + private static final JobContext sJobStub = new JobContext() { + + @Override + public boolean isCancelled() { + return false; + } + + @Override + public void setCancelListener(CancelListener listener) { + } + + @Override + public boolean setMode(int mode) { + return true; + } + }; + + private static int mTargetThumbnailSize; + private static int mTargetPreviewSize; + + public static void initialize(Context context) { + Resources resources = context.getResources(); + mTargetThumbnailSize = resources.getDimensionPixelSize(R.dimen.size_thumbnail); + mTargetPreviewSize = resources.getDimensionPixelSize(R.dimen.size_preview); + } + + public static int getTargetSize(MediaSize size) { + return (size == MediaSize.Thumbnail) ? mTargetThumbnailSize : mTargetPreviewSize; + } + + public static boolean downsample(File inBitmap, MediaSize targetSize, File outBitmap) { + if (MediaSize.Original == targetSize) { + return false; // MediaCache should use the local path for this. + } + int size = getTargetSize(targetSize); + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inPreferredConfig = Bitmap.Config.ARGB_8888; + // TODO: remove unnecessary job context from DecodeUtils. + Bitmap bitmap = DecodeUtils.decodeThumbnail(sJobStub, inBitmap.getPath(), options, size, + MediaItem.TYPE_THUMBNAIL); + boolean success = (bitmap != null); + if (success) { + success = writeAndRecycle(bitmap, outBitmap); + } + return success; + } + + public static boolean downsample(Bitmap inBitmap, MediaSize size, File outBitmap) { + if (MediaSize.Original == size) { + return false; // MediaCache should use the local path for this. + } + int targetSize = getTargetSize(size); + boolean success; + if (!needsDownsample(inBitmap, size)) { + success = writeAndRecycle(inBitmap, outBitmap); + } else { + float maxDimension = Math.max(inBitmap.getWidth(), inBitmap.getHeight()); + float scale = targetSize / maxDimension; + int targetWidth = Math.round(scale * inBitmap.getWidth()); + int targetHeight = Math.round(scale * inBitmap.getHeight()); + Bitmap scaled = Bitmap.createScaledBitmap(inBitmap, targetWidth, targetHeight, false); + success = writeAndRecycle(scaled, outBitmap); + inBitmap.recycle(); + } + return success; + } + + public static boolean extractImageFromVideo(File inVideo, File outBitmap) { + Bitmap bitmap = BitmapUtils.createVideoThumbnail(inVideo.getPath()); + return writeAndRecycle(bitmap, outBitmap); + } + + public static boolean needsDownsample(Bitmap bitmap, MediaSize size) { + if (size == MediaSize.Original) { + return false; + } + int targetSize = getTargetSize(size); + int maxDimension = Math.max(bitmap.getWidth(), bitmap.getHeight()); + return maxDimension > (targetSize * 4 / 3); + } + + public static boolean writeAndRecycle(Bitmap bitmap, File outBitmap) { + boolean success = writeToFile(bitmap, outBitmap); + bitmap.recycle(); + return success; + } + + public static boolean writeToFile(Bitmap bitmap, File outBitmap) { + boolean success = false; + try { + FileOutputStream out = new FileOutputStream(outBitmap); + success = bitmap.compress(CompressFormat.JPEG, QUALITY, out); + out.close(); + } catch (IOException e) { + Log.w(TAG, "Couldn't write bitmap to cache", e); + // success is already false + } + return success; + } + + public static int copyStream(InputStream in, OutputStream out) throws IOException { + byte[] buffer = mBufferPool.acquire(); + if (buffer == null) { + buffer = new byte[BUFFER_SIZE]; + } + try { + int totalWritten = 0; + int bytesRead; + while ((bytesRead = in.read(buffer)) >= 0) { + out.write(buffer, 0, bytesRead); + totalWritten += bytesRead; + } + return totalWritten; + } finally { + Utils.closeSilently(in); + Utils.closeSilently(out); + mBufferPool.release(buffer); + } + } +} diff --git a/src/com/android/photos/data/MediaRetriever.java b/src/com/android/photos/data/MediaRetriever.java new file mode 100644 index 000000000..f383e5ffa --- /dev/null +++ b/src/com/android/photos/data/MediaRetriever.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos.data; + +import android.net.Uri; + +import java.io.File; + +public interface MediaRetriever { + public enum MediaSize { + TemporaryThumbnail(5), Thumbnail(10), TemporaryPreview(15), Preview(20), Original(30); + + private final int mValue; + + private MediaSize(int value) { + mValue = value; + } + + public int getValue() { + return mValue; + } + + static MediaSize fromInteger(int value) { + switch (value) { + case 10: + return MediaSize.Thumbnail; + case 20: + return MediaSize.Preview; + case 30: + return MediaSize.Original; + default: + throw new IllegalArgumentException(); + } + } + + public boolean isBetterThan(MediaSize that) { + return mValue > that.mValue; + } + + public boolean isTemporary() { + return this == TemporaryThumbnail || this == TemporaryPreview; + } + } + + /** + * Returns the local File for the given Uri. If the image is not stored + * locally, null should be returned. The image should not be retrieved if it + * isn't already available. + * + * @param contentUri The media URI to search for. + * @return The local File of the image if it is available or null if it + * isn't. + */ + File getLocalFile(Uri contentUri); + + /** + * Returns the fast access image type for a given image size, if supported. + * This image should be smaller than size and should be quick to retrieve. + * It does not have to obey the expected aspect ratio. + * + * @param contentUri The original media Uri. + * @param size The target size to search for a fast-access image. + * @return The fast image type supported for the given image size or null of + * no fast image is supported. + */ + MediaSize getFastImageSize(Uri contentUri, MediaSize size); + + /** + * Returns a byte array containing the contents of the fast temporary image + * for a given image size. For example, a thumbnail may be smaller or of a + * different aspect ratio than the generated thumbnail. + * + * @param contentUri The original media Uri. + * @param temporarySize The target media size. Guaranteed to be a MediaSize + * for which isTemporary() returns true. + * @return A byte array of contents for for the given contentUri and + * fastImageType. null can be retrieved if the quick retrieval + * fails. + */ + byte[] getTemporaryImage(Uri contentUri, MediaSize temporarySize); + + /** + * Retrieves an image and saves it to a file. + * + * @param contentUri The original media Uri. + * @param size The target media size. + * @param tempFile The file to write the bitmap to. + * @return <code>true</code> on success. + */ + boolean getMedia(Uri contentUri, MediaSize imageSize, File tempFile); + + /** + * Normalizes a URI that may have additional parameters. It is fine to + * return contentUri. This is executed on the calling thread, so it must be + * a fast access operation and cannot depend, for example, on I/O. + * + * @param contentUri The URI to normalize + * @param size The size of the image being requested + * @return The normalized URI representation of contentUri. + */ + Uri normalizeUri(Uri contentUri, MediaSize size); + + /** + * Normalize the MediaSize for a given URI. Typically the size returned + * would be the passed-in size. Some URIs may only have one size used and + * should be treaded as Thumbnails, for example. This is executed on the + * calling thread, so it must be a fast access operation and cannot depend, + * for example, on I/O. + * + * @param contentUri The URI for the size being normalized. + * @param size The size to be normalized. + * @return The normalized size of the given URI. + */ + MediaSize normalizeMediaSize(Uri contentUri, MediaSize size); +} diff --git a/src/com/android/photos/data/NotificationWatcher.java b/src/com/android/photos/data/NotificationWatcher.java new file mode 100644 index 000000000..9041c236f --- /dev/null +++ b/src/com/android/photos/data/NotificationWatcher.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2013 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. + */ +package com.android.photos.data; + +import android.net.Uri; + +import com.android.photos.data.PhotoProvider.ChangeNotification; + +import java.util.ArrayList; + +/** + * Used for capturing notifications from PhotoProvider without relying on + * ContentResolver. MockContentResolver does not allow sending notification to + * ContentObservers, so PhotoProvider allows this alternative for testing. + */ +public class NotificationWatcher implements ChangeNotification { + private ArrayList<Uri> mUris = new ArrayList<Uri>(); + private boolean mSyncToNetwork = false; + + @Override + public void notifyChange(Uri uri, boolean syncToNetwork) { + mUris.add(uri); + mSyncToNetwork = mSyncToNetwork || syncToNetwork; + } + + public boolean isNotified(Uri uri) { + return mUris.contains(uri); + } + + public int notificationCount() { + return mUris.size(); + } + + public boolean syncToNetwork() { + return mSyncToNetwork; + } + + public void reset() { + mUris.clear(); + mSyncToNetwork = false; + } +} diff --git a/src/com/android/photos/data/PhotoDatabase.java b/src/com/android/photos/data/PhotoDatabase.java new file mode 100644 index 000000000..0c7b22730 --- /dev/null +++ b/src/com/android/photos/data/PhotoDatabase.java @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2013 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. + */ +package com.android.photos.data; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +import com.android.photos.data.PhotoProvider.Accounts; +import com.android.photos.data.PhotoProvider.Albums; +import com.android.photos.data.PhotoProvider.Metadata; +import com.android.photos.data.PhotoProvider.Photos; + +import java.util.ArrayList; +import java.util.List; + +/** + * Used in PhotoProvider to create and access the database containing + * information about photo and video information stored on the server. + */ +public class PhotoDatabase extends SQLiteOpenHelper { + @SuppressWarnings("unused") + private static final String TAG = PhotoDatabase.class.getSimpleName(); + static final int DB_VERSION = 3; + + private static final String SQL_CREATE_TABLE = "CREATE TABLE "; + + private static final String[][] CREATE_PHOTO = { + { Photos._ID, "INTEGER PRIMARY KEY AUTOINCREMENT" }, + // Photos.ACCOUNT_ID is a foreign key to Accounts._ID + { Photos.ACCOUNT_ID, "INTEGER NOT NULL" }, + { Photos.WIDTH, "INTEGER NOT NULL" }, + { Photos.HEIGHT, "INTEGER NOT NULL" }, + { Photos.DATE_TAKEN, "INTEGER NOT NULL" }, + // Photos.ALBUM_ID is a foreign key to Albums._ID + { Photos.ALBUM_ID, "INTEGER" }, + { Photos.MIME_TYPE, "TEXT NOT NULL" }, + { Photos.TITLE, "TEXT" }, + { Photos.DATE_MODIFIED, "INTEGER" }, + { Photos.ROTATION, "INTEGER" }, + }; + + private static final String[][] CREATE_ALBUM = { + { Albums._ID, "INTEGER PRIMARY KEY AUTOINCREMENT" }, + // Albums.ACCOUNT_ID is a foreign key to Accounts._ID + { Albums.ACCOUNT_ID, "INTEGER NOT NULL" }, + // Albums.PARENT_ID is a foreign key to Albums._ID + { Albums.PARENT_ID, "INTEGER" }, + { Albums.ALBUM_TYPE, "TEXT" }, + { Albums.VISIBILITY, "INTEGER NOT NULL" }, + { Albums.LOCATION_STRING, "TEXT" }, + { Albums.TITLE, "TEXT NOT NULL" }, + { Albums.SUMMARY, "TEXT" }, + { Albums.DATE_PUBLISHED, "INTEGER" }, + { Albums.DATE_MODIFIED, "INTEGER" }, + createUniqueConstraint(Albums.PARENT_ID, Albums.TITLE), + }; + + private static final String[][] CREATE_METADATA = { + { Metadata._ID, "INTEGER PRIMARY KEY AUTOINCREMENT" }, + // Metadata.PHOTO_ID is a foreign key to Photos._ID + { Metadata.PHOTO_ID, "INTEGER NOT NULL" }, + { Metadata.KEY, "TEXT NOT NULL" }, + { Metadata.VALUE, "TEXT NOT NULL" }, + createUniqueConstraint(Metadata.PHOTO_ID, Metadata.KEY), + }; + + private static final String[][] CREATE_ACCOUNT = { + { Accounts._ID, "INTEGER PRIMARY KEY AUTOINCREMENT" }, + { Accounts.ACCOUNT_NAME, "TEXT UNIQUE NOT NULL" }, + }; + + @Override + public void onCreate(SQLiteDatabase db) { + createTable(db, Accounts.TABLE, getAccountTableDefinition()); + createTable(db, Albums.TABLE, getAlbumTableDefinition()); + createTable(db, Photos.TABLE, getPhotoTableDefinition()); + createTable(db, Metadata.TABLE, getMetadataTableDefinition()); + } + + public PhotoDatabase(Context context, String dbName, int dbVersion) { + super(context, dbName, null, dbVersion); + } + + public PhotoDatabase(Context context, String dbName) { + super(context, dbName, null, DB_VERSION); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + recreate(db); + } + + @Override + public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) { + recreate(db); + } + + private void recreate(SQLiteDatabase db) { + dropTable(db, Metadata.TABLE); + dropTable(db, Photos.TABLE); + dropTable(db, Albums.TABLE); + dropTable(db, Accounts.TABLE); + onCreate(db); + } + + protected List<String[]> getAlbumTableDefinition() { + return tableCreationStrings(CREATE_ALBUM); + } + + protected List<String[]> getPhotoTableDefinition() { + return tableCreationStrings(CREATE_PHOTO); + } + + protected List<String[]> getMetadataTableDefinition() { + return tableCreationStrings(CREATE_METADATA); + } + + protected List<String[]> getAccountTableDefinition() { + return tableCreationStrings(CREATE_ACCOUNT); + } + + protected static void createTable(SQLiteDatabase db, String table, List<String[]> columns) { + StringBuilder create = new StringBuilder(SQL_CREATE_TABLE); + create.append(table).append('('); + boolean first = true; + for (String[] column : columns) { + if (!first) { + create.append(','); + } + first = false; + for (String val: column) { + create.append(val).append(' '); + } + } + create.append(')'); + db.beginTransaction(); + try { + db.execSQL(create.toString()); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + protected static String[] createUniqueConstraint(String column1, String column2) { + return new String[] { + "UNIQUE(", column1, ",", column2, ")" + }; + } + + protected static List<String[]> tableCreationStrings(String[][] createTable) { + ArrayList<String[]> create = new ArrayList<String[]>(createTable.length); + for (String[] line: createTable) { + create.add(line); + } + return create; + } + + protected static void addToTable(List<String[]> createTable, String[][] columns, String[][] constraints) { + if (columns != null) { + for (String[] column: columns) { + createTable.add(0, column); + } + } + if (constraints != null) { + for (String[] constraint: constraints) { + createTable.add(constraint); + } + } + } + + protected static void dropTable(SQLiteDatabase db, String table) { + db.beginTransaction(); + try { + db.execSQL("drop table if exists " + table); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } +} diff --git a/src/com/android/photos/data/PhotoProvider.java b/src/com/android/photos/data/PhotoProvider.java new file mode 100644 index 000000000..d4310ca95 --- /dev/null +++ b/src/com/android/photos/data/PhotoProvider.java @@ -0,0 +1,536 @@ +/* + * Copyright (C) 2013 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. + */ +package com.android.photos.data; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.database.sqlite.SQLiteQueryBuilder; +import android.media.ExifInterface; +import android.net.Uri; +import android.os.CancellationSignal; +import android.provider.BaseColumns; + +import com.android.gallery3d.common.ApiHelper; + +import java.util.List; + +/** + * A provider that gives access to photo and video information for media stored + * on the server. Only media that is or will be put on the server will be + * accessed by this provider. Use Photos.CONTENT_URI to query all photos and + * videos. Use Albums.CONTENT_URI to query all albums. Use Metadata.CONTENT_URI + * to query metadata about a photo or video, based on the ID of the media. Use + * ImageCache.THUMBNAIL_CONTENT_URI, ImageCache.PREVIEW_CONTENT_URI, or + * ImageCache.ORIGINAL_CONTENT_URI to query the path of the thumbnail, preview, + * or original-sized image respectfully. <br/> + * To add or update metadata, use the update function rather than insert. All + * values for the metadata must be in the ContentValues, even if they are also + * in the selection. The selection and selectionArgs are not used when updating + * metadata. If the metadata values are null, the row will be deleted. + */ +public class PhotoProvider extends SQLiteContentProvider { + @SuppressWarnings("unused") + private static final String TAG = PhotoProvider.class.getSimpleName(); + + protected static final String DB_NAME = "photo.db"; + public static final String AUTHORITY = PhotoProviderAuthority.AUTHORITY; + static final Uri BASE_CONTENT_URI = new Uri.Builder().scheme("content").authority(AUTHORITY) + .build(); + + // Used to allow mocking out the change notification because + // MockContextResolver disallows system-wide notification. + public static interface ChangeNotification { + void notifyChange(Uri uri, boolean syncToNetwork); + } + + /** + * Contains columns that can be accessed via Accounts.CONTENT_URI + */ + public static interface Accounts extends BaseColumns { + /** + * Internal database table used for account information + */ + public static final String TABLE = "accounts"; + /** + * Content URI for account information + */ + public static final Uri CONTENT_URI = Uri.withAppendedPath(BASE_CONTENT_URI, TABLE); + /** + * User name for this account. + */ + public static final String ACCOUNT_NAME = "name"; + } + + /** + * Contains columns that can be accessed via Photos.CONTENT_URI. + */ + public static interface Photos extends BaseColumns { + /** + * The image_type query parameter required for requesting a specific + * size of image. + */ + public static final String MEDIA_SIZE_QUERY_PARAMETER = "media_size"; + + /** Internal database table used for basic photo information. */ + public static final String TABLE = "photos"; + /** Content URI for basic photo and video information. */ + public static final Uri CONTENT_URI = Uri.withAppendedPath(BASE_CONTENT_URI, TABLE); + + /** Long foreign key to Accounts._ID */ + public static final String ACCOUNT_ID = "account_id"; + /** Column name for the width of the original image. Integer value. */ + public static final String WIDTH = "width"; + /** Column name for the height of the original image. Integer value. */ + public static final String HEIGHT = "height"; + /** + * Column name for the date that the original image was taken. Long + * value indicating the milliseconds since epoch in the GMT time zone. + */ + public static final String DATE_TAKEN = "date_taken"; + /** + * Column name indicating the long value of the album id that this image + * resides in. Will be NULL if it it has not been uploaded to the + * server. + */ + public static final String ALBUM_ID = "album_id"; + /** The column name for the mime-type String. */ + public static final String MIME_TYPE = "mime_type"; + /** The title of the photo. String value. */ + public static final String TITLE = "title"; + /** The date the photo entry was last updated. Long value. */ + public static final String DATE_MODIFIED = "date_modified"; + /** + * The rotation of the photo in degrees, if rotation has not already + * been applied. Integer value. + */ + public static final String ROTATION = "rotation"; + } + + /** + * Contains columns and Uri for accessing album information. + */ + public static interface Albums extends BaseColumns { + /** Internal database table used album information. */ + public static final String TABLE = "albums"; + /** Content URI for album information. */ + public static final Uri CONTENT_URI = Uri.withAppendedPath(BASE_CONTENT_URI, TABLE); + + /** Long foreign key to Accounts._ID */ + public static final String ACCOUNT_ID = "account_id"; + /** Parent directory or null if this is in the root. */ + public static final String PARENT_ID = "parent_id"; + /** The type of album. Non-null, if album is auto-generated. String value. */ + public static final String ALBUM_TYPE = "album_type"; + /** + * Column name for the visibility level of the album. Can be any of the + * VISIBILITY_* values. + */ + public static final String VISIBILITY = "visibility"; + /** The user-specified location associated with the album. String value. */ + public static final String LOCATION_STRING = "location_string"; + /** The title of the album. String value. */ + public static final String TITLE = "title"; + /** A short summary of the contents of the album. String value. */ + public static final String SUMMARY = "summary"; + /** The date the album was created. Long value */ + public static final String DATE_PUBLISHED = "date_published"; + /** The date the album entry was last updated. Long value. */ + public static final String DATE_MODIFIED = "date_modified"; + + // Privacy values for Albums.VISIBILITY + public static final int VISIBILITY_PRIVATE = 1; + public static final int VISIBILITY_SHARED = 2; + public static final int VISIBILITY_PUBLIC = 3; + } + + /** + * Contains columns and Uri for accessing photo and video metadata + */ + public static interface Metadata extends BaseColumns { + /** Internal database table used metadata information. */ + public static final String TABLE = "metadata"; + /** Content URI for photo and video metadata. */ + public static final Uri CONTENT_URI = Uri.withAppendedPath(BASE_CONTENT_URI, TABLE); + /** Foreign key to photo_id. Long value. */ + public static final String PHOTO_ID = "photo_id"; + /** Metadata key. String value */ + public static final String KEY = "key"; + /** + * Metadata value. Type is based on key. + */ + public static final String VALUE = "value"; + + /** A short summary of the photo. String value. */ + public static final String KEY_SUMMARY = "summary"; + /** The date the photo was added. Long value. */ + public static final String KEY_PUBLISHED = "date_published"; + /** The date the photo was last updated. Long value. */ + public static final String KEY_DATE_UPDATED = "date_updated"; + /** The size of the photo is bytes. Integer value. */ + public static final String KEY_SIZE_IN_BTYES = "size"; + /** The latitude associated with the photo. Double value. */ + public static final String KEY_LATITUDE = "latitude"; + /** The longitude associated with the photo. Double value. */ + public static final String KEY_LONGITUDE = "longitude"; + + /** The make of the camera used. String value. */ + public static final String KEY_EXIF_MAKE = ExifInterface.TAG_MAKE; + /** The model of the camera used. String value. */ + public static final String KEY_EXIF_MODEL = ExifInterface.TAG_MODEL;; + /** The exposure time used. Float value. */ + public static final String KEY_EXIF_EXPOSURE = ExifInterface.TAG_EXPOSURE_TIME; + /** Whether the flash was used. Boolean value. */ + public static final String KEY_EXIF_FLASH = ExifInterface.TAG_FLASH; + /** The focal length used. Float value. */ + public static final String KEY_EXIF_FOCAL_LENGTH = ExifInterface.TAG_FOCAL_LENGTH; + /** The fstop value used. Float value. */ + public static final String KEY_EXIF_FSTOP = ExifInterface.TAG_APERTURE; + /** The ISO equivalent value used. Integer value. */ + public static final String KEY_EXIF_ISO = ExifInterface.TAG_ISO; + } + + // SQL used within this class. + protected static final String WHERE_ID = BaseColumns._ID + " = ?"; + protected static final String WHERE_METADATA_ID = Metadata.PHOTO_ID + " = ? AND " + + Metadata.KEY + " = ?"; + + protected static final String SELECT_ALBUM_ID = "SELECT " + Albums._ID + " FROM " + + Albums.TABLE; + protected static final String SELECT_PHOTO_ID = "SELECT " + Photos._ID + " FROM " + + Photos.TABLE; + protected static final String SELECT_PHOTO_COUNT = "SELECT COUNT(*) FROM " + Photos.TABLE; + protected static final String DELETE_PHOTOS = "DELETE FROM " + Photos.TABLE; + protected static final String DELETE_METADATA = "DELETE FROM " + Metadata.TABLE; + protected static final String SELECT_METADATA_COUNT = "SELECT COUNT(*) FROM " + Metadata.TABLE; + protected static final String WHERE = " WHERE "; + protected static final String IN = " IN "; + protected static final String NESTED_SELECT_START = "("; + protected static final String NESTED_SELECT_END = ")"; + protected static final String[] PROJECTION_COUNT = { + "COUNT(*)" + }; + + /** + * For selecting the mime-type for an image. + */ + private static final String[] PROJECTION_MIME_TYPE = { + Photos.MIME_TYPE, + }; + + protected static final String[] BASE_COLUMNS_ID = { + BaseColumns._ID, + }; + + protected ChangeNotification mNotifier = null; + protected static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + + protected static final int MATCH_PHOTO = 1; + protected static final int MATCH_PHOTO_ID = 2; + protected static final int MATCH_ALBUM = 3; + protected static final int MATCH_ALBUM_ID = 4; + protected static final int MATCH_METADATA = 5; + protected static final int MATCH_METADATA_ID = 6; + protected static final int MATCH_ACCOUNT = 7; + protected static final int MATCH_ACCOUNT_ID = 8; + + static { + sUriMatcher.addURI(AUTHORITY, Photos.TABLE, MATCH_PHOTO); + // match against Photos._ID + sUriMatcher.addURI(AUTHORITY, Photos.TABLE + "/#", MATCH_PHOTO_ID); + sUriMatcher.addURI(AUTHORITY, Albums.TABLE, MATCH_ALBUM); + // match against Albums._ID + sUriMatcher.addURI(AUTHORITY, Albums.TABLE + "/#", MATCH_ALBUM_ID); + sUriMatcher.addURI(AUTHORITY, Metadata.TABLE, MATCH_METADATA); + // match against metadata/<Metadata._ID> + sUriMatcher.addURI(AUTHORITY, Metadata.TABLE + "/#", MATCH_METADATA_ID); + sUriMatcher.addURI(AUTHORITY, Accounts.TABLE, MATCH_ACCOUNT); + // match against Accounts._ID + sUriMatcher.addURI(AUTHORITY, Accounts.TABLE + "/#", MATCH_ACCOUNT_ID); + } + + @Override + public int deleteInTransaction(Uri uri, String selection, String[] selectionArgs, + boolean callerIsSyncAdapter) { + int match = matchUri(uri); + selection = addIdToSelection(match, selection); + selectionArgs = addIdToSelectionArgs(match, uri, selectionArgs); + return deleteCascade(uri, match, selection, selectionArgs); + } + + @Override + public String getType(Uri uri) { + Cursor cursor = query(uri, PROJECTION_MIME_TYPE, null, null, null); + String mimeType = null; + if (cursor.moveToNext()) { + mimeType = cursor.getString(0); + } + cursor.close(); + return mimeType; + } + + @Override + public Uri insertInTransaction(Uri uri, ContentValues values, boolean callerIsSyncAdapter) { + int match = matchUri(uri); + validateMatchTable(match); + String table = getTableFromMatch(match, uri); + SQLiteDatabase db = getDatabaseHelper().getWritableDatabase(); + Uri insertedUri = null; + long id = db.insert(table, null, values); + if (id != -1) { + // uri already matches the table. + insertedUri = ContentUris.withAppendedId(uri, id); + postNotifyUri(insertedUri); + } + return insertedUri; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, + String sortOrder) { + return query(uri, projection, selection, selectionArgs, sortOrder, null); + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, + String sortOrder, CancellationSignal cancellationSignal) { + projection = replaceCount(projection); + int match = matchUri(uri); + selection = addIdToSelection(match, selection); + selectionArgs = addIdToSelectionArgs(match, uri, selectionArgs); + String table = getTableFromMatch(match, uri); + Cursor c = query(table, projection, selection, selectionArgs, sortOrder, cancellationSignal); + if (c != null) { + c.setNotificationUri(getContext().getContentResolver(), uri); + } + return c; + } + + @Override + public int updateInTransaction(Uri uri, ContentValues values, String selection, + String[] selectionArgs, boolean callerIsSyncAdapter) { + int match = matchUri(uri); + int rowsUpdated = 0; + SQLiteDatabase db = getDatabaseHelper().getWritableDatabase(); + if (match == MATCH_METADATA) { + rowsUpdated = modifyMetadata(db, values); + } else { + selection = addIdToSelection(match, selection); + selectionArgs = addIdToSelectionArgs(match, uri, selectionArgs); + String table = getTableFromMatch(match, uri); + rowsUpdated = db.update(table, values, selection, selectionArgs); + } + postNotifyUri(uri); + return rowsUpdated; + } + + public void setMockNotification(ChangeNotification notification) { + mNotifier = notification; + } + + protected static String addIdToSelection(int match, String selection) { + String where; + switch (match) { + case MATCH_PHOTO_ID: + case MATCH_ALBUM_ID: + case MATCH_METADATA_ID: + where = WHERE_ID; + break; + default: + return selection; + } + return DatabaseUtils.concatenateWhere(selection, where); + } + + protected static String[] addIdToSelectionArgs(int match, Uri uri, String[] selectionArgs) { + String[] whereArgs; + switch (match) { + case MATCH_PHOTO_ID: + case MATCH_ALBUM_ID: + case MATCH_METADATA_ID: + whereArgs = new String[] { + uri.getPathSegments().get(1), + }; + break; + default: + return selectionArgs; + } + return DatabaseUtils.appendSelectionArgs(selectionArgs, whereArgs); + } + + protected static String[] addMetadataKeysToSelectionArgs(String[] selectionArgs, Uri uri) { + List<String> segments = uri.getPathSegments(); + String[] additionalArgs = { + segments.get(1), + segments.get(2), + }; + + return DatabaseUtils.appendSelectionArgs(selectionArgs, additionalArgs); + } + + protected static String getTableFromMatch(int match, Uri uri) { + String table; + switch (match) { + case MATCH_PHOTO: + case MATCH_PHOTO_ID: + table = Photos.TABLE; + break; + case MATCH_ALBUM: + case MATCH_ALBUM_ID: + table = Albums.TABLE; + break; + case MATCH_METADATA: + case MATCH_METADATA_ID: + table = Metadata.TABLE; + break; + case MATCH_ACCOUNT: + case MATCH_ACCOUNT_ID: + table = Accounts.TABLE; + break; + default: + throw unknownUri(uri); + } + return table; + } + + @Override + public SQLiteOpenHelper getDatabaseHelper(Context context) { + return new PhotoDatabase(context, DB_NAME); + } + + private int modifyMetadata(SQLiteDatabase db, ContentValues values) { + int rowCount; + if (values.get(Metadata.VALUE) == null) { + String[] selectionArgs = { + values.getAsString(Metadata.PHOTO_ID), values.getAsString(Metadata.KEY), + }; + rowCount = db.delete(Metadata.TABLE, WHERE_METADATA_ID, selectionArgs); + } else { + long rowId = db.replace(Metadata.TABLE, null, values); + rowCount = (rowId == -1) ? 0 : 1; + } + return rowCount; + } + + private int matchUri(Uri uri) { + int match = sUriMatcher.match(uri); + if (match == UriMatcher.NO_MATCH) { + throw unknownUri(uri); + } + return match; + } + + @Override + protected void notifyChange(ContentResolver resolver, Uri uri, boolean syncToNetwork) { + if (mNotifier != null) { + mNotifier.notifyChange(uri, syncToNetwork); + } else { + super.notifyChange(resolver, uri, syncToNetwork); + } + } + + protected static IllegalArgumentException unknownUri(Uri uri) { + return new IllegalArgumentException("Unknown Uri format: " + uri); + } + + protected static String nestWhere(String matchColumn, String table, String nestedWhere) { + String query = SQLiteQueryBuilder.buildQueryString(false, table, BASE_COLUMNS_ID, + nestedWhere, null, null, null, null); + return matchColumn + IN + NESTED_SELECT_START + query + NESTED_SELECT_END; + } + + protected static String metadataSelectionFromPhotos(String where) { + return nestWhere(Metadata.PHOTO_ID, Photos.TABLE, where); + } + + protected static String photoSelectionFromAlbums(String where) { + return nestWhere(Photos.ALBUM_ID, Albums.TABLE, where); + } + + protected static String photoSelectionFromAccounts(String where) { + return nestWhere(Photos.ACCOUNT_ID, Accounts.TABLE, where); + } + + protected static String albumSelectionFromAccounts(String where) { + return nestWhere(Albums.ACCOUNT_ID, Accounts.TABLE, where); + } + + protected int deleteCascade(Uri uri, int match, String selection, String[] selectionArgs) { + switch (match) { + case MATCH_PHOTO: + case MATCH_PHOTO_ID: + deleteCascade(Metadata.CONTENT_URI, MATCH_METADATA, + metadataSelectionFromPhotos(selection), selectionArgs); + break; + case MATCH_ALBUM: + case MATCH_ALBUM_ID: + deleteCascade(Photos.CONTENT_URI, MATCH_PHOTO, + photoSelectionFromAlbums(selection), selectionArgs); + break; + case MATCH_ACCOUNT: + case MATCH_ACCOUNT_ID: + deleteCascade(Photos.CONTENT_URI, MATCH_PHOTO, + photoSelectionFromAccounts(selection), selectionArgs); + deleteCascade(Albums.CONTENT_URI, MATCH_ALBUM, + albumSelectionFromAccounts(selection), selectionArgs); + break; + } + SQLiteDatabase db = getDatabaseHelper().getWritableDatabase(); + String table = getTableFromMatch(match, uri); + int deleted = db.delete(table, selection, selectionArgs); + if (deleted > 0) { + postNotifyUri(uri); + } + return deleted; + } + + private static void validateMatchTable(int match) { + switch (match) { + case MATCH_PHOTO: + case MATCH_ALBUM: + case MATCH_METADATA: + case MATCH_ACCOUNT: + break; + default: + throw new IllegalArgumentException("Operation not allowed on an existing row."); + } + } + + protected Cursor query(String table, String[] columns, String selection, + String[] selectionArgs, String orderBy, CancellationSignal cancellationSignal) { + SQLiteDatabase db = getDatabaseHelper().getReadableDatabase(); + if (ApiHelper.HAS_CANCELLATION_SIGNAL) { + return db.query(false, table, columns, selection, selectionArgs, null, null, + orderBy, null, cancellationSignal); + } else { + return db.query(table, columns, selection, selectionArgs, null, null, orderBy); + } + } + + protected static String[] replaceCount(String[] projection) { + if (projection != null && projection.length == 1 + && BaseColumns._COUNT.equals(projection[0])) { + return PROJECTION_COUNT; + } + return projection; + } +} diff --git a/src/com/android/photos/data/PhotoSetLoader.java b/src/com/android/photos/data/PhotoSetLoader.java new file mode 100644 index 000000000..56c82c4a9 --- /dev/null +++ b/src/com/android/photos/data/PhotoSetLoader.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos.data; + +import android.content.Context; +import android.content.CursorLoader; +import android.database.ContentObserver; +import android.database.Cursor; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.provider.MediaStore; +import android.provider.MediaStore.Files; +import android.provider.MediaStore.Files.FileColumns; + +import com.android.photos.drawables.DataUriThumbnailDrawable; +import com.android.photos.shims.LoaderCompatShim; + +import java.util.ArrayList; + +public class PhotoSetLoader extends CursorLoader implements LoaderCompatShim<Cursor> { + + public static final String SUPPORTED_OPERATIONS = "supported_operations"; + + private static final Uri CONTENT_URI = Files.getContentUri("external"); + public static final String[] PROJECTION = new String[] { + FileColumns._ID, + FileColumns.DATA, + FileColumns.WIDTH, + FileColumns.HEIGHT, + FileColumns.DATE_ADDED, + FileColumns.MEDIA_TYPE, + SUPPORTED_OPERATIONS, + }; + + private static final String SORT_ORDER = FileColumns.DATE_ADDED + " DESC"; + private static final String SELECTION = + FileColumns.MEDIA_TYPE + " == " + FileColumns.MEDIA_TYPE_IMAGE + + " OR " + + FileColumns.MEDIA_TYPE + " == " + FileColumns.MEDIA_TYPE_VIDEO; + + public static final int INDEX_ID = 0; + public static final int INDEX_DATA = 1; + public static final int INDEX_WIDTH = 2; + public static final int INDEX_HEIGHT = 3; + public static final int INDEX_DATE_ADDED = 4; + public static final int INDEX_MEDIA_TYPE = 5; + public static final int INDEX_SUPPORTED_OPERATIONS = 6; + + private static final Uri GLOBAL_CONTENT_URI = Uri.parse("content://" + MediaStore.AUTHORITY + "/external/"); + private final ContentObserver mGlobalObserver = new ForceLoadContentObserver(); + + public PhotoSetLoader(Context context) { + super(context, CONTENT_URI, PROJECTION, SELECTION, null, SORT_ORDER); + } + + @Override + protected void onStartLoading() { + super.onStartLoading(); + getContext().getContentResolver().registerContentObserver(GLOBAL_CONTENT_URI, + true, mGlobalObserver); + } + + @Override + protected void onReset() { + super.onReset(); + getContext().getContentResolver().unregisterContentObserver(mGlobalObserver); + } + + @Override + public Drawable drawableForItem(Cursor item, Drawable recycle) { + DataUriThumbnailDrawable drawable = null; + if (recycle == null || !(recycle instanceof DataUriThumbnailDrawable)) { + drawable = new DataUriThumbnailDrawable(); + } else { + drawable = (DataUriThumbnailDrawable) recycle; + } + drawable.setImage(item.getString(INDEX_DATA), + item.getInt(INDEX_WIDTH), item.getInt(INDEX_HEIGHT)); + return drawable; + } + + @Override + public Uri uriForItem(Cursor item) { + return null; + } + + @Override + public ArrayList<Uri> urisForSubItems(Cursor item) { + return null; + } + + @Override + public void deleteItemWithPath(Object path) { + + } + + @Override + public Object getPathForItem(Cursor item) { + return null; + } +} diff --git a/src/com/android/photos/data/SQLiteContentProvider.java b/src/com/android/photos/data/SQLiteContentProvider.java new file mode 100644 index 000000000..daffa6e79 --- /dev/null +++ b/src/com/android/photos/data/SQLiteContentProvider.java @@ -0,0 +1,265 @@ +/* + * Copyright (C) 2013 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 + */ + +package com.android.photos.data; + +import android.content.ContentProvider; +import android.content.ContentProviderOperation; +import android.content.ContentProviderResult; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.OperationApplicationException; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; + +/** + * General purpose {@link ContentProvider} base class that uses SQLiteDatabase + * for storage. + */ +public abstract class SQLiteContentProvider extends ContentProvider { + + @SuppressWarnings("unused") + private static final String TAG = "SQLiteContentProvider"; + + private SQLiteOpenHelper mOpenHelper; + private Set<Uri> mChangedUris; + + private final ThreadLocal<Boolean> mApplyingBatch = new ThreadLocal<Boolean>(); + private static final int SLEEP_AFTER_YIELD_DELAY = 4000; + + /** + * Maximum number of operations allowed in a batch between yield points. + */ + private static final int MAX_OPERATIONS_PER_YIELD_POINT = 500; + + @Override + public boolean onCreate() { + Context context = getContext(); + mOpenHelper = getDatabaseHelper(context); + mChangedUris = new HashSet<Uri>(); + return true; + } + + @Override + public void shutdown() { + getDatabaseHelper().close(); + } + + /** + * Returns a {@link SQLiteOpenHelper} that can open the database. + */ + public abstract SQLiteOpenHelper getDatabaseHelper(Context context); + + /** + * The equivalent of the {@link #insert} method, but invoked within a + * transaction. + */ + public abstract Uri insertInTransaction(Uri uri, ContentValues values, + boolean callerIsSyncAdapter); + + /** + * The equivalent of the {@link #update} method, but invoked within a + * transaction. + */ + public abstract int updateInTransaction(Uri uri, ContentValues values, String selection, + String[] selectionArgs, boolean callerIsSyncAdapter); + + /** + * The equivalent of the {@link #delete} method, but invoked within a + * transaction. + */ + public abstract int deleteInTransaction(Uri uri, String selection, String[] selectionArgs, + boolean callerIsSyncAdapter); + + /** + * Call this to add a URI to the list of URIs to be notified when the + * transaction is committed. + */ + protected void postNotifyUri(Uri uri) { + synchronized (mChangedUris) { + mChangedUris.add(uri); + } + } + + public boolean isCallerSyncAdapter(Uri uri) { + return false; + } + + public SQLiteOpenHelper getDatabaseHelper() { + return mOpenHelper; + } + + private boolean applyingBatch() { + return mApplyingBatch.get() != null && mApplyingBatch.get(); + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + Uri result = null; + boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); + boolean applyingBatch = applyingBatch(); + if (!applyingBatch) { + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + db.beginTransaction(); + try { + result = insertInTransaction(uri, values, callerIsSyncAdapter); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + onEndTransaction(callerIsSyncAdapter); + } else { + result = insertInTransaction(uri, values, callerIsSyncAdapter); + } + return result; + } + + @Override + public int bulkInsert(Uri uri, ContentValues[] values) { + int numValues = values.length; + boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + db.beginTransaction(); + try { + for (int i = 0; i < numValues; i++) { + @SuppressWarnings("unused") + Uri result = insertInTransaction(uri, values[i], callerIsSyncAdapter); + db.yieldIfContendedSafely(); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + onEndTransaction(callerIsSyncAdapter); + return numValues; + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + int count = 0; + boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); + boolean applyingBatch = applyingBatch(); + if (!applyingBatch) { + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + db.beginTransaction(); + try { + count = updateInTransaction(uri, values, selection, selectionArgs, + callerIsSyncAdapter); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + onEndTransaction(callerIsSyncAdapter); + } else { + count = updateInTransaction(uri, values, selection, selectionArgs, callerIsSyncAdapter); + } + + return count; + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + int count = 0; + boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); + boolean applyingBatch = applyingBatch(); + if (!applyingBatch) { + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + db.beginTransaction(); + try { + count = deleteInTransaction(uri, selection, selectionArgs, callerIsSyncAdapter); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + onEndTransaction(callerIsSyncAdapter); + } else { + count = deleteInTransaction(uri, selection, selectionArgs, callerIsSyncAdapter); + } + return count; + } + + @Override + public ContentProviderResult[] applyBatch(ArrayList<ContentProviderOperation> operations) + throws OperationApplicationException { + int ypCount = 0; + int opCount = 0; + boolean callerIsSyncAdapter = false; + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + db.beginTransaction(); + try { + mApplyingBatch.set(true); + final int numOperations = operations.size(); + final ContentProviderResult[] results = new ContentProviderResult[numOperations]; + for (int i = 0; i < numOperations; i++) { + if (++opCount >= MAX_OPERATIONS_PER_YIELD_POINT) { + throw new OperationApplicationException( + "Too many content provider operations between yield points. " + + "The maximum number of operations per yield point is " + + MAX_OPERATIONS_PER_YIELD_POINT, ypCount); + } + final ContentProviderOperation operation = operations.get(i); + if (!callerIsSyncAdapter && isCallerSyncAdapter(operation.getUri())) { + callerIsSyncAdapter = true; + } + if (i > 0 && operation.isYieldAllowed()) { + opCount = 0; + if (db.yieldIfContendedSafely(SLEEP_AFTER_YIELD_DELAY)) { + ypCount++; + } + } + results[i] = operation.apply(this, results, i); + } + db.setTransactionSuccessful(); + return results; + } finally { + mApplyingBatch.set(false); + db.endTransaction(); + onEndTransaction(callerIsSyncAdapter); + } + } + + protected Set<Uri> onEndTransaction(boolean callerIsSyncAdapter) { + Set<Uri> changed; + synchronized (mChangedUris) { + changed = new HashSet<Uri>(mChangedUris); + mChangedUris.clear(); + } + ContentResolver resolver = getContext().getContentResolver(); + for (Uri uri : changed) { + boolean syncToNetwork = !callerIsSyncAdapter && syncToNetwork(uri); + notifyChange(resolver, uri, syncToNetwork); + } + return changed; + } + + protected void notifyChange(ContentResolver resolver, Uri uri, boolean syncToNetwork) { + resolver.notifyChange(uri, null, syncToNetwork); + } + + protected boolean syncToNetwork(Uri uri) { + return false; + } +}
\ No newline at end of file diff --git a/src/com/android/photos/data/SparseArrayBitmapPool.java b/src/com/android/photos/data/SparseArrayBitmapPool.java new file mode 100644 index 000000000..95e10267b --- /dev/null +++ b/src/com/android/photos/data/SparseArrayBitmapPool.java @@ -0,0 +1,212 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos.data; + +import android.graphics.Bitmap; +import android.util.SparseArray; + +import android.util.Pools.Pool; +import android.util.Pools.SimplePool; + +/** + * Bitmap pool backed by a sparse array indexing linked lists of bitmaps + * sharing the same width. Performance will degrade if using this to store + * many bitmaps with the same width but many different heights. + */ +public class SparseArrayBitmapPool { + + private int mCapacityBytes; + private SparseArray<Node> mStore = new SparseArray<Node>(); + private int mSizeBytes = 0; + + private Pool<Node> mNodePool; + private Node mPoolNodesHead = null; + private Node mPoolNodesTail = null; + + protected static class Node { + Bitmap bitmap; + + // Each node is part of two doubly linked lists: + // - A pool-level list (accessed by mPoolNodesHead and mPoolNodesTail) + // that is used for FIFO eviction of nodes when the pool gets full. + // - A bucket-level list for each index of the sparse array, so that + // each index can store more than one item. + Node prevInBucket; + Node nextInBucket; + Node nextInPool; + Node prevInPool; + } + + /** + * @param capacityBytes Maximum capacity of the pool in bytes. + * @param nodePool Shared pool to use for recycling linked list nodes, or null. + */ + public SparseArrayBitmapPool(int capacityBytes, Pool<Node> nodePool) { + mCapacityBytes = capacityBytes; + if (nodePool == null) { + mNodePool = new SimplePool<Node>(32); + } else { + mNodePool = nodePool; + } + } + + /** + * Set the maximum capacity of the pool, and if necessary trim it down to size. + */ + public synchronized void setCapacity(int capacityBytes) { + mCapacityBytes = capacityBytes; + + // No-op unless current size exceeds the new capacity. + freeUpCapacity(0); + } + + private void freeUpCapacity(int bytesNeeded) { + int targetSize = mCapacityBytes - bytesNeeded; + // Repeatedly remove the oldest node until we have freed up at least bytesNeeded. + while (mPoolNodesTail != null && mSizeBytes > targetSize) { + unlinkAndRecycleNode(mPoolNodesTail, true); + } + } + + private void unlinkAndRecycleNode(Node n, boolean recycleBitmap) { + // Unlink the node from its sparse array bucket list. + if (n.prevInBucket != null) { + // This wasn't the head, update the previous node. + n.prevInBucket.nextInBucket = n.nextInBucket; + } else { + // This was the head of the bucket, replace it with the next node. + mStore.put(n.bitmap.getWidth(), n.nextInBucket); + } + if (n.nextInBucket != null) { + // This wasn't the tail, update the next node. + n.nextInBucket.prevInBucket = n.prevInBucket; + } + + // Unlink the node from the pool-wide list. + if (n.prevInPool != null) { + // This wasn't the head, update the previous node. + n.prevInPool.nextInPool = n.nextInPool; + } else { + // This was the head of the pool-wide list, update the head pointer. + mPoolNodesHead = n.nextInPool; + } + if (n.nextInPool != null) { + // This wasn't the tail, update the next node. + n.nextInPool.prevInPool = n.prevInPool; + } else { + // This was the tail, update the tail pointer. + mPoolNodesTail = n.prevInPool; + } + + // Recycle the node. + n.nextInBucket = null; + n.nextInPool = null; + n.prevInBucket = null; + n.prevInPool = null; + mSizeBytes -= n.bitmap.getByteCount(); + if (recycleBitmap) n.bitmap.recycle(); + n.bitmap = null; + mNodePool.release(n); + } + + /** + * @return Capacity of the pool in bytes. + */ + public synchronized int getCapacity() { + return mCapacityBytes; + } + + /** + * @return Total size in bytes of the bitmaps stored in the pool. + */ + public synchronized int getSize() { + return mSizeBytes; + } + + /** + * @return Bitmap from the pool with the desired height/width or null if none available. + */ + public synchronized Bitmap get(int width, int height) { + Node cur = mStore.get(width); + + // Traverse the list corresponding to the width bucket in the + // sparse array, and unlink and return the first bitmap that + // also has the correct height. + while (cur != null) { + if (cur.bitmap.getHeight() == height) { + Bitmap b = cur.bitmap; + unlinkAndRecycleNode(cur, false); + return b; + } + cur = cur.nextInBucket; + } + return null; + } + + /** + * Adds the given bitmap to the pool. + * @return Whether the bitmap was added to the pool. + */ + public synchronized boolean put(Bitmap b) { + if (b == null) { + return false; + } + + // Ensure there is enough room to contain the new bitmap. + int bytes = b.getByteCount(); + freeUpCapacity(bytes); + + Node newNode = mNodePool.acquire(); + if (newNode == null) { + newNode = new Node(); + } + newNode.bitmap = b; + + // We append to the head, and freeUpCapacity clears from the tail, + // resulting in FIFO eviction. + newNode.prevInBucket = null; + newNode.prevInPool = null; + newNode.nextInPool = mPoolNodesHead; + mPoolNodesHead = newNode; + + // Insert the node into its appropriate bucket based on width. + int key = b.getWidth(); + newNode.nextInBucket = mStore.get(key); + if (newNode.nextInBucket != null) { + // The bucket already had nodes, update the old head. + newNode.nextInBucket.prevInBucket = newNode; + } + mStore.put(key, newNode); + + if (newNode.nextInPool == null) { + // This is the only node in the list, update the tail pointer. + mPoolNodesTail = newNode; + } else { + newNode.nextInPool.prevInPool = newNode; + } + mSizeBytes += bytes; + return true; + } + + /** + * Empty the pool, recycling all the bitmaps currently in it. + */ + public synchronized void clear() { + // Clearing is equivalent to ensuring all the capacity is available. + freeUpCapacity(mCapacityBytes); + } +} diff --git a/src/com/android/photos/drawables/AutoThumbnailDrawable.java b/src/com/android/photos/drawables/AutoThumbnailDrawable.java new file mode 100644 index 000000000..b51b6709f --- /dev/null +++ b/src/com/android/photos/drawables/AutoThumbnailDrawable.java @@ -0,0 +1,309 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos.drawables; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.util.Log; + +import com.android.photos.data.GalleryBitmapPool; + +import java.io.InputStream; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public abstract class AutoThumbnailDrawable<T> extends Drawable { + + private static final String TAG = "AutoThumbnailDrawable"; + + private static ExecutorService sThreadPool = Executors.newSingleThreadExecutor(); + private static GalleryBitmapPool sBitmapPool = GalleryBitmapPool.getInstance(); + private static byte[] sTempStorage = new byte[64 * 1024]; + + // UI thread only + private Paint mPaint = new Paint(); + private Matrix mDrawMatrix = new Matrix(); + + // Decoder thread only + private BitmapFactory.Options mOptions = new BitmapFactory.Options(); + + // Shared, guarded by mLock + private Object mLock = new Object(); + private Bitmap mBitmap; + protected T mData; + private boolean mIsQueued; + private int mImageWidth, mImageHeight; + private Rect mBounds = new Rect(); + private int mSampleSize = 1; + + public AutoThumbnailDrawable() { + mPaint.setAntiAlias(true); + mPaint.setFilterBitmap(true); + mDrawMatrix.reset(); + mOptions.inTempStorage = sTempStorage; + } + + protected abstract byte[] getPreferredImageBytes(T data); + protected abstract InputStream getFallbackImageStream(T data); + protected abstract boolean dataChangedLocked(T data); + + public void setImage(T data, int width, int height) { + if (!dataChangedLocked(data)) return; + synchronized (mLock) { + mImageWidth = width; + mImageHeight = height; + mData = data; + setBitmapLocked(null); + refreshSampleSizeLocked(); + } + invalidateSelf(); + } + + private void setBitmapLocked(Bitmap b) { + if (b == mBitmap) { + return; + } + if (mBitmap != null) { + sBitmapPool.put(mBitmap); + } + mBitmap = b; + } + + @Override + protected void onBoundsChange(Rect bounds) { + super.onBoundsChange(bounds); + synchronized (mLock) { + mBounds.set(bounds); + if (mBounds.isEmpty()) { + mBitmap = null; + } else { + refreshSampleSizeLocked(); + updateDrawMatrixLocked(); + } + } + invalidateSelf(); + } + + @Override + public void draw(Canvas canvas) { + if (mBitmap != null) { + canvas.save(); + canvas.clipRect(mBounds); + canvas.concat(mDrawMatrix); + canvas.drawBitmap(mBitmap, 0, 0, mPaint); + canvas.restore(); + } else { + // TODO: Draw placeholder...? + } + } + + private void updateDrawMatrixLocked() { + if (mBitmap == null || mBounds.isEmpty()) { + mDrawMatrix.reset(); + return; + } + + float scale; + float dx = 0, dy = 0; + + int dwidth = mBitmap.getWidth(); + int dheight = mBitmap.getHeight(); + int vwidth = mBounds.width(); + int vheight = mBounds.height(); + + // Calculates a matrix similar to ScaleType.CENTER_CROP + if (dwidth * vheight > vwidth * dheight) { + scale = (float) vheight / (float) dheight; + dx = (vwidth - dwidth * scale) * 0.5f; + } else { + scale = (float) vwidth / (float) dwidth; + dy = (vheight - dheight * scale) * 0.5f; + } + if (scale < .8f) { + Log.w(TAG, "sample size was too small! Overdrawing! " + scale + ", " + mSampleSize); + } else if (scale > 1.5f) { + Log.w(TAG, "Potential quality loss! " + scale + ", " + mSampleSize); + } + + mDrawMatrix.setScale(scale, scale); + mDrawMatrix.postTranslate((int) (dx + 0.5f), (int) (dy + 0.5f)); + } + + private int calculateSampleSizeLocked(int dwidth, int dheight) { + float scale; + + int vwidth = mBounds.width(); + int vheight = mBounds.height(); + + // Inverse of updateDrawMatrixLocked + if (dwidth * vheight > vwidth * dheight) { + scale = (float) dheight / (float) vheight; + } else { + scale = (float) dwidth / (float) vwidth; + } + int result = Math.round(scale); + return result > 0 ? result : 1; + } + + private void refreshSampleSizeLocked() { + if (mBounds.isEmpty() || mImageWidth == 0 || mImageHeight == 0) { + return; + } + + int sampleSize = calculateSampleSizeLocked(mImageWidth, mImageHeight); + if (sampleSize != mSampleSize || mBitmap == null) { + mSampleSize = sampleSize; + loadBitmapLocked(); + } + } + + private void loadBitmapLocked() { + if (!mIsQueued && !mBounds.isEmpty()) { + unscheduleSelf(mUpdateBitmap); + sThreadPool.execute(mLoadBitmap); + mIsQueued = true; + } + } + + public float getAspectRatio() { + return (float) mImageWidth / (float) mImageHeight; + } + + @Override + public int getIntrinsicWidth() { + return -1; + } + + @Override + public int getIntrinsicHeight() { + return -1; + } + + @Override + public int getOpacity() { + Bitmap bm = mBitmap; + return (bm == null || bm.hasAlpha() || mPaint.getAlpha() < 255) ? + PixelFormat.TRANSLUCENT : PixelFormat.OPAQUE; + } + + @Override + public void setAlpha(int alpha) { + int oldAlpha = mPaint.getAlpha(); + if (alpha != oldAlpha) { + mPaint.setAlpha(alpha); + invalidateSelf(); + } + } + + @Override + public void setColorFilter(ColorFilter cf) { + mPaint.setColorFilter(cf); + invalidateSelf(); + } + + private final Runnable mLoadBitmap = new Runnable() { + @Override + public void run() { + T data; + synchronized (mLock) { + data = mData; + } + int preferredSampleSize = 1; + byte[] preferred = getPreferredImageBytes(data); + boolean hasPreferred = (preferred != null && preferred.length > 0); + if (hasPreferred) { + mOptions.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(preferred, 0, preferred.length, mOptions); + mOptions.inJustDecodeBounds = false; + } + int sampleSize, width, height; + synchronized (mLock) { + if (dataChangedLocked(data)) { + return; + } + width = mImageWidth; + height = mImageHeight; + if (hasPreferred) { + preferredSampleSize = calculateSampleSizeLocked( + mOptions.outWidth, mOptions.outHeight); + } + sampleSize = calculateSampleSizeLocked(width, height); + mIsQueued = false; + } + Bitmap b = null; + InputStream is = null; + try { + if (hasPreferred) { + mOptions.inSampleSize = preferredSampleSize; + mOptions.inBitmap = sBitmapPool.get( + mOptions.outWidth / preferredSampleSize, + mOptions.outHeight / preferredSampleSize); + b = BitmapFactory.decodeByteArray(preferred, 0, preferred.length, mOptions); + if (mOptions.inBitmap != null && b != mOptions.inBitmap) { + sBitmapPool.put(mOptions.inBitmap); + mOptions.inBitmap = null; + } + } + if (b == null) { + is = getFallbackImageStream(data); + mOptions.inSampleSize = sampleSize; + mOptions.inBitmap = sBitmapPool.get(width / sampleSize, height / sampleSize); + b = BitmapFactory.decodeStream(is, null, mOptions); + if (mOptions.inBitmap != null && b != mOptions.inBitmap) { + sBitmapPool.put(mOptions.inBitmap); + mOptions.inBitmap = null; + } + } + } catch (Exception e) { + Log.d(TAG, "Failed to fetch bitmap", e); + return; + } finally { + try { + if (is != null) { + is.close(); + } + } catch (Exception e) {} + if (b != null) { + synchronized (mLock) { + if (!dataChangedLocked(data)) { + setBitmapLocked(b); + scheduleSelf(mUpdateBitmap, 0); + } + } + } + } + } + }; + + private final Runnable mUpdateBitmap = new Runnable() { + @Override + public void run() { + synchronized (AutoThumbnailDrawable.this) { + updateDrawMatrixLocked(); + invalidateSelf(); + } + } + }; + +} diff --git a/src/com/android/photos/drawables/DataUriThumbnailDrawable.java b/src/com/android/photos/drawables/DataUriThumbnailDrawable.java new file mode 100644 index 000000000..c83b0c8fa --- /dev/null +++ b/src/com/android/photos/drawables/DataUriThumbnailDrawable.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos.drawables; + +import android.media.ExifInterface; +import android.text.TextUtils; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; + +public class DataUriThumbnailDrawable extends AutoThumbnailDrawable<String> { + + @Override + protected byte[] getPreferredImageBytes(String data) { + byte[] thumbnail = null; + try { + ExifInterface exif = new ExifInterface(data); + if (exif.hasThumbnail()) { + thumbnail = exif.getThumbnail(); + } + } catch (IOException e) { } + return thumbnail; + } + + @Override + protected InputStream getFallbackImageStream(String data) { + try { + return new FileInputStream(data); + } catch (FileNotFoundException e) { + return null; + } + } + + @Override + protected boolean dataChangedLocked(String data) { + return !TextUtils.equals(mData, data); + } +} diff --git a/src/com/android/photos/shims/BitmapJobDrawable.java b/src/com/android/photos/shims/BitmapJobDrawable.java new file mode 100644 index 000000000..32dbc8078 --- /dev/null +++ b/src/com/android/photos/shims/BitmapJobDrawable.java @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos.shims; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; + +import com.android.gallery3d.data.MediaItem; +import com.android.gallery3d.ui.BitmapLoader; +import com.android.gallery3d.util.Future; +import com.android.gallery3d.util.FutureListener; +import com.android.gallery3d.util.ThreadPool; +import com.android.photos.data.GalleryBitmapPool; + + +public class BitmapJobDrawable extends Drawable implements Runnable { + + private ThumbnailLoader mLoader; + private MediaItem mItem; + private Bitmap mBitmap; + private Paint mPaint = new Paint(); + private Matrix mDrawMatrix = new Matrix(); + private int mRotation = 0; + + public BitmapJobDrawable() { + } + + public void setMediaItem(MediaItem item) { + if (mItem == item) return; + + if (mLoader != null) { + mLoader.cancelLoad(); + } + mItem = item; + if (mBitmap != null) { + GalleryBitmapPool.getInstance().put(mBitmap); + mBitmap = null; + } + if (mItem != null) { + // TODO: Figure out why ThumbnailLoader doesn't like to be re-used + mLoader = new ThumbnailLoader(this); + mLoader.startLoad(); + mRotation = mItem.getRotation(); + } + invalidateSelf(); + } + + @Override + public void run() { + Bitmap bitmap = mLoader.getBitmap(); + if (bitmap != null) { + mBitmap = bitmap; + updateDrawMatrix(); + } + } + + @Override + protected void onBoundsChange(Rect bounds) { + super.onBoundsChange(bounds); + updateDrawMatrix(); + } + + @Override + public void draw(Canvas canvas) { + Rect bounds = getBounds(); + if (mBitmap != null) { + canvas.save(); + canvas.clipRect(bounds); + canvas.concat(mDrawMatrix); + canvas.rotate(mRotation, bounds.centerX(), bounds.centerY()); + canvas.drawBitmap(mBitmap, 0, 0, mPaint); + canvas.restore(); + } else { + mPaint.setColor(0xFFCCCCCC); + canvas.drawRect(bounds, mPaint); + } + } + + private void updateDrawMatrix() { + Rect bounds = getBounds(); + if (mBitmap == null || bounds.isEmpty()) { + mDrawMatrix.reset(); + return; + } + + float scale; + float dx = 0, dy = 0; + + int dwidth = mBitmap.getWidth(); + int dheight = mBitmap.getHeight(); + int vwidth = bounds.width(); + int vheight = bounds.height(); + + // Calculates a matrix similar to ScaleType.CENTER_CROP + if (dwidth * vheight > vwidth * dheight) { + scale = (float) vheight / (float) dheight; + dx = (vwidth - dwidth * scale) * 0.5f; + } else { + scale = (float) vwidth / (float) dwidth; + dy = (vheight - dheight * scale) * 0.5f; + } + + mDrawMatrix.setScale(scale, scale); + mDrawMatrix.postTranslate((int) (dx + 0.5f), (int) (dy + 0.5f)); + invalidateSelf(); + } + + @Override + public int getIntrinsicWidth() { + return MediaItem.getTargetSize(MediaItem.TYPE_MICROTHUMBNAIL); + } + + @Override + public int getIntrinsicHeight() { + return MediaItem.getTargetSize(MediaItem.TYPE_MICROTHUMBNAIL); + } + + @Override + public int getOpacity() { + Bitmap bm = mBitmap; + return (bm == null || bm.hasAlpha() || mPaint.getAlpha() < 255) ? + PixelFormat.TRANSLUCENT : PixelFormat.OPAQUE; + } + + @Override + public void setAlpha(int alpha) { + int oldAlpha = mPaint.getAlpha(); + if (alpha != oldAlpha) { + mPaint.setAlpha(alpha); + invalidateSelf(); + } + } + + @Override + public void setColorFilter(ColorFilter cf) { + mPaint.setColorFilter(cf); + invalidateSelf(); + } + + private static class ThumbnailLoader extends BitmapLoader { + private static final ThreadPool sThreadPool = new ThreadPool(0, 2); + private BitmapJobDrawable mParent; + + public ThumbnailLoader(BitmapJobDrawable parent) { + mParent = parent; + } + + @Override + protected Future<Bitmap> submitBitmapTask(FutureListener<Bitmap> l) { + return sThreadPool.submit( + mParent.mItem.requestImage(MediaItem.TYPE_MICROTHUMBNAIL), this); + } + + @Override + protected void onLoadComplete(Bitmap bitmap) { + mParent.scheduleSelf(mParent, 0); + } + } + +} diff --git a/src/com/android/photos/shims/LoaderCompatShim.java b/src/com/android/photos/shims/LoaderCompatShim.java new file mode 100644 index 000000000..d5bf710de --- /dev/null +++ b/src/com/android/photos/shims/LoaderCompatShim.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos.shims; + +import android.graphics.drawable.Drawable; +import android.net.Uri; + +import java.util.ArrayList; + + +public interface LoaderCompatShim<T> { + Drawable drawableForItem(T item, Drawable recycle); + Uri uriForItem(T item); + ArrayList<Uri> urisForSubItems(T item); + void deleteItemWithPath(Object path); + Object getPathForItem(T item); +} diff --git a/src/com/android/photos/shims/MediaItemsLoader.java b/src/com/android/photos/shims/MediaItemsLoader.java new file mode 100644 index 000000000..6142355a9 --- /dev/null +++ b/src/com/android/photos/shims/MediaItemsLoader.java @@ -0,0 +1,190 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos.shims; + +import android.content.AsyncTaskLoader; +import android.content.Context; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.provider.MediaStore.Files.FileColumns; +import android.util.SparseArray; + +import com.android.gallery3d.data.ContentListener; +import com.android.gallery3d.data.DataManager; +import com.android.gallery3d.data.MediaItem; +import com.android.gallery3d.data.MediaObject; +import com.android.gallery3d.data.MediaSet; +import com.android.gallery3d.data.MediaSet.ItemConsumer; +import com.android.gallery3d.data.MediaSet.SyncListener; +import com.android.gallery3d.data.Path; +import com.android.gallery3d.util.Future; +import com.android.photos.data.PhotoSetLoader; + +import java.util.ArrayList; + +/** + * Returns all MediaItems in a MediaSet, wrapping them in a cursor to appear + * like a PhotoSetLoader + */ +public class MediaItemsLoader extends AsyncTaskLoader<Cursor> implements LoaderCompatShim<Cursor> { + + private static final SyncListener sNullListener = new SyncListener() { + @Override + public void onSyncDone(MediaSet mediaSet, int resultCode) { + } + }; + + private final MediaSet mMediaSet; + private final DataManager mDataManager; + private Future<Integer> mSyncTask = null; + private ContentListener mObserver = new ContentListener() { + @Override + public void onContentDirty() { + onContentChanged(); + } + }; + private SparseArray<MediaItem> mMediaItems; + + public MediaItemsLoader(Context context) { + super(context); + mDataManager = DataManager.from(context); + String path = mDataManager.getTopSetPath(DataManager.INCLUDE_ALL); + mMediaSet = mDataManager.getMediaSet(path); + } + + public MediaItemsLoader(Context context, String parentPath) { + super(context); + mDataManager = DataManager.from(getContext()); + mMediaSet = mDataManager.getMediaSet(parentPath); + } + + @Override + protected void onStartLoading() { + super.onStartLoading(); + mMediaSet.addContentListener(mObserver); + mSyncTask = mMediaSet.requestSync(sNullListener); + forceLoad(); + } + + @Override + protected boolean onCancelLoad() { + if (mSyncTask != null) { + mSyncTask.cancel(); + mSyncTask = null; + } + return super.onCancelLoad(); + } + + @Override + protected void onStopLoading() { + super.onStopLoading(); + cancelLoad(); + mMediaSet.removeContentListener(mObserver); + } + + @Override + protected void onReset() { + super.onReset(); + onStopLoading(); + } + + @Override + public Cursor loadInBackground() { + // TODO: This probably doesn't work + mMediaSet.reload(); + final MatrixCursor cursor = new MatrixCursor(PhotoSetLoader.PROJECTION); + final Object[] row = new Object[PhotoSetLoader.PROJECTION.length]; + final SparseArray<MediaItem> mediaItems = new SparseArray<MediaItem>(); + mMediaSet.enumerateTotalMediaItems(new ItemConsumer() { + @Override + public void consume(int index, MediaItem item) { + row[PhotoSetLoader.INDEX_ID] = index; + row[PhotoSetLoader.INDEX_DATA] = item.getContentUri().toString(); + row[PhotoSetLoader.INDEX_DATE_ADDED] = item.getDateInMs(); + row[PhotoSetLoader.INDEX_HEIGHT] = item.getHeight(); + row[PhotoSetLoader.INDEX_WIDTH] = item.getWidth(); + row[PhotoSetLoader.INDEX_WIDTH] = item.getWidth(); + int rawMediaType = item.getMediaType(); + int mappedMediaType = FileColumns.MEDIA_TYPE_NONE; + if (rawMediaType == MediaItem.MEDIA_TYPE_IMAGE) { + mappedMediaType = FileColumns.MEDIA_TYPE_IMAGE; + } else if (rawMediaType == MediaItem.MEDIA_TYPE_VIDEO) { + mappedMediaType = FileColumns.MEDIA_TYPE_VIDEO; + } + row[PhotoSetLoader.INDEX_MEDIA_TYPE] = mappedMediaType; + row[PhotoSetLoader.INDEX_SUPPORTED_OPERATIONS] = + item.getSupportedOperations(); + cursor.addRow(row); + mediaItems.append(index, item); + } + }); + synchronized (mMediaSet) { + mMediaItems = mediaItems; + } + return cursor; + } + + @Override + public Drawable drawableForItem(Cursor item, Drawable recycle) { + BitmapJobDrawable drawable = null; + if (recycle == null || !(recycle instanceof BitmapJobDrawable)) { + drawable = new BitmapJobDrawable(); + } else { + drawable = (BitmapJobDrawable) recycle; + } + int index = item.getInt(PhotoSetLoader.INDEX_ID); + drawable.setMediaItem(mMediaItems.get(index)); + return drawable; + } + + public static int getThumbnailSize() { + return MediaItem.getTargetSize(MediaItem.TYPE_MICROTHUMBNAIL); + } + + @Override + public Uri uriForItem(Cursor item) { + int index = item.getInt(PhotoSetLoader.INDEX_ID); + MediaItem mi = mMediaItems.get(index); + return mi == null ? null : mi.getContentUri(); + } + + @Override + public ArrayList<Uri> urisForSubItems(Cursor item) { + return null; + } + + @Override + public void deleteItemWithPath(Object path) { + MediaObject o = mDataManager.getMediaObject((Path) path); + if (o != null) { + o.delete(); + } + } + + @Override + public Object getPathForItem(Cursor item) { + int index = item.getInt(PhotoSetLoader.INDEX_ID); + MediaItem mi = mMediaItems.get(index); + if (mi != null) { + return mi.getPath(); + } + return null; + } + +} diff --git a/src/com/android/photos/shims/MediaSetLoader.java b/src/com/android/photos/shims/MediaSetLoader.java new file mode 100644 index 000000000..9093bc139 --- /dev/null +++ b/src/com/android/photos/shims/MediaSetLoader.java @@ -0,0 +1,191 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos.shims; + +import android.content.AsyncTaskLoader; +import android.content.Context; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.graphics.drawable.Drawable; +import android.net.Uri; + +import com.android.gallery3d.data.ContentListener; +import com.android.gallery3d.data.DataManager; +import com.android.gallery3d.data.MediaItem; +import com.android.gallery3d.data.MediaObject; +import com.android.gallery3d.data.MediaSet; +import com.android.gallery3d.data.Path; +import com.android.gallery3d.data.MediaSet.SyncListener; +import com.android.gallery3d.util.Future; +import com.android.photos.data.AlbumSetLoader; + +import java.util.ArrayList; + +/** + * Returns all MediaSets in a MediaSet, wrapping them in a cursor to appear + * like a AlbumSetLoader. + */ +public class MediaSetLoader extends AsyncTaskLoader<Cursor> implements LoaderCompatShim<Cursor>{ + + private static final SyncListener sNullListener = new SyncListener() { + @Override + public void onSyncDone(MediaSet mediaSet, int resultCode) { + } + }; + + private final MediaSet mMediaSet; + private final DataManager mDataManager; + private Future<Integer> mSyncTask = null; + private ContentListener mObserver = new ContentListener() { + @Override + public void onContentDirty() { + onContentChanged(); + } + }; + + private ArrayList<MediaItem> mCoverItems; + + public MediaSetLoader(Context context) { + super(context); + mDataManager = DataManager.from(context); + String path = mDataManager.getTopSetPath(DataManager.INCLUDE_ALL); + mMediaSet = mDataManager.getMediaSet(path); + } + + public MediaSetLoader(Context context, String path) { + super(context); + mDataManager = DataManager.from(getContext()); + mMediaSet = mDataManager.getMediaSet(path); + } + + @Override + protected void onStartLoading() { + super.onStartLoading(); + mMediaSet.addContentListener(mObserver); + mSyncTask = mMediaSet.requestSync(sNullListener); + forceLoad(); + } + + @Override + protected boolean onCancelLoad() { + if (mSyncTask != null) { + mSyncTask.cancel(); + mSyncTask = null; + } + return super.onCancelLoad(); + } + + @Override + protected void onStopLoading() { + super.onStopLoading(); + cancelLoad(); + mMediaSet.removeContentListener(mObserver); + } + + @Override + protected void onReset() { + super.onReset(); + onStopLoading(); + } + + @Override + public Cursor loadInBackground() { + // TODO: This probably doesn't work + mMediaSet.reload(); + final MatrixCursor cursor = new MatrixCursor(AlbumSetLoader.PROJECTION); + final Object[] row = new Object[AlbumSetLoader.PROJECTION.length]; + int count = mMediaSet.getSubMediaSetCount(); + ArrayList<MediaItem> coverItems = new ArrayList<MediaItem>(count); + for (int i = 0; i < count; i++) { + MediaSet m = mMediaSet.getSubMediaSet(i); + m.reload(); + row[AlbumSetLoader.INDEX_ID] = i; + row[AlbumSetLoader.INDEX_TITLE] = m.getName(); + row[AlbumSetLoader.INDEX_COUNT] = m.getMediaItemCount(); + row[AlbumSetLoader.INDEX_SUPPORTED_OPERATIONS] = m.getSupportedOperations(); + MediaItem coverItem = m.getCoverMediaItem(); + if (coverItem != null) { + row[AlbumSetLoader.INDEX_TIMESTAMP] = coverItem.getDateInMs(); + } + coverItems.add(coverItem); + cursor.addRow(row); + } + synchronized (mMediaSet) { + mCoverItems = coverItems; + } + return cursor; + } + + @Override + public Drawable drawableForItem(Cursor item, Drawable recycle) { + BitmapJobDrawable drawable = null; + if (recycle == null || !(recycle instanceof BitmapJobDrawable)) { + drawable = new BitmapJobDrawable(); + } else { + drawable = (BitmapJobDrawable) recycle; + } + int index = item.getInt(AlbumSetLoader.INDEX_ID); + drawable.setMediaItem(mCoverItems.get(index)); + return drawable; + } + + public static int getThumbnailSize() { + return MediaItem.getTargetSize(MediaItem.TYPE_MICROTHUMBNAIL); + } + + @Override + public Uri uriForItem(Cursor item) { + int index = item.getInt(AlbumSetLoader.INDEX_ID); + MediaSet ms = mMediaSet.getSubMediaSet(index); + return ms == null ? null : ms.getContentUri(); + } + + @Override + public ArrayList<Uri> urisForSubItems(Cursor item) { + int index = item.getInt(AlbumSetLoader.INDEX_ID); + MediaSet ms = mMediaSet.getSubMediaSet(index); + if (ms == null) return null; + final ArrayList<Uri> result = new ArrayList<Uri>(); + ms.enumerateMediaItems(new MediaSet.ItemConsumer() { + @Override + public void consume(int index, MediaItem item) { + if (item != null) { + result.add(item.getContentUri()); + } + } + }); + return result; + } + + @Override + public void deleteItemWithPath(Object path) { + MediaObject o = mDataManager.getMediaObject((Path) path); + if (o != null) { + o.delete(); + } + } + + @Override + public Object getPathForItem(Cursor item) { + int index = item.getInt(AlbumSetLoader.INDEX_ID); + MediaSet ms = mMediaSet.getSubMediaSet(index); + if (ms != null) { + return ms.getPath(); + } + return null; + } +} diff --git a/src/com/android/photos/views/BlockingGLTextureView.java b/src/com/android/photos/views/BlockingGLTextureView.java new file mode 100644 index 000000000..8a0505185 --- /dev/null +++ b/src/com/android/photos/views/BlockingGLTextureView.java @@ -0,0 +1,438 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos.views; + +import android.content.Context; +import android.graphics.SurfaceTexture; +import android.opengl.GLSurfaceView.Renderer; +import android.opengl.GLUtils; +import android.util.Log; +import android.view.TextureView; +import android.view.TextureView.SurfaceTextureListener; + +import javax.microedition.khronos.egl.EGL10; +import javax.microedition.khronos.egl.EGLConfig; +import javax.microedition.khronos.egl.EGLContext; +import javax.microedition.khronos.egl.EGLDisplay; +import javax.microedition.khronos.egl.EGLSurface; +import javax.microedition.khronos.opengles.GL10; + +/** + * A TextureView that supports blocking rendering for synchronous drawing + */ +public class BlockingGLTextureView extends TextureView + implements SurfaceTextureListener { + + private RenderThread mRenderThread; + + public BlockingGLTextureView(Context context) { + super(context); + setSurfaceTextureListener(this); + } + + public void setRenderer(Renderer renderer) { + if (mRenderThread != null) { + throw new IllegalArgumentException("Renderer already set"); + } + mRenderThread = new RenderThread(renderer); + } + + public void render() { + mRenderThread.render(); + } + + public void destroy() { + if (mRenderThread != null) { + mRenderThread.finish(); + mRenderThread = null; + } + } + + @Override + public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, + int height) { + mRenderThread.setSurface(surface); + mRenderThread.setSize(width, height); + } + + @Override + public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, + int height) { + mRenderThread.setSize(width, height); + } + + @Override + public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) { + if (mRenderThread != null) { + mRenderThread.setSurface(null); + } + return false; + } + + @Override + public void onSurfaceTextureUpdated(SurfaceTexture surface) { + } + + @Override + protected void finalize() throws Throwable { + try { + destroy(); + } catch (Throwable t) { + // Ignore + } + super.finalize(); + } + + /** + * An EGL helper class. + */ + + private static class EglHelper { + private static final int EGL_CONTEXT_CLIENT_VERSION = 0x3098; + private static final int EGL_OPENGL_ES2_BIT = 4; + + EGL10 mEgl; + EGLDisplay mEglDisplay; + EGLSurface mEglSurface; + EGLConfig mEglConfig; + EGLContext mEglContext; + + private EGLConfig chooseEglConfig() { + int[] configsCount = new int[1]; + EGLConfig[] configs = new EGLConfig[1]; + int[] configSpec = getConfig(); + if (!mEgl.eglChooseConfig(mEglDisplay, configSpec, configs, 1, configsCount)) { + throw new IllegalArgumentException("eglChooseConfig failed " + + GLUtils.getEGLErrorString(mEgl.eglGetError())); + } else if (configsCount[0] > 0) { + return configs[0]; + } + return null; + } + + private static int[] getConfig() { + return new int[] { + EGL10.EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, + EGL10.EGL_RED_SIZE, 8, + EGL10.EGL_GREEN_SIZE, 8, + EGL10.EGL_BLUE_SIZE, 8, + EGL10.EGL_ALPHA_SIZE, 8, + EGL10.EGL_DEPTH_SIZE, 0, + EGL10.EGL_STENCIL_SIZE, 0, + EGL10.EGL_NONE + }; + } + + EGLContext createContext(EGL10 egl, EGLDisplay eglDisplay, EGLConfig eglConfig) { + int[] attribList = { EGL_CONTEXT_CLIENT_VERSION, 2, EGL10.EGL_NONE }; + return egl.eglCreateContext(eglDisplay, eglConfig, EGL10.EGL_NO_CONTEXT, attribList); + } + + /** + * Initialize EGL for a given configuration spec. + */ + public void start() { + /* + * Get an EGL instance + */ + mEgl = (EGL10) EGLContext.getEGL(); + + /* + * Get to the default display. + */ + mEglDisplay = mEgl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY); + + if (mEglDisplay == EGL10.EGL_NO_DISPLAY) { + throw new RuntimeException("eglGetDisplay failed"); + } + + /* + * We can now initialize EGL for that display + */ + int[] version = new int[2]; + if (!mEgl.eglInitialize(mEglDisplay, version)) { + throw new RuntimeException("eglInitialize failed"); + } + mEglConfig = chooseEglConfig(); + + /* + * Create an EGL context. We want to do this as rarely as we can, because an + * EGL context is a somewhat heavy object. + */ + mEglContext = createContext(mEgl, mEglDisplay, mEglConfig); + + if (mEglContext == null || mEglContext == EGL10.EGL_NO_CONTEXT) { + mEglContext = null; + throwEglException("createContext"); + } + + mEglSurface = null; + } + + /** + * Create an egl surface for the current SurfaceTexture surface. If a surface + * already exists, destroy it before creating the new surface. + * + * @return true if the surface was created successfully. + */ + public boolean createSurface(SurfaceTexture surface) { + /* + * Check preconditions. + */ + if (mEgl == null) { + throw new RuntimeException("egl not initialized"); + } + if (mEglDisplay == null) { + throw new RuntimeException("eglDisplay not initialized"); + } + if (mEglConfig == null) { + throw new RuntimeException("mEglConfig not initialized"); + } + + /* + * The window size has changed, so we need to create a new + * surface. + */ + destroySurfaceImp(); + + /* + * Create an EGL surface we can render into. + */ + if (surface != null) { + mEglSurface = mEgl.eglCreateWindowSurface(mEglDisplay, mEglConfig, surface, null); + } else { + mEglSurface = null; + } + + if (mEglSurface == null || mEglSurface == EGL10.EGL_NO_SURFACE) { + int error = mEgl.eglGetError(); + if (error == EGL10.EGL_BAD_NATIVE_WINDOW) { + Log.e("EglHelper", "createWindowSurface returned EGL_BAD_NATIVE_WINDOW."); + } + return false; + } + + /* + * Before we can issue GL commands, we need to make sure + * the context is current and bound to a surface. + */ + if (!mEgl.eglMakeCurrent(mEglDisplay, mEglSurface, mEglSurface, mEglContext)) { + /* + * Could not make the context current, probably because the underlying + * SurfaceView surface has been destroyed. + */ + logEglErrorAsWarning("EGLHelper", "eglMakeCurrent", mEgl.eglGetError()); + return false; + } + + return true; + } + + /** + * Create a GL object for the current EGL context. + */ + public GL10 createGL() { + return (GL10) mEglContext.getGL(); + } + + /** + * Display the current render surface. + * @return the EGL error code from eglSwapBuffers. + */ + public int swap() { + if (!mEgl.eglSwapBuffers(mEglDisplay, mEglSurface)) { + return mEgl.eglGetError(); + } + return EGL10.EGL_SUCCESS; + } + + public void destroySurface() { + destroySurfaceImp(); + } + + private void destroySurfaceImp() { + if (mEglSurface != null && mEglSurface != EGL10.EGL_NO_SURFACE) { + mEgl.eglMakeCurrent(mEglDisplay, EGL10.EGL_NO_SURFACE, + EGL10.EGL_NO_SURFACE, + EGL10.EGL_NO_CONTEXT); + mEgl.eglDestroySurface(mEglDisplay, mEglSurface); + mEglSurface = null; + } + } + + public void finish() { + if (mEglContext != null) { + mEgl.eglDestroyContext(mEglDisplay, mEglContext); + mEglContext = null; + } + if (mEglDisplay != null) { + mEgl.eglTerminate(mEglDisplay); + mEglDisplay = null; + } + } + + private void throwEglException(String function) { + throwEglException(function, mEgl.eglGetError()); + } + + public static void throwEglException(String function, int error) { + String message = formatEglError(function, error); + throw new RuntimeException(message); + } + + public static void logEglErrorAsWarning(String tag, String function, int error) { + Log.w(tag, formatEglError(function, error)); + } + + public static String formatEglError(String function, int error) { + return function + " failed: " + error; + } + + } + + private static class RenderThread extends Thread { + private static final int INVALID = -1; + private static final int RENDER = 1; + private static final int CHANGE_SURFACE = 2; + private static final int RESIZE_SURFACE = 3; + private static final int FINISH = 4; + + private EglHelper mEglHelper = new EglHelper(); + + private Object mLock = new Object(); + private int mExecMsgId = INVALID; + private SurfaceTexture mSurface; + private Renderer mRenderer; + private int mWidth, mHeight; + + private boolean mFinished = false; + private GL10 mGL; + + public RenderThread(Renderer renderer) { + super("RenderThread"); + mRenderer = renderer; + start(); + } + + private void checkRenderer() { + if (mRenderer == null) { + throw new IllegalArgumentException("Renderer is null!"); + } + } + + private void checkSurface() { + if (mSurface == null) { + throw new IllegalArgumentException("surface is null!"); + } + } + + public void setSurface(SurfaceTexture surface) { + // If the surface is null we're being torn down, don't need a + // renderer then + if (surface != null) { + checkRenderer(); + } + mSurface = surface; + exec(CHANGE_SURFACE); + } + + public void setSize(int width, int height) { + checkRenderer(); + checkSurface(); + mWidth = width; + mHeight = height; + exec(RESIZE_SURFACE); + } + + public void render() { + checkRenderer(); + if (mSurface != null) { + exec(RENDER); + mSurface.updateTexImage(); + } + } + + public void finish() { + mSurface = null; + exec(FINISH); + try { + join(); + } catch (InterruptedException e) { + // Ignore + } + } + + private void exec(int msgid) { + synchronized (mLock) { + if (mExecMsgId != INVALID) { + throw new IllegalArgumentException( + "Message already set - multithreaded access?"); + } + mExecMsgId = msgid; + mLock.notify(); + try { + mLock.wait(); + } catch (InterruptedException e) { + // Ignore + } + } + } + + private void handleMessageLocked(int what) { + switch (what) { + case CHANGE_SURFACE: + if (mEglHelper.createSurface(mSurface)) { + mGL = mEglHelper.createGL(); + mRenderer.onSurfaceCreated(mGL, mEglHelper.mEglConfig); + } + break; + case RESIZE_SURFACE: + mRenderer.onSurfaceChanged(mGL, mWidth, mHeight); + break; + case RENDER: + mRenderer.onDrawFrame(mGL); + mEglHelper.swap(); + break; + case FINISH: + mEglHelper.destroySurface(); + mEglHelper.finish(); + mFinished = true; + break; + } + } + + @Override + public void run() { + synchronized (mLock) { + mEglHelper.start(); + while (!mFinished) { + while (mExecMsgId == INVALID) { + try { + mLock.wait(); + } catch (InterruptedException e) { + // Ignore + } + } + handleMessageLocked(mExecMsgId); + mExecMsgId = INVALID; + mLock.notify(); + } + mExecMsgId = FINISH; + } + } + } +} diff --git a/src/com/android/photos/views/GalleryThumbnailView.java b/src/com/android/photos/views/GalleryThumbnailView.java new file mode 100644 index 000000000..e5dd6f2ff --- /dev/null +++ b/src/com/android/photos/views/GalleryThumbnailView.java @@ -0,0 +1,883 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos.views; + +import android.content.Context; +import android.content.res.TypedArray; +import android.database.DataSetObserver; +import android.graphics.Canvas; +import android.support.v4.view.MotionEventCompat; +import android.support.v4.view.VelocityTrackerCompat; +import android.support.v4.view.ViewCompat; +import android.support.v4.widget.EdgeEffectCompat; +import android.util.AttributeSet; +import android.util.Log; +import android.util.SparseArray; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.widget.ListAdapter; +import android.widget.OverScroller; + +import java.util.ArrayList; + +public class GalleryThumbnailView extends ViewGroup { + + public interface GalleryThumbnailAdapter extends ListAdapter { + /** + * @param position Position to get the intrinsic aspect ratio for + * @return width / height + */ + float getIntrinsicAspectRatio(int position); + } + + private static final String TAG = "GalleryThumbnailView"; + private static final float ASPECT_RATIO = (float) Math.sqrt(1.5f); + private static final int LAND_UNITS = 2; + private static final int PORT_UNITS = 3; + + private GalleryThumbnailAdapter mAdapter; + + private final RecycleBin mRecycler = new RecycleBin(); + + private final AdapterDataSetObserver mObserver = new AdapterDataSetObserver(); + + private boolean mDataChanged; + private int mOldItemCount; + private int mItemCount; + private boolean mHasStableIds; + + private int mFirstPosition; + + private boolean mPopulating; + private boolean mInLayout; + + private int mTouchSlop; + private int mMaximumVelocity; + private int mFlingVelocity; + private float mLastTouchX; + private float mTouchRemainderX; + private int mActivePointerId; + + private static final int TOUCH_MODE_IDLE = 0; + private static final int TOUCH_MODE_DRAGGING = 1; + private static final int TOUCH_MODE_FLINGING = 2; + + private int mTouchMode; + private final VelocityTracker mVelocityTracker = VelocityTracker.obtain(); + private final OverScroller mScroller; + + private final EdgeEffectCompat mLeftEdge; + private final EdgeEffectCompat mRightEdge; + + private int mLargeColumnWidth; + private int mSmallColumnWidth; + private int mLargeColumnUnitCount = 8; + private int mSmallColumnUnitCount = 10; + + public GalleryThumbnailView(Context context) { + this(context, null); + } + + public GalleryThumbnailView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public GalleryThumbnailView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + final ViewConfiguration vc = ViewConfiguration.get(context); + mTouchSlop = vc.getScaledTouchSlop(); + mMaximumVelocity = vc.getScaledMaximumFlingVelocity(); + mFlingVelocity = vc.getScaledMinimumFlingVelocity(); + mScroller = new OverScroller(context); + + mLeftEdge = new EdgeEffectCompat(context); + mRightEdge = new EdgeEffectCompat(context); + setWillNotDraw(false); + setClipToPadding(false); + } + + @Override + public void requestLayout() { + if (!mPopulating) { + super.requestLayout(); + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int widthMode = MeasureSpec.getMode(widthMeasureSpec); + int heightMode = MeasureSpec.getMode(heightMeasureSpec); + int widthSize = MeasureSpec.getSize(widthMeasureSpec); + int heightSize = MeasureSpec.getSize(heightMeasureSpec); + + if (widthMode != MeasureSpec.EXACTLY) { + Log.e(TAG, "onMeasure: must have an exact width or match_parent! " + + "Using fallback spec of EXACTLY " + widthSize); + } + if (heightMode != MeasureSpec.EXACTLY) { + Log.e(TAG, "onMeasure: must have an exact height or match_parent! " + + "Using fallback spec of EXACTLY " + heightSize); + } + + setMeasuredDimension(widthSize, heightSize); + + float portSpaces = mLargeColumnUnitCount / PORT_UNITS; + float height = getMeasuredHeight() / portSpaces; + mLargeColumnWidth = (int) (height / ASPECT_RATIO); + portSpaces++; + height = getMeasuredHeight() / portSpaces; + mSmallColumnWidth = (int) (height / ASPECT_RATIO); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + mInLayout = true; + populate(); + mInLayout = false; + + final int width = r - l; + final int height = b - t; + mLeftEdge.setSize(width, height); + mRightEdge.setSize(width, height); + } + + private void populate() { + if (getWidth() == 0 || getHeight() == 0) { + return; + } + + // TODO: Handle size changing +// final int colCount = mColCount; +// if (mItemTops == null || mItemTops.length != colCount) { +// mItemTops = new int[colCount]; +// mItemBottoms = new int[colCount]; +// final int top = getPaddingTop(); +// final int offset = top + Math.min(mRestoreOffset, 0); +// Arrays.fill(mItemTops, offset); +// Arrays.fill(mItemBottoms, offset); +// mLayoutRecords.clear(); +// if (mInLayout) { +// removeAllViewsInLayout(); +// } else { +// removeAllViews(); +// } +// mRestoreOffset = 0; +// } + + mPopulating = true; + layoutChildren(mDataChanged); + fillRight(mFirstPosition + getChildCount(), 0); + fillLeft(mFirstPosition - 1, 0); + mPopulating = false; + mDataChanged = false; + } + + final void layoutChildren(boolean queryAdapter) { +// TODO +// final int childCount = getChildCount(); +// for (int i = 0; i < childCount; i++) { +// View child = getChildAt(i); +// +// if (child.isLayoutRequested()) { +// final int widthSpec = MeasureSpec.makeMeasureSpec(child.getMeasuredWidth(), MeasureSpec.EXACTLY); +// final int heightSpec = MeasureSpec.makeMeasureSpec(child.getMeasuredHeight(), MeasureSpec.EXACTLY); +// child.measure(widthSpec, heightSpec); +// child.layout(child.getLeft(), child.getTop(), child.getRight(), child.getBottom()); +// } +// +// int childTop = mItemBottoms[col] > Integer.MIN_VALUE ? +// mItemBottoms[col] + mItemMargin : child.getTop(); +// if (span > 1) { +// int lowest = childTop; +// for (int j = col + 1; j < col + span; j++) { +// final int bottom = mItemBottoms[j] + mItemMargin; +// if (bottom > lowest) { +// lowest = bottom; +// } +// } +// childTop = lowest; +// } +// final int childHeight = child.getMeasuredHeight(); +// final int childBottom = childTop + childHeight; +// final int childLeft = paddingLeft + col * (colWidth + itemMargin); +// final int childRight = childLeft + child.getMeasuredWidth(); +// child.layout(childLeft, childTop, childRight, childBottom); +// } + } + + /** + * Obtain the view and add it to our list of children. The view can be made + * fresh, converted from an unused view, or used as is if it was in the + * recycle bin. + * + * @param startPosition Logical position in the list to start from + * @param x Left or right edge of the view to add + * @param forward If true, align left edge to x and increase position. + * If false, align right edge to x and decrease position. + * @return Number of views added + */ + private int makeAndAddColumn(int startPosition, int x, boolean forward) { + int columnWidth = mLargeColumnWidth; + int addViews = 0; + for (int remaining = mLargeColumnUnitCount, i = 0; + remaining > 0 && startPosition + i >= 0 && startPosition + i < mItemCount; + i += forward ? 1 : -1, addViews++) { + if (mAdapter.getIntrinsicAspectRatio(startPosition + i) >= 1f) { + // landscape + remaining -= LAND_UNITS; + } else { + // portrait + remaining -= PORT_UNITS; + if (remaining < 0) { + remaining += (mSmallColumnUnitCount - mLargeColumnUnitCount); + columnWidth = mSmallColumnWidth; + } + } + } + int nextTop = 0; + for (int i = 0; i < addViews; i++) { + int position = startPosition + (forward ? i : -i); + View child = obtainView(position, null); + if (child.getParent() != this) { + if (mInLayout) { + addViewInLayout(child, forward ? -1 : 0, child.getLayoutParams()); + } else { + addView(child, forward ? -1 : 0); + } + } + int heightSize = (int) (.5f + (mAdapter.getIntrinsicAspectRatio(position) >= 1f + ? columnWidth / ASPECT_RATIO + : columnWidth * ASPECT_RATIO)); + int heightSpec = MeasureSpec.makeMeasureSpec(heightSize, MeasureSpec.EXACTLY); + int widthSpec = MeasureSpec.makeMeasureSpec(columnWidth, MeasureSpec.EXACTLY); + child.measure(widthSpec, heightSpec); + int childLeft = forward ? x : x - columnWidth; + child.layout(childLeft, nextTop, childLeft + columnWidth, nextTop + heightSize); + nextTop += heightSize; + } + return addViews; + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + mVelocityTracker.addMovement(ev); + final int action = ev.getAction() & MotionEventCompat.ACTION_MASK; + switch (action) { + case MotionEvent.ACTION_DOWN: + mVelocityTracker.clear(); + mScroller.abortAnimation(); + mLastTouchX = ev.getX(); + mActivePointerId = MotionEventCompat.getPointerId(ev, 0); + mTouchRemainderX = 0; + if (mTouchMode == TOUCH_MODE_FLINGING) { + // Catch! + mTouchMode = TOUCH_MODE_DRAGGING; + return true; + } + break; + + case MotionEvent.ACTION_MOVE: { + final int index = MotionEventCompat.findPointerIndex(ev, mActivePointerId); + if (index < 0) { + Log.e(TAG, "onInterceptTouchEvent could not find pointer with id " + + mActivePointerId + " - did StaggeredGridView receive an inconsistent " + + "event stream?"); + return false; + } + final float x = MotionEventCompat.getX(ev, index); + final float dx = x - mLastTouchX + mTouchRemainderX; + final int deltaY = (int) dx; + mTouchRemainderX = dx - deltaY; + + if (Math.abs(dx) > mTouchSlop) { + mTouchMode = TOUCH_MODE_DRAGGING; + return true; + } + } + } + + return false; + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + mVelocityTracker.addMovement(ev); + final int action = ev.getAction() & MotionEventCompat.ACTION_MASK; + switch (action) { + case MotionEvent.ACTION_DOWN: + mVelocityTracker.clear(); + mScroller.abortAnimation(); + mLastTouchX = ev.getX(); + mActivePointerId = MotionEventCompat.getPointerId(ev, 0); + mTouchRemainderX = 0; + break; + + case MotionEvent.ACTION_MOVE: { + final int index = MotionEventCompat.findPointerIndex(ev, mActivePointerId); + if (index < 0) { + Log.e(TAG, "onInterceptTouchEvent could not find pointer with id " + + mActivePointerId + " - did StaggeredGridView receive an inconsistent " + + "event stream?"); + return false; + } + final float x = MotionEventCompat.getX(ev, index); + final float dx = x - mLastTouchX + mTouchRemainderX; + final int deltaX = (int) dx; + mTouchRemainderX = dx - deltaX; + + if (Math.abs(dx) > mTouchSlop) { + mTouchMode = TOUCH_MODE_DRAGGING; + } + + if (mTouchMode == TOUCH_MODE_DRAGGING) { + mLastTouchX = x; + + if (!trackMotionScroll(deltaX, true)) { + // Break fling velocity if we impacted an edge. + mVelocityTracker.clear(); + } + } + } break; + + case MotionEvent.ACTION_CANCEL: + mTouchMode = TOUCH_MODE_IDLE; + break; + + case MotionEvent.ACTION_UP: { + mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity); + final float velocity = VelocityTrackerCompat.getXVelocity(mVelocityTracker, + mActivePointerId); + if (Math.abs(velocity) > mFlingVelocity) { // TODO + mTouchMode = TOUCH_MODE_FLINGING; + mScroller.fling(0, 0, (int) velocity, 0, + Integer.MIN_VALUE, Integer.MAX_VALUE, 0, 0); + mLastTouchX = 0; + ViewCompat.postInvalidateOnAnimation(this); + } else { + mTouchMode = TOUCH_MODE_IDLE; + } + + } break; + } + return true; + } + + /** + * + * @param deltaX Pixels that content should move by + * @return true if the movement completed, false if it was stopped prematurely. + */ + private boolean trackMotionScroll(int deltaX, boolean allowOverScroll) { + final boolean contentFits = contentFits(); + final int allowOverhang = Math.abs(deltaX); + + final int overScrolledBy; + final int movedBy; + if (!contentFits) { + final int overhang; + final boolean up; + mPopulating = true; + if (deltaX > 0) { + overhang = fillLeft(mFirstPosition - 1, allowOverhang); + up = true; + } else { + overhang = fillRight(mFirstPosition + getChildCount(), allowOverhang); + up = false; + } + movedBy = Math.min(overhang, allowOverhang); + offsetChildren(up ? movedBy : -movedBy); + recycleOffscreenViews(); + mPopulating = false; + overScrolledBy = allowOverhang - overhang; + } else { + overScrolledBy = allowOverhang; + movedBy = 0; + } + + if (allowOverScroll) { + final int overScrollMode = ViewCompat.getOverScrollMode(this); + + if (overScrollMode == ViewCompat.OVER_SCROLL_ALWAYS || + (overScrollMode == ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS && !contentFits)) { + + if (overScrolledBy > 0) { + EdgeEffectCompat edge = deltaX > 0 ? mLeftEdge : mRightEdge; + edge.onPull((float) Math.abs(deltaX) / getWidth()); + ViewCompat.postInvalidateOnAnimation(this); + } + } + } + + return deltaX == 0 || movedBy != 0; + } + + /** + * Important: this method will leave offscreen views attached if they + * are required to maintain the invariant that child view with index i + * is always the view corresponding to position mFirstPosition + i. + */ + private void recycleOffscreenViews() { + final int height = getHeight(); + final int clearAbove = 0; + final int clearBelow = height; + for (int i = getChildCount() - 1; i >= 0; i--) { + final View child = getChildAt(i); + if (child.getTop() <= clearBelow) { + // There may be other offscreen views, but we need to maintain + // the invariant documented above. + break; + } + + if (mInLayout) { + removeViewsInLayout(i, 1); + } else { + removeViewAt(i); + } + + mRecycler.addScrap(child); + } + + while (getChildCount() > 0) { + final View child = getChildAt(0); + if (child.getBottom() >= clearAbove) { + // There may be other offscreen views, but we need to maintain + // the invariant documented above. + break; + } + + if (mInLayout) { + removeViewsInLayout(0, 1); + } else { + removeViewAt(0); + } + + mRecycler.addScrap(child); + mFirstPosition++; + } + } + + final void offsetChildren(int offset) { + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + child.layout(child.getLeft() + offset, child.getTop(), + child.getRight() + offset, child.getBottom()); + } + } + + private boolean contentFits() { + final int childCount = getChildCount(); + if (childCount == 0) return true; + if (childCount != mItemCount) return false; + + return getChildAt(0).getLeft() >= getPaddingLeft() && + getChildAt(childCount - 1).getRight() <= getWidth() - getPaddingRight(); + } + + private void recycleAllViews() { + for (int i = 0; i < getChildCount(); i++) { + mRecycler.addScrap(getChildAt(i)); + } + + if (mInLayout) { + removeAllViewsInLayout(); + } else { + removeAllViews(); + } + } + + private int fillRight(int pos, int overhang) { + int end = (getRight() - getLeft()) + overhang; + + int nextLeft = getChildCount() == 0 ? 0 : getChildAt(getChildCount() - 1).getRight(); + while (nextLeft < end && pos < mItemCount) { + pos += makeAndAddColumn(pos, nextLeft, true); + nextLeft = getChildAt(getChildCount() - 1).getRight(); + } + final int gridRight = getWidth() - getPaddingRight(); + return getChildAt(getChildCount() - 1).getRight() - gridRight; + } + + private int fillLeft(int pos, int overhang) { + int end = getPaddingLeft() - overhang; + + int nextRight = getChildAt(0).getLeft(); + while (nextRight > end && pos >= 0) { + pos -= makeAndAddColumn(pos, nextRight, false); + nextRight = getChildAt(0).getLeft(); + } + + mFirstPosition = pos + 1; + return getPaddingLeft() - getChildAt(0).getLeft(); + } + + @Override + public void computeScroll() { + if (mScroller.computeScrollOffset()) { + final int x = mScroller.getCurrX(); + final int dx = (int) (x - mLastTouchX); + mLastTouchX = x; + final boolean stopped = !trackMotionScroll(dx, false); + + if (!stopped && !mScroller.isFinished()) { + ViewCompat.postInvalidateOnAnimation(this); + } else { + if (stopped) { + final int overScrollMode = ViewCompat.getOverScrollMode(this); + if (overScrollMode != ViewCompat.OVER_SCROLL_NEVER) { + final EdgeEffectCompat edge; + if (dx > 0) { + edge = mLeftEdge; + } else { + edge = mRightEdge; + } + edge.onAbsorb(Math.abs((int) mScroller.getCurrVelocity())); + ViewCompat.postInvalidateOnAnimation(this); + } + mScroller.abortAnimation(); + } + mTouchMode = TOUCH_MODE_IDLE; + } + } + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + + if (!mLeftEdge.isFinished()) { + final int restoreCount = canvas.save(); + final int height = getHeight() - getPaddingTop() - getPaddingBottom(); + + canvas.rotate(270); + canvas.translate(-height + getPaddingTop(), 0); + mLeftEdge.setSize(height, getWidth()); + if (mLeftEdge.draw(canvas)) { + postInvalidateOnAnimation(); + } + canvas.restoreToCount(restoreCount); + } + if (!mRightEdge.isFinished()) { + final int restoreCount = canvas.save(); + final int width = getWidth(); + final int height = getHeight() - getPaddingTop() - getPaddingBottom(); + + canvas.rotate(90); + canvas.translate(-getPaddingTop(), width); + mRightEdge.setSize(height, width); + if (mRightEdge.draw(canvas)) { + postInvalidateOnAnimation(); + } + canvas.restoreToCount(restoreCount); + } + } + + /** + * Obtain a populated view from the adapter. If optScrap is non-null and is not + * reused it will be placed in the recycle bin. + * + * @param position position to get view for + * @param optScrap Optional scrap view; will be reused if possible + * @return A new view, a recycled view from mRecycler, or optScrap + */ + private final View obtainView(int position, View optScrap) { + View view = mRecycler.getTransientStateView(position); + if (view != null) { + return view; + } + + // Reuse optScrap if it's of the right type (and not null) + final int optType = optScrap != null ? + ((LayoutParams) optScrap.getLayoutParams()).viewType : -1; + final int positionViewType = mAdapter.getItemViewType(position); + final View scrap = optType == positionViewType ? + optScrap : mRecycler.getScrapView(positionViewType); + + view = mAdapter.getView(position, scrap, this); + + if (view != scrap && scrap != null) { + // The adapter didn't use it; put it back. + mRecycler.addScrap(scrap); + } + + ViewGroup.LayoutParams lp = view.getLayoutParams(); + + if (view.getParent() != this) { + if (lp == null) { + lp = generateDefaultLayoutParams(); + } else if (!checkLayoutParams(lp)) { + lp = generateLayoutParams(lp); + } + view.setLayoutParams(lp); + } + + final LayoutParams sglp = (LayoutParams) lp; + sglp.position = position; + sglp.viewType = positionViewType; + + return view; + } + + public GalleryThumbnailAdapter getAdapter() { + return mAdapter; + } + + public void setAdapter(GalleryThumbnailAdapter adapter) { + if (mAdapter != null) { + mAdapter.unregisterDataSetObserver(mObserver); + } + // TODO: If the new adapter says that there are stable IDs, remove certain layout records + // and onscreen views if they have changed instead of removing all of the state here. + clearAllState(); + mAdapter = adapter; + mDataChanged = true; + mOldItemCount = mItemCount = adapter != null ? adapter.getCount() : 0; + if (adapter != null) { + adapter.registerDataSetObserver(mObserver); + mRecycler.setViewTypeCount(adapter.getViewTypeCount()); + mHasStableIds = adapter.hasStableIds(); + } else { + mHasStableIds = false; + } + populate(); + } + + /** + * Clear all state because the grid will be used for a completely different set of data. + */ + private void clearAllState() { + // Clear all layout records and views + removeAllViews(); + + // Reset to the top of the grid + mFirstPosition = 0; + + // Clear recycler because there could be different view types now + mRecycler.clear(); + } + + @Override + protected LayoutParams generateDefaultLayoutParams() { + return new LayoutParams(LayoutParams.WRAP_CONTENT); + } + + @Override + protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams lp) { + return new LayoutParams(lp); + } + + @Override + protected boolean checkLayoutParams(ViewGroup.LayoutParams lp) { + return lp instanceof LayoutParams; + } + + @Override + public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) { + return new LayoutParams(getContext(), attrs); + } + + public static class LayoutParams extends ViewGroup.LayoutParams { + private static final int[] LAYOUT_ATTRS = new int[] { + android.R.attr.layout_span + }; + + private static final int SPAN_INDEX = 0; + + /** + * The number of columns this item should span + */ + public int span = 1; + + /** + * Item position this view represents + */ + int position; + + /** + * Type of this view as reported by the adapter + */ + int viewType; + + /** + * The column this view is occupying + */ + int column; + + /** + * The stable ID of the item this view displays + */ + long id = -1; + + public LayoutParams(int height) { + super(MATCH_PARENT, height); + + if (this.height == MATCH_PARENT) { + Log.w(TAG, "Constructing LayoutParams with height MATCH_PARENT - " + + "impossible! Falling back to WRAP_CONTENT"); + this.height = WRAP_CONTENT; + } + } + + public LayoutParams(Context c, AttributeSet attrs) { + super(c, attrs); + + if (this.width != MATCH_PARENT) { + Log.w(TAG, "Inflation setting LayoutParams width to " + this.width + + " - must be MATCH_PARENT"); + this.width = MATCH_PARENT; + } + if (this.height == MATCH_PARENT) { + Log.w(TAG, "Inflation setting LayoutParams height to MATCH_PARENT - " + + "impossible! Falling back to WRAP_CONTENT"); + this.height = WRAP_CONTENT; + } + + TypedArray a = c.obtainStyledAttributes(attrs, LAYOUT_ATTRS); + span = a.getInteger(SPAN_INDEX, 1); + a.recycle(); + } + + public LayoutParams(ViewGroup.LayoutParams other) { + super(other); + + if (this.width != MATCH_PARENT) { + Log.w(TAG, "Constructing LayoutParams with width " + this.width + + " - must be MATCH_PARENT"); + this.width = MATCH_PARENT; + } + if (this.height == MATCH_PARENT) { + Log.w(TAG, "Constructing LayoutParams with height MATCH_PARENT - " + + "impossible! Falling back to WRAP_CONTENT"); + this.height = WRAP_CONTENT; + } + } + } + + private class RecycleBin { + private ArrayList<View>[] mScrapViews; + private int mViewTypeCount; + private int mMaxScrap; + + private SparseArray<View> mTransientStateViews; + + public void setViewTypeCount(int viewTypeCount) { + if (viewTypeCount < 1) { + throw new IllegalArgumentException("Must have at least one view type (" + + viewTypeCount + " types reported)"); + } + if (viewTypeCount == mViewTypeCount) { + return; + } + + ArrayList<View>[] scrapViews = new ArrayList[viewTypeCount]; + for (int i = 0; i < viewTypeCount; i++) { + scrapViews[i] = new ArrayList<View>(); + } + mViewTypeCount = viewTypeCount; + mScrapViews = scrapViews; + } + + public void clear() { + final int typeCount = mViewTypeCount; + for (int i = 0; i < typeCount; i++) { + mScrapViews[i].clear(); + } + if (mTransientStateViews != null) { + mTransientStateViews.clear(); + } + } + + public void clearTransientViews() { + if (mTransientStateViews != null) { + mTransientStateViews.clear(); + } + } + + public void addScrap(View v) { + final LayoutParams lp = (LayoutParams) v.getLayoutParams(); + if (ViewCompat.hasTransientState(v)) { + if (mTransientStateViews == null) { + mTransientStateViews = new SparseArray<View>(); + } + mTransientStateViews.put(lp.position, v); + return; + } + + final int childCount = getChildCount(); + if (childCount > mMaxScrap) { + mMaxScrap = childCount; + } + + ArrayList<View> scrap = mScrapViews[lp.viewType]; + if (scrap.size() < mMaxScrap) { + scrap.add(v); + } + } + + public View getTransientStateView(int position) { + if (mTransientStateViews == null) { + return null; + } + + final View result = mTransientStateViews.get(position); + if (result != null) { + mTransientStateViews.remove(position); + } + return result; + } + + public View getScrapView(int type) { + ArrayList<View> scrap = mScrapViews[type]; + if (scrap.isEmpty()) { + return null; + } + + final int index = scrap.size() - 1; + final View result = scrap.get(index); + scrap.remove(index); + return result; + } + } + + private class AdapterDataSetObserver extends DataSetObserver { + @Override + public void onChanged() { + mDataChanged = true; + mOldItemCount = mItemCount; + mItemCount = mAdapter.getCount(); + + // TODO: Consider matching these back up if we have stable IDs. + mRecycler.clearTransientViews(); + + if (!mHasStableIds) { + recycleAllViews(); + } + + // TODO: consider repopulating in a deferred runnable instead + // (so that successive changes may still be batched) + requestLayout(); + } + + @Override + public void onInvalidated() { + } + } +} diff --git a/src/com/android/photos/views/HeaderGridView.java b/src/com/android/photos/views/HeaderGridView.java new file mode 100644 index 000000000..45a5eaf73 --- /dev/null +++ b/src/com/android/photos/views/HeaderGridView.java @@ -0,0 +1,466 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos.views; + +import android.content.Context; +import android.database.DataSetObservable; +import android.database.DataSetObserver; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.Filter; +import android.widget.Filterable; +import android.widget.FrameLayout; +import android.widget.GridView; +import android.widget.ListAdapter; +import android.widget.WrapperListAdapter; + +import java.util.ArrayList; + +/** + * A {@link GridView} that supports adding header rows in a + * very similar way to {@link ListView}. + * See {@link HeaderGridView#addHeaderView(View, Object, boolean)} + */ +public class HeaderGridView extends GridView { + private static final String TAG = "HeaderGridView"; + + /** + * A class that represents a fixed view in a list, for example a header at the top + * or a footer at the bottom. + */ + private static class FixedViewInfo { + /** The view to add to the grid */ + public View view; + public ViewGroup viewContainer; + /** The data backing the view. This is returned from {@link ListAdapter#getItem(int)}. */ + public Object data; + /** <code>true</code> if the fixed view should be selectable in the grid */ + public boolean isSelectable; + } + + private ArrayList<FixedViewInfo> mHeaderViewInfos = new ArrayList<FixedViewInfo>(); + + private void initHeaderGridView() { + super.setClipChildren(false); + } + + public HeaderGridView(Context context) { + super(context); + initHeaderGridView(); + } + + public HeaderGridView(Context context, AttributeSet attrs) { + super(context, attrs); + initHeaderGridView(); + } + + public HeaderGridView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + initHeaderGridView(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + ListAdapter adapter = getAdapter(); + if (adapter != null && adapter instanceof HeaderViewGridAdapter) { + ((HeaderViewGridAdapter) adapter).setNumColumns(getNumColumns()); + } + } + + @Override + public void setClipChildren(boolean clipChildren) { + // Ignore, since the header rows depend on not being clipped + } + + /** + * Add a fixed view to appear at the top of the grid. If addHeaderView is + * called more than once, the views will appear in the order they were + * added. Views added using this call can take focus if they want. + * <p> + * NOTE: Call this before calling setAdapter. This is so HeaderGridView can wrap + * the supplied cursor with one that will also account for header views. + * + * @param v The view to add. + * @param data Data to associate with this view + * @param isSelectable whether the item is selectable + */ + public void addHeaderView(View v, Object data, boolean isSelectable) { + ListAdapter adapter = getAdapter(); + + if (adapter != null && ! (adapter instanceof HeaderViewGridAdapter)) { + throw new IllegalStateException( + "Cannot add header view to grid -- setAdapter has already been called."); + } + + FixedViewInfo info = new FixedViewInfo(); + FrameLayout fl = new FullWidthFixedViewLayout(getContext()); + fl.addView(v); + info.view = v; + info.viewContainer = fl; + info.data = data; + info.isSelectable = isSelectable; + mHeaderViewInfos.add(info); + + // in the case of re-adding a header view, or adding one later on, + // we need to notify the observer + if (adapter != null) { + ((HeaderViewGridAdapter) adapter).notifyDataSetChanged(); + } + } + + /** + * Add a fixed view to appear at the top of the grid. If addHeaderView is + * called more than once, the views will appear in the order they were + * added. Views added using this call can take focus if they want. + * <p> + * NOTE: Call this before calling setAdapter. This is so HeaderGridView can wrap + * the supplied cursor with one that will also account for header views. + * + * @param v The view to add. + */ + public void addHeaderView(View v) { + addHeaderView(v, null, true); + } + + public int getHeaderViewCount() { + return mHeaderViewInfos.size(); + } + + /** + * Removes a previously-added header view. + * + * @param v The view to remove + * @return true if the view was removed, false if the view was not a header + * view + */ + public boolean removeHeaderView(View v) { + if (mHeaderViewInfos.size() > 0) { + boolean result = false; + ListAdapter adapter = getAdapter(); + if (adapter != null && ((HeaderViewGridAdapter) adapter).removeHeader(v)) { + result = true; + } + removeFixedViewInfo(v, mHeaderViewInfos); + return result; + } + return false; + } + + private void removeFixedViewInfo(View v, ArrayList<FixedViewInfo> where) { + int len = where.size(); + for (int i = 0; i < len; ++i) { + FixedViewInfo info = where.get(i); + if (info.view == v) { + where.remove(i); + break; + } + } + } + + @Override + public void setAdapter(ListAdapter adapter) { + if (mHeaderViewInfos.size() > 0) { + HeaderViewGridAdapter hadapter = new HeaderViewGridAdapter(mHeaderViewInfos, adapter); + int numColumns = getNumColumns(); + if (numColumns > 1) { + hadapter.setNumColumns(numColumns); + } + super.setAdapter(hadapter); + } else { + super.setAdapter(adapter); + } + } + + private class FullWidthFixedViewLayout extends FrameLayout { + public FullWidthFixedViewLayout(Context context) { + super(context); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int targetWidth = HeaderGridView.this.getMeasuredWidth() + - HeaderGridView.this.getPaddingLeft() + - HeaderGridView.this.getPaddingRight(); + widthMeasureSpec = MeasureSpec.makeMeasureSpec(targetWidth, + MeasureSpec.getMode(widthMeasureSpec)); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + } + + /** + * ListAdapter used when a HeaderGridView has header views. This ListAdapter + * wraps another one and also keeps track of the header views and their + * associated data objects. + *<p>This is intended as a base class; you will probably not need to + * use this class directly in your own code. + */ + private static class HeaderViewGridAdapter implements WrapperListAdapter, Filterable { + + // This is used to notify the container of updates relating to number of columns + // or headers changing, which changes the number of placeholders needed + private final DataSetObservable mDataSetObservable = new DataSetObservable(); + + private final ListAdapter mAdapter; + private int mNumColumns = 1; + + // This ArrayList is assumed to NOT be null. + ArrayList<FixedViewInfo> mHeaderViewInfos; + + boolean mAreAllFixedViewsSelectable; + + private final boolean mIsFilterable; + + public HeaderViewGridAdapter(ArrayList<FixedViewInfo> headerViewInfos, ListAdapter adapter) { + mAdapter = adapter; + mIsFilterable = adapter instanceof Filterable; + + if (headerViewInfos == null) { + throw new IllegalArgumentException("headerViewInfos cannot be null"); + } + mHeaderViewInfos = headerViewInfos; + + mAreAllFixedViewsSelectable = areAllListInfosSelectable(mHeaderViewInfos); + } + + public int getHeadersCount() { + return mHeaderViewInfos.size(); + } + + @Override + public boolean isEmpty() { + return (mAdapter == null || mAdapter.isEmpty()) && getHeadersCount() == 0; + } + + public void setNumColumns(int numColumns) { + if (numColumns < 1) { + throw new IllegalArgumentException("Number of columns must be 1 or more"); + } + if (mNumColumns != numColumns) { + mNumColumns = numColumns; + notifyDataSetChanged(); + } + } + + private boolean areAllListInfosSelectable(ArrayList<FixedViewInfo> infos) { + if (infos != null) { + for (FixedViewInfo info : infos) { + if (!info.isSelectable) { + return false; + } + } + } + return true; + } + + public boolean removeHeader(View v) { + for (int i = 0; i < mHeaderViewInfos.size(); i++) { + FixedViewInfo info = mHeaderViewInfos.get(i); + if (info.view == v) { + mHeaderViewInfos.remove(i); + + mAreAllFixedViewsSelectable = areAllListInfosSelectable(mHeaderViewInfos); + + mDataSetObservable.notifyChanged(); + return true; + } + } + + return false; + } + + @Override + public int getCount() { + if (mAdapter != null) { + return getHeadersCount() * mNumColumns + mAdapter.getCount(); + } else { + return getHeadersCount() * mNumColumns; + } + } + + @Override + public boolean areAllItemsEnabled() { + if (mAdapter != null) { + return mAreAllFixedViewsSelectable && mAdapter.areAllItemsEnabled(); + } else { + return true; + } + } + + @Override + public boolean isEnabled(int position) { + // Header (negative positions will throw an ArrayIndexOutOfBoundsException) + int numHeadersAndPlaceholders = getHeadersCount() * mNumColumns; + if (position < numHeadersAndPlaceholders) { + return (position % mNumColumns == 0) + && mHeaderViewInfos.get(position / mNumColumns).isSelectable; + } + + // Adapter + final int adjPosition = position - numHeadersAndPlaceholders; + int adapterCount = 0; + if (mAdapter != null) { + adapterCount = mAdapter.getCount(); + if (adjPosition < adapterCount) { + return mAdapter.isEnabled(adjPosition); + } + } + + throw new ArrayIndexOutOfBoundsException(position); + } + + @Override + public Object getItem(int position) { + // Header (negative positions will throw an ArrayIndexOutOfBoundsException) + int numHeadersAndPlaceholders = getHeadersCount() * mNumColumns; + if (position < numHeadersAndPlaceholders) { + if (position % mNumColumns == 0) { + return mHeaderViewInfos.get(position / mNumColumns).data; + } + return null; + } + + // Adapter + final int adjPosition = position - numHeadersAndPlaceholders; + int adapterCount = 0; + if (mAdapter != null) { + adapterCount = mAdapter.getCount(); + if (adjPosition < adapterCount) { + return mAdapter.getItem(adjPosition); + } + } + + throw new ArrayIndexOutOfBoundsException(position); + } + + @Override + public long getItemId(int position) { + int numHeadersAndPlaceholders = getHeadersCount() * mNumColumns; + if (mAdapter != null && position >= numHeadersAndPlaceholders) { + int adjPosition = position - numHeadersAndPlaceholders; + int adapterCount = mAdapter.getCount(); + if (adjPosition < adapterCount) { + return mAdapter.getItemId(adjPosition); + } + } + return -1; + } + + @Override + public boolean hasStableIds() { + if (mAdapter != null) { + return mAdapter.hasStableIds(); + } + return false; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + // Header (negative positions will throw an ArrayIndexOutOfBoundsException) + int numHeadersAndPlaceholders = getHeadersCount() * mNumColumns ; + if (position < numHeadersAndPlaceholders) { + View headerViewContainer = mHeaderViewInfos + .get(position / mNumColumns).viewContainer; + if (position % mNumColumns == 0) { + return headerViewContainer; + } else { + if (convertView == null) { + convertView = new View(parent.getContext()); + } + // We need to do this because GridView uses the height of the last item + // in a row to determine the height for the entire row. + convertView.setVisibility(View.INVISIBLE); + convertView.setMinimumHeight(headerViewContainer.getHeight()); + return convertView; + } + } + + // Adapter + final int adjPosition = position - numHeadersAndPlaceholders; + int adapterCount = 0; + if (mAdapter != null) { + adapterCount = mAdapter.getCount(); + if (adjPosition < adapterCount) { + return mAdapter.getView(adjPosition, convertView, parent); + } + } + + throw new ArrayIndexOutOfBoundsException(position); + } + + @Override + public int getItemViewType(int position) { + int numHeadersAndPlaceholders = getHeadersCount() * mNumColumns; + if (position < numHeadersAndPlaceholders && (position % mNumColumns != 0)) { + // Placeholders get the last view type number + return mAdapter != null ? mAdapter.getViewTypeCount() : 1; + } + if (mAdapter != null && position >= numHeadersAndPlaceholders) { + int adjPosition = position - numHeadersAndPlaceholders; + int adapterCount = mAdapter.getCount(); + if (adjPosition < adapterCount) { + return mAdapter.getItemViewType(adjPosition); + } + } + + return AdapterView.ITEM_VIEW_TYPE_HEADER_OR_FOOTER; + } + + @Override + public int getViewTypeCount() { + if (mAdapter != null) { + return mAdapter.getViewTypeCount() + 1; + } + return 2; + } + + @Override + public void registerDataSetObserver(DataSetObserver observer) { + mDataSetObservable.registerObserver(observer); + if (mAdapter != null) { + mAdapter.registerDataSetObserver(observer); + } + } + + @Override + public void unregisterDataSetObserver(DataSetObserver observer) { + mDataSetObservable.unregisterObserver(observer); + if (mAdapter != null) { + mAdapter.unregisterDataSetObserver(observer); + } + } + + @Override + public Filter getFilter() { + if (mIsFilterable) { + return ((Filterable) mAdapter).getFilter(); + } + return null; + } + + @Override + public ListAdapter getWrappedAdapter() { + return mAdapter; + } + + public void notifyDataSetChanged() { + mDataSetObservable.notifyChanged(); + } + } +} diff --git a/src/com/android/photos/views/SquareImageView.java b/src/com/android/photos/views/SquareImageView.java new file mode 100644 index 000000000..14eff1077 --- /dev/null +++ b/src/com/android/photos/views/SquareImageView.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos.views; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.ImageView; + + +public class SquareImageView extends ImageView { + + public SquareImageView(Context context) { + super(context); + } + + public SquareImageView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SquareImageView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int widthMode = MeasureSpec.getMode(widthMeasureSpec); + int heightMode = MeasureSpec.getMode(heightMeasureSpec); + if (widthMode == MeasureSpec.EXACTLY && heightMode != MeasureSpec.EXACTLY) { + int width = MeasureSpec.getSize(widthMeasureSpec); + int height = width; + if (heightMode == MeasureSpec.AT_MOST) { + height = Math.min(height, MeasureSpec.getSize(heightMeasureSpec)); + } + setMeasuredDimension(width, height); + } else { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + } +} diff --git a/src/com/android/photos/views/TiledImageRenderer.java b/src/com/android/photos/views/TiledImageRenderer.java new file mode 100644 index 000000000..c4e493b34 --- /dev/null +++ b/src/com/android/photos/views/TiledImageRenderer.java @@ -0,0 +1,825 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos.views; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Rect; +import android.graphics.RectF; +import android.support.v4.util.LongSparseArray; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.Pools.Pool; +import android.util.Pools.SynchronizedPool; +import android.view.View; +import android.view.WindowManager; + +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.glrenderer.BasicTexture; +import com.android.gallery3d.glrenderer.GLCanvas; +import com.android.gallery3d.glrenderer.UploadedTexture; + +/** + * Handles laying out, decoding, and drawing of tiles in GL + */ +public class TiledImageRenderer { + public static final int SIZE_UNKNOWN = -1; + + private static final String TAG = "TiledImageRenderer"; + private static final int UPLOAD_LIMIT = 1; + + /* + * This is the tile state in the CPU side. + * Life of a Tile: + * ACTIVATED (initial state) + * --> IN_QUEUE - by queueForDecode() + * --> RECYCLED - by recycleTile() + * IN_QUEUE --> DECODING - by decodeTile() + * --> RECYCLED - by recycleTile) + * DECODING --> RECYCLING - by recycleTile() + * --> DECODED - by decodeTile() + * --> DECODE_FAIL - by decodeTile() + * RECYCLING --> RECYCLED - by decodeTile() + * DECODED --> ACTIVATED - (after the decoded bitmap is uploaded) + * DECODED --> RECYCLED - by recycleTile() + * DECODE_FAIL -> RECYCLED - by recycleTile() + * RECYCLED --> ACTIVATED - by obtainTile() + */ + private static final int STATE_ACTIVATED = 0x01; + private static final int STATE_IN_QUEUE = 0x02; + private static final int STATE_DECODING = 0x04; + private static final int STATE_DECODED = 0x08; + private static final int STATE_DECODE_FAIL = 0x10; + private static final int STATE_RECYCLING = 0x20; + private static final int STATE_RECYCLED = 0x40; + + private static Pool<Bitmap> sTilePool = new SynchronizedPool<Bitmap>(64); + + // TILE_SIZE must be 2^N + private int mTileSize; + + private TileSource mModel; + private BasicTexture mPreview; + protected int mLevelCount; // cache the value of mScaledBitmaps.length + + // The mLevel variable indicates which level of bitmap we should use. + // Level 0 means the original full-sized bitmap, and a larger value means + // a smaller scaled bitmap (The width and height of each scaled bitmap is + // half size of the previous one). If the value is in [0, mLevelCount), we + // use the bitmap in mScaledBitmaps[mLevel] for display, otherwise the value + // is mLevelCount + private int mLevel = 0; + + private int mOffsetX; + private int mOffsetY; + + private int mUploadQuota; + private boolean mRenderComplete; + + private final RectF mSourceRect = new RectF(); + private final RectF mTargetRect = new RectF(); + + private final LongSparseArray<Tile> mActiveTiles = new LongSparseArray<Tile>(); + + // The following three queue are guarded by mQueueLock + private final Object mQueueLock = new Object(); + private final TileQueue mRecycledQueue = new TileQueue(); + private final TileQueue mUploadQueue = new TileQueue(); + private final TileQueue mDecodeQueue = new TileQueue(); + + // The width and height of the full-sized bitmap + protected int mImageWidth = SIZE_UNKNOWN; + protected int mImageHeight = SIZE_UNKNOWN; + + protected int mCenterX; + protected int mCenterY; + protected float mScale; + protected int mRotation; + + private boolean mLayoutTiles; + + // Temp variables to avoid memory allocation + private final Rect mTileRange = new Rect(); + private final Rect mActiveRange[] = {new Rect(), new Rect()}; + + private TileDecoder mTileDecoder; + private boolean mBackgroundTileUploaded; + + private int mViewWidth, mViewHeight; + private View mParent; + + /** + * Interface for providing tiles to a {@link TiledImageRenderer} + */ + public static interface TileSource { + + /** + * If the source does not care about the tile size, it should use + * {@link TiledImageRenderer#suggestedTileSize(Context)} + */ + public int getTileSize(); + public int getImageWidth(); + public int getImageHeight(); + public int getRotation(); + + /** + * Return a Preview image if available. This will be used as the base layer + * if higher res tiles are not yet available + */ + public BasicTexture getPreview(); + + /** + * The tile returned by this method can be specified this way: Assuming + * the image size is (width, height), first take the intersection of (0, + * 0) - (width, height) and (x, y) - (x + tileSize, y + tileSize). If + * in extending the region, we found some part of the region is outside + * the image, those pixels are filled with black. + * + * If level > 0, it does the same operation on a down-scaled version of + * the original image (down-scaled by a factor of 2^level), but (x, y) + * still refers to the coordinate on the original image. + * + * The method would be called by the decoder thread. + */ + public Bitmap getTile(int level, int x, int y, Bitmap reuse); + } + + public static int suggestedTileSize(Context context) { + return isHighResolution(context) ? 512 : 256; + } + + private static boolean isHighResolution(Context context) { + DisplayMetrics metrics = new DisplayMetrics(); + WindowManager wm = (WindowManager) + context.getSystemService(Context.WINDOW_SERVICE); + wm.getDefaultDisplay().getMetrics(metrics); + return metrics.heightPixels > 2048 || metrics.widthPixels > 2048; + } + + public TiledImageRenderer(View parent) { + mParent = parent; + mTileDecoder = new TileDecoder(); + mTileDecoder.start(); + } + + public int getViewWidth() { + return mViewWidth; + } + + public int getViewHeight() { + return mViewHeight; + } + + private void invalidate() { + mParent.postInvalidate(); + } + + public void setModel(TileSource model, int rotation) { + if (mModel != model) { + mModel = model; + notifyModelInvalidated(); + } + if (mRotation != rotation) { + mRotation = rotation; + mLayoutTiles = true; + } + } + + private void calculateLevelCount() { + if (mPreview != null) { + mLevelCount = Math.max(0, Utils.ceilLog2( + mImageWidth / (float) mPreview.getWidth())); + } else { + int levels = 1; + int maxDim = Math.max(mImageWidth, mImageHeight); + int t = mTileSize; + while (t < maxDim) { + t <<= 1; + levels++; + } + mLevelCount = levels; + } + } + + public void notifyModelInvalidated() { + invalidateTiles(); + if (mModel == null) { + mImageWidth = 0; + mImageHeight = 0; + mLevelCount = 0; + mPreview = null; + } else { + mImageWidth = mModel.getImageWidth(); + mImageHeight = mModel.getImageHeight(); + mPreview = mModel.getPreview(); + mTileSize = mModel.getTileSize(); + calculateLevelCount(); + } + mLayoutTiles = true; + } + + public void setViewSize(int width, int height) { + mViewWidth = width; + mViewHeight = height; + } + + public void setPosition(int centerX, int centerY, float scale) { + if (mCenterX == centerX && mCenterY == centerY + && mScale == scale) { + return; + } + mCenterX = centerX; + mCenterY = centerY; + mScale = scale; + mLayoutTiles = true; + } + + // Prepare the tiles we want to use for display. + // + // 1. Decide the tile level we want to use for display. + // 2. Decide the tile levels we want to keep as texture (in addition to + // the one we use for display). + // 3. Recycle unused tiles. + // 4. Activate the tiles we want. + private void layoutTiles() { + if (mViewWidth == 0 || mViewHeight == 0 || !mLayoutTiles) { + return; + } + mLayoutTiles = false; + + // The tile levels we want to keep as texture is in the range + // [fromLevel, endLevel). + int fromLevel; + int endLevel; + + // We want to use a texture larger than or equal to the display size. + mLevel = Utils.clamp(Utils.floorLog2(1f / mScale), 0, mLevelCount); + + // We want to keep one more tile level as texture in addition to what + // we use for display. So it can be faster when the scale moves to the + // next level. We choose the level closest to the current scale. + if (mLevel != mLevelCount) { + Rect range = mTileRange; + getRange(range, mCenterX, mCenterY, mLevel, mScale, mRotation); + mOffsetX = Math.round(mViewWidth / 2f + (range.left - mCenterX) * mScale); + mOffsetY = Math.round(mViewHeight / 2f + (range.top - mCenterY) * mScale); + fromLevel = mScale * (1 << mLevel) > 0.75f ? mLevel - 1 : mLevel; + } else { + // Activate the tiles of the smallest two levels. + fromLevel = mLevel - 2; + mOffsetX = Math.round(mViewWidth / 2f - mCenterX * mScale); + mOffsetY = Math.round(mViewHeight / 2f - mCenterY * mScale); + } + + fromLevel = Math.max(0, Math.min(fromLevel, mLevelCount - 2)); + endLevel = Math.min(fromLevel + 2, mLevelCount); + + Rect range[] = mActiveRange; + for (int i = fromLevel; i < endLevel; ++i) { + getRange(range[i - fromLevel], mCenterX, mCenterY, i, mRotation); + } + + // If rotation is transient, don't update the tile. + if (mRotation % 90 != 0) { + return; + } + + synchronized (mQueueLock) { + mDecodeQueue.clean(); + mUploadQueue.clean(); + mBackgroundTileUploaded = false; + + // Recycle unused tiles: if the level of the active tile is outside the + // range [fromLevel, endLevel) or not in the visible range. + int n = mActiveTiles.size(); + for (int i = 0; i < n; i++) { + Tile tile = mActiveTiles.valueAt(i); + int level = tile.mTileLevel; + if (level < fromLevel || level >= endLevel + || !range[level - fromLevel].contains(tile.mX, tile.mY)) { + mActiveTiles.removeAt(i); + i--; + n--; + recycleTile(tile); + } + } + } + + for (int i = fromLevel; i < endLevel; ++i) { + int size = mTileSize << i; + Rect r = range[i - fromLevel]; + for (int y = r.top, bottom = r.bottom; y < bottom; y += size) { + for (int x = r.left, right = r.right; x < right; x += size) { + activateTile(x, y, i); + } + } + } + invalidate(); + } + + private void invalidateTiles() { + synchronized (mQueueLock) { + mDecodeQueue.clean(); + mUploadQueue.clean(); + + // TODO(xx): disable decoder + int n = mActiveTiles.size(); + for (int i = 0; i < n; i++) { + Tile tile = mActiveTiles.valueAt(i); + recycleTile(tile); + } + mActiveTiles.clear(); + } + } + + private void getRange(Rect out, int cX, int cY, int level, int rotation) { + getRange(out, cX, cY, level, 1f / (1 << (level + 1)), rotation); + } + + // If the bitmap is scaled by the given factor "scale", return the + // rectangle containing visible range. The left-top coordinate returned is + // aligned to the tile boundary. + // + // (cX, cY) is the point on the original bitmap which will be put in the + // center of the ImageViewer. + private void getRange(Rect out, + int cX, int cY, int level, float scale, int rotation) { + + double radians = Math.toRadians(-rotation); + double w = mViewWidth; + double h = mViewHeight; + + double cos = Math.cos(radians); + double sin = Math.sin(radians); + int width = (int) Math.ceil(Math.max( + Math.abs(cos * w - sin * h), Math.abs(cos * w + sin * h))); + int height = (int) Math.ceil(Math.max( + Math.abs(sin * w + cos * h), Math.abs(sin * w - cos * h))); + + int left = (int) Math.floor(cX - width / (2f * scale)); + int top = (int) Math.floor(cY - height / (2f * scale)); + int right = (int) Math.ceil(left + width / scale); + int bottom = (int) Math.ceil(top + height / scale); + + // align the rectangle to tile boundary + int size = mTileSize << level; + left = Math.max(0, size * (left / size)); + top = Math.max(0, size * (top / size)); + right = Math.min(mImageWidth, right); + bottom = Math.min(mImageHeight, bottom); + + out.set(left, top, right, bottom); + } + + public void freeTextures() { + mLayoutTiles = true; + + mTileDecoder.finishAndWait(); + synchronized (mQueueLock) { + mUploadQueue.clean(); + mDecodeQueue.clean(); + Tile tile = mRecycledQueue.pop(); + while (tile != null) { + tile.recycle(); + tile = mRecycledQueue.pop(); + } + } + + int n = mActiveTiles.size(); + for (int i = 0; i < n; i++) { + Tile texture = mActiveTiles.valueAt(i); + texture.recycle(); + } + mActiveTiles.clear(); + mTileRange.set(0, 0, 0, 0); + + while (sTilePool.acquire() != null) {} + } + + public boolean draw(GLCanvas canvas) { + layoutTiles(); + uploadTiles(canvas); + + mUploadQuota = UPLOAD_LIMIT; + mRenderComplete = true; + + int level = mLevel; + int rotation = mRotation; + int flags = 0; + if (rotation != 0) { + flags |= GLCanvas.SAVE_FLAG_MATRIX; + } + + if (flags != 0) { + canvas.save(flags); + if (rotation != 0) { + int centerX = mViewWidth / 2, centerY = mViewHeight / 2; + canvas.translate(centerX, centerY); + canvas.rotate(rotation, 0, 0, 1); + canvas.translate(-centerX, -centerY); + } + } + try { + if (level != mLevelCount) { + int size = (mTileSize << level); + float length = size * mScale; + Rect r = mTileRange; + + for (int ty = r.top, i = 0; ty < r.bottom; ty += size, i++) { + float y = mOffsetY + i * length; + for (int tx = r.left, j = 0; tx < r.right; tx += size, j++) { + float x = mOffsetX + j * length; + drawTile(canvas, tx, ty, level, x, y, length); + } + } + } else if (mPreview != null) { + mPreview.draw(canvas, mOffsetX, mOffsetY, + Math.round(mImageWidth * mScale), + Math.round(mImageHeight * mScale)); + } + } finally { + if (flags != 0) { + canvas.restore(); + } + } + + if (mRenderComplete) { + if (!mBackgroundTileUploaded) { + uploadBackgroundTiles(canvas); + } + } else { + invalidate(); + } + return mRenderComplete || mPreview != null; + } + + private void uploadBackgroundTiles(GLCanvas canvas) { + mBackgroundTileUploaded = true; + int n = mActiveTiles.size(); + for (int i = 0; i < n; i++) { + Tile tile = mActiveTiles.valueAt(i); + if (!tile.isContentValid()) { + queueForDecode(tile); + } + } + } + + private void queueForDecode(Tile tile) { + synchronized (mQueueLock) { + if (tile.mTileState == STATE_ACTIVATED) { + tile.mTileState = STATE_IN_QUEUE; + if (mDecodeQueue.push(tile)) { + mQueueLock.notifyAll(); + } + } + } + } + + private void decodeTile(Tile tile) { + synchronized (mQueueLock) { + if (tile.mTileState != STATE_IN_QUEUE) { + return; + } + tile.mTileState = STATE_DECODING; + } + boolean decodeComplete = tile.decode(); + synchronized (mQueueLock) { + if (tile.mTileState == STATE_RECYCLING) { + tile.mTileState = STATE_RECYCLED; + if (tile.mDecodedTile != null) { + sTilePool.release(tile.mDecodedTile); + tile.mDecodedTile = null; + } + mRecycledQueue.push(tile); + return; + } + tile.mTileState = decodeComplete ? STATE_DECODED : STATE_DECODE_FAIL; + if (!decodeComplete) { + return; + } + mUploadQueue.push(tile); + } + invalidate(); + } + + private Tile obtainTile(int x, int y, int level) { + synchronized (mQueueLock) { + Tile tile = mRecycledQueue.pop(); + if (tile != null) { + tile.mTileState = STATE_ACTIVATED; + tile.update(x, y, level); + return tile; + } + return new Tile(x, y, level); + } + } + + private void recycleTile(Tile tile) { + synchronized (mQueueLock) { + if (tile.mTileState == STATE_DECODING) { + tile.mTileState = STATE_RECYCLING; + return; + } + tile.mTileState = STATE_RECYCLED; + if (tile.mDecodedTile != null) { + sTilePool.release(tile.mDecodedTile); + tile.mDecodedTile = null; + } + mRecycledQueue.push(tile); + } + } + + private void activateTile(int x, int y, int level) { + long key = makeTileKey(x, y, level); + Tile tile = mActiveTiles.get(key); + if (tile != null) { + if (tile.mTileState == STATE_IN_QUEUE) { + tile.mTileState = STATE_ACTIVATED; + } + return; + } + tile = obtainTile(x, y, level); + mActiveTiles.put(key, tile); + } + + private Tile getTile(int x, int y, int level) { + return mActiveTiles.get(makeTileKey(x, y, level)); + } + + private static long makeTileKey(int x, int y, int level) { + long result = x; + result = (result << 16) | y; + result = (result << 16) | level; + return result; + } + + private void uploadTiles(GLCanvas canvas) { + int quota = UPLOAD_LIMIT; + Tile tile = null; + while (quota > 0) { + synchronized (mQueueLock) { + tile = mUploadQueue.pop(); + } + if (tile == null) { + break; + } + if (!tile.isContentValid()) { + if (tile.mTileState == STATE_DECODED) { + tile.updateContent(canvas); + --quota; + } else { + Log.w(TAG, "Tile in upload queue has invalid state: " + tile.mTileState); + } + } + } + if (tile != null) { + invalidate(); + } + } + + // Draw the tile to a square at canvas that locates at (x, y) and + // has a side length of length. + private void drawTile(GLCanvas canvas, + int tx, int ty, int level, float x, float y, float length) { + RectF source = mSourceRect; + RectF target = mTargetRect; + target.set(x, y, x + length, y + length); + source.set(0, 0, mTileSize, mTileSize); + + Tile tile = getTile(tx, ty, level); + if (tile != null) { + if (!tile.isContentValid()) { + if (tile.mTileState == STATE_DECODED) { + if (mUploadQuota > 0) { + --mUploadQuota; + tile.updateContent(canvas); + } else { + mRenderComplete = false; + } + } else if (tile.mTileState != STATE_DECODE_FAIL){ + mRenderComplete = false; + queueForDecode(tile); + } + } + if (drawTile(tile, canvas, source, target)) { + return; + } + } + if (mPreview != null) { + int size = mTileSize << level; + float scaleX = (float) mPreview.getWidth() / mImageWidth; + float scaleY = (float) mPreview.getHeight() / mImageHeight; + source.set(tx * scaleX, ty * scaleY, (tx + size) * scaleX, + (ty + size) * scaleY); + canvas.drawTexture(mPreview, source, target); + } + } + + private boolean drawTile( + Tile tile, GLCanvas canvas, RectF source, RectF target) { + while (true) { + if (tile.isContentValid()) { + canvas.drawTexture(tile, source, target); + return true; + } + + // Parent can be divided to four quads and tile is one of the four. + Tile parent = tile.getParentTile(); + if (parent == null) { + return false; + } + if (tile.mX == parent.mX) { + source.left /= 2f; + source.right /= 2f; + } else { + source.left = (mTileSize + source.left) / 2f; + source.right = (mTileSize + source.right) / 2f; + } + if (tile.mY == parent.mY) { + source.top /= 2f; + source.bottom /= 2f; + } else { + source.top = (mTileSize + source.top) / 2f; + source.bottom = (mTileSize + source.bottom) / 2f; + } + tile = parent; + } + } + + private class Tile extends UploadedTexture { + public int mX; + public int mY; + public int mTileLevel; + public Tile mNext; + public Bitmap mDecodedTile; + public volatile int mTileState = STATE_ACTIVATED; + + public Tile(int x, int y, int level) { + mX = x; + mY = y; + mTileLevel = level; + } + + @Override + protected void onFreeBitmap(Bitmap bitmap) { + sTilePool.release(bitmap); + } + + boolean decode() { + // Get a tile from the original image. The tile is down-scaled + // by (1 << mTilelevel) from a region in the original image. + try { + Bitmap reuse = sTilePool.acquire(); + if (reuse != null && reuse.getWidth() != mTileSize) { + reuse = null; + } + mDecodedTile = mModel.getTile(mTileLevel, mX, mY, reuse); + } catch (Throwable t) { + Log.w(TAG, "fail to decode tile", t); + } + return mDecodedTile != null; + } + + @Override + protected Bitmap onGetBitmap() { + Utils.assertTrue(mTileState == STATE_DECODED); + + // We need to override the width and height, so that we won't + // draw beyond the boundaries. + int rightEdge = ((mImageWidth - mX) >> mTileLevel); + int bottomEdge = ((mImageHeight - mY) >> mTileLevel); + setSize(Math.min(mTileSize, rightEdge), Math.min(mTileSize, bottomEdge)); + + Bitmap bitmap = mDecodedTile; + mDecodedTile = null; + mTileState = STATE_ACTIVATED; + return bitmap; + } + + // We override getTextureWidth() and getTextureHeight() here, so the + // texture can be re-used for different tiles regardless of the actual + // size of the tile (which may be small because it is a tile at the + // boundary). + @Override + public int getTextureWidth() { + return mTileSize; + } + + @Override + public int getTextureHeight() { + return mTileSize; + } + + public void update(int x, int y, int level) { + mX = x; + mY = y; + mTileLevel = level; + invalidateContent(); + } + + public Tile getParentTile() { + if (mTileLevel + 1 == mLevelCount) { + return null; + } + int size = mTileSize << (mTileLevel + 1); + int x = size * (mX / size); + int y = size * (mY / size); + return getTile(x, y, mTileLevel + 1); + } + + @Override + public String toString() { + return String.format("tile(%s, %s, %s / %s)", + mX / mTileSize, mY / mTileSize, mLevel, mLevelCount); + } + } + + private static class TileQueue { + private Tile mHead; + + public Tile pop() { + Tile tile = mHead; + if (tile != null) { + mHead = tile.mNext; + } + return tile; + } + + public boolean push(Tile tile) { + if (contains(tile)) { + Log.w(TAG, "Attempting to add a tile already in the queue!"); + return false; + } + boolean wasEmpty = mHead == null; + tile.mNext = mHead; + mHead = tile; + return wasEmpty; + } + + private boolean contains(Tile tile) { + Tile other = mHead; + while (other != null) { + if (other == tile) { + return true; + } + other = other.mNext; + } + return false; + } + + public void clean() { + mHead = null; + } + } + + private class TileDecoder extends Thread { + + public void finishAndWait() { + interrupt(); + try { + join(); + } catch (InterruptedException e) { + Log.w(TAG, "Interrupted while waiting for TileDecoder thread to finish!"); + } + } + + private Tile waitForTile() throws InterruptedException { + synchronized (mQueueLock) { + while (true) { + Tile tile = mDecodeQueue.pop(); + if (tile != null) { + return tile; + } + mQueueLock.wait(); + } + } + } + + @Override + public void run() { + try { + while (!isInterrupted()) { + Tile tile = waitForTile(); + decodeTile(tile); + } + } catch (InterruptedException ex) { + // We were finished + } + } + + } +} diff --git a/src/com/android/photos/views/TiledImageView.java b/src/com/android/photos/views/TiledImageView.java new file mode 100644 index 000000000..8bc07c051 --- /dev/null +++ b/src/com/android/photos/views/TiledImageView.java @@ -0,0 +1,382 @@ +/* + * Copyright (C) 2013 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. + */ + +package com.android.photos.views; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Paint.Align; +import android.graphics.RectF; +import android.opengl.GLSurfaceView; +import android.opengl.GLSurfaceView.Renderer; +import android.os.Build; +import android.util.AttributeSet; +import android.view.Choreographer; +import android.view.Choreographer.FrameCallback; +import android.view.View; +import android.widget.FrameLayout; + +import com.android.gallery3d.glrenderer.BasicTexture; +import com.android.gallery3d.glrenderer.GLES20Canvas; +import com.android.photos.views.TiledImageRenderer.TileSource; + +import javax.microedition.khronos.egl.EGLConfig; +import javax.microedition.khronos.opengles.GL10; + +/** + * Shows an image using {@link TiledImageRenderer} using either {@link GLSurfaceView} + * or {@link BlockingGLTextureView}. + */ +public class TiledImageView extends FrameLayout { + + private static final boolean USE_TEXTURE_VIEW = false; + private static final boolean IS_SUPPORTED = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN; + private static final boolean USE_CHOREOGRAPHER = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN; + + private BlockingGLTextureView mTextureView; + private GLSurfaceView mGLSurfaceView; + private boolean mInvalPending = false; + private FrameCallback mFrameCallback; + + private static class ImageRendererWrapper { + // Guarded by locks + float scale; + int centerX, centerY; + int rotation; + TileSource source; + Runnable isReadyCallback; + + // GL thread only + TiledImageRenderer image; + } + + private float[] mValues = new float[9]; + + // ------------------------- + // Guarded by mLock + // ------------------------- + private Object mLock = new Object(); + private ImageRendererWrapper mRenderer; + + public TiledImageView(Context context) { + this(context, null); + } + + public TiledImageView(Context context, AttributeSet attrs) { + super(context, attrs); + if (!IS_SUPPORTED) { + return; + } + + mRenderer = new ImageRendererWrapper(); + mRenderer.image = new TiledImageRenderer(this); + View view; + if (USE_TEXTURE_VIEW) { + mTextureView = new BlockingGLTextureView(context); + mTextureView.setRenderer(new TileRenderer()); + view = mTextureView; + } else { + mGLSurfaceView = new GLSurfaceView(context); + mGLSurfaceView.setEGLContextClientVersion(2); + mGLSurfaceView.setRenderer(new TileRenderer()); + mGLSurfaceView.setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY); + view = mGLSurfaceView; + } + addView(view, new LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); + //setTileSource(new ColoredTiles()); + } + + public void destroy() { + if (!IS_SUPPORTED) { + return; + } + if (USE_TEXTURE_VIEW) { + mTextureView.destroy(); + } else { + mGLSurfaceView.queueEvent(mFreeTextures); + } + } + + private Runnable mFreeTextures = new Runnable() { + + @Override + public void run() { + mRenderer.image.freeTextures(); + } + }; + + public void onPause() { + if (!IS_SUPPORTED) { + return; + } + if (!USE_TEXTURE_VIEW) { + mGLSurfaceView.onPause(); + } + } + + public void onResume() { + if (!IS_SUPPORTED) { + return; + } + if (!USE_TEXTURE_VIEW) { + mGLSurfaceView.onResume(); + } + } + + public void setTileSource(TileSource source, Runnable isReadyCallback) { + if (!IS_SUPPORTED) { + return; + } + synchronized (mLock) { + mRenderer.source = source; + mRenderer.isReadyCallback = isReadyCallback; + mRenderer.centerX = source != null ? source.getImageWidth() / 2 : 0; + mRenderer.centerY = source != null ? source.getImageHeight() / 2 : 0; + mRenderer.rotation = source != null ? source.getRotation() : 0; + mRenderer.scale = 0; + updateScaleIfNecessaryLocked(mRenderer); + } + invalidate(); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, + int bottom) { + super.onLayout(changed, left, top, right, bottom); + if (!IS_SUPPORTED) { + return; + } + synchronized (mLock) { + updateScaleIfNecessaryLocked(mRenderer); + } + } + + private void updateScaleIfNecessaryLocked(ImageRendererWrapper renderer) { + if (renderer == null || renderer.source == null + || renderer.scale > 0 || getWidth() == 0) { + return; + } + renderer.scale = Math.min( + (float) getWidth() / (float) renderer.source.getImageWidth(), + (float) getHeight() / (float) renderer.source.getImageHeight()); + } + + @Override + protected void dispatchDraw(Canvas canvas) { + if (!IS_SUPPORTED) { + return; + } + if (USE_TEXTURE_VIEW) { + mTextureView.render(); + } + super.dispatchDraw(canvas); + } + + @SuppressLint("NewApi") + @Override + public void setTranslationX(float translationX) { + if (!IS_SUPPORTED) { + return; + } + super.setTranslationX(translationX); + } + + @Override + public void invalidate() { + if (!IS_SUPPORTED) { + return; + } + if (USE_TEXTURE_VIEW) { + super.invalidate(); + mTextureView.invalidate(); + } else { + if (USE_CHOREOGRAPHER) { + invalOnVsync(); + } else { + mGLSurfaceView.requestRender(); + } + } + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + private void invalOnVsync() { + if (!mInvalPending) { + mInvalPending = true; + if (mFrameCallback == null) { + mFrameCallback = new FrameCallback() { + @Override + public void doFrame(long frameTimeNanos) { + mInvalPending = false; + mGLSurfaceView.requestRender(); + } + }; + } + Choreographer.getInstance().postFrameCallback(mFrameCallback); + } + } + + private RectF mTempRectF = new RectF(); + public void positionFromMatrix(Matrix matrix) { + if (!IS_SUPPORTED) { + return; + } + if (mRenderer.source != null) { + final int rotation = mRenderer.source.getRotation(); + final boolean swap = !(rotation % 180 == 0); + final int width = swap ? mRenderer.source.getImageHeight() + : mRenderer.source.getImageWidth(); + final int height = swap ? mRenderer.source.getImageWidth() + : mRenderer.source.getImageHeight(); + mTempRectF.set(0, 0, width, height); + matrix.mapRect(mTempRectF); + matrix.getValues(mValues); + int cx = width / 2; + int cy = height / 2; + float scale = mValues[Matrix.MSCALE_X]; + int xoffset = Math.round((getWidth() - mTempRectF.width()) / 2 / scale); + int yoffset = Math.round((getHeight() - mTempRectF.height()) / 2 / scale); + if (rotation == 90 || rotation == 180) { + cx += (mTempRectF.left / scale) - xoffset; + } else { + cx -= (mTempRectF.left / scale) - xoffset; + } + if (rotation == 180 || rotation == 270) { + cy += (mTempRectF.top / scale) - yoffset; + } else { + cy -= (mTempRectF.top / scale) - yoffset; + } + mRenderer.scale = scale; + mRenderer.centerX = swap ? cy : cx; + mRenderer.centerY = swap ? cx : cy; + invalidate(); + } + } + + private class TileRenderer implements Renderer { + + private GLES20Canvas mCanvas; + + @Override + public void onSurfaceCreated(GL10 gl, EGLConfig config) { + mCanvas = new GLES20Canvas(); + BasicTexture.invalidateAllTextures(); + mRenderer.image.setModel(mRenderer.source, mRenderer.rotation); + } + + @Override + public void onSurfaceChanged(GL10 gl, int width, int height) { + mCanvas.setSize(width, height); + mRenderer.image.setViewSize(width, height); + } + + @Override + public void onDrawFrame(GL10 gl) { + mCanvas.clearBuffer(); + Runnable readyCallback; + synchronized (mLock) { + readyCallback = mRenderer.isReadyCallback; + mRenderer.image.setModel(mRenderer.source, mRenderer.rotation); + mRenderer.image.setPosition(mRenderer.centerX, mRenderer.centerY, + mRenderer.scale); + } + boolean complete = mRenderer.image.draw(mCanvas); + if (complete && readyCallback != null) { + synchronized (mLock) { + // Make sure we don't trample on a newly set callback/source + // if it changed while we were rendering + if (mRenderer.isReadyCallback == readyCallback) { + mRenderer.isReadyCallback = null; + } + } + if (readyCallback != null) { + post(readyCallback); + } + } + } + + } + + @SuppressWarnings("unused") + private static class ColoredTiles implements TileSource { + private static final int[] COLORS = new int[] { + Color.RED, + Color.BLUE, + Color.YELLOW, + Color.GREEN, + Color.CYAN, + Color.MAGENTA, + Color.WHITE, + }; + + private Paint mPaint = new Paint(); + private Canvas mCanvas = new Canvas(); + + @Override + public int getTileSize() { + return 256; + } + + @Override + public int getImageWidth() { + return 16384; + } + + @Override + public int getImageHeight() { + return 8192; + } + + @Override + public int getRotation() { + return 0; + } + + @Override + public Bitmap getTile(int level, int x, int y, Bitmap bitmap) { + int tileSize = getTileSize(); + if (bitmap == null) { + bitmap = Bitmap.createBitmap(tileSize, tileSize, + Bitmap.Config.ARGB_8888); + } + mCanvas.setBitmap(bitmap); + mCanvas.drawColor(COLORS[level]); + mPaint.setColor(Color.BLACK); + mPaint.setTextSize(20); + mPaint.setTextAlign(Align.CENTER); + mCanvas.drawText(x + "x" + y, 128, 128, mPaint); + tileSize <<= level; + x /= tileSize; + y /= tileSize; + mCanvas.drawText(x + "x" + y + " @ " + level, 128, 30, mPaint); + mCanvas.setBitmap(null); + return bitmap; + } + + @Override + public BasicTexture getPreview() { + return null; + } + } +} |