package org.commcare.utils;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.util.DisplayMetrics;
import android.util.Log;
import android.util.Pair;
import android.view.WindowManager;
import org.commcare.CommCareApplication;
import org.commcare.engine.references.JavaFileReference;
import org.commcare.logging.AndroidLogger;
import org.commcare.google.services.analytics.GoogleAnalyticsFields;
import org.commcare.google.services.analytics.GoogleAnalyticsUtils;
import org.commcare.preferences.CommCarePreferences;
import org.javarosa.core.reference.InvalidReferenceException;
import org.javarosa.core.reference.Reference;
import org.javarosa.core.reference.ReferenceManager;
import org.javarosa.core.services.Logger;
import java.io.File;
import java.io.IOException;
import java.math.BigInteger;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* @author ctsims
*/
public class MediaUtil {
private static final String TAG = MediaUtil.class.toString();
public static final String FORM_VIDEO = "video";
public static final String FORM_AUDIO = "audio";
public static final String FORM_IMAGE = "image";
/**
* Attempts to inflate an image from a CommCare UI definition source.
*
* @param jrUri The image to inflate
* @param boundingWidth the width of the container this image is being inflated into, to serve
* as a max width. If passed in as -1, gets set to screen width
* @param boundingHeight the height of the container this image is being inflated into, to
* serve as a max height. If passed in as -1, gets set to screen height
* @return A bitmap if one could be created. Null if error occurs or the image is unavailable.
*/
public static Bitmap inflateDisplayImage(Context context, String jrUri,
int boundingWidth, int boundingHeight,
boolean respectBoundsExactly) {
if (jrUri == null || jrUri.equals("")) {
return null;
}
try {
Reference ref = ReferenceManager.instance().DeriveReference(jrUri);
try {
if (!ref.doesBinaryExist()) {
return null;
}
if (!(ref instanceof JavaFileReference)) {
return BitmapFactory.decodeStream(ref.getStream());
}
} catch (IOException e) {
Logger.log(AndroidLogger.TYPE_ERROR_ASSERTION, "IO Exception loading reference: " + jrUri);
return null;
}
String imageFilename = ref.getLocalURI();
final File imageFile = new File(imageFilename);
if (!imageFile.exists()) {
return null;
}
DisplayMetrics displayMetrics = new DisplayMetrics();
((WindowManager)context.getSystemService(Context.WINDOW_SERVICE))
.getDefaultDisplay()
.getMetrics(displayMetrics);
if (boundingHeight == -1) {
boundingHeight = displayMetrics.heightPixels;
}
if (boundingWidth == -1) {
boundingWidth = displayMetrics.widthPixels;
}
if (CommCarePreferences.isSmartInflationEnabled()) {
GoogleAnalyticsUtils.reportFeatureUsage(GoogleAnalyticsFields.ACTION_USING_SMART_IMAGE_INFLATION);
// scale based on bounding dimens AND native density
return getBitmapScaledForNativeDensity(
context.getResources().getDisplayMetrics(), imageFile.getAbsolutePath(),
boundingHeight, boundingWidth, CommCarePreferences.getTargetInflationDensity());
} else {
// just scale down if the original image is way too big for its container
return getBitmapScaledToContainer(imageFile.getAbsolutePath(), boundingHeight,
boundingWidth, respectBoundsExactly);
}
} catch (InvalidReferenceException e) {
Log.e("ImageInflater", "image invalid reference exception for " + e.getReferenceString());
e.printStackTrace();
}
return null;
}
public static Bitmap inflateDisplayImage(Context context, String jrUri,
int boundingWidth, int boundingHeight) {
return inflateDisplayImage(context, jrUri, boundingWidth, boundingHeight, false);
}
public static Bitmap inflateDisplayImage(Context context, String jrUri) {
return inflateDisplayImage(context, jrUri, -1, -1, false);
}
/**
* Attempt to inflate an image source into a bitmap whose final dimensions are based upon
* 2 factors:
*
* 1) The application of a scaling factor, which is derived from the relative values of the
* target density declared by the app and the current device's actual density
* 2) The absolute dimensions of the bounding container into which this image is being inflated
* (may just be the screen dimens)
*
* @return the bitmap, or null if none could be created from the source
*/
public static Bitmap getBitmapScaledForNativeDensity(DisplayMetrics metrics, String imageFilepath,
int containerHeight, int containerWidth,
int targetDensity) {
Pair<File, Bitmap> cacheKey = getCacheFileLocationAndBitmap(imageFilepath,
String.format("density_%d_%d_%d", containerHeight, containerWidth, targetDensity));
if (cacheKey != null && cacheKey.second != null) {
return cacheKey.second;
}
BitmapFactory.Options o = new BitmapFactory.Options();
o.inJustDecodeBounds = true;
o.inScaled = false;
BitmapFactory.decodeFile(imageFilepath, o);
int imageHeight = o.outHeight;
int imageWidth = o.outWidth;
double scaleFactor = computeInflationScaleFactor(metrics, targetDensity);
int calculatedHeight = Math.round((float)(imageHeight * scaleFactor));
int calculatedWidth = Math.round((float)(imageWidth * scaleFactor));
Bitmap toReturn;
if (scaleFactor > 1) {
toReturn = attemptBoundedScaleUp(imageFilepath, calculatedHeight, calculatedWidth,
containerHeight, containerWidth);
} else {
toReturn = scaleDownToTargetOrContainer(imageFilepath, imageHeight, imageWidth,
calculatedHeight, calculatedWidth, containerHeight, containerWidth, false, true);
}
if (cacheKey != null) {
attemptWriteCacheToLocation(toReturn, cacheKey.first);
}
return toReturn;
}
private static void attemptWriteCacheToLocation(Bitmap toReturn, File cacheLocation) {
try {
FileUtil.writeBitmapToDiskAndCleanupHandles(toReturn,
ImageType.fromExtension(FileUtil.getExtension(cacheLocation.getPath())),
cacheLocation);
} catch (IOException e) {
e.printStackTrace();
Log.d(TAG, "Failed to write bitmap to cache for " + cacheLocation);
}
}
/**
* Attempts to load a cached filepath from the given location and tag, and returns the
* location for the cached file either way.
*
* If caching is unavailable, null should be returned. If an object is returned, the first
* argument must be non-null, and must have the same extension as the input filepath.
*
* The cache key/object will handle its own file path/modified clearance, the tag provided
* should differentiate between different ways of inflating the provided image path
*/
private static Pair<File, Bitmap> getCacheFileLocationAndBitmap(String imageFilepath,
String tag) {
File cacheKey = getCacheFileLocation(imageFilepath, tag);
if (cacheKey == null) {
return null;
}
Bitmap b = null;
if (cacheKey.exists()) {
try {
b = BitmapFactory.decodeFile(cacheKey.getPath());
} catch (RuntimeException e) {
try {
cacheKey.delete();
Log.d(TAG, "Removed potentially invalid cache from " +cacheKey.toString());
} catch (Exception inner) {
}
}
}
return new Pair<>(cacheKey,b);
}
private static File getCacheFileLocation(String imageFilepath, String tag) {
Context c = CommCareApplication.instance().getApplicationContext();
File cacheDirectory = c.getCacheDir();
if (!cacheDirectory.exists()) {
return null;
}
String ext = FileUtil.getExtension(imageFilepath);
if (ImageType.fromExtension(ext) == null) {
Log.d(TAG, "Couldn't identify the format of a file for caching: " + imageFilepath);
return null;
}
File fileToTransform = new File(imageFilepath);
String fileName = String.format("%s_%d_%s.%s",
getHashedImageFilepath(imageFilepath),
fileToTransform.lastModified(),
tag,
ext);
return new File(cacheDirectory, fileName);
}
public static String getHashedImageFilepath(String input) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(input.getBytes());
byte[] hashInBytes = md.digest();
BigInteger number = new BigInteger(1, hashInBytes);
String md5 = number.toString(16);
while (md5.length() < 32) {
md5 = "0" + md5;
}
return md5;
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("No MD5 platform hashing enabled");
}
}
/**
* @return Our custom scale factor, based on this device's density and what the image's target
* density was
*/
public static double computeInflationScaleFactor(DisplayMetrics metrics, int targetDensity) {
final int SCREEN_DENSITY = metrics.densityDpi;
double customDpiScaleFactor = (double)SCREEN_DENSITY / targetDensity;
double proportionalAdjustmentFactor = getCustomAndroidAdjustmentFactor(metrics);
return customDpiScaleFactor * proportionalAdjustmentFactor;
}
private static double getCustomAndroidAdjustmentFactor(DisplayMetrics metrics) {
// This is the formula Android *usually* uses to compute the value of metrics.density
// for a device. If this is in fact the value being used, we are not interested in it.
// However, if the actual value differs at all from the standard calculation, it means
// Android is taking other factors into consideration (such as straight up screen size)
// when it re-sizes an image for this device, so we want to incorporate that proportionally
// into our own version of the scale factor
double standardNativeScaleFactor = (double)metrics.densityDpi / DisplayMetrics.DENSITY_DEFAULT;
double actualNativeScaleFactor = metrics.density;
if (actualNativeScaleFactor > standardNativeScaleFactor) {
return 1 +
((actualNativeScaleFactor - standardNativeScaleFactor) / standardNativeScaleFactor);
} else if (actualNativeScaleFactor < standardNativeScaleFactor) {
return actualNativeScaleFactor / standardNativeScaleFactor;
} else {
return 1;
}
}
/**
* @return A bitmap representation of the given image file, scaled down to the smallest
* size that still fills the container
*
* More precisely, preserves the following 2 conditions:
* 1. The larger of the 2 sides takes on the size of the corresponding container dimension
* (e.g. if its width is larger than its height, then the new width should = containerWidth)
* 2. The aspect ratio of the original image is maintained (so the height would get scaled
* down proportionally with the width)
*/
public static Bitmap getBitmapScaledToContainer(String imageFilepath, int containerHeight,
int containerWidth,
boolean respectBoundsExactly) {
Pair<File, Bitmap> cacheKey = getCacheFileLocationAndBitmap(imageFilepath,
String.format("container_%d_%d_%b",containerHeight, containerWidth,
respectBoundsExactly));
if (cacheKey != null && cacheKey.second != null) {
return cacheKey.second;
}
// Determine dimensions of original image
BitmapFactory.Options o = new BitmapFactory.Options();
o.inJustDecodeBounds = true;
BitmapFactory.decodeFile(imageFilepath, o);
int imageHeight = o.outHeight;
int imageWidth = o.outWidth;
Bitmap toReturn = scaleDownToTargetOrContainer(imageFilepath, imageHeight, imageWidth, -1,
-1, containerHeight, containerWidth, true, respectBoundsExactly);
if (cacheKey != null) {
attemptWriteCacheToLocation(toReturn, cacheKey.first);
}
return toReturn;
}
public static Bitmap getBitmapScaledToContainer(File imageFile, int containerHeight,
int containerWidth) {
return getBitmapScaledToContainer(imageFile.getAbsolutePath(), containerHeight,
containerWidth, false);
}
/**
* @param scaleByContainerOnly If true, means that we're just trying to ensure that our bitmap
* isn't way bigger than necessary, rather than creating a bitmap
* of an exact size based on a target width and height. In this
* case, targetWidth and targetHeight are ignored and the 2nd case
* below is used.
*
* @return A bitmap representation of the given image file, scaled down if necessary such that
* the new dimensions of the image are the SMALLER of the following 2 options:
* 1) targetHeight and targetWidth
* 2) the largest dimensions for which the original aspect ratio is maintained, without
* exceeding either boundingWidth or boundingHeight (or just the original dimensions if the
* image already roughly fits in the bounds)
*/
private static Bitmap scaleDownToTargetOrContainer(String imageFilepath,
int originalHeight, int originalWidth,
int targetHeight, int targetWidth,
int boundingHeight, int boundingWidth,
boolean scaleByContainerOnly,
boolean respectBoundsExactly) {
Pair<Integer, Integer> dimensImposedByContainer = getRoughDimensImposedByContainer(
originalHeight, originalWidth, boundingHeight, boundingWidth);
int newWidth, newHeight;
if (scaleByContainerOnly) {
newWidth = dimensImposedByContainer.first;
newHeight = dimensImposedByContainer.second;
} else {
newWidth = Math.min(dimensImposedByContainer.first, targetWidth);
newHeight = Math.min(dimensImposedByContainer.second, targetHeight);
}
int approximateScaleDownFactor = getApproxScaleDownFactor(newWidth, originalWidth);
Bitmap b = inflateImageSafe(imageFilepath, approximateScaleDownFactor).first;
if (scaleByContainerOnly && !respectBoundsExactly) {
// Not worth performance loss of creating an exact scaled bitmap in this case
return b;
} else {
try {
// Here we want to be more precise because we have a target width and height, or
// specified that respecting the bounding container precisely is important
return Bitmap.createScaledBitmap(b, newWidth, newHeight, false);
} catch (OutOfMemoryError e) {
Log.d(TAG, "Ran out of memory attempting to scale image at: " + imageFilepath);
return null;
}
}
}
private static int getApproxScaleDownFactor(int newWidth, int originalWidth) {
if (newWidth == 0) {
return 1;
} else {
int scale = originalWidth / newWidth;
if (scale == 0) {
return 1;
}
return scale;
}
}
/**
* @return The smallest dimensions that both preserve the original aspect ratio, and mean
* that the image still fills the container. It is unimportant to scale down exactly to the
* container size; we just don't want to be creating a bitmap that is way bigger than necessary.
*/
private static Pair<Integer, Integer> getRoughDimensImposedByContainer(int originalHeight,
int originalWidth,
int boundingHeight,
int boundingWidth) {
if (originalHeight < boundingHeight || originalWidth < boundingWidth) {
// Since this is only meant to be a rough scale-down to keep the image from being way
// too large, we only want to scale down if both dimensions are currently exceeding
// their bounds
return new Pair<>(originalWidth, originalHeight);
}
double heightScaleDownFactor = (double)boundingHeight / originalHeight;
double widthScaleDownFactor = (double)boundingWidth / originalWidth;
// Choosing the larger of the scale down factors, so that the image still fills the entire
// container
double dominantScaleDownFactor = Math.max(widthScaleDownFactor, heightScaleDownFactor);
int widthImposedByContainer = (int)Math.round(originalWidth * dominantScaleDownFactor);
int heightImposedByContainer = (int)Math.round(originalHeight * dominantScaleDownFactor);
return new Pair<>(widthImposedByContainer, heightImposedByContainer);
}
/**
* @return A bitmap representation of the given image file, scaled up as close as possible to
* desiredWidth and desiredHeight, without exceeding either boundingHeight or boundingWidth
*/
private static Bitmap attemptBoundedScaleUp(String imageFilepath,
int desiredHeight, int desiredWidth,
int boundingHeight, int boundingWidth) {
if (boundingHeight < desiredHeight || boundingWidth < desiredWidth) {
Pair<Integer, Integer> dimensForScaleUp = boundedScaleUpHelper(
desiredHeight, desiredWidth, boundingHeight, boundingWidth);
desiredWidth = dimensForScaleUp.first;
desiredHeight = dimensForScaleUp.second;
}
try {
BitmapFactory.Options o = new BitmapFactory.Options();
o.inScaled = false;
Bitmap originalBitmap = BitmapFactory.decodeFile(imageFilepath, o);
try {
return Bitmap.createScaledBitmap(originalBitmap, desiredWidth, desiredHeight, false);
} catch (OutOfMemoryError e) {
return originalBitmap;
}
} catch (OutOfMemoryError e) {
// Just inflating the image at its original size caused an OOM error, don't have a
// choice but to scale down
return performSafeScaleDown(imageFilepath, 2, 1).first;
}
}
/**
* @return A (width, height) pair representing the largest possible dimensions that both:
* a) do not exceed boundingHeight or boundingWidth, and
* b) maintain the aspect ratio given by originalHeight and originalWidth
*/
private static Pair<Integer, Integer> boundedScaleUpHelper(int originalHeight,
int originalWidth,
int boundingHeight,
int boundingWidth) {
double heightScaleFactor = (double)boundingHeight / originalHeight;
double widthScaleFactor = (double)boundingWidth / originalWidth;
double dominantScaleFactor = Math.min(heightScaleFactor, widthScaleFactor);
int scaledUpWidthImposedByContainer = (int)Math.round(originalWidth * dominantScaleFactor);
int scaledUpHeightImposedByContainer = (int)Math.round(originalHeight * dominantScaleFactor);
return new Pair<>(scaledUpWidthImposedByContainer, scaledUpHeightImposedByContainer);
}
/**
* Inflate an image file into a bitmap, attempting first to inflate it at the given scale-down
* factor, but progressively scaling down further if an OutOfMemoryError is encountered
*
* @return the bitmap, plus a boolean value representing whether the image had to be downsized
*/
public static Pair<Bitmap, Boolean> inflateImageSafe(String imageFilepath, int scaleDownFactor) {
return performSafeScaleDown(imageFilepath, scaleDownFactor, 0);
}
public static Pair<Bitmap, Boolean> inflateImageSafe(String imageFilepath) {
return inflateImageSafe(imageFilepath, 1);
}
/**
* @return A scaled-down bitmap for the given image file, progressively increasing the
* scale-down factor by 1 until allocating memory for the bitmap does not cause an OOM error,
* and a boolean value representing whether the image had to be downsized
*/
private static Pair<Bitmap, Boolean> performSafeScaleDown(String imageFilepath,
int scaleDownFactor, int depth) {
if (depth == 5) {
// Limit the number of recursive calls
return new Pair<>(null, true);
}
BitmapFactory.Options options = new BitmapFactory.Options();
options.inSampleSize = scaleDownFactor;
try {
return new Pair<>(BitmapFactory.decodeFile(imageFilepath, options), scaleDownFactor > 1);
} catch (OutOfMemoryError e) {
return performSafeScaleDown(imageFilepath, scaleDownFactor + 1, depth + 1);
}
}
}