diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 6a89c6c286..fc6af695aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -35,6 +35,7 @@ import org.signal.core.util.logging.AndroidLogger; import org.signal.core.util.logging.Log; import org.signal.core.util.tracing.Tracer; import org.signal.glide.SignalGlideCodecs; +import org.thoughtcrime.securesms.emoji.JumboEmoji; import org.thoughtcrime.securesms.mms.SignalGlideModule; import org.signal.ringrtc.CallManager; import org.thoughtcrime.securesms.avatar.AvatarPickerStorage; @@ -192,6 +193,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr .addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this)) .addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary) .addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge())) + .addPostRender(() -> JumboEmoji.updateCurrentVersion(this)) .execute(); Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/Emoji.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/Emoji.java index 155d541594..6935bf17ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/Emoji.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/Emoji.java @@ -1,18 +1,27 @@ package org.thoughtcrime.securesms.components.emoji; +import androidx.annotation.Nullable; + import java.util.Arrays; +import java.util.Collections; import java.util.List; public class Emoji { private final List variations; + private final List rawVariations; public Emoji(String... variations) { - this.variations = Arrays.asList(variations); + this(Arrays.asList(variations), Collections.emptyList()); } public Emoji(List variations) { + this(variations, Collections.emptyList()); + } + + public Emoji(List variations, List rawVariations) { this.variations = variations; + this.rawVariations = rawVariations; } public String getValue() { @@ -26,4 +35,11 @@ public class Emoji { public boolean hasMultipleVariations() { return variations.size() > 1; } + + public @Nullable String getRawVariation(int variationIndex) { + if (rawVariations != null && variationIndex >= 0 && variationIndex < rawVariations.size()) { + return rawVariations.get(variationIndex); + } + return null; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiProvider.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiProvider.java index e53bcbba6c..c7b1b9437e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiProvider.java @@ -149,8 +149,8 @@ public class EmojiProvider { throw new IllegalStateException("Unexpected subclass " + loadResult.getClass()); } - if (jumboEmoji) { - JumboEmoji.LoadResult result = JumboEmoji.loadJumboEmoji(context, drawInfo.getRawEmoji()); + if (jumboEmoji && drawInfo.getJumboSheet() != null) { + JumboEmoji.LoadResult result = JumboEmoji.loadJumboEmoji(context, drawInfo); if (result instanceof JumboEmoji.LoadResult.Immediate) { ThreadUtil.runOnMain(() -> { jumboLoaded.set(true); @@ -171,7 +171,11 @@ public class EmojiProvider { @Override public void onFailure(ExecutionException exception) { - Log.d(TAG, "Failed to load jumbo emoji bitmap resource", exception); + if (exception.getCause() instanceof JumboEmoji.CannotAutoDownload) { + Log.i(TAG, "Download restrictions are preventing jumbomoji use"); + } else { + Log.d(TAG, "Failed to load jumbo emoji bitmap resource", exception); + } } }); } @@ -200,15 +204,19 @@ public class EmojiProvider { Bitmap bitmap = null; - if (jumboEmoji) { - JumboEmoji.LoadResult result = JumboEmoji.loadJumboEmoji(context, drawInfo.getRawEmoji()); + if (jumboEmoji && drawInfo.getJumboSheet() != null) { + JumboEmoji.LoadResult result = JumboEmoji.loadJumboEmoji(context, drawInfo); if (result instanceof JumboEmoji.LoadResult.Immediate) { bitmap = ((JumboEmoji.LoadResult.Immediate) result).getBitmap(); } else if (result instanceof JumboEmoji.LoadResult.Async) { try { bitmap = ((JumboEmoji.LoadResult.Async) result).getTask().get(10, TimeUnit.SECONDS); } catch (InterruptedException | ExecutionException | TimeoutException exception) { - Log.d(TAG, "Failed to load jumbo emoji bitmap resource", exception); + if (exception.getCause() instanceof JumboEmoji.CannotAutoDownload) { + Log.i(TAG, "Download restrictions are preventing jumbomoji use"); + } else { + Log.d(TAG, "Failed to load jumbo emoji bitmap resource", exception); + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java index f7c1859f65..d5bbf0f180 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java @@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser; import org.thoughtcrime.securesms.components.mention.MentionAnnotation; import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate; +import org.thoughtcrime.securesms.emoji.JumboEmoji; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.guava.Optional; @@ -43,7 +44,8 @@ public class EmojiTextView extends AppCompatTextView { private final boolean scaleEmojis; - private static final char ELLIPSIS = '…'; + private static final char ELLIPSIS = '…'; + private static final float JUMBOMOJI_SCALE = 0.8f; private boolean forceCustom; private CharSequence previousText; @@ -113,13 +115,13 @@ public class EmojiTextView extends AppCompatTextView { public void setText(@Nullable CharSequence text, BufferType type) { EmojiParser.CandidateList candidates = isInEditMode() ? null : EmojiProvider.getCandidates(text); - if (scaleEmojis && candidates != null && candidates.allEmojis) { + if (scaleEmojis && candidates != null && candidates.allEmojis && (candidates.hasJumboForAll() || JumboEmoji.canDownloadJumbo(getContext()))) { int emojis = candidates.size(); float scale = 1.0f; - if (emojis <= 5) scale += 0.9f; - if (emojis <= 4) scale += 0.9f; - if (emojis <= 2) scale += 0.9f; + if (emojis <= 5) scale += JUMBOMOJI_SCALE; + if (emojis <= 4) scale += JUMBOMOJI_SCALE; + if (emojis <= 2) scale += JUMBOMOJI_SCALE; isJumbomoji = scale > 1.0f; super.setTextSize(TypedValue.COMPLEX_UNIT_PX, originalFontSize * scale); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiDrawInfo.kt b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiDrawInfo.kt index a19fa638ba..28de3aca78 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiDrawInfo.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiDrawInfo.kt @@ -1,13 +1,5 @@ package org.thoughtcrime.securesms.components.emoji.parsing import org.thoughtcrime.securesms.emoji.EmojiPage -import org.thoughtcrime.securesms.util.Hex -import java.nio.charset.Charset -data class EmojiDrawInfo(val page: EmojiPage, val index: Int, private val emoji: String) { - val rawEmoji: String - get() { - val emojiBytes: ByteArray = emoji.toByteArray(Charset.forName("UTF-16")) - return Hex.toStringCondensed(emojiBytes.slice(2 until emojiBytes.size).toByteArray()) - } -} +data class EmojiDrawInfo(val page: EmojiPage, val index: Int, private val emoji: String, val rawEmoji: String?, val jumboSheet: String?) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiParser.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiParser.java index 91450e102b..ced9178683 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiParser.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiParser.java @@ -24,6 +24,8 @@ package org.thoughtcrime.securesms.components.emoji.parsing; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.thoughtcrime.securesms.emoji.JumboEmoji; + import java.util.Iterator; import java.util.LinkedList; import java.util.List; @@ -127,6 +129,15 @@ public class EmojiParser { return list.size(); } + public boolean hasJumboForAll() { + for (Candidate candidate : list) { + if (!JumboEmoji.hasJumboEmoji(candidate.drawInfo)) { + return false; + } + } + return true; + } + @Override public @NonNull Iterator iterator() { return list.iterator(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index 506bd6815f..76bf99b33d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -612,7 +612,7 @@ public abstract class MessageRecord extends DisplayRecord { if (isJumboji == null) { if (getBody().length() <= EmojiSource.getLatest().getMaxEmojiLength() * JumboEmoji.MAX_JUMBOJI_COUNT) { EmojiParser.CandidateList candidates = EmojiProvider.getCandidates(getDisplayBody(context)); - isJumboji = candidates != null && candidates.allEmojis && candidates.size() <= JumboEmoji.MAX_JUMBOJI_COUNT; + isJumboji = candidates != null && candidates.allEmojis && candidates.size() <= JumboEmoji.MAX_JUMBOJI_COUNT && (candidates.hasJumboForAll() || JumboEmoji.canDownloadJumbo(context)); } else { isJumboji = false; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiDownloader.java b/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiDownloader.java index 920ca7460a..4c1bd3ab57 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiDownloader.java +++ b/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiDownloader.java @@ -4,10 +4,12 @@ import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.util.Consumer; import com.mobilecoin.lib.util.Hex; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.util.Arrays; import java.util.UUID; @@ -44,6 +46,16 @@ public class EmojiDownloader { () -> new EmojiFiles.Name(imagePath, UUID.randomUUID())); } + public static void streamFileFromRemote(@NonNull EmojiFiles.Version version, + @NonNull String bucket, + @NonNull String path, + @NonNull Consumer streamConsumer) + throws IOException + { + streamFromRemote(() -> EmojiRemote.getObject(new EmojiFileRequest(version.getVersion(), bucket, path)), + streamConsumer); + } + private static @NonNull EmojiFiles.Name downloadAndVerifyFromRemote(@NonNull Context context, @NonNull EmojiFiles.Version version, @NonNull Producer responseProducer, @@ -90,6 +102,23 @@ public class EmojiDownloader { } } + private static void streamFromRemote(@NonNull Producer responseProducer, + @NonNull Consumer streamConsumer) 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"); + } + + streamConsumer.accept(Okio.buffer(responseBody.source()).inputStream()); + } + } + private static @Nullable String getMD5FromResponse(@NonNull Response response) { Pattern pattern = Pattern.compile(".*([a-f0-9]{32}).*"); String header = response.header("etag"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiJsonParser.kt b/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiJsonParser.kt index d54dc5e743..1c214d46be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiJsonParser.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiJsonParser.kt @@ -18,6 +18,7 @@ typealias UriFactory = (sprite: String, format: String) -> Uri */ object EmojiJsonParser { private val OBJECT_MAPPER = ObjectMapper() + private const val ESTIMATED_EMOJI_COUNT = 3500 @JvmStatic fun verify(body: InputStream) { @@ -36,11 +37,12 @@ object EmojiJsonParser { val format: String = node["format"].textValue() val obsolete: List = node["obsolete"].toObseleteList() val dataPages: List = getDataPages(format, node["emoji"], uriFactory) + val jumboPages: Map = getJumboPages(node["jumbomoji"]) val displayPages: List = mergeToDisplayPages(dataPages) val metrics: EmojiMetrics = node["metrics"].toEmojiMetrics() val densities: List = node["densities"].toDensityList() - return ParsedEmojiData(metrics, densities, format, displayPages, dataPages, obsolete) + return ParsedEmojiData(metrics, densities, format, displayPages, dataPages, jumboPages, obsolete) } private fun getDataPages(format: String, emoji: JsonNode, uriFactory: UriFactory): List { @@ -64,13 +66,33 @@ object EmojiJsonParser { .toList() } + private fun getJumboPages(jumbo: JsonNode?): Map { + if (jumbo != null) { + return jumbo.fields() + .asSequence() + .map { (page: String, node: JsonNode) -> + node.associate { it.textValue() to page } + } + .flatMap { it.entries } + .associateTo(HashMap(ESTIMATED_EMOJI_COUNT)) { it.key to it.value } + } + return emptyMap() + } + private fun createPage(pageName: String, format: String, page: JsonNode, uriFactory: UriFactory): EmojiPageModel { val category = EmojiCategory.forKey(pageName.asCategoryKey()) val pageList = page.mapIndexed { i, data -> if (data.size() == 0) { throw IllegalStateException("Page index $pageName.$i had no data") } else { - Emoji(data.map { it.textValue().encodeAsUtf16() }) + val variations: MutableList = mutableListOf() + val rawVariations: MutableList = mutableListOf() + data.forEach { + variations += it.textValue().encodeAsUtf16() + rawVariations += it.textValue() + } + + Emoji(variations, rawVariations) } } @@ -111,5 +133,6 @@ data class ParsedEmojiData( override val format: String, override val displayPages: List, override val dataPages: List, + override val jumboPages: Map, override val obsolete: List ) : EmojiData diff --git a/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiRemote.kt b/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiRemote.kt index 7c1888222a..e3b75d3ade 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiRemote.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiRemote.kt @@ -6,7 +6,7 @@ import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import java.io.IOException -private const val VERSION_URL = "https://updates.signal.org/dynamic/android/emoji/version_v2.txt" +private const val VERSION_URL = "https://updates.signal.org/dynamic/android/emoji/version_v3.txt" private const val BASE_STATIC_BUCKET_URL = "https://updates.signal.org/static/android/emoji" /** @@ -93,3 +93,11 @@ class EmojiImageRequest( ) : EmojiRequest { override val url: String = "$BASE_STATIC_BUCKET_URL/$version/$density/$name.$format" } + +class EmojiFileRequest( + version: Int, + density: String, + name: String, +) : EmojiRequest { + override val url: String = "$BASE_STATIC_BUCKET_URL/$version/$density/$name" +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiSource.kt b/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiSource.kt index aa1032f454..91d8f09187 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiSource.kt @@ -62,8 +62,13 @@ class EmojiSource( .filter { it.spriteUri != null } .forEach { page -> val emojiPage = emojiPageFactory(page.spriteUri!!) - page.emoji.forEachIndexed { idx, emoji -> - tree.add(emoji, EmojiDrawInfo(emojiPage, idx, emoji)) + + var overallIndex = 0 + page.displayEmoji.forEach { emoji: Emoji -> + emoji.variations.forEachIndexed { variationIndex, variation -> + val raw = emoji.getRawVariation(variationIndex) + tree.add(variation, EmojiDrawInfo(emojiPage, overallIndex++, variation, raw, jumboPages[raw])) + } } } @@ -142,6 +147,7 @@ interface EmojiData { val format: String val displayPages: List val dataPages: List + val jumboPages: Map val obsolete: List } diff --git a/app/src/main/java/org/thoughtcrime/securesms/emoji/JumboEmoji.kt b/app/src/main/java/org/thoughtcrime/securesms/emoji/JumboEmoji.kt index f22231e5f5..4d5724f3ac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/emoji/JumboEmoji.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/emoji/JumboEmoji.kt @@ -3,16 +3,22 @@ package org.thoughtcrime.securesms.emoji import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.os.SystemClock import androidx.annotation.MainThread +import org.signal.core.util.ThreadUtil import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.components.emoji.parsing.EmojiDrawInfo +import org.thoughtcrime.securesms.emoji.protos.JumbomojiPack import org.thoughtcrime.securesms.jobmanager.impl.AutoDownloadEmojiConstraint +import org.thoughtcrime.securesms.keyvalue.SignalStore 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 +import java.util.concurrent.TimeUnit private val TAG = Log.tag(JumboEmoji::class.java) @@ -21,30 +27,67 @@ private val TAG = Log.tag(JumboEmoji::class.java) */ object JumboEmoji { + private val executor = ThreadUtil.trace(SignalExecutors.newCachedSingleThreadExecutor("jumbo-emoji")) + const val MAX_JUMBOJI_COUNT = 5 + private const val JUMBOMOJI_SUPPORTED_VERSION = 5 + private val cache: MutableMap = SoftHashMap(16) - private val tasks: MutableMap> = hashMapOf() private val versionToFormat: MutableMap = hashMapOf() + private val downloadedJumbos: MutableSet = mutableSetOf() + + private val networkCheckThrottle: Long = TimeUnit.MINUTES.toMillis(1) + private var lastNetworkCheck: Long = 0 + private var canDownload: Boolean = false + + @Volatile + private var currentVersion: Int = -1 + + @JvmStatic + @MainThread + fun updateCurrentVersion(context: Context) { + SignalExecutors.BOUNDED.execute { + val version: EmojiFiles.Version = EmojiFiles.Version.readVersion(context, true) ?: return@execute + + if (EmojiFiles.getLatestEmojiData(context, version)?.format != null) { + currentVersion = version.version + ThreadUtil.runOnMain { downloadedJumbos.addAll(SignalStore.emojiValues().getJumboEmojiSheets(version.version)) } + } + } + } + + @JvmStatic + @MainThread + fun canDownloadJumbo(context: Context): Boolean { + val now = SystemClock.elapsedRealtime() + if (now - networkCheckThrottle > lastNetworkCheck) { + canDownload = AutoDownloadEmojiConstraint.canAutoDownloadJumboEmoji(context) + lastNetworkCheck = now + } + return canDownload && currentVersion >= JUMBOMOJI_SUPPORTED_VERSION + } + + @JvmStatic + @MainThread + fun hasJumboEmoji(drawInfo: EmojiDrawInfo): Boolean { + return downloadedJumbos.contains(drawInfo.jumboSheet) + } @Suppress("FoldInitializerAndIfToElvis") @JvmStatic @MainThread - fun loadJumboEmoji(context: Context, rawEmoji: String): LoadResult { + fun loadJumboEmoji(context: Context, drawInfo: EmojiDrawInfo): LoadResult { val applicationContext: Context = context.applicationContext - val name: String = "jumbo/$rawEmoji" - val bitmap: Bitmap? = cache[name] - val task: ListenableFutureTask? = tasks[name] + val archiveName = "jumbo/${drawInfo.jumboSheet}.proto" + val emojiName: String = drawInfo.rawEmoji!! + val bitmap: Bitmap? = cache[emojiName] if (bitmap != null) { return LoadResult.Immediate(bitmap) } - if (task != null) { - return LoadResult.Async(task) - } - val newTask = ListenableFutureTask { val version: EmojiFiles.Version? = EmojiFiles.Version.readVersion(applicationContext, true) if (version == null) { @@ -59,38 +102,50 @@ object JumboEmoji { throw NoVersionData() } + currentVersion = version.version + var jumbos: EmojiFiles.JumboCollection = EmojiFiles.JumboCollection.read(applicationContext, version) - val uuid = jumbos.getUUIDForName(name) + val uuid = jumbos.getUUIDForName(emojiName) if (uuid == null) { - if (!AutoDownloadEmojiConstraint.canAutoDownloadEmoji(applicationContext)) { + if (!AutoDownloadEmojiConstraint.canAutoDownloadJumboEmoji(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) + EmojiDownloader.streamFileFromRemote(version, version.density, archiveName) { stream -> + stream.use { remote -> + val jumbomojiPack = JumbomojiPack.parseFrom(remote) + + jumbomojiPack.itemsList.forEach { jumbo -> + val emojiNameEntry = EmojiFiles.Name(jumbo.name, UUID.randomUUID()) + val outputStream = EmojiFiles.openForWriting(applicationContext, version, emojiNameEntry.uuid) + + outputStream.use { jumbo.image.writeTo(it) } + + jumbos = EmojiFiles.JumboCollection.append(applicationContext, jumbos, emojiNameEntry) + } + } + } + + SignalStore.emojiValues().addJumboEmojiSheet(version.version, drawInfo.jumboSheet) } - val inputStream = EmojiFiles.openForReadingJumbo(applicationContext, version, jumbos, name) - inputStream.use { BitmapFactory.decodeStream(it, null, BitmapFactory.Options()) } + EmojiFiles.openForReadingJumbo(applicationContext, version, jumbos, emojiName).use { BitmapFactory.decodeStream(it, null, BitmapFactory.Options()) } } - tasks[name] = newTask - - SimpleTask.run(SignalExecutors.SERIAL, newTask::run) { + SimpleTask.run(executor, newTask::run) { try { val newBitmap: Bitmap? = newTask.get() if (newBitmap == null) { Log.w(TAG, "Failed to load jumbo emoji") } else { - cache[name] = newBitmap + cache[emojiName] = newBitmap + downloadedJumbos.add(drawInfo.jumboSheet!!) } } catch (e: ExecutionException) { - Log.d(TAG, "Failed to load jumbo emoji", e.cause) - } finally { - tasks.remove(name) + // do nothing, emoji provider will log the exception } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/AutoDownloadEmojiConstraint.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/AutoDownloadEmojiConstraint.java index 94ff035ab4..a86927598a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/AutoDownloadEmojiConstraint.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/AutoDownloadEmojiConstraint.java @@ -54,11 +54,15 @@ public class AutoDownloadEmojiConstraint implements Constraint { } public static boolean canAutoDownloadEmoji(@NonNull Context context) { - return getAllowedAutoDownloadTypes(context).contains(IMAGE_TYPE); + return getAllowedAutoDownloadTypes(context, true).contains(IMAGE_TYPE); } - private static @NonNull Set getAllowedAutoDownloadTypes(@NonNull Context context) { - if (NetworkUtil.isConnectedWifi(context)) return Collections.singleton(IMAGE_TYPE); + public static boolean canAutoDownloadJumboEmoji(@NonNull Context context) { + return getAllowedAutoDownloadTypes(context, false).contains(IMAGE_TYPE); + } + + private static @NonNull Set getAllowedAutoDownloadTypes(@NonNull Context context, boolean forceWifi) { + if (NetworkUtil.isConnectedWifi(context)) return forceWifi ? Collections.singleton(IMAGE_TYPE) : TextSecurePreferences.getWifiMediaDownloadAllowed(context); else if (NetworkUtil.isConnectedRoaming(context)) return TextSecurePreferences.getRoamingMediaDownloadAllowed(context); else if (NetworkUtil.isConnectedMobile(context)) return TextSecurePreferences.getMobileMediaDownloadAllowed(context); else return Collections.emptySet(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/DownloadLatestEmojiDataJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/DownloadLatestEmojiDataJob.java index b7e2a81742..e9ca56468e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/DownloadLatestEmojiDataJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/DownloadLatestEmojiDataJob.java @@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.emoji.EmojiJsonRequest; import org.thoughtcrime.securesms.emoji.EmojiPageCache; import org.thoughtcrime.securesms.emoji.EmojiRemote; import org.thoughtcrime.securesms.emoji.EmojiSource; +import org.thoughtcrime.securesms.emoji.JumboEmoji; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.impl.AutoDownloadEmojiConstraint; @@ -158,6 +159,7 @@ public class DownloadLatestEmojiDataJob extends BaseJob { clearOldEmojiData(context, targetVersion); markComplete(targetVersion); EmojiSource.refresh(); + JumboEmoji.updateCurrentVersion(context); } else { Log.d(TAG, "Server has an older version than we do. Skipping."); } @@ -359,6 +361,10 @@ public class DownloadLatestEmojiDataJob extends BaseJob { .forEach(FileUtils::deleteDirectory); EmojiPageCache.INSTANCE.clear(); + + if (version != null) { + SignalStore.emojiValues().clearJumboEmojiSheets(version.getVersion()); + } } public static final class Factory implements Job.Factory { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 0ed0df4f5f..9b17c63329 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -41,6 +41,7 @@ import org.thoughtcrime.securesms.migrations.CachedAttachmentsMigrationJob; import org.thoughtcrime.securesms.migrations.DatabaseMigrationJob; import org.thoughtcrime.securesms.migrations.DeleteDeprecatedLogsMigrationJob; import org.thoughtcrime.securesms.migrations.DirectoryRefreshMigrationJob; +import org.thoughtcrime.securesms.migrations.EmojiDownloadMigrationJob; import org.thoughtcrime.securesms.migrations.KbsEnclaveMigrationJob; import org.thoughtcrime.securesms.migrations.LegacyMigrationJob; import org.thoughtcrime.securesms.migrations.MigrationCompleteJob; @@ -187,6 +188,7 @@ public final class JobManagerFactories { put(DatabaseMigrationJob.KEY, new DatabaseMigrationJob.Factory()); put(DeleteDeprecatedLogsMigrationJob.KEY, new DeleteDeprecatedLogsMigrationJob.Factory()); put(DirectoryRefreshMigrationJob.KEY, new DirectoryRefreshMigrationJob.Factory()); + put(EmojiDownloadMigrationJob.KEY, new EmojiDownloadMigrationJob.Factory()); put(KbsEnclaveMigrationJob.KEY, new KbsEnclaveMigrationJob.Factory()); put(LegacyMigrationJob.KEY, new LegacyMigrationJob.Factory()); put(MigrationCompleteJob.KEY, new MigrationCompleteJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/EmojiValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/EmojiValues.java index cbf9018145..cd23edda63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/EmojiValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/EmojiValues.java @@ -11,7 +11,9 @@ import org.thoughtcrime.securesms.util.Util; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; public class EmojiValues extends SignalStoreValues { @@ -28,6 +30,7 @@ public class EmojiValues extends SignalStoreValues { private static final String SEARCH_VERSION = PREFIX + "search_version"; private static final String SEARCH_LANGUAGE = PREFIX + "search_language"; private static final String LAST_SEARCH_CHECK = PREFIX + "last_search_check"; + private static final String JUMBO_EMOJI_DOWNLOAD = PREFIX + "jumbo_emoji_v"; public static final String NO_LANGUAGE = "NO_LANGUAGE"; @@ -131,4 +134,22 @@ public class EmojiValues extends SignalStoreValues { public void setLastSearchIndexCheck(long time) { putLong(LAST_SEARCH_CHECK, time); } + + public void addJumboEmojiSheet(int version, String sheet) { + Set sheets = getJumboEmojiSheets(version); + sheets.add(sheet); + getStore().beginWrite() + .putString(JUMBO_EMOJI_DOWNLOAD + version, Util.join(sheets, ",")) + .apply(); + } + + public HashSet getJumboEmojiSheets(int version) { + return new HashSet<>(Arrays.asList(getStore().getString(JUMBO_EMOJI_DOWNLOAD + version, "").split(","))); + } + + public void clearJumboEmojiSheets(int version) { + getStore().beginWrite() + .remove(JUMBO_EMOJI_DOWNLOAD + version) + .apply(); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java index f8c4603e11..a9c8f4b05e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java @@ -93,9 +93,10 @@ public class ApplicationMigrations { //static final int CHANGE_NUMBER_CAPABILITY_3 = 49; static final int PNI = 50; static final int FIX_DEPRECATION = 51; // Only used to trigger clearing the 'client deprecated' flag + static final int JUMBOMOJI_DOWNLOAD = 52; } - public static final int CURRENT_VERSION = 51; + public static final int CURRENT_VERSION = 52; /** * This *must* be called after the {@link JobManager} has been instantiated, but *before* the call @@ -401,6 +402,10 @@ public class ApplicationMigrations { jobs.put(Version.PNI, new PniMigrationJob()); } + if (lastSeenVersion < Version.JUMBOMOJI_DOWNLOAD) { + jobs.put(Version.JUMBOMOJI_DOWNLOAD, new EmojiDownloadMigrationJob()); + } + return jobs; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/EmojiDownloadMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/EmojiDownloadMigrationJob.java new file mode 100644 index 0000000000..d9bbebf78c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/EmojiDownloadMigrationJob.java @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob; + +/** + * Schedules a emoji download job to get the latest version. + */ +public final class EmojiDownloadMigrationJob extends MigrationJob { + + public static final String KEY = "EmojiDownloadMigrationJob"; + + EmojiDownloadMigrationJob() { + this(new Parameters.Builder().build()); + } + + private EmojiDownloadMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public boolean isUiBlocking() { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void performMigration() { + ApplicationDependencies.getJobManager().add(new DownloadLatestEmojiDataJob(false)); + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull EmojiDownloadMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new EmojiDownloadMigrationJob(parameters); + } + } +} diff --git a/app/src/main/proto/Emoji.proto b/app/src/main/proto/Emoji.proto new file mode 100644 index 0000000000..8cd0006227 --- /dev/null +++ b/app/src/main/proto/Emoji.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package signal; + +option java_package = "org.thoughtcrime.securesms.emoji.protos"; +option java_multiple_files = true; + +message JumbomojiPack { + repeated JumbomojiItem items = 1; +} + +message JumbomojiItem { + string name = 1; + bytes image = 2; +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/emoji/EmojiJsonParserTest.kt b/app/src/test/java/org/thoughtcrime/securesms/emoji/EmojiJsonParserTest.kt index 84bf452f1b..bc2816fb42 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/emoji/EmojiJsonParserTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/emoji/EmojiJsonParserTest.kt @@ -51,6 +51,30 @@ private const val SAMPLE_JSON_WITH_OBSOLETE = """ } """ +private const val SAMPLE_JSON_WITH_JUMBOS = """ + { + "emoji": { + "Places_1": [["0002"], ["0003", "0004", "0005"]], + "Places_2": [["0003"], ["0008", "0009", "0000"]], + "Foods": [["0001"], ["0002", "0003", "0004"]] + }, + "jumbomoji": { + "People_0": ["d83dde00","d83dde03","d83dde04", "d83dde01"], + "People_1": ["ad83dde00","ad83dde03","ad83dde04", "ad83dde01"] + }, + "obsolete": [ + {"obsoleted": "0012", "replace_with": "0023"} + ], + "metrics": { + "raw_height": 64, + "raw_width": 64, + "per_row": 16 + }, + "densities": [ "xhdpi" ], + "format": "png" + } +""" + private val SAMPLE_JSON_WITHOUT_OBSOLETE_EXPECTED = listOf( StaticEmojiPageModel(EmojiCategory.FOODS, listOf(Emoji("\u0001"), Emoji("\u0002", "\u0003", "\u0004")), Uri.parse("file:///Foods")), StaticEmojiPageModel(EmojiCategory.PLACES, listOf(Emoji("\ud83c\udf0d"), Emoji("\u0003", "\u0004", "\u0005")), Uri.parse("file:///Places")) @@ -128,6 +152,23 @@ class EmojiJsonParserTest { Assert.assertEquals(result.format, "png") } + @Test + fun `Given sample with jumbo, when I parse, then I expect source with jumbo map`() { + val result: ParsedEmojiData = EmojiJsonParser.parse(SAMPLE_JSON_WITH_JUMBOS.byteInputStream(), this::uriFactory).getOrThrow() + + val jumboPages = result.jumboPages + + Assert.assertEquals("People_0", jumboPages["d83dde00"]) + Assert.assertEquals("People_0", jumboPages["d83dde03"]) + Assert.assertEquals("People_0", jumboPages["d83dde04"]) + Assert.assertEquals("People_0", jumboPages["d83dde01"]) + + Assert.assertEquals("People_1", jumboPages["ad83dde00"]) + Assert.assertEquals("People_1", jumboPages["ad83dde03"]) + Assert.assertEquals("People_1", jumboPages["ad83dde04"]) + Assert.assertEquals("People_1", jumboPages["ad83dde01"]) + } + private fun uriFactory(sprite: String, format: String) = Uri.parse("file:///$sprite") private fun EmojiPageModel.isSameAs(other: EmojiPageModel) = diff --git a/app/src/test/java/org/thoughtcrime/securesms/emoji/EmojiSourceTest.kt b/app/src/test/java/org/thoughtcrime/securesms/emoji/EmojiSourceTest.kt index 33d270b374..3e213b7afe 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/emoji/EmojiSourceTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/emoji/EmojiSourceTest.kt @@ -10,7 +10,7 @@ class EmojiSourceTest { @Test fun `Given a bunch of data pages with max value 100100, when I get the maxEmojiLength, then I expect 6`() { - val emojiDataFake = ParsedEmojiData(EmojiMetrics(-1, -1, -1), listOf(), "png", listOf(), dataPages = generatePages(), listOf()) + val emojiDataFake = ParsedEmojiData(EmojiMetrics(-1, -1, -1), listOf(), "png", listOf(), dataPages = generatePages(), emptyMap(), listOf()) val testSubject = EmojiSource(0f, emojiDataFake) { uri -> EmojiPage.Disk(uri) } Assert.assertEquals(6, testSubject.maxEmojiLength)