Merge branch 'master' into dev

Conflicts:
	Clover/app/src/main/java/org/floens/chan/ChanApplication.java
	Clover/app/src/main/java/org/floens/chan/core/manager/ReplyManager.java
	Clover/app/src/main/java/org/floens/chan/ui/fragment/ReplyFragment.java
	Clover/app/src/main/java/org/floens/chan/ui/view/ThumbnailImageView.java
filtering
Floens 10 years ago
commit de59ff1a63
  1. 2
      Clover/app/src/main/AndroidManifest.xml
  2. 15
      Clover/app/src/main/assets/captcha/captcha.html
  3. 20
      Clover/app/src/main/java/org/floens/chan/ChanApplication.java
  4. 10
      Clover/app/src/main/java/org/floens/chan/core/manager/BoardManager.java
  5. 19
      Clover/app/src/main/java/org/floens/chan/core/manager/ReplyManager.java
  6. 304
      Clover/app/src/main/java/org/floens/chan/test/TestActivity.java
  7. 4
      Clover/app/src/main/java/org/floens/chan/ui/fragment/ReplyFragment.java
  8. 14
      Clover/app/src/main/java/org/floens/chan/ui/layout/CaptchaLayout.java
  9. 13
      Clover/app/src/main/java/org/floens/chan/ui/view/MultiImageView.java
  10. 252
      Clover/app/src/main/java/org/floens/chan/utils/FileCache.java

@ -37,6 +37,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity android:name=".test.TestActivity">
</activity>
<activity <activity
android:name=".ui.activity.ChanActivity" android:name=".ui.activity.ChanActivity"
android:configChanges="keyboardHidden|orientation|screenSize" android:configChanges="keyboardHidden|orientation|screenSize"

@ -3,14 +3,6 @@
<head> <head>
<meta name=viewport content="width=device-width, initial-scale=1"> <meta name=viewport content="width=device-width, initial-scale=1">
<style type="text/css"> <style type="text/css">
body.light {
background: #ffffff;
}
body.dark {
background: #000000;
}
#loadingCaptcha { #loadingCaptcha {
font-family: sans-serif; font-family: sans-serif;
font-size: 20px; font-size: 20px;
@ -18,8 +10,9 @@
</style> </style>
<script src='https://www.google.com/recaptcha/api.js?onload=globalOnCaptchaLoaded&render=explicit'></script> <script src='https://www.google.com/recaptcha/api.js?onload=globalOnCaptchaLoaded&render=explicit'></script>
</head> </head>
<body class="__theme__"> <body>
<div id="captcha-loading">Loading captcha...</div> <div id="captcha-loading">Loading captcha...</div>
<div id="captcha-error"></div>
<div id="captcha-container"></div> <div id="captcha-container"></div>
<script type="text/javascript"> <script type="text/javascript">
@ -36,6 +29,10 @@ window.globalOnCaptchaLoaded = function() {
CaptchaCallback.onCaptchaLoaded(); CaptchaCallback.onCaptchaLoaded();
document.getElementById('captcha-loading').style.display = 'none'; document.getElementById('captcha-loading').style.display = 'none';
} }
window.onerror = function(message, url, line) {
document.getElementById('captcha-error').appendChild(document.createTextNode(line + ': ' + message + ' @ ' + url));
}
</script> </script>
</body> </body>
</html> </html>

@ -19,6 +19,7 @@ package org.floens.chan;
import android.app.Application; import android.app.Application;
import android.content.Context; import android.content.Context;
import android.content.pm.PackageManager;
import android.os.StrictMode; import android.os.StrictMode;
import android.view.ViewConfiguration; import android.view.ViewConfiguration;
@ -40,6 +41,7 @@ import org.floens.chan.utils.Logger;
import java.io.File; import java.io.File;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.util.Locale;
import de.greenrobot.event.EventBus; import de.greenrobot.event.EventBus;
@ -62,6 +64,7 @@ public class ChanApplication extends Application {
private static DatabaseManager databaseManager; private static DatabaseManager databaseManager;
private static FileCache fileCache; private static FileCache fileCache;
private String userAgent;
private int activityForegroundCounter = 0; private int activityForegroundCounter = 0;
public ChanApplication() { public ChanApplication() {
@ -126,6 +129,16 @@ public class ChanApplication extends Application {
ChanUrls.loadScheme(ChanSettings.getNetworkHttps()); ChanUrls.loadScheme(ChanSettings.getNetworkHttps());
// User agent is <appname>/<version>
String version = "";
try {
version = getPackageManager().getPackageInfo(getPackageName(), 0).versionName;
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
version = version.toLowerCase(Locale.ENGLISH).replace(" ", "_");
userAgent = getString(R.string.app_name) + "/" + version;
IconCache.createIcons(this); IconCache.createIcons(this);
cleanupOutdated(); cleanupOutdated();
@ -134,15 +147,18 @@ public class ChanApplication extends Application {
replyManager = new ReplyManager(this); replyManager = new ReplyManager(this);
volleyRequestQueue = Volley.newRequestQueue(this, replyManager.getUserAgent(), null, new File(cacheDir, Volley.DEFAULT_CACHE_DIR), VOLLEY_CACHE_SIZE); volleyRequestQueue = Volley.newRequestQueue(this, getUserAgent(), null, new File(cacheDir, Volley.DEFAULT_CACHE_DIR), VOLLEY_CACHE_SIZE);
imageLoader = new ImageLoader(volleyRequestQueue, new BitmapLruImageCache(VOLLEY_LRU_CACHE_SIZE)); imageLoader = new ImageLoader(volleyRequestQueue, new BitmapLruImageCache(VOLLEY_LRU_CACHE_SIZE));
fileCache = new FileCache(new File(cacheDir, FILE_CACHE_NAME), FILE_CACHE_DISK_SIZE); fileCache = new FileCache(new File(cacheDir, FILE_CACHE_NAME), FILE_CACHE_DISK_SIZE, getUserAgent());
databaseManager = new DatabaseManager(this); databaseManager = new DatabaseManager(this);
boardManager = new BoardManager(); boardManager = new BoardManager();
watchManager = new WatchManager(this); watchManager = new WatchManager(this);
}
public String getUserAgent() {
return userAgent;
} }
public void activityEnteredForeground() { public void activityEnteredForeground() {

@ -101,13 +101,6 @@ public class BoardManager {
} }
private void storeBoards() { private void storeBoards() {
Logger.d(TAG, "Storing boards in database");
for (Board test : allBoards) {
if (test.saved) {
Logger.d(TAG, "Board with value " + test.value + " saved");
}
}
updateByValueMap(); updateByValueMap();
ChanApplication.getDatabaseManager().setBoards(allBoards); ChanApplication.getDatabaseManager().setBoards(allBoards);
@ -130,8 +123,6 @@ public class BoardManager {
has = false; has = false;
for (int i = 0; i < allBoards.size(); i++) { for (int i = 0; i < allBoards.size(); i++) {
if (allBoards.get(i).value.equals(serverBoard.value)) { if (allBoards.get(i).value.equals(serverBoard.value)) {
Logger.d(TAG, "Replaced board " + serverBoard.value + " with the server one");
Board old = allBoards.get(i); Board old = allBoards.get(i);
serverBoard.id = old.id; serverBoard.id = old.id;
serverBoard.saved = old.saved; serverBoard.saved = old.saved;
@ -162,7 +153,6 @@ public class BoardManager {
new BoardsRequest(ChanUrls.getBoardsUrl(), new Response.Listener<List<Board>>() { new BoardsRequest(ChanUrls.getBoardsUrl(), new Response.Listener<List<Board>>() {
@Override @Override
public void onResponse(List<Board> data) { public void onResponse(List<Board> data) {
Logger.i(TAG, "Got boards from server");
setBoardsFromServer(data); setBoardsFromServer(data);
} }
}, new Response.ErrorListener() { }, new Response.ErrorListener() {

@ -19,7 +19,6 @@ package org.floens.chan.core.manager;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager;
import android.text.TextUtils; import android.text.TextUtils;
import com.squareup.okhttp.Callback; import com.squareup.okhttp.Callback;
@ -63,7 +62,6 @@ public class ReplyManager {
private Reply draft; private Reply draft;
private FileListener fileListener; private FileListener fileListener;
private final Random random = new Random(); private final Random random = new Random();
private String userAgent;
private OkHttpClient client; private OkHttpClient client;
public ReplyManager(Context context) { public ReplyManager(Context context) {
@ -74,21 +72,6 @@ public class ReplyManager {
client.setConnectTimeout(TIMEOUT, TimeUnit.MILLISECONDS); client.setConnectTimeout(TIMEOUT, TimeUnit.MILLISECONDS);
client.setReadTimeout(TIMEOUT, TimeUnit.MILLISECONDS); client.setReadTimeout(TIMEOUT, TimeUnit.MILLISECONDS);
client.setWriteTimeout(TIMEOUT, TimeUnit.MILLISECONDS); client.setWriteTimeout(TIMEOUT, TimeUnit.MILLISECONDS);
// User agent is <appname>/<version>
String version = "";
try {
version = context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName;
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
version = version.toLowerCase(Locale.ENGLISH).replace(" ", "_");
userAgent = context.getString(R.string.app_name) + "/" + version;
}
public String getUserAgent() {
return userAgent;
} }
/** /**
@ -475,7 +458,7 @@ public class ReplyManager {
} }
private void makeOkHttpCall(Request.Builder requestBuilder, Callback callback) { private void makeOkHttpCall(Request.Builder requestBuilder, Callback callback) {
requestBuilder.header("User-Agent", getUserAgent()); requestBuilder.header("User-Agent", ChanApplication.getInstance().getUserAgent());
Request request = requestBuilder.build(); Request request = requestBuilder.build();
client.newCall(request).enqueue(callback); client.newCall(request).enqueue(callback);
} }

@ -0,0 +1,304 @@
/*
* Clover - 4chan browser https://github.com/Floens/Clover/
* Copyright (C) 2014 Floens
*
* 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 org.floens.chan.test;
import android.app.Activity;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
import com.android.volley.VolleyError;
import org.floens.chan.ChanApplication;
import org.floens.chan.core.loader.ChanLoader;
import org.floens.chan.core.model.ChanThread;
import org.floens.chan.core.model.Loadable;
import org.floens.chan.core.model.Post;
import org.floens.chan.utils.FileCache;
import org.floens.chan.utils.Logger;
import org.floens.chan.utils.ThemeHelper;
import java.io.File;
// Poor mans unit testing.
// Move to proper unit testing when the gradle plugin fully supports it.
public class TestActivity extends Activity implements View.OnClickListener {
private static final String TAG = "FileCacheTest";
private final Handler handler = new Handler(Looper.getMainLooper());
private Button clearCache;
private Button stats;
private Button simpleTest;
private Button cacheTest;
private Button timeoutTest;
private FileCache fileCache;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
ThemeHelper.getInstance().reloadPostViewColors(this);
LinearLayout linearLayout = new LinearLayout(this);
linearLayout.setOrientation(LinearLayout.VERTICAL);
clearCache = new Button(this);
clearCache.setText("Clear cache");
clearCache.setOnClickListener(this);
linearLayout.addView(clearCache);
stats = new Button(this);
stats.setText("Stats");
stats.setOnClickListener(this);
linearLayout.addView(stats);
simpleTest = new Button(this);
simpleTest.setText("Test download and cancel");
simpleTest.setOnClickListener(this);
linearLayout.addView(simpleTest);
cacheTest = new Button(this);
cacheTest.setText("Test cache size");
cacheTest.setOnClickListener(this);
linearLayout.addView(cacheTest);
timeoutTest = new Button(this);
timeoutTest.setText("Test multiple parallel");
timeoutTest.setOnClickListener(this);
linearLayout.addView(timeoutTest);
setContentView(linearLayout);
File cacheDir = getExternalCacheDir() != null ? getExternalCacheDir() : getCacheDir();
File fileCacheDir = new File(cacheDir, "filecache");
fileCache = new FileCache(fileCacheDir, 50 * 1024 * 1024, ChanApplication.getInstance().getUserAgent());
}
@Override
public void onClick(View v) {
if (v == clearCache) {
clearCache();
} else if (v == stats) {
stats();
} else if (v == simpleTest) {
testDownloadAndCancel();
} else if (v == cacheTest) {
testCache();
} else if (v == timeoutTest) {
testTimeout();
}
}
public void clearCache() {
fileCache.clearCache();
}
public void stats() {
fileCache.logStats();
}
public void testDownloadAndCancel() {
// 1.9MB file of the clover Logger.i(TAG,
final String testImage = "http://a.pomf.se/ndbolc.png";
final File cacheFile = fileCache.get(testImage);
Logger.i(TAG, "Downloading " + testImage);
final FileCache.FileCacheDownloader downloader = fileCache.downloadFile(testImage, new FileCache.DownloadedCallback() {
@Override
public void onProgress(long downloaded, long total, boolean done) {
Logger.i(TAG, "onProgress " + downloaded + "/" + total + " " + done);
}
@Override
public void onSuccess(File file) {
Logger.i(TAG, "onSuccess " + file.exists());
}
@Override
public void onFail(boolean notFound) {
Logger.i(TAG, "onFail Cachefile exists() = " + cacheFile.exists());
}
});
handler.postDelayed(new Runnable() {
@Override
public void run() {
fileCache.downloadFile(testImage, new FileCache.DownloadedCallback() {
@Override
public void onProgress(long downloaded, long total, boolean done) {
Logger.i(TAG, "2nd progress " + downloaded + "/" + total);
}
@Override
public void onSuccess(File file) {
Logger.i(TAG, "2nd onSuccess " + file.exists());
}
@Override
public void onFail(boolean notFound) {
Logger.i(TAG, "2nd onFail Cachefile exists() = " + cacheFile.exists());
}
});
}
}, 200);
handler.postDelayed(new Runnable() {
@Override
public void run() {
Logger.i(TAG, "Cancelling download!");
downloader.cancel();
}
}, 500);
handler.postDelayed(new Runnable() {
@Override
public void run() {
Logger.i(TAG, "File exists() = " + cacheFile.exists());
}
}, 600);
handler.postDelayed(new Runnable() {
@Override
public void run() {
final File cache404File = fileCache.get(testImage + "404");
fileCache.downloadFile(testImage + "404", new FileCache.DownloadedCallback() {
@Override
public void onProgress(long downloaded, long total, boolean done) {
Logger.i(TAG, "404 progress " + downloaded + "/" + total + " " + done);
}
@Override
public void onSuccess(File file) {
Logger.i(TAG, "404 onSuccess " + file.exists());
}
@Override
public void onFail(boolean notFound) {
Logger.i(TAG, "404 onFail " + cache404File.exists());
}
});
}
}, 1000);
}
private void testCache() {
Loadable loadable = new Loadable("g");
loadable.mode = Loadable.Mode.CATALOG;
ChanLoader loader = new ChanLoader(loadable);
loader.addListener(new ChanLoader.ChanLoaderCallback() {
@Override
public void onChanLoaderData(ChanThread result) {
for (Post post : result.posts) {
if (post.hasImage) {
final String imageUrl = post.imageUrl;
fileCache.downloadFile(imageUrl, new FileCache.DownloadedCallback() {
@Override
public void onProgress(long downloaded, long total, boolean done) {
Logger.i(TAG, "Progress for " + imageUrl + " " + downloaded + "/" + total + " " + done);
}
@Override
public void onSuccess(File file) {
Logger.i(TAG, "onSuccess for " + imageUrl + " exists() = " + file.exists());
}
@Override
public void onFail(boolean notFound) {
Logger.i(TAG, "onFail for " + imageUrl);
}
});
}
}
}
@Override
public void onChanLoaderError(VolleyError error) {
}
});
loader.requestData();
}
private void testTimeout() {
testTimeoutInner("https://i.4cdn.org/hr/1429923649068.jpg", fileCache, 0);
handler.postDelayed(new Runnable() {
@Override
public void run() {
testTimeoutInner("https://i.4cdn.org/hr/1430058524427.jpg", fileCache, 0);
}
}, 200);
handler.postDelayed(new Runnable() {
@Override
public void run() {
testTimeoutInner("https://i.4cdn.org/hr/1430058627352.jpg", fileCache, 0);
}
}, 400);
handler.postDelayed(new Runnable() {
@Override
public void run() {
testTimeoutInner("https://i.4cdn.org/hr/1430058580015.jpg", fileCache, 0);
}
}, 600);
}
private void testTimeoutInner(final String url, final FileCache fileCache, final int tries) {
final File cacheFile = fileCache.get(url);
Logger.i(TAG, "Downloading " + url + " try " + tries);
final FileCache.FileCacheDownloader downloader = fileCache.downloadFile(url, new FileCache.DownloadedCallback() {
@Override
public void onProgress(long downloaded, long total, boolean done) {
Logger.i(TAG, "onProgress " + url + " " + downloaded + "/" + total);
}
@Override
public void onSuccess(File file) {
Logger.i(TAG, "onSuccess " + file.exists());
}
@Override
public void onFail(boolean notFound) {
Logger.i(TAG, "onFail Cachefile exists() = " + cacheFile.exists());
}
});
handler.postDelayed(new Runnable() {
@Override
public void run() {
if (downloader == null) {
Logger.i(TAG, "Downloader null, cannot cancel");
} else {
downloader.cancel();
handler.postDelayed(new Runnable() {
@Override
public void run() {
if (tries < 10) {
testTimeoutInner(url, fileCache, tries + 1);
} else {
fileCache.logStats();
}
}
}, 500);
}
}
}, 1000);
}
}

@ -204,7 +204,7 @@ public class ReplyFragment extends DialogFragment implements CaptchaLayout.Captc
String baseUrl = loadable.isThreadMode() ? ChanUrls.getThreadUrlDesktop(loadable.board, loadable.no) : ChanUrls.getBoardUrlDesktop(loadable.board); String baseUrl = loadable.isThreadMode() ? ChanUrls.getThreadUrlDesktop(loadable.board, loadable.no) : ChanUrls.getBoardUrlDesktop(loadable.board);
captchaLayout.initCaptcha(baseUrl, ChanUrls.getCaptchaSiteKey(), captchaLayout.initCaptcha(baseUrl, ChanUrls.getCaptchaSiteKey(),
ThemeHelper.getInstance().getTheme().isLightTheme, ChanApplication.getReplyManager().getUserAgent(), this); ThemeHelper.getInstance().getTheme().isLightTheme, ChanApplication.getInstance().getUserAgent(), this);
} else { } else {
Logger.e(TAG, "Loadable in ReplyFragment was null"); Logger.e(TAG, "Loadable in ReplyFragment was null");
closeReply(); closeReply();
@ -581,8 +581,8 @@ public class ReplyFragment extends DialogFragment implements CaptchaLayout.Captc
if (ChanSettings.passLoggedIn()) { if (ChanSettings.passLoggedIn()) {
flipPage(0); flipPage(0);
} else { } else {
captchaLayout.reset();
flipPage(1); flipPage(1);
captchaLayout.reset();
} }
} else if (response.isSuccessful) { } else if (response.isSuccessful) {
shouldSaveDraft = false; shouldSaveDraft = false;

@ -21,7 +21,10 @@ import android.annotation.SuppressLint;
import android.content.Context; import android.content.Context;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.AttributeSet; import android.util.AttributeSet;
import android.util.Log;
import android.webkit.ConsoleMessage;
import android.webkit.JavascriptInterface; import android.webkit.JavascriptInterface;
import android.webkit.WebChromeClient;
import android.webkit.WebSettings; import android.webkit.WebSettings;
import android.webkit.WebView; import android.webkit.WebView;
@ -29,6 +32,8 @@ import org.floens.chan.utils.AndroidUtils;
import org.floens.chan.utils.IOUtils; import org.floens.chan.utils.IOUtils;
public class CaptchaLayout extends WebView { public class CaptchaLayout extends WebView {
private static final String TAG = "CaptchaLayout";
private CaptchaCallback callback; private CaptchaCallback callback;
private boolean loaded = false; private boolean loaded = false;
private String baseUrl; private String baseUrl;
@ -58,6 +63,15 @@ public class CaptchaLayout extends WebView {
settings.setJavaScriptEnabled(true); settings.setJavaScriptEnabled(true);
settings.setUserAgentString(userAgent); settings.setUserAgentString(userAgent);
setWebChromeClient(new WebChromeClient() {
@Override
public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
Log.i(TAG, consoleMessage.lineNumber() + ":" + consoleMessage.message() + " " + consoleMessage.sourceId());
return true;
}
});
setBackgroundColor(0x00000000);
addJavascriptInterface(new CaptchaInterface(this), "CaptchaCallback"); addJavascriptInterface(new CaptchaInterface(this), "CaptchaCallback");
} }

@ -45,7 +45,6 @@ import org.floens.chan.utils.Logger;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.concurrent.Future;
import pl.droidsonroids.gif.GifDrawable; import pl.droidsonroids.gif.GifDrawable;
import pl.droidsonroids.gif.GifImageView; import pl.droidsonroids.gif.GifImageView;
@ -64,9 +63,9 @@ public class MultiImageView extends FrameLayout implements View.OnClickListener
private boolean hasContent = false; private boolean hasContent = false;
private ImageContainer thumbnailRequest; private ImageContainer thumbnailRequest;
private Future bigImageRequest; private FileCache.FileCacheDownloader bigImageRequest;
private Future gifRequest; private FileCache.FileCacheDownloader gifRequest;
private Future videoRequest; private FileCache.FileCacheDownloader videoRequest;
private VideoView videoView; private VideoView videoView;
private boolean videoError = false; private boolean videoError = false;
@ -398,13 +397,13 @@ public class MultiImageView extends FrameLayout implements View.OnClickListener
thumbnailRequest.cancelRequest(); thumbnailRequest.cancelRequest();
} }
if (bigImageRequest != null) { if (bigImageRequest != null) {
bigImageRequest.cancel(true); bigImageRequest.cancel();
} }
if (gifRequest != null) { if (gifRequest != null) {
gifRequest.cancel(true); gifRequest.cancel();
} }
if (videoRequest != null) { if (videoRequest != null) {
videoRequest.cancel(true); videoRequest.cancel();
} }
} }

@ -17,81 +17,150 @@
*/ */
package org.floens.chan.utils; package org.floens.chan.utils;
import android.util.Log; import android.os.Handler;
import android.os.Looper;
import com.squareup.okhttp.Call; import com.squareup.okhttp.Call;
import com.squareup.okhttp.OkHttpClient; import com.squareup.okhttp.OkHttpClient;
import com.squareup.okhttp.Protocol;
import com.squareup.okhttp.Request; import com.squareup.okhttp.Request;
import com.squareup.okhttp.Response; import com.squareup.okhttp.Response;
import com.squareup.okhttp.ResponseBody; import com.squareup.okhttp.ResponseBody;
import com.squareup.okhttp.internal.Util; import com.squareup.okhttp.internal.Util;
import org.floens.chan.ChanApplication;
import java.io.BufferedOutputStream; import java.io.BufferedOutputStream;
import java.io.Closeable; import java.io.Closeable;
import java.io.File; import java.io.File;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.InterruptedIOException; import java.io.InterruptedIOException;
import java.io.OutputStream; import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import okio.BufferedSource; import okio.BufferedSource;
public class FileCache { public class FileCache {
private static final String TAG = "FileCache"; private static final String TAG = "FileCache";
private static final int TIMEOUT = 10000;
private static final int TRIM_TRIES = 20;
private static final int THREAD_COUNT = 2;
private static final ExecutorService executor = Executors.newFixedThreadPool(2); private static final ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
private String userAgent;
private OkHttpClient httpClient; private OkHttpClient httpClient;
private static String userAgent;
private final File directory; private final File directory;
private final long maxSize; private final long maxSize;
private long size; private long size;
public FileCache(File directory, long maxSize) { private List<FileCacheDownloader> downloaders = new ArrayList<>();
public FileCache(File directory, long maxSize, String userAgent) {
this.directory = directory; this.directory = directory;
this.maxSize = maxSize; this.maxSize = maxSize;
this.userAgent = userAgent;
httpClient = new OkHttpClient(); httpClient = new OkHttpClient();
userAgent = ChanApplication.getReplyManager().getUserAgent(); httpClient.setConnectTimeout(TIMEOUT, TimeUnit.MILLISECONDS);
httpClient.setReadTimeout(TIMEOUT, TimeUnit.MILLISECONDS);
httpClient.setWriteTimeout(TIMEOUT, TimeUnit.MILLISECONDS);
// Disable SPDY, causes reproducable timeouts, only one download at the same time and other fun stuff
httpClient.setProtocols(Collections.singletonList(Protocol.HTTP_1_1));
makeDir(); makeDir();
calculateSize(); calculateSize();
} }
public File get(String key) { public void logStats() {
makeDir(); Logger.i(TAG, "Cache size = " + size + "/" + maxSize);
Logger.i(TAG, "downloaders.size() = " + downloaders.size());
return new File(directory, Integer.toString(key.hashCode())); for (FileCacheDownloader downloader : downloaders) {
Logger.i(TAG, "url = " + downloader.getUrl() + " cancelled = " + downloader.cancelled);
}
} }
public void put(File file) { public void clearCache() {
size += file.length(); Logger.d(TAG, "Clearing cache");
for (FileCacheDownloader downloader : downloaders) {
trim(); downloader.cancel();
} }
public boolean delete(File file) { if (directory.exists() && directory.isDirectory()) {
size -= file.length(); for (File file : directory.listFiles()) {
if (!file.delete()) {
Logger.d(TAG, "Could not delete cache file while clearing cache " + file.getName());
}
}
}
calculateSize();
}
return file.delete(); /**
* Start downloading the file located at the url.<br>
* If the file is in the cache then the callback is executed immediately and null is returned.<br>
* Otherwise if the file is downloading or has not yet started downloading an {@link FileCacheDownloader} is returned.<br>
* Only call this method on the UI thread.<br>
*
* @param urlString the url to download.
* @param callback callback to execute callbacks on.
* @return null if in the cache, {@link FileCacheDownloader} otherwise.
*/
public FileCacheDownloader downloadFile(final String urlString, final DownloadedCallback callback) {
FileCacheDownloader downloader = null;
for (FileCacheDownloader downloaderItem : downloaders) {
if (downloaderItem.getUrl().equals(urlString)) {
downloader = downloaderItem;
break;
}
} }
public Future<?> downloadFile(final String urlString, final DownloadedCallback callback) { if (downloader != null) {
downloader.addCallback(callback);
return downloader;
} else {
File file = get(urlString); File file = get(urlString);
if (file.exists()) { if (file.exists()) {
file.setLastModified(Time.get()); // TODO: setLastModified doesn't seem to work on Android...
if (!file.setLastModified(Time.get())) {
// Logger.e(TAG, "Could not set last modified time on file");
}
callback.onProgress(0, 0, true); callback.onProgress(0, 0, true);
callback.onSuccess(file); callback.onSuccess(file);
return null; return null;
} else { } else {
FileCacheDownloader downloader = new FileCacheDownloader(this, urlString, file, callback); FileCacheDownloader newDownloader = new FileCacheDownloader(this, urlString, file, userAgent);
return executor.submit(downloader); newDownloader.addCallback(callback);
Future<?> future = executor.submit(newDownloader);
newDownloader.setFuture(future);
downloaders.add(newDownloader);
return newDownloader;
}
}
}
public File get(String key) {
makeDir();
return new File(directory, Integer.toString(key.hashCode()));
} }
private void put(File file) {
size += file.length();
trim();
}
private boolean delete(File file) {
size -= file.length();
return file.delete();
} }
private void makeDir() { private void makeDir() {
@ -106,9 +175,9 @@ public class FileCache {
private void trim() { private void trim() {
int tries = 0; int tries = 0;
while (size > maxSize && tries++ < 10) { while (size > maxSize && tries++ < TRIM_TRIES) {
File[] files = directory.listFiles(); File[] files = directory.listFiles();
if (files == null) { if (files == null || files.length <= 1) {
break; break;
} }
long age = Long.MAX_VALUE; long age = Long.MAX_VALUE;
@ -123,12 +192,12 @@ public class FileCache {
} }
if (oldest == null) { if (oldest == null) {
Log.e(TAG, "No files to trim"); Logger.e(TAG, "No files to trim");
break; break;
} else { } else {
Log.d(TAG, "Deleting " + oldest.getAbsolutePath()); Logger.d(TAG, "Deleting " + oldest.getAbsolutePath());
if (!delete(oldest)) { if (!delete(oldest)) {
Log.e(TAG, "Cannot delete cache file"); Logger.e(TAG, "Cannot delete cache file while trimming");
calculateSize(); calculateSize();
break; break;
} }
@ -149,6 +218,10 @@ public class FileCache {
} }
} }
private void removeFromDownloaders(FileCacheDownloader downloader) {
downloaders.remove(downloader);
}
public interface DownloadedCallback { public interface DownloadedCallback {
void onProgress(long downloaded, long total, boolean done); void onProgress(long downloaded, long total, boolean done);
@ -157,51 +230,89 @@ public class FileCache {
void onFail(boolean notFound); void onFail(boolean notFound);
} }
private static class FileCacheDownloader implements Runnable { public static class FileCacheDownloader implements Runnable {
private final FileCache fileCache; private final FileCache fileCache;
private final String url; private final String url;
private final File output; private final File output;
private final DownloadedCallback callback; private final String userAgent;
private boolean cancelled = false;
// Modify the callbacks list on the UI thread only!
private final List<DownloadedCallback> callbacks = new ArrayList<>();
private AtomicBoolean running = new AtomicBoolean(false);
private AtomicBoolean userCancelled = new AtomicBoolean(false);
private Closeable downloadInput; private Closeable downloadInput;
private Closeable downloadOutput; private Closeable downloadOutput;
private Call call; private Call call;
private ResponseBody body; private ResponseBody body;
private boolean cancelled = false;
private Future<?> future;
public FileCacheDownloader(FileCache fileCache, String url, File output, DownloadedCallback callback) { private FileCacheDownloader(FileCache fileCache, String url, File output, String userAgent) {
this.fileCache = fileCache; this.fileCache = fileCache;
this.url = url; this.url = url;
this.output = output; this.output = output;
this.callback = callback; this.userAgent = userAgent;
}
public String getUrl() {
return url;
}
public void addCallback(DownloadedCallback callback) {
callbacks.add(callback);
}
/**
* Cancel this download by interrupting the downloading thread. No callbacks will be executed.
*/
public void cancel() {
if (userCancelled.compareAndSet(false, true)) {
future.cancel(true);
// Did not start running yet, call cancelDueToCancellation manually to remove from downloaders list.
if (!running.get()) {
cancelDueToCancellation();
}
}
} }
public void run() { public void run() {
Logger.d(TAG, "Start load of " + url);
try { try {
running.set(true);
execute(); execute();
} catch (InterruptedIOException | InterruptedException e) {
cancelDueToCancellation(e);
} catch (Exception e) { } catch (Exception e) {
if (userCancelled.get()) {
cancelDueToCancellation();
} else {
cancelDueToException(e); cancelDueToException(e);
}
} finally { } finally {
finish(); cleanup();
}
} }
private void setFuture(Future<?> future) {
this.future = future;
} }
private void cancelDueToException(Exception e) { private void cancelDueToException(Exception e) {
if (cancelled) return; if (cancelled) return;
cancelled = true; cancelled = true;
Log.w(TAG, "IOException downloading file", e); Logger.w(TAG, "IOException downloading url " + url, e);
purgeOutput();
post(new Runnable() { post(new Runnable() {
@Override @Override
public void run() { public void run() {
purgeOutput();
removeFromDownloadersList();
for (DownloadedCallback callback : callbacks) {
callback.onProgress(0, 0, true); callback.onProgress(0, 0, true);
callback.onFail(false); callback.onFail(false);
} }
}
}); });
} }
@ -209,44 +320,57 @@ public class FileCache {
if (cancelled) return; if (cancelled) return;
cancelled = true; cancelled = true;
Log.w(TAG, "Cancel due to http error, code: " + code); Logger.w(TAG, "Cancel " + url + " due to http error, code: " + code);
purgeOutput();
post(new Runnable() { post(new Runnable() {
@Override @Override
public void run() { public void run() {
purgeOutput();
removeFromDownloadersList();
for (DownloadedCallback callback : callbacks) {
callback.onProgress(0, 0, true); callback.onProgress(0, 0, true);
callback.onFail(code == 404); callback.onFail(code == 404);
} }
}
}); });
} }
private void cancelDueToCancellation(Exception e) { private void cancelDueToCancellation() {
if (cancelled) return; if (cancelled) return;
cancelled = true; cancelled = true;
Log.d(TAG, "Cancel due to cancellation"); Logger.d(TAG, "Cancel " + url + " due to cancellation");
post(new Runnable() {
@Override
public void run() {
purgeOutput(); purgeOutput();
removeFromDownloadersList();
// No callback }
});
} }
private void success() { private void success() {
fileCache.put(output); Logger.d(TAG, "Success downloading " + url);
post(new Runnable() { post(new Runnable() {
@Override @Override
public void run() { public void run() {
fileCache.put(output);
removeFromDownloadersList();
for (DownloadedCallback callback : callbacks) {
callback.onProgress(0, 0, true); callback.onProgress(0, 0, true);
callback.onSuccess(output); callback.onSuccess(output);
} }
}
}); });
call = null; call = null;
} }
private void finish() { /**
* Always called before any cancelDueTo method or success on the downloading thread.
*/
private void cleanup() {
Util.closeQuietly(downloadInput); Util.closeQuietly(downloadInput);
Util.closeQuietly(downloadOutput); Util.closeQuietly(downloadOutput);
@ -261,29 +385,27 @@ public class FileCache {
} }
} }
private void removeFromDownloadersList() {
fileCache.removeFromDownloaders(this);
}
private void purgeOutput() { private void purgeOutput() {
if (output.exists()) { if (output.exists()) {
if (!output.delete()) { if (!output.delete()) {
Log.w(TAG, "Could not delete the file in purgeOutput"); Logger.w(TAG, "Could not delete the file in purgeOutput");
} }
} }
} }
private long progressDownloaded; private void postProgress(final long downloaded, final long total, final boolean done) {
private long progressTotal; post(new Runnable() {
private boolean progressDone;
private final Runnable progressRunnable = new Runnable() {
@Override @Override
public void run() { public void run() {
callback.onProgress(progressDownloaded, progressTotal, progressDone); for (DownloadedCallback callback : callbacks) {
callback.onProgress(downloaded, total, done);
} }
}; }
});
private void progress(long downloaded, long total, boolean done) {
progressDownloaded = downloaded;
progressTotal = total;
progressDone = done;
post(progressRunnable);
} }
private void post(Runnable runnable) { private void post(Runnable runnable) {
@ -293,7 +415,7 @@ public class FileCache {
private void execute() throws Exception { private void execute() throws Exception {
Request request = new Request.Builder() Request request = new Request.Builder()
.url(url) .url(url)
.header("User-Agent", FileCache.userAgent) .header("User-Agent", userAgent)
.build(); .build();
call = fileCache.httpClient.newCall(request); call = fileCache.httpClient.newCall(request);
@ -311,6 +433,8 @@ public class FileCache {
downloadInput = source; downloadInput = source;
downloadOutput = outputStream; downloadOutput = outputStream;
Logger.d(TAG, "Got input stream for " + url);
int read; int read;
long total = 0; long total = 0;
long totalLast = 0; long totalLast = 0;
@ -321,8 +445,10 @@ public class FileCache {
if (total >= totalLast + 16384) { if (total >= totalLast + 16384) {
totalLast = total; totalLast = total;
progress(total, contentLength, false); postProgress(total, contentLength <= 0 ? total : contentLength, false);
} }
if (Thread.currentThread().isInterrupted()) throw new InterruptedIOException();
} }
if (Thread.currentThread().isInterrupted()) throw new InterruptedIOException(); if (Thread.currentThread().isInterrupted()) throw new InterruptedIOException();

Loading…
Cancel
Save