/* * 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.Context; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.Color; import android.text.TextUtils; import android.util.Log; import java.io.File; import java.math.BigInteger; import java.util.ArrayList; import java.util.List; import dan.dit.whatsthat.preferences.Language; import dan.dit.whatsthat.preferences.Tongue; import dan.dit.whatsthat.riddle.Riddle; import dan.dit.whatsthat.riddle.RiddleInitializer; import dan.dit.whatsthat.riddle.types.PracticalRiddleType; import dan.dit.whatsthat.solution.Solution; 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.FixedRandom; import dan.dit.whatsthat.util.image.ImageUtil; public class ImageObfuscator { public static final int IS_OBFUSCATED_HINT = 0x00000001; // not stored in pixel, do not start with FF private static final double BRIGHTNESS_THRESHOLD = 0.5; // threshold when a pixel is considered to be bright private static final int VERSION_1 = 0xFF000001; private static final int VERSION_NUMBER = VERSION_1; // Version number the hidden image was created with, stored in pixel, start with FF!! private static final int HIDDEN_IMAGE_IDENTIFIER_ID = 0xFFFDCDAD; // random identifier to tell if this (probably) was a valid hidden image, stored in pixel, start with FF!! private static final int RESULT_REGISTRATION_FAILED_INVALID_ID = -1; private static final int RESULT_REGISTRATION_FAILED_NOT_RESTORABLE = -2; private static final int RESULT_REGISTRATION_FAILED_COULDNT_SAVE_IMAGE_1 = -3; private static final int RESULT_REGISTRATION_FAILED_COULDNT_SAVE_IMAGE_2 = -4; private static final int RESULT_REGISTRATION_FAILED_COULDNT_SAVE_IMAGE_3 = -5; private static final int RESULT_REGISTRATION_FAILED_IMAGE_BUILDING = -6; private static final int RESULT_REGISTRATION_FAILED = -7; private static final int RESULT_REGISTRATION_FAILED_COULDNT_SAVE_IMAGE = -8; public static final int RESULT_REGISTRATION_SUCCESS_NO_RIDDLE = 0; public static final int RESULT_REGISTRATION_SUCCESS_WITH_RIDDLE = 1; public static final String FILE_EXTENSION = ".wte.png"; // needs also to be changed in manifest, but better never change private static void addMetadataToImage(PracticalRiddleType preferredType, int[][] raster) { // corner pixels are unused and can be used to store some data, use full alpha so there is no bit // of information lost for the color bits! //Version number raster[0][0] = VERSION_NUMBER; //id to identify it 'really' is a hidden image created by this program raster[raster.length-1][raster[0].length-1] = HIDDEN_IMAGE_IDENTIFIER_ID; // if available, the preferred type to play this image with if (preferredType != null) { raster[0][raster[0].length - 1] = 0xFF000000 | preferredType.getId(); } else { raster[0][raster[0].length - 1] = 0xFF000000 | PracticalRiddleType.NO_ID; } } public static String toHex(String arg) { return String.format("%040x", new BigInteger(1, arg.getBytes())); } public static String convertHexToString(String hex) { return new String(new BigInteger(hex, 16).toByteArray()); } private static String getHexFromRaster(int[][] raster, int startRow, int lastRow, int startColumn, int lastColumn) { String result = ""; // cannot use a string builder as we need to add letters before current string final int count = (lastRow - startRow + 1) * (lastColumn - startColumn + 1); int row = startRow; int column = startColumn; for (int i = 0; i < count; i++) { int number = raster[row][column]; String asHex = Integer.toHexString(number & 0xFFFFFF); for (int j = asHex.length(); j < 3; j++) { asHex = "0" + asHex; // leading zeros might have been cut, so add them before } result = asHex + result; if (column < lastColumn) { column++; } else { column = startColumn; row++; } } return result; } private static boolean addHexToRaster(String hex, int[][] raster, int startRow, int lastRow, int startColumn, int lastColumn) { int currIndex = hex.length(); final int count = (lastRow - startRow + 1) * (lastColumn - startColumn + 1); int row = startRow; int column = startColumn; for (int i = 0; i < count; i++) { int currNumber = 0xFF000000; raster[row][column] = currNumber; // clear pixel if (currIndex > 0) { int endIndex = currIndex; currIndex = Math.max(endIndex - 3, 0); // at most 24 bits that fit into the RGB part of the raster pixels currNumber |= Integer.parseInt(hex.substring(currIndex, endIndex), 16); raster[row][column] = currNumber; if (column < lastColumn) { column++; } else { column = startColumn; row++; } } } // if currIndex>0 hash longer than image raster, we cannot fit the hash into the pixels return currIndex <= 0; } private static boolean addImagedataToImage(Image image, int[][] raster, String solutionInputData) { // 1. HASH: String hash = image.getHash(); // very important info as this identifies the image (if already known to recipient) if (hash == null) { return false; // illegal image } Log.d("Image", "Adding imagedata for image with hash " + hash + " and raster size " + raster.length + " on " + raster[0].length); if (!addHexToRaster(hash, raster, 1, raster.length - 2, 0, 0)) { Log.d("Image", "Failed adding hash to raster: " + hash); return false; } // 2. SOLUTION WORD IN USER LANGUAGE (or any available language): Tongue tongue = Language.getInstance().getTongue(); Solution solution = image.getSolution(tongue); Compacter wordsCmp = new Compacter(); wordsCmp.appendData(tongue.getShortcut()); for (String word : solution.getWords()) { wordsCmp.appendData(word); } String words = wordsCmp.compact(); String wordsHex = toHex(words); if (!addHexToRaster(wordsHex, raster, 1, raster.length - 2, raster[0].length - 1, raster[0].length - 1)) { Log.d("Image", "Failed adding wordsHex to raster: " + wordsHex); return false; } // 3. IMAGE ORIGIN AND AUTHOR (at least the name, maybe source) ImageAuthor author = image.getAuthor(); Compacter imageOriginAuthorCmp = new Compacter(2); imageOriginAuthorCmp.appendData(image.getOrigin()); imageOriginAuthorCmp.appendData(author.compact()); String imageOriginAuthorHex = toHex(imageOriginAuthorCmp.compact()); if (!addHexToRaster(imageOriginAuthorHex, raster, 0, 0, 1, raster[0].length - 2)) { Log.d("Image", "Failed adding authorHex to raster: " + imageOriginAuthorHex); // but we do not fail in this case } else { Log.d("Image", "Added imageoriginauthor data: " + imageOriginAuthorCmp); } // 4. SOLUTION DATA (so that the same solution input is created for everyone) // if something is given we need to add all, no partial data allowed if (!TextUtils.isEmpty(solutionInputData)) { String solutionDataHex = toHex(solutionInputData); if (!addHexToRaster(solutionDataHex, raster, raster.length - 1, raster.length - 1, 1, raster[0].length - 2)) { Log.d("Image", "Failed adding solutionInputData to raster: " + solutionDataHex); return false; } } return true; } private static int extractMetadataFromImagePreferredRiddle(int[][] raster) { return raster[0][raster[0].length - 1] & 0xFFFFFF; } private static int extractMetadataFromImageVersionNumber(int[][] raster) { return raster[0][0]; } private static int extractMetadataFromImageIdentifierId(int[][] raster) { return raster[raster.length-1][raster[0].length-1]; // fixed indices } public static int registerObfuscated(Context context, Bitmap obfuscated, String obfuscatedFileName) { if (context == null || obfuscated == null) { return RESULT_REGISTRATION_FAILED; } int[][] raster = new int[obfuscated.getHeight()][obfuscated.getWidth()]; for (int x = 0; x < obfuscated.getWidth(); x++) { for (int y = 0; y < obfuscated.getHeight(); y++) { raster[y][x] = obfuscated.getPixel(x, y); } } int id = extractMetadataFromImageIdentifierId(raster); if (id != HIDDEN_IMAGE_IDENTIFIER_ID) { return RESULT_REGISTRATION_FAILED_INVALID_ID; } if (TextUtils.isEmpty(obfuscatedFileName)) { obfuscatedFileName = "-_" + System.currentTimeMillis(); } int fileNameSepIndex = obfuscatedFileName.indexOf('_'); String riddleOrigin; if (fileNameSepIndex != -1) { riddleOrigin = obfuscatedFileName.substring(0, fileNameSepIndex); } else { riddleOrigin = "-"; } String solutionInputData = convertHexToString(getHexFromRaster(raster, raster.length - 1, raster.length - 1, 1, raster[0].length - 2)); Compacter imageOriginAndAuthorCmp = new Compacter(convertHexToString(getHexFromRaster (raster, 0, 0, 1, raster[0].length - 2))); Log.d("Image", "READ IMAGEORIGINANDAUTHOR DATA: " + imageOriginAndAuthorCmp); String imageOrigin = imageOriginAndAuthorCmp.getSize() == 0 ? Image .ORIGIN_IS_EXTERNAL_OBFUSCATED : imageOriginAndAuthorCmp.getData(0); String authorData = imageOriginAndAuthorCmp.getSize() <= 1 ? "" : imageOriginAndAuthorCmp .getData(1); int preferredRiddleId = extractMetadataFromImagePreferredRiddle(raster); PracticalRiddleType preferredType = null; for (PracticalRiddleType type : PracticalRiddleType.ALL_PLAYABLE_TYPES) { if (type.getId() == preferredRiddleId) { preferredType = type; } } String path = ExternalStorage.getExternalStoragePathIfMounted(Image.IMAGES_DIRECTORY_NAME); if (path == null) { return RESULT_REGISTRATION_FAILED_COULDNT_SAVE_IMAGE_1; } File file = new File(path + File.separator + imageOrigin); if (!file.isDirectory() && !file.mkdirs()) { Log.e("Image", "Not a directory and couldnt make it to one: " + file); return RESULT_REGISTRATION_FAILED_COULDNT_SAVE_IMAGE_2; } file = new File(file, obfuscatedFileName); if (!ImageUtil.saveToFile(obfuscated, file, Bitmap.CompressFormat.PNG, 100)) { Log.e("Image", "Could not save to file: " + file + " obfuscated: " + obfuscated); return RESULT_REGISTRATION_FAILED_COULDNT_SAVE_IMAGE_3; } Bitmap restored = restoreImage(obfuscated); if (restored == null) { return RESULT_REGISTRATION_FAILED_NOT_RESTORABLE; } Image.Builder builder = new Image.Builder(); builder.calculateHashAndPreferences(new BitmapUtil.ByteBufferHolder(), restored); if (preferredType != null) { builder.addPreferredRiddleType(preferredType); } try { Compacter cmp = new Compacter(authorData); for (int i = cmp.getSize(); i < ImageAuthor.REQUIRED_DATA_FIELDS_COUNT; i++) { cmp.appendData("-"); } builder.setAuthor(new ImageAuthor(cmp)); } catch (CompactedDataCorruptException e) { Log.e("Image", "Compacted author data from raster corrupt: " + e); } Compacter cmp = new Compacter(convertHexToString(getHexFromRaster(raster, 1, raster.length - 2, raster[0].length - 1, raster[0].length - 1))); if (cmp.getSize() == 0) { cmp.appendData(Tongue.ENGLISH.getShortcut()); } if (cmp.getSize() == 1) { cmp.appendData("?"); } Tongue tongue = Tongue.getByShortcut(cmp.getData(0)); List<String> words = new ArrayList<>(cmp.getSize() - 1); for (int i = 1; i < cmp.getSize(); i++) { words.add(cmp.getData(i)); } builder.addSolution(new Solution(tongue, words)); int obfuscationVersionId = extractMetadataFromImageVersionNumber(raster); builder.setObfuscation(obfuscationVersionId); builder.setRelativeImagePath(obfuscatedFileName); builder.setOrigin(imageOrigin); Image image; try { image = builder.build(); } catch (BuildException be) { Log.e("Image", "Failed registering obfuscated image when building: " + be); return RESULT_REGISTRATION_FAILED_IMAGE_BUILDING; } Log.d("Image", "Created image: " + image + " origin " + image.getOrigin() + " obfuscation " + image.getObfuscation() + " solutions " + image.getSolutions() + " author " + image.getAuthor()); if (!image.saveToDatabase(context)) { return RESULT_REGISTRATION_FAILED_COULDNT_SAVE_IMAGE; } if (preferredType != null && riddleOrigin != null && !riddleOrigin.equalsIgnoreCase(Image.ORIGIN_IS_THE_APP)) { // create an unfinished riddle if there is a valid origin given which is not the app itself RiddleInitializer.INSTANCE.checkedIdsInit(context); Log.d("Image", "Creating riddle for loaded obfuscated image: " + image + " type: " + preferredType + " origin: " + riddleOrigin); Riddle riddle = new Riddle(image.getHash(), preferredType, riddleOrigin, solutionInputData); if (riddle.saveToDatabase(context)) { Riddle.saveLastVisibleRiddleId(context, riddle.getId()); Riddle.saveLastVisibleRiddleType(context, preferredType); if (!RiddleInitializer.INSTANCE.isNotInitialized()) { RiddleInitializer.INSTANCE.getRiddleManager().onUnsolvedRiddle(riddle); } return RESULT_REGISTRATION_SUCCESS_WITH_RIDDLE; } } return RESULT_REGISTRATION_SUCCESS_NO_RIDDLE; } /** * Creates an obfuscated image from the original bitmap including the given logo. * The original image will not be humanly readable, humans will only be able to recognize the * logo. * Keep it mind that the restored image is only almost equal to the original image and the hash * of restored and original will not match. * @param image Image to obfuscate. * @param logoSource The required logo to include. * @param solutionInputData The compacted solution input data currently visible to the user. * Will be used to recreate the image and show the same letters to * everyone. * @return An obfuscated version of the original image with only the logo visible. Can be restored * to be almost equal to the original image. Can be null if loading the image's bitmap fails. */ public static Bitmap makeHidden(Resources res, Image image, PracticalRiddleType prefferedType, Logo logoSource, String solutionInputData) { Bitmap original = image.loadBitmap(res, new Dimension(0, 0), true); if (original == null) { return null; } Log.d("Image", "Making hidden: " + image + " origin " + image.getOrigin() + " obfuscation " + image.getObfuscation() + " solutions " + image.getSolutions() + " author " + image.getAuthor()); Bitmap logo = logoSource.getSized(original.getWidth(), original.getHeight()); int hiddenHeight = original.getHeight() + 2; int hiddenWidth = original.getWidth() + 2; //extract image data for easier working int[][] raster = new int[hiddenHeight][hiddenWidth]; for (int x=0;x<original.getWidth();x++) { for (int y=0;y<original.getHeight();y++) { raster[y + 1][x + 1]=original.getPixel(x, y); } } // do the transformation, we loose very little information and do not need to save a bigger picture for it // the extra lines are for the purpose of storing identifying and metadata if needed // Step1: Fixed permutation, one transposition for each pixel of the original image FixedRandom random = new FixedRandom(); for (int y=1;y<raster.length - 1;y++) { for (int x=1;x<raster[y].length - 1;x++) { int currRGB = raster[y][x]; int tarX=random.next(raster[y].length - 3) + 1; // x in [1,image width - 1] int tarY=random.next(raster.length - 3) + 1; // y in [1, image height - 1] raster[y][x]=raster[tarY][tarX]; raster[tarY][tarX]=currRGB; } } //Step2: Make all pixels not in the logo darker and the logo pixel brighter for (int y=1;y<raster.length - 1;y++) { for (int x=1;x<raster[y].length - 1;x++) { int rgb = raster[y][x]; int logoRgb=logo.getPixel(x - 1, y - 1); int red = Color.red(rgb); int green = Color.green(rgb); int blue = Color.blue(rgb); int alpha = Color.alpha(rgb); //Make some assumptions to improve and allow us to manipulate the image without needing much extra memory. // We will lose the 4 least significant bits for alpha, we cannot use other colors since Bitmaps store these values // in premultiplied alpha format, which will make getPixel(setPixel(rgb)) != rgb because of rounding errors. // Additional bits can be sacrificed to get the alpha even higher or to adjust the greyness of the pixels, but this results // in restored obfuscated images with lots of different alpha to become significantly different from their original image. alpha = (alpha/8)*8+7; // assume alpha mod 8 == 7 (to leave 255 (no alpha) the same)!! //increase alpha as much as possible with mostly having a delta of 4 to the original image and leaving alpha=255 as is // stores information in bits 1 and 2 of alpha int alphaFactor = 3-alpha/64; // 0, 1, 2 or 3 alpha += alphaFactor * 64 - alphaFactor; /*//Move the color by the line 64/128/256 (arbitary multiples of 2) and maximize the greyness to get a better contrast // for images with pixels that have strong colors and little grey; stores information in bit4 of alpha int maxGreynessIndex = 0; double maxGreyness = Double.MAX_VALUE; for (int i=0; i < 2; i++) { double greyness = ColorAnalysisUtil.getGreyness(red + 64*i, green + 128*i, blue); if (greyness < maxGreyness) { maxGreynessIndex = i; maxGreyness = greyness; } } red+=(64*maxGreynessIndex)%256;red%=256; green+=(128*maxGreynessIndex)%256;green%=256; //blue+=(256*maxGreynessIndex)%256;blue%=256; alpha-= 8*maxGreynessIndex; */ // make the logo by changing pixel brightness accordingly, storing this information in bit3 of alpha raster[y][x]=rgb=ColorAnalysisUtil.toRGB(red, green, blue, alpha); boolean pixelVeryBright = ColorAnalysisUtil.getBrightnessNoAlpha(rgb) > BRIGHTNESS_THRESHOLD; boolean insideLogo = ColorAnalysisUtil.getBrightnessWithAlpha(logoRgb) <= logoSource.getThreshold(); if ((!pixelVeryBright && insideLogo) || (pixelVeryBright && !insideLogo)) { // It is a pixel of the logo and it currently is too dark or // it is a pixel not in the logo and it is too bright red = 255 - red; green = 255 - green; blue = 255 - blue; alpha -= 4; } raster[y][x] = ColorAnalysisUtil.toRGB(red, green, blue, alpha); } } // end transformation // include the metadata addMetadataToImage(prefferedType, raster); if (!addImagedataToImage(image, raster, solutionInputData)) { return null; } Bitmap hidden = Bitmap.createBitmap(hiddenWidth, hiddenHeight, original.getConfig()); hidden.setHasAlpha(true); // draw the raster in the new image for (int y=0;y<raster.length;y++) { for (int x=0;x<raster[y].length;x++) { hidden.setPixel(x, y, raster[y][x]); } } return hidden; } public static Bitmap restoreImage(Bitmap hidden) { if (hidden.getHeight() < 3 || hidden.getWidth() < 3) { return null; // not a valid hidden image } // must not use the logo! Then we can restore images from unknown logo sources and have the logo customizable //extract image data for easier working int[][] raster = new int[hidden.getHeight()][hidden.getWidth()]; for (int x=0;x<hidden.getWidth();x++) { for (int y=0;y<hidden.getHeight();y++) { raster[y][x]=hidden.getPixel(x, y); } } if (extractMetadataFromImageIdentifierId(raster) != HIDDEN_IMAGE_IDENTIFIER_ID) { return null; } // Choose restoration algorithm by version number. if (extractMetadataFromImageVersionNumber(raster) != VERSION_1) { return null; // cannot restore that version } // Revert transformation // Revert Step2: //(Step2: Make all pixels not in the logo darker and the logo pixel brighter) for (int y=1;y<raster.length - 1;y++) { for (int x=1;x<raster[y].length - 1;x++) { int rgb = raster[y][x]; int red = Color.red(rgb); int green = Color.green(rgb); int blue = Color.blue(rgb); int alpha = Color.alpha(rgb); if ((alpha&4)==0) { red = 255 - red; green = 255 - green; blue = 255 - blue; alpha += 4; } /*int greynessIndex = 1 - (alpha&8)/8; red+=((256-64)*greynessIndex)%256;red%=256; green+=((256-128)*greynessIndex)%256;green%=256; //blue+=((256-256)*greynessIndex)%256;blue%=256; */ int alphaFactor = 3 - alpha % 4; alpha -= alphaFactor * 64 - alphaFactor; raster[y][x]=ColorAnalysisUtil.toRGB(red, green, blue, alpha); } } //Revert Step1: // (Step1: Fixed permutation, one transposition for each pixel of the original image) FixedRandom random = new FixedRandom(); int permutations = (raster.length - 2) * (raster[0].length - 2); int[] tarX = new int[permutations]; int[] tarY = new int[permutations]; for (int i = 0; i < permutations; i++) { tarX[i] = random.next(raster[0].length - 3) + 1; // x in [1,image width - 1] tarY[i] = random.next(raster.length - 3) + 1; // y in [1, image height - 1] } for (int i = permutations - 1; i >= 0; i--) { int x = i % (raster[0].length - 2) + 1; int y = i / (raster[0].length - 2) + 1; int currRGB = raster[y][x]; int tarXCurr=tarX[i]; int tarYCurr=tarY[i]; raster[y][x]=raster[tarYCurr][tarXCurr]; raster[tarYCurr][tarXCurr]=currRGB; } // End Revert transformation Bitmap original = Bitmap.createBitmap(hidden.getWidth() - 2, hidden.getHeight() - 2, hidden.getConfig()); original.setHasAlpha(true); // draw the raster in the new image for (int y=1;y<raster.length - 1;y++) { for (int x=1;x<raster[y].length - 1;x++) { original.setPixel(x - 1, y - 1, raster[y][x]); } } return original; } public static boolean checkIfValidObfuscatedImage(Bitmap image) { return image != null && image.getPixel(image.getWidth() - 1, image.getHeight() - 1) == HIDDEN_IMAGE_IDENTIFIER_ID; } }