Add support for OTA emoji download.

This commit is contained in:
Alex Hart
2021-04-19 10:36:33 -03:00
committed by Cody Henthorne
parent 7fa200401c
commit 85e0e74bc6
55 changed files with 1653 additions and 621 deletions

View File

@@ -458,6 +458,7 @@ dependencies {
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "com.fasterxml.jackson.module:jackson-module-kotlin:2.12.0"
}
dependencyVerification {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 365 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 434 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 664 KiB

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 608 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 552 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 631 KiB

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 653 KiB

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 652 KiB

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 531 KiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 685 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 603 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 391 KiB

After

Width:  |  Height:  |  Size: 138 KiB

File diff suppressed because one or more lines are too long

View File

@@ -40,8 +40,10 @@ import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.gcm.FcmJobService;
import org.thoughtcrime.securesms.jobs.CreateSignedPreKeyJob;
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
import org.thoughtcrime.securesms.jobs.GroupV1MigrationJob;
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
@@ -57,10 +59,8 @@ import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.registration.RegistrationUtil;
import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager;
import org.thoughtcrime.securesms.ringrtc.RingRtcLogger;
import org.thoughtcrime.securesms.service.DirectoryRefreshListener;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.service.LocalBackupListener;
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
@@ -154,6 +154,8 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addNonBlocking(RefreshPreKeysJob::scheduleIfNecessary)
.addNonBlocking(StorageSyncHelper::scheduleRoutineSync)
.addNonBlocking(() -> ApplicationDependencies.getJobManager().beginJobLoop())
.addNonBlocking(EmojiSource::refresh)
.addNonBlocking(DownloadLatestEmojiDataJob::scheduleIfNecessary)
.addPostRender(this::initializeExpiringMessageManager)
.execute();

View File

@@ -1,17 +1,20 @@
package org.thoughtcrime.securesms.components.emoji;
import android.net.Uri;
import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
public class CompositeEmojiPageModel implements EmojiPageModel {
@AttrRes private final int iconAttr;
@NonNull private final EmojiPageModel[] models;
@AttrRes private final int iconAttr;
@NonNull private final List<EmojiPageModel> models;
public CompositeEmojiPageModel(@AttrRes int iconAttr, @NonNull EmojiPageModel... models) {
public CompositeEmojiPageModel(@AttrRes int iconAttr, @NonNull List<EmojiPageModel> models) {
this.iconAttr = iconAttr;
this.models = models;
}
@@ -39,12 +42,7 @@ public class CompositeEmojiPageModel implements EmojiPageModel {
}
@Override
public boolean hasSpriteMap() {
return false;
}
@Override
public @Nullable String getSprite() {
public @Nullable Uri getSpriteUri() {
return null;
}

View File

@@ -11,6 +11,10 @@ public class Emoji {
this.variations = Arrays.asList(variations);
}
public Emoji(List<String> variations) {
this.variations = variations;
}
public String getValue() {
return variations.get(0);
}

View File

@@ -13,6 +13,7 @@ import androidx.viewpager.widget.PagerAdapter;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.util.ResUtil;
@@ -66,7 +67,7 @@ public class EmojiKeyboardProvider implements MediaKeyboardProvider,
}, this);
models.add(recentModel);
models.addAll(EmojiPages.DISPLAY_PAGES);
models.addAll(EmojiSource.getLatest().getDisplayPages());
currentPosition = recentModel.getEmoji().size() > 0 ? 0 : 1;
}

View File

@@ -1,12 +1,15 @@
package org.thoughtcrime.securesms.components.emoji;
import android.net.Uri;
import androidx.annotation.Nullable;
import java.util.List;
public interface EmojiPageModel {
int getIconAttr();
List<String> getEmoji();
List<Emoji> getDisplayEmoji();
boolean hasSpriteMap();
String getSprite();
@Nullable Uri getSpriteUri();
boolean isDynamic();
}

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.components.emoji;
import android.annotation.TargetApi;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
@@ -9,8 +8,6 @@ import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.widget.TextView;
@@ -18,18 +15,18 @@ import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.bumptech.glide.load.DataSource;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.engine.GlideException;
import com.bumptech.glide.request.RequestListener;
import com.bumptech.glide.request.target.Target;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiDrawInfo;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiPageBitmap;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiTree;
import org.thoughtcrime.securesms.util.FutureTaskListener;
import org.whispersystems.libsignal.util.Pair;
import java.util.List;
import java.util.concurrent.ExecutionException;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.mms.GlideApp;
class EmojiProvider {
@@ -37,15 +34,7 @@ class EmojiProvider {
private static volatile EmojiProvider instance = null;
private static final Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.ANTI_ALIAS_FLAG);
private final EmojiTree emojiTree = new EmojiTree();
private static final int EMOJI_RAW_HEIGHT = 64;
private static final int EMOJI_RAW_WIDTH = 64;
private static final int EMOJI_VERT_PAD = 0;
private static final int EMOJI_PER_ROW = 16;
private final float decodeScale;
private final float verticalPad;
private final Context context;
public static EmojiProvider getInstance(Context context) {
if (instance == null) {
@@ -59,28 +48,12 @@ class EmojiProvider {
}
private EmojiProvider(Context context) {
this.decodeScale = Math.min(1f, context.getResources().getDimension(R.dimen.emoji_drawer_size) / EMOJI_RAW_HEIGHT);
this.verticalPad = EMOJI_VERT_PAD * this.decodeScale;
for (EmojiPageModel page : EmojiPages.DATA_PAGES) {
if (page.hasSpriteMap()) {
EmojiPageBitmap pageBitmap = new EmojiPageBitmap(context, page, decodeScale);
List<String> emojis = page.getEmoji();
for (int i = 0; i < emojis.size(); i++) {
emojiTree.add(emojis.get(i), new EmojiDrawInfo(pageBitmap, i));
}
}
}
for (Pair<String,String> obsolete : EmojiPages.OBSOLETE) {
emojiTree.add(obsolete.first(), emojiTree.getEmoji(obsolete.second(), 0, obsolete.second().length()));
}
this.context = context.getApplicationContext();
}
@Nullable EmojiParser.CandidateList getCandidates(@Nullable CharSequence text) {
if (text == null) return null;
return new EmojiParser(emojiTree).findCandidates(text);
return new EmojiParser(EmojiSource.getLatest().getEmojiTree()).findCandidates(text);
}
@Nullable Spannable emojify(@Nullable CharSequence text, @NonNull TextView tv) {
@@ -89,9 +62,10 @@ class EmojiProvider {
@Nullable Spannable emojify(@Nullable EmojiParser.CandidateList matches,
@Nullable CharSequence text,
@NonNull TextView tv) {
@NonNull TextView tv)
{
if (matches == null || text == null) return null;
SpannableStringBuilder builder = new SpannableStringBuilder(text);
SpannableStringBuilder builder = new SpannableStringBuilder(text);
for (EmojiParser.Candidate candidate : matches) {
Drawable drawable = getEmojiDrawable(candidate.getDrawInfo());
@@ -106,48 +80,71 @@ class EmojiProvider {
}
@Nullable Drawable getEmojiDrawable(CharSequence emoji) {
EmojiDrawInfo drawInfo = emojiTree.getEmoji(emoji, 0, emoji.length());
EmojiDrawInfo drawInfo = EmojiSource.getLatest().getEmojiTree().getEmoji(emoji, 0, emoji.length());
return getEmojiDrawable(drawInfo);
}
private @Nullable Drawable getEmojiDrawable(@Nullable EmojiDrawInfo drawInfo) {
if (drawInfo == null) {
if (drawInfo == null) {
return null;
}
final EmojiDrawable drawable = new EmojiDrawable(drawInfo, decodeScale);
drawInfo.getPage().get().addListener(new FutureTaskListener<Bitmap>() {
@Override public void onSuccess(final Bitmap result) {
ThreadUtil.runOnMain(() -> drawable.setBitmap(result));
}
final EmojiSource source = EmojiSource.getLatest();
final EmojiDrawable drawable = new EmojiDrawable(source, drawInfo);
GlideApp.with(context)
.asBitmap()
.diskCacheStrategy(DiskCacheStrategy.NONE)
.load(drawInfo.getPage().getModel())
.addListener(new RequestListener<Bitmap>() {
@Override
public boolean onLoadFailed(@Nullable GlideException e, Object model, Target<Bitmap> target, boolean isFirstResource) {
Log.d(TAG, "Failed to load emoji bitmap resource", e);
return false;
}
@Override
public boolean onResourceReady(Bitmap resource, Object model, Target<Bitmap> target, DataSource dataSource, boolean isFirstResource) {
ThreadUtil.runOnMain(() -> drawable.setBitmap(resource));
return true;
}
})
.submit();
@Override public void onFailure(ExecutionException error) {
Log.w(TAG, error);
}
});
return drawable;
}
class EmojiDrawable extends Drawable {
private final EmojiDrawInfo info;
private Bitmap bmp;
private float intrinsicWidth;
private float intrinsicHeight;
static final class EmojiDrawable extends Drawable {
private final float intrinsicWidth;
private final float intrinsicHeight;
private final Rect emojiBounds;
private Bitmap bmp;
@Override
public int getIntrinsicWidth() {
return (int)intrinsicWidth;
return (int) intrinsicWidth;
}
@Override
public int getIntrinsicHeight() {
return (int)intrinsicHeight;
return (int) intrinsicHeight;
}
EmojiDrawable(EmojiDrawInfo info, float decodeScale) {
this.info = info;
this.intrinsicWidth = EMOJI_RAW_WIDTH * decodeScale;
this.intrinsicHeight = EMOJI_RAW_HEIGHT * decodeScale;
EmojiDrawable(@NonNull EmojiSource source, @NonNull EmojiDrawInfo info) {
this.intrinsicWidth = source.getMetrics().getRawWidth() * source.getDecodeScale();
this.intrinsicHeight = source.getMetrics().getRawHeight() * source.getDecodeScale();
final int glyphWidth = (int) (source.getMetrics().getRawWidth() * source.getDecodeScale());
final int glyphHeight = (int) (source.getMetrics().getRawHeight() * source.getDecodeScale());
final int index = info.getIndex();
final int emojiPerRow = source.getMetrics().getPerRow();
final int xStart = (index % emojiPerRow) * glyphWidth;
final int yStart = (index / emojiPerRow) * glyphHeight;
this.emojiBounds = new Rect(xStart,
yStart,
xStart + glyphWidth,
yStart + glyphHeight);
}
@Override
@@ -156,22 +153,15 @@ class EmojiProvider {
return;
}
final int row = info.getIndex() / EMOJI_PER_ROW;
final int row_index = info.getIndex() % EMOJI_PER_ROW;
canvas.drawBitmap(bmp,
new Rect((int)(row_index * intrinsicWidth),
(int)(row * intrinsicHeight + row * verticalPad)+1,
(int)(((row_index + 1) * intrinsicWidth)-1),
(int)((row + 1) * intrinsicHeight + row * verticalPad)-1),
emojiBounds,
getBounds(),
paint);
}
@TargetApi(VERSION_CODES.HONEYCOMB_MR1)
public void setBitmap(Bitmap bitmap) {
ThreadUtil.assertMainThread();
if (VERSION.SDK_INT < VERSION_CODES.HONEYCOMB_MR1 || bmp == null || !bmp.sameAs(bitmap)) {
if (bmp == null || !bmp.sameAs(bitmap)) {
bmp = bitmap;
invalidateSelf();
}
@@ -188,5 +178,4 @@ class EmojiProvider {
@Override
public void setColorFilter(ColorFilter cf) { }
}
}

View File

@@ -20,7 +20,6 @@ import androidx.core.content.ContextCompat;
import androidx.core.widget.TextViewCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
@@ -233,8 +232,8 @@ public class EmojiTextView extends AppCompatTextView {
@Override
public void invalidateDrawable(@NonNull Drawable drawable) {
if (drawable instanceof EmojiDrawable) invalidate();
else super.invalidateDrawable(drawable);
if (drawable instanceof EmojiProvider.EmojiDrawable) invalidate();
else super.invalidateDrawable(drawable);
}
@Override

View File

@@ -7,9 +7,10 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.emoji.ObsoleteEmoji;
import org.thoughtcrime.securesms.util.StringUtil;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.Pair;
import java.util.HashMap;
import java.util.HashSet;
@@ -19,38 +20,10 @@ import java.util.Set;
import java.util.regex.Pattern;
public final class EmojiUtil {
private static final Map<String, String> VARIATION_MAP = new HashMap<>();
static {
for (EmojiPageModel page : EmojiPages.DATA_PAGES) {
for (Emoji emoji : page.getDisplayEmoji()) {
for (String variation : emoji.getVariations()) {
VARIATION_MAP.put(variation, emoji.getValue());
}
}
}
}
public static final int MAX_EMOJI_LENGTH;
static {
int max = 0;
for (EmojiPageModel page : EmojiPages.DATA_PAGES) {
for (String emoji : page.getEmoji()) {
max = Math.max(max, emoji.length());
}
}
MAX_EMOJI_LENGTH = max;
}
private static final Pattern EMOJI_PATTERN = Pattern.compile("^(?:(?:[\u00a9\u00ae\u203c\u2049\u2122\u2139\u2194-\u2199\u21a9-\u21aa\u231a-\u231b\u2328\u23cf\u23e9-\u23f3\u23f8-\u23fa\u24c2\u25aa-\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614-\u2615\u2618\u261d\u2620\u2622-\u2623\u2626\u262a\u262e-\u262f\u2638-\u263a\u2648-\u2653\u2660\u2663\u2665-\u2666\u2668\u267b\u267f\u2692-\u2694\u2696-\u2697\u2699\u269b-\u269c\u26a0-\u26a1\u26aa-\u26ab\u26b0-\u26b1\u26bd-\u26be\u26c4-\u26c5\u26c8\u26ce-\u26cf\u26d1\u26d3-\u26d4\u26e9-\u26ea\u26f0-\u26f5\u26f7-\u26fa\u26fd\u2702\u2705\u2708-\u270d\u270f\u2712\u2714\u2716\u271d\u2721\u2728\u2733-\u2734\u2744\u2747\u274c\u274e\u2753-\u2755\u2757\u2763-\u2764\u2795-\u2797\u27a1\u27b0\u27bf\u2934-\u2935\u2b05-\u2b07\u2b1b-\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299\ud83c\udc04\ud83c\udccf\ud83c\udd70-\ud83c\udd71\ud83c\udd7e-\ud83c\udd7f\ud83c\udd8e\ud83c\udd91-\ud83c\udd9a\ud83c\ude01-\ud83c\ude02\ud83c\ude1a\ud83c\ude2f\ud83c\ude32-\ud83c\ude3a\ud83c\ude50-\ud83c\ude51\u200d\ud83c\udf00-\ud83d\uddff\ud83d\ude00-\ud83d\ude4f\ud83d\ude80-\ud83d\udeff\ud83e\udd00-\ud83e\uddff\udb40\udc20-\udb40\udc7f]|\u200d[\u2640\u2642]|[\ud83c\udde6-\ud83c\uddff]{2}|.[\u20e0\u20e3\ufe0f]+)+)+$");
private EmojiUtil() {}
public static List<EmojiPageModel> getDisplayPages() {
return EmojiPages.DISPLAY_PAGES;
}
/**
* This will return all ways we know of expressing a singular emoji. This is to aid in search,
* where some platforms may send an emoji we've locally marked as 'obsolete'.
@@ -60,11 +33,11 @@ public final class EmojiUtil {
out.add(emoji);
for (Pair<String, String> pair : EmojiPages.OBSOLETE) {
if (pair.first().equals(emoji)) {
out.add(pair.second());
} else if (pair.second().equals(emoji)) {
out.add(pair.first());
for (ObsoleteEmoji obsoleteEmoji : EmojiSource.getLatest().getObsolete()) {
if (obsoleteEmoji.getObsolete().equals(emoji)) {
out.add(obsoleteEmoji.getReplaceWith());
} else if (obsoleteEmoji.getReplaceWith().equals(emoji)) {
out.add(obsoleteEmoji.getObsolete());
}
}
@@ -79,7 +52,7 @@ public final class EmojiUtil {
* If the emoji has no skin variations, this function will return the original emoji.
*/
public static @NonNull String getCanonicalRepresentation(@NonNull String emoji) {
String canonical = VARIATION_MAP.get(emoji);
String canonical = EmojiSource.getLatest().getVariationMap().get(emoji);
return canonical != null ? canonical : emoji;
}

View File

@@ -2,10 +2,12 @@ package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.content.SharedPreferences;
import android.net.Uri;
import android.preference.PreferenceManager;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import com.fasterxml.jackson.databind.type.CollectionType;
@@ -63,11 +65,7 @@ public class RecentEmojiPageModel implements EmojiPageModel {
return Stream.of(getEmoji()).map(Emoji::new).toList();
}
@Override public boolean hasSpriteMap() {
return false;
}
@Override public String getSprite() {
@Override public @Nullable Uri getSpriteUri() {
return null;
}

View File

@@ -1,23 +1,25 @@
package org.thoughtcrime.securesms.components.emoji;
import android.net.Uri;
import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
public class StaticEmojiPageModel implements EmojiPageModel {
@AttrRes private final int iconAttr;
@NonNull private final List<Emoji> emoji;
@Nullable private final String sprite;
@Nullable private final Uri sprite;
public StaticEmojiPageModel(@AttrRes int iconAttr, @NonNull String[] strings, @Nullable String sprite) {
public StaticEmojiPageModel(@AttrRes int iconAttr, @NonNull String[] strings, @Nullable Uri sprite) {
List<Emoji> emoji = new ArrayList<>(strings.length);
for (String s : strings) {
emoji.add(new Emoji(s));
emoji.add(new Emoji(Collections.singletonList(s)));
}
this.iconAttr = iconAttr;
@@ -25,9 +27,9 @@ public class StaticEmojiPageModel implements EmojiPageModel {
this.sprite = sprite;
}
public StaticEmojiPageModel(@AttrRes int iconAttr, @NonNull Emoji[] emoji, @Nullable String sprite) {
public StaticEmojiPageModel(@AttrRes int iconAttr, @NonNull List<Emoji> emoji, @Nullable Uri sprite) {
this.iconAttr = iconAttr;
this.emoji = Arrays.asList(emoji);
this.emoji = Collections.unmodifiableList(emoji);
this.sprite = sprite;
}
@@ -50,12 +52,7 @@ public class StaticEmojiPageModel implements EmojiPageModel {
}
@Override
public boolean hasSpriteMap() {
return sprite != null;
}
@Override
public @Nullable String getSprite() {
public @Nullable Uri getSpriteUri() {
return sprite;
}

View File

@@ -3,17 +3,19 @@ package org.thoughtcrime.securesms.components.emoji.parsing;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.emoji.EmojiPageReference;
public class EmojiDrawInfo {
private final EmojiPageBitmap page;
private final int index;
private final EmojiPageReference page;
private final int index;
public EmojiDrawInfo(final @NonNull EmojiPageBitmap page, final int index) {
public EmojiDrawInfo(final @NonNull EmojiPageReference page, final int index) {
this.page = page;
this.index = index;
}
public @NonNull EmojiPageBitmap getPage() {
public @NonNull EmojiPageReference getPage() {
return page;
}

View File

@@ -1,103 +0,0 @@
package org.thoughtcrime.securesms.components.emoji.parsing;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.AssetManager;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import androidx.annotation.NonNull;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel;
import org.thoughtcrime.securesms.util.ListenableFutureTask;
import org.thoughtcrime.securesms.util.Stopwatch;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import java.io.IOException;
import java.io.InputStream;
import java.lang.ref.SoftReference;
import java.util.concurrent.Callable;
public class EmojiPageBitmap {
private static final String TAG = Log.tag(EmojiPageBitmap.class);
private final Context context;
private final EmojiPageModel model;
private final float decodeScale;
private SoftReference<Bitmap> bitmapReference;
private ListenableFutureTask<Bitmap> task;
public EmojiPageBitmap(@NonNull Context context, @NonNull EmojiPageModel model, float decodeScale) {
this.context = context.getApplicationContext();
this.model = model;
this.decodeScale = decodeScale;
}
@SuppressLint("StaticFieldLeak")
public ListenableFutureTask<Bitmap> get() {
ThreadUtil.assertMainThread();
if (bitmapReference != null && bitmapReference.get() != null) {
return new ListenableFutureTask<>(bitmapReference.get());
} else if (task != null) {
return task;
} else {
Callable<Bitmap> callable = () -> {
try {
Log.i(TAG, "loading page " + model.getSprite());
return loadPage();
} catch (IOException ioe) {
Log.w(TAG, ioe);
}
return null;
};
task = new ListenableFutureTask<>(callable);
SimpleTask.run(() -> {
task.run();
return null;
},
unused -> task = null);
}
return task;
}
private Bitmap loadPage() throws IOException {
if (bitmapReference != null && bitmapReference.get() != null) return bitmapReference.get();
float scale = decodeScale;
AssetManager assetManager = context.getAssets();
InputStream assetStream = assetManager.open(model.getSprite());
BitmapFactory.Options options = new BitmapFactory.Options();
if (Util.isLowMemory(context)) {
Log.i(TAG, "Low memory detected. Changing sample size.");
options.inSampleSize = 2;
scale = decodeScale * 2;
}
Stopwatch stopwatch = new Stopwatch(model.getSprite());
Bitmap bitmap = BitmapFactory.decodeStream(assetStream, null, options);
stopwatch.split("decode");
Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, (int)(bitmap.getWidth() * scale), (int)(bitmap.getHeight() * scale), true);
stopwatch.split("scale");
stopwatch.stop(TAG);
bitmapReference = new SoftReference<>(scaledBitmap);
Log.i(TAG, "onPageLoaded(" + model.getSprite() + ") originalByteCount: " + bitmap.getByteCount()
+ " scaledByteCount: " + scaledBitmap.getByteCount()
+ " scaledSize: " + scaledBitmap.getWidth() + "x" + scaledBitmap.getHeight());
return scaledBitmap;
}
@Override
public @NonNull String toString() {
return model.getSprite();
}
}

View File

@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.components.emoji.EmojiUtil;
import org.thoughtcrime.securesms.database.CursorList;
import org.thoughtcrime.securesms.database.DatabaseContentProviders;
import org.thoughtcrime.securesms.database.model.StickerRecord;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.stickers.StickerSearchRepository;
import org.thoughtcrime.securesms.util.Throttler;
@@ -56,7 +57,7 @@ class ConversationStickerViewModel extends ViewModel {
}
void onInputTextUpdated(@NonNull String text) {
if (TextUtils.isEmpty(text) || text.length() > EmojiUtil.MAX_EMOJI_LENGTH) {
if (TextUtils.isEmpty(text) || text.length() > EmojiSource.getLatest().getMaxEmojiLength()) {
stickers.setValue(CursorList.emptyList());
} else {
repository.searchByEmoji(text, stickers::postValue);

View File

@@ -84,8 +84,8 @@ public class ApplicationDependencies {
private static volatile ViewOnceMessageManager viewOnceMessageManager;
private static volatile ExpiringMessageManager expiringMessageManager;
private static volatile Payments payments;
private static volatile ShakeToReport shakeToReport;
private static volatile SignalCallManager signalCallManager;
private static volatile ShakeToReport shakeToReport;
private static volatile OkHttpClient okHttpClient;
@MainThread
@@ -451,7 +451,6 @@ public class ApplicationDependencies {
synchronized (LOCK) {
if (okHttpClient == null) {
okHttpClient = new OkHttpClient.Builder()
.proxySelector(new ContentProxySelector())
.addInterceptor(new StandardUserAgentInterceptor())
.dns(SignalServiceNetworkAccess.DNS)
.build();

View File

@@ -0,0 +1,23 @@
package org.thoughtcrime.securesms.emoji
import androidx.annotation.AttrRes
import org.thoughtcrime.securesms.R
/**
* All the different Emoji categories the app is aware of in the order we want to display them.
*/
enum class EmojiCategory(val priority: Int, val key: String, @AttrRes val icon: Int) {
PEOPLE(0, "People", R.attr.emoji_category_people),
NATURE(1, "Nature", R.attr.emoji_category_nature),
FOODS(2, "Foods", R.attr.emoji_category_foods),
ACTIVITY(3, "Activity", R.attr.emoji_category_activity),
PLACES(4, "Places", R.attr.emoji_category_places),
OBJECTS(5, "Objects", R.attr.emoji_category_objects),
SYMBOLS(6, "Symbols", R.attr.emoji_category_symbols),
FLAGS(7, "Flags", R.attr.emoji_category_flags),
EMOTICONS(8, "Emoticons", R.attr.emoji_category_emoticons);
companion object {
fun forKey(key: String) = values().first { it.key == key }
}
}

View File

@@ -0,0 +1,34 @@
package org.thoughtcrime.securesms.emoji
import android.content.Context
import android.content.Intent
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.service.PersistentAlarmManagerListener
import java.util.concurrent.TimeUnit
private val INTERVAL_WITHOUT_REMOTE_DOWNLOAD = TimeUnit.DAYS.toMillis(1)
private val INTERVAL_WITH_REMOTE_DOWNLOAD = TimeUnit.DAYS.toMillis(7)
class EmojiDownloadListener : PersistentAlarmManagerListener() {
override fun getNextScheduledExecutionTime(context: Context): Long = SignalStore.emojiValues().nextScheduledCheck
override fun onAlarm(context: Context, scheduledTime: Long): Long {
ApplicationDependencies.getJobManager().add(DownloadLatestEmojiDataJob(false))
val nextTime: Long = System.currentTimeMillis() + if (EmojiFiles.Version.exists(context)) INTERVAL_WITH_REMOTE_DOWNLOAD else INTERVAL_WITHOUT_REMOTE_DOWNLOAD
SignalStore.emojiValues().nextScheduledCheck = nextTime
return nextTime
}
companion object {
@JvmStatic
fun schedule(context: Context) {
EmojiDownloadListener().onReceive(context, Intent())
}
}
}

View File

@@ -0,0 +1,205 @@
package org.thoughtcrime.securesms.emoji
import android.content.Context
import android.net.Uri
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import okio.Okio
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream
import org.thoughtcrime.securesms.mms.PartAuthority
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.lang.Exception
import java.util.UUID
/**
* File structure:
* <p>
* emoji/
* .version -- Contains MD5 hash of current version plus a uuid mapping
* `uuid`/ -- Directory for a specific MD5hash underneath which all the data lives.
* | .names -- Contains name mappings for downloaded files. When a file finishes downloading, we create a random UUID name for it and add it to .names
* | `uuid1`
* | `uuid2`
* | ...
* <p>
* .version format:
* <p>
* {"version": ..., "uuid": "..."}
* <p>
* .name format:
* [
* {"name": "...", "uuid": "..."}
* ]
*/
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 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 getFilesUri(name: String, format: String): Uri = PartAuthority.getEmojiUri(name)
private fun getOutputStream(context: Context, outputFile: File): OutputStream {
val attachmentSecret = AttachmentSecretProvider.getInstance(context).orCreateAttachmentSecret
return ModernEncryptingPartOutputStream.createFor(attachmentSecret, outputFile, true).second
}
private fun getInputStream(context: Context, inputFile: File): InputStream {
val attachmentSecret = AttachmentSecretProvider.getInstance(context).orCreateAttachmentSecret
return ModernDecryptingPartInputStream.createFor(attachmentSecret, inputFile, 0)
}
object EmojiFiles {
@JvmStatic
fun getBaseDirectory(context: Context): File = context.getEmojiDirectory()
@JvmStatic
fun delete(context: Context, version: Version, uuid: UUID) {
try {
version.getFile(context, uuid).delete()
} catch (e: IOException) {
Log.i(TAG, "Failed to delete file.")
}
}
@JvmStatic
fun openForReading(context: Context, name: String): InputStream {
val version: Version = Version.readVersion(context) ?: throw IOException("No emoji version is present on disk")
val names: NameCollection = NameCollection.read(context, version)
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))
}
@JvmStatic
fun getMd5(context: Context, version: Version, uuid: UUID): ByteArray? {
val file = version.getFile(context, uuid)
try {
getInputStream(context, file).use {
return Okio.buffer(Okio.source(it)).buffer.md5().toByteArray()
}
} catch (e: Exception) {
Log.i(TAG, "Could not read emoji data file md5", e)
return null
}
}
@JvmStatic
fun getLatestEmojiData(context: Context, version: Version): EmojiData? {
val names = NameCollection.read(context, version)
val dataUuid = names.getUUIDForEmojiData() ?: return null
val file = version.getFile(context, dataUuid)
getInputStream(context, file).use {
return EmojiJsonParser.parse(it, ::getFilesUri).getOrElse { throwable ->
Log.w(TAG, "Failed to parse emoji_data", throwable)
null
}
}
}
class Version(@JsonProperty val version: Int, @JsonProperty val uuid: UUID, @JsonProperty val density: String) {
fun getFile(context: Context, uuid: UUID): File = File(getDirectory(context), uuid.toString())
private fun getDirectory(context: Context): File = File(context.getEmojiDirectory(), this.uuid.toString()).apply { mkdir() }
companion object {
private val objectMapper = ObjectMapper().registerKotlinModule()
@JvmStatic
fun exists(context: Context): Boolean = context.getVersionFile().exists()
@JvmStatic
fun readVersion(context: Context): Version? {
try {
getInputStream(context, context.getVersionFile()).use {
return objectMapper.readValue(it, Version::class.java)
}
} catch (e: Exception) {
Log.w(TAG, "Could not read current emoji version from disk.")
return null
}
}
@JvmStatic
fun writeVersion(context: Context, version: Version) {
val versionFile: File = context.getVersionFile()
try {
if (versionFile.exists()) {
versionFile.delete()
}
getOutputStream(context, versionFile).use {
objectMapper.writeValue(it, version)
}
} catch (e: Exception) {
Log.w(TAG, "Could not write current emoji version from disk.")
}
}
}
}
class Name(@JsonProperty val name: String, @JsonProperty val uuid: UUID) {
companion object {
@JvmStatic
fun forEmojiDataJson(): Name = Name(EMOJI_JSON, UUID.randomUUID())
}
}
class NameCollection(@JsonProperty val versionUuid: UUID, @JsonProperty val names: List<Name>) {
companion object {
private val objectMapper = ObjectMapper().registerKotlinModule()
@JvmStatic
fun read(context: Context, version: Version): NameCollection {
try {
getInputStream(context, context.getNameFile(version.uuid)).use {
return objectMapper.readValue(it)
}
} catch (e: Exception) {
return NameCollection(version.uuid, listOf())
}
}
@JvmStatic
fun append(context: Context, nameCollection: NameCollection, name: Name): NameCollection {
val collection = NameCollection(nameCollection.versionUuid, nameCollection.names + name)
getOutputStream(context, context.getNameFile(nameCollection.versionUuid)).use {
objectMapper.writeValue(it, collection)
}
return collection
}
}
@JsonIgnore
fun getUUIDForEmojiData(): UUID? = getUUIDForName(EMOJI_JSON)
@JsonIgnore
fun getUUIDForName(name: String): UUID? = names.firstOrNull { it.name == name }?.uuid
}
}

View File

@@ -0,0 +1,115 @@
package org.thoughtcrime.securesms.emoji
import android.net.Uri
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.google.android.gms.common.util.Hex
import org.thoughtcrime.securesms.components.emoji.CompositeEmojiPageModel
import org.thoughtcrime.securesms.components.emoji.Emoji
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel
import org.thoughtcrime.securesms.components.emoji.StaticEmojiPageModel
import java.io.InputStream
import java.nio.charset.Charset
typealias UriFactory = (sprite: String, format: String) -> Uri
/**
* Takes an emoji_data.json file data and parses it into an EmojiSource
*/
object EmojiJsonParser {
private val OBJECT_MAPPER = ObjectMapper()
@JvmStatic
fun verify(body: InputStream) {
parse(body) { _, _ -> Uri.EMPTY }.getOrThrow()
}
fun parse(body: InputStream, uriFactory: UriFactory): Result<ParsedEmojiData> {
return try {
Result.success(buildEmojiSourceFromNode(OBJECT_MAPPER.readTree(body), uriFactory))
} catch (e: Exception) {
Result.failure(e)
}
}
private fun buildEmojiSourceFromNode(node: JsonNode, uriFactory: UriFactory): ParsedEmojiData {
val format: String = node["format"].textValue()
val obsolete: List<ObsoleteEmoji> = node["obsolete"].toObseleteList()
val dataPages: List<EmojiPageModel> = getDataPages(format, node["emoji"], uriFactory)
val displayPages: List<EmojiPageModel> = mergeToDisplayPages(dataPages)
val metrics: EmojiMetrics = node["metrics"].toEmojiMetrics()
val densities: List<String> = node["densities"].toDensityList()
return ParsedEmojiData(metrics, densities, format, displayPages, dataPages, obsolete)
}
private fun getDataPages(format: String, emoji: JsonNode, uriFactory: UriFactory): List<EmojiPageModel> {
return emoji.fields()
.asSequence()
.sortedWith { lhs, rhs ->
val lhsCategory = EmojiCategory.forKey(lhs.key.asCategoryKey())
val rhsCategory = EmojiCategory.forKey(rhs.key.asCategoryKey())
val comp = lhsCategory.priority.compareTo(rhsCategory.priority)
if (comp == 0) {
val lhsIndex = lhs.key.getPageIndex()
val rhsIndex = rhs.key.getPageIndex()
lhsIndex.compareTo(rhsIndex)
} else {
comp
}
}
.map { createPage(it.key, format, it.value, uriFactory) }
.toList()
}
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() })
}
}
return StaticEmojiPageModel(category.icon, pageList, uriFactory(pageName, format))
}
private fun mergeToDisplayPages(dataPages: List<EmojiPageModel>): List<EmojiPageModel> {
return dataPages.groupBy { it.iconAttr }
.map { (icon, pages) -> if (pages.size <= 1) pages.first() else CompositeEmojiPageModel(icon, pages) }
}
}
private fun JsonNode?.toObseleteList(): List<ObsoleteEmoji> {
return if (this == null) {
listOf()
} else {
map { node ->
ObsoleteEmoji(node["obsoleted"].textValue().encodeAsUtf16(), node["replace_with"].textValue().encodeAsUtf16())
}.toList()
}
}
private fun JsonNode.toEmojiMetrics(): EmojiMetrics {
return EmojiMetrics(this["raw_width"].asInt(), this["raw_height"].asInt(), this["per_row"].asInt())
}
private fun JsonNode.toDensityList(): List<String> {
return map { it.textValue() }
}
private fun String.encodeAsUtf16() = String(Hex.stringToBytes(this), Charset.forName("UTF-16"))
private fun String.asCategoryKey() = replace("(_\\d+)*$".toRegex(), "")
private fun String.getPageIndex() = "^.*_(\\d+)+$".toRegex().find(this)?.let { it.groupValues[1] }?.toInt() ?: throw IllegalStateException("No index.")
data class ParsedEmojiData(
override val metrics: EmojiMetrics,
override val densities: List<String>,
override val format: String,
override val displayPages: List<EmojiPageModel>,
override val dataPages: List<EmojiPageModel>,
override val obsolete: List<ObsoleteEmoji>
) : EmojiData

View File

@@ -0,0 +1,22 @@
package org.thoughtcrime.securesms.emoji
import android.net.Uri
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
/**
* Used by Emoji provider to set up a glide request.
*/
class EmojiPageReference {
val model: Any
constructor(uri: Uri) {
model = uri
}
constructor(decryptableUri: DecryptableStreamUriLoader.DecryptableUri) {
model = decryptableUri
}
}
typealias EmojiPageReferenceFactory = (uri: Uri) -> EmojiPageReference

View File

@@ -0,0 +1,95 @@
package org.thoughtcrime.securesms.emoji
import okhttp3.Request
import okhttp3.Response
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.txt"
private const val BASE_STATIC_BUCKET_URL = "https://updates.signal.org/static/android/emoji"
/**
* Responsible for communicating with S3 to download Emoji related objects.
*/
object EmojiRemote {
private const val TAG = "EmojiRemote"
private val okHttpClient = ApplicationDependencies.getOkHttpClient()
@JvmStatic
@Throws(IOException::class)
fun getVersion(): Int {
val request = Request.Builder()
.get()
.url(VERSION_URL)
.build()
try {
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException()
}
return response.body()?.bytes()?.let { String(it).trim().toIntOrNull() } ?: throw IOException()
}
} catch (e: IOException) {
throw e
}
}
/**
* Downloads and returns the MD5 hash stored in an S3 object's ETag
*/
@JvmStatic
fun getMd5(emojiRequest: EmojiRequest): ByteArray? {
val request = Request.Builder()
.head()
.url(emojiRequest.url)
.build()
try {
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException()
}
return response.header("ETag")?.toByteArray()
}
} catch (e: IOException) {
Log.w(TAG, "Could not retrieve md5", e)
return null
}
}
/**
* Downloads an object for the specified name.
*/
@JvmStatic
fun getObject(emojiRequest: EmojiRequest): Response {
val request = Request.Builder()
.get()
.url(emojiRequest.url)
.build()
return okHttpClient.newCall(request).execute()
}
}
interface EmojiRequest {
val url: String
}
class EmojiJsonRequest(version: Int) : EmojiRequest {
override val url: String = "$BASE_STATIC_BUCKET_URL/$version/emoji_data.json"
}
class EmojiImageRequest(
version: Int,
density: String,
name: String,
format: String
) : EmojiRequest {
override val url: String = "$BASE_STATIC_BUCKET_URL/$version/$density/$name.$format"
}

View File

@@ -0,0 +1,159 @@
package org.thoughtcrime.securesms.emoji
import android.net.Uri
import androidx.annotation.WorkerThread
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.Emoji
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel
import org.thoughtcrime.securesms.components.emoji.StaticEmojiPageModel
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiDrawInfo
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiTree
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
import org.thoughtcrime.securesms.util.ScreenDensity
import java.io.InputStream
import java.util.concurrent.CountDownLatch
import java.util.concurrent.atomic.AtomicReference
import kotlin.math.min
/**
* The entry point for the application to request Emoji data for custom emojis.
*/
class EmojiSource(
val decodeScale: Float,
private val emojiData: EmojiData,
private val emojiPageReferenceFactory: EmojiPageReferenceFactory
) : EmojiData by emojiData {
val variationMap: Map<String, String> by lazy {
val map = mutableMapOf<String, String>()
for (page: EmojiPageModel in dataPages) {
for (emoji: Emoji in page.displayEmoji) {
for (variation: String in emoji.variations) {
map[variation] = emoji.value
}
}
}
map
}
val maxEmojiLength: Int by lazy {
dataPages.map { it.emoji.map(String::length) }
.flatten()
.maxOrZero()
}
val emojiTree: EmojiTree by lazy {
val tree = EmojiTree()
dataPages
.filter { it.spriteUri != null }
.forEach { page ->
val reference = emojiPageReferenceFactory(page.spriteUri!!)
page.emoji.forEachIndexed { idx, emoji ->
tree.add(emoji, EmojiDrawInfo(reference, idx))
}
}
obsolete.forEach {
tree.add(it.obsolete, tree.getEmoji(it.replaceWith, 0, it.replaceWith.length))
}
tree
}
companion object {
private val emojiSource = AtomicReference<EmojiSource>()
private val emojiLatch = CountDownLatch(1)
@JvmStatic
val latest: EmojiSource
get() {
emojiLatch.await()
return emojiSource.get()
}
@JvmStatic
@WorkerThread
fun refresh() {
emojiSource.set(getEmojiSource())
emojiLatch.countDown()
}
private fun getEmojiSource(): EmojiSource {
return loadRemoteBasedEmojis() ?: loadAssetBasedEmojis()
}
private fun loadRemoteBasedEmojis(): EmojiSource? {
val context = ApplicationDependencies.getApplication()
val version = EmojiFiles.Version.readVersion(context) ?: return null
val emojiData = EmojiFiles.getLatestEmojiData(context, version)
val density = ScreenDensity.xhdpiRelativeDensityScaleFactor(version.density)
return emojiData?.let {
val decodeScale = min(1f, context.resources.getDimension(R.dimen.emoji_drawer_size) / it.metrics.rawHeight)
EmojiSource(decodeScale * density, it) { uri: Uri -> EmojiPageReference(DecryptableStreamUriLoader.DecryptableUri(uri)) }
}
}
private fun loadAssetBasedEmojis(): EmojiSource {
val context = ApplicationDependencies.getApplication()
val emojiData: InputStream = ApplicationDependencies.getApplication().assets.open("emoji/emoji_data.json")
emojiData.use {
val parsedData: ParsedEmojiData = EmojiJsonParser.parse(it, ::getAssetsUri).getOrThrow()
val decodeScale = min(1f, context.resources.getDimension(R.dimen.emoji_drawer_size) / parsedData.metrics.rawHeight)
return EmojiSource(
decodeScale * ScreenDensity.xhdpiRelativeDensityScaleFactor("xhdpi"),
parsedData.copy(
displayPages = parsedData.displayPages + PAGE_EMOTICONS,
dataPages = parsedData.dataPages + PAGE_EMOTICONS
)
) { uri: Uri -> EmojiPageReference(uri) }
}
}
}
}
private fun List<Int>.maxOrZero(): Int = maxOrNull() ?: 0
interface EmojiData {
val metrics: EmojiMetrics
val densities: List<String>
val format: String
val displayPages: List<EmojiPageModel>
val dataPages: List<EmojiPageModel>
val obsolete: List<ObsoleteEmoji>
}
data class ObsoleteEmoji(val obsolete: String, val replaceWith: String)
data class EmojiMetrics(val rawHeight: Int, val rawWidth: Int, val perRow: Int)
private fun getAssetsUri(name: String, format: String): Uri = Uri.parse("file:///android_asset/emoji/$name.$format")
private val PAGE_EMOTICONS: EmojiPageModel = StaticEmojiPageModel(
EmojiCategory.EMOTICONS.icon,
arrayOf(
":-)", ";-)", "(-:", ":->", ":-D", "\\o/",
":-P", "B-)", ":-$", ":-*", "O:-)", "=-O",
"O_O", "O_o", "o_O", ":O", ":-!", ":-x",
":-|", ":-\\", ":-(", ":'(", ":-[", ">:-(",
"^.^", "^_^", "\\(\u02c6\u02da\u02c6)/",
"\u30fd(\u00b0\u25c7\u00b0 )\u30ce", "\u00af\\(\u00b0_o)/\u00af",
"\u00af\\_(\u30c4)_/\u00af", "(\u00ac_\u00ac)",
"(>_<)", "(\u2565\ufe4f\u2565)", "(\u261e\uff9f\u30ee\uff9f)\u261e",
"\u261c(\uff9f\u30ee\uff9f\u261c)", "\u261c(\u2312\u25bd\u2312)\u261e",
"(\u256f\u00b0\u25a1\u00b0)\u256f\ufe35", "\u253b\u2501\u253b",
"\u252c\u2500\u252c", "\u30ce(\u00b0\u2013\u00b0\u30ce)",
"(^._.^)\uff89", "\u0e05^\u2022\ufecc\u2022^\u0e05",
"\u0295\u2022\u1d25\u2022\u0294", "(\u2022_\u2022)",
" \u25a0-\u25a0\u00ac <(\u2022_\u2022) ", "(\u25a0_\u25a0\u00ac)",
"\u01aa(\u0693\u05f2)\u200e\u01aa\u200b\u200b"
),
null
)

View File

@@ -15,6 +15,7 @@ import androidx.recyclerview.widget.StaggeredGridLayoutManager;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.net.ContentProxySelector;
import java.util.List;
@@ -47,7 +48,7 @@ public class GiphyMp4Fragment extends Fragment {
ContentLoadingProgressBar progressBar = view.findViewById(R.id.content_loading);
TextView nothingFound = view.findViewById(R.id.nothing_found);
GiphyMp4ViewModel viewModel = ViewModelProviders.of(requireActivity(), new GiphyMp4ViewModel.Factory(isForMms)).get(GiphyMp4ViewModel.class);
GiphyMp4MediaSourceFactory mediaSourceFactory = new GiphyMp4MediaSourceFactory(ApplicationDependencies.getOkHttpClient());
GiphyMp4MediaSourceFactory mediaSourceFactory = new GiphyMp4MediaSourceFactory(ApplicationDependencies.getOkHttpClient().newBuilder().proxySelector(new ContentProxySelector()).build());
GiphyMp4Adapter adapter = new GiphyMp4Adapter(mediaSourceFactory, viewModel::saveToBlob);
List<GiphyMp4ProjectionPlayerHolder> holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(requireContext(),
getViewLifecycleOwner().getLifecycle(),

View File

@@ -6,17 +6,15 @@ import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.signal.paging.PagedDataSource;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.giph.model.GiphyImage;
import org.thoughtcrime.securesms.giph.model.GiphyResponse;
import org.thoughtcrime.securesms.net.ContentProxySelector;
import org.thoughtcrime.securesms.util.JsonUtils;
import org.whispersystems.util.Base64;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedList;
import java.util.List;
@@ -36,7 +34,7 @@ final class GiphyMp4PagedDataSource implements PagedDataSource<GiphyImage> {
GiphyMp4PagedDataSource(@Nullable String searchQuery) {
this.searchString = searchQuery;
this.client = ApplicationDependencies.getOkHttpClient();
this.client = ApplicationDependencies.getOkHttpClient().newBuilder().proxySelector(new ContentProxySelector()).build();
}
@Override

View File

@@ -0,0 +1,80 @@
package org.thoughtcrime.securesms.jobmanager.impl;
import android.app.job.JobInfo;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import org.thoughtcrime.securesms.jobmanager.Constraint;
import org.thoughtcrime.securesms.util.NetworkUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.util.Collections;
import java.util.Set;
/**
* Constraint for Emoji Downloads which respects users settings regarding image downloads and requires network.
*/
public class AutoDownloadEmojiConstraint implements Constraint {
public static final String KEY = "AutoDownloadEmojiConstraint";
private static final String IMAGE_TYPE = "images";
private final Context context;
private AutoDownloadEmojiConstraint(@NonNull Context context) {
this.context = context.getApplicationContext();
}
@Override
public boolean isMet() {
return canAutoDownloadEmoji(context);
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@RequiresApi(26)
@Override
public void applyToJobInfo(@NonNull JobInfo.Builder jobInfoBuilder) {
boolean canDownloadWhileRoaming = TextSecurePreferences.getRoamingMediaDownloadAllowed(context).contains(IMAGE_TYPE);
boolean canDownloadWhileMobile = TextSecurePreferences.getMobileMediaDownloadAllowed(context).contains(IMAGE_TYPE);
if (canDownloadWhileRoaming) {
jobInfoBuilder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY);
} else if (canDownloadWhileMobile) {
jobInfoBuilder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_NOT_ROAMING);
} else {
jobInfoBuilder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED);
}
}
public static boolean canAutoDownloadEmoji(@NonNull Context context) {
return getAllowedAutoDownloadTypes(context).contains(IMAGE_TYPE);
}
private static @NonNull Set<String> getAllowedAutoDownloadTypes(@NonNull Context context) {
if (NetworkUtil.isConnectedWifi(context)) return Collections.singleton(IMAGE_TYPE);
else if (NetworkUtil.isConnectedRoaming(context)) return TextSecurePreferences.getRoamingMediaDownloadAllowed(context);
else if (NetworkUtil.isConnectedMobile(context)) return TextSecurePreferences.getMobileMediaDownloadAllowed(context);
else return Collections.emptySet();
}
public static final class Factory implements Constraint.Factory<AutoDownloadEmojiConstraint> {
private final Context context;
public Factory(@NonNull Context context) {
this.context = context.getApplicationContext();
}
@Override
public AutoDownloadEmojiConstraint create() {
return new AutoDownloadEmojiConstraint(context);
}
}
}

View File

@@ -0,0 +1,461 @@
package org.thoughtcrime.securesms.jobs;
import android.content.Context;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.IntPair;
import com.annimon.stream.Stream;
import com.mobilecoin.lib.util.Hex;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.emoji.EmojiData;
import org.thoughtcrime.securesms.emoji.EmojiFiles;
import org.thoughtcrime.securesms.emoji.EmojiImageRequest;
import org.thoughtcrime.securesms.emoji.EmojiJsonRequest;
import org.thoughtcrime.securesms.emoji.EmojiRemote;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.impl.AutoDownloadEmojiConstraint;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.FileUtils;
import org.thoughtcrime.securesms.util.ScreenDensity;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import okhttp3.Response;
import okhttp3.ResponseBody;
import okio.HashingSink;
import okio.Okio;
import okio.Source;
/**
* Downloads Emoji JSON and Images to local persistent storage.
* <p>
*/
public class DownloadLatestEmojiDataJob extends BaseJob {
private static final long INTERVAL_WITHOUT_REMOTE_DOWNLOAD = TimeUnit.DAYS.toMillis(1);
private static final long INTERVAL_WITH_REMOTE_DOWNLOAD = TimeUnit.DAYS.toMillis(7);
private static final String TAG = Log.tag(DownloadLatestEmojiDataJob.class);
public static final String KEY = "DownloadLatestEmojiDataJob";
private static final String QUEUE_KEY = "EmojiDownloadJobs";
private static final String VERSION_INT = "version_int";
private static final String VERSION_UUID = "version_uuid";
private static final String VERSION_DENSITY = "version_density";
private EmojiFiles.Version targetVersion;
public static void scheduleIfNecessary() {
long nextScheduledCheck = SignalStore.emojiValues().getNextScheduledCheck();
if (nextScheduledCheck <= System.currentTimeMillis()) {
Log.i(TAG, "Scheduling DownloadLatestEmojiDataJob.");
ApplicationDependencies.getJobManager().add(new DownloadLatestEmojiDataJob(false));
long interval;
if (EmojiFiles.Version.exists(ApplicationDependencies.getApplication())) {
interval = INTERVAL_WITH_REMOTE_DOWNLOAD;
} else {
interval = INTERVAL_WITHOUT_REMOTE_DOWNLOAD;
}
SignalStore.emojiValues().setNextScheduledCheck(System.currentTimeMillis() + interval);
}
}
public DownloadLatestEmojiDataJob(boolean force) {
this(new Job.Parameters.Builder()
.setQueue(QUEUE_KEY)
.addConstraint(force ? NetworkConstraint.KEY : AutoDownloadEmojiConstraint.KEY)
.setMaxInstancesForQueue(1)
.setMaxAttempts(5)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.build(), null);
}
public DownloadLatestEmojiDataJob(@NonNull Parameters parameters, @Nullable EmojiFiles.Version targetVersion) {
super(parameters);
this.targetVersion = targetVersion;
}
@Override
protected void onRun() throws Exception {
EmojiFiles.Version version = EmojiFiles.Version.readVersion(context);
int localVersion = (version != null) ? version.getVersion() : 0;
int serverVersion = EmojiRemote.getVersion();
String bucket;
if (targetVersion == null) {
ScreenDensity density = ScreenDensity.get(context);
bucket = getDesiredRemoteBucketForDensity(density);
} else {
bucket = targetVersion.getDensity();
}
Log.d(TAG, "LocalVersion: " + localVersion + ", SeverVersion: " + serverVersion + ", Bucket: " + bucket);
if (bucket == null) {
Log.d(TAG, "This device has too low a display density to download remote emoji.");
} else if (localVersion == serverVersion) {
Log.d(TAG, "Already have latest emoji data. Skipping.");
} else if (serverVersion > localVersion) {
Log.d(TAG, "New server data detected. Starting download...");
if (targetVersion == null || targetVersion.getVersion() != serverVersion) {
targetVersion = new EmojiFiles.Version(serverVersion, UUID.randomUUID(), bucket);
}
if (isCanceled()) {
Log.w(TAG, "Job was cancelled prior to downloading json.");
return;
}
EmojiData emojiData = downloadJson(context, targetVersion);
List<String> supportedDensities = emojiData.getDensities();
String format = emojiData.getFormat();
List<String> imagePaths = Stream.of(emojiData.getDataPages())
.map(EmojiPageModel::getSpriteUri)
.map(Uri::getLastPathSegment)
.toList();
String density = resolveDensity(supportedDensities, targetVersion.getDensity());
targetVersion = new EmojiFiles.Version(targetVersion.getVersion(), targetVersion.getUuid(), density);
if (isCanceled()) {
Log.w(TAG, "Job was cancelled after downloading json.");
return;
}
downloadImages(context, targetVersion, imagePaths, format, this::isCanceled);
if (isCanceled()) {
Log.w(TAG, "Job was cancelled during or after downloading images.");
return;
}
clearOldEmojiData(context, targetVersion);
markComplete(targetVersion);
EmojiSource.refresh();
} else {
Log.d(TAG, "Server has an older version than we do. Skipping.");
}
}
@Override
protected boolean onShouldRetry(@NonNull Exception e) {
return e instanceof IOException;
}
@Override
public @NonNull Data serialize() {
if (targetVersion == null) {
return Data.EMPTY;
} else {
return new Data.Builder()
.putInt(VERSION_INT, targetVersion.getVersion())
.putString(VERSION_UUID, targetVersion.getUuid().toString())
.putString(VERSION_DENSITY, targetVersion.getDensity())
.build();
}
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
public void onFailure() {
}
private static @Nullable String getDesiredRemoteBucketForDensity(@NonNull ScreenDensity screenDensity) {
if (screenDensity.isKnownDensity()) {
return screenDensity.getBucket();
} else {
return "xhdpi";
}
}
private static @Nullable String resolveDensity(@NonNull List<String> supportedDensities, @NonNull String desiredDensity) {
if (supportedDensities.isEmpty()) {
throw new IllegalStateException("Version does not have any supported densities.");
}
if (supportedDensities.contains(desiredDensity)) {
Log.d(TAG, "Version supports our density.");
return desiredDensity;
} else {
Log.d(TAG, "Version does not support our density.");
}
List<String> allDensities = Arrays.asList("ldpi", "mdpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi");
int desiredIndex = allDensities.indexOf(desiredDensity);
if (desiredIndex == -1) {
Log.d(TAG, "Unknown density. Falling back...");
if (supportedDensities.contains("xhdpi")) {
return "xhdpi";
} else {
return supportedDensities.get(0);
}
}
return Stream.of(allDensities)
.indexed()
.sorted((lhs, rhs) -> {
int lhsDistance = Math.abs(desiredIndex - lhs.getFirst());
int rhsDistance = Math.abs(desiredIndex - rhs.getFirst());
int comp = Integer.compare(lhsDistance, rhsDistance);
if (comp == 0) {
return Integer.compare(lhs.getFirst(), rhs.getFirst());
} else {
return comp;
}
})
.map(IntPair::getSecond)
.filter(supportedDensities::contains)
.findFirst()
.orElseThrow(() -> new IllegalStateException("No density available."));
}
private static @Nullable byte[] getRemoteImageHash(@NonNull EmojiFiles.Version version, @NonNull String imagePath, @NonNull String format) {
return EmojiRemote.getMd5(new EmojiImageRequest(version.getVersion(), version.getDensity(), imagePath, format));
}
private static @NonNull EmojiData downloadJson(@NonNull Context context, @NonNull EmojiFiles.Version version) throws IOException, InvalidEmojiDataJsonException {
EmojiFiles.NameCollection names = EmojiFiles.NameCollection.read(context, version);
UUID emojiData = names.getUUIDForEmojiData();
byte[] remoteHash = EmojiRemote.getMd5(new EmojiJsonRequest(version.getVersion()));
byte[] localHash;
if (emojiData != null) {
localHash = EmojiFiles.getMd5(context, version, emojiData);
} else {
localHash = null;
}
if (!Arrays.equals(localHash, remoteHash)) {
Log.d(TAG, "Downloading JSON from Remote");
assertRemoteDownloadConstraints(context);
EmojiFiles.Name name = downloadAndVerifyJsonFromRemote(context, version);
EmojiFiles.NameCollection.append(context, names, name);
} else {
Log.d(TAG, "Already have JSON from remote, skipping download");
}
EmojiData latestData = EmojiFiles.getLatestEmojiData(context, version);
if (latestData == null) {
throw new InvalidEmojiDataJsonException();
}
return latestData;
}
private static void downloadImages(@NonNull Context context,
@NonNull EmojiFiles.Version version,
@NonNull List<String> imagePaths,
@NonNull String format,
@NonNull Producer<Boolean> cancelled) throws IOException
{
EmojiFiles.NameCollection names = EmojiFiles.NameCollection.read(context, version);
for (final String imagePath : imagePaths) {
if (cancelled.produce()) {
Log.w(TAG, "Job was cancelled while downloading images.");
return;
}
UUID uuid = names.getUUIDForName(imagePath);
byte[] hash;
if (uuid != null) {
hash = EmojiFiles.getMd5(context, version, uuid);
} else {
hash = null;
}
byte[] ImageHash = getRemoteImageHash(version, imagePath, format);
if (hash == null || !Arrays.equals(hash, ImageHash)) {
if (hash != null) {
Log.d(TAG, "Hash mismatch. Deleting data and re-downloading file.");
EmojiFiles.delete(context, version, uuid);
}
assertRemoteDownloadConstraints(context);
EmojiFiles.Name name = downloadAndVerifyImageFromRemote(context, version, version.getDensity(), imagePath, format);
names = EmojiFiles.NameCollection.append(context, names, name);
} else {
Log.d(TAG, "Already have Image from remote, skipping download");
}
}
}
private void markComplete(@NonNull EmojiFiles.Version version) {
EmojiFiles.Version.writeVersion(context, version);
}
private static void assertRemoteDownloadConstraints(@NonNull Context context) throws IOException {
if (!AutoDownloadEmojiConstraint.canAutoDownloadEmoji(context)) {
throw new IOException("Network conditions no longer permit download.");
}
}
private 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);
}
private 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();
HashingSink sink = HashingSink.md5(Okio.sink(outputStream));
Okio.buffer(source).readAll(sink);
savedMd5 = sink.hash().toByteArray();
}
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 static void clearOldEmojiData(@NonNull Context context, @Nullable EmojiFiles.Version newVersion) {
EmojiFiles.Version version = EmojiFiles.Version.readVersion(context);
final String currentDirectoryName;
final String newVersionDirectoryName;
if (version != null) {
currentDirectoryName = version.getUuid().toString();
} else {
currentDirectoryName = "";
}
if (newVersion != null) {
newVersionDirectoryName = newVersion.getUuid().toString();
} else {
newVersionDirectoryName = "";
}
File emojiDirectory = EmojiFiles.getBaseDirectory(context);
File[] files = emojiDirectory.listFiles();
if (files == null) {
Log.d(TAG, "No emoji data to delete.");
return;
}
Log.d(TAG, "Deleting old folders of emoji data");
Stream.of(files)
.filter(File::isDirectory)
.filterNot(file -> file.getName().equals(currentDirectoryName))
.filterNot(file -> file.getName().equals(newVersionDirectoryName))
.forEach(FileUtils::deleteDirectory);
}
public static final class Factory implements Job.Factory<DownloadLatestEmojiDataJob> {
@Override
public @NonNull DownloadLatestEmojiDataJob create(@NonNull Parameters parameters, @NonNull Data data) {
final EmojiFiles.Version version;
if (data.hasInt(VERSION_INT) &&
data.hasString(VERSION_UUID) &&
data.hasString(VERSION_DENSITY)) {
int versionInt = data.getInt(VERSION_INT);
UUID uuid = UUID.fromString(data.getString(VERSION_UUID));
String density = data.getString(VERSION_DENSITY);
version = new EmojiFiles.Version(versionInt, uuid, density);
} else {
version = null;
}
return new DownloadLatestEmojiDataJob(parameters, version);
}
}
private interface Producer<T> {
@NonNull T produce();
}
/**
* Thrown when the JSON on the server is invalid. In this case, we should NOT
* try to download this version again.
*/
private static class InvalidEmojiDataJsonException extends Exception { }
}

View File

@@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.jobmanager.Constraint;
import org.thoughtcrime.securesms.jobmanager.ConstraintObserver;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JobMigration;
import org.thoughtcrime.securesms.jobmanager.impl.AutoDownloadEmojiConstraint;
import org.thoughtcrime.securesms.jobmanager.impl.CellServiceConstraintObserver;
import org.thoughtcrime.securesms.jobmanager.impl.ChargingConstraint;
import org.thoughtcrime.securesms.jobmanager.impl.ChargingConstraintObserver;
@@ -77,6 +78,7 @@ public final class JobManagerFactories {
put(ConversationShortcutUpdateJob.KEY, new ConversationShortcutUpdateJob.Factory());
put(CreateSignedPreKeyJob.KEY, new CreateSignedPreKeyJob.Factory());
put(DirectoryRefreshJob.KEY, new DirectoryRefreshJob.Factory());
put(DownloadLatestEmojiDataJob.KEY, new DownloadLatestEmojiDataJob.Factory());
put(FcmRefreshJob.KEY, new FcmRefreshJob.Factory());
put(GroupV1MigrationJob.KEY, new GroupV1MigrationJob.Factory());
put(GroupCallUpdateSendJob.KEY, new GroupCallUpdateSendJob.Factory());
@@ -197,6 +199,7 @@ public final class JobManagerFactories {
public static Map<String, Constraint.Factory> getConstraintFactories(@NonNull Application application) {
return new HashMap<String, Constraint.Factory>() {{
put(AutoDownloadEmojiConstraint.KEY, new AutoDownloadEmojiConstraint.Factory(application));
put(ChargingConstraint.KEY, new ChargingConstraint.Factory());
put(NetworkConstraint.KEY, new NetworkConstraint.Factory(application));
put(NetworkOrCellServiceConstraint.KEY, new NetworkOrCellServiceConstraint.Factory(application));

View File

@@ -10,6 +10,7 @@ import java.util.List;
public class EmojiValues extends SignalStoreValues {
private static final String PREFIX = "emojiPref__";
private static final String NEXT_SCHEDULED_CHECK = PREFIX + "next_scheduled_check";
EmojiValues(@NonNull KeyValueStore store) {
super(store);
@@ -25,6 +26,14 @@ public class EmojiValues extends SignalStoreValues {
return Collections.emptyList();
}
public long getNextScheduledCheck() {
return getStore().getLong(NEXT_SCHEDULED_CHECK, 0);
}
public void setNextScheduledCheck(long nextScheduledCheck) {
putLong(NEXT_SCHEDULED_CHECK, nextScheduledCheck);
}
public void setPreferredVariation(@NonNull String emoji) {
String canonical = EmojiUtil.getCanonicalRepresentation(emoji);

View File

@@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.util.AppSignatureUtil;
import org.thoughtcrime.securesms.util.ByteUnit;
import org.thoughtcrime.securesms.util.CensorshipUtil;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.ScreenDensity;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
@@ -46,8 +47,8 @@ public class LogSectionSystemInfo implements LogSection {
builder.append("Model : ").append(Build.MODEL).append("\n");
builder.append("Product : ").append(Build.PRODUCT).append("\n");
builder.append("Screen : ").append(getScreenResolution(context)).append(", ")
.append(getScreenDensityClass(context)).append(", ")
.append(getScreenRefreshRate(context)).append("\n");
.append(ScreenDensity.get(context)).append(", ")
.append(getScreenRefreshRate(context)).append("\n");
builder.append("Font Scale : ").append(context.getResources().getConfiguration().fontScale).append("\n");
builder.append("Android : ").append(Build.VERSION.RELEASE).append(" (")
.append(Build.VERSION.INCREMENTAL).append(", ")
@@ -133,30 +134,6 @@ public class LogSectionSystemInfo implements LogSection {
return displayMetrics.widthPixels + "x" + displayMetrics.heightPixels;
}
private static @NonNull String getScreenDensityClass(@NonNull Context context) {
int density = context.getResources().getDisplayMetrics().densityDpi;
LinkedHashMap<Integer, String> levels = new LinkedHashMap<Integer, String>() {{
put(DisplayMetrics.DENSITY_LOW, "ldpi");
put(DisplayMetrics.DENSITY_MEDIUM, "mdpi");
put(DisplayMetrics.DENSITY_HIGH, "hdpi");
put(DisplayMetrics.DENSITY_XHIGH, "xhdpi");
put(DisplayMetrics.DENSITY_XXHIGH, "xxhdpi");
put(DisplayMetrics.DENSITY_XXXHIGH, "xxxhdpi");
}};
String densityString = "unknown";
for (Map.Entry<Integer, String> entry : levels.entrySet()) {
densityString = entry.getValue();
if (entry.getKey() > density) {
break;
}
}
return densityString + " (" + density + ")";
}
private static @NonNull String getScreenRefreshRate(@NonNull Context context) {
return String.format(Locale.ENGLISH, "%.2f hz", ServiceUtil.getWindowManager(context).getDefaultDisplay().getRefreshRate());
}

View File

@@ -8,10 +8,12 @@ import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.apache.http.auth.AUTH;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.AttachmentId;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.emoji.EmojiFiles;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.providers.DeprecatedPersistentBlobProvider;
import org.thoughtcrime.securesms.providers.PartProvider;
@@ -26,15 +28,18 @@ public class PartAuthority {
private static final String PART_URI_STRING = "content://" + AUTHORITY + "/part";
private static final String STICKER_URI_STRING = "content://" + AUTHORITY + "/sticker";
private static final String WALLPAPER_URI_STRING = "content://" + AUTHORITY + "/wallpaper";
private static final String EMOJI_URI_STRING = "content://" + AUTHORITY + "/emoji";
private static final Uri PART_CONTENT_URI = Uri.parse(PART_URI_STRING);
private static final Uri STICKER_CONTENT_URI = Uri.parse(STICKER_URI_STRING);
private static final Uri WALLPAPER_CONTENT_URI = Uri.parse(WALLPAPER_URI_STRING);
private static final Uri EMOJI_CONTENT_URI = Uri.parse(EMOJI_URI_STRING);
private static final int PART_ROW = 1;
private static final int PERSISTENT_ROW = 2;
private static final int BLOB_ROW = 3;
private static final int STICKER_ROW = 4;
private static final int WALLPAPER_ROW = 5;
private static final int EMOJI_ROW = 6;
private static final UriMatcher uriMatcher;
@@ -43,6 +48,7 @@ public class PartAuthority {
uriMatcher.addURI(AUTHORITY, "part/*/#", PART_ROW);
uriMatcher.addURI(AUTHORITY, "sticker/#", STICKER_ROW);
uriMatcher.addURI(AUTHORITY, "wallpaper/*", WALLPAPER_ROW);
uriMatcher.addURI(AUTHORITY, "emoji/*", EMOJI_ROW);
uriMatcher.addURI(DeprecatedPersistentBlobProvider.AUTHORITY, DeprecatedPersistentBlobProvider.EXPECTED_PATH_OLD, PERSISTENT_ROW);
uriMatcher.addURI(DeprecatedPersistentBlobProvider.AUTHORITY, DeprecatedPersistentBlobProvider.EXPECTED_PATH_NEW, PERSISTENT_ROW);
uriMatcher.addURI(BlobProvider.AUTHORITY, BlobProvider.PATH, BLOB_ROW);
@@ -65,6 +71,7 @@ public class PartAuthority {
case PERSISTENT_ROW: return DeprecatedPersistentBlobProvider.getInstance(context).getStream(context, ContentUris.parseId(uri));
case BLOB_ROW: return BlobProvider.getInstance().getStream(context, uri);
case WALLPAPER_ROW: return WallpaperStorage.read(context, getWallpaperFilename(uri));
case EMOJI_ROW: return EmojiFiles.openForReading(context, getEmojiFilename(uri));
default: return context.getContentResolver().openInputStream(uri);
}
} catch (SecurityException se) {
@@ -162,10 +169,18 @@ public class PartAuthority {
return Uri.withAppendedPath(WALLPAPER_CONTENT_URI, filename);
}
public static Uri getEmojiUri(String sprite) {
return Uri.withAppendedPath(EMOJI_CONTENT_URI, sprite);
}
public static String getWallpaperFilename(Uri uri) {
return uri.getPathSegments().get(1);
}
public static String getEmojiFilename(Uri uri) {
return uri.getPathSegments().get(1);
}
public static boolean isLocalUri(final @NonNull Uri uri) {
int match = uriMatcher.match(uri);
switch (match) {

View File

@@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.database.MessageDatabase;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.reactions.ReactionDetails;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.sms.MessageSender;
@@ -41,7 +42,7 @@ final class ReactWithAnyEmojiRepository {
this.recentEmojiPageModel = new RecentEmojiPageModel(context, storageKey);
this.emojiPages = new LinkedList<>();
emojiPages.addAll(Stream.of(EmojiUtil.getDisplayPages())
emojiPages.addAll(Stream.of(EmojiSource.getLatest().getDisplayPages())
.map(page -> new ReactWithAnyEmojiPage(Collections.singletonList(new ReactWithAnyEmojiPageBlock(getCategoryLabel(page.getIconAttr()), page))))
.toList());
emojiPages.remove(emojiPages.size() - 1);

View File

@@ -1,5 +1,7 @@
package org.thoughtcrime.securesms.reactions.any;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -38,12 +40,7 @@ class ThisMessageEmojiPageModel implements EmojiPageModel {
}
@Override
public boolean hasSpriteMap() {
return false;
}
@Override
public @Nullable String getSprite() {
public @Nullable Uri getSpriteUri() {
return null;
}

View File

@@ -0,0 +1,82 @@
package org.thoughtcrime.securesms.util;
import android.content.Context;
import android.util.DisplayMetrics;
import androidx.annotation.NonNull;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Helper class to get density information about a device's display
*/
public final class ScreenDensity {
private static final String UNKNOWN = "unknown";
private static final float XHDPI_TO_LDPI = 0.25f;
private static final float XHDPI_TO_MDPI = 0.5f;
private static final float XHDPI_TO_HDPI = 0.75f;
private static final LinkedHashMap<Integer, String> LEVELS = new LinkedHashMap<Integer, String>() {{
put(DisplayMetrics.DENSITY_LOW, "ldpi");
put(DisplayMetrics.DENSITY_MEDIUM, "mdpi");
put(DisplayMetrics.DENSITY_HIGH, "hdpi");
put(DisplayMetrics.DENSITY_XHIGH, "xhdpi");
put(DisplayMetrics.DENSITY_XXHIGH, "xxhdpi");
put(DisplayMetrics.DENSITY_XXXHIGH, "xxxhdpi");
}};
private final String bucket;
private final int density;
public ScreenDensity(String bucket, int density) {
this.bucket = bucket;
this.density = density;
}
public static @NonNull ScreenDensity get(@NonNull Context context) {
int density = context.getResources().getDisplayMetrics().densityDpi;
String bucket = UNKNOWN;
for (Map.Entry<Integer, String> entry : LEVELS.entrySet()) {
bucket = entry.getValue();
if (entry.getKey() > density) {
break;
}
}
return new ScreenDensity(bucket, density);
}
public String getBucket() {
return bucket;
}
public boolean isKnownDensity() {
return !bucket.equals(UNKNOWN);
}
@Override
public @NonNull String toString() {
return bucket + " (" + density + ")";
}
public static float xhdpiRelativeDensityScaleFactor(@NonNull String density) {
switch (density) {
case "ldpi":
return XHDPI_TO_LDPI;
case "mdpi":
return XHDPI_TO_MDPI;
case "hdpi":
return XHDPI_TO_HDPI;
case "xhdpi":
return 1f;
default:
throw new IllegalStateException("Unsupported density: " + density);
}
}
}

View File

@@ -5,42 +5,59 @@ import android.content.Context;
import androidx.test.core.app.ApplicationProvider;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.powermock.api.mockito.PowerMockito;
import org.powermock.core.classloader.annotations.PowerMockIgnore;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.rule.PowerMockRule;
import org.robolectric.ParameterizedRobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collection;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
@RunWith(ParameterizedRobolectricTestRunner.class)
@Config(manifest = Config.NONE, application = Application.class)
@PowerMockIgnore({"org.mockito.*", "org.robolectric.*", "android.*", "androidx.*" })
@PrepareForTest({ApplicationDependencies.class, AttachmentSecretProvider.class})
public class EmojiUtilTest_isEmoji {
public @Rule PowerMockRule rule = new PowerMockRule();
private final String input;
private final boolean output;
@ParameterizedRobolectricTestRunner.Parameters
public static Collection<Object[]> data() {
return Arrays.asList(new Object[][]{
{ null, false },
{ "", false },
{ "cat", false },
{ "ᑢᗩᖶ", false },
{ "♍︎♋︎⧫︎", false },
{ "", false },
{ "¯\\_(ツ)_/¯", false},
{ "\uD83D\uDE0D", true }, // Smiling face with heart-shaped eyes
{ "\uD83D\uDD77", true }, // Spider
{ "\uD83E\uDD37", true }, // Person shrugging
{ "\uD83E\uDD37\uD83C\uDFFF\u200D♂", true }, // Man shrugging dark skin tone
{ "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66", true }, // Family: Man, Woman, Girl, Boy
{ "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83D\uDC69\uD83C\uDFFB\u200D\uD83D\uDC67\uD83C\uDFFB\u200D\uD83D\uDC66\uD83C\uDFFB", true }, // Family - Man: Light Skin Tone, Woman: Light Skin Tone, Girl: Light Skin Tone, Boy: Light Skin Tone (NOTE: Not widely supported, good stretch test)
{ "\uD83D\uDE0Dhi", false }, // Smiling face with heart-shaped eyes, text afterwards
{ "\uD83D\uDE0D ", false }, // Smiling face with heart-shaped eyes, space afterwards
{ "\uD83D\uDE0D\uD83D\uDE0D", false }, // Smiling face with heart-shaped eyes, twice
{null, false},
{"", false},
{"cat", false},
{"ᑢᗩᖶ", false},
{"♍︎♋︎⧫︎", false},
{"", false},
{"¯\\_(ツ)_/¯", false},
{"\uD83D\uDE0D", true}, // Smiling face with heart-shaped eyes
{"\uD83D\uDD77", true}, // Spider
{"\uD83E\uDD37", true}, // Person shrugging
{"\uD83E\uDD37\uD83C\uDFFF\u200D♂", true}, // Man shrugging dark skin tone
{"\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66", true}, // Family: Man, Woman, Girl, Boy
{"\uD83D\uDC68\uD83C\uDFFB\u200D\uD83D\uDC69\uD83C\uDFFB\u200D\uD83D\uDC67\uD83C\uDFFB\u200D\uD83D\uDC66\uD83C\uDFFB", true}, // Family - Man: Light Skin Tone, Woman: Light Skin Tone, Girl: Light Skin Tone, Boy: Light Skin Tone (NOTE: Not widely supported, good stretch test)
{"\uD83D\uDE0Dhi", false}, // Smiling face with heart-shaped eyes, text afterwards
{"\uD83D\uDE0D ", false}, // Smiling face with heart-shaped eyes, space afterwards
{"\uD83D\uDE0D\uD83D\uDE0D", false}, // Smiling face with heart-shaped eyes, twice
});
}
@@ -54,6 +71,12 @@ public class EmojiUtilTest_isEmoji {
public void isEmoji() {
Context context = ApplicationProvider.getApplicationContext();
PowerMockito.mockStatic(ApplicationDependencies.class);
PowerMockito.when(ApplicationDependencies.getApplication()).thenReturn((Application) context);
PowerMockito.mockStatic(AttachmentSecretProvider.class);
PowerMockito.when(AttachmentSecretProvider.getInstance(any())).thenThrow(IOException.class);
EmojiSource.refresh();
assertEquals(output, EmojiUtil.isEmoji(context, input));
}
}

View File

@@ -0,0 +1,138 @@
package org.thoughtcrime.securesms.emoji
import android.app.Application
import android.net.Uri
import com.fasterxml.jackson.core.JsonParseException
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.thoughtcrime.securesms.components.emoji.CompositeEmojiPageModel
import org.thoughtcrime.securesms.components.emoji.Emoji
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel
import org.thoughtcrime.securesms.components.emoji.StaticEmojiPageModel
private const val INVALID_JSON = "{{}"
private const val EMPTY_JSON = "{}"
private const val SAMPLE_JSON_WITHOUT_OBSOLETE = """
{
"emoji": {
"Places": [["d83cdf0d"], ["0003", "0004", "0005"]],
"Foods": [["0001"], ["0002", "0003", "0004"]]
},
"metrics": {
"raw_height": 64,
"raw_width": 64,
"per_row": 16
},
"densities": [ "xhdpi" ],
"format": "png"
}
"""
private const val SAMPLE_JSON_WITH_OBSOLETE = """
{
"emoji": {
"Places_1": [["0002"], ["0003", "0004", "0005"]],
"Places_2": [["0003"], ["0008", "0009", "0000"]],
"Foods": [["0001"], ["0002", "0003", "0004"]]
},
"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.icon, listOf(Emoji("\u0001"), Emoji("\u0002", "\u0003", "\u0004")), Uri.parse("file:///Foods")),
StaticEmojiPageModel(EmojiCategory.PLACES.icon, listOf(Emoji("\ud83c\udf0d"), Emoji("\u0003", "\u0004", "\u0005")), Uri.parse("file:///Places"))
)
private val SAMPLE_JSON_WITH_OBSOLETE_EXPECTED_DISPLAY = listOf(
StaticEmojiPageModel(EmojiCategory.FOODS.icon, listOf(Emoji("\u0001"), Emoji("\u0002", "\u0003", "\u0004")), Uri.parse("file:///Foods")),
CompositeEmojiPageModel(
EmojiCategory.PLACES.icon,
listOf(
StaticEmojiPageModel(EmojiCategory.PLACES.icon, listOf(Emoji("\u0002"), Emoji("\u0003", "\u0004", "\u0005")), Uri.parse("file:///Places_1")),
StaticEmojiPageModel(EmojiCategory.PLACES.icon, listOf(Emoji("\u0003"), Emoji("\u0008", "\u0009", "\u0000")), Uri.parse("file:///Places_2"))
)
)
)
private val SAMPLE_JSON_WITH_OBSOLETE_EXPECTED_DATA = listOf(
StaticEmojiPageModel(EmojiCategory.FOODS.icon, listOf(Emoji("\u0001"), Emoji("\u0002", "\u0003", "\u0004")), Uri.parse("file:///Foods")),
StaticEmojiPageModel(EmojiCategory.PLACES.icon, listOf(Emoji("\u0002"), Emoji("\u0003", "\u0004", "\u0005")), Uri.parse("file:///Places_1")),
StaticEmojiPageModel(EmojiCategory.PLACES.icon, listOf(Emoji("\u0003"), Emoji("\u0008", "\u0009", "\u0000")), Uri.parse("file:///Places_2"))
)
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class EmojiJsonParserTest {
@Test(expected = NullPointerException::class)
fun `Given empty json, when I parse, then I expect a NullPointerException`() {
val result = EmojiJsonParser.parse(EMPTY_JSON.byteInputStream(), this::uriFactory)
result.getOrThrow()
}
@Test(expected = JsonParseException::class)
fun `Given invalid json, when I parse, then I expect a JsonParseException`() {
val result = EmojiJsonParser.parse(INVALID_JSON.byteInputStream(), this::uriFactory)
result.getOrThrow()
}
@Test
fun `Given sample without obselete, when I parse, then I expect source without obsolete`() {
val result: ParsedEmojiData = EmojiJsonParser.parse(SAMPLE_JSON_WITHOUT_OBSOLETE.byteInputStream(), this::uriFactory).getOrThrow()
Assert.assertTrue(result.obsolete.isEmpty())
Assert.assertTrue(result.displayPages == result.dataPages)
Assert.assertEquals(SAMPLE_JSON_WITHOUT_OBSOLETE_EXPECTED.size, result.dataPages.size)
result.dataPages.zip(SAMPLE_JSON_WITHOUT_OBSOLETE_EXPECTED).forEach { (actual, expected) ->
Assert.assertTrue(actual.isSameAs(expected))
}
}
@Test
fun `Given sample with obsolete, when I parse, then I expect source with obsolete`() {
val result: ParsedEmojiData = EmojiJsonParser.parse(SAMPLE_JSON_WITH_OBSOLETE.byteInputStream(), this::uriFactory).getOrThrow()
Assert.assertTrue(result.obsolete.size == 1)
Assert.assertEquals("\u0012", result.obsolete[0].obsolete)
Assert.assertEquals("\u0023", result.obsolete[0].replaceWith)
Assert.assertFalse(result.displayPages == result.dataPages)
Assert.assertEquals(SAMPLE_JSON_WITH_OBSOLETE_EXPECTED_DISPLAY.size, result.displayPages.size)
result.displayPages.zip(SAMPLE_JSON_WITH_OBSOLETE_EXPECTED_DISPLAY).forEach { (actual, expected) ->
Assert.assertTrue(actual.isSameAs(expected))
}
Assert.assertEquals(SAMPLE_JSON_WITH_OBSOLETE_EXPECTED_DATA.size, result.dataPages.size)
result.dataPages.zip(SAMPLE_JSON_WITH_OBSOLETE_EXPECTED_DATA).forEach { (actual, expected) ->
Assert.assertTrue(actual.isSameAs(expected))
}
Assert.assertEquals(result.densities, listOf("xhdpi"))
Assert.assertEquals(result.format, "png")
}
private fun uriFactory(sprite: String, format: String) = Uri.parse("file:///$sprite")
private fun EmojiPageModel.isSameAs(other: EmojiPageModel) =
this.javaClass == other.javaClass &&
this.emoji == other.emoji &&
this.iconAttr == other.iconAttr &&
this.spriteUri == other.spriteUri
}

View File

@@ -0,0 +1,33 @@
package org.thoughtcrime.securesms.emoji
import android.net.Uri
import org.junit.Assert
import org.junit.Test
import org.thoughtcrime.securesms.components.emoji.Emoji
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel
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 testSubject = EmojiSource(0f, emojiDataFake, ::EmojiPageReference)
Assert.assertEquals(6, testSubject.maxEmojiLength)
}
private fun generatePages() = (1..10).map { EmojiPageModelFake((1..100).shuffled().map { Emoji("$it$it") }) }
private class EmojiPageModelFake(private val displayE: List<Emoji>) : EmojiPageModel {
override fun getEmoji(): List<String> = displayE.map { it.variations }.flatten()
override fun getDisplayEmoji(): List<Emoji> = displayE
override fun getIconAttr(): Int = TODO("Not yet implemented")
override fun getSpriteUri(): Uri = TODO("Not yet implemented")
override fun isDynamic(): Boolean = TODO("Not yet implemented")
}
}

View File

@@ -213,14 +213,17 @@ dependencyVerification {
['com.davemorrissey.labs:subsampling-scale-image-view:3.6.0',
'550c5baa07e0bb4ff0a18b705e96d34436d22619248bd8c08c08c730b1f55cfe'],
['com.fasterxml.jackson.core:jackson-annotations:2.9.0',
'45d32ac61ef8a744b464c54c2b3414be571016dd46bfc2bec226761cf7ae457a'],
['com.fasterxml.jackson.core:jackson-annotations:2.12.0',
'c28fbe62e7be1e29df75953fa8a887ff875d4482291fbfddb1aec5c91191ecda'],
['com.fasterxml.jackson.core:jackson-core:2.9.9',
'3083079be6088db2ed0a0c6ff92204e0aa48fa1de9db5b59c468f35acf882c2c'],
['com.fasterxml.jackson.core:jackson-core:2.12.0',
'8acab5ef6e4f332bbb331b3fcd24d716598770d13a47e7215aa5ee625d1fd9c9'],
['com.fasterxml.jackson.core:jackson-databind:2.9.9.2',
'fb262d42ea2de98044b62d393950a5aa050435fec38bbcadf2325cf7dc41b848'],
['com.fasterxml.jackson.core:jackson-databind:2.12.0',
'75d470eda0dd559e43f2ad08209fa09ecd268833492ba93fa46f6f3607acbab7'],
['com.fasterxml.jackson.module:jackson-module-kotlin:2.12.0',
'ee69650831f72ff2411026b507f3583e90ca88b40e9dae3067f87b34088e0ced'],
['com.github.bumptech.glide:annotations:4.11.0',
'd219d238006d824962176229d4708abcdddcfe342c6a18a5d0fa48d6f0479b3e'],
@@ -462,6 +465,9 @@ dependencyVerification {
['org.greenrobot:eventbus:3.0.0',
'180d4212467df06f2fbc9c8d8a2984533ac79c87769ad883bc421612f0b4e17c'],
['org.jetbrains.kotlin:kotlin-reflect:1.4.10',
'3ab3413ec945f801448360ad97bc6e14fec6d606889ede3c707cc277b4467f45'],
['org.jetbrains.kotlin:kotlin-stdlib-common:1.4.32',
'e1ff6f55ee9e7591dcc633f7757bac25a7edb1cc7f738b37ec652f10f66a4145'],