kopia lustrzana https://github.com/ryukoposting/Signal-Android
Add support for jumbo emoji.
rodzic
449acaf9df
commit
34f679b10b
|
@ -34,14 +34,13 @@ class TextAvatarDrawable(
|
|||
val textSize = Avatars.getTextSizeForLength(context, avatar.text, size * 0.8f, size * 0.45f)
|
||||
val width = bounds.width()
|
||||
val candidates = EmojiProvider.getCandidates(avatar.text)
|
||||
var hasEmoji = false
|
||||
|
||||
textPaint.textSize = textSize
|
||||
|
||||
val newText = if (candidates == null || candidates.size() == 0) {
|
||||
SpannableString(avatar.text)
|
||||
} else {
|
||||
EmojiProvider.emojify(context, candidates, avatar.text, textPaint, synchronous)
|
||||
EmojiProvider.emojify(context, candidates, avatar.text, textPaint, synchronous, true)
|
||||
}
|
||||
|
||||
if (newText == null) return
|
||||
|
|
|
@ -50,7 +50,7 @@ public class SearchView extends androidx.appcompat.widget.SearchView {
|
|||
result = new InputFilter[1];
|
||||
}
|
||||
|
||||
result[0] = new EmojiFilter(view);
|
||||
result[0] = new EmojiFilter(view, false);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
|
@ -33,10 +33,11 @@ public class EmojiEditText extends AppCompatEditText {
|
|||
|
||||
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0);
|
||||
boolean forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
|
||||
boolean jumboEmoji = a.getBoolean(R.styleable.EmojiTextView_emoji_forceJumbo, false);
|
||||
a.recycle();
|
||||
|
||||
if (!isInEditMode() && (forceCustom || !SignalStore.settings().isPreferSystemEmoji())) {
|
||||
setFilters(appendEmojiFilter(this.getFilters()));
|
||||
setFilters(appendEmojiFilter(this.getFilters(), jumboEmoji));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -54,7 +55,7 @@ public class EmojiEditText extends AppCompatEditText {
|
|||
else super.invalidateDrawable(drawable);
|
||||
}
|
||||
|
||||
private InputFilter[] appendEmojiFilter(@Nullable InputFilter[] originalFilters) {
|
||||
private InputFilter[] appendEmojiFilter(@Nullable InputFilter[] originalFilters, boolean jumboEmoji) {
|
||||
InputFilter[] result;
|
||||
|
||||
if (originalFilters != null) {
|
||||
|
@ -64,7 +65,7 @@ public class EmojiEditText extends AppCompatEditText {
|
|||
result = new InputFilter[1];
|
||||
}
|
||||
|
||||
result[0] = new EmojiFilter(this);
|
||||
result[0] = new EmojiFilter(this, jumboEmoji);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
|
@ -8,9 +8,11 @@ import android.widget.TextView;
|
|||
|
||||
public class EmojiFilter implements InputFilter {
|
||||
private TextView view;
|
||||
private boolean jumboEmoji;
|
||||
|
||||
public EmojiFilter(TextView view) {
|
||||
this.view = view;
|
||||
public EmojiFilter(TextView view, boolean jumboEmoji) {
|
||||
this.view = view;
|
||||
this.jumboEmoji = jumboEmoji;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -19,7 +21,7 @@ public class EmojiFilter implements InputFilter {
|
|||
char[] v = new char[end - start];
|
||||
TextUtils.getChars(source, start, end, v, 0);
|
||||
|
||||
Spannable emojified = EmojiProvider.emojify(new String(v), view);
|
||||
Spannable emojified = EmojiProvider.emojify(new String(v), view, jumboEmoji);
|
||||
|
||||
if (source instanceof Spanned && emojified != null) {
|
||||
TextUtils.copySpansFrom((Spanned) source, start, end, null, emojified, 0);
|
||||
|
|
|
@ -19,15 +19,17 @@ import org.signal.core.util.ThreadUtil;
|
|||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiDrawInfo;
|
||||
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiFiles;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiPageCache;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||
import org.thoughtcrime.securesms.emoji.JumboEmoji;
|
||||
import org.thoughtcrime.securesms.util.DeviceProperties;
|
||||
import org.thoughtcrime.securesms.util.FutureTaskListener;
|
||||
import org.thoughtcrime.securesms.util.ListenableFutureTask;
|
||||
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public class EmojiProvider {
|
||||
|
||||
|
@ -39,23 +41,24 @@ public class EmojiProvider {
|
|||
return new EmojiParser(EmojiSource.getLatest().getEmojiTree()).findCandidates(text);
|
||||
}
|
||||
|
||||
static @Nullable Spannable emojify(@Nullable CharSequence text, @NonNull TextView tv) {
|
||||
static @Nullable Spannable emojify(@Nullable CharSequence text, @NonNull TextView tv, boolean jumboEmoji) {
|
||||
if (tv.isInEditMode()) {
|
||||
return null;
|
||||
} else {
|
||||
return emojify(getCandidates(text), text, tv);
|
||||
return emojify(getCandidates(text), text, tv, jumboEmoji);
|
||||
}
|
||||
}
|
||||
|
||||
static @Nullable Spannable emojify(@Nullable EmojiParser.CandidateList matches,
|
||||
@Nullable CharSequence text,
|
||||
@NonNull TextView tv)
|
||||
@NonNull TextView tv,
|
||||
boolean jumboEmoji)
|
||||
{
|
||||
if (matches == null || text == null || tv.isInEditMode()) return null;
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder(text);
|
||||
|
||||
for (EmojiParser.Candidate candidate : matches) {
|
||||
Drawable drawable = getEmojiDrawable(tv.getContext(), candidate.getDrawInfo(), tv::requestLayout);
|
||||
Drawable drawable = getEmojiDrawable(tv.getContext(), candidate.getDrawInfo(), tv::requestLayout, jumboEmoji);
|
||||
|
||||
if (drawable != null) {
|
||||
builder.setSpan(new EmojiSpan(drawable, tv), candidate.getStartIndex(), candidate.getEndIndex(),
|
||||
|
@ -70,7 +73,8 @@ public class EmojiProvider {
|
|||
@Nullable EmojiParser.CandidateList matches,
|
||||
@Nullable CharSequence text,
|
||||
@NonNull Paint paint,
|
||||
boolean synchronous)
|
||||
boolean synchronous,
|
||||
boolean jumboEmoji)
|
||||
{
|
||||
if (matches == null || text == null) return null;
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder(text);
|
||||
|
@ -78,9 +82,9 @@ public class EmojiProvider {
|
|||
for (EmojiParser.Candidate candidate : matches) {
|
||||
Drawable drawable;
|
||||
if (synchronous) {
|
||||
drawable = getEmojiDrawableSync(context, candidate.getDrawInfo());
|
||||
drawable = getEmojiDrawableSync(context, candidate.getDrawInfo(), jumboEmoji);
|
||||
} else {
|
||||
drawable = getEmojiDrawable(context, candidate.getDrawInfo(), null);
|
||||
drawable = getEmojiDrawable(context, candidate.getDrawInfo(), null, jumboEmoji);
|
||||
}
|
||||
|
||||
if (drawable != null) {
|
||||
|
@ -93,8 +97,12 @@ public class EmojiProvider {
|
|||
}
|
||||
|
||||
static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable CharSequence emoji) {
|
||||
return getEmojiDrawable(context, emoji, false);
|
||||
}
|
||||
|
||||
static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable CharSequence emoji, boolean jumboEmoji) {
|
||||
EmojiDrawInfo drawInfo = EmojiSource.getLatest().getEmojiTree().getEmoji(emoji, 0, emoji.length());
|
||||
return getEmojiDrawable(context, drawInfo, null);
|
||||
return getEmojiDrawable(context, drawInfo, null, jumboEmoji);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -104,7 +112,7 @@ public class EmojiProvider {
|
|||
* @param drawInfo Information about the emoji being displayed
|
||||
* @param onEmojiLoaded Runnable which will trigger when an emoji is loaded from disk
|
||||
*/
|
||||
private static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable EmojiDrawInfo drawInfo, @Nullable Runnable onEmojiLoaded) {
|
||||
private static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable EmojiDrawInfo drawInfo, @Nullable Runnable onEmojiLoaded, boolean jumboEmoji) {
|
||||
if (drawInfo == null) {
|
||||
return null;
|
||||
}
|
||||
|
@ -112,6 +120,7 @@ public class EmojiProvider {
|
|||
final int lowMemoryDecodeScale = DeviceProperties.isLowMemoryDevice(context) ? 2 : 1;
|
||||
final EmojiSource source = EmojiSource.getLatest();
|
||||
final EmojiDrawable drawable = new EmojiDrawable(source, drawInfo, lowMemoryDecodeScale);
|
||||
final AtomicBoolean jumboLoaded = new AtomicBoolean(false);
|
||||
|
||||
EmojiPageCache.LoadResult loadResult = EmojiPageCache.INSTANCE.load(context, drawInfo.getPage(), lowMemoryDecodeScale);
|
||||
|
||||
|
@ -122,9 +131,11 @@ public class EmojiProvider {
|
|||
@Override
|
||||
public void onSuccess(Bitmap result) {
|
||||
ThreadUtil.runOnMain(() -> {
|
||||
drawable.setBitmap(result);
|
||||
if (onEmojiLoaded != null) {
|
||||
onEmojiLoaded.run();
|
||||
if (!jumboLoaded.get()) {
|
||||
drawable.setBitmap(result);
|
||||
if (onEmojiLoaded != null) {
|
||||
onEmojiLoaded.run();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -138,6 +149,36 @@ public class EmojiProvider {
|
|||
throw new IllegalStateException("Unexpected subclass " + loadResult.getClass());
|
||||
}
|
||||
|
||||
if (jumboEmoji) {
|
||||
JumboEmoji.LoadResult result = JumboEmoji.loadJumboEmoji(context, drawInfo.getRawEmoji());
|
||||
if (result instanceof JumboEmoji.LoadResult.Immediate) {
|
||||
ThreadUtil.runOnMain(() -> {
|
||||
jumboLoaded.set(true);
|
||||
drawable.setSingleBitmap(((JumboEmoji.LoadResult.Immediate) result).getBitmap());
|
||||
});
|
||||
} else if (result instanceof JumboEmoji.LoadResult.Async) {
|
||||
((JumboEmoji.LoadResult.Async) result).getTask().addListener(new FutureTaskListener<Bitmap>() {
|
||||
@Override
|
||||
public void onSuccess(Bitmap result) {
|
||||
ThreadUtil.runOnMain(() -> {
|
||||
jumboLoaded.set(true);
|
||||
drawable.setSingleBitmap(result);
|
||||
if (onEmojiLoaded != null) {
|
||||
onEmojiLoaded.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(ExecutionException exception) {
|
||||
Log.d(TAG, "Failed to load jumbo emoji bitmap resource", exception);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return drawable;
|
||||
}
|
||||
|
||||
return drawable;
|
||||
}
|
||||
|
||||
|
@ -147,7 +188,7 @@ public class EmojiProvider {
|
|||
* @param context Context object used in reading and writing from disk
|
||||
* @param drawInfo Information about the emoji being displayed
|
||||
*/
|
||||
private static @Nullable Drawable getEmojiDrawableSync(@NonNull Context context, @Nullable EmojiDrawInfo drawInfo) {
|
||||
private static @Nullable Drawable getEmojiDrawableSync(@NonNull Context context, @Nullable EmojiDrawInfo drawInfo, boolean jumboEmoji) {
|
||||
ThreadUtil.assertNotMainThread();
|
||||
if (drawInfo == null) {
|
||||
return null;
|
||||
|
@ -157,24 +198,45 @@ public class EmojiProvider {
|
|||
final EmojiSource source = EmojiSource.getLatest();
|
||||
final EmojiDrawable drawable = new EmojiDrawable(source, drawInfo, lowMemoryDecodeScale);
|
||||
|
||||
EmojiPageCache.LoadResult loadResult = EmojiPageCache.INSTANCE.load(context, drawInfo.getPage(), lowMemoryDecodeScale);
|
||||
Bitmap bitmap = null;
|
||||
Bitmap bitmap = null;
|
||||
|
||||
if (loadResult instanceof EmojiPageCache.LoadResult.Immediate) {
|
||||
Log.d(TAG, "Cached emoji page: " + drawInfo.getPage().getUri().toString());
|
||||
bitmap = ((EmojiPageCache.LoadResult.Immediate) loadResult).getBitmap();
|
||||
} else if (loadResult instanceof EmojiPageCache.LoadResult.Async) {
|
||||
Log.d(TAG, "Loading emoji page: " + drawInfo.getPage().getUri().toString());
|
||||
try {
|
||||
bitmap = ((EmojiPageCache.LoadResult.Async) loadResult).getTask().get(2, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException | ExecutionException | TimeoutException exception) {
|
||||
Log.d(TAG, "Failed to load emoji bitmap resource", exception);
|
||||
if (jumboEmoji) {
|
||||
JumboEmoji.LoadResult result = JumboEmoji.loadJumboEmoji(context, drawInfo.getRawEmoji());
|
||||
if (result instanceof JumboEmoji.LoadResult.Immediate) {
|
||||
bitmap = ((JumboEmoji.LoadResult.Immediate) result).getBitmap();
|
||||
} else if (result instanceof JumboEmoji.LoadResult.Async) {
|
||||
try {
|
||||
bitmap = ((JumboEmoji.LoadResult.Async) result).getTask().get(10, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException | ExecutionException | TimeoutException exception) {
|
||||
Log.d(TAG, "Failed to load jumbo emoji bitmap resource", exception);
|
||||
}
|
||||
}
|
||||
|
||||
if (bitmap != null) {
|
||||
drawable.setSingleBitmap(bitmap);
|
||||
}
|
||||
} else {
|
||||
throw new IllegalStateException("Unexpected subclass " + loadResult.getClass());
|
||||
}
|
||||
|
||||
drawable.setBitmap(bitmap);
|
||||
if (!jumboEmoji || bitmap == null) {
|
||||
EmojiPageCache.LoadResult loadResult = EmojiPageCache.INSTANCE.load(context, drawInfo.getPage(), lowMemoryDecodeScale);
|
||||
|
||||
if (loadResult instanceof EmojiPageCache.LoadResult.Immediate) {
|
||||
Log.d(TAG, "Cached emoji page: " + drawInfo.getPage().getUri().toString());
|
||||
bitmap = ((EmojiPageCache.LoadResult.Immediate) loadResult).getBitmap();
|
||||
} else if (loadResult instanceof EmojiPageCache.LoadResult.Async) {
|
||||
Log.d(TAG, "Loading emoji page: " + drawInfo.getPage().getUri().toString());
|
||||
try {
|
||||
bitmap = ((EmojiPageCache.LoadResult.Async) loadResult).getTask().get(2, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException | ExecutionException | TimeoutException exception) {
|
||||
Log.d(TAG, "Failed to load emoji bitmap resource", exception);
|
||||
}
|
||||
} else {
|
||||
throw new IllegalStateException("Unexpected subclass " + loadResult.getClass());
|
||||
}
|
||||
|
||||
drawable.setBitmap(bitmap);
|
||||
}
|
||||
|
||||
return drawable;
|
||||
}
|
||||
|
||||
|
@ -183,7 +245,8 @@ public class EmojiProvider {
|
|||
private final float intrinsicHeight;
|
||||
private final Rect emojiBounds;
|
||||
|
||||
private Bitmap bmp;
|
||||
private Bitmap bmp;
|
||||
private boolean isSingleBitmap;
|
||||
|
||||
@Override
|
||||
public int getIntrinsicWidth() {
|
||||
|
@ -219,12 +282,21 @@ public class EmojiProvider {
|
|||
}
|
||||
|
||||
canvas.drawBitmap(bmp,
|
||||
emojiBounds,
|
||||
isSingleBitmap ? null : emojiBounds,
|
||||
getBounds(),
|
||||
PAINT);
|
||||
}
|
||||
|
||||
public void setBitmap(Bitmap bitmap) {
|
||||
setBitmap(bitmap, false);
|
||||
}
|
||||
|
||||
public void setSingleBitmap(Bitmap bitmap) {
|
||||
setBitmap(bitmap, true);
|
||||
}
|
||||
|
||||
private void setBitmap(Bitmap bitmap, boolean isSingleBitmap) {
|
||||
this.isSingleBitmap = isSingleBitmap;
|
||||
if (bmp == null || !bmp.sameAs(bitmap)) {
|
||||
bmp = bitmap;
|
||||
invalidateSelf();
|
||||
|
|
|
@ -57,6 +57,7 @@ public class EmojiTextView extends AppCompatTextView {
|
|||
private int lastLineWidth = -1;
|
||||
private TextDirectionHeuristic textDirection;
|
||||
private boolean isJumbomoji;
|
||||
private boolean forceJumboEmoji;
|
||||
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
|
||||
|
@ -77,6 +78,7 @@ public class EmojiTextView extends AppCompatTextView {
|
|||
forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
|
||||
renderMentions = a.getBoolean(R.styleable.EmojiTextView_emoji_renderMentions, true);
|
||||
measureLastLine = a.getBoolean(R.styleable.EmojiTextView_measureLastLine, false);
|
||||
forceJumboEmoji = a.getBoolean(R.styleable.EmojiTextView_emoji_forceJumbo, false);
|
||||
a.recycle();
|
||||
|
||||
a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textSize});
|
||||
|
@ -112,10 +114,10 @@ public class EmojiTextView extends AppCompatTextView {
|
|||
int emojis = candidates.size();
|
||||
float scale = 1.0f;
|
||||
|
||||
if (emojis <= 8) scale += 0.25f;
|
||||
if (emojis <= 6) scale += 0.25f;
|
||||
if (emojis <= 4) scale += 0.25f;
|
||||
if (emojis <= 2) scale += 0.25f;
|
||||
if (emojis <= 8) scale += 0.75f;
|
||||
if (emojis <= 6) scale += 0.75f;
|
||||
if (emojis <= 4) scale += 0.75f;
|
||||
if (emojis <= 2) scale += 0.75f;
|
||||
|
||||
isJumbomoji = scale > 1.0f;
|
||||
super.setTextSize(TypedValue.COMPLEX_UNIT_PX, originalFontSize * scale);
|
||||
|
@ -137,7 +139,7 @@ public class EmojiTextView extends AppCompatTextView {
|
|||
if (useSystemEmoji || candidates == null || candidates.size() == 0) {
|
||||
super.setText(new SpannableStringBuilder(Optional.fromNullable(text).or("")), BufferType.SPANNABLE);
|
||||
} else {
|
||||
CharSequence emojified = EmojiProvider.emojify(candidates, text, this);
|
||||
CharSequence emojified = EmojiProvider.emojify(candidates, text, this, isJumbomoji || forceJumboEmoji);
|
||||
super.setText(new SpannableStringBuilder(emojified), BufferType.SPANNABLE);
|
||||
}
|
||||
|
||||
|
@ -261,7 +263,7 @@ public class EmojiTextView extends AppCompatTextView {
|
|||
if (useSystemEmoji || newCandidates == null || newCandidates.size() == 0) {
|
||||
super.setText(newContent, BufferType.SPANNABLE);
|
||||
} else {
|
||||
CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this);
|
||||
CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji);
|
||||
super.setText(emojified, BufferType.SPANNABLE);
|
||||
}
|
||||
}
|
||||
|
@ -292,7 +294,7 @@ public class EmojiTextView extends AppCompatTextView {
|
|||
.append(Optional.fromNullable(overflowText).or(""));
|
||||
|
||||
EmojiParser.CandidateList newCandidates = isInEditMode() ? null : EmojiProvider.getCandidates(newContent);
|
||||
CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this);
|
||||
CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji);
|
||||
|
||||
super.setText(emojified, BufferType.SPANNABLE);
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
|
|||
val newText = if (newCandidates == null || newCandidates.size() == 0) {
|
||||
text
|
||||
} else {
|
||||
EmojiProvider.emojify(newCandidates, text, this)
|
||||
EmojiProvider.emojify(newCandidates, text, this, false)
|
||||
}
|
||||
|
||||
val newContent = if (width == 0 || maxLines == -1) {
|
||||
|
|
|
@ -1,33 +0,0 @@
|
|||
package org.thoughtcrime.securesms.components.emoji.parsing;
|
||||
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.emoji.EmojiPage;
|
||||
|
||||
public class EmojiDrawInfo {
|
||||
|
||||
private final EmojiPage page;
|
||||
private final int index;
|
||||
|
||||
public EmojiDrawInfo(final @NonNull EmojiPage page, final int index) {
|
||||
this.page = page;
|
||||
this.index = index;
|
||||
}
|
||||
|
||||
public @NonNull EmojiPage getPage() {
|
||||
return page;
|
||||
}
|
||||
|
||||
public int getIndex() {
|
||||
return index;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String toString() {
|
||||
return "DrawInfo{" +
|
||||
"page=" + page +
|
||||
", index=" + index +
|
||||
'}';
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package org.thoughtcrime.securesms.components.emoji.parsing
|
||||
|
||||
import org.thoughtcrime.securesms.emoji.EmojiPage
|
||||
import org.thoughtcrime.securesms.util.Hex
|
||||
import java.nio.charset.Charset
|
||||
|
||||
data class EmojiDrawInfo(val page: EmojiPage, val index: Int, private val emoji: String) {
|
||||
val rawEmoji: String
|
||||
get() {
|
||||
val emojiBytes: ByteArray = emoji.toByteArray(Charset.forName("UTF-16"))
|
||||
return Hex.toStringCondensed(emojiBytes.slice(2 until emojiBytes.size).toByteArray())
|
||||
}
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
package org.thoughtcrime.securesms.emoji;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.mobilecoin.lib.util.Hex;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Arrays;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
import okio.Okio;
|
||||
import okio.Sink;
|
||||
import okio.Source;
|
||||
|
||||
/**
|
||||
* Helper for downloading Emoji files via {@link EmojiRemote}.
|
||||
*/
|
||||
public class EmojiDownloader {
|
||||
|
||||
public static @NonNull EmojiFiles.Name downloadAndVerifyJsonFromRemote(@NonNull Context context, @NonNull EmojiFiles.Version version) throws IOException {
|
||||
return downloadAndVerifyFromRemote(context,
|
||||
version,
|
||||
() -> EmojiRemote.getObject(new EmojiJsonRequest(version.getVersion())),
|
||||
EmojiFiles.Name::forEmojiDataJson);
|
||||
}
|
||||
|
||||
public static @NonNull EmojiFiles.Name downloadAndVerifyImageFromRemote(@NonNull Context context,
|
||||
@NonNull EmojiFiles.Version version,
|
||||
@NonNull String bucket,
|
||||
@NonNull String imagePath,
|
||||
@NonNull String format) throws IOException
|
||||
{
|
||||
return downloadAndVerifyFromRemote(context,
|
||||
version,
|
||||
() -> EmojiRemote.getObject(new EmojiImageRequest(version.getVersion(), bucket, imagePath, format)),
|
||||
() -> new EmojiFiles.Name(imagePath, UUID.randomUUID()));
|
||||
}
|
||||
|
||||
private static @NonNull EmojiFiles.Name downloadAndVerifyFromRemote(@NonNull Context context,
|
||||
@NonNull EmojiFiles.Version version,
|
||||
@NonNull Producer<Response> responseProducer,
|
||||
@NonNull Producer<EmojiFiles.Name> nameProducer) throws IOException
|
||||
{
|
||||
try (Response response = responseProducer.produce()) {
|
||||
if (!response.isSuccessful()) {
|
||||
throw new IOException("Unsuccessful response " + response.code());
|
||||
}
|
||||
|
||||
ResponseBody responseBody = response.body();
|
||||
if (responseBody == null) {
|
||||
throw new IOException("No response body");
|
||||
}
|
||||
|
||||
String responseMD5 = getMD5FromResponse(response);
|
||||
if (responseMD5 == null) {
|
||||
throw new IOException("Invalid ETag on response");
|
||||
}
|
||||
|
||||
EmojiFiles.Name name = nameProducer.produce();
|
||||
|
||||
byte[] savedMd5;
|
||||
|
||||
try (OutputStream outputStream = EmojiFiles.openForWriting(context, version, name.getUuid())) {
|
||||
Source source = response.body().source();
|
||||
Sink sink = Okio.sink(outputStream);
|
||||
|
||||
Okio.buffer(source).readAll(sink);
|
||||
outputStream.flush();
|
||||
|
||||
source.close();
|
||||
sink.close();
|
||||
|
||||
savedMd5 = EmojiFiles.getMd5(context, version, name.getUuid());
|
||||
}
|
||||
|
||||
if (!Arrays.equals(savedMd5, Hex.toByteArray(responseMD5))) {
|
||||
EmojiFiles.delete(context, version, name.getUuid());
|
||||
throw new IOException("MD5 Mismatch.");
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
private static @Nullable String getMD5FromResponse(@NonNull Response response) {
|
||||
Pattern pattern = Pattern.compile(".*([a-f0-9]{32}).*");
|
||||
String header = response.header("etag");
|
||||
Matcher matcher = pattern.matcher(header);
|
||||
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private interface Producer<T> {
|
||||
@NonNull T produce();
|
||||
}
|
||||
}
|
|
@ -47,11 +47,13 @@ private const val TAG = "EmojiFiles"
|
|||
private const val EMOJI_DIRECTORY = "emoji"
|
||||
private const val VERSION_FILE = ".version"
|
||||
private const val NAME_FILE = ".names"
|
||||
private const val JUMBO_FILE = ".jumbos"
|
||||
private const val EMOJI_JSON = "emoji_data.json"
|
||||
|
||||
private fun Context.getEmojiDirectory(): File = getDir(EMOJI_DIRECTORY, Context.MODE_PRIVATE)
|
||||
private fun Context.getVersionFile(): File = File(getEmojiDirectory(), VERSION_FILE)
|
||||
private fun Context.getNameFile(versionUuid: UUID): File = File(File(getEmojiDirectory(), versionUuid.toString()).apply { mkdir() }, NAME_FILE)
|
||||
private fun Context.getJumboFile(versionUuid: UUID): File = File(File(getEmojiDirectory(), versionUuid.toString()).apply { mkdir() }, JUMBO_FILE)
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
private fun getFilesUri(name: String, format: String): Uri = PartAuthority.getEmojiUri(name)
|
||||
|
@ -89,6 +91,13 @@ object EmojiFiles {
|
|||
return getInputStream(context, file)
|
||||
}
|
||||
|
||||
fun openForReadingJumbo(context: Context, version: Version, names: JumboCollection, name: String): InputStream {
|
||||
val dataUuid: UUID = names.getUUIDForName(name) ?: throw IOException("Could not get UUID for name $name")
|
||||
val file: File = version.getFile(context, dataUuid)
|
||||
|
||||
return getInputStream(context, file)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun openForWriting(context: Context, version: Version, uuid: UUID): OutputStream {
|
||||
return getOutputStream(context, version.getFile(context, uuid))
|
||||
|
@ -137,7 +146,8 @@ object EmojiFiles {
|
|||
private val objectMapper = ObjectMapper().registerKotlinModule()
|
||||
|
||||
@JvmStatic
|
||||
fun readVersion(context: Context): Version? {
|
||||
@JvmOverloads
|
||||
fun readVersion(context: Context, skipValidation: Boolean = false): Version? {
|
||||
val version = try {
|
||||
getInputStream(context, context.getVersionFile()).use {
|
||||
objectMapper.readValue(it, Version::class.java)
|
||||
|
@ -147,7 +157,7 @@ object EmojiFiles {
|
|||
null
|
||||
}
|
||||
|
||||
return if (isVersionValid(context, version)) {
|
||||
return if (skipValidation || isVersionValid(context, version)) {
|
||||
version
|
||||
} else {
|
||||
null
|
||||
|
@ -236,4 +246,34 @@ object EmojiFiles {
|
|||
@JsonIgnore
|
||||
fun getUUIDForName(name: String): UUID? = names.firstOrNull { it.name == name }?.uuid
|
||||
}
|
||||
|
||||
class JumboCollection(@JsonProperty val versionUuid: UUID, @JsonProperty val names: List<Name>) {
|
||||
companion object {
|
||||
|
||||
private val objectMapper = ObjectMapper().registerKotlinModule()
|
||||
|
||||
@JvmStatic
|
||||
fun read(context: Context, version: Version): JumboCollection {
|
||||
try {
|
||||
getInputStream(context, context.getJumboFile(version.uuid)).use {
|
||||
return objectMapper.readValue(it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
return JumboCollection(version.uuid, listOf())
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun append(context: Context, nameCollection: JumboCollection, name: Name): JumboCollection {
|
||||
val collection = JumboCollection(nameCollection.versionUuid, nameCollection.names + name)
|
||||
getOutputStream(context, context.getJumboFile(nameCollection.versionUuid)).use {
|
||||
objectMapper.writeValue(it, collection)
|
||||
}
|
||||
return collection
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getUUIDForName(name: String): UUID? = names.firstOrNull { it.name == name }?.uuid
|
||||
}
|
||||
}
|
||||
|
|
|
@ -75,7 +75,7 @@ object EmojiPageCache {
|
|||
val bitmapOptions = BitmapFactory.Options()
|
||||
bitmapOptions.inSampleSize = emojiPageRequest.inSampleSize
|
||||
|
||||
return BitmapFactory.decodeStream(inputStream, null, bitmapOptions)
|
||||
return inputStream.use { BitmapFactory.decodeStream(it, null, bitmapOptions) }
|
||||
}
|
||||
|
||||
private data class EmojiPageRequest(val emojiPage: EmojiPage, val inSampleSize: Int)
|
||||
|
|
|
@ -63,7 +63,7 @@ class EmojiSource(
|
|||
.forEach { page ->
|
||||
val emojiPage = emojiPageFactory(page.spriteUri!!)
|
||||
page.emoji.forEachIndexed { idx, emoji ->
|
||||
tree.add(emoji, EmojiDrawInfo(emojiPage, idx))
|
||||
tree.add(emoji, EmojiDrawInfo(emojiPage, idx, emoji))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
package org.thoughtcrime.securesms.emoji
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.annotation.MainThread
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.AutoDownloadEmojiConstraint
|
||||
import org.thoughtcrime.securesms.util.ListenableFutureTask
|
||||
import org.thoughtcrime.securesms.util.SoftHashMap
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ExecutionException
|
||||
|
||||
private val TAG = Log.tag(JumboEmoji::class.java)
|
||||
|
||||
/**
|
||||
* For Jumbo Emojis, will download, add to in-memory cache, and load from disk.
|
||||
*/
|
||||
object JumboEmoji {
|
||||
|
||||
private val cache: MutableMap<String, Bitmap> = SoftHashMap(16)
|
||||
private val tasks: MutableMap<String, ListenableFutureTask<Bitmap>> = hashMapOf()
|
||||
private val versionToFormat: MutableMap<UUID, String?> = hashMapOf()
|
||||
|
||||
@Suppress("FoldInitializerAndIfToElvis")
|
||||
@JvmStatic
|
||||
@MainThread
|
||||
fun loadJumboEmoji(context: Context, rawEmoji: String): LoadResult {
|
||||
val applicationContext: Context = context.applicationContext
|
||||
|
||||
val name: String = "jumbo/$rawEmoji"
|
||||
val bitmap: Bitmap? = cache[name]
|
||||
val task: ListenableFutureTask<Bitmap>? = tasks[name]
|
||||
|
||||
if (bitmap != null) {
|
||||
return LoadResult.Immediate(bitmap)
|
||||
}
|
||||
|
||||
if (task != null) {
|
||||
return LoadResult.Async(task)
|
||||
}
|
||||
|
||||
val newTask = ListenableFutureTask<Bitmap> {
|
||||
val version: EmojiFiles.Version? = EmojiFiles.Version.readVersion(applicationContext, true)
|
||||
if (version == null) {
|
||||
throw NoVersionData()
|
||||
}
|
||||
|
||||
val format: String? = versionToFormat.getOrPut(version.uuid) {
|
||||
EmojiFiles.getLatestEmojiData(context, version)?.format
|
||||
}
|
||||
|
||||
if (format == null) {
|
||||
throw NoVersionData()
|
||||
}
|
||||
|
||||
var jumbos: EmojiFiles.JumboCollection = EmojiFiles.JumboCollection.read(applicationContext, version)
|
||||
|
||||
val uuid = jumbos.getUUIDForName(name)
|
||||
|
||||
if (uuid == null) {
|
||||
if (!AutoDownloadEmojiConstraint.canAutoDownloadEmoji(applicationContext)) {
|
||||
throw CannotAutoDownload()
|
||||
}
|
||||
|
||||
Log.i(TAG, "No file for emoji, downloading jumbo")
|
||||
val emojiFilesName: EmojiFiles.Name = EmojiDownloader.downloadAndVerifyImageFromRemote(applicationContext, version, version.density, name, format)
|
||||
jumbos = EmojiFiles.JumboCollection.append(applicationContext, jumbos, emojiFilesName)
|
||||
}
|
||||
|
||||
val inputStream = EmojiFiles.openForReadingJumbo(applicationContext, version, jumbos, name)
|
||||
inputStream.use { BitmapFactory.decodeStream(it, null, BitmapFactory.Options()) }
|
||||
}
|
||||
|
||||
tasks[name] = newTask
|
||||
|
||||
SimpleTask.run(SignalExecutors.SERIAL, newTask::run) {
|
||||
try {
|
||||
val newBitmap: Bitmap? = newTask.get()
|
||||
if (newBitmap == null) {
|
||||
Log.w(TAG, "Failed to load jumbo emoji")
|
||||
} else {
|
||||
cache[name] = newBitmap
|
||||
}
|
||||
} catch (e: ExecutionException) {
|
||||
Log.d(TAG, "Failed to load jumbo emoji", e.cause)
|
||||
} finally {
|
||||
tasks.remove(name)
|
||||
}
|
||||
}
|
||||
|
||||
return LoadResult.Async(newTask)
|
||||
}
|
||||
|
||||
class NoVersionData : Throwable()
|
||||
class CannotAutoDownload : IOException()
|
||||
|
||||
sealed class LoadResult {
|
||||
data class Immediate(val bitmap: Bitmap) : LoadResult()
|
||||
data class Async(val task: ListenableFutureTask<Bitmap>) : LoadResult()
|
||||
}
|
||||
}
|
|
@ -14,6 +14,7 @@ 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.EmojiDownloader;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiFiles;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiImageRequest;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiJsonRequest;
|
||||
|
@ -260,7 +261,7 @@ public class DownloadLatestEmojiDataJob extends BaseJob {
|
|||
if (!Arrays.equals(localHash, remoteHash)) {
|
||||
Log.d(TAG, "Downloading JSON from Remote");
|
||||
assertRemoteDownloadConstraints(context);
|
||||
EmojiFiles.Name name = downloadAndVerifyJsonFromRemote(context, version);
|
||||
EmojiFiles.Name name = EmojiDownloader.downloadAndVerifyJsonFromRemote(context, version);
|
||||
EmojiFiles.NameCollection.append(context, names, name);
|
||||
} else {
|
||||
Log.d(TAG, "Already have JSON from remote, skipping download");
|
||||
|
@ -305,7 +306,7 @@ public class DownloadLatestEmojiDataJob extends BaseJob {
|
|||
}
|
||||
|
||||
assertRemoteDownloadConstraints(context);
|
||||
EmojiFiles.Name name = downloadAndVerifyImageFromRemote(context, version, version.getDensity(), imagePath, format);
|
||||
EmojiFiles.Name name = EmojiDownloader.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");
|
||||
|
@ -323,83 +324,6 @@ public class DownloadLatestEmojiDataJob extends BaseJob {
|
|||
}
|
||||
}
|
||||
|
||||
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();
|
||||
Sink sink = Okio.sink(outputStream);
|
||||
|
||||
Okio.buffer(source).readAll(sink);
|
||||
outputStream.flush();
|
||||
|
||||
source.close();
|
||||
sink.close();
|
||||
|
||||
savedMd5 = EmojiFiles.getMd5(context, version, name.getUuid());
|
||||
}
|
||||
|
||||
if (!Arrays.equals(savedMd5, Hex.toByteArray(responseMD5))) {
|
||||
EmojiFiles.delete(context, version, name.getUuid());
|
||||
throw new IOException("MD5 Mismatch.");
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
private static @Nullable String getMD5FromResponse(@NonNull Response response) {
|
||||
Pattern pattern = Pattern.compile(".*([a-f0-9]{32}).*");
|
||||
String header = response.header("etag");
|
||||
Matcher matcher = pattern.matcher(header);
|
||||
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void clearOldEmojiData(@NonNull Context context, @Nullable EmojiFiles.Version newVersion) {
|
||||
EmojiFiles.Version version = EmojiFiles.Version.readVersion(context);
|
||||
|
||||
|
|
|
@ -38,6 +38,7 @@
|
|||
app:layout_constraintStart_toStartOf="@id/avatar_picker_item_image"
|
||||
app:layout_constraintTop_toTopOf="@id/avatar_picker_item_image"
|
||||
app:emoji_forceCustom="true"
|
||||
app:emoji_forceJumbo="true"
|
||||
tools:ignore="SpUsage"
|
||||
tools:text="AF" />
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
android:fontFamily="sans-serif-medium"
|
||||
android:gravity="center"
|
||||
tools:text="AF"
|
||||
app:emoji_forceJumbo="true"
|
||||
app:emoji_forceCustom="true"/>
|
||||
|
||||
<View
|
||||
|
|
|
@ -36,6 +36,7 @@
|
|||
android:gravity="center"
|
||||
android:imeOptions="actionNext"
|
||||
android:inputType="textCapCharacters"
|
||||
app:emoji_forceJumbo="true"
|
||||
app:layout_constraintBottom_toBottomOf="@id/avatar_picker_item_image"
|
||||
app:layout_constraintEnd_toEndOf="@id/avatar_picker_item_image"
|
||||
app:layout_constraintStart_toStartOf="@id/avatar_picker_item_image"
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
android:gravity="center"
|
||||
android:imeOptions="actionNext"
|
||||
android:inputType="textCapCharacters"
|
||||
app:emoji_forceJumbo="true"
|
||||
app:layout_constraintBottom_toBottomOf="@id/avatar_picker_item_image"
|
||||
app:layout_constraintEnd_toEndOf="@id/avatar_picker_item_image"
|
||||
app:layout_constraintStart_toStartOf="@id/avatar_picker_item_image"
|
||||
|
|
|
@ -121,6 +121,7 @@
|
|||
<attr name="emoji_maxLength" format="integer" />
|
||||
<attr name="emoji_forceCustom" format="boolean" />
|
||||
<attr name="emoji_renderMentions" format="boolean" />
|
||||
<attr name="emoji_forceJumbo" format="boolean" />
|
||||
</declare-styleable>
|
||||
|
||||
<declare-styleable name="RingtonePreference">
|
||||
|
|
Ładowanie…
Reference in New Issue