From c84de8fa604bb0998a93fa39c3684596e87524cc Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 3 Sep 2021 16:32:16 -0400 Subject: [PATCH] Add a cache for GIFs. --- .../securesms/ApplicationContext.java | 2 +- .../dependencies/ApplicationDependencies.java | 14 ++ .../ApplicationDependencyProvider.java | 7 +- .../securesms/util/storage/FileStorage.java | 19 ++- .../video/exo/ChunkedDataSource.java | 110 ++++++++------ .../securesms/video/exo/GiphyMp4Cache.kt | 135 ++++++++++++++++++ app/src/main/res/layout/giphy_mp4_player.xml | 1 - 7 files changed, 238 insertions(+), 50 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/video/exo/GiphyMp4Cache.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index fe72658028..bf160b1c59 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -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))) diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java index eb9ac44079..600aefb7e6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java @@ -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(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 3156f0417b..23a5d80534 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/storage/FileStorage.java b/app/src/main/java/org/thoughtcrime/securesms/util/storage/FileStorage.java index 476ee61c15..9bda355b9b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/storage/FileStorage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/storage/FileStorage.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/exo/ChunkedDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/video/exo/ChunkedDataSource.java index 45da165972..ea76af98af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/exo/ChunkedDataSource.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/exo/ChunkedDataSource.java @@ -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; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/exo/GiphyMp4Cache.kt b/app/src/main/java/org/thoughtcrime/securesms/video/exo/GiphyMp4Cache.kt new file mode 100644 index 0000000000..387483b7d6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/exo/GiphyMp4Cache.kt @@ -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 = mutableSetOf() + private val uriToEntry: MutableMap = 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 = 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): 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() + } + } +} diff --git a/app/src/main/res/layout/giphy_mp4_player.xml b/app/src/main/res/layout/giphy_mp4_player.xml index 9cba24e111..f17614ddd2 100644 --- a/app/src/main/res/layout/giphy_mp4_player.xml +++ b/app/src/main/res/layout/giphy_mp4_player.xml @@ -1,6 +1,5 @@