/*
* Copyright 2015 Daniel Dittmar
*
* 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 dan.dit.whatsthat.image;
import android.content.ContentValues;
import android.content.Context;
import android.content.res.Resources;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.support.annotation.NonNull;
import android.text.TextUtils;
import android.util.Log;
import java.io.File;
import java.util.LinkedList;
import java.util.List;
import dan.dit.whatsthat.preferences.Tongue;
import dan.dit.whatsthat.riddle.types.ContentRiddleType;
import dan.dit.whatsthat.riddle.types.FormatRiddleType;
import dan.dit.whatsthat.riddle.types.PracticalRiddleType;
import dan.dit.whatsthat.riddle.types.RiddleType;
import dan.dit.whatsthat.solution.Solution;
import dan.dit.whatsthat.storage.ImageTable;
import dan.dit.whatsthat.storage.ImagesContentProvider;
import dan.dit.whatsthat.util.general.BuildException;
import dan.dit.whatsthat.util.compaction.CompactedDataCorruptException;
import dan.dit.whatsthat.util.compaction.Compacter;
import dan.dit.whatsthat.util.image.BitmapUtil;
import dan.dit.whatsthat.util.image.ColorAnalysisUtil;
import dan.dit.whatsthat.util.image.Dimension;
import dan.dit.whatsthat.util.image.ExternalStorage;
import dan.dit.whatsthat.util.image.ImageUtil;
import dan.dit.whatsthat.util.mosaic.data.MosaicTile;
/**
* An instance of the Image class references an image which is uniquely identified by its hash.
* Images are predefined drawables, created by the user or received from other users. Each riddle
* is created upon an Image object by requesting the corresponding bitmap.
* Images store metadata like the author, the image name, solution words and preferred and refused
* riddle types.
* Created by daniel on 24.03.15.
*/
public class Image implements MosaicTile<String> {
public static final String SHAREDPREFERENCES_FILENAME ="dan.dit.whatsthat.imagePrefs";
public static final String ORIGIN_IS_THE_APP = "WhatsThat";
public static final String IMAGES_DIRECTORY_NAME = ".images";
private static final int NO_AVERAGE_COLOR = 0;
public static final String ORIGIN_IS_EXTERNAL_OBFUSCATED = "ExtObf";
// instead of building everytime on every device this app runs we built once for every new release of images all essential data
// that takes long time like hash or preference/refused calculation, save it into a simple text file which we then read on first app
// launch, create Image objects and save to database like before. This saves alot of initial loading time and does not require
// shipping database files which make collide with different SQL versions or paths for those files or access violations
private long mTimestamp;
// the md5 hash of the image which identifies it
private String mHash;
private int mResId; // either resId or resPath is specified to be valid and link to a valid image
private String mRelativePath;
private String mName; // a name identifying the image
private ImageAuthor mAuthor;
private String mOrigin;
private int mIsObfuscated; // is obfuscated if != 0
private List<Solution> mSolutions; // always at least one solution needed
private List<RiddleType> mPreferredRiddleTypes; // can be null
private List<RiddleType> mRefusedRiddleTypes; // can be null
private int mAverageColor = NO_AVERAGE_COLOR; // average color of the bitmap
private Image() {}
@Override
public boolean equals(Object other) {
if (other instanceof Image) {
return mHash.equals(((Image) other).mHash);
} else {
return super.equals(other);
}
}
@Override
public int hashCode() {
return mHash.hashCode();
}
/**
* Deletes the given image from the database. This should not commonly be used! It is required
* though if the image needed to be removed because of copyright issues or if the custom image
* got deleted from the hard disk. Even if the image is no longer accessible riddles that used this
* image can be kept.
* @param context The application context
* @param hash The md5 hash of the image. The hash can be obtained by ImageUtil.
* @return True if successfully deleted from the database.
*/
private static boolean deleteFromDatabase(Context context, String hash) {
return context.getContentResolver().delete(ImagesContentProvider.buildImageUri(hash), null, null) > 0;
}
boolean deleteFromDatabase(Context context) {
Log.d("Image", "Deleting " + toString() + " from database.");
return deleteFromDatabase(context, getHash());
}
/**
* Saves this image to the database, overwriting any previous entry with the same image hash.
* @param context The application context.
* @return If the image has been saved successfully.
*/
boolean saveToDatabase(Context context) {
ContentValues cv = new ContentValues();
cv.put(ImageTable.COLUMN_TIMESTAMP, mTimestamp);
cv.put(ImageTable.COLUMN_AUTHOR, mAuthor.compact());
cv.put(ImageTable.COLUMN_HASH, mHash);
cv.put(ImageTable.COLUMN_NAME, mName);
cv.put(ImageTable.COLUMN_OBFUSCATION, mIsObfuscated);
cv.put(ImageTable.COLUMN_ORIGIN, mOrigin);
cv.put(ImageTable.COLUMN_AVERAGE_COLOR, mAverageColor);
// one of mResId or mResPath is valid
if (mResId != 0) {
cv.put(ImageTable.COLUMN_RESNAME, ImageUtil.getDrawableNameFromResId(context.getResources(), mResId));
} else {
cv.put(ImageTable.COLUMN_RESNAME, 0);
cv.put(ImageTable.COLUMN_SAVELOC, mRelativePath);
}
// solutions
Compacter cmp = new Compacter();
for (Solution sol : mSolutions) {
cmp.appendData(sol.compact());
}
cv.put(ImageTable.COLUMN_SOLUTIONS, cmp.compact());
// preferred riddle types
if (mPreferredRiddleTypes != null) {
cmp = new Compacter();
for (RiddleType riddleType : mPreferredRiddleTypes) {
cmp.appendData(riddleType.getFullName());
}
cv.put(ImageTable.COLUMN_RIDDLEPREFTYPES, cmp.compact());
}
// refused riddle types
if (mRefusedRiddleTypes != null) {
cmp = new Compacter();
for (RiddleType riddleType : mRefusedRiddleTypes) {
cmp.appendData(riddleType.getFullName());
}
cv.put(ImageTable.COLUMN_RIDDLEREFUSEDTYPES, cmp.compact());
}
cv.put(ImagesContentProvider.SQL_INSERT_OR_REPLACE, true);
return context.getContentResolver().insert(ImagesContentProvider.CONTENT_URI_IMAGE, cv) != null;
}
public static Image loadFromCursor(Context context, Cursor cursor) {
String resName = cursor.getString(cursor.getColumnIndexOrThrow(ImageTable.COLUMN_RESNAME));
int resId = TextUtils.isEmpty(resName) ? 0 : ImageUtil.getDrawableResIdFromName(context, resName);
String resPathRaw = cursor.getString(cursor.getColumnIndexOrThrow(ImageTable.COLUMN_SAVELOC));
String resPath = TextUtils.isEmpty(resPathRaw) ? null : resPathRaw;
String hash = cursor.getString(cursor.getColumnIndexOrThrow(ImageTable.COLUMN_HASH));
ImageAuthor author;
try {
author = new ImageAuthor(new Compacter(cursor.getString(cursor.getColumnIndexOrThrow(ImageTable.COLUMN_AUTHOR))));
} catch (CompactedDataCorruptException exp) {
Log.e("Image", "Failed loading image with hash " + hash + " from database. ImageAuthor data corrupt.");
return null;
}
String name = cursor.getString(cursor.getColumnIndexOrThrow(ImageTable.COLUMN_NAME));
long timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(ImageTable.COLUMN_TIMESTAMP));
Image.Builder builder = new Image.Builder(resId, resPath, name, author, timestamp, hash);
builder.setOrigin(cursor.getString(cursor.getColumnIndexOrThrow(ImageTable.COLUMN_ORIGIN)));
builder.setObfuscation(cursor.getInt(cursor.getColumnIndexOrThrow(ImageTable.COLUMN_OBFUSCATION)));
builder.setAverageColor(cursor.getInt(cursor.getColumnIndexOrThrow(ImageTable.COLUMN_AVERAGE_COLOR)));
// solutions
for (String sol : new Compacter(cursor.getString(cursor.getColumnIndexOrThrow(ImageTable.COLUMN_SOLUTIONS)))) {
try {
builder.addSolution(new Solution(new Compacter(sol)));
} catch (CompactedDataCorruptException exp) {
Log.e("Image", "Problem loading image with hash " + hash + " from database. Failed to load solution " + sol);
return null;
}
}
// preferred riddle types
String riddleData = cursor.getString(cursor.getColumnIndexOrThrow(ImageTable.COLUMN_RIDDLEPREFTYPES));
if (!TextUtils.isEmpty(riddleData)) {
for (String prefRiddleType : new Compacter(riddleData)) {
builder.addPreferredRiddleType(RiddleType.getInstance(prefRiddleType));
}
}
// refused riddle types
riddleData = cursor.getString(cursor.getColumnIndexOrThrow(ImageTable.COLUMN_RIDDLEREFUSEDTYPES));
if (!TextUtils.isEmpty(riddleData)) {
for (String prefRiddleType : new Compacter(riddleData)) {
builder.addRefusedRiddleType(PracticalRiddleType.getInstance(prefRiddleType));
}
}
Image result = null;
try {
result = builder.build(context, null);
} catch (BuildException be) {
Log.e("Image", "Failed loading image with hash " + hash + " from database. Building failed.");
}
return result;
}
public ImageAuthor getAuthor() {
return mAuthor;
}
public String getHash() {
return mHash;
}
public List<Solution> getSolutions() {
return mSolutions;
}
public List<RiddleType> getPreferredRiddleTypes() {
return mPreferredRiddleTypes;
}
public List<RiddleType> getRefusedRiddleTypes() {
return mRefusedRiddleTypes;
}
public String getName() {
return mName;
}
public String getOrigin() {
return mOrigin;
}
private Bitmap loadBitmapExecute(Resources res, int reqWidth, int reqHeight, boolean enforceDimension) {
Bitmap result = null;
if (mRelativePath != null) {
String path = ExternalStorage.getExternalStoragePathIfMounted(IMAGES_DIRECTORY_NAME);
if (path != null) {
File imagePath = new File(path + "/" + mOrigin + "/" + mRelativePath);
result = ImageUtil.loadBitmap(imagePath, reqWidth, reqHeight, enforceDimension);
}
} else if (mResId != 0) {
result = ImageUtil.loadBitmap(res, mResId, reqWidth, reqHeight, enforceDimension);
} else {
Log.e("Image", "Trying to load bitmap without relative path or res id! Use other method!");
}
return result;
}
// will fail when loading images only built by being parsed as they don't have a valid resource id or name
public Bitmap loadBitmap(Resources res, Dimension reqDimension, boolean enforceDimension) {
int reqWidth = reqDimension.getWidth();
int reqHeight = reqDimension.getHeight();
Bitmap result;
if (mIsObfuscated == 0) {
// not obfuscated, we can optimize the loading
return loadBitmapExecute(res, reqWidth, reqHeight, enforceDimension);
} else {
// we first need to deobfuscate the image
result = loadBitmapExecute(res, 0, 0, false);
if (result != null) {
result = ImageObfuscator.restoreImage(result);
if (result != null) {
result = BitmapUtil.attemptBitmapScaling(result, reqWidth, reqHeight, enforceDimension);
}
}
}
return result;
}
public Bitmap loadBitmap(Context context, Dimension reqDimension, boolean enforceDimension) {
if (mResId == 0 && mRelativePath == null) {
// try to use name to get a image resource id
Log.d("Image", "No res path and no res id for name " + mName);
mResId = ImageUtil.getDrawableResIdFromName(context, mName);
if (mResId != 0) {
return ImageUtil.loadBitmap(context.getResources(), mResId, reqDimension.getWidth(), reqDimension.getHeight(), enforceDimension);
} else {
Log.e("Image", "Failed retrieving res id for image " + mName + ": did not load bitmap.");
return null;
}
} else {
return loadBitmap(context.getResources(), reqDimension, enforceDimension);
}
}
@Override
public String toString() {
return mName + ":" + mHash;
}
public @NonNull
Solution getSolution(Tongue tongue) {
for (Solution sol : mSolutions) {
if (sol.getTongue().equals(tongue)) {
return sol;
}
}
// didn't find a solution in the wanted tongue, check the tongue's parent
Tongue parent = tongue.getParentTongue();
if (parent != null) {
for (Solution sol : mSolutions) {
if (sol.getTongue().equals(parent)) {
return sol;
}
}
}
// still didn't find a solution, if we wanted something else than english lets try to get the english language else just take any solution
if (!Tongue.ENGLISH.equals(tongue)) {
return getSolution(Tongue.ENGLISH); // will either be the english solution or the first solution in the list
}
return mSolutions.get(0);
}
public String getRelativePath() {
return mRelativePath;
}
public int getObfuscation() {
return mIsObfuscated;
}
public int getAverageColor() {
return mAverageColor;
}
@Override
@NonNull
public String getSource() {
return mHash;
}
@Override
public int getAverageARGB() {
return mAverageColor;
}
/**
* A builder for the Image class that allows recreation from the database or fresh creation
* of a new image object.
*/
protected static class Builder {
private Image mImage = new Image();
public Builder() {
mImage.mTimestamp = System.currentTimeMillis();
}
public Builder(int resId, String relativePath, String name, ImageAuthor author, long timestamp, String hash) {
mImage.mResId = resId;
mImage.mRelativePath = relativePath;
mImage.mName = name;
mImage.mAuthor = author;
mImage.mTimestamp = timestamp;
mImage.mHash = hash;
}
private static final Dimension EMPTY_DIMENSION = new Dimension(0, 0);
private void calculateHashAndPreferences(Context context, BitmapUtil.ByteBufferHolder buffer) {
Bitmap image = mImage.loadBitmap(context, EMPTY_DIMENSION, false);
calculateHashAndPreferences(buffer, image);
}
protected void calculateHashAndPreferences(BitmapUtil.ByteBufferHolder buffer, Bitmap image) {
if (image != null) {
BitmapUtil.extractDataFromBitmap(buffer, image);
byte[] bytes = buffer.array();
if (bytes != null) {
mImage.mHash = ImageUtil.getHash(bytes);
}
calculateAverageColor(image);
addOwnFormatAsPreference(image);
addOwnContrastAsPreference(image);
addOwnGreynessAsPreference(image);
}
}
private void calculateAverageColor(Bitmap image) {
mImage.mAverageColor = ColorAnalysisUtil.getAverageColor(image);
}
private void addOwnGreynessAsPreference(Bitmap image) {
double greyness = BitmapUtil.calculateGreyness(image);
if (greyness <= BitmapUtil.GREYNESS_STRONG_THRESHOLD) {
addPreferredRiddleType(ContentRiddleType.GREY_VERY_INSTANCE);
} else if (greyness > BitmapUtil.GREYNESS_MEDIUM_THRESHOLD) {
addPreferredRiddleType(ContentRiddleType.GREY_LITTLE_INSTANCE);
} else {
addPreferredRiddleType(ContentRiddleType.GREY_MEDIUM_INSTANCE);
}
}
public void setResourceName(Context context, String resourceName) {
mImage.mName = resourceName;
mImage.mResId = ImageUtil.getDrawableResIdFromName(context, resourceName);
}
public Builder setRelativeImagePath(String relativePath) {
if (TextUtils.isEmpty(relativePath)) {
return this;
}
mImage.mName = relativePath;
mImage.mRelativePath = relativePath;
return this;
}
public void setAuthor(ImageAuthor author) {
mImage.mAuthor = author;
}
public Builder setHash(String hash) {
if (TextUtils.isEmpty(hash)) {
return this;
}
mImage.mHash = hash;
return this;
}
private void addOwnFormatAsPreference(Bitmap bitmap) {
boolean almostASquare = ImageUtil.isAspectRatioSquareSimilar(bitmap.getWidth(), bitmap.getHeight());
if (almostASquare) {
addPreferredRiddleType(FormatRiddleType.SQUARE_INSTANCE);
} else if (bitmap.getWidth() > bitmap.getHeight()) {
addPreferredRiddleType(FormatRiddleType.LANDSCAPE_INSTANCE);
} else {
addPreferredRiddleType(FormatRiddleType.PORTRAIT_INSTANCE);
}
}
private void addOwnContrastAsPreference(Bitmap bitmap) {
double contrast = BitmapUtil.calculateContrast(bitmap);
if (BitmapUtil.CONTRAST_STRONG_THRESHOLD > contrast && contrast >= BitmapUtil.CONTRAST_WEAK_THRESHOLD) {
addPreferredRiddleType(ContentRiddleType.CONTRAST_MEDIUM_INSTANCE);
} else if (BitmapUtil.CONTRAST_STRONG_THRESHOLD <= contrast) {
addPreferredRiddleType(ContentRiddleType.CONTRAST_STRONG_INSTANCE);
} else {
addPreferredRiddleType(ContentRiddleType.CONTRAST_WEAK_INSTANCE);
}
}
public Builder setOrigin(String origin) {
mImage.mOrigin = origin;
return this;
}
public Builder setObfuscation(int obfuscation) {
mImage.mIsObfuscated = obfuscation;
return this;
}
public Builder setObfuscation(String obfuscation) {
if (!TextUtils.isEmpty(obfuscation)) {
try {
mImage.mIsObfuscated = Integer.parseInt(obfuscation);
} catch (NumberFormatException nfe) {
mImage.mIsObfuscated = ImageObfuscator.IS_OBFUSCATED_HINT;
}
}
return this;
}
public Builder setAverageColor(String averageColor) {
if (!TextUtils.isEmpty(averageColor)) {
try {
setAverageColor(Integer.parseInt(averageColor));
} catch (NumberFormatException nfe) {
setAverageColor(NO_AVERAGE_COLOR);
}
}
return this;
}
public Builder addSolution(Solution solution) {
if (mImage.mSolutions == null) {
mImage.mSolutions = new LinkedList<>();
}
if (solution != null) {
mImage.mSolutions.add(solution);
}
return this;
}
public Builder setSolutions(List<Solution> solutions) {
mImage.mSolutions = solutions;
return this;
}
public Builder addPreferredRiddleType(RiddleType type) {
if (mImage.mPreferredRiddleTypes == null) {
mImage.mPreferredRiddleTypes = new LinkedList<>();
}
if (type != null) {
mImage.mPreferredRiddleTypes.add(type);
}
return this;
}
public Builder setPreferredRiddleTypes(List<RiddleType> types) {
mImage.mPreferredRiddleTypes = types;
return this;
}
public Builder addRefusedRiddleType(PracticalRiddleType type) {
if (mImage.mRefusedRiddleTypes == null) {
mImage.mRefusedRiddleTypes = new LinkedList<>();
}
if (type != null) {
mImage.mRefusedRiddleTypes.add(type);
}
return this;
}
public Builder setRefusedRiddleTypes(List<RiddleType> types) {
mImage.mRefusedRiddleTypes = types;
return this;
}
protected Image build() throws BuildException {
if (mImage.mAuthor == null) {
throw new BuildException("Source: " + mImage.mName).setMissingData("Image", "Author");
}
if (TextUtils.isEmpty(mImage.mOrigin)) {
mImage.mOrigin = ORIGIN_IS_THE_APP;
}
if (TextUtils.isEmpty(mImage.mRelativePath) && mImage.mResId == 0) {
throw new BuildException("Source: " + mImage.mName).setMissingData("Image","ResPath or resId");
}
if (mImage.mSolutions == null || mImage.mSolutions.isEmpty()) {
throw new BuildException("Source: " + mImage.mName).setMissingData("Image", "Solutions");
}
if (TextUtils.isEmpty(mImage.mHash)) {
throw new BuildException("Source: " + mImage.mName).setMissingData("Image", "Hash");
}
return mImage;
}
public Image build(Context context, BitmapUtil.ByteBufferHolder buffer) throws BuildException {
if (TextUtils.isEmpty(mImage.mHash)) {
if (context == null) {
throw new BuildException("Source: " + mImage.mName).setMissingData("Image", "No context and no hash.");
}
Log.d("Image", "Building image with no hash yet: " + mImage.mName + " solutions " + mImage.mSolutions);
calculateHashAndPreferences(context, buffer);
}
build();
return mImage;
}
public void setAverageColor(int averageColor) {
mImage.mAverageColor = averageColor;
}
protected List<Solution> getSolutions() {
return mImage.mSolutions;
}
public ImageAuthor getAuthor() {
return mImage.mAuthor;
}
public String getOrigin() {
return mImage.mOrigin;
}
}
}