/*******************************************************************************
* This file is part of RedReader.
*
* RedReader 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.
*
* RedReader 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 RedReader. If not, see <http://www.gnu.org/licenses/>.
******************************************************************************/
package org.quantumbadger.redreader.cache;
import android.content.Context;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Build;
import android.preference.PreferenceManager;
import android.util.Log;
import org.quantumbadger.redreader.account.RedditAccount;
import org.quantumbadger.redreader.activities.BugReportActivity;
import org.quantumbadger.redreader.common.General;
import org.quantumbadger.redreader.common.PrefsUtility;
import org.quantumbadger.redreader.common.PrioritisedCachedThreadPool;
import org.quantumbadger.redreader.jsonwrap.JsonValue;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.concurrent.PriorityBlockingQueue;
import java.util.concurrent.atomic.AtomicBoolean;
// TODO consider moving to service
public final class CacheManager {
private static final String ext = ".rr_cache_data", tempExt = ".rr_cache_data_tmp";
private static final AtomicBoolean isAlreadyInitialized = new AtomicBoolean(false);
private final CacheDbManager dbManager;
private final PriorityBlockingQueue<CacheRequest> requests = new PriorityBlockingQueue<>();
private final PrioritisedDownloadQueue downloadQueue;
private final PrioritisedCachedThreadPool mDiskCacheThreadPool = new PrioritisedCachedThreadPool(2, "Disk Cache");
private final Context context;
private static CacheManager singleton;
public static synchronized CacheManager getInstance(final Context context) {
if(singleton == null) singleton = new CacheManager(context.getApplicationContext());
return singleton;
}
private CacheManager(final Context context) {
if(!isAlreadyInitialized.compareAndSet(false, true)) {
throw new RuntimeException("Attempt to initialize the cache twice.");
}
this.context = context;
dbManager = new CacheDbManager(context);
downloadQueue = new PrioritisedDownloadQueue(context);
final RequestHandlerThread requestHandler = new RequestHandlerThread();
requestHandler.start();
}
private Long isCacheFile(final String file) {
if(!file.endsWith(ext)) return null;
final String[] fileSplit = file.split("\\.");
if(fileSplit.length != 2) return null;
try {
return Long.parseLong(fileSplit[0]);
} catch(Exception e) {
return null;
}
}
private void getCacheFileList(final File dir, final HashSet<Long> currentFiles) {
final String[] list = dir.list();
if(list == null) return;
for(final String file : list) {
final Long cacheFileId = isCacheFile(file);
if(cacheFileId != null) {
currentFiles.add(cacheFileId);
}
}
}
private static void pruneTemp(final File dir) {
final String[] list = dir.list();
if(list == null) return;
for(final String file : list) {
if(file.endsWith(tempExt)) {
new File(dir, file).delete();
}
}
}
public static List<File> getCacheDirs(Context context) {
final ArrayList<File> dirs = new ArrayList<>();
dirs.add(context.getCacheDir());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
for(final File dir : context.getExternalCacheDirs()) {
if(dir != null) {
dirs.add(dir);
}
}
} else {
final File extDir = context.getExternalCacheDir();
if (extDir != null) {
dirs.add(extDir);
}
}
return dirs;
}
public void pruneTemp() {
List<File> dirs = getCacheDirs(context);
for (File dir : dirs) {
pruneTemp(dir);
}
}
public synchronized void pruneCache() {
try {
final HashSet<Long> currentFiles = new HashSet<>(128);
List<File> dirs = getCacheDirs(context);
for (File dir : dirs) {
getCacheFileList(dir, currentFiles);
}
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
final HashMap<Integer, Long> maxAge = PrefsUtility.pref_cache_maxage(context, prefs);
final ArrayList<Long> filesToDelete = dbManager.getFilesToPrune(currentFiles, maxAge, 72);
Log.i("CacheManager", "Pruning " + filesToDelete.size() + " files");
for(final long id : filesToDelete) {
final File file = getExistingCacheFile(id);
if(file != null) file.delete();
}
} catch(Throwable t) {
BugReportActivity.handleGlobalError(context, t);
}
}
public synchronized void emptyTheWholeCache() {
dbManager.emptyTheWholeCache();
}
public void makeRequest(final CacheRequest request) {
requests.put(request);
}
public LinkedList<CacheEntry> getSessions(URI url, RedditAccount user) {
return dbManager.select(url, user.username, null);
}
public File getPreferredCacheLocation() {
return new File(
PrefsUtility.pref_cache_location(context, PreferenceManager.getDefaultSharedPreferences(context)));
}
public class WritableCacheFile {
private final NotifyOutputStream os;
private long cacheFileId = -1;
private ReadableCacheFile readableCacheFile = null;
private final CacheRequest request;
private final File location;
private WritableCacheFile(final CacheRequest request, final UUID session, final String mimetype) throws IOException {
this.request = request;
location = getPreferredCacheLocation();
final File tmpFile = new File(location, UUID.randomUUID().toString() + tempExt);
final FileOutputStream fos = new FileOutputStream(tmpFile);
final OutputStream bufferedOs = new BufferedOutputStream(fos, 64 * 1024);
final NotifyOutputStream.Listener listener = new NotifyOutputStream.Listener() {
public void onClose() throws IOException {
cacheFileId = dbManager.newEntry(request, session, mimetype);
final File dstFile = new File(location, cacheFileId + ext);
General.moveFile(tmpFile, dstFile);
dbManager.setEntryDone(cacheFileId);
readableCacheFile = new ReadableCacheFile(cacheFileId);
}
};
this.os = new NotifyOutputStream(bufferedOs, listener);
}
public NotifyOutputStream getOutputStream() {
return os;
}
public ReadableCacheFile getReadableCacheFile() throws IOException {
if(readableCacheFile == null) {
if(!request.isJson) {
BugReportActivity.handleGlobalError(context, "Attempt to read cache file before closing");
}
try {
os.flush();
os.close();
} catch(IOException e) {
Log.e("getReadableCacheFile", "Error closing " + cacheFileId);
throw e;
}
}
return readableCacheFile;
}
}
public class ReadableCacheFile {
private final long id;
private ReadableCacheFile(final long id) {
this.id = id;
}
public InputStream getInputStream() throws IOException {
return getCacheFileInputStream(id);
}
public Uri getUri() throws IOException {
return getCacheFileUri(id);
}
@Override
public String toString() {
return String.format(Locale.US, "[ReadableCacheFile : id %d]", id);
}
public long getSize() {
return getExistingCacheFile(id).length();
}
}
public WritableCacheFile openNewCacheFile(final CacheRequest request, final UUID session, final String mimetype) throws IOException {
return new WritableCacheFile(request, session, mimetype);
}
private File getExistingCacheFile(final long id) {
List<File> dirs = getCacheDirs(context);
for (File dir : dirs) {
final File f = new File(dir, id + ext);
if (f.exists())
return f;
}
return null;
}
private InputStream getCacheFileInputStream(final long id) throws IOException {
final File cacheFile = getExistingCacheFile(id);
if(cacheFile == null) {
return null;
}
return new BufferedInputStream(new FileInputStream(cacheFile), 8 * 1024);
}
private Uri getCacheFileUri(final long id) throws IOException {
final File cacheFile = getExistingCacheFile(id);
if(cacheFile == null) {
return null;
}
return Uri.fromFile(cacheFile);
}
private class RequestHandlerThread extends Thread {
public RequestHandlerThread() {
super("Request Handler Thread");
}
@Override
public void run() {
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND);
try {
CacheRequest request;
while((request = requests.take()) != null) {
handleRequest(request);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
private void handleRequest(final CacheRequest request) {
if(request.url == null) {
request.notifyFailure(
CacheRequest.REQUEST_FAILURE_MALFORMED_URL,
new NullPointerException("URL was null"),
null,
"URL was null");
return;
}
if(request.downloadStrategy.shouldDownloadWithoutCheckingCache()) {
queueDownload(request);
} else {
final LinkedList<CacheEntry> result = dbManager.select(request.url, request.user.username, request.requestSession);
if(result.isEmpty()) {
if(request.downloadStrategy.shouldDownloadIfNotCached()) {
queueDownload(request);
} else {
request.notifyFailure(
CacheRequest.REQUEST_FAILURE_CACHE_MISS,
null,
null,
"Could not find this data in the cache");
}
} else {
final CacheEntry entry = mostRecentFromList(result);
if(request.downloadStrategy.shouldDownloadIfCacheEntryFound(entry)) {
queueDownload(request);
} else {
handleCacheEntryFound(entry, request);
}
}
}
}
private CacheEntry mostRecentFromList(final LinkedList<CacheEntry> list) {
CacheEntry entry = null;
for(final CacheEntry e : list) {
if(entry == null || entry.timestamp < e.timestamp) {
entry = e;
}
}
return entry;
}
private void queueDownload(final CacheRequest request) {
request.notifyDownloadNecessary();
try {
downloadQueue.add(request, CacheManager.this);
} catch(final Exception e) {
request.notifyFailure(CacheRequest.REQUEST_FAILURE_MALFORMED_URL, e, null, e.toString());
}
}
private void handleCacheEntryFound(final CacheEntry entry, final CacheRequest request) {
final File cacheFile = getExistingCacheFile(entry.id);
if(cacheFile == null) {
request.notifyFailure(
CacheRequest.REQUEST_FAILURE_STORAGE,
null,
null,
"A cache entry was found in the database, but the actual data couldn't be found. Press refresh to download the content again.");
dbManager.delete(entry.id);
return;
}
mDiskCacheThreadPool.add(new PrioritisedCachedThreadPool.Task() {
@Override
public int getPrimaryPriority() {
return request.priority;
}
@Override
public int getSecondaryPriority() {
return request.listId;
}
@Override
public void run() {
if(request.isJson) {
InputStream cacheFileInputStream = null;
try {
cacheFileInputStream = getCacheFileInputStream(entry.id);
if(cacheFileInputStream == null) {
request.notifyFailure(CacheRequest.REQUEST_FAILURE_CACHE_MISS, null, null, "Couldn't retrieve cache file");
return;
}
final JsonValue value = new JsonValue(cacheFileInputStream);
request.notifyJsonParseStarted(value, entry.timestamp, entry.session, true);
value.buildInThisThread();
} catch(Throwable t) {
if(cacheFileInputStream != null) {
try {
cacheFileInputStream.close();
} catch(IOException e) {
// Ignore
}
}
dbManager.delete(entry.id);
final File existingCacheFile = getExistingCacheFile(entry.id);
if(existingCacheFile != null) {
existingCacheFile.delete();
}
request.notifyFailure(CacheRequest.REQUEST_FAILURE_PARSE, t, null, "Error parsing the JSON stream");
return;
}
}
request.notifySuccess(new ReadableCacheFile(entry.id), entry.timestamp, entry.session, true, entry.mimetype);
}
});
}
}
}