fix recent emoji pane

1) Make recent list properly update and invalidate.
2) Show most-recently-used first.
3) Refactoring

Closes #3171
// FREEBIE
master
Jake McGinty 2015-05-14 21:08:37 -07:00 committed by Moxie Marlinspike
parent 5ec9197912
commit cf420de65f
11 changed files with 210 additions and 172 deletions

View File

@ -16,7 +16,6 @@ import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.RelativeLayout;
import com.astuetz.PagerSlidingTabStrip;
@ -38,6 +37,7 @@ public class EmojiDrawer extends Fragment {
private KeyboardAwareLinearLayout container;
private ViewPager pager;
private PagerSlidingTabStrip strip;
private RecentEmojiPageModel recentModel;
public static EmojiDrawer newInstance(@ArrayRes int categories, @ArrayRes int icons) {
final EmojiDrawer fragment = new EmojiDrawer();
@ -104,6 +104,7 @@ public class EmojiDrawer extends Fragment {
getArguments().getInt("icons")),
new EmojiSelectionListener() {
@Override public void onEmojiSelected(int emojiCode) {
recentModel.onCodePointSelected(emojiCode);
composeText.insertEmoji(emojiCode);
}
}));
@ -114,7 +115,8 @@ public class EmojiDrawer extends Fragment {
final int[] icons = ResUtil.getResourceIds(getActivity(), iconsRes);
final int[] pages = ResUtil.getResourceIds(getActivity(), pagesRes);
final List<EmojiPageModel> models = new LinkedList<>();
models.add(new RecentEmojiPageModel(getActivity()));
recentModel = new RecentEmojiPageModel(getActivity());
models.add(recentModel);
for (int i = 0; i < icons.length; i++) {
models.add(new StaticEmojiPageModel(icons[i], getResources().getIntArray(pages[i])));
}

View File

@ -3,29 +3,26 @@ package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.support.v7.widget.AppCompatEditText;
import android.util.AttributeSet;
import android.util.Log;
import org.thoughtcrime.securesms.components.emoji.EmojiProvider.InvalidatingPageLoadedListener;
public class EmojiEditText extends AppCompatEditText {
public EmojiEditText(Context context) {
super(context);
init();
}
public EmojiEditText(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public EmojiEditText(Context context, AttributeSet attrs,
int defStyleAttr)
{
super(context, attrs, defStyleAttr);
init();
}
private void init() {
@Override public void setText(CharSequence text, BufferType type) {
super.setText(EmojiProvider.getInstance(getContext()).emojify(text, EmojiProvider.EMOJI_SMALL, new PostInvalidateCallback(this)),
BufferType.SPANNABLE);
}
public void insertEmoji(int codePoint) {
@ -34,7 +31,7 @@ public class EmojiEditText extends AppCompatEditText {
final char[] chars = Character.toChars(codePoint);
final CharSequence text = EmojiProvider.getInstance(getContext()).emojify(new String(chars),
EmojiProvider.EMOJI_SMALL,
new InvalidatingPageLoadedListener(this));
new PostInvalidateCallback(this));
getText().replace(Math.min(start, end), Math.max(start, end), text);
setSelection(end + chars.length);

View File

@ -17,7 +17,7 @@ import android.widget.GridView;
import android.widget.ImageView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiProvider.InvalidatingPageLoadedListener;
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel.OnModelChangedListener;
public class EmojiPageFragment extends Fragment {
private static final String TAG = EmojiPageFragment.class.getSimpleName();
@ -42,11 +42,16 @@ public class EmojiPageFragment extends Fragment {
grid.setColumnWidth(getResources().getDimensionPixelSize(R.dimen.emoji_drawer_size) + 2 * getResources().getDimensionPixelSize(R.dimen.emoji_drawer_item_padding));
grid.setOnItemClickListener(new OnItemClickListener() {
@Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
model.onCodePointSelected((Integer)view.getTag());
if (listener != null) listener.onEmojiSelected((Integer)view.getTag());
}
});
grid.setAdapter(new EmojiGridAdapter(getActivity(), model));
model.setOnModelChangedListener(new OnModelChangedListener() {
@Override public void onModelChanged() {
((EmojiGridAdapter)grid.getAdapter()).notifyDataSetChanged();
}
});
return view;
}
@ -99,9 +104,7 @@ public class EmojiPageFragment extends Fragment {
final Integer unicodeTag = model.getCodePoints()[position];
final EmojiProvider provider = EmojiProvider.getInstance(context);
final Drawable drawable = provider.getEmojiDrawable(unicodeTag,
EmojiProvider.EMOJI_HUGE,
new InvalidatingPageLoadedListener(view));
final Drawable drawable = provider.getEmojiDrawable(unicodeTag, EmojiProvider.EMOJI_HUGE);
view.setImageDrawable(drawable);
view.setPadding(pad, pad, pad, pad);

View File

@ -1,7 +1,16 @@
package org.thoughtcrime.securesms.components.emoji;
public interface EmojiPageModel {
int getIconRes();
int[] getCodePoints();
void onCodePointSelected(int codePoint);
public abstract class EmojiPageModel {
protected OnModelChangedListener listener;
public abstract int getIconRes();
public abstract int[] getCodePoints();
public void setOnModelChangedListener(OnModelChangedListener listener) {
this.listener = listener;
}
interface OnModelChangedListener {
void onModelChanged();
}
}

View File

@ -8,18 +8,20 @@ import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.Drawable.Callback;
import android.os.AsyncTask;
import android.os.Handler;
import android.os.Looper;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.style.ImageSpan;
import android.util.Log;
import android.util.SparseArray;
import android.view.View;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.BitmapDecodingException;
import org.thoughtcrime.securesms.util.BitmapUtil;
import org.thoughtcrime.securesms.util.FutureTaskListener;
import org.thoughtcrime.securesms.util.ListenableFutureTask;
import org.thoughtcrime.securesms.util.ResUtil;
import org.thoughtcrime.securesms.util.Util;
@ -27,17 +29,14 @@ import java.io.IOException;
import java.io.InputStream;
import java.lang.ref.SoftReference;
import java.util.Arrays;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Callable;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class EmojiProvider {
private static final String TAG = EmojiProvider.class.getSimpleName();
private static final ExecutorService executor = Util.newSingleThreadedLifoExecutor();
private static volatile EmojiProvider instance = null;
private static final SparseArray<SoftReference<Bitmap>> bitmaps = new SparseArray<>();
private static final Paint paint = new Paint();
private static final Handler handler = new Handler(Looper.getMainLooper());
private static final String TAG = EmojiProvider.class.getSimpleName();
private static volatile EmojiProvider instance = null;
private static final Paint paint = new Paint();
static { paint.setFilterBitmap(true); }
private final SparseArray<DrawInfo> offsets = new SparseArray<>();
@ -55,7 +54,7 @@ public class EmojiProvider {
private final Context context;
private final int bigDrawSize;
private final int[] pages;
private final Handler handler = new Handler(Looper.getMainLooper());
public static EmojiProvider getInstance(Context context) {
if (instance == null) {
@ -69,72 +68,28 @@ public class EmojiProvider {
}
private EmojiProvider(Context context) {
this.context = context.getApplicationContext();
int[] pages = ResUtil.getResourceIds(context, R.array.emoji_categories);
this.context = context.getApplicationContext();
this.bigDrawSize = context.getResources().getDimensionPixelSize(R.dimen.emoji_drawer_size);
this.pages = ResUtil.getResourceIds(context, R.array.emoji_categories);
for (int i = 0; i < pages.length; i++) {
final int[] page = context.getResources().getIntArray(pages[i]);
for (int j = 0; j < page.length; j++) {
offsets.put(page[j], new DrawInfo(i, j));
final EmojiPageBitmap page = new EmojiPageBitmap(i);
final int[] codePoints = context.getResources().getIntArray(pages[i]);
for (int j = 0; j < codePoints.length; j++) {
offsets.put(codePoints[j], new DrawInfo(page, j));
}
}
}
private void preloadPage(final int page, final PageLoadedListener pageLoadListener) {
executor.submit(new Runnable() {
@Override
public void run() {
try {
loadPage(page);
if (pageLoadListener != null) {
pageLoadListener.onPageLoaded();
}
} catch (IOException ioe) {
Log.w(TAG, ioe);
}
}
});
}
private void loadPage(int page) throws IOException {
if (page < 0 || page >= pages.length) {
throw new IndexOutOfBoundsException("can't load page that doesn't exist");
}
if (bitmaps.get(page) != null && bitmaps.get(page).get() != null) return;
try {
final String file = "emoji_" + page + "_wrapped.png";
final InputStream measureStream = context.getAssets().open(file);
final InputStream bitmapStream = context.getAssets().open(file);
final Bitmap bitmap = BitmapUtil.createScaledBitmap(measureStream, bitmapStream, (float) bigDrawSize / (float) EMOJI_RAW_SIZE);
bitmaps.put(page, new SoftReference<>(bitmap));
Log.w(TAG, "onPageLoaded(" + page + ")");
} catch (IOException ioe) {
Log.w(TAG, ioe);
throw ioe;
} catch (BitmapDecodingException bde) {
Log.w(TAG, bde);
throw new AssertionError("emoji sprite asset is corrupted or android decoding is broken");
}
}
public CharSequence emojify(CharSequence text, PageLoadedListener pageLoadedListener) {
return emojify(text, EMOJI_LARGE, pageLoadedListener);
}
public CharSequence emojify(CharSequence text, double size, PageLoadedListener pageLoadedListener) {
public CharSequence emojify(CharSequence text, double size, Callback callback) {
Matcher matches = EMOJI_RANGE.matcher(text);
SpannableStringBuilder builder = new SpannableStringBuilder(text);
while (matches.find()) {
int codePoint = matches.group().codePointAt(0);
Drawable drawable = getEmojiDrawable(codePoint, size, pageLoadedListener);
Drawable drawable = getEmojiDrawable(codePoint, size);
if (drawable != null) {
ImageSpan imageSpan = new ImageSpan(drawable, ImageSpan.ALIGN_BOTTOM);
char[] chars = new char[matches.end() - matches.start()];
Arrays.fill(chars, ' ');
builder.setSpan(imageSpan, matches.start(), matches.end(),
builder.setSpan(new InvalidatingDrawableSpan(drawable, callback), matches.start(), matches.end(),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
@ -142,51 +97,52 @@ public class EmojiProvider {
return builder;
}
public Drawable getEmojiDrawable(int emojiCode, double size, PageLoadedListener pageLoadedListener) {
return getEmojiDrawable(offsets.get(emojiCode), size, pageLoadedListener);
public Drawable getEmojiDrawable(int emojiCode, double size) {
return getEmojiDrawable(offsets.get(emojiCode), size);
}
private Drawable getEmojiDrawable(DrawInfo drawInfo, double size, PageLoadedListener pageLoadedListener) {
if (drawInfo == null) {
return null;
}
final Drawable drawable = new EmojiDrawable(drawInfo, bigDrawSize);
private Drawable getEmojiDrawable(DrawInfo drawInfo, double size) {
if (drawInfo == null) return null;
final EmojiDrawable drawable = new EmojiDrawable(drawInfo, bigDrawSize);
drawable.setBounds(0, 0, (int)((double)bigDrawSize * size), (int)((double)bigDrawSize * size));
if (bitmaps.get(drawInfo.page) == null || bitmaps.get(drawInfo.page).get() == null) {
preloadPage(drawInfo.page, pageLoadedListener);
}
drawInfo.page.get().addListener(new FutureTaskListener<Bitmap>() {
@Override public void onSuccess(final Bitmap result) {
handler.post(new Runnable() {
@Override public void run() {
drawable.setBitmap(result);
}
});
}
@Override public void onFailure(Throwable error) {
Log.w(TAG, error);
}
});
return drawable;
}
public class EmojiDrawable extends Drawable {
private final int index;
private final int page;
private final int emojiSize;
private Bitmap bmp;
@Override public int getIntrinsicWidth() {
return emojiSize;
}
@Override public int getIntrinsicHeight() {
return emojiSize;
}
public EmojiDrawable(DrawInfo info, int emojiSize) {
this.index = info.index;
this.page = info.page;
this.emojiSize = emojiSize;
}
@Override
public void draw(Canvas canvas) {
if (bitmaps.get(page) == null || bitmaps.get(page).get() == null) {
preloadPage(page, new PageLoadedListener() {
@Override public void onPageLoaded() {
handler.post(new Runnable() {
@Override public void run() {
invalidateSelf();
}
});
}
});
return;
}
if (bmp == null) {
bmp = bitmaps.get(page).get();
}
if (bmp == null) return;
Rect b = copyBounds();
@ -202,6 +158,12 @@ public class EmojiProvider {
paint);
}
public void setBitmap(Bitmap bitmap) {
Util.assertMainThread();
bmp = bitmap;
invalidateSelf();
}
@Override
public int getOpacity() {
return PixelFormat.TRANSLUCENT;
@ -212,39 +174,13 @@ public class EmojiProvider {
@Override
public void setColorFilter(ColorFilter cf) { }
@Override
public String toString() {
return "EmojiDrawable{" +
"page=" + page +
", index=" + index +
'}';
}
}
public static class InvalidatingPageLoadedListener implements PageLoadedListener {
private final View view;
public InvalidatingPageLoadedListener(final View view) {
this.view = view;
}
@Override
public void onPageLoaded() {
view.postInvalidate();
}
@Override
public String toString() {
return "InvalidatingPageLoadedListener{}";
}
}
class DrawInfo {
int page;
int index;
EmojiPageBitmap page;
int index;
public DrawInfo(final int page, final int index) {
public DrawInfo(final EmojiPageBitmap page, final int index) {
this.page = page;
this.index = index;
}
@ -258,7 +194,67 @@ public class EmojiProvider {
}
}
interface PageLoadedListener {
void onPageLoaded();
private class EmojiPageBitmap {
private int page;
private SoftReference<Bitmap> bitmapReference;
private ListenableFutureTask<Bitmap> task;
public EmojiPageBitmap(int page) {
this.page = page;
}
private ListenableFutureTask<Bitmap> get() {
Util.assertMainThread();
if (bitmapReference != null && bitmapReference.get() != null) {
return new ListenableFutureTask<>(bitmapReference.get());
} else if (task != null) {
return task;
} else {
Callable<Bitmap> callable = new Callable<Bitmap>() {
@Override public Bitmap call() throws Exception {
try {
Log.w(TAG, "loading page " + page);
return loadPage();
} catch (IOException ioe) {
Log.w(TAG, ioe);
}
return null;
}
};
task = new ListenableFutureTask<>(callable);
new AsyncTask<Void, Void, Void>() {
@Override protected Void doInBackground(Void... params) {
task.run();
return null;
}
@Override protected void onPostExecute(Void aVoid) {
task = null;
}
}.execute();
}
return task;
}
private Bitmap loadPage() throws IOException {
if (bitmapReference != null && bitmapReference.get() != null) return bitmapReference.get();
try {
final String file = "emoji_" + page + "_wrapped.png";
final InputStream measureStream = context.getAssets().open(file);
final InputStream bitmapStream = context.getAssets().open(file);
final Bitmap bitmap = BitmapUtil.createScaledBitmap(measureStream, bitmapStream, (float)bigDrawSize / (float)EMOJI_RAW_SIZE);
bitmapReference = new SoftReference<>(bitmap);
Log.w(TAG, "onPageLoaded(" + page + ")");
return bitmap;
} catch (IOException ioe) {
Log.w(TAG, ioe);
throw ioe;
} catch (BitmapDecodingException bde) {
Log.w(TAG, bde);
throw new AssertionError("emoji sprite asset is corrupted or android decoding is broken");
}
}
}
}

View File

@ -1,45 +1,26 @@
package org.thoughtcrime.securesms.components.emoji;
import android.annotation.TargetApi;
import android.content.Context;
import android.graphics.Rect;
import android.os.Build.VERSION_CODES;
import android.support.v7.widget.AppCompatTextView;
import android.text.method.TransformationMethod;
import android.util.AttributeSet;
import android.view.View;
import org.thoughtcrime.securesms.components.emoji.EmojiProvider.InvalidatingPageLoadedListener;
public class EmojiTextView extends AppCompatTextView {
public EmojiTextView(Context context) {
super(context);
init();
}
public EmojiTextView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public EmojiTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
setTransformationMethod(new EmojiTransformationMethod());
}
private static class EmojiTransformationMethod implements TransformationMethod {
@Override public CharSequence getTransformation(CharSequence source, View view) {
return EmojiProvider.getInstance(view.getContext()).emojify(source,
@Override public void setText(CharSequence text, BufferType type) {
super.setText(EmojiProvider.getInstance(getContext()).emojify(text,
EmojiProvider.EMOJI_SMALL,
new InvalidatingPageLoadedListener(view));
}
@Override public void onFocusChanged(View view, CharSequence sourceText, boolean focused,
int direction, Rect previouslyFocusedRect) { }
new PostInvalidateCallback(this)),
BufferType.SPANNABLE);
}
}

View File

@ -0,0 +1,12 @@
package org.thoughtcrime.securesms.components.emoji;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.Drawable.Callback;
import android.text.style.ImageSpan;
public class InvalidatingDrawableSpan extends ImageSpan {
public InvalidatingDrawableSpan(Drawable drawable, Callback callback) {
super(drawable, ALIGN_BOTTOM);
drawable.setCallback(callback);
}
}

View File

@ -0,0 +1,25 @@
package org.thoughtcrime.securesms.components.emoji;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.Drawable.Callback;
import android.view.View;
public class PostInvalidateCallback implements Callback {
private final View view;
public PostInvalidateCallback(View view) {
this.view = view;
}
@Override public void invalidateDrawable(Drawable who) {
view.postInvalidate();
}
@Override public void scheduleDrawable(Drawable who, Runnable what, long when) {
}
@Override public void unscheduleDrawable(Drawable who, Runnable what) {
}
}

View File

@ -18,13 +18,14 @@ import java.io.IOException;
import java.util.Iterator;
import java.util.LinkedHashSet;
public class RecentEmojiPageModel implements EmojiPageModel {
public class RecentEmojiPageModel extends EmojiPageModel {
private static final String TAG = RecentEmojiPageModel.class.getSimpleName();
private static final String EMOJI_LRU_PREFERENCE = "pref_recent_emoji";
private static final int EMOJI_LRU_SIZE = 50;
private final SharedPreferences prefs;
private final LinkedHashSet<Integer> recentlyUsed;
private OnModelChangedListener listener;
public RecentEmojiPageModel(Context context) {
this.prefs = PreferenceManager.getDefaultSharedPreferences(context);
@ -50,10 +51,11 @@ public class RecentEmojiPageModel implements EmojiPageModel {
}
@Override public int[] getCodePoints() {
return toPrimitiveArray(recentlyUsed);
return toReversePrimitiveArray(recentlyUsed);
}
@Override public void onCodePointSelected(int codePoint) {
public void onCodePointSelected(int codePoint) {
Log.w(TAG, "onCodePointSelected(" + codePoint + ")");
recentlyUsed.remove(codePoint);
recentlyUsed.add(codePoint);
@ -80,6 +82,12 @@ public class RecentEmojiPageModel implements EmojiPageModel {
return null;
}
}.execute();
if (listener != null) listener.onModelChanged();
}
@Override public void setOnModelChangedListener(OnModelChangedListener listener) {
this.listener = listener;
}
private LinkedHashSet<Integer> fromHexString(@Nullable LinkedHashSet<String> stringSet) {
@ -100,11 +108,11 @@ public class RecentEmojiPageModel implements EmojiPageModel {
return stringSet;
}
private int[] toPrimitiveArray(@NonNull LinkedHashSet<Integer> integerSet) {
private int[] toReversePrimitiveArray(@NonNull LinkedHashSet<Integer> integerSet) {
int[] ints = new int[integerSet.size()];
int i = 0;
int i = integerSet.size() - 1;
for (Integer integer : integerSet) {
ints[i++] = integer;
ints[i--] = integer;
}
return ints;
}

View File

@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.components.emoji;
import android.support.annotation.DrawableRes;
import android.support.annotation.NonNull;
public class StaticEmojiPageModel implements EmojiPageModel {
public class StaticEmojiPageModel extends EmojiPageModel {
@DrawableRes private final int icon;
@NonNull private final int[] codePoints;
@ -19,6 +19,4 @@ public class StaticEmojiPageModel implements EmojiPageModel {
@NonNull public int[] getCodePoints() {
return codePoints;
}
@Override public void onCodePointSelected(int codePoint) { }
}

View File

@ -24,6 +24,7 @@ import android.graphics.Typeface;
import android.os.Build;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.os.Looper;
import android.provider.Telephony;
import android.telephony.TelephonyManager;
import android.text.Spannable;
@ -296,4 +297,10 @@ public class Util {
public static boolean isMmsCapable(Context context) {
return (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) || OutgoingLegacyMmsConnection.isConnectionPossible(context);
}
public static void assertMainThread() {
if (Looper.myLooper() != Looper.getMainLooper()) {
throw new AssertionError("Main-thread assertion failed.");
}
}
}