/* * Overchan Android (Meta Imageboard Client) * Copyright (C) 2014-2016 miku-nyan <https://github.com/miku-nyan> * * This program 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. * * This program 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 this program. If not, see <http://www.gnu.org/licenses/>. */ package nya.miku.wishmaster.cache; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.InputStream; import java.io.OutputStream; import java.util.LinkedList; import java.util.ListIterator; 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.database.sqlite.SQLiteStatement; import android.os.Build; import android.os.Environment; import android.provider.BaseColumns; import nya.miku.wishmaster.common.IOUtils; import nya.miku.wishmaster.common.Logger; import nya.miku.wishmaster.ui.CompatibilityImpl; /** * Общий файловый кэш (LRU) * @author miku-nyan * */ public class FileCache { private static final String TAG = "FileCache"; public static final String PREFIX_ORIGINALS = "orig_"; /*package*/ static final String PREFIX_BITMAPS = "thumb_"; /*package*/ static final String PREFIX_PAGES = "page_"; //не удаляются, если в совокупности занимают менее 10% /*package*/ static final String PREFIX_DRAFTS = "draft_"; /*package*/ static final String PREFIX_BOARDS = "boards_"; //не удаляются никогда /** имя файла для состояния вкладок */ /*package*/ static final String TABS_FILENAME = "tabsstate"; //хранятся в отдельной директории (не кэш) private static final String NOMEDIA = ".nomedia"; //не удаляется никогда private static final float PAGES_QUOTA = 0.1f; private final FileCacheDB database; private final File filesDirectory; private final File directory; private long maxSize; private long maxPagesSize; private volatile long size; private volatile long pagesSize; private volatile boolean initialized = false; private final Object initLock = new Object(); /** * Конструктор * @param context контекст приложения * @param maxSize максимальный размер в байтах (0 - неограниченный) */ public FileCache(Context context, final long maxSize) { this.filesDirectory = getAvailableFilesDir(context); this.directory = getAvailableCacheDir(context); this.database = new FileCacheDB(context); transferTabsState(); //legacy makeDir(); makeNomedia(); Thread initThread = new Thread() { @Override public void run() { long[] sizeInDB = database.getSize(); if (sizeInDB[0] != 0) { FileCache.this.size = sizeInDB[0]; FileCache.this.pagesSize = sizeInDB[1]; } else { resetCache(); } setMaxSizeValues(maxSize); synchronized (initLock) { initialized = true; initLock.notifyAll(); Logger.d(TAG, "File Cache initialized"); } } }; initThread.start(); } private void ensureInitialized() { if (initialized) return; synchronized (initLock) { while (!initialized) { try { initLock.wait(); } catch (Exception e) { Logger.e(TAG, e); } } } } /** * Установить максимальный размер кэша * @param maxSize максимальный размер в байтах (0 - неограниченный) */ public void setMaxSize(long maxSize) { ensureInitialized(); setMaxSizeValues(maxSize); trim(); } private void setMaxSizeValues(long maxSize) { this.maxSize = maxSize; this.maxPagesSize = (long) (maxSize * PAGES_QUOTA); } /** * Получить текущий размер кэша * @return текущий размер в байтах */ public long getCurrentSize() { ensureInitialized(); return size; } /** * Получить текущий размер кэша в мегабайтах * @return текущий размер в мегабайтах */ public double getCurrentSizeMB() { return (double) getCurrentSize() / (1024 * 1024); } /** * Очистить кэш (удалить все файлы) */ public void clearCache() { ensureInitialized(); database.put("_clear_cache", -1); //database will reset if app crash for (File f : filesOfDir(directory)) { if (!isUndeletable(f)) f.delete(); } resetCache(); } /** * Получить директорию для хранения файлов (не являющуюся кэшем). * Данная директория не очищается вместе с кэшем, её размер не контролируется. */ public File getFilesDirectory() { return filesDirectory; } /** * Получить файл из кэша * @param fileName имя файла * @return полученный файл, если файл существует, или null, если файл отсутствует */ public File get(String fileName) { ensureInitialized(); synchronized (this) { File file = pathToFile(fileName); if (file.exists() && !file.isDirectory()) { file.setLastModified(System.currentTimeMillis()); database.touch(fileName); return file; } return null; } } /** * Создать объект для нового файла (если файл с таким именем уже присутствует в кэше, он удаляется). * По окончании действий с файлом (после окончания записи) необходимо вызвать метод {@link #put(File)}, чтобы учесть размер нового файла, * или метод {@link #abort(File)}, чтобы отменить создание файла (удалить файл и запись о нём). * Действия при работе с файлами (при необходимости) нужно синхронизировать дополнительно. * @param fileName имя файла * @return объект типа {@link File} */ public File create(String fileName) { ensureInitialized(); synchronized (this) { makeDir(); File file = pathToFile(fileName); if (file.exists()) { delete(file, false); } database.put(fileName, -1); return file; } } /** * Учитывает размер созданного файла, добавляет к размеру кэша, в случае необходимости удаляются устаревшие файлы. * @param file объект типа {@link File} */ public void put(File file) { ensureInitialized(); synchronized (this) { size += file.length(); if (isPageFile(file)) pagesSize += file.length(); database.put(file.getName(), file.length()); trim(); } } /** * Отменить создание файла, удалить файл и запись в базе данных. * @param file объект типа {@link File} */ public void abort(File file) { ensureInitialized(); synchronized (this) { file.delete(); database.remove(file.getName()); } } /** * Удалить файл из кэша * @param file объект типа {@link File} * @return true, если файл удалён успешно, false в противном случае */ public boolean delete(File file) { return delete(file, true); } private boolean delete(File file, boolean removeFromDB) { ensureInitialized(); synchronized (this) { size -= file.length(); if (isPageFile(file)) pagesSize -= file.length(); if (file.delete()) { if (removeFromDB) database.remove(file.getName()); return true; } else { resetCache(); return false; } } } private File pathToFile(String fileName) { return new File(directory, fileName); } private void makeDir() { if (!directory.exists()) { if (!directory.mkdirs()) { Logger.e(TAG, "Unable to create file cache dir " + directory.getPath()); } } } private void makeNomedia() { try { pathToFile(NOMEDIA).createNewFile(); } catch (Exception e) { Logger.e(TAG, "couldn't create .nomedia file", e); } } private synchronized void trim() { for (int i=0; i<3; ++i) { if (maxSize == 0 || size <= maxSize) return; LinkedList<String> files = database.getFilesForTrim(size - maxSize); while (size > maxSize) { File oldest = null; for (ListIterator<String> it = files.listIterator(); it.hasNext();) { File file = pathToFile(it.next()); if (isPageFile(file) && pagesSize < maxPagesSize) continue; it.remove(); oldest = file; break; } if (oldest == null) { Logger.e(TAG, "No files to trim"); break; } else { Logger.d(TAG, "Deleting " + oldest.getPath()); if (!delete(oldest)) { Logger.e(TAG, "Cannot delete cache file: " + oldest.getPath()); break; } } } } } private synchronized void resetCache() { database.resetDB(); database.insertFiles(filesOfDir(directory)); long[] sizeInDB = database.getSize(); size = sizeInDB[0]; pagesSize = sizeInDB[1]; } private boolean isUndeletable(File file) { return isUndeletable(file.getName()); } private static boolean isUndeletable(String filename) { return filename.startsWith(PREFIX_BOARDS) || filename.equals(NOMEDIA); } private static boolean isPageFile(File file) { return isPageFile(file.getName()); } private static boolean isPageFile(String filename) { //the same condition must be in method FileCacheDB.getSize() (SQL) for the sum calculation return filename.startsWith(PREFIX_PAGES) || filename.startsWith(PREFIX_DRAFTS); } private File[] filesOfDir(File directory) { File[] files = directory.listFiles(); if (files == null) return new File[0]; return files; } private static class FileCacheDB { private static final int DB_VERSION = 1000; private static final String DB_NAME = "filecache.db"; private static final String TABLE_NAME = "files"; private static final String COL_FILENAME = "name"; private static final String COL_FILESIZE = "size"; private static final String COL_TIMESTAMP = "time"; private final DBHelper dbHelper; public FileCacheDB(Context context) { dbHelper = new DBHelper(context); } public boolean isExists(String filename) { Cursor c = dbHelper.getReadableDatabase().query(TABLE_NAME, null, COL_FILENAME + " = ?", new String[] { filename }, null, null, null); boolean result = false; if (c != null && c.moveToFirst()) result = true; if (c != null) c.close(); return result; } public void touch(String filename) { ContentValues cv = new ContentValues(); cv.put(COL_TIMESTAMP, System.currentTimeMillis()); dbHelper.getWritableDatabase().update(TABLE_NAME, cv, COL_FILENAME + " = ?", new String[] { filename }); } public void put(String filename, long size) { ContentValues cv = new ContentValues(); cv.put(COL_FILENAME, filename); cv.put(COL_FILESIZE, size); cv.put(COL_TIMESTAMP, System.currentTimeMillis()); if (isExists(filename)) { dbHelper.getWritableDatabase().update(TABLE_NAME, cv, COL_FILENAME + " = ?", new String[] { filename }); } else { dbHelper.getWritableDatabase().insert(TABLE_NAME, null, cv); } } public void insertFiles(File[] files) { SQLiteDatabase database = dbHelper.getWritableDatabase(); SQLiteStatement statement = database.compileStatement("INSERT INTO " + TABLE_NAME + " (" + COL_FILENAME + ", " + COL_FILESIZE + ", " + COL_TIMESTAMP + ") VALUES (?, ?, ?)"); database.beginTransaction(); try { for (File file : files) { statement.bindString(1, file.getName()); statement.bindLong(2, file.length()); statement.bindLong(3, file.lastModified()); statement.executeInsert(); } database.setTransactionSuccessful(); } finally { database.endTransaction(); } } public LinkedList<String> getFilesForTrim(long size) { LinkedList<String> list = new LinkedList<>(); Cursor c = dbHelper.getReadableDatabase().query(TABLE_NAME, null, null, null, null, null, COL_TIMESTAMP, "1000"); if (c != null) { if (c.moveToFirst()) { int nameIndex = c.getColumnIndex(COL_FILENAME); int sizeIndex = c.getColumnIndex(COL_FILESIZE); long tSize = 0; do { String filename = c.getString(nameIndex); if (!isUndeletable(filename)) { list.add(filename); if (!isPageFile(filename)) tSize += c.getLong(sizeIndex); if (tSize >= size) break; } } while (c.moveToNext()); } c.close(); } return list; } public long[] getSize() { try { return getSizeInternal(); } catch (SQLiteException e) { if (e.getMessage() != null && e.getMessage().contains("no such table")) { Logger.e(TAG, "table in database not exists", e); resetDB(); return getSizeInternal(); } else { throw e; } } } private long[] getSizeInternal() { long[] result = new long[] { 0, 0 }; Cursor c = dbHelper.getReadableDatabase().query(TABLE_NAME, null, COL_FILESIZE + " = -1", null, null, null, null); if (c != null) { if (c.moveToFirst()) { c.close(); resetDB(); return result; } c.close(); } c = dbHelper.getReadableDatabase().rawQuery("SELECT SUM(" + COL_FILESIZE + ") FROM " + TABLE_NAME, null); if (c != null) { if (c.moveToFirst()) result[0] = c.getInt(0); c.close(); } c = dbHelper.getReadableDatabase().rawQuery("SELECT SUM(" + COL_FILESIZE + ") FROM " + TABLE_NAME + " WHERE " + COL_FILENAME + " LIKE '" + PREFIX_PAGES + "%' OR " + COL_FILENAME + " LIKE '" + PREFIX_DRAFTS + "%'", null); if (c != null) { if (c.moveToFirst()) result[1] = c.getInt(0); c.close(); } return result; } public void remove(String filename) { dbHelper.getWritableDatabase().delete(TABLE_NAME, COL_FILENAME + " = ?", new String[] { filename }); } public void resetDB() { dbHelper.resetDB(); } private static class DBHelper extends SQLiteOpenHelper implements BaseColumns { public DBHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL(createTable(TABLE_NAME, new String[] { COL_FILENAME, COL_FILESIZE, COL_TIMESTAMP }, new String[] { "text", "integer", "integer" })); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion < newVersion) { db.execSQL(dropTable(TABLE_NAME)); onCreate(db); } } @Override public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) { onUpgrade(db, oldVersion, newVersion); } private static String createTable(String tableName, String[] columns, String[] types) { StringBuilder sql = new StringBuilder(110).append("create table ").append(tableName).append(" ("). append(_ID).append(" integer primary key autoincrement,"); for (int i=0; i<columns.length; ++i) { sql.append(columns[i]).append(' ').append(types == null ? "text" : types[i]).append(','); } sql.setCharAt(sql.length()-1, ')'); return sql.append(';').toString(); } private static String dropTable(String tableName) { return "DROP TABLE IF EXISTS " + tableName; } private void resetDB() { SQLiteDatabase db = getWritableDatabase(); db.execSQL(dropTable(TABLE_NAME)); onCreate(db); } } } private static File getAvailableCacheDir(Context context) { File externalCacheDir = null; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.FROYO) { externalCacheDir = CompatibilityImpl.getExternalCacheDir(context); } else if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { externalCacheDir = new File(Environment.getExternalStorageDirectory(), "/Android/data/" + context.getPackageName() + "/cache/"); } return externalCacheDir != null ? externalCacheDir : context.getCacheDir(); } private static File getAvailableFilesDir(Context context) { File externalFilesDir = null; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.FROYO) { externalFilesDir = CompatibilityImpl.getExternalFilesDir(context); } else if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { externalFilesDir = new File(Environment.getExternalStorageDirectory(), "/Android/data/" + context.getPackageName() + "/files/"); } return externalFilesDir != null ? externalFilesDir : context.getFilesDir(); } private void transferTabsState() { File to = new File(filesDirectory, TABS_FILENAME); if (to.exists()) return; File from; File file1 = new File(directory, "tabsstate"); File file2 = new File(directory, "tabsstate_2"); boolean file1Exists = file1.exists() && !file1.isDirectory(); boolean file2Exists = file2.exists() && !file2.isDirectory(); if (!file1Exists && !file2Exists) return; else if (!file1Exists) from = file2; else if (!file2Exists) from = file1; else from = file1.lastModified() > file2.lastModified() ? file2 : file1; copyFile(from, to); try { if (file1Exists && file1.delete()) database.remove("tabsstate"); if (file2Exists && file2.delete()) database.remove("tabsstate_2"); } catch (Exception e) { Logger.e(TAG, e); } } private static void copyFile(File from, File to) { InputStream in = null; OutputStream out = null; try { File parent = to.getParentFile(); if (!parent.exists() || !parent.isDirectory()) parent.mkdirs(); in = new FileInputStream(from); out = new FileOutputStream(to); IOUtils.copyStream(in, out); } catch (Exception e) { Logger.e(TAG, e); } finally { IOUtils.closeQuietly(in); IOUtils.closeQuietly(out); } } }