Add support for jumbo emoji.

This commit is contained in:
Cody Henthorne
2022-01-06 10:24:08 -05:00
committed by Alex Hart
parent 449acaf9df
commit 34f679b10b
20 changed files with 401 additions and 163 deletions

View File

@@ -0,0 +1,108 @@
package org.thoughtcrime.securesms.emoji;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.mobilecoin.lib.util.Hex;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Arrays;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import okhttp3.Response;
import okhttp3.ResponseBody;
import okio.Okio;
import okio.Sink;
import okio.Source;
/**
* Helper for downloading Emoji files via {@link EmojiRemote}.
*/
public class EmojiDownloader {
public static @NonNull EmojiFiles.Name downloadAndVerifyJsonFromRemote(@NonNull Context context, @NonNull EmojiFiles.Version version) throws IOException {
return downloadAndVerifyFromRemote(context,
version,
() -> EmojiRemote.getObject(new EmojiJsonRequest(version.getVersion())),
EmojiFiles.Name::forEmojiDataJson);
}
public static @NonNull EmojiFiles.Name downloadAndVerifyImageFromRemote(@NonNull Context context,
@NonNull EmojiFiles.Version version,
@NonNull String bucket,
@NonNull String imagePath,
@NonNull String format) throws IOException
{
return downloadAndVerifyFromRemote(context,
version,
() -> EmojiRemote.getObject(new EmojiImageRequest(version.getVersion(), bucket, imagePath, format)),
() -> new EmojiFiles.Name(imagePath, UUID.randomUUID()));
}
private static @NonNull EmojiFiles.Name downloadAndVerifyFromRemote(@NonNull Context context,
@NonNull EmojiFiles.Version version,
@NonNull Producer<Response> responseProducer,
@NonNull Producer<EmojiFiles.Name> nameProducer) throws IOException
{
try (Response response = responseProducer.produce()) {
if (!response.isSuccessful()) {
throw new IOException("Unsuccessful response " + response.code());
}
ResponseBody responseBody = response.body();
if (responseBody == null) {
throw new IOException("No response body");
}
String responseMD5 = getMD5FromResponse(response);
if (responseMD5 == null) {
throw new IOException("Invalid ETag on response");
}
EmojiFiles.Name name = nameProducer.produce();
byte[] savedMd5;
try (OutputStream outputStream = EmojiFiles.openForWriting(context, version, name.getUuid())) {
Source source = response.body().source();
Sink sink = Okio.sink(outputStream);
Okio.buffer(source).readAll(sink);
outputStream.flush();
source.close();
sink.close();
savedMd5 = EmojiFiles.getMd5(context, version, name.getUuid());
}
if (!Arrays.equals(savedMd5, Hex.toByteArray(responseMD5))) {
EmojiFiles.delete(context, version, name.getUuid());
throw new IOException("MD5 Mismatch.");
}
return name;
}
}
private static @Nullable String getMD5FromResponse(@NonNull Response response) {
Pattern pattern = Pattern.compile(".*([a-f0-9]{32}).*");
String header = response.header("etag");
Matcher matcher = pattern.matcher(header);
if (matcher.find()) {
return matcher.group(1);
} else {
return null;
}
}
private interface Producer<T> {
@NonNull T produce();
}
}

View File

@@ -47,11 +47,13 @@ private const val TAG = "EmojiFiles"
private const val EMOJI_DIRECTORY = "emoji"
private const val VERSION_FILE = ".version"
private const val NAME_FILE = ".names"
private const val JUMBO_FILE = ".jumbos"
private const val EMOJI_JSON = "emoji_data.json"
private fun Context.getEmojiDirectory(): File = getDir(EMOJI_DIRECTORY, Context.MODE_PRIVATE)
private fun Context.getVersionFile(): File = File(getEmojiDirectory(), VERSION_FILE)
private fun Context.getNameFile(versionUuid: UUID): File = File(File(getEmojiDirectory(), versionUuid.toString()).apply { mkdir() }, NAME_FILE)
private fun Context.getJumboFile(versionUuid: UUID): File = File(File(getEmojiDirectory(), versionUuid.toString()).apply { mkdir() }, JUMBO_FILE)
@Suppress("UNUSED_PARAMETER")
private fun getFilesUri(name: String, format: String): Uri = PartAuthority.getEmojiUri(name)
@@ -89,6 +91,13 @@ object EmojiFiles {
return getInputStream(context, file)
}
fun openForReadingJumbo(context: Context, version: Version, names: JumboCollection, name: String): InputStream {
val dataUuid: UUID = names.getUUIDForName(name) ?: throw IOException("Could not get UUID for name $name")
val file: File = version.getFile(context, dataUuid)
return getInputStream(context, file)
}
@JvmStatic
fun openForWriting(context: Context, version: Version, uuid: UUID): OutputStream {
return getOutputStream(context, version.getFile(context, uuid))
@@ -137,7 +146,8 @@ object EmojiFiles {
private val objectMapper = ObjectMapper().registerKotlinModule()
@JvmStatic
fun readVersion(context: Context): Version? {
@JvmOverloads
fun readVersion(context: Context, skipValidation: Boolean = false): Version? {
val version = try {
getInputStream(context, context.getVersionFile()).use {
objectMapper.readValue(it, Version::class.java)
@@ -147,7 +157,7 @@ object EmojiFiles {
null
}
return if (isVersionValid(context, version)) {
return if (skipValidation || isVersionValid(context, version)) {
version
} else {
null
@@ -236,4 +246,34 @@ object EmojiFiles {
@JsonIgnore
fun getUUIDForName(name: String): UUID? = names.firstOrNull { it.name == name }?.uuid
}
class JumboCollection(@JsonProperty val versionUuid: UUID, @JsonProperty val names: List<Name>) {
companion object {
private val objectMapper = ObjectMapper().registerKotlinModule()
@JvmStatic
fun read(context: Context, version: Version): JumboCollection {
try {
getInputStream(context, context.getJumboFile(version.uuid)).use {
return objectMapper.readValue(it)
}
} catch (e: Exception) {
return JumboCollection(version.uuid, listOf())
}
}
@JvmStatic
fun append(context: Context, nameCollection: JumboCollection, name: Name): JumboCollection {
val collection = JumboCollection(nameCollection.versionUuid, nameCollection.names + name)
getOutputStream(context, context.getJumboFile(nameCollection.versionUuid)).use {
objectMapper.writeValue(it, collection)
}
return collection
}
}
@JsonIgnore
fun getUUIDForName(name: String): UUID? = names.firstOrNull { it.name == name }?.uuid
}
}

View File

@@ -75,7 +75,7 @@ object EmojiPageCache {
val bitmapOptions = BitmapFactory.Options()
bitmapOptions.inSampleSize = emojiPageRequest.inSampleSize
return BitmapFactory.decodeStream(inputStream, null, bitmapOptions)
return inputStream.use { BitmapFactory.decodeStream(it, null, bitmapOptions) }
}
private data class EmojiPageRequest(val emojiPage: EmojiPage, val inSampleSize: Int)

View File

@@ -63,7 +63,7 @@ class EmojiSource(
.forEach { page ->
val emojiPage = emojiPageFactory(page.spriteUri!!)
page.emoji.forEachIndexed { idx, emoji ->
tree.add(emoji, EmojiDrawInfo(emojiPage, idx))
tree.add(emoji, EmojiDrawInfo(emojiPage, idx, emoji))
}
}

View File

@@ -0,0 +1,105 @@
package org.thoughtcrime.securesms.emoji
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.annotation.MainThread
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.jobmanager.impl.AutoDownloadEmojiConstraint
import org.thoughtcrime.securesms.util.ListenableFutureTask
import org.thoughtcrime.securesms.util.SoftHashMap
import org.thoughtcrime.securesms.util.concurrent.SimpleTask
import java.io.IOException
import java.util.UUID
import java.util.concurrent.ExecutionException
private val TAG = Log.tag(JumboEmoji::class.java)
/**
* For Jumbo Emojis, will download, add to in-memory cache, and load from disk.
*/
object JumboEmoji {
private val cache: MutableMap<String, Bitmap> = SoftHashMap(16)
private val tasks: MutableMap<String, ListenableFutureTask<Bitmap>> = hashMapOf()
private val versionToFormat: MutableMap<UUID, String?> = hashMapOf()
@Suppress("FoldInitializerAndIfToElvis")
@JvmStatic
@MainThread
fun loadJumboEmoji(context: Context, rawEmoji: String): LoadResult {
val applicationContext: Context = context.applicationContext
val name: String = "jumbo/$rawEmoji"
val bitmap: Bitmap? = cache[name]
val task: ListenableFutureTask<Bitmap>? = tasks[name]
if (bitmap != null) {
return LoadResult.Immediate(bitmap)
}
if (task != null) {
return LoadResult.Async(task)
}
val newTask = ListenableFutureTask<Bitmap> {
val version: EmojiFiles.Version? = EmojiFiles.Version.readVersion(applicationContext, true)
if (version == null) {
throw NoVersionData()
}
val format: String? = versionToFormat.getOrPut(version.uuid) {
EmojiFiles.getLatestEmojiData(context, version)?.format
}
if (format == null) {
throw NoVersionData()
}
var jumbos: EmojiFiles.JumboCollection = EmojiFiles.JumboCollection.read(applicationContext, version)
val uuid = jumbos.getUUIDForName(name)
if (uuid == null) {
if (!AutoDownloadEmojiConstraint.canAutoDownloadEmoji(applicationContext)) {
throw CannotAutoDownload()
}
Log.i(TAG, "No file for emoji, downloading jumbo")
val emojiFilesName: EmojiFiles.Name = EmojiDownloader.downloadAndVerifyImageFromRemote(applicationContext, version, version.density, name, format)
jumbos = EmojiFiles.JumboCollection.append(applicationContext, jumbos, emojiFilesName)
}
val inputStream = EmojiFiles.openForReadingJumbo(applicationContext, version, jumbos, name)
inputStream.use { BitmapFactory.decodeStream(it, null, BitmapFactory.Options()) }
}
tasks[name] = newTask
SimpleTask.run(SignalExecutors.SERIAL, newTask::run) {
try {
val newBitmap: Bitmap? = newTask.get()
if (newBitmap == null) {
Log.w(TAG, "Failed to load jumbo emoji")
} else {
cache[name] = newBitmap
}
} catch (e: ExecutionException) {
Log.d(TAG, "Failed to load jumbo emoji", e.cause)
} finally {
tasks.remove(name)
}
}
return LoadResult.Async(newTask)
}
class NoVersionData : Throwable()
class CannotAutoDownload : IOException()
sealed class LoadResult {
data class Immediate(val bitmap: Bitmap) : LoadResult()
data class Async(val task: ListenableFutureTask<Bitmap>) : LoadResult()
}
}