Add a cache for GIFs.
This commit is contained in:
parent
8e020c05f6
commit
c84de8fa60
7 changed files with 238 additions and 50 deletions
|
@ -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)))
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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">
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue