/* * WikiImageUtils.java * Copyright (C) 2015 Nicholas Killewald * * This file is distributed under the terms of the BSD license. * The source package should have a LICENSE file at the toplevel. */ package net.exclaimindustries.geohashdroid.wiki; import android.content.Context; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; import android.location.Location; import android.net.Uri; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.content.ContextCompat; import net.exclaimindustries.geohashdroid.R; import net.exclaimindustries.geohashdroid.util.Info; import net.exclaimindustries.geohashdroid.util.UnitConverter; import net.exclaimindustries.tools.BitmapTools; import java.io.ByteArrayOutputStream; import java.text.DecimalFormat; import java.util.Calendar; /** * <code>WikiImageUtils</code> contains static methods that do stuff to * image files related to uploading them to the wiki. It entails a decent * amount of the picturesque functionality formerly found in * <code>WikiPictureEditor</code>. It does depend a bit on {@link WikiUtils}. * * @author Nicholas Killewald */ public class WikiImageUtils { /** The largest width we'll allow to be uploaded. */ private static final int MAX_UPLOAD_WIDTH = 800; /** The largest height we'll allow to be uploaded. */ private static final int MAX_UPLOAD_HEIGHT = 600; /** The JPEG quality setting to be uploaded. */ private static final int IMAGE_JPEG_QUALITY = 90; /** * Amount of time until we don't consider this to be a "live" picture. * Currently 15 minutes. Note that there's no timeout for a "retro" * picture, as that's determined by when the user started the trek. */ private static final int LIVE_TIMEOUT = 900000; // Padding around the sides of the icons/text. private static final int INFOBOX_BOX_PADDING = 8; // Padding between things in the infobox. private static final int INFOBOX_ITEM_PADDING = 8; private static Paint mBackgroundPaint; private static Paint mTextPaint; private static DecimalFormat mDistFormat = new DecimalFormat("###.######"); /** * This is just a convenient holder for the various info related to an * image. It's used when making image calls. */ public static class ImageInfo { /** The image's URI. Should not be null. */ public Uri uri; /** * The location of either the image or the user, depending on if the * geodata from the image could be read. May be null. */ public Location location; /** The timestamp of the image, if possible. Defaults to -1. */ public long timestamp = -1L; } /** * Gets the name of a particular image as it will appear on the wiki. This * name should wind up being unique unless you made two images on the exact * same timestamp. So don't do that. * * @param info Info object containing expedition data * @param imageInfo ImageInfo object, previously made by {@link #readImageInfo(Uri, Location, Calendar)} * @param username current username (must not be null) * @return the name of the image on the wiki */ public static String getImageWikiName(Info info, ImageInfo imageInfo, String username) { // Just to be clear, this is the wiki page name (expedition and all), // the username, and the image's timestamp (as millis past the epoch). return WikiUtils.getWikiPageName(info) + "_" + username + "_" + imageInfo.timestamp + ".jpg"; } /** * <p> * Creates an {@link ImageInfo} object from the given Uri. Note that as far * as Android goes for the time being, locationIfNoneSet and timeIfNoneSet * will ALWAYS be what's used, ever since the changes that require me to use * the document opening interface as opposed to the old MediaStore method. * This will change if I can ever get reasonable metadata out of the * document provider's methods. * </p> * * @param uri the URI of the image * @param locationIfNoneSet location to use if the image has no location metadata stored in it (it won't) * @param timeIfNoneSet Calendar containing a timestamp to use if the image has no time metadata stored in it (it won't) * @return a brand new ImageInfo */ @NonNull public static ImageInfo readImageInfo(@NonNull Uri uri, @Nullable Location locationIfNoneSet, @NonNull Calendar timeIfNoneSet) { // This got a lot simpler, but sadly much less robust, after the Android // change that broke permissions on MediaStore... ImageInfo toReturn = new ImageInfo(); toReturn.uri = uri; toReturn.location = locationIfNoneSet; toReturn.timestamp = timeIfNoneSet.getTimeInMillis(); return toReturn; } /** * Loads, shrinks, stamps, and JPEGifies an image for the wiki. Call this * to get a byte array, then shove that out the door. Do it quick, too, as * this might get sort of big on memory use. * * @param context a Context for getting necessary paints and resources * @param info an Info object for determining the distance to the destination * @param imageInfo ImageInfo containing image stuff to retrieve * @param drawInfobox true to draw the infobox, false to just shrink and compress * @return a byte array of JPEG data, or null if something went wrong */ @Nullable public static byte[] createWikiImage(@NonNull Context context, @NonNull Info info, @NonNull ImageInfo imageInfo, boolean drawInfobox) { // First, we want to scale the image to cut down on memory use and // upload time. The Geohashing wiki tends to frown upon images over // 150k, so scaling and compressing are the way to go. Bitmap bitmap = BitmapTools .createRatioPreservedDownscaledBitmapFromUri( context, imageInfo.uri, MAX_UPLOAD_WIDTH, MAX_UPLOAD_HEIGHT, true); // If the Bitmap wound up null, we're in trouble. if(bitmap == null) return null; // Then, put the infobox up if that's what we're into. if(drawInfobox) drawInfobox(context, info, imageInfo, bitmap); // Finally, compress it and away it goes! ByteArrayOutputStream bytes = new ByteArrayOutputStream(); bitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_JPEG_QUALITY, bytes); byte[] toReturn = bytes.toByteArray(); bitmap.recycle(); return toReturn; } /** * Puts the handy infobox on a Bitmap. * * @param context a Context, for resources * @param info an Info object for the current expedition * @param imageInfo some ImageInfo * @param bitmap the Bitmap (must be read/write, will be edited) * @throws java.lang.IllegalArgumentException if you tried to pass an immutable Bitmap */ public static void drawInfobox(@NonNull Context context, @NonNull Info info, @NonNull ImageInfo imageInfo, @NonNull Bitmap bitmap) { if (!bitmap.isMutable()) throw new IllegalArgumentException("The Bitmap has to be mutable in order to draw an infobox on it!"); // PAINT! makePaints(context); // First, we need to draw something. Get a Canvas. Canvas c = new Canvas(bitmap); String[] strings; int[] icons; // I'm sure this could have less redundant code if I wasn't thinking too // hard about it... if (imageInfo.location != null) { // Assemble all our data. Our three strings will be the final // destination, our current location, and the distance. strings = new String[3]; strings[0] = UnitConverter.makeFullCoordinateString(context, info.getFinalLocation(), false, UnitConverter.OUTPUT_LONG); strings[1] = UnitConverter.makeFullCoordinateString(context, imageInfo.location, false, UnitConverter.OUTPUT_LONG); strings[2] = UnitConverter.makeDistanceString(context, mDistFormat, info.getDistanceInMeters(imageInfo.location)); // Then, to the render method! icons = new int[3]; icons[0] = R.drawable.final_destination_wiki_image; icons[1] = R.drawable.current_location_wiki_image; icons[2] = R.drawable.distance_wiki_image; } else { // Otherwise, just throw up an unknown. Location's still there, // though. strings = new String[2]; strings[0] = UnitConverter.makeFullCoordinateString(context, info.getFinalLocation(), false, UnitConverter.OUTPUT_LONG); strings[1] = context.getString(R.string.location_unknown); icons = new int[2]; icons[0] = R.drawable.final_destination_wiki_image; icons[1] = R.drawable.current_location_wiki_image; } drawStrings(context.getResources(), strings, icons, c, mTextPaint, mBackgroundPaint); } private static void makePaints(Context context) { // These are for efficiency's sake so we don't rebuild paints uselessly. if(mBackgroundPaint == null) { mBackgroundPaint = new Paint(); mBackgroundPaint.setStyle(Paint.Style.FILL); mBackgroundPaint.setColor(ContextCompat.getColor(context, R.color.infobox_background)); } if(mTextPaint == null) { mTextPaint = new Paint(); mTextPaint.setColor(ContextCompat.getColor(context, R.color.infobox_text)); mTextPaint.setTextSize(context.getResources().getDimension(R.dimen.infobox_picture_fontsize)); mTextPaint.setAntiAlias(true); } } private static void drawStrings(Resources resources, String[] strings, int[] icons, Canvas c, Paint textPaint, Paint backgroundPaint) { // All we do here is prepare the Bitmaps before tossing them to the // OTHER method. Hence the need for a Resources. Bitmap[] bitmaps = new Bitmap[icons.length]; BitmapFactory.Options opts = new BitmapFactory.Options(); opts.inScaled = false; for(int i = 0; i < icons.length; i++) { bitmaps[i] = BitmapFactory.decodeResource(resources, icons[i], opts); } drawStrings(strings, bitmaps, c, textPaint, backgroundPaint); } private static void drawStrings(String[] strings, Bitmap[] bitmaps, Canvas c, Paint textPaint, Paint backgroundPaint) { // The default paint should do, right? Paint paint = new Paint(); // We need SOME strings. If we've got nothing, bail out. This doesn't // apply to icons, though; if there's fewer icons than there are // strings, the strings just get placed on the left margin. if(strings.length < 1) return; if(bitmaps == null) bitmaps = new Bitmap[0]; // First, init our variables. This is as good a place as any to do so. Rect textBounds = new Rect(); int[] stringHeights = new int[strings.length]; int[] iconHeights = new int[strings.length]; // Yes, strings.length. int totalHeight = INFOBOX_BOX_PADDING * 2; int longestWidth = 0; // The height of the box is the total heights of either the texts or // images (whichever is bigger), plus the margins. The width of it is // the LONGEST icon/text combo. for(int i = 0; i < strings.length; i++) { String s = strings[i]; textPaint.getTextBounds(s, 0, s.length(), textBounds); int textWidth = textBounds.width(); int textHeight = textBounds.height(); int iconWidth = 0; int iconHeight = 0; // If we even HAVE an icon for this... if(bitmaps.length > i) { // With an extra shot of padding for the icon... iconWidth = bitmaps[i].getWidth() + INFOBOX_ITEM_PADDING; iconHeight = bitmaps[i].getHeight(); } // Now, add the tallest of those into the height... totalHeight += (textHeight > iconHeight ? textHeight : iconHeight); // ...keep track of the individual heights so we don't have to keep // recalculating them... stringHeights[i] = textHeight; iconHeights[i] = iconHeight; // ...and see if the sum of the widths is wider than the widest we // found so far. if(textWidth + iconWidth > longestWidth) { longestWidth = textWidth + iconWidth; } } // With the total height and widest width, we've got us a rectangle. Rect drawBounds = new Rect(c.getWidth() - longestWidth - (INFOBOX_BOX_PADDING * 2), 0, c.getWidth(), totalHeight); c.drawRect(drawBounds, backgroundPaint); // Now, place each of the strings with their respective icons next to // them. Topmost one is index 0, they're all left-justified, and the // icons go in first. int curHeight = INFOBOX_BOX_PADDING; for(int i = 0; i < strings.length; i++) { // Oh, and we want to center the text and the icons. The way the // textBounds grabber works, it just gives us the rectangle around // the entire text. That rectangle, however, doesn't account for // any font descenders or whatnot unless such glyphs are in the // string itself. For what we're doing, however, that should work, // as the numbers and letters we use don't descend. If that changes // at any point, we need to get more in-depth with font analysis to // make sure everything centers consistently on the same baseline. int iconOffset = 0; int textOffsetX = 0; int textOffsetY = 0; int heightOffset; if(stringHeights[i] > iconHeights[i]) { // String is taller, icon needs to adjust. iconOffset = (stringHeights[i] - iconHeights[i]) / 2; heightOffset = stringHeights[i]; } else { // Icon is taller, string needs to adjust. textOffsetY = (iconHeights[i] - stringHeights[i]) / 2; heightOffset = iconHeights[i]; } // textOffsetY must also be adjusted, since Android draws text from // the baseline, not the top-left corner of the text block. textOffsetY += stringHeights[i]; // The icon and text need to be vertically centered. The way that // happens depends on which is bigger. At time of writing, the // icons were, but knowing me, this could change at any time. // Icon! if(bitmaps.length > i) { c.drawBitmap(bitmaps[i], drawBounds.left + INFOBOX_BOX_PADDING, curHeight + iconOffset, paint); textOffsetX = bitmaps[i].getWidth() + INFOBOX_ITEM_PADDING; // Oh, and jettison that Bitmap. bitmaps[i].recycle(); } // Text! c.drawText(strings[i], drawBounds.left + INFOBOX_BOX_PADDING + textOffsetX, curHeight + textOffsetY, textPaint); // Then set the height for the next row. curHeight += heightOffset; } } /** * Gets the live/retro prefix for an image to upload to the wiki. Note that * this might be an empty string (it's possible to have a non-live, * non-retro image). * * @param context a Context, for translation purposes * @param imageInfo the requisite ImageInfo * @param info the requisite Info * @return a prefix tag */ @NonNull public static String getImagePrefixTag(Context context, ImageInfo imageInfo, Info info) { if(info.isRetroHash()) { return context.getText(R.string.wiki_post_picture_summary_retro).toString(); } else if(System.currentTimeMillis() - imageInfo.timestamp < LIVE_TIMEOUT) { // If the picture was WITHIN the timeout, post it with the // live title. If not (and it's not retro), don't put any // title on it. return context.getText(R.string.wiki_post_picture_summary).toString(); } else { // If it's neither live nor retro, just return a blank. return ""; } } }