package com.android.ex.photo; import android.app.Activity; import android.app.ActivityManager; import android.content.Context; import android.content.Intent; import android.content.res.Resources; import android.database.Cursor; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Process; import androidx.annotation.IdRes; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.loader.app.LoaderManager; import androidx.loader.content.Loader; import androidx.viewpager.widget.ViewPager.OnPageChangeListener; import android.text.TextUtils; import android.util.DisplayMetrics; import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ViewPropertyAnimator; import android.view.ViewTreeObserver.OnGlobalLayoutListener; import android.view.WindowManager; import android.view.accessibility.AccessibilityManager; import android.view.animation.AlphaAnimation; import android.view.animation.Animation; import android.view.animation.Animation.AnimationListener; import android.view.animation.AnimationSet; import android.view.animation.ScaleAnimation; import android.view.animation.TranslateAnimation; import android.widget.ImageView; import com.android.ex.photo.ActionBarInterface.OnMenuVisibilityListener; import com.android.ex.photo.PhotoViewPager.InterceptType; import com.android.ex.photo.PhotoViewPager.OnInterceptTouchListener; import com.android.ex.photo.adapters.PhotoPagerAdapter; import com.android.ex.photo.fragments.PhotoViewFragment; import com.android.ex.photo.loaders.PhotoBitmapLoader; import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface.BitmapResult; import com.android.ex.photo.loaders.PhotoPagerLoader; import com.android.ex.photo.provider.PhotoContract; import com.android.ex.photo.util.ImageUtils; import com.android.ex.photo.util.Util; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; /** * This class implements all the logic of the photo view activity. An activity should use this class * calling through from relevant activity methods to the methods of the same name here. * * To customize the photo viewer activity, you should subclass this and implement your * customizations here. Then subclass {@link PhotoViewActivity} and override just * {@link PhotoViewActivity#createController createController} to instantiate your controller * subclass. */ public class PhotoViewController implements LoaderManager.LoaderCallbacks, OnPageChangeListener, OnInterceptTouchListener, OnMenuVisibilityListener, PhotoViewCallbacks { /** * Defines the interface between the Activity and this class. * * The activity itself must delegate all appropriate method calls into this class, to the * methods of the same name. */ public interface ActivityInterface { public Context getContext(); public Context getApplicationContext(); public Intent getIntent(); public void setContentView(int resId); public T findViewById(int id); public Resources getResources(); public FragmentManager getSupportFragmentManager(); public LoaderManager getSupportLoaderManager(); public ActionBarInterface getActionBarInterface(); public boolean onOptionsItemSelected(MenuItem item); public void finish(); public void overridePendingTransition(int enterAnim, int exitAnim); public PhotoViewController getController(); } private final static String TAG = "PhotoViewController"; private final static String STATE_INITIAL_URI_KEY = "com.android.ex.PhotoViewFragment.INITIAL_URI"; private final static String STATE_CURRENT_URI_KEY = "com.android.ex.PhotoViewFragment.CURRENT_URI"; private final static String STATE_CURRENT_INDEX_KEY = "com.android.ex.PhotoViewFragment.CURRENT_INDEX"; private final static String STATE_FULLSCREEN_KEY = "com.android.ex.PhotoViewFragment.FULLSCREEN"; private final static String STATE_ACTIONBARTITLE_KEY = "com.android.ex.PhotoViewFragment.ACTIONBARTITLE"; private final static String STATE_ACTIONBARSUBTITLE_KEY = "com.android.ex.PhotoViewFragment.ACTIONBARSUBTITLE"; private final static String STATE_ENTERANIMATIONFINISHED_KEY = "com.android.ex.PhotoViewFragment.SCALEANIMATIONFINISHED"; protected final static String ARG_IMAGE_URI = "image_uri"; public static final int LOADER_PHOTO_LIST = 100; /** Count used when the real photo count is unknown [but, may be determined] */ public static final int ALBUM_COUNT_UNKNOWN = -1; public static final int ENTER_ANIMATION_DURATION_MS = 250; public static final int EXIT_ANIMATION_DURATION_MS = 250; /** Argument key for the dialog message */ public static final String KEY_MESSAGE = "dialog_message"; public static int sMemoryClass; public static int sMaxPhotoSize; // The maximum size (either width or height) private final ActivityInterface mActivity; private int mLastFlags; private final View.OnSystemUiVisibilityChangeListener mSystemUiVisibilityChangeListener; /** The URI of the photos we're viewing; may be {@code null} */ private String mPhotosUri; /** The uri of the initial photo */ private String mInitialPhotoUri; /** The index of the currently viewed photo */ private int mCurrentPhotoIndex; /** The uri of the currently viewed photo */ private String mCurrentPhotoUri; /** The query projection to use; may be {@code null} */ private String[] mProjection; /** The total number of photos; only valid if {@link #mIsEmpty} is {@code false}. */ protected int mAlbumCount = ALBUM_COUNT_UNKNOWN; /** {@code true} if the view is empty. Otherwise, {@code false}. */ protected boolean mIsEmpty; /** the main root view */ protected View mRootView; /** Background image that contains nothing, so it can be alpha faded from * transparent to black without affecting any other views. */ @Nullable protected View mBackground; /** The main pager; provides left/right swipe between photos */ protected PhotoViewPager mViewPager; /** The temporary image so that we can quickly scale up the fullscreen thumbnail */ @Nullable protected ImageView mTemporaryImage; /** Adapter to create pager views */ protected PhotoPagerAdapter mAdapter; /** Whether or not we're in "full screen" mode */ protected boolean mFullScreen; /** The listeners wanting full screen state for each screen position */ private final Map mScreenListeners = new HashMap(); /** The set of listeners wanting full screen state */ private final Set mCursorListeners = new HashSet(); /** When {@code true}, restart the loader when the activity becomes active */ private boolean mKickLoader; /** Don't attempt operations that may trigger a fragment transaction when the activity is * destroyed */ private boolean mIsDestroyedCompat; /** Whether or not this activity is paused */ protected boolean mIsPaused = true; /** The maximum scale factor applied to images when they are initially displayed */ protected float mMaxInitialScale; /** The title in the actionbar */ protected String mActionBarTitle; /** The subtitle in the actionbar */ protected String mActionBarSubtitle; private boolean mEnterAnimationFinished; protected boolean mScaleAnimationEnabled; protected int mAnimationStartX; protected int mAnimationStartY; protected int mAnimationStartWidth; protected int mAnimationStartHeight; /** Whether lights out should invoked based on timer */ protected boolean mIsTimerLightsOutEnabled; protected boolean mActionBarHiddenInitially; protected boolean mDisplayThumbsFullScreen; private final AccessibilityManager mAccessibilityManager; protected BitmapCallback mBitmapCallback; protected final Handler mHandler = new Handler(); // TODO Find a better way to do this. We basically want the activity to display the // "loading..." progress until the fragment takes over and shows it's own "loading..." // progress [located in photo_header_view.xml]. We could potentially have all status displayed // by the activity, but, that gets tricky when it comes to screen rotation. For now, we // track the loading by this variable which is fragile and may cause phantom "loading..." // text. private long mEnterFullScreenDelayTime; private int lastAnnouncedTitle = -1; public PhotoViewController(ActivityInterface activity) { mActivity = activity; // View.OnSystemUiVisibilityChangeListener is an API that was introduced in API level 11. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB) { mSystemUiVisibilityChangeListener = null; } else { mSystemUiVisibilityChangeListener = new View.OnSystemUiVisibilityChangeListener() { @Override public void onSystemUiVisibilityChange(int visibility) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && visibility == 0 && mLastFlags == 3846) { setFullScreen(false /* fullscreen */, true /* setDelayedRunnable */); } } }; } mAccessibilityManager = (AccessibilityManager) activity.getContext().getSystemService(Context.ACCESSIBILITY_SERVICE); } public PhotoPagerAdapter createPhotoPagerAdapter(Context context, androidx.fragment.app.FragmentManager fm, Cursor c, float maxScale) { return new PhotoPagerAdapter(context, fm, c, maxScale, mDisplayThumbsFullScreen); } public PhotoViewController.ActivityInterface getActivity() { return mActivity; } public void onCreate(Bundle savedInstanceState) { initMaxPhotoSize(); final ActivityManager mgr = (ActivityManager) mActivity.getApplicationContext(). getSystemService(Activity.ACTIVITY_SERVICE); sMemoryClass = mgr.getMemoryClass(); final Intent intent = mActivity.getIntent(); // uri of the photos to view; optional if (intent.hasExtra(Intents.EXTRA_PHOTOS_URI)) { mPhotosUri = intent.getStringExtra(Intents.EXTRA_PHOTOS_URI); } mIsTimerLightsOutEnabled = intent.getBooleanExtra( Intents.EXTRA_ENABLE_TIMER_LIGHTS_OUT, true); if (intent.getBooleanExtra(Intents.EXTRA_SCALE_UP_ANIMATION, false)) { mScaleAnimationEnabled = true; mAnimationStartX = intent.getIntExtra(Intents.EXTRA_ANIMATION_START_X, 0); mAnimationStartY = intent.getIntExtra(Intents.EXTRA_ANIMATION_START_Y, 0); mAnimationStartWidth = intent.getIntExtra(Intents.EXTRA_ANIMATION_START_WIDTH, 0); mAnimationStartHeight = intent.getIntExtra(Intents.EXTRA_ANIMATION_START_HEIGHT, 0); } mActionBarHiddenInitially = intent.getBooleanExtra( Intents.EXTRA_ACTION_BAR_HIDDEN_INITIALLY, false) && !Util.isTouchExplorationEnabled(mAccessibilityManager); mDisplayThumbsFullScreen = intent.getBooleanExtra( Intents.EXTRA_DISPLAY_THUMBS_FULLSCREEN, false); // projection for the query; optional // If not set, the default projection is used. // This projection must include the columns from the default projection. if (intent.hasExtra(Intents.EXTRA_PROJECTION)) { mProjection = intent.getStringArrayExtra(Intents.EXTRA_PROJECTION); } else { mProjection = null; } // Set the max initial scale, defaulting to 1x mMaxInitialScale = intent.getFloatExtra(Intents.EXTRA_MAX_INITIAL_SCALE, 1.0f); mCurrentPhotoUri = null; mCurrentPhotoIndex = -1; // We allow specifying the current photo by either index or uri. // This is because some users may have live datasets that can change, // adding new items to either the beginning or end of the set. For clients // that do not need that capability, ability to specify the current photo // by index is offered as a convenience. if (intent.hasExtra(Intents.EXTRA_PHOTO_INDEX)) { mCurrentPhotoIndex = intent.getIntExtra(Intents.EXTRA_PHOTO_INDEX, -1); } if (intent.hasExtra(Intents.EXTRA_INITIAL_PHOTO_URI)) { mInitialPhotoUri = intent.getStringExtra(Intents.EXTRA_INITIAL_PHOTO_URI); mCurrentPhotoUri = mInitialPhotoUri; } mIsEmpty = true; if (savedInstanceState != null) { mInitialPhotoUri = savedInstanceState.getString(STATE_INITIAL_URI_KEY); mCurrentPhotoUri = savedInstanceState.getString(STATE_CURRENT_URI_KEY); mCurrentPhotoIndex = savedInstanceState.getInt(STATE_CURRENT_INDEX_KEY); mFullScreen = savedInstanceState.getBoolean(STATE_FULLSCREEN_KEY, false) && !Util.isTouchExplorationEnabled(mAccessibilityManager); mActionBarTitle = savedInstanceState.getString(STATE_ACTIONBARTITLE_KEY); mActionBarSubtitle = savedInstanceState.getString(STATE_ACTIONBARSUBTITLE_KEY); mEnterAnimationFinished = savedInstanceState.getBoolean( STATE_ENTERANIMATIONFINISHED_KEY, false); } else { mFullScreen = mActionBarHiddenInitially; } mActivity.setContentView(getContentViewId()); // Create the adapter and add the view pager mAdapter = createPhotoPagerAdapter(mActivity.getContext(), mActivity.getSupportFragmentManager(), null, mMaxInitialScale); final Resources resources = mActivity.getResources(); mRootView = findViewById(getRootViewId()); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { mRootView.setOnSystemUiVisibilityChangeListener(getSystemUiVisibilityChangeListener()); } mBackground = getBackground(); mTemporaryImage = getTemporaryImage(); mViewPager = (PhotoViewPager) findViewById(R.id.photo_view_pager); mViewPager.setAdapter(mAdapter); mViewPager.setOnPageChangeListener(this); mViewPager.setOnInterceptTouchListener(this); mViewPager.setPageMargin(resources.getDimensionPixelSize(R.dimen.photo_page_margin)); mBitmapCallback = new BitmapCallback(); if (!mScaleAnimationEnabled || mEnterAnimationFinished) { // We are not running the scale up animation. Just let the fragments // display and handle the animation. mActivity.getSupportLoaderManager().initLoader(LOADER_PHOTO_LIST, null, this); // Make the background opaque immediately so that we don't see the activity // behind this one. if (hasBackground()) { mBackground.setVisibility(View.VISIBLE); } } else { // Attempt to load the initial image thumbnail. Once we have the // image, animate it up. Once the animation is complete, we can kick off // loading the ViewPager. After the primary fullres image is loaded, we will // make our temporary image invisible and display the ViewPager. mViewPager.setVisibility(View.GONE); Bundle args = new Bundle(); args.putString(ARG_IMAGE_URI, mInitialPhotoUri); mActivity.getSupportLoaderManager().initLoader( BITMAP_LOADER_THUMBNAIL, args, mBitmapCallback); } mEnterFullScreenDelayTime = resources.getInteger(R.integer.reenter_fullscreen_delay_time_in_millis); final ActionBarInterface actionBar = mActivity.getActionBarInterface(); if (actionBar != null) { actionBar.setDisplayHomeAsUpEnabled(true); actionBar.addOnMenuVisibilityListener(this); actionBar.setDisplayOptionsShowTitle(); // Set the title and subtitle immediately here, rather than waiting // for the fragment to be initialized. setActionBarTitles(actionBar); } if (!mScaleAnimationEnabled) { setLightsOutMode(mFullScreen); } else { // Keep lights out mode as false. This is to prevent jank cause by concurrent // animations during the enter animation. setLightsOutMode(false); } } private void initMaxPhotoSize() { if (sMaxPhotoSize == 0) { final DisplayMetrics metrics = new DisplayMetrics(); final WindowManager wm = (WindowManager) mActivity.getContext().getSystemService(Context.WINDOW_SERVICE); final ImageUtils.ImageSize imageSize = ImageUtils.sUseImageSize; wm.getDefaultDisplay().getMetrics(metrics); switch (imageSize) { case EXTRA_SMALL: // Use a photo that's 80% of the "small" size sMaxPhotoSize = (Math.min(metrics.heightPixels, metrics.widthPixels) * 800) / 1000; break; case SMALL: // Fall through. case NORMAL: // Fall through. default: sMaxPhotoSize = Math.min(metrics.heightPixels, metrics.widthPixels); break; } } } public boolean onCreateOptionsMenu(Menu menu) { return true; } public boolean onPrepareOptionsMenu(Menu menu) { return true; } public void onActivityResult(int requestCode, int resultCode, Intent data) {} protected View findViewById(int id) { return mActivity.findViewById(id); } /** * Returns the android id of the viewer's root view. Subclasses should override this method if * they provide their own layout. */ @IdRes protected int getRootViewId() { return R.id.photo_activity_root_view; } /** * Returns the android layout id of the root layout that should be inflated for the viewer. * Subclasses should override this method if they provide their own layout. */ @IdRes protected int getContentViewId() { return R.layout.photo_activity_view; } /** * Returns the android view for the viewer's background view, if it has one. Subclasses should * override this if they have a different (or no) background view. */ @Nullable protected View getBackground() { return findViewById(R.id.photo_activity_background); } /** * Returns whether or not the view has a background object. Subclasses should override this if * they do not contain a background object. */ protected boolean hasBackground() { return mBackground != null; } /** * Returns the android view for the viewer's temporary image, if it has one. Subclasses should * override this if they have a different (or no) temporary image view. */ @Nullable protected ImageView getTemporaryImage() { return (ImageView) findViewById(R.id.photo_activity_temporary_image); } /** * Returns whether or not the view has a temporary image view. Subclasses should override this * if they do not use a temporary image. */ protected boolean hasTemporaryImage() { return mTemporaryImage != null; } public void onStart() {} public void onResume() { setFullScreen(mFullScreen, false); mIsPaused = false; if (mKickLoader) { mKickLoader = false; mActivity.getSupportLoaderManager().initLoader(LOADER_PHOTO_LIST, null, this); } } public void onPause() { mIsPaused = true; } public void onStop() {} public void onDestroy() { mIsDestroyedCompat = true; } private boolean isDestroyedCompat() { return mIsDestroyedCompat; } public boolean onBackPressed() { // If we are in fullscreen mode, and the default is not full screen, then // switch back to actionBar display mode. if (mFullScreen && !mActionBarHiddenInitially) { toggleFullScreen(); } else { if (mScaleAnimationEnabled) { runExitAnimation(); } else { return false; } } return true; } public void onSaveInstanceState(Bundle outState) { outState.putString(STATE_INITIAL_URI_KEY, mInitialPhotoUri); outState.putString(STATE_CURRENT_URI_KEY, mCurrentPhotoUri); outState.putInt(STATE_CURRENT_INDEX_KEY, mCurrentPhotoIndex); outState.putBoolean(STATE_FULLSCREEN_KEY, mFullScreen); outState.putString(STATE_ACTIONBARTITLE_KEY, mActionBarTitle); outState.putString(STATE_ACTIONBARSUBTITLE_KEY, mActionBarSubtitle); outState.putBoolean(STATE_ENTERANIMATIONFINISHED_KEY, mEnterAnimationFinished); } public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case android.R.id.home: mActivity.finish(); return true; default: return false; } } @Override public void addScreenListener(int position, OnScreenListener listener) { mScreenListeners.put(position, listener); } @Override public void removeScreenListener(int position) { mScreenListeners.remove(position); } @Override public synchronized void addCursorListener(CursorChangedListener listener) { mCursorListeners.add(listener); } @Override public synchronized void removeCursorListener(CursorChangedListener listener) { mCursorListeners.remove(listener); } @Override public boolean isFragmentFullScreen(Fragment fragment) { if (mViewPager == null || mAdapter == null || mAdapter.getCount() == 0) { return mFullScreen; } return mFullScreen || (mViewPager.getCurrentItem() != mAdapter.getItemPosition(fragment)); } @Override public void toggleFullScreen() { setFullScreen(!mFullScreen, true); } public void onPhotoRemoved(long photoId) { final Cursor data = mAdapter.getCursor(); if (data == null) { // Huh?! How would this happen? return; } final int dataCount = data.getCount(); if (dataCount <= 1) { mActivity.finish(); return; } mActivity.getSupportLoaderManager().restartLoader(LOADER_PHOTO_LIST, null, this); } @Override public Loader onCreateLoader(int id, Bundle args) { if (id == LOADER_PHOTO_LIST) { return new PhotoPagerLoader(mActivity.getContext(), Uri.parse(mPhotosUri), mProjection); } return null; } @Override public Loader onCreateBitmapLoader(int id, Bundle args, String uri) { switch (id) { case BITMAP_LOADER_AVATAR: case BITMAP_LOADER_THUMBNAIL: case BITMAP_LOADER_PHOTO: return new PhotoBitmapLoader(mActivity.getContext(), uri); default: return null; } } @Override public void onLoadFinished(Loader loader, Cursor data) { final int id = loader.getId(); if (id == LOADER_PHOTO_LIST) { if (data == null || data.getCount() == 0) { mIsEmpty = true; mAdapter.swapCursor(null); } else { mAlbumCount = data.getCount(); if (mCurrentPhotoUri != null) { int index = 0; // Clear query params. Compare only the path. final int uriIndex = data.getColumnIndex(PhotoContract.PhotoViewColumns.URI); final Uri currentPhotoUri; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { currentPhotoUri = Uri.parse(mCurrentPhotoUri).buildUpon() .clearQuery().build(); } else { currentPhotoUri = Uri.parse(mCurrentPhotoUri).buildUpon() .query(null).build(); } // Rewind data cursor to the start if it has already advanced. data.moveToPosition(-1); while (data.moveToNext()) { final String uriString = data.getString(uriIndex); final Uri uri; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { uri = Uri.parse(uriString).buildUpon().clearQuery().build(); } else { uri = Uri.parse(uriString).buildUpon().query(null).build(); } if (currentPhotoUri != null && currentPhotoUri.equals(uri)) { mCurrentPhotoIndex = index; break; } index++; } } // We're paused; don't do anything now, we'll get re-invoked // when the activity becomes active again if (mIsPaused) { mKickLoader = true; mAdapter.swapCursor(null); return; } boolean wasEmpty = mIsEmpty; mIsEmpty = false; mAdapter.swapCursor(data); if (mViewPager.getAdapter() == null) { mViewPager.setAdapter(mAdapter); } notifyCursorListeners(data); // Use an index of 0 if the index wasn't specified or couldn't be found if (mCurrentPhotoIndex < 0) { mCurrentPhotoIndex = 0; } mViewPager.setCurrentItem(mCurrentPhotoIndex, false); if (wasEmpty) { setViewActivated(mCurrentPhotoIndex); } } // Update the any action items updateActionItems(); } } @Override public void onLoaderReset(androidx.loader.content.Loader loader) { // If the loader is reset, remove the reference in the adapter to this cursor if (!isDestroyedCompat()) { // This will cause a fragment transaction which can't happen if we're destroyed, // but we don't care in that case because we're destroyed anyways. mAdapter.swapCursor(null); } } public void updateActionItems() { // Do nothing, but allow extending classes to do work } private synchronized void notifyCursorListeners(Cursor data) { // tell all of the objects listening for cursor changes // that the cursor has changed for (CursorChangedListener listener : mCursorListeners) { listener.onCursorChanged(data); } } @Override public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { if (positionOffset < 0.0001) { OnScreenListener before = mScreenListeners.get(position - 1); if (before != null) { before.onViewUpNext(); } OnScreenListener after = mScreenListeners.get(position + 1); if (after != null) { after.onViewUpNext(); } } } @Override public void onPageSelected(int position) { mCurrentPhotoIndex = position; setViewActivated(position); } @Override public void onPageScrollStateChanged(int state) { } @Override public boolean isFragmentActive(Fragment fragment) { if (mViewPager == null || mAdapter == null) { return false; } return mViewPager.getCurrentItem() == mAdapter.getItemPosition(fragment); } @Override public void onFragmentVisible(PhotoViewFragment fragment) { // Do nothing, we handle this in setViewActivated } @Override public InterceptType onTouchIntercept(float origX, float origY) { boolean interceptLeft = false; boolean interceptRight = false; for (OnScreenListener listener : mScreenListeners.values()) { if (!interceptLeft) { interceptLeft = listener.onInterceptMoveLeft(origX, origY); } if (!interceptRight) { interceptRight = listener.onInterceptMoveRight(origX, origY); } } if (interceptLeft) { if (interceptRight) { return InterceptType.BOTH; } return InterceptType.LEFT; } else if (interceptRight) { return InterceptType.RIGHT; } return InterceptType.NONE; } /** * Updates the title bar according to the value of {@link #mFullScreen}. */ protected void setFullScreen(boolean fullScreen, boolean setDelayedRunnable) { if (Util.isTouchExplorationEnabled(mAccessibilityManager)) { // Disallow full screen mode when accessibility is enabled so that the action bar // stays accessible. fullScreen = false; setDelayedRunnable = false; } final boolean fullScreenChanged = (fullScreen != mFullScreen); mFullScreen = fullScreen; if (mFullScreen) { setLightsOutMode(true); cancelEnterFullScreenRunnable(); } else { setLightsOutMode(false); if (setDelayedRunnable) { postEnterFullScreenRunnableWithDelay(); } } if (fullScreenChanged) { for (OnScreenListener listener : mScreenListeners.values()) { listener.onFullScreenChanged(mFullScreen); } } } /** * Posts a runnable to enter full screen after mEnterFullScreenDelayTime. This method is a * no-op if mIsTimerLightsOutEnabled is set to false. */ private void postEnterFullScreenRunnableWithDelay() { if (mIsTimerLightsOutEnabled) { mHandler.postDelayed(mEnterFullScreenRunnable, mEnterFullScreenDelayTime); } } private void cancelEnterFullScreenRunnable() { mHandler.removeCallbacks(mEnterFullScreenRunnable); } protected void setLightsOutMode(boolean enabled) { setImmersiveMode(enabled); } private final Runnable mEnterFullScreenRunnable = new Runnable() { @Override public void run() { setFullScreen(true, true); } }; @Override public void setViewActivated(int position) { OnScreenListener listener = mScreenListeners.get(position); if (listener != null) { listener.onViewActivated(); } final Cursor cursor = getCursorAtProperPosition(); mCurrentPhotoIndex = position; // FLAG: get the column indexes once in onLoadFinished(). // That would make this more efficient, instead of looking these up // repeatedly whenever we want them. int uriIndex = cursor.getColumnIndex(PhotoContract.PhotoViewColumns.URI); mCurrentPhotoUri = cursor.getString(uriIndex); updateActionBar(); if (mAccessibilityManager.isEnabled() && lastAnnouncedTitle != position) { String announcement = getPhotoAccessibilityAnnouncement(position); if (announcement != null) { Util.announceForAccessibility(mRootView, mAccessibilityManager, announcement); lastAnnouncedTitle = position; } } // Restart the timer to return to fullscreen. cancelEnterFullScreenRunnable(); postEnterFullScreenRunnableWithDelay(); } /** * Adjusts the activity title and subtitle to reflect the photo name and count. */ public void updateActionBar() { final int position = mViewPager.getCurrentItem() + 1; final boolean hasAlbumCount = mAlbumCount >= 0; final Cursor cursor = getCursorAtProperPosition(); if (cursor != null) { // FLAG: We should grab the indexes when we first get the cursor // and store them so we don't need to do it each time. final int photoNameIndex = cursor.getColumnIndex(PhotoContract.PhotoViewColumns.NAME); mActionBarTitle = cursor.getString(photoNameIndex); } else { mActionBarTitle = null; } if (mIsEmpty || !hasAlbumCount || position <= 0) { mActionBarSubtitle = null; } else { mActionBarSubtitle = mActivity.getResources().getString( R.string.photo_view_count, position, mAlbumCount); } setActionBarTitles(mActivity.getActionBarInterface()); } /** * Returns a string used as an announcement for accessibility after the user moves to a new * photo. It will be called after {@link #updateActionBar} has been called. * @param position the index in the album of the currently active photo * @return announcement for accessibility */ protected String getPhotoAccessibilityAnnouncement(int position) { String announcement = mActionBarTitle; if (mActionBarSubtitle != null) { announcement = mActivity.getContext().getResources().getString( R.string.titles, mActionBarTitle, mActionBarSubtitle); } return announcement; } /** * Sets the Action Bar title to {@link #mActionBarTitle} and the subtitle to * {@link #mActionBarSubtitle} */ protected final void setActionBarTitles(ActionBarInterface actionBar) { if (actionBar == null) { return; } actionBar.setTitle(getInputOrEmpty(mActionBarTitle)); actionBar.setSubtitle(getInputOrEmpty(mActionBarSubtitle)); } /** * If the input string is non-null, it is returned, otherwise an empty string is returned; * @param in * @return */ private static final String getInputOrEmpty(String in) { if (in == null) { return ""; } return in; } /** * Utility method that will return the cursor that contains the data * at the current position so that it refers to the current image on screen. * @return the cursor at the current position or * null if no cursor exists or if the {@link PhotoViewPager} is null. */ public Cursor getCursorAtProperPosition() { if (mViewPager == null) { return null; } final int position = mViewPager.getCurrentItem(); final Cursor cursor = mAdapter.getCursor(); if (cursor == null) { return null; } cursor.moveToPosition(position); return cursor; } public Cursor getCursor() { return (mAdapter == null) ? null : mAdapter.getCursor(); } @Override public void onMenuVisibilityChanged(boolean isVisible) { if (isVisible) { cancelEnterFullScreenRunnable(); } else { postEnterFullScreenRunnableWithDelay(); } } @Override public void onNewPhotoLoaded(int position) { // do nothing } protected void setPhotoIndex(int index) { mCurrentPhotoIndex = index; } @Override public void onFragmentPhotoLoadComplete(PhotoViewFragment fragment, boolean success) { if (hasTemporaryImage() && mTemporaryImage.getVisibility() != View.GONE && TextUtils.equals(fragment.getPhotoUri(), mCurrentPhotoUri)) { if (success) { // The fragment for the current image is now ready for display. if (hasTemporaryImage()) { mTemporaryImage.setVisibility(View.GONE); } mViewPager.setVisibility(View.VISIBLE); } else { // This means that we are unable to load the fragment's photo. // I'm not sure what the best thing to do here is, but at least if // we display the viewPager, the fragment itself can decide how to // display the failure of its own image. Log.w(TAG, "Failed to load fragment image"); if (hasTemporaryImage()) { mTemporaryImage.setVisibility(View.GONE); } mViewPager.setVisibility(View.VISIBLE); } mActivity.getSupportLoaderManager().destroyLoader( PhotoViewCallbacks.BITMAP_LOADER_THUMBNAIL); } } protected boolean isFullScreen() { return mFullScreen; } @Override public void onCursorChanged(PhotoViewFragment fragment, Cursor cursor) { // do nothing } @Override public PhotoPagerAdapter getAdapter() { return mAdapter; } public void onEnterAnimationComplete() { mEnterAnimationFinished = true; mViewPager.setVisibility(View.VISIBLE); setLightsOutMode(mFullScreen); } private void onExitAnimationComplete() { mActivity.finish(); mActivity.overridePendingTransition(0, 0); } private void runEnterAnimation() { final int totalWidth = mRootView.getMeasuredWidth(); final int totalHeight = mRootView.getMeasuredHeight(); // FLAG: Need to handle the aspect ratio of the bitmap. If it's a portrait // bitmap, then we need to position the view higher so that the middle // pixels line up. if (hasTemporaryImage()) { mTemporaryImage.setVisibility(View.VISIBLE); } // We need to take a full screen image, and scale/translate it so that // it appears at exactly the same location onscreen as it is in the // prior activity. // The final image will take either the full screen width or height (or both). final float scaleW = (float) mAnimationStartWidth / totalWidth; final float scaleY = (float) mAnimationStartHeight / totalHeight; final float scale = Math.max(scaleW, scaleY); final int translateX = calculateTranslate(mAnimationStartX, mAnimationStartWidth, totalWidth, scale); final int translateY = calculateTranslate(mAnimationStartY, mAnimationStartHeight, totalHeight, scale); final int version = android.os.Build.VERSION.SDK_INT; if (version >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { if (hasBackground()) { mBackground.setAlpha(0f); mBackground.animate().alpha(1f).setDuration(ENTER_ANIMATION_DURATION_MS).start(); mBackground.setVisibility(View.VISIBLE); } if (hasTemporaryImage()) { mTemporaryImage.setScaleX(scale); mTemporaryImage.setScaleY(scale); mTemporaryImage.setTranslationX(translateX); mTemporaryImage.setTranslationY(translateY); Runnable endRunnable = new Runnable() { @Override public void run() { PhotoViewController.this.onEnterAnimationComplete(); } }; ViewPropertyAnimator animator = mTemporaryImage.animate().scaleX(1f).scaleY(1f) .translationX(0).translationY(0).setDuration(ENTER_ANIMATION_DURATION_MS); if (version >= Build.VERSION_CODES.JELLY_BEAN) { animator.withEndAction(endRunnable); } else { mHandler.postDelayed(endRunnable, ENTER_ANIMATION_DURATION_MS); } animator.start(); } } else { if (hasBackground()) { final Animation alphaAnimation = new AlphaAnimation(0f, 1f); alphaAnimation.setDuration(ENTER_ANIMATION_DURATION_MS); mBackground.startAnimation(alphaAnimation); mBackground.setVisibility(View.VISIBLE); } if (hasTemporaryImage()) { final Animation translateAnimation = new TranslateAnimation(translateX, translateY, 0, 0); translateAnimation.setDuration(ENTER_ANIMATION_DURATION_MS); Animation scaleAnimation = new ScaleAnimation(scale, scale, 0, 0); scaleAnimation.setDuration(ENTER_ANIMATION_DURATION_MS); AnimationSet animationSet = new AnimationSet(true); animationSet.addAnimation(translateAnimation); animationSet.addAnimation(scaleAnimation); AnimationListener listener = new AnimationListener() { @Override public void onAnimationEnd(Animation arg0) { PhotoViewController.this.onEnterAnimationComplete(); } @Override public void onAnimationRepeat(Animation arg0) { } @Override public void onAnimationStart(Animation arg0) { } }; animationSet.setAnimationListener(listener); mTemporaryImage.startAnimation(animationSet); } } } private void runExitAnimation() { Intent intent = mActivity.getIntent(); // FLAG: should just fall back to a standard animation if either: // 1. images have been added or removed since we've been here, or // 2. we are currently looking at some image other than the one we // started on. final int totalWidth = mRootView.getMeasuredWidth(); final int totalHeight = mRootView.getMeasuredHeight(); // We need to take a full screen image, and scale/translate it so that // it appears at exactly the same location onscreen as it is in the // prior activity. // The final image will take either the full screen width or height (or both). final float scaleW = (float) mAnimationStartWidth / totalWidth; final float scaleY = (float) mAnimationStartHeight / totalHeight; final float scale = Math.max(scaleW, scaleY); final int translateX = calculateTranslate(mAnimationStartX, mAnimationStartWidth, totalWidth, scale); final int translateY = calculateTranslate(mAnimationStartY, mAnimationStartHeight, totalHeight, scale); final int version = android.os.Build.VERSION.SDK_INT; if (version >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { if (hasBackground()) { mBackground.animate().alpha(0f).setDuration(EXIT_ANIMATION_DURATION_MS).start(); mBackground.setVisibility(View.VISIBLE); } Runnable endRunnable = new Runnable() { @Override public void run() { PhotoViewController.this.onExitAnimationComplete(); } }; // If the temporary image is still visible it means that we have // not yet loaded the fullres image, so we need to animate // the temporary image out. ViewPropertyAnimator animator = null; if (hasTemporaryImage() && mTemporaryImage.getVisibility() == View.VISIBLE) { animator = mTemporaryImage.animate().scaleX(scale).scaleY(scale) .translationX(translateX).translationY(translateY) .setDuration(EXIT_ANIMATION_DURATION_MS); } else { animator = mViewPager.animate().scaleX(scale).scaleY(scale) .translationX(translateX).translationY(translateY) .setDuration(EXIT_ANIMATION_DURATION_MS); } // If the user has swiped to a different photo, fade out the current photo // along with the scale animation. if (!mInitialPhotoUri.equals(mCurrentPhotoUri)) { animator.alpha(0f); } if (version >= android.os.Build.VERSION_CODES.JELLY_BEAN) { animator.withEndAction(endRunnable); } else { mHandler.postDelayed(endRunnable, EXIT_ANIMATION_DURATION_MS); } animator.start(); } else { if (hasBackground()) { final Animation alphaAnimation = new AlphaAnimation(1f, 0f); alphaAnimation.setDuration(EXIT_ANIMATION_DURATION_MS); mBackground.startAnimation(alphaAnimation); mBackground.setVisibility(View.VISIBLE); } final Animation scaleAnimation = new ScaleAnimation(1f, 1f, scale, scale); scaleAnimation.setDuration(EXIT_ANIMATION_DURATION_MS); AnimationListener listener = new AnimationListener() { @Override public void onAnimationEnd(Animation arg0) { PhotoViewController.this.onExitAnimationComplete(); } @Override public void onAnimationRepeat(Animation arg0) { } @Override public void onAnimationStart(Animation arg0) { } }; scaleAnimation.setAnimationListener(listener); // If the temporary image is still visible it means that we have // not yet loaded the fullres image, so we need to animate // the temporary image out. if (hasTemporaryImage() && mTemporaryImage.getVisibility() == View.VISIBLE) { mTemporaryImage.startAnimation(scaleAnimation); } else { mViewPager.startAnimation(scaleAnimation); } } } private int calculateTranslate(int start, int startSize, int totalSize, float scale) { // Translation takes precedence over scale. What this means is that if // we want an view's upper left corner to be a particular spot on screen, // but that view is scaled to something other than 1, we need to take into // account the pixels lost to scaling. // So if we have a view that is 200x300, and we want it's upper left corner // to be at 50x50, but it's scaled by 50%, we can't just translate it to 50x50. // If we were to do that, the view's *visible* upper left corner would be at // 100x200. We need to take into account the difference between the outside // size of the view (i.e. the size prior to scaling) and the scaled size. // scaleFromEdge is the difference between the visible left edge and the // actual left edge, due to scaling. // scaleFromTop is the difference between the visible top edge, and the // actual top edge, due to scaling. int scaleFromEdge = Math.round((totalSize - totalSize * scale) / 2); // The imageView is fullscreen, regardless of the aspect ratio of the actual image. // This means that some portion of the imageView will be blank. We need to // take into account the size of the blank area so that the actual image // lines up with the starting image. int blankSize = Math.round((totalSize * scale - startSize) / 2); return start - scaleFromEdge - blankSize; } private void initTemporaryImage(Drawable drawable) { if (mEnterAnimationFinished) { // Forget this, we've already run the animation. return; } if (hasTemporaryImage()) { mTemporaryImage.setImageDrawable(drawable); } if (drawable != null) { // We have not yet run the enter animation. Start it now. int totalWidth = mRootView.getMeasuredWidth(); if (totalWidth == 0) { // the measure pass has not yet finished. We can't properly // run out animation until that is done. Listen for the layout // to occur, then fire the animation. final View base = mRootView; base.getViewTreeObserver().addOnGlobalLayoutListener( new OnGlobalLayoutListener() { @Override public void onGlobalLayout() { int version = android.os.Build.VERSION.SDK_INT; if (version >= android.os.Build.VERSION_CODES.JELLY_BEAN) { base.getViewTreeObserver().removeOnGlobalLayoutListener(this); } else { base.getViewTreeObserver().removeGlobalOnLayoutListener(this); } runEnterAnimation(); } }); } else { // initiate the animation runEnterAnimation(); } } // Kick off the photo list loader mActivity.getSupportLoaderManager().initLoader(LOADER_PHOTO_LIST, null, this); } public void showActionBar() { mActivity.getActionBarInterface().show(); } public void hideActionBar() { mActivity.getActionBarInterface().hide(); } public boolean isScaleAnimationEnabled() { return mScaleAnimationEnabled; } public boolean isEnterAnimationFinished() { return mEnterAnimationFinished; } public View getRootView() { return mRootView; } private class BitmapCallback implements LoaderManager.LoaderCallbacks { @Override public Loader onCreateLoader(int id, Bundle args) { String uri = args.getString(ARG_IMAGE_URI); switch (id) { case PhotoViewCallbacks.BITMAP_LOADER_THUMBNAIL: return onCreateBitmapLoader(PhotoViewCallbacks.BITMAP_LOADER_THUMBNAIL, args, uri); case PhotoViewCallbacks.BITMAP_LOADER_AVATAR: return onCreateBitmapLoader(PhotoViewCallbacks.BITMAP_LOADER_AVATAR, args, uri); } return null; } @Override public void onLoadFinished(Loader loader, BitmapResult result) { Drawable drawable = result.getDrawable(mActivity.getResources()); final ActionBarInterface actionBar = mActivity.getActionBarInterface(); switch (loader.getId()) { case PhotoViewCallbacks.BITMAP_LOADER_THUMBNAIL: // We just loaded the initial thumbnail that we can display // while waiting for the full viewPager to get initialized. initTemporaryImage(drawable); break; case PhotoViewCallbacks.BITMAP_LOADER_AVATAR: if (drawable == null) { actionBar.setLogo(null); } else { actionBar.setLogo(drawable); } break; } } @Override public void onLoaderReset(Loader loader) { // Do nothing } } public void setImmersiveMode(boolean enabled) { int flags = 0; final int version = Build.VERSION.SDK_INT; final boolean manuallyUpdateActionBar = version < Build.VERSION_CODES.JELLY_BEAN; if (enabled && (!isScaleAnimationEnabled() || isEnterAnimationFinished())) { // Turning on immersive mode causes an animation. If the scale animation is enabled and // the enter animation isn't yet complete, then an immersive mode animation should not // occur, since two concurrent animations are very janky. // Disable immersive mode for seconary users to prevent b/12015090 (freezing crash) // This is fixed in KK_MR2 but there is no way to differentiate between KK and KK_MR2. if (version > Build.VERSION_CODES.KITKAT || version == Build.VERSION_CODES.KITKAT && !kitkatIsSecondaryUser()) { flags = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_IMMERSIVE; } else if (version >= Build.VERSION_CODES.JELLY_BEAN) { // Clients that use the scale animation should set the following system UI flags to // prevent janky animations on exit when the status bar is hidden: // View.SYSTEM_UI_FLAG_VISIBLE | View.SYSTEM_UI_FLAG_STABLE // As well, client should ensure `android:fitsSystemWindows` is set on the root // content view. flags = View.SYSTEM_UI_FLAG_LOW_PROFILE | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_FULLSCREEN; } else if (version >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { flags = View.SYSTEM_UI_FLAG_LOW_PROFILE; } else if (version >= Build.VERSION_CODES.HONEYCOMB) { flags = View.STATUS_BAR_HIDDEN; } if (manuallyUpdateActionBar) { hideActionBar(); } } else { if (version >= Build.VERSION_CODES.KITKAT) { flags = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE; } else if (version >= Build.VERSION_CODES.JELLY_BEAN) { flags = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE; } else if (version >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { flags = View.SYSTEM_UI_FLAG_VISIBLE; } else if (version >= Build.VERSION_CODES.HONEYCOMB) { flags = View.STATUS_BAR_VISIBLE; } if (manuallyUpdateActionBar) { showActionBar(); } } if (version >= Build.VERSION_CODES.HONEYCOMB) { mLastFlags = flags; getRootView().setSystemUiVisibility(flags); } } /** * Return true iff the app is being run as a secondary user on kitkat. * * This is a hack which we only know to work on kitkat. */ private boolean kitkatIsSecondaryUser() { if (Build.VERSION.SDK_INT != Build.VERSION_CODES.KITKAT) { throw new IllegalStateException("kitkatIsSecondary user is only callable on KitKat"); } return Process.myUid() > 100000; } /** * Note: This should only be called when API level is 11 or above. */ public View.OnSystemUiVisibilityChangeListener getSystemUiVisibilityChangeListener() { return mSystemUiVisibilityChangeListener; } }