package cl.monsoon.s1next.widget;
import android.content.res.Resources;
import android.util.LruCache;
import com.bumptech.glide.Priority;
import com.bumptech.glide.disklrucache.DiskLruCache;
import com.bumptech.glide.load.Key;
import com.bumptech.glide.load.data.DataFetcher;
import com.bumptech.glide.load.model.GlideUrl;
import com.bumptech.glide.util.ContentLengthInputStream;
import com.bumptech.glide.util.Util;
import com.google.common.io.Closeables;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import cl.monsoon.s1next.App;
import cl.monsoon.s1next.BuildConfig;
import cl.monsoon.s1next.R;
import cl.monsoon.s1next.data.api.Api;
import cl.monsoon.s1next.data.pref.DownloadPreferencesManager;
import okhttp3.Call;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import static java.net.HttpURLConnection.HTTP_BAD_METHOD;
import static java.net.HttpURLConnection.HTTP_GONE;
import static java.net.HttpURLConnection.HTTP_MOVED_PERM;
import static java.net.HttpURLConnection.HTTP_MOVED_TEMP;
import static java.net.HttpURLConnection.HTTP_MULT_CHOICE;
import static java.net.HttpURLConnection.HTTP_NOT_AUTHORITATIVE;
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
import static java.net.HttpURLConnection.HTTP_NOT_IMPLEMENTED;
import static java.net.HttpURLConnection.HTTP_NO_CONTENT;
import static java.net.HttpURLConnection.HTTP_OK;
import static java.net.HttpURLConnection.HTTP_REQ_TOO_LONG;
import static okhttp3.internal.http.StatusLine.HTTP_PERM_REDIRECT;
import static okhttp3.internal.http.StatusLine.HTTP_TEMP_REDIRECT;
/**
* Fetches an {@link java.io.InputStream} using the OkHttp library.
* <p>
* Forked from https://github.com/bumptech/glide/blob/master/integration/okhttp/src/main/java/com/bumptech/glide/integration/okhttp/OkHttpStreamFetcher.java
*/
final class OkHttpStreamFetcher implements DataFetcher<InputStream> {
private final Resources mResources;
private final DownloadPreferencesManager mDownloadPreferencesManager;
private final OkHttpClient mOkHttpClient;
private final GlideUrl mGlideUrl;
private volatile Call mCall;
private ResponseBody mResponseBody;
private InputStream mInputStream;
public OkHttpStreamFetcher(OkHttpClient okHttpClient, GlideUrl glideUrl) {
this.mOkHttpClient = okHttpClient;
this.mGlideUrl = glideUrl;
mResources = App.get().getResources();
mDownloadPreferencesManager = App.getAppComponent(App.get()).getDownloadPreferencesManager();
}
@Override
public InputStream loadData(Priority priority) throws IOException {
Key key = null;
String url = mGlideUrl.toStringUrl();
if (Api.isAvatarUrl(url)) {
key = new OriginalKey(url,
mDownloadPreferencesManager.getAvatarCacheInvalidationIntervalSignature());
if (AvatarUrlsCache.has(key)) {
// already have cached this avatar url
mInputStream = mResources.openRawResource(+R.drawable.ic_avatar_placeholder);
return mInputStream;
}
}
Request request = new Request.Builder()
.url(url)
.build();
mCall = mOkHttpClient.newCall(request);
Response response = mCall.execute();
mResponseBody = response.body();
if (!response.isSuccessful()) {
// if (this this a avatar URL) && (this URL is cacheable)
if (key != null && isCacheable(response)) {
AvatarUrlsCache.put(key);
mInputStream = mResources.openRawResource(+R.drawable.ic_avatar_placeholder);
return mInputStream;
}
throw new IOException("Response (status code " + response.code() + ") is unsuccessful.");
}
long contentLength = mResponseBody.contentLength();
mInputStream = ContentLengthInputStream.obtain(mResponseBody.byteStream(), contentLength);
return mInputStream;
}
@Override
public void cleanup() {
Closeables.closeQuietly(mInputStream);
try {
Closeables.close(mResponseBody, true);
} catch (IOException ignored) {
}
}
@Override
public String getId() {
return mGlideUrl.getCacheKey();
}
@Override
public void cancel() {
if (mCall != null) {
mCall.cancel();
mCall = null;
}
}
/**
* Forked form {@link okhttp3.internal.http.CacheStrategy#isCacheable(Response, Request)}.
*/
private static boolean isCacheable(Response response) {
// Always go to network for uncacheable response codes (RFC 7231 section 6.1),
// This implementation doesn't support caching partial content.
switch (response.code()) {
case HTTP_OK:
case HTTP_NOT_AUTHORITATIVE:
case HTTP_NO_CONTENT:
case HTTP_MULT_CHOICE:
case HTTP_MOVED_PERM:
case HTTP_NOT_FOUND:
case HTTP_BAD_METHOD:
case HTTP_GONE:
case HTTP_REQ_TOO_LONG:
case HTTP_NOT_IMPLEMENTED:
case HTTP_PERM_REDIRECT:
// These codes can be cached unless headers forbid it.
break;
case HTTP_MOVED_TEMP:
case HTTP_TEMP_REDIRECT:
// These codes can only be cached with the right response headers.
// http://tools.ietf.org/html/rfc7234#section-3
// s-maxage is not checked because OkHttp is a private cache that should ignore s-maxage.
if (response.header("Expires") != null
|| response.cacheControl().maxAgeSeconds() != -1
|| response.cacheControl().isPublic()
|| response.cacheControl().isPrivate()) {
break;
}
// Fall-through.
default:
// All other codes cannot be cached.
return false;
}
return true;
}
/**
* We would get 404 status code if user hasn't set up their
* avatar (in this case, we use the default avatar for this user).
* So we can use this mechanism to cache these user avatars
* (because they just use the default avatar).
* <p>
* According to RFC (http://tools.ietf.org/html/rfc7231#section-6.1),
* we could cache some responses with specific status codes (like 301, 404 and 405).
* Glide only cache the images whose response is successful.
* But we also cache those user avatar URLs whose status code
* is cacheable in order to tell Glide we use the default
* avatar (error placeholder in implementation).
* <p>
* If this cache contains an avatar URL that Glide requests,
* just throw an exception to tell Glide to use the error
* placeholder for this image.
*
* @see cl.monsoon.s1next.widget.OkHttpStreamFetcher#loadData(com.bumptech.glide.Priority)
*/
private enum AvatarUrlsCache {
INSTANCE;
private static final int MEMORY_CACHE_MAX_NUMBER = 1000;
private static final String DISK_CACHE_DIRECTORY = "avatar_urls_disk_cache";
private static final long DISK_CACHE_MAX_SIZE = 1000 * 1000; // 1MB
/**
* We only cache the avatar URLs as keys.
* So we use this to represent the
* null value because {@link android.util.LruCache}
* doesn't accept null as a value.
*/
private static final Object NULL_VALUE = new Object();
private static final Object DISK_CACHE_LOCK = new Object();
/**
* We use both disk cache and memory cache.
*/
private final DiskLruCache diskLruCache;
private final LruCache<String, Object> lruCache;
private final KeyGenerator keyGenerator;
AvatarUrlsCache() {
lruCache = new LruCache<>(MEMORY_CACHE_MAX_NUMBER);
File file = new File(App.get().getCacheDir().getPath()
+ File.separator + DISK_CACHE_DIRECTORY);
try {
diskLruCache = DiskLruCache.open(file, BuildConfig.VERSION_CODE, 1,
DISK_CACHE_MAX_SIZE);
} catch (IOException e) {
throw new RuntimeException("Failed to open the cache in " + file + ".", e);
}
keyGenerator = new KeyGenerator();
}
private static boolean has(Key key) {
String encodedKey = INSTANCE.keyGenerator.getKey(key);
if (encodedKey == null) {
return false;
}
if (INSTANCE.lruCache.get(encodedKey) != null) {
return true;
}
try {
synchronized (DISK_CACHE_LOCK) {
return INSTANCE.diskLruCache.get(encodedKey) != null;
}
} catch (IOException ignore) {
return false;
}
}
private static void put(Key key) {
String encodedKey = INSTANCE.keyGenerator.getKey(key);
if (encodedKey == null) {
return;
}
INSTANCE.lruCache.put(encodedKey, NULL_VALUE);
try {
synchronized (DISK_CACHE_LOCK) {
DiskLruCache.Editor editor = INSTANCE.diskLruCache.edit(encodedKey);
if (editor.getFile(0).createNewFile()) {
editor.commit();
} else {
editor.abort();
}
}
} catch (IOException ignore) {
}
}
/**
* Forked from {@link com.bumptech.glide.load.engine.cache.SafeKeyGenerator}.
*/
private static final class KeyGenerator {
private static final int AVATAR_URL_KEYS_MEMORY_CACHE_MAX_NUMBER = 1000;
private final LruCache<Key, String> lruCache = new LruCache<>(AVATAR_URL_KEYS_MEMORY_CACHE_MAX_NUMBER);
public String getKey(Key key) {
String value = lruCache.get(key);
if (value == null) {
try {
// TODO: https://github.com/bumptech/glide/pull/798 when Glide 4 was released
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
key.updateDiskCacheKey(messageDigest);
value = Util.sha256BytesToHex(messageDigest.digest());
lruCache.put(key, value);
} catch (NoSuchAlgorithmException | UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
}
return value;
}
}
}
/**
* Forked from {@link com.bumptech.glide.load.engine.OriginalKey}.
*/
private static final class OriginalKey implements Key {
private final String id;
private final Key signature;
public OriginalKey(String id, Key signature) {
this.id = id;
this.signature = signature;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
OriginalKey that = (OriginalKey) o;
return id.equals(that.id) && signature.equals(that.signature);
}
@Override
public int hashCode() {
int result = id.hashCode();
result = 31 * result + signature.hashCode();
return result;
}
@Override
public void updateDiskCacheKey(MessageDigest messageDigest) throws UnsupportedEncodingException {
messageDigest.update(id.getBytes(STRING_CHARSET_NAME));
signature.updateDiskCacheKey(messageDigest);
}
}
}