package cgeo.geocaching.utils; import cgeo.geocaching.CgeoApplication; import cgeo.geocaching.R; import cgeo.geocaching.models.Image; import cgeo.geocaching.storage.LocalStorage; import android.app.Application; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Canvas; import android.graphics.Matrix; import android.graphics.Point; import android.graphics.Rect; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.media.ExifInterface; import android.net.Uri; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.text.Html; import android.text.Html.ImageGetter; import android.util.Base64; import android.util.Base64InputStream; import android.widget.TextView; import java.io.BufferedOutputStream; import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.lang.ref.WeakReference; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collection; import java.util.Date; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Set; import java.util.concurrent.LinkedBlockingQueue; import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.functions.Consumer; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.ImmutablePair; public final class ImageUtils { private static final int[] ORIENTATIONS = { ExifInterface.ORIENTATION_ROTATE_90, ExifInterface.ORIENTATION_ROTATE_180, ExifInterface.ORIENTATION_ROTATE_270 }; private static final int[] ROTATION = { 90, 180, 270 }; private static final int MAX_DISPLAY_IMAGE_XY = 800; // Images whose URL contains one of those patterns will not be available on the Images tab // for opening into an external application. private static final String[] NO_EXTERNAL = { "geocheck.org" }; private ImageUtils() { // Do not let this class be instantiated, this is a utility class. } /** * Scales a bitmap to the device display size. * * @param image * The image Bitmap representation to scale * @return BitmapDrawable The scaled image */ @NonNull public static BitmapDrawable scaleBitmapToFitDisplay(@NonNull final Bitmap image) { final Point displaySize = DisplayUtils.getDisplaySize(); final int maxWidth = displaySize.x - 25; final int maxHeight = displaySize.y - 25; return scaleBitmapTo(image, maxWidth, maxHeight); } /** * Reads and scales an image file to the device display size. * * @param filename * The image file to read and scale * @return Bitmap The scaled image or Null if source image can't be read */ @Nullable public static Bitmap readAndScaleImageToFitDisplay(@NonNull final String filename) { final Point displaySize = DisplayUtils.getDisplaySize(); // Restrict image size to 800 x 800 to prevent OOM on tablets final int maxWidth = Math.min(displaySize.x - 25, MAX_DISPLAY_IMAGE_XY); final int maxHeight = Math.min(displaySize.y - 25, MAX_DISPLAY_IMAGE_XY); final Bitmap image = readDownsampledImage(filename, maxWidth, maxHeight); if (image == null) { return null; } final BitmapDrawable scaledImage = scaleBitmapTo(image, maxWidth, maxHeight); return scaledImage.getBitmap(); } /** * Scales a bitmap to the given bounds if it is larger, otherwise returns the original bitmap. * * @param image * The bitmap to scale * @return BitmapDrawable The scaled image */ @NonNull private static BitmapDrawable scaleBitmapTo(@NonNull final Bitmap image, final int maxWidth, final int maxHeight) { final Application app = CgeoApplication.getInstance(); Bitmap result = image; int width = image.getWidth(); int height = image.getHeight(); if (width > maxWidth || height > maxHeight) { final double ratio = Math.min((double) maxHeight / (double) height, (double) maxWidth / (double) width); width = (int) Math.ceil(width * ratio); height = (int) Math.ceil(height * ratio); result = Bitmap.createScaledBitmap(image, width, height, true); } final BitmapDrawable resultDrawable = new BitmapDrawable(app.getResources(), result); resultDrawable.setBounds(new Rect(0, 0, width, height)); return resultDrawable; } /** * Store a bitmap to file. * * @param bitmap * The bitmap to store * @param format * The image format * @param quality * The image quality * @param pathOfOutputImage * Path to store to */ public static void storeBitmap(final Bitmap bitmap, final Bitmap.CompressFormat format, final int quality, final String pathOfOutputImage) { try { final FileOutputStream out = new FileOutputStream(pathOfOutputImage); final BufferedOutputStream bos = new BufferedOutputStream(out); bitmap.compress(format, quality, bos); bos.flush(); bos.close(); } catch (final IOException e) { Log.e("ImageHelper.storeBitmap", e); } } /** * Scales an image to the desired bounds and encodes to file. * * @param filePath * Image to read * @param maxXY * bounds * @return filename and path, <tt>null</tt> if something fails */ @Nullable public static String readScaleAndWriteImage(@NonNull final String filePath, final int maxXY) { if (maxXY <= 0) { return filePath; } final Bitmap image = readDownsampledImage(filePath, maxXY, maxXY); if (image == null) { return null; } final File tempImageFile = getOutputImageFile(); if (tempImageFile == null) { Log.e("ImageUtils.readScaleAndWriteImage: unable to write scaled image"); return null; } final String uploadFilename = tempImageFile.getPath(); final BitmapDrawable scaledImage = scaleBitmapTo(image, maxXY, maxXY); storeBitmap(scaledImage.getBitmap(), Bitmap.CompressFormat.JPEG, 75, uploadFilename); return uploadFilename; } /** * Reads and scales an image file with downsampling in one step to prevent memory consumption. * * @param filePath * The file to read * @param maxX * The desired width * @param maxY * The desired height * @return Bitmap the image or null if file can't be read */ @Nullable public static Bitmap readDownsampledImage(@NonNull final String filePath, final int maxX, final int maxY) { int orientation = ExifInterface.ORIENTATION_NORMAL; try { final ExifInterface exif = new ExifInterface(filePath); orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); } catch (final IOException e) { Log.e("ImageUtils.readDownsampledImage", e); } final BitmapFactory.Options sizeOnlyOptions = new BitmapFactory.Options(); sizeOnlyOptions.inJustDecodeBounds = true; BitmapFactory.decodeFile(filePath, sizeOnlyOptions); final int myMaxXY = Math.max(sizeOnlyOptions.outHeight, sizeOnlyOptions.outWidth); final int maxXY = Math.max(maxX, maxY); final int sampleSize = myMaxXY / maxXY; final BitmapFactory.Options sampleOptions = new BitmapFactory.Options(); if (sampleSize > 1) { sampleOptions.inSampleSize = sampleSize; } final Bitmap decodedImage = BitmapFactory.decodeFile(filePath, sampleOptions); if (decodedImage != null) { for (int i = 0; i < ORIENTATIONS.length; i++) { if (orientation == ORIENTATIONS[i]) { final Matrix matrix = new Matrix(); matrix.postRotate(ROTATION[i]); return Bitmap.createBitmap(decodedImage, 0, 0, decodedImage.getWidth(), decodedImage.getHeight(), matrix, true); } } } return decodedImage; } /** Create a File for saving an image or video * * @return the temporary image file to use, or <tt>null</tt> if the media directory could * not be created. * */ @Nullable public static File getOutputImageFile() { // To be safe, you should check that the SDCard is mounted // using Environment.getExternalStorageState() before doing this. final File mediaStorageDir = LocalStorage.getLogPictureDirectory(); // This location works best if you want the created images to be shared // between applications and persist after your app has been uninstalled. // Create the storage directory if it does not exist if (!mediaStorageDir.exists() && !FileUtils.mkdirs(mediaStorageDir)) { Log.e("ImageUtils.getOutputImageFile: cannot create media storage directory"); return null; } // Create a media file name final String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()); return new File(mediaStorageDir.getPath() + File.separator + "IMG_" + timeStamp + ".jpg"); } @Nullable public static Uri getOutputImageFileUri() { final File file = getOutputImageFile(); if (file == null) { return null; } return Uri.fromFile(file); } /** * Check if the URL contains one of the given substrings. * * @param url the URL to check * @param patterns a list of substrings to check against * @return <tt>true</tt> if the URL contains at least one of the patterns, <tt>false</tt> otherwise */ public static boolean containsPattern(final String url, final String[] patterns) { for (final String entry : patterns) { if (StringUtils.containsIgnoreCase(url, entry)) { return true; } } return false; } /** * Decode a base64-encoded string and save the result into a file. * * @param inString the encoded string * @param outFile the file to save the decoded result into */ public static void decodeBase64ToFile(final String inString, final File outFile) { FileOutputStream out = null; try { out = new FileOutputStream(outFile); decodeBase64ToStream(inString, out); } catch (final IOException e) { Log.e("HtmlImage.decodeBase64ToFile: cannot write file for decoded inline image", e); } finally { IOUtils.closeQuietly(out); } } /** * Decode a base64-encoded string and save the result into a stream. * * @param inString * the encoded string * @param out * the stream to save the decoded result into */ public static void decodeBase64ToStream(final String inString, final OutputStream out) throws IOException { Base64InputStream in = null; try { in = new Base64InputStream(new ByteArrayInputStream(inString.getBytes(TextUtils.CHARSET_ASCII)), Base64.DEFAULT); IOUtils.copy(in, out); } finally { IOUtils.closeQuietly(in); } } @NonNull public static BitmapDrawable getTransparent1x1Drawable(final Resources res) { return new BitmapDrawable(res, BitmapFactory.decodeResource(res, R.drawable.image_no_placement)); } /** * Add images present in the HTML description to the existing collection. * @param images a collection of images * @param geocode the common title for images in the description * @param htmlText the HTML description to be parsed, can be repeated */ public static void addImagesFromHtml(final Collection<Image> images, final String geocode, final String... htmlText) { final Set<String> urls = new LinkedHashSet<>(); for (final Image image : images) { urls.add(image.getUrl()); } for (final String text: htmlText) { Html.fromHtml(StringUtils.defaultString(text), new ImageGetter() { @Override public Drawable getDrawable(final String source) { if (!urls.contains(source) && canBeOpenedExternally(source)) { images.add(new Image.Builder() .setUrl(source) .setTitle(StringUtils.defaultString(geocode)) .build()); urls.add(source); } return null; } }, null); } } /** * Container which can hold a drawable (initially an empty one) and get a newer version when it * becomes available. It also invalidates the view the container belongs to, so that it is * redrawn properly. * <p/> * When a new version of the drawable is available, it is put into a queue and, if needed (no other elements * waiting in the queue), a refresh is launched on the UI thread. This refresh will empty the queue (including * elements arrived in the meantime) and ensures that the view is uploaded only once all the queued requests have * been handled. */ public static class ContainerDrawable extends BitmapDrawable implements Consumer<Drawable> { private static final Object lock = new Object(); // Used to lock the queue to determine if a refresh needs to be scheduled private static final LinkedBlockingQueue<ImmutablePair<ContainerDrawable, Drawable>> REDRAW_QUEUE = new LinkedBlockingQueue<>(); private static final Set<TextView> VIEWS = new HashSet<>(); // Modified only on the UI thread, from redrawQueuedDrawables private static final Runnable REDRAW_QUEUED_DRAWABLES = new Runnable() { @Override public void run() { redrawQueuedDrawables(); } }; private Drawable drawable; protected final WeakReference<TextView> viewRef; @SuppressWarnings("deprecation") public ContainerDrawable(@NonNull final TextView view, final Observable<? extends Drawable> drawableObservable) { viewRef = new WeakReference<>(view); drawable = null; setBounds(0, 0, 0, 0); drawableObservable.subscribe(this); } @Override public final void draw(final Canvas canvas) { if (drawable != null) { drawable.draw(canvas); } } @Override public final void accept(final Drawable newDrawable) { final boolean needsRedraw; synchronized (lock) { // Check for emptiness inside the call to match the behaviour in redrawQueuedDrawables(). needsRedraw = REDRAW_QUEUE.isEmpty(); REDRAW_QUEUE.add(ImmutablePair.of(this, newDrawable)); } if (needsRedraw) { AndroidSchedulers.mainThread().scheduleDirect(REDRAW_QUEUED_DRAWABLES); } } /** * Update the container with the new drawable. Called on the UI thread. * * @param newDrawable the new drawable * @return the view to update or <tt>null</tt> if the view is not alive anymore */ protected TextView updateDrawable(final Drawable newDrawable) { setBounds(0, 0, newDrawable.getIntrinsicWidth(), newDrawable.getIntrinsicHeight()); drawable = newDrawable; return viewRef.get(); } private static void redrawQueuedDrawables() { if (!REDRAW_QUEUE.isEmpty()) { // Add a small margin so that drawables arriving between the beginning of the allocation and the draining // of the queue might be absorbed without reallocation. final List<ImmutablePair<ContainerDrawable, Drawable>> toRedraw = new ArrayList<>(REDRAW_QUEUE.size() + 16); synchronized (lock) { // Empty the queue inside the lock to match the check done in call(). REDRAW_QUEUE.drainTo(toRedraw); } for (final ImmutablePair<ContainerDrawable, Drawable> redrawable : toRedraw) { final TextView view = redrawable.left.updateDrawable(redrawable.right); if (view != null) { VIEWS.add(view); } } for (final TextView view : VIEWS) { // This forces the relayout of the text around the updated images. view.setText(view.getText()); } VIEWS.clear(); } } } /** * Image that automatically scales to fit a line of text in the containing {@link TextView}. */ public static final class LineHeightContainerDrawable extends ContainerDrawable { public LineHeightContainerDrawable(@NonNull final TextView view, final Observable<? extends Drawable> drawableObservable) { super(view, drawableObservable); } @Override protected TextView updateDrawable(final Drawable newDrawable) { final TextView view = super.updateDrawable(newDrawable); if (view != null) { setBounds(scaleImageToLineHeight(newDrawable, view)); } return view; } } public static boolean canBeOpenedExternally(final String source) { return !containsPattern(source, NO_EXTERNAL); } @NonNull public static Rect scaleImageToLineHeight(final Drawable drawable, final TextView view) { final int lineHeight = (int) (view.getLineHeight() * 0.8); final int width = drawable.getIntrinsicWidth() * lineHeight / drawable.getIntrinsicHeight(); return new Rect(0, 0, width, lineHeight); } @Nullable public static Bitmap convertToBitmap(final Drawable drawable) { if (drawable instanceof BitmapDrawable) { return ((BitmapDrawable) drawable).getBitmap(); } // handle solid colors, which have no width int width = drawable.getIntrinsicWidth(); width = width > 0 ? width : 1; int height = drawable.getIntrinsicHeight(); height = height > 0 ? height : 1; final Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); final Canvas canvas = new Canvas(bitmap); drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); drawable.draw(canvas); return bitmap; } }