/*
* 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.riddle;
import android.content.Context;
import android.graphics.Bitmap;
import android.os.AsyncTask;
import android.util.Log;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import dan.dit.whatsthat.image.Image;
import dan.dit.whatsthat.riddle.control.RiddleGame;
import dan.dit.whatsthat.riddle.types.PracticalRiddleType;
import dan.dit.whatsthat.riddle.types.RiddleType;
import dan.dit.whatsthat.system.RiddleFragment;
import dan.dit.whatsthat.util.general.PercentProgressListener;
import dan.dit.whatsthat.util.image.Dimension;
/**
* Class responsible for producing a RiddleGame. Works asynchronously, can be used to create specific riddles
* and specific riddles for an image or just plain creation of a new RiddleGame for a given PracticalRiddleType.
* Created by daniel on 29.04.15.
*/
public class RiddleMaker {
private static final int PROGRESS_FOUND_IMAGE_AND_MADE_BASIC_RIDDLE = 15;
private static final int PROGRESS_LOADED_IMAGE_BITMAP = 30;
private static final int PROGRESS_INITIALIZED_RIDDLE_BITMAP = 100;
private static final double PICK_DISTRIBUTION_EXP_LAMBDA = 0.3; // lambda of the exponential distribution http://de.wikipedia.org/wiki/Exponentialverteilung
private final Random mRandom = new Random();
private RiddleConfig mMakeRiddleConfig;
private Worker mWorker;
private RiddleMakerListener mListener;
private Context mContext;
private PracticalRiddleType mType;
private List<Image> mAllImages;
private Map<RiddleType, Set<String>> mUsedImagesForTypes;
private Dimension mBitmapDimension;
/**
* The listener used the invoker of the RiddleMaker to get informed
* about progress, results and failure.
*/
public interface RiddleMakerListener extends PercentProgressListener {
/**
* The making of the riddle is done and successful. The Maker is not considered
* making anymore at this point.
* @param riddle The non null riddle that was newly made.
*/
void onRiddleReady(RiddleGame riddle);
/**
* The making failed for some (serious) reason. No future riddle will be delivered.
*/
void onError(Image image, Riddle riddle);
}
/**
* Cancels the running RiddleMaker, if not running does nothing.
*/
public void cancel() {
if (isRunning()) {
mWorker.cancel(true);
onClose();
}
}
/**
* Checks if this RiddleMaker is currently running and making a riddle.
* If yes, it is not allowed to make any riddles.
* @return If this maker is running.
*/
public boolean isRunning() {
return mWorker != null;
}
private void onClose() {
mWorker = null;
mListener = null;
mContext = null;
mUsedImagesForTypes = null;
mAllImages = null;
}
public void remakeCurrentWithNewType(Context context, Riddle currentRiddle, PracticalRiddleType
newType, Dimension maxCanvasDim, int screenDensity, RiddleMakerListener listener) {
if (context == null || maxCanvasDim == null || listener == null || newType == null) {
throw new IllegalArgumentException("No context, riddle type or listener or canvas dim given.");
}
if (currentRiddle == null) {
throw new IllegalArgumentException("No current riddle given.");
}
if (isRunning()) {
throw new IllegalStateException("Already making riddle.");
}
// we need to find the image of the riddle
Image image = RiddleFragment.ALL_IMAGES.get(currentRiddle.mCore.getImageHash());
if (image == null) {
Log.e("Riddle", "Image not available for riddle " + currentRiddle);
listener.onError(null, currentRiddle); // image not available, this can happen if an extern image is deleted while this riddle is still unsolved
// we need to get rid of this riddle
return;
}
String newOrigin = Riddle.makeRemadeOrigin(currentRiddle.getRemadeCount() + 1);
Riddle riddle = new Riddle(image.getHash(), newType, newOrigin);
Dimension canvasDim = new Dimension(maxCanvasDim);
canvasDim.fitInsideWithRatio(riddle.getType().getSuggestedCanvasAspectRatio());
Dimension bitmapDim = new Dimension(canvasDim);
bitmapDim.fitInsideWithRatio(riddle.getType().getSuggestedBitmapAspectRatio()); // and then the bitmap inside the canvas
mListener = listener;
mContext = context;
mType = riddle.getType();
mBitmapDimension = bitmapDim;
mMakeRiddleConfig = new RiddleConfig(canvasDim.getWidth(), canvasDim.getHeight());
mMakeRiddleConfig.mScreenDensity = screenDensity;
mMakeRiddleConfig.setAchievementData(riddle.getType());
Log.d("Image", "Remaking current riddle " + currentRiddle + " to new type " + newType + "" +
" and image " + image + " and bitmapDim " + bitmapDim);
mWorker = new Worker(riddle, image);
mWorker.execute();
}
/**
* Remakes an old riddle. No parameter must be null, this RiddleMaker must not be running or
* an exception will be thrown in either case.
* @param context A context.
* @param suggestedId Attempts to search the unsolved riddles and to use the riddle that matches this id.
* @param maxCanvasDim The maximum dimension of the riddle's canvas.
* @param screenDensity The density of the device.
* @param listener A listener that is notified about progress, failure and success.
*/
public void remakeOld(Context context, long suggestedId, Dimension maxCanvasDim, int screenDensity, RiddleMakerListener listener) {
if (context == null || maxCanvasDim == null || listener == null) {
throw new IllegalArgumentException("No context, riddle type or listener or canvas dim given.");
}
if (isRunning()) {
throw new IllegalStateException("Already making riddle.");
}
List<Riddle> allUnsolvedRiddles = RiddleInitializer.INSTANCE.getRiddleManager().getUnsolvedRiddles();
Log.d("Riddle", "Suggested id of unsolved riddles: " + suggestedId + " of " + allUnsolvedRiddles);
// attempt finding the riddle the user suggested
Riddle riddle = null;
int riddleIndex = 0;
for (Riddle rid : allUnsolvedRiddles) {
if (rid.mCore.getId() == suggestedId) {
riddle = rid;
break;
}
riddleIndex++;
}
if (riddle == null && !allUnsolvedRiddles.isEmpty()) {
riddleIndex = 0;
riddle = allUnsolvedRiddles.get(riddleIndex); // get the front (newest)
}
if (riddle == null) {
listener.onError(null, null); // no unsolved riddles
Log.e("Riddle", "No unsolved riddles during remakeOld");
return;
}
// we need to find the image of the riddle
Image image = RiddleFragment.ALL_IMAGES.get(riddle.mCore.getImageHash());
if (image == null) {
Log.e("Riddle", "Image not available for riddle " + riddle);
listener.onError(null, riddle); // image not available, this can happen if an extern image is deleted while this riddle is still unsolved
// we need to get rid of this riddle
return;
}
allUnsolvedRiddles.remove(riddleIndex);
allUnsolvedRiddles.add(riddle); // add to the end (list changes are backed by riddle manager's list)
Dimension canvasDim = new Dimension(maxCanvasDim);
canvasDim.fitInsideWithRatio(riddle.getType().getSuggestedCanvasAspectRatio());
Dimension bitmapDim = new Dimension(canvasDim);
bitmapDim.fitInsideWithRatio(riddle.getType().getSuggestedBitmapAspectRatio()); // and then the bitmap inside the canvas
mListener = listener;
mContext = context;
mType = riddle.getType();
mBitmapDimension = bitmapDim;
mMakeRiddleConfig = new RiddleConfig(canvasDim.getWidth(), canvasDim.getHeight());
mMakeRiddleConfig.mScreenDensity = screenDensity;
mMakeRiddleConfig.setAchievementData(riddle.getType());
Log.d("Image", "Remaking old riddle " + riddle + " and image " + image + " and bitmapDim " + bitmapDim);
mWorker = new Worker(riddle, image);
mWorker.execute();
}
/**
* Opens a specific image riddle bypassing the selection system. No parameter must be null, this RiddleMaker must not be running or
* an exception will be thrown in either case.
* @param context A context.
* @param image The image to use.
* @param type The type of the riddle
* @param maxCanvasDim The maximum dimension of the riddle's canvas.
* @param screenDensity The density of the device.
* @param listener A listener that is notified about progress, failure and success.
*/
public void makeSpecific(Context context, Image image, PracticalRiddleType type, Dimension maxCanvasDim, int screenDensity, RiddleMakerListener listener) {
if (context == null || maxCanvasDim == null || listener == null || image == null || type == null) {
throw new IllegalArgumentException("No context, riddle type or listener or canvas dim or image given.");
}
if (isRunning()) {
throw new IllegalStateException("Already making riddle.");
}
Dimension canvasDim = new Dimension(maxCanvasDim);
canvasDim.fitInsideWithRatio(type.getSuggestedCanvasAspectRatio());
Dimension bitmapDim = new Dimension(canvasDim);
bitmapDim.fitInsideWithRatio(type.getSuggestedBitmapAspectRatio()); // and then the bitmap inside the canvas
mListener = listener;
mContext = context;
mType = type;
mBitmapDimension = bitmapDim;
mMakeRiddleConfig = new RiddleConfig(canvasDim.getWidth(), canvasDim.getHeight());
mMakeRiddleConfig.mScreenDensity = screenDensity;
mMakeRiddleConfig.setAchievementData(type);
Log.d("Image", "Cheating a riddle for image " + image + " and bitmapDim " + bitmapDim);
mWorker = new Worker(null, image);
mWorker.execute();
}
/**
* Starts making a new riddle. No parameter must be null, this RiddleMaker must not be running or
* an exception will be thrown in either case.
* @param context A context.
* @param type The type a new InitializedRiddle is wanted for.
* @param maxCanvasDim The maximum dimension of the riddle's canvas.
* @param screenDensity The density of the device.
* @param listener A listener that is notified about progress, failure and success.
*/
public void makeNew(Context context, PracticalRiddleType type, Dimension maxCanvasDim, int screenDensity, RiddleMakerListener listener) {
if (listener == null || context == null || type == null || maxCanvasDim == null) {
throw new IllegalArgumentException("No context, riddle type or listener or canvas dim given.");
}
if (isRunning()) {
throw new IllegalStateException("Already making riddle.");
}
Dimension canvasDim = new Dimension(maxCanvasDim);
canvasDim.fitInsideWithRatio(type.getSuggestedCanvasAspectRatio()); // the canvas needs to fit in
Dimension bitmapDim = new Dimension(canvasDim);
bitmapDim.fitInsideWithRatio(type.getSuggestedBitmapAspectRatio()); // and then the bitmap inside the canvas
Log.d("Image", "Making new riddle for type " + type + " and bitmapDim " + bitmapDim);
mAllImages = new LinkedList<>(RiddleFragment.ALL_IMAGES.values());
mUsedImagesForTypes = RiddleInitializer.INSTANCE.makeUsedImagesCopy();
mBitmapDimension = bitmapDim;
mMakeRiddleConfig = new RiddleConfig(canvasDim.getWidth(), canvasDim.getHeight());
mMakeRiddleConfig.mScreenDensity = screenDensity;
mMakeRiddleConfig.setAchievementData(type);
mContext = context;
mType = type;
mListener = listener;
mWorker = new Worker();
mWorker.execute();
}
/**
* The task that is doing all the work assembling and loading all required data. Steps
* of building an InitializedRiddle is described within the doInBackground method.
* Cancelling will not invoke the listener's onError method.
*/
private class Worker extends AsyncTask<Void, Integer, RiddleGame> implements PercentProgressListener {
private Riddle mUseRiddle;
private Image mUseImage;
/**
* A worker than searches for an image for the given type. This only fails if the the bitmap
* for the image got deleted (external image).
*/
public Worker() {}
/**
* Creates a worker that attempts to use the given image and riddle. If a parameter
* is null then an image/riddle is trying to be found and loaded for the set type.
* No checks are done if the image and riddle match.
* @param riddle A riddle to use for the image. Its core should belong to the given image.
* @param image The image to use.
*/
public Worker(Riddle riddle, Image image) {
mUseRiddle = riddle;
mUseImage = image;
}
@Override
public void onCancelled(RiddleGame nothing) {
onClose();
}
@Override
protected RiddleGame doInBackground(Void... voids) {
// Step0: See if we already know an image and if yes use it.
if (mUseImage == null) {
// Step1: Find an image for the type.
mUseImage = findImage(mType, mAllImages, mUsedImagesForTypes);
}
// Step2: Check if everything is fine and if yes make the basic riddle for the image.
if (isCancelled()) {
return null;
} else if (mUseImage == null) {
// we didn't find an image, no images available?!
Log.e("Riddle", "Making riddle did not find an image for type " + mType + " and all images: " + mAllImages);
return null;
}
if (mUseRiddle == null) {
mUseRiddle = new Riddle(mUseImage.getHash(), mType, null);
publishProgress(PROGRESS_FOUND_IMAGE_AND_MADE_BASIC_RIDDLE);
}
// Step3: Check if everything is fine and if yes load the image's bitmap.
if (isCancelled()) {
return null;
}
Bitmap bitmap = mUseImage.loadBitmap(mContext, mBitmapDimension, mType.enforcesBitmapAspectRatio());
publishProgress(PROGRESS_LOADED_IMAGE_BITMAP);
// Step4: Check if everything is fine and if yes create and initialize the final riddle.
if (isCancelled()) {
return null;
} else if (bitmap == null) {
// we didn't find the bitmap for the image?!
return null;
}
RiddleGame riddleGame = mType.makeRiddle(mUseRiddle, mUseImage, bitmap, mContext.getResources(), mMakeRiddleConfig, Worker.this);
publishProgress(PROGRESS_INITIALIZED_RIDDLE_BITMAP);
if (isCancelled()) {
riddleGame.close();
return null;
}
return riddleGame;
}
@Override
public void onProgressUpdate(Integer... progress) {
if (mListener != null) {
mListener.onProgressUpdate(progress[0]);
}
}
@Override
public void onProgressUpdate(int progress) {
publishProgress((PROGRESS_LOADED_IMAGE_BITMAP
+ (PROGRESS_INITIALIZED_RIDDLE_BITMAP - PROGRESS_LOADED_IMAGE_BITMAP) * progress / PercentProgressListener.PROGRESS_COMPLETE));
}
@Override
public void onPostExecute(RiddleGame riddle) {
RiddleMakerListener listener = mListener;
onClose(); //closing removes listener, so keep a temp reference, close before calling ready
if (riddle != null) {
listener.onProgressUpdate(PercentProgressListener.PROGRESS_COMPLETE);
listener.onRiddleReady(riddle);
} else {
listener.onError(mUseImage, mUseRiddle);
}
}
}
/**
* Searches for an image for the given type in the collection of given images. The used images for type
* mapping is used to sort the collection by putting a focus on offering new images that fit the riddle type.
* @param type The type.
* @param allImages The image collection to search.
* @param usedImagesForType A mapping of already used images for all riddle types.
* @return An image. Will only be null if the given collection of images was empty.
*/
private Image findImage(PracticalRiddleType type, Collection<Image> allImages, Map<RiddleType, Set<String>> usedImagesForType) {
//assume all available images already loaded and used images for riddles loaded
/*
* Attributes for all images:
* I = interest in image, R = refusal to image
* 1: I>0, R=0 2: I=0, R=0 3: R>0
* A: Not yet used, B: Used but not for type, C: already used for type
* Sort by categories like 1A|2A|1B|2B|1C|2C|3A|3B|3C
* Sort each category descending by I-R
* Pick first available
*/
//ABC defined by usedImagesForType
//calculate INTEREST I
final Map<String, Integer> IMAGE_INTEREST = new HashMap<>(allImages.size());
for (Image image : allImages) {
IMAGE_INTEREST.put(image.getHash(), type.calculateInterest(image));
}
//calculate REFUSAL R
final Map<String, Integer> IMAGE_REFUSAL = new HashMap<>(allImages.size());
for (Image image : allImages) {
IMAGE_REFUSAL.put(image.getHash(), type.calculateRefusal(image));
}
// init categories
final int categoriesCount = 9;
List<List<Image>> categories = new ArrayList<>(categoriesCount);
for (int i = 0; i < categoriesCount; i++) {
categories.add(new ArrayList<Image>());
}
final int CategoryAIndex1 = 0;
final int CategoryAIndex2 = 1;
final int CategoryBIndex1 = 2;
final int CategoryBIndex2 = 3;
final int CategoryCIndex1 = 4;
final int CategoryCIndex2 = 5;
final int CategoryAIndex3 = 6;
final int CategoryBIndex3 = 7;
final int CategoryCIndex3 = 8;
// fill categories
for (Image image : allImages) {
String key = image.getHash();
int interest = IMAGE_INTEREST.get(key);
int refusal = IMAGE_REFUSAL.get(key);
// calc 1,2,3,A,B,C for current image, easy from this point here
boolean isOne = interest > 0 && refusal == 0;
boolean isTwo = interest == 0 && refusal == 0;
boolean isThree = refusal > 0;
boolean isA = true;
boolean isB = false;
//boolean isC = false;
for (RiddleType otherType : usedImagesForType.keySet()) {
if (usedImagesForType.get(otherType).contains(key)) {
isA = false; // some type used this image
isB = true; // we assume only other types used the image
if (otherType.equals(type)) {
isB = false; // assumption wrong, this type already used this image
break;
}
}
}
//isC = !isB
// put into category, 1A|2A|1B|2B|1C|2C|3A|3B|3C
int catIndex;
if (isOne && isA) {
catIndex = CategoryAIndex1;
} else if (isTwo && isA) {
catIndex = CategoryAIndex2;
} else if (isOne && isB) {
catIndex = CategoryBIndex1;
} else if (isTwo && isB) {
catIndex = CategoryBIndex2;
} else if (isOne) { // and isC
catIndex = CategoryCIndex1;
} else if (isTwo) { // and isC
catIndex = CategoryCIndex2;
} else if (isThree && isA) {
catIndex = CategoryAIndex3;
} else if (isThree && isB) {
catIndex = CategoryBIndex3;
} else {
catIndex = CategoryCIndex3;
}
categories.get(catIndex).add(image);
}
// sort categories
for (int i = 0; i < categoriesCount; i++) {
Collections.sort(categories.get(i), new Comparator<Image>() {
@Override
public int compare(Image image1, Image image2) {
int interestRefusal1 = IMAGE_INTEREST.get(image1.getHash()) - IMAGE_REFUSAL.get(image1.getHash());
int interestRefusal2 = IMAGE_INTEREST.get(image2.getHash()) - IMAGE_REFUSAL.get(image2.getHash());
// the image with the higher value is considered to be better fitting and needs to get to the front of the list
return interestRefusal2 - interestRefusal1;
}
});
}
for (int i = 0; i < categoriesCount; i++) {
Log.d("Riddle", "Category " + i + " for type " + type + " = " + categories.get(i));
}
// find the image
for (int i = 0; i < categoriesCount; i++) {
// pick one at random, but make it more likely to pick first one available since everything is sorted
if (!categories.get(i).isEmpty()) {
if (i == CategoryAIndex3 || i == CategoryBIndex3 || i == CategoryCIndex3) {
mMakeRiddleConfig.mHintImageRefused = true;
}
if (i == CategoryBIndex1 || i == CategoryBIndex2 || i == CategoryBIndex3) {
mMakeRiddleConfig.mHintImageReusedOtherType = true;
}
if (i == CategoryCIndex1 || i == CategoryCIndex2 || i == CategoryCIndex3) {
// is C, use uniform distribution to get an image
mMakeRiddleConfig.mHintImageReused = true;
return categories.get(i).get(mRandom.nextInt(categories.get(i).size()));
} else {
// use inversion method and an exponentially distributed pseudorandom number
double rand = mRandom.nextDouble();
rand = (-Math.log(1.0 - rand) / PICK_DISTRIBUTION_EXP_LAMBDA);
int number = Math.max(0, Math.min((int) rand, categories.get(i).size() - 1));
return categories.get(i).get(number);
}
}
}
return null; // no images given!
}
}