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

@ -18,7 +18,6 @@ package org.thoughtcrime.securesms;
import android.content.Context;
import android.os.Build;
import android.os.SystemClock;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@ -171,6 +170,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addNonBlocking(StorageSyncHelper::scheduleRoutineSync)
.addNonBlocking(() -> ApplicationDependencies.getJobManager().beginJobLoop())
.addNonBlocking(EmojiSource::refresh)
.addNonBlocking(() -> ApplicationDependencies.getGiphyMp4Cache().onAppStart(this))
.addPostRender(() -> RateLimitUtil.retryAllRateLimitedMessages(this))
.addPostRender(this::initializeExpiringMessageManager)
.addPostRender(() -> SignalStore.settings().setDefaultSms(Util.isDefaultSmsProvider(this)))

View file

@ -39,6 +39,7 @@ import org.thoughtcrime.securesms.util.EarlyMessageCache;
import org.thoughtcrime.securesms.util.FrameRateTracker;
import org.thoughtcrime.securesms.util.Hex;
import org.thoughtcrime.securesms.util.IasKeyStore;
import org.thoughtcrime.securesms.video.exo.GiphyMp4Cache;
import org.whispersystems.signalservice.api.KeyBackupService;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
@ -98,6 +99,7 @@ public class ApplicationDependencies {
private static volatile TextSecureSessionStore sessionStore;
private static volatile TextSecurePreKeyStore preKeyStore;
private static volatile SignalSenderKeyStore senderKeyStore;
private static volatile GiphyMp4Cache giphyMp4Cache;
@MainThread
public static void init(@NonNull Application application, @NonNull Provider provider) {
@ -551,6 +553,17 @@ public class ApplicationDependencies {
return senderKeyStore;
}
public static @NonNull GiphyMp4Cache getGiphyMp4Cache() {
if (giphyMp4Cache == null) {
synchronized (LOCK) {
if (giphyMp4Cache == null) {
giphyMp4Cache = provider.provideGiphyMp4Cache();
}
}
}
return giphyMp4Cache;
}
public interface Provider {
@NonNull GroupsV2Operations provideGroupsV2Operations();
@NonNull SignalServiceAccountManager provideSignalServiceAccountManager();
@ -583,5 +596,6 @@ public class ApplicationDependencies {
@NonNull TextSecureSessionStore provideSessionStore();
@NonNull TextSecurePreKeyStore providePreKeyStore();
@NonNull SignalSenderKeyStore provideSenderKeyStore();
@NonNull GiphyMp4Cache provideGiphyMp4Cache();
}
}

View file

@ -58,7 +58,7 @@ import org.thoughtcrime.securesms.util.EarlyMessageCache;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.FrameRateTracker;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.whispersystems.libsignal.state.SignalProtocolStore;
import org.thoughtcrime.securesms.video.exo.GiphyMp4Cache;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
@ -284,6 +284,11 @@ public class ApplicationDependencyProvider implements ApplicationDependencies.Pr
return new SignalSenderKeyStore(context);
}
@Override
public @NonNull GiphyMp4Cache provideGiphyMp4Cache() {
return new GiphyMp4Cache(ByteUnit.MEGABYTES.toBytes(16));
}
private @NonNull WebSocketFactory provideWebSocketFactory(@NonNull SignalWebSocketHealthMonitor healthMonitor) {
return new WebSocketFactory() {
@Override

View file

@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.util.storage;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.signal.core.util.StreamUtil;
@ -33,8 +34,8 @@ public final class FileStorage {
@NonNull InputStream inputStream,
@NonNull String directoryName,
@NonNull String fileNameBase,
@NonNull String extension
) throws IOException
@NonNull String extension)
throws IOException
{
File directory = context.getDir(directoryName, Context.MODE_PRIVATE);
File file = File.createTempFile(fileNameBase, "." + extension, directory);
@ -47,7 +48,8 @@ public final class FileStorage {
@WorkerThread
public static @NonNull InputStream read(@NonNull Context context,
@NonNull String directoryName,
@NonNull String filename) throws IOException
@NonNull String filename)
throws IOException
{
File directory = context.getDir(directoryName, Context.MODE_PRIVATE);
File file = new File(directory, filename);
@ -80,6 +82,17 @@ public final class FileStorage {
}
}
/**
* Note that you will always get a file back, but that file may not exist on disk.
*/
@WorkerThread
public static @NonNull File getFile(@NonNull Context context,
@NonNull String directoryName,
@NonNull String filename)
{
return new File(context.getDir(directoryName, Context.MODE_PRIVATE), filename);
}
private static @NonNull OutputStream getOutputStream(@NonNull Context context, File outputFile) throws IOException {
AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret();
return ModernEncryptingPartOutputStream.createFor(attachmentSecret, outputFile, true).second;

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()
}
}
}

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="0dp">