package com.kaltura.playersdk.helpers; import android.content.Context; import android.net.Uri; import android.support.annotation.NonNull; import android.text.TextUtils; import android.webkit.WebResourceResponse; import com.kaltura.playersdk.utils.Utilities; import org.json.JSONException; import org.json.JSONObject; import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.ProtocolException; import java.net.URL; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.regex.Pattern; import static com.kaltura.playersdk.helpers.KStringUtilities.md5; import static com.kaltura.playersdk.utils.LogUtils.LOGD; import static com.kaltura.playersdk.utils.LogUtils.LOGE; /** * Created by nissimpardo on 25/10/15. */ public class CacheManager { private static final String TAG = "CacheManager"; public static final String CACHED_STRINGS_JSON = "CachedStrings.json"; private JSONObject mCacheConditions; private CacheSQLHelper mSQLHelper; private String mBaseURL; private float mCacheSize = 0; private String mCachePath; private File mFilesDir; private Context mAppContext; private List<Pattern> mIncludePatterns; private void logCacheHit(Uri url, String fileId) { LOGD(TAG, "CACHE HIT: " + fileId + " : " + url); } private void logCacheMiss(Uri url, String fileId) { LOGD(TAG, "CACHE MISS: " + fileId + " : " + url); } private void logCacheIgnored(Uri url, String method) { LOGD(TAG, "CACHE IGNORE: " + method + " " + url); } private void logCacheSaved(Uri url, String fileId) { LOGD(TAG, "CACHE SAVED: " + fileId + " : " + url); } private void logCacheDeleted(String fileId) { LOGD(TAG, "CACHE DELETE: " + fileId); } public CacheManager(Context context) { mAppContext = context.getApplicationContext(); mFilesDir = mAppContext.getFilesDir(); mCachePath = mFilesDir + "/kaltura/"; File cacheDir = new File(mCachePath); if (!cacheDir.exists()) { cacheDir.mkdirs(); } mSQLHelper = new CacheSQLHelper(context); String string = Utilities.readAssetToString(mAppContext, CACHED_STRINGS_JSON); if (string != null) { try { mCacheConditions = new JSONObject(string); } catch (JSONException e) { LOGE(TAG, "Invalid json", e); } } } public void setBaseURL(String baseURL) { mBaseURL = baseURL; } public void setCacheSize(float cacheSize) { mCacheSize = cacheSize; } private boolean shouldStore(Uri uri, Map<String, String> headers, String method) { if (! method.equalsIgnoreCase("GET")) { return false; // only cache GETs } if (! (uri.getScheme().equals("http") || uri.getScheme().equals("https"))) { return false; // only cache http(s) } // Allow app-specific inclusion. String uriString = uri.toString(); for (Pattern pattern : mIncludePatterns) { if (pattern.matcher(uriString).matches()) { return true; } } if (! uriString.startsWith(mBaseURL)) { return false; // not our server } // Special case: do not cache the embedFrame page, UNLESS localContentId is set. if (uri.getPath().contains("/mwEmbedFrame.php") || uri.getPath().contains("/embedIframeJs/")) { if (TextUtils.isEmpty(getLocalContentId(uri))) { return false; } } return true; } private void deleteLessUsedFiles(long newCacheSize) { long freeBytesInternal = new File(mFilesDir.getAbsoluteFile().toString()).getFreeSpace(); long cahceSize = (long)(mCacheSize * 1024 * 1024); long actualCacheSize = Math.min(cahceSize, freeBytesInternal); long currentCacheSize = mSQLHelper.cacheSize(); boolean shouldDeleteLessUsedFiles = currentCacheSize + newCacheSize > actualCacheSize; if (shouldDeleteLessUsedFiles) { mSQLHelper.deleteLessUsedFiles(currentCacheSize + newCacheSize - actualCacheSize, new CacheSQLHelper.KSQLHelperDeleteListener() { @Override public void fileDeleted(String fileId) { File deletedFile = new File(mCachePath, fileId); if (deletedFile.exists()) { deletedFile.delete(); logCacheDeleted(fileId); } } }); } } private void setRequestParams(HttpURLConnection connection, Map<String, String> headers, String method) { try { connection.setRequestMethod(method); } catch (ProtocolException e) { LOGE(TAG, "Invalid method " + method, e); // This can't really happen. But if it did, and we're on a debug build, the app should crash. throw new IllegalArgumentException(e); } for (Map.Entry<String, String> entry : headers.entrySet()) { connection.setRequestProperty(entry.getKey(), entry.getValue()); } } public boolean removeCachedResponse(Uri requestUrl) { String fileId = getCacheFileId(requestUrl); if (!mSQLHelper.removeFile(fileId)) { LOGE(TAG, "Failed to remove cache entry for request: " + requestUrl); return false; } else { File file = new File(mCachePath, fileId); if (!file.delete()) { LOGE(TAG, "Failed to delete file for request: " + requestUrl); return false; } } logCacheDeleted(fileId); return true; } public boolean refreshCachedResponse(Uri url) throws IOException { boolean remove = removeCachedResponse(url); if (!remove) { return false; } cacheResponse(url); return true; } public void cacheResponse(Uri requestUrl) throws IOException { // Explicitly load and save the URL - don't even check db. String fileName = getCacheFileId(requestUrl); File targetFile = new File(mCachePath, fileName); WebResourceResponse resp = getResponseFromNetwork(requestUrl, Collections.<String, String>emptyMap(), "GET", fileName, targetFile); InputStream inputStream = resp.getData(); // Must fully read the input stream so that it gets cached. But we don't need the data now. try { byte[] buffer = new byte[1024]; //noinspection StatementWithEmptyBody while (inputStream.read(buffer, 0, buffer.length) >= 0); } finally { inputStream.close(); } } public WebResourceResponse getResponse(final Uri requestUrl, Map<String, String> headers, String method) throws IOException { if (!shouldStore(requestUrl, headers, method)) { logCacheIgnored(requestUrl, method); return null; } boolean online = Utilities.isOnline(mAppContext); if (!online && requestUrl.toString().contains("playManifest")) { return webResourceResponse("text/plain", "UTF-8", new ByteArrayInputStream("Empty".getBytes())); } InputStream inputStream; String fileName = getCacheFileId(requestUrl); File targetFile = new File(mCachePath, fileName); String contentType; String encoding; HashMap<String, Object> fileParams = mSQLHelper.fetchParamsForFile(fileName); if (mSQLHelper.sizeForId(fileName) > 0 && fileParams != null) { logCacheHit(requestUrl, fileName); FileInputStream fileInputStream = new FileInputStream(targetFile); inputStream = new BufferedInputStream(fileInputStream); contentType = (String)fileParams.get(CacheSQLHelper.COL_MIMETYPE); encoding = (String)fileParams.get(CacheSQLHelper.COL_ENCODING); mSQLHelper.updateDate(fileName); WebResourceResponse response = webResourceResponse(contentType, encoding, inputStream); return response; } else { logCacheMiss(requestUrl, fileName); if (!online) { LOGE(TAG, "Error: device is offline and response is not cached."); } return getResponseFromNetwork(requestUrl, headers, method, fileName, targetFile); } } @NonNull private WebResourceResponse getResponseFromNetwork(final Uri requestUrl, Map<String, String> headers, String method, String fileName, File targetFile) throws IOException { String contentType; InputStream inputStream = null; URL url = new URL(requestUrl.toString()); final HttpURLConnection connection = (HttpURLConnection) url.openConnection(); try { setRequestParams(connection, headers, method); connection.connect(); contentType = connection.getContentType(); Map<String, List<String>> headerFields = connection.getHeaderFields(); if (contentType == null) { contentType = ""; } String[] contentTypeParts = TextUtils.split(contentType, ";"); String encoding = null; if (contentTypeParts.length >= 2) { contentType = contentTypeParts[0].trim(); encoding = contentTypeParts[1].trim(); } mSQLHelper.addFile(fileName, contentType, encoding); inputStream = new CachingInputStream(targetFile.getAbsolutePath(), connection.getInputStream(), new CachingInputStream.KInputStreamListener() { @Override public void streamClosed(long fileSize, String filePath) { int trimLength = mCachePath.length(); String fileId = filePath.substring(trimLength); mSQLHelper.updateFileSize(fileId, fileSize); logCacheSaved(requestUrl, fileId); deleteLessUsedFiles(fileSize); connection.disconnect(); } }); return webResourceResponse(contentType, encoding, inputStream); } finally { // if inputStream wasn't created, streamClosed() will not get called and the connection may leak. if (inputStream == null) { connection.disconnect(); } } } @NonNull private static WebResourceResponse webResourceResponse(String contentType, String encoding, InputStream inputStream) { return new WebResourceResponse(contentType, encoding, inputStream); } @NonNull private String getCacheFileId(Uri requestUrl) { if (requestUrl.getFragment() != null) { String localContentId = getLocalContentId(requestUrl); if (!TextUtils.isEmpty(localContentId)) { return md5("contentId:" + localContentId); } } return md5(requestUrl.toString()); } private String getLocalContentId(Uri requestUrl) { return KStringUtilities.extractFragmentParam(requestUrl, "localContentId"); } public void release() { if (mSQLHelper != null) { mSQLHelper.close(); mSQLHelper = null; } } public void setIncludePatterns(List<Pattern> includePatterns) { mIncludePatterns = new ArrayList<>(includePatterns); // make a safe copy. } }