diff --git a/Clover/app/src/main/AndroidManifest.xml b/Clover/app/src/main/AndroidManifest.xml index 036db05b..5ebdb2af 100644 --- a/Clover/app/src/main/AndroidManifest.xml +++ b/Clover/app/src/main/AndroidManifest.xml @@ -37,6 +37,8 @@ along with this program. If not, see . + + - +
Loading captcha...
+
diff --git a/Clover/app/src/main/java/org/floens/chan/ChanApplication.java b/Clover/app/src/main/java/org/floens/chan/ChanApplication.java index 707006fd..d8ea649c 100644 --- a/Clover/app/src/main/java/org/floens/chan/ChanApplication.java +++ b/Clover/app/src/main/java/org/floens/chan/ChanApplication.java @@ -19,6 +19,7 @@ package org.floens.chan; import android.app.Application; import android.content.Context; +import android.content.pm.PackageManager; import android.os.StrictMode; import android.view.ViewConfiguration; @@ -40,6 +41,7 @@ import org.floens.chan.utils.Logger; import java.io.File; import java.lang.reflect.Field; +import java.util.Locale; import de.greenrobot.event.EventBus; @@ -62,6 +64,7 @@ public class ChanApplication extends Application { private static DatabaseManager databaseManager; private static FileCache fileCache; + private String userAgent; private int activityForegroundCounter = 0; public ChanApplication() { @@ -126,6 +129,16 @@ public class ChanApplication extends Application { ChanUrls.loadScheme(ChanSettings.getNetworkHttps()); + // User agent is / + 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); cleanupOutdated(); @@ -134,15 +147,18 @@ public class ChanApplication extends Application { 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)); - 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); boardManager = new BoardManager(); watchManager = new WatchManager(this); + } + public String getUserAgent() { + return userAgent; } public void activityEnteredForeground() { diff --git a/Clover/app/src/main/java/org/floens/chan/core/manager/BoardManager.java b/Clover/app/src/main/java/org/floens/chan/core/manager/BoardManager.java index 9053f803..d2bf5c6f 100644 --- a/Clover/app/src/main/java/org/floens/chan/core/manager/BoardManager.java +++ b/Clover/app/src/main/java/org/floens/chan/core/manager/BoardManager.java @@ -101,13 +101,6 @@ public class BoardManager { } 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(); ChanApplication.getDatabaseManager().setBoards(allBoards); @@ -130,8 +123,6 @@ public class BoardManager { has = false; for (int i = 0; i < allBoards.size(); i++) { if (allBoards.get(i).value.equals(serverBoard.value)) { - Logger.d(TAG, "Replaced board " + serverBoard.value + " with the server one"); - Board old = allBoards.get(i); serverBoard.id = old.id; serverBoard.saved = old.saved; @@ -162,7 +153,6 @@ public class BoardManager { new BoardsRequest(ChanUrls.getBoardsUrl(), new Response.Listener>() { @Override public void onResponse(List data) { - Logger.i(TAG, "Got boards from server"); setBoardsFromServer(data); } }, new Response.ErrorListener() { diff --git a/Clover/app/src/main/java/org/floens/chan/core/manager/ReplyManager.java b/Clover/app/src/main/java/org/floens/chan/core/manager/ReplyManager.java index 644a228c..43d55887 100644 --- a/Clover/app/src/main/java/org/floens/chan/core/manager/ReplyManager.java +++ b/Clover/app/src/main/java/org/floens/chan/core/manager/ReplyManager.java @@ -19,7 +19,6 @@ package org.floens.chan.core.manager; import android.content.Context; import android.content.Intent; -import android.content.pm.PackageManager; import android.text.TextUtils; import com.squareup.okhttp.Callback; @@ -63,7 +62,6 @@ public class ReplyManager { private Reply draft; private FileListener fileListener; private final Random random = new Random(); - private String userAgent; private OkHttpClient client; public ReplyManager(Context context) { @@ -74,21 +72,6 @@ public class ReplyManager { client.setConnectTimeout(TIMEOUT, TimeUnit.MILLISECONDS); client.setReadTimeout(TIMEOUT, TimeUnit.MILLISECONDS); client.setWriteTimeout(TIMEOUT, TimeUnit.MILLISECONDS); - - // User agent is / - 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) { - requestBuilder.header("User-Agent", getUserAgent()); + requestBuilder.header("User-Agent", ChanApplication.getInstance().getUserAgent()); Request request = requestBuilder.build(); client.newCall(request).enqueue(callback); } diff --git a/Clover/app/src/main/java/org/floens/chan/test/TestActivity.java b/Clover/app/src/main/java/org/floens/chan/test/TestActivity.java new file mode 100644 index 00000000..136a8013 --- /dev/null +++ b/Clover/app/src/main/java/org/floens/chan/test/TestActivity.java @@ -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 . + */ +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); + } +} diff --git a/Clover/app/src/main/java/org/floens/chan/ui/fragment/ReplyFragment.java b/Clover/app/src/main/java/org/floens/chan/ui/fragment/ReplyFragment.java index 3e059d79..f9892cb7 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/fragment/ReplyFragment.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/fragment/ReplyFragment.java @@ -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); captchaLayout.initCaptcha(baseUrl, ChanUrls.getCaptchaSiteKey(), - ThemeHelper.getInstance().getTheme().isLightTheme, ChanApplication.getReplyManager().getUserAgent(), this); + ThemeHelper.getInstance().getTheme().isLightTheme, ChanApplication.getInstance().getUserAgent(), this); } else { Logger.e(TAG, "Loadable in ReplyFragment was null"); closeReply(); @@ -581,8 +581,8 @@ public class ReplyFragment extends DialogFragment implements CaptchaLayout.Captc if (ChanSettings.passLoggedIn()) { flipPage(0); } else { - captchaLayout.reset(); flipPage(1); + captchaLayout.reset(); } } else if (response.isSuccessful) { shouldSaveDraft = false; diff --git a/Clover/app/src/main/java/org/floens/chan/ui/layout/CaptchaLayout.java b/Clover/app/src/main/java/org/floens/chan/ui/layout/CaptchaLayout.java index 63777362..a681ca13 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/layout/CaptchaLayout.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/layout/CaptchaLayout.java @@ -21,7 +21,10 @@ import android.annotation.SuppressLint; import android.content.Context; import android.text.TextUtils; import android.util.AttributeSet; +import android.util.Log; +import android.webkit.ConsoleMessage; import android.webkit.JavascriptInterface; +import android.webkit.WebChromeClient; import android.webkit.WebSettings; import android.webkit.WebView; @@ -29,6 +32,8 @@ import org.floens.chan.utils.AndroidUtils; import org.floens.chan.utils.IOUtils; public class CaptchaLayout extends WebView { + private static final String TAG = "CaptchaLayout"; + private CaptchaCallback callback; private boolean loaded = false; private String baseUrl; @@ -58,6 +63,15 @@ public class CaptchaLayout extends WebView { settings.setJavaScriptEnabled(true); 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"); } diff --git a/Clover/app/src/main/java/org/floens/chan/ui/view/MultiImageView.java b/Clover/app/src/main/java/org/floens/chan/ui/view/MultiImageView.java index 382431a5..9e7ae5ca 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/view/MultiImageView.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/view/MultiImageView.java @@ -45,7 +45,6 @@ import org.floens.chan.utils.Logger; import java.io.File; import java.io.IOException; -import java.util.concurrent.Future; import pl.droidsonroids.gif.GifDrawable; import pl.droidsonroids.gif.GifImageView; @@ -64,9 +63,9 @@ public class MultiImageView extends FrameLayout implements View.OnClickListener private boolean hasContent = false; private ImageContainer thumbnailRequest; - private Future bigImageRequest; - private Future gifRequest; - private Future videoRequest; + private FileCache.FileCacheDownloader bigImageRequest; + private FileCache.FileCacheDownloader gifRequest; + private FileCache.FileCacheDownloader videoRequest; private VideoView videoView; private boolean videoError = false; @@ -398,13 +397,13 @@ public class MultiImageView extends FrameLayout implements View.OnClickListener thumbnailRequest.cancelRequest(); } if (bigImageRequest != null) { - bigImageRequest.cancel(true); + bigImageRequest.cancel(); } if (gifRequest != null) { - gifRequest.cancel(true); + gifRequest.cancel(); } if (videoRequest != null) { - videoRequest.cancel(true); + videoRequest.cancel(); } } diff --git a/Clover/app/src/main/java/org/floens/chan/utils/FileCache.java b/Clover/app/src/main/java/org/floens/chan/utils/FileCache.java index 0ed17843..0fc17225 100644 --- a/Clover/app/src/main/java/org/floens/chan/utils/FileCache.java +++ b/Clover/app/src/main/java/org/floens/chan/utils/FileCache.java @@ -17,83 +17,152 @@ */ 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.OkHttpClient; +import com.squareup.okhttp.Protocol; import com.squareup.okhttp.Request; import com.squareup.okhttp.Response; import com.squareup.okhttp.ResponseBody; import com.squareup.okhttp.internal.Util; -import org.floens.chan.ChanApplication; - import java.io.BufferedOutputStream; import java.io.Closeable; import java.io.File; import java.io.FileOutputStream; import java.io.InterruptedIOException; 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.Executors; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import okio.BufferedSource; public class 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 static String userAgent; private final File directory; private final long maxSize; - private long size; - public FileCache(File directory, long maxSize) { + private List downloaders = new ArrayList<>(); + + public FileCache(File directory, long maxSize, String userAgent) { this.directory = directory; this.maxSize = maxSize; + this.userAgent = userAgent; 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(); calculateSize(); } + public void logStats() { + Logger.i(TAG, "Cache size = " + size + "/" + maxSize); + Logger.i(TAG, "downloaders.size() = " + downloaders.size()); + for (FileCacheDownloader downloader : downloaders) { + Logger.i(TAG, "url = " + downloader.getUrl() + " cancelled = " + downloader.cancelled); + } + } + + public void clearCache() { + Logger.d(TAG, "Clearing cache"); + for (FileCacheDownloader downloader : downloaders) { + downloader.cancel(); + } + + if (directory.exists() && directory.isDirectory()) { + for (File file : directory.listFiles()) { + if (!file.delete()) { + Logger.d(TAG, "Could not delete cache file while clearing cache " + file.getName()); + } + } + } + calculateSize(); + } + + /** + * Start downloading the file located at the url.
+ * If the file is in the cache then the callback is executed immediately and null is returned.
+ * Otherwise if the file is downloading or has not yet started downloading an {@link FileCacheDownloader} is returned.
+ * Only call this method on the UI thread.
+ * + * @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; + } + } + + if (downloader != null) { + downloader.addCallback(callback); + return downloader; + } else { + File file = get(urlString); + if (file.exists()) { + // 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.onSuccess(file); + return null; + } else { + FileCacheDownloader newDownloader = new FileCacheDownloader(this, urlString, file, userAgent); + 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())); } - public void put(File file) { + private void put(File file) { size += file.length(); trim(); } - public boolean delete(File file) { + private boolean delete(File file) { size -= file.length(); return file.delete(); } - public Future downloadFile(final String urlString, final DownloadedCallback callback) { - File file = get(urlString); - if (file.exists()) { - file.setLastModified(Time.get()); - callback.onProgress(0, 0, true); - callback.onSuccess(file); - return null; - } else { - FileCacheDownloader downloader = new FileCacheDownloader(this, urlString, file, callback); - return executor.submit(downloader); - } - } - private void makeDir() { if (!directory.exists()) { if (!directory.mkdirs()) { @@ -106,9 +175,9 @@ public class FileCache { private void trim() { int tries = 0; - while (size > maxSize && tries++ < 10) { + while (size > maxSize && tries++ < TRIM_TRIES) { File[] files = directory.listFiles(); - if (files == null) { + if (files == null || files.length <= 1) { break; } long age = Long.MAX_VALUE; @@ -123,12 +192,12 @@ public class FileCache { } if (oldest == null) { - Log.e(TAG, "No files to trim"); + Logger.e(TAG, "No files to trim"); break; } else { - Log.d(TAG, "Deleting " + oldest.getAbsolutePath()); + Logger.d(TAG, "Deleting " + oldest.getAbsolutePath()); if (!delete(oldest)) { - Log.e(TAG, "Cannot delete cache file"); + Logger.e(TAG, "Cannot delete cache file while trimming"); calculateSize(); break; } @@ -149,6 +218,10 @@ public class FileCache { } } + private void removeFromDownloaders(FileCacheDownloader downloader) { + downloaders.remove(downloader); + } + public interface DownloadedCallback { void onProgress(long downloaded, long total, boolean done); @@ -157,50 +230,88 @@ public class FileCache { void onFail(boolean notFound); } - private static class FileCacheDownloader implements Runnable { + public static class FileCacheDownloader implements Runnable { private final FileCache fileCache; private final String url; private final File output; - private final DownloadedCallback callback; - private boolean cancelled = false; + private final String userAgent; + + // Modify the callbacks list on the UI thread only! + private final List callbacks = new ArrayList<>(); + + private AtomicBoolean running = new AtomicBoolean(false); + private AtomicBoolean userCancelled = new AtomicBoolean(false); private Closeable downloadInput; private Closeable downloadOutput; private Call call; 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.url = url; 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() { + Logger.d(TAG, "Start load of " + url); try { + running.set(true); execute(); - } catch (InterruptedIOException | InterruptedException e) { - cancelDueToCancellation(e); } catch (Exception e) { - cancelDueToException(e); + if (userCancelled.get()) { + cancelDueToCancellation(); + } else { + cancelDueToException(e); + } } finally { - finish(); + cleanup(); } } + private void setFuture(Future future) { + this.future = future; + } + private void cancelDueToException(Exception e) { if (cancelled) return; cancelled = true; - Log.w(TAG, "IOException downloading file", e); - - purgeOutput(); + Logger.w(TAG, "IOException downloading url " + url, e); post(new Runnable() { @Override public void run() { - callback.onProgress(0, 0, true); - callback.onFail(false); + purgeOutput(); + removeFromDownloadersList(); + for (DownloadedCallback callback : callbacks) { + callback.onProgress(0, 0, true); + callback.onFail(false); + } } }); } @@ -209,44 +320,57 @@ public class FileCache { if (cancelled) return; cancelled = true; - Log.w(TAG, "Cancel due to http error, code: " + code); - - purgeOutput(); + Logger.w(TAG, "Cancel " + url + " due to http error, code: " + code); post(new Runnable() { @Override public void run() { - callback.onProgress(0, 0, true); - callback.onFail(code == 404); + purgeOutput(); + removeFromDownloadersList(); + for (DownloadedCallback callback : callbacks) { + callback.onProgress(0, 0, true); + callback.onFail(code == 404); + } } }); } - private void cancelDueToCancellation(Exception e) { + private void cancelDueToCancellation() { if (cancelled) return; cancelled = true; - Log.d(TAG, "Cancel due to cancellation"); - - purgeOutput(); + Logger.d(TAG, "Cancel " + url + " due to cancellation"); - // No callback + post(new Runnable() { + @Override + public void run() { + purgeOutput(); + removeFromDownloadersList(); + } + }); } private void success() { - fileCache.put(output); + Logger.d(TAG, "Success downloading " + url); post(new Runnable() { @Override public void run() { - callback.onProgress(0, 0, true); - callback.onSuccess(output); + fileCache.put(output); + removeFromDownloadersList(); + for (DownloadedCallback callback : callbacks) { + callback.onProgress(0, 0, true); + callback.onSuccess(output); + } } }); 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(downloadOutput); @@ -261,29 +385,27 @@ public class FileCache { } } + private void removeFromDownloadersList() { + fileCache.removeFromDownloaders(this); + } + private void purgeOutput() { if (output.exists()) { 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 long progressTotal; - private boolean progressDone; - private final Runnable progressRunnable = new Runnable() { - @Override - public void run() { - callback.onProgress(progressDownloaded, progressTotal, progressDone); - } - }; - - private void progress(long downloaded, long total, boolean done) { - progressDownloaded = downloaded; - progressTotal = total; - progressDone = done; - post(progressRunnable); + private void postProgress(final long downloaded, final long total, final boolean done) { + post(new Runnable() { + @Override + public void run() { + for (DownloadedCallback callback : callbacks) { + callback.onProgress(downloaded, total, done); + } + } + }); } private void post(Runnable runnable) { @@ -293,7 +415,7 @@ public class FileCache { private void execute() throws Exception { Request request = new Request.Builder() .url(url) - .header("User-Agent", FileCache.userAgent) + .header("User-Agent", userAgent) .build(); call = fileCache.httpClient.newCall(request); @@ -311,6 +433,8 @@ public class FileCache { downloadInput = source; downloadOutput = outputStream; + Logger.d(TAG, "Got input stream for " + url); + int read; long total = 0; long totalLast = 0; @@ -321,8 +445,10 @@ public class FileCache { if (total >= totalLast + 16384) { 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();