Add a cache for GIFs.

This commit is contained in:
Greyson Parrelli
2021-09-03 16:32:16 -04:00
committed by Cody Henthorne
parent 8e020c05f6
commit c84de8fa60
7 changed files with 238 additions and 50 deletions

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.video.exo;
import android.content.Context;
import android.net.Uri;
import androidx.annotation.NonNull;
@@ -10,6 +11,9 @@ import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DataSpec;
import com.google.android.exoplayer2.upstream.TransferListener;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.net.ChunkedDataFetcher;
import java.io.EOFException;
@@ -28,9 +32,10 @@ public class ChunkedDataSource implements DataSource {
private final OkHttpClient okHttpClient;
private final TransferListener transferListener;
private DataSpec dataSpec;
private volatile InputStream inputStream;
private volatile Exception exception;
private DataSpec dataSpec;
private GiphyMp4Cache.ReadData cacheEntry;
private volatile Exception exception;
ChunkedDataSource(@NonNull OkHttpClient okHttpClient, @Nullable TransferListener listener) {
this.okHttpClient = okHttpClient;
@@ -46,57 +51,73 @@ public class ChunkedDataSource implements DataSource {
this.dataSpec = dataSpec;
this.exception = null;
if (inputStream != null) {
inputStream.close();
if (cacheEntry != null) {
cacheEntry.release();
}
this.inputStream = null;
CountDownLatch countDownLatch = new CountDownLatch(1);
ChunkedDataFetcher fetcher = new ChunkedDataFetcher(okHttpClient);
fetcher.fetch(this.dataSpec.uri.toString(), dataSpec.length, new ChunkedDataFetcher.Callback() {
@Override
public void onSuccess(InputStream stream) {
inputStream = stream;
countDownLatch.countDown();
}
@Override
public void onFailure(Exception e) {
exception = e;
countDownLatch.countDown();
}
});
// XXX Android can't handle all videos starting at once, so this randomly offsets them
try {
countDownLatch.await(30, TimeUnit.SECONDS);
Thread.sleep((long) (Math.random() * 750));
} catch (InterruptedException e) {
throw new IOException(e);
// Exoplayer sometimes interrupts the thread
}
if (exception != null) {
throw new IOException(exception);
Context context = ApplicationDependencies.getApplication();
GiphyMp4Cache cache = ApplicationDependencies.getGiphyMp4Cache();
cacheEntry = cache.read(context, dataSpec.uri);
if (cacheEntry == null) {
CountDownLatch countDownLatch = new CountDownLatch(1);
ChunkedDataFetcher fetcher = new ChunkedDataFetcher(okHttpClient);
fetcher.fetch(this.dataSpec.uri.toString(), dataSpec.length, new ChunkedDataFetcher.Callback() {
@Override
public void onSuccess(InputStream stream) {
try {
cacheEntry = cache.write(context, dataSpec.uri, stream);
} catch (IOException e) {
exception = e;
}
countDownLatch.countDown();
}
@Override
public void onFailure(Exception e) {
exception = e;
countDownLatch.countDown();
}
});
try {
countDownLatch.await(30, TimeUnit.SECONDS);
} catch (InterruptedException e) {
throw new IOException(e);
}
if (exception != null) {
throw new IOException(exception);
}
if (cacheEntry == null) {
throw new IOException("Timed out waiting for download.");
}
if (transferListener != null) {
transferListener.onTransferStart(this, dataSpec, false);
}
if (dataSpec.length != C.LENGTH_UNSET && dataSpec.length - dataSpec.position <= 0) {
throw new EOFException("No more data");
}
}
if (inputStream == null) {
throw new IOException("Timed out waiting for input stream");
}
if (transferListener != null) {
transferListener.onTransferStart(this, dataSpec, false);
}
if (dataSpec.length != C.LENGTH_UNSET && dataSpec.length - dataSpec.position <= 0) {
throw new EOFException("No more data");
}
return dataSpec.length;
return cacheEntry.getLength();
}
@Override
public int read(@NonNull byte[] buffer, int offset, int readLength) throws IOException {
int read = inputStream.read(buffer, offset, readLength);
int read = cacheEntry.getInputStream().read(buffer, offset, readLength);
if (read > 0 && transferListener != null) {
transferListener.onBytesTransferred(this, dataSpec, false, read);
@@ -112,9 +133,10 @@ public class ChunkedDataSource implements DataSource {
@Override
public void close() throws IOException {
if (inputStream != null) {
inputStream.close();
if (cacheEntry != null) {
cacheEntry.release();
}
cacheEntry = null;
}
}

View File

@@ -0,0 +1,135 @@
package org.thoughtcrime.securesms.video.exo
import android.content.Context
import android.net.Uri
import androidx.annotation.WorkerThread
import org.signal.core.util.StreamUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.util.storage.FileStorage
import java.io.IOException
import java.io.InputStream
/**
* A simple disk cache for MP4 GIFS. While entries are stored on disk, the data has lifecycle of a single application session and will be cleared every app
* start. This lets us keep stuff simple and maintain all of our metadata and state in memory.
*
* Features
* - Write entire files into the cache
* - Keep entries that are actively being read in the cache by maintaining locks on entries
* - When the cache is over the size limit, inactive entries will be evicted in LRU order.
*/
class GiphyMp4Cache(private val maxSize: Long) {
companion object {
private val TAG = Log.tag(GiphyMp4Cache::class.java)
private val DATA_LOCK = Object()
private const val DIRECTORY = "mp4gif_cache"
private const val PREFIX = "entry_"
private const val EXTENSION = "mp4"
}
private val lockedUris: MutableSet<Uri> = mutableSetOf()
private val uriToEntry: MutableMap<Uri, Entry> = mutableMapOf()
@WorkerThread
fun onAppStart(context: Context) {
synchronized(DATA_LOCK) {
lockedUris.clear()
for (file in FileStorage.getAllFiles(context, DIRECTORY, PREFIX)) {
if (!file.delete()) {
Log.w(TAG, "Failed to delete: " + file.name)
}
}
}
}
@Throws(IOException::class)
fun write(context: Context, uri: Uri, inputStream: InputStream): ReadData {
synchronized(DATA_LOCK) {
lockedUris.add(uri)
}
val filename: String = FileStorage.save(context, inputStream, DIRECTORY, PREFIX, EXTENSION)
val size = FileStorage.getFile(context, DIRECTORY, filename).length()
synchronized(DATA_LOCK) {
uriToEntry[uri] = Entry(
uri = uri,
filename = filename,
size = size,
lastAccessed = System.currentTimeMillis()
)
}
return readFromStorage(context, uri) ?: throw IOException("Could not find file immediately after writing!")
}
fun read(context: Context, uri: Uri): ReadData? {
synchronized(DATA_LOCK) {
lockedUris.add(uri)
}
return try {
readFromStorage(context, uri)
} catch (e: IOException) {
null
}
}
@Throws(IOException::class)
fun readFromStorage(context: Context, uri: Uri): ReadData? {
val entry: Entry = synchronized(DATA_LOCK) {
uriToEntry[uri]
} ?: return null
val length: Long = FileStorage.getFile(context, DIRECTORY, entry.filename).length()
val inputStream: InputStream = FileStorage.read(context, DIRECTORY, entry.filename)
return ReadData(inputStream, length) { onEntryReleased(context, uri) }
}
private fun onEntryReleased(context: Context, uri: Uri) {
synchronized(DATA_LOCK) {
lockedUris.remove(uri)
var totalSize: Long = calculateTotalSize(uriToEntry)
if (totalSize > maxSize) {
val evictCandidatesInLruOrder: MutableList<Entry> = ArrayList(
uriToEntry.entries
.filter { e -> !lockedUris.contains(e.key) }
.map { e -> e.value }
.sortedBy { e -> e.lastAccessed }
)
while (totalSize > maxSize && evictCandidatesInLruOrder.isNotEmpty()) {
val toEvict: Entry = evictCandidatesInLruOrder.removeAt(0)
if (!FileStorage.getFile(context, DIRECTORY, toEvict.filename).delete()) {
Log.w(TAG, "Failed to delete ${toEvict.filename}")
}
uriToEntry.remove(toEvict.uri)
totalSize = calculateTotalSize(uriToEntry)
}
}
}
}
private fun calculateTotalSize(data: Map<Uri, Entry>): Long {
return data.values.map { e -> e.size }.reduceOrNull { sum, size -> sum + size } ?: 0
}
fun interface Lease {
fun release()
}
private data class Entry(val uri: Uri, val filename: String, val size: Long, val lastAccessed: Long)
data class ReadData(val inputStream: InputStream, val length: Long, val lease: Lease) {
fun release() {
StreamUtil.close(inputStream)
lease.release()
}
}
}