DatabaseImageCache.java

/*
 * Copyright 2016 the Cook-E development team
 *
 * This file is part of Cook-E.
 *
 * Cook-E is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Cook-E is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Cook-E.  If not, see <http://www.gnu.org/licenses/>.
 */

package org.cook_e.data;

import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.database.sqlite.SQLiteOpenHelper;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.util.Log;

import java.io.ByteArrayOutputStream;
import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.zip.CRC32;

/**
 * An image cache that uses local SQLite databases
 */
public class DatabaseImageCache implements ImageCache {

    /**
     * The tag used for logging
     */
    private static final String TAG = DatabaseImageCache.class.getSimpleName();

    /**
     * The format used for compression
     */
    private static final Bitmap.CompressFormat FORMAT = Bitmap.CompressFormat.PNG;

    /**
     * The compression quality level, 0-100 (ignored for PNG)
     */
    private static final int QUALITY = 100;


    /**
     * The helper used for database access
     */
    private final SQLiteOpenHelper mHelper;

    public DatabaseImageCache(Context context) {
        mHelper = new ImageOpenHelper(context);
    }

    @Override
    public long put(Bitmap image) throws CacheException {
        Objects.requireNonNull(image, "image must not be null");
        // Compress image into a byte array to calculate the CRC
        final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        final CRCOutputStream crcStream = new CRCOutputStream(bytes);
        final boolean compressResult = image.compress(FORMAT, QUALITY, crcStream);
        if (!compressResult) {
            throw new CacheException("Failed to compress image");
        }

        final long crc = crcStream.getCrc();
        // Check for existing image
        try {
            final SQLiteDatabase db = mHelper.getWritableDatabase();
            try {
                db.beginTransaction();
                // Select reference count
                final Cursor result = db.query(ImageOpenHelper.TABLE_NAME, array("refcount"),
                        "crc = ?", array(Long.toString(crc)), null, null, null);
                try {

                    if (result.getCount() > 0) {
                        // Increment reference count
                        if (!result.moveToFirst()) {
                            throw new CacheException("Failed to access first result");
                        }
                        final long refCount = result.getLong(result.getColumnIndexOrThrow(
                                "refcount"));

                        // Check reference count value
                        if (refCount == Long.MAX_VALUE) {
                            throw new CacheException("Image reference count is at maximum");
                        }

                        final ContentValues values = new ContentValues();
                        values.put("refcount", refCount + 1);
                        final long count = db.update(ImageOpenHelper.TABLE_NAME, values, "crc = ?",
                                array(Long.toString(crc)));
                        if (count != 1) {
                            throw new CacheException("Failed to update reference count");
                        }
                    } else {
                        // Insert image
                        final ContentValues values = new ContentValues();
                        values.put("crc", crc);
                        values.put("refcount", 1);
                        final byte[] imageBytes = bytes.toByteArray();
                        Log.v(TAG, "Image bytes: length = " + imageBytes.length);
                        values.put("data", imageBytes);
                        final long rowId = db.insert(ImageOpenHelper.TABLE_NAME, null, values);
                        if (rowId == -1) {
                            throw new CacheException("Failed to insert image");
                        }
                    }
                } finally {
                    result.close();
                }
                db.setTransactionSuccessful();
            } finally {
                db.endTransaction();
                db.close();
            }
        } catch (SQLiteException e) {
            throw new CacheException("Database error", e);
        }
        return crc;
    }

    @Override
    public Bitmap get(long id) throws CacheException {
        try {
            final SQLiteDatabase db = mHelper.getReadableDatabase();
            try {
                final Cursor result = db.query(ImageOpenHelper.TABLE_NAME, array("data"),
                        "crc = ?", array(Long.toString(id)), null, null, null);
                try {
                    Log.v(TAG, "Result count: " + result.getCount());
                    Log.v(TAG, "data index: " + result.getColumnIndexOrThrow("data"));
                    if (result.getCount() > 0) {
                        // Read and return image
                        Log.v(TAG, "Initial position: " + result.getPosition());
                        if (!result.moveToFirst()) {
                            throw new CacheException("Could not move to first result");
                        }
                        Log.v(TAG, "Updated position: " + result.getPosition());
                        Log.v(TAG, "Data column type: " + result.getType(result.getColumnIndexOrThrow("data")));
                        final byte[] imageBytes = result.getBlob(result.getColumnIndexOrThrow(
                                "data"));
                        return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.length);
                    } else {
                        // Not found
                        return null;
                    }
                } finally {
                    result.close();
                }
            } finally {
                db.close();
            }
        } catch (SQLiteException e) {
            throw new CacheException("Database error", e);
        }
    }

    @Override
    public Bitmap remove(long id) throws CacheException {
        try {
            final SQLiteDatabase db = mHelper.getWritableDatabase();
            db.beginTransaction();
            try {
                // Search for an existing image
                final Cursor result = db.query(ImageOpenHelper.TABLE_NAME, array("refcount", "data"),
                        "crc = ?", array(Long.toString(id)), null, null, null);
                try {
                    if (result.getCount() > 0) {
                        // Read and return image
                        if (!result.moveToFirst()) {
                            throw new CacheException("Could not move to first result");
                        }
                        final byte[] imageBytes = result.getBlob(result.getColumnIndexOrThrow(
                                "data"));
                        final Bitmap image = BitmapFactory.decodeByteArray(imageBytes, 0,
                                imageBytes.length);
                        decrementReferenceCount(db, id);
                        db.setTransactionSuccessful();
                        return image;
                    } else {
                        // Not found
                        return null;
                    }
                } finally {
                    result.close();
                }
            } finally {
                db.endTransaction();
                db.close();
            }
        } catch (SQLiteException e) {
            throw new CacheException("Database error", e);
        }
    }

    /**
     * Decrements the reference count of the image with the specified CRC, and deletes the image
     * if the reference count reaches 0
     * @param db the database to access
     * @param crc the CRC of the image to manipulate
     */
    private void decrementReferenceCount(SQLiteDatabase db, long crc) throws CacheException {
        // Get current reference count
        final Cursor result = db.query(ImageOpenHelper.TABLE_NAME, array("refcount"), "crc = ?",
                array(Long.toString(crc)), null, null, null);
        try {
            if (result.getCount() > 0) {
                result.moveToFirst();
                // Get and update reference count
                long refCount = result.getLong(result.getColumnIndexOrThrow("refcount"));
                refCount -= 1;

                if (refCount == 0) {
                    // Delete image
                    if (db.delete(ImageOpenHelper.TABLE_NAME, "crc = ?", array(Long.toString(crc))) != 1) {
                        throw new CacheException("Failed to delete image with reference count 0");
                    }
                }
                else {
                    // Update reference count
                    final ContentValues values = new ContentValues();
                    values.put("refcount", refCount);
                    if (db.update(ImageOpenHelper.TABLE_NAME, values, "crc = ?", array(Long.toString(crc))) != 1) {
                        throw new CacheException("Failed to update reference count");
                    }
                }
            }
            else {
                throw new CacheException("No image found with requested ID");
            }
        }
        finally {
            result.close();
        }
    }

    @Override
    public void clear() throws CacheException {
        try {
            final SQLiteDatabase db = mHelper.getWritableDatabase();
            try {
                db.delete(ImageOpenHelper.TABLE_NAME, null, null);
            }
            finally {
                db.close();
            }
        }
        catch (SQLiteException e) {
            throw new CacheException("Database error", e);
        }
    }

    /**
     * Converts an argument list into an array
     * @param values the values to return
     * @param <T> the type of the values
     * @return an array containing the provided values
     */
    @SafeVarargs
    private static <T> T[] array(T... values) {
        return values;
    }


    /**
     * An OpenHelper used to access an image cache database
     */
    private static class ImageOpenHelper extends SQLiteOpenHelper {

        /**
         * The name of the database
         */
        private static final String DATABASE_NAME = "image_cache";
        /**
         * The name of the table
         */
        public static final String TABLE_NAME = "image_cache";
        /**
         * The column key for the image CRC32
         */
        public static final String COL_CRC = "crc";
        /**
         * The column key for the image reference count
         */
        public static final String COL_REFCOUNT = "refcount";
        /**
         * The column key for the image data
         */
        public static final String COL_DATA = "data";
        /**
         * The current database version
         */
        private static final int DATABASE_VERSION = 1;

        public ImageOpenHelper(Context context) {
            super(context, DATABASE_NAME, null, DATABASE_VERSION);
        }

        @Override
        public void onCreate(SQLiteDatabase db) {
            db.execSQL("CREATE TABLE " + TABLE_NAME + "(" + COL_CRC + " INTEGER PRIMARY KEY," +
                    " " + COL_REFCOUNT + " INTEGER NOT NULL DEFAULT 0," +
                    " " + COL_DATA + " BLOB NOT NULL)");
        }

        @Override
        public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
            // Implement later when new versions are introduced
        }
    }

    /**
     * An OutputStream that calculates the CRC32 checksum of the bytes written to it
     */
    private static class CRCOutputStream extends FilterOutputStream {

        /**
         * The CRC calculator
         */
        private CRC32 mCrc;

        /**
         * Creates a CRCOutputStream that wraps another stream
         *
         * @param inner the stream to wrap. Must not be null.
         */
        public CRCOutputStream(OutputStream inner) {
            super(inner);
            mCrc = new CRC32();
        }

        /**
         * Writes a byte to the underlying stream and updates the CRC32 checksum to include
         * the written byte.
         *
         * If the underlying stream throws an exception, the checksum is not updated.
         *
         * @param oneByte the byte to write
         * @throws IOException if an error occurred writing the byte
         */
        @Override
        public void write(int oneByte) throws IOException {
            super.write(oneByte);
            mCrc.update(oneByte);
        }

        /**
         * Returns the CRC32 checksum of the bytes written so far
         *
         * @return the checksum
         */
        public long getCrc() {
            return mCrc.getValue();
        }
    }
}