mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-21 17:29:32 +01:00
Add a cache for GIFs.
This commit is contained in:
committed by
Cody Henthorne
parent
8e020c05f6
commit
c84de8fa60
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user