/**
Copyright 2015 Tim Engler, Rareventure LLC
This file is part of Tiny Travel Tracker.
Tiny Travel Tracker is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Tiny Travel Tracker is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Tiny Travel Tracker. If not, see <http://www.gnu.org/licenses/>.
*/
/*
* 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.rareventure.gps2.reviewer.imageviewer;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import android.app.AlertDialog;
import android.content.ContentResolver;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.graphics.Bitmap;
import android.os.Bundle;
import android.provider.MediaStore;
import android.util.AttributeSet;
import android.util.Log;
import android.util.SparseArray;
import android.view.GestureDetector;
import android.view.KeyEvent;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnTouchListener;
import android.view.Window;
import android.view.WindowManager;
import android.view.animation.AlphaAnimation;
import android.view.animation.Animation;
import android.widget.Toast;
import android.widget.ZoomButtonsController;
import com.rareventure.gps2.GTG;
import com.rareventure.gps2.GTGActivity;
import com.rareventure.gps2.R;
import com.rareventure.gps2.database.cache.MediaLocTime;
// This activity can display a whole picture and navigate them in a specific
// gallery. It has two modes: normal mode and slide show mode. In normal mode
// the user view one image at a time, and can click "previous" and "next"
// button to see the previous or next image. In slide show mode it shows one
// image after another, with some transition effect.
public class ViewImage extends GTGActivity implements View.OnClickListener {
private static final String TAG = "ViewImage";
private ImageGetter mGetter;
// Choices for what adjacents to load.
private static final int[] sOrderAdjacents = new int[] {0, 1, -1};
final GetterHandler mHandler = new GetterHandler();
private boolean mFullScreenInNormalMode;
public static int mCurrentPosition = 0;
private View mNextImageView;
private View mPrevImageView;
private final Animation mHideNextImageViewAnimation =
new AlphaAnimation(1F, 0F);
private final Animation mHidePrevImageViewAnimation =
new AlphaAnimation(1F, 0F);
private final Animation mShowNextImageViewAnimation =
new AlphaAnimation(0F, 1F);
private final Animation mShowPrevImageViewAnimation =
new AlphaAnimation(0F, 1F);
public static final String KEY_IMAGE_LIST = "image_list";
/**
* These are the indexes of images that have been deleted.
* We pretend to delete images (but don't actually) and then
* when we pause, only then do we actually delete them.
* TODO 3 I don't understand the code well enough to do this another
* way. This whole thing should probably be rewritten.
*/
private ArrayList<Integer> deletedImageIndexes = new ArrayList<Integer>();
public static ArrayList<MediaLocTime> mAllImages;
GestureDetector mGestureDetector;
private ZoomButtonsController mZoomButtonsController;
// The image view displayed for normal mode.
private ImageViewTouch mImageView;
// This is the cache for thumbnail bitmaps.
private BitmapCache mCache;
private final Runnable mDismissOnScreenControlRunner = new Runnable() {
public void run() {
hideOnScreenControls();
}
};
private View mPlayVideo;
private void updateNextPrevControls() {
boolean showPrev = !isAtBeginning();
boolean showNext = !isAtEnd();
boolean prevIsVisible = mPrevImageView.getVisibility() == View.VISIBLE;
boolean nextIsVisible = mNextImageView.getVisibility() == View.VISIBLE;
if (showPrev && !prevIsVisible) {
Animation a = mShowPrevImageViewAnimation;
a.setDuration(500);
mPrevImageView.startAnimation(a);
mPrevImageView.setVisibility(View.VISIBLE);
} else if (!showPrev && prevIsVisible) {
Animation a = mHidePrevImageViewAnimation;
a.setDuration(500);
mPrevImageView.startAnimation(a);
mPrevImageView.setVisibility(View.GONE);
}
if (showNext && !nextIsVisible) {
Animation a = mShowNextImageViewAnimation;
a.setDuration(500);
mNextImageView.startAnimation(a);
mNextImageView.setVisibility(View.VISIBLE);
} else if (!showNext && nextIsVisible) {
Animation a = mHideNextImageViewAnimation;
a.setDuration(500);
mNextImageView.startAnimation(a);
mNextImageView.setVisibility(View.GONE);
}
}
private void hideOnScreenControls() {
if (mNextImageView.getVisibility() == View.VISIBLE) {
Animation a = mHideNextImageViewAnimation;
a.setDuration(500);
mNextImageView.startAnimation(a);
mNextImageView.setVisibility(View.INVISIBLE);
}
if (mPrevImageView.getVisibility() == View.VISIBLE) {
Animation a = mHidePrevImageViewAnimation;
a.setDuration(500);
mPrevImageView.startAnimation(a);
mPrevImageView.setVisibility(View.INVISIBLE);
}
mZoomButtonsController.setVisible(false);
}
private void showOnScreenControls() {
// // If the view has not been attached to the window yet, the
// // zoomButtonControls will not able to show up. So delay it until the
// // view has attached to window.
// if (mActionIconPanel.getWindowToken() == null) {
// mHandler.postGetterCallback(new Runnable() {
// public void run() {
// showOnScreenControls();
// }
// });
// return;
// }
updateNextPrevControls();
MediaLocTime image = mAllImages.get(mCurrentPosition);
if (image.isVideo()) {
mZoomButtonsController.setVisible(false);
mPlayVideo.setVisibility(View.VISIBLE);
} else {
updateZoomButtonsEnabled();
mZoomButtonsController.setVisible(true);
mPlayVideo.setVisibility(View.GONE);
}
}
@Override
public boolean dispatchTouchEvent(MotionEvent m) {
if (mZoomButtonsController.isVisible()) {
scheduleDismissOnScreenControls();
}
return super.dispatchTouchEvent(m);
}
private void updateZoomButtonsEnabled() {
ImageViewTouch imageView = mImageView;
float scale = imageView.getScale();
mZoomButtonsController.setZoomInEnabled(scale < imageView.mMaxZoom);
mZoomButtonsController.setZoomOutEnabled(scale > 1);
}
@Override
protected void onDestroy() {
// This is necessary to make the ZoomButtonsController unregister
// its configuration change receiver.
if (mZoomButtonsController != null) {
mZoomButtonsController.setVisible(false);
}
super.onDestroy();
}
private void scheduleDismissOnScreenControls() {
mHandler.removeCallbacks(mDismissOnScreenControlRunner);
mHandler.postDelayed(mDismissOnScreenControlRunner, 2000);
}
private void setupOnScreenControls(View rootView, View ownerView) {
mNextImageView = rootView.findViewById(R.id.next_image);
mPrevImageView = rootView.findViewById(R.id.prev_image);
mPlayVideo = rootView.findViewById(R.id.play);
mNextImageView.setOnClickListener(this);
mPrevImageView.setOnClickListener(this);
mPlayVideo.setOnClickListener(this);
setupZoomButtonController(ownerView);
setupOnTouchListeners(rootView);
}
private void setupZoomButtonController(final View ownerView) {
mZoomButtonsController = new ZoomButtonsController(ownerView);
mZoomButtonsController.setAutoDismissed(false);
mZoomButtonsController.setZoomSpeed(100);
mZoomButtonsController.setOnZoomListener(
new ZoomButtonsController.OnZoomListener() {
public void onVisibilityChanged(boolean visible) {
if (visible) {
updateZoomButtonsEnabled();
}
}
public void onZoom(boolean zoomIn) {
if (zoomIn) {
mImageView.zoomIn();
} else {
mImageView.zoomOut();
}
mZoomButtonsController.setVisible(true);
updateZoomButtonsEnabled();
}
});
}
private void setupOnTouchListeners(View rootView) {
mGestureDetector = new GestureDetector(this, new MyGestureListener());
// If the user touches anywhere on the panel (including the
// next/prev button). We show the on-screen controls. In addition
// to that, if the touch is not on the prev/next button, we
// pass the event to the gesture detector to detect double tap.
final OnTouchListener buttonListener = new OnTouchListener() {
public boolean onTouch(View v, MotionEvent event) {
scheduleDismissOnScreenControls();
return false;
}
};
OnTouchListener rootListener = new OnTouchListener() {
public boolean onTouch(View v, MotionEvent event) {
buttonListener.onTouch(v, event);
mGestureDetector.onTouchEvent(event);
// We do not use the return value of
// mGestureDetector.onTouchEvent because we will not receive
// the "up" event if we return false for the "down" event.
return true;
}
};
mNextImageView.setOnTouchListener(buttonListener);
mPrevImageView.setOnTouchListener(buttonListener);
rootView.setOnTouchListener(rootListener);
}
private class MyGestureListener extends
GestureDetector.SimpleOnGestureListener {
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2,
float distanceX, float distanceY) {
ImageViewTouch imageView = mImageView;
if (imageView.getScale() > 1F) {
imageView.postTranslateCenter(-distanceX, -distanceY);
}
return true;
}
@Override
public boolean onSingleTapConfirmed(MotionEvent e) {
showOnScreenControls();
scheduleDismissOnScreenControls();
return true;
}
@Override
public boolean onDoubleTap(MotionEvent e) {
ImageViewTouch imageView = mImageView;
// Switch between the original scale and 3x scale.
if (imageView.getScale() > 2F) {
mImageView.zoomTo(1f);
} else {
mImageView.zoomToPoint(3f, e.getX(), e.getY());
}
return true;
}
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
super.onCreateOptionsMenu(menu);
MenuInflater mi = getMenuInflater();
mi.inflate(R.menu.view_image_menu, menu);
return true;
}
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
return super.onPrepareOptionsMenu(menu);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if(item.getTitle().equals(getText(R.string.share)))
{
startShareMediaActivity(mAllImages.get(mCurrentPosition));
return true;
}
if(item.getTitle().equals(getText(R.string.delete)))
{
AlertDialog.Builder alert = new AlertDialog.Builder(this);
alert.setTitle(getText(R.string.delete));
alert.setMessage(R.string.view_image_delete_file_query);
alert.setPositiveButton(R.string.delete,
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog,
int whichButton) {
deletedImageIndexes.add(mCurrentPosition);
//if there are no images left
if(mAllImages.size() == deletedImageIndexes.size())
{
finish();
}
//we have to get off the image we deleted
else if(isAtBeginning())
moveNextOrPrevious(1);
else moveNextOrPrevious(-1);
}
});
alert.setNegativeButton(R.string.cancel, null);
alert.show();
return true;
}
if(item.getTitle().equals(R.string.view_image_menu_view_in_gallery))
{
startShareMediaActivity(mAllImages.get(mCurrentPosition));
return true;
}
return super.onOptionsItemSelected(item);
}
void setImage(int pos, boolean showControls) {
mCurrentPosition = pos;
Bitmap b = mCache.getBitmap(pos);
if (b != null) {
MediaLocTime image = mAllImages.get(pos);
mImageView.setImageRotateBitmapResetBase(
new RotateBitmap(b, 0), true);
updateZoomButtonsEnabled();
}
ImageGetterCallback cb = new ImageGetterCallback() {
public void completed() {
}
public boolean wantsThumbnail(int pos, int offset) {
return !mCache.hasBitmap(pos + offset);
}
public boolean wantsFullImage(int pos, int offset) {
return offset == 0;
}
public int fullImageSizeToUse(int pos, int offset) {
// this number should be bigger so that we can zoom. we may
// need to get fancier and read in the fuller size image as the
// user starts to zoom.
// Originally the value is set to 480 in order to avoid OOM.
// Now we set it to 2048 because of using
// native memory allocation for Bitmaps.
final int imageViewSize = 2048;
return imageViewSize;
}
public int [] loadOrder() {
return sOrderAdjacents;
}
public void imageLoaded(int pos, int offset, RotateBitmap bitmap,
boolean isThumb, MediaLocTime media) {
// shouldn't get here after onPause()
// We may get a result from a previous request, or
// get a thumbnail after we deleted an item
if (pos != mCurrentPosition || pos >= mAllImages.size() ||
media != mAllImages.get(pos)) {
bitmap.recycle();
return;
}
if (isThumb) {
mCache.put(pos + offset, bitmap.getBitmap());
}
if (offset == 0) {
// isThumb: We always load thumb bitmap first, so we will
// reset the supp matrix for then thumb bitmap, and keep
// the supp matrix when the full bitmap is loaded.
mImageView.setImageRotateBitmapResetBase(bitmap, isThumb);
updateZoomButtonsEnabled();
}
}
};
// Could be null if we're stopping a slide show in the course of pausing
if (mGetter != null) {
mGetter.setPosition(pos, cb, mAllImages, mHandler);
}
if (showControls) showOnScreenControls();
scheduleDismissOnScreenControls();
}
@Override
public int getRequirements() {
return GTG.REQUIREMENTS_FULL_PASSWORD_PROTECTED_UI;
}
@Override
protected void requestWindowFeatureHook()
{
requestWindowFeature(Window.FEATURE_NO_TITLE);
}
@Override
public void doOnCreate(Bundle instanceState) {
super.doOnCreate(instanceState);
Intent intent = getIntent();
mFullScreenInNormalMode = intent.getBooleanExtra(
MediaStore.EXTRA_FULL_SCREEN, true);
setDefaultKeyMode(DEFAULT_KEYS_SHORTCUT);
setContentView(R.layout.viewimage);
mImageView = (ImageViewTouch) findViewById(R.id.image);
mImageView.setEnableTrackballScroll(true);
mCache = new BitmapCache(3);
mImageView.setRecycler(mCache);
makeGetter();
if (mFullScreenInNormalMode) {
getWindow().addFlags(
WindowManager.LayoutParams.FLAG_FULLSCREEN);
}
setupOnScreenControls(findViewById(R.id.rootLayout), mImageView);
}
private void makeGetter() {
mGetter = new ImageGetter(getContentResolver());
}
@Override
public void doOnResume() {
super.doOnResume();
// normally this will never be zero but if one "backs" into this
// activity after removing the sdcard it could be zero. in that
// case just "finish" since there's nothing useful that can happen.
int count = mAllImages == null ? 0 : mAllImages.size();
if (count == 0) {
finish();
return;
} else if (count <= mCurrentPosition) {
mCurrentPosition = count - 1;
}
if (mGetter == null) {
makeGetter();
}
setImage(mCurrentPosition, true);
}
@Override
public void finish() {
super.finish();
}
@Override
public void doOnPause(boolean resumeCalled)
{
super.doOnPause(resumeCalled);
if(!resumeCalled)
return;
//tim - note that we moved this from onStop because onStop is called after
// the next activities onResume(). So the mediagalleryfragment was displaying
//the images after we had marked the deleted
// mGetter could be null if we call finish() and leave early in
// onStart().
if (mGetter != null) {
mGetter.cancelCurrent();
mGetter.stop();
mGetter = null;
}
// removing all callback in the message queue
mHandler.removeAllGetterCallbacks();
hideOnScreenControls();
deleteMarkedImages();
mImageView.clear();
mCache.clear();
}
/**
* We actually delete the images here. Should not be called when ImageGetter is running
* or activity at all, actually
*/
private void deleteMarkedImages() {
Collections.sort(deletedImageIndexes);
ContentResolver cr = getContentResolver();
for(int i = deletedImageIndexes.size()-1; i >= 0; i--)
{
int index = deletedImageIndexes.get(i).intValue();
if(index <= mCurrentPosition)
mCurrentPosition --;
MediaLocTime media = mAllImages.remove(index);
String filename = media.getFilename(cr);
if(media.isVideo())
cr.delete(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, MediaStore.Video.Media._ID + "=?",
new String[]{ Long.toString(media.getFk()) } );
else
cr.delete(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaStore.Images.Media._ID + "=?",
new String[]{ Long.toString(media.getFk()) } );
if(filename != null)
new File(filename).delete();
GTG.mediaLocTimeMap.notifyMltNotClean(media);
}
deletedImageIndexes.clear();
}
private void startShareMediaActivity(MediaLocTime image) {
boolean isVideo = image.isVideo();
Intent intent = new Intent();
intent.setAction(Intent.ACTION_SEND);
intent.setType(image.getMimeType(getContentResolver()));
intent.putExtra(Intent.EXTRA_STREAM, image.getUri(this));
try {
startActivity(Intent.createChooser(intent, getText(
isVideo ? R.string.sendVideo : R.string.sendImage)));
} catch (android.content.ActivityNotFoundException ex) {
Toast.makeText(this, isVideo
? R.string.no_way_to_share_image
: R.string.no_way_to_share_video,
Toast.LENGTH_SHORT).show();
}
}
private void startPlayVideoActivity() {
MediaLocTime image = mAllImages.get(mCurrentPosition);
Intent intent = new Intent(
Intent.ACTION_VIEW);
intent.setDataAndType(image.getUri(this), "video/*");
try {
startActivity(intent);
} catch (android.content.ActivityNotFoundException ex) {
Log.e(TAG, "Couldn't view video " + image.getUri(this), ex);
}
}
public void onClick(View v) {
switch (v.getId()) {
case R.id.play:
startPlayVideoActivity();
break;
// case R.id.share: {
// IImage image = mAllImages.getImageAt(mCurrentPosition);
// if (!MenuHelper.isWhiteListUri(image.fullSizeImageUri())) {
// return;
// }
// startShareMediaActivity(image);
// break;
// }
case R.id.next_image:
moveNextOrPrevious(1);
break;
case R.id.prev_image:
moveNextOrPrevious(-1);
break;
}
}
private boolean isAtBeginning()
{
int priorImagePos = mCurrentPosition - 1;
while(deletedImageIndexes.contains(priorImagePos))
priorImagePos --;
if(priorImagePos < 0)
return true;
return false;
}
private boolean isAtEnd()
{
int nextImagePos = mCurrentPosition + 1;
while(deletedImageIndexes.contains(nextImagePos))
nextImagePos ++;
if(nextImagePos >= mAllImages.size())
return true;
return false;
}
private void moveNextOrPrevious(int delta) {
int nextImagePos = mCurrentPosition;
while(delta != 0)
{
if(delta < 0)
{
nextImagePos --;
}
else {
nextImagePos ++;
}
if(deletedImageIndexes.contains(nextImagePos))
continue;
delta += (delta < 0 ? 1 : -1);
}
if ((0 <= nextImagePos) && (nextImagePos < mAllImages.size())) {
setImage(nextImagePos, true);
showOnScreenControls();
}
}
}
class ImageViewTouch extends ImageViewTouchBase {
private final ViewImage mViewImage;
private boolean mEnableTrackballScroll;
public ImageViewTouch(Context context) {
super(context);
mViewImage = (ViewImage) context;
}
public ImageViewTouch(Context context, AttributeSet attrs) {
super(context, attrs);
mViewImage = (ViewImage) context;
}
public void setEnableTrackballScroll(boolean enable) {
mEnableTrackballScroll = enable;
}
protected void postTranslateCenter(float dx, float dy) {
super.postTranslate(dx, dy);
center(true, true);
}
private static final float PAN_RATE = 20;
// This is the time we allow the dpad to change the image position again.
private long mNextChangePositionTime;
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
// Don't respond to arrow keys if trackball scrolling is not enabled
if (!mEnableTrackballScroll) {
if ((keyCode >= KeyEvent.KEYCODE_DPAD_UP)
&& (keyCode <= KeyEvent.KEYCODE_DPAD_RIGHT)) {
return super.onKeyDown(keyCode, event);
}
}
int current = mViewImage.mCurrentPosition;
int nextImagePos = -2; // default no next image
try {
switch (keyCode) {
case KeyEvent.KEYCODE_DPAD_LEFT: {
if (getScale() <= 1F && event.getEventTime()
>= mNextChangePositionTime) {
nextImagePos = current - 1;
mNextChangePositionTime = event.getEventTime() + 500;
} else {
panBy(PAN_RATE, 0);
center(true, false);
}
return true;
}
case KeyEvent.KEYCODE_DPAD_RIGHT: {
if (getScale() <= 1F && event.getEventTime()
>= mNextChangePositionTime) {
nextImagePos = current + 1;
mNextChangePositionTime = event.getEventTime() + 500;
} else {
panBy(-PAN_RATE, 0);
center(true, false);
}
return true;
}
case KeyEvent.KEYCODE_DPAD_UP: {
panBy(0, PAN_RATE);
center(false, true);
return true;
}
case KeyEvent.KEYCODE_DPAD_DOWN: {
panBy(0, -PAN_RATE);
center(false, true);
return true;
}
}
} finally {
if (nextImagePos >= 0
&& nextImagePos < mViewImage.mAllImages.size()) {
synchronized (mViewImage) {
mViewImage.setImage(nextImagePos, true);
}
} else if (nextImagePos != -2) {
center(true, true);
}
}
return super.onKeyDown(keyCode, event);
}
}
// This is a cache for Bitmap displayed in ViewImage (normal mode, thumb only).
class BitmapCache implements ImageViewTouchBase.Recycler {
public static class Entry {
int mPos;
Bitmap mBitmap;
public Entry() {
clear();
}
public void clear() {
mPos = -1;
mBitmap = null;
}
}
private final Entry [] mCache;
public BitmapCache(int size) {
mCache = new Entry [size];
for (int i = 0; i < size; i++) {
mCache[i] = new Entry();
}
}
// Given the position, find the associated entry. Returns null if there is
// no such entry.
private Entry findEntry(int pos) {
for (Entry e : mCache) {
if (pos == e.mPos) {
return e;
}
}
return null;
}
// Returns the thumb bitmap if we have it, otherwise return null.
public synchronized Bitmap getBitmap(int pos) {
Entry e = findEntry(pos);
if (e != null) {
return e.mBitmap;
}
return null;
}
public synchronized void put(int pos, Bitmap bitmap) {
// First see if we already have this entry.
if (findEntry(pos) != null) {
return;
}
// Find the best entry we should replace.
// See if there is any empty entry.
// Otherwise assuming sequential access, kick out the entry with the
// greatest distance.
Entry best = null;
int maxDist = -1;
for (Entry e : mCache) {
if (e.mPos == -1) {
best = e;
break;
} else {
int dist = Math.abs(pos - e.mPos);
if (dist > maxDist) {
maxDist = dist;
best = e;
}
}
}
// Recycle the image being kicked out.
// This only works because our current usage is sequential, so we
// do not happen to recycle the image being displayed.
if (best.mBitmap != null) {
best.mBitmap.recycle();
}
best.mPos = pos;
best.mBitmap = bitmap;
}
// Recycle all bitmaps in the cache and clear the cache.
public synchronized void clear() {
for (Entry e : mCache) {
if (e.mBitmap != null) {
e.mBitmap.recycle();
}
e.clear();
}
}
// Returns whether the bitmap is in the cache.
public synchronized boolean hasBitmap(int pos) {
Entry e = findEntry(pos);
return (e != null);
}
// Recycle the bitmap if it's not in the cache.
// The input must be non-null.
public synchronized void recycle(Bitmap b) {
for (Entry e : mCache) {
if (e.mPos != -1) {
if (e.mBitmap == b) {
return;
}
}
}
b.recycle();
}
}