mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-22 01:40:07 +01:00
Add support for jumbo emoji.
This commit is contained in:
committed by
Alex Hart
parent
449acaf9df
commit
34f679b10b
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
105
app/src/main/java/org/thoughtcrime/securesms/emoji/JumboEmoji.kt
Normal file
105
app/src/main/java/org/thoughtcrime/securesms/emoji/JumboEmoji.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user