summaryrefslogtreecommitdiffstats
path: root/src/com/android/photos
diff options
context:
space:
mode:
Diffstat (limited to 'src/com/android/photos')
-rw-r--r--src/com/android/photos/AlbumActivity.java48
-rw-r--r--src/com/android/photos/AlbumFragment.java168
-rw-r--r--src/com/android/photos/AlbumSetFragment.java135
-rw-r--r--src/com/android/photos/BitmapRegionTileSource.java183
-rw-r--r--src/com/android/photos/FullscreenViewer.java44
-rw-r--r--src/com/android/photos/GalleryActivity.java184
-rw-r--r--src/com/android/photos/MultiChoiceManager.java295
-rw-r--r--src/com/android/photos/MultiSelectGridFragment.java348
-rw-r--r--src/com/android/photos/PhotoFragment.java25
-rw-r--r--src/com/android/photos/PhotoSetFragment.java133
-rw-r--r--src/com/android/photos/SelectionManager.java184
-rw-r--r--src/com/android/photos/adapters/AlbumSetCursorAdapter.java75
-rw-r--r--src/com/android/photos/adapters/PhotoThumbnailAdapter.java75
-rw-r--r--src/com/android/photos/data/AlbumSetLoader.java54
-rw-r--r--src/com/android/photos/data/BitmapDecoder.java224
-rw-r--r--src/com/android/photos/data/FileRetriever.java109
-rw-r--r--src/com/android/photos/data/GalleryBitmapPool.java161
-rw-r--r--src/com/android/photos/data/MediaCache.java676
-rw-r--r--src/com/android/photos/data/MediaCacheDatabase.java286
-rw-r--r--src/com/android/photos/data/MediaCacheUtils.java167
-rw-r--r--src/com/android/photos/data/MediaRetriever.java129
-rw-r--r--src/com/android/photos/data/NotificationWatcher.java55
-rw-r--r--src/com/android/photos/data/PhotoDatabase.java195
-rw-r--r--src/com/android/photos/data/PhotoProvider.java536
-rw-r--r--src/com/android/photos/data/PhotoSetLoader.java115
-rw-r--r--src/com/android/photos/data/SQLiteContentProvider.java265
-rw-r--r--src/com/android/photos/data/SparseArrayBitmapPool.java212
-rw-r--r--src/com/android/photos/drawables/AutoThumbnailDrawable.java309
-rw-r--r--src/com/android/photos/drawables/DataUriThumbnailDrawable.java54
-rw-r--r--src/com/android/photos/shims/BitmapJobDrawable.java180
-rw-r--r--src/com/android/photos/shims/LoaderCompatShim.java31
-rw-r--r--src/com/android/photos/shims/MediaItemsLoader.java190
-rw-r--r--src/com/android/photos/shims/MediaSetLoader.java191
-rw-r--r--src/com/android/photos/views/BlockingGLTextureView.java438
-rw-r--r--src/com/android/photos/views/GalleryThumbnailView.java883
-rw-r--r--src/com/android/photos/views/HeaderGridView.java466
-rw-r--r--src/com/android/photos/views/SquareImageView.java53
-rw-r--r--src/com/android/photos/views/TiledImageRenderer.java825
-rw-r--r--src/com/android/photos/views/TiledImageView.java382
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;
+ }
+ }
+}