diff options
author | Owen Lin <owenlin@google.com> | 2011-08-17 22:07:43 +0800 |
---|---|---|
committer | Owen Lin <owenlin@google.com> | 2011-08-18 13:33:50 +0800 |
commit | a2fba687d4d2dbb3b2db8866b054ecb0e42871b2 (patch) | |
tree | dacc5a60ed945fe989aebf1f227f72bc90ebc4b8 /src | |
parent | a053a3179cfee3d2bb666eff5f4f03a96b092e04 (diff) | |
download | android_packages_apps_Snap-a2fba687d4d2dbb3b2db8866b054ecb0e42871b2.tar.gz android_packages_apps_Snap-a2fba687d4d2dbb3b2db8866b054ecb0e42871b2.tar.bz2 android_packages_apps_Snap-a2fba687d4d2dbb3b2db8866b054ecb0e42871b2.zip |
Initial code for Gallery2.
fix: 5176434
Change-Id: I041e282b9c7b34ceb1db8b033be2b853bb3a992c
Diffstat (limited to 'src')
179 files changed, 31671 insertions, 0 deletions
diff --git a/src/com/android/gallery3d/anim/AlphaAnimation.java b/src/com/android/gallery3d/anim/AlphaAnimation.java new file mode 100644 index 000000000..cb17527b8 --- /dev/null +++ b/src/com/android/gallery3d/anim/AlphaAnimation.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2010 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.gallery3d.anim; + +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.ui.GLCanvas; + +public class AlphaAnimation extends CanvasAnimation { + private final float mStartAlpha; + private final float mEndAlpha; + private float mCurrentAlpha; + + public AlphaAnimation(float from, float to) { + mStartAlpha = from; + mEndAlpha = to; + mCurrentAlpha = from; + } + + @Override + public void apply(GLCanvas canvas) { + canvas.multiplyAlpha(mCurrentAlpha); + } + + @Override + public int getCanvasSaveFlags() { + return GLCanvas.SAVE_FLAG_ALPHA; + } + + @Override + protected void onCalculate(float progress) { + mCurrentAlpha = Utils.clamp(mStartAlpha + + (mEndAlpha - mStartAlpha) * progress, 0f, 1f); + } +} diff --git a/src/com/android/gallery3d/anim/Animation.java b/src/com/android/gallery3d/anim/Animation.java new file mode 100644 index 000000000..bd5a6cd72 --- /dev/null +++ b/src/com/android/gallery3d/anim/Animation.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2010 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.gallery3d.anim; + +import com.android.gallery3d.common.Utils; + +import android.view.animation.Interpolator; + +// Animation calculates a value according to the current input time. +// +// 1. First we need to use setDuration(int) to set the duration of the +// animation. The duration is in milliseconds. +// 2. Then we should call start(). The actual start time is the first value +// passed to calculate(long). +// 3. Each time we want to get an animation value, we call +// calculate(long currentTimeMillis) to ask the Animation to calculate it. +// The parameter passed to calculate(long) should be nonnegative. +// 4. Use get() to get that value. +// +// In step 3, onCalculate(float progress) is called so subclasses can calculate +// the value according to progress (progress is a value in [0,1]). +// +// Before onCalculate(float) is called, There is an optional interpolator which +// can change the progress value. The interpolator can be set by +// setInterpolator(Interpolator). If the interpolator is used, the value passed +// to onCalculate may be (for example, the overshoot effect). +// +// The isActive() method returns true after the animation start() is called and +// before calculate is passed a value which reaches the duration of the +// animation. +// +// The start() method can be called again to restart the Animation. +// +abstract public class Animation { + private static final long ANIMATION_START = -1; + private static final long NO_ANIMATION = -2; + + private long mStartTime = NO_ANIMATION; + private int mDuration; + private Interpolator mInterpolator; + + public void setInterpolator(Interpolator interpolator) { + mInterpolator = interpolator; + } + + public void setDuration(int duration) { + mDuration = duration; + } + + public void start() { + mStartTime = ANIMATION_START; + } + + public void setStartTime(long time) { + mStartTime = time; + } + + public boolean isActive() { + return mStartTime != NO_ANIMATION; + } + + public void forceStop() { + mStartTime = NO_ANIMATION; + } + + public boolean calculate(long currentTimeMillis) { + if (mStartTime == NO_ANIMATION) return false; + if (mStartTime == ANIMATION_START) mStartTime = currentTimeMillis; + int elapse = (int) (currentTimeMillis - mStartTime); + float x = Utils.clamp((float) elapse / mDuration, 0f, 1f); + Interpolator i = mInterpolator; + onCalculate(i != null ? i.getInterpolation(x) : x); + if (elapse >= mDuration) mStartTime = NO_ANIMATION; + return mStartTime != NO_ANIMATION; + } + + abstract protected void onCalculate(float progress); +} diff --git a/src/com/android/gallery3d/anim/AnimationSet.java b/src/com/android/gallery3d/anim/AnimationSet.java new file mode 100644 index 000000000..773cb4314 --- /dev/null +++ b/src/com/android/gallery3d/anim/AnimationSet.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2010 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.gallery3d.anim; + +import com.android.gallery3d.ui.GLCanvas; + +import java.util.ArrayList; + +public class AnimationSet extends CanvasAnimation { + + private final ArrayList<CanvasAnimation> mAnimations = + new ArrayList<CanvasAnimation>(); + private int mSaveFlags = 0; + + + public void addAnimation(CanvasAnimation anim) { + mAnimations.add(anim); + mSaveFlags |= anim.getCanvasSaveFlags(); + } + + @Override + public void apply(GLCanvas canvas) { + for (int i = 0, n = mAnimations.size(); i < n; i++) { + mAnimations.get(i).apply(canvas); + } + } + + @Override + public int getCanvasSaveFlags() { + return mSaveFlags; + } + + @Override + protected void onCalculate(float progress) { + // DO NOTHING + } + + @Override + public boolean calculate(long currentTimeMillis) { + boolean more = false; + for (CanvasAnimation anim : mAnimations) { + more |= anim.calculate(currentTimeMillis); + } + return more; + } + + @Override + public void start() { + for (CanvasAnimation anim : mAnimations) { + anim.start(); + } + } + + @Override + public boolean isActive() { + for (CanvasAnimation anim : mAnimations) { + if (anim.isActive()) return true; + } + return false; + } + +} diff --git a/src/com/android/gallery3d/anim/CanvasAnimation.java b/src/com/android/gallery3d/anim/CanvasAnimation.java new file mode 100644 index 000000000..4c8bcc825 --- /dev/null +++ b/src/com/android/gallery3d/anim/CanvasAnimation.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2010 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.gallery3d.anim; + +import com.android.gallery3d.ui.GLCanvas; + +public abstract class CanvasAnimation extends Animation { + + public abstract int getCanvasSaveFlags(); + public abstract void apply(GLCanvas canvas); +} diff --git a/src/com/android/gallery3d/anim/FloatAnimation.java b/src/com/android/gallery3d/anim/FloatAnimation.java new file mode 100644 index 000000000..1294ec2f4 --- /dev/null +++ b/src/com/android/gallery3d/anim/FloatAnimation.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2010 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.gallery3d.anim; + +public class FloatAnimation extends Animation { + + private final float mFrom; + private final float mTo; + private float mCurrent; + + public FloatAnimation(float from, float to, int duration) { + mFrom = from; + mTo = to; + mCurrent = from; + setDuration(duration); + } + + @Override + protected void onCalculate(float progress) { + mCurrent = mFrom + (mTo - mFrom) * progress; + } + + public float get() { + return mCurrent; + } +} diff --git a/src/com/android/gallery3d/app/AbstractGalleryActivity.java b/src/com/android/gallery3d/app/AbstractGalleryActivity.java new file mode 100644 index 000000000..d0d7b0fad --- /dev/null +++ b/src/com/android/gallery3d/app/AbstractGalleryActivity.java @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.R; +import com.android.gallery3d.data.DataManager; +import com.android.gallery3d.data.ImageCacheService; +import com.android.gallery3d.ui.GLRoot; +import com.android.gallery3d.ui.GLRootView; +import com.android.gallery3d.ui.PositionRepository; +import com.android.gallery3d.util.ThreadPool; + +import android.app.ActionBar; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnCancelListener; +import android.content.DialogInterface.OnClickListener; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Bundle; + +public class AbstractGalleryActivity extends Activity implements GalleryActivity { + @SuppressWarnings("unused") + private static final String TAG = "AbstractGalleryActivity"; + private GLRootView mGLRootView; + private StateManager mStateManager; + private PositionRepository mPositionRepository = new PositionRepository(); + + private AlertDialog mAlertDialog = null; + private BroadcastReceiver mMountReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (getExternalCacheDir() != null) onStorageReady(); + } + }; + private IntentFilter mMountFilter = new IntentFilter(Intent.ACTION_MEDIA_MOUNTED); + + @Override + protected void onSaveInstanceState(Bundle outState) { + mGLRootView.lockRenderThread(); + try { + super.onSaveInstanceState(outState); + getStateManager().saveState(outState); + } finally { + mGLRootView.unlockRenderThread(); + } + } + + public Context getAndroidContext() { + return this; + } + + public ImageCacheService getImageCacheService() { + return ((GalleryApp) getApplication()).getImageCacheService(); + } + + public DataManager getDataManager() { + return ((GalleryApp) getApplication()).getDataManager(); + } + + public ThreadPool getThreadPool() { + return ((GalleryApp) getApplication()).getThreadPool(); + } + + public GalleryApp getGalleryApplication() { + return (GalleryApp) getApplication(); + } + + public synchronized StateManager getStateManager() { + if (mStateManager == null) { + mStateManager = new StateManager(this); + } + return mStateManager; + } + + public GLRoot getGLRoot() { + return mGLRootView; + } + + public PositionRepository getPositionRepository() { + return mPositionRepository; + } + + @Override + public void setContentView(int resId) { + super.setContentView(resId); + mGLRootView = (GLRootView) findViewById(R.id.gl_root_view); + } + + public int getActionBarHeight() { + ActionBar actionBar = getActionBar(); + return actionBar != null ? actionBar.getHeight() : 0; + } + + protected void onStorageReady() { + if (mAlertDialog != null) { + mAlertDialog.dismiss(); + mAlertDialog = null; + unregisterReceiver(mMountReceiver); + } + } + + @Override + protected void onStart() { + super.onStart(); + if (getExternalCacheDir() == null) { + OnCancelListener onCancel = new OnCancelListener() { + @Override + public void onCancel(DialogInterface dialog) { + finish(); + } + }; + OnClickListener onClick = new OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.cancel(); + } + }; + mAlertDialog = new AlertDialog.Builder(this) + .setIcon(android.R.drawable.ic_dialog_alert) + .setTitle("No Storage") + .setMessage("No external storage available.") + .setNegativeButton(android.R.string.cancel, onClick) + .setOnCancelListener(onCancel) + .show(); + registerReceiver(mMountReceiver, mMountFilter); + } + } + + @Override + protected void onStop() { + super.onStop(); + if (mAlertDialog != null) { + unregisterReceiver(mMountReceiver); + mAlertDialog.dismiss(); + mAlertDialog = null; + } + } + + @Override + protected void onResume() { + super.onResume(); + mGLRootView.lockRenderThread(); + try { + getStateManager().resume(); + getDataManager().resume(); + } finally { + mGLRootView.unlockRenderThread(); + } + mGLRootView.onResume(); + } + + @Override + protected void onPause() { + super.onPause(); + mGLRootView.onPause(); + mGLRootView.lockRenderThread(); + try { + getStateManager().pause(); + getDataManager().pause(); + } finally { + mGLRootView.unlockRenderThread(); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + mGLRootView.lockRenderThread(); + try { + getStateManager().notifyActivityResult( + requestCode, resultCode, data); + } finally { + mGLRootView.unlockRenderThread(); + } + } + + @Override + public GalleryActionBar getGalleryActionBar() { + return null; + } +} diff --git a/src/com/android/gallery3d/app/ActivityState.java b/src/com/android/gallery3d/app/ActivityState.java new file mode 100644 index 000000000..bfacc5484 --- /dev/null +++ b/src/com/android/gallery3d/app/ActivityState.java @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.ui.GLView; + +import android.app.ActionBar; +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.WindowManager; + +abstract public class ActivityState { + public static final int FLAG_HIDE_ACTION_BAR = 1; + public static final int FLAG_HIDE_STATUS_BAR = 2; + + protected GalleryActivity mActivity; + protected Bundle mData; + protected int mFlags; + + protected ResultEntry mReceivedResults; + protected ResultEntry mResult; + + protected static class ResultEntry { + public int requestCode; + public int resultCode = Activity.RESULT_CANCELED; + public Intent resultData; + ResultEntry next; + } + + protected ActivityState() { + } + + protected void setContentPane(GLView content) { + mActivity.getGLRoot().setContentPane(content); + } + + void initialize(GalleryActivity activity, Bundle data) { + mActivity = activity; + mData = data; + } + + public Bundle getData() { + return mData; + } + + protected void onBackPressed() { + mActivity.getStateManager().finishState(this); + } + + protected void setStateResult(int resultCode, Intent data) { + if (mResult == null) return; + mResult.resultCode = resultCode; + mResult.resultData = data; + } + + protected void onSaveState(Bundle outState) { + } + + protected void onStateResult(int requestCode, int resultCode, Intent data) { + } + + protected void onCreate(Bundle data, Bundle storedState) { + } + + protected void onPause() { + } + + // should only be called by StateManager + void resume() { + Activity activity = (Activity) mActivity; + ActionBar actionBar = activity.getActionBar(); + if (actionBar != null) { + if ((mFlags & FLAG_HIDE_ACTION_BAR) != 0) { + actionBar.hide(); + } else { + actionBar.show(); + } + int stateCount = mActivity.getStateManager().getStateCount(); + actionBar.setDisplayOptions( + stateCount == 1 ? 0 : ActionBar.DISPLAY_HOME_AS_UP, + ActionBar.DISPLAY_HOME_AS_UP); + } + + activity.invalidateOptionsMenu(); + + if ((mFlags & FLAG_HIDE_STATUS_BAR) != 0) { + WindowManager.LayoutParams params = ((Activity) mActivity).getWindow().getAttributes(); + params.systemUiVisibility = View.STATUS_BAR_HIDDEN; + ((Activity) mActivity).getWindow().setAttributes(params); + } else { + WindowManager.LayoutParams params = ((Activity) mActivity).getWindow().getAttributes(); + params.systemUiVisibility = View.STATUS_BAR_VISIBLE; + ((Activity) mActivity).getWindow().setAttributes(params); + } + + ResultEntry entry = mReceivedResults; + if (entry != null) { + mReceivedResults = null; + onStateResult(entry.requestCode, entry.resultCode, entry.resultData); + } + onResume(); + } + + // a subclass of ActivityState should override the method to resume itself + protected void onResume() { + } + + protected boolean onCreateActionBar(Menu menu) { + return false; + } + + protected boolean onItemSelected(MenuItem item) { + return false; + } + + protected void onDestroy() { + } +} diff --git a/src/com/android/gallery3d/app/AlbumDataAdapter.java b/src/com/android/gallery3d/app/AlbumDataAdapter.java new file mode 100644 index 000000000..9934cf88c --- /dev/null +++ b/src/com/android/gallery3d/app/AlbumDataAdapter.java @@ -0,0 +1,367 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.common.Utils; +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.ui.AlbumView; +import com.android.gallery3d.ui.SynchronizedHandler; + +import android.os.Handler; +import android.os.Message; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; + +public class AlbumDataAdapter implements AlbumView.Model { + @SuppressWarnings("unused") + private static final String TAG = "AlbumDataAdapter"; + private static final int DATA_CACHE_SIZE = 1000; + + private static final int MSG_LOAD_START = 1; + private static final int MSG_LOAD_FINISH = 2; + private static final int MSG_RUN_OBJECT = 3; + + private static final int MIN_LOAD_COUNT = 32; + private static final int MAX_LOAD_COUNT = 64; + + private final MediaItem[] mData; + private final long[] mItemVersion; + private final long[] mSetVersion; + + private int mActiveStart = 0; + private int mActiveEnd = 0; + + private int mContentStart = 0; + private int mContentEnd = 0; + + private final MediaSet mSource; + private long mSourceVersion = MediaObject.INVALID_DATA_VERSION; + + private final Handler mMainHandler; + private int mSize = 0; + + private AlbumView.ModelListener mModelListener; + private MySourceListener mSourceListener = new MySourceListener(); + private LoadingListener mLoadingListener; + + private ReloadTask mReloadTask; + + public AlbumDataAdapter(GalleryActivity context, MediaSet mediaSet) { + mSource = mediaSet; + + mData = new MediaItem[DATA_CACHE_SIZE]; + mItemVersion = new long[DATA_CACHE_SIZE]; + mSetVersion = new long[DATA_CACHE_SIZE]; + Arrays.fill(mItemVersion, MediaObject.INVALID_DATA_VERSION); + Arrays.fill(mSetVersion, MediaObject.INVALID_DATA_VERSION); + + mMainHandler = new SynchronizedHandler(context.getGLRoot()) { + @Override + public void handleMessage(Message message) { + switch (message.what) { + case MSG_RUN_OBJECT: + ((Runnable) message.obj).run(); + return; + case MSG_LOAD_START: + if (mLoadingListener != null) mLoadingListener.onLoadingStarted(); + return; + case MSG_LOAD_FINISH: + if (mLoadingListener != null) mLoadingListener.onLoadingFinished(); + return; + } + } + }; + } + + public void resume() { + mSource.addContentListener(mSourceListener); + mReloadTask = new ReloadTask(); + mReloadTask.start(); + } + + public void pause() { + mReloadTask.terminate(); + mReloadTask = null; + mSource.removeContentListener(mSourceListener); + } + + public MediaItem get(int index) { + if (!isActive(index)) { + throw new IllegalArgumentException(String.format( + "%s not in (%s, %s)", index, mActiveStart, mActiveEnd)); + } + return mData[index % mData.length]; + } + + public int getActiveStart() { + return mActiveStart; + } + + public int getActiveEnd() { + return mActiveEnd; + } + + public boolean isActive(int index) { + return index >= mActiveStart && index < mActiveEnd; + } + + public int size() { + return mSize; + } + + private void clearSlot(int slotIndex) { + mData[slotIndex] = null; + mItemVersion[slotIndex] = MediaObject.INVALID_DATA_VERSION; + mSetVersion[slotIndex] = MediaObject.INVALID_DATA_VERSION; + } + + private void setContentWindow(int contentStart, int contentEnd) { + if (contentStart == mContentStart && contentEnd == mContentEnd) return; + int end = mContentEnd; + int start = mContentStart; + + // We need change the content window before calling reloadData(...) + synchronized (this) { + mContentStart = contentStart; + mContentEnd = contentEnd; + } + MediaItem[] data = mData; + long[] itemVersion = mItemVersion; + long[] setVersion = mSetVersion; + if (contentStart >= end || start >= contentEnd) { + for (int i = start, n = end; i < n; ++i) { + clearSlot(i % DATA_CACHE_SIZE); + } + } else { + for (int i = start; i < contentStart; ++i) { + clearSlot(i % DATA_CACHE_SIZE); + } + for (int i = contentEnd, n = end; i < n; ++i) { + clearSlot(i % DATA_CACHE_SIZE); + } + } + if (mReloadTask != null) mReloadTask.notifyDirty(); + } + + public void setActiveWindow(int start, int end) { + if (start == mActiveStart && end == mActiveEnd) return; + + mActiveStart = start; + mActiveEnd = end; + + Utils.assertTrue(start <= end + && end - start <= mData.length && end <= mSize); + + int length = mData.length; + mActiveStart = start; + mActiveEnd = end; + + // If no data is visible, keep the cache content + if (start == end) return; + + int contentStart = Utils.clamp((start + end) / 2 - length / 2, + 0, Math.max(0, mSize - length)); + int contentEnd = Math.min(contentStart + length, mSize); + if (mContentStart > start || mContentEnd < end + || Math.abs(contentStart - mContentStart) > MIN_LOAD_COUNT) { + setContentWindow(contentStart, contentEnd); + } + } + + private class MySourceListener implements ContentListener { + public void onContentDirty() { + if (mReloadTask != null) mReloadTask.notifyDirty(); + } + } + + public void setModelListener(AlbumView.ModelListener listener) { + mModelListener = listener; + } + + public void setLoadingListener(LoadingListener listener) { + mLoadingListener = listener; + } + + private <T> T executeAndWait(Callable<T> callable) { + FutureTask<T> task = new FutureTask<T>(callable); + mMainHandler.sendMessage( + mMainHandler.obtainMessage(MSG_RUN_OBJECT, task)); + try { + return task.get(); + } catch (InterruptedException e) { + return null; + } catch (ExecutionException e) { + throw new RuntimeException(e); + } + } + + private static class UpdateInfo { + public long version; + public int reloadStart; + public int reloadCount; + + public int size; + public ArrayList<MediaItem> items; + } + + private class GetUpdateInfo implements Callable<UpdateInfo> { + private final long mVersion; + + public GetUpdateInfo(long version) { + mVersion = version; + } + + public UpdateInfo call() throws Exception { + UpdateInfo info = new UpdateInfo(); + long version = mVersion; + info.version = mSourceVersion; + info.size = mSize; + long setVersion[] = mSetVersion; + for (int i = mContentStart, n = mContentEnd; i < n; ++i) { + int index = i % DATA_CACHE_SIZE; + if (setVersion[index] != version) { + info.reloadStart = i; + info.reloadCount = Math.min(MAX_LOAD_COUNT, n - i); + return info; + } + } + return mSourceVersion == mVersion ? null : info; + } + } + + private class UpdateContent implements Callable<Void> { + + private UpdateInfo mUpdateInfo; + + public UpdateContent(UpdateInfo info) { + mUpdateInfo = info; + } + + @Override + public Void call() throws Exception { + UpdateInfo info = mUpdateInfo; + mSourceVersion = info.version; + if (mSize != info.size) { + mSize = info.size; + if (mModelListener != null) mModelListener.onSizeChanged(mSize); + if (mContentEnd > mSize) mContentEnd = mSize; + if (mActiveEnd > mSize) mActiveEnd = mSize; + } + + ArrayList<MediaItem> items = info.items; + + if (items == null) return null; + int start = Math.max(info.reloadStart, mContentStart); + int end = Math.min(info.reloadStart + items.size(), mContentEnd); + + for (int i = start; i < end; ++i) { + int index = i % DATA_CACHE_SIZE; + mSetVersion[index] = info.version; + MediaItem updateItem = items.get(i - info.reloadStart); + long itemVersion = updateItem.getDataVersion(); + if (mItemVersion[index] != itemVersion) { + mItemVersion[index] = itemVersion; + mData[index] = updateItem; + if (mModelListener != null && i >= mActiveStart && i < mActiveEnd) { + mModelListener.onWindowContentChanged(i); + } + } + } + return null; + } + } + + /* + * The thread model of ReloadTask + * * + * [Reload Task] [Main Thread] + * | | + * getUpdateInfo() --> | (synchronous call) + * (wait) <---- getUpdateInfo() + * | | + * Load Data | + * | | + * updateContent() --> | (synchronous call) + * (wait) updateContent() + * | | + * | | + */ + private class ReloadTask extends Thread { + + private volatile boolean mActive = true; + private volatile boolean mDirty = true; + private boolean mIsLoading = false; + + private void updateLoading(boolean loading) { + if (mIsLoading == loading) return; + mIsLoading = loading; + mMainHandler.sendEmptyMessage(loading ? MSG_LOAD_START : MSG_LOAD_FINISH); + } + + @Override + public void run() { + boolean updateComplete = false; + while (mActive) { + synchronized (this) { + if (mActive && !mDirty && updateComplete) { + updateLoading(false); + Utils.waitWithoutInterrupt(this); + continue; + } + } + mDirty = false; + updateLoading(true); + long version; + synchronized (DataManager.LOCK) { + version = mSource.reload(); + } + UpdateInfo info = executeAndWait(new GetUpdateInfo(version)); + updateComplete = info == null; + if (updateComplete) continue; + synchronized (DataManager.LOCK) { + if (info.version != version) { + info.size = mSource.getMediaItemCount(); + info.version = version; + } + if (info.reloadCount > 0) { + info.items = mSource.getMediaItem(info.reloadStart, info.reloadCount); + } + } + executeAndWait(new UpdateContent(info)); + } + updateLoading(false); + } + + public synchronized void notifyDirty() { + mDirty = true; + notifyAll(); + } + + public synchronized void terminate() { + mActive = false; + notifyAll(); + } + } +} diff --git a/src/com/android/gallery3d/app/AlbumPage.java b/src/com/android/gallery3d/app/AlbumPage.java new file mode 100644 index 000000000..5c09ce2d2 --- /dev/null +++ b/src/com/android/gallery3d/app/AlbumPage.java @@ -0,0 +1,602 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.R; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.data.DataManager; +import com.android.gallery3d.data.MediaDetails; +import com.android.gallery3d.data.MediaItem; +import com.android.gallery3d.data.MediaObject; +import com.android.gallery3d.data.MediaSet; +import com.android.gallery3d.data.MtpDevice; +import com.android.gallery3d.data.Path; +import com.android.gallery3d.ui.ActionModeHandler; +import com.android.gallery3d.ui.ActionModeHandler.ActionModeListener; +import com.android.gallery3d.ui.AlbumView; +import com.android.gallery3d.ui.DetailsWindow; +import com.android.gallery3d.ui.DetailsWindow.CloseListener; +import com.android.gallery3d.ui.GLCanvas; +import com.android.gallery3d.ui.GLView; +import com.android.gallery3d.ui.GridDrawer; +import com.android.gallery3d.ui.HighlightDrawer; +import com.android.gallery3d.ui.PositionProvider; +import com.android.gallery3d.ui.PositionRepository; +import com.android.gallery3d.ui.PositionRepository.Position; +import com.android.gallery3d.ui.SelectionManager; +import com.android.gallery3d.ui.SlotView; +import com.android.gallery3d.ui.StaticBackground; +import com.android.gallery3d.util.Future; +import com.android.gallery3d.util.GalleryUtils; + +import android.app.Activity; +import android.app.ProgressDialog; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.provider.MediaStore; +import android.view.ActionMode; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View.MeasureSpec; +import android.widget.Toast; + +import java.util.Random; + +public class AlbumPage extends ActivityState implements GalleryActionBar.ClusterRunner, + SelectionManager.SelectionListener { + @SuppressWarnings("unused") + private static final String TAG = "AlbumPage"; + + public static final String KEY_MEDIA_PATH = "media-path"; + public static final String KEY_SET_CENTER = "set-center"; + public static final String KEY_AUTO_SELECT_ALL = "auto-select-all"; + public static final String KEY_SHOW_CLUSTER_MENU = "cluster-menu"; + + private static final int REQUEST_SLIDESHOW = 1; + private static final int REQUEST_PHOTO = 2; + private static final int REQUEST_DO_ANIMATION = 3; + + private static final float USER_DISTANCE_METER = 0.3f; + + private boolean mIsActive = false; + private StaticBackground mStaticBackground; + private AlbumView mAlbumView; + private Path mMediaSetPath; + + private AlbumDataAdapter mAlbumDataAdapter; + + protected SelectionManager mSelectionManager; + private GridDrawer mGridDrawer; + private HighlightDrawer mHighlightDrawer; + + private boolean mGetContent; + private boolean mShowClusterMenu; + + private ActionMode mActionMode; + private ActionModeHandler mActionModeHandler; + private int mFocusIndex = 0; + private DetailsWindow mDetailsWindow; + private MediaSet mMediaSet; + private boolean mShowDetails; + private float mUserDistance; // in pixel + + private ProgressDialog mProgressDialog; + private Future<?> mPendingTask; + + private Future<Void> mSyncTask = null; + + private GLView mRootPane = new GLView() { + private float mMatrix[] = new float[16]; + + @Override + protected void onLayout( + boolean changed, int left, int top, int right, int bottom) { + mStaticBackground.layout(0, 0, right - left, bottom - top); + + int slotViewTop = GalleryActionBar.getHeight((Activity) mActivity); + int slotViewBottom = bottom - top; + int slotViewRight = right - left; + + if (mShowDetails) { + mDetailsWindow.measure( + MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); + int width = mDetailsWindow.getMeasuredWidth(); + int detailLeft = right - left - width; + slotViewRight = detailLeft; + mDetailsWindow.layout(detailLeft, slotViewTop, detailLeft + width, + bottom - top); + } else { + mAlbumView.setSelectionDrawer(mGridDrawer); + } + + mAlbumView.layout(0, slotViewTop, slotViewRight, slotViewBottom); + GalleryUtils.setViewPointMatrix(mMatrix, + (right - left) / 2, (bottom - top) / 2, -mUserDistance); + PositionRepository.getInstance(mActivity).setOffset( + 0, slotViewTop); + } + + @Override + protected void render(GLCanvas canvas) { + canvas.save(GLCanvas.SAVE_FLAG_MATRIX); + canvas.multiplyMatrix(mMatrix, 0); + super.render(canvas); + canvas.restore(); + } + }; + + @Override + protected void onBackPressed() { + if (mShowDetails) { + hideDetails(); + } else if (mSelectionManager.inSelectionMode()) { + mSelectionManager.leaveSelectionMode(); + } else { + mAlbumView.savePositions(PositionRepository.getInstance(mActivity)); + super.onBackPressed(); + } + } + + public void onSingleTapUp(int slotIndex) { + MediaItem item = mAlbumDataAdapter.get(slotIndex); + if (item == null) { + Log.w(TAG, "item not ready yet, ignore the click"); + return; + } + if (mShowDetails) { + mHighlightDrawer.setHighlightItem(item.getPath()); + mDetailsWindow.reloadDetails(slotIndex); + } else if (!mSelectionManager.inSelectionMode()) { + if (mGetContent) { + onGetContent(item); + } else { + boolean playVideo = + (item.getSupportedOperations() & MediaItem.SUPPORT_PLAY) != 0; + if (playVideo) { + // Play the video. + PhotoPage.playVideo((Activity) mActivity, item.getPlayUri(), item.getName()); + } else { + // Get into the PhotoPage. + Bundle data = new Bundle(); + mAlbumView.savePositions(PositionRepository.getInstance(mActivity)); + data.putInt(PhotoPage.KEY_INDEX_HINT, slotIndex); + data.putString(PhotoPage.KEY_MEDIA_SET_PATH, + mMediaSetPath.toString()); + data.putString(PhotoPage.KEY_MEDIA_ITEM_PATH, + item.getPath().toString()); + mActivity.getStateManager().startStateForResult( + PhotoPage.class, REQUEST_PHOTO, data); + } + } + } else { + mSelectionManager.toggle(item.getPath()); + mAlbumView.invalidate(); + } + } + + private void onGetContent(final MediaItem item) { + DataManager dm = mActivity.getDataManager(); + Activity activity = (Activity) mActivity; + if (mData.getString(Gallery.EXTRA_CROP) != null) { + // TODO: Handle MtpImagew + Uri uri = dm.getContentUri(item.getPath()); + Intent intent = new Intent(CropImage.ACTION_CROP, uri) + .addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT) + .putExtras(getData()); + if (mData.getParcelable(MediaStore.EXTRA_OUTPUT) == null) { + intent.putExtra(CropImage.KEY_RETURN_DATA, true); + } + activity.startActivity(intent); + activity.finish(); + } else { + activity.setResult(Activity.RESULT_OK, + new Intent(null, item.getContentUri())); + activity.finish(); + } + } + + public void onLongTap(int slotIndex) { + if (mGetContent) return; + if (mShowDetails) { + onSingleTapUp(slotIndex); + } else { + MediaItem item = mAlbumDataAdapter.get(slotIndex); + if (item == null) return; + mSelectionManager.setAutoLeaveSelectionMode(true); + mSelectionManager.toggle(item.getPath()); + mAlbumView.invalidate(); + } + } + + public void doCluster(int clusterType) { + String basePath = mMediaSet.getPath().toString(); + String newPath = FilterUtils.newClusterPath(basePath, clusterType); + Bundle data = new Bundle(getData()); + data.putString(AlbumSetPage.KEY_MEDIA_PATH, newPath); + if (mShowClusterMenu) { + Context context = mActivity.getAndroidContext(); + data.putString(AlbumSetPage.KEY_SET_TITLE, mMediaSet.getName()); + data.putString(AlbumSetPage.KEY_SET_SUBTITLE, + GalleryActionBar.getClusterByTypeString(context, clusterType)); + } + + mAlbumView.savePositions(PositionRepository.getInstance(mActivity)); + mActivity.getStateManager().startStateForResult( + AlbumSetPage.class, REQUEST_DO_ANIMATION, data); + } + + public void doFilter(int filterType) { + String basePath = mMediaSet.getPath().toString(); + String newPath = FilterUtils.switchFilterPath(basePath, filterType); + Bundle data = new Bundle(getData()); + data.putString(AlbumPage.KEY_MEDIA_PATH, newPath); + mAlbumView.savePositions(PositionRepository.getInstance(mActivity)); + mActivity.getStateManager().switchState(this, AlbumPage.class, data); + } + + public void onOperationComplete() { + mAlbumView.invalidate(); + // TODO: enable animation + } + + @Override + protected void onCreate(Bundle data, Bundle restoreState) { + mUserDistance = GalleryUtils.meterToPixel(USER_DISTANCE_METER); + initializeViews(); + initializeData(data); + mGetContent = data.getBoolean(Gallery.KEY_GET_CONTENT, false); + mShowClusterMenu = data.getBoolean(KEY_SHOW_CLUSTER_MENU, false); + + startTransition(data); + + // Enable auto-select-all for mtp album + if (data.getBoolean(KEY_AUTO_SELECT_ALL)) { + mSelectionManager.selectAll(); + } + } + + private void startTransition() { + final PositionRepository repository = + PositionRepository.getInstance(mActivity); + mAlbumView.startTransition(new PositionProvider() { + private Position mTempPosition = new Position(); + public Position getPosition(long identity, Position target) { + Position p = repository.get(identity); + if (p != null) return p; + mTempPosition.set(target); + mTempPosition.z = 128; + return mTempPosition; + } + }); + } + + private void startTransition(Bundle data) { + final PositionRepository repository = + PositionRepository.getInstance(mActivity); + final int[] center = data == null + ? null + : data.getIntArray(KEY_SET_CENTER); + final Random random = new Random(); + mAlbumView.startTransition(new PositionProvider() { + private Position mTempPosition = new Position(); + public Position getPosition(long identity, Position target) { + Position p = repository.get(identity); + if (p != null) return p; + if (center != null) { + random.setSeed(identity); + mTempPosition.set(center[0], center[1], + 0, random.nextInt(60) - 30, 0); + } else { + mTempPosition.set(target); + mTempPosition.z = 128; + } + return mTempPosition; + } + }); + } + + @Override + protected void onResume() { + super.onResume(); + mIsActive = true; + setContentPane(mRootPane); + mAlbumDataAdapter.resume(); + mAlbumView.resume(); + mActionModeHandler.resume(); + } + + @Override + protected void onPause() { + super.onPause(); + mIsActive = false; + mAlbumDataAdapter.pause(); + mAlbumView.pause(); + if (mDetailsWindow != null) { + mDetailsWindow.pause(); + } + Future<?> task = mPendingTask; + if (task != null) { + // cancel on going task + task.cancel(); + task.waitDone(); + if (mProgressDialog != null) { + mProgressDialog.dismiss(); + mProgressDialog = null; + } + } + if (mSyncTask != null) { + mSyncTask.cancel(); + mSyncTask = null; + } + mActionModeHandler.pause(); + } + + @Override + protected void onDestroy() { + if (mAlbumDataAdapter != null) { + mAlbumDataAdapter.setLoadingListener(null); + } + } + + private void initializeViews() { + mStaticBackground = new StaticBackground((Context) mActivity); + mRootPane.addComponent(mStaticBackground); + + mSelectionManager = new SelectionManager(mActivity, false); + mSelectionManager.setSelectionListener(this); + mGridDrawer = new GridDrawer((Context) mActivity, mSelectionManager); + Config.AlbumPage config = Config.AlbumPage.get((Context) mActivity); + mAlbumView = new AlbumView(mActivity, + config.slotWidth, config.slotHeight, config.displayItemSize); + mAlbumView.setSelectionDrawer(mGridDrawer); + mRootPane.addComponent(mAlbumView); + mAlbumView.setListener(new SlotView.SimpleListener() { + @Override + public void onSingleTapUp(int slotIndex) { + AlbumPage.this.onSingleTapUp(slotIndex); + } + @Override + public void onLongTap(int slotIndex) { + AlbumPage.this.onLongTap(slotIndex); + } + }); + mActionModeHandler = new ActionModeHandler(mActivity, mSelectionManager); + mActionModeHandler.setActionModeListener(new ActionModeListener() { + public boolean onActionItemClicked(MenuItem item) { + return onItemSelected(item); + } + }); + mStaticBackground.setImage(R.drawable.background, + R.drawable.background_portrait); + } + + private void initializeData(Bundle data) { + mMediaSetPath = Path.fromString(data.getString(KEY_MEDIA_PATH)); + mMediaSet = mActivity.getDataManager().getMediaSet(mMediaSetPath); + Utils.assertTrue(mMediaSet != null, + "MediaSet is null. Path = %s", mMediaSetPath); + mSelectionManager.setSourceMediaSet(mMediaSet); + mAlbumDataAdapter = new AlbumDataAdapter(mActivity, mMediaSet); + mAlbumDataAdapter.setLoadingListener(new MyLoadingListener()); + mAlbumView.setModel(mAlbumDataAdapter); + } + + private void showDetails() { + mShowDetails = true; + if (mDetailsWindow == null) { + mHighlightDrawer = new HighlightDrawer(mActivity.getAndroidContext()); + mDetailsWindow = new DetailsWindow(mActivity, new MyDetailsSource()); + mDetailsWindow.setCloseListener(new CloseListener() { + public void onClose() { + hideDetails(); + } + }); + mRootPane.addComponent(mDetailsWindow); + } + mAlbumView.setSelectionDrawer(mHighlightDrawer); + mDetailsWindow.show(); + } + + private void hideDetails() { + mShowDetails = false; + mAlbumView.setSelectionDrawer(mGridDrawer); + mDetailsWindow.hide(); + } + + @Override + protected boolean onCreateActionBar(Menu menu) { + Activity activity = (Activity) mActivity; + GalleryActionBar actionBar = mActivity.getGalleryActionBar(); + MenuInflater inflater = activity.getMenuInflater(); + + if (mGetContent) { + inflater.inflate(R.menu.pickup, menu); + int typeBits = mData.getInt(Gallery.KEY_TYPE_BITS, + DataManager.INCLUDE_IMAGE); + + actionBar.setTitle(GalleryUtils.getSelectionModePrompt(typeBits)); + } else { + inflater.inflate(R.menu.album, menu); + actionBar.setTitle(mMediaSet.getName()); + if (mMediaSet instanceof MtpDevice) { + menu.findItem(R.id.action_slideshow).setVisible(false); + } else { + menu.findItem(R.id.action_slideshow).setVisible(true); + } + + MenuItem groupBy = menu.findItem(R.id.action_group_by); + FilterUtils.setupMenuItems(actionBar, mMediaSetPath, true); + + if (groupBy != null) { + groupBy.setVisible(mShowClusterMenu); + } + + actionBar.setTitle(mMediaSet.getName()); + } + actionBar.setSubtitle(null); + + return true; + } + + @Override + protected boolean onItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.action_select: + mSelectionManager.setAutoLeaveSelectionMode(false); + mSelectionManager.enterSelectionMode(); + return true; + case R.id.action_group_by: { + mActivity.getGalleryActionBar().showClusterDialog(this); + return true; + } + case R.id.action_slideshow: { + Bundle data = new Bundle(); + data.putString(SlideshowPage.KEY_SET_PATH, + mMediaSetPath.toString()); + data.putBoolean(SlideshowPage.KEY_REPEAT, true); + mActivity.getStateManager().startStateForResult( + SlideshowPage.class, REQUEST_SLIDESHOW, data); + return true; + } + case R.id.action_details: { + if (mShowDetails) { + hideDetails(); + } else { + showDetails(); + } + return true; + } + default: + return false; + } + } + + @Override + protected void onStateResult(int request, int result, Intent data) { + switch (request) { + case REQUEST_SLIDESHOW: { + // data could be null, if there is no images in the album + if (data == null) return; + mFocusIndex = data.getIntExtra(SlideshowPage.KEY_PHOTO_INDEX, 0); + mAlbumView.setCenterIndex(mFocusIndex); + break; + } + case REQUEST_PHOTO: { + if (data == null) return; + mFocusIndex = data.getIntExtra(PhotoPage.KEY_INDEX_HINT, 0); + mAlbumView.setCenterIndex(mFocusIndex); + startTransition(); + break; + } + case REQUEST_DO_ANIMATION: { + startTransition(null); + break; + } + } + } + + public void onSelectionModeChange(int mode) { + switch (mode) { + case SelectionManager.ENTER_SELECTION_MODE: { + mActionMode = mActionModeHandler.startActionMode(); + break; + } + case SelectionManager.LEAVE_SELECTION_MODE: { + mActionMode.finish(); + mRootPane.invalidate(); + break; + } + case SelectionManager.SELECT_ALL_MODE: { + int count = mSelectionManager.getSelectedCount(); + String format = mActivity.getResources().getQuantityString( + R.plurals.number_of_items_selected, count); + mActionModeHandler.setTitle(String.format(format, count)); + mActionModeHandler.updateSupportedOperation(); + mRootPane.invalidate(); + break; + } + } + } + + public void onSelectionChange(Path path, boolean selected) { + Utils.assertTrue(mActionMode != null); + int count = mSelectionManager.getSelectedCount(); + String format = mActivity.getResources().getQuantityString( + R.plurals.number_of_items_selected, count); + mActionModeHandler.setTitle(String.format(format, count)); + mActionModeHandler.updateSupportedOperation(path, selected); + } + + private class MyLoadingListener implements LoadingListener { + @Override + public void onLoadingStarted() { + GalleryUtils.setSpinnerVisibility((Activity) mActivity, true); + } + + @Override + public void onLoadingFinished() { + if (!mIsActive) return; + if (mAlbumDataAdapter.size() == 0) { + if (mSyncTask == null) { + mSyncTask = mMediaSet.requestSync(); + } + if (mSyncTask.isDone()){ + Toast.makeText((Context) mActivity, + R.string.empty_album, Toast.LENGTH_LONG).show(); + mActivity.getStateManager().finishState(AlbumPage.this); + } + } + if (mSyncTask == null || mSyncTask.isDone()) { + GalleryUtils.setSpinnerVisibility((Activity) mActivity, false); + } + } + } + + private class MyDetailsSource implements DetailsWindow.DetailsSource { + private int mIndex; + public int size() { + return mAlbumDataAdapter.size(); + } + + // If requested index is out of active window, suggest a valid index. + // If there is no valid index available, return -1. + public int findIndex(int indexHint) { + if (mAlbumDataAdapter.isActive(indexHint)) { + mIndex = indexHint; + } else { + mIndex = mAlbumDataAdapter.getActiveStart(); + if (!mAlbumDataAdapter.isActive(mIndex)) { + return -1; + } + } + return mIndex; + } + + public MediaDetails getDetails() { + MediaObject item = mAlbumDataAdapter.get(mIndex); + if (item != null) { + mHighlightDrawer.setHighlightItem(item.getPath()); + return item.getDetails(); + } else { + return null; + } + } + } +} diff --git a/src/com/android/gallery3d/app/AlbumPicker.java b/src/com/android/gallery3d/app/AlbumPicker.java new file mode 100644 index 000000000..b86aee879 --- /dev/null +++ b/src/com/android/gallery3d/app/AlbumPicker.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.gallery3d.app; + +import com.android.gallery3d.R; +import com.android.gallery3d.data.DataManager; +import com.android.gallery3d.ui.GLRoot; +import com.android.gallery3d.ui.GLRootView; + +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.view.View.OnClickListener; + +public class AlbumPicker extends AbstractGalleryActivity + implements OnClickListener { + + public static final String KEY_ALBUM_PATH = "album-path"; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.dialog_picker); + ((GLRootView) findViewById(R.id.gl_root_view)).setZOrderOnTop(true); + findViewById(R.id.cancel).setOnClickListener(this); + setTitle(R.string.select_album); + Intent intent = getIntent(); + Bundle extras = intent.getExtras(); + Bundle data = extras == null ? new Bundle() : new Bundle(extras); + + data.putBoolean(Gallery.KEY_GET_ALBUM, true); + data.putString(AlbumSetPage.KEY_MEDIA_PATH, + getDataManager().getTopSetPath(DataManager.INCLUDE_IMAGE)); + getStateManager().startState(AlbumSetPage.class, data); + } + + @Override + public void onBackPressed() { + // send the back event to the top sub-state + GLRoot root = getGLRoot(); + root.lockRenderThread(); + try { + getStateManager().getTopState().onBackPressed(); + } finally { + root.unlockRenderThread(); + } + } + + @Override + public void onClick(View v) { + if (v.getId() == R.id.cancel) finish(); + } +} diff --git a/src/com/android/gallery3d/app/AlbumSetDataAdapter.java b/src/com/android/gallery3d/app/AlbumSetDataAdapter.java new file mode 100644 index 000000000..9086ddbf4 --- /dev/null +++ b/src/com/android/gallery3d/app/AlbumSetDataAdapter.java @@ -0,0 +1,384 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.common.Utils; +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.ui.AlbumSetView; +import com.android.gallery3d.ui.SynchronizedHandler; + +import android.os.Handler; +import android.os.Message; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; + +public class AlbumSetDataAdapter implements AlbumSetView.Model { + @SuppressWarnings("unused") + private static final String TAG = "AlbumSetDataAdapter"; + + private static final int INDEX_NONE = -1; + + private static final int MIN_LOAD_COUNT = 4; + private static final int MAX_COVER_COUNT = 4; + + private static final int MSG_LOAD_START = 1; + private static final int MSG_LOAD_FINISH = 2; + private static final int MSG_RUN_OBJECT = 3; + + private static final MediaItem[] EMPTY_MEDIA_ITEMS = new MediaItem[0]; + + private final MediaSet[] mData; + private final MediaItem[][] mCoverData; + private final long[] mItemVersion; + private final long[] mSetVersion; + + private int mActiveStart = 0; + private int mActiveEnd = 0; + + private int mContentStart = 0; + private int mContentEnd = 0; + + private final MediaSet mSource; + private long mSourceVersion = MediaObject.INVALID_DATA_VERSION; + private int mSize; + + private AlbumSetView.ModelListener mModelListener; + private LoadingListener mLoadingListener; + private ReloadTask mReloadTask; + + private final Handler mMainHandler; + + private MySourceListener mSourceListener = new MySourceListener(); + + public AlbumSetDataAdapter(GalleryActivity activity, MediaSet albumSet, int cacheSize) { + mSource = Utils.checkNotNull(albumSet); + mCoverData = new MediaItem[cacheSize][]; + mData = new MediaSet[cacheSize]; + mItemVersion = new long[cacheSize]; + mSetVersion = new long[cacheSize]; + Arrays.fill(mItemVersion, MediaObject.INVALID_DATA_VERSION); + Arrays.fill(mSetVersion, MediaObject.INVALID_DATA_VERSION); + + mMainHandler = new SynchronizedHandler(activity.getGLRoot()) { + @Override + public void handleMessage(Message message) { + switch (message.what) { + case MSG_RUN_OBJECT: + ((Runnable) message.obj).run(); + return; + case MSG_LOAD_START: + if (mLoadingListener != null) mLoadingListener.onLoadingStarted(); + return; + case MSG_LOAD_FINISH: + if (mLoadingListener != null) mLoadingListener.onLoadingFinished(); + return; + } + } + }; + } + + public void pause() { + mReloadTask.terminate(); + mReloadTask = null; + mSource.removeContentListener(mSourceListener); + } + + public void resume() { + mSource.addContentListener(mSourceListener); + mReloadTask = new ReloadTask(); + mReloadTask.start(); + } + + public MediaSet getMediaSet(int index) { + if (index < mActiveStart && index >= mActiveEnd) { + throw new IllegalArgumentException(String.format( + "%s not in (%s, %s)", index, mActiveStart, mActiveEnd)); + } + return mData[index % mData.length]; + } + + public MediaItem[] getCoverItems(int index) { + if (index < mActiveStart && index >= mActiveEnd) { + throw new IllegalArgumentException(String.format( + "%s not in (%s, %s)", index, mActiveStart, mActiveEnd)); + } + MediaItem[] result = mCoverData[index % mCoverData.length]; + + // If the result is not ready yet, return an empty array + return result == null ? EMPTY_MEDIA_ITEMS : result; + } + + public int getActiveStart() { + return mActiveStart; + } + + public int getActiveEnd() { + return mActiveEnd; + } + + public boolean isActive(int index) { + return index >= mActiveStart && index < mActiveEnd; + } + + public int size() { + return mSize; + } + + private void clearSlot(int slotIndex) { + mData[slotIndex] = null; + mCoverData[slotIndex] = null; + mItemVersion[slotIndex] = MediaObject.INVALID_DATA_VERSION; + mSetVersion[slotIndex] = MediaObject.INVALID_DATA_VERSION; + } + + private void setContentWindow(int contentStart, int contentEnd) { + if (contentStart == mContentStart && contentEnd == mContentEnd) return; + MediaItem[][] data = mCoverData; + int length = data.length; + + int start = this.mContentStart; + int end = this.mContentEnd; + + mContentStart = contentStart; + mContentEnd = contentEnd; + + if (contentStart >= end || start >= contentEnd) { + for (int i = start, n = end; i < n; ++i) { + clearSlot(i % length); + } + } else { + for (int i = start; i < contentStart; ++i) { + clearSlot(i % length); + } + for (int i = contentEnd, n = end; i < n; ++i) { + clearSlot(i % length); + } + } + mReloadTask.notifyDirty(); + } + + public void setActiveWindow(int start, int end) { + if (start == mActiveStart && end == mActiveEnd) return; + + Utils.assertTrue(start <= end + && end - start <= mCoverData.length && end <= mSize); + + mActiveStart = start; + mActiveEnd = end; + + int length = mCoverData.length; + // If no data is visible, keep the cache content + if (start == end) return; + + int contentStart = Utils.clamp((start + end) / 2 - length / 2, + 0, Math.max(0, mSize - length)); + int contentEnd = Math.min(contentStart + length, mSize); + if (mContentStart > start || mContentEnd < end + || Math.abs(contentStart - mContentStart) > MIN_LOAD_COUNT) { + setContentWindow(contentStart, contentEnd); + } + } + + private class MySourceListener implements ContentListener { + public void onContentDirty() { + mReloadTask.notifyDirty(); + } + } + + public void setModelListener(AlbumSetView.ModelListener listener) { + mModelListener = listener; + } + + public void setLoadingListener(LoadingListener listener) { + mLoadingListener = listener; + } + + private static void getRepresentativeItems(MediaSet set, int wanted, + ArrayList<MediaItem> result) { + if (set.getMediaItemCount() > 0) { + result.addAll(set.getMediaItem(0, wanted)); + } + + int n = set.getSubMediaSetCount(); + for (int i = 0; i < n && wanted > result.size(); i++) { + MediaSet subset = set.getSubMediaSet(i); + double perSet = (double) (wanted - result.size()) / (n - i); + int m = (int) Math.ceil(perSet); + getRepresentativeItems(subset, m, result); + } + } + + private static class UpdateInfo { + public long version; + public int index; + + public int size; + public MediaSet item; + public MediaItem covers[]; + } + + private class GetUpdateInfo implements Callable<UpdateInfo> { + + private final long mVersion; + + public GetUpdateInfo(long version) { + mVersion = version; + } + + private int getInvalidIndex(long version) { + long setVersion[] = mSetVersion; + int length = setVersion.length; + for (int i = mContentStart, n = mContentEnd; i < n; ++i) { + int index = i % length; + if (setVersion[i % length] != version) return i; + } + return INDEX_NONE; + } + + @Override + public UpdateInfo call() throws Exception { + int index = getInvalidIndex(mVersion); + if (index == INDEX_NONE + && mSourceVersion == mVersion) return null; + UpdateInfo info = new UpdateInfo(); + info.version = mSourceVersion; + info.index = index; + info.size = mSize; + return info; + } + } + + private class UpdateContent implements Callable<Void> { + private UpdateInfo mUpdateInfo; + + public UpdateContent(UpdateInfo info) { + mUpdateInfo = info; + } + + public Void call() { + UpdateInfo info = mUpdateInfo; + mSourceVersion = info.version; + if (mSize != info.size) { + mSize = info.size; + if (mModelListener != null) mModelListener.onSizeChanged(mSize); + if (mContentEnd > mSize) mContentEnd = mSize; + if (mActiveEnd > mSize) mActiveEnd = mSize; + } + // Note: info.index could be INDEX_NONE, i.e., -1 + if (info.index >= mContentStart && info.index < mContentEnd) { + int pos = info.index % mCoverData.length; + mSetVersion[pos] = info.version; + long itemVersion = info.item.getDataVersion(); + if (mItemVersion[pos] == itemVersion) return null; + mItemVersion[pos] = itemVersion; + mData[pos] = info.item; + mCoverData[pos] = info.covers; + if (mModelListener != null + && info.index >= mActiveStart && info.index < mActiveEnd) { + mModelListener.onWindowContentChanged(info.index); + } + } + return null; + } + } + + private <T> T executeAndWait(Callable<T> callable) { + FutureTask<T> task = new FutureTask<T>(callable); + mMainHandler.sendMessage( + mMainHandler.obtainMessage(MSG_RUN_OBJECT, task)); + try { + return task.get(); + } catch (InterruptedException e) { + return null; + } catch (ExecutionException e) { + throw new RuntimeException(e); + } + } + + // TODO: load active range first + private class ReloadTask extends Thread { + private volatile boolean mActive = true; + private volatile boolean mDirty = true; + private volatile boolean mIsLoading = false; + + private void updateLoading(boolean loading) { + if (mIsLoading == loading) return; + mIsLoading = loading; + mMainHandler.sendEmptyMessage(loading ? MSG_LOAD_START : MSG_LOAD_FINISH); + } + + @Override + public void run() { + boolean updateComplete = false; + while (mActive) { + synchronized (this) { + if (mActive && !mDirty && updateComplete) { + updateLoading(false); + Utils.waitWithoutInterrupt(this); + continue; + } + } + mDirty = false; + updateLoading(true); + + long version; + synchronized (DataManager.LOCK) { + version = mSource.reload(); + } + UpdateInfo info = executeAndWait(new GetUpdateInfo(version)); + updateComplete = info == null; + if (updateComplete) continue; + + synchronized (DataManager.LOCK) { + if (info.version != version) { + info.version = version; + info.size = mSource.getSubMediaSetCount(); + } + if (info.index != INDEX_NONE) { + info.item = mSource.getSubMediaSet(info.index); + if (info.item == null) continue; + ArrayList<MediaItem> covers = new ArrayList<MediaItem>(); + getRepresentativeItems(info.item, MAX_COVER_COUNT, covers); + info.covers = covers.toArray(new MediaItem[covers.size()]); + } + } + executeAndWait(new UpdateContent(info)); + } + updateLoading(false); + } + + public synchronized void notifyDirty() { + mDirty = true; + notifyAll(); + } + + public synchronized void terminate() { + mActive = false; + notifyAll(); + } + } +} + + diff --git a/src/com/android/gallery3d/app/AlbumSetPage.java b/src/com/android/gallery3d/app/AlbumSetPage.java new file mode 100644 index 000000000..688ff81f2 --- /dev/null +++ b/src/com/android/gallery3d/app/AlbumSetPage.java @@ -0,0 +1,586 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.R; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.data.DataManager; +import com.android.gallery3d.data.MediaDetails; +import com.android.gallery3d.data.MediaObject; +import com.android.gallery3d.data.MediaSet; +import com.android.gallery3d.data.Path; +import com.android.gallery3d.picasasource.PicasaSource; +import com.android.gallery3d.settings.GallerySettings; +import com.android.gallery3d.ui.ActionModeHandler; +import com.android.gallery3d.ui.ActionModeHandler.ActionModeListener; +import com.android.gallery3d.ui.AlbumSetView; +import com.android.gallery3d.ui.DetailsWindow; +import com.android.gallery3d.ui.DetailsWindow.CloseListener; +import com.android.gallery3d.ui.GLCanvas; +import com.android.gallery3d.ui.GLView; +import com.android.gallery3d.ui.GridDrawer; +import com.android.gallery3d.ui.HighlightDrawer; +import com.android.gallery3d.ui.PositionProvider; +import com.android.gallery3d.ui.PositionRepository; +import com.android.gallery3d.ui.PositionRepository.Position; +import com.android.gallery3d.ui.SelectionManager; +import com.android.gallery3d.ui.SlotView; +import com.android.gallery3d.ui.StaticBackground; +import com.android.gallery3d.ui.SynchronizedHandler; +import com.android.gallery3d.util.GalleryUtils; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.graphics.Rect; +import android.os.Bundle; +import android.os.Message; +import android.provider.MediaStore; +import android.view.ActionMode; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View.MeasureSpec; +import android.widget.Toast; + +public class AlbumSetPage extends ActivityState implements + SelectionManager.SelectionListener, GalleryActionBar.ClusterRunner, + EyePosition.EyePositionListener { + @SuppressWarnings("unused") + private static final String TAG = "AlbumSetPage"; + + public static final String KEY_MEDIA_PATH = "media-path"; + public static final String KEY_SET_TITLE = "set-title"; + public static final String KEY_SET_SUBTITLE = "set-subtitle"; + private static final int DATA_CACHE_SIZE = 256; + private static final int REQUEST_DO_ANIMATION = 1; + private static final int MSG_GOTO_MANAGE_CACHE_PAGE = 1; + + private boolean mIsActive = false; + private StaticBackground mStaticBackground; + private AlbumSetView mAlbumSetView; + + private MediaSet mMediaSet; + private String mTitle; + private String mSubtitle; + private boolean mShowClusterTabs; + + protected SelectionManager mSelectionManager; + private AlbumSetDataAdapter mAlbumSetDataAdapter; + private GridDrawer mGridDrawer; + private HighlightDrawer mHighlightDrawer; + + private boolean mGetContent; + private boolean mGetAlbum; + private ActionMode mActionMode; + private ActionModeHandler mActionModeHandler; + private DetailsWindow mDetailsWindow; + private boolean mShowDetails; + private EyePosition mEyePosition; + + // The eyes' position of the user, the origin is at the center of the + // device and the unit is in pixels. + private float mX; + private float mY; + private float mZ; + + private SynchronizedHandler mHandler; + + private GLView mRootPane = new GLView() { + private float mMatrix[] = new float[16]; + + @Override + protected void onLayout( + boolean changed, int left, int top, int right, int bottom) { + mStaticBackground.layout(0, 0, right - left, bottom - top); + mEyePosition.resetPosition(); + + int slotViewTop = GalleryActionBar.getHeight((Activity) mActivity); + int slotViewBottom = bottom - top; + int slotViewRight = right - left; + + if (mShowDetails) { + mDetailsWindow.measure( + MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); + int width = mDetailsWindow.getMeasuredWidth(); + int detailLeft = right - left - width; + slotViewRight = detailLeft; + mDetailsWindow.layout(detailLeft, slotViewTop, detailLeft + width, + bottom - top); + } else { + mAlbumSetView.setSelectionDrawer(mGridDrawer); + } + + mAlbumSetView.layout(0, slotViewTop, slotViewRight, slotViewBottom); + PositionRepository.getInstance(mActivity).setOffset( + 0, slotViewTop); + } + + @Override + protected void render(GLCanvas canvas) { + canvas.save(GLCanvas.SAVE_FLAG_MATRIX); + GalleryUtils.setViewPointMatrix(mMatrix, + getWidth() / 2 + mX, getHeight() / 2 + mY, mZ); + canvas.multiplyMatrix(mMatrix, 0); + super.render(canvas); + canvas.restore(); + } + }; + + @Override + public void onEyePositionChanged(float x, float y, float z) { + mRootPane.lockRendering(); + mX = x; + mY = y; + mZ = z; + mRootPane.unlockRendering(); + mRootPane.invalidate(); + } + + @Override + public void onBackPressed() { + if (mShowDetails) { + hideDetails(); + } else if (mSelectionManager.inSelectionMode()) { + mSelectionManager.leaveSelectionMode(); + } else { + mAlbumSetView.savePositions( + PositionRepository.getInstance(mActivity)); + super.onBackPressed(); + } + } + + private void savePositions(int slotIndex, int center[]) { + Rect offset = new Rect(); + mRootPane.getBoundsOf(mAlbumSetView, offset); + mAlbumSetView.savePositions(PositionRepository.getInstance(mActivity)); + Rect r = mAlbumSetView.getSlotRect(slotIndex); + int scrollX = mAlbumSetView.getScrollX(); + int scrollY = mAlbumSetView.getScrollY(); + center[0] = offset.left + (r.left + r.right) / 2 - scrollX; + center[1] = offset.top + (r.top + r.bottom) / 2 - scrollY; + } + + public void onSingleTapUp(int slotIndex) { + MediaSet targetSet = mAlbumSetDataAdapter.getMediaSet(slotIndex); + if (targetSet == null) return; // Content is dirty, we shall reload soon + + if (mShowDetails) { + Path path = targetSet.getPath(); + mHighlightDrawer.setHighlightItem(path); + mDetailsWindow.reloadDetails(slotIndex); + } else if (!mSelectionManager.inSelectionMode()) { + Bundle data = new Bundle(getData()); + String mediaPath = targetSet.getPath().toString(); + int[] center = new int[2]; + savePositions(slotIndex, center); + data.putIntArray(AlbumPage.KEY_SET_CENTER, center); + if (mGetAlbum && targetSet.isLeafAlbum()) { + Activity activity = (Activity) mActivity; + Intent result = new Intent() + .putExtra(AlbumPicker.KEY_ALBUM_PATH, targetSet.getPath().toString()); + activity.setResult(Activity.RESULT_OK, result); + activity.finish(); + } else if (targetSet.getSubMediaSetCount() > 0) { + data.putString(AlbumSetPage.KEY_MEDIA_PATH, mediaPath); + mActivity.getStateManager().startStateForResult( + AlbumSetPage.class, REQUEST_DO_ANIMATION, data); + } else { + if (!mGetContent && (targetSet.getSupportedOperations() + & MediaObject.SUPPORT_IMPORT) != 0) { + data.putBoolean(AlbumPage.KEY_AUTO_SELECT_ALL, true); + } + data.putString(AlbumPage.KEY_MEDIA_PATH, mediaPath); + boolean inAlbum = mActivity.getStateManager().hasStateClass(AlbumPage.class); + // We only show cluster menu in the first AlbumPage in stack + data.putBoolean(AlbumPage.KEY_SHOW_CLUSTER_MENU, !inAlbum); + mActivity.getStateManager().startStateForResult( + AlbumPage.class, REQUEST_DO_ANIMATION, data); + } + } else { + mSelectionManager.toggle(targetSet.getPath()); + mAlbumSetView.invalidate(); + } + } + + public void onLongTap(int slotIndex) { + if (mGetContent || mGetAlbum) return; + if (mShowDetails) { + onSingleTapUp(slotIndex); + } else { + MediaSet set = mAlbumSetDataAdapter.getMediaSet(slotIndex); + if (set == null) return; + mSelectionManager.setAutoLeaveSelectionMode(true); + mSelectionManager.toggle(set.getPath()); + mAlbumSetView.invalidate(); + } + } + + public void doCluster(int clusterType) { + String basePath = mMediaSet.getPath().toString(); + String newPath = FilterUtils.switchClusterPath(basePath, clusterType); + Bundle data = new Bundle(getData()); + data.putString(AlbumSetPage.KEY_MEDIA_PATH, newPath); + mAlbumSetView.savePositions(PositionRepository.getInstance(mActivity)); + mActivity.getStateManager().switchState(this, AlbumSetPage.class, data); + } + + public void doFilter(int filterType) { + String basePath = mMediaSet.getPath().toString(); + String newPath = FilterUtils.switchFilterPath(basePath, filterType); + Bundle data = new Bundle(getData()); + data.putString(AlbumSetPage.KEY_MEDIA_PATH, newPath); + mAlbumSetView.savePositions(PositionRepository.getInstance(mActivity)); + mActivity.getStateManager().switchState(this, AlbumSetPage.class, data); + } + + public void onOperationComplete() { + mAlbumSetView.invalidate(); + // TODO: enable animation + } + + @Override + public void onCreate(Bundle data, Bundle restoreState) { + mHandler = new SynchronizedHandler(mActivity.getGLRoot()) { + @Override + public void handleMessage(Message message) { + Utils.assertTrue(message.what == MSG_GOTO_MANAGE_CACHE_PAGE); + Bundle data = new Bundle(); + String mediaPath = mActivity.getDataManager().getTopSetPath( + DataManager.INCLUDE_ALL); + data.putString(AlbumSetPage.KEY_MEDIA_PATH, mediaPath); + mActivity.getStateManager().startState(ManageCachePage.class, data); + } + }; + + initializeViews(); + initializeData(data); + mGetContent = data.getBoolean(Gallery.KEY_GET_CONTENT, false); + mGetAlbum = data.getBoolean(Gallery.KEY_GET_ALBUM, false); + mTitle = data.getString(AlbumSetPage.KEY_SET_TITLE); + mSubtitle = data.getString(AlbumSetPage.KEY_SET_SUBTITLE); + mEyePosition = new EyePosition(mActivity.getAndroidContext(), this); + + startTransition(); + } + + @Override + public void onPause() { + super.onPause(); + mIsActive = false; + mActionModeHandler.pause(); + mAlbumSetDataAdapter.pause(); + mAlbumSetView.pause(); + mEyePosition.pause(); + if (mDetailsWindow != null) { + mDetailsWindow.pause(); + } + GalleryActionBar actionBar = mActivity.getGalleryActionBar(); + if (actionBar != null) actionBar.hideClusterTabs(); + } + + @Override + public void onResume() { + super.onResume(); + mIsActive = true; + setContentPane(mRootPane); + mAlbumSetDataAdapter.resume(); + mAlbumSetView.resume(); + mEyePosition.resume(); + mActionModeHandler.resume(); + GalleryActionBar actionBar = mActivity.getGalleryActionBar(); + if (mShowClusterTabs && actionBar != null) actionBar.showClusterTabs(this); + } + + private void initializeData(Bundle data) { + String mediaPath = data.getString(AlbumSetPage.KEY_MEDIA_PATH); + mMediaSet = mActivity.getDataManager().getMediaSet(mediaPath); + mSelectionManager.setSourceMediaSet(mMediaSet); + mAlbumSetDataAdapter = new AlbumSetDataAdapter( + mActivity, mMediaSet, DATA_CACHE_SIZE); + mAlbumSetDataAdapter.setLoadingListener(new MyLoadingListener()); + mAlbumSetView.setModel(mAlbumSetDataAdapter); + } + + private void initializeViews() { + mSelectionManager = new SelectionManager(mActivity, true); + mSelectionManager.setSelectionListener(this); + mStaticBackground = new StaticBackground(mActivity.getAndroidContext()); + mRootPane.addComponent(mStaticBackground); + + mGridDrawer = new GridDrawer((Context) mActivity, mSelectionManager); + Config.AlbumSetPage config = Config.AlbumSetPage.get((Context) mActivity); + mAlbumSetView = new AlbumSetView(mActivity, mGridDrawer, + config.slotWidth, config.slotHeight, + config.displayItemSize, config.labelFontSize, + config.labelOffsetY, config.labelMargin); + mAlbumSetView.setListener(new SlotView.SimpleListener() { + @Override + public void onSingleTapUp(int slotIndex) { + AlbumSetPage.this.onSingleTapUp(slotIndex); + } + @Override + public void onLongTap(int slotIndex) { + AlbumSetPage.this.onLongTap(slotIndex); + } + }); + + mActionModeHandler = new ActionModeHandler(mActivity, mSelectionManager); + mActionModeHandler.setActionModeListener(new ActionModeListener() { + public boolean onActionItemClicked(MenuItem item) { + return onItemSelected(item); + } + }); + mRootPane.addComponent(mAlbumSetView); + + mStaticBackground.setImage(R.drawable.background, + R.drawable.background_portrait); + } + + @Override + protected boolean onCreateActionBar(Menu menu) { + Activity activity = (Activity) mActivity; + GalleryActionBar actionBar = mActivity.getGalleryActionBar(); + MenuInflater inflater = activity.getMenuInflater(); + + final boolean inAlbum = mActivity.getStateManager().hasStateClass( + AlbumPage.class); + + if (mGetContent) { + inflater.inflate(R.menu.pickup, menu); + int typeBits = mData.getInt( + Gallery.KEY_TYPE_BITS, DataManager.INCLUDE_IMAGE); + int id = R.string.select_image; + if ((typeBits & DataManager.INCLUDE_VIDEO) != 0) { + id = (typeBits & DataManager.INCLUDE_IMAGE) == 0 + ? R.string.select_video + : R.string.select_item; + } + actionBar.setTitle(id); + } else if (mGetAlbum) { + inflater.inflate(R.menu.pickup, menu); + actionBar.setTitle(R.string.select_album); + } else { + mShowClusterTabs = !inAlbum; + inflater.inflate(R.menu.albumset, menu); + if (mTitle != null) { + actionBar.setTitle(mTitle); + } else { + actionBar.setTitle(activity.getApplicationInfo().labelRes); + } + MenuItem selectItem = menu.findItem(R.id.action_select); + + if (selectItem != null) { + boolean selectAlbums = !inAlbum && + actionBar.getClusterTypeAction() == FilterUtils.CLUSTER_BY_ALBUM; + if (selectAlbums) { + selectItem.setTitle(R.string.select_album); + } else { + selectItem.setTitle(R.string.select_group); + } + } + + MenuItem switchCamera = menu.findItem(R.id.action_camera); + if (switchCamera != null) { + switchCamera.setVisible(GalleryUtils.isCameraAvailable(activity)); + } + + actionBar.setSubtitle(mSubtitle); + } + return true; + } + + @Override + protected boolean onItemSelected(MenuItem item) { + Activity activity = (Activity) mActivity; + switch (item.getItemId()) { + case R.id.action_select: + mSelectionManager.setAutoLeaveSelectionMode(false); + mSelectionManager.enterSelectionMode(); + return true; + case R.id.action_details: + if (mAlbumSetDataAdapter.size() != 0) { + if (mShowDetails) { + hideDetails(); + } else { + showDetails(); + } + } else { + Toast.makeText(activity, + activity.getText(R.string.no_albums_alert), + Toast.LENGTH_SHORT).show(); + } + return true; + case R.id.action_camera: { + Intent intent = new Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA) + .setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP + | Intent.FLAG_ACTIVITY_NEW_TASK); + activity.startActivity(intent); + return true; + } + case R.id.action_manage_offline: { + mHandler.sendEmptyMessage(MSG_GOTO_MANAGE_CACHE_PAGE); + return true; + } + case R.id.action_sync_picasa_albums: { + PicasaSource.requestSync(activity); + return true; + } + case R.id.action_settings: { + activity.startActivity(new Intent(activity, GallerySettings.class)); + return true; + } + default: + return false; + } + } + + @Override + protected void onStateResult(int requestCode, int resultCode, Intent data) { + switch (requestCode) { + case REQUEST_DO_ANIMATION: { + startTransition(); + } + } + } + + private void startTransition() { + final PositionRepository repository = + PositionRepository.getInstance(mActivity); + mAlbumSetView.startTransition(new PositionProvider() { + private Position mTempPosition = new Position(); + public Position getPosition(long identity, Position target) { + Position p = repository.get(identity); + if (p == null) { + p = mTempPosition; + p.set(target.x, target.y, 128, target.theta, 1); + } + return p; + } + }); + } + + private String getSelectedString() { + GalleryActionBar actionBar = mActivity.getGalleryActionBar(); + int count = mSelectionManager.getSelectedCount(); + int action = actionBar.getClusterTypeAction(); + int string = action == FilterUtils.CLUSTER_BY_ALBUM + ? R.plurals.number_of_albums_selected + : R.plurals.number_of_groups_selected; + String format = mActivity.getResources().getQuantityString(string, count); + return String.format(format, count); + } + + public void onSelectionModeChange(int mode) { + + switch (mode) { + case SelectionManager.ENTER_SELECTION_MODE: { + mActivity.getGalleryActionBar().hideClusterTabs(); + mActionMode = mActionModeHandler.startActionMode(); + break; + } + case SelectionManager.LEAVE_SELECTION_MODE: { + mActionMode.finish(); + mActivity.getGalleryActionBar().showClusterTabs(this); + mRootPane.invalidate(); + break; + } + case SelectionManager.SELECT_ALL_MODE: { + mActionModeHandler.setTitle(getSelectedString()); + mRootPane.invalidate(); + break; + } + } + } + + public void onSelectionChange(Path path, boolean selected) { + Utils.assertTrue(mActionMode != null); + mActionModeHandler.setTitle(getSelectedString()); + mActionModeHandler.updateSupportedOperation(path, selected); + } + + private void hideDetails() { + mShowDetails = false; + mAlbumSetView.setSelectionDrawer(mGridDrawer); + mDetailsWindow.hide(); + } + + private void showDetails() { + mShowDetails = true; + if (mDetailsWindow == null) { + mHighlightDrawer = new HighlightDrawer(mActivity.getAndroidContext()); + mDetailsWindow = new DetailsWindow(mActivity, new MyDetailsSource()); + mDetailsWindow.setCloseListener(new CloseListener() { + public void onClose() { + hideDetails(); + } + }); + mRootPane.addComponent(mDetailsWindow); + } + mAlbumSetView.setSelectionDrawer(mHighlightDrawer); + mDetailsWindow.show(); + } + + private class MyLoadingListener implements LoadingListener { + public void onLoadingStarted() { + GalleryUtils.setSpinnerVisibility((Activity) mActivity, true); + } + + public void onLoadingFinished() { + if (!mIsActive) return; + GalleryUtils.setSpinnerVisibility((Activity) mActivity, false); + if (mAlbumSetDataAdapter.size() == 0) { + Toast.makeText((Context) mActivity, + R.string.empty_album, Toast.LENGTH_LONG).show(); + if (mActivity.getStateManager().getStateCount() > 1) { + mActivity.getStateManager().finishState(AlbumSetPage.this); + } + } + } + } + + private class MyDetailsSource implements DetailsWindow.DetailsSource { + private int mIndex; + public int size() { + return mAlbumSetDataAdapter.size(); + } + + // If requested index is out of active window, suggest a valid index. + // If there is no valid index available, return -1. + public int findIndex(int indexHint) { + if (mAlbumSetDataAdapter.isActive(indexHint)) { + mIndex = indexHint; + } else { + mIndex = mAlbumSetDataAdapter.getActiveStart(); + if (!mAlbumSetDataAdapter.isActive(mIndex)) { + return -1; + } + } + return mIndex; + } + + public MediaDetails getDetails() { + MediaObject item = mAlbumSetDataAdapter.getMediaSet(mIndex); + if (item != null) { + mHighlightDrawer.setHighlightItem(item.getPath()); + return item.getDetails(); + } else { + return null; + } + } + } +} diff --git a/src/com/android/gallery3d/app/Config.java b/src/com/android/gallery3d/app/Config.java new file mode 100644 index 000000000..4586235f6 --- /dev/null +++ b/src/com/android/gallery3d/app/Config.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.R; + +import android.content.Context; +import android.content.res.Resources; + +final class Config { + public static class AlbumSetPage { + private static AlbumSetPage sInstance; + + public final int slotWidth; + public final int slotHeight; + public final int displayItemSize; + public final int labelFontSize; + public final int labelOffsetY; + public final int labelMargin; + + public static synchronized AlbumSetPage get(Context context) { + if (sInstance == null) { + sInstance = new AlbumSetPage(context); + } + return sInstance; + } + + private AlbumSetPage(Context context) { + Resources r = context.getResources(); + slotWidth = r.getDimensionPixelSize(R.dimen.albumset_slot_width); + slotHeight = r.getDimensionPixelSize(R.dimen.albumset_slot_height); + displayItemSize = r.getDimensionPixelSize(R.dimen.albumset_display_item_size); + labelFontSize = r.getDimensionPixelSize(R.dimen.albumset_label_font_size); + labelOffsetY = r.getDimensionPixelSize(R.dimen.albumset_label_offset_y); + labelMargin = r.getDimensionPixelSize(R.dimen.albumset_label_margin); + } + } + + public static class AlbumPage { + private static AlbumPage sInstance; + + public final int slotWidth; + public final int slotHeight; + public final int displayItemSize; + + public static synchronized AlbumPage get(Context context) { + if (sInstance == null) { + sInstance = new AlbumPage(context); + } + return sInstance; + } + + private AlbumPage(Context context) { + Resources r = context.getResources(); + slotWidth = r.getDimensionPixelSize(R.dimen.album_slot_width); + slotHeight = r.getDimensionPixelSize(R.dimen.album_slot_height); + displayItemSize = r.getDimensionPixelSize(R.dimen.album_display_item_size); + } + } + + public static class ManageCachePage extends AlbumSetPage { + private static ManageCachePage sInstance; + + public final int cacheBarHeight; + public final int cacheBarPinLeftMargin; + public final int cacheBarPinRightMargin; + public final int cacheBarButtonRightMargin; + public final int cacheBarFontSize; + + public static synchronized ManageCachePage get(Context context) { + if (sInstance == null) { + sInstance = new ManageCachePage(context); + } + return sInstance; + } + + public ManageCachePage(Context context) { + super(context); + Resources r = context.getResources(); + cacheBarHeight = r.getDimensionPixelSize(R.dimen.cache_bar_height); + cacheBarPinLeftMargin = r.getDimensionPixelSize(R.dimen.cache_bar_pin_left_margin); + cacheBarPinRightMargin = r.getDimensionPixelSize( + R.dimen.cache_bar_pin_right_margin); + cacheBarButtonRightMargin = r.getDimensionPixelSize( + R.dimen.cache_bar_button_right_margin); + cacheBarFontSize = r.getDimensionPixelSize(R.dimen.cache_bar_font_size); + } + } + + public static class PhotoPage { + private static PhotoPage sInstance; + + // These are all height values. See the comment in FilmStripView for + // the meaning of these values. + public final int filmstripTopMargin; + public final int filmstripMidMargin; + public final int filmstripBottomMargin; + public final int filmstripThumbSize; + public final int filmstripContentSize; + public final int filmstripGripSize; + public final int filmstripBarSize; + + // These are width values. + public final int filmstripGripWidth; + + public static synchronized PhotoPage get(Context context) { + if (sInstance == null) { + sInstance = new PhotoPage(context); + } + return sInstance; + } + + public PhotoPage(Context context) { + Resources r = context.getResources(); + filmstripTopMargin = r.getDimensionPixelSize(R.dimen.filmstrip_top_margin); + filmstripMidMargin = r.getDimensionPixelSize(R.dimen.filmstrip_mid_margin); + filmstripBottomMargin = r.getDimensionPixelSize(R.dimen.filmstrip_bottom_margin); + filmstripThumbSize = r.getDimensionPixelSize(R.dimen.filmstrip_thumb_size); + filmstripContentSize = r.getDimensionPixelSize(R.dimen.filmstrip_content_size); + filmstripGripSize = r.getDimensionPixelSize(R.dimen.filmstrip_grip_size); + filmstripBarSize = r.getDimensionPixelSize(R.dimen.filmstrip_bar_size); + filmstripGripWidth = r.getDimensionPixelSize(R.dimen.filmstrip_grip_width); + } + } +} + diff --git a/src/com/android/gallery3d/app/CropImage.java b/src/com/android/gallery3d/app/CropImage.java new file mode 100644 index 000000000..6c0a0c7eb --- /dev/null +++ b/src/com/android/gallery3d/app/CropImage.java @@ -0,0 +1,850 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.R; +import com.android.gallery3d.common.BitmapUtils; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.data.DataManager; +import com.android.gallery3d.data.LocalImage; +import com.android.gallery3d.data.MediaItem; +import com.android.gallery3d.data.MediaObject; +import com.android.gallery3d.data.Path; +import com.android.gallery3d.picasasource.PicasaSource; +import com.android.gallery3d.ui.BitmapTileProvider; +import com.android.gallery3d.ui.CropView; +import com.android.gallery3d.ui.GLRoot; +import com.android.gallery3d.ui.SynchronizedHandler; +import com.android.gallery3d.ui.TileImageViewAdapter; +import com.android.gallery3d.util.Future; +import com.android.gallery3d.util.FutureListener; +import com.android.gallery3d.util.GalleryUtils; +import com.android.gallery3d.util.InterruptableOutputStream; +import com.android.gallery3d.util.ThreadPool.CancelListener; +import com.android.gallery3d.util.ThreadPool.Job; +import com.android.gallery3d.util.ThreadPool.JobContext; + +import android.app.ProgressDialog; +import android.app.WallpaperManager; +import android.content.ContentValues; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.Bitmap.CompressFormat; +import android.graphics.Bitmap.Config; +import android.graphics.BitmapFactory; +import android.graphics.BitmapRegionDecoder; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.os.Handler; +import android.os.Message; +import android.provider.MediaStore; +import android.provider.MediaStore.Images; +import android.view.Menu; +import android.view.MenuItem; +import android.view.Window; +import android.widget.Toast; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +/** + * The activity can crop specific region of interest from an image. + */ +public class CropImage extends AbstractGalleryActivity { + private static final String TAG = "CropImage"; + public static final String ACTION_CROP = "com.android.camera.action.CROP"; + + private static final int MAX_PIXEL_COUNT = 5 * 1000000; // 5M pixels + private static final int MAX_FILE_INDEX = 1000; + private static final int TILE_SIZE = 512; + private static final int BACKUP_PIXEL_COUNT = 480000; // around 800x600 + + private static final int MSG_LARGE_BITMAP = 1; + private static final int MSG_BITMAP = 2; + private static final int MSG_SAVE_COMPLETE = 3; + + private static final int MAX_BACKUP_IMAGE_SIZE = 320; + private static final int DEFAULT_COMPRESS_QUALITY = 90; + + public static final String KEY_RETURN_DATA = "return-data"; + public static final String KEY_CROPPED_RECT = "cropped-rect"; + public static final String KEY_ASPECT_X = "aspectX"; + public static final String KEY_ASPECT_Y = "aspectY"; + public static final String KEY_SPOTLIGHT_X = "spotlightX"; + public static final String KEY_SPOTLIGHT_Y = "spotlightY"; + public static final String KEY_OUTPUT_X = "outputX"; + public static final String KEY_OUTPUT_Y = "outputY"; + public static final String KEY_SCALE = "scale"; + public static final String KEY_DATA = "data"; + public static final String KEY_SCALE_UP_IF_NEEDED = "scaleUpIfNeeded"; + public static final String KEY_OUTPUT_FORMAT = "outputFormat"; + public static final String KEY_SET_AS_WALLPAPER = "set-as-wallpaper"; + public static final String KEY_NO_FACE_DETECTION = "noFaceDetection"; + + private static final String KEY_STATE = "state"; + + private static final int STATE_INIT = 0; + private static final int STATE_LOADED = 1; + private static final int STATE_SAVING = 2; + + public static final String DOWNLOAD_STRING = "download"; + public static final File DOWNLOAD_BUCKET = new File( + Environment.getExternalStorageDirectory(), DOWNLOAD_STRING); + + public static final String CROP_ACTION = "com.android.camera.action.CROP"; + + private int mState = STATE_INIT; + + private CropView mCropView; + + private boolean mDoFaceDetection = true; + + private Handler mMainHandler; + + // We keep the following members so that we can free them + + // mBitmap is the unrotated bitmap we pass in to mCropView for detect faces. + // mCropView is responsible for rotating it to the way that it is viewed by users. + private Bitmap mBitmap; + private BitmapTileProvider mBitmapTileProvider; + private BitmapRegionDecoder mRegionDecoder; + private Bitmap mBitmapInIntent; + private boolean mUseRegionDecoder = false; + + private ProgressDialog mProgressDialog; + private Future<BitmapRegionDecoder> mLoadTask; + private Future<Bitmap> mLoadBitmapTask; + private Future<Intent> mSaveTask; + + private MediaItem mMediaItem; + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + requestWindowFeature(Window.FEATURE_ACTION_BAR); + requestWindowFeature(Window.FEATURE_ACTION_BAR_OVERLAY); + + // Initialize UI + setContentView(R.layout.cropimage); + mCropView = new CropView(this); + getGLRoot().setContentPane(mCropView); + + mMainHandler = new SynchronizedHandler(getGLRoot()) { + @Override + public void handleMessage(Message message) { + switch (message.what) { + case MSG_LARGE_BITMAP: { + mProgressDialog.dismiss(); + onBitmapRegionDecoderAvailable((BitmapRegionDecoder) message.obj); + break; + } + case MSG_BITMAP: { + mProgressDialog.dismiss(); + onBitmapAvailable((Bitmap) message.obj); + break; + } + case MSG_SAVE_COMPLETE: { + mProgressDialog.dismiss(); + setResult(RESULT_OK, (Intent) message.obj); + finish(); + break; + } + } + } + }; + + setCropParameters(); + } + + @Override + protected void onSaveInstanceState(Bundle saveState) { + saveState.putInt(KEY_STATE, mState); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + getMenuInflater().inflate(R.menu.crop, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.cancel: { + setResult(RESULT_CANCELED); + finish(); + break; + } + case R.id.save: { + onSaveClicked(); + break; + } + } + return true; + } + + private class SaveOutput implements Job<Intent> { + private RectF mCropRect; + + public SaveOutput(RectF cropRect) { + mCropRect = cropRect; + } + + public Intent run(JobContext jc) { + RectF cropRect = mCropRect; + Bundle extra = getIntent().getExtras(); + + Rect rect = new Rect( + Math.round(cropRect.left), Math.round(cropRect.top), + Math.round(cropRect.right), Math.round(cropRect.bottom)); + + Intent result = new Intent(); + result.putExtra(KEY_CROPPED_RECT, rect); + Bitmap cropped = null; + boolean outputted = false; + if (extra != null) { + Uri uri = (Uri) extra.getParcelable(MediaStore.EXTRA_OUTPUT); + if (uri != null) { + if (jc.isCancelled()) return null; + outputted = true; + cropped = getCroppedImage(rect); + if (!saveBitmapToUri(jc, cropped, uri)) return null; + } + if (extra.getBoolean(KEY_RETURN_DATA, false)) { + if (jc.isCancelled()) return null; + outputted = true; + if (cropped == null) cropped = getCroppedImage(rect); + result.putExtra(KEY_DATA, cropped); + } + if (extra.getBoolean(KEY_SET_AS_WALLPAPER, false)) { + if (jc.isCancelled()) return null; + outputted = true; + if (cropped == null) cropped = getCroppedImage(rect); + if (!setAsWallpaper(jc, cropped)) return null; + } + } + if (!outputted) { + if (jc.isCancelled()) return null; + if (cropped == null) cropped = getCroppedImage(rect); + Uri data = saveToMediaProvider(jc, cropped); + if (data != null) result.setData(data); + } + return result; + } + } + + public static String determineCompressFormat(MediaObject obj) { + String compressFormat = "JPEG"; + if (obj instanceof MediaItem) { + String mime = ((MediaItem) obj).getMimeType(); + if (mime.contains("png") || mime.contains("gif")) { + // Set the compress format to PNG for png and gif images + // because they may contain alpha values. + compressFormat = "PNG"; + } + } + return compressFormat; + } + + private boolean setAsWallpaper(JobContext jc, Bitmap wallpaper) { + try { + WallpaperManager.getInstance(this).setBitmap(wallpaper); + } catch (IOException e) { + Log.w(TAG, "fail to set wall paper", e); + } + return true; + } + + private File saveMedia( + JobContext jc, Bitmap cropped, File directory, String filename) { + // Try file-1.jpg, file-2.jpg, ... until we find a filename + // which does not exist yet. + File candidate = null; + String fileExtension = getFileExtension(); + for (int i = 1; i < MAX_FILE_INDEX; ++i) { + candidate = new File(directory, filename + "-" + i + "." + + fileExtension); + try { + if (candidate.createNewFile()) break; + } catch (IOException e) { + Log.e(TAG, "fail to create new file: " + + candidate.getAbsolutePath(), e); + return null; + } + } + if (!candidate.exists() || !candidate.isFile()) { + throw new RuntimeException("cannot create file: " + filename); + } + + candidate.setReadable(true, false); + candidate.setWritable(true, false); + + try { + FileOutputStream fos = new FileOutputStream(candidate); + try { + saveBitmapToOutputStream(jc, cropped, + convertExtensionToCompressFormat(fileExtension), fos); + } finally { + fos.close(); + } + } catch (IOException e) { + Log.e(TAG, "fail to save image: " + + candidate.getAbsolutePath(), e); + candidate.delete(); + return null; + } + + if (jc.isCancelled()) { + candidate.delete(); + return null; + } + + return candidate; + } + + private Uri saveToMediaProvider(JobContext jc, Bitmap cropped) { + if (PicasaSource.isPicasaImage(mMediaItem)) { + return savePicasaImage(jc, cropped); + } else if (mMediaItem instanceof LocalImage) { + return saveLocalImage(jc, cropped); + } else { + Log.w(TAG, "no output for crop image " + mMediaItem); + return null; + } + } + + private Uri savePicasaImage(JobContext jc, Bitmap cropped) { + if (!DOWNLOAD_BUCKET.isDirectory() && !DOWNLOAD_BUCKET.mkdirs()) { + throw new RuntimeException("cannot create download folder"); + } + + String filename = PicasaSource.getImageTitle(mMediaItem); + int pos = filename.lastIndexOf('.'); + if (pos >= 0) filename = filename.substring(0, pos); + File output = saveMedia(jc, cropped, DOWNLOAD_BUCKET, filename); + if (output == null) return null; + + long now = System.currentTimeMillis() / 1000; + ContentValues values = new ContentValues(); + values.put(Images.Media.TITLE, PicasaSource.getImageTitle(mMediaItem)); + values.put(Images.Media.DISPLAY_NAME, output.getName()); + values.put(Images.Media.DATE_TAKEN, PicasaSource.getDateTaken(mMediaItem)); + values.put(Images.Media.DATE_MODIFIED, now); + values.put(Images.Media.DATE_ADDED, now); + values.put(Images.Media.MIME_TYPE, "image/jpeg"); + values.put(Images.Media.ORIENTATION, 0); + values.put(Images.Media.DATA, output.getAbsolutePath()); + values.put(Images.Media.SIZE, output.length()); + + double latitude = PicasaSource.getLatitude(mMediaItem); + double longitude = PicasaSource.getLongitude(mMediaItem); + if (GalleryUtils.isValidLocation(latitude, longitude)) { + values.put(Images.Media.LATITUDE, latitude); + values.put(Images.Media.LONGITUDE, longitude); + } + return getContentResolver().insert( + Images.Media.EXTERNAL_CONTENT_URI, values); + } + + private Uri saveLocalImage(JobContext jc, Bitmap cropped) { + LocalImage localImage = (LocalImage) mMediaItem; + + File oldPath = new File(localImage.filePath); + File directory = new File(oldPath.getParent()); + + String filename = oldPath.getName(); + int pos = filename.lastIndexOf('.'); + if (pos >= 0) filename = filename.substring(0, pos); + File output = saveMedia(jc, cropped, directory, filename); + if (output == null) return null; + + long now = System.currentTimeMillis() / 1000; + ContentValues values = new ContentValues(); + values.put(Images.Media.TITLE, localImage.caption); + values.put(Images.Media.DISPLAY_NAME, output.getName()); + values.put(Images.Media.DATE_TAKEN, localImage.dateTakenInMs); + values.put(Images.Media.DATE_MODIFIED, now); + values.put(Images.Media.DATE_ADDED, now); + values.put(Images.Media.MIME_TYPE, "image/jpeg"); + values.put(Images.Media.ORIENTATION, 0); + values.put(Images.Media.DATA, output.getAbsolutePath()); + values.put(Images.Media.SIZE, output.length()); + + if (GalleryUtils.isValidLocation(localImage.latitude, localImage.longitude)) { + values.put(Images.Media.LATITUDE, localImage.latitude); + values.put(Images.Media.LONGITUDE, localImage.longitude); + } + return getContentResolver().insert( + Images.Media.EXTERNAL_CONTENT_URI, values); + } + + private boolean saveBitmapToOutputStream( + JobContext jc, Bitmap bitmap, CompressFormat format, OutputStream os) { + // We wrap the OutputStream so that it can be interrupted. + final InterruptableOutputStream ios = new InterruptableOutputStream(os); + jc.setCancelListener(new CancelListener() { + public void onCancel() { + ios.interrupt(); + } + }); + try { + bitmap.compress(format, DEFAULT_COMPRESS_QUALITY, os); + if (!jc.isCancelled()) return false; + } finally { + jc.setCancelListener(null); + Utils.closeSilently(os); + } + return false; + } + + private boolean saveBitmapToUri(JobContext jc, Bitmap bitmap, Uri uri) { + try { + return saveBitmapToOutputStream(jc, bitmap, + convertExtensionToCompressFormat(getFileExtension()), + getContentResolver().openOutputStream(uri)); + } catch (FileNotFoundException e) { + Log.w(TAG, "cannot write output", e); + } + return true; + } + + private CompressFormat convertExtensionToCompressFormat(String extension) { + return extension.equals("png") + ? CompressFormat.PNG + : CompressFormat.JPEG; + } + + private String getFileExtension() { + String requestFormat = getIntent().getStringExtra(KEY_OUTPUT_FORMAT); + String outputFormat = (requestFormat == null) + ? determineCompressFormat(mMediaItem) + : requestFormat; + + outputFormat = outputFormat.toLowerCase(); + return (outputFormat.equals("png") || outputFormat.equals("gif")) + ? "png" // We don't support gif compression. + : "jpg"; + } + + private void onSaveClicked() { + Bundle extra = getIntent().getExtras(); + RectF cropRect = mCropView.getCropRectangle(); + if (cropRect == null) return; + mState = STATE_SAVING; + int messageId = extra != null && extra.getBoolean(KEY_SET_AS_WALLPAPER) + ? R.string.wallpaper + : R.string.saving_image; + mProgressDialog = ProgressDialog.show( + this, null, getString(messageId), true, false); + mSaveTask = getThreadPool().submit(new SaveOutput(cropRect), + new FutureListener<Intent>() { + public void onFutureDone(Future<Intent> future) { + mSaveTask = null; + if (future.get() == null) return; + mMainHandler.sendMessage(mMainHandler.obtainMessage( + MSG_SAVE_COMPLETE, future.get())); + } + }); + } + + private Bitmap getCroppedImage(Rect rect) { + Utils.assertTrue(rect.width() > 0 && rect.height() > 0); + + Bundle extras = getIntent().getExtras(); + // (outputX, outputY) = the width and height of the returning bitmap. + int outputX = rect.width(); + int outputY = rect.height(); + if (extras != null) { + outputX = extras.getInt(KEY_OUTPUT_X, outputX); + outputY = extras.getInt(KEY_OUTPUT_Y, outputY); + } + + if (outputX * outputY > MAX_PIXEL_COUNT) { + float scale = (float) Math.sqrt( + (double) MAX_PIXEL_COUNT / outputX / outputY); + Log.w(TAG, "scale down the cropped image: " + scale); + outputX = Math.round(scale * outputX); + outputY = Math.round(scale * outputY); + } + + // (rect.width() * scaleX, rect.height() * scaleY) = + // the size of drawing area in output bitmap + float scaleX = 1; + float scaleY = 1; + Rect dest = new Rect(0, 0, outputX, outputY); + if (extras == null || extras.getBoolean(KEY_SCALE, true)) { + scaleX = (float) outputX / rect.width(); + scaleY = (float) outputY / rect.height(); + if (extras == null || !extras.getBoolean( + KEY_SCALE_UP_IF_NEEDED, false)) { + if (scaleX > 1f) scaleX = 1; + if (scaleY > 1f) scaleY = 1; + } + } + + // Keep the content in the center (or crop the content) + int rectWidth = Math.round(rect.width() * scaleX); + int rectHeight = Math.round(rect.height() * scaleY); + dest.set(Math.round((outputX - rectWidth) / 2f), + Math.round((outputY - rectHeight) / 2f), + Math.round((outputX + rectWidth) / 2f), + Math.round((outputY + rectHeight) / 2f)); + + if (mBitmapInIntent != null) { + Bitmap source = mBitmapInIntent; + Bitmap result = Bitmap.createBitmap( + outputX, outputY, Config.ARGB_8888); + Canvas canvas = new Canvas(result); + canvas.drawBitmap(source, rect, dest, null); + return result; + } + + int rotation = mMediaItem.getRotation(); + rotateRectangle(rect, mCropView.getImageWidth(), + mCropView.getImageHeight(), 360 - rotation); + rotateRectangle(dest, outputX, outputY, 360 - rotation); + if (mUseRegionDecoder) { + BitmapFactory.Options options = new BitmapFactory.Options(); + int sample = BitmapUtils.computeSampleSizeLarger( + Math.max(scaleX, scaleY)); + options.inSampleSize = sample; + if ((rect.width() / sample) == dest.width() + && (rect.height() / sample) == dest.height() + && rotation == 0) { + // To prevent concurrent access in GLThread + synchronized (mRegionDecoder) { + return mRegionDecoder.decodeRegion(rect, options); + } + } + Bitmap result = Bitmap.createBitmap( + outputX, outputY, Config.ARGB_8888); + Canvas canvas = new Canvas(result); + rotateCanvas(canvas, outputX, outputY, rotation); + drawInTiles(canvas, mRegionDecoder, rect, dest, sample); + return result; + } else { + Bitmap result = Bitmap.createBitmap(outputX, outputY, Config.ARGB_8888); + Canvas canvas = new Canvas(result); + rotateCanvas(canvas, outputX, outputY, rotation); + canvas.drawBitmap(mBitmap, + rect, dest, new Paint(Paint.FILTER_BITMAP_FLAG)); + return result; + } + } + + private static void rotateCanvas( + Canvas canvas, int width, int height, int rotation) { + canvas.translate(width / 2, height / 2); + canvas.rotate(rotation); + if (((rotation / 90) & 0x01) == 0) { + canvas.translate(-width / 2, -height / 2); + } else { + canvas.translate(-height / 2, -width / 2); + } + } + + private static void rotateRectangle( + Rect rect, int width, int height, int rotation) { + if (rotation == 0 || rotation == 360) return; + + int w = rect.width(); + int h = rect.height(); + switch (rotation) { + case 90: { + rect.top = rect.left; + rect.left = height - rect.bottom; + rect.right = rect.left + h; + rect.bottom = rect.top + w; + return; + } + case 180: { + rect.left = width - rect.right; + rect.top = height - rect.bottom; + rect.right = rect.left + w; + rect.bottom = rect.top + h; + return; + } + case 270: { + rect.left = rect.top; + rect.top = width - rect.right; + rect.right = rect.left + h; + rect.bottom = rect.top + w; + return; + } + default: throw new AssertionError(); + } + } + + private void drawInTiles(Canvas canvas, + BitmapRegionDecoder decoder, Rect rect, Rect dest, int sample) { + int tileSize = TILE_SIZE * sample; + Rect tileRect = new Rect(); + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inPreferredConfig = Config.ARGB_8888; + options.inSampleSize = sample; + canvas.translate(dest.left, dest.top); + canvas.scale((float) sample * dest.width() / rect.width(), + (float) sample * dest.height() / rect.height()); + Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG); + for (int tx = rect.left, x = 0; + tx < rect.right; tx += tileSize, x += TILE_SIZE) { + for (int ty = rect.top, y = 0; + ty < rect.bottom; ty += tileSize, y += TILE_SIZE) { + tileRect.set(tx, ty, tx + tileSize, ty + tileSize); + if (tileRect.intersect(rect)) { + Bitmap bitmap; + + // To prevent concurrent access in GLThread + synchronized (decoder) { + bitmap = decoder.decodeRegion(tileRect, options); + } + canvas.drawBitmap(bitmap, x, y, paint); + bitmap.recycle(); + } + } + } + } + + private void onBitmapRegionDecoderAvailable( + BitmapRegionDecoder regionDecoder) { + + if (regionDecoder == null) { + Toast.makeText(this, "fail to load image", Toast.LENGTH_SHORT).show(); + finish(); + return; + } + mRegionDecoder = regionDecoder; + mUseRegionDecoder = true; + mState = STATE_LOADED; + + BitmapFactory.Options options = new BitmapFactory.Options(); + int width = regionDecoder.getWidth(); + int height = regionDecoder.getHeight(); + options.inSampleSize = BitmapUtils.computeSampleSize(width, height, + BitmapUtils.UNCONSTRAINED, BACKUP_PIXEL_COUNT); + mBitmap = regionDecoder.decodeRegion( + new Rect(0, 0, width, height), options); + mCropView.setDataModel(new TileImageViewAdapter( + mBitmap, regionDecoder), mMediaItem.getRotation()); + if (mDoFaceDetection) { + mCropView.detectFaces(mBitmap); + } else { + mCropView.initializeHighlightRectangle(); + } + } + + private void onBitmapAvailable(Bitmap bitmap) { + if (bitmap == null) { + Toast.makeText(this, "fail to load image", Toast.LENGTH_SHORT).show(); + finish(); + return; + } + mUseRegionDecoder = false; + mState = STATE_LOADED; + + mBitmap = bitmap; + BitmapFactory.Options options = new BitmapFactory.Options(); + mCropView.setDataModel(new BitmapTileProvider(bitmap, 512), + mMediaItem.getRotation()); + if (mDoFaceDetection) { + mCropView.detectFaces(bitmap); + } else { + mCropView.initializeHighlightRectangle(); + } + } + + private void setCropParameters() { + Bundle extras = getIntent().getExtras(); + if (extras == null) + return; + int aspectX = extras.getInt(KEY_ASPECT_X, 0); + int aspectY = extras.getInt(KEY_ASPECT_Y, 0); + if (aspectX != 0 && aspectY != 0) { + mCropView.setAspectRatio((float) aspectX / aspectY); + } + + float spotlightX = extras.getFloat(KEY_SPOTLIGHT_X, 0); + float spotlightY = extras.getFloat(KEY_SPOTLIGHT_Y, 0); + if (spotlightX != 0 && spotlightY != 0) { + mCropView.setSpotlightRatio(spotlightX, spotlightY); + } + } + + private void initializeData() { + Bundle extras = getIntent().getExtras(); + + if (extras != null) { + if (extras.containsKey(KEY_NO_FACE_DETECTION)) { + mDoFaceDetection = !extras.getBoolean(KEY_NO_FACE_DETECTION); + } + + mBitmapInIntent = extras.getParcelable(KEY_DATA); + + if (mBitmapInIntent != null) { + mBitmapTileProvider = + new BitmapTileProvider(mBitmapInIntent, MAX_BACKUP_IMAGE_SIZE); + mCropView.setDataModel(mBitmapTileProvider, 0); + if (mDoFaceDetection) { + mCropView.detectFaces(mBitmapInIntent); + } else { + mCropView.initializeHighlightRectangle(); + } + mState = STATE_LOADED; + return; + } + } + + mProgressDialog = ProgressDialog.show( + this, null, getString(R.string.loading_image), true, false); + + mMediaItem = getMediaItemFromIntentData(); + if (mMediaItem == null) return; + + boolean supportedByBitmapRegionDecoder = + (mMediaItem.getSupportedOperations() & MediaItem.SUPPORT_FULL_IMAGE) != 0; + if (supportedByBitmapRegionDecoder) { + mLoadTask = getThreadPool().submit(new LoadDataTask(mMediaItem), + new FutureListener<BitmapRegionDecoder>() { + public void onFutureDone(Future<BitmapRegionDecoder> future) { + mLoadTask = null; + BitmapRegionDecoder decoder = future.get(); + if (future.isCancelled()) { + if (decoder != null) decoder.recycle(); + return; + } + mMainHandler.sendMessage(mMainHandler.obtainMessage( + MSG_LARGE_BITMAP, decoder)); + } + }); + } else { + mLoadBitmapTask = getThreadPool().submit(new LoadBitmapDataTask(mMediaItem), + new FutureListener<Bitmap>() { + public void onFutureDone(Future<Bitmap> future) { + mLoadBitmapTask = null; + Bitmap bitmap = future.get(); + if (future.isCancelled()) { + if (bitmap != null) bitmap.recycle(); + return; + } + mMainHandler.sendMessage(mMainHandler.obtainMessage( + MSG_BITMAP, bitmap)); + } + }); + } + } + + @Override + protected void onResume() { + super.onResume(); + if (mState == STATE_INIT) initializeData(); + if (mState == STATE_SAVING) onSaveClicked(); + + // TODO: consider to do it in GLView system + GLRoot root = getGLRoot(); + root.lockRenderThread(); + try { + mCropView.resume(); + } finally { + root.unlockRenderThread(); + } + } + + @Override + protected void onPause() { + super.onPause(); + + Future<BitmapRegionDecoder> loadTask = mLoadTask; + if (loadTask != null && !loadTask.isDone()) { + // load in progress, try to cancel it + loadTask.cancel(); + loadTask.waitDone(); + mProgressDialog.dismiss(); + } + + Future<Bitmap> loadBitmapTask = mLoadBitmapTask; + if (loadBitmapTask != null && !loadBitmapTask.isDone()) { + // load in progress, try to cancel it + loadBitmapTask.cancel(); + loadBitmapTask.waitDone(); + mProgressDialog.dismiss(); + } + + Future<Intent> saveTask = mSaveTask; + if (saveTask != null && !saveTask.isDone()) { + // save in progress, try to cancel it + saveTask.cancel(); + saveTask.waitDone(); + mProgressDialog.dismiss(); + } + GLRoot root = getGLRoot(); + root.lockRenderThread(); + try { + mCropView.pause(); + } finally { + root.unlockRenderThread(); + } + } + + private MediaItem getMediaItemFromIntentData() { + Uri uri = getIntent().getData(); + DataManager manager = getDataManager(); + if (uri == null) { + Log.w(TAG, "no data given"); + return null; + } + Path path = manager.findPathByUri(uri); + if (path == null) { + Log.w(TAG, "cannot get path for: " + uri); + return null; + } + return (MediaItem) manager.getMediaObject(path); + } + + private class LoadDataTask implements Job<BitmapRegionDecoder> { + MediaItem mItem; + + public LoadDataTask(MediaItem item) { + mItem = item; + } + + public BitmapRegionDecoder run(JobContext jc) { + return mItem == null ? null : mItem.requestLargeImage().run(jc); + } + } + + private class LoadBitmapDataTask implements Job<Bitmap> { + MediaItem mItem; + + public LoadBitmapDataTask(MediaItem item) { + mItem = item; + } + public Bitmap run(JobContext jc) { + return mItem == null + ? null + : mItem.requestImage(MediaItem.TYPE_THUMBNAIL).run(jc); + } + } +} diff --git a/src/com/android/gallery3d/app/DialogPicker.java b/src/com/android/gallery3d/app/DialogPicker.java new file mode 100644 index 000000000..ebfc52158 --- /dev/null +++ b/src/com/android/gallery3d/app/DialogPicker.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.R; +import com.android.gallery3d.ui.GLRoot; +import com.android.gallery3d.ui.GLRootView; +import com.android.gallery3d.util.GalleryUtils; + +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.view.View.OnClickListener; + +public class DialogPicker extends AbstractGalleryActivity + implements OnClickListener { + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.dialog_picker); + ((GLRootView) findViewById(R.id.gl_root_view)).setZOrderOnTop(true); + findViewById(R.id.cancel).setOnClickListener(this); + + int typeBits = GalleryUtils.determineTypeBits(this, getIntent()); + setTitle(GalleryUtils.getSelectionModePrompt(typeBits)); + Intent intent = getIntent(); + Bundle extras = intent.getExtras(); + Bundle data = extras == null ? new Bundle() : new Bundle(extras); + + data.putBoolean(Gallery.KEY_GET_CONTENT, true); + data.putString(AlbumSetPage.KEY_MEDIA_PATH, + getDataManager().getTopSetPath(typeBits)); + getStateManager().startState(AlbumSetPage.class, data); + } + + @Override + public void onBackPressed() { + // send the back event to the top sub-state + GLRoot root = getGLRoot(); + root.lockRenderThread(); + try { + getStateManager().getTopState().onBackPressed(); + } finally { + root.unlockRenderThread(); + } + } + + @Override + public void onClick(View v) { + if (v.getId() == R.id.cancel) finish(); + } +} diff --git a/src/com/android/gallery3d/app/EyePosition.java b/src/com/android/gallery3d/app/EyePosition.java new file mode 100644 index 000000000..1c3aa60bb --- /dev/null +++ b/src/com/android/gallery3d/app/EyePosition.java @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.util.GalleryUtils; + +import android.content.Context; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.os.SystemClock; +import android.view.Display; +import android.view.Surface; +import android.view.WindowManager; + +public class EyePosition { + private static final String TAG = "EyePosition"; + + public interface EyePositionListener { + public void onEyePositionChanged(float x, float y, float z); + } + + private static final float GYROSCOPE_THRESHOLD = 0.15f; + private static final float GYROSCOPE_LIMIT = 10f; + private static final int GYROSCOPE_SETTLE_DOWN = 15; + private static final float GYROSCOPE_RESTORE_FACTOR = 0.995f; + + private static final double USER_ANGEL = Math.toRadians(10); + private static final float USER_ANGEL_COS = (float) Math.cos(USER_ANGEL); + private static final float USER_ANGEL_SIN = (float) Math.sin(USER_ANGEL); + private static final float MAX_VIEW_RANGE = (float) 0.5; + private static final int NOT_STARTED = -1; + + private static final float USER_DISTANCE_METER = 0.3f; + + private Context mContext; + private EyePositionListener mListener; + private Display mDisplay; + // The eyes' position of the user, the origin is at the center of the + // device and the unit is in pixels. + private float mX; + private float mY; + private float mZ; + + private final float mUserDistance; // in pixel + private final float mLimit; + private long mStartTime = NOT_STARTED; + private Sensor mSensor; + private PositionListener mPositionListener = new PositionListener(); + + private int mGyroscopeCountdown = 0; + + public EyePosition(Context context, EyePositionListener listener) { + mContext = context; + mListener = listener; + mUserDistance = GalleryUtils.meterToPixel(USER_DISTANCE_METER); + mLimit = mUserDistance * MAX_VIEW_RANGE; + + WindowManager wManager = (WindowManager) mContext + .getSystemService(Context.WINDOW_SERVICE); + mDisplay = wManager.getDefaultDisplay(); + + SensorManager sManager = (SensorManager) mContext + .getSystemService(Context.SENSOR_SERVICE); + mSensor = sManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE); + if (mSensor == null) { + Log.w(TAG, "no gyroscope, use accelerometer instead"); + mSensor = sManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); + } + if (mSensor == null) { + Log.w(TAG, "no sensor available"); + } + } + + public void resetPosition() { + mStartTime = NOT_STARTED; + mX = mY = 0; + mZ = -mUserDistance; + mListener.onEyePositionChanged(mX, mY, mZ); + } + + /* + * We assume the user is at the following position + * + * /|\ user's eye + * | / + * -G(gravity) | / + * |_/ + * / |/_____\ -Y (-y direction of device) + * user angel + */ + private void onAccelerometerChanged(float gx, float gy, float gz) { + + float x = gx, y = gy, z = gz; + + switch (mDisplay.getRotation()) { + case Surface.ROTATION_90: x = -gy; y= gx; break; + case Surface.ROTATION_180: x = -gx; y = -gy; break; + case Surface.ROTATION_270: x = gy; y = -gx; break; + } + + float temp = x * x + y * y + z * z; + float t = -y /temp; + + float tx = t * x; + float ty = -1 + t * y; + float tz = t * z; + + float length = (float) Math.sqrt(tx * tx + ty * ty + tz * tz); + float glength = (float) Math.sqrt(temp); + + mX = Utils.clamp((x * USER_ANGEL_COS / glength + + tx * USER_ANGEL_SIN / length) * mUserDistance, + -mLimit, mLimit); + mY = -Utils.clamp((y * USER_ANGEL_COS / glength + + ty * USER_ANGEL_SIN / length) * mUserDistance, + -mLimit, mLimit); + mZ = (float) -Math.sqrt( + mUserDistance * mUserDistance - mX * mX - mY * mY); + mListener.onEyePositionChanged(mX, mY, mZ); + } + + private void onGyroscopeChanged(float gx, float gy, float gz) { + long now = SystemClock.elapsedRealtime(); + float distance = (gx > 0 ? gx : -gx) + (gy > 0 ? gy : - gy); + if (distance < GYROSCOPE_THRESHOLD + || distance > GYROSCOPE_LIMIT || mGyroscopeCountdown > 0) { + --mGyroscopeCountdown; + mStartTime = now; + float limit = mUserDistance / 20f; + if (mX > limit || mX < -limit || mY > limit || mY < -limit) { + mX *= GYROSCOPE_RESTORE_FACTOR; + mY *= GYROSCOPE_RESTORE_FACTOR; + mZ = (float) -Math.sqrt( + mUserDistance * mUserDistance - mX * mX - mY * mY); + mListener.onEyePositionChanged(mX, mY, mZ); + } + return; + } + + float t = (now - mStartTime) / 1000f * mUserDistance * (-mZ); + mStartTime = now; + + float x = -gy, y = -gx; + switch (mDisplay.getRotation()) { + case Surface.ROTATION_90: x = -gx; y= gy; break; + case Surface.ROTATION_180: x = gy; y = gx; break; + case Surface.ROTATION_270: x = gx; y = -gy; break; + } + + mX = Utils.clamp((float) (mX + x * t / Math.hypot(mZ, mX)), + -mLimit, mLimit) * GYROSCOPE_RESTORE_FACTOR; + mY = Utils.clamp((float) (mY + y * t / Math.hypot(mZ, mY)), + -mLimit, mLimit) * GYROSCOPE_RESTORE_FACTOR; + + mZ = (float) -Math.sqrt( + mUserDistance * mUserDistance - mX * mX - mY * mY); + mListener.onEyePositionChanged(mX, mY, mZ); + } + + private class PositionListener implements SensorEventListener { + public void onAccuracyChanged(Sensor sensor, int accuracy) { + } + + public void onSensorChanged(SensorEvent event) { + switch (event.sensor.getType()) { + case Sensor.TYPE_GYROSCOPE: { + onGyroscopeChanged( + event.values[0], event.values[1], event.values[2]); + break; + } + case Sensor.TYPE_ACCELEROMETER: { + onAccelerometerChanged( + event.values[0], event.values[1], event.values[2]); + } + } + } + } + + public void pause() { + if (mSensor != null) { + SensorManager sManager = (SensorManager) mContext + .getSystemService(Context.SENSOR_SERVICE); + sManager.unregisterListener(mPositionListener); + } + } + + public void resume() { + if (mSensor != null) { + SensorManager sManager = (SensorManager) mContext + .getSystemService(Context.SENSOR_SERVICE); + sManager.registerListener(mPositionListener, + mSensor, SensorManager.SENSOR_DELAY_GAME); + } + + mStartTime = NOT_STARTED; + mGyroscopeCountdown = GYROSCOPE_SETTLE_DOWN; + mX = mY = 0; + mZ = -mUserDistance; + mListener.onEyePositionChanged(mX, mY, mZ); + } +} diff --git a/src/com/android/gallery3d/app/FilterUtils.java b/src/com/android/gallery3d/app/FilterUtils.java new file mode 100644 index 000000000..9b8ea2d62 --- /dev/null +++ b/src/com/android/gallery3d/app/FilterUtils.java @@ -0,0 +1,296 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.R; +import com.android.gallery3d.data.MediaObject; +import com.android.gallery3d.data.Path; + +// This class handles filtering and clustering. +// +// We allow at most only one filter operation at a time (Currently it +// doesn't make sense to use more than one). Also each clustering operation +// can be applied at most once. In addition, there is one more constraint +// ("fixed set constraint") described below. +// +// A clustered album (not including album set) and its base sets are fixed. +// For example, +// +// /cluster/{base_set}/time/7 +// +// This set and all sets inside base_set (recursively) are fixed because +// 1. We can not change this set to use another clustering condition (like +// changing "time" to "location"). +// 2. Neither can we change any set in the base_set. +// The reason is in both cases the 7th set may not exist in the new clustering. +// --------------------- +// newPath operation: create a new path based on a source path and put an extra +// condition on top of it: +// +// T = newFilterPath(S, filterType); +// T = newClusterPath(S, clusterType); +// +// Similar functions can be used to replace the current condition (if there is one). +// +// T = switchFilterPath(S, filterType); +// T = switchClusterPath(S, clusterType); +// +// For all fixed set in the path defined above, if some clusterType and +// filterType are already used, they cannot not be used as parameter for these +// functions. setupMenuItems() makes sure those types cannot be selected. +// +public class FilterUtils { + private static final String TAG = "FilterUtils"; + + public static final int CLUSTER_BY_ALBUM = 1; + public static final int CLUSTER_BY_TIME = 2; + public static final int CLUSTER_BY_LOCATION = 4; + public static final int CLUSTER_BY_TAG = 8; + public static final int CLUSTER_BY_SIZE = 16; + public static final int CLUSTER_BY_FACE = 32; + + public static final int FILTER_IMAGE_ONLY = 1; + public static final int FILTER_VIDEO_ONLY = 2; + public static final int FILTER_ALL = 4; + + // These are indices of the return values of getAppliedFilters(). + // The _F suffix means "fixed". + private static final int CLUSTER_TYPE = 0; + private static final int FILTER_TYPE = 1; + private static final int CLUSTER_TYPE_F = 2; + private static final int FILTER_TYPE_F = 3; + private static final int CLUSTER_CURRENT_TYPE = 4; + private static final int FILTER_CURRENT_TYPE = 5; + + public static void setupMenuItems(GalleryActionBar model, Path path, boolean inAlbum) { + int[] result = new int[6]; + getAppliedFilters(path, result); + int ctype = result[CLUSTER_TYPE]; + int ftype = result[FILTER_TYPE]; + int ftypef = result[FILTER_TYPE_F]; + int ccurrent = result[CLUSTER_CURRENT_TYPE]; + int fcurrent = result[FILTER_CURRENT_TYPE]; + + setMenuItemApplied(model, CLUSTER_BY_TIME, + (ctype & CLUSTER_BY_TIME) != 0, (ccurrent & CLUSTER_BY_TIME) != 0); + setMenuItemApplied(model, CLUSTER_BY_LOCATION, + (ctype & CLUSTER_BY_LOCATION) != 0, (ccurrent & CLUSTER_BY_LOCATION) != 0); + setMenuItemApplied(model, CLUSTER_BY_TAG, + (ctype & CLUSTER_BY_TAG) != 0, (ccurrent & CLUSTER_BY_TAG) != 0); + setMenuItemApplied(model, CLUSTER_BY_FACE, + (ctype & CLUSTER_BY_FACE) != 0, (ccurrent & CLUSTER_BY_FACE) != 0); + + model.setClusterItemVisibility(CLUSTER_BY_ALBUM, !inAlbum || ctype == 0); + + setMenuItemApplied(model, R.id.action_cluster_album, ctype == 0, + ccurrent == 0); + + // A filtering is available if it's not applied, and the old filtering + // (if any) is not fixed. + setMenuItemAppliedEnabled(model, R.string.show_images_only, + (ftype & FILTER_IMAGE_ONLY) != 0, + (ftype & FILTER_IMAGE_ONLY) == 0 && ftypef == 0, + (fcurrent & FILTER_IMAGE_ONLY) != 0); + setMenuItemAppliedEnabled(model, R.string.show_videos_only, + (ftype & FILTER_VIDEO_ONLY) != 0, + (ftype & FILTER_VIDEO_ONLY) == 0 && ftypef == 0, + (fcurrent & FILTER_VIDEO_ONLY) != 0); + setMenuItemAppliedEnabled(model, R.string.show_all, + ftype == 0, ftype != 0 && ftypef == 0, fcurrent == 0); + } + + // Gets the filters applied in the path. + private static void getAppliedFilters(Path path, int[] result) { + getAppliedFilters(path, result, false); + } + + private static void getAppliedFilters(Path path, int[] result, boolean underCluster) { + String[] segments = path.split(); + // Recurse into sub media sets. + for (int i = 0; i < segments.length; i++) { + if (segments[i].startsWith("{")) { + String[] sets = Path.splitSequence(segments[i]); + for (int j = 0; j < sets.length; j++) { + Path sub = Path.fromString(sets[j]); + getAppliedFilters(sub, result, underCluster); + } + } + } + + // update current selection + if (segments[0].equals("cluster")) { + // if this is a clustered album, set underCluster to true. + if (segments.length == 4) { + underCluster = true; + } + + int ctype = toClusterType(segments[2]); + result[CLUSTER_TYPE] |= ctype; + result[CLUSTER_CURRENT_TYPE] = ctype; + if (underCluster) { + result[CLUSTER_TYPE_F] |= ctype; + } + } + } + + private static int toClusterType(String s) { + if (s.equals("time")) { + return CLUSTER_BY_TIME; + } else if (s.equals("location")) { + return CLUSTER_BY_LOCATION; + } else if (s.equals("tag")) { + return CLUSTER_BY_TAG; + } else if (s.equals("size")) { + return CLUSTER_BY_SIZE; + } else if (s.equals("face")) { + return CLUSTER_BY_FACE; + } + return 0; + } + + private static void setMenuItemApplied( + GalleryActionBar model, int id, boolean applied, boolean updateTitle) { + model.setClusterItemEnabled(id, !applied); + } + + private static void setMenuItemAppliedEnabled(GalleryActionBar model, int id, boolean applied, boolean enabled, boolean updateTitle) { + model.setClusterItemEnabled(id, enabled); + } + + // Add a specified filter to the path. + public static String newFilterPath(String base, int filterType) { + int mediaType; + switch (filterType) { + case FILTER_IMAGE_ONLY: + mediaType = MediaObject.MEDIA_TYPE_IMAGE; + break; + case FILTER_VIDEO_ONLY: + mediaType = MediaObject.MEDIA_TYPE_VIDEO; + break; + default: /* FILTER_ALL */ + return base; + } + + return "/filter/mediatype/" + mediaType + "/{" + base + "}"; + } + + // Add a specified clustering to the path. + public static String newClusterPath(String base, int clusterType) { + String kind; + switch (clusterType) { + case CLUSTER_BY_TIME: + kind = "time"; + break; + case CLUSTER_BY_LOCATION: + kind = "location"; + break; + case CLUSTER_BY_TAG: + kind = "tag"; + break; + case CLUSTER_BY_SIZE: + kind = "size"; + break; + case CLUSTER_BY_FACE: + kind = "face"; + break; + default: /* CLUSTER_BY_ALBUM */ + return base; + } + + return "/cluster/{" + base + "}/" + kind; + } + + // Change the topmost filter to the specified type. + public static String switchFilterPath(String base, int filterType) { + return newFilterPath(removeOneFilterFromPath(base), filterType); + } + + // Change the topmost clustering to the specified type. + public static String switchClusterPath(String base, int clusterType) { + return newClusterPath(removeOneClusterFromPath(base), clusterType); + } + + // Remove the topmost clustering (if any) from the path. + private static String removeOneClusterFromPath(String base) { + boolean[] done = new boolean[1]; + return removeOneClusterFromPath(base, done); + } + + private static String removeOneClusterFromPath(String base, boolean[] done) { + if (done[0]) return base; + + String[] segments = Path.split(base); + if (segments[0].equals("cluster")) { + done[0] = true; + return Path.splitSequence(segments[1])[0]; + } + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < segments.length; i++) { + sb.append("/"); + if (segments[i].startsWith("{")) { + sb.append("{"); + String[] sets = Path.splitSequence(segments[i]); + for (int j = 0; j < sets.length; j++) { + if (j > 0) { + sb.append(","); + } + sb.append(removeOneClusterFromPath(sets[j], done)); + } + sb.append("}"); + } else { + sb.append(segments[i]); + } + } + return sb.toString(); + } + + // Remove the topmost filter (if any) from the path. + private static String removeOneFilterFromPath(String base) { + boolean[] done = new boolean[1]; + return removeOneFilterFromPath(base, done); + } + + private static String removeOneFilterFromPath(String base, boolean[] done) { + if (done[0]) return base; + + String[] segments = Path.split(base); + if (segments[0].equals("filter") && segments[1].equals("mediatype")) { + done[0] = true; + return Path.splitSequence(segments[3])[0]; + } + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < segments.length; i++) { + sb.append("/"); + if (segments[i].startsWith("{")) { + sb.append("{"); + String[] sets = Path.splitSequence(segments[i]); + for (int j = 0; j < sets.length; j++) { + if (j > 0) { + sb.append(","); + } + sb.append(removeOneFilterFromPath(sets[j], done)); + } + sb.append("}"); + } else { + sb.append(segments[i]); + } + } + return sb.toString(); + } +} diff --git a/src/com/android/gallery3d/app/Gallery.java b/src/com/android/gallery3d/app/Gallery.java new file mode 100644 index 000000000..2c5263b03 --- /dev/null +++ b/src/com/android/gallery3d/app/Gallery.java @@ -0,0 +1,232 @@ +/* + * Copyright (C) 2009 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.gallery3d.app; + +import com.android.gallery3d.R; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.data.DataManager; +import com.android.gallery3d.data.MediaItem; +import com.android.gallery3d.data.MediaSet; +import com.android.gallery3d.data.Path; +import com.android.gallery3d.ui.GLRoot; +import com.android.gallery3d.util.GalleryUtils; + +import android.app.ActionBar; +import android.content.ContentResolver; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; +import android.view.Window; +import android.widget.Toast; + +public final class Gallery extends AbstractGalleryActivity { + public static final String EXTRA_SLIDESHOW = "slideshow"; + public static final String EXTRA_CROP = "crop"; + + public static final String ACTION_REVIEW = "com.android.camera.action.REVIEW"; + public static final String KEY_GET_CONTENT = "get-content"; + public static final String KEY_GET_ALBUM = "get-album"; + public static final String KEY_TYPE_BITS = "type-bits"; + public static final String KEY_MEDIA_TYPES = "mediaTypes"; + + private static final String TAG = "Gallery"; + private GalleryActionBar mActionBar; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + requestWindowFeature(Window.FEATURE_ACTION_BAR); + requestWindowFeature(Window.FEATURE_ACTION_BAR_OVERLAY); + requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); + + setContentView(R.layout.main); + mActionBar = new GalleryActionBar(this); + + if (savedInstanceState != null) { + getStateManager().restoreFromState(savedInstanceState); + } else { + initializeByIntent(); + } + } + + private void initializeByIntent() { + Intent intent = getIntent(); + String action = intent.getAction(); + + if (Intent.ACTION_GET_CONTENT.equalsIgnoreCase(action)) { + startGetContent(intent); + } else if (Intent.ACTION_PICK.equalsIgnoreCase(action)) { + // We do NOT really support the PICK intent. Handle it as + // the GET_CONTENT. However, we need to translate the type + // in the intent here. + Log.w(TAG, "action PICK is not supported"); + String type = Utils.ensureNotNull(intent.getType()); + if (type.startsWith("vnd.android.cursor.dir/")) { + if (type.endsWith("/image")) intent.setType("image/*"); + if (type.endsWith("/video")) intent.setType("video/*"); + } + startGetContent(intent); + } else if (Intent.ACTION_VIEW.equalsIgnoreCase(action) + || ACTION_REVIEW.equalsIgnoreCase(action)){ + startViewAction(intent); + } else { + startDefaultPage(); + } + } + + public void startDefaultPage() { + Bundle data = new Bundle(); + data.putString(AlbumSetPage.KEY_MEDIA_PATH, + getDataManager().getTopSetPath(DataManager.INCLUDE_ALL)); + getStateManager().startState(AlbumSetPage.class, data); + } + + private void startGetContent(Intent intent) { + Bundle data = intent.getExtras() != null + ? new Bundle(intent.getExtras()) + : new Bundle(); + data.putBoolean(KEY_GET_CONTENT, true); + int typeBits = GalleryUtils.determineTypeBits(this, intent); + data.putInt(KEY_TYPE_BITS, typeBits); + data.putString(AlbumSetPage.KEY_MEDIA_PATH, + getDataManager().getTopSetPath(typeBits)); + getStateManager().startState(AlbumSetPage.class, data); + } + + private String getContentType(Intent intent) { + String type = intent.getType(); + if (type != null) return type; + + Uri uri = intent.getData(); + try { + return getContentResolver().getType(uri); + } catch (Throwable t) { + Log.w(TAG, "get type fail", t); + return null; + } + } + + private void startViewAction(Intent intent) { + Boolean slideshow = intent.getBooleanExtra(EXTRA_SLIDESHOW, false); + if (slideshow) { + getActionBar().hide(); + DataManager manager = getDataManager(); + Path path = manager.findPathByUri(intent.getData()); + if (path == null || manager.getMediaObject(path) + instanceof MediaItem) { + path = Path.fromString( + manager.getTopSetPath(DataManager.INCLUDE_IMAGE)); + } + Bundle data = new Bundle(); + data.putString(SlideshowPage.KEY_SET_PATH, path.toString()); + data.putBoolean(SlideshowPage.KEY_RANDOM_ORDER, true); + data.putBoolean(SlideshowPage.KEY_REPEAT, true); + getStateManager().startState(SlideshowPage.class, data); + } else { + Bundle data = new Bundle(); + DataManager dm = getDataManager(); + Uri uri = intent.getData(); + String contentType = getContentType(intent); + if (contentType == null) { + Toast.makeText(this, + R.string.no_such_item, Toast.LENGTH_LONG).show(); + finish(); + return; + } + if (contentType.startsWith( + ContentResolver.CURSOR_DIR_BASE_TYPE)) { + int mediaType = intent.getIntExtra(KEY_MEDIA_TYPES, 0); + if (mediaType != 0) { + uri = uri.buildUpon().appendQueryParameter( + KEY_MEDIA_TYPES, String.valueOf(mediaType)) + .build(); + } + Path albumPath = dm.findPathByUri(uri); + if (albumPath != null) { + MediaSet mediaSet = (MediaSet) dm.getMediaObject(albumPath); + data.putString(AlbumPage.KEY_MEDIA_PATH, albumPath.toString()); + getStateManager().startState(AlbumPage.class, data); + } else { + startDefaultPage(); + } + } else { + Path itemPath = dm.findPathByUri(uri); + Path albumPath = dm.getDefaultSetOf(itemPath); + if (albumPath != null) { + data.putString(PhotoPage.KEY_MEDIA_SET_PATH, + albumPath.toString()); + } + data.putString(PhotoPage.KEY_MEDIA_ITEM_PATH, itemPath.toString()); + getStateManager().startState(PhotoPage.class, data); + } + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + return getStateManager().createOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + GLRoot root = getGLRoot(); + root.lockRenderThread(); + try { + return getStateManager().itemSelected(item); + } finally { + root.unlockRenderThread(); + } + } + + @Override + public void onBackPressed() { + // send the back event to the top sub-state + GLRoot root = getGLRoot(); + root.lockRenderThread(); + try { + getStateManager().onBackPressed(); + } finally { + root.unlockRenderThread(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + GLRoot root = getGLRoot(); + root.lockRenderThread(); + try { + getStateManager().destroy(); + } finally { + root.unlockRenderThread(); + } + } + + @Override + protected void onResume() { + Utils.assertTrue(getStateManager().getStateCount() > 0); + super.onResume(); + } + + @Override + public GalleryActionBar getGalleryActionBar() { + return mActionBar; + } +} diff --git a/src/com/android/gallery3d/app/GalleryActionBar.java b/src/com/android/gallery3d/app/GalleryActionBar.java new file mode 100644 index 000000000..b9b59ee39 --- /dev/null +++ b/src/com/android/gallery3d/app/GalleryActionBar.java @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.gallery3d.app; + +import java.util.ArrayList; + +import com.android.gallery3d.R; + +import android.app.ActionBar; +import android.app.ActionBar.Tab; +import android.app.Activity; +import android.app.AlertDialog; +import android.app.FragmentTransaction; +import android.content.Context; +import android.content.DialogInterface; +import android.view.Menu; +import android.view.MenuItem; +import android.widget.ShareActionProvider; + +public class GalleryActionBar implements ActionBar.TabListener { + private static final String TAG = "GalleryActionBar"; + + public interface ClusterRunner { + public void doCluster(int id); + } + + private static class ActionItem { + public int action; + public boolean enabled; + public boolean visible; + public int tabTitle; + public int dialogTitle; + public int clusterBy; + + public ActionItem(int action, boolean applied, boolean enabled, int title, + int clusterBy) { + this(action, applied, enabled, title, title, clusterBy); + } + + public ActionItem(int action, boolean applied, boolean enabled, int tabTitle, + int dialogTitle, int clusterBy) { + this.action = action; + this.enabled = enabled; + this.tabTitle = tabTitle; + this.dialogTitle = dialogTitle; + this.clusterBy = clusterBy; + this.visible = true; + } + } + + private static final ActionItem[] sClusterItems = new ActionItem[] { + new ActionItem(FilterUtils.CLUSTER_BY_ALBUM, true, false, R.string.albums, + R.string.group_by_album), + new ActionItem(FilterUtils.CLUSTER_BY_LOCATION, true, false, + R.string.locations, R.string.location, R.string.group_by_location), + new ActionItem(FilterUtils.CLUSTER_BY_TIME, true, false, R.string.times, + R.string.time, R.string.group_by_time), + new ActionItem(FilterUtils.CLUSTER_BY_FACE, true, false, R.string.people, + R.string.group_by_faces), + new ActionItem(FilterUtils.CLUSTER_BY_TAG, true, false, R.string.tags, + R.string.group_by_tags) + }; + + private ClusterRunner mClusterRunner; + private CharSequence[] mTitles; + private ArrayList<Integer> mActions; + private Context mContext; + private ActionBar mActionBar; + // We need this because ActionBar.getSelectedTab() doesn't work when + // ActionBar is hidden. + private Tab mCurrentTab; + + public GalleryActionBar(Activity activity) { + mActionBar = activity.getActionBar(); + mContext = activity; + + for (ActionItem item : sClusterItems) { + mActionBar.addTab(mActionBar.newTab().setText(item.tabTitle). + setTag(item).setTabListener(this)); + } + } + + public static int getHeight(Activity activity) { + ActionBar actionBar = activity.getActionBar(); + return actionBar != null ? actionBar.getHeight() : 0; + } + + private void createDialogData() { + ArrayList<CharSequence> titles = new ArrayList<CharSequence>(); + mActions = new ArrayList<Integer>(); + for (ActionItem item : sClusterItems) { + if (item.enabled && item.visible) { + titles.add(mContext.getString(item.dialogTitle)); + mActions.add(item.action); + } + } + mTitles = new CharSequence[titles.size()]; + titles.toArray(mTitles); + } + + public void setClusterItemEnabled(int id, boolean enabled) { + for (ActionItem item : sClusterItems) { + if (item.action == id) { + item.enabled = enabled; + return; + } + } + } + + public void setClusterItemVisibility(int id, boolean visible) { + for (ActionItem item : sClusterItems) { + if (item.action == id) { + item.visible = visible; + return; + } + } + } + + public int getClusterTypeAction() { + if (mCurrentTab != null) { + ActionItem item = (ActionItem) mCurrentTab.getTag(); + return item.action; + } + // By default, it's group-by-album + return FilterUtils.CLUSTER_BY_ALBUM; + } + + public static String getClusterByTypeString(Context context, int type) { + for (ActionItem item : sClusterItems) { + if (item.action == type) { + return context.getString(item.clusterBy); + } + } + return null; + } + + public static ShareActionProvider initializeShareActionProvider(Menu menu) { + MenuItem item = menu.findItem(R.id.action_share); + ShareActionProvider shareActionProvider = null; + if (item != null) { + shareActionProvider = (ShareActionProvider) item.getActionProvider(); + shareActionProvider.setShareHistoryFileName( + ShareActionProvider.DEFAULT_SHARE_HISTORY_FILE_NAME); + } + return shareActionProvider; + } + + public void showClusterTabs(ClusterRunner runner) { + mActionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_TABS); + mClusterRunner = runner; + } + + public void hideClusterTabs() { + mActionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD); + mClusterRunner = null; + } + + public void showClusterDialog(final ClusterRunner clusterRunner) { + createDialogData(); + final ArrayList<Integer> actions = mActions; + new AlertDialog.Builder(mContext).setTitle(R.string.group_by).setItems( + mTitles, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + clusterRunner.doCluster(actions.get(which).intValue()); + } + }).create().show(); + } + + public void setTitle(String title) { + if (mActionBar != null) mActionBar.setTitle(title); + } + + public void setTitle(int titleId) { + if (mActionBar != null) mActionBar.setTitle(titleId); + } + + public void setSubtitle(String title) { + if (mActionBar != null) mActionBar.setSubtitle(title); + } + + public void setNavigationMode(int mode) { + if (mActionBar != null) mActionBar.setNavigationMode(mode); + } + + public int getHeight() { + return mActionBar == null ? 0 : mActionBar.getHeight(); + } + + @Override + public void onTabSelected(Tab tab, FragmentTransaction ft) { + if (mCurrentTab == tab) return; + mCurrentTab = tab; + ActionItem item = (ActionItem) tab.getTag(); + if (mClusterRunner != null) mClusterRunner.doCluster(item.action); + } + + @Override + public void onTabUnselected(Tab tab, FragmentTransaction ft) { + } + + @Override + public void onTabReselected(Tab tab, FragmentTransaction ft) { + } +} diff --git a/src/com/android/gallery3d/app/GalleryActivity.java b/src/com/android/gallery3d/app/GalleryActivity.java new file mode 100644 index 000000000..02f2f72f3 --- /dev/null +++ b/src/com/android/gallery3d/app/GalleryActivity.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.ui.GLRoot; +import com.android.gallery3d.ui.PositionRepository; + +public interface GalleryActivity extends GalleryContext { + public StateManager getStateManager(); + public GLRoot getGLRoot(); + public PositionRepository getPositionRepository(); + public GalleryApp getGalleryApplication(); + public GalleryActionBar getGalleryActionBar(); +} diff --git a/src/com/android/gallery3d/app/GalleryApp.java b/src/com/android/gallery3d/app/GalleryApp.java new file mode 100644 index 000000000..b3a305e53 --- /dev/null +++ b/src/com/android/gallery3d/app/GalleryApp.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.data.DataManager; +import com.android.gallery3d.data.DownloadCache; +import com.android.gallery3d.data.ImageCacheService; +import com.android.gallery3d.util.ThreadPool; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.res.Resources; +import android.os.Looper; + +public interface GalleryApp { + public DataManager getDataManager(); + public ImageCacheService getImageCacheService(); + public DownloadCache getDownloadCache(); + public ThreadPool getThreadPool(); + + public Context getAndroidContext(); + public Looper getMainLooper(); + public ContentResolver getContentResolver(); + public Resources getResources(); +} diff --git a/src/com/android/gallery3d/app/GalleryAppImpl.java b/src/com/android/gallery3d/app/GalleryAppImpl.java new file mode 100644 index 000000000..a11d92017 --- /dev/null +++ b/src/com/android/gallery3d/app/GalleryAppImpl.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.data.DataManager; +import com.android.gallery3d.data.DownloadCache; +import com.android.gallery3d.data.ImageCacheService; +import com.android.gallery3d.picasasource.PicasaSource; +import com.android.gallery3d.util.GalleryUtils; +import com.android.gallery3d.util.ThreadPool; +import com.android.gallery3d.widget.WidgetUtils; + +import android.app.Application; +import android.content.Context; + +import java.io.File; + +public class GalleryAppImpl extends Application implements GalleryApp { + + private static final String DOWNLOAD_FOLDER = "download"; + private static final long DOWNLOAD_CAPACITY = 64 * 1024 * 1024; // 64M + + private ImageCacheService mImageCacheService; + private DataManager mDataManager; + private ThreadPool mThreadPool; + private DownloadCache mDownloadCache; + + @Override + public void onCreate() { + super.onCreate(); + GalleryUtils.initialize(this); + WidgetUtils.initialize(this); + PicasaSource.initialize(this); + } + + public Context getAndroidContext() { + return this; + } + + public synchronized DataManager getDataManager() { + if (mDataManager == null) { + mDataManager = new DataManager(this); + mDataManager.initializeSourceMap(); + } + return mDataManager; + } + + public synchronized ImageCacheService getImageCacheService() { + if (mImageCacheService == null) { + mImageCacheService = new ImageCacheService(getAndroidContext()); + } + return mImageCacheService; + } + + public synchronized ThreadPool getThreadPool() { + if (mThreadPool == null) { + mThreadPool = new ThreadPool(); + } + return mThreadPool; + } + + public synchronized DownloadCache getDownloadCache() { + if (mDownloadCache == null) { + File cacheDir = new File(getExternalCacheDir(), DOWNLOAD_FOLDER); + + if (!cacheDir.isDirectory()) cacheDir.mkdirs(); + + if (!cacheDir.isDirectory()) { + throw new RuntimeException( + "fail to create: " + cacheDir.getAbsolutePath()); + } + mDownloadCache = new DownloadCache(this, cacheDir, DOWNLOAD_CAPACITY); + } + return mDownloadCache; + } +} diff --git a/src/com/android/gallery3d/app/GalleryContext.java b/src/com/android/gallery3d/app/GalleryContext.java new file mode 100644 index 000000000..022b4a704 --- /dev/null +++ b/src/com/android/gallery3d/app/GalleryContext.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.data.DataManager; +import com.android.gallery3d.data.ImageCacheService; +import com.android.gallery3d.util.ThreadPool; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.res.Resources; +import android.os.Looper; + +public interface GalleryContext { + public ImageCacheService getImageCacheService(); + public DataManager getDataManager(); + + public Context getAndroidContext(); + + public Looper getMainLooper(); + public Resources getResources(); + public ContentResolver getContentResolver(); + public ThreadPool getThreadPool(); +} diff --git a/src/com/android/gallery3d/app/LoadingListener.java b/src/com/android/gallery3d/app/LoadingListener.java new file mode 100644 index 000000000..ecbd798d2 --- /dev/null +++ b/src/com/android/gallery3d/app/LoadingListener.java @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +public interface LoadingListener { + public void onLoadingStarted(); + public void onLoadingFinished(); +} diff --git a/src/com/android/gallery3d/app/Log.java b/src/com/android/gallery3d/app/Log.java new file mode 100644 index 000000000..07a8ea588 --- /dev/null +++ b/src/com/android/gallery3d/app/Log.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +public class Log { + public static int v(String tag, String msg) { + return android.util.Log.v(tag, msg); + } + public static int v(String tag, String msg, Throwable tr) { + return android.util.Log.v(tag, msg, tr); + } + public static int d(String tag, String msg) { + return android.util.Log.d(tag, msg); + } + public static int d(String tag, String msg, Throwable tr) { + return android.util.Log.d(tag, msg, tr); + } + public static int i(String tag, String msg) { + return android.util.Log.i(tag, msg); + } + public static int i(String tag, String msg, Throwable tr) { + return android.util.Log.i(tag, msg, tr); + } + public static int w(String tag, String msg) { + return android.util.Log.w(tag, msg); + } + public static int w(String tag, String msg, Throwable tr) { + return android.util.Log.w(tag, msg, tr); + } + public static int w(String tag, Throwable tr) { + return android.util.Log.w(tag, tr); + } + public static int e(String tag, String msg) { + return android.util.Log.e(tag, msg); + } + public static int e(String tag, String msg, Throwable tr) { + return android.util.Log.e(tag, msg, tr); + } +} diff --git a/src/com/android/gallery3d/app/ManageCachePage.java b/src/com/android/gallery3d/app/ManageCachePage.java new file mode 100644 index 000000000..a0190db77 --- /dev/null +++ b/src/com/android/gallery3d/app/ManageCachePage.java @@ -0,0 +1,271 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.R; +import com.android.gallery3d.data.MediaObject; +import com.android.gallery3d.data.MediaSet; +import com.android.gallery3d.data.Path; +import com.android.gallery3d.ui.AlbumSetView; +import com.android.gallery3d.ui.CacheBarView; +import com.android.gallery3d.ui.GLCanvas; +import com.android.gallery3d.ui.GLView; +import com.android.gallery3d.ui.ManageCacheDrawer; +import com.android.gallery3d.ui.MenuExecutor; +import com.android.gallery3d.ui.SelectionDrawer; +import com.android.gallery3d.ui.SelectionManager; +import com.android.gallery3d.ui.SlotView; +import com.android.gallery3d.ui.StaticBackground; +import com.android.gallery3d.util.GalleryUtils; + +import android.app.ActionBar; +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.widget.Toast; + +import java.util.ArrayList; + +public class ManageCachePage extends ActivityState implements + SelectionManager.SelectionListener, CacheBarView.Listener, + MenuExecutor.ProgressListener, EyePosition.EyePositionListener { + public static final String KEY_MEDIA_PATH = "media-path"; + private static final String TAG = "ManageCachePage"; + + private static final float USER_DISTANCE_METER = 0.3f; + private static final int DATA_CACHE_SIZE = 256; + + private StaticBackground mStaticBackground; + private AlbumSetView mAlbumSetView; + + private MediaSet mMediaSet; + + protected SelectionManager mSelectionManager; + protected SelectionDrawer mSelectionDrawer; + private AlbumSetDataAdapter mAlbumSetDataAdapter; + private float mUserDistance; // in pixel + + private CacheBarView mCacheBar; + + private EyePosition mEyePosition; + + // The eyes' position of the user, the origin is at the center of the + // device and the unit is in pixels. + private float mX; + private float mY; + private float mZ; + + private int mAlbumCountToMakeAvailableOffline; + + private GLView mRootPane = new GLView() { + private float mMatrix[] = new float[16]; + + @Override + protected void onLayout( + boolean changed, int left, int top, int right, int bottom) { + mStaticBackground.layout(0, 0, right - left, bottom - top); + mEyePosition.resetPosition(); + + Config.ManageCachePage config = Config.ManageCachePage.get((Context) mActivity); + + ActionBar actionBar = ((Activity) mActivity).getActionBar(); + int slotViewTop = GalleryActionBar.getHeight((Activity) mActivity); + int slotViewBottom = bottom - top - config.cacheBarHeight; + + mAlbumSetView.layout(0, slotViewTop, right - left, slotViewBottom); + mCacheBar.layout(0, bottom - top - config.cacheBarHeight, + right - left, bottom - top); + } + + @Override + protected void render(GLCanvas canvas) { + canvas.save(GLCanvas.SAVE_FLAG_MATRIX); + GalleryUtils.setViewPointMatrix(mMatrix, + getWidth() / 2 + mX, getHeight() / 2 + mY, mZ); + canvas.multiplyMatrix(mMatrix, 0); + super.render(canvas); + canvas.restore(); + } + }; + + public void onEyePositionChanged(float x, float y, float z) { + mRootPane.lockRendering(); + mX = x; + mY = y; + mZ = z; + mRootPane.unlockRendering(); + mRootPane.invalidate(); + } + + public void onSingleTapUp(int slotIndex) { + MediaSet targetSet = mAlbumSetDataAdapter.getMediaSet(slotIndex); + if (targetSet == null) return; // Content is dirty, we shall reload soon + + // ignore selection action if the target set does not support cache + // operation (like a local album). + if ((targetSet.getSupportedOperations() + & MediaSet.SUPPORT_CACHE) == 0) { + showToastForLocalAlbum(); + return; + } + + Path path = targetSet.getPath(); + boolean isFullyCached = + (targetSet.getCacheFlag() == MediaObject.CACHE_FLAG_FULL); + boolean isSelected = mSelectionManager.isItemSelected(path); + + if (!isFullyCached) { + // We only count the media sets that will be made available offline + // in this session. + if (isSelected) { + --mAlbumCountToMakeAvailableOffline; + } else { + ++mAlbumCountToMakeAvailableOffline; + } + } + + long sizeOfTarget = targetSet.getCacheSize(); + if (isFullyCached ^ isSelected) { + mCacheBar.increaseTargetCacheSize(-sizeOfTarget); + } else { + mCacheBar.increaseTargetCacheSize(sizeOfTarget); + } + + mSelectionManager.toggle(path); + mAlbumSetView.invalidate(); + } + + @Override + public void onCreate(Bundle data, Bundle restoreState) { + initializeViews(); + initializeData(data); + mEyePosition = new EyePosition(mActivity.getAndroidContext(), this); + } + + @Override + public void onPause() { + super.onPause(); + mAlbumSetDataAdapter.pause(); + mAlbumSetView.pause(); + mCacheBar.pause(); + mEyePosition.pause(); + } + + @Override + public void onResume() { + super.onResume(); + setContentPane(mRootPane); + mAlbumSetDataAdapter.resume(); + mAlbumSetView.resume(); + mCacheBar.resume(); + mEyePosition.resume(); + } + + private void initializeData(Bundle data) { + mUserDistance = GalleryUtils.meterToPixel(USER_DISTANCE_METER); + String mediaPath = data.getString(ManageCachePage.KEY_MEDIA_PATH); + mMediaSet = mActivity.getDataManager().getMediaSet(mediaPath); + mSelectionManager.setSourceMediaSet(mMediaSet); + + // We will always be in selection mode in this page. + mSelectionManager.setAutoLeaveSelectionMode(false); + mSelectionManager.enterSelectionMode(); + + mAlbumSetDataAdapter = new AlbumSetDataAdapter( + mActivity, mMediaSet, DATA_CACHE_SIZE); + mAlbumSetView.setModel(mAlbumSetDataAdapter); + } + + private void initializeViews() { + mSelectionManager = new SelectionManager(mActivity, true); + mSelectionManager.setSelectionListener(this); + mStaticBackground = new StaticBackground(mActivity.getAndroidContext()); + mRootPane.addComponent(mStaticBackground); + + mSelectionDrawer = new ManageCacheDrawer( + (Context) mActivity, mSelectionManager); + Config.ManageCachePage config = Config.ManageCachePage.get((Context) mActivity); + mAlbumSetView = new AlbumSetView(mActivity, mSelectionDrawer, + config.slotWidth, config.slotHeight, + config.displayItemSize, config.labelFontSize, + config.labelOffsetY, config.labelMargin); + mAlbumSetView.setListener(new SlotView.SimpleListener() { + @Override + public void onSingleTapUp(int slotIndex) { + ManageCachePage.this.onSingleTapUp(slotIndex); + } + }); + mRootPane.addComponent(mAlbumSetView); + + mCacheBar = new CacheBarView(mActivity, R.drawable.manage_bar, + config.cacheBarHeight, + config.cacheBarPinLeftMargin, + config.cacheBarPinRightMargin, + config.cacheBarButtonRightMargin, + config.cacheBarFontSize); + + mCacheBar.setListener(this); + mRootPane.addComponent(mCacheBar); + + mStaticBackground.setImage(R.drawable.background, + R.drawable.background_portrait); + } + + public void onDoneClicked() { + ArrayList<Path> ids = mSelectionManager.getSelected(false); + if (ids.size() == 0) { + onBackPressed(); + return; + } + showToast(); + + MenuExecutor menuExecutor = new MenuExecutor(mActivity, + mSelectionManager); + menuExecutor.startAction(R.id.action_toggle_full_caching, + R.string.process_caching_requests, this); + } + + private void showToast() { + if (mAlbumCountToMakeAvailableOffline > 0) { + Activity activity = (Activity) mActivity; + Toast.makeText(activity, activity.getResources().getQuantityString( + R.plurals.make_albums_available_offline, + mAlbumCountToMakeAvailableOffline), + Toast.LENGTH_SHORT).show(); + } + } + + private void showToastForLocalAlbum() { + Activity activity = (Activity) mActivity; + Toast.makeText(activity, activity.getResources().getString( + R.string.try_to_set_local_album_available_offline), + Toast.LENGTH_SHORT).show(); + } + + public void onProgressComplete(int result) { + onBackPressed(); + } + + public void onProgressUpdate(int index) { + } + + public void onSelectionModeChange(int mode) { + } + + public void onSelectionChange(Path path, boolean selected) { + } +} diff --git a/src/com/android/gallery3d/app/MovieActivity.java b/src/com/android/gallery3d/app/MovieActivity.java new file mode 100644 index 000000000..fea364e85 --- /dev/null +++ b/src/com/android/gallery3d/app/MovieActivity.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2007 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.gallery3d.app; + +import com.android.gallery3d.R; + +import android.app.Activity; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.database.Cursor; +import android.media.AudioManager; +import android.os.Bundle; +import android.provider.MediaStore; +import android.provider.MediaStore.Video.VideoColumns; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; + +/** + * This activity plays a video from a specified URI. + */ +public class MovieActivity extends Activity { + @SuppressWarnings("unused") + private static final String TAG = "MovieActivity"; + + private MoviePlayer mPlayer; + private boolean mFinishOnCompletion; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + requestWindowFeature(Window.FEATURE_ACTION_BAR); + requestWindowFeature(Window.FEATURE_ACTION_BAR_OVERLAY); + + setContentView(R.layout.movie_view); + View rootView = findViewById(R.id.root); + Intent intent = getIntent(); + setVideoTitle(intent); + mPlayer = new MoviePlayer(rootView, this, intent.getData()) { + @Override + public void onCompletion() { + if (mFinishOnCompletion) { + finish(); + } + } + }; + if (intent.hasExtra(MediaStore.EXTRA_SCREEN_ORIENTATION)) { + int orientation = intent.getIntExtra( + MediaStore.EXTRA_SCREEN_ORIENTATION, + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); + if (orientation != getRequestedOrientation()) { + setRequestedOrientation(orientation); + } + } + mFinishOnCompletion = intent.getBooleanExtra(MediaStore.EXTRA_FINISH_ON_COMPLETION, true); + Window win = getWindow(); + WindowManager.LayoutParams winParams = win.getAttributes(); + winParams.buttonBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_OFF; + win.setAttributes(winParams); + + } + + private void setVideoTitle(Intent intent) { + String title = intent.getStringExtra(Intent.EXTRA_TITLE); + if (title == null) { + Cursor cursor = null; + try { + cursor = getContentResolver().query(intent.getData(), + new String[] {VideoColumns.TITLE}, null, null, null); + if (cursor != null && cursor.moveToNext()) { + title = cursor.getString(0); + } + } catch (Throwable t) { + Log.w(TAG, "cannot get title from: " + intent.getDataString(), t); + } finally { + if (cursor != null) cursor.close(); + } + } + if (title != null) getActionBar().setTitle(title); + } + + @Override + public void onStart() { + ((AudioManager) getSystemService(AUDIO_SERVICE)) + .requestAudioFocus(null, AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT); + super.onStart(); + } + + @Override + protected void onStop() { + ((AudioManager) getSystemService(AUDIO_SERVICE)) + .abandonAudioFocus(null); + super.onStop(); + } + + @Override + public void onPause() { + mPlayer.onPause(); + super.onPause(); + } + + @Override + public void onResume() { + mPlayer.onResume(); + super.onResume(); + } + + @Override + public void onDestroy() { + mPlayer.onDestroy(); + super.onDestroy(); + } +} diff --git a/src/com/android/gallery3d/app/MoviePlayer.java b/src/com/android/gallery3d/app/MoviePlayer.java new file mode 100644 index 000000000..423994485 --- /dev/null +++ b/src/com/android/gallery3d/app/MoviePlayer.java @@ -0,0 +1,291 @@ +/* + * Copyright (C) 2009 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.gallery3d.app; + +import com.android.gallery3d.R; +import com.android.gallery3d.common.BlobCache; +import com.android.gallery3d.util.CacheManager; +import com.android.gallery3d.util.GalleryUtils; + +import android.app.ActionBar; +import android.app.AlertDialog; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnCancelListener; +import android.content.DialogInterface.OnClickListener; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioManager; +import android.media.MediaPlayer; +import android.net.Uri; +import android.os.Handler; +import android.view.KeyEvent; +import android.view.View; +import android.widget.MediaController; +import android.widget.VideoView; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; + +public class MoviePlayer implements + MediaPlayer.OnErrorListener, MediaPlayer.OnCompletionListener { + @SuppressWarnings("unused") + private static final String TAG = "MoviePlayer"; + + // Copied from MediaPlaybackService in the Music Player app. + private static final String SERVICECMD = "com.android.music.musicservicecommand"; + private static final String CMDNAME = "command"; + private static final String CMDPAUSE = "pause"; + + private Context mContext; + private final VideoView mVideoView; + private final View mProgressView; + private final Bookmarker mBookmarker; + private final Uri mUri; + private final Handler mHandler = new Handler(); + private final AudioBecomingNoisyReceiver mAudioBecomingNoisyReceiver; + private final ActionBar mActionBar; + + private boolean mHasPaused; + + private final Runnable mPlayingChecker = new Runnable() { + public void run() { + if (mVideoView.isPlaying()) { + mProgressView.setVisibility(View.GONE); + } else { + mHandler.postDelayed(mPlayingChecker, 250); + } + } + }; + + public MoviePlayer(View rootView, final MovieActivity movieActivity, Uri videoUri) { + mContext = movieActivity.getApplicationContext(); + mVideoView = (VideoView) rootView.findViewById(R.id.surface_view); + mProgressView = rootView.findViewById(R.id.progress_indicator); + mBookmarker = new Bookmarker(movieActivity); + mActionBar = movieActivity.getActionBar(); + mUri = videoUri; + + // For streams that we expect to be slow to start up, show a + // progress spinner until playback starts. + String scheme = mUri.getScheme(); + if ("http".equalsIgnoreCase(scheme) || "rtsp".equalsIgnoreCase(scheme)) { + mHandler.postDelayed(mPlayingChecker, 250); + } else { + mProgressView.setVisibility(View.GONE); + } + + mVideoView.setOnErrorListener(this); + mVideoView.setOnCompletionListener(this); + mVideoView.setVideoURI(mUri); + + MediaController mediaController = new MediaController(movieActivity) { + @Override + public void show() { + super.show(); + mActionBar.show(); + } + + @Override + public void hide() { + super.hide(); + mActionBar.hide(); + } + }; + mVideoView.setMediaController(mediaController); + mediaController.setOnKeyListener(new View.OnKeyListener() { + public boolean onKey(View v, int keyCode, KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_BACK) { + if (event.getAction() == KeyEvent.ACTION_UP) { + movieActivity.onBackPressed(); + } + return true; + } + return false; + } + }); + + mAudioBecomingNoisyReceiver = new AudioBecomingNoisyReceiver(); + mAudioBecomingNoisyReceiver.register(); + + // make the video view handle keys for seeking and pausing + mVideoView.requestFocus(); + + Intent i = new Intent(SERVICECMD); + i.putExtra(CMDNAME, CMDPAUSE); + movieActivity.sendBroadcast(i); + + final Integer bookmark = mBookmarker.getBookmark(mUri); + if (bookmark != null) { + showResumeDialog(movieActivity, bookmark); + } else { + mVideoView.start(); + } + } + + private void showResumeDialog(Context context, final int bookmark) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.resume_playing_title); + builder.setMessage(String.format( + context.getString(R.string.resume_playing_message), + GalleryUtils.formatDuration(context, bookmark / 1000))); + builder.setOnCancelListener(new OnCancelListener() { + public void onCancel(DialogInterface dialog) { + onCompletion(); + } + }); + builder.setPositiveButton( + R.string.resume_playing_resume, new OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + mVideoView.seekTo(bookmark); + mVideoView.start(); + } + }); + builder.setNegativeButton( + R.string.resume_playing_restart, new OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + mVideoView.start(); + } + }); + builder.show(); + } + + public void onPause() { + mHandler.removeCallbacksAndMessages(null); + mBookmarker.setBookmark(mUri, mVideoView.getCurrentPosition(), + mVideoView.getDuration()); + mVideoView.suspend(); + mHasPaused = true; + } + + public void onResume() { + if (mHasPaused) { + Integer bookmark = mBookmarker.getBookmark(mUri); + if (bookmark != null) { + mVideoView.seekTo(bookmark); + } + } + mVideoView.resume(); + } + + public void onDestroy() { + mVideoView.stopPlayback(); + mAudioBecomingNoisyReceiver.unregister(); + } + + public boolean onError(MediaPlayer player, int arg1, int arg2) { + mHandler.removeCallbacksAndMessages(null); + mProgressView.setVisibility(View.GONE); + return false; + } + + public void onCompletion(MediaPlayer mp) { + onCompletion(); + } + + public void onCompletion() { + } + + private class AudioBecomingNoisyReceiver extends BroadcastReceiver { + + public void register() { + mContext.registerReceiver(this, + new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)); + } + + public void unregister() { + mContext.unregisterReceiver(this); + } + + @Override + public void onReceive(Context context, Intent intent) { + if (mVideoView.isPlaying()) { + mVideoView.pause(); + } + } + } +} + +class Bookmarker { + private static final String TAG = "Bookmarker"; + + private static final String BOOKMARK_CACHE_FILE = "bookmark"; + private static final int BOOKMARK_CACHE_MAX_ENTRIES = 100; + private static final int BOOKMARK_CACHE_MAX_BYTES = 10 * 1024; + private static final int BOOKMARK_CACHE_VERSION = 1; + + private static final int HALF_MINUTE = 30 * 1000; + private static final int TWO_MINUTES = 4 * HALF_MINUTE; + + private final Context mContext; + + public Bookmarker(Context context) { + mContext = context; + } + + public void setBookmark(Uri uri, int bookmark, int duration) { + try { + BlobCache cache = CacheManager.getCache(mContext, + BOOKMARK_CACHE_FILE, BOOKMARK_CACHE_MAX_ENTRIES, + BOOKMARK_CACHE_MAX_BYTES, BOOKMARK_CACHE_VERSION); + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(bos); + dos.writeUTF(uri.toString()); + dos.writeInt(bookmark); + dos.writeInt(duration); + dos.flush(); + cache.insert(uri.hashCode(), bos.toByteArray()); + } catch (Throwable t) { + Log.w(TAG, "setBookmark failed", t); + } + } + + public Integer getBookmark(Uri uri) { + try { + BlobCache cache = CacheManager.getCache(mContext, + BOOKMARK_CACHE_FILE, BOOKMARK_CACHE_MAX_ENTRIES, + BOOKMARK_CACHE_MAX_BYTES, BOOKMARK_CACHE_VERSION); + + byte[] data = cache.lookup(uri.hashCode()); + if (data == null) return null; + + DataInputStream dis = new DataInputStream( + new ByteArrayInputStream(data)); + + String uriString = dis.readUTF(dis); + int bookmark = dis.readInt(); + int duration = dis.readInt(); + + if (!uriString.equals(uri.toString())) { + return null; + } + + if ((bookmark < HALF_MINUTE) || (duration < TWO_MINUTES) + || (bookmark > (duration - HALF_MINUTE))) { + return null; + } + return Integer.valueOf(bookmark); + } catch (Throwable t) { + Log.w(TAG, "getBookmark failed", t); + } + return null; + } +} diff --git a/src/com/android/gallery3d/app/PackagesMonitor.java b/src/com/android/gallery3d/app/PackagesMonitor.java new file mode 100644 index 000000000..cb202a31c --- /dev/null +++ b/src/com/android/gallery3d/app/PackagesMonitor.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.picasasource.PicasaSource; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +public class PackagesMonitor extends BroadcastReceiver { + public static final String KEY_PACKAGES_VERSION = "packages-version"; + + public synchronized static int getPackagesVersion(Context context) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + return prefs.getInt(KEY_PACKAGES_VERSION, 1); + } + + @Override + public void onReceive(Context context, Intent intent) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + + int version = prefs.getInt(KEY_PACKAGES_VERSION, 1); + prefs.edit().putInt(KEY_PACKAGES_VERSION, version + 1).commit(); + + String action = intent.getAction(); + String packageName = intent.getData().getSchemeSpecificPart(); + if (Intent.ACTION_PACKAGE_ADDED.equals(action)) { + PicasaSource.onPackageAdded(context, packageName); + } else if (Intent.ACTION_PACKAGE_REMOVED.equals(action)) { + PicasaSource.onPackageRemoved(context, packageName); + } + } +} diff --git a/src/com/android/gallery3d/app/PhotoDataAdapter.java b/src/com/android/gallery3d/app/PhotoDataAdapter.java new file mode 100644 index 000000000..c05c89a0d --- /dev/null +++ b/src/com/android/gallery3d/app/PhotoDataAdapter.java @@ -0,0 +1,794 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.common.Utils; +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.ui.PhotoView; +import com.android.gallery3d.ui.PhotoView.ImageData; +import com.android.gallery3d.ui.SynchronizedHandler; +import com.android.gallery3d.ui.TileImageViewAdapter; +import com.android.gallery3d.util.Future; +import com.android.gallery3d.util.FutureListener; +import com.android.gallery3d.util.ThreadPool; + +import android.graphics.Bitmap; +import android.graphics.BitmapRegionDecoder; +import android.os.Handler; +import android.os.Message; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; + +public class PhotoDataAdapter implements PhotoPage.Model { + @SuppressWarnings("unused") + private static final String TAG = "PhotoDataAdapter"; + + private static final int MSG_LOAD_START = 1; + private static final int MSG_LOAD_FINISH = 2; + private static final int MSG_RUN_OBJECT = 3; + + private static final int MIN_LOAD_COUNT = 8; + private static final int DATA_CACHE_SIZE = 32; + private static final int IMAGE_CACHE_SIZE = 5; + + private static final int BIT_SCREEN_NAIL = 1; + private static final int BIT_FULL_IMAGE = 2; + + private static final long VERSION_OUT_OF_RANGE = MediaObject.nextVersionNumber(); + + // sImageFetchSeq is the fetching sequence for images. + // We want to fetch the current screennail first (offset = 0), the next + // screennail (offset = +1), then the previous screennail (offset = -1) etc. + // After all the screennail are fetched, we fetch the full images (only some + // of them because of we don't want to use too much memory). + private static ImageFetch[] sImageFetchSeq; + + private static class ImageFetch { + int indexOffset; + int imageBit; + public ImageFetch(int offset, int bit) { + indexOffset = offset; + imageBit = bit; + } + } + + static { + int k = 0; + sImageFetchSeq = new ImageFetch[1 + (IMAGE_CACHE_SIZE - 1) * 2 + 3]; + sImageFetchSeq[k++] = new ImageFetch(0, BIT_SCREEN_NAIL); + + for (int i = 1; i < IMAGE_CACHE_SIZE; ++i) { + sImageFetchSeq[k++] = new ImageFetch(i, BIT_SCREEN_NAIL); + sImageFetchSeq[k++] = new ImageFetch(-i, BIT_SCREEN_NAIL); + } + + sImageFetchSeq[k++] = new ImageFetch(0, BIT_FULL_IMAGE); + sImageFetchSeq[k++] = new ImageFetch(1, BIT_FULL_IMAGE); + sImageFetchSeq[k++] = new ImageFetch(-1, BIT_FULL_IMAGE); + } + + private final TileImageViewAdapter mTileProvider = new TileImageViewAdapter(); + + // PhotoDataAdapter caches MediaItems (data) and ImageEntries (image). + // + // The MediaItems are stored in the mData array, which has DATA_CACHE_SIZE + // entries. The valid index range are [mContentStart, mContentEnd). We keep + // mContentEnd - mContentStart <= DATA_CACHE_SIZE, so we can use + // (i % DATA_CACHE_SIZE) as index to the array. + // + // The valid MediaItem window size (mContentEnd - mContentStart) may be + // smaller than DATA_CACHE_SIZE because we only update the window and reload + // the MediaItems when there are significant changes to the window position + // (>= MIN_LOAD_COUNT). + private final MediaItem mData[] = new MediaItem[DATA_CACHE_SIZE]; + private int mContentStart = 0; + private int mContentEnd = 0; + + /* + * The ImageCache is a version-to-ImageEntry map. It only holds + * the ImageEntries in the range of [mActiveStart, mActiveEnd). + * We also keep mActiveEnd - mActiveStart <= IMAGE_CACHE_SIZE. + * Besides, the [mActiveStart, mActiveEnd) range must be contained + * within the[mContentStart, mContentEnd) range. + */ + private HashMap<Long, ImageEntry> mImageCache = new HashMap<Long, ImageEntry>(); + private int mActiveStart = 0; + private int mActiveEnd = 0; + + // mCurrentIndex is the "center" image the user is viewing. The change of + // mCurrentIndex triggers the data loading and image loading. + private int mCurrentIndex; + + // mChanges keeps the version number (of MediaItem) about the previous, + // current, and next image. If the version number changes, we invalidate + // the model. This is used after a database reload or mCurrentIndex changes. + private final long mChanges[] = new long[3]; + + private final Handler mMainHandler; + private final ThreadPool mThreadPool; + + private final PhotoView mPhotoView; + private final MediaSet mSource; + private ReloadTask mReloadTask; + + private long mSourceVersion = MediaObject.INVALID_DATA_VERSION; + private int mSize = 0; + private Path mItemPath; + private boolean mIsActive; + + public interface DataListener extends LoadingListener { + public void onPhotoChanged(int index, Path item); + } + + private DataListener mDataListener; + + private final SourceListener mSourceListener = new SourceListener(); + + // The path of the current viewing item will be stored in mItemPath. + // If mItemPath is not null, mCurrentIndex is only a hint for where we + // can find the item. If mItemPath is null, then we use the mCurrentIndex to + // find the image being viewed. + public PhotoDataAdapter(GalleryActivity activity, + PhotoView view, MediaSet mediaSet, Path itemPath, int indexHint) { + mSource = Utils.checkNotNull(mediaSet); + mPhotoView = Utils.checkNotNull(view); + mItemPath = Utils.checkNotNull(itemPath); + mCurrentIndex = indexHint; + mThreadPool = activity.getThreadPool(); + + Arrays.fill(mChanges, MediaObject.INVALID_DATA_VERSION); + + mMainHandler = new SynchronizedHandler(activity.getGLRoot()) { + @SuppressWarnings("unchecked") + @Override + public void handleMessage(Message message) { + switch (message.what) { + case MSG_RUN_OBJECT: + ((Runnable) message.obj).run(); + return; + case MSG_LOAD_START: { + if (mDataListener != null) mDataListener.onLoadingStarted(); + return; + } + case MSG_LOAD_FINISH: { + if (mDataListener != null) mDataListener.onLoadingFinished(); + return; + } + default: throw new AssertionError(); + } + } + }; + + updateSlidingWindow(); + } + + private long getVersion(int index) { + if (index < 0 || index >= mSize) return VERSION_OUT_OF_RANGE; + if (index >= mContentStart && index < mContentEnd) { + MediaItem item = mData[index % DATA_CACHE_SIZE]; + if (item != null) return item.getDataVersion(); + } + return MediaObject.INVALID_DATA_VERSION; + } + + private void fireModelInvalidated() { + for (int i = -1; i <= 1; ++i) { + long current = getVersion(mCurrentIndex + i); + long change = mChanges[i + 1]; + if (current != change) { + mPhotoView.notifyImageInvalidated(i); + mChanges[i + 1] = current; + } + } + } + + public void setDataListener(DataListener listener) { + mDataListener = listener; + } + + private void updateScreenNail(long version, Future<Bitmap> future) { + ImageEntry entry = mImageCache.get(version); + if (entry == null || entry.screenNailTask == null) { + Bitmap screenNail = future.get(); + if (screenNail != null) screenNail.recycle(); + return; + } + entry.screenNailTask = null; + entry.screenNail = future.get(); + + if (entry.screenNail == null) { + entry.failToLoad = true; + } else { + for (int i = -1; i <=1; ++i) { + if (version == getVersion(mCurrentIndex + i)) { + if (i == 0) updateTileProvider(entry); + mPhotoView.notifyImageInvalidated(i); + } + } + } + updateImageRequests(); + } + + private void updateFullImage(long version, Future<BitmapRegionDecoder> future) { + ImageEntry entry = mImageCache.get(version); + if (entry == null || entry.fullImageTask == null) { + BitmapRegionDecoder fullImage = future.get(); + if (fullImage != null) fullImage.recycle(); + return; + } + entry.fullImageTask = null; + entry.fullImage = future.get(); + if (entry.fullImage != null) { + if (version == getVersion(mCurrentIndex)) { + updateTileProvider(entry); + mPhotoView.notifyImageInvalidated(0); + } + } + updateImageRequests(); + } + + public void resume() { + mIsActive = true; + mSource.addContentListener(mSourceListener); + updateImageCache(); + updateImageRequests(); + + mReloadTask = new ReloadTask(); + mReloadTask.start(); + + mPhotoView.notifyModelInvalidated(); + } + + public void pause() { + mIsActive = false; + + mReloadTask.terminate(); + mReloadTask = null; + + mSource.removeContentListener(mSourceListener); + + for (ImageEntry entry : mImageCache.values()) { + if (entry.fullImageTask != null) entry.fullImageTask.cancel(); + if (entry.screenNailTask != null) entry.screenNailTask.cancel(); + } + mImageCache.clear(); + mTileProvider.clear(); + } + + private ImageData getImage(int index) { + if (index < 0 || index >= mSize || !mIsActive) return null; + Utils.assertTrue(index >= mActiveStart && index < mActiveEnd); + + ImageEntry entry = mImageCache.get(getVersion(index)); + Bitmap screennail = entry == null ? null : entry.screenNail; + if (screennail != null) { + return new ImageData(screennail, entry.rotation); + } else { + return new ImageData(null, 0); + } + } + + public ImageData getPreviousImage() { + return getImage(mCurrentIndex - 1); + } + + public ImageData getNextImage() { + return getImage(mCurrentIndex + 1); + } + + private void updateCurrentIndex(int index) { + mCurrentIndex = index; + updateSlidingWindow(); + + MediaItem item = mData[index % DATA_CACHE_SIZE]; + mItemPath = item == null ? null : item.getPath(); + + updateImageCache(); + updateImageRequests(); + updateTileProvider(); + mPhotoView.notifyOnNewImage(); + + if (mDataListener != null) { + mDataListener.onPhotoChanged(index, mItemPath); + } + fireModelInvalidated(); + } + + public void next() { + updateCurrentIndex(mCurrentIndex + 1); + } + + public void previous() { + updateCurrentIndex(mCurrentIndex - 1); + } + + public void jumpTo(int index) { + if (mCurrentIndex == index) return; + updateCurrentIndex(index); + } + + public Bitmap getBackupImage() { + return mTileProvider.getBackupImage(); + } + + public int getImageHeight() { + return mTileProvider.getImageHeight(); + } + + public int getImageWidth() { + return mTileProvider.getImageWidth(); + } + + public int getImageRotation() { + ImageEntry entry = mImageCache.get(getVersion(mCurrentIndex)); + return entry == null ? 0 : entry.rotation; + } + + public int getLevelCount() { + return mTileProvider.getLevelCount(); + } + + public Bitmap getTile(int level, int x, int y, int tileSize) { + return mTileProvider.getTile(level, x, y, tileSize); + } + + public boolean isFailedToLoad() { + return mTileProvider.isFailedToLoad(); + } + + public boolean isEmpty() { + return mSize == 0; + } + + public int getCurrentIndex() { + return mCurrentIndex; + } + + public MediaItem getCurrentMediaItem() { + return mData[mCurrentIndex % DATA_CACHE_SIZE]; + } + + public void setCurrentPhoto(Path path, int indexHint) { + if (mItemPath == path) return; + mItemPath = path; + mCurrentIndex = indexHint; + updateSlidingWindow(); + updateImageCache(); + fireModelInvalidated(); + + // We need to reload content if the path doesn't match. + MediaItem item = getCurrentMediaItem(); + if (item != null && item.getPath() != path) { + if (mReloadTask != null) mReloadTask.notifyDirty(); + } + } + + private void updateTileProvider() { + ImageEntry entry = mImageCache.get(getVersion(mCurrentIndex)); + if (entry == null) { // in loading + mTileProvider.clear(); + } else { + updateTileProvider(entry); + } + } + + private void updateTileProvider(ImageEntry entry) { + Bitmap screenNail = entry.screenNail; + BitmapRegionDecoder fullImage = entry.fullImage; + if (screenNail != null) { + if (fullImage != null) { + mTileProvider.setBackupImage(screenNail, + fullImage.getWidth(), fullImage.getHeight()); + mTileProvider.setRegionDecoder(fullImage); + } else { + int width = screenNail.getWidth(); + int height = screenNail.getHeight(); + mTileProvider.setBackupImage(screenNail, width, height); + } + } else { + mTileProvider.clear(); + if (entry.failToLoad) mTileProvider.setFailedToLoad(); + } + } + + private void updateSlidingWindow() { + // 1. Update the image window + int start = Utils.clamp(mCurrentIndex - IMAGE_CACHE_SIZE / 2, + 0, Math.max(0, mSize - IMAGE_CACHE_SIZE)); + int end = Math.min(mSize, start + IMAGE_CACHE_SIZE); + + if (mActiveStart == start && mActiveEnd == end) return; + + mActiveStart = start; + mActiveEnd = end; + + // 2. Update the data window + start = Utils.clamp(mCurrentIndex - DATA_CACHE_SIZE / 2, + 0, Math.max(0, mSize - DATA_CACHE_SIZE)); + end = Math.min(mSize, start + DATA_CACHE_SIZE); + if (mContentStart > mActiveStart || mContentEnd < mActiveEnd + || Math.abs(start - mContentStart) > MIN_LOAD_COUNT) { + for (int i = mContentStart; i < mContentEnd; ++i) { + if (i < start || i >= end) { + mData[i % DATA_CACHE_SIZE] = null; + } + } + mContentStart = start; + mContentEnd = end; + if (mReloadTask != null) mReloadTask.notifyDirty(); + } + } + + private void updateImageRequests() { + if (!mIsActive) return; + + int currentIndex = mCurrentIndex; + MediaItem item = mData[currentIndex % DATA_CACHE_SIZE]; + if (item == null || item.getPath() != mItemPath) { + // current item mismatch - don't request image + return; + } + + // 1. Find the most wanted request and start it (if not already started). + Future<?> task = null; + for (int i = 0; i < sImageFetchSeq.length; i++) { + int offset = sImageFetchSeq[i].indexOffset; + int bit = sImageFetchSeq[i].imageBit; + task = startTaskIfNeeded(currentIndex + offset, bit); + if (task != null) break; + } + + // 2. Cancel everything else. + for (ImageEntry entry : mImageCache.values()) { + if (entry.screenNailTask != null && entry.screenNailTask != task) { + entry.screenNailTask.cancel(); + entry.screenNailTask = null; + entry.requestedBits &= ~BIT_SCREEN_NAIL; + } + if (entry.fullImageTask != null && entry.fullImageTask != task) { + entry.fullImageTask.cancel(); + entry.fullImageTask = null; + entry.requestedBits &= ~BIT_FULL_IMAGE; + } + } + } + + // Returns the task if we started the task or the task is already started. + private Future<?> startTaskIfNeeded(int index, int which) { + if (index < mActiveStart || index >= mActiveEnd) return null; + + ImageEntry entry = mImageCache.get(getVersion(index)); + if (entry == null) return null; + + if (which == BIT_SCREEN_NAIL && entry.screenNailTask != null) { + return entry.screenNailTask; + } else if (which == BIT_FULL_IMAGE && entry.fullImageTask != null) { + return entry.fullImageTask; + } + + MediaItem item = mData[index % DATA_CACHE_SIZE]; + Utils.assertTrue(item != null); + + if (which == BIT_SCREEN_NAIL + && (entry.requestedBits & BIT_SCREEN_NAIL) == 0) { + entry.requestedBits |= BIT_SCREEN_NAIL; + entry.screenNailTask = mThreadPool.submit( + item.requestImage(MediaItem.TYPE_THUMBNAIL), + new ScreenNailListener(item.getDataVersion())); + // request screen nail + return entry.screenNailTask; + } + if (which == BIT_FULL_IMAGE + && (entry.requestedBits & BIT_FULL_IMAGE) == 0 + && (item.getSupportedOperations() + & MediaItem.SUPPORT_FULL_IMAGE) != 0) { + entry.requestedBits |= BIT_FULL_IMAGE; + entry.fullImageTask = mThreadPool.submit( + item.requestLargeImage(), + new FullImageListener(item.getDataVersion())); + // request full image + return entry.fullImageTask; + } + return null; + } + + private void updateImageCache() { + HashSet<Long> toBeRemoved = new HashSet<Long>(mImageCache.keySet()); + for (int i = mActiveStart; i < mActiveEnd; ++i) { + MediaItem item = mData[i % DATA_CACHE_SIZE]; + long version = item == null + ? MediaObject.INVALID_DATA_VERSION + : item.getDataVersion(); + if (version == MediaObject.INVALID_DATA_VERSION) continue; + ImageEntry entry = mImageCache.get(version); + toBeRemoved.remove(version); + if (entry != null) { + if (Math.abs(i - mCurrentIndex) > 1) { + if (entry.fullImageTask != null) { + entry.fullImageTask.cancel(); + entry.fullImageTask = null; + } + entry.fullImage = null; + entry.requestedBits &= ~BIT_FULL_IMAGE; + } + } else { + entry = new ImageEntry(); + entry.rotation = item.getRotation(); + mImageCache.put(version, entry); + } + } + + // Clear the data and requests for ImageEntries outside the new window. + for (Long version : toBeRemoved) { + ImageEntry entry = mImageCache.remove(version); + if (entry.fullImageTask != null) entry.fullImageTask.cancel(); + if (entry.screenNailTask != null) entry.screenNailTask.cancel(); + } + } + + private class FullImageListener + implements Runnable, FutureListener<BitmapRegionDecoder> { + private final long mVersion; + private Future<BitmapRegionDecoder> mFuture; + + public FullImageListener(long version) { + mVersion = version; + } + + public void onFutureDone(Future<BitmapRegionDecoder> future) { + mFuture = future; + mMainHandler.sendMessage( + mMainHandler.obtainMessage(MSG_RUN_OBJECT, this)); + } + + public void run() { + updateFullImage(mVersion, mFuture); + } + } + + private class ScreenNailListener + implements Runnable, FutureListener<Bitmap> { + private final long mVersion; + private Future<Bitmap> mFuture; + + public ScreenNailListener(long version) { + mVersion = version; + } + + public void onFutureDone(Future<Bitmap> future) { + mFuture = future; + mMainHandler.sendMessage( + mMainHandler.obtainMessage(MSG_RUN_OBJECT, this)); + } + + public void run() { + updateScreenNail(mVersion, mFuture); + } + } + + private static class ImageEntry { + public int requestedBits = 0; + public int rotation; + public BitmapRegionDecoder fullImage; + public Bitmap screenNail; + public Future<Bitmap> screenNailTask; + public Future<BitmapRegionDecoder> fullImageTask; + public boolean failToLoad = false; + } + + private class SourceListener implements ContentListener { + public void onContentDirty() { + if (mReloadTask != null) mReloadTask.notifyDirty(); + } + } + + private <T> T executeAndWait(Callable<T> callable) { + FutureTask<T> task = new FutureTask<T>(callable); + mMainHandler.sendMessage( + mMainHandler.obtainMessage(MSG_RUN_OBJECT, task)); + try { + return task.get(); + } catch (InterruptedException e) { + return null; + } catch (ExecutionException e) { + throw new RuntimeException(e); + } + } + + private static class UpdateInfo { + public long version; + public boolean reloadContent; + public Path target; + public int indexHint; + public int contentStart; + public int contentEnd; + + public int size; + public ArrayList<MediaItem> items; + } + + private class GetUpdateInfo implements Callable<UpdateInfo> { + + private boolean needContentReload() { + for (int i = mContentStart, n = mContentEnd; i < n; ++i) { + if (mData[i % DATA_CACHE_SIZE] == null) return true; + } + MediaItem current = mData[mCurrentIndex % DATA_CACHE_SIZE]; + return current == null || current.getPath() != mItemPath; + } + + @Override + public UpdateInfo call() throws Exception { + UpdateInfo info = new UpdateInfo(); + info.version = mSourceVersion; + info.reloadContent = needContentReload(); + info.target = mItemPath; + info.indexHint = mCurrentIndex; + info.contentStart = mContentStart; + info.contentEnd = mContentEnd; + info.size = mSize; + return info; + } + } + + private class UpdateContent implements Callable<Void> { + UpdateInfo mUpdateInfo; + + public UpdateContent(UpdateInfo updateInfo) { + mUpdateInfo = updateInfo; + } + + @Override + public Void call() throws Exception { + UpdateInfo info = mUpdateInfo; + mSourceVersion = info.version; + + if (info.size != mSize) { + mSize = info.size; + if (mContentEnd > mSize) mContentEnd = mSize; + if (mActiveEnd > mSize) mActiveEnd = mSize; + } + + if (info.indexHint == MediaSet.INDEX_NOT_FOUND) { + // The image has been deleted, clear mItemPath, the + // mCurrentIndex will be updated in the updateCurrentItem(). + mItemPath = null; + updateCurrentItem(); + } else { + mCurrentIndex = info.indexHint; + } + + updateSlidingWindow(); + + if (info.items != null) { + int start = Math.max(info.contentStart, mContentStart); + int end = Math.min(info.contentStart + info.items.size(), mContentEnd); + int dataIndex = start % DATA_CACHE_SIZE; + for (int i = start; i < end; ++i) { + mData[dataIndex] = info.items.get(i - info.contentStart); + if (++dataIndex == DATA_CACHE_SIZE) dataIndex = 0; + } + } + if (mItemPath == null) { + MediaItem current = mData[mCurrentIndex % DATA_CACHE_SIZE]; + mItemPath = current == null ? null : current.getPath(); + } + updateImageCache(); + updateTileProvider(); + updateImageRequests(); + fireModelInvalidated(); + return null; + } + + private void updateCurrentItem() { + if (mSize == 0) return; + if (mCurrentIndex >= mSize) { + mCurrentIndex = mSize - 1; + mPhotoView.notifyOnNewImage(); + mPhotoView.startSlideInAnimation(PhotoView.TRANS_SLIDE_IN_LEFT); + } else { + mPhotoView.notifyOnNewImage(); + mPhotoView.startSlideInAnimation(PhotoView.TRANS_SLIDE_IN_RIGHT); + } + } + } + + private class ReloadTask extends Thread { + private volatile boolean mActive = true; + private volatile boolean mDirty = true; + + private boolean mIsLoading = false; + + private void updateLoading(boolean loading) { + if (mIsLoading == loading) return; + mIsLoading = loading; + mMainHandler.sendEmptyMessage(loading ? MSG_LOAD_START : MSG_LOAD_FINISH); + } + + @Override + public void run() { + while (mActive) { + synchronized (this) { + if (!mDirty && mActive) { + updateLoading(false); + Utils.waitWithoutInterrupt(this); + continue; + } + } + mDirty = false; + UpdateInfo info = executeAndWait(new GetUpdateInfo()); + synchronized (DataManager.LOCK) { + updateLoading(true); + long version = mSource.reload(); + if (info.version != version) { + info.reloadContent = true; + info.size = mSource.getMediaItemCount(); + } + if (!info.reloadContent) continue; + info.items = mSource.getMediaItem(info.contentStart, info.contentEnd); + MediaItem item = findCurrentMediaItem(info); + if (item == null || item.getPath() != info.target) { + info.indexHint = findIndexOfTarget(info); + } + } + executeAndWait(new UpdateContent(info)); + } + } + + public synchronized void notifyDirty() { + mDirty = true; + notifyAll(); + } + + public synchronized void terminate() { + mActive = false; + notifyAll(); + } + + private MediaItem findCurrentMediaItem(UpdateInfo info) { + ArrayList<MediaItem> items = info.items; + int index = info.indexHint - info.contentStart; + return index < 0 || index >= items.size() ? null : items.get(index); + } + + private int findIndexOfTarget(UpdateInfo info) { + if (info.target == null) return info.indexHint; + ArrayList<MediaItem> items = info.items; + + // First, try to find the item in the data just loaded + if (items != null) { + for (int i = 0, n = items.size(); i < n; ++i) { + if (items.get(i).getPath() == info.target) return i + info.contentStart; + } + } + + // Not found, find it in mSource. + return mSource.getIndexOfItem(info.target, info.indexHint); + } + } +} diff --git a/src/com/android/gallery3d/app/PhotoPage.java b/src/com/android/gallery3d/app/PhotoPage.java new file mode 100644 index 000000000..f28eb221d --- /dev/null +++ b/src/com/android/gallery3d/app/PhotoPage.java @@ -0,0 +1,581 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.R; +import com.android.gallery3d.data.DataManager; +import com.android.gallery3d.data.MediaDetails; +import com.android.gallery3d.data.MediaItem; +import com.android.gallery3d.data.MediaObject; +import com.android.gallery3d.data.MediaSet; +import com.android.gallery3d.data.MtpDevice; +import com.android.gallery3d.data.Path; +import com.android.gallery3d.picasasource.PicasaSource; +import com.android.gallery3d.ui.DetailsWindow; +import com.android.gallery3d.ui.DetailsWindow.CloseListener; +import com.android.gallery3d.ui.DetailsWindow.DetailsSource; +import com.android.gallery3d.ui.FilmStripView; +import com.android.gallery3d.ui.GLCanvas; +import com.android.gallery3d.ui.GLView; +import com.android.gallery3d.ui.ImportCompleteListener; +import com.android.gallery3d.ui.MenuExecutor; +import com.android.gallery3d.ui.PhotoView; +import com.android.gallery3d.ui.PositionRepository; +import com.android.gallery3d.ui.PositionRepository.Position; +import com.android.gallery3d.ui.SelectionManager; +import com.android.gallery3d.ui.SynchronizedHandler; +import com.android.gallery3d.ui.UserInteractionListener; +import com.android.gallery3d.util.GalleryUtils; + +import android.app.ActionBar; +import android.app.ActionBar.OnMenuVisibilityListener; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.View.MeasureSpec; +import android.view.WindowManager; +import android.widget.ShareActionProvider; +import android.widget.Toast; + +public class PhotoPage extends ActivityState + implements PhotoView.PhotoTapListener, FilmStripView.Listener, + UserInteractionListener { + private static final String TAG = "PhotoPage"; + + private static final int MSG_HIDE_BARS = 1; + private static final int HIDE_BARS_TIMEOUT = 3500; + + private static final int REQUEST_SLIDESHOW = 1; + private static final int REQUEST_CROP = 2; + private static final int REQUEST_CROP_PICASA = 3; + + public static final String KEY_MEDIA_SET_PATH = "media-set-path"; + public static final String KEY_MEDIA_ITEM_PATH = "media-item-path"; + public static final String KEY_INDEX_HINT = "index-hint"; + + private GalleryApp mApplication; + private SelectionManager mSelectionManager; + + private PhotoView mPhotoView; + private PhotoPage.Model mModel; + private FilmStripView mFilmStripView; + private DetailsWindow mDetailsWindow; + private boolean mShowDetails; + + // mMediaSet could be null if there is no KEY_MEDIA_SET_PATH supplied. + // E.g., viewing a photo in gmail attachment + private MediaSet mMediaSet; + private Menu mMenu; + + private Intent mResultIntent = new Intent(); + private int mCurrentIndex = 0; + private Handler mHandler; + private boolean mShowBars; + private ActionBar mActionBar; + private MyMenuVisibilityListener mMenuVisibilityListener; + private boolean mIsMenuVisible; + private boolean mIsInteracting; + private MediaItem mCurrentPhoto = null; + private MenuExecutor mMenuExecutor; + private boolean mIsActive; + private ShareActionProvider mShareActionProvider; + + public static interface Model extends PhotoView.Model { + public void resume(); + public void pause(); + public boolean isEmpty(); + public MediaItem getCurrentMediaItem(); + public int getCurrentIndex(); + public void setCurrentPhoto(Path path, int indexHint); + } + + private class MyMenuVisibilityListener implements OnMenuVisibilityListener { + public void onMenuVisibilityChanged(boolean isVisible) { + mIsMenuVisible = isVisible; + refreshHidingMessage(); + } + } + + private GLView mRootPane = new GLView() { + + @Override + protected void renderBackground(GLCanvas view) { + view.clearBuffer(); + } + + @Override + protected void onLayout( + boolean changed, int left, int top, int right, int bottom) { + mPhotoView.layout(0, 0, right - left, bottom - top); + PositionRepository.getInstance(mActivity).setOffset(0, 0); + int filmStripHeight = 0; + if (mFilmStripView != null) { + mFilmStripView.measure( + MeasureSpec.makeMeasureSpec(right - left, MeasureSpec.EXACTLY), + MeasureSpec.UNSPECIFIED); + filmStripHeight = mFilmStripView.getMeasuredHeight(); + mFilmStripView.layout(0, bottom - top - filmStripHeight, + right - left, bottom - top); + } + if (mShowDetails) { + mDetailsWindow.measure( + MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); + int width = mDetailsWindow.getMeasuredWidth(); + int viewTop = GalleryActionBar.getHeight((Activity) mActivity); + mDetailsWindow.layout( + 0, viewTop, width, bottom - top - filmStripHeight); + } + } + }; + + @Override + public void onCreate(Bundle data, Bundle restoreState) { + mActionBar = ((Activity) mActivity).getActionBar(); + mSelectionManager = new SelectionManager(mActivity, false); + mMenuExecutor = new MenuExecutor(mActivity, mSelectionManager); + + mPhotoView = new PhotoView(mActivity); + mPhotoView.setPhotoTapListener(this); + mRootPane.addComponent(mPhotoView); + mApplication = (GalleryApp)((Activity) mActivity).getApplication(); + + String setPathString = data.getString(KEY_MEDIA_SET_PATH); + Path itemPath = Path.fromString(data.getString(KEY_MEDIA_ITEM_PATH)); + + if (setPathString != null) { + mMediaSet = mActivity.getDataManager().getMediaSet(setPathString); + mCurrentIndex = data.getInt(KEY_INDEX_HINT, 0); + mMediaSet = (MediaSet) + mActivity.getDataManager().getMediaObject(setPathString); + if (mMediaSet == null) { + Log.w(TAG, "failed to restore " + setPathString); + } + PhotoDataAdapter pda = new PhotoDataAdapter( + mActivity, mPhotoView, mMediaSet, itemPath, mCurrentIndex); + mModel = pda; + mPhotoView.setModel(mModel); + + Config.PhotoPage config = Config.PhotoPage.get((Context) mActivity); + + mFilmStripView = new FilmStripView(mActivity, mMediaSet, + config.filmstripTopMargin, config.filmstripMidMargin, config.filmstripBottomMargin, + config.filmstripContentSize, config.filmstripThumbSize, config.filmstripBarSize, + config.filmstripGripSize, config.filmstripGripWidth); + mRootPane.addComponent(mFilmStripView); + mFilmStripView.setListener(this); + mFilmStripView.setUserInteractionListener(this); + mFilmStripView.setFocusIndex(mCurrentIndex); + mFilmStripView.setStartIndex(mCurrentIndex); + + mResultIntent.putExtra(KEY_INDEX_HINT, mCurrentIndex); + setStateResult(Activity.RESULT_OK, mResultIntent); + + pda.setDataListener(new PhotoDataAdapter.DataListener() { + + public void onPhotoChanged(int index, Path item) { + mFilmStripView.setFocusIndex(index); + mCurrentIndex = index; + mResultIntent.putExtra(KEY_INDEX_HINT, index); + if (item != null) { + mResultIntent.putExtra(KEY_MEDIA_ITEM_PATH, item.toString()); + MediaItem photo = mModel.getCurrentMediaItem(); + if (photo != null) updateCurrentPhoto(photo); + } else { + mResultIntent.removeExtra(KEY_MEDIA_ITEM_PATH); + } + setStateResult(Activity.RESULT_OK, mResultIntent); + } + + @Override + public void onLoadingFinished() { + GalleryUtils.setSpinnerVisibility((Activity) mActivity, false); + if (!mModel.isEmpty()) { + MediaItem photo = mModel.getCurrentMediaItem(); + if (photo != null) updateCurrentPhoto(photo); + } else if (mIsActive) { + mActivity.getStateManager().finishState(PhotoPage.this); + } + } + + + @Override + public void onLoadingStarted() { + GalleryUtils.setSpinnerVisibility((Activity) mActivity, true); + } + }); + } else { + // Get default media set by the URI + MediaItem mediaItem = (MediaItem) + mActivity.getDataManager().getMediaObject(itemPath); + mModel = new SinglePhotoDataAdapter(mActivity, mPhotoView, mediaItem); + mPhotoView.setModel(mModel); + updateCurrentPhoto(mediaItem); + } + mHandler = new SynchronizedHandler(mActivity.getGLRoot()) { + @Override + public void handleMessage(Message message) { + switch (message.what) { + case MSG_HIDE_BARS: { + hideBars(); + break; + } + default: throw new AssertionError(message.what); + } + } + }; + + // start the opening animation + mPhotoView.setOpenedItem(itemPath); + } + + private void updateCurrentPhoto(MediaItem photo) { + if (mCurrentPhoto == photo) return; + mCurrentPhoto = photo; + if (mCurrentPhoto == null) return; + updateMenuOperations(); + if (mShowDetails) { + mDetailsWindow.reloadDetails(mModel.getCurrentIndex()); + } + String title = photo.getName(); + if (title != null) mActionBar.setTitle(title); + mPhotoView.showVideoPlayIcon(photo.getMediaType() + == MediaObject.MEDIA_TYPE_VIDEO); + + // If we have an ActionBar then we update the share intent + if (mShareActionProvider != null) { + Path path = photo.getPath(); + DataManager manager = mActivity.getDataManager(); + int type = manager.getMediaType(path); + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType(MenuExecutor.getMimeType(type)); + intent.putExtra(Intent.EXTRA_STREAM, manager.getContentUri(path)); + mShareActionProvider.setShareIntent(intent); + } + } + + private void updateMenuOperations() { + if (mCurrentPhoto == null || mMenu == null) return; + int supportedOperations = mCurrentPhoto.getSupportedOperations(); + if (!GalleryUtils.isEditorAvailable((Context) mActivity, "image/*")) { + supportedOperations &= ~MediaObject.SUPPORT_EDIT; + } + MenuExecutor.updateMenuOperation(mMenu, supportedOperations); + } + + private void showBars() { + if (mShowBars) return; + mShowBars = true; + mActionBar.show(); + WindowManager.LayoutParams params = ((Activity) mActivity).getWindow().getAttributes(); + params.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE; + ((Activity) mActivity).getWindow().setAttributes(params); + if (mFilmStripView != null) { + mFilmStripView.show(); + } + } + + private void hideBars() { + if (!mShowBars) return; + mShowBars = false; + mActionBar.hide(); + WindowManager.LayoutParams params = ((Activity) mActivity).getWindow().getAttributes(); + params.systemUiVisibility = View. SYSTEM_UI_FLAG_LOW_PROFILE; + ((Activity) mActivity).getWindow().setAttributes(params); + if (mFilmStripView != null) { + mFilmStripView.hide(); + } + } + + private void refreshHidingMessage() { + mHandler.removeMessages(MSG_HIDE_BARS); + if (!mIsMenuVisible && !mIsInteracting) { + mHandler.sendEmptyMessageDelayed(MSG_HIDE_BARS, HIDE_BARS_TIMEOUT); + } + } + + public void onUserInteraction() { + showBars(); + refreshHidingMessage(); + } + + public void onUserInteractionTap() { + if (mShowBars) { + hideBars(); + mHandler.removeMessages(MSG_HIDE_BARS); + } else { + showBars(); + refreshHidingMessage(); + } + } + + public void onUserInteractionBegin() { + showBars(); + mIsInteracting = true; + refreshHidingMessage(); + } + + public void onUserInteractionEnd() { + mIsInteracting = false; + refreshHidingMessage(); + } + + @Override + protected void onBackPressed() { + if (mShowDetails) { + hideDetails(); + } else { + PositionRepository repository = PositionRepository.getInstance(mActivity); + repository.clear(); + if (mCurrentPhoto != null) { + Position position = new Position(); + position.x = mRootPane.getWidth() / 2; + position.y = mRootPane.getHeight() / 2; + position.z = -1000; + repository.putPosition( + Long.valueOf(System.identityHashCode(mCurrentPhoto.getPath())), + position); + } + super.onBackPressed(); + } + } + + @Override + protected boolean onCreateActionBar(Menu menu) { + MenuInflater inflater = ((Activity) mActivity).getMenuInflater(); + inflater.inflate(R.menu.photo, menu); + menu.findItem(R.id.action_slideshow).setVisible( + mMediaSet != null && !(mMediaSet instanceof MtpDevice)); + mShareActionProvider = GalleryActionBar.initializeShareActionProvider(menu); + mMenu = menu; + mShowBars = true; + updateMenuOperations(); + return true; + } + + @Override + protected boolean onItemSelected(MenuItem item) { + MediaItem current = mModel.getCurrentMediaItem(); + + if (current == null) { + // item is not ready, ignore + return true; + } + + int currentIndex = mModel.getCurrentIndex(); + Path path = current.getPath(); + + DataManager manager = mActivity.getDataManager(); + int action = item.getItemId(); + switch (action) { + case R.id.action_slideshow: { + Bundle data = new Bundle(); + data.putString(SlideshowPage.KEY_SET_PATH, + mMediaSet.getPath().toString()); + data.putInt(SlideshowPage.KEY_PHOTO_INDEX, currentIndex); + data.putBoolean(SlideshowPage.KEY_REPEAT, true); + mActivity.getStateManager().startStateForResult( + SlideshowPage.class, REQUEST_SLIDESHOW, data); + return true; + } + case R.id.action_crop: { + Activity activity = (Activity) mActivity; + Intent intent = new Intent(CropImage.CROP_ACTION); + intent.setClass(activity, CropImage.class); + intent.setData(manager.getContentUri(path)); + activity.startActivityForResult(intent, PicasaSource.isPicasaImage(current) + ? REQUEST_CROP_PICASA + : REQUEST_CROP); + return true; + } + case R.id.action_details: { + if (mShowDetails) { + hideDetails(); + } else { + showDetails(currentIndex); + } + return true; + } + case R.id.action_setas: + case R.id.action_confirm_delete: + case R.id.action_rotate_ccw: + case R.id.action_rotate_cw: + case R.id.action_show_on_map: + case R.id.action_edit: + mSelectionManager.deSelectAll(); + mSelectionManager.toggle(path); + mMenuExecutor.onMenuClicked(item, null); + return true; + case R.id.action_import: + mSelectionManager.deSelectAll(); + mSelectionManager.toggle(path); + mMenuExecutor.onMenuClicked(item, + new ImportCompleteListener(mActivity)); + return true; + default : + return false; + } + } + + private void hideDetails() { + mShowDetails = false; + mDetailsWindow.hide(); + } + + private void showDetails(int index) { + mShowDetails = true; + if (mDetailsWindow == null) { + mDetailsWindow = new DetailsWindow(mActivity, new MyDetailsSource()); + mDetailsWindow.setCloseListener(new CloseListener() { + public void onClose() { + hideDetails(); + } + }); + mRootPane.addComponent(mDetailsWindow); + } + mDetailsWindow.reloadDetails(index); + mDetailsWindow.show(); + } + + public void onSingleTapUp(int x, int y) { + MediaItem item = mModel.getCurrentMediaItem(); + if (item == null) { + // item is not ready, ignore + return; + } + + boolean playVideo = + (item.getSupportedOperations() & MediaItem.SUPPORT_PLAY) != 0; + + if (playVideo) { + // determine if the point is at center (1/6) of the photo view. + // (The position of the "play" icon is at center (1/6) of the photo) + int w = mPhotoView.getWidth(); + int h = mPhotoView.getHeight(); + playVideo = (Math.abs(x - w / 2) * 12 <= w) + && (Math.abs(y - h / 2) * 12 <= h); + } + + if (playVideo) { + playVideo((Activity) mActivity, item.getPlayUri(), item.getName()); + } else { + onUserInteractionTap(); + } + } + + public static void playVideo(Activity activity, Uri uri, String title) { + try { + Intent intent = new Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, "video/*"); + intent.putExtra(Intent.EXTRA_TITLE, title); + activity.startActivity(intent); + } catch (ActivityNotFoundException e) { + Toast.makeText(activity, activity.getString(R.string.video_err), + Toast.LENGTH_SHORT).show(); + } + } + + // Called by FileStripView + public void onSlotSelected(int slotIndex) { + ((PhotoDataAdapter) mModel).jumpTo(slotIndex); + } + + @Override + protected void onStateResult(int requestCode, int resultCode, Intent data) { + switch (requestCode) { + case REQUEST_CROP: + if (resultCode == Activity.RESULT_OK) { + if (data == null) break; + Path path = mApplication + .getDataManager().findPathByUri(data.getData()); + if (path != null) { + mModel.setCurrentPhoto(path, mCurrentIndex); + } + } + break; + case REQUEST_CROP_PICASA: { + int message = resultCode == Activity.RESULT_OK + ? R.string.crop_saved + : R.string.crop_not_saved; + Toast.makeText(mActivity.getAndroidContext(), + message, Toast.LENGTH_SHORT).show(); + break; + } + case REQUEST_SLIDESHOW: { + if (data == null) break; + String path = data.getStringExtra(SlideshowPage.KEY_ITEM_PATH); + int index = data.getIntExtra(SlideshowPage.KEY_PHOTO_INDEX, 0); + if (path != null) { + mModel.setCurrentPhoto(Path.fromString(path), index); + } + } + } + } + + @Override + public void onPause() { + super.onPause(); + mIsActive = false; + if (mFilmStripView != null) { + mFilmStripView.pause(); + } + if (mDetailsWindow != null) { + mDetailsWindow.pause(); + } + mPhotoView.pause(); + mModel.pause(); + mHandler.removeMessages(MSG_HIDE_BARS); + mActionBar.removeOnMenuVisibilityListener(mMenuVisibilityListener); + } + + @Override + protected void onResume() { + super.onResume(); + mIsActive = true; + setContentPane(mRootPane); + mModel.resume(); + mPhotoView.resume(); + if (mFilmStripView != null) { + mFilmStripView.resume(); + } + if (mMenuVisibilityListener == null) { + mMenuVisibilityListener = new MyMenuVisibilityListener(); + } + mActionBar.addOnMenuVisibilityListener(mMenuVisibilityListener); + onUserInteraction(); + } + + private class MyDetailsSource implements DetailsSource { + public MediaDetails getDetails() { + return mModel.getCurrentMediaItem().getDetails(); + } + public int size() { + return mMediaSet != null ? mMediaSet.getMediaItemCount() : 1; + } + public int findIndex(int indexHint) { + return indexHint; + } + } +} diff --git a/src/com/android/gallery3d/app/SinglePhotoDataAdapter.java b/src/com/android/gallery3d/app/SinglePhotoDataAdapter.java new file mode 100644 index 000000000..11e0013cc --- /dev/null +++ b/src/com/android/gallery3d/app/SinglePhotoDataAdapter.java @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.common.BitmapUtils; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.data.MediaItem; +import com.android.gallery3d.data.Path; +import com.android.gallery3d.ui.PhotoView; +import com.android.gallery3d.ui.PhotoView.ImageData; +import com.android.gallery3d.ui.SynchronizedHandler; +import com.android.gallery3d.ui.TileImageViewAdapter; +import com.android.gallery3d.util.Future; +import com.android.gallery3d.util.FutureListener; +import com.android.gallery3d.util.ThreadPool; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.BitmapRegionDecoder; +import android.graphics.Rect; +import android.os.Handler; +import android.os.Message; + +public class SinglePhotoDataAdapter extends TileImageViewAdapter + implements PhotoPage.Model { + + private static final String TAG = "SinglePhotoDataAdapter"; + private static final int SIZE_BACKUP = 640; + private static final int MSG_UPDATE_IMAGE = 1; + + private MediaItem mItem; + private boolean mHasFullImage; + private Future<?> mTask; + private BitmapRegionDecoder mDecoder; + private Bitmap mBackup; + private Handler mHandler; + + private PhotoView mPhotoView; + private ThreadPool mThreadPool; + + public SinglePhotoDataAdapter( + GalleryActivity activity, PhotoView view, MediaItem item) { + mItem = Utils.checkNotNull(item); + mHasFullImage = (item.getSupportedOperations() & + MediaItem.SUPPORT_FULL_IMAGE) != 0; + mPhotoView = Utils.checkNotNull(view); + mHandler = new SynchronizedHandler(activity.getGLRoot()) { + @Override + @SuppressWarnings("unchecked") + public void handleMessage(Message message) { + Utils.assertTrue(message.what == MSG_UPDATE_IMAGE); + if (mHasFullImage) { + onDecodeLargeComplete((Future<BitmapRegionDecoder>) + message.obj); + } else { + onDecodeThumbComplete((Future<Bitmap>) message.obj); + } + } + }; + mThreadPool = activity.getThreadPool(); + } + + private FutureListener<BitmapRegionDecoder> mLargeListener = + new FutureListener<BitmapRegionDecoder>() { + public void onFutureDone(Future<BitmapRegionDecoder> future) { + mHandler.sendMessage( + mHandler.obtainMessage(MSG_UPDATE_IMAGE, future)); + } + }; + + private FutureListener<Bitmap> mThumbListener = + new FutureListener<Bitmap>() { + public void onFutureDone(Future<Bitmap> future) { + mHandler.sendMessage( + mHandler.obtainMessage(MSG_UPDATE_IMAGE, future)); + } + }; + + public boolean isEmpty() { + return false; + } + + public int getImageRotation() { + return mItem.getRotation(); + } + + private void onDecodeLargeComplete(Future<BitmapRegionDecoder> future) { + try { + mDecoder = future.get(); + if (mDecoder == null) return; + int width = mDecoder.getWidth(); + int height = mDecoder.getHeight(); + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = BitmapUtils.computeSampleSizeLarger( + width, height, SIZE_BACKUP); + mBackup = mDecoder.decodeRegion( + new Rect(0, 0, width, height), options); + setBackupImage(mBackup, width, height); + setRegionDecoder(mDecoder); + mPhotoView.notifyImageInvalidated(0); + } catch (Throwable t) { + Log.w(TAG, "fail to decode large", t); + } + } + + private void onDecodeThumbComplete(Future<Bitmap> future) { + try { + mBackup = future.get(); + if (mBackup == null) return; + setBackupImage(mBackup, mBackup.getWidth(), mBackup.getHeight()); + mPhotoView.notifyOnNewImage(); + mPhotoView.notifyImageInvalidated(0); // the current image + } catch (Throwable t) { + Log.w(TAG, "fail to decode thumb", t); + } + } + + public void resume() { + if (mTask == null) { + if (mHasFullImage) { + mTask = mThreadPool.submit( + mItem.requestLargeImage(), mLargeListener); + } else { + mTask = mThreadPool.submit( + mItem.requestImage(MediaItem.TYPE_THUMBNAIL), + mThumbListener); + } + } + } + + public void pause() { + Future<?> task = mTask; + task.cancel(); + task.waitDone(); + if (task.get() == null) { + mTask = null; + } + } + + public ImageData getNextImage() { + return null; + } + + public ImageData getPreviousImage() { + return null; + } + + public void next() { + throw new UnsupportedOperationException(); + } + + public void previous() { + throw new UnsupportedOperationException(); + } + + public MediaItem getCurrentMediaItem() { + return mItem; + } + + public int getCurrentIndex() { + return 0; + } + + public void setCurrentPhoto(Path path, int indexHint) { + // ignore + } +} diff --git a/src/com/android/gallery3d/app/SlideshowDataAdapter.java b/src/com/android/gallery3d/app/SlideshowDataAdapter.java new file mode 100644 index 000000000..6f9b98e8e --- /dev/null +++ b/src/com/android/gallery3d/app/SlideshowDataAdapter.java @@ -0,0 +1,187 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.app.SlideshowPage.Slide; +import com.android.gallery3d.data.ContentListener; +import com.android.gallery3d.data.MediaItem; +import com.android.gallery3d.data.MediaObject; +import com.android.gallery3d.util.Future; +import com.android.gallery3d.util.FutureListener; +import com.android.gallery3d.util.ThreadPool; +import com.android.gallery3d.util.ThreadPool.Job; +import com.android.gallery3d.util.ThreadPool.JobContext; + +import android.graphics.Bitmap; + +import java.util.LinkedList; +import java.util.concurrent.atomic.AtomicBoolean; + +public class SlideshowDataAdapter implements SlideshowPage.Model { + @SuppressWarnings("unused") + private static final String TAG = "SlideshowDataAdapter"; + + private static final int IMAGE_QUEUE_CAPACITY = 3; + + public interface SlideshowSource { + public void addContentListener(ContentListener listener); + public void removeContentListener(ContentListener listener); + public long reload(); + public MediaItem getMediaItem(int index); + } + + private final SlideshowSource mSource; + + private int mLoadIndex = 0; + private int mNextOutput = 0; + private boolean mIsActive = false; + private boolean mNeedReset; + private boolean mDataReady; + + private final LinkedList<Slide> mImageQueue = new LinkedList<Slide>(); + + private Future<Void> mReloadTask; + private final ThreadPool mThreadPool; + + private long mDataVersion = MediaObject.INVALID_DATA_VERSION; + private final AtomicBoolean mNeedReload = new AtomicBoolean(false); + private final SourceListener mSourceListener = new SourceListener(); + + public SlideshowDataAdapter(GalleryContext context, SlideshowSource source, int index) { + mSource = source; + mLoadIndex = index; + mNextOutput = index; + mThreadPool = context.getThreadPool(); + } + + public MediaItem loadItem() { + if (mNeedReload.compareAndSet(true, false)) { + long v = mSource.reload(); + if (v != mDataVersion) { + mDataVersion = v; + mNeedReset = true; + return null; + } + } + return mSource.getMediaItem(mLoadIndex); + } + + private class ReloadTask implements Job<Void> { + public Void run(JobContext jc) { + while (true) { + synchronized (SlideshowDataAdapter.this) { + while (mIsActive && (!mDataReady + || mImageQueue.size() >= IMAGE_QUEUE_CAPACITY)) { + try { + SlideshowDataAdapter.this.wait(); + } catch (InterruptedException ex) { + // ignored. + } + continue; + } + } + if (!mIsActive) return null; + mNeedReset = false; + + MediaItem item = loadItem(); + + if (mNeedReset) { + synchronized (SlideshowDataAdapter.this) { + mImageQueue.clear(); + mLoadIndex = mNextOutput; + } + continue; + } + + if (item == null) { + synchronized (SlideshowDataAdapter.this) { + if (!mNeedReload.get()) mDataReady = false; + SlideshowDataAdapter.this.notifyAll(); + } + continue; + } + + Bitmap bitmap = item + .requestImage(MediaItem.TYPE_THUMBNAIL) + .run(jc); + + if (bitmap != null) { + synchronized (SlideshowDataAdapter.this) { + mImageQueue.addLast( + new Slide(item, mLoadIndex, bitmap)); + if (mImageQueue.size() == 1) { + SlideshowDataAdapter.this.notifyAll(); + } + } + } + ++mLoadIndex; + } + } + } + + private class SourceListener implements ContentListener { + public void onContentDirty() { + synchronized (SlideshowDataAdapter.this) { + mNeedReload.set(true); + mDataReady = true; + SlideshowDataAdapter.this.notifyAll(); + } + } + } + + private synchronized Slide innerNextBitmap() { + while (mIsActive && mDataReady && mImageQueue.isEmpty()) { + try { + wait(); + } catch (InterruptedException t) { + throw new AssertionError(); + } + } + if (mImageQueue.isEmpty()) return null; + mNextOutput++; + this.notifyAll(); + return mImageQueue.removeFirst(); + } + + public Future<Slide> nextSlide(FutureListener<Slide> listener) { + return mThreadPool.submit(new Job<Slide>() { + public Slide run(JobContext jc) { + jc.setMode(ThreadPool.MODE_NONE); + return innerNextBitmap(); + } + }, listener); + } + + public void pause() { + synchronized (this) { + mIsActive = false; + notifyAll(); + } + mSource.removeContentListener(mSourceListener); + mReloadTask.cancel(); + mReloadTask.waitDone(); + mReloadTask = null; + } + + public synchronized void resume() { + mIsActive = true; + mSource.addContentListener(mSourceListener); + mNeedReload.set(true); + mDataReady = true; + mReloadTask = mThreadPool.submit(new ReloadTask()); + } +} diff --git a/src/com/android/gallery3d/app/SlideshowDream.java b/src/com/android/gallery3d/app/SlideshowDream.java new file mode 100644 index 000000000..f4abe86ab --- /dev/null +++ b/src/com/android/gallery3d/app/SlideshowDream.java @@ -0,0 +1,28 @@ +package com.android.gallery3d.app; + +import android.app.Activity; +import android.content.Intent; +import android.support.v13.dreams.BasicDream; +import android.graphics.Canvas; +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.view.View; +import android.widget.ImageView; +import android.widget.ViewFlipper; + +public class SlideshowDream extends BasicDream { + @Override + public void onCreate(Bundle bndl) { + super.onCreate(bndl); + Intent i = new Intent( + Intent.ACTION_VIEW, + android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI) +// Uri.fromFile(Environment.getExternalStoragePublicDirectory( +// Environment.DIRECTORY_PICTURES))) + .putExtra(Gallery.EXTRA_SLIDESHOW, true) + .setFlags(getIntent().getFlags()); + startActivity(i); + finish(); + } +} diff --git a/src/com/android/gallery3d/app/SlideshowPage.java b/src/com/android/gallery3d/app/SlideshowPage.java new file mode 100644 index 000000000..cdf9308ec --- /dev/null +++ b/src/com/android/gallery3d/app/SlideshowPage.java @@ -0,0 +1,338 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.data.ContentListener; +import com.android.gallery3d.data.MediaItem; +import com.android.gallery3d.data.MediaObject; +import com.android.gallery3d.data.MediaSet; +import com.android.gallery3d.ui.GLCanvas; +import com.android.gallery3d.ui.GLView; +import com.android.gallery3d.ui.SlideshowView; +import com.android.gallery3d.ui.SynchronizedHandler; +import com.android.gallery3d.util.Future; +import com.android.gallery3d.util.FutureListener; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.os.PowerManager; +import android.os.PowerManager.WakeLock; +import android.view.MotionEvent; + +import java.util.ArrayList; +import java.util.Random; + +public class SlideshowPage extends ActivityState { + private static final String TAG = "SlideshowPage"; + + public static final String KEY_SET_PATH = "media-set-path"; + public static final String KEY_ITEM_PATH = "media-item-path"; + public static final String KEY_PHOTO_INDEX = "photo-index"; + public static final String KEY_RANDOM_ORDER = "random-order"; + public static final String KEY_REPEAT = "repeat"; + + private static final long SLIDESHOW_DELAY = 3000; // 3 seconds + + private static final int MSG_LOAD_NEXT_BITMAP = 1; + private static final int MSG_SHOW_PENDING_BITMAP = 2; + + public static interface Model { + public void pause(); + public void resume(); + public Future<Slide> nextSlide(FutureListener<Slide> listener); + } + + public static class Slide { + public Bitmap bitmap; + public MediaItem item; + public int index; + + public Slide(MediaItem item, int index, Bitmap bitmap) { + this.bitmap = bitmap; + this.item = item; + this.index = index; + } + } + + private Handler mHandler; + private Model mModel; + private SlideshowView mSlideshowView; + + private Slide mPendingSlide = null; + private boolean mIsActive = false; + private WakeLock mWakeLock; + private Intent mResultIntent = new Intent(); + + private GLView mRootPane = new GLView() { + @Override + protected void onLayout( + boolean changed, int left, int top, int right, int bottom) { + mSlideshowView.layout(0, 0, right - left, bottom - top); + } + + @Override + protected boolean onTouch(MotionEvent event) { + if (event.getAction() == MotionEvent.ACTION_UP) { + onBackPressed(); + } + return true; + } + + @Override + protected void renderBackground(GLCanvas canvas) { + canvas.clearBuffer(); + } + }; + + @Override + public void onCreate(Bundle data, Bundle restoreState) { + mFlags |= (FLAG_HIDE_ACTION_BAR | FLAG_HIDE_STATUS_BAR); + + PowerManager pm = (PowerManager) mActivity.getAndroidContext() + .getSystemService(Context.POWER_SERVICE); + mWakeLock = pm.newWakeLock(PowerManager.SCREEN_BRIGHT_WAKE_LOCK + | PowerManager.ON_AFTER_RELEASE, TAG); + + mHandler = new SynchronizedHandler(mActivity.getGLRoot()) { + @Override + public void handleMessage(Message message) { + switch (message.what) { + case MSG_SHOW_PENDING_BITMAP: + showPendingBitmap(); + break; + case MSG_LOAD_NEXT_BITMAP: + loadNextBitmap(); + break; + default: throw new AssertionError(); + } + } + }; + initializeViews(); + initializeData(data); + } + + private void loadNextBitmap() { + mModel.nextSlide(new FutureListener<Slide>() { + public void onFutureDone(Future<Slide> future) { + mPendingSlide = future.get(); + mHandler.sendEmptyMessage(MSG_SHOW_PENDING_BITMAP); + } + }); + } + + private void showPendingBitmap() { + // mPendingBitmap could be null, if + // 1.) there is no more items + // 2.) mModel is paused + Slide slide = mPendingSlide; + if (slide == null) { + if (mIsActive) { + mActivity.getStateManager().finishState(SlideshowPage.this); + } + return; + } + + mSlideshowView.next(slide.bitmap, slide.item.getRotation()); + + setStateResult(Activity.RESULT_OK, mResultIntent + .putExtra(KEY_ITEM_PATH, slide.item.getPath().toString()) + .putExtra(KEY_PHOTO_INDEX, slide.index)); + mHandler.sendEmptyMessageDelayed(MSG_LOAD_NEXT_BITMAP, + SLIDESHOW_DELAY); + } + + @Override + public void onPause() { + super.onPause(); + mWakeLock.release(); + mIsActive = false; + mModel.pause(); + mSlideshowView.release(); + + mHandler.removeMessages(MSG_LOAD_NEXT_BITMAP); + mHandler.removeMessages(MSG_SHOW_PENDING_BITMAP); + } + + @Override + public void onResume() { + super.onResume(); + mWakeLock.acquire(); + mIsActive = true; + mModel.resume(); + + if (mPendingSlide != null) { + showPendingBitmap(); + } else { + loadNextBitmap(); + } + } + + private void initializeData(Bundle data) { + String mediaPath = data.getString(KEY_SET_PATH); + boolean random = data.getBoolean(KEY_RANDOM_ORDER, false); + MediaSet mediaSet = mActivity.getDataManager().getMediaSet(mediaPath); + + if (random) { + boolean repeat = data.getBoolean(KEY_REPEAT); + mModel = new SlideshowDataAdapter( + mActivity, new ShuffleSource(mediaSet, repeat), 0); + setStateResult(Activity.RESULT_OK, + mResultIntent.putExtra(KEY_PHOTO_INDEX, 0)); + } else { + int index = data.getInt(KEY_PHOTO_INDEX); + boolean repeat = data.getBoolean(KEY_REPEAT); + mModel = new SlideshowDataAdapter(mActivity, + new SequentialSource(mediaSet, repeat), index); + setStateResult(Activity.RESULT_OK, + mResultIntent.putExtra(KEY_PHOTO_INDEX, index)); + } + } + + private void initializeViews() { + mSlideshowView = new SlideshowView(); + mRootPane.addComponent(mSlideshowView); + setContentPane(mRootPane); + } + + private static MediaItem findMediaItem(MediaSet mediaSet, int index) { + for (int i = 0, n = mediaSet.getSubMediaSetCount(); i < n; ++i) { + MediaSet subset = mediaSet.getSubMediaSet(i); + int count = subset.getTotalMediaItemCount(); + if (index < count) { + return findMediaItem(subset, index); + } + index -= count; + } + ArrayList<MediaItem> list = mediaSet.getMediaItem(index, 1); + return list.isEmpty() ? null : list.get(0); + } + + private static class ShuffleSource implements SlideshowDataAdapter.SlideshowSource { + private static final int RETRY_COUNT = 5; + private final MediaSet mMediaSet; + private final Random mRandom = new Random(); + private int mOrder[] = new int[0]; + private boolean mRepeat; + private long mSourceVersion = MediaSet.INVALID_DATA_VERSION; + private int mLastIndex = -1; + + public ShuffleSource(MediaSet mediaSet, boolean repeat) { + mMediaSet = Utils.checkNotNull(mediaSet); + mRepeat = repeat; + } + + public MediaItem getMediaItem(int index) { + if (!mRepeat && index >= mOrder.length) return null; + mLastIndex = mOrder[index % mOrder.length]; + MediaItem item = findMediaItem(mMediaSet, mLastIndex); + for (int i = 0; i < RETRY_COUNT && item == null; ++i) { + Log.w(TAG, "fail to find image: " + mLastIndex); + mLastIndex = mRandom.nextInt(mOrder.length); + item = findMediaItem(mMediaSet, mLastIndex); + } + return item; + } + + public long reload() { + long version = mMediaSet.reload(); + if (version != mSourceVersion) { + mSourceVersion = version; + int count = mMediaSet.getTotalMediaItemCount(); + if (count != mOrder.length) generateOrderArray(count); + } + return version; + } + + private void generateOrderArray(int totalCount) { + if (mOrder.length != totalCount) { + mOrder = new int[totalCount]; + for (int i = 0; i < totalCount; ++i) { + mOrder[i] = i; + } + } + for (int i = totalCount - 1; i > 0; --i) { + Utils.swap(mOrder, i, mRandom.nextInt(i + 1)); + } + if (mOrder[0] == mLastIndex && totalCount > 1) { + Utils.swap(mOrder, 0, mRandom.nextInt(totalCount - 1) + 1); + } + } + + public void addContentListener(ContentListener listener) { + mMediaSet.addContentListener(listener); + } + + public void removeContentListener(ContentListener listener) { + mMediaSet.removeContentListener(listener); + } + } + + private static class SequentialSource implements SlideshowDataAdapter.SlideshowSource { + private static final int DATA_SIZE = 32; + + private ArrayList<MediaItem> mData = new ArrayList<MediaItem>(); + private int mDataStart = 0; + private long mDataVersion = MediaObject.INVALID_DATA_VERSION; + private final MediaSet mMediaSet; + private final boolean mRepeat; + + public SequentialSource(MediaSet mediaSet, boolean repeat) { + mMediaSet = mediaSet; + mRepeat = repeat; + } + + public MediaItem getMediaItem(int index) { + int dataEnd = mDataStart + mData.size(); + + if (mRepeat) { + index = index % mMediaSet.getMediaItemCount(); + } + if (index < mDataStart || index >= dataEnd) { + mData = mMediaSet.getMediaItem(index, DATA_SIZE); + mDataStart = index; + dataEnd = index + mData.size(); + } + + return (index < mDataStart || index >= dataEnd) + ? null + : mData.get(index - mDataStart); + } + + public long reload() { + long version = mMediaSet.reload(); + if (version != mDataVersion) { + mDataVersion = version; + mData.clear(); + } + return mDataVersion; + } + + public void addContentListener(ContentListener listener) { + mMediaSet.addContentListener(listener); + } + + public void removeContentListener(ContentListener listener) { + mMediaSet.removeContentListener(listener); + } + } +} diff --git a/src/com/android/gallery3d/app/StateManager.java b/src/com/android/gallery3d/app/StateManager.java new file mode 100644 index 000000000..b551f693a --- /dev/null +++ b/src/com/android/gallery3d/app/StateManager.java @@ -0,0 +1,277 @@ +/* + * Copyright (C) 2010 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.gallery3d.app; + +import com.android.gallery3d.common.Utils; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.os.Parcelable; +import android.view.Menu; +import android.view.MenuItem; + +import java.util.Stack; + +public class StateManager { + @SuppressWarnings("unused") + private static final String TAG = "StateManager"; + private boolean mIsResumed = false; + + private static final String KEY_MAIN = "activity-state"; + private static final String KEY_DATA = "data"; + private static final String KEY_STATE = "bundle"; + private static final String KEY_CLASS = "class"; + + private GalleryActivity mContext; + private Stack<StateEntry> mStack = new Stack<StateEntry>(); + private ActivityState.ResultEntry mResult; + + public StateManager(GalleryActivity context) { + mContext = context; + } + + public void startState(Class<? extends ActivityState> klass, + Bundle data) { + Log.v(TAG, "startState " + klass); + ActivityState state = null; + try { + state = klass.newInstance(); + } catch (Exception e) { + throw new AssertionError(e); + } + if (!mStack.isEmpty()) { + ActivityState top = getTopState(); + if (mIsResumed) top.onPause(); + } + state.initialize(mContext, data); + + mStack.push(new StateEntry(data, state)); + state.onCreate(data, null); + if (mIsResumed) state.resume(); + } + + public void startStateForResult(Class<? extends ActivityState> klass, + int requestCode, Bundle data) { + Log.v(TAG, "startStateForResult " + klass + ", " + requestCode); + ActivityState state = null; + try { + state = klass.newInstance(); + } catch (Exception e) { + throw new AssertionError(e); + } + state.initialize(mContext, data); + state.mResult = new ActivityState.ResultEntry(); + state.mResult.requestCode = requestCode; + + if (!mStack.isEmpty()) { + ActivityState as = getTopState(); + as.mReceivedResults = state.mResult; + if (mIsResumed) as.onPause(); + } else { + mResult = state.mResult; + } + + mStack.push(new StateEntry(data, state)); + state.onCreate(data, null); + if (mIsResumed) state.resume(); + } + + public boolean createOptionsMenu(Menu menu) { + if (!mStack.isEmpty()) { + ((Activity) mContext).setProgressBarIndeterminateVisibility(false); + return getTopState().onCreateActionBar(menu); + } else { + return false; + } + } + + public void resume() { + if (mIsResumed) return; + mIsResumed = true; + if (!mStack.isEmpty()) getTopState().resume(); + } + + public void pause() { + if (!mIsResumed) return; + mIsResumed = false; + if (!mStack.isEmpty()) getTopState().onPause(); + } + + public void notifyActivityResult(int requestCode, int resultCode, Intent data) { + getTopState().onStateResult(requestCode, resultCode, data); + } + + public int getStateCount() { + return mStack.size(); + } + + public boolean itemSelected(MenuItem item) { + if (!mStack.isEmpty()) { + if (mStack.size() > 1 && item.getItemId() == android.R.id.home) { + getTopState().onBackPressed(); + return true; + } else { + return getTopState().onItemSelected(item); + } + } + return false; + } + + public void onBackPressed() { + if (!mStack.isEmpty()) { + getTopState().onBackPressed(); + } + } + + void finishState(ActivityState state) { + Log.v(TAG, "finishState " + state.getClass()); + if (state != mStack.peek().activityState) { + throw new IllegalArgumentException("The stateview to be finished" + + " is not at the top of the stack: " + state + ", " + + mStack.peek().activityState); + } + + // Remove the top state. + mStack.pop(); + if (mIsResumed) state.onPause(); + mContext.getGLRoot().setContentPane(null); + state.onDestroy(); + + if (mStack.isEmpty()) { + Log.v(TAG, "no more state, finish activity"); + Activity activity = (Activity) mContext.getAndroidContext(); + if (mResult != null) { + activity.setResult(mResult.resultCode, mResult.resultData); + } + activity.finish(); + + // The finish() request is rejected (only happens under Monkey), + // so we start the default page instead. + if (!activity.isFinishing()) { + Log.v(TAG, "finish() failed, start default page"); + ((Gallery) mContext).startDefaultPage(); + } + } else { + // Restore the immediately previous state + ActivityState top = mStack.peek().activityState; + if (mIsResumed) top.resume(); + } + } + + void switchState(ActivityState oldState, + Class<? extends ActivityState> klass, Bundle data) { + Log.v(TAG, "switchState " + oldState + ", " + klass); + if (oldState != mStack.peek().activityState) { + throw new IllegalArgumentException("The stateview to be finished" + + " is not at the top of the stack: " + oldState + ", " + + mStack.peek().activityState); + } + // Remove the top state. + mStack.pop(); + if (mIsResumed) oldState.onPause(); + oldState.onDestroy(); + + // Create new state. + ActivityState state = null; + try { + state = klass.newInstance(); + } catch (Exception e) { + throw new AssertionError(e); + } + state.initialize(mContext, data); + mStack.push(new StateEntry(data, state)); + state.onCreate(data, null); + if (mIsResumed) state.resume(); + } + + public void destroy() { + Log.v(TAG, "destroy"); + while (!mStack.isEmpty()) { + mStack.pop().activityState.onDestroy(); + } + mStack.clear(); + } + + @SuppressWarnings("unchecked") + public void restoreFromState(Bundle inState) { + Log.v(TAG, "restoreFromState"); + Parcelable list[] = inState.getParcelableArray(KEY_MAIN); + + for (Parcelable parcelable : list) { + Bundle bundle = (Bundle) parcelable; + Class<? extends ActivityState> klass = + (Class<? extends ActivityState>) bundle.getSerializable(KEY_CLASS); + + Bundle data = bundle.getBundle(KEY_DATA); + Bundle state = bundle.getBundle(KEY_STATE); + + ActivityState activityState; + try { + Log.v(TAG, "restoreFromState " + klass); + activityState = klass.newInstance(); + } catch (Exception e) { + throw new AssertionError(e); + } + activityState.initialize(mContext, data); + activityState.onCreate(data, state); + mStack.push(new StateEntry(data, activityState)); + } + } + + public void saveState(Bundle outState) { + Log.v(TAG, "saveState"); + Parcelable list[] = new Parcelable[mStack.size()]; + + int i = 0; + for (StateEntry entry : mStack) { + Bundle bundle = new Bundle(); + bundle.putSerializable(KEY_CLASS, entry.activityState.getClass()); + bundle.putBundle(KEY_DATA, entry.data); + Bundle state = new Bundle(); + entry.activityState.onSaveState(state); + bundle.putBundle(KEY_STATE, state); + Log.v(TAG, "saveState " + entry.activityState.getClass()); + list[i++] = bundle; + } + outState.putParcelableArray(KEY_MAIN, list); + } + + public boolean hasStateClass(Class<? extends ActivityState> klass) { + for (StateEntry entry : mStack) { + if (klass.isInstance(entry.activityState)) { + return true; + } + } + return false; + } + + public ActivityState getTopState() { + Utils.assertTrue(!mStack.isEmpty()); + return mStack.peek().activityState; + } + + private static class StateEntry { + public Bundle data; + public ActivityState activityState; + + public StateEntry(Bundle data, ActivityState state) { + this.data = data; + this.activityState = state; + } + } +} diff --git a/src/com/android/gallery3d/app/UsbDeviceActivity.java b/src/com/android/gallery3d/app/UsbDeviceActivity.java new file mode 100644 index 000000000..28bd667e3 --- /dev/null +++ b/src/com/android/gallery3d/app/UsbDeviceActivity.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.gallery3d.app; + + +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; + +/* This Activity does nothing but receive USB_DEVICE_ATTACHED events from the + * USB service and springboards to the main Gallery activity + */ +public final class UsbDeviceActivity extends Activity { + + static final String TAG = "UsbDeviceActivity"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // + Intent intent = new Intent(this, Gallery.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + try { + startActivity(intent); + } catch (ActivityNotFoundException e) { + Log.e(TAG, "unable to start Gallery activity", e); + } + finish(); + } +} diff --git a/src/com/android/gallery3d/app/Wallpaper.java b/src/com/android/gallery3d/app/Wallpaper.java new file mode 100644 index 000000000..07a3d5313 --- /dev/null +++ b/src/com/android/gallery3d/app/Wallpaper.java @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2007 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.gallery3d.app; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.Display; + +/** + * Wallpaper picker for the gallery application. This just redirects to the + * standard pick action. + */ +public class Wallpaper extends Activity { + @SuppressWarnings("unused") + private static final String TAG = "Wallpaper"; + + private static final String IMAGE_TYPE = "image/*"; + private static final String KEY_STATE = "activity-state"; + private static final String KEY_PICKED_ITEM = "picked-item"; + + private static final int STATE_INIT = 0; + private static final int STATE_PHOTO_PICKED = 1; + + private int mState = STATE_INIT; + private Uri mPickedItem; + + @Override + protected void onCreate(Bundle bundle) { + super.onCreate(bundle); + if (bundle != null) { + mState = bundle.getInt(KEY_STATE); + mPickedItem = (Uri) bundle.getParcelable(KEY_PICKED_ITEM); + } + } + + @Override + protected void onSaveInstanceState(Bundle saveState) { + saveState.putInt(KEY_STATE, mState); + if (mPickedItem != null) { + saveState.putParcelable(KEY_PICKED_ITEM, mPickedItem); + } + } + + @SuppressWarnings("fallthrough") + @Override + protected void onResume() { + super.onResume(); + Intent intent = getIntent(); + switch (mState) { + case STATE_INIT: { + mPickedItem = intent.getData(); + if (mPickedItem == null) { + Intent request = new Intent(Intent.ACTION_GET_CONTENT) + .setClass(this, DialogPicker.class) + .setType(IMAGE_TYPE); + startActivityForResult(request, STATE_PHOTO_PICKED); + return; + } + mState = STATE_PHOTO_PICKED; + // fall-through + } + case STATE_PHOTO_PICKED: { + int width = getWallpaperDesiredMinimumWidth(); + int height = getWallpaperDesiredMinimumHeight(); + Display display = getWindowManager().getDefaultDisplay(); + float spotlightX = (float) display.getWidth() / width; + float spotlightY = (float) display.getHeight() / height; + Intent request = new Intent(CropImage.ACTION_CROP) + .setDataAndType(mPickedItem, IMAGE_TYPE) + .addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT) + .putExtra(CropImage.KEY_OUTPUT_X, width) + .putExtra(CropImage.KEY_OUTPUT_Y, height) + .putExtra(CropImage.KEY_ASPECT_X, width) + .putExtra(CropImage.KEY_ASPECT_Y, height) + .putExtra(CropImage.KEY_SPOTLIGHT_X, spotlightX) + .putExtra(CropImage.KEY_SPOTLIGHT_Y, spotlightY) + .putExtra(CropImage.KEY_SCALE, true) + .putExtra(CropImage.KEY_NO_FACE_DETECTION, true) + .putExtra(CropImage.KEY_SET_AS_WALLPAPER, true); + startActivity(request); + finish(); + } + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (resultCode != RESULT_OK) { + setResult(resultCode); + finish(); + return; + } + mState = requestCode; + if (mState == STATE_PHOTO_PICKED) { + mPickedItem = data.getData(); + } + + // onResume() would be called next + } +} diff --git a/src/com/android/gallery3d/data/ChangeNotifier.java b/src/com/android/gallery3d/data/ChangeNotifier.java new file mode 100644 index 000000000..e1e601dd6 --- /dev/null +++ b/src/com/android/gallery3d/data/ChangeNotifier.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.app.GalleryApp; + +import android.net.Uri; + +import java.util.concurrent.atomic.AtomicBoolean; + +// This handles change notification for media sets. +public class ChangeNotifier { + + private MediaSet mMediaSet; + private AtomicBoolean mContentDirty = new AtomicBoolean(true); + + public ChangeNotifier(MediaSet set, Uri uri, GalleryApp application) { + mMediaSet = set; + application.getDataManager().registerChangeNotifier(uri, this); + } + + // Returns the dirty flag and clear it. + public boolean isDirty() { + return mContentDirty.compareAndSet(true, false); + } + + public void fakeChange() { + onChange(false); + } + + public void clearDirty() { + mContentDirty.set(false); + } + + protected void onChange(boolean selfChange) { + if (mContentDirty.compareAndSet(false, true)) { + mMediaSet.notifyContentChanged(); + } + } +}
\ No newline at end of file diff --git a/src/com/android/gallery3d/data/ClusterAlbum.java b/src/com/android/gallery3d/data/ClusterAlbum.java new file mode 100644 index 000000000..32f902301 --- /dev/null +++ b/src/com/android/gallery3d/data/ClusterAlbum.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import java.util.ArrayList; + +public class ClusterAlbum extends MediaSet implements ContentListener { + private static final String TAG = "ClusterAlbum"; + private ArrayList<Path> mPaths = new ArrayList<Path>(); + private String mName = ""; + private DataManager mDataManager; + private MediaSet mClusterAlbumSet; + + public ClusterAlbum(Path path, DataManager dataManager, + MediaSet clusterAlbumSet) { + super(path, nextVersionNumber()); + mDataManager = dataManager; + mClusterAlbumSet = clusterAlbumSet; + mClusterAlbumSet.addContentListener(this); + } + + void setMediaItems(ArrayList<Path> paths) { + mPaths = paths; + } + + ArrayList<Path> getMediaItems() { + return mPaths; + } + + public void setName(String name) { + mName = name; + } + + @Override + public String getName() { + return mName; + } + + @Override + public int getMediaItemCount() { + return mPaths.size(); + } + + @Override + public ArrayList<MediaItem> getMediaItem(int start, int count) { + return getMediaItemFromPath(mPaths, start, count, mDataManager); + } + + public static ArrayList<MediaItem> getMediaItemFromPath( + ArrayList<Path> paths, int start, int count, + DataManager dataManager) { + if (start >= paths.size()) { + return new ArrayList<MediaItem>(); + } + int end = Math.min(start + count, paths.size()); + ArrayList<Path> subset = new ArrayList<Path>(paths.subList(start, end)); + final MediaItem[] buf = new MediaItem[end - start]; + ItemConsumer consumer = new ItemConsumer() { + public void consume(int index, MediaItem item) { + buf[index] = item; + } + }; + dataManager.mapMediaItems(subset, consumer, 0); + ArrayList<MediaItem> result = new ArrayList<MediaItem>(end - start); + for (int i = 0; i < buf.length; i++) { + result.add(buf[i]); + } + return result; + } + + @Override + protected int enumerateMediaItems(ItemConsumer consumer, int startIndex) { + mDataManager.mapMediaItems(mPaths, consumer, startIndex); + return mPaths.size(); + } + + @Override + public int getTotalMediaItemCount() { + return mPaths.size(); + } + + @Override + public long reload() { + if (mClusterAlbumSet.reload() > mDataVersion) { + mDataVersion = nextVersionNumber(); + } + return mDataVersion; + } + + public void onContentDirty() { + notifyContentChanged(); + } + + @Override + public int getSupportedOperations() { + return SUPPORT_SHARE | SUPPORT_DELETE | SUPPORT_INFO; + } + + @Override + public void delete() { + ItemConsumer consumer = new ItemConsumer() { + public void consume(int index, MediaItem item) { + if ((item.getSupportedOperations() & SUPPORT_DELETE) != 0) { + item.delete(); + } + } + }; + mDataManager.mapMediaItems(mPaths, consumer, 0); + } + + @Override + public boolean isLeafAlbum() { + return true; + } +} diff --git a/src/com/android/gallery3d/data/ClusterAlbumSet.java b/src/com/android/gallery3d/data/ClusterAlbumSet.java new file mode 100644 index 000000000..5b0569a67 --- /dev/null +++ b/src/com/android/gallery3d/data/ClusterAlbumSet.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.app.GalleryApp; + +import android.content.Context; +import android.net.Uri; + +import java.util.ArrayList; +import java.util.HashSet; + +public class ClusterAlbumSet extends MediaSet implements ContentListener { + private static final String TAG = "ClusterAlbumSet"; + private GalleryApp mApplication; + private MediaSet mBaseSet; + private int mKind; + private ArrayList<ClusterAlbum> mAlbums = new ArrayList<ClusterAlbum>(); + private boolean mFirstReloadDone; + + public ClusterAlbumSet(Path path, GalleryApp application, + MediaSet baseSet, int kind) { + super(path, INVALID_DATA_VERSION); + mApplication = application; + mBaseSet = baseSet; + mKind = kind; + baseSet.addContentListener(this); + } + + @Override + public MediaSet getSubMediaSet(int index) { + return mAlbums.get(index); + } + + @Override + public int getSubMediaSetCount() { + return mAlbums.size(); + } + + @Override + public String getName() { + return mBaseSet.getName(); + } + + @Override + public long reload() { + if (mBaseSet.reload() > mDataVersion) { + if (mFirstReloadDone) { + updateClustersContents(); + } else { + updateClusters(); + mFirstReloadDone = true; + } + mDataVersion = nextVersionNumber(); + } + return mDataVersion; + } + + public void onContentDirty() { + notifyContentChanged(); + } + + private void updateClusters() { + mAlbums.clear(); + Clustering clustering; + Context context = mApplication.getAndroidContext(); + switch (mKind) { + case ClusterSource.CLUSTER_ALBUMSET_TIME: + clustering = new TimeClustering(context); + break; + case ClusterSource.CLUSTER_ALBUMSET_LOCATION: + clustering = new LocationClustering(context); + break; + case ClusterSource.CLUSTER_ALBUMSET_TAG: + clustering = new TagClustering(context); + break; + case ClusterSource.CLUSTER_ALBUMSET_FACE: + clustering = new FaceClustering(context); + break; + default: /* CLUSTER_ALBUMSET_SIZE */ + clustering = new SizeClustering(context); + break; + } + + clustering.run(mBaseSet); + int n = clustering.getNumberOfClusters(); + DataManager dataManager = mApplication.getDataManager(); + for (int i = 0; i < n; i++) { + Path childPath; + String childName = clustering.getClusterName(i); + if (mKind == ClusterSource.CLUSTER_ALBUMSET_TAG) { + childPath = mPath.getChild(Uri.encode(childName)); + } else if (mKind == ClusterSource.CLUSTER_ALBUMSET_SIZE) { + long minSize = ((SizeClustering) clustering).getMinSize(i); + childPath = mPath.getChild(minSize); + } else { + childPath = mPath.getChild(i); + } + ClusterAlbum album = (ClusterAlbum) dataManager.peekMediaObject( + childPath); + if (album == null) { + album = new ClusterAlbum(childPath, dataManager, this); + } + album.setMediaItems(clustering.getCluster(i)); + album.setName(childName); + mAlbums.add(album); + } + } + + private void updateClustersContents() { + final HashSet<Path> existing = new HashSet<Path>(); + mBaseSet.enumerateTotalMediaItems(new MediaSet.ItemConsumer() { + public void consume(int index, MediaItem item) { + existing.add(item.getPath()); + } + }); + + int n = mAlbums.size(); + + // The loop goes backwards because we may remove empty albums from + // mAlbums. + for (int i = n - 1; i >= 0; i--) { + ArrayList<Path> oldPaths = mAlbums.get(i).getMediaItems(); + ArrayList<Path> newPaths = new ArrayList<Path>(); + int m = oldPaths.size(); + for (int j = 0; j < m; j++) { + Path p = oldPaths.get(j); + if (existing.contains(p)) { + newPaths.add(p); + } + } + mAlbums.get(i).setMediaItems(newPaths); + if (newPaths.isEmpty()) { + mAlbums.remove(i); + } + } + } +} diff --git a/src/com/android/gallery3d/data/ClusterSource.java b/src/com/android/gallery3d/data/ClusterSource.java new file mode 100644 index 000000000..a1f22e57a --- /dev/null +++ b/src/com/android/gallery3d/data/ClusterSource.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.app.GalleryApp; + +class ClusterSource extends MediaSource { + static final int CLUSTER_ALBUMSET_TIME = 0; + static final int CLUSTER_ALBUMSET_LOCATION = 1; + static final int CLUSTER_ALBUMSET_TAG = 2; + static final int CLUSTER_ALBUMSET_SIZE = 3; + static final int CLUSTER_ALBUMSET_FACE = 4; + + static final int CLUSTER_ALBUM_TIME = 0x100; + static final int CLUSTER_ALBUM_LOCATION = 0x101; + static final int CLUSTER_ALBUM_TAG = 0x102; + static final int CLUSTER_ALBUM_SIZE = 0x103; + static final int CLUSTER_ALBUM_FACE = 0x104; + + GalleryApp mApplication; + PathMatcher mMatcher; + + public ClusterSource(GalleryApp application) { + super("cluster"); + mApplication = application; + mMatcher = new PathMatcher(); + mMatcher.add("/cluster/*/time", CLUSTER_ALBUMSET_TIME); + mMatcher.add("/cluster/*/location", CLUSTER_ALBUMSET_LOCATION); + mMatcher.add("/cluster/*/tag", CLUSTER_ALBUMSET_TAG); + mMatcher.add("/cluster/*/size", CLUSTER_ALBUMSET_SIZE); + mMatcher.add("/cluster/*/face", CLUSTER_ALBUMSET_FACE); + + mMatcher.add("/cluster/*/time/*", CLUSTER_ALBUM_TIME); + mMatcher.add("/cluster/*/location/*", CLUSTER_ALBUM_LOCATION); + mMatcher.add("/cluster/*/tag/*", CLUSTER_ALBUM_TAG); + mMatcher.add("/cluster/*/size/*", CLUSTER_ALBUM_SIZE); + mMatcher.add("/cluster/*/face/*", CLUSTER_ALBUM_FACE); + } + + // The names we accept are: + // /cluster/{set}/time /cluster/{set}/time/k + // /cluster/{set}/location /cluster/{set}/location/k + // /cluster/{set}/tag /cluster/{set}/tag/encoded_tag + // /cluster/{set}/size /cluster/{set}/size/min_size + @Override + public MediaObject createMediaObject(Path path) { + int matchType = mMatcher.match(path); + String setsName = mMatcher.getVar(0); + DataManager dataManager = mApplication.getDataManager(); + MediaSet[] sets = dataManager.getMediaSetsFromString(setsName); + switch (matchType) { + case CLUSTER_ALBUMSET_TIME: + case CLUSTER_ALBUMSET_LOCATION: + case CLUSTER_ALBUMSET_TAG: + case CLUSTER_ALBUMSET_SIZE: + case CLUSTER_ALBUMSET_FACE: + return new ClusterAlbumSet(path, mApplication, sets[0], matchType); + case CLUSTER_ALBUM_TIME: + case CLUSTER_ALBUM_LOCATION: + case CLUSTER_ALBUM_TAG: + case CLUSTER_ALBUM_SIZE: + case CLUSTER_ALBUM_FACE: { + MediaSet parent = dataManager.getMediaSet(path.getParent()); + // The actual content in the ClusterAlbum will be filled later + // when the reload() method in the parent is run. + return new ClusterAlbum(path, dataManager, parent); + } + default: + throw new RuntimeException("bad path: " + path); + } + } +} diff --git a/src/com/android/gallery3d/data/Clustering.java b/src/com/android/gallery3d/data/Clustering.java new file mode 100644 index 000000000..542dda27f --- /dev/null +++ b/src/com/android/gallery3d/data/Clustering.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import java.util.ArrayList; + +public abstract class Clustering { + public abstract void run(MediaSet baseSet); + public abstract int getNumberOfClusters(); + public abstract ArrayList<Path> getCluster(int index); + public abstract String getClusterName(int index); +} diff --git a/src/com/android/gallery3d/data/ComboAlbum.java b/src/com/android/gallery3d/data/ComboAlbum.java new file mode 100644 index 000000000..8ca2077a4 --- /dev/null +++ b/src/com/android/gallery3d/data/ComboAlbum.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.gallery3d.data; + +import com.android.gallery3d.app.GalleryApp; + +import java.util.ArrayList; + +// ComboAlbum combines multiple media sets into one. It lists all media items +// from the input albums. +// This only handles SubMediaSets, not MediaItems. (That's all we need now) +public class ComboAlbum extends MediaSet implements ContentListener { + private static final String TAG = "ComboAlbum"; + private final MediaSet[] mSets; + private final String mName; + + public ComboAlbum(Path path, MediaSet[] mediaSets, String name) { + super(path, nextVersionNumber()); + mSets = mediaSets; + for (MediaSet set : mSets) { + set.addContentListener(this); + } + mName = name; + } + + @Override + public ArrayList<MediaItem> getMediaItem(int start, int count) { + ArrayList<MediaItem> items = new ArrayList<MediaItem>(); + for (MediaSet set : mSets) { + int size = set.getMediaItemCount(); + if (count < 1) break; + if (start < size) { + int fetchCount = (start + count <= size) ? count : size - start; + ArrayList<MediaItem> fetchItems = set.getMediaItem(start, fetchCount); + items.addAll(fetchItems); + count -= fetchItems.size(); + start = 0; + } else { + start -= size; + } + } + return items; + } + + @Override + public int getMediaItemCount() { + int count = 0; + for (MediaSet set : mSets) { + count += set.getMediaItemCount(); + } + return count; + } + + @Override + public String getName() { + return mName; + } + + @Override + public long reload() { + boolean changed = false; + for (int i = 0, n = mSets.length; i < n; ++i) { + long version = mSets[i].reload(); + if (version > mDataVersion) changed = true; + } + if (changed) mDataVersion = nextVersionNumber(); + return mDataVersion; + } + + public void onContentDirty() { + notifyContentChanged(); + } +} diff --git a/src/com/android/gallery3d/data/ComboAlbumSet.java b/src/com/android/gallery3d/data/ComboAlbumSet.java new file mode 100644 index 000000000..aa196039d --- /dev/null +++ b/src/com/android/gallery3d/data/ComboAlbumSet.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.R; +import com.android.gallery3d.app.GalleryApp; + +// ComboAlbumSet combines multiple media sets into one. It lists all sub +// media sets from the input album sets. +// This only handles SubMediaSets, not MediaItems. (That's all we need now) +public class ComboAlbumSet extends MediaSet implements ContentListener { + private static final String TAG = "ComboAlbumSet"; + private final MediaSet[] mSets; + private final String mName; + + public ComboAlbumSet(Path path, GalleryApp application, MediaSet[] mediaSets) { + super(path, nextVersionNumber()); + mSets = mediaSets; + for (MediaSet set : mSets) { + set.addContentListener(this); + } + mName = application.getResources().getString( + R.string.set_label_all_albums); + } + + @Override + public MediaSet getSubMediaSet(int index) { + for (MediaSet set : mSets) { + int size = set.getSubMediaSetCount(); + if (index < size) { + return set.getSubMediaSet(index); + } + index -= size; + } + return null; + } + + @Override + public int getSubMediaSetCount() { + int count = 0; + for (MediaSet set : mSets) { + count += set.getSubMediaSetCount(); + } + return count; + } + + @Override + public String getName() { + return mName; + } + + @Override + public long reload() { + boolean changed = false; + for (int i = 0, n = mSets.length; i < n; ++i) { + long version = mSets[i].reload(); + if (version > mDataVersion) changed = true; + } + if (changed) mDataVersion = nextVersionNumber(); + return mDataVersion; + } + + public void onContentDirty() { + notifyContentChanged(); + } +} diff --git a/src/com/android/gallery3d/data/ComboSource.java b/src/com/android/gallery3d/data/ComboSource.java new file mode 100644 index 000000000..867d47e64 --- /dev/null +++ b/src/com/android/gallery3d/data/ComboSource.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.app.GalleryApp; + +class ComboSource extends MediaSource { + private static final int COMBO_ALBUMSET = 0; + private static final int COMBO_ALBUM = 1; + private GalleryApp mApplication; + private PathMatcher mMatcher; + + public ComboSource(GalleryApp application) { + super("combo"); + mApplication = application; + mMatcher = new PathMatcher(); + mMatcher.add("/combo/*", COMBO_ALBUMSET); + mMatcher.add("/combo/*/*", COMBO_ALBUM); + } + + // The only path we accept is "/combo/{set1, set2, ...} and /combo/item/{set1, set2, ...}" + @Override + public MediaObject createMediaObject(Path path) { + String[] segments = path.split(); + if (segments.length < 2) { + throw new RuntimeException("bad path: " + path); + } + + DataManager dataManager = mApplication.getDataManager(); + switch (mMatcher.match(path)) { + case COMBO_ALBUMSET: + return new ComboAlbumSet(path, mApplication, + dataManager.getMediaSetsFromString(segments[1])); + + case COMBO_ALBUM: + return new ComboAlbum(path, + dataManager.getMediaSetsFromString(segments[2]), segments[1]); + } + return null; + } +} diff --git a/src/com/android/gallery3d/data/ContentListener.java b/src/com/android/gallery3d/data/ContentListener.java new file mode 100644 index 000000000..5e2952685 --- /dev/null +++ b/src/com/android/gallery3d/data/ContentListener.java @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +public interface ContentListener { + public void onContentDirty(); +}
\ No newline at end of file diff --git a/src/com/android/gallery3d/data/DataManager.java b/src/com/android/gallery3d/data/DataManager.java new file mode 100644 index 000000000..f7dac5ebd --- /dev/null +++ b/src/com/android/gallery3d/data/DataManager.java @@ -0,0 +1,333 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.data.MediaSet.ItemConsumer; +import com.android.gallery3d.data.MediaSource.PathId; +import com.android.gallery3d.picasasource.PicasaSource; + +import android.database.ContentObserver; +import android.net.Uri; +import android.os.Handler; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map.Entry; +import java.util.WeakHashMap; + +// DataManager manages all media sets and media items in the system. +// +// Each MediaSet and MediaItem has a unique 64 bits id. The most significant +// 32 bits represents its parent, and the least significant 32 bits represents +// the self id. For MediaSet the self id is is globally unique, but for +// MediaItem it's unique only relative to its parent. +// +// To make sure the id is the same when the MediaSet is re-created, a child key +// is provided to obtainSetId() to make sure the same self id will be used as +// when the parent and key are the same. A sequence of child keys is called a +// path. And it's used to identify a specific media set even if the process is +// killed and re-created, so child keys should be stable identifiers. + +public class DataManager { + public static final int INCLUDE_IMAGE = 1; + public static final int INCLUDE_VIDEO = 2; + public static final int INCLUDE_ALL = INCLUDE_IMAGE | INCLUDE_VIDEO; + public static final int INCLUDE_LOCAL_ONLY = 4; + public static final int INCLUDE_LOCAL_IMAGE_ONLY = + INCLUDE_LOCAL_ONLY | INCLUDE_IMAGE; + public static final int INCLUDE_LOCAL_VIDEO_ONLY = + INCLUDE_LOCAL_ONLY | INCLUDE_VIDEO; + public static final int INCLUDE_LOCAL_ALL_ONLY = + INCLUDE_LOCAL_ONLY | INCLUDE_IMAGE | INCLUDE_VIDEO; + + // Any one who would like to access data should require this lock + // to prevent concurrency issue. + public static final Object LOCK = new Object(); + + private static final String TAG = "DataManager"; + + // This is the path for the media set seen by the user at top level. + private static final String TOP_SET_PATH = + "/combo/{/mtp,/local/all,/picasa/all}"; + private static final String TOP_IMAGE_SET_PATH = + "/combo/{/mtp,/local/image,/picasa/image}"; + private static final String TOP_VIDEO_SET_PATH = + "/combo/{/local/video,/picasa/video}"; + private static final String TOP_LOCAL_SET_PATH = + "/local/all"; + private static final String TOP_LOCAL_IMAGE_SET_PATH = + "/local/image"; + private static final String TOP_LOCAL_VIDEO_SET_PATH = + "/local/video"; + + public static final Comparator<MediaItem> sDateTakenComparator = + new DateTakenComparator(); + + private static class DateTakenComparator implements Comparator<MediaItem> { + public int compare(MediaItem item1, MediaItem item2) { + return -Utils.compare(item1.getDateInMs(), item2.getDateInMs()); + } + } + + private final Handler mDefaultMainHandler; + + private GalleryApp mApplication; + private int mActiveCount = 0; + + private HashMap<Uri, NotifyBroker> mNotifierMap = + new HashMap<Uri, NotifyBroker>(); + + + private HashMap<String, MediaSource> mSourceMap = + new LinkedHashMap<String, MediaSource>(); + + public DataManager(GalleryApp application) { + mApplication = application; + mDefaultMainHandler = new Handler(application.getMainLooper()); + } + + public synchronized void initializeSourceMap() { + if (!mSourceMap.isEmpty()) return; + + // the order matters, the UriSource must come last + addSource(new LocalSource(mApplication)); + addSource(new PicasaSource(mApplication)); + addSource(new MtpSource(mApplication)); + addSource(new ComboSource(mApplication)); + addSource(new ClusterSource(mApplication)); + addSource(new FilterSource(mApplication)); + addSource(new UriSource(mApplication)); + + if (mActiveCount > 0) { + for (MediaSource source : mSourceMap.values()) { + source.resume(); + } + } + } + + public String getTopSetPath(int typeBits) { + + switch (typeBits) { + case INCLUDE_IMAGE: return TOP_IMAGE_SET_PATH; + case INCLUDE_VIDEO: return TOP_VIDEO_SET_PATH; + case INCLUDE_ALL: return TOP_SET_PATH; + case INCLUDE_LOCAL_IMAGE_ONLY: return TOP_LOCAL_IMAGE_SET_PATH; + case INCLUDE_LOCAL_VIDEO_ONLY: return TOP_LOCAL_VIDEO_SET_PATH; + case INCLUDE_LOCAL_ALL_ONLY: return TOP_LOCAL_SET_PATH; + default: throw new IllegalArgumentException(); + } + } + + // open for debug + void addSource(MediaSource source) { + mSourceMap.put(source.getPrefix(), source); + } + + public MediaObject peekMediaObject(Path path) { + return path.getObject(); + } + + public MediaSet peekMediaSet(Path path) { + return (MediaSet) path.getObject(); + } + + public MediaObject getMediaObject(Path path) { + MediaObject obj = path.getObject(); + if (obj != null) return obj; + + MediaSource source = mSourceMap.get(path.getPrefix()); + if (source == null) { + Log.w(TAG, "cannot find media source for path: " + path); + return null; + } + + MediaObject object = source.createMediaObject(path); + if (object == null) { + Log.w(TAG, "cannot create media object: " + path); + } + return object; + } + + public MediaObject getMediaObject(String s) { + return getMediaObject(Path.fromString(s)); + } + + public MediaSet getMediaSet(Path path) { + return (MediaSet) getMediaObject(path); + } + + public MediaSet getMediaSet(String s) { + return (MediaSet) getMediaObject(s); + } + + public MediaSet[] getMediaSetsFromString(String segment) { + String[] seq = Path.splitSequence(segment); + int n = seq.length; + MediaSet[] sets = new MediaSet[n]; + for (int i = 0; i < n; i++) { + sets[i] = getMediaSet(seq[i]); + } + return sets; + } + + // Maps a list of Paths to MediaItems, and invoke consumer.consume() + // for each MediaItem (may not be in the same order as the input list). + // An index number is also passed to consumer.consume() to identify + // the original position in the input list of the corresponding Path (plus + // startIndex). + public void mapMediaItems(ArrayList<Path> list, ItemConsumer consumer, + int startIndex) { + HashMap<String, ArrayList<PathId>> map = + new HashMap<String, ArrayList<PathId>>(); + + // Group the path by the prefix. + int n = list.size(); + for (int i = 0; i < n; i++) { + Path path = list.get(i); + String prefix = path.getPrefix(); + ArrayList<PathId> group = map.get(prefix); + if (group == null) { + group = new ArrayList<PathId>(); + map.put(prefix, group); + } + group.add(new PathId(path, i + startIndex)); + } + + // For each group, ask the corresponding media source to map it. + for (Entry<String, ArrayList<PathId>> entry : map.entrySet()) { + String prefix = entry.getKey(); + MediaSource source = mSourceMap.get(prefix); + source.mapMediaItems(entry.getValue(), consumer); + } + } + + // The following methods forward the request to the proper object. + public int getSupportedOperations(Path path) { + return getMediaObject(path).getSupportedOperations(); + } + + public void delete(Path path) { + getMediaObject(path).delete(); + } + + public void rotate(Path path, int degrees) { + getMediaObject(path).rotate(degrees); + } + + public Uri getContentUri(Path path) { + return getMediaObject(path).getContentUri(); + } + + public int getMediaType(Path path) { + return getMediaObject(path).getMediaType(); + } + + public MediaDetails getDetails(Path path) { + return getMediaObject(path).getDetails(); + } + + public void cache(Path path, int flag) { + getMediaObject(path).cache(flag); + } + + public Path findPathByUri(Uri uri) { + if (uri == null) return null; + for (MediaSource source : mSourceMap.values()) { + Path path = source.findPathByUri(uri); + if (path != null) return path; + } + return null; + } + + public Path getDefaultSetOf(Path item) { + MediaSource source = mSourceMap.get(item.getPrefix()); + return source == null ? null : source.getDefaultSetOf(item); + } + + // Returns number of bytes used by cached pictures currently downloaded. + public long getTotalUsedCacheSize() { + long sum = 0; + for (MediaSource source : mSourceMap.values()) { + sum += source.getTotalUsedCacheSize(); + } + return sum; + } + + // Returns number of bytes used by cached pictures if all pending + // downloads and removals are completed. + public long getTotalTargetCacheSize() { + long sum = 0; + for (MediaSource source : mSourceMap.values()) { + sum += source.getTotalTargetCacheSize(); + } + return sum; + } + + public void registerChangeNotifier(Uri uri, ChangeNotifier notifier) { + NotifyBroker broker = null; + synchronized (mNotifierMap) { + broker = mNotifierMap.get(uri); + if (broker == null) { + broker = new NotifyBroker(mDefaultMainHandler); + mApplication.getContentResolver() + .registerContentObserver(uri, true, broker); + mNotifierMap.put(uri, broker); + } + } + broker.registerNotifier(notifier); + } + + public void resume() { + if (++mActiveCount == 1) { + for (MediaSource source : mSourceMap.values()) { + source.resume(); + } + } + } + + public void pause() { + if (--mActiveCount == 0) { + for (MediaSource source : mSourceMap.values()) { + source.pause(); + } + } + } + + private static class NotifyBroker extends ContentObserver { + private WeakHashMap<ChangeNotifier, Object> mNotifiers = + new WeakHashMap<ChangeNotifier, Object>(); + + public NotifyBroker(Handler handler) { + super(handler); + } + + public synchronized void registerNotifier(ChangeNotifier notifier) { + mNotifiers.put(notifier, null); + } + + @Override + public synchronized void onChange(boolean selfChange) { + for(ChangeNotifier notifier : mNotifiers.keySet()) { + notifier.onChange(selfChange); + } + } + } +} diff --git a/src/com/android/gallery3d/data/DecodeUtils.java b/src/com/android/gallery3d/data/DecodeUtils.java new file mode 100644 index 000000000..e7ae638c2 --- /dev/null +++ b/src/com/android/gallery3d/data/DecodeUtils.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.common.BitmapUtils; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.util.ThreadPool.CancelListener; +import com.android.gallery3d.util.ThreadPool.JobContext; + +import android.content.ContentResolver; +import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; +import android.graphics.BitmapFactory; +import android.graphics.BitmapFactory.Options; +import android.graphics.BitmapRegionDecoder; +import android.graphics.Rect; +import android.net.Uri; +import android.os.ParcelFileDescriptor; + +import java.io.FileDescriptor; +import java.io.FileInputStream; + +public class DecodeUtils { + private static final String TAG = "DecodeService"; + + private static class DecodeCanceller implements CancelListener { + Options mOptions; + public DecodeCanceller(Options options) { + mOptions = options; + } + public void onCancel() { + mOptions.requestCancelDecode(); + } + } + + public static Bitmap requestDecode(JobContext jc, final String filePath, + Options options) { + if (options == null) options = new Options(); + jc.setCancelListener(new DecodeCanceller(options)); + return ensureGLCompatibleBitmap( + BitmapFactory.decodeFile(filePath, options)); + } + + public static Bitmap requestDecode(JobContext jc, byte[] bytes, + Options options) { + return requestDecode(jc, bytes, 0, bytes.length, options); + } + + public static Bitmap requestDecode(JobContext jc, byte[] bytes, int offset, + int length, Options options) { + if (options == null) options = new Options(); + jc.setCancelListener(new DecodeCanceller(options)); + return ensureGLCompatibleBitmap( + BitmapFactory.decodeByteArray(bytes, offset, length, options)); + } + + public static Bitmap requestDecode(JobContext jc, final String filePath, + Options options, int targetSize) { + FileInputStream fis = null; + try { + fis = new FileInputStream(filePath); + FileDescriptor fd = fis.getFD(); + return requestDecode(jc, fd, options, targetSize); + } catch (Exception ex) { + Log.w(TAG, ex); + return null; + } finally { + Utils.closeSilently(fis); + } + } + + public static Bitmap requestDecode(JobContext jc, FileDescriptor fd, + Options options, int targetSize) { + if (options == null) options = new Options(); + jc.setCancelListener(new DecodeCanceller(options)); + + options.inJustDecodeBounds = true; + BitmapFactory.decodeFileDescriptor(fd, null, options); + if (jc.isCancelled()) return null; + + options.inSampleSize = BitmapUtils.computeSampleSizeLarger( + options.outWidth, options.outHeight, targetSize); + options.inJustDecodeBounds = false; + return ensureGLCompatibleBitmap( + BitmapFactory.decodeFileDescriptor(fd, null, options)); + } + + public static Bitmap requestDecode(JobContext jc, + FileDescriptor fileDescriptor, Rect paddings, Options options) { + if (options == null) options = new Options(); + jc.setCancelListener(new DecodeCanceller(options)); + return ensureGLCompatibleBitmap(BitmapFactory.decodeFileDescriptor + (fileDescriptor, paddings, options)); + } + + // TODO: This function should not be called directly from + // DecodeUtils.requestDecode(...), since we don't have the knowledge + // if the bitmap will be uploaded to GL. + public static Bitmap ensureGLCompatibleBitmap(Bitmap bitmap) { + if (bitmap == null || bitmap.getConfig() != null) return bitmap; + Bitmap newBitmap = bitmap.copy(Config.ARGB_8888, false); + bitmap.recycle(); + return newBitmap; + } + + public static BitmapRegionDecoder requestCreateBitmapRegionDecoder( + JobContext jc, byte[] bytes, int offset, int length, + boolean shareable) { + if (offset < 0 || length <= 0 || offset + length > bytes.length) { + throw new IllegalArgumentException(String.format( + "offset = %s, length = %s, bytes = %s", + offset, length, bytes.length)); + } + + try { + return BitmapRegionDecoder.newInstance( + bytes, offset, length, shareable); + } catch (Throwable t) { + Log.w(TAG, t); + return null; + } + } + + public static BitmapRegionDecoder requestCreateBitmapRegionDecoder( + JobContext jc, String filePath, boolean shareable) { + try { + return BitmapRegionDecoder.newInstance(filePath, shareable); + } catch (Throwable t) { + Log.w(TAG, t); + return null; + } + } + + public static BitmapRegionDecoder requestCreateBitmapRegionDecoder( + JobContext jc, FileDescriptor fd, boolean shareable) { + try { + return BitmapRegionDecoder.newInstance(fd, shareable); + } catch (Throwable t) { + Log.w(TAG, t); + return null; + } + } + + public static BitmapRegionDecoder requestCreateBitmapRegionDecoder( + JobContext jc, Uri uri, ContentResolver resolver, + boolean shareable) { + ParcelFileDescriptor pfd = null; + try { + pfd = resolver.openFileDescriptor(uri, "r"); + return BitmapRegionDecoder.newInstance( + pfd.getFileDescriptor(), shareable); + } catch (Throwable t) { + Log.w(TAG, t); + return null; + } finally { + Utils.closeSilently(pfd); + } + } +} diff --git a/src/com/android/gallery3d/data/DownloadCache.java b/src/com/android/gallery3d/data/DownloadCache.java new file mode 100644 index 000000000..30ba668c3 --- /dev/null +++ b/src/com/android/gallery3d/data/DownloadCache.java @@ -0,0 +1,398 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.common.LruCache; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.data.DownloadEntry.Columns; +import com.android.gallery3d.util.Future; +import com.android.gallery3d.util.FutureListener; +import com.android.gallery3d.util.ThreadPool; +import com.android.gallery3d.util.ThreadPool.CancelListener; +import com.android.gallery3d.util.ThreadPool.Job; +import com.android.gallery3d.util.ThreadPool.JobContext; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +import java.io.File; +import java.net.URL; +import java.util.HashMap; +import java.util.HashSet; +import java.util.WeakHashMap; + +public class DownloadCache { + private static final String TAG = "DownloadCache"; + private static final int MAX_DELETE_COUNT = 16; + private static final int LRU_CAPACITY = 4; + + private static final String TABLE_NAME = DownloadEntry.SCHEMA.getTableName(); + + private static final String QUERY_PROJECTION[] = {Columns.ID, Columns.DATA}; + private static final String WHERE_HASH_AND_URL = String.format( + "%s = ? AND %s = ?", Columns.HASH_CODE, Columns.CONTENT_URL); + private static final int QUERY_INDEX_ID = 0; + private static final int QUERY_INDEX_DATA = 1; + + private static final String FREESPACE_PROJECTION[] = { + Columns.ID, Columns.DATA, Columns.CONTENT_URL, Columns.CONTENT_SIZE}; + private static final String FREESPACE_ORDER_BY = + String.format("%s ASC", Columns.LAST_ACCESS); + private static final int FREESPACE_IDNEX_ID = 0; + private static final int FREESPACE_IDNEX_DATA = 1; + private static final int FREESPACE_INDEX_CONTENT_URL = 2; + private static final int FREESPACE_INDEX_CONTENT_SIZE = 3; + + private static final String ID_WHERE = Columns.ID + " = ?"; + + private static final String SUM_PROJECTION[] = + {String.format("sum(%s)", Columns.CONTENT_SIZE)}; + private static final int SUM_INDEX_SUM = 0; + + private final LruCache<String, Entry> mEntryMap = + new LruCache<String, Entry>(LRU_CAPACITY); + private final HashMap<String, DownloadTask> mTaskMap = + new HashMap<String, DownloadTask>(); + private final File mRoot; + private final GalleryApp mApplication; + private final SQLiteDatabase mDatabase; + private final long mCapacity; + + private long mTotalBytes = 0; + private boolean mInitialized = false; + private WeakHashMap<Object, Entry> mAssociateMap = new WeakHashMap<Object, Entry>(); + + public DownloadCache(GalleryApp application, File root, long capacity) { + mRoot = Utils.checkNotNull(root); + mApplication = Utils.checkNotNull(application); + mCapacity = capacity; + mDatabase = new DatabaseHelper(application.getAndroidContext()) + .getWritableDatabase(); + } + + private Entry findEntryInDatabase(String stringUrl) { + long hash = Utils.crc64Long(stringUrl); + String whereArgs[] = {String.valueOf(hash), stringUrl}; + Cursor cursor = mDatabase.query(TABLE_NAME, QUERY_PROJECTION, + WHERE_HASH_AND_URL, whereArgs, null, null, null); + try { + if (cursor.moveToNext()) { + File file = new File(cursor.getString(QUERY_INDEX_DATA)); + long id = cursor.getInt(QUERY_INDEX_ID); + Entry entry = null; + synchronized (mEntryMap) { + entry = mEntryMap.get(stringUrl); + if (entry == null) { + entry = new Entry(id, file); + mEntryMap.put(stringUrl, entry); + } + } + return entry; + } + } finally { + cursor.close(); + } + return null; + } + + public Entry lookup(URL url) { + if (!mInitialized) initialize(); + String stringUrl = url.toString(); + + // First find in the entry-pool + synchronized (mEntryMap) { + Entry entry = mEntryMap.get(stringUrl); + if (entry != null) { + updateLastAccess(entry.mId); + return entry; + } + } + + // Then, find it in database + TaskProxy proxy = new TaskProxy(); + synchronized (mTaskMap) { + Entry entry = findEntryInDatabase(stringUrl); + if (entry != null) { + updateLastAccess(entry.mId); + return entry; + } + } + return null; + } + + public Entry download(JobContext jc, URL url) { + if (!mInitialized) initialize(); + + String stringUrl = url.toString(); + + // First find in the entry-pool + synchronized (mEntryMap) { + Entry entry = mEntryMap.get(stringUrl); + if (entry != null) { + updateLastAccess(entry.mId); + return entry; + } + } + + // Then, find it in database + TaskProxy proxy = new TaskProxy(); + synchronized (mTaskMap) { + Entry entry = findEntryInDatabase(stringUrl); + if (entry != null) { + updateLastAccess(entry.mId); + return entry; + } + + // Finally, we need to download the file .... + // First check if we are downloading it now ... + DownloadTask task = mTaskMap.get(stringUrl); + if (task == null) { // if not, start the download task now + task = new DownloadTask(stringUrl); + mTaskMap.put(stringUrl, task); + task.mFuture = mApplication.getThreadPool().submit(task, task); + } + task.addProxy(proxy); + } + + return proxy.get(jc); + } + + private void updateLastAccess(long id) { + ContentValues values = new ContentValues(); + values.put(Columns.LAST_ACCESS, System.currentTimeMillis()); + mDatabase.update(TABLE_NAME, values, + ID_WHERE, new String[] {String.valueOf(id)}); + } + + private synchronized void freeSomeSpaceIfNeed(int maxDeleteFileCount) { + if (mTotalBytes <= mCapacity) return; + Cursor cursor = mDatabase.query(TABLE_NAME, + FREESPACE_PROJECTION, null, null, null, null, FREESPACE_ORDER_BY); + try { + while (maxDeleteFileCount > 0 + && mTotalBytes > mCapacity && cursor.moveToNext()) { + long id = cursor.getLong(FREESPACE_IDNEX_ID); + String url = cursor.getString(FREESPACE_INDEX_CONTENT_URL); + long size = cursor.getLong(FREESPACE_INDEX_CONTENT_SIZE); + String path = cursor.getString(FREESPACE_IDNEX_DATA); + boolean containsKey; + synchronized (mEntryMap) { + containsKey = mEntryMap.containsKey(url); + } + if (!containsKey) { + --maxDeleteFileCount; + mTotalBytes -= size; + new File(path).delete(); + mDatabase.delete(TABLE_NAME, + ID_WHERE, new String[]{String.valueOf(id)}); + } else { + // skip delete, since it is being used + } + } + } finally { + cursor.close(); + } + } + + private synchronized long insertEntry(String url, File file) { + long size = file.length(); + mTotalBytes += size; + + ContentValues values = new ContentValues(); + String hashCode = String.valueOf(Utils.crc64Long(url)); + values.put(Columns.DATA, file.getAbsolutePath()); + values.put(Columns.HASH_CODE, hashCode); + values.put(Columns.CONTENT_URL, url); + values.put(Columns.CONTENT_SIZE, size); + values.put(Columns.LAST_UPDATED, System.currentTimeMillis()); + return mDatabase.insert(TABLE_NAME, "", values); + } + + private synchronized void initialize() { + if (mInitialized) return; + mInitialized = true; + if (!mRoot.isDirectory()) mRoot.mkdirs(); + if (!mRoot.isDirectory()) { + throw new RuntimeException("cannot create " + mRoot.getAbsolutePath()); + } + + Cursor cursor = mDatabase.query( + TABLE_NAME, SUM_PROJECTION, null, null, null, null, null); + mTotalBytes = 0; + try { + if (cursor.moveToNext()) { + mTotalBytes = cursor.getLong(SUM_INDEX_SUM); + } + } finally { + cursor.close(); + } + if (mTotalBytes > mCapacity) freeSomeSpaceIfNeed(MAX_DELETE_COUNT); + } + + private final class DatabaseHelper extends SQLiteOpenHelper { + public static final String DATABASE_NAME = "download.db"; + public static final int DATABASE_VERSION = 2; + + public DatabaseHelper(Context context) { + super(context, DATABASE_NAME, null, DATABASE_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + DownloadEntry.SCHEMA.createTables(db); + // Delete old files + for (File file : mRoot.listFiles()) { + if (!file.delete()) { + Log.w(TAG, "fail to remove: " + file.getAbsolutePath()); + } + } + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + //reset everything + DownloadEntry.SCHEMA.dropTables(db); + onCreate(db); + } + } + + public class Entry { + public File cacheFile; + protected long mId; + + Entry(long id, File cacheFile) { + mId = id; + this.cacheFile = Utils.checkNotNull(cacheFile); + } + + public void associateWith(Object object) { + mAssociateMap.put(Utils.checkNotNull(object), this); + } + } + + private class DownloadTask implements Job<File>, FutureListener<File> { + private HashSet<TaskProxy> mProxySet = new HashSet<TaskProxy>(); + private Future<File> mFuture; + private final String mUrl; + + public DownloadTask(String url) { + mUrl = Utils.checkNotNull(url); + } + + public void removeProxy(TaskProxy proxy) { + synchronized (mTaskMap) { + Utils.assertTrue(mProxySet.remove(proxy)); + if (mProxySet.isEmpty()) { + mFuture.cancel(); + mTaskMap.remove(mUrl); + } + } + } + + // should be used in synchronized block of mDatabase + public void addProxy(TaskProxy proxy) { + proxy.mTask = this; + mProxySet.add(proxy); + } + + public void onFutureDone(Future<File> future) { + File file = future.get(); + long id = 0; + if (file != null) { // insert to database + id = insertEntry(mUrl, file); + } + + if (future.isCancelled()) { + Utils.assertTrue(mProxySet.isEmpty()); + return; + } + + synchronized (mTaskMap) { + Entry entry = null; + synchronized (mEntryMap) { + if (file != null) { + entry = new Entry(id, file); + Utils.assertTrue(mEntryMap.put(mUrl, entry) == null); + } + } + for (TaskProxy proxy : mProxySet) { + proxy.setResult(entry); + } + mTaskMap.remove(mUrl); + freeSomeSpaceIfNeed(MAX_DELETE_COUNT); + } + } + + public File run(JobContext jc) { + // TODO: utilize etag + jc.setMode(ThreadPool.MODE_NETWORK); + File tempFile = null; + try { + URL url = new URL(mUrl); + tempFile = File.createTempFile("cache", ".tmp", mRoot); + // download from url to tempFile + jc.setMode(ThreadPool.MODE_NETWORK); + boolean downloaded = DownloadUtils.requestDownload(jc, url, tempFile); + jc.setMode(ThreadPool.MODE_NONE); + if (downloaded) return tempFile; + } catch (Exception e) { + Log.e(TAG, String.format("fail to download %s", mUrl), e); + } finally { + jc.setMode(ThreadPool.MODE_NONE); + } + if (tempFile != null) tempFile.delete(); + return null; + } + } + + public static class TaskProxy { + private DownloadTask mTask; + private boolean mIsCancelled = false; + private Entry mEntry; + + synchronized void setResult(Entry entry) { + if (mIsCancelled) return; + mEntry = entry; + notifyAll(); + } + + public synchronized Entry get(JobContext jc) { + jc.setCancelListener(new CancelListener() { + public void onCancel() { + mTask.removeProxy(TaskProxy.this); + synchronized (TaskProxy.this) { + mIsCancelled = true; + TaskProxy.this.notifyAll(); + } + } + }); + while (!mIsCancelled && mEntry == null) { + try { + wait(); + } catch (InterruptedException e) { + Log.w(TAG, "ignore interrupt", e); + } + } + jc.setCancelListener(null); + return mEntry; + } + } +} diff --git a/src/com/android/gallery3d/data/DownloadEntry.java b/src/com/android/gallery3d/data/DownloadEntry.java new file mode 100644 index 000000000..578523f73 --- /dev/null +++ b/src/com/android/gallery3d/data/DownloadEntry.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.common.Entry; +import com.android.gallery3d.common.EntrySchema; + + +@Entry.Table("download") +public class DownloadEntry extends Entry { + public static final EntrySchema SCHEMA = new EntrySchema(DownloadEntry.class); + + public static interface Columns extends Entry.Columns { + public static final String HASH_CODE = "hash_code"; + public static final String CONTENT_URL = "content_url"; + public static final String CONTENT_SIZE = "_size"; + public static final String ETAG = "etag"; + public static final String LAST_ACCESS = "last_access"; + public static final String LAST_UPDATED = "last_updated"; + public static final String DATA = "_data"; + } + + @Column(value = "hash_code", indexed = true) + public long hashCode; + + @Column("content_url") + public String contentUrl; + + @Column("_size") + public long contentSize; + + @Column("etag") + public String eTag; + + @Column(value = "last_access", indexed = true) + public long lastAccessTime; + + @Column(value = "last_updated") + public long lastUpdatedTime; + + @Column("_data") + public String path; + + @Override + public String toString() { + // Note: THIS IS REQUIRED. We used all the fields here. Otherwise, + // ProGuard will remove these UNUSED fields. However, these + // fields are needed to generate database. + return new StringBuilder() + .append("hash_code: ").append(hashCode).append(", ") + .append("content_url").append(contentUrl).append(", ") + .append("_size").append(contentSize).append(", ") + .append("etag").append(eTag).append(", ") + .append("last_access").append(lastAccessTime).append(", ") + .append("last_updated").append(lastUpdatedTime).append(",") + .append("_data").append(path) + .toString(); + } +} diff --git a/src/com/android/gallery3d/data/DownloadUtils.java b/src/com/android/gallery3d/data/DownloadUtils.java new file mode 100644 index 000000000..9632db984 --- /dev/null +++ b/src/com/android/gallery3d/data/DownloadUtils.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.util.ThreadPool.CancelListener; +import com.android.gallery3d.util.ThreadPool.JobContext; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.io.OutputStream; +import java.net.URL; + +public class DownloadUtils { + private static final String TAG = "DownloadService"; + + public static boolean requestDownload(JobContext jc, URL url, File file) { + FileOutputStream fos = null; + try { + fos = new FileOutputStream(file); + return download(jc, url, fos); + } catch (Throwable t) { + return false; + } finally { + Utils.closeSilently(fos); + } + } + + public static byte[] requestDownload(JobContext jc, URL url) { + ByteArrayOutputStream baos = null; + try { + baos = new ByteArrayOutputStream(); + if (!download(jc, url, baos)) { + return null; + } + return baos.toByteArray(); + } catch (Throwable t) { + Log.w(TAG, t); + return null; + } finally { + Utils.closeSilently(baos); + } + } + + public static void dump(JobContext jc, InputStream is, OutputStream os) + throws IOException { + byte buffer[] = new byte[4096]; + int rc = is.read(buffer, 0, buffer.length); + final Thread thread = Thread.currentThread(); + jc.setCancelListener(new CancelListener() { + public void onCancel() { + thread.interrupt(); + } + }); + while (rc > 0) { + if (jc.isCancelled()) throw new InterruptedIOException(); + os.write(buffer, 0, rc); + rc = is.read(buffer, 0, buffer.length); + } + jc.setCancelListener(null); + Thread.interrupted(); // consume the interrupt signal + } + + public static boolean download(JobContext jc, URL url, OutputStream output) { + InputStream input = null; + try { + input = url.openStream(); + dump(jc, input, output); + return true; + } catch (Throwable t) { + Log.w(TAG, "fail to download", t); + return false; + } finally { + Utils.closeSilently(input); + } + } +}
\ No newline at end of file diff --git a/src/com/android/gallery3d/data/Face.java b/src/com/android/gallery3d/data/Face.java new file mode 100644 index 000000000..cc1a2d3dc --- /dev/null +++ b/src/com/android/gallery3d/data/Face.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.gallery3d.data; + +import com.android.gallery3d.common.Utils; + +public class Face implements Comparable<Face> { + private String mName; + private String mPersonId; + + public Face(String name, String personId) { + mName = name; + mPersonId = personId; + Utils.assertTrue(mName != null && mPersonId != null); + } + + public String getName() { + return mName; + } + + public String getPersonId() { + return mPersonId; + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof Face) { + Face face = (Face) obj; + return mPersonId.equals(face.mPersonId); + } + return false; + } + + @Override + public int hashCode() { + return mPersonId.hashCode(); + } + + public int compareTo(Face another) { + return mPersonId.compareTo(another.mPersonId); + } +} diff --git a/src/com/android/gallery3d/data/FaceClustering.java b/src/com/android/gallery3d/data/FaceClustering.java new file mode 100644 index 000000000..6ed73b560 --- /dev/null +++ b/src/com/android/gallery3d/data/FaceClustering.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.gallery3d.data; + +import com.android.gallery3d.R; + +import android.content.Context; + +import java.util.ArrayList; +import java.util.Map; +import java.util.TreeMap; + +public class FaceClustering extends Clustering { + @SuppressWarnings("unused") + private static final String TAG = "FaceClustering"; + + private ArrayList<ArrayList<Path>> mClusters; + private String[] mNames; + private String mUntaggedString; + + public FaceClustering(Context context) { + mUntaggedString = context.getResources().getString(R.string.untagged); + } + + @Override + public void run(MediaSet baseSet) { + final TreeMap<Face, ArrayList<Path>> map = + new TreeMap<Face, ArrayList<Path>>(); + final ArrayList<Path> untagged = new ArrayList<Path>(); + + baseSet.enumerateTotalMediaItems(new MediaSet.ItemConsumer() { + public void consume(int index, MediaItem item) { + Path path = item.getPath(); + + Face[] faces = item.getFaces(); + if (faces == null || faces.length == 0) { + untagged.add(path); + return; + } + for (int j = 0; j < faces.length; j++) { + Face key = faces[j]; + ArrayList<Path> list = map.get(key); + if (list == null) { + list = new ArrayList<Path>(); + map.put(key, list); + } + list.add(path); + } + } + }); + + int m = map.size(); + mClusters = new ArrayList<ArrayList<Path>>(); + mNames = new String[m + ((untagged.size() > 0) ? 1 : 0)]; + int i = 0; + for (Map.Entry<Face, ArrayList<Path>> entry : map.entrySet()) { + mNames[i++] = entry.getKey().getName(); + mClusters.add(entry.getValue()); + } + if (untagged.size() > 0) { + mNames[i++] = mUntaggedString; + mClusters.add(untagged); + } + } + + @Override + public int getNumberOfClusters() { + return mClusters.size(); + } + + @Override + public ArrayList<Path> getCluster(int index) { + return mClusters.get(index); + } + + @Override + public String getClusterName(int index) { + return mNames[index]; + } +} diff --git a/src/com/android/gallery3d/data/FilterSet.java b/src/com/android/gallery3d/data/FilterSet.java new file mode 100644 index 000000000..9cb7e02ef --- /dev/null +++ b/src/com/android/gallery3d/data/FilterSet.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import java.util.ArrayList; + +// FilterSet filters a base MediaSet according to a condition. Currently the +// condition is a matching media type. It can be extended to other conditions +// if needed. +public class FilterSet extends MediaSet implements ContentListener { + @SuppressWarnings("unused") + private static final String TAG = "FilterSet"; + + private final DataManager mDataManager; + private final MediaSet mBaseSet; + private final int mMediaType; + private final ArrayList<Path> mPaths = new ArrayList<Path>(); + private final ArrayList<MediaSet> mAlbums = new ArrayList<MediaSet>(); + + public FilterSet(Path path, DataManager dataManager, MediaSet baseSet, + int mediaType) { + super(path, INVALID_DATA_VERSION); + mDataManager = dataManager; + mBaseSet = baseSet; + mMediaType = mediaType; + mBaseSet.addContentListener(this); + } + + @Override + public String getName() { + return mBaseSet.getName(); + } + + @Override + public MediaSet getSubMediaSet(int index) { + return mAlbums.get(index); + } + + @Override + public int getSubMediaSetCount() { + return mAlbums.size(); + } + + @Override + public int getMediaItemCount() { + return mPaths.size(); + } + + @Override + public ArrayList<MediaItem> getMediaItem(int start, int count) { + return ClusterAlbum.getMediaItemFromPath( + mPaths, start, count, mDataManager); + } + + @Override + public long reload() { + if (mBaseSet.reload() > mDataVersion) { + updateData(); + mDataVersion = nextVersionNumber(); + } + return mDataVersion; + } + + @Override + public void onContentDirty() { + notifyContentChanged(); + } + + private void updateData() { + // Albums + mAlbums.clear(); + String basePath = "/filter/mediatype/" + mMediaType; + + for (int i = 0, n = mBaseSet.getSubMediaSetCount(); i < n; i++) { + MediaSet set = mBaseSet.getSubMediaSet(i); + String filteredPath = basePath + "/{" + set.getPath().toString() + "}"; + MediaSet filteredSet = mDataManager.getMediaSet(filteredPath); + filteredSet.reload(); + if (filteredSet.getMediaItemCount() > 0 + || filteredSet.getSubMediaSetCount() > 0) { + mAlbums.add(filteredSet); + } + } + + // Items + mPaths.clear(); + final int total = mBaseSet.getMediaItemCount(); + final Path[] buf = new Path[total]; + + mBaseSet.enumerateMediaItems(new MediaSet.ItemConsumer() { + public void consume(int index, MediaItem item) { + if (item.getMediaType() == mMediaType) { + if (index < 0 || index >= total) return; + Path path = item.getPath(); + buf[index] = path; + } + } + }); + + for (int i = 0; i < total; i++) { + if (buf[i] != null) { + mPaths.add(buf[i]); + } + } + } + + @Override + public int getSupportedOperations() { + return SUPPORT_SHARE | SUPPORT_DELETE; + } + + @Override + public void delete() { + ItemConsumer consumer = new ItemConsumer() { + public void consume(int index, MediaItem item) { + if ((item.getSupportedOperations() & SUPPORT_DELETE) != 0) { + item.delete(); + } + } + }; + mDataManager.mapMediaItems(mPaths, consumer, 0); + } +} diff --git a/src/com/android/gallery3d/data/FilterSource.java b/src/com/android/gallery3d/data/FilterSource.java new file mode 100644 index 000000000..d1a04c995 --- /dev/null +++ b/src/com/android/gallery3d/data/FilterSource.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.app.GalleryApp; + +class FilterSource extends MediaSource { + private static final String TAG = "FilterSource"; + private static final int FILTER_BY_MEDIATYPE = 0; + + private GalleryApp mApplication; + private PathMatcher mMatcher; + + public FilterSource(GalleryApp application) { + super("filter"); + mApplication = application; + mMatcher = new PathMatcher(); + mMatcher.add("/filter/mediatype/*/*", FILTER_BY_MEDIATYPE); + } + + // The name we accept is: + // /filter/mediatype/k/{set} + // where k is the media type we want. + @Override + public MediaObject createMediaObject(Path path) { + int matchType = mMatcher.match(path); + int mediaType = mMatcher.getIntVar(0); + String setsName = mMatcher.getVar(1); + DataManager dataManager = mApplication.getDataManager(); + MediaSet[] sets = dataManager.getMediaSetsFromString(setsName); + switch (matchType) { + case FILTER_BY_MEDIATYPE: + return new FilterSet(path, dataManager, sets[0], mediaType); + default: + throw new RuntimeException("bad path: " + path); + } + } +} diff --git a/src/com/android/gallery3d/data/ImageCacheRequest.java b/src/com/android/gallery3d/data/ImageCacheRequest.java new file mode 100644 index 000000000..104ff4839 --- /dev/null +++ b/src/com/android/gallery3d/data/ImageCacheRequest.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.common.BitmapUtils; +import com.android.gallery3d.data.ImageCacheService.ImageData; +import com.android.gallery3d.util.ThreadPool.Job; +import com.android.gallery3d.util.ThreadPool.JobContext; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; + +abstract class ImageCacheRequest implements Job<Bitmap> { + private static final String TAG = "ImageCacheRequest"; + + protected GalleryApp mApplication; + private Path mPath; + private int mType; + private int mTargetSize; + + public ImageCacheRequest(GalleryApp application, + Path path, int type, int targetSize) { + mApplication = application; + mPath = path; + mType = type; + mTargetSize = targetSize; + } + + public Bitmap run(JobContext jc) { + String debugTag = mPath + "," + + ((mType == MediaItem.TYPE_THUMBNAIL) ? "THUMB" : + (mType == MediaItem.TYPE_MICROTHUMBNAIL) ? "MICROTHUMB" : "?"); + ImageCacheService cacheService = mApplication.getImageCacheService(); + + ImageData data = cacheService.getImageData(mPath, mType); + if (jc.isCancelled()) return null; + + if (data != null) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inPreferredConfig = Bitmap.Config.ARGB_8888; + Bitmap bitmap = DecodeUtils.requestDecode(jc, data.mData, + data.mOffset, data.mData.length - data.mOffset, options); + if (bitmap == null && !jc.isCancelled()) { + Log.w(TAG, "decode cached failed " + debugTag); + } + return bitmap; + } else { + Bitmap bitmap = onDecodeOriginal(jc, mType); + if (jc.isCancelled()) return null; + + if (bitmap == null) { + Log.w(TAG, "decode orig failed " + debugTag); + return null; + } + + if (mType == MediaItem.TYPE_MICROTHUMBNAIL) { + bitmap = BitmapUtils.resizeDownAndCropCenter(bitmap, + mTargetSize, true); + } else { + bitmap = BitmapUtils.resizeDownBySideLength(bitmap, + mTargetSize, true); + } + if (jc.isCancelled()) return null; + + byte[] array = BitmapUtils.compressBitmap(bitmap); + if (jc.isCancelled()) return null; + + cacheService.putImageData(mPath, mType, array); + return bitmap; + } + } + + public abstract Bitmap onDecodeOriginal(JobContext jc, int targetSize); +} diff --git a/src/com/android/gallery3d/data/ImageCacheService.java b/src/com/android/gallery3d/data/ImageCacheService.java new file mode 100644 index 000000000..3adce1332 --- /dev/null +++ b/src/com/android/gallery3d/data/ImageCacheService.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.common.BlobCache; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.util.CacheManager; +import com.android.gallery3d.util.GalleryUtils; + +import android.content.Context; + +import java.io.IOException; +import java.nio.ByteBuffer; + +public class ImageCacheService { + @SuppressWarnings("unused") + private static final String TAG = "ImageCacheService"; + + private static final String IMAGE_CACHE_FILE = "imgcache"; + private static final int IMAGE_CACHE_MAX_ENTRIES = 5000; + private static final int IMAGE_CACHE_MAX_BYTES = 200 * 1024 * 1024; + private static final int IMAGE_CACHE_VERSION = 3; + + private BlobCache mCache; + + public ImageCacheService(Context context) { + mCache = CacheManager.getCache(context, IMAGE_CACHE_FILE, + IMAGE_CACHE_MAX_ENTRIES, IMAGE_CACHE_MAX_BYTES, + IMAGE_CACHE_VERSION); + } + + public static class ImageData { + public ImageData(byte[] data, int offset) { + mData = data; + mOffset = offset; + } + public byte[] mData; + public int mOffset; + } + + public ImageData getImageData(Path path, int type) { + byte[] key = makeKey(path, type); + long cacheKey = Utils.crc64Long(key); + try { + byte[] value = null; + synchronized (mCache) { + value = mCache.lookup(cacheKey); + } + if (value == null) return null; + if (isSameKey(key, value)) { + int offset = key.length; + return new ImageData(value, offset); + } + } catch (IOException ex) { + // ignore. + } + return null; + } + + public void putImageData(Path path, int type, byte[] value) { + byte[] key = makeKey(path, type); + long cacheKey = Utils.crc64Long(key); + ByteBuffer buffer = ByteBuffer.allocate(key.length + value.length); + buffer.put(key); + buffer.put(value); + synchronized (mCache) { + try { + mCache.insert(cacheKey, buffer.array()); + } catch (IOException ex) { + // ignore. + } + } + } + + private static byte[] makeKey(Path path, int type) { + return GalleryUtils.getBytes(path.toString() + "+" + type); + } + + private static boolean isSameKey(byte[] key, byte[] buffer) { + int n = key.length; + if (buffer.length < n) { + return false; + } + for (int i = 0; i < n; ++i) { + if (key[i] != buffer[i]) { + return false; + } + } + return true; + } +} diff --git a/src/com/android/gallery3d/data/LocalAlbum.java b/src/com/android/gallery3d/data/LocalAlbum.java new file mode 100644 index 000000000..5bd4398b4 --- /dev/null +++ b/src/com/android/gallery3d/data/LocalAlbum.java @@ -0,0 +1,252 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.util.GalleryUtils; + +import android.content.ContentResolver; +import android.database.Cursor; +import android.net.Uri; +import android.provider.MediaStore.Images; +import android.provider.MediaStore.Images.ImageColumns; +import android.provider.MediaStore.Video; +import android.provider.MediaStore.Video.VideoColumns; + +import java.util.ArrayList; + +// LocalAlbumSet lists all media items in one bucket on local storage. +// The media items need to be all images or all videos, but not both. +public class LocalAlbum extends MediaSet { + private static final String TAG = "LocalAlbum"; + private static final String[] COUNT_PROJECTION = { "count(*)" }; + + private static final int INVALID_COUNT = -1; + private final String mWhereClause; + private final String mOrderClause; + private final Uri mBaseUri; + private final String[] mProjection; + + private final GalleryApp mApplication; + private final ContentResolver mResolver; + private final int mBucketId; + private final String mBucketName; + private final boolean mIsImage; + private final ChangeNotifier mNotifier; + private final Path mItemPath; + private int mCachedCount = INVALID_COUNT; + + public LocalAlbum(Path path, GalleryApp application, int bucketId, + boolean isImage, String name) { + super(path, nextVersionNumber()); + mApplication = application; + mResolver = application.getContentResolver(); + mBucketId = bucketId; + mBucketName = name; + mIsImage = isImage; + + if (isImage) { + mWhereClause = ImageColumns.BUCKET_ID + " = ?"; + mOrderClause = ImageColumns.DATE_TAKEN + " DESC, " + + ImageColumns._ID + " DESC"; + mBaseUri = Images.Media.EXTERNAL_CONTENT_URI; + mProjection = LocalImage.PROJECTION; + mItemPath = LocalImage.ITEM_PATH; + } else { + mWhereClause = VideoColumns.BUCKET_ID + " = ?"; + mOrderClause = VideoColumns.DATE_TAKEN + " DESC, " + + VideoColumns._ID + " DESC"; + mBaseUri = Video.Media.EXTERNAL_CONTENT_URI; + mProjection = LocalVideo.PROJECTION; + mItemPath = LocalVideo.ITEM_PATH; + } + + mNotifier = new ChangeNotifier(this, mBaseUri, application); + } + + public LocalAlbum(Path path, GalleryApp application, int bucketId, + boolean isImage) { + this(path, application, bucketId, isImage, + LocalAlbumSet.getBucketName(application.getContentResolver(), + bucketId)); + } + + @Override + public ArrayList<MediaItem> getMediaItem(int start, int count) { + DataManager dataManager = mApplication.getDataManager(); + Uri uri = mBaseUri.buildUpon() + .appendQueryParameter("limit", start + "," + count).build(); + ArrayList<MediaItem> list = new ArrayList<MediaItem>(); + GalleryUtils.assertNotInRenderThread(); + Cursor cursor = mResolver.query( + uri, mProjection, mWhereClause, + new String[]{String.valueOf(mBucketId)}, + mOrderClause); + if (cursor == null) { + Log.w(TAG, "query fail: " + uri); + return list; + } + + try { + while (cursor.moveToNext()) { + int id = cursor.getInt(0); // _id must be in the first column + Path childPath = mItemPath.getChild(id); + MediaItem item = loadOrUpdateItem(childPath, cursor, + dataManager, mApplication, mIsImage); + list.add(item); + } + } finally { + cursor.close(); + } + return list; + } + + private static MediaItem loadOrUpdateItem(Path path, Cursor cursor, + DataManager dataManager, GalleryApp app, boolean isImage) { + LocalMediaItem item = (LocalMediaItem) dataManager.peekMediaObject(path); + if (item == null) { + if (isImage) { + item = new LocalImage(path, app, cursor); + } else { + item = new LocalVideo(path, app, cursor); + } + } else { + item.updateContent(cursor); + } + return item; + } + + // The pids array are sorted by the (path) id. + public static MediaItem[] getMediaItemById( + GalleryApp application, boolean isImage, ArrayList<Integer> ids) { + // get the lower and upper bound of (path) id + MediaItem[] result = new MediaItem[ids.size()]; + if (ids.isEmpty()) return result; + int idLow = ids.get(0); + int idHigh = ids.get(ids.size() - 1); + + // prepare the query parameters + Uri baseUri; + String[] projection; + Path itemPath; + if (isImage) { + baseUri = Images.Media.EXTERNAL_CONTENT_URI; + projection = LocalImage.PROJECTION; + itemPath = LocalImage.ITEM_PATH; + } else { + baseUri = Video.Media.EXTERNAL_CONTENT_URI; + projection = LocalVideo.PROJECTION; + itemPath = LocalVideo.ITEM_PATH; + } + + ContentResolver resolver = application.getContentResolver(); + DataManager dataManager = application.getDataManager(); + Cursor cursor = resolver.query(baseUri, projection, "_id BETWEEN ? AND ?", + new String[]{String.valueOf(idLow), String.valueOf(idHigh)}, + "_id"); + if (cursor == null) { + Log.w(TAG, "query fail" + baseUri); + return result; + } + try { + int n = ids.size(); + int i = 0; + + while (i < n && cursor.moveToNext()) { + int id = cursor.getInt(0); // _id must be in the first column + + // Match id with the one on the ids list. + if (ids.get(i) > id) { + continue; + } + + while (ids.get(i) < id) { + if (++i >= n) { + return result; + } + } + + Path childPath = itemPath.getChild(id); + MediaItem item = loadOrUpdateItem(childPath, cursor, dataManager, + application, isImage); + result[i] = item; + ++i; + } + return result; + } finally { + cursor.close(); + } + } + + public static Cursor getItemCursor(ContentResolver resolver, Uri uri, + String[] projection, int id) { + return resolver.query(uri, projection, "_id=?", + new String[]{String.valueOf(id)}, null); + } + + @Override + public int getMediaItemCount() { + if (mCachedCount == INVALID_COUNT) { + Cursor cursor = mResolver.query( + mBaseUri, COUNT_PROJECTION, mWhereClause, + new String[]{String.valueOf(mBucketId)}, null); + if (cursor == null) { + Log.w(TAG, "query fail"); + return 0; + } + try { + Utils.assertTrue(cursor.moveToNext()); + mCachedCount = cursor.getInt(0); + } finally { + cursor.close(); + } + } + return mCachedCount; + } + + @Override + public String getName() { + return mBucketName; + } + + @Override + public long reload() { + if (mNotifier.isDirty()) { + mDataVersion = nextVersionNumber(); + mCachedCount = INVALID_COUNT; + } + return mDataVersion; + } + + @Override + public int getSupportedOperations() { + return SUPPORT_DELETE | SUPPORT_SHARE | SUPPORT_INFO; + } + + @Override + public void delete() { + GalleryUtils.assertNotInRenderThread(); + mResolver.delete(mBaseUri, mWhereClause, + new String[]{String.valueOf(mBucketId)}); + } + + @Override + public boolean isLeafAlbum() { + return true; + } +} diff --git a/src/com/android/gallery3d/data/LocalAlbumSet.java b/src/com/android/gallery3d/data/LocalAlbumSet.java new file mode 100644 index 000000000..60bef9a33 --- /dev/null +++ b/src/com/android/gallery3d/data/LocalAlbumSet.java @@ -0,0 +1,263 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.R; +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.util.GalleryUtils; +import com.android.gallery3d.util.MediaSetUtils; + +import android.content.ContentResolver; +import android.database.Cursor; +import android.net.Uri; +import android.provider.MediaStore.Files; +import android.provider.MediaStore.Files.FileColumns; +import android.provider.MediaStore.Images; +import android.provider.MediaStore.Images.ImageColumns; +import android.provider.MediaStore.Video; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashSet; + +// LocalAlbumSet lists all image or video albums in the local storage. +// The path should be "/local/image", "local/video" or "/local/all" +public class LocalAlbumSet extends MediaSet { + public static final Path PATH_ALL = Path.fromString("/local/all"); + public static final Path PATH_IMAGE = Path.fromString("/local/image"); + public static final Path PATH_VIDEO = Path.fromString("/local/video"); + + private static final String TAG = "LocalAlbumSet"; + private static final String EXTERNAL_MEDIA = "external"; + + // The indices should match the following projections. + private static final int INDEX_BUCKET_ID = 0; + private static final int INDEX_MEDIA_TYPE = 1; + private static final int INDEX_BUCKET_NAME = 2; + + private static final Uri mBaseUri = Files.getContentUri(EXTERNAL_MEDIA); + private static final Uri mWatchUriImage = Images.Media.EXTERNAL_CONTENT_URI; + private static final Uri mWatchUriVideo = Video.Media.EXTERNAL_CONTENT_URI; + + // The order is import it must match to the index in MediaStore. + private static final String[] PROJECTION_BUCKET = { + ImageColumns.BUCKET_ID, + FileColumns.MEDIA_TYPE, + ImageColumns.BUCKET_DISPLAY_NAME }; + + private final GalleryApp mApplication; + private final int mType; + private ArrayList<MediaSet> mAlbums = new ArrayList<MediaSet>(); + private final ChangeNotifier mNotifierImage; + private final ChangeNotifier mNotifierVideo; + private final String mName; + + public LocalAlbumSet(Path path, GalleryApp application) { + super(path, nextVersionNumber()); + mApplication = application; + mType = getTypeFromPath(path); + mNotifierImage = new ChangeNotifier(this, mWatchUriImage, application); + mNotifierVideo = new ChangeNotifier(this, mWatchUriVideo, application); + mName = application.getResources().getString( + R.string.set_label_local_albums); + } + + private static int getTypeFromPath(Path path) { + String name[] = path.split(); + if (name.length < 2) { + throw new IllegalArgumentException(path.toString()); + } + if ("all".equals(name[1])) return MEDIA_TYPE_ALL; + if ("image".equals(name[1])) return MEDIA_TYPE_IMAGE; + if ("video".equals(name[1])) return MEDIA_TYPE_VIDEO; + throw new IllegalArgumentException(path.toString()); + } + + @Override + public MediaSet getSubMediaSet(int index) { + return mAlbums.get(index); + } + + @Override + public int getSubMediaSetCount() { + return mAlbums.size(); + } + + @Override + public String getName() { + return mName; + } + + private BucketEntry[] loadBucketEntries(Cursor cursor) { + HashSet<BucketEntry> buffer = new HashSet<BucketEntry>(); + int typeBits = 0; + if ((mType & MEDIA_TYPE_IMAGE) != 0) { + typeBits |= (1 << FileColumns.MEDIA_TYPE_IMAGE); + } + if ((mType & MEDIA_TYPE_VIDEO) != 0) { + typeBits |= (1 << FileColumns.MEDIA_TYPE_VIDEO); + } + try { + while (cursor.moveToNext()) { + if ((typeBits & (1 << cursor.getInt(INDEX_MEDIA_TYPE))) != 0) { + buffer.add(new BucketEntry( + cursor.getInt(INDEX_BUCKET_ID), + cursor.getString(INDEX_BUCKET_NAME))); + } + } + } finally { + cursor.close(); + } + return buffer.toArray(new BucketEntry[buffer.size()]); + } + + + private static int findBucket(BucketEntry entries[], int bucketId) { + for (int i = 0, n = entries.length; i < n ; ++i) { + if (entries[i].bucketId == bucketId) return i; + } + return -1; + } + + @SuppressWarnings("unchecked") + protected ArrayList<MediaSet> loadSubMediaSets() { + // Note: it will be faster if we only select media_type and bucket_id. + // need to test the performance if that is worth + + Uri uri = mBaseUri.buildUpon(). + appendQueryParameter("distinct", "true").build(); + GalleryUtils.assertNotInRenderThread(); + Cursor cursor = mApplication.getContentResolver().query( + uri, PROJECTION_BUCKET, null, null, null); + if (cursor == null) { + Log.w(TAG, "cannot open local database: " + uri); + return new ArrayList<MediaSet>(); + } + BucketEntry[] entries = loadBucketEntries(cursor); + int offset = 0; + + int index = findBucket(entries, MediaSetUtils.CAMERA_BUCKET_ID); + if (index != -1) { + Utils.swap(entries, index, offset++); + } + index = findBucket(entries, MediaSetUtils.DOWNLOAD_BUCKET_ID); + if (index != -1) { + Utils.swap(entries, index, offset++); + } + + Arrays.sort(entries, offset, entries.length, new Comparator<BucketEntry>() { + @Override + public int compare(BucketEntry a, BucketEntry b) { + int result = a.bucketName.compareTo(b.bucketName); + return result != 0 + ? result + : Utils.compare(a.bucketId, b.bucketId); + } + }); + ArrayList<MediaSet> albums = new ArrayList<MediaSet>(); + DataManager dataManager = mApplication.getDataManager(); + for (BucketEntry entry : entries) { + albums.add(getLocalAlbum(dataManager, + mType, mPath, entry.bucketId, entry.bucketName)); + } + for (int i = 0, n = albums.size(); i < n; ++i) { + albums.get(i).reload(); + } + return albums; + } + + private MediaSet getLocalAlbum( + DataManager manager, int type, Path parent, int id, String name) { + Path path = parent.getChild(id); + MediaObject object = manager.peekMediaObject(path); + if (object != null) return (MediaSet) object; + switch (type) { + case MEDIA_TYPE_IMAGE: + return new LocalAlbum(path, mApplication, id, true, name); + case MEDIA_TYPE_VIDEO: + return new LocalAlbum(path, mApplication, id, false, name); + case MEDIA_TYPE_ALL: + Comparator<MediaItem> comp = DataManager.sDateTakenComparator; + return new LocalMergeAlbum(path, comp, new MediaSet[] { + getLocalAlbum(manager, MEDIA_TYPE_IMAGE, PATH_IMAGE, id, name), + getLocalAlbum(manager, MEDIA_TYPE_VIDEO, PATH_VIDEO, id, name)}); + } + throw new IllegalArgumentException(String.valueOf(type)); + } + + public static String getBucketName(ContentResolver resolver, int bucketId) { + Uri uri = mBaseUri.buildUpon() + .appendQueryParameter("limit", "1") + .build(); + + Cursor cursor = resolver.query( + uri, PROJECTION_BUCKET, "bucket_id = ?", + new String[]{String.valueOf(bucketId)}, null); + + if (cursor == null) { + Log.w(TAG, "query fail: " + uri); + return ""; + } + try { + return cursor.moveToNext() + ? cursor.getString(INDEX_BUCKET_NAME) + : ""; + } finally { + cursor.close(); + } + } + + @Override + public long reload() { + // "|" is used instead of "||" because we want to clear both flags. + if (mNotifierImage.isDirty() | mNotifierVideo.isDirty()) { + mDataVersion = nextVersionNumber(); + mAlbums = loadSubMediaSets(); + } + return mDataVersion; + } + + // For debug only. Fake there is a ContentObserver.onChange() event. + void fakeChange() { + mNotifierImage.fakeChange(); + mNotifierVideo.fakeChange(); + } + + private static class BucketEntry { + public String bucketName; + public int bucketId; + + public BucketEntry(int id, String name) { + bucketId = id; + bucketName = Utils.ensureNotNull(name); + } + + @Override + public int hashCode() { + return bucketId; + } + + @Override + public boolean equals(Object object) { + if (!(object instanceof BucketEntry)) return false; + BucketEntry entry = (BucketEntry) object; + return bucketId == entry.bucketId; + } + } +} diff --git a/src/com/android/gallery3d/data/LocalImage.java b/src/com/android/gallery3d/data/LocalImage.java new file mode 100644 index 000000000..f3dedf037 --- /dev/null +++ b/src/com/android/gallery3d/data/LocalImage.java @@ -0,0 +1,289 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.common.BitmapUtils; +import com.android.gallery3d.util.UpdateHelper; +import com.android.gallery3d.util.GalleryUtils; +import com.android.gallery3d.util.ThreadPool.Job; +import com.android.gallery3d.util.ThreadPool.JobContext; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.BitmapRegionDecoder; +import android.media.ExifInterface; +import android.net.Uri; +import android.provider.MediaStore.Images; +import android.provider.MediaStore.Images.ImageColumns; + +import java.io.File; +import java.io.IOException; + +// LocalImage represents an image in the local storage. +public class LocalImage extends LocalMediaItem { + private static final int THUMBNAIL_TARGET_SIZE = 640; + private static final int MICROTHUMBNAIL_TARGET_SIZE = 200; + + private static final String TAG = "LocalImage"; + + static final Path ITEM_PATH = Path.fromString("/local/image/item"); + + // Must preserve order between these indices and the order of the terms in + // the following PROJECTION array. + private static final int INDEX_ID = 0; + private static final int INDEX_CAPTION = 1; + private static final int INDEX_MIME_TYPE = 2; + private static final int INDEX_LATITUDE = 3; + private static final int INDEX_LONGITUDE = 4; + private static final int INDEX_DATE_TAKEN = 5; + private static final int INDEX_DATE_ADDED = 6; + private static final int INDEX_DATE_MODIFIED = 7; + private static final int INDEX_DATA = 8; + private static final int INDEX_ORIENTATION = 9; + private static final int INDEX_BUCKET_ID = 10; + private static final int INDEX_SIZE_ID = 11; + + static final String[] PROJECTION = { + ImageColumns._ID, // 0 + ImageColumns.TITLE, // 1 + ImageColumns.MIME_TYPE, // 2 + ImageColumns.LATITUDE, // 3 + ImageColumns.LONGITUDE, // 4 + ImageColumns.DATE_TAKEN, // 5 + ImageColumns.DATE_ADDED, // 6 + ImageColumns.DATE_MODIFIED, // 7 + ImageColumns.DATA, // 8 + ImageColumns.ORIENTATION, // 9 + ImageColumns.BUCKET_ID, // 10 + ImageColumns.SIZE // 11 + }; + + private final GalleryApp mApplication; + + public int rotation; + + public LocalImage(Path path, GalleryApp application, Cursor cursor) { + super(path, nextVersionNumber()); + mApplication = application; + loadFromCursor(cursor); + } + + public LocalImage(Path path, GalleryApp application, int id) { + super(path, nextVersionNumber()); + mApplication = application; + ContentResolver resolver = mApplication.getContentResolver(); + Uri uri = Images.Media.EXTERNAL_CONTENT_URI; + Cursor cursor = LocalAlbum.getItemCursor(resolver, uri, PROJECTION, id); + if (cursor == null) { + throw new RuntimeException("cannot get cursor for: " + path); + } + try { + if (cursor.moveToNext()) { + loadFromCursor(cursor); + } else { + throw new RuntimeException("cannot find data for: " + path); + } + } finally { + cursor.close(); + } + } + + private void loadFromCursor(Cursor cursor) { + id = cursor.getInt(INDEX_ID); + caption = cursor.getString(INDEX_CAPTION); + mimeType = cursor.getString(INDEX_MIME_TYPE); + latitude = cursor.getDouble(INDEX_LATITUDE); + longitude = cursor.getDouble(INDEX_LONGITUDE); + dateTakenInMs = cursor.getLong(INDEX_DATE_TAKEN); + filePath = cursor.getString(INDEX_DATA); + rotation = cursor.getInt(INDEX_ORIENTATION); + bucketId = cursor.getInt(INDEX_BUCKET_ID); + fileSize = cursor.getLong(INDEX_SIZE_ID); + } + + @Override + protected boolean updateFromCursor(Cursor cursor) { + UpdateHelper uh = new UpdateHelper(); + id = uh.update(id, cursor.getInt(INDEX_ID)); + caption = uh.update(caption, cursor.getString(INDEX_CAPTION)); + mimeType = uh.update(mimeType, cursor.getString(INDEX_MIME_TYPE)); + latitude = uh.update(latitude, cursor.getDouble(INDEX_LATITUDE)); + longitude = uh.update(longitude, cursor.getDouble(INDEX_LONGITUDE)); + dateTakenInMs = uh.update( + dateTakenInMs, cursor.getLong(INDEX_DATE_TAKEN)); + dateAddedInSec = uh.update( + dateAddedInSec, cursor.getLong(INDEX_DATE_ADDED)); + dateModifiedInSec = uh.update( + dateModifiedInSec, cursor.getLong(INDEX_DATE_MODIFIED)); + filePath = uh.update(filePath, cursor.getString(INDEX_DATA)); + rotation = uh.update(rotation, cursor.getInt(INDEX_ORIENTATION)); + bucketId = uh.update(bucketId, cursor.getInt(INDEX_BUCKET_ID)); + fileSize = uh.update(fileSize, cursor.getLong(INDEX_SIZE_ID)); + return uh.isUpdated(); + } + + @Override + public Job<Bitmap> requestImage(int type) { + return new LocalImageRequest(mApplication, mPath, type, filePath); + } + + public static class LocalImageRequest extends ImageCacheRequest { + private String mLocalFilePath; + + LocalImageRequest(GalleryApp application, Path path, int type, + String localFilePath) { + super(application, path, type, getTargetSize(type)); + mLocalFilePath = localFilePath; + } + + @Override + public Bitmap onDecodeOriginal(JobContext jc, int type) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inPreferredConfig = Bitmap.Config.ARGB_8888; + return DecodeUtils.requestDecode( + jc, mLocalFilePath, options, getTargetSize(type)); + } + } + + static int getTargetSize(int type) { + switch (type) { + case TYPE_THUMBNAIL: + return THUMBNAIL_TARGET_SIZE; + case TYPE_MICROTHUMBNAIL: + return MICROTHUMBNAIL_TARGET_SIZE; + default: + throw new RuntimeException( + "should only request thumb/microthumb from cache"); + } + } + + @Override + public Job<BitmapRegionDecoder> requestLargeImage() { + return new LocalLargeImageRequest(filePath); + } + + public static class LocalLargeImageRequest + implements Job<BitmapRegionDecoder> { + String mLocalFilePath; + + public LocalLargeImageRequest(String localFilePath) { + mLocalFilePath = localFilePath; + } + + public BitmapRegionDecoder run(JobContext jc) { + return DecodeUtils.requestCreateBitmapRegionDecoder( + jc, mLocalFilePath, false); + } + } + + @Override + public int getSupportedOperations() { + int operation = SUPPORT_DELETE | SUPPORT_SHARE | SUPPORT_CROP + | SUPPORT_SETAS | SUPPORT_EDIT | SUPPORT_INFO; + if (BitmapUtils.isSupportedByRegionDecoder(mimeType)) { + operation |= SUPPORT_FULL_IMAGE; + } + + if (BitmapUtils.isRotationSupported(mimeType)) { + operation |= SUPPORT_ROTATE; + } + + if (GalleryUtils.isValidLocation(latitude, longitude)) { + operation |= SUPPORT_SHOW_ON_MAP; + } + return operation; + } + + @Override + public void delete() { + GalleryUtils.assertNotInRenderThread(); + Uri baseUri = Images.Media.EXTERNAL_CONTENT_URI; + mApplication.getContentResolver().delete(baseUri, "_id=?", + new String[]{String.valueOf(id)}); + } + + private static String getExifOrientation(int orientation) { + switch (orientation) { + case 0: + return String.valueOf(ExifInterface.ORIENTATION_NORMAL); + case 90: + return String.valueOf(ExifInterface.ORIENTATION_ROTATE_90); + case 180: + return String.valueOf(ExifInterface.ORIENTATION_ROTATE_180); + case 270: + return String.valueOf(ExifInterface.ORIENTATION_ROTATE_270); + default: + throw new AssertionError("invalid: " + orientation); + } + } + + @Override + public void rotate(int degrees) { + GalleryUtils.assertNotInRenderThread(); + Uri baseUri = Images.Media.EXTERNAL_CONTENT_URI; + ContentValues values = new ContentValues(); + int rotation = (this.rotation + degrees) % 360; + if (rotation < 0) rotation += 360; + + if (mimeType.equalsIgnoreCase("image/jpeg")) { + try { + ExifInterface exif = new ExifInterface(filePath); + exif.setAttribute(ExifInterface.TAG_ORIENTATION, + getExifOrientation(rotation)); + exif.saveAttributes(); + } catch (IOException e) { + Log.w(TAG, "cannot set exif data: " + filePath); + } + + // We need to update the filesize as well + fileSize = new File(filePath).length(); + values.put(Images.Media.SIZE, fileSize); + } + + values.put(Images.Media.ORIENTATION, rotation); + mApplication.getContentResolver().update(baseUri, values, "_id=?", + new String[]{String.valueOf(id)}); + } + + @Override + public Uri getContentUri() { + Uri baseUri = Images.Media.EXTERNAL_CONTENT_URI; + return baseUri.buildUpon().appendPath(String.valueOf(id)).build(); + } + + @Override + public int getMediaType() { + return MEDIA_TYPE_IMAGE; + } + + @Override + public MediaDetails getDetails() { + MediaDetails details = super.getDetails(); + details.addDetail(MediaDetails.INDEX_ORIENTATION, Integer.valueOf(rotation)); + MediaDetails.extractExifInfo(details, filePath); + return details; + } + + @Override + public int getRotation() { + return rotation; + } +} diff --git a/src/com/android/gallery3d/data/LocalMediaItem.java b/src/com/android/gallery3d/data/LocalMediaItem.java new file mode 100644 index 000000000..a76fedf32 --- /dev/null +++ b/src/com/android/gallery3d/data/LocalMediaItem.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.util.GalleryUtils; + +import android.database.Cursor; + +import java.text.DateFormat; +import java.util.Date; + +// +// LocalMediaItem is an abstract class captures those common fields +// in LocalImage and LocalVideo. +// +public abstract class LocalMediaItem extends MediaItem { + + @SuppressWarnings("unused") + private static final String TAG = "LocalMediaItem"; + + // database fields + public int id; + public String caption; + public String mimeType; + public long fileSize; + public double latitude = INVALID_LATLNG; + public double longitude = INVALID_LATLNG; + public long dateTakenInMs; + public long dateAddedInSec; + public long dateModifiedInSec; + public String filePath; + public int bucketId; + + public LocalMediaItem(Path path, long version) { + super(path, version); + } + + @Override + public long getDateInMs() { + return dateTakenInMs; + } + + @Override + public String getName() { + return caption; + } + + @Override + public void getLatLong(double[] latLong) { + latLong[0] = latitude; + latLong[1] = longitude; + } + + abstract protected boolean updateFromCursor(Cursor cursor); + + public int getBucketId() { + return bucketId; + } + + protected void updateContent(Cursor cursor) { + if (updateFromCursor(cursor)) { + mDataVersion = nextVersionNumber(); + } + } + + @Override + public MediaDetails getDetails() { + MediaDetails details = super.getDetails(); + details.addDetail(MediaDetails.INDEX_PATH, filePath); + details.addDetail(MediaDetails.INDEX_TITLE, caption); + DateFormat formater = DateFormat.getDateTimeInstance(); + details.addDetail(MediaDetails.INDEX_DATETIME, formater.format(new Date(dateTakenInMs))); + + if (GalleryUtils.isValidLocation(latitude, longitude)) { + details.addDetail(MediaDetails.INDEX_LOCATION, new double[] {latitude, longitude}); + } + if (fileSize > 0) details.addDetail(MediaDetails.INDEX_SIZE, fileSize); + return details; + } + + @Override + public String getMimeType() { + return mimeType; + } + + public long getSize() { + return fileSize; + } +} diff --git a/src/com/android/gallery3d/data/LocalMergeAlbum.java b/src/com/android/gallery3d/data/LocalMergeAlbum.java new file mode 100644 index 000000000..bb796d53a --- /dev/null +++ b/src/com/android/gallery3d/data/LocalMergeAlbum.java @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import java.lang.ref.SoftReference; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.SortedMap; +import java.util.TreeMap; + +// MergeAlbum merges items from two or more MediaSets. It uses a Comparator to +// determine the order of items. The items are assumed to be sorted in the input +// media sets (with the same order that the Comparator uses). +// +// This only handles MediaItems, not SubMediaSets. +public class LocalMergeAlbum extends MediaSet implements ContentListener { + @SuppressWarnings("unused") + private static final String TAG = "LocalMergeAlbum"; + private static final int PAGE_SIZE = 64; + + private final Comparator<MediaItem> mComparator; + private final MediaSet[] mSources; + + private String mName; + private FetchCache[] mFetcher; + private int mSupportedOperation; + + // mIndex maps global position to the position of each underlying media sets. + private TreeMap<Integer, int[]> mIndex = new TreeMap<Integer, int[]>(); + + public LocalMergeAlbum( + Path path, Comparator<MediaItem> comparator, MediaSet[] sources) { + super(path, INVALID_DATA_VERSION); + mComparator = comparator; + mSources = sources; + mName = sources.length == 0 ? "" : sources[0].getName(); + for (MediaSet set : mSources) { + set.addContentListener(this); + } + } + + private void updateData() { + ArrayList<MediaSet> matches = new ArrayList<MediaSet>(); + int supported = mSources.length == 0 ? 0 : MediaItem.SUPPORT_ALL; + mFetcher = new FetchCache[mSources.length]; + for (int i = 0, n = mSources.length; i < n; ++i) { + mFetcher[i] = new FetchCache(mSources[i]); + supported &= mSources[i].getSupportedOperations(); + } + mSupportedOperation = supported; + mIndex.clear(); + mIndex.put(0, new int[mSources.length]); + mName = mSources.length == 0 ? "" : mSources[0].getName(); + } + + private void invalidateCache() { + for (int i = 0, n = mSources.length; i < n; i++) { + mFetcher[i].invalidate(); + } + mIndex.clear(); + mIndex.put(0, new int[mSources.length]); + } + + @Override + public String getName() { + return mName; + } + + @Override + public int getMediaItemCount() { + return getTotalMediaItemCount(); + } + + @Override + public ArrayList<MediaItem> getMediaItem(int start, int count) { + + // First find the nearest mark position <= start. + SortedMap<Integer, int[]> head = mIndex.headMap(start + 1); + int markPos = head.lastKey(); + int[] subPos = head.get(markPos).clone(); + MediaItem[] slot = new MediaItem[mSources.length]; + + int size = mSources.length; + + // fill all slots + for (int i = 0; i < size; i++) { + slot[i] = mFetcher[i].getItem(subPos[i]); + } + + ArrayList<MediaItem> result = new ArrayList<MediaItem>(); + + for (int i = markPos; i < start + count; i++) { + int k = -1; // k points to the best slot up to now. + for (int j = 0; j < size; j++) { + if (slot[j] != null) { + if (k == -1 || mComparator.compare(slot[j], slot[k]) < 0) { + k = j; + } + } + } + + // If we don't have anything, all streams are exhausted. + if (k == -1) break; + + // Pick the best slot and refill it. + subPos[k]++; + if (i >= start) { + result.add(slot[k]); + } + slot[k] = mFetcher[k].getItem(subPos[k]); + + // Periodically leave a mark in the index, so we can come back later. + if ((i + 1) % PAGE_SIZE == 0) { + mIndex.put(i + 1, subPos.clone()); + } + } + + return result; + } + + @Override + public int getTotalMediaItemCount() { + int count = 0; + for (MediaSet set : mSources) { + count += set.getTotalMediaItemCount(); + } + return count; + } + + @Override + public long reload() { + boolean changed = false; + for (int i = 0, n = mSources.length; i < n; ++i) { + if (mSources[i].reload() > mDataVersion) changed = true; + } + if (changed) { + mDataVersion = nextVersionNumber(); + updateData(); + invalidateCache(); + } + return mDataVersion; + } + + @Override + public void onContentDirty() { + notifyContentChanged(); + } + + @Override + public int getSupportedOperations() { + return mSupportedOperation; + } + + @Override + public void delete() { + for (MediaSet set : mSources) { + set.delete(); + } + } + + @Override + public void rotate(int degrees) { + for (MediaSet set : mSources) { + set.rotate(degrees); + } + } + + private static class FetchCache { + private MediaSet mBaseSet; + private SoftReference<ArrayList<MediaItem>> mCacheRef; + private int mStartPos; + + public FetchCache(MediaSet baseSet) { + mBaseSet = baseSet; + } + + public void invalidate() { + mCacheRef = null; + } + + public MediaItem getItem(int index) { + boolean needLoading = false; + ArrayList<MediaItem> cache = null; + if (mCacheRef == null + || index < mStartPos || index >= mStartPos + PAGE_SIZE) { + needLoading = true; + } else { + cache = mCacheRef.get(); + if (cache == null) { + needLoading = true; + } + } + + if (needLoading) { + cache = mBaseSet.getMediaItem(index, PAGE_SIZE); + mCacheRef = new SoftReference<ArrayList<MediaItem>>(cache); + mStartPos = index; + } + + if (index < mStartPos || index >= mStartPos + cache.size()) { + return null; + } + + return cache.get(index - mStartPos); + } + } + + @Override + public boolean isLeafAlbum() { + return true; + } +} diff --git a/src/com/android/gallery3d/data/LocalSource.java b/src/com/android/gallery3d/data/LocalSource.java new file mode 100644 index 000000000..58ac22490 --- /dev/null +++ b/src/com/android/gallery3d/data/LocalSource.java @@ -0,0 +1,272 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.app.Gallery; +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.data.MediaSet.ItemConsumer; + +import android.content.ContentProviderClient; +import android.content.ContentUris; +import android.content.UriMatcher; +import android.net.Uri; +import android.provider.MediaStore; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; + +class LocalSource extends MediaSource { + + public static final String KEY_BUCKET_ID = "bucketId"; + + private GalleryApp mApplication; + private PathMatcher mMatcher; + private static final int NO_MATCH = -1; + private final UriMatcher mUriMatcher = new UriMatcher(NO_MATCH); + public static final Comparator<PathId> sIdComparator = new IdComparator(); + + private static final int LOCAL_IMAGE_ALBUMSET = 0; + private static final int LOCAL_VIDEO_ALBUMSET = 1; + private static final int LOCAL_IMAGE_ALBUM = 2; + private static final int LOCAL_VIDEO_ALBUM = 3; + private static final int LOCAL_IMAGE_ITEM = 4; + private static final int LOCAL_VIDEO_ITEM = 5; + private static final int LOCAL_ALL_ALBUMSET = 6; + private static final int LOCAL_ALL_ALBUM = 7; + + private static final String TAG = "LocalSource"; + + private ContentProviderClient mClient; + + public LocalSource(GalleryApp context) { + super("local"); + mApplication = context; + mMatcher = new PathMatcher(); + mMatcher.add("/local/image", LOCAL_IMAGE_ALBUMSET); + mMatcher.add("/local/video", LOCAL_VIDEO_ALBUMSET); + mMatcher.add("/local/all", LOCAL_ALL_ALBUMSET); + + mMatcher.add("/local/image/*", LOCAL_IMAGE_ALBUM); + mMatcher.add("/local/video/*", LOCAL_VIDEO_ALBUM); + mMatcher.add("/local/all/*", LOCAL_ALL_ALBUM); + mMatcher.add("/local/image/item/*", LOCAL_IMAGE_ITEM); + mMatcher.add("/local/video/item/*", LOCAL_VIDEO_ITEM); + + mUriMatcher.addURI(MediaStore.AUTHORITY, + "external/images/media/#", LOCAL_IMAGE_ITEM); + mUriMatcher.addURI(MediaStore.AUTHORITY, + "external/video/media/#", LOCAL_VIDEO_ITEM); + mUriMatcher.addURI(MediaStore.AUTHORITY, + "external/images/media", LOCAL_IMAGE_ALBUM); + mUriMatcher.addURI(MediaStore.AUTHORITY, + "external/video/media", LOCAL_VIDEO_ALBUM); + } + + @Override + public MediaObject createMediaObject(Path path) { + GalleryApp app = mApplication; + switch (mMatcher.match(path)) { + case LOCAL_ALL_ALBUMSET: + case LOCAL_IMAGE_ALBUMSET: + case LOCAL_VIDEO_ALBUMSET: + return new LocalAlbumSet(path, mApplication); + case LOCAL_IMAGE_ALBUM: + return new LocalAlbum(path, app, mMatcher.getIntVar(0), true); + case LOCAL_VIDEO_ALBUM: + return new LocalAlbum(path, app, mMatcher.getIntVar(0), false); + case LOCAL_ALL_ALBUM: { + int bucketId = mMatcher.getIntVar(0); + DataManager dataManager = app.getDataManager(); + MediaSet imageSet = (MediaSet) dataManager.getMediaObject( + LocalAlbumSet.PATH_IMAGE.getChild(bucketId)); + MediaSet videoSet = (MediaSet) dataManager.getMediaObject( + LocalAlbumSet.PATH_VIDEO.getChild(bucketId)); + Comparator<MediaItem> comp = DataManager.sDateTakenComparator; + return new LocalMergeAlbum( + path, comp, new MediaSet[] {imageSet, videoSet}); + } + case LOCAL_IMAGE_ITEM: + return new LocalImage(path, mApplication, mMatcher.getIntVar(0)); + case LOCAL_VIDEO_ITEM: + return new LocalVideo(path, mApplication, mMatcher.getIntVar(0)); + default: + throw new RuntimeException("bad path: " + path); + } + } + + private static int getMediaType(String type, int defaultType) { + if (type == null) return defaultType; + try { + int value = Integer.parseInt(type); + if ((value & (MEDIA_TYPE_IMAGE + | MEDIA_TYPE_VIDEO)) != 0) return value; + } catch (NumberFormatException e) { + Log.w(TAG, "invalid type: " + type, e); + } + return defaultType; + } + + // The media type bit passed by the intent + private static final int MEDIA_TYPE_IMAGE = 1; + private static final int MEDIA_TYPE_VIDEO = 4; + + private Path getAlbumPath(Uri uri, int defaultType) { + int mediaType = getMediaType( + uri.getQueryParameter(Gallery.KEY_MEDIA_TYPES), + defaultType); + String bucketId = uri.getQueryParameter(KEY_BUCKET_ID); + int id = 0; + try { + id = Integer.parseInt(bucketId); + } catch (NumberFormatException e) { + Log.w(TAG, "invalid bucket id: " + bucketId, e); + return null; + } + switch (mediaType) { + case MEDIA_TYPE_IMAGE: + return Path.fromString("/local/image").getChild(id); + case MEDIA_TYPE_VIDEO: + return Path.fromString("/local/video").getChild(id); + default: + return Path.fromString("/merge/{/local/image,/local/video}") + .getChild(id); + } + } + + @Override + public Path findPathByUri(Uri uri) { + try { + switch (mUriMatcher.match(uri)) { + case LOCAL_IMAGE_ITEM: { + long id = ContentUris.parseId(uri); + return id >= 0 ? LocalImage.ITEM_PATH.getChild(id) : null; + } + case LOCAL_VIDEO_ITEM: { + long id = ContentUris.parseId(uri); + return id >= 0 ? LocalVideo.ITEM_PATH.getChild(id) : null; + } + case LOCAL_IMAGE_ALBUM: { + return getAlbumPath(uri, MEDIA_TYPE_IMAGE); + } + case LOCAL_VIDEO_ALBUM: { + return getAlbumPath(uri, MEDIA_TYPE_VIDEO); + } + } + } catch (NumberFormatException e) { + Log.w(TAG, "uri: " + uri.toString(), e); + } + return null; + } + + @Override + public Path getDefaultSetOf(Path item) { + MediaObject object = mApplication.getDataManager().getMediaObject(item); + if (object instanceof LocalImage) { + return Path.fromString("/local/image/").getChild( + String.valueOf(((LocalImage) object).getBucketId())); + } else if (object instanceof LocalVideo) { + return Path.fromString("/local/video/").getChild( + String.valueOf(((LocalVideo) object).getBucketId())); + } + return null; + } + + @Override + public void mapMediaItems(ArrayList<PathId> list, ItemConsumer consumer) { + ArrayList<PathId> imageList = new ArrayList<PathId>(); + ArrayList<PathId> videoList = new ArrayList<PathId>(); + int n = list.size(); + for (int i = 0; i < n; i++) { + PathId pid = list.get(i); + // We assume the form is: "/local/{image,video}/item/#" + // We don't use mMatcher for efficiency's reason. + Path parent = pid.path.getParent(); + if (parent == LocalImage.ITEM_PATH) { + imageList.add(pid); + } else if (parent == LocalVideo.ITEM_PATH) { + videoList.add(pid); + } + } + // TODO: use "files" table so we can merge the two cases. + processMapMediaItems(imageList, consumer, true); + processMapMediaItems(videoList, consumer, false); + } + + private void processMapMediaItems(ArrayList<PathId> list, + ItemConsumer consumer, boolean isImage) { + // Sort path by path id + Collections.sort(list, sIdComparator); + int n = list.size(); + for (int i = 0; i < n; ) { + PathId pid = list.get(i); + + // Find a range of items. + ArrayList<Integer> ids = new ArrayList<Integer>(); + int startId = Integer.parseInt(pid.path.getSuffix()); + ids.add(startId); + + int j; + for (j = i + 1; j < n; j++) { + PathId pid2 = list.get(j); + int curId = Integer.parseInt(pid2.path.getSuffix()); + if (curId - startId >= MediaSet.MEDIAITEM_BATCH_FETCH_COUNT) { + break; + } + ids.add(curId); + } + + MediaItem[] items = LocalAlbum.getMediaItemById( + mApplication, isImage, ids); + for(int k = i ; k < j; k++) { + PathId pid2 = list.get(k); + consumer.consume(pid2.id, items[k - i]); + } + + i = j; + } + } + + // This is a comparator which compares the suffix number in two Paths. + private static class IdComparator implements Comparator<PathId> { + public int compare(PathId p1, PathId p2) { + String s1 = p1.path.getSuffix(); + String s2 = p2.path.getSuffix(); + int len1 = s1.length(); + int len2 = s2.length(); + if (len1 < len2) { + return -1; + } else if (len1 > len2) { + return 1; + } else { + return s1.compareTo(s2); + } + } + } + + @Override + public void resume() { + mClient = mApplication.getContentResolver() + .acquireContentProviderClient(MediaStore.AUTHORITY); + } + + @Override + public void pause() { + mClient.release(); + mClient = null; + } +} diff --git a/src/com/android/gallery3d/data/LocalVideo.java b/src/com/android/gallery3d/data/LocalVideo.java new file mode 100644 index 000000000..d1498e856 --- /dev/null +++ b/src/com/android/gallery3d/data/LocalVideo.java @@ -0,0 +1,213 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.R; +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.common.BitmapUtils; +import com.android.gallery3d.util.UpdateHelper; +import com.android.gallery3d.util.GalleryUtils; +import com.android.gallery3d.util.ThreadPool.Job; +import com.android.gallery3d.util.ThreadPool.JobContext; + +import android.content.ContentResolver; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.BitmapRegionDecoder; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.net.Uri; +import android.provider.MediaStore.Video; +import android.provider.MediaStore.Video.VideoColumns; + +import java.io.File; + +// LocalVideo represents a video in the local storage. +public class LocalVideo extends LocalMediaItem { + + static final Path ITEM_PATH = Path.fromString("/local/video/item"); + + // Must preserve order between these indices and the order of the terms in + // the following PROJECTION array. + private static final int INDEX_ID = 0; + private static final int INDEX_CAPTION = 1; + private static final int INDEX_MIME_TYPE = 2; + private static final int INDEX_LATITUDE = 3; + private static final int INDEX_LONGITUDE = 4; + private static final int INDEX_DATE_TAKEN = 5; + private static final int INDEX_DATE_ADDED = 6; + private static final int INDEX_DATE_MODIFIED = 7; + private static final int INDEX_DATA = 8; + private static final int INDEX_DURATION = 9; + private static final int INDEX_BUCKET_ID = 10; + private static final int INDEX_SIZE_ID = 11; + + static final String[] PROJECTION = new String[] { + VideoColumns._ID, + VideoColumns.TITLE, + VideoColumns.MIME_TYPE, + VideoColumns.LATITUDE, + VideoColumns.LONGITUDE, + VideoColumns.DATE_TAKEN, + VideoColumns.DATE_ADDED, + VideoColumns.DATE_MODIFIED, + VideoColumns.DATA, + VideoColumns.DURATION, + VideoColumns.BUCKET_ID, + VideoColumns.SIZE + }; + + private final GalleryApp mApplication; + private static Bitmap sOverlay; + + public int durationInSec; + + public LocalVideo(Path path, GalleryApp application, Cursor cursor) { + super(path, nextVersionNumber()); + mApplication = application; + loadFromCursor(cursor); + } + + public LocalVideo(Path path, GalleryApp context, int id) { + super(path, nextVersionNumber()); + mApplication = context; + ContentResolver resolver = mApplication.getContentResolver(); + Uri uri = Video.Media.EXTERNAL_CONTENT_URI; + Cursor cursor = LocalAlbum.getItemCursor(resolver, uri, PROJECTION, id); + if (cursor == null) { + throw new RuntimeException("cannot get cursor for: " + path); + } + try { + if (cursor.moveToNext()) { + loadFromCursor(cursor); + } else { + throw new RuntimeException("cannot find data for: " + path); + } + } finally { + cursor.close(); + } + } + + private void loadFromCursor(Cursor cursor) { + id = cursor.getInt(INDEX_ID); + caption = cursor.getString(INDEX_CAPTION); + mimeType = cursor.getString(INDEX_MIME_TYPE); + latitude = cursor.getDouble(INDEX_LATITUDE); + longitude = cursor.getDouble(INDEX_LONGITUDE); + dateTakenInMs = cursor.getLong(INDEX_DATE_TAKEN); + filePath = cursor.getString(INDEX_DATA); + durationInSec = cursor.getInt(INDEX_DURATION) / 1000; + bucketId = cursor.getInt(INDEX_BUCKET_ID); + fileSize = cursor.getLong(INDEX_SIZE_ID); + } + + @Override + protected boolean updateFromCursor(Cursor cursor) { + UpdateHelper uh = new UpdateHelper(); + id = uh.update(id, cursor.getInt(INDEX_ID)); + caption = uh.update(caption, cursor.getString(INDEX_CAPTION)); + mimeType = uh.update(mimeType, cursor.getString(INDEX_MIME_TYPE)); + latitude = uh.update(latitude, cursor.getDouble(INDEX_LATITUDE)); + longitude = uh.update(longitude, cursor.getDouble(INDEX_LONGITUDE)); + dateTakenInMs = uh.update( + dateTakenInMs, cursor.getLong(INDEX_DATE_TAKEN)); + dateAddedInSec = uh.update( + dateAddedInSec, cursor.getLong(INDEX_DATE_ADDED)); + dateModifiedInSec = uh.update( + dateModifiedInSec, cursor.getLong(INDEX_DATE_MODIFIED)); + filePath = uh.update(filePath, cursor.getString(INDEX_DATA)); + durationInSec = uh.update( + durationInSec, cursor.getInt(INDEX_DURATION) / 1000); + bucketId = uh.update(bucketId, cursor.getInt(INDEX_BUCKET_ID)); + fileSize = uh.update(fileSize, cursor.getLong(INDEX_SIZE_ID)); + return uh.isUpdated(); + } + + @Override + public Job<Bitmap> requestImage(int type) { + return new LocalVideoRequest(mApplication, getPath(), type, filePath); + } + + public static class LocalVideoRequest extends ImageCacheRequest { + private String mLocalFilePath; + + LocalVideoRequest(GalleryApp application, Path path, int type, + String localFilePath) { + super(application, path, type, LocalImage.getTargetSize(type)); + mLocalFilePath = localFilePath; + } + + @Override + public Bitmap onDecodeOriginal(JobContext jc, int type) { + Bitmap bitmap = BitmapUtils.createVideoThumbnail(mLocalFilePath); + if (bitmap == null || jc.isCancelled()) return null; + return bitmap; + } + } + + @Override + public Job<BitmapRegionDecoder> requestLargeImage() { + throw new UnsupportedOperationException("Cannot regquest a large image" + + " to a local video!"); + } + + @Override + public int getSupportedOperations() { + return SUPPORT_DELETE | SUPPORT_SHARE | SUPPORT_PLAY | SUPPORT_INFO; + } + + @Override + public void delete() { + GalleryUtils.assertNotInRenderThread(); + Uri baseUri = Video.Media.EXTERNAL_CONTENT_URI; + mApplication.getContentResolver().delete(baseUri, "_id=?", + new String[]{String.valueOf(id)}); + } + + @Override + public void rotate(int degrees) { + // TODO + } + + @Override + public Uri getContentUri() { + Uri baseUri = Video.Media.EXTERNAL_CONTENT_URI; + return baseUri.buildUpon().appendPath(String.valueOf(id)).build(); + } + + @Override + public Uri getPlayUri() { + return Uri.fromFile(new File(filePath)); + } + + @Override + public int getMediaType() { + return MEDIA_TYPE_VIDEO; + } + + @Override + public MediaDetails getDetails() { + MediaDetails details = super.getDetails(); + int s = durationInSec; + if (s > 0) { + details.addDetail(MediaDetails.INDEX_DURATION, GalleryUtils.formatDuration( + mApplication.getAndroidContext(), durationInSec)); + } + return details; + } +} diff --git a/src/com/android/gallery3d/data/LocationClustering.java b/src/com/android/gallery3d/data/LocationClustering.java new file mode 100644 index 000000000..3cb1399e5 --- /dev/null +++ b/src/com/android/gallery3d/data/LocationClustering.java @@ -0,0 +1,304 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.R; +import com.android.gallery3d.util.ReverseGeocoder; +import com.android.gallery3d.util.GalleryUtils; + +import android.content.Context; +import android.widget.Toast; + +import java.util.ArrayList; + +class LocationClustering extends Clustering { + private static final String TAG = "LocationClustering"; + + private static final int MIN_GROUPS = 1; + private static final int MAX_GROUPS = 20; + private static final int MAX_ITERATIONS = 30; + + // If the total distance change is less than this ratio, stop iterating. + private static final float STOP_CHANGE_RATIO = 0.01f; + private Context mContext; + private ArrayList<ArrayList<SmallItem>> mClusters; + private ArrayList<String> mNames; + private String mNoLocationString; + + private static class Point { + public Point(double lat, double lng) { + latRad = Math.toRadians(lat); + lngRad = Math.toRadians(lng); + } + public Point() {} + public double latRad, lngRad; + } + + private static class SmallItem { + Path path; + double lat, lng; + } + + public LocationClustering(Context context) { + mContext = context; + mNoLocationString = mContext.getResources().getString(R.string.no_location); + } + + @Override + public void run(MediaSet baseSet) { + final int total = baseSet.getTotalMediaItemCount(); + final SmallItem[] buf = new SmallItem[total]; + // Separate items to two sets: with or without lat-long. + final double[] latLong = new double[2]; + baseSet.enumerateTotalMediaItems(new MediaSet.ItemConsumer() { + public void consume(int index, MediaItem item) { + if (index < 0 || index >= total) return; + SmallItem s = new SmallItem(); + s.path = item.getPath(); + item.getLatLong(latLong); + s.lat = latLong[0]; + s.lng = latLong[1]; + buf[index] = s; + } + }); + + final ArrayList<SmallItem> withLatLong = new ArrayList<SmallItem>(); + final ArrayList<SmallItem> withoutLatLong = new ArrayList<SmallItem>(); + final ArrayList<Point> points = new ArrayList<Point>(); + for (int i = 0; i < total; i++) { + SmallItem s = buf[i]; + if (s == null) continue; + if (GalleryUtils.isValidLocation(s.lat, s.lng)) { + withLatLong.add(s); + points.add(new Point(s.lat, s.lng)); + } else { + withoutLatLong.add(s); + } + } + + ArrayList<ArrayList<SmallItem>> clusters = new ArrayList<ArrayList<SmallItem>>(); + + int m = withLatLong.size(); + if (m > 0) { + // cluster the items with lat-long + Point[] pointsArray = new Point[m]; + pointsArray = points.toArray(pointsArray); + int[] bestK = new int[1]; + int[] index = kMeans(pointsArray, bestK); + + for (int i = 0; i < bestK[0]; i++) { + clusters.add(new ArrayList<SmallItem>()); + } + + for (int i = 0; i < m; i++) { + clusters.get(index[i]).add(withLatLong.get(i)); + } + } + + ReverseGeocoder geocoder = new ReverseGeocoder(mContext); + mNames = new ArrayList<String>(); + boolean hasUnresolvedAddress = false; + mClusters = new ArrayList<ArrayList<SmallItem>>(); + for (ArrayList<SmallItem> cluster : clusters) { + String name = generateName(cluster, geocoder); + if (name != null) { + mNames.add(name); + mClusters.add(cluster); + } else { + // move cluster-i to no location cluster + withoutLatLong.addAll(cluster); + hasUnresolvedAddress = true; + } + } + + if (withoutLatLong.size() > 0) { + mNames.add(mNoLocationString); + mClusters.add(withoutLatLong); + } + + if (hasUnresolvedAddress) { + Toast.makeText(mContext, R.string.no_connectivity, + Toast.LENGTH_LONG).show(); + } + } + + private static String generateName(ArrayList<SmallItem> items, + ReverseGeocoder geocoder) { + ReverseGeocoder.SetLatLong set = new ReverseGeocoder.SetLatLong(); + + int n = items.size(); + for (int i = 0; i < n; i++) { + SmallItem item = items.get(i); + double itemLatitude = item.lat; + double itemLongitude = item.lng; + + if (set.mMinLatLatitude > itemLatitude) { + set.mMinLatLatitude = itemLatitude; + set.mMinLatLongitude = itemLongitude; + } + if (set.mMaxLatLatitude < itemLatitude) { + set.mMaxLatLatitude = itemLatitude; + set.mMaxLatLongitude = itemLongitude; + } + if (set.mMinLonLongitude > itemLongitude) { + set.mMinLonLatitude = itemLatitude; + set.mMinLonLongitude = itemLongitude; + } + if (set.mMaxLonLongitude < itemLongitude) { + set.mMaxLonLatitude = itemLatitude; + set.mMaxLonLongitude = itemLongitude; + } + } + + return geocoder.computeAddress(set); + } + + @Override + public int getNumberOfClusters() { + return mClusters.size(); + } + + @Override + public ArrayList<Path> getCluster(int index) { + ArrayList<SmallItem> items = mClusters.get(index); + ArrayList<Path> result = new ArrayList<Path>(items.size()); + for (int i = 0, n = items.size(); i < n; i++) { + result.add(items.get(i).path); + } + return result; + } + + @Override + public String getClusterName(int index) { + return mNames.get(index); + } + + // Input: n points + // Output: the best k is stored in bestK[0], and the return value is the + // an array which specifies the group that each point belongs (0 to k - 1). + private static int[] kMeans(Point points[], int[] bestK) { + int n = points.length; + + // min and max number of groups wanted + int minK = Math.min(n, MIN_GROUPS); + int maxK = Math.min(n, MAX_GROUPS); + + Point[] center = new Point[maxK]; // center of each group. + Point[] groupSum = new Point[maxK]; // sum of points in each group. + int[] groupCount = new int[maxK]; // number of points in each group. + int[] grouping = new int[n]; // The group assignment for each point. + + for (int i = 0; i < maxK; i++) { + center[i] = new Point(); + groupSum[i] = new Point(); + } + + // The score we want to minimize is: + // (sum of distance from each point to its group center) * sqrt(k). + float bestScore = Float.MAX_VALUE; + // The best group assignment up to now. + int[] bestGrouping = new int[n]; + // The best K up to now. + bestK[0] = 1; + + float lastDistance = 0; + float totalDistance = 0; + + for (int k = minK; k <= maxK; k++) { + // step 1: (arbitrarily) pick k points as the initial centers. + int delta = n / k; + for (int i = 0; i < k; i++) { + Point p = points[i * delta]; + center[i].latRad = p.latRad; + center[i].lngRad = p.lngRad; + } + + for (int iter = 0; iter < MAX_ITERATIONS; iter++) { + // step 2: assign each point to the nearest center. + for (int i = 0; i < k; i++) { + groupSum[i].latRad = 0; + groupSum[i].lngRad = 0; + groupCount[i] = 0; + } + totalDistance = 0; + + for (int i = 0; i < n; i++) { + Point p = points[i]; + float bestDistance = Float.MAX_VALUE; + int bestIndex = 0; + for (int j = 0; j < k; j++) { + float distance = (float) GalleryUtils.fastDistanceMeters( + p.latRad, p.lngRad, center[j].latRad, center[j].lngRad); + // We may have small non-zero distance introduced by + // floating point calculation, so zero out small + // distances less than 1 meter. + if (distance < 1) { + distance = 0; + } + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = j; + } + } + grouping[i] = bestIndex; + groupCount[bestIndex]++; + groupSum[bestIndex].latRad += p.latRad; + groupSum[bestIndex].lngRad += p.lngRad; + totalDistance += bestDistance; + } + + // step 3: calculate new centers + for (int i = 0; i < k; i++) { + if (groupCount[i] > 0) { + center[i].latRad = groupSum[i].latRad / groupCount[i]; + center[i].lngRad = groupSum[i].lngRad / groupCount[i]; + } + } + + if (totalDistance == 0 || (Math.abs(lastDistance - totalDistance) + / totalDistance) < STOP_CHANGE_RATIO) { + break; + } + lastDistance = totalDistance; + } + + // step 4: remove empty groups and reassign group number + int reassign[] = new int[k]; + int realK = 0; + for (int i = 0; i < k; i++) { + if (groupCount[i] > 0) { + reassign[i] = realK++; + } + } + + // step 5: calculate the final score + float score = totalDistance * (float) Math.sqrt(realK); + + if (score < bestScore) { + bestScore = score; + bestK[0] = realK; + for (int i = 0; i < n; i++) { + bestGrouping[i] = reassign[grouping[i]]; + } + if (score == 0) { + break; + } + } + } + return bestGrouping; + } +} diff --git a/src/com/android/gallery3d/data/Log.java b/src/com/android/gallery3d/data/Log.java new file mode 100644 index 000000000..3384eb66c --- /dev/null +++ b/src/com/android/gallery3d/data/Log.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +public class Log { + public static int v(String tag, String msg) { + return android.util.Log.v(tag, msg); + } + public static int v(String tag, String msg, Throwable tr) { + return android.util.Log.v(tag, msg, tr); + } + public static int d(String tag, String msg) { + return android.util.Log.d(tag, msg); + } + public static int d(String tag, String msg, Throwable tr) { + return android.util.Log.d(tag, msg, tr); + } + public static int i(String tag, String msg) { + return android.util.Log.i(tag, msg); + } + public static int i(String tag, String msg, Throwable tr) { + return android.util.Log.i(tag, msg, tr); + } + public static int w(String tag, String msg) { + return android.util.Log.w(tag, msg); + } + public static int w(String tag, String msg, Throwable tr) { + return android.util.Log.w(tag, msg, tr); + } + public static int w(String tag, Throwable tr) { + return android.util.Log.w(tag, tr); + } + public static int e(String tag, String msg) { + return android.util.Log.e(tag, msg); + } + public static int e(String tag, String msg, Throwable tr) { + return android.util.Log.e(tag, msg, tr); + } +} diff --git a/src/com/android/gallery3d/data/MediaDetails.java b/src/com/android/gallery3d/data/MediaDetails.java new file mode 100644 index 000000000..1b56ac42e --- /dev/null +++ b/src/com/android/gallery3d/data/MediaDetails.java @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.R; + +import android.media.ExifInterface; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Iterator; +import java.util.TreeMap; +import java.util.Map.Entry; + +public class MediaDetails implements Iterable<Entry<Integer, Object>> { + @SuppressWarnings("unused") + private static final String TAG = "MediaDetails"; + + private TreeMap<Integer, Object> mDetails = new TreeMap<Integer, Object>(); + private HashMap<Integer, Integer> mUnits = new HashMap<Integer, Integer>(); + + public static final int INDEX_TITLE = 1; + public static final int INDEX_DESCRIPTION = 2; + public static final int INDEX_DATETIME = 3; + public static final int INDEX_LOCATION = 4; + public static final int INDEX_WIDTH = 5; + public static final int INDEX_HEIGHT = 6; + public static final int INDEX_ORIENTATION = 7; + public static final int INDEX_DURATION = 8; + public static final int INDEX_MIMETYPE = 9; + public static final int INDEX_SIZE = 10; + + // for EXIF + public static final int INDEX_MAKE = 100; + public static final int INDEX_MODEL = 101; + public static final int INDEX_FLASH = 102; + public static final int INDEX_FOCAL_LENGTH = 103; + public static final int INDEX_WHITE_BALANCE = 104; + public static final int INDEX_APERTURE = 105; + public static final int INDEX_SHUTTER_SPEED = 106; + public static final int INDEX_EXPOSURE_TIME = 107; + public static final int INDEX_ISO = 108; + + // Put this last because it may be long. + public static final int INDEX_PATH = 200; + + public static class FlashState { + private static int FLASH_FIRED_MASK = 1; + private static int FLASH_RETURN_MASK = 2 | 4; + private static int FLASH_MODE_MASK = 8 | 16; + private static int FLASH_FUNCTION_MASK = 32; + private static int FLASH_RED_EYE_MASK = 64; + private int mState; + + public FlashState(int state) { + mState = state; + } + + public boolean isFlashFired() { + return (mState & FLASH_FIRED_MASK) != 0; + } + + public int getFlashReturn() { + return (mState & FLASH_RETURN_MASK) >> 1; + } + + public int getFlashMode() { + return (mState & FLASH_MODE_MASK) >> 3; + } + + public boolean isFlashPresent() { + return (mState & FLASH_FUNCTION_MASK) != 0; + } + + public boolean isRedEyeModePresent() { + return (mState & FLASH_RED_EYE_MASK) != 0; + } + } + + public void addDetail(int index, Object value) { + mDetails.put(index, value); + } + + public Object getDetail(int index) { + return mDetails.get(index); + } + + public int size() { + return mDetails.size(); + } + + public Iterator<Entry<Integer, Object>> iterator() { + return mDetails.entrySet().iterator(); + } + + public void setUnit(int index, int unit) { + mUnits.put(index, unit); + } + + public boolean hasUnit(int index) { + return mUnits.containsKey(index); + } + + public int getUnit(int index) { + return mUnits.get(index); + } + + private static void setExifData(MediaDetails details, ExifInterface exif, String tag, + int key) { + String value = exif.getAttribute(tag); + if (value != null) { + if (key == MediaDetails.INDEX_FLASH) { + MediaDetails.FlashState state = new MediaDetails.FlashState( + Integer.valueOf(value.toString())); + details.addDetail(key, state); + } else { + details.addDetail(key, value); + } + } + } + + public static void extractExifInfo(MediaDetails details, String filePath) { + try { + ExifInterface exif = new ExifInterface(filePath); + setExifData(details, exif, ExifInterface.TAG_FLASH, MediaDetails.INDEX_FLASH); + setExifData(details, exif, ExifInterface.TAG_IMAGE_WIDTH, MediaDetails.INDEX_WIDTH); + setExifData(details, exif, ExifInterface.TAG_IMAGE_LENGTH, + MediaDetails.INDEX_HEIGHT); + setExifData(details, exif, ExifInterface.TAG_MAKE, MediaDetails.INDEX_MAKE); + setExifData(details, exif, ExifInterface.TAG_MODEL, MediaDetails.INDEX_MODEL); + setExifData(details, exif, ExifInterface.TAG_APERTURE, MediaDetails.INDEX_APERTURE); + setExifData(details, exif, ExifInterface.TAG_ISO, MediaDetails.INDEX_ISO); + setExifData(details, exif, ExifInterface.TAG_WHITE_BALANCE, + MediaDetails.INDEX_WHITE_BALANCE); + setExifData(details, exif, ExifInterface.TAG_EXPOSURE_TIME, + MediaDetails.INDEX_EXPOSURE_TIME); + + double data = exif.getAttributeDouble(ExifInterface.TAG_FOCAL_LENGTH, 0); + if (data != 0f) { + details.addDetail(MediaDetails.INDEX_FOCAL_LENGTH, data); + details.setUnit(MediaDetails.INDEX_FOCAL_LENGTH, R.string.unit_mm); + } + } catch (IOException ex) { + // ignore it. + Log.w(TAG, "", ex); + } + } +} diff --git a/src/com/android/gallery3d/data/MediaItem.java b/src/com/android/gallery3d/data/MediaItem.java new file mode 100644 index 000000000..430d8327d --- /dev/null +++ b/src/com/android/gallery3d/data/MediaItem.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.util.ThreadPool.Job; + +import android.graphics.Bitmap; +import android.graphics.BitmapRegionDecoder; + +// MediaItem represents an image or a video item. +public abstract class MediaItem extends MediaObject { + // NOTE: These type numbers are stored in the image cache, so it should not + // not be changed without resetting the cache. + public static final int TYPE_THUMBNAIL = 1; + public static final int TYPE_MICROTHUMBNAIL = 2; + + public static final int IMAGE_READY = 0; + public static final int IMAGE_WAIT = 1; + public static final int IMAGE_ERROR = -1; + + // TODO: fix default value for latlng and change this. + public static final double INVALID_LATLNG = 0f; + + public abstract Job<Bitmap> requestImage(int type); + public abstract Job<BitmapRegionDecoder> requestLargeImage(); + + public MediaItem(Path path, long version) { + super(path, version); + } + + public long getDateInMs() { + return 0; + } + + public String getName() { + return null; + } + + public void getLatLong(double[] latLong) { + latLong[0] = INVALID_LATLNG; + latLong[1] = INVALID_LATLNG; + } + + public String[] getTags() { + return null; + } + + public Face[] getFaces() { + return null; + } + + public int getRotation() { + return 0; + } + + public long getSize() { + return 0; + } + + public abstract String getMimeType(); +} diff --git a/src/com/android/gallery3d/data/MediaObject.java b/src/com/android/gallery3d/data/MediaObject.java new file mode 100644 index 000000000..d0f1672fc --- /dev/null +++ b/src/com/android/gallery3d/data/MediaObject.java @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.net.Uri; + +public abstract class MediaObject { + @SuppressWarnings("unused") + private static final String TAG = "MediaObject"; + public static final long INVALID_DATA_VERSION = -1; + + // These are the bits returned from getSupportedOperations(): + public static final int SUPPORT_DELETE = 1 << 0; + public static final int SUPPORT_ROTATE = 1 << 1; + public static final int SUPPORT_SHARE = 1 << 2; + public static final int SUPPORT_CROP = 1 << 3; + public static final int SUPPORT_SHOW_ON_MAP = 1 << 4; + public static final int SUPPORT_SETAS = 1 << 5; + public static final int SUPPORT_FULL_IMAGE = 1 << 6; + public static final int SUPPORT_PLAY = 1 << 7; + public static final int SUPPORT_CACHE = 1 << 8; + public static final int SUPPORT_EDIT = 1 << 9; + public static final int SUPPORT_INFO = 1 << 10; + public static final int SUPPORT_IMPORT = 1 << 11; + public static final int SUPPORT_ALL = 0xffffffff; + + // These are the bits returned from getMediaType(): + public static final int MEDIA_TYPE_UNKNOWN = 1; + public static final int MEDIA_TYPE_IMAGE = 2; + public static final int MEDIA_TYPE_VIDEO = 4; + public static final int MEDIA_TYPE_ALL = MEDIA_TYPE_IMAGE | MEDIA_TYPE_VIDEO; + + // These are flags for cache() and return values for getCacheFlag(): + public static final int CACHE_FLAG_NO = 0; + public static final int CACHE_FLAG_SCREENNAIL = 1; + public static final int CACHE_FLAG_FULL = 2; + + // These are return values for getCacheStatus(): + public static final int CACHE_STATUS_NOT_CACHED = 0; + public static final int CACHE_STATUS_CACHING = 1; + public static final int CACHE_STATUS_CACHED_SCREENNAIL = 2; + public static final int CACHE_STATUS_CACHED_FULL = 3; + + private static long sVersionSerial = 0; + + protected long mDataVersion; + + protected final Path mPath; + + public MediaObject(Path path, long version) { + path.setObject(this); + mPath = path; + mDataVersion = version; + } + + public Path getPath() { + return mPath; + } + + public int getSupportedOperations() { + return 0; + } + + public void delete() { + throw new UnsupportedOperationException(); + } + + public void rotate(int degrees) { + throw new UnsupportedOperationException(); + } + + public Uri getContentUri() { + throw new UnsupportedOperationException(); + } + + public Uri getPlayUri() { + throw new UnsupportedOperationException(); + } + + public int getMediaType() { + return MEDIA_TYPE_UNKNOWN; + } + + public boolean Import() { + throw new UnsupportedOperationException(); + } + + public MediaDetails getDetails() { + MediaDetails details = new MediaDetails(); + return details; + } + + public long getDataVersion() { + return mDataVersion; + } + + public int getCacheFlag() { + return CACHE_FLAG_NO; + } + + public int getCacheStatus() { + throw new UnsupportedOperationException(); + } + + public long getCacheSize() { + throw new UnsupportedOperationException(); + } + + public void cache(int flag) { + throw new UnsupportedOperationException(); + } + + public static synchronized long nextVersionNumber() { + return ++MediaObject.sVersionSerial; + } +} diff --git a/src/com/android/gallery3d/data/MediaSet.java b/src/com/android/gallery3d/data/MediaSet.java new file mode 100644 index 000000000..99f00a0dd --- /dev/null +++ b/src/com/android/gallery3d/data/MediaSet.java @@ -0,0 +1,219 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.util.Future; + +import java.util.ArrayList; +import java.util.WeakHashMap; + +// MediaSet is a directory-like data structure. +// It contains MediaItems and sub-MediaSets. +// +// The primary interface are: +// getMediaItemCount(), getMediaItem() and +// getSubMediaSetCount(), getSubMediaSet(). +// +// getTotalMediaItemCount() returns the number of all MediaItems, including +// those in sub-MediaSets. +public abstract class MediaSet extends MediaObject { + public static final int MEDIAITEM_BATCH_FETCH_COUNT = 500; + public static final int INDEX_NOT_FOUND = -1; + + public MediaSet(Path path, long version) { + super(path, version); + } + + public int getMediaItemCount() { + return 0; + } + + // Returns the media items in the range [start, start + count). + // + // The number of media items returned may be less than the specified count + // if there are not enough media items available. The number of + // media items available may not be consistent with the return value of + // getMediaItemCount() because the contents of database may have already + // changed. + public ArrayList<MediaItem> getMediaItem(int start, int count) { + return new ArrayList<MediaItem>(); + } + + public int getSubMediaSetCount() { + return 0; + } + + public MediaSet getSubMediaSet(int index) { + throw new IndexOutOfBoundsException(); + } + + public boolean isLeafAlbum() { + return false; + } + + public int getTotalMediaItemCount() { + int total = getMediaItemCount(); + for (int i = 0, n = getSubMediaSetCount(); i < n; i++) { + total += getSubMediaSet(i).getTotalMediaItemCount(); + } + return total; + } + + // TODO: we should have better implementation of sub classes + public int getIndexOfItem(Path path, int hint) { + // hint < 0 is handled below + // first, try to find it around the hint + int start = Math.max(0, + hint - MEDIAITEM_BATCH_FETCH_COUNT / 2); + ArrayList<MediaItem> list = getMediaItem( + start, MEDIAITEM_BATCH_FETCH_COUNT); + int index = getIndexOf(path, list); + if (index != INDEX_NOT_FOUND) return start + index; + + // try to find it globally + start = start == 0 ? MEDIAITEM_BATCH_FETCH_COUNT : 0; + list = getMediaItem(start, MEDIAITEM_BATCH_FETCH_COUNT); + while (true) { + index = getIndexOf(path, list); + if (index != INDEX_NOT_FOUND) return start + index; + if (list.size() < MEDIAITEM_BATCH_FETCH_COUNT) return INDEX_NOT_FOUND; + start += MEDIAITEM_BATCH_FETCH_COUNT; + list = getMediaItem(start, MEDIAITEM_BATCH_FETCH_COUNT); + } + } + + protected int getIndexOf(Path path, ArrayList<MediaItem> list) { + for (int i = 0, n = list.size(); i < n; ++i) { + if (list.get(i).mPath == path) return i; + } + return INDEX_NOT_FOUND; + } + + public abstract String getName(); + + private WeakHashMap<ContentListener, Object> mListeners = + new WeakHashMap<ContentListener, Object>(); + + // NOTE: The MediaSet only keeps a weak reference to the listener. The + // listener is automatically removed when there is no other reference to + // the listener. + public void addContentListener(ContentListener listener) { + if (mListeners.containsKey(listener)) { + throw new IllegalArgumentException(); + } + mListeners.put(listener, null); + } + + public void removeContentListener(ContentListener listener) { + if (!mListeners.containsKey(listener)) { + throw new IllegalArgumentException(); + } + mListeners.remove(listener); + } + + // This should be called by subclasses when the content is changed. + public void notifyContentChanged() { + for (ContentListener listener : mListeners.keySet()) { + listener.onContentDirty(); + } + } + + // Reload the content. Return the current data version. reload() should be called + // in the same thread as getMediaItem(int, int) and getSubMediaSet(int). + public abstract long reload(); + + @Override + public MediaDetails getDetails() { + MediaDetails details = super.getDetails(); + details.addDetail(MediaDetails.INDEX_TITLE, getName()); + return details; + } + + // Enumerate all media items in this media set (including the ones in sub + // media sets), in an efficient order. ItemConsumer.consumer() will be + // called for each media item with its index. + public void enumerateMediaItems(ItemConsumer consumer) { + enumerateMediaItems(consumer, 0); + } + + public void enumerateTotalMediaItems(ItemConsumer consumer) { + enumerateTotalMediaItems(consumer, 0); + } + + public static interface ItemConsumer { + void consume(int index, MediaItem item); + } + + // The default implementation uses getMediaItem() for enumerateMediaItems(). + // Subclasses may override this and use more efficient implementations. + // Returns the number of items enumerated. + protected int enumerateMediaItems(ItemConsumer consumer, int startIndex) { + int total = getMediaItemCount(); + int start = 0; + while (start < total) { + int count = Math.min(MEDIAITEM_BATCH_FETCH_COUNT, total - start); + ArrayList<MediaItem> items = getMediaItem(start, count); + for (int i = 0, n = items.size(); i < n; i++) { + MediaItem item = items.get(i); + consumer.consume(startIndex + start + i, item); + } + start += count; + } + return total; + } + + // Recursively enumerate all media items under this set. + // Returns the number of items enumerated. + protected int enumerateTotalMediaItems( + ItemConsumer consumer, int startIndex) { + int start = 0; + start += enumerateMediaItems(consumer, startIndex); + int m = getSubMediaSetCount(); + for (int i = 0; i < m; i++) { + start += getSubMediaSet(i).enumerateTotalMediaItems( + consumer, startIndex + start); + } + return start; + } + + public Future<Void> requestSync() { + return FUTURE_STUB; + } + + private static final Future<Void> FUTURE_STUB = new Future<Void>() { + @Override + public void cancel() {} + + @Override + public boolean isCancelled() { + return false; + } + + @Override + public boolean isDone() { + return true; + } + + @Override + public Void get() { + return null; + } + + @Override + public void waitDone() {} + }; +} diff --git a/src/com/android/gallery3d/data/MediaSource.java b/src/com/android/gallery3d/data/MediaSource.java new file mode 100644 index 000000000..ae98e0fcc --- /dev/null +++ b/src/com/android/gallery3d/data/MediaSource.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.data.MediaSet.ItemConsumer; + +import android.net.Uri; + +import java.util.ArrayList; + +public abstract class MediaSource { + private static final String TAG = "MediaSource"; + private String mPrefix; + + protected MediaSource(String prefix) { + mPrefix = prefix; + } + + public String getPrefix() { + return mPrefix; + } + + public Path findPathByUri(Uri uri) { + return null; + } + + public abstract MediaObject createMediaObject(Path path); + + public void pause() { + } + + public void resume() { + } + + public Path getDefaultSetOf(Path item) { + return null; + } + + public long getTotalUsedCacheSize() { + return 0; + } + + public long getTotalTargetCacheSize() { + return 0; + } + + public static class PathId { + public PathId(Path path, int id) { + this.path = path; + this.id = id; + } + public Path path; + public int id; + } + + // Maps a list of Paths (all belong to this MediaSource) to MediaItems, + // and invoke consumer.consume() for each MediaItem with the given id. + // + // This default implementation uses getMediaObject for each Path. Subclasses + // may override this and provide more efficient implementation (like + // batching the database query). + public void mapMediaItems(ArrayList<PathId> list, ItemConsumer consumer) { + int n = list.size(); + for (int i = 0; i < n; i++) { + PathId pid = list.get(i); + MediaObject obj = pid.path.getObject(); + if (obj == null) { + try { + obj = createMediaObject(pid.path); + } catch (Throwable th) { + Log.w(TAG, "cannot create media object: " + pid.path, th); + } + } + if (obj != null) { + consumer.consume(pid.id, (MediaItem) obj); + } + } + } +} diff --git a/src/com/android/gallery3d/data/MtpClient.java b/src/com/android/gallery3d/data/MtpClient.java new file mode 100644 index 000000000..6991c1637 --- /dev/null +++ b/src/com/android/gallery3d/data/MtpClient.java @@ -0,0 +1,442 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.hardware.usb.UsbConstants; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbDeviceConnection; +import android.hardware.usb.UsbInterface; +import android.hardware.usb.UsbManager; +import android.mtp.MtpDevice; +import android.mtp.MtpDeviceInfo; +import android.mtp.MtpObjectInfo; +import android.mtp.MtpStorageInfo; +import android.os.ParcelFileDescriptor; +import android.util.Log; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +/** + * This class helps an application manage a list of connected MTP or PTP devices. + * It listens for MTP devices being attached and removed from the USB host bus + * and notifies the application when the MTP device list changes. + */ +public class MtpClient { + + private static final String TAG = "MtpClient"; + + private static final String ACTION_USB_PERMISSION = + "android.mtp.MtpClient.action.USB_PERMISSION"; + + private final Context mContext; + private final UsbManager mUsbManager; + private final ArrayList<Listener> mListeners = new ArrayList<Listener>(); + // mDevices contains all MtpDevices that have been seen by our client, + // so we can inform when the device has been detached. + // mDevices is also used for synchronization in this class. + private final HashMap<String, MtpDevice> mDevices = new HashMap<String, MtpDevice>(); + // List of MTP devices we should not try to open for which we are currently + // asking for permission to open. + private final ArrayList<String> mRequestPermissionDevices = new ArrayList<String>(); + // List of MTP devices we should not try to open. + // We add devices to this list if the user canceled a permission request or we were + // unable to open the device. + private final ArrayList<String> mIgnoredDevices = new ArrayList<String>(); + + private final PendingIntent mPermissionIntent; + + private final BroadcastReceiver mUsbReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + UsbDevice usbDevice = (UsbDevice)intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + String deviceName = usbDevice.getDeviceName(); + + synchronized (mDevices) { + MtpDevice mtpDevice = mDevices.get(deviceName); + + if (UsbManager.ACTION_USB_DEVICE_ATTACHED.equals(action)) { + if (mtpDevice == null) { + mtpDevice = openDeviceLocked(usbDevice); + } + if (mtpDevice != null) { + for (Listener listener : mListeners) { + listener.deviceAdded(mtpDevice); + } + } + } else if (UsbManager.ACTION_USB_DEVICE_DETACHED.equals(action)) { + if (mtpDevice != null) { + mDevices.remove(deviceName); + mRequestPermissionDevices.remove(deviceName); + mIgnoredDevices.remove(deviceName); + for (Listener listener : mListeners) { + listener.deviceRemoved(mtpDevice); + } + } + } else if (ACTION_USB_PERMISSION.equals(action)) { + mRequestPermissionDevices.remove(deviceName); + boolean permission = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, + false); + Log.d(TAG, "ACTION_USB_PERMISSION: " + permission); + if (permission) { + if (mtpDevice == null) { + mtpDevice = openDeviceLocked(usbDevice); + } + if (mtpDevice != null) { + for (Listener listener : mListeners) { + listener.deviceAdded(mtpDevice); + } + } + } else { + // so we don't ask for permission again + mIgnoredDevices.add(deviceName); + } + } + } + } + }; + + /** + * An interface for being notified when MTP or PTP devices are attached + * or removed. In the current implementation, only PTP devices are supported. + */ + public interface Listener { + /** + * Called when a new device has been added + * + * @param device the new device that was added + */ + public void deviceAdded(MtpDevice device); + + /** + * Called when a new device has been removed + * + * @param device the device that was removed + */ + public void deviceRemoved(MtpDevice device); + } + + /** + * Tests to see if a {@link android.hardware.usb.UsbDevice} + * supports the PTP protocol (typically used by digital cameras) + * + * @param device the device to test + * @return true if the device is a PTP device. + */ + static public boolean isCamera(UsbDevice device) { + int count = device.getInterfaceCount(); + for (int i = 0; i < count; i++) { + UsbInterface intf = device.getInterface(i); + if (intf.getInterfaceClass() == UsbConstants.USB_CLASS_STILL_IMAGE && + intf.getInterfaceSubclass() == 1 && + intf.getInterfaceProtocol() == 1) { + return true; + } + } + return false; + } + + /** + * MtpClient constructor + * + * @param context the {@link android.content.Context} to use for the MtpClient + */ + public MtpClient(Context context) { + mContext = context; + mUsbManager = (UsbManager)context.getSystemService(Context.USB_SERVICE); + mPermissionIntent = PendingIntent.getBroadcast(mContext, 0, new Intent(ACTION_USB_PERMISSION), 0); + IntentFilter filter = new IntentFilter(); + filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); + filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); + filter.addAction(ACTION_USB_PERMISSION); + context.registerReceiver(mUsbReceiver, filter); + } + + /** + * Opens the {@link android.hardware.usb.UsbDevice} for an MTP or PTP + * device and return an {@link android.mtp.MtpDevice} for it. + * + * @param device the device to open + * @return an MtpDevice for the device. + */ + private MtpDevice openDeviceLocked(UsbDevice usbDevice) { + String deviceName = usbDevice.getDeviceName(); + + // don't try to open devices that we have decided to ignore + // or are currently asking permission for + if (isCamera(usbDevice) && !mIgnoredDevices.contains(deviceName) + && !mRequestPermissionDevices.contains(deviceName)) { + if (!mUsbManager.hasPermission(usbDevice)) { + mUsbManager.requestPermission(usbDevice, mPermissionIntent); + mRequestPermissionDevices.add(deviceName); + } else { + UsbDeviceConnection connection = mUsbManager.openDevice(usbDevice); + if (connection != null) { + MtpDevice mtpDevice = new MtpDevice(usbDevice); + if (mtpDevice.open(connection)) { + mDevices.put(usbDevice.getDeviceName(), mtpDevice); + return mtpDevice; + } else { + // so we don't try to open it again + mIgnoredDevices.add(deviceName); + } + } else { + // so we don't try to open it again + mIgnoredDevices.add(deviceName); + } + } + } + return null; + } + + /** + * Closes all resources related to the MtpClient object + */ + public void close() { + mContext.unregisterReceiver(mUsbReceiver); + } + + /** + * Registers a {@link android.mtp.MtpClient.Listener} interface to receive + * notifications when MTP or PTP devices are added or removed. + * + * @param listener the listener to register + */ + public void addListener(Listener listener) { + synchronized (mDevices) { + if (!mListeners.contains(listener)) { + mListeners.add(listener); + } + } + } + + /** + * Unregisters a {@link android.mtp.MtpClient.Listener} interface. + * + * @param listener the listener to unregister + */ + public void removeListener(Listener listener) { + synchronized (mDevices) { + mListeners.remove(listener); + } + } + + /** + * Retrieves an {@link android.mtp.MtpDevice} object for the USB device + * with the given name. + * + * @param deviceName the name of the USB device + * @return the MtpDevice, or null if it does not exist + */ + public MtpDevice getDevice(String deviceName) { + synchronized (mDevices) { + return mDevices.get(deviceName); + } + } + + /** + * Retrieves an {@link android.mtp.MtpDevice} object for the USB device + * with the given ID. + * + * @param id the ID of the USB device + * @return the MtpDevice, or null if it does not exist + */ + public MtpDevice getDevice(int id) { + synchronized (mDevices) { + return mDevices.get(UsbDevice.getDeviceName(id)); + } + } + + /** + * Retrieves a list of all currently connected {@link android.mtp.MtpDevice}. + * + * @return the list of MtpDevices + */ + public List<MtpDevice> getDeviceList() { + synchronized (mDevices) { + // Query the USB manager since devices might have attached + // before we added our listener. + for (UsbDevice usbDevice : mUsbManager.getDeviceList().values()) { + if (mDevices.get(usbDevice.getDeviceName()) == null) { + openDeviceLocked(usbDevice); + } + } + + return new ArrayList<MtpDevice>(mDevices.values()); + } + } + + /** + * Retrieves a list of all {@link android.mtp.MtpStorageInfo} + * for the MTP or PTP device with the given USB device name + * + * @param deviceName the name of the USB device + * @return the list of MtpStorageInfo + */ + public List<MtpStorageInfo> getStorageList(String deviceName) { + MtpDevice device = getDevice(deviceName); + if (device == null) { + return null; + } + int[] storageIds = device.getStorageIds(); + if (storageIds == null) { + return null; + } + + int length = storageIds.length; + ArrayList<MtpStorageInfo> storageList = new ArrayList<MtpStorageInfo>(length); + for (int i = 0; i < length; i++) { + MtpStorageInfo info = device.getStorageInfo(storageIds[i]); + if (info == null) { + Log.w(TAG, "getStorageInfo failed"); + } else { + storageList.add(info); + } + } + return storageList; + } + + /** + * Retrieves the {@link android.mtp.MtpObjectInfo} for an object on + * the MTP or PTP device with the given USB device name with the given + * object handle + * + * @param deviceName the name of the USB device + * @param objectHandle handle of the object to query + * @return the MtpObjectInfo + */ + public MtpObjectInfo getObjectInfo(String deviceName, int objectHandle) { + MtpDevice device = getDevice(deviceName); + if (device == null) { + return null; + } + return device.getObjectInfo(objectHandle); + } + + /** + * Deletes an object on the MTP or PTP device with the given USB device name. + * + * @param deviceName the name of the USB device + * @param objectHandle handle of the object to delete + * @return true if the deletion succeeds + */ + public boolean deleteObject(String deviceName, int objectHandle) { + MtpDevice device = getDevice(deviceName); + if (device == null) { + return false; + } + return device.deleteObject(objectHandle); + } + + /** + * Retrieves a list of {@link android.mtp.MtpObjectInfo} for all objects + * on the MTP or PTP device with the given USB device name and given storage ID + * and/or object handle. + * If the object handle is zero, then all objects in the root of the storage unit + * will be returned. Otherwise, all immediate children of the object will be returned. + * If the storage ID is also zero, then all objects on all storage units will be returned. + * + * @param deviceName the name of the USB device + * @param storageId the ID of the storage unit to query, or zero for all + * @param objectHandle the handle of the parent object to query, or zero for the storage root + * @return the list of MtpObjectInfo + */ + public List<MtpObjectInfo> getObjectList(String deviceName, int storageId, int objectHandle) { + MtpDevice device = getDevice(deviceName); + if (device == null) { + return null; + } + if (objectHandle == 0) { + // all objects in root of storage + objectHandle = 0xFFFFFFFF; + } + int[] handles = device.getObjectHandles(storageId, 0, objectHandle); + if (handles == null) { + return null; + } + + int length = handles.length; + ArrayList<MtpObjectInfo> objectList = new ArrayList<MtpObjectInfo>(length); + for (int i = 0; i < length; i++) { + MtpObjectInfo info = device.getObjectInfo(handles[i]); + if (info == null) { + Log.w(TAG, "getObjectInfo failed"); + } else { + objectList.add(info); + } + } + return objectList; + } + + /** + * Returns the data for an object as a byte array. + * + * @param deviceName the name of the USB device containing the object + * @param objectHandle handle of the object to read + * @param objectSize the size of the object (this should match + * {@link android.mtp.MtpObjectInfo#getCompressedSize} + * @return the object's data, or null if reading fails + */ + public byte[] getObject(String deviceName, int objectHandle, int objectSize) { + MtpDevice device = getDevice(deviceName); + if (device == null) { + return null; + } + return device.getObject(objectHandle, objectSize); + } + + /** + * Returns the thumbnail data for an object as a byte array. + * + * @param deviceName the name of the USB device containing the object + * @param objectHandle handle of the object to read + * @return the object's thumbnail, or null if reading fails + */ + public byte[] getThumbnail(String deviceName, int objectHandle) { + MtpDevice device = getDevice(deviceName); + if (device == null) { + return null; + } + return device.getThumbnail(objectHandle); + } + + /** + * Copies the data for an object to a file in external storage. + * + * @param deviceName the name of the USB device containing the object + * @param objectHandle handle of the object to read + * @param destPath path to destination for the file transfer. + * This path should be in the external storage as defined by + * {@link android.os.Environment#getExternalStorageDirectory} + * @return true if the file transfer succeeds + */ + public boolean importFile(String deviceName, int objectHandle, String destPath) { + MtpDevice device = getDevice(deviceName); + if (device == null) { + return false; + } + return device.importFile(objectHandle, destPath); + } +} diff --git a/src/com/android/gallery3d/data/MtpContext.java b/src/com/android/gallery3d/data/MtpContext.java new file mode 100644 index 000000000..652849445 --- /dev/null +++ b/src/com/android/gallery3d/data/MtpContext.java @@ -0,0 +1,141 @@ +package com.android.gallery3d.data; + +import com.android.gallery3d.R; +import com.android.gallery3d.util.GalleryUtils; + +import android.content.Context; +import android.hardware.usb.UsbDevice; +import android.media.MediaScannerConnection; +import android.media.MediaScannerConnection.MediaScannerConnectionClient; +import android.mtp.MtpObjectInfo; +import android.net.Uri; +import android.os.Environment; +import android.util.Log; +import android.widget.Toast; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +public class MtpContext implements MtpClient.Listener { + private static final String TAG = "MtpContext"; + + public static final String NAME_IMPORTED_FOLDER = "Imported"; + + private ScannerClient mScannerClient; + private Context mContext; + private MtpClient mClient; + + private static final class ScannerClient implements MediaScannerConnectionClient { + ArrayList<String> mPaths = new ArrayList<String>(); + MediaScannerConnection mScannerConnection; + boolean mConnected; + Object mLock = new Object(); + + public ScannerClient(Context context) { + mScannerConnection = new MediaScannerConnection(context, this); + } + + public void scanPath(String path) { + synchronized (mLock) { + if (mConnected) { + mScannerConnection.scanFile(path, null); + } else { + mPaths.add(path); + mScannerConnection.connect(); + } + } + } + + @Override + public void onMediaScannerConnected() { + synchronized (mLock) { + mConnected = true; + if (!mPaths.isEmpty()) { + for (String path : mPaths) { + mScannerConnection.scanFile(path, null); + } + mPaths.clear(); + } + } + } + + @Override + public void onScanCompleted(String path, Uri uri) { + } + } + + public MtpContext(Context context) { + mContext = context; + mScannerClient = new ScannerClient(context); + mClient = new MtpClient(mContext); + } + + public void pause() { + mClient.removeListener(this); + } + + public void resume() { + mClient.addListener(this); + notifyDirty(); + } + + public void deviceAdded(android.mtp.MtpDevice device) { + notifyDirty(); + showToast(R.string.camera_connected); + } + + public void deviceRemoved(android.mtp.MtpDevice device) { + notifyDirty(); + showToast(R.string.camera_disconnected); + } + + private void notifyDirty() { + mContext.getContentResolver().notifyChange(Uri.parse("mtp://"), null); + } + + private void showToast(final int msg) { + Toast.makeText(mContext, msg, Toast.LENGTH_SHORT).show(); + } + + public MtpClient getMtpClient() { + return mClient; + } + + public boolean copyFile(String deviceName, MtpObjectInfo objInfo) { + if (GalleryUtils.hasSpaceForSize(objInfo.getCompressedSize())) { + File dest = Environment.getExternalStorageDirectory(); + dest = new File(dest, NAME_IMPORTED_FOLDER); + dest.mkdirs(); + String destPath = new File(dest, objInfo.getName()).getAbsolutePath(); + int objectId = objInfo.getObjectHandle(); + if (mClient.importFile(deviceName, objectId, destPath)) { + mScannerClient.scanPath(destPath); + return true; + } + } else { + Log.w(TAG, "No space to import " + objInfo.getName() + + " whose size = " + objInfo.getCompressedSize()); + } + return false; + } + + public boolean copyAlbum(String deviceName, String albumName, + List<MtpObjectInfo> children) { + File dest = Environment.getExternalStorageDirectory(); + dest = new File(dest, albumName); + dest.mkdirs(); + int success = 0; + for (MtpObjectInfo child : children) { + if (!GalleryUtils.hasSpaceForSize(child.getCompressedSize())) continue; + + File importedFile = new File(dest, child.getName()); + String path = importedFile.getAbsolutePath(); + if (mClient.importFile(deviceName, child.getObjectHandle(), path)) { + mScannerClient.scanPath(path); + success++; + } + } + return success == children.size(); + } +} diff --git a/src/com/android/gallery3d/data/MtpDevice.java b/src/com/android/gallery3d/data/MtpDevice.java new file mode 100644 index 000000000..e654583c5 --- /dev/null +++ b/src/com/android/gallery3d/data/MtpDevice.java @@ -0,0 +1,174 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.app.GalleryApp; + +import android.hardware.usb.UsbDevice; +import android.mtp.MtpConstants; +import android.mtp.MtpObjectInfo; +import android.mtp.MtpStorageInfo; +import android.net.Uri; +import android.util.Log; + +import java.util.ArrayList; +import java.util.List; + +public class MtpDevice extends MediaSet { + private static final String TAG = "MtpDevice"; + + private final GalleryApp mApplication; + private final int mDeviceId; + private final String mDeviceName; + private final DataManager mDataManager; + private final MtpContext mMtpContext; + private final String mName; + private final ChangeNotifier mNotifier; + private final Path mItemPath; + private List<MtpObjectInfo> mJpegChildren; + + public MtpDevice(Path path, GalleryApp application, int deviceId, + String name, MtpContext mtpContext) { + super(path, nextVersionNumber()); + mApplication = application; + mDeviceId = deviceId; + mDeviceName = UsbDevice.getDeviceName(deviceId); + mDataManager = application.getDataManager(); + mMtpContext = mtpContext; + mName = name; + mNotifier = new ChangeNotifier(this, Uri.parse("mtp://"), application); + mItemPath = Path.fromString("/mtp/item/" + String.valueOf(deviceId)); + mJpegChildren = new ArrayList<MtpObjectInfo>(); + } + + public MtpDevice(Path path, GalleryApp application, int deviceId, + MtpContext mtpContext) { + this(path, application, deviceId, + MtpDeviceSet.getDeviceName(mtpContext, deviceId), mtpContext); + } + + private List<MtpObjectInfo> loadItems() { + ArrayList<MtpObjectInfo> result = new ArrayList<MtpObjectInfo>(); + + List<MtpStorageInfo> storageList = mMtpContext.getMtpClient() + .getStorageList(mDeviceName); + if (storageList == null) return result; + + for (MtpStorageInfo info : storageList) { + collectJpegChildren(info.getStorageId(), 0, result); + } + + return result; + } + + private void collectJpegChildren(int storageId, int objectId, + ArrayList<MtpObjectInfo> result) { + ArrayList<MtpObjectInfo> dirChildren = new ArrayList<MtpObjectInfo>(); + + queryChildren(storageId, objectId, result, dirChildren); + + for (int i = 0, n = dirChildren.size(); i < n; i++) { + MtpObjectInfo info = dirChildren.get(i); + collectJpegChildren(storageId, info.getObjectHandle(), result); + } + } + + private void queryChildren(int storageId, int objectId, + ArrayList<MtpObjectInfo> jpeg, ArrayList<MtpObjectInfo> dir) { + List<MtpObjectInfo> children = mMtpContext.getMtpClient().getObjectList( + mDeviceName, storageId, objectId); + if (children == null) return; + + for (MtpObjectInfo obj : children) { + int format = obj.getFormat(); + switch (format) { + case MtpConstants.FORMAT_JFIF: + case MtpConstants.FORMAT_EXIF_JPEG: + jpeg.add(obj); + break; + case MtpConstants.FORMAT_ASSOCIATION: + dir.add(obj); + break; + default: + Log.w(TAG, "other type: name = " + obj.getName() + + ", format = " + format); + } + } + } + + public static MtpObjectInfo getObjectInfo(MtpContext mtpContext, int deviceId, + int objectId) { + String deviceName = UsbDevice.getDeviceName(deviceId); + return mtpContext.getMtpClient().getObjectInfo(deviceName, objectId); + } + + @Override + public ArrayList<MediaItem> getMediaItem(int start, int count) { + ArrayList<MediaItem> result = new ArrayList<MediaItem>(); + int begin = start; + int end = Math.min(start + count, mJpegChildren.size()); + + DataManager dataManager = mApplication.getDataManager(); + for (int i = begin; i < end; i++) { + MtpObjectInfo child = mJpegChildren.get(i); + Path childPath = mItemPath.getChild(child.getObjectHandle()); + MtpImage image = (MtpImage) dataManager.peekMediaObject(childPath); + if (image == null) { + image = new MtpImage( + childPath, mApplication, mDeviceId, child, mMtpContext); + } else { + image.updateContent(child); + } + result.add(image); + } + return result; + } + + @Override + public int getMediaItemCount() { + return mJpegChildren.size(); + } + + @Override + public String getName() { + return mName; + } + + @Override + public long reload() { + if (mNotifier.isDirty()) { + mDataVersion = nextVersionNumber(); + mJpegChildren = loadItems(); + } + return mDataVersion; + } + + @Override + public int getSupportedOperations() { + return SUPPORT_IMPORT; + } + + @Override + public boolean Import() { + return mMtpContext.copyAlbum(mDeviceName, mName, mJpegChildren); + } + + @Override + public boolean isLeafAlbum() { + return true; + } +} diff --git a/src/com/android/gallery3d/data/MtpDeviceSet.java b/src/com/android/gallery3d/data/MtpDeviceSet.java new file mode 100644 index 000000000..6521623d4 --- /dev/null +++ b/src/com/android/gallery3d/data/MtpDeviceSet.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.R; +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.util.MediaSetUtils; + +import android.mtp.MtpDeviceInfo; +import android.net.Uri; +import android.util.Log; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +// MtpDeviceSet -- MtpDevice -- MtpImage +public class MtpDeviceSet extends MediaSet { + private static final String TAG = "MtpDeviceSet"; + + private GalleryApp mApplication; + private final ArrayList<MediaSet> mDeviceSet = new ArrayList<MediaSet>(); + private final ChangeNotifier mNotifier; + private final MtpContext mMtpContext; + private final String mName; + + public MtpDeviceSet(Path path, GalleryApp application, MtpContext mtpContext) { + super(path, nextVersionNumber()); + mApplication = application; + mNotifier = new ChangeNotifier(this, Uri.parse("mtp://"), application); + mMtpContext = mtpContext; + mName = application.getResources().getString(R.string.set_label_mtp_devices); + } + + private void loadDevices() { + DataManager dataManager = mApplication.getDataManager(); + // Enumerate all devices + mDeviceSet.clear(); + List<android.mtp.MtpDevice> devices = mMtpContext.getMtpClient().getDeviceList(); + Log.v(TAG, "loadDevices: " + devices + ", size=" + devices.size()); + for (android.mtp.MtpDevice mtpDevice : devices) { + int deviceId = mtpDevice.getDeviceId(); + Path childPath = mPath.getChild(deviceId); + MtpDevice device = (MtpDevice) dataManager.peekMediaObject(childPath); + if (device == null) { + device = new MtpDevice(childPath, mApplication, deviceId, mMtpContext); + } + Log.d(TAG, "add device " + device); + mDeviceSet.add(device); + } + + Collections.sort(mDeviceSet, MediaSetUtils.NAME_COMPARATOR); + for (int i = 0, n = mDeviceSet.size(); i < n; i++) { + mDeviceSet.get(i).reload(); + } + } + + public static String getDeviceName(MtpContext mtpContext, int deviceId) { + android.mtp.MtpDevice device = mtpContext.getMtpClient().getDevice(deviceId); + if (device == null) { + return ""; + } + MtpDeviceInfo info = device.getDeviceInfo(); + if (info == null) { + return ""; + } + String manufacturer = info.getManufacturer().trim(); + String model = info.getModel().trim(); + return manufacturer + " " + model; + } + + @Override + public MediaSet getSubMediaSet(int index) { + return index < mDeviceSet.size() ? mDeviceSet.get(index) : null; + } + + @Override + public int getSubMediaSetCount() { + return mDeviceSet.size(); + } + + @Override + public String getName() { + return mName; + } + + @Override + public long reload() { + if (mNotifier.isDirty()) { + mDataVersion = nextVersionNumber(); + loadDevices(); + } + return mDataVersion; + } +} diff --git a/src/com/android/gallery3d/data/MtpImage.java b/src/com/android/gallery3d/data/MtpImage.java new file mode 100644 index 000000000..4766d88f8 --- /dev/null +++ b/src/com/android/gallery3d/data/MtpImage.java @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.provider.GalleryProvider; +import com.android.gallery3d.util.ThreadPool; +import com.android.gallery3d.util.ThreadPool.Job; +import com.android.gallery3d.util.ThreadPool.JobContext; + +import android.graphics.Bitmap; +import android.graphics.BitmapRegionDecoder; +import android.hardware.usb.UsbDevice; +import android.mtp.MtpObjectInfo; +import android.net.Uri; +import android.util.Log; + +import java.text.DateFormat; +import java.util.Date; + +public class MtpImage extends MediaItem { + private static final String TAG = "MtpImage"; + + private final int mDeviceId; + private int mObjectId; + private int mObjectSize; + private long mDateTaken; + private String mFileName; + private final ThreadPool mThreadPool; + private final MtpContext mMtpContext; + private final MtpObjectInfo mObjInfo; + private final int mImageWidth; + private final int mImageHeight; + + MtpImage(Path path, GalleryApp application, int deviceId, + MtpObjectInfo objInfo, MtpContext mtpContext) { + super(path, nextVersionNumber()); + mDeviceId = deviceId; + mObjInfo = objInfo; + mObjectId = objInfo.getObjectHandle(); + mObjectSize = objInfo.getCompressedSize(); + mDateTaken = objInfo.getDateCreated(); + mFileName = objInfo.getName(); + mImageWidth = objInfo.getImagePixWidth(); + mImageHeight = objInfo.getImagePixHeight(); + mThreadPool = application.getThreadPool(); + mMtpContext = mtpContext; + } + + MtpImage(Path path, GalleryApp app, int deviceId, int objectId, MtpContext mtpContext) { + this(path, app, deviceId, MtpDevice.getObjectInfo(mtpContext, deviceId, objectId), + mtpContext); + } + + @Override + public long getDateInMs() { + return mDateTaken; + } + + @Override + public Job<Bitmap> requestImage(int type) { + return new Job<Bitmap>() { + public Bitmap run(JobContext jc) { + GetThumbnailBytes job = new GetThumbnailBytes(); + byte[] thumbnail = mThreadPool.submit(job).get(); + if (thumbnail == null) { + Log.w(TAG, "decoding thumbnail failed"); + return null; + } + return DecodeUtils.requestDecode(jc, thumbnail, null); + } + }; + } + + @Override + public Job<BitmapRegionDecoder> requestLargeImage() { + return new Job<BitmapRegionDecoder>() { + public BitmapRegionDecoder run(JobContext jc) { + byte[] bytes = mMtpContext.getMtpClient().getObject( + UsbDevice.getDeviceName(mDeviceId), mObjectId, mObjectSize); + return DecodeUtils.requestCreateBitmapRegionDecoder( + jc, bytes, 0, bytes.length, false); + } + }; + } + + public byte[] getImageData() { + return mMtpContext.getMtpClient().getObject( + UsbDevice.getDeviceName(mDeviceId), mObjectId, mObjectSize); + } + + @Override + public boolean Import() { + return mMtpContext.copyFile(UsbDevice.getDeviceName(mDeviceId), mObjInfo); + } + + @Override + public int getSupportedOperations() { + return SUPPORT_FULL_IMAGE | SUPPORT_IMPORT; + } + + private class GetThumbnailBytes implements Job<byte[]> { + public byte[] run(JobContext jc) { + return mMtpContext.getMtpClient().getThumbnail( + UsbDevice.getDeviceName(mDeviceId), mObjectId); + } + } + + public void updateContent(MtpObjectInfo info) { + if (mObjectId != info.getObjectHandle() || mDateTaken != info.getDateCreated()) { + mObjectId = info.getObjectHandle(); + mDateTaken = info.getDateCreated(); + mDataVersion = nextVersionNumber(); + } + } + + @Override + public String getMimeType() { + // Currently only JPEG is supported in MTP. + return "image/jpeg"; + } + + @Override + public int getMediaType() { + return MEDIA_TYPE_IMAGE; + } + + @Override + public long getSize() { + return mObjectSize; + } + + @Override + public Uri getContentUri() { + return GalleryProvider.BASE_URI.buildUpon() + .appendEncodedPath(mPath.toString().substring(1)) + .build(); + } + + @Override + public MediaDetails getDetails() { + MediaDetails details = super.getDetails(); + DateFormat formater = DateFormat.getDateTimeInstance(); + details.addDetail(MediaDetails.INDEX_TITLE, mFileName); + details.addDetail(MediaDetails.INDEX_DATETIME, formater.format(new Date(mDateTaken))); + details.addDetail(MediaDetails.INDEX_WIDTH, mImageWidth); + details.addDetail(MediaDetails.INDEX_HEIGHT, mImageHeight); + details.addDetail(MediaDetails.INDEX_SIZE, Long.valueOf(mObjectSize)); + return details; + } + +} diff --git a/src/com/android/gallery3d/data/MtpSource.java b/src/com/android/gallery3d/data/MtpSource.java new file mode 100644 index 000000000..683a40291 --- /dev/null +++ b/src/com/android/gallery3d/data/MtpSource.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.app.GalleryApp; + +class MtpSource extends MediaSource { + private static final String TAG = "MtpSource"; + + private static final int MTP_DEVICESET = 0; + private static final int MTP_DEVICE = 1; + private static final int MTP_ITEM = 2; + + GalleryApp mApplication; + PathMatcher mMatcher; + MtpContext mMtpContext; + + public MtpSource(GalleryApp application) { + super("mtp"); + mApplication = application; + mMatcher = new PathMatcher(); + mMatcher.add("/mtp", MTP_DEVICESET); + mMatcher.add("/mtp/*", MTP_DEVICE); + mMatcher.add("/mtp/item/*/*", MTP_ITEM); + mMtpContext = new MtpContext(mApplication.getAndroidContext()); + } + + @Override + public MediaObject createMediaObject(Path path) { + switch (mMatcher.match(path)) { + case MTP_DEVICESET: { + return new MtpDeviceSet(path, mApplication, mMtpContext); + } + case MTP_DEVICE: { + int deviceId = mMatcher.getIntVar(0); + return new MtpDevice(path, mApplication, deviceId, mMtpContext); + } + case MTP_ITEM: { + int deviceId = mMatcher.getIntVar(0); + int objectId = mMatcher.getIntVar(1); + return new MtpImage(path, mApplication, deviceId, objectId, mMtpContext); + } + default: + throw new RuntimeException("bad path: " + path); + } + } + + @Override + public void pause() { + mMtpContext.pause(); + } + + @Override + public void resume() { + mMtpContext.resume(); + } +} diff --git a/src/com/android/gallery3d/data/Path.java b/src/com/android/gallery3d/data/Path.java new file mode 100644 index 000000000..3de1c7c76 --- /dev/null +++ b/src/com/android/gallery3d/data/Path.java @@ -0,0 +1,237 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.util.IdentityCache; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; + +public class Path { + private static final String TAG = "Path"; + private static Path sRoot = new Path(null, "ROOT"); + + private final Path mParent; + private final String mSegment; + private WeakReference<MediaObject> mObject; + private IdentityCache<String, Path> mChildren; + + private Path(Path parent, String segment) { + mParent = parent; + mSegment = segment; + } + + public Path getChild(String segment) { + synchronized (Path.class) { + if (mChildren == null) { + mChildren = new IdentityCache<String, Path>(); + } else { + Path p = mChildren.get(segment); + if (p != null) return p; + } + + Path p = new Path(this, segment); + mChildren.put(segment, p); + return p; + } + } + + public Path getParent() { + synchronized (Path.class) { + return mParent; + } + } + + public Path getChild(int segment) { + return getChild(String.valueOf(segment)); + } + + public Path getChild(long segment) { + return getChild(String.valueOf(segment)); + } + + public void setObject(MediaObject object) { + synchronized (Path.class) { + Utils.assertTrue(mObject == null || mObject.get() == null); + mObject = new WeakReference<MediaObject>(object); + } + } + + public MediaObject getObject() { + synchronized (Path.class) { + return (mObject == null) ? null : mObject.get(); + } + } + + @Override + public String toString() { + synchronized (Path.class) { + StringBuilder sb = new StringBuilder(); + String[] segments = split(); + for (int i = 0; i < segments.length; i++) { + sb.append("/"); + sb.append(segments[i]); + } + return sb.toString(); + } + } + + public static Path fromString(String s) { + synchronized (Path.class) { + String[] segments = split(s); + Path current = sRoot; + for (int i = 0; i < segments.length; i++) { + current = current.getChild(segments[i]); + } + return current; + } + } + + public String[] split() { + synchronized (Path.class) { + int n = 0; + for (Path p = this; p != sRoot; p = p.mParent) { + n++; + } + String[] segments = new String[n]; + int i = n - 1; + for (Path p = this; p != sRoot; p = p.mParent) { + segments[i--] = p.mSegment; + } + return segments; + } + } + + public static String[] split(String s) { + int n = s.length(); + if (n == 0) return new String[0]; + if (s.charAt(0) != '/') { + throw new RuntimeException("malformed path:" + s); + } + ArrayList<String> segments = new ArrayList<String>(); + int i = 1; + while (i < n) { + int brace = 0; + int j; + for (j = i; j < n; j++) { + char c = s.charAt(j); + if (c == '{') ++brace; + else if (c == '}') --brace; + else if (brace == 0 && c == '/') break; + } + if (brace != 0) { + throw new RuntimeException("unbalanced brace in path:" + s); + } + segments.add(s.substring(i, j)); + i = j + 1; + } + String[] result = new String[segments.size()]; + segments.toArray(result); + return result; + } + + // Splits a string to an array of strings. + // For example, "{foo,bar,baz}" -> {"foo","bar","baz"}. + public static String[] splitSequence(String s) { + int n = s.length(); + if (s.charAt(0) != '{' || s.charAt(n-1) != '}') { + throw new RuntimeException("bad sequence: " + s); + } + ArrayList<String> segments = new ArrayList<String>(); + int i = 1; + while (i < n - 1) { + int brace = 0; + int j; + for (j = i; j < n - 1; j++) { + char c = s.charAt(j); + if (c == '{') ++brace; + else if (c == '}') --brace; + else if (brace == 0 && c == ',') break; + } + if (brace != 0) { + throw new RuntimeException("unbalanced brace in path:" + s); + } + segments.add(s.substring(i, j)); + i = j + 1; + } + String[] result = new String[segments.size()]; + segments.toArray(result); + return result; + } + + public String getPrefix() { + synchronized (Path.class) { + Path current = this; + if (current == sRoot) return ""; + while (current.mParent != sRoot) { + current = current.mParent; + } + return current.mSegment; + } + } + + public String getSuffix() { + // We don't need lock because mSegment is final. + return mSegment; + } + + public String getSuffix(int level) { + // We don't need lock because mSegment and mParent are final. + Path p = this; + while (level-- != 0) { + p = p.mParent; + } + return p.mSegment; + } + + // Below are for testing/debugging only + static void clearAll() { + synchronized (Path.class) { + sRoot = new Path(null, ""); + } + } + + static void dumpAll() { + dumpAll(sRoot, "", ""); + } + + static void dumpAll(Path p, String prefix1, String prefix2) { + synchronized (Path.class) { + MediaObject obj = p.getObject(); + Log.d(TAG, prefix1 + p.mSegment + ":" + + (obj == null ? "null" : obj.getClass().getSimpleName())); + if (p.mChildren != null) { + ArrayList<String> childrenKeys = p.mChildren.keys(); + int i = 0, n = childrenKeys.size(); + for (String key : childrenKeys) { + Path child = p.mChildren.get(key); + if (child == null) { + ++i; + continue; + } + Log.d(TAG, prefix2 + "|"); + if (++i < n) { + dumpAll(child, prefix2 + "+-- ", prefix2 + "| "); + } else { + dumpAll(child, prefix2 + "+-- ", prefix2 + " "); + } + } + } + } + } +} diff --git a/src/com/android/gallery3d/data/PathMatcher.java b/src/com/android/gallery3d/data/PathMatcher.java new file mode 100644 index 000000000..9c6b840d5 --- /dev/null +++ b/src/com/android/gallery3d/data/PathMatcher.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import java.util.ArrayList; +import java.util.HashMap; + +public class PathMatcher { + public static final int NOT_FOUND = -1; + + private ArrayList<String> mVariables = new ArrayList<String>(); + private Node mRoot = new Node(); + + public PathMatcher() { + mRoot = new Node(); + } + + public void add(String pattern, int kind) { + String[] segments = Path.split(pattern); + Node current = mRoot; + for (int i = 0; i < segments.length; i++) { + current = current.addChild(segments[i]); + } + current.setKind(kind); + } + + public int match(Path path) { + String[] segments = path.split(); + mVariables.clear(); + Node current = mRoot; + for (int i = 0; i < segments.length; i++) { + Node next = current.getChild(segments[i]); + if (next == null) { + next = current.getChild("*"); + if (next != null) { + mVariables.add(segments[i]); + } else { + return NOT_FOUND; + } + } + current = next; + } + return current.getKind(); + } + + public String getVar(int index) { + return mVariables.get(index); + } + + public int getIntVar(int index) { + return Integer.parseInt(mVariables.get(index)); + } + + public long getLongVar(int index) { + return Long.parseLong(mVariables.get(index)); + } + + private static class Node { + private HashMap<String, Node> mMap; + private int mKind = NOT_FOUND; + + Node addChild(String segment) { + if (mMap == null) { + mMap = new HashMap<String, Node>(); + } else { + Node node = mMap.get(segment); + if (node != null) return node; + } + + Node n = new Node(); + mMap.put(segment, n); + return n; + } + + Node getChild(String segment) { + if (mMap == null) return null; + return mMap.get(segment); + } + + void setKind(int kind) { + mKind = kind; + } + + int getKind() { + return mKind; + } + } +} diff --git a/src/com/android/gallery3d/data/SizeClustering.java b/src/com/android/gallery3d/data/SizeClustering.java new file mode 100644 index 000000000..7e24b337b --- /dev/null +++ b/src/com/android/gallery3d/data/SizeClustering.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.R; + +import android.content.Context; +import android.content.res.Resources; + +import java.util.ArrayList; + +public class SizeClustering extends Clustering { + private static final String TAG = "SizeClustering"; + + private Context mContext; + private ArrayList<Path>[] mClusters; + private String[] mNames; + private long mMinSizes[]; + + private static final long MEGA_BYTES = 1024L*1024; + private static final long GIGA_BYTES = 1024L*1024*1024; + + private static final long[] SIZE_LEVELS = { + 0, + 1 * MEGA_BYTES, + 10 * MEGA_BYTES, + 100 * MEGA_BYTES, + 1 * GIGA_BYTES, + 2 * GIGA_BYTES, + 4 * GIGA_BYTES, + }; + + public SizeClustering(Context context) { + mContext = context; + } + + @Override + public void run(MediaSet baseSet) { + final ArrayList<Path>[] group = + (ArrayList<Path>[]) new ArrayList[SIZE_LEVELS.length]; + baseSet.enumerateTotalMediaItems(new MediaSet.ItemConsumer() { + public void consume(int index, MediaItem item) { + // Find the cluster this item belongs to. + long size = item.getSize(); + int i; + for (i = 0; i < SIZE_LEVELS.length - 1; i++) { + if (size < SIZE_LEVELS[i + 1]) { + break; + } + } + + ArrayList<Path> list = group[i]; + if (list == null) { + list = new ArrayList<Path>(); + group[i] = list; + } + list.add(item.getPath()); + } + }); + + int count = 0; + for (int i = 0; i < group.length; i++) { + if (group[i] != null) { + count++; + } + } + + mClusters = (ArrayList<Path>[]) new ArrayList[count]; + mNames = new String[count]; + mMinSizes = new long[count]; + + Resources res = mContext.getResources(); + int k = 0; + // Go through group in the reverse order, so the group with the largest + // size will show first. + for (int i = group.length - 1; i >= 0; i--) { + if (group[i] == null) continue; + + mClusters[k] = group[i]; + if (i == 0) { + mNames[k] = String.format( + res.getString(R.string.size_below), getSizeString(i + 1)); + } else if (i == group.length - 1) { + mNames[k] = String.format( + res.getString(R.string.size_above), getSizeString(i)); + } else { + String minSize = getSizeString(i); + String maxSize = getSizeString(i + 1); + mNames[k] = String.format( + res.getString(R.string.size_between), minSize, maxSize); + } + mMinSizes[k] = SIZE_LEVELS[i]; + k++; + } + } + + private String getSizeString(int index) { + long bytes = SIZE_LEVELS[index]; + if (bytes >= GIGA_BYTES) { + return (bytes / GIGA_BYTES) + "GB"; + } else { + return (bytes / MEGA_BYTES) + "MB"; + } + } + + @Override + public int getNumberOfClusters() { + return mClusters.length; + } + + @Override + public ArrayList<Path> getCluster(int index) { + return mClusters[index]; + } + + @Override + public String getClusterName(int index) { + return mNames[index]; + } + + public long getMinSize(int index) { + return mMinSizes[index]; + } +} diff --git a/src/com/android/gallery3d/data/TagClustering.java b/src/com/android/gallery3d/data/TagClustering.java new file mode 100644 index 000000000..c87305132 --- /dev/null +++ b/src/com/android/gallery3d/data/TagClustering.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.R; + +import android.content.Context; + +import java.util.ArrayList; +import java.util.Map; +import java.util.TreeMap; + +public class TagClustering extends Clustering { + @SuppressWarnings("unused") + private static final String TAG = "TagClustering"; + + private ArrayList<ArrayList<Path>> mClusters; + private String[] mNames; + private String mUntaggedString; + + public TagClustering(Context context) { + mUntaggedString = context.getResources().getString(R.string.untagged); + } + + @Override + public void run(MediaSet baseSet) { + final TreeMap<String, ArrayList<Path>> map = + new TreeMap<String, ArrayList<Path>>(); + final ArrayList<Path> untagged = new ArrayList<Path>(); + + baseSet.enumerateTotalMediaItems(new MediaSet.ItemConsumer() { + public void consume(int index, MediaItem item) { + Path path = item.getPath(); + + String[] tags = item.getTags(); + if (tags == null || tags.length == 0) { + untagged.add(path); + return; + } + for (int j = 0; j < tags.length; j++) { + String key = tags[j]; + ArrayList<Path> list = map.get(key); + if (list == null) { + list = new ArrayList<Path>(); + map.put(key, list); + } + list.add(path); + } + } + }); + + int m = map.size(); + mClusters = new ArrayList<ArrayList<Path>>(); + mNames = new String[m + ((untagged.size() > 0) ? 1 : 0)]; + int i = 0; + for (Map.Entry<String, ArrayList<Path>> entry : map.entrySet()) { + mNames[i++] = entry.getKey(); + mClusters.add(entry.getValue()); + } + if (untagged.size() > 0) { + mNames[i++] = mUntaggedString; + mClusters.add(untagged); + } + } + + @Override + public int getNumberOfClusters() { + return mClusters.size(); + } + + @Override + public ArrayList<Path> getCluster(int index) { + return mClusters.get(index); + } + + @Override + public String getClusterName(int index) { + return mNames[index]; + } +} diff --git a/src/com/android/gallery3d/data/TimeClustering.java b/src/com/android/gallery3d/data/TimeClustering.java new file mode 100644 index 000000000..1ccf14c13 --- /dev/null +++ b/src/com/android/gallery3d/data/TimeClustering.java @@ -0,0 +1,436 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.util.GalleryUtils; + +import android.content.Context; +import android.text.format.DateFormat; +import android.text.format.DateUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; + +public class TimeClustering extends Clustering { + private static final String TAG = "TimeClustering"; + + // If 2 items are greater than 25 miles apart, they will be in different + // clusters. + private static final int GEOGRAPHIC_DISTANCE_CUTOFF_IN_MILES = 20; + + // Do not want to split based on anything under 1 min. + private static final long MIN_CLUSTER_SPLIT_TIME_IN_MS = 60000L; + + // Disregard a cluster split time of anything over 2 hours. + private static final long MAX_CLUSTER_SPLIT_TIME_IN_MS = 7200000L; + + // Try and get around 9 clusters (best-effort for the common case). + private static final int NUM_CLUSTERS_TARGETED = 9; + + // Try and merge 2 clusters if they are both smaller than min cluster size. + // The min cluster size can range from 8 to 15. + private static final int MIN_MIN_CLUSTER_SIZE = 8; + private static final int MAX_MIN_CLUSTER_SIZE = 15; + + // Try and split a cluster if it is bigger than max cluster size. + // The max cluster size can range from 20 to 50. + private static final int MIN_MAX_CLUSTER_SIZE = 20; + private static final int MAX_MAX_CLUSTER_SIZE = 50; + + // Initially put 2 items in the same cluster as long as they are within + // 3 cluster frequencies of each other. + private static int CLUSTER_SPLIT_MULTIPLIER = 3; + + // The minimum change factor in the time between items to consider a + // partition. + // Example: (Item 3 - Item 2) / (Item 2 - Item 1). + private static final int MIN_PARTITION_CHANGE_FACTOR = 2; + + // Make the cluster split time of a large cluster half that of a regular + // cluster. + private static final int PARTITION_CLUSTER_SPLIT_TIME_FACTOR = 2; + + private Context mContext; + private ArrayList<Cluster> mClusters; + private String[] mNames; + private Cluster mCurrCluster; + + private long mClusterSplitTime = + (MIN_CLUSTER_SPLIT_TIME_IN_MS + MAX_CLUSTER_SPLIT_TIME_IN_MS) / 2; + private long mLargeClusterSplitTime = + mClusterSplitTime / PARTITION_CLUSTER_SPLIT_TIME_FACTOR; + private int mMinClusterSize = (MIN_MIN_CLUSTER_SIZE + MAX_MIN_CLUSTER_SIZE) / 2; + private int mMaxClusterSize = (MIN_MAX_CLUSTER_SIZE + MAX_MAX_CLUSTER_SIZE) / 2; + + + private static final Comparator<SmallItem> sDateComparator = + new DateComparator(); + + private static class DateComparator implements Comparator<SmallItem> { + public int compare(SmallItem item1, SmallItem item2) { + return -Utils.compare(item1.dateInMs, item2.dateInMs); + } + } + + public TimeClustering(Context context) { + mContext = context; + mClusters = new ArrayList<Cluster>(); + mCurrCluster = new Cluster(); + } + + @Override + public void run(MediaSet baseSet) { + final int total = baseSet.getTotalMediaItemCount(); + final SmallItem[] buf = new SmallItem[total]; + final double[] latLng = new double[2]; + + baseSet.enumerateTotalMediaItems(new MediaSet.ItemConsumer() { + public void consume(int index, MediaItem item) { + if (index < 0 || index >= total) return; + SmallItem s = new SmallItem(); + s.path = item.getPath(); + s.dateInMs = item.getDateInMs(); + item.getLatLong(latLng); + s.lat = latLng[0]; + s.lng = latLng[1]; + buf[index] = s; + } + }); + + ArrayList<SmallItem> items = new ArrayList<SmallItem>(total); + for (int i = 0; i < total; i++) { + if (buf[i] != null) { + items.add(buf[i]); + } + } + + Collections.sort(items, sDateComparator); + + int n = items.size(); + long minTime = 0; + long maxTime = 0; + for (int i = 0; i < n; i++) { + long t = items.get(i).dateInMs; + if (t == 0) continue; + if (minTime == 0) { + minTime = maxTime = t; + } else { + minTime = Math.min(minTime, t); + maxTime = Math.max(maxTime, t); + } + } + + setTimeRange(maxTime - minTime, n); + + for (int i = 0; i < n; i++) { + compute(items.get(i)); + } + + compute(null); + + int m = mClusters.size(); + mNames = new String[m]; + for (int i = 0; i < m; i++) { + mNames[i] = mClusters.get(i).generateCaption(mContext); + } + } + + @Override + public int getNumberOfClusters() { + return mClusters.size(); + } + + @Override + public ArrayList<Path> getCluster(int index) { + ArrayList<SmallItem> items = mClusters.get(index).getItems(); + ArrayList<Path> result = new ArrayList<Path>(items.size()); + for (int i = 0, n = items.size(); i < n; i++) { + result.add(items.get(i).path); + } + return result; + } + + @Override + public String getClusterName(int index) { + return mNames[index]; + } + + private void setTimeRange(long timeRange, int numItems) { + if (numItems != 0) { + int meanItemsPerCluster = numItems / NUM_CLUSTERS_TARGETED; + // Heuristic to get min and max cluster size - half and double the + // desired items per cluster. + mMinClusterSize = meanItemsPerCluster / 2; + mMaxClusterSize = meanItemsPerCluster * 2; + mClusterSplitTime = timeRange / numItems * CLUSTER_SPLIT_MULTIPLIER; + } + mClusterSplitTime = Utils.clamp(mClusterSplitTime, MIN_CLUSTER_SPLIT_TIME_IN_MS, MAX_CLUSTER_SPLIT_TIME_IN_MS); + mLargeClusterSplitTime = mClusterSplitTime / PARTITION_CLUSTER_SPLIT_TIME_FACTOR; + mMinClusterSize = Utils.clamp(mMinClusterSize, MIN_MIN_CLUSTER_SIZE, MAX_MIN_CLUSTER_SIZE); + mMaxClusterSize = Utils.clamp(mMaxClusterSize, MIN_MAX_CLUSTER_SIZE, MAX_MAX_CLUSTER_SIZE); + } + + private void compute(SmallItem currentItem) { + if (currentItem != null) { + int numClusters = mClusters.size(); + int numCurrClusterItems = mCurrCluster.size(); + boolean geographicallySeparateItem = false; + boolean itemAddedToCurrentCluster = false; + + // Determine if this item should go in the current cluster or be the + // start of a new cluster. + if (numCurrClusterItems == 0) { + mCurrCluster.addItem(currentItem); + } else { + SmallItem prevItem = mCurrCluster.getLastItem(); + if (isGeographicallySeparated(prevItem, currentItem)) { + mClusters.add(mCurrCluster); + geographicallySeparateItem = true; + } else if (numCurrClusterItems > mMaxClusterSize) { + splitAndAddCurrentCluster(); + } else if (timeDistance(prevItem, currentItem) < mClusterSplitTime) { + mCurrCluster.addItem(currentItem); + itemAddedToCurrentCluster = true; + } else if (numClusters > 0 && numCurrClusterItems < mMinClusterSize + && !mCurrCluster.mGeographicallySeparatedFromPrevCluster) { + mergeAndAddCurrentCluster(); + } else { + mClusters.add(mCurrCluster); + } + + // Creating a new cluster and adding the current item to it. + if (!itemAddedToCurrentCluster) { + mCurrCluster = new Cluster(); + if (geographicallySeparateItem) { + mCurrCluster.mGeographicallySeparatedFromPrevCluster = true; + } + mCurrCluster.addItem(currentItem); + } + } + } else { + if (mCurrCluster.size() > 0) { + int numClusters = mClusters.size(); + int numCurrClusterItems = mCurrCluster.size(); + + // The last cluster may potentially be too big or too small. + if (numCurrClusterItems > mMaxClusterSize) { + splitAndAddCurrentCluster(); + } else if (numClusters > 0 && numCurrClusterItems < mMinClusterSize + && !mCurrCluster.mGeographicallySeparatedFromPrevCluster) { + mergeAndAddCurrentCluster(); + } else { + mClusters.add(mCurrCluster); + } + mCurrCluster = new Cluster(); + } + } + } + + private void splitAndAddCurrentCluster() { + ArrayList<SmallItem> currClusterItems = mCurrCluster.getItems(); + int numCurrClusterItems = mCurrCluster.size(); + int secondPartitionStartIndex = getPartitionIndexForCurrentCluster(); + if (secondPartitionStartIndex != -1) { + Cluster partitionedCluster = new Cluster(); + for (int j = 0; j < secondPartitionStartIndex; j++) { + partitionedCluster.addItem(currClusterItems.get(j)); + } + mClusters.add(partitionedCluster); + partitionedCluster = new Cluster(); + for (int j = secondPartitionStartIndex; j < numCurrClusterItems; j++) { + partitionedCluster.addItem(currClusterItems.get(j)); + } + mClusters.add(partitionedCluster); + } else { + mClusters.add(mCurrCluster); + } + } + + private int getPartitionIndexForCurrentCluster() { + int partitionIndex = -1; + float largestChange = MIN_PARTITION_CHANGE_FACTOR; + ArrayList<SmallItem> currClusterItems = mCurrCluster.getItems(); + int numCurrClusterItems = mCurrCluster.size(); + int minClusterSize = mMinClusterSize; + + // Could be slightly more efficient here but this code seems cleaner. + if (numCurrClusterItems > minClusterSize + 1) { + for (int i = minClusterSize; i < numCurrClusterItems - minClusterSize; i++) { + SmallItem prevItem = currClusterItems.get(i - 1); + SmallItem currItem = currClusterItems.get(i); + SmallItem nextItem = currClusterItems.get(i + 1); + + long timeNext = nextItem.dateInMs; + long timeCurr = currItem.dateInMs; + long timePrev = prevItem.dateInMs; + + if (timeNext == 0 || timeCurr == 0 || timePrev == 0) continue; + + long diff1 = Math.abs(timeNext - timeCurr); + long diff2 = Math.abs(timeCurr - timePrev); + + float change = Math.max(diff1 / (diff2 + 0.01f), diff2 / (diff1 + 0.01f)); + if (change > largestChange) { + if (timeDistance(currItem, prevItem) > mLargeClusterSplitTime) { + partitionIndex = i; + largestChange = change; + } else if (timeDistance(nextItem, currItem) > mLargeClusterSplitTime) { + partitionIndex = i + 1; + largestChange = change; + } + } + } + } + return partitionIndex; + } + + private void mergeAndAddCurrentCluster() { + int numClusters = mClusters.size(); + Cluster prevCluster = mClusters.get(numClusters - 1); + ArrayList<SmallItem> currClusterItems = mCurrCluster.getItems(); + int numCurrClusterItems = mCurrCluster.size(); + if (prevCluster.size() < mMinClusterSize) { + for (int i = 0; i < numCurrClusterItems; i++) { + prevCluster.addItem(currClusterItems.get(i)); + } + mClusters.set(numClusters - 1, prevCluster); + } else { + mClusters.add(mCurrCluster); + } + } + + // Returns true if a, b are sufficiently geographically separated. + private static boolean isGeographicallySeparated(SmallItem itemA, SmallItem itemB) { + if (!GalleryUtils.isValidLocation(itemA.lat, itemA.lng) + || !GalleryUtils.isValidLocation(itemB.lat, itemB.lng)) { + return false; + } + + double distance = GalleryUtils.fastDistanceMeters( + Math.toRadians(itemA.lat), + Math.toRadians(itemA.lng), + Math.toRadians(itemB.lat), + Math.toRadians(itemB.lng)); + return (GalleryUtils.toMile(distance) > GEOGRAPHIC_DISTANCE_CUTOFF_IN_MILES); + } + + // Returns the time interval between the two items in milliseconds. + private static long timeDistance(SmallItem a, SmallItem b) { + return Math.abs(a.dateInMs - b.dateInMs); + } +} + +class SmallItem { + Path path; + long dateInMs; + double lat, lng; +} + +class Cluster { + @SuppressWarnings("unused") + private static final String TAG = "Cluster"; + private static final String MMDDYY_FORMAT = "MMddyy"; + + // This is for TimeClustering only. + public boolean mGeographicallySeparatedFromPrevCluster = false; + + private ArrayList<SmallItem> mItems = new ArrayList<SmallItem>(); + + public Cluster() { + } + + public void addItem(SmallItem item) { + mItems.add(item); + } + + public int size() { + return mItems.size(); + } + + public SmallItem getLastItem() { + int n = mItems.size(); + return (n == 0) ? null : mItems.get(n - 1); + } + + public ArrayList<SmallItem> getItems() { + return mItems; + } + + public String generateCaption(Context context) { + int n = mItems.size(); + long minTimestamp = 0; + long maxTimestamp = 0; + + for (int i = 0; i < n; i++) { + long t = mItems.get(i).dateInMs; + if (t == 0) continue; + if (minTimestamp == 0) { + minTimestamp = maxTimestamp = t; + } else { + minTimestamp = Math.min(minTimestamp, t); + maxTimestamp = Math.max(maxTimestamp, t); + } + } + if (minTimestamp == 0) return ""; + + String caption; + String minDay = DateFormat.format(MMDDYY_FORMAT, minTimestamp) + .toString(); + String maxDay = DateFormat.format(MMDDYY_FORMAT, maxTimestamp) + .toString(); + + if (minDay.substring(4).equals(maxDay.substring(4))) { + // The items are from the same year - show at least as + // much granularity as abbrev_all allows. + caption = DateUtils.formatDateRange(context, minTimestamp, + maxTimestamp, DateUtils.FORMAT_ABBREV_ALL); + + // Get a more granular date range string if the min and + // max timestamp are on the same day and from the + // current year. + if (minDay.equals(maxDay)) { + int flags = DateUtils.FORMAT_ABBREV_MONTH | DateUtils.FORMAT_SHOW_DATE; + // Contains the year only if the date does not + // correspond to the current year. + String dateRangeWithOptionalYear = DateUtils.formatDateTime( + context, minTimestamp, flags); + String dateRangeWithYear = DateUtils.formatDateTime( + context, minTimestamp, flags | DateUtils.FORMAT_SHOW_YEAR); + if (!dateRangeWithOptionalYear.equals(dateRangeWithYear)) { + // This means both dates are from the same year + // - show the time. + // Not enough room to display the time range. + // Pick the mid-point. + long midTimestamp = (minTimestamp + maxTimestamp) / 2; + caption = DateUtils.formatDateRange(context, midTimestamp, + midTimestamp, DateUtils.FORMAT_SHOW_TIME | flags); + } + } + } else { + // The items are not from the same year - only show + // month and year. + int flags = DateUtils.FORMAT_NO_MONTH_DAY + | DateUtils.FORMAT_ABBREV_MONTH | DateUtils.FORMAT_SHOW_DATE; + caption = DateUtils.formatDateRange(context, minTimestamp, + maxTimestamp, flags); + } + + return caption; + } +} diff --git a/src/com/android/gallery3d/data/UriImage.java b/src/com/android/gallery3d/data/UriImage.java new file mode 100644 index 000000000..3a7ed7c3f --- /dev/null +++ b/src/com/android/gallery3d/data/UriImage.java @@ -0,0 +1,266 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.common.BitmapUtils; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.util.ThreadPool.CancelListener; +import com.android.gallery3d.util.ThreadPool.Job; +import com.android.gallery3d.util.ThreadPool.JobContext; + +import android.content.ContentResolver; +import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; +import android.graphics.BitmapFactory.Options; +import android.graphics.BitmapRegionDecoder; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.webkit.MimeTypeMap; + +import java.io.FileNotFoundException; +import java.net.URI; +import java.net.URL; + +public class UriImage extends MediaItem { + private static final String TAG = "UriImage"; + + private static final int STATE_INIT = 0; + private static final int STATE_DOWNLOADING = 1; + private static final int STATE_DOWNLOADED = 2; + private static final int STATE_ERROR = -1; + + private final Uri mUri; + private final String mContentType; + + private DownloadCache.Entry mCacheEntry; + private ParcelFileDescriptor mFileDescriptor; + private int mState = STATE_INIT; + private int mWidth; + private int mHeight; + + private GalleryApp mApplication; + + public UriImage(GalleryApp application, Path path, Uri uri) { + super(path, nextVersionNumber()); + mUri = uri; + mApplication = Utils.checkNotNull(application); + mContentType = getMimeType(uri); + } + + private String getMimeType(Uri uri) { + if (ContentResolver.SCHEME_FILE.equals(uri.getScheme())) { + String extension = + MimeTypeMap.getFileExtensionFromUrl(uri.toString()); + String type = MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(extension); + if (type != null) return type; + } + return mApplication.getContentResolver().getType(uri); + } + + @Override + public Job<Bitmap> requestImage(int type) { + return new BitmapJob(type); + } + + @Override + public Job<BitmapRegionDecoder> requestLargeImage() { + return new RegionDecoderJob(); + } + + private void openFileOrDownloadTempFile(JobContext jc) { + int state = openOrDownloadInner(jc); + synchronized (this) { + mState = state; + if (mState != STATE_DOWNLOADED) { + if (mFileDescriptor != null) { + Utils.closeSilently(mFileDescriptor); + mFileDescriptor = null; + } + } + notifyAll(); + } + } + + private int openOrDownloadInner(JobContext jc) { + String scheme = mUri.getScheme(); + if (ContentResolver.SCHEME_CONTENT.equals(scheme) + || ContentResolver.SCHEME_ANDROID_RESOURCE.equals(scheme) + || ContentResolver.SCHEME_FILE.equals(scheme)) { + try { + mFileDescriptor = mApplication.getContentResolver() + .openFileDescriptor(mUri, "r"); + if (jc.isCancelled()) return STATE_INIT; + return STATE_DOWNLOADED; + } catch (FileNotFoundException e) { + Log.w(TAG, "fail to open: " + mUri, e); + return STATE_ERROR; + } + } else { + try { + URL url = new URI(mUri.toString()).toURL(); + mCacheEntry = mApplication.getDownloadCache().download(jc, url); + if (jc.isCancelled()) return STATE_INIT; + if (mCacheEntry == null) { + Log.w(TAG, "download failed " + url); + return STATE_ERROR; + } + mFileDescriptor = ParcelFileDescriptor.open( + mCacheEntry.cacheFile, ParcelFileDescriptor.MODE_READ_ONLY); + return STATE_DOWNLOADED; + } catch (Throwable t) { + Log.w(TAG, "download error", t); + return STATE_ERROR; + } + } + } + + private boolean prepareInputFile(JobContext jc) { + jc.setCancelListener(new CancelListener() { + public void onCancel() { + synchronized (this) { + notifyAll(); + } + } + }); + + while (true) { + synchronized (this) { + if (jc.isCancelled()) return false; + if (mState == STATE_INIT) { + mState = STATE_DOWNLOADING; + // Then leave the synchronized block and continue. + } else if (mState == STATE_ERROR) { + return false; + } else if (mState == STATE_DOWNLOADED) { + return true; + } else /* if (mState == STATE_DOWNLOADING) */ { + try { + wait(); + } catch (InterruptedException ex) { + // ignored. + } + continue; + } + } + // This is only reached for STATE_INIT->STATE_DOWNLOADING + openFileOrDownloadTempFile(jc); + } + } + + private class RegionDecoderJob implements Job<BitmapRegionDecoder> { + public BitmapRegionDecoder run(JobContext jc) { + if (!prepareInputFile(jc)) return null; + BitmapRegionDecoder decoder = DecodeUtils.requestCreateBitmapRegionDecoder( + jc, mFileDescriptor.getFileDescriptor(), false); + mWidth = decoder.getWidth(); + mHeight = decoder.getHeight(); + return decoder; + } + } + + private class BitmapJob implements Job<Bitmap> { + private int mType; + + protected BitmapJob(int type) { + mType = type; + } + + public Bitmap run(JobContext jc) { + if (!prepareInputFile(jc)) return null; + int targetSize = LocalImage.getTargetSize(mType); + Options options = new Options(); + options.inPreferredConfig = Config.ARGB_8888; + Bitmap bitmap = DecodeUtils.requestDecode(jc, + mFileDescriptor.getFileDescriptor(), options, targetSize); + if (jc.isCancelled() || bitmap == null) { + return null; + } + + if (mType == MediaItem.TYPE_MICROTHUMBNAIL) { + bitmap = BitmapUtils.resizeDownAndCropCenter(bitmap, + targetSize, true); + } else { + bitmap = BitmapUtils.resizeDownBySideLength(bitmap, + targetSize, true); + } + + return bitmap; + } + } + + @Override + public int getSupportedOperations() { + int supported = SUPPORT_EDIT | SUPPORT_SETAS; + if (isSharable()) supported |= SUPPORT_SHARE; + if (BitmapUtils.isSupportedByRegionDecoder(mContentType)) { + supported |= SUPPORT_FULL_IMAGE; + } + return supported; + } + + private boolean isSharable() { + // We cannot grant read permission to the receiver since we put + // the data URI in EXTRA_STREAM instead of the data part of an intent + // And there are issues in MediaUploader and Bluetooth file sender to + // share a general image data. So, we only share for local file. + return ContentResolver.SCHEME_FILE.equals(mUri.getScheme()); + } + + @Override + public int getMediaType() { + return MEDIA_TYPE_IMAGE; + } + + @Override + public Uri getContentUri() { + return mUri; + } + + @Override + public MediaDetails getDetails() { + MediaDetails details = super.getDetails(); + if (mWidth != 0 && mHeight != 0) { + details.addDetail(MediaDetails.INDEX_WIDTH, mWidth); + details.addDetail(MediaDetails.INDEX_HEIGHT, mHeight); + } + details.addDetail(MediaDetails.INDEX_MIMETYPE, mContentType); + if (ContentResolver.SCHEME_FILE.equals(mUri.getScheme())) { + String filePath = mUri.getPath(); + details.addDetail(MediaDetails.INDEX_PATH, filePath); + MediaDetails.extractExifInfo(details, filePath); + } + return details; + } + + @Override + public String getMimeType() { + return mContentType; + } + + @Override + protected void finalize() throws Throwable { + try { + if (mFileDescriptor != null) { + Utils.closeSilently(mFileDescriptor); + } + } finally { + super.finalize(); + } + } +} diff --git a/src/com/android/gallery3d/data/UriSource.java b/src/com/android/gallery3d/data/UriSource.java new file mode 100644 index 000000000..ac62b93a7 --- /dev/null +++ b/src/com/android/gallery3d/data/UriSource.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.app.GalleryApp; + +import android.net.Uri; + +import java.net.URLDecoder; +import java.net.URLEncoder; + +class UriSource extends MediaSource { + @SuppressWarnings("unused") + private static final String TAG = "UriSource"; + + private GalleryApp mApplication; + + public UriSource(GalleryApp context) { + super("uri"); + mApplication = context; + } + + @Override + public MediaObject createMediaObject(Path path) { + String segment[] = path.split(); + if (segment.length != 2) { + throw new RuntimeException("bad path: " + path); + } + + String decoded = URLDecoder.decode(segment[1]); + return new UriImage(mApplication, path, Uri.parse(decoded)); + } + + @Override + public Path findPathByUri(Uri uri) { + String type = mApplication.getContentResolver().getType(uri); + // Assume the type is image if the type cannot be resolved + // This could happen for "http" URI. + if (type == null || type.startsWith("image/")) { + return Path.fromString("/uri/" + URLEncoder.encode(uri.toString())); + } + return null; + } +} diff --git a/src/com/android/gallery3d/provider/GalleryProvider.java b/src/com/android/gallery3d/provider/GalleryProvider.java new file mode 100644 index 000000000..f5f0f1b3c --- /dev/null +++ b/src/com/android/gallery3d/provider/GalleryProvider.java @@ -0,0 +1,224 @@ +/* + * Copyright (C) 2009 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.gallery3d.provider; + +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.data.DataManager; +import com.android.gallery3d.data.DownloadCache; +import com.android.gallery3d.data.MediaItem; +import com.android.gallery3d.data.MediaObject; +import com.android.gallery3d.data.MtpImage; +import com.android.gallery3d.data.Path; +import com.android.gallery3d.picasasource.PicasaSource; +import com.android.gallery3d.util.GalleryUtils; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.Binder; +import android.os.Bundle; +import android.os.ParcelFileDescriptor; +import android.provider.MediaStore.Images.ImageColumns; +import android.util.Log; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.OutputStream; + +public class GalleryProvider extends ContentProvider { + private static final String TAG = "GalleryProvider"; + + public static final String AUTHORITY = "com.android.gallery3d.provider"; + public static final Uri BASE_URI = Uri.parse("content://" + AUTHORITY); + + private DataManager mDataManager; + private DownloadCache mDownloadCache; + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException(); + } + + // TODO: consider concurrent access + @Override + public String getType |