Add support for OTA emoji download.
@@ -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 {
|
||||
|
||||
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 365 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 434 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 664 KiB After Width: | Height: | Size: 303 KiB |
|
Before Width: | Height: | Size: 608 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 552 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 631 KiB After Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 653 KiB After Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 652 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 531 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 685 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 603 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 391 KiB After Width: | Height: | Size: 138 KiB |
1
app/src/main/assets/emoji/emoji_data.json
Normal 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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
205
app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiFiles.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
|
||||