Move all files to natural position.

This commit is contained in:
Alan Evans
2020-01-06 10:52:48 -05:00
parent 0df36047e7
commit 9ebe920195
3016 changed files with 6 additions and 36 deletions

View File

@@ -0,0 +1,53 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.os.Build;
import androidx.annotation.RequiresApi;
import android.util.AttributeSet;
import android.widget.ToggleButton;
public class AccessibleToggleButton extends ToggleButton {
private OnCheckedChangeListener listener;
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public AccessibleToggleButton(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
public AccessibleToggleButton(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public AccessibleToggleButton(Context context, AttributeSet attrs) {
super(context, attrs);
}
public AccessibleToggleButton(Context context) {
super(context);
}
@Override
public void setOnCheckedChangeListener(OnCheckedChangeListener listener) {
super.setOnCheckedChangeListener(listener);
this.listener = listener;
}
public void setChecked(boolean checked, boolean notifyListener) {
if (!notifyListener) {
super.setOnCheckedChangeListener(null);
}
super.setChecked(checked);
if (!notifyListener) {
super.setOnCheckedChangeListener(listener);
}
}
public OnCheckedChangeListener getOnCheckedChangeListener() {
return this.listener;
}
}

View File

@@ -0,0 +1,158 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import androidx.annotation.ColorInt;
import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.util.AttributeSet;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.TextView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import org.thoughtcrime.securesms.util.views.Stub;
import java.util.List;
public class AlbumThumbnailView extends FrameLayout {
private @Nullable SlideClickListener thumbnailClickListener;
private @Nullable SlidesClickedListener downloadClickListener;
private int currentSizeClass;
private ViewGroup albumCellContainer;
private Stub<TransferControlView> transferControls;
private final SlideClickListener defaultThumbnailClickListener = (v, slide) -> {
if (thumbnailClickListener != null) {
thumbnailClickListener.onClick(v, slide);
}
};
private final OnLongClickListener defaultLongClickListener = v -> this.performLongClick();
public AlbumThumbnailView(@NonNull Context context) {
super(context);
initialize();
}
public AlbumThumbnailView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
initialize();
}
private void initialize() {
inflate(getContext(), R.layout.album_thumbnail_view, this);
albumCellContainer = findViewById(R.id.album_cell_container);
transferControls = new Stub<>(findViewById(R.id.album_transfer_controls_stub));
}
public void setSlides(@NonNull GlideRequests glideRequests, @NonNull List<Slide> slides, boolean showControls) {
if (slides.size() < 2) {
throw new IllegalStateException("Provided less than two slides.");
}
if (showControls) {
transferControls.get().setShowDownloadText(true);
transferControls.get().setSlides(slides);
transferControls.get().setDownloadClickListener(v -> {
if (downloadClickListener != null) {
downloadClickListener.onClick(v, slides);
}
});
} else {
if (transferControls.resolved()) {
transferControls.get().setVisibility(GONE);
}
}
int sizeClass = sizeClass(slides.size());
if (sizeClass != currentSizeClass) {
inflateLayout(sizeClass);
currentSizeClass = sizeClass;
}
showSlides(glideRequests, slides);
}
public void setCellBackgroundColor(@ColorInt int color) {
ViewGroup cellRoot = findViewById(R.id.album_thumbnail_root);
if (cellRoot != null) {
for (int i = 0; i < cellRoot.getChildCount(); i++) {
cellRoot.getChildAt(i).setBackgroundColor(color);
}
}
}
public void setThumbnailClickListener(@Nullable SlideClickListener listener) {
thumbnailClickListener = listener;
}
public void setDownloadClickListener(@Nullable SlidesClickedListener listener) {
downloadClickListener = listener;
}
private void inflateLayout(int sizeClass) {
albumCellContainer.removeAllViews();
switch (sizeClass) {
case 2:
inflate(getContext(), R.layout.album_thumbnail_2, albumCellContainer);
break;
case 3:
inflate(getContext(), R.layout.album_thumbnail_3, albumCellContainer);
break;
case 4:
inflate(getContext(), R.layout.album_thumbnail_4, albumCellContainer);
break;
case 5:
inflate(getContext(), R.layout.album_thumbnail_5, albumCellContainer);
break;
default:
inflate(getContext(), R.layout.album_thumbnail_many, albumCellContainer);
break;
}
}
private void showSlides(@NonNull GlideRequests glideRequests, @NonNull List<Slide> slides) {
setSlide(glideRequests, slides.get(0), R.id.album_cell_1);
setSlide(glideRequests, slides.get(1), R.id.album_cell_2);
if (slides.size() >= 3) {
setSlide(glideRequests, slides.get(2), R.id.album_cell_3);
}
if (slides.size() >= 4) {
setSlide(glideRequests, slides.get(3), R.id.album_cell_4);
}
if (slides.size() >= 5) {
setSlide(glideRequests, slides.get(4), R.id.album_cell_5);
}
if (slides.size() > 5) {
TextView text = findViewById(R.id.album_cell_overflow_text);
text.setText(getContext().getString(R.string.AlbumThumbnailView_plus, slides.size() - 5));
}
}
private void setSlide(@NonNull GlideRequests glideRequests, @NonNull Slide slide, @IdRes int id) {
ThumbnailView cell = findViewById(id);
cell.setImageResource(glideRequests, slide, false, false);
cell.setThumbnailClickListener(defaultThumbnailClickListener);
cell.setOnLongClickListener(defaultLongClickListener);
}
private int sizeClass(int size) {
return Math.min(size, 6);
}
}

View File

@@ -0,0 +1,72 @@
package org.thoughtcrime.securesms.components;
import android.annotation.TargetApi;
import android.content.Context;
import android.content.res.TypedArray;
import android.os.Build.VERSION_CODES;
import androidx.core.content.ContextCompat;
import android.util.AttributeSet;
import android.view.View;
import android.widget.ImageView;
import android.widget.LinearLayout;
import org.thoughtcrime.securesms.R;
public class AlertView extends LinearLayout {
private static final String TAG = AlertView.class.getSimpleName();
private ImageView approvalIndicator;
private ImageView failedIndicator;
public AlertView(Context context) {
this(context, null);
}
public AlertView(Context context, AttributeSet attrs) {
super(context, attrs);
initialize(attrs);
}
@TargetApi(VERSION_CODES.HONEYCOMB)
public AlertView(final Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
initialize(attrs);
}
private void initialize(AttributeSet attrs) {
inflate(getContext(), R.layout.alert_view, this);
approvalIndicator = findViewById(R.id.pending_approval_indicator);
failedIndicator = findViewById(R.id.sms_failed_indicator);
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.AlertView, 0, 0);
boolean useSmallIcon = typedArray.getBoolean(R.styleable.AlertView_useSmallIcon, false);
typedArray.recycle();
if (useSmallIcon) {
int size = getResources().getDimensionPixelOffset(R.dimen.alertview_small_icon_size);
failedIndicator.getLayoutParams().width = size;
failedIndicator.getLayoutParams().height = size;
requestLayout();
}
}
}
public void setNone() {
this.setVisibility(View.GONE);
}
public void setPendingApproval() {
this.setVisibility(View.VISIBLE);
approvalIndicator.setVisibility(View.VISIBLE);
failedIndicator.setVisibility(View.GONE);
}
public void setFailed() {
this.setVisibility(View.VISIBLE);
approvalIndicator.setVisibility(View.GONE);
failedIndicator.setVisibility(View.VISIBLE);
}
}

View File

@@ -0,0 +1,71 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ViewUtil;
public class AnimatingToggle extends FrameLayout {
private View current;
private final Animation inAnimation;
private final Animation outAnimation;
public AnimatingToggle(Context context) {
this(context, null);
}
public AnimatingToggle(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public AnimatingToggle(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
this.outAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.animation_toggle_out);
this.inAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.animation_toggle_in);
this.outAnimation.setInterpolator(new FastOutSlowInInterpolator());
this.inAnimation.setInterpolator(new FastOutSlowInInterpolator());
}
@Override
public void addView(@NonNull View child, int index, ViewGroup.LayoutParams params) {
super.addView(child, index, params);
if (!isInEditMode()) {
if (getChildCount() == 1) {
current = child;
child.setVisibility(View.VISIBLE);
} else {
child.setVisibility(View.GONE);
}
child.setClickable(false);
}
}
public void display(@Nullable View view) {
if (view == current) return;
if (current != null) ViewUtil.animateOut(current, outAnimation, View.GONE);
if (view != null) ViewUtil.animateIn(view, inAnimation);
current = view;
}
public void displayQuick(@Nullable View view) {
if (view == current) return;
if (current != null) current.setVisibility(View.GONE);
if (view != null) view.setVisibility(View.VISIBLE);
current = view;
}
}

View File

@@ -0,0 +1,125 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RectF;
import android.os.Bundle;
import android.os.Parcelable;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.Util;
public class ArcProgressBar extends View {
private static final int DEFAULT_WIDTH = 10;
private static final float DEFAULT_PROGRESS = 0f;
private static final int DEFAULT_BACKGROUND_COLOR = 0xFF000000;
private static final int DEFAULT_FOREGROUND_COLOR = 0xFFFFFFFF;
private static final float DEFAULT_START_ANGLE = 0f;
private static final float DEFAULT_SWEEP_ANGLE = 360f;
private static final boolean DEFAULT_ROUNDED_ENDS = true;
private static final String SUPER = "arcprogressbar.super";
private static final String PROGRESS = "arcprogressbar.progress";
private float progress;
private final float width;
private final RectF arcRect = new RectF();
private final Paint arcBackgroundPaint;
private final Paint arcForegroundPaint;
private final float arcStartAngle;
private final float arcSweepAngle;
public ArcProgressBar(@NonNull Context context) {
this(context, null);
}
public ArcProgressBar(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public ArcProgressBar(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray attributes = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ArcProgressBar, defStyleAttr, 0);
width = attributes.getDimensionPixelSize(R.styleable.ArcProgressBar_arcWidth, DEFAULT_WIDTH);
progress = attributes.getFloat(R.styleable.ArcProgressBar_arcProgress, DEFAULT_PROGRESS);
arcBackgroundPaint = createPaint(width, attributes.getColor(R.styleable.ArcProgressBar_arcBackgroundColor, DEFAULT_BACKGROUND_COLOR));
arcForegroundPaint = createPaint(width, attributes.getColor(R.styleable.ArcProgressBar_arcForegroundColor, DEFAULT_FOREGROUND_COLOR));
arcStartAngle = attributes.getFloat(R.styleable.ArcProgressBar_arcStartAngle, DEFAULT_START_ANGLE);
arcSweepAngle = attributes.getFloat(R.styleable.ArcProgressBar_arcSweepAngle, DEFAULT_SWEEP_ANGLE);
if (attributes.getBoolean(R.styleable.ArcProgressBar_arcRoundedEnds, DEFAULT_ROUNDED_ENDS)) {
arcForegroundPaint.setStrokeCap(Paint.Cap.ROUND);
if (arcSweepAngle <= 360f) {
arcBackgroundPaint.setStrokeCap(Paint.Cap.ROUND);
}
}
attributes.recycle();
}
private static Paint createPaint(float width, @ColorInt int color) {
Paint paint = new Paint();
paint.setStrokeWidth(width);
paint.setStyle(Paint.Style.STROKE);
paint.setAntiAlias(true);
paint.setColor(color);
return paint;
}
public void setProgress(float progress) {
if (this.progress != progress) {
this.progress = progress;
invalidate();
}
}
@Override
protected @Nullable Parcelable onSaveInstanceState() {
Parcelable superState = super.onSaveInstanceState();
Bundle bundle = new Bundle();
bundle.putParcelable(SUPER, superState);
bundle.putFloat(PROGRESS, progress);
return bundle;
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
if (state.getClass() != Bundle.class) throw new IllegalStateException("Expected");
Bundle restoreState = (Bundle) state;
Parcelable superState = restoreState.getParcelable(SUPER);
super.onRestoreInstanceState(superState);
progress = restoreState.getLong(PROGRESS);
}
@Override
protected void onDraw(Canvas canvas) {
float halfWidth = width / 2f;
arcRect.set(0 + halfWidth,
0 + halfWidth,
getWidth() - halfWidth,
getHeight() - halfWidth);
canvas.drawArc(arcRect, arcStartAngle, arcSweepAngle, false, arcBackgroundPaint);
canvas.drawArc(arcRect, arcStartAngle, arcSweepAngle * Util.clamp(progress, 0f, 1f), false, arcForegroundPaint);
}
}

View File

@@ -0,0 +1,292 @@
package org.thoughtcrime.securesms.components;
import android.Manifest;
import android.animation.Animator;
import android.annotation.TargetApi;
import android.app.Activity;
import android.content.Context;
import android.graphics.drawable.BitmapDrawable;
import android.net.Uri;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.loader.app.LoaderManager;
import android.util.Pair;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewAnimationUtils;
import android.view.ViewTreeObserver;
import android.view.animation.Animation;
import android.view.animation.AnimationSet;
import android.view.animation.OvershootInterpolator;
import android.view.animation.ScaleAnimation;
import android.view.animation.TranslateAnimation;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.PopupWindow;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.util.ViewUtil;
public class AttachmentTypeSelector extends PopupWindow {
public static final int ADD_GALLERY = 1;
public static final int ADD_DOCUMENT = 2;
public static final int ADD_SOUND = 3;
public static final int ADD_CONTACT_INFO = 4;
public static final int TAKE_PHOTO = 5;
public static final int ADD_LOCATION = 6;
public static final int ADD_GIF = 7;
private static final int ANIMATION_DURATION = 300;
@SuppressWarnings("unused")
private static final String TAG = AttachmentTypeSelector.class.getSimpleName();
private final @NonNull LoaderManager loaderManager;
private final @NonNull RecentPhotoViewRail recentRail;
private final @NonNull ImageView imageButton;
private final @NonNull ImageView audioButton;
private final @NonNull ImageView documentButton;
private final @NonNull ImageView contactButton;
private final @NonNull ImageView cameraButton;
private final @NonNull ImageView locationButton;
private final @NonNull ImageView gifButton;
private final @NonNull ImageView closeButton;
private @Nullable View currentAnchor;
private @Nullable AttachmentClickedListener listener;
public AttachmentTypeSelector(@NonNull Context context, @NonNull LoaderManager loaderManager, @Nullable AttachmentClickedListener listener) {
super(context);
LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
LinearLayout layout = (LinearLayout) inflater.inflate(R.layout.attachment_type_selector, null, true);
this.listener = listener;
this.loaderManager = loaderManager;
this.recentRail = ViewUtil.findById(layout, R.id.recent_photos);
this.imageButton = ViewUtil.findById(layout, R.id.gallery_button);
this.audioButton = ViewUtil.findById(layout, R.id.audio_button);
this.documentButton = ViewUtil.findById(layout, R.id.document_button);
this.contactButton = ViewUtil.findById(layout, R.id.contact_button);
this.cameraButton = ViewUtil.findById(layout, R.id.camera_button);
this.locationButton = ViewUtil.findById(layout, R.id.location_button);
this.gifButton = ViewUtil.findById(layout, R.id.giphy_button);
this.closeButton = ViewUtil.findById(layout, R.id.close_button);
this.imageButton.setOnClickListener(new PropagatingClickListener(ADD_GALLERY));
this.audioButton.setOnClickListener(new PropagatingClickListener(ADD_SOUND));
this.documentButton.setOnClickListener(new PropagatingClickListener(ADD_DOCUMENT));
this.contactButton.setOnClickListener(new PropagatingClickListener(ADD_CONTACT_INFO));
this.cameraButton.setOnClickListener(new PropagatingClickListener(TAKE_PHOTO));
this.locationButton.setOnClickListener(new PropagatingClickListener(ADD_LOCATION));
this.gifButton.setOnClickListener(new PropagatingClickListener(ADD_GIF));
this.closeButton.setOnClickListener(new CloseClickListener());
this.recentRail.setListener(new RecentPhotoSelectedListener());
setContentView(layout);
setWidth(LinearLayout.LayoutParams.MATCH_PARENT);
setHeight(LinearLayout.LayoutParams.WRAP_CONTENT);
setBackgroundDrawable(new BitmapDrawable());
setAnimationStyle(0);
setInputMethodMode(PopupWindow.INPUT_METHOD_NOT_NEEDED);
setFocusable(true);
setTouchable(true);
loaderManager.initLoader(1, null, recentRail);
}
public void show(@NonNull Activity activity, final @NonNull View anchor) {
if (Permissions.hasAll(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
recentRail.setVisibility(View.VISIBLE);
loaderManager.restartLoader(1, null, recentRail);
} else {
recentRail.setVisibility(View.GONE);
}
this.currentAnchor = anchor;
showAtLocation(anchor, Gravity.BOTTOM, 0, 0);
getContentView().getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
getContentView().getViewTreeObserver().removeGlobalOnLayoutListener(this);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
animateWindowInCircular(anchor, getContentView());
} else {
animateWindowInTranslate(getContentView());
}
}
});
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
animateButtonIn(imageButton, ANIMATION_DURATION / 2);
animateButtonIn(cameraButton, ANIMATION_DURATION / 2);
animateButtonIn(audioButton, ANIMATION_DURATION / 3);
animateButtonIn(locationButton, ANIMATION_DURATION / 3);
animateButtonIn(documentButton, ANIMATION_DURATION / 4);
animateButtonIn(gifButton, ANIMATION_DURATION / 4);
animateButtonIn(contactButton, 0);
animateButtonIn(closeButton, 0);
}
}
@Override
public void dismiss() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
animateWindowOutCircular(currentAnchor, getContentView());
} else {
animateWindowOutTranslate(getContentView());
}
}
public void setListener(@Nullable AttachmentClickedListener listener) {
this.listener = listener;
}
private void animateButtonIn(View button, int delay) {
AnimationSet animation = new AnimationSet(true);
Animation scale = new ScaleAnimation(0.0f, 1.0f, 0.0f, 1.0f,
Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.0f);
animation.addAnimation(scale);
animation.setInterpolator(new OvershootInterpolator(1));
animation.setDuration(ANIMATION_DURATION);
animation.setStartOffset(delay);
button.startAnimation(animation);
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
private void animateWindowInCircular(@Nullable View anchor, @NonNull View contentView) {
Pair<Integer, Integer> coordinates = getClickOrigin(anchor, contentView);
Animator animator = ViewAnimationUtils.createCircularReveal(contentView,
coordinates.first,
coordinates.second,
0,
Math.max(contentView.getWidth(), contentView.getHeight()));
animator.setDuration(ANIMATION_DURATION);
animator.start();
}
private void animateWindowInTranslate(@NonNull View contentView) {
Animation animation = new TranslateAnimation(0, 0, contentView.getHeight(), 0);
animation.setDuration(ANIMATION_DURATION);
getContentView().startAnimation(animation);
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
private void animateWindowOutCircular(@Nullable View anchor, @NonNull View contentView) {
Pair<Integer, Integer> coordinates = getClickOrigin(anchor, contentView);
Animator animator = ViewAnimationUtils.createCircularReveal(getContentView(),
coordinates.first,
coordinates.second,
Math.max(getContentView().getWidth(), getContentView().getHeight()),
0);
animator.setDuration(ANIMATION_DURATION);
animator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
}
@Override
public void onAnimationEnd(Animator animation) {
AttachmentTypeSelector.super.dismiss();
}
@Override
public void onAnimationCancel(Animator animation) {
}
@Override
public void onAnimationRepeat(Animator animation) {
}
});
animator.start();
}
private void animateWindowOutTranslate(@NonNull View contentView) {
Animation animation = new TranslateAnimation(0, 0, 0, contentView.getTop() + contentView.getHeight());
animation.setDuration(ANIMATION_DURATION);
animation.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
}
@Override
public void onAnimationEnd(Animation animation) {
AttachmentTypeSelector.super.dismiss();
}
@Override
public void onAnimationRepeat(Animation animation) {
}
});
getContentView().startAnimation(animation);
}
private Pair<Integer, Integer> getClickOrigin(@Nullable View anchor, @NonNull View contentView) {
if (anchor == null) return new Pair<>(0, 0);
final int[] anchorCoordinates = new int[2];
anchor.getLocationOnScreen(anchorCoordinates);
anchorCoordinates[0] += anchor.getWidth() / 2;
anchorCoordinates[1] += anchor.getHeight() / 2;
final int[] contentCoordinates = new int[2];
contentView.getLocationOnScreen(contentCoordinates);
int x = anchorCoordinates[0] - contentCoordinates[0];
int y = anchorCoordinates[1] - contentCoordinates[1];
return new Pair<>(x, y);
}
private class RecentPhotoSelectedListener implements RecentPhotoViewRail.OnItemClickedListener {
@Override
public void onItemClicked(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height, long size) {
animateWindowOutTranslate(getContentView());
if (listener != null) listener.onQuickAttachment(uri, mimeType, bucketId, dateTaken, width, height, size);
}
}
private class PropagatingClickListener implements View.OnClickListener {
private final int type;
private PropagatingClickListener(int type) {
this.type = type;
}
@Override
public void onClick(View v) {
animateWindowOutTranslate(getContentView());
if (listener != null) listener.onClick(type);
}
}
private class CloseClickListener implements View.OnClickListener {
@Override
public void onClick(View v) {
dismiss();
}
}
public interface AttachmentClickedListener {
void onClick(int type);
void onQuickAttachment(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height, long size);
}
}

View File

@@ -0,0 +1,375 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Color;
import android.graphics.PorterDuff;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.SeekBar;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.airbnb.lottie.LottieAnimationView;
import com.airbnb.lottie.LottieProperty;
import com.airbnb.lottie.SimpleColorFilter;
import com.airbnb.lottie.model.KeyPath;
import com.airbnb.lottie.value.LottieValueCallback;
import com.pnikosis.materialishprogress.ProgressWheel;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.audio.AudioSlidePlayer;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.events.PartProgressEvent;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.AudioSlide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import java.io.IOException;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
public final class AudioView extends FrameLayout implements AudioSlidePlayer.Listener {
private static final String TAG = AudioView.class.getSimpleName();
private static final int FORWARDS = 1;
private static final int REVERSE = -1;
@NonNull private final AnimatingToggle controlToggle;
@NonNull private final ViewGroup container;
@NonNull private final View progressAndPlay;
@NonNull private final LottieAnimationView playPauseButton;
@NonNull private final ImageView downloadButton;
@NonNull private final ProgressWheel circleProgress;
@NonNull private final SeekBar seekBar;
private final boolean smallView;
private final boolean autoRewind;
@Nullable private final TextView timestamp;
@Nullable private SlideClickListener downloadListener;
@Nullable private AudioSlidePlayer audioSlidePlayer;
private int backwardsCounter;
private int lottieDirection;
private boolean isPlaying;
public AudioView(Context context) {
this(context, null);
}
public AudioView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public AudioView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray typedArray = null;
try {
typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AudioView, 0, 0);
smallView = typedArray.getBoolean(R.styleable.AudioView_small, false);
autoRewind = typedArray.getBoolean(R.styleable.AudioView_autoRewind, false);
inflate(context, smallView ? R.layout.audio_view_small : R.layout.audio_view, this);
this.container = findViewById(R.id.audio_widget_container);
this.controlToggle = findViewById(R.id.control_toggle);
this.playPauseButton = findViewById(R.id.play);
this.progressAndPlay = findViewById(R.id.progress_and_play);
this.downloadButton = findViewById(R.id.download);
this.circleProgress = findViewById(R.id.circle_progress);
this.seekBar = findViewById(R.id.seek);
this.timestamp = findViewById(R.id.timestamp);
lottieDirection = REVERSE;
this.playPauseButton.setOnClickListener(new PlayPauseClickedListener());
this.seekBar.setOnSeekBarChangeListener(new SeekBarModifiedListener());
setTint(typedArray.getColor(R.styleable.AudioView_foregroundTintColor, Color.WHITE),
typedArray.getColor(R.styleable.AudioView_backgroundTintColor, Color.WHITE));
container.setBackgroundColor(typedArray.getColor(R.styleable.AudioView_widgetBackground, Color.TRANSPARENT));
} finally {
if (typedArray != null) {
typedArray.recycle();
}
}
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this);
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
EventBus.getDefault().unregister(this);
}
public void setAudio(final @NonNull AudioSlide audio,
final boolean showControls)
{
if (showControls && audio.isPendingDownload()) {
controlToggle.displayQuick(downloadButton);
seekBar.setEnabled(false);
downloadButton.setOnClickListener(new DownloadClickedListener(audio));
if (circleProgress.isSpinning()) circleProgress.stopSpinning();
} else if (showControls && audio.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_STARTED) {
controlToggle.displayQuick(progressAndPlay);
seekBar.setEnabled(false);
circleProgress.spin();
} else {
seekBar.setEnabled(true);
if (circleProgress.isSpinning()) circleProgress.stopSpinning();
showPlayButton();
lottieDirection = REVERSE;
playPauseButton.cancelAnimation();
playPauseButton.setFrame(0);
}
this.audioSlidePlayer = AudioSlidePlayer.createFor(getContext(), audio, this);
}
public void cleanup() {
if (this.audioSlidePlayer != null && isPlaying) {
this.audioSlidePlayer.stop();
}
}
public void setDownloadClickListener(@Nullable SlideClickListener listener) {
this.downloadListener = listener;
}
@Override
public void onStart() {
isPlaying = true;
togglePlayToPause();
}
@Override
public void onStop() {
isPlaying = false;
togglePauseToPlay();
if (autoRewind || seekBar.getProgress() + 5 >= seekBar.getMax()) {
backwardsCounter = 4;
rewind();
}
}
@Override
public void setFocusable(boolean focusable) {
super.setFocusable(focusable);
this.playPauseButton.setFocusable(focusable);
this.seekBar.setFocusable(focusable);
this.seekBar.setFocusableInTouchMode(focusable);
this.downloadButton.setFocusable(focusable);
}
@Override
public void setClickable(boolean clickable) {
super.setClickable(clickable);
this.playPauseButton.setClickable(clickable);
this.seekBar.setClickable(clickable);
this.seekBar.setOnTouchListener(clickable ? null : new TouchIgnoringListener());
this.downloadButton.setClickable(clickable);
}
@Override
public void setEnabled(boolean enabled) {
super.setEnabled(enabled);
this.playPauseButton.setEnabled(enabled);
this.seekBar.setEnabled(enabled);
this.downloadButton.setEnabled(enabled);
}
@Override
public void onProgress(double progress, long millis) {
int seekProgress = (int) Math.floor(progress * seekBar.getMax());
if (seekProgress > seekBar.getProgress() || backwardsCounter > 3) {
backwardsCounter = 0;
seekBar.setProgress(seekProgress);
updateProgress((float) progress, millis);
} else {
backwardsCounter++;
}
}
private void updateProgress(float progress, long millis) {
if (timestamp != null) {
timestamp.setText(String.format(Locale.getDefault(), "%02d:%02d",
TimeUnit.MILLISECONDS.toMinutes(millis),
TimeUnit.MILLISECONDS.toSeconds(millis)));
}
if (smallView) {
circleProgress.setInstantProgress(seekBar.getProgress() == 0 ? 1 : progress);
}
}
public void setTint(int foregroundTint, int backgroundTint) {
post(()-> this.playPauseButton.addValueCallback(new KeyPath("**"),
LottieProperty.COLOR_FILTER,
new LottieValueCallback<>(new SimpleColorFilter(foregroundTint))));
this.downloadButton.setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
this.circleProgress.setBarColor(foregroundTint);
if (this.timestamp != null) {
this.timestamp.setTextColor(foregroundTint);
}
this.seekBar.getProgressDrawable().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
this.seekBar.getThumb().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
}
public void getSeekBarGlobalVisibleRect(@NonNull Rect rect) {
seekBar.getGlobalVisibleRect(rect);
}
private double getProgress() {
if (this.seekBar.getProgress() <= 0 || this.seekBar.getMax() <= 0) {
return 0;
} else {
return (double)this.seekBar.getProgress() / (double)this.seekBar.getMax();
}
}
private void togglePlayToPause() {
startLottieAnimation(FORWARDS);
}
private void togglePauseToPlay() {
startLottieAnimation(REVERSE);
}
private void startLottieAnimation(int direction) {
showPlayButton();
if (lottieDirection == direction) {
return;
}
lottieDirection = direction;
playPauseButton.pauseAnimation();
playPauseButton.setSpeed(direction * 2);
playPauseButton.resumeAnimation();
}
private void showPlayButton() {
if (!smallView || seekBar.getProgress() == 0) {
circleProgress.setInstantProgress(1);
}
circleProgress.setVisibility(VISIBLE);
playPauseButton.setVisibility(VISIBLE);
controlToggle.displayQuick(progressAndPlay);
}
public void stopPlaybackAndReset() {
if (this.audioSlidePlayer != null && isPlaying) {
this.audioSlidePlayer.stop();
togglePauseToPlay();
}
rewind();
}
private class PlayPauseClickedListener implements View.OnClickListener {
@Override
public void onClick(View v) {
if (lottieDirection == REVERSE) {
try {
Log.d(TAG, "playbutton onClick");
if (audioSlidePlayer != null) {
togglePlayToPause();
audioSlidePlayer.play(getProgress());
}
} catch (IOException e) {
Log.w(TAG, e);
}
} else {
Log.d(TAG, "pausebutton onClick");
if (audioSlidePlayer != null) {
togglePauseToPlay();
audioSlidePlayer.stop();
if (autoRewind) {
rewind();
}
}
}
}
}
private void rewind() {
seekBar.setProgress(0);
updateProgress(0, 0);
}
private class DownloadClickedListener implements View.OnClickListener {
private final @NonNull AudioSlide slide;
private DownloadClickedListener(@NonNull AudioSlide slide) {
this.slide = slide;
}
@Override
public void onClick(View v) {
if (downloadListener != null) downloadListener.onClick(v, slide);
}
}
private class SeekBarModifiedListener implements SeekBar.OnSeekBarChangeListener {
private boolean wasPlaying;
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {}
@Override
public synchronized void onStartTrackingTouch(SeekBar seekBar) {
wasPlaying = isPlaying;
if (audioSlidePlayer != null && isPlaying) {
audioSlidePlayer.stop();
}
}
@Override
public synchronized void onStopTrackingTouch(SeekBar seekBar) {
try {
if (audioSlidePlayer != null && wasPlaying) {
audioSlidePlayer.play(getProgress());
}
} catch (IOException e) {
Log.w(TAG, e);
}
}
}
private static class TouchIgnoringListener implements OnTouchListener {
@Override
public boolean onTouch(View v, MotionEvent event) {
return true;
}
}
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
public void onEventAsync(final PartProgressEvent event) {
if (audioSlidePlayer != null && event.attachment.equals(audioSlidePlayer.getAudioSlide().asAttachment())) {
circleProgress.setInstantProgress(((float) event.progress) / event.total);
}
}
}

View File

@@ -0,0 +1,173 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.drawable.Drawable;
import android.provider.ContactsContract;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatImageView;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contacts.avatars.ContactColors;
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientExporter;
import org.thoughtcrime.securesms.util.ThemeUtil;
import java.util.Objects;
public final class AvatarImageView extends AppCompatImageView {
private static final int SIZE_LARGE = 1;
private static final int SIZE_SMALL = 2;
@SuppressWarnings("unused")
private static final String TAG = AvatarImageView.class.getSimpleName();
private static final Paint LIGHT_THEME_OUTLINE_PAINT = new Paint();
private static final Paint DARK_THEME_OUTLINE_PAINT = new Paint();
static {
LIGHT_THEME_OUTLINE_PAINT.setColor(Color.argb((int) (255 * 0.2), 0, 0, 0));
LIGHT_THEME_OUTLINE_PAINT.setStyle(Paint.Style.STROKE);
LIGHT_THEME_OUTLINE_PAINT.setStrokeWidth(1f);
LIGHT_THEME_OUTLINE_PAINT.setAntiAlias(true);
DARK_THEME_OUTLINE_PAINT.setColor(Color.argb((int) (255 * 0.2), 255, 255, 255));
DARK_THEME_OUTLINE_PAINT.setStyle(Paint.Style.STROKE);
DARK_THEME_OUTLINE_PAINT.setStrokeWidth(1f);
DARK_THEME_OUTLINE_PAINT.setAntiAlias(true);
}
private int size;
private boolean inverted;
private Paint outlinePaint;
private OnClickListener listener;
private @Nullable RecipientContactPhoto recipientContactPhoto;
private @NonNull Drawable unknownRecipientDrawable;
public AvatarImageView(Context context) {
super(context);
initialize(context, null);
}
public AvatarImageView(Context context, AttributeSet attrs) {
super(context, attrs);
initialize(context, attrs);
}
private void initialize(@NonNull Context context, @Nullable AttributeSet attrs) {
setScaleType(ScaleType.CENTER_CROP);
if (attrs != null) {
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AvatarImageView, 0, 0);
inverted = typedArray.getBoolean(R.styleable.AvatarImageView_inverted, false);
size = typedArray.getInt(R.styleable.AvatarImageView_fallbackImageSize, SIZE_LARGE);
typedArray.recycle();
}
outlinePaint = ThemeUtil.isDarkTheme(getContext()) ? DARK_THEME_OUTLINE_PAINT : LIGHT_THEME_OUTLINE_PAINT;
unknownRecipientDrawable = new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20).asDrawable(getContext(), ContactColors.UNKNOWN_COLOR.toConversationColor(getContext()), inverted);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
float width = getWidth() - getPaddingRight() - getPaddingLeft();
float height = getHeight() - getPaddingBottom() - getPaddingTop();
float cx = width / 2f;
float cy = height / 2f;
float radius = Math.min(cx, cy) - (outlinePaint.getStrokeWidth() / 2f);
canvas.translate(getPaddingLeft(), getPaddingTop());
canvas.drawCircle(cx, cy, radius, outlinePaint);
}
@Override
public void setOnClickListener(OnClickListener listener) {
this.listener = listener;
super.setOnClickListener(listener);
}
public void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient, boolean quickContactEnabled) {
if (recipient != null) {
RecipientContactPhoto photo = new RecipientContactPhoto(recipient);
if (!photo.equals(recipientContactPhoto)) {
requestManager.clear(this);
recipientContactPhoto = photo;
Drawable fallbackContactPhotoDrawable = size == SIZE_SMALL
? photo.recipient.getSmallFallbackContactPhotoDrawable(getContext(), inverted)
: photo.recipient.getFallbackContactPhotoDrawable(getContext(), inverted);
if (photo.contactPhoto != null) {
requestManager.load(photo.contactPhoto)
.fallback(fallbackContactPhotoDrawable)
.error(fallbackContactPhotoDrawable)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.circleCrop()
.into(this);
} else {
setImageDrawable(fallbackContactPhotoDrawable);
}
}
setAvatarClickHandler(recipient, quickContactEnabled);
} else {
recipientContactPhoto = null;
requestManager.clear(this);
setImageDrawable(unknownRecipientDrawable);
super.setOnClickListener(listener);
}
}
private void setAvatarClickHandler(final Recipient recipient, boolean quickContactEnabled) {
super.setOnClickListener(v -> {
if (!recipient.isGroup() && quickContactEnabled) {
if (recipient.getContactUri() != null) {
ContactsContract.QuickContact.showQuickContact(getContext(), AvatarImageView.this, recipient.getContactUri(), ContactsContract.QuickContact.MODE_LARGE, null);
} else {
getContext().startActivity(RecipientExporter.export(recipient).asAddContactIntent());
}
} else {
listener.onClick(v);
}
});
}
private static class RecipientContactPhoto {
private final @NonNull Recipient recipient;
private final @Nullable ContactPhoto contactPhoto;
private final boolean ready;
RecipientContactPhoto(@NonNull Recipient recipient) {
this.recipient = recipient;
this.ready = !recipient.isResolving();
this.contactPhoto = recipient.getContactPhoto();
}
public boolean equals(@Nullable RecipientContactPhoto other) {
if (other == null) return false;
return other.recipient.equals(recipient) &&
other.recipient.getColor().equals(recipient.getColor()) &&
other.ready == ready &&
Objects.equals(other.contactPhoto, contactPhoto);
}
}
}

View File

@@ -0,0 +1,76 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.LayerDrawable;
import org.thoughtcrime.securesms.R;
public class BubbleDrawableBuilder {
private int color;
private int shadowColor;
private boolean hasShadow = true;
private boolean[] corners = new boolean[]{true,true,true,true};
protected BubbleDrawableBuilder() { }
public BubbleDrawableBuilder setColor(int color) {
this.color = color;
return this;
}
public BubbleDrawableBuilder setShadowColor(int shadowColor) {
this.shadowColor = shadowColor;
return this;
}
public BubbleDrawableBuilder setHasShadow(boolean hasShadow) {
this.hasShadow = hasShadow;
return this;
}
public BubbleDrawableBuilder setCorners(boolean[] corners) {
this.corners = corners;
return this;
}
public Drawable create(Context context) {
final GradientDrawable bubble = new GradientDrawable();
final int radius = context.getResources().getDimensionPixelSize(R.dimen.message_bubble_corner_radius);
final float[] radii = cornerBooleansToRadii(corners, radius);
bubble.setColor(color);
bubble.setCornerRadii(radii);
if (!hasShadow) {
return bubble;
} else {
final GradientDrawable shadow = new GradientDrawable();
final int distance = context.getResources().getDimensionPixelSize(R.dimen.message_bubble_shadow_distance);
shadow.setColor(shadowColor);
shadow.setCornerRadii(radii);
final LayerDrawable layers = new LayerDrawable(new Drawable[]{shadow, bubble});
layers.setLayerInset(1, 0, 0, 0, distance);
return layers;
}
}
private float[] cornerBooleansToRadii(boolean[] corners, int radius) {
if (corners == null || corners.length != 4) {
throw new AssertionError("there are four corners in a rectangle, silly");
}
float[] radii = new float[8];
int i = 0;
for (boolean corner : corners) {
radii[i] = radii[i+1] = corner ? radius : 0;
i += 2;
}
return radii;
}
}

View File

@@ -0,0 +1,39 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Color;
import android.graphics.PorterDuff;
import android.graphics.drawable.Drawable;
import androidx.appcompat.widget.AppCompatImageView;
import android.util.AttributeSet;
import org.thoughtcrime.securesms.R;
public class CircleColorImageView extends AppCompatImageView {
public CircleColorImageView(Context context) {
this(context, null);
}
public CircleColorImageView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public CircleColorImageView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
int circleColor = Color.WHITE;
if (attrs != null) {
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.CircleColorImageView, 0, 0);
circleColor = typedArray.getColor(R.styleable.CircleColorImageView_circleColor, Color.WHITE);
typedArray.recycle();
}
Drawable circle = context.getResources().getDrawable(R.drawable.circle_tintable);
circle.setColorFilter(circleColor, PorterDuff.Mode.SRC_IN);
setBackgroundDrawable(circle);
}
}

View File

@@ -0,0 +1,210 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.Configuration;
import android.os.Build;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.view.inputmethod.EditorInfoCompat;
import androidx.core.view.inputmethod.InputConnectionCompat;
import androidx.core.view.inputmethod.InputContentInfoCompat;
import androidx.core.os.BuildCompat;
import android.text.InputType;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.TextUtils.TruncateAt;
import android.text.style.RelativeSizeSpan;
import android.util.AttributeSet;
import org.thoughtcrime.securesms.logging.Log;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.components.emoji.EmojiEditText;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
public class ComposeText extends EmojiEditText {
private CharSequence hint;
private SpannableString subHint;
@Nullable private InputPanel.MediaListener mediaListener;
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
public ComposeText(Context context) {
super(context);
initialize();
}
public ComposeText(Context context, AttributeSet attrs) {
super(context, attrs);
initialize();
}
public ComposeText(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initialize();
}
public String getTextTrimmed(){
return getText().toString().trim();
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
if (!TextUtils.isEmpty(hint)) {
if (!TextUtils.isEmpty(subHint)) {
setHint(new SpannableStringBuilder().append(ellipsizeToWidth(hint))
.append("\n")
.append(ellipsizeToWidth(subHint)));
} else {
setHint(ellipsizeToWidth(hint));
}
}
}
@Override
protected void onSelectionChanged(int selStart, int selEnd) {
super.onSelectionChanged(selStart, selEnd);
if (cursorPositionChangedListener != null) {
cursorPositionChangedListener.onCursorPositionChanged(selStart, selEnd);
}
}
private CharSequence ellipsizeToWidth(CharSequence text) {
return TextUtils.ellipsize(text,
getPaint(),
getWidth() - getPaddingLeft() - getPaddingRight(),
TruncateAt.END);
}
public void setHint(@NonNull String hint, @Nullable CharSequence subHint) {
this.hint = hint;
if (subHint != null) {
this.subHint = new SpannableString(subHint);
this.subHint.setSpan(new RelativeSizeSpan(0.5f), 0, subHint.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
} else {
this.subHint = null;
}
if (this.subHint != null) {
super.setHint(new SpannableStringBuilder().append(ellipsizeToWidth(this.hint))
.append("\n")
.append(ellipsizeToWidth(this.subHint)));
} else {
super.setHint(ellipsizeToWidth(this.hint));
}
}
public void appendInvite(String invite) {
if (!TextUtils.isEmpty(getText()) && !getText().toString().equals(" ")) {
append(" ");
}
append(invite);
setSelection(getText().length());
}
public void setCursorPositionChangedListener(@Nullable CursorPositionChangedListener listener) {
this.cursorPositionChangedListener = listener;
}
private boolean isLandscape() {
return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
}
public void setTransport(TransportOption transport) {
final boolean useSystemEmoji = TextSecurePreferences.isSystemEmojiPreferred(getContext());
final boolean isIncognito = TextSecurePreferences.isIncognitoKeyboardEnabled(getContext());
int imeOptions = (getImeOptions() & ~EditorInfo.IME_MASK_ACTION) | EditorInfo.IME_ACTION_SEND;
int inputType = getInputType();
if (isLandscape()) setImeActionLabel(transport.getComposeHint(), EditorInfo.IME_ACTION_SEND);
else setImeActionLabel(null, 0);
if (useSystemEmoji) {
inputType = (inputType & ~InputType.TYPE_MASK_VARIATION) | InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE;
}
setInputType(inputType);
setImeOptions(imeOptions);
setHint(transport.getComposeHint(),
transport.getSimName().isPresent()
? getContext().getString(R.string.conversation_activity__from_sim_name, transport.getSimName().get())
: null);
}
@Override
public InputConnection onCreateInputConnection(EditorInfo editorInfo) {
InputConnection inputConnection = super.onCreateInputConnection(editorInfo);
if(TextSecurePreferences.isEnterSendsEnabled(getContext())) {
editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION;
}
if (Build.VERSION.SDK_INT < 21) return inputConnection;
if (mediaListener == null) return inputConnection;
if (inputConnection == null) return null;
EditorInfoCompat.setContentMimeTypes(editorInfo, new String[] {"image/jpeg", "image/png", "image/gif"});
return InputConnectionCompat.createWrapper(inputConnection, editorInfo, new CommitContentListener(mediaListener));
}
public void setMediaListener(@Nullable InputPanel.MediaListener mediaListener) {
this.mediaListener = mediaListener;
}
private void initialize() {
if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) {
setImeOptions(getImeOptions() | 16777216);
}
}
@RequiresApi(api = Build.VERSION_CODES.HONEYCOMB_MR2)
private static class CommitContentListener implements InputConnectionCompat.OnCommitContentListener {
private static final String TAG = CommitContentListener.class.getSimpleName();
private final InputPanel.MediaListener mediaListener;
private CommitContentListener(@NonNull InputPanel.MediaListener mediaListener) {
this.mediaListener = mediaListener;
}
@Override
public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) {
if (BuildCompat.isAtLeastNMR1() && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
try {
inputContentInfo.requestPermission();
} catch (Exception e) {
Log.w(TAG, e);
return false;
}
}
if (inputContentInfo.getDescription().getMimeTypeCount() > 0) {
mediaListener.onMediaSelected(inputContentInfo.getContentUri(),
inputContentInfo.getDescription().getMimeType(0));
return true;
}
return false;
}
}
public interface CursorPositionChangedListener {
void onCursorPositionChanged(int start, int end);
}
}

View File

@@ -0,0 +1,180 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Rect;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.Toolbar;
import androidx.core.widget.TextViewCompat;
import android.text.Editable;
import android.text.InputType;
import android.text.TextWatcher;
import android.util.AttributeSet;
import android.view.TouchDelegate;
import android.view.View;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.LinearLayout;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
public class ContactFilterToolbar extends Toolbar {
private OnFilterChangedListener listener;
private EditText searchText;
private AnimatingToggle toggle;
private ImageView keyboardToggle;
private ImageView dialpadToggle;
private ImageView clearToggle;
private LinearLayout toggleContainer;
public ContactFilterToolbar(Context context) {
this(context, null);
}
public ContactFilterToolbar(Context context, AttributeSet attrs) {
this(context, attrs, R.attr.toolbarStyle);
}
public ContactFilterToolbar(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
inflate(context, R.layout.contact_filter_toolbar, this);
this.searchText = ViewUtil.findById(this, R.id.search_view);
this.toggle = ViewUtil.findById(this, R.id.button_toggle);
this.keyboardToggle = ViewUtil.findById(this, R.id.search_keyboard);
this.dialpadToggle = ViewUtil.findById(this, R.id.search_dialpad);
this.clearToggle = ViewUtil.findById(this, R.id.search_clear);
this.toggleContainer = ViewUtil.findById(this, R.id.toggle_container);
this.keyboardToggle.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
searchText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PERSON_NAME);
ServiceUtil.getInputMethodManager(getContext()).showSoftInput(searchText, 0);
displayTogglingView(dialpadToggle);
}
});
this.dialpadToggle.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
searchText.setInputType(InputType.TYPE_CLASS_PHONE);
ServiceUtil.getInputMethodManager(getContext()).showSoftInput(searchText, 0);
displayTogglingView(keyboardToggle);
}
});
this.clearToggle.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
searchText.setText("");
if (SearchUtil.isTextInput(searchText)) displayTogglingView(dialpadToggle);
else displayTogglingView(keyboardToggle);
}
});
this.searchText.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
@Override
public void afterTextChanged(Editable s) {
if (!SearchUtil.isEmpty(searchText)) displayTogglingView(clearToggle);
else if (SearchUtil.isTextInput(searchText)) displayTogglingView(dialpadToggle);
else if (SearchUtil.isPhoneInput(searchText)) displayTogglingView(keyboardToggle);
notifyListener();
}
});
setLogo(null);
setContentInsetStartWithNavigation(0);
expandTapArea(toggleContainer, dialpadToggle);
styleSearchText(searchText, context, attrs, defStyleAttr);
searchText.requestFocus();
}
private void styleSearchText(@NonNull EditText searchText,
@NonNull Context context,
@NonNull AttributeSet attrs,
int defStyle)
{
final TypedArray attributes = context.obtainStyledAttributes(attrs,
R.styleable.ContactFilterToolbar,
defStyle,
0);
int styleResource = attributes.getResourceId(R.styleable.ContactFilterToolbar_searchTextStyle, -1);
if (styleResource != -1) {
TextViewCompat.setTextAppearance(searchText, styleResource);
}
attributes.recycle();
}
public void clear() {
searchText.setText("");
notifyListener();
}
public void setOnFilterChangedListener(OnFilterChangedListener listener) {
this.listener = listener;
}
private void notifyListener() {
if (listener != null) listener.onFilterChanged(searchText.getText().toString());
}
private void displayTogglingView(View view) {
toggle.display(view);
expandTapArea(toggleContainer, view);
}
private void expandTapArea(final View container, final View child) {
final int padding = getResources().getDimensionPixelSize(R.dimen.contact_selection_actions_tap_area);
container.post(new Runnable() {
@Override
public void run() {
Rect rect = new Rect();
child.getHitRect(rect);
rect.top -= padding;
rect.left -= padding;
rect.right += padding;
rect.bottom += padding;
container.setTouchDelegate(new TouchDelegate(rect, child));
}
});
}
private static class SearchUtil {
static boolean isTextInput(EditText editText) {
return (editText.getInputType() & InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_TEXT;
}
static boolean isPhoneInput(EditText editText) {
return (editText.getInputType() & InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_PHONE;
}
public static boolean isEmpty(EditText editText) {
return editText.getText().length() <= 0;
}
}
public interface OnFilterChangedListener {
void onFilterChanged(String filter);
}
}

View File

@@ -0,0 +1,41 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import com.google.android.material.tabs.TabLayout;
import android.util.AttributeSet;
import android.view.View;
import java.util.List;
/**
* An implementation of {@link TabLayout} that disables taps when the view is disabled.
*/
public class ControllableTabLayout extends TabLayout {
private List<View> touchables;
public ControllableTabLayout(Context context) {
super(context);
}
public ControllableTabLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
public ControllableTabLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public void setEnabled(boolean enabled) {
if (isEnabled() && !enabled) {
touchables = getTouchables();
}
for (View touchable : touchables) {
touchable.setClickable(enabled);
}
super.setEnabled(enabled);
}
}

View File

@@ -0,0 +1,34 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.viewpager.widget.ViewPager;
import android.util.AttributeSet;
import android.view.MotionEvent;
import org.thoughtcrime.securesms.components.viewpager.HackyViewPager;
/**
* An implementation of {@link ViewPager} that disables swiping when the view is disabled.
*/
public class ControllableViewPager extends HackyViewPager {
public ControllableViewPager(@NonNull Context context) {
super(context);
}
public ControllableViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
return isEnabled() && super.onTouchEvent(ev);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return isEnabled() && super.onInterceptTouchEvent(ev);
}
}

View File

@@ -0,0 +1,177 @@
package org.thoughtcrime.securesms.components;
import android.Manifest;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.PorterDuff;
import android.os.AsyncTask;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.util.AttributeSet;
import android.view.View;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.dualsim.SubscriptionInfoCompat;
import org.thoughtcrime.securesms.util.dualsim.SubscriptionManagerCompat;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Locale;
public class ConversationItemFooter extends LinearLayout {
private TextView dateView;
private TextView simView;
private ExpirationTimerView timerView;
private ImageView insecureIndicatorView;
private DeliveryStatusView deliveryStatusView;
public ConversationItemFooter(Context context) {
super(context);
init(null);
}
public ConversationItemFooter(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(attrs);
}
public ConversationItemFooter(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(attrs);
}
private void init(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.conversation_item_footer, this);
dateView = findViewById(R.id.footer_date);
simView = findViewById(R.id.footer_sim_info);
timerView = findViewById(R.id.footer_expiration_timer);
insecureIndicatorView = findViewById(R.id.footer_insecure_indicator);
deliveryStatusView = findViewById(R.id.footer_delivery_status);
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemFooter, 0, 0);
setTextColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_text_color, getResources().getColor(R.color.core_white)));
setIconColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_icon_color, getResources().getColor(R.color.core_white)));
typedArray.recycle();
}
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
timerView.stopAnimation();
}
public void setMessageRecord(@NonNull MessageRecord messageRecord, @NonNull Locale locale) {
presentDate(messageRecord, locale);
presentSimInfo(messageRecord);
presentTimer(messageRecord);
presentInsecureIndicator(messageRecord);
presentDeliveryStatus(messageRecord);
}
public void setTextColor(int color) {
dateView.setTextColor(color);
simView.setTextColor(color);
}
public void setIconColor(int color) {
timerView.setColorFilter(color, PorterDuff.Mode.SRC_IN);
insecureIndicatorView.setColorFilter(color);
deliveryStatusView.setTint(color);
}
private void presentDate(@NonNull MessageRecord messageRecord, @NonNull Locale locale) {
dateView.forceLayout();
if (messageRecord.isFailed()) {
dateView.setText(R.string.ConversationItem_error_not_delivered);
} else if (messageRecord.isPendingInsecureSmsFallback()) {
dateView.setText(R.string.ConversationItem_click_to_approve_unencrypted);
} else {
dateView.setText(DateUtils.getExtendedRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp()));
}
}
private void presentSimInfo(@NonNull MessageRecord messageRecord) {
SubscriptionManagerCompat subscriptionManager = new SubscriptionManagerCompat(getContext());
if (messageRecord.isPush() || messageRecord.getSubscriptionId() == -1 || !Permissions.hasAll(getContext(), Manifest.permission.READ_PHONE_STATE) || !subscriptionManager.isMultiSim()) {
simView.setVisibility(View.GONE);
} else {
Optional<SubscriptionInfoCompat> subscriptionInfo = subscriptionManager.getActiveSubscriptionInfo(messageRecord.getSubscriptionId());
if (subscriptionInfo.isPresent() && messageRecord.isOutgoing()) {
simView.setText(getContext().getString(R.string.ConversationItem_from_s, subscriptionInfo.get().getDisplayName()));
simView.setVisibility(View.VISIBLE);
} else if (subscriptionInfo.isPresent()) {
simView.setText(getContext().getString(R.string.ConversationItem_to_s, subscriptionInfo.get().getDisplayName()));
simView.setVisibility(View.VISIBLE);
} else {
simView.setVisibility(View.GONE);
}
}
}
@SuppressLint("StaticFieldLeak")
private void presentTimer(@NonNull final MessageRecord messageRecord) {
if (messageRecord.getExpiresIn() > 0 && !messageRecord.isPending()) {
this.timerView.setVisibility(View.VISIBLE);
this.timerView.setPercentComplete(0);
if (messageRecord.getExpireStarted() > 0) {
this.timerView.setExpirationTime(messageRecord.getExpireStarted(),
messageRecord.getExpiresIn());
this.timerView.startAnimation();
if (messageRecord.getExpireStarted() + messageRecord.getExpiresIn() <= System.currentTimeMillis()) {
ApplicationContext.getInstance(getContext()).getExpiringMessageManager().checkSchedule();
}
} else if (!messageRecord.isOutgoing() && !messageRecord.isMediaPending()) {
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
ExpiringMessageManager expirationManager = ApplicationContext.getInstance(getContext()).getExpiringMessageManager();
long id = messageRecord.getId();
boolean mms = messageRecord.isMms();
if (mms) DatabaseFactory.getMmsDatabase(getContext()).markExpireStarted(id);
else DatabaseFactory.getSmsDatabase(getContext()).markExpireStarted(id);
expirationManager.scheduleDeletion(id, mms, messageRecord.getExpiresIn());
return null;
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
} else {
this.timerView.setVisibility(View.GONE);
}
}
private void presentInsecureIndicator(@NonNull MessageRecord messageRecord) {
insecureIndicatorView.setVisibility(messageRecord.isSecure() ? View.GONE : View.VISIBLE);
}
private void presentDeliveryStatus(@NonNull MessageRecord messageRecord) {
if (!messageRecord.isFailed() && !messageRecord.isPendingInsecureSmsFallback()) {
if (!messageRecord.isOutgoing()) deliveryStatusView.setNone();
else if (messageRecord.isPending()) deliveryStatusView.setPending();
else if (messageRecord.isRemoteRead()) deliveryStatusView.setRead();
else if (messageRecord.isDelivered()) deliveryStatusView.setDelivered();
else deliveryStatusView.setSent();
} else {
deliveryStatusView.setNone();
}
}
}

View File

@@ -0,0 +1,156 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import android.util.AttributeSet;
import android.widget.FrameLayout;
import android.widget.ImageView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import org.thoughtcrime.securesms.util.ThemeUtil;
import java.util.List;
public class ConversationItemThumbnail extends FrameLayout {
private ThumbnailView thumbnail;
private AlbumThumbnailView album;
private ImageView shade;
private ConversationItemFooter footer;
private CornerMask cornerMask;
private Outliner outliner;
private boolean borderless;
public ConversationItemThumbnail(Context context) {
super(context);
init(null);
}
public ConversationItemThumbnail(Context context, AttributeSet attrs) {
super(context, attrs);
init(attrs);
}
public ConversationItemThumbnail(final Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init(attrs);
}
private void init(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.conversation_item_thumbnail, this);
this.thumbnail = findViewById(R.id.conversation_thumbnail_image);
this.album = findViewById(R.id.conversation_thumbnail_album);
this.shade = findViewById(R.id.conversation_thumbnail_shade);
this.footer = findViewById(R.id.conversation_thumbnail_footer);
this.cornerMask = new CornerMask(this);
this.outliner = new Outliner();
outliner.setColor(ThemeUtil.getThemedColor(getContext(), R.attr.conversation_item_image_outline_color));
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemThumbnail, 0, 0);
thumbnail.setBounds(typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_minWidth, 0),
typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_maxWidth, 0),
typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_minHeight, 0),
typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_maxHeight, 0));
typedArray.recycle();
}
}
@SuppressWarnings("SuspiciousNameCombination")
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
if (!borderless) {
cornerMask.mask(canvas);
if (album.getVisibility() != VISIBLE) {
outliner.draw(canvas);
}
}
}
@Override
public void setFocusable(boolean focusable) {
thumbnail.setFocusable(focusable);
album.setFocusable(focusable);
}
@Override
public void setClickable(boolean clickable) {
thumbnail.setClickable(clickable);
album.setClickable(clickable);
}
@Override
public void setOnLongClickListener(@Nullable OnLongClickListener l) {
thumbnail.setOnLongClickListener(l);
album.setOnLongClickListener(l);
}
public void showShade(boolean show) {
shade.setVisibility(show ? VISIBLE : GONE);
forceLayout();
}
public void setCorners(int topLeft, int topRight, int bottomRight, int bottomLeft) {
cornerMask.setRadii(topLeft, topRight, bottomRight, bottomLeft);
outliner.setRadii(topLeft, topRight, bottomRight, bottomLeft);
}
public void setBorderless(boolean borderless) {
this.borderless = borderless;
}
public ConversationItemFooter getFooter() {
return footer;
}
@UiThread
public void setImageResource(@NonNull GlideRequests glideRequests, @NonNull List<Slide> slides,
boolean showControls, boolean isPreview)
{
if (slides.size() == 1) {
thumbnail.setVisibility(VISIBLE);
album.setVisibility(GONE);
Attachment attachment = slides.get(0).asAttachment();
thumbnail.setImageResource(glideRequests, slides.get(0), showControls, isPreview, attachment.getWidth(), attachment.getHeight());
setTouchDelegate(thumbnail.getTouchDelegate());
} else {
thumbnail.setVisibility(GONE);
album.setVisibility(VISIBLE);
album.setSlides(glideRequests, slides, showControls);
setTouchDelegate(album.getTouchDelegate());
}
}
public void setConversationColor(@ColorInt int color) {
if (album.getVisibility() == VISIBLE) {
album.setCellBackgroundColor(color);
}
}
public void setThumbnailClickListener(SlideClickListener listener) {
thumbnail.setThumbnailClickListener(listener);
album.setThumbnailClickListener(listener);
}
public void setDownloadClickListener(SlidesClickedListener listener) {
thumbnail.setDownloadClickListener(listener);
album.setDownloadClickListener(listener);
}
}

View File

@@ -0,0 +1,88 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import android.util.AttributeSet;
import android.view.View;
import android.widget.TextView;
import org.thoughtcrime.securesms.R;
/**
* Bottom navigation bar shown in the {@link org.thoughtcrime.securesms.conversation.ConversationActivity}
* when the user is searching within a conversation. Shows details about the results and allows the
* user to move between them.
*/
public class ConversationSearchBottomBar extends ConstraintLayout {
private View searchDown;
private View searchUp;
private TextView searchPositionText;
private View progressWheel;
private EventListener eventListener;
public ConversationSearchBottomBar(Context context) {
super(context);
}
public ConversationSearchBottomBar(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
this.searchUp = findViewById(R.id.conversation_search_up);
this.searchDown = findViewById(R.id.conversation_search_down);
this.searchPositionText = findViewById(R.id.conversation_search_position);
this.progressWheel = findViewById(R.id.conversation_search_progress_wheel);
}
public void setData(int position, int count) {
progressWheel.setVisibility(GONE);
searchUp.setOnClickListener(v -> {
if (eventListener != null) {
eventListener.onSearchMoveUpPressed();
}
});
searchDown.setOnClickListener(v -> {
if (eventListener != null) {
eventListener.onSearchMoveDownPressed();
}
});
if (count > 0) {
searchPositionText.setText(getResources().getString(R.string.ConversationActivity_search_position, position + 1, count));
} else {
searchPositionText.setText(R.string.ConversationActivity_no_results);
}
setViewEnabled(searchUp, position < (count - 1));
setViewEnabled(searchDown, position > 0);
}
public void showLoading() {
progressWheel.setVisibility(VISIBLE);
}
private void setViewEnabled(@NonNull View view, boolean enabled) {
view.setEnabled(enabled);
view.setAlpha(enabled ? 1f : 0.25f);
}
public void setEventListener(@Nullable EventListener eventListener) {
this.eventListener = eventListener;
}
public interface EventListener {
void onSearchMoveUpPressed();
void onSearchMoveDownPressed();
}
}

View File

@@ -0,0 +1,54 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.graphics.PorterDuff;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.util.AttributeSet;
import android.view.View;
import android.widget.LinearLayout;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.List;
public class ConversationTypingView extends LinearLayout {
private AvatarImageView avatar;
private View bubble;
private TypingIndicatorView indicator;
public ConversationTypingView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
avatar = findViewById(R.id.typing_avatar);
bubble = findViewById(R.id.typing_bubble);
indicator = findViewById(R.id.typing_indicator);
}
public void setTypists(@NonNull GlideRequests glideRequests, @NonNull List<Recipient> typists, boolean isGroupThread) {
if (typists.isEmpty()) {
indicator.stopAnimation();
return;
}
Recipient typist = typists.get(0);
bubble.getBackground().setColorFilter(typist.getColor().toConversationColor(getContext()), PorterDuff.Mode.MULTIPLY);
if (isGroupThread) {
avatar.setAvatar(glideRequests, typist, false);
avatar.setVisibility(VISIBLE);
} else {
avatar.setVisibility(GONE);
}
indicator.startAnimation();
}
}

View File

@@ -0,0 +1,75 @@
package org.thoughtcrime.securesms.components;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.RectF;
import androidx.annotation.NonNull;
import android.view.View;
public class CornerMask {
private final float[] radii = new float[8];
private final Paint clearPaint = new Paint();
private final Path outline = new Path();
private final Path corners = new Path();
private final RectF bounds = new RectF();
public CornerMask(@NonNull View view) {
view.setLayerType(View.LAYER_TYPE_HARDWARE, null);
clearPaint.setColor(Color.BLACK);
clearPaint.setStyle(Paint.Style.FILL);
clearPaint.setAntiAlias(true);
clearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
}
public void mask(Canvas canvas) {
bounds.left = 0;
bounds.top = 0;
bounds.right = canvas.getWidth();
bounds.bottom = canvas.getHeight();
corners.reset();
corners.addRoundRect(bounds, radii, Path.Direction.CW);
// Note: There's a bug in the P beta where most PorterDuff modes aren't working. But CLEAR does.
// So we find and inverse path and use Mode.CLEAR.
// See issue https://issuetracker.google.com/issues/111394085.
outline.reset();
outline.addRect(bounds, Path.Direction.CW);
outline.op(corners, Path.Op.DIFFERENCE);
canvas.drawPath(outline, clearPaint);
}
public void setRadius(int radius) {
setRadii(radius, radius, radius, radius);
}
public void setRadii(int topLeft, int topRight, int bottomRight, int bottomLeft) {
radii[0] = radii[1] = topLeft;
radii[2] = radii[3] = topRight;
radii[4] = radii[5] = bottomRight;
radii[6] = radii[7] = bottomLeft;
}
public void setTopLeftRadius(int radius) {
radii[0] = radii[1] = radius;
}
public void setTopRightRadius(int radius) {
radii[2] = radii[3] = radius;
}
public void setBottomRightRadius(int radius) {
radii[4] = radii[5] = radius;
}
public void setBottomLeftRadius(int radius) {
radii[6] = radii[7] = radius;
}
}

View File

@@ -0,0 +1,259 @@
package org.thoughtcrime.securesms.components;
import android.app.Dialog;
import android.content.Context;
import android.content.res.TypedArray;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.preference.DialogPreference;
import androidx.preference.PreferenceDialogFragmentCompat;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.util.AttributeSet;
import org.thoughtcrime.securesms.logging.Log;
import android.view.View;
import android.widget.AdapterView;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Spinner;
import android.widget.TextView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.CustomDefaultPreference.CustomDefaultPreferenceDialogFragmentCompat.CustomPreferenceValidator;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.net.URI;
import java.net.URISyntaxException;
public class CustomDefaultPreference extends DialogPreference {
private static final String TAG = CustomDefaultPreference.class.getSimpleName();
private final int inputType;
private final String customPreference;
private final String customToggle;
private CustomPreferenceValidator validator;
private String defaultValue;
public CustomDefaultPreference(Context context, AttributeSet attrs) {
super(context, attrs);
int[] attributeNames = new int[]{android.R.attr.inputType, R.attr.custom_pref_toggle};
TypedArray attributes = context.obtainStyledAttributes(attrs, attributeNames);
this.inputType = attributes.getInt(0, 0);
this.customPreference = getKey();
this.customToggle = attributes.getString(1);
this.validator = new CustomDefaultPreferenceDialogFragmentCompat.NullValidator();
attributes.recycle();
setPersistent(false);
setDialogLayoutResource(R.layout.custom_default_preference_dialog);
}
public CustomDefaultPreference setValidator(CustomPreferenceValidator validator) {
this.validator = validator;
return this;
}
public CustomDefaultPreference setDefaultValue(String defaultValue) {
this.defaultValue = defaultValue;
this.setSummary(getSummary());
return this;
}
@Override
public String getSummary() {
if (isCustom()) {
return getContext().getString(R.string.CustomDefaultPreference_using_custom,
getPrettyPrintValue(getCustomValue()));
} else {
return getContext().getString(R.string.CustomDefaultPreference_using_default,
getPrettyPrintValue(getDefaultValue()));
}
}
private String getPrettyPrintValue(String value) {
if (TextUtils.isEmpty(value)) return getContext().getString(R.string.CustomDefaultPreference_none);
else return value;
}
private boolean isCustom() {
return TextSecurePreferences.getBooleanPreference(getContext(), customToggle, false);
}
private void setCustom(boolean custom) {
TextSecurePreferences.setBooleanPreference(getContext(), customToggle, custom);
}
private String getCustomValue() {
return TextSecurePreferences.getStringPreference(getContext(), customPreference, "");
}
private void setCustomValue(String value) {
TextSecurePreferences.setStringPreference(getContext(), customPreference, value);
}
private String getDefaultValue() {
return defaultValue;
}
public static class CustomDefaultPreferenceDialogFragmentCompat extends PreferenceDialogFragmentCompat {
private static final String INPUT_TYPE = "input_type";
private Spinner spinner;
private EditText customText;
private TextView defaultLabel;
public static CustomDefaultPreferenceDialogFragmentCompat newInstance(String key) {
CustomDefaultPreferenceDialogFragmentCompat fragment = new CustomDefaultPreferenceDialogFragmentCompat();
Bundle b = new Bundle(1);
b.putString(PreferenceDialogFragmentCompat.ARG_KEY, key);
fragment.setArguments(b);
return fragment;
}
@Override
protected void onBindDialogView(@NonNull View view) {
Log.i(TAG, "onBindDialogView");
super.onBindDialogView(view);
CustomDefaultPreference preference = (CustomDefaultPreference)getPreference();
this.spinner = (Spinner) view.findViewById(R.id.default_or_custom);
this.defaultLabel = (TextView) view.findViewById(R.id.default_label);
this.customText = (EditText) view.findViewById(R.id.custom_edit);
this.customText.setInputType(preference.inputType);
this.customText.addTextChangedListener(new TextValidator());
this.customText.setText(preference.getCustomValue());
this.spinner.setOnItemSelectedListener(new SelectionLister());
this.defaultLabel.setText(preference.getPrettyPrintValue(preference.defaultValue));
}
@Override
public @NonNull Dialog onCreateDialog(Bundle instanceState) {
Dialog dialog = super.onCreateDialog(instanceState);
CustomDefaultPreference preference = (CustomDefaultPreference)getPreference();
if (preference.isCustom()) spinner.setSelection(1, true);
else spinner.setSelection(0, true);
return dialog;
}
@Override
public void onDialogClosed(boolean positiveResult) {
CustomDefaultPreference preference = (CustomDefaultPreference)getPreference();
if (positiveResult) {
if (spinner != null) preference.setCustom(spinner.getSelectedItemPosition() == 1);
if (customText != null) preference.setCustomValue(customText.getText().toString());
preference.setSummary(preference.getSummary());
}
}
interface CustomPreferenceValidator {
public boolean isValid(String value);
}
private static class NullValidator implements CustomPreferenceValidator {
@Override
public boolean isValid(String value) {
return true;
}
}
private class TextValidator implements TextWatcher {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {}
@Override
public void afterTextChanged(Editable s) {
CustomDefaultPreference preference = (CustomDefaultPreference)getPreference();
if (spinner.getSelectedItemPosition() == 1) {
Button positiveButton = ((AlertDialog)getDialog()).getButton(AlertDialog.BUTTON_POSITIVE);
positiveButton.setEnabled(preference.validator.isValid(s.toString()));
}
}
}
public static class UriValidator implements CustomPreferenceValidator {
@Override
public boolean isValid(String value) {
if (TextUtils.isEmpty(value)) return true;
try {
new URI(value);
return true;
} catch (URISyntaxException mue) {
return false;
}
}
}
public static class HostnameValidator implements CustomPreferenceValidator {
@Override
public boolean isValid(String value) {
if (TextUtils.isEmpty(value)) return true;
try {
URI uri = new URI(null, value, null, null);
return true;
} catch (URISyntaxException mue) {
return false;
}
}
}
public static class PortValidator implements CustomPreferenceValidator {
@Override
public boolean isValid(String value) {
try {
Integer.parseInt(value);
return true;
} catch (NumberFormatException e) {
return false;
}
}
}
private class SelectionLister implements AdapterView.OnItemSelectedListener {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
CustomDefaultPreference preference = (CustomDefaultPreference)getPreference();
Button positiveButton = ((AlertDialog)getDialog()).getButton(AlertDialog.BUTTON_POSITIVE);
defaultLabel.setVisibility(position == 0 ? View.VISIBLE : View.GONE);
customText.setVisibility(position == 0 ? View.GONE : View.VISIBLE);
positiveButton.setEnabled(position == 0 || preference.validator.isValid(customText.getText().toString()));
}
@Override
public void onNothingSelected(AdapterView<?> parent) {
defaultLabel.setVisibility(View.VISIBLE);
customText.setVisibility(View.GONE);
}
}
}
}

View File

@@ -0,0 +1,35 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.widget.EditText;
import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.drawable.DrawableCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ThemeUtil;
/**
* Custom styled search view that we can insert into ActionBar menus
*/
public class DarkSearchView extends androidx.appcompat.widget.SearchView {
public DarkSearchView(@NonNull Context context) {
this(context, null);
}
public DarkSearchView(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, R.attr.search_view_style_dark);
}
public DarkSearchView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
super(context, attrs, defStyleAttr);
EditText searchText = findViewById(androidx.appcompat.R.id.search_src_text);
searchText.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_subtitle_color));
}
}

View File

@@ -0,0 +1,110 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Color;
import android.os.Build;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Animation;
import android.view.animation.LinearInterpolator;
import android.view.animation.RotateAnimation;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import org.thoughtcrime.securesms.R;
import pl.tajchert.sample.DotsTextView;
public class DeliveryStatusView extends FrameLayout {
private static final String TAG = DeliveryStatusView.class.getSimpleName();
private static final RotateAnimation ROTATION_ANIMATION = new RotateAnimation(0, 360f,
Animation.RELATIVE_TO_SELF, 0.5f,
Animation.RELATIVE_TO_SELF, 0.5f);
static {
ROTATION_ANIMATION.setInterpolator(new LinearInterpolator());
ROTATION_ANIMATION.setDuration(1500);
ROTATION_ANIMATION.setRepeatCount(Animation.INFINITE);
}
private final ImageView pendingIndicator;
private final ImageView sentIndicator;
private final ImageView deliveredIndicator;
private final ImageView readIndicator;
public DeliveryStatusView(Context context) {
this(context, null);
}
public DeliveryStatusView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public DeliveryStatusView(final Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
inflate(context, R.layout.delivery_status_view, this);
this.deliveredIndicator = findViewById(R.id.delivered_indicator);
this.sentIndicator = findViewById(R.id.sent_indicator);
this.pendingIndicator = findViewById(R.id.pending_indicator);
this.readIndicator = findViewById(R.id.read_indicator);
if (attrs != null) {
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.DeliveryStatusView, 0, 0);
setTint(typedArray.getColor(R.styleable.DeliveryStatusView_iconColor, getResources().getColor(R.color.core_white)));
typedArray.recycle();
}
}
public void setNone() {
this.setVisibility(View.GONE);
}
public void setPending() {
this.setVisibility(View.VISIBLE);
pendingIndicator.setVisibility(View.VISIBLE);
pendingIndicator.startAnimation(ROTATION_ANIMATION);
sentIndicator.setVisibility(View.GONE);
deliveredIndicator.setVisibility(View.GONE);
readIndicator.setVisibility(View.GONE);
}
public void setSent() {
this.setVisibility(View.VISIBLE);
pendingIndicator.setVisibility(View.GONE);
pendingIndicator.clearAnimation();
sentIndicator.setVisibility(View.VISIBLE);
deliveredIndicator.setVisibility(View.GONE);
readIndicator.setVisibility(View.GONE);
}
public void setDelivered() {
this.setVisibility(View.VISIBLE);
pendingIndicator.setVisibility(View.GONE);
pendingIndicator.clearAnimation();
sentIndicator.setVisibility(View.GONE);
deliveredIndicator.setVisibility(View.VISIBLE);
readIndicator.setVisibility(View.GONE);
}
public void setRead() {
this.setVisibility(View.VISIBLE);
pendingIndicator.setVisibility(View.GONE);
pendingIndicator.clearAnimation();
sentIndicator.setVisibility(View.GONE);
deliveredIndicator.setVisibility(View.GONE);
readIndicator.setVisibility(View.VISIBLE);
}
public void setTint(int color) {
pendingIndicator.setColorFilter(color);
deliveredIndicator.setColorFilter(color);
sentIndicator.setColorFilter(color);
readIndicator.setColorFilter(color);
}
}

View File

@@ -0,0 +1,168 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Color;
import android.graphics.PorterDuff;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.pnikosis.materialishprogress.ProgressWheel;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.events.PartProgressEvent;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.util.Util;
public class DocumentView extends FrameLayout {
private static final String TAG = DocumentView.class.getSimpleName();
private final @NonNull AnimatingToggle controlToggle;
private final @NonNull ImageView downloadButton;
private final @NonNull ProgressWheel downloadProgress;
private final @NonNull View container;
private final @NonNull ViewGroup iconContainer;
private final @NonNull TextView fileName;
private final @NonNull TextView fileSize;
private final @NonNull TextView document;
private @Nullable SlideClickListener downloadListener;
private @Nullable SlideClickListener viewListener;
private @Nullable Slide documentSlide;
public DocumentView(@NonNull Context context) {
this(context, null);
}
public DocumentView(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public DocumentView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
super(context, attrs, defStyleAttr);
inflate(context, R.layout.document_view, this);
this.container = findViewById(R.id.document_container);
this.iconContainer = findViewById(R.id.icon_container);
this.controlToggle = findViewById(R.id.control_toggle);
this.downloadButton = findViewById(R.id.download);
this.downloadProgress = findViewById(R.id.download_progress);
this.fileName = findViewById(R.id.file_name);
this.fileSize = findViewById(R.id.file_size);
this.document = findViewById(R.id.document);
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.DocumentView, 0, 0);
int titleColor = typedArray.getInt(R.styleable.DocumentView_doc_titleColor, Color.BLACK);
int captionColor = typedArray.getInt(R.styleable.DocumentView_doc_captionColor, Color.BLACK);
int downloadTint = typedArray.getInt(R.styleable.DocumentView_doc_downloadButtonTint, Color.WHITE);
typedArray.recycle();
fileName.setTextColor(titleColor);
fileSize.setTextColor(captionColor);
downloadButton.setColorFilter(downloadTint, PorterDuff.Mode.MULTIPLY);
downloadProgress.setBarColor(downloadTint);
}
}
public void setDownloadClickListener(@Nullable SlideClickListener listener) {
this.downloadListener = listener;
}
public void setDocumentClickListener(@Nullable SlideClickListener listener) {
this.viewListener = listener;
}
public void setDocument(final @NonNull Slide documentSlide,
final boolean showControls)
{
if (showControls && documentSlide.isPendingDownload()) {
controlToggle.displayQuick(downloadButton);
downloadButton.setOnClickListener(new DownloadClickedListener(documentSlide));
if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
} else if (showControls && documentSlide.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_STARTED) {
controlToggle.displayQuick(downloadProgress);
downloadProgress.spin();
} else {
controlToggle.displayQuick(iconContainer);
if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
}
this.documentSlide = documentSlide;
this.fileName.setText(documentSlide.getFileName()
.or(documentSlide.getCaption())
.or(getContext().getString(R.string.DocumentView_unnamed_file)));
this.fileSize.setText(Util.getPrettyFileSize(documentSlide.getFileSize()));
this.document.setText(documentSlide.getFileType(getContext()).or("").toLowerCase());
this.setOnClickListener(new OpenClickedListener(documentSlide));
}
@Override
public void setFocusable(boolean focusable) {
super.setFocusable(focusable);
this.downloadButton.setFocusable(focusable);
}
@Override
public void setClickable(boolean clickable) {
super.setClickable(clickable);
this.downloadButton.setClickable(clickable);
}
@Override
public void setEnabled(boolean enabled) {
super.setEnabled(enabled);
this.downloadButton.setEnabled(enabled);
}
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
public void onEventAsync(final PartProgressEvent event) {
if (documentSlide != null && event.attachment.equals(documentSlide.asAttachment())) {
downloadProgress.setInstantProgress(((float) event.progress) / event.total);
}
}
private class DownloadClickedListener implements View.OnClickListener {
private final @NonNull Slide slide;
private DownloadClickedListener(@NonNull Slide slide) {
this.slide = slide;
}
@Override
public void onClick(View v) {
if (downloadListener != null) downloadListener.onClick(v, slide);
}
}
private class OpenClickedListener implements View.OnClickListener {
private final @NonNull Slide slide;
private OpenClickedListener(@NonNull Slide slide) {
this.slide = slide;
}
@Override
public void onClick(View v) {
if (!slide.isPendingDownload() && !slide.isInProgress() && viewListener != null) {
viewListener.onClick(v, slide);
}
}
}
}

View File

@@ -0,0 +1,122 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.util.AttributeSet;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.Util;
import java.lang.ref.WeakReference;
import java.util.concurrent.TimeUnit;
public class ExpirationTimerView extends androidx.appcompat.widget.AppCompatImageView {
private long startedAt;
private long expiresIn;
private boolean visible = false;
private boolean stopped = true;
private final int[] frames = new int[]{ R.drawable.ic_timer_00_12,
R.drawable.ic_timer_05_12,
R.drawable.ic_timer_10_12,
R.drawable.ic_timer_15_12,
R.drawable.ic_timer_20_12,
R.drawable.ic_timer_25_12,
R.drawable.ic_timer_30_12,
R.drawable.ic_timer_35_12,
R.drawable.ic_timer_40_12,
R.drawable.ic_timer_45_12,
R.drawable.ic_timer_50_12,
R.drawable.ic_timer_55_12,
R.drawable.ic_timer_60_12 };
public ExpirationTimerView(Context context) {
super(context);
}
public ExpirationTimerView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public ExpirationTimerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public void setExpirationTime(long startedAt, long expiresIn) {
this.startedAt = startedAt;
this.expiresIn = expiresIn;
setPercentComplete(calculateProgress(this.startedAt, this.expiresIn));
}
public void setPercentComplete(float percentage) {
float percentFull = 1 - percentage;
int frame = (int) Math.ceil(percentFull * (frames.length - 1));
frame = Math.max(0, Math.min(frame, frames.length - 1));
setImageResource(frames[frame]);
}
public void startAnimation() {
synchronized (this) {
visible = true;
if (!stopped) return;
else stopped = false;
}
Util.runOnMainDelayed(new AnimationUpdateRunnable(this), calculateAnimationDelay(this.startedAt, this.expiresIn));
}
public void stopAnimation() {
synchronized (this) {
visible = false;
}
}
private float calculateProgress(long startedAt, long expiresIn) {
long progressed = System.currentTimeMillis() - startedAt;
float percentComplete = (float)progressed / (float)expiresIn;
return Math.max(0, Math.min(percentComplete, 1));
}
private long calculateAnimationDelay(long startedAt, long expiresIn) {
long progressed = System.currentTimeMillis() - startedAt;
long remaining = expiresIn - progressed;
if (remaining < TimeUnit.SECONDS.toMillis(30)) {
return 50;
} else {
return 1000;
}
}
private static class AnimationUpdateRunnable implements Runnable {
private final WeakReference<ExpirationTimerView> expirationTimerViewReference;
private AnimationUpdateRunnable(@NonNull ExpirationTimerView expirationTimerView) {
this.expirationTimerViewReference = new WeakReference<>(expirationTimerView);
}
@Override
public void run() {
ExpirationTimerView timerView = expirationTimerViewReference.get();
if (timerView == null) return;
timerView.setExpirationTime(timerView.startedAt, timerView.expiresIn);
synchronized (timerView) {
if (!timerView.visible) {
timerView.stopped = true;
return;
}
}
Util.runOnMainDelayed(this, timerView.calculateAnimationDelay(timerView.startedAt, timerView.expiresIn));
}
}
}

View File

@@ -0,0 +1,95 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.graphics.Typeface;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.ForegroundColorSpan;
import android.text.style.StyleSpan;
import android.text.style.TypefaceSpan;
import android.util.AttributeSet;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.ResUtil;
import org.thoughtcrime.securesms.util.spans.CenterAlignedRelativeSizeSpan;
public class FromTextView extends EmojiTextView {
private static final String TAG = FromTextView.class.getSimpleName();
public FromTextView(Context context) {
super(context);
}
public FromTextView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public void setText(Recipient recipient) {
setText(recipient, true);
}
public void setText(Recipient recipient, boolean read) {
setText(recipient, read, null);
}
public void setText(Recipient recipient, boolean read, @Nullable String suffix) {
String fromString = recipient.toShortString(getContext());
int typeface;
if (!read) {
typeface = Typeface.BOLD;
} else {
typeface = Typeface.NORMAL;
}
SpannableStringBuilder builder = new SpannableStringBuilder();
SpannableString fromSpan = new SpannableString(fromString);
fromSpan.setSpan(new StyleSpan(typeface), 0, builder.length(),
Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
if (recipient.isLocalNumber()) {
builder.append(getContext().getString(R.string.note_to_self));
} else if (!FeatureFlags.PROFILE_DISPLAY && recipient.getName(getContext()) == null && !TextUtils.isEmpty(recipient.getProfileName())) {
SpannableString profileName = new SpannableString(" (~" + recipient.getProfileName() + ") ");
profileName.setSpan(new CenterAlignedRelativeSizeSpan(0.75f), 0, profileName.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
profileName.setSpan(new TypefaceSpan("sans-serif-light"), 0, profileName.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
profileName.setSpan(new ForegroundColorSpan(ResUtil.getColor(getContext(), R.attr.conversation_list_item_subject_color)), 0, profileName.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
if (ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_RTL){
builder.append(profileName);
builder.append(fromSpan);
} else {
builder.append(fromSpan);
builder.append(profileName);
}
} else {
builder.append(fromSpan);
}
if (suffix != null) {
builder.append(suffix);
}
setText(builder);
if (recipient.isBlocked()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_block_grey600_18dp, 0, 0, 0);
else if (recipient.isMuted()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_volume_off_grey600_18dp, 0, 0, 0);
else setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0);
}
}

View File

@@ -0,0 +1,33 @@
package org.thoughtcrime.securesms.components;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.widget.ImageView;
import com.bumptech.glide.request.target.BitmapImageViewTarget;
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
public class GlideBitmapListeningTarget extends BitmapImageViewTarget {
private final SettableFuture<Boolean> loaded;
public GlideBitmapListeningTarget(@NonNull ImageView view, @NonNull SettableFuture<Boolean> loaded) {
super(view);
this.loaded = loaded;
}
@Override
protected void setResource(@Nullable Bitmap resource) {
super.setResource(resource);
loaded.set(true);
}
@Override
public void onLoadFailed(@Nullable Drawable errorDrawable) {
super.onLoadFailed(errorDrawable);
loaded.set(true);
}
}

View File

@@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.components;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.widget.ImageView;
import com.bumptech.glide.request.target.DrawableImageViewTarget;
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
public class GlideDrawableListeningTarget extends DrawableImageViewTarget {
private final SettableFuture<Boolean> loaded;
public GlideDrawableListeningTarget(@NonNull ImageView view, @NonNull SettableFuture<Boolean> loaded) {
super(view);
this.loaded = loaded;
}
@Override
protected void setResource(@Nullable Drawable resource) {
super.setResource(resource);
loaded.set(true);
}
@Override
public void onLoadFailed(@Nullable Drawable errorDrawable) {
super.onLoadFailed(errorDrawable);
loaded.set(true);
}
}

View File

@@ -0,0 +1,78 @@
package org.thoughtcrime.securesms.components;
import android.annotation.TargetApi;
import android.content.Context;
import android.os.Build;
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
import android.util.AttributeSet;
import android.view.animation.AlphaAnimation;
import android.view.animation.Animation;
import android.view.animation.AnimationSet;
import android.view.animation.ScaleAnimation;
import android.widget.LinearLayout;
public class HidingLinearLayout extends LinearLayout {
public HidingLinearLayout(Context context) {
super(context);
}
public HidingLinearLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
public HidingLinearLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public void hide() {
if (!isEnabled() || getVisibility() == GONE) return;
AnimationSet animation = new AnimationSet(true);
animation.addAnimation(new ScaleAnimation(1, 0.5f, 1, 1, Animation.RELATIVE_TO_SELF, 1f, Animation.RELATIVE_TO_SELF, 0.5f));
animation.addAnimation(new AlphaAnimation(1, 0));
animation.setDuration(100);
animation.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
}
@Override
public void onAnimationRepeat(Animation animation) {
}
@Override
public void onAnimationEnd(Animation animation) {
setVisibility(GONE);
}
});
animateWith(animation);
}
public void show() {
if (!isEnabled() || getVisibility() == VISIBLE) return;
setVisibility(VISIBLE);
AnimationSet animation = new AnimationSet(true);
animation.addAnimation(new ScaleAnimation(0.5f, 1, 1, 1, Animation.RELATIVE_TO_SELF, 1f, Animation.RELATIVE_TO_SELF, 0.5f));
animation.addAnimation(new AlphaAnimation(0, 1));
animation.setDuration(100);
animateWith(animation);
}
private void animateWith(Animation animation) {
animation.setDuration(150);
animation.setInterpolator(new FastOutSlowInInterpolator());
startAnimation(animation);
}
public void disable() {
setVisibility(GONE);
setEnabled(false);
}
}

View File

@@ -0,0 +1,85 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.graphics.PorterDuffXfermode;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.view.View;
import org.thoughtcrime.securesms.R;
public class HourglassView extends View {
private final Paint foregroundPaint;
private final Paint backgroundPaint;
private final Paint progressPaint;
private Bitmap empty;
private Bitmap full;
private float percentage;
private int offset;
public HourglassView(Context context) {
this(context, null);
}
public HourglassView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public HourglassView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
int tint = 0;
if (attrs != null) {
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.HourglassView, 0, 0);
this.empty = BitmapFactory.decodeResource(getResources(), typedArray.getResourceId(R.styleable.HourglassView_empty, 0));
this.full = BitmapFactory.decodeResource(getResources(), typedArray.getResourceId(R.styleable.HourglassView_full, 0));
this.percentage = typedArray.getInt(R.styleable.HourglassView_percentage, 50);
this.offset = typedArray.getInt(R.styleable.HourglassView_offset, 0);
tint = typedArray.getColor(R.styleable.HourglassView_tint, 0);
typedArray.recycle();
}
this.backgroundPaint = new Paint();
this.foregroundPaint = new Paint();
this.progressPaint = new Paint();
this.backgroundPaint.setColorFilter(new PorterDuffColorFilter(tint, PorterDuff.Mode.MULTIPLY));
this.foregroundPaint.setColorFilter(new PorterDuffColorFilter(tint, PorterDuff.Mode.MULTIPLY));
this.progressPaint.setColor(getResources().getColor(R.color.black));
this.progressPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
setLayerType(View.LAYER_TYPE_SOFTWARE, null);
}
@Override
public void onDraw(Canvas canvas) {
float progressHeight = (full.getHeight() - (offset*2)) * (percentage / 100);
canvas.drawBitmap(full, 0, 0, backgroundPaint);
canvas.drawRect(0, 0, full.getWidth(), offset + progressHeight, progressPaint);
canvas.drawBitmap(empty, 0, 0, foregroundPaint);
}
public void setPercentage(float percentage) {
this.percentage = percentage;
invalidate();
}
public void setTint(int tint) {
this.backgroundPaint.setColorFilter(new PorterDuffColorFilter(tint, PorterDuff.Mode.MULTIPLY));
this.foregroundPaint.setColorFilter(new PorterDuffColorFilter(tint, PorterDuff.Mode.MULTIPLY));
}
}

View File

@@ -0,0 +1,105 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.drawable.Drawable;
import androidx.appcompat.widget.AppCompatImageView;
import android.util.AttributeSet;
import org.thoughtcrime.securesms.R;
public class ImageDivet extends AppCompatImageView {
private static final float CORNER_OFFSET = 12F;
private static final String[] POSITIONS = new String[] {"bottom_right"};
private Drawable drawable;
private int drawableIntrinsicWidth;
private int drawableIntrinsicHeight;
private int position;
private float density;
public ImageDivet(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
initialize(attrs);
}
public ImageDivet(Context context, AttributeSet attrs) {
super(context, attrs);
initialize(attrs);
}
public ImageDivet(Context context) {
super(context);
initialize(null);
}
private void initialize(AttributeSet attrs) {
if (attrs != null) {
position = attrs.getAttributeListValue(null, "position", POSITIONS, -1);
}
density = getContext().getResources().getDisplayMetrics().density;
setDrawable();
}
private void setDrawable() {
int attributes[] = new int[] {R.attr.lower_right_divet};
TypedArray drawables = getContext().obtainStyledAttributes(attributes);
switch (position) {
case 0:
drawable = drawables.getDrawable(0);
break;
}
drawableIntrinsicWidth = drawable.getIntrinsicWidth();
drawableIntrinsicHeight = drawable.getIntrinsicHeight();
drawables.recycle();
}
@Override
public void onDraw(Canvas c) {
super.onDraw(c);
c.save();
computeBounds(c);
drawable.draw(c);
c.restore();
}
public void setPosition(int position) {
this.position = position;
setDrawable();
invalidate();
}
public int getPosition() {
return position;
}
public float getCloseOffset() {
return CORNER_OFFSET * density;
}
public float getFarOffset() {
return getCloseOffset() + drawableIntrinsicHeight;
}
private void computeBounds(Canvas c) {
final int right = getWidth();
final int bottom = getHeight();
switch (position) {
case 0:
drawable.setBounds(
right - drawableIntrinsicWidth,
bottom - drawableIntrinsicHeight,
right,
bottom);
break;
}
}
}

View File

@@ -0,0 +1,93 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.util.AttributeSet;
import android.widget.EditText;
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout.OnKeyboardShownListener;
import org.thoughtcrime.securesms.util.ServiceUtil;
public class InputAwareLayout extends KeyboardAwareLinearLayout implements OnKeyboardShownListener {
private InputView current;
public InputAwareLayout(Context context) {
this(context, null);
}
public InputAwareLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public InputAwareLayout(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
addOnKeyboardShownListener(this);
}
@Override public void onKeyboardShown() {
hideAttachedInput(true);
}
public void show(@NonNull final EditText imeTarget, @NonNull final InputView input) {
if (isKeyboardOpen()) {
hideSoftkey(imeTarget, new Runnable() {
@Override public void run() {
hideAttachedInput(true);
input.show(getKeyboardHeight(), true);
current = input;
}
});
} else {
if (current != null) current.hide(true);
input.show(getKeyboardHeight(), current != null);
current = input;
}
}
public InputView getCurrentInput() {
return current;
}
public void hideCurrentInput(EditText imeTarget) {
if (isKeyboardOpen()) hideSoftkey(imeTarget, null);
else hideAttachedInput(false);
}
public void hideAttachedInput(boolean instant) {
if (current != null) current.hide(instant);
current = null;
}
public boolean isInputOpen() {
return (isKeyboardOpen() || (current != null && current.isShowing()));
}
public void showSoftkey(final EditText inputTarget) {
postOnKeyboardOpen(new Runnable() {
@Override public void run() {
hideAttachedInput(true);
}
});
inputTarget.post(new Runnable() {
@Override public void run() {
inputTarget.requestFocus();
ServiceUtil.getInputMethodManager(inputTarget.getContext()).showSoftInput(inputTarget, 0);
}
});
}
public void hideSoftkey(final EditText inputTarget, @Nullable Runnable runAfterClose) {
if (runAfterClose != null) postOnKeyboardClose(runAfterClose);
ServiceUtil.getInputMethodManager(inputTarget.getContext())
.hideSoftInputFromWindow(inputTarget.getWindowToken(), 0);
}
public interface InputView {
void show(int height, boolean immediate);
void hide(boolean immediate);
boolean isShowing();
}
}

View File

@@ -0,0 +1,494 @@
package org.thoughtcrime.securesms.components;
import android.annotation.TargetApi;
import android.content.Context;
import android.net.Uri;
import android.os.Build;
import androidx.annotation.DimenRes;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.text.format.DateUtils;
import android.util.AttributeSet;
import android.view.KeyEvent;
import android.view.View;
import android.view.animation.AlphaAnimation;
import android.view.animation.Animation;
import android.view.animation.AnimationSet;
import android.view.animation.Interpolator;
import android.view.animation.TranslateAnimation;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider;
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdapter;
import org.thoughtcrime.securesms.database.model.StickerRecord;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.QuoteModel;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.List;
import java.util.concurrent.TimeUnit;
public class InputPanel extends LinearLayout
implements MicrophoneRecorderView.Listener,
KeyboardAwareLinearLayout.OnKeyboardShownListener,
EmojiKeyboardProvider.EmojiEventListener,
ConversationStickerSuggestionAdapter.EventListener
{
private static final String TAG = InputPanel.class.getSimpleName();
private static final int FADE_TIME = 150;
private RecyclerView stickerSuggestion;
private QuoteView quoteView;
private LinkPreviewView linkPreview;
private EmojiToggle mediaKeyboard;
private ComposeText composeText;
private View quickCameraToggle;
private View quickAudioToggle;
private View buttonToggle;
private View recordingContainer;
private View recordLockCancel;
private MicrophoneRecorderView microphoneRecorderView;
private SlideToCancel slideToCancel;
private RecordTime recordTime;
private @Nullable Listener listener;
private boolean emojiVisible;
private ConversationStickerSuggestionAdapter stickerSuggestionAdapter;
public InputPanel(Context context) {
super(context);
}
public InputPanel(Context context, AttributeSet attrs) {
super(context, attrs);
}
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
public InputPanel(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public void onFinishInflate() {
super.onFinishInflate();
View quoteDismiss = findViewById(R.id.quote_dismiss);
this.stickerSuggestion = findViewById(R.id.input_panel_sticker_suggestion);
this.quoteView = findViewById(R.id.quote_view);
this.linkPreview = findViewById(R.id.link_preview);
this.mediaKeyboard = findViewById(R.id.emoji_toggle);
this.composeText = findViewById(R.id.embedded_text_editor);
this.quickCameraToggle = findViewById(R.id.quick_camera_toggle);
this.quickAudioToggle = findViewById(R.id.quick_audio_toggle);
this.buttonToggle = findViewById(R.id.button_toggle);
this.recordingContainer = findViewById(R.id.recording_container);
this.recordLockCancel = findViewById(R.id.record_cancel);
this.slideToCancel = new SlideToCancel(findViewById(R.id.slide_to_cancel));
this.microphoneRecorderView = findViewById(R.id.recorder_view);
this.microphoneRecorderView.setListener(this);
this.recordTime = new RecordTime(findViewById(R.id.record_time),
findViewById(R.id.microphone),
TimeUnit.HOURS.toSeconds(1),
() -> microphoneRecorderView.cancelAction());
this.recordLockCancel.setOnClickListener(v -> microphoneRecorderView.cancelAction());
if (TextSecurePreferences.isSystemEmojiPreferred(getContext())) {
mediaKeyboard.setVisibility(View.GONE);
emojiVisible = false;
} else {
mediaKeyboard.setVisibility(View.VISIBLE);
emojiVisible = true;
}
quoteDismiss.setOnClickListener(v -> clearQuote());
linkPreview.setCloseClickedListener(() -> {
if (listener != null) {
listener.onLinkPreviewCanceled();
}
});
stickerSuggestionAdapter = new ConversationStickerSuggestionAdapter(GlideApp.with(this), this);
stickerSuggestion.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.HORIZONTAL, false));
stickerSuggestion.setAdapter(stickerSuggestionAdapter);
}
public void setListener(final @NonNull Listener listener) {
this.listener = listener;
mediaKeyboard.setOnClickListener(v -> listener.onEmojiToggle());
}
public void setMediaListener(@NonNull MediaListener listener) {
composeText.setMediaListener(listener);
}
public void setQuote(@NonNull GlideRequests glideRequests,
long id,
@NonNull Recipient author,
@NonNull String body,
@NonNull SlideDeck attachments,
boolean isViewOnce)
{
this.quoteView.setQuote(glideRequests, id, author, body, false, attachments, isViewOnce);
this.quoteView.setVisibility(View.VISIBLE);
if (this.linkPreview.getVisibility() == View.VISIBLE) {
int cornerRadius = readDimen(R.dimen.message_corner_collapse_radius);
this.linkPreview.setCorners(cornerRadius, cornerRadius);
}
}
public void clearQuote() {
this.quoteView.dismiss();
if (this.linkPreview.getVisibility() == View.VISIBLE) {
int cornerRadius = readDimen(R.dimen.message_corner_radius);
this.linkPreview.setCorners(cornerRadius, cornerRadius);
}
}
public Optional<QuoteModel> getQuote() {
if (quoteView.getQuoteId() > 0 && quoteView.getVisibility() == View.VISIBLE) {
return Optional.of(new QuoteModel(quoteView.getQuoteId(), quoteView.getAuthor().getId(), quoteView.getBody(), false, quoteView.getAttachments()));
} else {
return Optional.absent();
}
}
public void setLinkPreviewLoading() {
this.linkPreview.setVisibility(View.VISIBLE);
this.linkPreview.setLoading();
}
public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull Optional<LinkPreview> preview) {
if (preview.isPresent()) {
this.linkPreview.setVisibility(View.VISIBLE);
this.linkPreview.setLinkPreview(glideRequests, preview.get(), true);
} else {
this.linkPreview.setVisibility(View.GONE);
}
int cornerRadius = quoteView.getVisibility() == VISIBLE ? readDimen(R.dimen.message_corner_collapse_radius)
: readDimen(R.dimen.message_corner_radius);
this.linkPreview.setCorners(cornerRadius, cornerRadius);
}
public void clickOnComposeInput() {
composeText.performClick();
}
public void setMediaKeyboard(@NonNull MediaKeyboard mediaKeyboard) {
this.mediaKeyboard.attach(mediaKeyboard);
}
public void setStickerSuggestions(@NonNull List<StickerRecord> stickers) {
stickerSuggestion.setVisibility(stickers.isEmpty() ? View.GONE : View.VISIBLE);
stickerSuggestionAdapter.setStickers(stickers);
}
public void showMediaKeyboardToggle(boolean show) {
emojiVisible = show;
mediaKeyboard.setVisibility(show ? View.VISIBLE : GONE);
}
public void setMediaKeyboardToggleMode(boolean isSticker) {
mediaKeyboard.setStickerMode(isSticker);
}
public boolean isStickerMode() {
return mediaKeyboard.isStickerMode();
}
public View getMediaKeyboardToggleAnchorView() {
return mediaKeyboard;
}
@Override
public void onRecordPermissionRequired() {
if (listener != null) listener.onRecorderPermissionRequired();
}
@Override
public void onRecordPressed() {
if (listener != null) listener.onRecorderStarted();
recordTime.display();
slideToCancel.display();
if (emojiVisible) ViewUtil.fadeOut(mediaKeyboard, FADE_TIME, View.INVISIBLE);
ViewUtil.fadeOut(composeText, FADE_TIME, View.INVISIBLE);
ViewUtil.fadeOut(quickCameraToggle, FADE_TIME, View.INVISIBLE);
ViewUtil.fadeOut(quickAudioToggle, FADE_TIME, View.INVISIBLE);
buttonToggle.animate().alpha(0).setDuration(FADE_TIME).start();
}
@Override
public void onRecordReleased() {
long elapsedTime = onRecordHideEvent();
if (listener != null) {
Log.d(TAG, "Elapsed time: " + elapsedTime);
if (elapsedTime > 1000) {
listener.onRecorderFinished();
} else {
Toast.makeText(getContext(), R.string.InputPanel_tap_and_hold_to_record_a_voice_message_release_to_send, Toast.LENGTH_LONG).show();
listener.onRecorderCanceled();
}
}
}
@Override
public void onRecordMoved(float offsetX, float absoluteX) {
slideToCancel.moveTo(offsetX);
int direction = ViewCompat.getLayoutDirection(this);
float position = absoluteX / recordingContainer.getWidth();
if (direction == ViewCompat.LAYOUT_DIRECTION_LTR && position <= 0.5 ||
direction == ViewCompat.LAYOUT_DIRECTION_RTL && position >= 0.6)
{
this.microphoneRecorderView.cancelAction();
}
}
@Override
public void onRecordCanceled() {
onRecordHideEvent();
if (listener != null) listener.onRecorderCanceled();
}
@Override
public void onRecordLocked() {
slideToCancel.hide();
recordLockCancel.setVisibility(View.VISIBLE);
buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
if (listener != null) listener.onRecorderLocked();
}
public void onPause() {
this.microphoneRecorderView.cancelAction();
}
public void setEnabled(boolean enabled) {
composeText.setEnabled(enabled);
mediaKeyboard.setEnabled(enabled);
quickAudioToggle.setEnabled(enabled);
quickCameraToggle.setEnabled(enabled);
}
private long onRecordHideEvent() {
recordLockCancel.setVisibility(View.GONE);
ListenableFuture<Void> future = slideToCancel.hide();
long elapsedTime = recordTime.hide();
future.addListener(new AssertedSuccessListener<Void>() {
@Override
public void onSuccess(Void result) {
if (emojiVisible) ViewUtil.fadeIn(mediaKeyboard, FADE_TIME);
ViewUtil.fadeIn(composeText, FADE_TIME);
ViewUtil.fadeIn(quickCameraToggle, FADE_TIME);
ViewUtil.fadeIn(quickAudioToggle, FADE_TIME);
buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
}
});
return elapsedTime;
}
@Override
public void onKeyboardShown() {
mediaKeyboard.setToMedia();
}
@Override
public void onKeyEvent(KeyEvent keyEvent) {
composeText.dispatchKeyEvent(keyEvent);
}
@Override
public void onEmojiSelected(String emoji) {
composeText.insertEmoji(emoji);
}
@Override
public void onStickerSuggestionClicked(@NonNull StickerRecord sticker) {
if (listener != null) {
listener.onStickerSuggestionSelected(sticker);
}
}
private int readDimen(@DimenRes int dimenRes) {
return getResources().getDimensionPixelSize(dimenRes);
}
public boolean isRecordingInLockedMode() {
return microphoneRecorderView.isRecordingLocked();
}
public void releaseRecordingLock() {
microphoneRecorderView.unlockAction();
}
public interface Listener {
void onRecorderStarted();
void onRecorderLocked();
void onRecorderFinished();
void onRecorderCanceled();
void onRecorderPermissionRequired();
void onEmojiToggle();
void onLinkPreviewCanceled();
void onStickerSuggestionSelected(@NonNull StickerRecord sticker);
}
private static class SlideToCancel {
private final View slideToCancelView;
SlideToCancel(View slideToCancelView) {
this.slideToCancelView = slideToCancelView;
}
public void display() {
ViewUtil.fadeIn(this.slideToCancelView, FADE_TIME);
}
public ListenableFuture<Void> hide() {
final SettableFuture<Void> future = new SettableFuture<>();
AnimationSet animation = new AnimationSet(true);
animation.addAnimation(new TranslateAnimation(Animation.ABSOLUTE, slideToCancelView.getTranslationX(),
Animation.ABSOLUTE, 0,
Animation.RELATIVE_TO_SELF, 0,
Animation.RELATIVE_TO_SELF, 0));
animation.addAnimation(new AlphaAnimation(1, 0));
animation.setDuration(MicrophoneRecorderView.ANIMATION_DURATION);
animation.setFillBefore(true);
animation.setFillAfter(false);
slideToCancelView.postDelayed(() -> future.set(null), MicrophoneRecorderView.ANIMATION_DURATION);
slideToCancelView.setVisibility(View.GONE);
slideToCancelView.startAnimation(animation);
return future;
}
void moveTo(float offset) {
Animation animation = new TranslateAnimation(Animation.ABSOLUTE, offset,
Animation.ABSOLUTE, offset,
Animation.RELATIVE_TO_SELF, 0,
Animation.RELATIVE_TO_SELF, 0);
animation.setDuration(0);
animation.setFillAfter(true);
animation.setFillBefore(true);
slideToCancelView.startAnimation(animation);
}
}
private static class RecordTime implements Runnable {
private final @NonNull TextView recordTimeView;
private final @NonNull View microphone;
private final @NonNull Runnable onLimitHit;
private final long limitSeconds;
private long startTime;
private RecordTime(@NonNull TextView recordTimeView, @NonNull View microphone, long limitSeconds, @NonNull Runnable onLimitHit) {
this.recordTimeView = recordTimeView;
this.microphone = microphone;
this.limitSeconds = limitSeconds;
this.onLimitHit = onLimitHit;
}
@MainThread
public void display() {
this.startTime = System.currentTimeMillis();
this.recordTimeView.setText(DateUtils.formatElapsedTime(0));
ViewUtil.fadeIn(this.recordTimeView, FADE_TIME);
Util.runOnMainDelayed(this, TimeUnit.SECONDS.toMillis(1));
microphone.setVisibility(View.VISIBLE);
microphone.startAnimation(pulseAnimation());
}
@MainThread
public long hide() {
long elapsedTime = System.currentTimeMillis() - startTime;
this.startTime = 0;
ViewUtil.fadeOut(this.recordTimeView, FADE_TIME, View.INVISIBLE);
microphone.clearAnimation();
ViewUtil.fadeOut(this.microphone, FADE_TIME, View.INVISIBLE);
return elapsedTime;
}
@Override
@MainThread
public void run() {
long localStartTime = startTime;
if (localStartTime > 0) {
long elapsedTime = System.currentTimeMillis() - localStartTime;
long elapsedSeconds = TimeUnit.MILLISECONDS.toSeconds(elapsedTime);
if (elapsedSeconds >= limitSeconds) {
onLimitHit.run();
} else {
recordTimeView.setText(DateUtils.formatElapsedTime(elapsedSeconds));
Util.runOnMainDelayed(this, TimeUnit.SECONDS.toMillis(1));
}
}
}
private static Animation pulseAnimation() {
AlphaAnimation animation = new AlphaAnimation(0, 1);
animation.setInterpolator(pulseInterpolator());
animation.setRepeatCount(Animation.INFINITE);
animation.setDuration(1000);
return animation;
}
private static Interpolator pulseInterpolator() {
return input -> {
input *= 5;
if (input > 1) {
input = 4 - input;
}
return Math.max(0, Math.min(1, input));
};
}
}
public interface MediaListener {
void onMediaSelected(@NonNull Uri uri, String contentType);
}
}

View File

@@ -0,0 +1,271 @@
/**
* Copyright (C) 2014 Open Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.components;
import android.annotation.TargetApi;
import android.content.Context;
import android.graphics.Rect;
import android.os.Build;
import android.os.Build.VERSION_CODES;
import android.preference.PreferenceManager;
import androidx.appcompat.widget.LinearLayoutCompat;
import android.util.AttributeSet;
import org.thoughtcrime.securesms.logging.Log;
import android.view.Surface;
import android.view.View;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.Util;
import java.lang.reflect.Field;
import java.util.HashSet;
import java.util.Set;
/**
* LinearLayout that, when a view container, will report back when it thinks a soft keyboard
* has been opened and what its height would be.
*/
public class KeyboardAwareLinearLayout extends LinearLayoutCompat {
private static final String TAG = KeyboardAwareLinearLayout.class.getSimpleName();
private final Rect rect = new Rect();
private final Set<OnKeyboardHiddenListener> hiddenListeners = new HashSet<>();
private final Set<OnKeyboardShownListener> shownListeners = new HashSet<>();
private final int minKeyboardSize;
private final int minCustomKeyboardSize;
private final int defaultCustomKeyboardSize;
private final int minCustomKeyboardTopMarginPortrait;
private final int minCustomKeyboardTopMarginLandscape;
private final int statusBarHeight;
private int viewInset;
private boolean keyboardOpen = false;
private int rotation = -1;
private boolean isFullscreen = false;
public KeyboardAwareLinearLayout(Context context) {
this(context, null);
}
public KeyboardAwareLinearLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public KeyboardAwareLinearLayout(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
final int statusBarRes = getResources().getIdentifier("status_bar_height", "dimen", "android");
minKeyboardSize = getResources().getDimensionPixelSize(R.dimen.min_keyboard_size);
minCustomKeyboardSize = getResources().getDimensionPixelSize(R.dimen.min_custom_keyboard_size);
defaultCustomKeyboardSize = getResources().getDimensionPixelSize(R.dimen.default_custom_keyboard_size);
minCustomKeyboardTopMarginPortrait = getResources().getDimensionPixelSize(R.dimen.min_custom_keyboard_top_margin_portrait);
minCustomKeyboardTopMarginLandscape = getResources().getDimensionPixelSize(R.dimen.min_custom_keyboard_top_margin_portrait);
statusBarHeight = statusBarRes > 0 ? getResources().getDimensionPixelSize(statusBarRes) : 0;
viewInset = getViewInset();
}
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
updateRotation();
updateKeyboardState();
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
private void updateRotation() {
int oldRotation = rotation;
rotation = getDeviceRotation();
if (oldRotation != rotation) {
Log.i(TAG, "rotation changed");
onKeyboardClose();
}
}
private void updateKeyboardState() {
if (viewInset == 0 && Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) viewInset = getViewInset();
getWindowVisibleDisplayFrame(rect);
final int availableHeight = getAvailableHeight();
final int keyboardHeight = availableHeight - (rect.bottom - rect.top);
if (keyboardHeight > minKeyboardSize) {
if (getKeyboardHeight() != keyboardHeight) {
if (isLandscape()) {
setKeyboardLandscapeHeight(keyboardHeight);
} else {
setKeyboardPortraitHeight(keyboardHeight);
}
}
if (!keyboardOpen) {
onKeyboardOpen(keyboardHeight);
}
} else if (keyboardOpen) {
onKeyboardClose();
}
}
@TargetApi(VERSION_CODES.LOLLIPOP)
private int getViewInset() {
try {
Field attachInfoField = View.class.getDeclaredField("mAttachInfo");
attachInfoField.setAccessible(true);
Object attachInfo = attachInfoField.get(this);
if (attachInfo != null) {
Field stableInsetsField = attachInfo.getClass().getDeclaredField("mStableInsets");
stableInsetsField.setAccessible(true);
Rect insets = (Rect)stableInsetsField.get(attachInfo);
return insets.bottom;
}
} catch (NoSuchFieldException nsfe) {
Log.w(TAG, "field reflection error when measuring view inset", nsfe);
} catch (IllegalAccessException iae) {
Log.w(TAG, "access reflection error when measuring view inset", iae);
}
return 0;
}
private int getAvailableHeight() {
final int availableHeight = this.getRootView().getHeight() - viewInset - (!isFullscreen ? statusBarHeight : 0);
final int availableWidth = this.getRootView().getWidth() - (!isFullscreen ? statusBarHeight : 0);
if (isLandscape() && availableHeight > availableWidth) {
//noinspection SuspiciousNameCombination
return availableWidth;
}
return availableHeight;
}
protected void onKeyboardOpen(int keyboardHeight) {
Log.i(TAG, "onKeyboardOpen(" + keyboardHeight + ")");
keyboardOpen = true;
notifyShownListeners();
}
protected void onKeyboardClose() {
Log.i(TAG, "onKeyboardClose()");
keyboardOpen = false;
notifyHiddenListeners();
}
public boolean isKeyboardOpen() {
return keyboardOpen;
}
public int getKeyboardHeight() {
return isLandscape() ? getKeyboardLandscapeHeight() : getKeyboardPortraitHeight();
}
public boolean isLandscape() {
int rotation = getDeviceRotation();
return rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270;
}
private int getDeviceRotation() {
return ServiceUtil.getWindowManager(getContext()).getDefaultDisplay().getRotation();
}
private int getKeyboardLandscapeHeight() {
int keyboardHeight = PreferenceManager.getDefaultSharedPreferences(getContext())
.getInt("keyboard_height_landscape", defaultCustomKeyboardSize);
return Util.clamp(keyboardHeight, minCustomKeyboardSize, getRootView().getHeight() - minCustomKeyboardTopMarginLandscape);
}
private int getKeyboardPortraitHeight() {
int keyboardHeight = PreferenceManager.getDefaultSharedPreferences(getContext())
.getInt("keyboard_height_portrait", defaultCustomKeyboardSize);
return Util.clamp(keyboardHeight, minCustomKeyboardSize, getRootView().getHeight() - minCustomKeyboardTopMarginPortrait);
}
private void setKeyboardPortraitHeight(int height) {
PreferenceManager.getDefaultSharedPreferences(getContext())
.edit().putInt("keyboard_height_portrait", height).apply();
}
private void setKeyboardLandscapeHeight(int height) {
PreferenceManager.getDefaultSharedPreferences(getContext())
.edit().putInt("keyboard_height_landscape", height).apply();
}
public void postOnKeyboardClose(final Runnable runnable) {
if (keyboardOpen) {
addOnKeyboardHiddenListener(new OnKeyboardHiddenListener() {
@Override public void onKeyboardHidden() {
removeOnKeyboardHiddenListener(this);
runnable.run();
}
});
} else {
runnable.run();
}
}
public void postOnKeyboardOpen(final Runnable runnable) {
if (!keyboardOpen) {
addOnKeyboardShownListener(new OnKeyboardShownListener() {
@Override public void onKeyboardShown() {
removeOnKeyboardShownListener(this);
runnable.run();
}
});
} else {
runnable.run();
}
}
public void addOnKeyboardHiddenListener(OnKeyboardHiddenListener listener) {
hiddenListeners.add(listener);
}
public void removeOnKeyboardHiddenListener(OnKeyboardHiddenListener listener) {
hiddenListeners.remove(listener);
}
public void addOnKeyboardShownListener(OnKeyboardShownListener listener) {
shownListeners.add(listener);
}
public void removeOnKeyboardShownListener(OnKeyboardShownListener listener) {
shownListeners.remove(listener);
}
public void setFullscreen(boolean isFullscreen) {
this.isFullscreen = isFullscreen;
}
private void notifyHiddenListeners() {
final Set<OnKeyboardHiddenListener> listeners = new HashSet<>(hiddenListeners);
for (OnKeyboardHiddenListener listener : listeners) {
listener.onKeyboardHidden();
}
}
private void notifyShownListeners() {
final Set<OnKeyboardShownListener> listeners = new HashSet<>(shownListeners);
for (OnKeyboardShownListener listener : listeners) {
listener.onKeyboardShown();
}
}
public interface OnKeyboardHiddenListener {
void onKeyboardHidden();
}
public interface OnKeyboardShownListener {
void onKeyboardShown();
}
}

View File

@@ -0,0 +1,93 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Color;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.text.Editable;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.TextView;
import org.thoughtcrime.securesms.R;
public class LabeledEditText extends FrameLayout implements View.OnFocusChangeListener {
private TextView label;
private EditText input;
private View border;
private ViewGroup textContainer;
public LabeledEditText(@NonNull Context context) {
super(context);
init(null);
}
public LabeledEditText(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(attrs);
}
private void init(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.labeled_edit_text, this);
String labelText = "";
int backgroundColor = Color.BLACK;
int textLayout = R.layout.labeled_edit_text_default;
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.LabeledEditText, 0, 0);
labelText = typedArray.getString(R.styleable.LabeledEditText_labeledEditText_label);
backgroundColor = typedArray.getColor(R.styleable.LabeledEditText_labeledEditText_background, Color.BLACK);
textLayout = typedArray.getResourceId(R.styleable.LabeledEditText_labeledEditText_textLayout, R.layout.labeled_edit_text_default);
typedArray.recycle();
}
label = findViewById(R.id.label);
border = findViewById(R.id.border);
textContainer = findViewById(R.id.text_container);
inflate(getContext(), textLayout, textContainer);
input = findViewById(R.id.input);
label.setText(labelText);
label.setBackgroundColor(backgroundColor);
if (TextUtils.isEmpty(labelText)) {
label.setVisibility(INVISIBLE);
}
input.setOnFocusChangeListener(this);
}
public EditText getInput() {
return input;
}
public void setText(String text) {
input.setText(text);
}
public Editable getText() {
return input.getText();
}
@Override
public void onFocusChange(View v, boolean hasFocus) {
border.setBackgroundResource(hasFocus ? R.drawable.labeled_edit_text_background_active
: R.drawable.labeled_edit_text_background_inactive);
}
@Override
public void setEnabled(boolean enabled) {
super.setEnabled(enabled);
input.setEnabled(enabled);
}
}

View File

@@ -0,0 +1,147 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.TextView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.ImageSlide;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import org.thoughtcrime.securesms.util.ThemeUtil;
import okhttp3.HttpUrl;
public class LinkPreviewView extends FrameLayout {
private static final int TYPE_CONVERSATION = 0;
private static final int TYPE_COMPOSE = 1;
private ViewGroup container;
private OutlinedThumbnailView thumbnail;
private TextView title;
private TextView site;
private View divider;
private View closeButton;
private View spinner;
private int type;
private int defaultRadius;
private CornerMask cornerMask;
private Outliner outliner;
private CloseClickedListener closeClickedListener;
public LinkPreviewView(Context context) {
super(context);
init(null);
}
public LinkPreviewView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(attrs);
}
private void init(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.link_preview, this);
container = findViewById(R.id.linkpreview_container);
thumbnail = findViewById(R.id.linkpreview_thumbnail);
title = findViewById(R.id.linkpreview_title);
site = findViewById(R.id.linkpreview_site);
divider = findViewById(R.id.linkpreview_divider);
spinner = findViewById(R.id.linkpreview_progress_wheel);
closeButton = findViewById(R.id.linkpreview_close);
defaultRadius = getResources().getDimensionPixelSize(R.dimen.thumbnail_default_radius);
cornerMask = new CornerMask(this);
outliner = new Outliner();
outliner.setColor(ThemeUtil.getThemedColor(getContext(), R.attr.conversation_item_image_outline_color));
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.LinkPreviewView, 0, 0);
type = typedArray.getInt(R.styleable.LinkPreviewView_linkpreview_type, 0);
typedArray.recycle();
}
if (type == TYPE_COMPOSE) {
container.setBackgroundColor(Color.TRANSPARENT);
container.setPadding(0, 0, 0, 0);
divider.setVisibility(VISIBLE);
closeButton.setVisibility(VISIBLE);
closeButton.setOnClickListener(v -> {
if (closeClickedListener != null) {
closeClickedListener.onCloseClicked();
}
});
}
setWillNotDraw(false);
}
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
if (type == TYPE_COMPOSE) return;
cornerMask.mask(canvas);
outliner.draw(canvas);
}
public void setLoading() {
title.setVisibility(GONE);
site.setVisibility(GONE);
thumbnail.setVisibility(GONE);
spinner.setVisibility(VISIBLE);
}
public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull LinkPreview linkPreview, boolean showThumbnail) {
title.setVisibility(VISIBLE);
site.setVisibility(VISIBLE);
thumbnail.setVisibility(VISIBLE);
spinner.setVisibility(GONE);
title.setText(linkPreview.getTitle());
HttpUrl url = HttpUrl.parse(linkPreview.getUrl());
if (url != null) {
site.setText(url.topPrivateDomain());
}
if (showThumbnail && linkPreview.getThumbnail().isPresent()) {
thumbnail.setVisibility(VISIBLE);
thumbnail.setImageResource(glideRequests, new ImageSlide(getContext(), linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION, false);
thumbnail.showDownloadText(false);
} else {
thumbnail.setVisibility(GONE);
}
}
public void setCorners(int topLeft, int topRight) {
cornerMask.setRadii(topLeft, topRight, 0, 0);
outliner.setRadii(topLeft, topRight, 0, 0);
thumbnail.setCorners(topLeft, defaultRadius, defaultRadius, defaultRadius);
postInvalidate();
}
public void setCloseClickedListener(@Nullable CloseClickedListener closeClickedListener) {
this.closeClickedListener = closeClickedListener;
}
public void setDownloadClickedListener(SlidesClickedListener listener) {
thumbnail.setDownloadClickListener(listener);
}
public interface CloseClickedListener {
void onCloseClicked();
}
}

View File

@@ -0,0 +1,86 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class MaskView extends View {
private View target;
private ViewGroup activityContentView;
private Paint maskPaint;
private Rect drawingRect = new Rect();
private final ViewTreeObserver.OnDrawListener onDrawListener = this::invalidate;
public MaskView(@NonNull Context context) {
super(context);
}
public MaskView(@NonNull Context context, @Nullable AttributeSet attributeSet) {
super(context, attributeSet);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
maskPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
setLayerType(LAYER_TYPE_HARDWARE, maskPaint);
maskPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT));
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
activityContentView = getRootView().findViewById(android.R.id.content);
}
public void setTarget(@Nullable View target) {
if (this.target != null) {
this.target.getViewTreeObserver().removeOnDrawListener(onDrawListener);
}
this.target = target;
if (this.target != null) {
this.target.getViewTreeObserver().addOnDrawListener(onDrawListener);
}
invalidate();
}
@Override
protected void onDraw(@NonNull Canvas canvas) {
super.onDraw(canvas);
if (target == null) {
return;
}
target.getDrawingRect(drawingRect);
activityContentView.offsetDescendantRectToMyCoords(target, drawingRect);
Bitmap mask = Bitmap.createBitmap(target.getWidth(), target.getHeight(), Bitmap.Config.ARGB_8888);
Canvas maskCanvas = new Canvas(mask);
target.draw(maskCanvas);
canvas.drawBitmap(mask, 0, drawingRect.top, maskPaint);
mask.recycle();
}
}

View File

@@ -0,0 +1,44 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import org.thoughtcrime.securesms.R;
public class MaxHeightFrameLayout extends FrameLayout {
private final int maxHeight;
public MaxHeightFrameLayout(@NonNull Context context) {
this(context, null);
}
public MaxHeightFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public MaxHeightFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
if (attrs != null) {
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MaxHeightFrameLayout);
maxHeight = a.getDimensionPixelSize(R.styleable.MaxHeightFrameLayout_mhfl_maxHeight, 0);
a.recycle();
} else {
maxHeight = 0;
}
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, Math.min(bottom, top + maxHeight));
}
}

View File

@@ -0,0 +1,42 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import androidx.annotation.Nullable;
import android.util.AttributeSet;
import android.widget.ScrollView;
import org.thoughtcrime.securesms.R;
public class MaxHeightScrollView extends ScrollView {
private int maxHeight = -1;
public MaxHeightScrollView(Context context) {
super(context);
initialize(null);
}
public MaxHeightScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
initialize(attrs);
}
private void initialize(@Nullable AttributeSet attrs) {
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.MaxHeightScrollView, 0, 0);
maxHeight = typedArray.getDimensionPixelOffset(R.styleable.MaxHeightScrollView_scrollView_maxHeight, -1);
typedArray.recycle();
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (maxHeight >= 0) {
heightMeasureSpec = MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST);
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
}

View File

@@ -0,0 +1,270 @@
package org.thoughtcrime.securesms.components;
import android.Manifest;
import android.content.Context;
import android.graphics.PorterDuff;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.Animation;
import android.view.animation.AnimationSet;
import android.view.animation.AnticipateOvershootInterpolator;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.LinearInterpolator;
import android.view.animation.OvershootInterpolator;
import android.view.animation.ScaleAnimation;
import android.view.animation.TranslateAnimation;
import android.widget.FrameLayout;
import android.widget.ImageView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.util.ViewUtil;
public final class MicrophoneRecorderView extends FrameLayout implements View.OnTouchListener {
enum State {
NOT_RUNNING,
RUNNING_HELD,
RUNNING_LOCKED
}
public static final int ANIMATION_DURATION = 200;
private FloatingRecordButton floatingRecordButton;
private LockDropTarget lockDropTarget;
private @Nullable Listener listener;
private @NonNull State state = State.NOT_RUNNING;
public MicrophoneRecorderView(Context context) {
super(context);
}
public MicrophoneRecorderView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public void onFinishInflate() {
super.onFinishInflate();
floatingRecordButton = new FloatingRecordButton(getContext(), findViewById(R.id.quick_audio_fab));
lockDropTarget = new LockDropTarget (getContext(), findViewById(R.id.lock_drop_target));
View recordButton = ViewUtil.findById(this, R.id.quick_audio_toggle);
recordButton.setOnTouchListener(this);
}
public void cancelAction() {
if (state != State.NOT_RUNNING) {
state = State.NOT_RUNNING;
hideUi();
if (listener != null) listener.onRecordCanceled();
}
}
public boolean isRecordingLocked() {
return state == State.RUNNING_LOCKED;
}
private void lockAction() {
if (state == State.RUNNING_HELD) {
state = State.RUNNING_LOCKED;
hideUi();
if (listener != null) listener.onRecordLocked();
}
}
public void unlockAction() {
if (state == State.RUNNING_LOCKED) {
state = State.NOT_RUNNING;
hideUi();
if (listener != null) listener.onRecordReleased();
}
}
private void hideUi() {
floatingRecordButton.hide();
lockDropTarget.hide();
}
@Override
public boolean onTouch(View v, final MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (!Permissions.hasAll(getContext(), Manifest.permission.RECORD_AUDIO)) {
if (listener != null) listener.onRecordPermissionRequired();
} else {
state = State.RUNNING_HELD;
floatingRecordButton.display(event.getX(), event.getY());
lockDropTarget.display();
if (listener != null) listener.onRecordPressed();
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
if (this.state == State.RUNNING_HELD) {
state = State.NOT_RUNNING;
hideUi();
if (listener != null) listener.onRecordReleased();
}
break;
case MotionEvent.ACTION_MOVE:
if (this.state == State.RUNNING_HELD) {
this.floatingRecordButton.moveTo(event.getX(), event.getY());
if (listener != null) listener.onRecordMoved(floatingRecordButton.lastOffsetX, event.getRawX());
int dimensionPixelSize = getResources().getDimensionPixelSize(R.dimen.recording_voice_lock_target);
if (floatingRecordButton.lastOffsetY <= dimensionPixelSize) {
lockAction();
}
}
break;
}
return false;
}
public void setListener(@Nullable Listener listener) {
this.listener = listener;
}
public interface Listener {
void onRecordPressed();
void onRecordReleased();
void onRecordCanceled();
void onRecordLocked();
void onRecordMoved(float offsetX, float absoluteX);
void onRecordPermissionRequired();
}
private static class FloatingRecordButton {
private final ImageView recordButtonFab;
private float startPositionX;
private float startPositionY;
private float lastOffsetX;
private float lastOffsetY;
FloatingRecordButton(Context context, ImageView recordButtonFab) {
this.recordButtonFab = recordButtonFab;
this.recordButtonFab.getBackground().setColorFilter(context.getResources()
.getColor(R.color.red_500),
PorterDuff.Mode.SRC_IN);
}
void display(float x, float y) {
this.startPositionX = x;
this.startPositionY = y;
recordButtonFab.setVisibility(View.VISIBLE);
AnimationSet animation = new AnimationSet(true);
animation.addAnimation(new TranslateAnimation(Animation.ABSOLUTE, 0,
Animation.ABSOLUTE, 0,
Animation.ABSOLUTE, 0,
Animation.ABSOLUTE, 0));
animation.addAnimation(new ScaleAnimation(.5f, 1f, .5f, 1f,
Animation.RELATIVE_TO_SELF, .5f,
Animation.RELATIVE_TO_SELF, .5f));
animation.setDuration(ANIMATION_DURATION);
animation.setInterpolator(new OvershootInterpolator());
recordButtonFab.startAnimation(animation);
}
void moveTo(float x, float y) {
lastOffsetX = getXOffset(x);
lastOffsetY = getYOffset(y);
if (Math.abs(lastOffsetX) > Math.abs(lastOffsetY)) {
lastOffsetY = 0;
} else {
lastOffsetX = 0;
}
recordButtonFab.setTranslationX(lastOffsetX);
recordButtonFab.setTranslationY(lastOffsetY);
}
void hide() {
recordButtonFab.setTranslationX(0);
recordButtonFab.setTranslationY(0);
if (recordButtonFab.getVisibility() != VISIBLE) return;
AnimationSet animation = new AnimationSet(false);
Animation scaleAnimation = new ScaleAnimation(1, 0.5f, 1, 0.5f,
Animation.RELATIVE_TO_SELF, 0.5f,
Animation.RELATIVE_TO_SELF, 0.5f);
Animation translateAnimation = new TranslateAnimation(Animation.ABSOLUTE, lastOffsetX,
Animation.ABSOLUTE, 0,
Animation.ABSOLUTE, lastOffsetY,
Animation.ABSOLUTE, 0);
scaleAnimation.setInterpolator(new AnticipateOvershootInterpolator(1.5f));
translateAnimation.setInterpolator(new DecelerateInterpolator());
animation.addAnimation(scaleAnimation);
animation.addAnimation(translateAnimation);
animation.setDuration(ANIMATION_DURATION);
animation.setInterpolator(new AnticipateOvershootInterpolator(1.5f));
recordButtonFab.setVisibility(View.GONE);
recordButtonFab.clearAnimation();
recordButtonFab.startAnimation(animation);
}
private float getXOffset(float x) {
return ViewCompat.getLayoutDirection(recordButtonFab) == ViewCompat.LAYOUT_DIRECTION_LTR ?
-Math.max(0, this.startPositionX - x) : Math.max(0, x - this.startPositionX);
}
private float getYOffset(float y) {
return Math.min(0, y - this.startPositionY);
}
}
private static class LockDropTarget {
private final View lockDropTarget;
private final int dropTargetPosition;
LockDropTarget(Context context, View lockDropTarget) {
this.lockDropTarget = lockDropTarget;
this.dropTargetPosition = context.getResources().getDimensionPixelSize(R.dimen.recording_voice_lock_target);
}
void display() {
lockDropTarget.setScaleX(1);
lockDropTarget.setScaleY(1);
lockDropTarget.setAlpha(0);
lockDropTarget.setTranslationY(0);
lockDropTarget.setVisibility(VISIBLE);
lockDropTarget.animate()
.setStartDelay(ANIMATION_DURATION * 2)
.setDuration(ANIMATION_DURATION)
.setInterpolator(new DecelerateInterpolator())
.translationY(dropTargetPosition)
.alpha(1)
.start();
}
void hide() {
lockDropTarget.animate()
.setStartDelay(0)
.setDuration(ANIMATION_DURATION)
.setInterpolator(new LinearInterpolator())
.scaleX(0).scaleY(0)
.start();
}
}
}

View File

@@ -0,0 +1,54 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.graphics.Canvas;
import androidx.annotation.NonNull;
import androidx.annotation.UiThread;
import android.util.AttributeSet;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.GlideRequest;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.util.ThemeUtil;
import static com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade;
public class OutlinedThumbnailView extends ThumbnailView {
private CornerMask cornerMask;
private Outliner outliner;
public OutlinedThumbnailView(Context context) {
super(context);
init();
}
public OutlinedThumbnailView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
private void init() {
cornerMask = new CornerMask(this);
outliner = new Outliner();
outliner.setColor(ThemeUtil.getThemedColor(getContext(), R.attr.conversation_item_image_outline_color));
setRadius(0);
setWillNotDraw(false);
}
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
cornerMask.mask(canvas);
outliner.draw(canvas);
}
public void setCorners(int topLeft, int topRight, int bottomRight, int bottomLeft) {
cornerMask.setRadii(topLeft, topRight, bottomRight, bottomLeft);
outliner.setRadii(topLeft, topRight, bottomRight, bottomLeft);
postInvalidate();
}
}

View File

@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.components;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
public class Outliner {
private final float[] radii = new float[8];
private final Path corners = new Path();
private final RectF bounds = new RectF();
private final Paint outlinePaint = new Paint();
{
outlinePaint.setStyle(Paint.Style.STROKE);
outlinePaint.setStrokeWidth(1f);
outlinePaint.setAntiAlias(true);
}
public void setColor(@ColorInt int color) {
outlinePaint.setColor(color);
}
public void draw(Canvas canvas) {
draw(canvas, 0, canvas.getWidth(), canvas.getHeight(), 0);
}
public void draw(Canvas canvas, int top, int right, int bottom, int left) {
final float halfStrokeWidth = outlinePaint.getStrokeWidth() / 2;
bounds.left = left + halfStrokeWidth;
bounds.top = top + halfStrokeWidth;
bounds.right = right - halfStrokeWidth;
bounds.bottom = bottom - halfStrokeWidth;
corners.reset();
corners.addRoundRect(bounds, radii, Path.Direction.CW);
canvas.drawPath(corners, outlinePaint);
}
public void setRadius(int radius) {
setRadii(radius, radius, radius, radius);
}
public void setRadii(int topLeft, int topRight, int bottomRight, int bottomLeft) {
radii[0] = radii[1] = topLeft;
radii[2] = radii[3] = topRight;
radii[4] = radii[5] = bottomRight;
radii[6] = radii[7] = bottomLeft;
}
}

View File

@@ -0,0 +1,173 @@
/**
* Copyright (C) 2011 Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.components;
import android.content.Context;
import androidx.annotation.NonNull;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.AdapterView;
import android.widget.RelativeLayout;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contacts.RecipientsAdapter;
import org.thoughtcrime.securesms.contacts.RecipientsEditor;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
import java.util.LinkedList;
import java.util.List;
import java.util.StringTokenizer;
/**
* Panel component combining both an editable field with a button for
* a list-based contact selector.
*
* @author Moxie Marlinspike
*/
public class PushRecipientsPanel extends RelativeLayout implements RecipientForeverObserver {
private final String TAG = PushRecipientsPanel.class.getSimpleName();
private RecipientsPanelChangedListener panelChangeListener;
private RecipientsEditor recipientsText;
private View panel;
private static final int RECIPIENTS_MAX_LENGTH = 312;
public PushRecipientsPanel(Context context) {
super(context);
initialize();
}
public PushRecipientsPanel(Context context, AttributeSet attrs) {
super(context, attrs);
initialize();
}
public PushRecipientsPanel(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
initialize();
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
Stream.of(getRecipients()).map(Recipient::live).forEach(r -> r.removeForeverObserver(this));
}
public List<Recipient> getRecipients() {
String rawText = recipientsText.getText().toString();
return getRecipientsFromString(getContext(), rawText);
}
public void disable() {
recipientsText.setText("");
panel.setVisibility(View.GONE);
}
public void setPanelChangeListener(RecipientsPanelChangedListener panelChangeListener) {
this.panelChangeListener = panelChangeListener;
}
private void initialize() {
LayoutInflater inflater = (LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
inflater.inflate(R.layout.push_recipients_panel, this, true);
View imageButton = findViewById(R.id.contacts_button);
((MarginLayoutParams) imageButton.getLayoutParams()).topMargin = 0;
panel = findViewById(R.id.recipients_panel);
initRecipientsEditor();
}
private void initRecipientsEditor() {
this.recipientsText = (RecipientsEditor)findViewById(R.id.recipients_text);
List<Recipient> recipients = getRecipients();
Stream.of(recipients).map(Recipient::live).forEach(r -> r.observeForever(this));
recipientsText.setAdapter(new RecipientsAdapter(this.getContext()));
recipientsText.populate(recipients);
recipientsText.setOnFocusChangeListener(new FocusChangedListener());
recipientsText.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) {
if (panelChangeListener != null) {
panelChangeListener.onRecipientsPanelUpdate(getRecipients());
}
recipientsText.setText("");
}
});
}
private @NonNull List<Recipient> getRecipientsFromString(Context context, @NonNull String rawText) {
StringTokenizer tokenizer = new StringTokenizer(rawText, ",");
List<Recipient> recipients = new LinkedList<>();
while (tokenizer.hasMoreTokens()) {
String token = tokenizer.nextToken().trim();
if (!TextUtils.isEmpty(token)) {
if (hasBracketedNumber(token)) recipients.add(Recipient.external(context, parseBracketedNumber(token)));
else recipients.add(Recipient.external(context, token));
}
}
return recipients;
}
private boolean hasBracketedNumber(String recipient) {
int openBracketIndex = recipient.indexOf('<');
return (openBracketIndex != -1) &&
(recipient.indexOf('>', openBracketIndex) != -1);
}
private String parseBracketedNumber(String recipient) {
int begin = recipient.indexOf('<');
int end = recipient.indexOf('>', begin);
String value = recipient.substring(begin + 1, end);
return value;
}
@Override
public void onRecipientChanged(@NonNull Recipient recipient) {
recipientsText.populate(getRecipients());
}
private class FocusChangedListener implements View.OnFocusChangeListener {
public void onFocusChange(View v, boolean hasFocus) {
if (!hasFocus && (panelChangeListener != null)) {
panelChangeListener.onRecipientsPanelUpdate(getRecipients());
}
}
}
public interface RecipientsPanelChangedListener {
public void onRecipientsPanelUpdate(List<Recipient> recipients);
}
}

View File

@@ -0,0 +1,285 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import com.annimon.stream.Stream;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import java.util.List;
public class QuoteView extends FrameLayout implements RecipientForeverObserver {
private static final String TAG = QuoteView.class.getSimpleName();
private static final int MESSAGE_TYPE_PREVIEW = 0;
private static final int MESSAGE_TYPE_OUTGOING = 1;
private static final int MESSAGE_TYPE_INCOMING = 2;
private ViewGroup mainView;
private ViewGroup footerView;
private TextView authorView;
private TextView bodyView;
private ImageView quoteBarView;
private ImageView thumbnailView;
private View attachmentVideoOverlayView;
private ViewGroup attachmentContainerView;
private TextView attachmentNameView;
private ImageView dismissView;
private long id;
private LiveRecipient author;
private String body;
private TextView mediaDescriptionText;
private TextView missingLinkText;
private SlideDeck attachments;
private int messageType;
private int largeCornerRadius;
private int smallCornerRadius;
private CornerMask cornerMask;
public QuoteView(Context context) {
super(context);
initialize(null);
}
public QuoteView(Context context, AttributeSet attrs) {
super(context, attrs);
initialize(attrs);
}
public QuoteView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initialize(attrs);
}
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public QuoteView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
initialize(attrs);
}
private void initialize(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.quote_view, this);
this.mainView = findViewById(R.id.quote_main);
this.footerView = findViewById(R.id.quote_missing_footer);
this.authorView = findViewById(R.id.quote_author);
this.bodyView = findViewById(R.id.quote_text);
this.quoteBarView = findViewById(R.id.quote_bar);
this.thumbnailView = findViewById(R.id.quote_thumbnail);
this.attachmentVideoOverlayView = findViewById(R.id.quote_video_overlay);
this.attachmentContainerView = findViewById(R.id.quote_attachment_container);
this.attachmentNameView = findViewById(R.id.quote_attachment_name);
this.dismissView = findViewById(R.id.quote_dismiss);
this.mediaDescriptionText = findViewById(R.id.media_type);
this.missingLinkText = findViewById(R.id.quote_missing_text);
this.largeCornerRadius = getResources().getDimensionPixelSize(R.dimen.quote_corner_radius_large);
this.smallCornerRadius = getResources().getDimensionPixelSize(R.dimen.quote_corner_radius_bottom);
cornerMask = new CornerMask(this);
cornerMask.setRadii(largeCornerRadius, largeCornerRadius, smallCornerRadius, smallCornerRadius);
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.QuoteView, 0, 0);
int primaryColor = typedArray.getColor(R.styleable.QuoteView_quote_colorPrimary, Color.BLACK);
int secondaryColor = typedArray.getColor(R.styleable.QuoteView_quote_colorSecondary, Color.BLACK);
messageType = typedArray.getInt(R.styleable.QuoteView_message_type, 0);
typedArray.recycle();
dismissView.setVisibility(messageType == MESSAGE_TYPE_PREVIEW ? VISIBLE : GONE);
authorView.setTextColor(primaryColor);
bodyView.setTextColor(primaryColor);
attachmentNameView.setTextColor(primaryColor);
mediaDescriptionText.setTextColor(secondaryColor);
missingLinkText.setTextColor(primaryColor);
if (messageType == MESSAGE_TYPE_PREVIEW) {
int radius = getResources().getDimensionPixelOffset(R.dimen.quote_corner_radius_preview);
cornerMask.setTopLeftRadius(radius);
cornerMask.setTopRightRadius(radius);
}
}
dismissView.setOnClickListener(view -> setVisibility(GONE));
}
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
cornerMask.mask(canvas);
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
if (author != null) author.removeForeverObserver(this);
}
public void setQuote(GlideRequests glideRequests,
long id,
@NonNull Recipient author,
@Nullable String body,
boolean originalMissing,
@NonNull SlideDeck attachments,
boolean isViewOnce)
{
if (this.author != null) this.author.removeForeverObserver(this);
this.id = id;
this.author = author.live();
this.body = body;
this.attachments = attachments;
this.author.observeForever(this);
setQuoteAuthor(author);
setQuoteText(body, attachments, isViewOnce);
setQuoteAttachment(glideRequests, attachments);
setQuoteMissingFooter(originalMissing);
}
public void setTopCornerSizes(boolean topLeftLarge, boolean topRightLarge) {
cornerMask.setTopLeftRadius(topLeftLarge ? largeCornerRadius : smallCornerRadius);
cornerMask.setTopRightRadius(topRightLarge ? largeCornerRadius : smallCornerRadius);
}
public void dismiss() {
if (this.author != null) this.author.removeForeverObserver(this);
this.id = 0;
this.author = null;
this.body = null;
setVisibility(GONE);
}
@Override
public void onRecipientChanged(@NonNull Recipient recipient) {
setQuoteAuthor(recipient);
}
private void setQuoteAuthor(@NonNull Recipient author) {
boolean outgoing = messageType != MESSAGE_TYPE_INCOMING;
authorView.setText(author.isLocalNumber() ? getContext().getString(R.string.QuoteView_you)
: author.toShortString(getContext()));
// We use the raw color resource because Android 4.x was struggling with tints here
quoteBarView.setImageResource(author.getColor().toQuoteBarColorResource(getContext(), outgoing));
mainView.setBackgroundColor(author.getColor().toQuoteBackgroundColor(getContext(), outgoing));
}
private void setQuoteText(@Nullable String body, @NonNull SlideDeck attachments, boolean isViewOnce) {
if (!TextUtils.isEmpty(body) || !attachments.containsMediaSlide()) {
bodyView.setVisibility(VISIBLE);
bodyView.setText(body == null ? "" : body);
mediaDescriptionText.setVisibility(GONE);
return;
}
bodyView.setVisibility(GONE);
mediaDescriptionText.setVisibility(VISIBLE);
List<Slide> audioSlides = Stream.of(attachments.getSlides()).filter(Slide::hasAudio).limit(1).toList();
List<Slide> documentSlides = Stream.of(attachments.getSlides()).filter(Slide::hasDocument).limit(1).toList();
List<Slide> imageSlides = Stream.of(attachments.getSlides()).filter(Slide::hasImage).limit(1).toList();
List<Slide> videoSlides = Stream.of(attachments.getSlides()).filter(Slide::hasVideo).limit(1).toList();
List<Slide> stickerSlides = Stream.of(attachments.getSlides()).filter(Slide::hasSticker).limit(1).toList();
// Given that most types have images, we specifically check images last
if (isViewOnce) {
mediaDescriptionText.setText(R.string.QuoteView_media);
} else if (!audioSlides.isEmpty()) {
mediaDescriptionText.setText(R.string.QuoteView_audio);
} else if (!documentSlides.isEmpty()) {
mediaDescriptionText.setVisibility(GONE);
} else if (!videoSlides.isEmpty()) {
mediaDescriptionText.setText(R.string.QuoteView_video);
} else if (!stickerSlides.isEmpty()) {
mediaDescriptionText.setText(R.string.QuoteView_sticker);
} else if (!imageSlides.isEmpty()) {
mediaDescriptionText.setText(R.string.QuoteView_photo);
}
}
private void setQuoteAttachment(@NonNull GlideRequests glideRequests, @NonNull SlideDeck slideDeck) {
List<Slide> imageVideoSlides = Stream.of(slideDeck.getSlides()).filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).limit(1).toList();
List<Slide> documentSlides = Stream.of(attachments.getSlides()).filter(Slide::hasDocument).limit(1).toList();
attachmentVideoOverlayView.setVisibility(GONE);
if (!imageVideoSlides.isEmpty() && imageVideoSlides.get(0).getThumbnailUri() != null) {
thumbnailView.setVisibility(VISIBLE);
attachmentContainerView.setVisibility(GONE);
dismissView.setBackgroundResource(R.drawable.dismiss_background);
if (imageVideoSlides.get(0).hasVideo()) {
attachmentVideoOverlayView.setVisibility(VISIBLE);
}
glideRequests.load(new DecryptableUri(imageVideoSlides.get(0).getThumbnailUri()))
.centerCrop()
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.into(thumbnailView);
} else if (!documentSlides.isEmpty()){
thumbnailView.setVisibility(GONE);
attachmentContainerView.setVisibility(VISIBLE);
attachmentNameView.setText(documentSlides.get(0).getFileName().or(""));
} else {
thumbnailView.setVisibility(GONE);
attachmentContainerView.setVisibility(GONE);
dismissView.setBackgroundDrawable(null);
}
if (ThemeUtil.isDarkTheme(getContext())) {
dismissView.setBackgroundResource(R.drawable.circle_alpha);
}
}
private void setQuoteMissingFooter(boolean missing) {
footerView.setVisibility(missing ? VISIBLE : GONE);
footerView.setBackgroundColor(author.get().getColor().toQuoteFooterColor(getContext(), messageType != MESSAGE_TYPE_INCOMING));
}
public long getQuoteId() {
return id;
}
public Recipient getAuthor() {
return author.get();
}
public String getBody() {
return body;
}
public List<Attachment> getAttachments() {
return attachments.asAttachments();
}
}

View File

@@ -0,0 +1,88 @@
package org.thoughtcrime.securesms.components;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import androidx.appcompat.app.AlertDialog;
import org.thoughtcrime.securesms.logging.Log;
import android.widget.Toast;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.util.concurrent.TimeUnit;
public class RatingManager {
private static final int DAYS_SINCE_INSTALL_THRESHOLD = 7;
private static final int DAYS_UNTIL_REPROMPT_THRESHOLD = 4;
private static final String TAG = RatingManager.class.getSimpleName();
public static void showRatingDialogIfNecessary(Context context) {
if (!TextSecurePreferences.isRatingEnabled(context)) return;
long daysSinceInstall = getDaysSinceInstalled(context);
long laterTimestamp = TextSecurePreferences.getRatingLaterTimestamp(context);
if (daysSinceInstall >= DAYS_SINCE_INSTALL_THRESHOLD &&
System.currentTimeMillis() >= laterTimestamp)
{
showRatingDialog(context);
}
}
private static void showRatingDialog(final Context context) {
new AlertDialog.Builder(context)
.setTitle(R.string.RatingManager_rate_this_app)
.setMessage(R.string.RatingManager_if_you_enjoy_using_this_app_please_take_a_moment)
.setPositiveButton(R.string.RatingManager_rate_now, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
TextSecurePreferences.setRatingEnabled(context, false);
startPlayStore(context);
}
})
.setNegativeButton(R.string.RatingManager_no_thanks, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
TextSecurePreferences.setRatingEnabled(context, false);
}
})
.setNeutralButton(R.string.RatingManager_later, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
long waitUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(DAYS_UNTIL_REPROMPT_THRESHOLD);
TextSecurePreferences.setRatingLaterTimestamp(context, waitUntil);
}
})
.show();
}
private static void startPlayStore(Context context) {
Uri uri = Uri.parse("market://details?id=" + context.getPackageName());
try {
context.startActivity(new Intent(Intent.ACTION_VIEW, uri));
} catch (ActivityNotFoundException e) {
Log.w(TAG, e);
Toast.makeText(context, R.string.RatingManager_whoops_the_play_store_app_does_not_appear_to_be_installed, Toast.LENGTH_LONG).show();
}
}
private static long getDaysSinceInstalled(Context context) {
try {
long installTimestamp = context.getPackageManager()
.getPackageInfo(context.getPackageName(), 0)
.firstInstallTime;
return TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis() - installTimestamp);
} catch (PackageManager.NameNotFoundException e) {
Log.w(TAG, e);
return 0;
}
}
}

View File

@@ -0,0 +1,169 @@
package org.thoughtcrime.securesms.components;
import android.annotation.TargetApi;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.provider.MediaStore;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import com.bumptech.glide.load.Key;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
import com.bumptech.glide.signature.MediaStoreSignature;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
import org.thoughtcrime.securesms.database.loaders.RecentPhotosLoader;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.io.File;
public class RecentPhotoViewRail extends FrameLayout implements LoaderManager.LoaderCallbacks<Cursor> {
@NonNull private final RecyclerView recyclerView;
@Nullable private OnItemClickedListener listener;
public RecentPhotoViewRail(Context context) {
this(context, null);
}
public RecentPhotoViewRail(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public RecentPhotoViewRail(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
inflate(context, R.layout.recent_photo_view, this);
this.recyclerView = ViewUtil.findById(this, R.id.photo_list);
this.recyclerView.setLayoutManager(new LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false));
this.recyclerView.setItemAnimator(new DefaultItemAnimator());
}
public void setListener(@Nullable OnItemClickedListener listener) {
this.listener = listener;
if (this.recyclerView.getAdapter() != null) {
((RecentPhotoAdapter)this.recyclerView.getAdapter()).setListener(listener);
}
}
@Override
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
return new RecentPhotosLoader(getContext());
}
@Override
public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor data) {
this.recyclerView.setAdapter(new RecentPhotoAdapter(getContext(), data, RecentPhotosLoader.BASE_URL, listener));
}
@Override
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
((CursorRecyclerViewAdapter)this.recyclerView.getAdapter()).changeCursor(null);
}
private static class RecentPhotoAdapter extends CursorRecyclerViewAdapter<RecentPhotoAdapter.RecentPhotoViewHolder> {
@SuppressWarnings("unused")
private static final String TAG = RecentPhotoAdapter.class.getSimpleName();
@NonNull private final Uri baseUri;
@Nullable private OnItemClickedListener clickedListener;
private RecentPhotoAdapter(@NonNull Context context, @NonNull Cursor cursor, @NonNull Uri baseUri, @Nullable OnItemClickedListener listener) {
super(context, cursor);
this.baseUri = baseUri;
this.clickedListener = listener;
}
@Override
public RecentPhotoViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) {
View itemView = LayoutInflater.from(parent.getContext())
.inflate(R.layout.recent_photo_view_item, parent, false);
return new RecentPhotoViewHolder(itemView);
}
@Override
public void onBindItemViewHolder(RecentPhotoViewHolder viewHolder, @NonNull Cursor cursor) {
viewHolder.imageView.setImageDrawable(null);
String path = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATA));
long dateTaken = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_TAKEN));
long dateModified = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_MODIFIED));
String mimeType = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.MIME_TYPE));
String bucketId = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.BUCKET_ID));
int orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.ORIENTATION));
long size = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.SIZE));
int width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation)));
int height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation)));
final Uri uri = Uri.fromFile(new File(path));
Key signature = new MediaStoreSignature(mimeType, dateModified, orientation);
GlideApp.with(getContext().getApplicationContext())
.load(uri)
.signature(signature)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transition(DrawableTransitionOptions.withCrossFade())
.into(viewHolder.imageView);
viewHolder.imageView.setOnClickListener(v -> {
if (clickedListener != null) clickedListener.onItemClicked(uri, mimeType, bucketId, dateTaken, width, height, size);
});
}
@TargetApi(16)
@SuppressWarnings("SuspiciousNameCombination")
private String getWidthColumn(int orientation) {
if (orientation == 0 || orientation == 180) return MediaStore.Images.ImageColumns.WIDTH;
else return MediaStore.Images.ImageColumns.HEIGHT;
}
@TargetApi(16)
@SuppressWarnings("SuspiciousNameCombination")
private String getHeightColumn(int orientation) {
if (orientation == 0 || orientation == 180) return MediaStore.Images.ImageColumns.HEIGHT;
else return MediaStore.Images.ImageColumns.WIDTH;
}
public void setListener(@Nullable OnItemClickedListener listener) {
this.clickedListener = listener;
}
static class RecentPhotoViewHolder extends RecyclerView.ViewHolder {
ImageView imageView;
RecentPhotoViewHolder(View itemView) {
super(itemView);
this.imageView = ViewUtil.findById(itemView, R.id.thumbnail);
}
}
}
public interface OnItemClickedListener {
void onItemClicked(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height, long size);
}
}

View File

@@ -0,0 +1,205 @@
/**
* Modified version of
* https://github.com/AndroidDeveloperLB/LollipopContactsRecyclerViewFastScroller
*
* Their license:
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thoughtcrime.securesms.components;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewTreeObserver;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
public final class RecyclerViewFastScroller extends LinearLayout {
private static final int BUBBLE_ANIMATION_DURATION = 100;
private static final int TRACK_SNAP_RANGE = 5;
@NonNull private final TextView bubble;
@NonNull private final View handle;
@Nullable private RecyclerView recyclerView;
private int height;
private ObjectAnimator currentAnimator;
private final RecyclerView.OnScrollListener onScrollListener = new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) {
if (handle.isSelected()) return;
final int offset = recyclerView.computeVerticalScrollOffset();
final int range = recyclerView.computeVerticalScrollRange();
final int extent = recyclerView.computeVerticalScrollExtent();
final int offsetRange = Math.max(range - extent, 1);
setBubbleAndHandlePosition((float) Util.clamp(offset, 0, offsetRange) / offsetRange);
}
};
public interface FastScrollAdapter {
CharSequence getBubbleText(int position);
}
public RecyclerViewFastScroller(final Context context) {
this(context, null);
}
public RecyclerViewFastScroller(final Context context, final AttributeSet attrs) {
super(context, attrs);
setOrientation(HORIZONTAL);
setClipChildren(false);
setScrollContainer(true);
inflate(context, R.layout.recycler_view_fast_scroller, this);
bubble = ViewUtil.findById(this, R.id.fastscroller_bubble);
handle = ViewUtil.findById(this, R.id.fastscroller_handle);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
height = h;
}
@Override
public boolean onTouchEvent(@NonNull MotionEvent event) {
final int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
if (event.getX() < ViewUtil.getX(handle) - handle.getPaddingLeft() ||
event.getY() < ViewUtil.getY(handle) - handle.getPaddingTop() ||
event.getY() > ViewUtil.getY(handle) + handle.getHeight() + handle.getPaddingBottom())
{
return false;
}
if (currentAnimator != null) {
currentAnimator.cancel();
}
if (bubble.getVisibility() != VISIBLE) {
showBubble();
}
handle.setSelected(true);
case MotionEvent.ACTION_MOVE:
final float y = event.getY();
setBubbleAndHandlePosition(y / height);
setRecyclerViewPosition(y);
return true;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
handle.setSelected(false);
hideBubble();
return true;
}
return super.onTouchEvent(event);
}
public void setRecyclerView(final @Nullable RecyclerView recyclerView) {
if (this.recyclerView != null) {
this.recyclerView.removeOnScrollListener(onScrollListener);
}
this.recyclerView = recyclerView;
if (recyclerView != null) {
recyclerView.addOnScrollListener(onScrollListener);
recyclerView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
@Override
public boolean onPreDraw() {
recyclerView.getViewTreeObserver().removeOnPreDrawListener(this);
if (handle.isSelected()) return true;
final int verticalScrollOffset = recyclerView.computeVerticalScrollOffset();
final int verticalScrollRange = recyclerView.computeVerticalScrollRange();
float proportion = (float)verticalScrollOffset / ((float)verticalScrollRange - height);
setBubbleAndHandlePosition(height * proportion);
return true;
}
});
}
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
if (recyclerView != null)
recyclerView.removeOnScrollListener(onScrollListener);
}
private void setRecyclerViewPosition(float y) {
if (recyclerView != null) {
final int itemCount = recyclerView.getAdapter().getItemCount();
float proportion;
if (ViewUtil.getY(handle) == 0) {
proportion = 0f;
} else if (ViewUtil.getY(handle) + handle.getHeight() >= height - TRACK_SNAP_RANGE) {
proportion = 1f;
} else {
proportion = y / (float)height;
}
final int targetPos = Util.clamp((int)(proportion * (float)itemCount), 0, itemCount - 1);
((LinearLayoutManager) recyclerView.getLayoutManager()).scrollToPositionWithOffset(targetPos, 0);
final CharSequence bubbleText = ((FastScrollAdapter) recyclerView.getAdapter()).getBubbleText(targetPos);
bubble.setText(bubbleText);
}
}
private void setBubbleAndHandlePosition(float y) {
final int handleHeight = handle.getHeight();
final int bubbleHeight = bubble.getHeight();
final int handleY = Util.clamp((int)((height - handleHeight) * y), 0, height - handleHeight);
ViewUtil.setY(handle, handleY);
ViewUtil.setY(bubble, Util.clamp(handleY - bubbleHeight - bubble.getPaddingBottom() + handleHeight,
0,
height - bubbleHeight));
}
private void showBubble() {
bubble.setVisibility(VISIBLE);
if (currentAnimator != null) currentAnimator.cancel();
currentAnimator = ObjectAnimator.ofFloat(bubble, "alpha", 0f, 1f).setDuration(BUBBLE_ANIMATION_DURATION);
currentAnimator.start();
}
private void hideBubble() {
if (currentAnimator != null) currentAnimator.cancel();
currentAnimator = ObjectAnimator.ofFloat(bubble, "alpha", 1f, 0f).setDuration(BUBBLE_ANIMATION_DURATION);
currentAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
bubble.setVisibility(INVISIBLE);
currentAnimator = null;
}
@Override
public void onAnimationCancel(Animator animation) {
super.onAnimationCancel(animation);
bubble.setVisibility(INVISIBLE);
currentAnimator = null;
}
});
currentAnimator.start();
}
}

View File

@@ -0,0 +1,79 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.ImageView;
import org.thoughtcrime.securesms.R;
public class RemovableEditableMediaView extends FrameLayout {
private final @NonNull ImageView remove;
private final @NonNull ImageView edit;
private final int removeSize;
private final int editSize;
private @Nullable View current;
public RemovableEditableMediaView(Context context) {
this(context, null);
}
public RemovableEditableMediaView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public RemovableEditableMediaView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
this.remove = (ImageView)LayoutInflater.from(context).inflate(R.layout.media_view_remove_button, this, false);
this.edit = (ImageView)LayoutInflater.from(context).inflate(R.layout.media_view_edit_button, this, false);
this.removeSize = getResources().getDimensionPixelSize(R.dimen.media_bubble_remove_button_size);
this.editSize = getResources().getDimensionPixelSize(R.dimen.media_bubble_edit_button_size);
this.remove.setVisibility(View.GONE);
this.edit.setVisibility(View.GONE);
}
@Override
public void onFinishInflate() {
super.onFinishInflate();
this.addView(remove);
this.addView(edit);
}
public void display(@Nullable View view, boolean editable) {
edit.setVisibility(editable ? View.VISIBLE : View.GONE);
if (view == current) return;
if (current != null) current.setVisibility(View.GONE);
if (view != null) {
view.setPadding(view.getPaddingLeft(), removeSize / 2, removeSize / 2, view.getPaddingRight());
edit.setPadding(0, 0, removeSize / 2, 0);
view.setVisibility(View.VISIBLE);
remove.setVisibility(View.VISIBLE);
} else {
remove.setVisibility(View.GONE);
edit.setVisibility(View.GONE);
}
current = view;
}
public void setRemoveClickListener(View.OnClickListener listener) {
this.remove.setOnClickListener(listener);
}
public void setEditClickListener(View.OnClickListener listener) {
this.edit.setOnClickListener(listener);
}
}

View File

@@ -0,0 +1,84 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import androidx.appcompat.widget.AppCompatImageButton;
import android.util.AttributeSet;
import android.view.HapticFeedbackConstants;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
public class RepeatableImageKey extends AppCompatImageButton {
private KeyEventListener listener;
public RepeatableImageKey(Context context) {
super(context);
init();
}
public RepeatableImageKey(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public RepeatableImageKey(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
setOnClickListener(new RepeaterClickListener());
setOnTouchListener(new RepeaterTouchListener());
}
public void setOnKeyEventListener(KeyEventListener listener) {
this.listener = listener;
}
private void notifyListener() {
if (this.listener != null) this.listener.onKeyEvent();
}
private class RepeaterClickListener implements OnClickListener {
@Override public void onClick(View v) {
notifyListener();
}
}
private class Repeater implements Runnable {
@Override
public void run() {
notifyListener();
postDelayed(this, ViewConfiguration.getKeyRepeatDelay());
}
}
private class RepeaterTouchListener implements OnTouchListener {
private final Repeater repeater;
RepeaterTouchListener() {
this.repeater = new Repeater();
}
@Override
public boolean onTouch(View view, MotionEvent motionEvent) {
switch (motionEvent.getAction()) {
case MotionEvent.ACTION_DOWN:
view.postDelayed(repeater, ViewConfiguration.getKeyRepeatTimeout());
performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
return false;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
view.removeCallbacks(repeater);
return false;
default:
return false;
}
}
}
public interface KeyEventListener {
void onKeyEvent();
}
}

View File

@@ -0,0 +1,155 @@
package org.thoughtcrime.securesms.components;
import android.animation.Animator;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.os.Build;
import androidx.annotation.MainThread;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.SearchView;
import androidx.appcompat.widget.Toolbar;
import androidx.core.content.ContextCompat;
import android.util.AttributeSet;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewAnimationUtils;
import android.widget.EditText;
import android.widget.LinearLayout;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
public class SearchToolbar extends LinearLayout {
private float x, y;
private MenuItem searchItem;
private SearchListener listener;
public SearchToolbar(Context context) {
super(context);
initialize();
}
public SearchToolbar(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
initialize();
}
public SearchToolbar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initialize();
}
private void initialize() {
inflate(getContext(), R.layout.search_toolbar, this);
setOrientation(VERTICAL);
Toolbar toolbar = findViewById(R.id.toolbar);
Drawable drawable = ContextCompat.getDrawable(getContext(), R.drawable.ic_arrow_left_24);
toolbar.setNavigationIcon(drawable);
toolbar.setCollapseIcon(drawable);
toolbar.inflateMenu(R.menu.conversation_list_search);
this.searchItem = toolbar.getMenu().findItem(R.id.action_filter_search);
SearchView searchView = (SearchView) searchItem.getActionView();
EditText searchText = searchView.findViewById(R.id.search_src_text);
searchView.setSubmitButtonEnabled(false);
if (searchText != null) searchText.setHint(R.string.SearchToolbar_search);
else searchView.setQueryHint(getResources().getString(R.string.SearchToolbar_search));
searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
@Override
public boolean onQueryTextSubmit(String query) {
if (listener != null) listener.onSearchTextChange(query);
return true;
}
@Override
public boolean onQueryTextChange(String newText) {
return onQueryTextSubmit(newText);
}
});
searchItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() {
@Override
public boolean onMenuItemActionExpand(MenuItem item) {
return true;
}
@Override
public boolean onMenuItemActionCollapse(MenuItem item) {
hide();
return true;
}
});
toolbar.setNavigationOnClickListener(v -> hide());
}
@MainThread
public void display(float x, float y) {
if (getVisibility() != View.VISIBLE) {
this.x = x;
this.y = y;
searchItem.expandActionView();
if (Build.VERSION.SDK_INT >= 21) {
Animator animator = ViewAnimationUtils.createCircularReveal(this, (int)x, (int)y, 0, getWidth());
animator.setDuration(400);
setVisibility(View.VISIBLE);
animator.start();
} else {
setVisibility(View.VISIBLE);
}
}
}
public void collapse() {
searchItem.collapseActionView();
}
@MainThread
private void hide() {
if (getVisibility() == View.VISIBLE) {
if (listener != null) listener.onSearchClosed();
if (Build.VERSION.SDK_INT >= 21) {
Animator animator = ViewAnimationUtils.createCircularReveal(this, (int)x, (int)y, getWidth(), 0);
animator.setDuration(400);
animator.addListener(new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
setVisibility(View.INVISIBLE);
}
});
animator.start();
} else {
setVisibility(View.INVISIBLE);
}
}
}
public boolean isVisible() {
return getVisibility() == View.VISIBLE;
}
@MainThread
public void setListener(SearchListener listener) {
this.listener = listener;
}
public interface SearchListener {
void onSearchTextChange(String text);
void onSearchClosed();
}
}

View File

@@ -0,0 +1,27 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.util.AttributeSet;
import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
/**
* Custom styled search view that we can insert into ActionBar menus
*/
public class SearchView extends androidx.appcompat.widget.SearchView {
public SearchView(@NonNull Context context) {
this(context, null);
}
public SearchView(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, R.attr.search_view_style);
}
public SearchView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
}

View File

@@ -0,0 +1,118 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.AppCompatImageButton;
import android.util.AttributeSet;
import android.view.View;
import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.TransportOptions;
import org.thoughtcrime.securesms.TransportOptions.OnTransportChangedListener;
import org.thoughtcrime.securesms.TransportOptionsPopup;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.whispersystems.libsignal.util.guava.Optional;
public class SendButton extends AppCompatImageButton
implements TransportOptions.OnTransportChangedListener,
TransportOptionsPopup.SelectedListener,
View.OnLongClickListener
{
private final TransportOptions transportOptions;
private Optional<TransportOptionsPopup> transportOptionsPopup = Optional.absent();
@SuppressWarnings("unused")
public SendButton(Context context) {
super(context);
this.transportOptions = initializeTransportOptions(false);
ViewUtil.mirrorIfRtl(this, getContext());
}
@SuppressWarnings("unused")
public SendButton(Context context, AttributeSet attrs) {
super(context, attrs);
this.transportOptions = initializeTransportOptions(false);
ViewUtil.mirrorIfRtl(this, getContext());
}
@SuppressWarnings("unused")
public SendButton(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
this.transportOptions = initializeTransportOptions(false);
ViewUtil.mirrorIfRtl(this, getContext());
}
private TransportOptions initializeTransportOptions(boolean media) {
if (isInEditMode()) return null;
TransportOptions transportOptions = new TransportOptions(getContext(), media);
transportOptions.addOnTransportChangedListener(this);
setOnLongClickListener(this);
return transportOptions;
}
private TransportOptionsPopup getTransportOptionsPopup() {
if (!transportOptionsPopup.isPresent()) {
transportOptionsPopup = Optional.of(new TransportOptionsPopup(getContext(), this, this));
}
return transportOptionsPopup.get();
}
public boolean isManualSelection() {
return transportOptions.isManualSelection();
}
public void addOnTransportChangedListener(OnTransportChangedListener listener) {
transportOptions.addOnTransportChangedListener(listener);
}
public TransportOption getSelectedTransport() {
return transportOptions.getSelectedTransport();
}
public void resetAvailableTransports(boolean isMediaMessage) {
transportOptions.reset(isMediaMessage);
}
public void disableTransport(TransportOption.Type type) {
transportOptions.disableTransport(type);
}
public void setDefaultTransport(TransportOption.Type type) {
transportOptions.setDefaultTransport(type);
}
public void setTransport(@NonNull TransportOption option) {
transportOptions.setSelectedTransport(option);
}
public void setDefaultSubscriptionId(Optional<Integer> subscriptionId) {
transportOptions.setDefaultSubscriptionId(subscriptionId);
}
@Override
public void onSelected(TransportOption option) {
transportOptions.setSelectedTransport(option);
getTransportOptionsPopup().dismiss();
}
@Override
public void onChange(TransportOption newTransport, boolean isManualSelection) {
setImageResource(newTransport.getDrawable());
setContentDescription(newTransport.getDescription());
}
@Override
public boolean onLongClick(View v) {
if (isEnabled() && transportOptions.getEnabledTransports().size() > 1) {
getTransportOptionsPopup().display(transportOptions.getEnabledTransports());
return true;
}
return false;
}
}

View File

@@ -0,0 +1,107 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.RectF;
import android.util.AttributeSet;
import android.view.View;
import org.thoughtcrime.securesms.R;
public class ShapeScrim extends View {
private enum ShapeType {
CIRCLE, SQUARE
}
private final Paint eraser;
private final ShapeType shape;
private final float radius;
private Bitmap scrim;
private Canvas scrimCanvas;
public ShapeScrim(Context context) {
this(context, null);
}
public ShapeScrim(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ShapeScrim(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
if (attrs != null) {
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ShapeScrim, 0, 0);
String shapeName = typedArray.getString(R.styleable.ShapeScrim_shape);
if ("square".equalsIgnoreCase(shapeName)) this.shape = ShapeType.SQUARE;
else if ("circle".equalsIgnoreCase(shapeName)) this.shape = ShapeType.CIRCLE;
else this.shape = ShapeType.SQUARE;
this.radius = typedArray.getFloat(R.styleable.ShapeScrim_radius, 0.4f);
typedArray.recycle();
} else {
this.shape = ShapeType.SQUARE;
this.radius = 0.4f;
}
this.eraser = new Paint();
this.eraser.setColor(0xFFFFFFFF);
this.eraser.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
}
@Override
public void onDraw(Canvas canvas) {
super.onDraw(canvas);
int shortDimension = getWidth() < getHeight() ? getWidth() : getHeight();
float drawRadius = shortDimension * radius;
if (scrimCanvas == null) {
scrim = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
scrimCanvas = new Canvas(scrim);
}
scrim.eraseColor(Color.TRANSPARENT);
scrimCanvas.drawColor(Color.parseColor("#55BDBDBD"));
if (shape == ShapeType.CIRCLE) drawCircle(scrimCanvas, drawRadius, eraser);
else drawSquare(scrimCanvas, drawRadius, eraser);
canvas.drawBitmap(scrim, 0, 0, null);
}
@Override
public void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
super.onSizeChanged(width, height, oldHeight, oldHeight);
if (width != oldWidth || height != oldHeight) {
scrim = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
scrimCanvas = new Canvas(scrim);
}
}
private void drawCircle(Canvas canvas, float radius, Paint eraser) {
canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius, eraser);
}
private void drawSquare(Canvas canvas, float radius, Paint eraser) {
float left = (getWidth() / 2 ) - radius;
float top = (getHeight() / 2) - radius;
float right = left + (radius * 2);
float bottom = top + (radius * 2);
RectF square = new RectF(left, top, right, bottom);
canvas.drawRoundRect(square, 25, 25, eraser);
}
}

View File

@@ -0,0 +1,233 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.net.Uri;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import android.util.AttributeSet;
import android.view.View;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import com.annimon.stream.Stream;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contactshare.ContactUtil;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
public class SharedContactView extends LinearLayout implements RecipientForeverObserver {
private ImageView avatarView;
private TextView nameView;
private TextView numberView;
private TextView actionButtonView;
private ConversationItemFooter footer;
private Contact contact;
private Locale locale;
private GlideRequests glideRequests;
private EventListener eventListener;
private CornerMask cornerMask;
private int bigCornerRadius;
private int smallCornerRadius;
private final Map<RecipientId, LiveRecipient> activeRecipients = new HashMap<>();
public SharedContactView(Context context) {
super(context);
initialize(null);
}
public SharedContactView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
initialize(attrs);
}
public SharedContactView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initialize(attrs);
}
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public SharedContactView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
initialize(attrs);
}
private void initialize(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.shared_contact_view, this);
avatarView = findViewById(R.id.contact_avatar);
nameView = findViewById(R.id.contact_name);
numberView = findViewById(R.id.contact_number);
actionButtonView = findViewById(R.id.contact_action_button);
footer = findViewById(R.id.contact_footer);
cornerMask = new CornerMask(this);
bigCornerRadius = getResources().getDimensionPixelOffset(R.dimen.message_corner_radius);
smallCornerRadius = getResources().getDimensionPixelOffset(R.dimen.message_corner_collapse_radius);
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.SharedContactView, 0, 0);
int titleColor = typedArray.getInt(R.styleable.SharedContactView_contact_titleColor, Color.BLACK);
int captionColor = typedArray.getInt(R.styleable.SharedContactView_contact_captionColor, Color.BLACK);
int iconColor = typedArray.getInt(R.styleable.SharedContactView_contact_footerIconColor, Color.BLACK);
float footerAlpha = typedArray.getFloat(R.styleable.SharedContactView_contact_footerAlpha, 1);
typedArray.recycle();
nameView.setTextColor(titleColor);
numberView.setTextColor(captionColor);
footer.setTextColor(captionColor);
footer.setIconColor(iconColor);
footer.setAlpha(footerAlpha);
}
}
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
cornerMask.mask(canvas);
}
public void setContact(@NonNull Contact contact, @NonNull GlideRequests glideRequests, @NonNull Locale locale) {
this.glideRequests = glideRequests;
this.locale = locale;
this.contact = contact;
Stream.of(activeRecipients.values()).forEach(recipient -> recipient.removeForeverObserver(this));
this.activeRecipients.clear();
presentContact(contact);
presentAvatar(contact.getAvatarAttachment() != null ? contact.getAvatarAttachment().getDataUri() : null);
presentActionButtons(ContactUtil.getRecipients(getContext(), contact));
for (LiveRecipient recipient : activeRecipients.values()) {
recipient.observeForever(this);
}
}
public void setSingularStyle() {
cornerMask.setBottomLeftRadius(bigCornerRadius);
cornerMask.setBottomRightRadius(bigCornerRadius);
}
public void setClusteredIncomingStyle() {
cornerMask.setBottomLeftRadius(smallCornerRadius);
cornerMask.setBottomRightRadius(bigCornerRadius);
}
public void setClusteredOutgoingStyle() {
cornerMask.setBottomLeftRadius(bigCornerRadius);
cornerMask.setBottomRightRadius(smallCornerRadius);
}
public void setEventListener(@NonNull EventListener eventListener) {
this.eventListener = eventListener;
}
public @NonNull View getAvatarView() {
return avatarView;
}
public ConversationItemFooter getFooter() {
return footer;
}
@Override
public void onRecipientChanged(@NonNull Recipient recipient) {
presentActionButtons(Collections.singletonList(recipient.getId()));
}
private void presentContact(@Nullable Contact contact) {
if (contact != null) {
nameView.setText(ContactUtil.getDisplayName(contact));
numberView.setText(ContactUtil.getDisplayNumber(contact, locale));
} else {
nameView.setText("");
numberView.setText("");
}
}
private void presentAvatar(@Nullable Uri uri) {
if (uri != null) {
glideRequests.load(new DecryptableUri(uri))
.fallback(R.drawable.ic_contact_picture)
.circleCrop()
.diskCacheStrategy(DiskCacheStrategy.ALL)
.dontAnimate()
.into(avatarView);
} else {
glideRequests.load(R.drawable.ic_contact_picture)
.circleCrop()
.diskCacheStrategy(DiskCacheStrategy.ALL)
.into(avatarView);
}
}
private void presentActionButtons(@NonNull List<RecipientId> recipients) {
for (RecipientId recipientId : recipients) {
activeRecipients.put(recipientId, Recipient.live(recipientId));
}
List<Recipient> pushUsers = new ArrayList<>(recipients.size());
List<Recipient> systemUsers = new ArrayList<>(recipients.size());
for (LiveRecipient recipient : activeRecipients.values()) {
if (recipient.get().getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) {
pushUsers.add(recipient.get());
} else if (recipient.get().isSystemContact()) {
systemUsers.add(recipient.get());
}
}
if (!pushUsers.isEmpty()) {
actionButtonView.setText(R.string.SharedContactView_message);
actionButtonView.setOnClickListener(v -> {
if (eventListener != null) {
eventListener.onMessageClicked(pushUsers);
}
});
} else if (!systemUsers.isEmpty()) {
actionButtonView.setText(R.string.SharedContactView_invite_to_signal);
actionButtonView.setOnClickListener(v -> {
if (eventListener != null) {
eventListener.onInviteClicked(systemUsers);
}
});
} else {
actionButtonView.setText(R.string.SharedContactView_add_to_contacts);
actionButtonView.setOnClickListener(v -> {
if (eventListener != null && contact != null) {
eventListener.onAddToContactsClicked(contact);
}
});
}
}
public interface EventListener {
void onAddToContactsClicked(@NonNull Contact contact);
void onInviteClicked(@NonNull List<Recipient> choices);
void onMessageClicked(@NonNull List<Recipient> choices);
}
}

View File

@@ -0,0 +1,46 @@
package org.thoughtcrime.securesms.components;
import android.annotation.TargetApi;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.BitmapFactory;
import android.os.Build.VERSION_CODES;
import android.util.AttributeSet;
import android.widget.FrameLayout;
import org.thoughtcrime.securesms.R;
public class SquareFrameLayout extends FrameLayout {
private final boolean squareHeight;
@SuppressWarnings("unused")
public SquareFrameLayout(Context context) {
this(context, null);
}
@SuppressWarnings("unused")
public SquareFrameLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
@TargetApi(VERSION_CODES.HONEYCOMB) @SuppressWarnings("unused")
public SquareFrameLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
if (attrs != null) {
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.SquareFrameLayout, 0, 0);
this.squareHeight = typedArray.getBoolean(R.styleable.SquareFrameLayout_square_height, false);
typedArray.recycle();
} else {
this.squareHeight = false;
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//noinspection SuspiciousNameCombination
if (squareHeight) super.onMeasure(heightMeasureSpec, heightMeasureSpec);
else super.onMeasure(widthMeasureSpec, widthMeasureSpec);
}
}

View File

@@ -0,0 +1,25 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import androidx.appcompat.widget.AppCompatImageView;
import android.util.AttributeSet;
public class SquareImageView extends AppCompatImageView {
public SquareImageView(Context context) {
super(context);
}
public SquareImageView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public SquareImageView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//noinspection SuspiciousNameCombination
super.onMeasure(widthMeasureSpec, widthMeasureSpec);
}
}

View File

@@ -0,0 +1,35 @@
package org.thoughtcrime.securesms.components;
import android.annotation.TargetApi;
import android.content.Context;
import android.os.Build.VERSION_CODES;
import android.util.AttributeSet;
import android.widget.LinearLayout;
public class SquareLinearLayout extends LinearLayout {
@SuppressWarnings("unused")
public SquareLinearLayout(Context context) {
super(context);
}
@SuppressWarnings("unused")
public SquareLinearLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
@TargetApi(VERSION_CODES.HONEYCOMB) @SuppressWarnings("unused")
public SquareLinearLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@TargetApi(VERSION_CODES.LOLLIPOP) @SuppressWarnings("unused")
public SquareLinearLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//noinspection SuspiciousNameCombination
super.onMeasure(widthMeasureSpec, widthMeasureSpec);
}
}

View File

@@ -0,0 +1,67 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.util.AttributeSet;
import android.view.View;
import android.widget.FrameLayout;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
public class StickerView extends FrameLayout {
private ThumbnailView image;
private View missingShade;
public StickerView(@NonNull Context context) {
super(context);
init();
}
public StickerView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
private void init() {
inflate(getContext(), R.layout.sticker_view, this);
this.image = findViewById(R.id.sticker_thumbnail);
this.missingShade = findViewById(R.id.sticker_missing_shade);
}
@Override
public void setFocusable(boolean focusable) {
image.setFocusable(focusable);
}
@Override
public void setClickable(boolean clickable) {
image.setClickable(clickable);
}
@Override
public void setOnLongClickListener(@Nullable OnLongClickListener l) {
image.setOnLongClickListener(l);
}
public void setSticker(@NonNull GlideRequests glideRequests, @NonNull Slide stickerSlide) {
boolean showControls = stickerSlide.asAttachment().getDataUri() == null;
image.setImageResource(glideRequests, stickerSlide, showControls, false);
missingShade.setVisibility(showControls ? View.VISIBLE : View.GONE);
}
public void setThumbnailClickListener(@NonNull SlideClickListener listener) {
image.setThumbnailClickListener(listener);
}
public void setDownloadClickListener(@NonNull SlidesClickedListener listener) {
image.setDownloadClickListener(listener);
}
}

View File

@@ -0,0 +1,52 @@
package org.thoughtcrime.securesms.components;
import android.annotation.TargetApi;
import android.content.Context;
import android.os.Build;
import androidx.preference.CheckBoxPreference;
import androidx.preference.Preference;
import android.util.AttributeSet;
import org.thoughtcrime.securesms.R;
public class SwitchPreferenceCompat extends CheckBoxPreference {
private Preference.OnPreferenceClickListener listener;
public SwitchPreferenceCompat(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
setLayoutRes();
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public SwitchPreferenceCompat(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
setLayoutRes();
}
public SwitchPreferenceCompat(Context context, AttributeSet attrs) {
super(context, attrs);
setLayoutRes();
}
public SwitchPreferenceCompat(Context context) {
super(context);
setLayoutRes();
}
private void setLayoutRes() {
setWidgetLayoutResource(R.layout.switch_compat_preference);
}
@Override
public void setOnPreferenceClickListener(Preference.OnPreferenceClickListener listener) {
this.listener = listener;
}
@Override
protected void onClick() {
if (listener == null || !listener.onPreferenceClick(this)) {
super.onClick();
}
}
}

View File

@@ -0,0 +1,122 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.database.Cursor;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
import org.thoughtcrime.securesms.database.MediaDatabase;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
public class ThreadPhotoRailView extends FrameLayout {
@NonNull private final RecyclerView recyclerView;
@Nullable private OnItemClickedListener listener;
public ThreadPhotoRailView(Context context) {
this(context, null);
}
public ThreadPhotoRailView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ThreadPhotoRailView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
inflate(context, R.layout.recipient_preference_photo_rail, this);
this.recyclerView = ViewUtil.findById(this, R.id.photo_list);
this.recyclerView.setLayoutManager(new LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false));
this.recyclerView.setItemAnimator(new DefaultItemAnimator());
this.recyclerView.setNestedScrollingEnabled(false);
}
public void setListener(@Nullable OnItemClickedListener listener) {
this.listener = listener;
if (this.recyclerView.getAdapter() != null) {
((ThreadPhotoRailAdapter)this.recyclerView.getAdapter()).setListener(listener);
}
}
public void setCursor(@NonNull GlideRequests glideRequests, @Nullable Cursor cursor) {
this.recyclerView.setAdapter(new ThreadPhotoRailAdapter(getContext(), glideRequests, cursor, this.listener));
}
private static class ThreadPhotoRailAdapter extends CursorRecyclerViewAdapter<ThreadPhotoRailAdapter.ThreadPhotoViewHolder> {
@SuppressWarnings("unused")
private static final String TAG = ThreadPhotoRailAdapter.class.getSimpleName();
@NonNull private final GlideRequests glideRequests;
@Nullable private OnItemClickedListener clickedListener;
private ThreadPhotoRailAdapter(@NonNull Context context,
@NonNull GlideRequests glideRequests,
@Nullable Cursor cursor,
@Nullable OnItemClickedListener listener)
{
super(context, cursor);
this.glideRequests = glideRequests;
this.clickedListener = listener;
}
@Override
public ThreadPhotoViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) {
View itemView = LayoutInflater.from(parent.getContext())
.inflate(R.layout.recipient_preference_photo_rail_item, parent, false);
return new ThreadPhotoViewHolder(itemView);
}
@Override
public void onBindItemViewHolder(ThreadPhotoViewHolder viewHolder, @NonNull Cursor cursor) {
ThumbnailView imageView = viewHolder.imageView;
MediaDatabase.MediaRecord mediaRecord = MediaDatabase.MediaRecord.from(getContext(), cursor);
Slide slide = MediaUtil.getSlideForAttachment(getContext(), mediaRecord.getAttachment());
if (slide != null) {
imageView.setImageResource(glideRequests, slide, false, false);
}
imageView.setOnClickListener(v -> {
if (clickedListener != null) clickedListener.onItemClicked(mediaRecord);
});
}
public void setListener(@Nullable OnItemClickedListener listener) {
this.clickedListener = listener;
}
static class ThreadPhotoViewHolder extends RecyclerView.ViewHolder {
ThumbnailView imageView;
ThreadPhotoViewHolder(View itemView) {
super(itemView);
this.imageView = ViewUtil.findById(itemView, R.id.thumbnail);
}
}
}
public interface OnItemClickedListener {
void onItemClicked(MediaDatabase.MediaRecord mediaRecord);
}
}

View File

@@ -0,0 +1,501 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.net.Uri;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.annotation.UiThread;
import com.bumptech.glide.RequestBuilder;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation;
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
import com.bumptech.glide.load.resource.bitmap.FitCenter;
import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
import com.bumptech.glide.request.RequestOptions;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.blurhash.BlurHash;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideRequest;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Collections;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.ExecutionException;
import static com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade;
public class ThumbnailView extends FrameLayout {
private static final String TAG = ThumbnailView.class.getSimpleName();
private static final int WIDTH = 0;
private static final int HEIGHT = 1;
private static final int MIN_WIDTH = 0;
private static final int MAX_WIDTH = 1;
private static final int MIN_HEIGHT = 2;
private static final int MAX_HEIGHT = 3;
private ImageView image;
private ImageView blurhash;
private View playOverlay;
private View captionIcon;
private OnClickListener parentClickListener;
private final int[] dimens = new int[2];
private final int[] bounds = new int[4];
private final int[] measureDimens = new int[2];
private Optional<TransferControlView> transferControls = Optional.absent();
private SlideClickListener thumbnailClickListener = null;
private SlidesClickedListener downloadClickListener = null;
private Slide slide = null;
private BitmapTransformation fit = new CenterCrop();
private int radius;
public ThumbnailView(Context context) {
this(context, null);
}
public ThumbnailView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ThumbnailView(final Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
inflate(context, R.layout.thumbnail_view, this);
this.image = findViewById(R.id.thumbnail_image);
this.blurhash = findViewById(R.id.thumbnail_blurhash);
this.playOverlay = findViewById(R.id.play_overlay);
this.captionIcon = findViewById(R.id.thumbnail_caption_icon);
super.setOnClickListener(new ThumbnailClickDispatcher());
if (attrs != null) {
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0);
bounds[MIN_WIDTH] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minWidth, 0);
bounds[MAX_WIDTH] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0);
bounds[MIN_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0);
bounds[MAX_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0);
radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, getResources().getDimensionPixelSize(R.dimen.thumbnail_default_radius));
fit = typedArray.getInt(R.styleable.ThumbnailView_thumbnail_fit, 0) == 1 ? new FitCenter() : new CenterCrop();
typedArray.recycle();
} else {
radius = getResources().getDimensionPixelSize(R.dimen.message_corner_collapse_radius);
}
}
@Override
protected void onMeasure(int originalWidthMeasureSpec, int originalHeightMeasureSpec) {
fillTargetDimensions(measureDimens, dimens, bounds);
if (measureDimens[WIDTH] == 0 && measureDimens[HEIGHT] == 0) {
super.onMeasure(originalWidthMeasureSpec, originalHeightMeasureSpec);
return;
}
int finalWidth = measureDimens[WIDTH] + getPaddingLeft() + getPaddingRight();
int finalHeight = measureDimens[HEIGHT] + getPaddingTop() + getPaddingBottom();
super.onMeasure(MeasureSpec.makeMeasureSpec(finalWidth, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(finalHeight, MeasureSpec.EXACTLY));
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
float playOverlayScale = 1;
float captionIconScale = 1;
int playOverlayWidth = playOverlay.getLayoutParams().width;
if (playOverlayWidth * 2 > getWidth()) {
playOverlayScale /= 2;
captionIconScale = 0;
}
playOverlay.setScaleX(playOverlayScale);
playOverlay.setScaleY(playOverlayScale);
captionIcon.setScaleX(captionIconScale);
captionIcon.setScaleY(captionIconScale);
}
@SuppressWarnings("SuspiciousNameCombination")
private void fillTargetDimensions(int[] targetDimens, int[] dimens, int[] bounds) {
int dimensFilledCount = getNonZeroCount(dimens);
int boundsFilledCount = getNonZeroCount(bounds);
boolean dimensAreInvalid = dimensFilledCount > 0 && dimensFilledCount < dimens.length;
if (dimensAreInvalid) {
Log.w(TAG, String.format(Locale.ENGLISH, "Width or height has been specified, but not both. Dimens: %d x %d", dimens[WIDTH], dimens[HEIGHT]));
}
if (dimensAreInvalid || dimensFilledCount == 0 || boundsFilledCount == 0) {
targetDimens[WIDTH] = 0;
targetDimens[HEIGHT] = 0;
return;
}
double naturalWidth = dimens[WIDTH];
double naturalHeight = dimens[HEIGHT];
int minWidth = bounds[MIN_WIDTH];
int maxWidth = bounds[MAX_WIDTH];
int minHeight = bounds[MIN_HEIGHT];
int maxHeight = bounds[MAX_HEIGHT];
if (boundsFilledCount > 0 && boundsFilledCount < bounds.length) {
throw new IllegalStateException(String.format(Locale.ENGLISH, "One or more min/max dimensions have been specified, but not all. Bounds: [%d, %d, %d, %d]",
minWidth, maxWidth, minHeight, maxHeight));
}
double measuredWidth = naturalWidth;
double measuredHeight = naturalHeight;
boolean widthInBounds = measuredWidth >= minWidth && measuredWidth <= maxWidth;
boolean heightInBounds = measuredHeight >= minHeight && measuredHeight <= maxHeight;
if (!widthInBounds || !heightInBounds) {
double minWidthRatio = naturalWidth / minWidth;
double maxWidthRatio = naturalWidth / maxWidth;
double minHeightRatio = naturalHeight / minHeight;
double maxHeightRatio = naturalHeight / maxHeight;
if (maxWidthRatio > 1 || maxHeightRatio > 1) {
if (maxWidthRatio >= maxHeightRatio) {
measuredWidth /= maxWidthRatio;
measuredHeight /= maxWidthRatio;
} else {
measuredWidth /= maxHeightRatio;
measuredHeight /= maxHeightRatio;
}
measuredWidth = Math.max(measuredWidth, minWidth);
measuredHeight = Math.max(measuredHeight, minHeight);
} else if (minWidthRatio < 1 || minHeightRatio < 1) {
if (minWidthRatio <= minHeightRatio) {
measuredWidth /= minWidthRatio;
measuredHeight /= minWidthRatio;
} else {
measuredWidth /= minHeightRatio;
measuredHeight /= minHeightRatio;
}
measuredWidth = Math.min(measuredWidth, maxWidth);
measuredHeight = Math.min(measuredHeight, maxHeight);
}
}
targetDimens[WIDTH] = (int) measuredWidth;
targetDimens[HEIGHT] = (int) measuredHeight;
}
private int getNonZeroCount(int[] vals) {
int count = 0;
for (int val : vals) {
if (val > 0) {
count++;
}
}
return count;
}
@Override
public void setOnClickListener(OnClickListener l) {
parentClickListener = l;
}
@Override
public void setFocusable(boolean focusable) {
super.setFocusable(focusable);
if (transferControls.isPresent()) transferControls.get().setFocusable(focusable);
}
@Override
public void setClickable(boolean clickable) {
super.setClickable(clickable);
if (transferControls.isPresent()) transferControls.get().setClickable(clickable);
}
private TransferControlView getTransferControls() {
if (!transferControls.isPresent()) {
transferControls = Optional.of(ViewUtil.inflateStub(this, R.id.transfer_controls_stub));
}
return transferControls.get();
}
public void setBounds(int minWidth, int maxWidth, int minHeight, int maxHeight) {
bounds[MIN_WIDTH] = minWidth;
bounds[MAX_WIDTH] = maxWidth;
bounds[MIN_HEIGHT] = minHeight;
bounds[MAX_HEIGHT] = maxHeight;
forceLayout();
}
@UiThread
public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull Slide slide,
boolean showControls, boolean isPreview)
{
return setImageResource(glideRequests, slide, showControls, isPreview, 0, 0);
}
@UiThread
public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull Slide slide,
boolean showControls, boolean isPreview,
int naturalWidth, int naturalHeight)
{
if (showControls) {
getTransferControls().setSlide(slide);
getTransferControls().setDownloadClickListener(new DownloadClickDispatcher());
} else if (transferControls.isPresent()) {
getTransferControls().setVisibility(View.GONE);
}
if (slide.getThumbnailUri() != null && slide.hasPlayOverlay() &&
(slide.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_DONE || isPreview))
{
this.playOverlay.setVisibility(View.VISIBLE);
} else {
this.playOverlay.setVisibility(View.GONE);
}
if (Util.equals(slide, this.slide)) {
Log.i(TAG, "Not re-loading slide " + slide.asAttachment().getDataUri());
return new SettableFuture<>(false);
}
if (this.slide != null && this.slide.getFastPreflightId() != null &&
(!slide.hasVideo() || Util.equals(this.slide.getThumbnailUri(), slide.getThumbnailUri())) &&
Util.equals(this.slide.getFastPreflightId(), slide.getFastPreflightId()))
{
Log.i(TAG, "Not re-loading slide for fast preflight: " + slide.getFastPreflightId());
this.slide = slide;
return new SettableFuture<>(false);
}
Log.i(TAG, "loading part with id " + slide.asAttachment().getDataUri()
+ ", progress " + slide.getTransferState() + ", fast preflight id: " +
slide.asAttachment().getFastPreflightId());
BlurHash previousBlurhash = this.slide != null ? this.slide.getPlaceholderBlur() : null;
this.slide = slide;
this.captionIcon.setVisibility(slide.getCaption().isPresent() ? VISIBLE : GONE);
dimens[WIDTH] = naturalWidth;
dimens[HEIGHT] = naturalHeight;
invalidate();
SettableFuture<Boolean> result = new SettableFuture<>();
boolean resultHandled = false;
if (slide.hasPlaceholder() && (previousBlurhash == null || !Objects.equals(slide.getPlaceholderBlur(), previousBlurhash))) {
buildPlaceholderGlideRequest(glideRequests, slide).into(new GlideBitmapListeningTarget(blurhash, result));
resultHandled = true;
} else if (!slide.hasPlaceholder()) {
glideRequests.clear(blurhash);
blurhash.setImageDrawable(null);
}
if (slide.getThumbnailUri() != null) {
if (!MediaUtil.isJpegType(slide.getContentType()) && !MediaUtil.isVideoType(slide.getContentType())) {
SettableFuture<Boolean> thumbnailFuture = new SettableFuture<>();
thumbnailFuture.deferTo(result);
thumbnailFuture.addListener(new BlurhashClearListener(glideRequests, blurhash));
}
buildThumbnailGlideRequest(glideRequests, slide).into(new GlideDrawableListeningTarget(image, result));
resultHandled = true;
} else {
glideRequests.clear(image);
image.setImageDrawable(null);
}
if (!resultHandled) {
result.set(false);
}
return result;
}
public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull Uri uri) {
SettableFuture<Boolean> future = new SettableFuture<>();
if (transferControls.isPresent()) getTransferControls().setVisibility(View.GONE);
GlideRequest request = glideRequests.load(new DecryptableUri(uri))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transition(withCrossFade());
if (radius > 0) {
request = request.transforms(new CenterCrop(), new RoundedCorners(radius));
} else {
request = request.transforms(new CenterCrop());
}
request.into(new GlideDrawableListeningTarget(image, future));
blurhash.setImageDrawable(null);
return future;
}
public void setThumbnailClickListener(SlideClickListener listener) {
this.thumbnailClickListener = listener;
}
public void setDownloadClickListener(SlidesClickedListener listener) {
this.downloadClickListener = listener;
}
public void clear(GlideRequests glideRequests) {
glideRequests.clear(image);
if (transferControls.isPresent()) {
getTransferControls().clear();
}
slide = null;
}
public void showDownloadText(boolean showDownloadText) {
getTransferControls().setShowDownloadText(showDownloadText);
}
public void showProgressSpinner() {
getTransferControls().showProgressSpinner();
}
protected void setRadius(int radius) {
this.radius = radius;
}
private GlideRequest buildThumbnailGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
GlideRequest request = applySizing(glideRequests.load(new DecryptableUri(slide.getThumbnailUri()))
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.transition(withCrossFade()), fit);
if (slide.isInProgress()) return request;
else return request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture));
}
private RequestBuilder buildPlaceholderGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
GlideRequest<Bitmap> bitmap = glideRequests.asBitmap();
BlurHash placeholderBlur = slide.getPlaceholderBlur();
if (placeholderBlur != null) {
bitmap = bitmap.load(placeholderBlur);
} else {
bitmap = bitmap.load(slide.getPlaceholderRes(getContext().getTheme()));
}
return applySizing(bitmap.diskCacheStrategy(DiskCacheStrategy.NONE), new CenterCrop());
}
private GlideRequest applySizing(@NonNull GlideRequest request, @NonNull BitmapTransformation fitting) {
int[] size = new int[2];
fillTargetDimensions(size, dimens, bounds);
if (size[WIDTH] == 0 && size[HEIGHT] == 0) {
size[WIDTH] = getDefaultWidth();
size[HEIGHT] = getDefaultHeight();
}
request = request.override(size[WIDTH], size[HEIGHT]);
if (radius > 0) {
return request.transforms(fitting, new RoundedCorners(radius));
} else {
return request.transforms(fitting);
}
}
private int getDefaultWidth() {
ViewGroup.LayoutParams params = getLayoutParams();
if (params != null) {
return Math.max(params.width, 0);
}
return 0;
}
private int getDefaultHeight() {
ViewGroup.LayoutParams params = getLayoutParams();
if (params != null) {
return Math.max(params.height, 0);
}
return 0;
}
private class ThumbnailClickDispatcher implements View.OnClickListener {
@Override
public void onClick(View view) {
if (thumbnailClickListener != null &&
slide != null &&
slide.asAttachment().getDataUri() != null &&
slide.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_DONE)
{
thumbnailClickListener.onClick(view, slide);
} else if (parentClickListener != null) {
parentClickListener.onClick(view);
}
}
}
private class DownloadClickDispatcher implements View.OnClickListener {
@Override
public void onClick(View view) {
Log.i(TAG, "onClick() for download button");
if (downloadClickListener != null && slide != null) {
downloadClickListener.onClick(view, Collections.singletonList(slide));
} else {
Log.w(TAG, "Received a download button click, but unable to execute it. slide: " + String.valueOf(slide) + " downloadClickListener: " + String.valueOf(downloadClickListener));
}
}
}
private static class BlurhashClearListener implements ListenableFuture.Listener<Boolean> {
private final GlideRequests glideRequests;
private final ImageView blurhash;
private BlurhashClearListener(@NonNull GlideRequests glideRequests, @NonNull ImageView blurhash) {
this.glideRequests = glideRequests;
this.blurhash = blurhash;
}
@Override
public void onSuccess(Boolean result) {
glideRequests.clear(blurhash);
blurhash.setImageDrawable(null);
}
@Override
public void onFailure(ExecutionException e) {
glideRequests.clear(blurhash);
blurhash.setImageDrawable(null);
}
}
}

View File

@@ -0,0 +1,233 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.graphics.PorterDuff;
import android.os.Build;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.widget.ImageView;
import android.widget.PopupWindow;
import android.widget.TextView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.util.ThemeUtil;
/**
* Class for creating simple tooltips to show throughout the app. Utilizes a popup window so you
* don't have to worry about view hierarchies or anything.
*/
public class TooltipPopup extends PopupWindow {
public static final int POSITION_ABOVE = 0;
public static final int POSITION_BELOW = 1;
public static final int POSITION_START = 2;
public static final int POSITION_END = 3;
private static final int POSITION_LEFT = 4;
private static final int POSITION_RIGHT = 5;
private final View anchor;
private final ImageView arrow;
private final int position;
public static Builder forTarget(@NonNull View anchor) {
return new Builder(anchor);
}
private TooltipPopup(@NonNull View anchor,
int rawPosition,
@NonNull String text,
@ColorInt int backgroundTint,
@ColorInt int textColor,
@Nullable Object iconGlideModel,
@Nullable OnDismissListener dismissListener)
{
super(LayoutInflater.from(anchor.getContext()).inflate(R.layout.tooltip, null),
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
this.anchor = anchor;
this.position = getRtlPosition(anchor.getContext(), rawPosition);
switch (rawPosition) {
case POSITION_ABOVE: arrow = getContentView().findViewById(R.id.tooltip_arrow_bottom); break;
case POSITION_BELOW: arrow = getContentView().findViewById(R.id.tooltip_arrow_top); break;
case POSITION_START: arrow = getContentView().findViewById(R.id.tooltip_arrow_end); break;
case POSITION_END: arrow = getContentView().findViewById(R.id.tooltip_arrow_start); break;
default: throw new AssertionError("Invalid position!");
}
arrow.setVisibility(View.VISIBLE);
TextView textView = getContentView().findViewById(R.id.tooltip_text);
textView.setText(text);
if (textColor != 0) {
textView.setTextColor(textColor);
}
View bubble = getContentView().findViewById(R.id.tooltip_bubble);
if (backgroundTint == 0) {
bubble.getBackground().setColorFilter(ThemeUtil.getThemedColor(anchor.getContext(), R.attr.tooltip_default_color), PorterDuff.Mode.MULTIPLY);
arrow.setColorFilter(ThemeUtil.getThemedColor(anchor.getContext(), R.attr.tooltip_default_color), PorterDuff.Mode.MULTIPLY);
} else {
bubble.getBackground().setColorFilter(backgroundTint, PorterDuff.Mode.MULTIPLY);
arrow.setColorFilter(backgroundTint, PorterDuff.Mode.MULTIPLY);
}
if (iconGlideModel != null) {
ImageView iconView = getContentView().findViewById(R.id.tooltip_icon);
iconView.setVisibility(View.VISIBLE);
GlideApp.with(anchor.getContext()).load(iconGlideModel).into(iconView);
}
if (Build.VERSION.SDK_INT >= 21) {
setElevation(10);
}
getContentView().setOnClickListener(v -> dismiss());
setOnDismissListener(dismissListener);
setBackgroundDrawable(null);
setOutsideTouchable(true);
}
private void show() {
if (anchor.getWidth() == 0 && anchor.getHeight() == 0) {
anchor.post(this::show);
return;
}
getContentView().measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
int tooltipSpacing = anchor.getContext().getResources().getDimensionPixelOffset(R.dimen.tooltip_popup_margin);
int xoffset;
int yoffset;
switch (position) {
case POSITION_ABOVE:
xoffset = 0;
yoffset = -(anchor.getHeight() + getContentView().getMeasuredHeight() + tooltipSpacing);
onLayout(() -> setArrowHorizontalPosition(arrow, anchor));
break;
case POSITION_BELOW:
xoffset = 0;
yoffset = tooltipSpacing;
onLayout(() -> setArrowHorizontalPosition(arrow, anchor));
break;
case POSITION_LEFT:
xoffset = -getContentView().getMeasuredWidth() - tooltipSpacing;
yoffset = -(getContentView().getMeasuredHeight()/2 + anchor.getHeight()/2);
onLayout(() -> setArrowVerticalPosition(arrow, anchor));
break;
case POSITION_RIGHT:
xoffset = anchor.getWidth() + tooltipSpacing;
yoffset = -(getContentView().getMeasuredHeight()/2 + anchor.getHeight()/2);
onLayout(() -> setArrowVerticalPosition(arrow, anchor));
break;
default:
throw new AssertionError("Invalid tooltip position!");
}
showAsDropDown(anchor, xoffset, yoffset);
}
private void onLayout(@NonNull Runnable runnable) {
getContentView().getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
getContentView().getViewTreeObserver().removeOnGlobalLayoutListener(this);
runnable.run();
}
});
}
private static void setArrowHorizontalPosition(@NonNull View arrow, @NonNull View anchor) {
int arrowCenterX = getAbsolutePosition(arrow)[0] + arrow.getWidth()/2;
int anchorCenterX = getAbsolutePosition(anchor)[0] + anchor.getWidth()/2;
arrow.setTranslationX(anchorCenterX - arrowCenterX);
}
private static void setArrowVerticalPosition(@NonNull View arrow, @NonNull View anchor) {
int arrowCenterY = getAbsolutePosition(arrow)[1] + arrow.getHeight()/2;
int anchorCenterY = getAbsolutePosition(anchor)[1] + anchor.getHeight()/2;
arrow.setTranslationY(anchorCenterY - arrowCenterY);
}
private static int[] getAbsolutePosition(@NonNull View view) {
int[] position = new int[2];
view.getLocationOnScreen(position);
return position;
}
private static int getRtlPosition(@NonNull Context context, int position) {
if (position == POSITION_ABOVE || position == POSITION_BELOW) {
return position;
} else if (context.getResources().getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) {
return position == POSITION_START ? POSITION_RIGHT : POSITION_LEFT;
} else {
return position == POSITION_START ? POSITION_LEFT : POSITION_RIGHT;
}
}
public static class Builder {
private final View anchor;
private int backgroundTint;
private int textColor;
private int textResId;
private Object iconGlideModel;
private OnDismissListener dismissListener;
private Builder(@NonNull View anchor) {
this.anchor = anchor;
}
public Builder setBackgroundTint(@ColorInt int color) {
this.backgroundTint = color;
return this;
}
public Builder setTextColor(@ColorInt int color) {
this.textColor = color;
return this;
}
public Builder setText(@StringRes int stringResId) {
this.textResId = stringResId;
return this;
}
public Builder setIconGlideModel(Object model) {
this.iconGlideModel = model;
return this;
}
public Builder setOnDismissListener(OnDismissListener dismissListener) {
this.dismissListener = dismissListener;
return this;
}
public TooltipPopup show(int position) {
String text = anchor.getContext().getString(textResId);
TooltipPopup tooltip = new TooltipPopup(anchor, position, text, backgroundTint, textColor, iconGlideModel, dismissListener);
tooltip.show();
return tooltip;
}
}
}

View File

@@ -0,0 +1,254 @@
package org.thoughtcrime.securesms.components;
import android.animation.LayoutTransition;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import com.annimon.stream.Stream;
import com.pnikosis.materialishprogress.ProgressWheel;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.events.PartProgressEvent;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public final class TransferControlView extends FrameLayout {
private static final int UPLOAD_TASK_WEIGHT = 1;
/**
* A weighting compared to {@link #UPLOAD_TASK_WEIGHT}
*/
private static final int COMPRESSION_TASK_WEIGHT = 3;
@Nullable private List<Slide> slides;
@Nullable private View current;
private final ProgressWheel progressWheel;
private final View downloadDetails;
private final TextView downloadDetailsText;
private final Map<Attachment, Float> networkProgress;
private final Map<Attachment, Float> compresssionProgress;
public TransferControlView(Context context) {
this(context, null);
}
public TransferControlView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public TransferControlView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
inflate(context, R.layout.transfer_controls_view, this);
setLongClickable(false);
ViewUtil.setBackground(this, ContextCompat.getDrawable(context, R.drawable.transfer_controls_background));
setVisibility(GONE);
setLayoutTransition(new LayoutTransition());
this.networkProgress = new HashMap<>();
this.compresssionProgress = new HashMap<>();
this.progressWheel = ViewUtil.findById(this, R.id.progress_wheel);
this.downloadDetails = ViewUtil.findById(this, R.id.download_details);
this.downloadDetailsText = ViewUtil.findById(this, R.id.download_details_text);
}
@Override
public void setFocusable(boolean focusable) {
super.setFocusable(focusable);
downloadDetails.setFocusable(focusable);
}
@Override
public void setClickable(boolean clickable) {
super.setClickable(clickable);
downloadDetails.setClickable(clickable);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this);
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
EventBus.getDefault().unregister(this);
}
public void setSlide(final @NonNull Slide slides) {
setSlides(Collections.singletonList(slides));
}
public void setSlides(final @NonNull List<Slide> slides) {
if (slides.isEmpty()) {
throw new IllegalArgumentException("Must provide at least one slide.");
}
this.slides = slides;
if (!isUpdateToExistingSet(slides)) {
networkProgress.clear();
compresssionProgress.clear();
Stream.of(slides).forEach(s -> networkProgress.put(s.asAttachment(), 0f));
}
for (Slide slide : slides) {
if (slide.asAttachment().getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_DONE) {
networkProgress.put(slide.asAttachment(), 1f);
}
}
switch (getTransferState(slides)) {
case AttachmentDatabase.TRANSFER_PROGRESS_STARTED:
showProgressSpinner(calculateProgress(networkProgress, compresssionProgress));
break;
case AttachmentDatabase.TRANSFER_PROGRESS_PENDING:
case AttachmentDatabase.TRANSFER_PROGRESS_FAILED:
downloadDetailsText.setText(getDownloadText(this.slides));
display(downloadDetails);
break;
default:
display(null);
break;
}
}
public void showProgressSpinner() {
showProgressSpinner(calculateProgress(networkProgress, compresssionProgress));
}
public void showProgressSpinner(float progress) {
if (progress == 0) {
progressWheel.spin();
} else {
progressWheel.setInstantProgress(progress);
}
display(progressWheel);
}
public void setDownloadClickListener(final @Nullable OnClickListener listener) {
downloadDetails.setOnClickListener(listener);
}
public void clear() {
clearAnimation();
setVisibility(GONE);
if (current != null) {
current.clearAnimation();
current.setVisibility(GONE);
}
current = null;
slides = null;
}
public void setShowDownloadText(boolean showDownloadText) {
downloadDetailsText.setVisibility(showDownloadText ? VISIBLE : GONE);
forceLayout();
}
private boolean isUpdateToExistingSet(@NonNull List<Slide> slides) {
if (slides.size() != networkProgress.size()) {
return false;
}
for (Slide slide : slides) {
if (!networkProgress.containsKey(slide.asAttachment())) {
return false;
}
}
return true;
}
private int getTransferState(@NonNull List<Slide> slides) {
int transferState = AttachmentDatabase.TRANSFER_PROGRESS_DONE;
for (Slide slide : slides) {
if (slide.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_PENDING && transferState == AttachmentDatabase.TRANSFER_PROGRESS_DONE) {
transferState = slide.getTransferState();
} else {
transferState = Math.max(transferState, slide.getTransferState());
}
}
return transferState;
}
private String getDownloadText(@NonNull List<Slide> slides) {
if (slides.size() == 1) {
return slides.get(0).getContentDescription();
} else {
int downloadCount = Stream.of(slides).reduce(0, (count, slide) -> slide.getTransferState() != AttachmentDatabase.TRANSFER_PROGRESS_DONE ? count + 1 : count);
return getContext().getResources().getQuantityString(R.plurals.TransferControlView_n_items, downloadCount, downloadCount);
}
}
private void display(@Nullable final View view) {
if (current != null) {
current.setVisibility(GONE);
}
if (view != null) {
view.setVisibility(VISIBLE);
setVisibility(VISIBLE);
} else {
setVisibility(GONE);
}
current = view;
}
private static float calculateProgress(@NonNull Map<Attachment, Float> uploadDownloadProgress, Map<Attachment, Float> compresssionProgress) {
float totalDownloadProgress = 0;
float totalCompressionProgress = 0;
for (float progress : uploadDownloadProgress.values()) {
totalDownloadProgress += progress;
}
for (float progress : compresssionProgress.values()) {
totalCompressionProgress += progress;
}
float weightedProgress = UPLOAD_TASK_WEIGHT * totalDownloadProgress + COMPRESSION_TASK_WEIGHT * totalCompressionProgress;
float weightedTotal = UPLOAD_TASK_WEIGHT * uploadDownloadProgress.size() + COMPRESSION_TASK_WEIGHT * compresssionProgress.size();
return weightedProgress / weightedTotal;
}
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
public void onEventAsync(final PartProgressEvent event) {
if (networkProgress.containsKey(event.attachment)) {
float proportionCompleted = ((float) event.progress) / event.total;
if (event.type == PartProgressEvent.Type.COMPRESSION) {
compresssionProgress.put(event.attachment, proportionCompleted);
} else {
networkProgress.put(event.attachment, proportionCompleted);
}
progressWheel.setInstantProgress(calculateProgress(networkProgress, compresssionProgress));
}
}
}

View File

@@ -0,0 +1,122 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.PorterDuff;
import androidx.annotation.Nullable;
import android.util.AttributeSet;
import android.view.View;
import android.widget.LinearLayout;
import org.thoughtcrime.securesms.R;
public class TypingIndicatorView extends LinearLayout {
private static final long DURATION = 300;
private static final long PRE_DELAY = 500;
private static final long POST_DELAY = 500;
private static final long CYCLE_DURATION = 1500;
private static final long DOT_DURATION = 600;
private static final float MIN_ALPHA = 0.4f;
private static final float MIN_SCALE = 0.75f;
private boolean isActive;
private long startTime;
private View dot1;
private View dot2;
private View dot3;
public TypingIndicatorView(Context context) {
super(context);
initialize(null);
}
public TypingIndicatorView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
initialize(attrs);
}
private void initialize(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.typing_indicator_view, this);
setWillNotDraw(false);
dot1 = findViewById(R.id.typing_dot1);
dot2 = findViewById(R.id.typing_dot2);
dot3 = findViewById(R.id.typing_dot3);
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.TypingIndicatorView, 0, 0);
int tint = typedArray.getColor(R.styleable.TypingIndicatorView_typingIndicator_tint, Color.WHITE);
typedArray.recycle();
dot1.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY);
dot2.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY);
dot3.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY);
}
}
@Override
protected void onDraw(Canvas canvas) {
if (!isActive) {
super.onDraw(canvas);
return;
}
long timeInCycle = (System.currentTimeMillis() - startTime) % CYCLE_DURATION;
render(dot1, timeInCycle, 0);
render(dot2, timeInCycle, 150);
render(dot3, timeInCycle, 300);
super.onDraw(canvas);
postInvalidate();
}
private void render(View dot, long timeInCycle, long start) {
long end = start + DOT_DURATION;
long peak = start + (DOT_DURATION / 2);
if (timeInCycle < start || timeInCycle > end) {
renderDefault(dot);
} else if (timeInCycle < peak) {
renderFadeIn(dot, timeInCycle, start);
} else {
renderFadeOut(dot, timeInCycle, peak);
}
}
private void renderDefault(View dot) {
dot.setAlpha(MIN_ALPHA);
dot.setScaleX(MIN_SCALE);
dot.setScaleY(MIN_SCALE);
}
private void renderFadeIn(View dot, long timeInCycle, long fadeInStart) {
float percent = (float) (timeInCycle - fadeInStart) / 300;
dot.setAlpha(MIN_ALPHA + (1 - MIN_ALPHA) * percent);
dot.setScaleX(MIN_SCALE + (1 - MIN_SCALE) * percent);
dot.setScaleY(MIN_SCALE + (1 - MIN_SCALE) * percent);
}
private void renderFadeOut(View dot, long timeInCycle, long fadeOutStart) {
float percent = (float) (timeInCycle - fadeOutStart) / 300;
dot.setAlpha(1 - (1 - MIN_ALPHA) * percent);
dot.setScaleX(1 - (1 - MIN_SCALE) * percent);
dot.setScaleY(1 - (1 - MIN_SCALE) * percent);
}
public void startAnimation() {
isActive = true;
startTime = System.currentTimeMillis();
postInvalidate();
}
public void stopAnimation() {
isActive = false;
}
}

View File

@@ -0,0 +1,194 @@
package org.thoughtcrime.securesms.components;
import android.annotation.SuppressLint;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import android.content.Context;
import androidx.annotation.NonNull;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@SuppressLint("UseSparseArrays")
public class TypingStatusRepository {
private static final String TAG = TypingStatusRepository.class.getSimpleName();
private static final long RECIPIENT_TYPING_TIMEOUT = TimeUnit.SECONDS.toMillis(15);
private final Map<Long, Set<Typist>> typistMap;
private final Map<Typist, Runnable> timers;
private final Map<Long, MutableLiveData<TypingState>> notifiers;
private final MutableLiveData<Set<Long>> threadsNotifier;
public TypingStatusRepository() {
this.typistMap = new HashMap<>();
this.timers = new HashMap<>();
this.notifiers = new HashMap<>();
this.threadsNotifier = new MutableLiveData<>();
}
public synchronized void onTypingStarted(@NonNull Context context, long threadId, @NonNull Recipient author, int device) {
if (author.isLocalNumber()) {
return;
}
Set<Typist> typists = Util.getOrDefault(typistMap, threadId, new LinkedHashSet<>());
Typist typist = new Typist(author, device, threadId);
if (!typists.contains(typist)) {
typists.add(typist);
typistMap.put(threadId, typists);
notifyThread(threadId, typists, false);
}
Runnable timer = timers.get(typist);
if (timer != null) {
Util.cancelRunnableOnMain(timer);
}
timer = () -> onTypingStopped(context, threadId, author, device, false);
Util.runOnMainDelayed(timer, RECIPIENT_TYPING_TIMEOUT);
timers.put(typist, timer);
}
public synchronized void onTypingStopped(@NonNull Context context, long threadId, @NonNull Recipient author, int device, boolean isReplacedByIncomingMessage) {
if (author.isLocalNumber()) {
return;
}
Set<Typist> typists = Util.getOrDefault(typistMap, threadId, new LinkedHashSet<>());
Typist typist = new Typist(author, device, threadId);
if (typists.contains(typist)) {
typists.remove(typist);
notifyThread(threadId, typists, isReplacedByIncomingMessage);
}
if (typists.isEmpty()) {
typistMap.remove(threadId);
}
Runnable timer = timers.get(typist);
if (timer != null) {
Util.cancelRunnableOnMain(timer);
timers.remove(typist);
}
}
public synchronized LiveData<TypingState> getTypists(long threadId) {
MutableLiveData<TypingState> notifier = Util.getOrDefault(notifiers, threadId, new MutableLiveData<>());
notifiers.put(threadId, notifier);
return notifier;
}
public synchronized LiveData<Set<Long>> getTypingThreads() {
return threadsNotifier;
}
public synchronized void clear() {
TypingState empty = new TypingState(Collections.emptyList(), false);
for (MutableLiveData<TypingState> notifier : notifiers.values()) {
notifier.postValue(empty);
}
notifiers.clear();
typistMap.clear();
timers.clear();
threadsNotifier.postValue(Collections.emptySet());
}
private void notifyThread(long threadId, @NonNull Set<Typist> typists, boolean isReplacedByIncomingMessage) {
Log.d(TAG, "notifyThread() threadId: " + threadId + " typists: " + typists.size() + " isReplaced: " + isReplacedByIncomingMessage);
MutableLiveData<TypingState> notifier = Util.getOrDefault(notifiers, threadId, new MutableLiveData<>());
notifiers.put(threadId, notifier);
Set<Recipient> uniqueTypists = new LinkedHashSet<>();
for (Typist typist : typists) {
uniqueTypists.add(typist.getAuthor());
}
notifier.postValue(new TypingState(new ArrayList<>(uniqueTypists), isReplacedByIncomingMessage));
Set<Long> activeThreads = Stream.of(typistMap.keySet()).filter(t -> !typistMap.get(t).isEmpty()).collect(Collectors.toSet());
threadsNotifier.postValue(activeThreads);
}
public static class TypingState {
private final List<Recipient> typists;
private final boolean replacedByIncomingMessage;
public TypingState(List<Recipient> typists, boolean replacedByIncomingMessage) {
this.typists = typists;
this.replacedByIncomingMessage = replacedByIncomingMessage;
}
public List<Recipient> getTypists() {
return typists;
}
public boolean isReplacedByIncomingMessage() {
return replacedByIncomingMessage;
}
}
private static class Typist {
private final Recipient author;
private final int device;
private final long threadId;
private Typist(@NonNull Recipient author, int device, long threadId) {
this.author = author;
this.device = device;
this.threadId = threadId;
}
public Recipient getAuthor() {
return author;
}
public int getDevice() {
return device;
}
public long getThreadId() {
return threadId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Typist typist = (Typist) o;
if (device != typist.device) return false;
if (threadId != typist.threadId) return false;
return author.equals(typist.author);
}
@Override
public int hashCode() {
int result = author.hashCode();
result = 31 * result + device;
result = 31 * result + (int) (threadId ^ (threadId >>> 32));
return result;
}
}
}

View File

@@ -0,0 +1,116 @@
package org.thoughtcrime.securesms.components;
import android.annotation.SuppressLint;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.TypingSendJob;
import org.thoughtcrime.securesms.util.Util;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@SuppressLint("UseSparseArrays")
public class TypingStatusSender {
private static final String TAG = TypingStatusSender.class.getSimpleName();
private static final long REFRESH_TYPING_TIMEOUT = TimeUnit.SECONDS.toMillis(10);
private static final long PAUSE_TYPING_TIMEOUT = TimeUnit.SECONDS.toMillis(3);
private final Context context;
private final Map<Long, TimerPair> selfTypingTimers;
public TypingStatusSender(@NonNull Context context) {
this.context = context;
this.selfTypingTimers = new HashMap<>();
}
public synchronized void onTypingStarted(long threadId) {
TimerPair pair = Util.getOrDefault(selfTypingTimers, threadId, new TimerPair());
selfTypingTimers.put(threadId, pair);
if (pair.getStart() == null) {
sendTyping(threadId, true);
Runnable start = new StartRunnable(threadId);
Util.runOnMainDelayed(start, REFRESH_TYPING_TIMEOUT);
pair.setStart(start);
}
if (pair.getStop() != null) {
Util.cancelRunnableOnMain(pair.getStop());
}
Runnable stop = () -> onTypingStopped(threadId, true);
Util.runOnMainDelayed(stop, PAUSE_TYPING_TIMEOUT);
pair.setStop(stop);
}
public synchronized void onTypingStopped(long threadId) {
onTypingStopped(threadId, false);
}
private synchronized void onTypingStopped(long threadId, boolean notify) {
TimerPair pair = Util.getOrDefault(selfTypingTimers, threadId, new TimerPair());
selfTypingTimers.put(threadId, pair);
if (pair.getStart() != null) {
Util.cancelRunnableOnMain(pair.getStart());
if (notify) {
sendTyping(threadId, false);
}
}
if (pair.getStop() != null) {
Util.cancelRunnableOnMain(pair.getStop());
}
pair.setStart(null);
pair.setStop(null);
}
private void sendTyping(long threadId, boolean typingStarted) {
ApplicationDependencies.getJobManager().add(new TypingSendJob(threadId, typingStarted));
}
private class StartRunnable implements Runnable {
private final long threadId;
private StartRunnable(long threadId) {
this.threadId = threadId;
}
@Override
public void run() {
sendTyping(threadId, true);
Util.runOnMainDelayed(this, REFRESH_TYPING_TIMEOUT);
}
}
private static class TimerPair {
private Runnable start;
private Runnable stop;
public Runnable getStart() {
return start;
}
public void setStart(Runnable start) {
this.start = start;
}
public Runnable getStop() {
return stop;
}
public void setStop(Runnable stop) {
this.stop = stop;
}
}
}

View File

@@ -0,0 +1,140 @@
package org.thoughtcrime.securesms.components;
import android.annotation.SuppressLint;
import android.content.Context;
import android.net.Uri;
import android.os.AsyncTask;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.util.AttributeSet;
import org.thoughtcrime.securesms.logging.Log;
import android.util.Pair;
import android.view.View;
import android.widget.FrameLayout;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.request.target.Target;
import com.davemorrissey.labs.subscaleview.ImageSource;
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView;
import com.davemorrissey.labs.subscaleview.decoder.DecoderFactory;
import com.github.chrisbanes.photoview.PhotoView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.subsampling.AttachmentBitmapDecoder;
import org.thoughtcrime.securesms.components.subsampling.AttachmentRegionDecoder;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.util.BitmapDecodingException;
import org.thoughtcrime.securesms.util.BitmapUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
import java.io.IOException;
import java.io.InputStream;
public class ZoomingImageView extends FrameLayout {
private static final String TAG = ZoomingImageView.class.getSimpleName();
private final PhotoView photoView;
private final SubsamplingScaleImageView subsamplingImageView;
public ZoomingImageView(Context context) {
this(context, null);
}
public ZoomingImageView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ZoomingImageView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
inflate(context, R.layout.zooming_image_view, this);
this.photoView = findViewById(R.id.image_view);
this.subsamplingImageView = findViewById(R.id.subsampling_image_view);
this.subsamplingImageView.setOrientation(SubsamplingScaleImageView.ORIENTATION_USE_EXIF);
this.photoView.setOnClickListener(v -> ZoomingImageView.this.callOnClick());
this.subsamplingImageView.setOnClickListener(v -> ZoomingImageView.this.callOnClick());
}
@SuppressLint("StaticFieldLeak")
public void setImageUri(@NonNull GlideRequests glideRequests, @NonNull Uri uri, @NonNull String contentType)
{
final Context context = getContext();
final int maxTextureSize = BitmapUtil.getMaxTextureSize();
Log.i(TAG, "Max texture size: " + maxTextureSize);
new AsyncTask<Void, Void, Pair<Integer, Integer>>() {
@Override
protected @Nullable Pair<Integer, Integer> doInBackground(Void... params) {
if (MediaUtil.isGif(contentType)) return null;
try {
InputStream inputStream = PartAuthority.getAttachmentStream(context, uri);
return BitmapUtil.getDimensions(inputStream);
} catch (IOException | BitmapDecodingException e) {
Log.w(TAG, e);
return null;
}
}
protected void onPostExecute(@Nullable Pair<Integer, Integer> dimensions) {
Log.i(TAG, "Dimensions: " + (dimensions == null ? "(null)" : dimensions.first + ", " + dimensions.second));
if (dimensions == null || (dimensions.first <= maxTextureSize && dimensions.second <= maxTextureSize)) {
Log.i(TAG, "Loading in standard image view...");
setImageViewUri(glideRequests, uri);
} else {
Log.i(TAG, "Loading in subsampling image view...");
setSubsamplingImageViewUri(uri);
}
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
private void setImageViewUri(@NonNull GlideRequests glideRequests, @NonNull Uri uri) {
photoView.setVisibility(View.VISIBLE);
subsamplingImageView.setVisibility(View.GONE);
glideRequests.load(new DecryptableUri(uri))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontTransform()
.override(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL)
.into(photoView);
}
private void setSubsamplingImageViewUri(@NonNull Uri uri) {
subsamplingImageView.setBitmapDecoderFactory(new AttachmentBitmapDecoderFactory());
subsamplingImageView.setRegionDecoderFactory(new AttachmentRegionDecoderFactory());
subsamplingImageView.setVisibility(View.VISIBLE);
photoView.setVisibility(View.GONE);
subsamplingImageView.setImage(ImageSource.uri(uri));
}
public void cleanup() {
photoView.setImageDrawable(null);
subsamplingImageView.recycle();
}
private static class AttachmentBitmapDecoderFactory implements DecoderFactory<AttachmentBitmapDecoder> {
@Override
public AttachmentBitmapDecoder make() throws IllegalAccessException, InstantiationException {
return new AttachmentBitmapDecoder();
}
}
private static class AttachmentRegionDecoderFactory implements DecoderFactory<AttachmentRegionDecoder> {
@Override
public AttachmentRegionDecoder make() throws IllegalAccessException, InstantiationException {
return new AttachmentRegionDecoder();
}
}
}

View File

@@ -0,0 +1,33 @@
package org.thoughtcrime.securesms.components.camera;
import android.content.Context;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
public class CameraSurfaceView extends SurfaceView implements SurfaceHolder.Callback {
private boolean ready;
@SuppressWarnings("deprecation")
public CameraSurfaceView(Context context) {
super(context);
getHolder().setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);
getHolder().addCallback(this);
}
public boolean isReady() {
return ready;
}
@Override
public void surfaceCreated(SurfaceHolder holder) {
ready = true;
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
ready = false;
}
}

View File

@@ -0,0 +1,108 @@
package org.thoughtcrime.securesms.components.camera;
import android.app.Activity;
import android.hardware.Camera;
import android.hardware.Camera.CameraInfo;
import android.hardware.Camera.Parameters;
import android.hardware.Camera.Size;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.util.DisplayMetrics;
import org.thoughtcrime.securesms.logging.Log;
import android.view.Surface;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
@SuppressWarnings("deprecation")
public class CameraUtils {
private static final String TAG = CameraUtils.class.getSimpleName();
/*
* modified from: https://github.com/commonsguy/cwac-camera/blob/master/camera/src/com/commonsware/cwac/camera/CameraUtils.java
*/
public static @Nullable Size getPreferredPreviewSize(int displayOrientation,
int width,
int height,
@NonNull Parameters parameters) {
final int targetWidth = displayOrientation % 180 == 90 ? height : width;
final int targetHeight = displayOrientation % 180 == 90 ? width : height;
final double targetRatio = (double) targetWidth / targetHeight;
Log.d(TAG, String.format(Locale.US,
"getPreferredPreviewSize(%d, %d, %d) -> target %dx%d, AR %.02f",
displayOrientation, width, height,
targetWidth, targetHeight, targetRatio));
List<Size> sizes = parameters.getSupportedPreviewSizes();
List<Size> ideals = new LinkedList<>();
List<Size> bigEnough = new LinkedList<>();
for (Size size : sizes) {
Log.d(TAG, String.format(Locale.US, " %dx%d (%.02f)", size.width, size.height, (float)size.width / size.height));
if (size.height == size.width * targetRatio && size.height >= targetHeight && size.width >= targetWidth) {
ideals.add(size);
Log.d(TAG, " (ideal ratio)");
} else if (size.width >= targetWidth && size.height >= targetHeight) {
bigEnough.add(size);
Log.d(TAG, " (good size, suboptimal ratio)");
}
}
if (!ideals.isEmpty()) return Collections.min(ideals, new AreaComparator());
else if (!bigEnough.isEmpty()) return Collections.min(bigEnough, new AspectRatioComparator(targetRatio));
else return Collections.max(sizes, new AreaComparator());
}
// based on
// http://developer.android.com/reference/android/hardware/Camera.html#setDisplayOrientation(int)
// and http://stackoverflow.com/a/10383164/115145
public static int getCameraDisplayOrientation(@NonNull Activity activity,
@NonNull CameraInfo info)
{
int rotation = activity.getWindowManager().getDefaultDisplay().getRotation();
int degrees = 0;
DisplayMetrics dm = new DisplayMetrics();
activity.getWindowManager().getDefaultDisplay().getMetrics(dm);
switch (rotation) {
case Surface.ROTATION_0: degrees = 0; break;
case Surface.ROTATION_90: degrees = 90; break;
case Surface.ROTATION_180: degrees = 180; break;
case Surface.ROTATION_270: degrees = 270; break;
}
if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
return (360 - ((info.orientation + degrees) % 360)) % 360;
} else {
return (info.orientation - degrees + 360) % 360;
}
}
private static class AreaComparator implements Comparator<Size> {
@Override
public int compare(Size lhs, Size rhs) {
return Long.signum(lhs.width * lhs.height - rhs.width * rhs.height);
}
}
private static class AspectRatioComparator extends AreaComparator {
private final double target;
public AspectRatioComparator(double target) {
this.target = target;
}
@Override
public int compare(Size lhs, Size rhs) {
final double lhsDiff = Math.abs(target - (double) lhs.width / lhs.height);
final double rhsDiff = Math.abs(target - (double) rhs.width / rhs.height);
if (lhsDiff < rhsDiff) return -1;
else if (lhsDiff > rhsDiff) return 1;
else return super.compare(lhs, rhs);
}
}
}

View File

@@ -0,0 +1,606 @@
/***
Copyright (c) 2013-2014 CommonsWare, LLC
Portions Copyright (C) 2007 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License"); you may
not use this file except in compliance with the License. You may obtain
a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package org.thoughtcrime.securesms.components.camera;
import android.annotation.TargetApi;
import android.app.Activity;
import android.content.Context;
import android.content.pm.ActivityInfo;
import android.content.res.TypedArray;
import android.graphics.Color;
import android.graphics.Rect;
import android.hardware.Camera;
import android.hardware.Camera.CameraInfo;
import android.hardware.Camera.Parameters;
import android.hardware.Camera.Size;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Build.VERSION;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.util.AttributeSet;
import org.thoughtcrime.securesms.logging.Log;
import android.view.OrientationEventListener;
import android.view.ViewGroup;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.BitmapUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
@SuppressWarnings("deprecation")
public class CameraView extends ViewGroup {
private static final String TAG = CameraView.class.getSimpleName();
private final CameraSurfaceView surface;
private final OnOrientationChange onOrientationChange;
private volatile Optional<Camera> camera = Optional.absent();
private volatile int cameraId = CameraInfo.CAMERA_FACING_BACK;
private volatile int displayOrientation = -1;
private @NonNull State state = State.PAUSED;
private @Nullable Size previewSize;
private @NonNull List<CameraViewListener> listeners = Collections.synchronizedList(new LinkedList<CameraViewListener>());
private int outputOrientation = -1;
public CameraView(Context context) {
this(context, null);
}
public CameraView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public CameraView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
setBackgroundColor(Color.BLACK);
if (attrs != null) {
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CameraView);
int camera = typedArray.getInt(R.styleable.CameraView_camera, -1);
if (camera != -1) cameraId = camera;
else if (isMultiCamera()) cameraId = TextSecurePreferences.getDirectCaptureCameraId(context);
typedArray.recycle();
}
surface = new CameraSurfaceView(getContext());
onOrientationChange = new OnOrientationChange(context.getApplicationContext());
addView(surface);
}
@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
public void onResume() {
if (state != State.PAUSED) return;
state = State.RESUMED;
Log.i(TAG, "onResume() queued");
enqueueTask(new SerialAsyncTask<Void>() {
@Override
protected
@Nullable
Void onRunBackground() {
try {
long openStartMillis = System.currentTimeMillis();
camera = Optional.fromNullable(Camera.open(cameraId));
Log.i(TAG, "camera.open() -> " + (System.currentTimeMillis() - openStartMillis) + "ms");
synchronized (CameraView.this) {
CameraView.this.notifyAll();
}
if (camera.isPresent()) onCameraReady(camera.get());
} catch (Exception e) {
Log.w(TAG, e);
}
return null;
}
@Override
protected void onPostMain(Void avoid) {
if (!camera.isPresent()) {
Log.w(TAG, "tried to open camera but got null");
for (CameraViewListener listener : listeners) {
listener.onCameraFail();
}
return;
}
if (getActivity().getRequestedOrientation() != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) {
onOrientationChange.enable();
}
Log.i(TAG, "onResume() completed");
}
});
}
public void onPause() {
if (state == State.PAUSED) return;
state = State.PAUSED;
Log.i(TAG, "onPause() queued");
enqueueTask(new SerialAsyncTask<Void>() {
private Optional<Camera> cameraToDestroy;
@Override
protected void onPreMain() {
cameraToDestroy = camera;
camera = Optional.absent();
}
@Override
protected Void onRunBackground() {
if (cameraToDestroy.isPresent()) {
try {
stopPreview();
cameraToDestroy.get().setPreviewCallback(null);
cameraToDestroy.get().release();
Log.w(TAG, "released old camera instance");
} catch (Exception e) {
Log.w(TAG, e);
}
}
return null;
}
@Override protected void onPostMain(Void avoid) {
onOrientationChange.disable();
displayOrientation = -1;
outputOrientation = -1;
removeView(surface);
addView(surface);
Log.i(TAG, "onPause() completed");
}
});
for (CameraViewListener listener : listeners) {
listener.onCameraStop();
}
}
public boolean isStarted() {
return state != State.PAUSED;
}
@SuppressWarnings("SuspiciousNameCombination")
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
final int width = r - l;
final int height = b - t;
final int previewWidth;
final int previewHeight;
if (camera.isPresent() && previewSize != null) {
if (displayOrientation == 90 || displayOrientation == 270) {
previewWidth = previewSize.height;
previewHeight = previewSize.width;
} else {
previewWidth = previewSize.width;
previewHeight = previewSize.height;
}
} else {
previewWidth = width;
previewHeight = height;
}
if (previewHeight == 0 || previewWidth == 0) {
Log.w(TAG, "skipping layout due to zero-width/height preview size");
return;
}
if (width * previewHeight > height * previewWidth) {
final int scaledChildHeight = previewHeight * width / previewWidth;
surface.layout(0, (height - scaledChildHeight) / 2, width, (height + scaledChildHeight) / 2);
} else {
final int scaledChildWidth = previewWidth * height / previewHeight;
surface.layout((width - scaledChildWidth) / 2, 0, (width + scaledChildWidth) / 2, height);
}
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
Log.i(TAG, "onSizeChanged(" + oldw + "x" + oldh + " -> " + w + "x" + h + ")");
super.onSizeChanged(w, h, oldw, oldh);
if (camera.isPresent()) startPreview(camera.get().getParameters());
}
public void addListener(@NonNull CameraViewListener listener) {
listeners.add(listener);
}
public void setPreviewCallback(final @NonNull PreviewCallback previewCallback) {
enqueueTask(new PostInitializationTask<Void>() {
@Override
protected void onPostMain(Void avoid) {
if (camera.isPresent()) {
camera.get().setPreviewCallback(new Camera.PreviewCallback() {
@Override
public void onPreviewFrame(byte[] data, Camera camera) {
if (!CameraView.this.camera.isPresent()) {
return;
}
final int rotation = getCameraPictureOrientation();
final Size previewSize = camera.getParameters().getPreviewSize();
if (data != null) {
previewCallback.onPreviewFrame(new PreviewFrame(data, previewSize.width, previewSize.height, rotation));
}
}
});
}
}
});
}
public boolean isMultiCamera() {
return Camera.getNumberOfCameras() > 1;
}
public boolean isRearCamera() {
return cameraId == CameraInfo.CAMERA_FACING_BACK;
}
public void flipCamera() {
if (Camera.getNumberOfCameras() > 1) {
cameraId = cameraId == CameraInfo.CAMERA_FACING_BACK
? CameraInfo.CAMERA_FACING_FRONT
: CameraInfo.CAMERA_FACING_BACK;
onPause();
onResume();
TextSecurePreferences.setDirectCaptureCameraId(getContext(), cameraId);
}
}
@TargetApi(14)
private void onCameraReady(final @NonNull Camera camera) {
final Parameters parameters = camera.getParameters();
if (VERSION.SDK_INT >= 14) {
parameters.setRecordingHint(true);
final List<String> focusModes = parameters.getSupportedFocusModes();
if (focusModes.contains(Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) {
parameters.setFocusMode(Parameters.FOCUS_MODE_CONTINUOUS_PICTURE);
} else if (focusModes.contains(Parameters.FOCUS_MODE_CONTINUOUS_VIDEO)) {
parameters.setFocusMode(Parameters.FOCUS_MODE_CONTINUOUS_VIDEO);
}
}
displayOrientation = CameraUtils.getCameraDisplayOrientation(getActivity(), getCameraInfo());
camera.setDisplayOrientation(displayOrientation);
camera.setParameters(parameters);
enqueueTask(new PostInitializationTask<Void>() {
@Override
protected Void onRunBackground() {
try {
camera.setPreviewDisplay(surface.getHolder());
startPreview(parameters);
} catch (Exception e) {
Log.w(TAG, "couldn't set preview display", e);
}
return null;
}
});
}
private void startPreview(final @NonNull Parameters parameters) {
if (this.camera.isPresent()) {
try {
final Camera camera = this.camera.get();
final Size preferredPreviewSize = getPreferredPreviewSize(parameters);
if (preferredPreviewSize != null && !parameters.getPreviewSize().equals(preferredPreviewSize)) {
Log.i(TAG, "starting preview with size " + preferredPreviewSize.width + "x" + preferredPreviewSize.height);
if (state == State.ACTIVE) stopPreview();
previewSize = preferredPreviewSize;
parameters.setPreviewSize(preferredPreviewSize.width, preferredPreviewSize.height);
camera.setParameters(parameters);
} else {
previewSize = parameters.getPreviewSize();
}
long previewStartMillis = System.currentTimeMillis();
camera.startPreview();
Log.i(TAG, "camera.startPreview() -> " + (System.currentTimeMillis() - previewStartMillis) + "ms");
state = State.ACTIVE;
Util.runOnMain(new Runnable() {
@Override
public void run() {
requestLayout();
for (CameraViewListener listener : listeners) {
listener.onCameraStart();
}
}
});
} catch (Exception e) {
Log.w(TAG, e);
}
}
}
private void stopPreview() {
if (camera.isPresent()) {
try {
camera.get().stopPreview();
state = State.RESUMED;
} catch (Exception e) {
Log.w(TAG, e);
}
}
}
private Size getPreferredPreviewSize(@NonNull Parameters parameters) {
return CameraUtils.getPreferredPreviewSize(displayOrientation,
getMeasuredWidth(),
getMeasuredHeight(),
parameters);
}
private int getCameraPictureOrientation() {
if (getActivity().getRequestedOrientation() != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) {
outputOrientation = getCameraPictureRotation(getActivity().getWindowManager()
.getDefaultDisplay()
.getOrientation());
} else if (getCameraInfo().facing == CameraInfo.CAMERA_FACING_FRONT) {
outputOrientation = (360 - displayOrientation) % 360;
} else {
outputOrientation = displayOrientation;
}
return outputOrientation;
}
// https://github.com/signalapp/Signal-Android/issues/4715
private boolean isTroublemaker() {
return getCameraInfo().facing == CameraInfo.CAMERA_FACING_FRONT &&
"JWR66Y".equals(Build.DISPLAY) &&
"yakju".equals(Build.PRODUCT);
}
private @NonNull CameraInfo getCameraInfo() {
final CameraInfo info = new Camera.CameraInfo();
Camera.getCameraInfo(cameraId, info);
return info;
}
// XXX this sucks
private Activity getActivity() {
return (Activity)getContext();
}
public int getCameraPictureRotation(int orientation) {
final CameraInfo info = getCameraInfo();
final int rotation;
orientation = (orientation + 45) / 90 * 90;
if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
rotation = (info.orientation - orientation + 360) % 360;
} else {
rotation = (info.orientation + orientation) % 360;
}
return rotation;
}
private class OnOrientationChange extends OrientationEventListener {
public OnOrientationChange(Context context) {
super(context);
disable();
}
@Override
public void onOrientationChanged(int orientation) {
if (camera.isPresent() && orientation != ORIENTATION_UNKNOWN) {
int newOutputOrientation = getCameraPictureRotation(orientation);
if (newOutputOrientation != outputOrientation) {
outputOrientation = newOutputOrientation;
Camera.Parameters params = camera.get().getParameters();
params.setRotation(outputOrientation);
try {
camera.get().setParameters(params);
}
catch (Exception e) {
Log.e(TAG, "Exception updating camera parameters in orientation change", e);
}
}
}
}
}
public void takePicture(final Rect previewRect) {
if (!camera.isPresent() || camera.get().getParameters() == null) {
Log.w(TAG, "camera not in capture-ready state");
return;
}
camera.get().setOneShotPreviewCallback(new Camera.PreviewCallback() {
@Override
public void onPreviewFrame(byte[] data, final Camera camera) {
final int rotation = getCameraPictureOrientation();
final Size previewSize = camera.getParameters().getPreviewSize();
final Rect croppingRect = getCroppedRect(previewSize, previewRect, rotation);
Log.i(TAG, "previewSize: " + previewSize.width + "x" + previewSize.height);
Log.i(TAG, "data bytes: " + data.length);
Log.i(TAG, "previewFormat: " + camera.getParameters().getPreviewFormat());
Log.i(TAG, "croppingRect: " + croppingRect.toString());
Log.i(TAG, "rotation: " + rotation);
new CaptureTask(previewSize, rotation, croppingRect).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, data);
}
});
}
private Rect getCroppedRect(Size cameraPreviewSize, Rect visibleRect, int rotation) {
final int previewWidth = cameraPreviewSize.width;
final int previewHeight = cameraPreviewSize.height;
if (rotation % 180 > 0) rotateRect(visibleRect);
float scale = (float) previewWidth / visibleRect.width();
if (visibleRect.height() * scale > previewHeight) {
scale = (float) previewHeight / visibleRect.height();
}
final float newWidth = visibleRect.width() * scale;
final float newHeight = visibleRect.height() * scale;
final float centerX = (VERSION.SDK_INT < 14 || isTroublemaker()) ? previewWidth - newWidth / 2 : previewWidth / 2;
final float centerY = previewHeight / 2;
visibleRect.set((int) (centerX - newWidth / 2),
(int) (centerY - newHeight / 2),
(int) (centerX + newWidth / 2),
(int) (centerY + newHeight / 2));
if (rotation % 180 > 0) rotateRect(visibleRect);
return visibleRect;
}
@SuppressWarnings("SuspiciousNameCombination")
private void rotateRect(Rect rect) {
rect.set(rect.top, rect.left, rect.bottom, rect.right);
}
private void enqueueTask(SerialAsyncTask job) {
AsyncTask.SERIAL_EXECUTOR.execute(job);
}
public static abstract class SerialAsyncTask<Result> implements Runnable {
@Override
public final void run() {
if (!onWait()) {
Log.w(TAG, "skipping task, preconditions not met in onWait()");
return;
}
Util.runOnMainSync(this::onPreMain);
final Result result = onRunBackground();
Util.runOnMainSync(() -> onPostMain(result));
}
protected boolean onWait() { return true; }
protected void onPreMain() {}
protected Result onRunBackground() { return null; }
protected void onPostMain(Result result) {}
}
private abstract class PostInitializationTask<Result> extends SerialAsyncTask<Result> {
@Override protected boolean onWait() {
synchronized (CameraView.this) {
if (!camera.isPresent()) {
return false;
}
while (getMeasuredHeight() <= 0 || getMeasuredWidth() <= 0 || !surface.isReady()) {
Log.i(TAG, String.format("waiting. surface ready? %s", surface.isReady()));
Util.wait(CameraView.this, 0);
}
return true;
}
}
}
private class CaptureTask extends AsyncTask<byte[], Void, byte[]> {
private final Size previewSize;
private final int rotation;
private final Rect croppingRect;
public CaptureTask(Size previewSize, int rotation, Rect croppingRect) {
this.previewSize = previewSize;
this.rotation = rotation;
this.croppingRect = croppingRect;
}
@Override
protected byte[] doInBackground(byte[]... params) {
final byte[] data = params[0];
try {
return BitmapUtil.createFromNV21(data,
previewSize.width,
previewSize.height,
rotation,
croppingRect,
cameraId == CameraInfo.CAMERA_FACING_FRONT);
} catch (IOException e) {
Log.w(TAG, e);
return null;
}
}
@Override
protected void onPostExecute(byte[] imageBytes) {
if (imageBytes != null) {
for (CameraViewListener listener : listeners) {
listener.onImageCapture(imageBytes);
}
}
}
}
private static class PreconditionsNotMetException extends Exception {}
public interface CameraViewListener {
void onImageCapture(@NonNull final byte[] imageBytes);
void onCameraFail();
void onCameraStart();
void onCameraStop();
}
public interface PreviewCallback {
void onPreviewFrame(@NonNull PreviewFrame frame);
}
public static class PreviewFrame {
private final @NonNull byte[] data;
private final int width;
private final int height;
private final int orientation;
private PreviewFrame(@NonNull byte[] data, int width, int height, int orientation) {
this.data = data;
this.width = width;
this.height = height;
this.orientation = orientation;
}
public @NonNull byte[] getData() {
return data;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
public int getOrientation() {
return orientation;
}
}
private enum State {
PAUSED, RESUMED, ACTIVE
}
}

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 AnimatingImageSpan extends ImageSpan {
public AnimatingImageSpan(Drawable drawable, Callback callback) {
super(drawable, ALIGN_BOTTOM);
drawable.setCallback(callback);
}
}

View File

@@ -0,0 +1,60 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import androidx.annotation.Nullable;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.View;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ResUtil;
public class AsciiEmojiView extends View {
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
private String emoji;
public AsciiEmojiView(Context context) {
super(context);
}
public AsciiEmojiView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public void setEmoji(String emoji) {
this.emoji = emoji;
}
@Override
protected void onDraw(Canvas canvas) {
if (TextUtils.isEmpty(emoji)) {
return;
}
float targetFontSize = 0.75f * getHeight() - getPaddingTop() - getPaddingBottom();
paint.setTextSize(targetFontSize);
paint.setColor(ResUtil.getColor(getContext(), R.attr.emoji_text_color));
paint.setTextAlign(Paint.Align.CENTER);
int xPos = (getWidth() / 2);
int yPos = (int) ((getHeight() / 2) - ((paint.descent() + paint.ascent()) / 2));
float overflow = paint.measureText(emoji) / (getWidth() - getPaddingLeft() - getPaddingRight());
if (overflow > 1f) {
paint.setTextSize(targetFontSize / overflow);
yPos = (int) ((getHeight() / 2) - ((paint.descent() + paint.ascent()) / 2));
}
canvas.drawText(emoji, xPos, yPos, paint);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//noinspection SuspiciousNameCombination
super.onMeasure(widthMeasureSpec, widthMeasureSpec);
}
}

View File

@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.components.emoji;
import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.LinkedList;
import java.util.List;
public class CompositeEmojiPageModel implements EmojiPageModel {
@AttrRes private final int iconAttr;
@NonNull private final EmojiPageModel[] models;
public CompositeEmojiPageModel(@AttrRes int iconAttr, @NonNull EmojiPageModel... models) {
this.iconAttr = iconAttr;
this.models = models;
}
public int getIconAttr() {
return iconAttr;
}
@Override
public @NonNull List<String> getEmoji() {
List<String> emojis = new LinkedList<>();
for (EmojiPageModel model : models) {
emojis.addAll(model.getEmoji());
}
return emojis;
}
@Override
public @NonNull List<Emoji> getDisplayEmoji() {
List<Emoji> emojis = new LinkedList<>();
for (EmojiPageModel model : models) {
emojis.addAll(model.getDisplayEmoji());
}
return emojis;
}
@Override
public boolean hasSpriteMap() {
return false;
}
@Override
public @Nullable String getSprite() {
return null;
}
@Override
public boolean isDynamic() {
return false;
}
}

View File

@@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.components.emoji;
import java.util.Arrays;
import java.util.List;
public class Emoji {
private final List<String> variations;
public Emoji(String... variations) {
this.variations = Arrays.asList(variations);
}
public String getValue() {
return variations.get(0);
}
public List<String> getVariations() {
return variations;
}
}

View File

@@ -0,0 +1,62 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatEditText;
import android.text.InputFilter;
import android.util.AttributeSet;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
public class EmojiEditText extends AppCompatEditText {
private static final String TAG = EmojiEditText.class.getSimpleName();
public EmojiEditText(Context context) {
this(context, null);
}
public EmojiEditText(Context context, AttributeSet attrs) {
this(context, attrs, R.attr.editTextStyle);
}
public EmojiEditText(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
if (!TextSecurePreferences.isSystemEmojiPreferred(getContext())) {
setFilters(appendEmojiFilter(this.getFilters()));
}
}
public void insertEmoji(String emoji) {
final int start = getSelectionStart();
final int end = getSelectionEnd();
getText().replace(Math.min(start, end), Math.max(start, end), emoji);
setSelection(start + emoji.length());
}
@Override
public void invalidateDrawable(@NonNull Drawable drawable) {
if (drawable instanceof EmojiDrawable) invalidate();
else super.invalidateDrawable(drawable);
}
private InputFilter[] appendEmojiFilter(@Nullable InputFilter[] originalFilters) {
InputFilter[] result;
if (originalFilters != null) {
result = new InputFilter[originalFilters.length + 1];
System.arraycopy(originalFilters, 0, result, 1, originalFilters.length);
} else {
result = new InputFilter[1];
}
result[0] = new EmojiFilter(this);
return result;
}
}

View File

@@ -0,0 +1,30 @@
package org.thoughtcrime.securesms.components.emoji;
import android.text.InputFilter;
import android.text.Spannable;
import android.text.Spanned;
import android.text.TextUtils;
import android.widget.TextView;
public class EmojiFilter implements InputFilter {
private TextView view;
public EmojiFilter(TextView view) {
this.view = view;
}
@Override
public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend)
{
char[] v = new char[end - start];
TextUtils.getChars(source, start, end, v, 0);
Spannable emojified = EmojiProvider.getInstance(view.getContext()).emojify(new String(v), view);
if (source instanceof Spanned && emojified != null) {
TextUtils.copySpansFrom((Spanned) source, start, end, null, emojified, 0);
}
return emojified;
}
}

View File

@@ -0,0 +1,20 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.util.AttributeSet;
import androidx.appcompat.widget.AppCompatImageView;
public class EmojiImageView extends AppCompatImageView {
public EmojiImageView(Context context) {
super(context);
}
public EmojiImageView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public void setImageEmoji(CharSequence emoji) {
setImageDrawable(EmojiProvider.getInstance(getContext()).getEmojiDrawable(emoji));
}
}

View File

@@ -0,0 +1,164 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.viewpager.widget.PagerAdapter;
import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.util.ResUtil;
import org.thoughtcrime.securesms.util.ThemeUtil;
import java.util.LinkedList;
import java.util.List;
/**
* A provider to select emoji in the {@link org.thoughtcrime.securesms.components.emoji.MediaKeyboard}.
*/
public class EmojiKeyboardProvider implements MediaKeyboardProvider,
MediaKeyboardProvider.TabIconProvider,
MediaKeyboardProvider.BackspaceObserver,
VariationSelectorListener
{
private static final KeyEvent DELETE_KEY_EVENT = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL);
private final Context context;
private final List<EmojiPageModel> models;
private final RecentEmojiPageModel recentModel;
private final EmojiPagerAdapter emojiPagerAdapter;
private final EmojiEventListener emojiEventListener;
private Controller controller;
public EmojiKeyboardProvider(@NonNull Context context, @Nullable EmojiEventListener emojiEventListener) {
this.context = context;
this.emojiEventListener = emojiEventListener;
this.models = new LinkedList<>();
this.recentModel = new RecentEmojiPageModel(context);
this.emojiPagerAdapter = new EmojiPagerAdapter(context, models, new EmojiEventListener() {
@Override
public void onEmojiSelected(String emoji) {
recentModel.onCodePointSelected(emoji);
if (emojiEventListener != null) {
emojiEventListener.onEmojiSelected(emoji);
}
}
@Override
public void onKeyEvent(KeyEvent keyEvent) {
if (emojiEventListener != null) {
emojiEventListener.onKeyEvent(keyEvent);
}
}
}, this);
models.add(recentModel);
models.addAll(EmojiPages.DISPLAY_PAGES);
}
@Override
public void requestPresentation(@NonNull Presenter presenter, boolean isSoloProvider) {
presenter.present(this, emojiPagerAdapter, this, this, null, null, recentModel.getEmoji().size() > 0 ? 0 : 1);
}
@Override
public void setController(@Nullable Controller controller) {
this.controller = controller;
}
@Override
public int getProviderIconView(boolean selected) {
if (selected) {
return ThemeUtil.isDarkTheme(context) ? R.layout.emoji_keyboard_icon_dark_selected : R.layout.emoji_keyboard_icon_light_selected;
} else {
return ThemeUtil.isDarkTheme(context) ? R.layout.emoji_keyboard_icon_dark : R.layout.emoji_keyboard_icon_light;
}
}
@Override
public void loadCategoryTabIcon(@NonNull GlideRequests glideRequests, @NonNull ImageView imageView, int index) {
Drawable drawable = ResUtil.getDrawable(context, models.get(index).getIconAttr());
imageView.setImageDrawable(drawable);
}
@Override
public void onBackspaceClicked() {
if (emojiEventListener != null) {
emojiEventListener.onKeyEvent(DELETE_KEY_EVENT);
}
}
@Override
public void onVariationSelectorStateChanged(boolean open) {
if (controller != null) {
controller.setViewPagerEnabled(!open);
}
}
@Override
public boolean equals(@Nullable Object obj) {
return obj instanceof EmojiKeyboardProvider;
}
private static class EmojiPagerAdapter extends PagerAdapter {
private Context context;
private List<EmojiPageModel> pages;
private EmojiEventListener emojiSelectionListener;
private VariationSelectorListener variationSelectorListener;
public EmojiPagerAdapter(@NonNull Context context,
@NonNull List<EmojiPageModel> pages,
@NonNull EmojiEventListener emojiSelectionListener,
@NonNull VariationSelectorListener variationSelectorListener)
{
super();
this.context = context;
this.pages = pages;
this.emojiSelectionListener = emojiSelectionListener;
this.variationSelectorListener = variationSelectorListener;
}
@Override
public int getCount() {
return pages.size();
}
@Override
public @NonNull Object instantiateItem(@NonNull ViewGroup container, int position) {
EmojiPageView page = new EmojiPageView(context, emojiSelectionListener, variationSelectorListener);
page.setModel(pages.get(position));
container.addView(page);
return page;
}
@Override
public void destroyItem(ViewGroup container, int position, Object object) {
container.removeView((View)object);
}
@Override
public void setPrimaryItem(ViewGroup container, int position, Object object) {
EmojiPageView current = (EmojiPageView) object;
current.onSelected();
super.setPrimaryItem(container, position, object);
}
@Override
public boolean isViewFromObject(View view, Object object) {
return view == object;
}
}
public interface EmojiEventListener {
void onEmojiSelected(String emoji);
void onKeyEvent(KeyEvent keyEvent);
}
}

View File

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

View File

@@ -0,0 +1,98 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.widget.FrameLayout;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener;
public class EmojiPageView extends FrameLayout implements VariationSelectorListener {
private static final String TAG = EmojiPageView.class.getSimpleName();
private EmojiPageModel model;
private EmojiPageViewGridAdapter adapter;
private RecyclerView recyclerView;
private GridLayoutManager layoutManager;
private RecyclerView.OnItemTouchListener scrollDisabler;
private VariationSelectorListener variationSelectorListener;
private EmojiVariationSelectorPopup popup;
public EmojiPageView(@NonNull Context context,
@NonNull EmojiEventListener emojiSelectionListener,
@NonNull VariationSelectorListener variationSelectorListener)
{
super(context);
final View view = LayoutInflater.from(getContext()).inflate(R.layout.emoji_grid_layout, this, true);
this.variationSelectorListener = variationSelectorListener;
recyclerView = view.findViewById(R.id.emoji);
layoutManager = new GridLayoutManager(context, 8);
scrollDisabler = new ScrollDisabler();
popup = new EmojiVariationSelectorPopup(context, emojiSelectionListener);
adapter = new EmojiPageViewGridAdapter(EmojiProvider.getInstance(context),
popup,
emojiSelectionListener,
this);
recyclerView.setLayoutManager(layoutManager);
recyclerView.setAdapter(adapter);
}
public void onSelected() {
if (model.isDynamic() && adapter != null) {
adapter.notifyDataSetChanged();
}
}
public void setModel(EmojiPageModel model) {
this.model = model;
adapter.setEmoji(model.getDisplayEmoji());
}
@Override
protected void onVisibilityChanged(@NonNull View changedView, int visibility) {
if (visibility != VISIBLE) {
popup.dismiss();
}
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
int idealWidth = getContext().getResources().getDimensionPixelOffset(R.dimen.emoji_drawer_item_width);
layoutManager.setSpanCount(Math.max(w / idealWidth, 1));
}
@Override
public void onVariationSelectorStateChanged(boolean open) {
if (open) {
recyclerView.addOnItemTouchListener(scrollDisabler);
} else {
post(() -> recyclerView.removeOnItemTouchListener(scrollDisabler));
}
if (variationSelectorListener != null) {
variationSelectorListener.onVariationSelectorStateChanged(open);
}
}
private static class ScrollDisabler implements RecyclerView.OnItemTouchListener {
@Override
public boolean onInterceptTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent motionEvent) {
return true;
}
@Override
public void onTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent motionEvent) { }
@Override
public void onRequestDisallowInterceptTouchEvent(boolean b) { }
}
}

View File

@@ -0,0 +1,116 @@
package org.thoughtcrime.securesms.components.emoji;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.PopupWindow;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
import java.util.ArrayList;
import java.util.List;
public class EmojiPageViewGridAdapter extends RecyclerView.Adapter<EmojiPageViewGridAdapter.EmojiViewHolder> implements PopupWindow.OnDismissListener {
private final List<Emoji> emojiList;
private final EmojiProvider emojiProvider;
private final EmojiVariationSelectorPopup popup;
private final VariationSelectorListener variationSelectorListener;
private final EmojiEventListener emojiEventListener;
public EmojiPageViewGridAdapter(@NonNull EmojiProvider emojiProvider,
@NonNull EmojiVariationSelectorPopup popup,
@NonNull EmojiEventListener emojiEventListener,
@NonNull VariationSelectorListener variationSelectorListener)
{
this.emojiList = new ArrayList<>();
this.emojiProvider = emojiProvider;
this.popup = popup;
this.emojiEventListener = emojiEventListener;
this.variationSelectorListener = variationSelectorListener;
popup.setOnDismissListener(this);
}
@NonNull
@Override
public EmojiViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
return new EmojiViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.emoji_display_item, viewGroup, false));
}
@Override
public void onBindViewHolder(@NonNull EmojiViewHolder viewHolder, int i) {
Emoji emoji = emojiList.get(i);
Drawable drawable = emojiProvider.getEmojiDrawable(emoji.getValue());
if (drawable != null) {
viewHolder.textView.setVisibility(View.GONE);
viewHolder.imageView.setVisibility(View.VISIBLE);
viewHolder.imageView.setImageDrawable(drawable);
} else {
viewHolder.textView.setVisibility(View.VISIBLE);
viewHolder.imageView.setVisibility(View.GONE);
viewHolder.textView.setEmoji(emoji.getValue());
}
viewHolder.itemView.setOnClickListener(v -> {
emojiEventListener.onEmojiSelected(emoji.getValue());
});
if (emoji.getVariations().size() > 1) {
viewHolder.itemView.setOnLongClickListener(v -> {
popup.dismiss();
popup.setVariations(emoji.getVariations());
popup.showAsDropDown(viewHolder.itemView, 0, -(2 * viewHolder.itemView.getHeight()));
variationSelectorListener.onVariationSelectorStateChanged(true);
return true;
});
viewHolder.hintCorner.setVisibility(View.VISIBLE);
} else {
viewHolder.itemView.setOnLongClickListener(null);
viewHolder.hintCorner.setVisibility(View.GONE);
}
}
@Override
public int getItemCount() {
return emojiList.size();
}
public void setEmoji(@NonNull List<Emoji> emojiList) {
this.emojiList.clear();
this.emojiList.addAll(emojiList);
notifyDataSetChanged();
}
@Override
public void onDismiss() {
variationSelectorListener.onVariationSelectorStateChanged(false);
}
static class EmojiViewHolder extends RecyclerView.ViewHolder {
private final ImageView imageView;
private final AsciiEmojiView textView;
private final ImageView hintCorner;
public EmojiViewHolder(@NonNull View itemView) {
super(itemView);
this.imageView = itemView.findViewById(R.id.emoji_image);
this.textView = itemView.findViewById(R.id.emoji_text);
this.hintCorner = itemView.findViewById(R.id.emoji_variation_hint);
}
}
public interface VariationSelectorListener {
void onVariationSelectorStateChanged(boolean open);
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,191 @@
package org.thoughtcrime.securesms.components.emoji;
import android.annotation.TargetApi;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
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 androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.widget.TextView;
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.logging.Log;
import org.thoughtcrime.securesms.util.FutureTaskListener;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.Pair;
import java.util.List;
import java.util.concurrent.ExecutionException;
class EmojiProvider {
private static final String TAG = EmojiProvider.class.getSimpleName();
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 = 32;
private final float decodeScale;
private final float verticalPad;
public static EmojiProvider getInstance(Context context) {
if (instance == null) {
synchronized (EmojiProvider.class) {
if (instance == null) {
instance = new EmojiProvider(context);
}
}
}
return instance;
}
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()));
}
}
@Nullable EmojiParser.CandidateList getCandidates(@Nullable CharSequence text) {
if (text == null) return null;
return new EmojiParser(emojiTree).findCandidates(text);
}
@Nullable Spannable emojify(@Nullable CharSequence text, @NonNull TextView tv) {
return emojify(getCandidates(text), text, tv);
}
@Nullable Spannable emojify(@Nullable EmojiParser.CandidateList matches,
@Nullable CharSequence text,
@NonNull TextView tv) {
if (matches == null || text == null) return null;
SpannableStringBuilder builder = new SpannableStringBuilder(text);
for (EmojiParser.Candidate candidate : matches) {
Drawable drawable = getEmojiDrawable(candidate.getDrawInfo());
if (drawable != null) {
builder.setSpan(new EmojiSpan(drawable, tv), candidate.getStartIndex(), candidate.getEndIndex(),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
return builder;
}
@Nullable Drawable getEmojiDrawable(CharSequence emoji) {
EmojiDrawInfo drawInfo = emojiTree.getEmoji(emoji, 0, emoji.length());
return getEmojiDrawable(drawInfo);
}
private @Nullable Drawable getEmojiDrawable(@Nullable EmojiDrawInfo drawInfo) {
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) {
Util.runOnMain(() -> drawable.setBitmap(result));
}
@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;
@Override
public int getIntrinsicWidth() {
return (int)intrinsicWidth;
}
@Override
public int getIntrinsicHeight() {
return (int)intrinsicHeight;
}
EmojiDrawable(EmojiDrawInfo info, float decodeScale) {
this.info = info;
this.intrinsicWidth = EMOJI_RAW_WIDTH * decodeScale;
this.intrinsicHeight = EMOJI_RAW_HEIGHT * decodeScale;
}
@Override
public void draw(@NonNull Canvas canvas) {
if (bmp == null) {
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),
getBounds(),
paint);
}
@TargetApi(VERSION_CODES.HONEYCOMB_MR1)
public void setBitmap(Bitmap bitmap) {
Util.assertMainThread();
if (VERSION.SDK_INT < VERSION_CODES.HONEYCOMB_MR1 || bmp == null || !bmp.sameAs(bitmap)) {
bmp = bitmap;
invalidateSelf();
}
}
@Override
public int getOpacity() {
return PixelFormat.TRANSLUCENT;
}
@Override
public void setAlpha(int alpha) { }
@Override
public void setColorFilter(ColorFilter cf) { }
}
}

View File

@@ -0,0 +1,48 @@
package org.thoughtcrime.securesms.components.emoji;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Paint.FontMetricsInt;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import android.widget.TextView;
import org.thoughtcrime.securesms.R;
public class EmojiSpan extends AnimatingImageSpan {
private final float SHIFT_FACTOR = 1.5f;
private final int size;
private final FontMetricsInt fm;
public EmojiSpan(@NonNull Drawable drawable, @NonNull TextView tv) {
super(drawable, tv);
fm = tv.getPaint().getFontMetricsInt();
size = fm != null ? Math.abs(fm.descent) + Math.abs(fm.ascent)
: tv.getResources().getDimensionPixelSize(R.dimen.conversation_item_body_text_size);
getDrawable().setBounds(0, 0, size, size);
}
@Override
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, FontMetricsInt fm) {
if (fm != null && this.fm != null) {
fm.ascent = this.fm.ascent;
fm.descent = this.fm.descent;
fm.top = this.fm.top;
fm.bottom = this.fm.bottom;
fm.leading = this.fm.leading;
return size;
} else {
return super.getSize(paint, text, start, end, fm);
}
}
@Override
public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) {
int height = bottom - top;
int centeringMargin = (height - size) / 2;
int adjustedMargin = (int) (centeringMargin * SHIFT_FACTOR);
super.draw(canvas, text, start, end, x, top, y, bottom - adjustedMargin, paint);
}
}

View File

@@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.components.emoji;
public final class EmojiStrings {
public static final String BUST_IN_SILHOUETTE = "\uD83D\uDC64";
}

View File

@@ -0,0 +1,199 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.widget.TextViewCompat;
import androidx.appcompat.widget.AppCompatTextView;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.TypedValue;
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.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional;
public class EmojiTextView extends AppCompatTextView {
private final boolean scaleEmojis;
private static final char ELLIPSIS = '…';
private CharSequence previousText;
private BufferType previousBufferType;
private float originalFontSize;
private boolean useSystemEmoji;
private boolean sizeChangeInProgress;
private int maxLength;
private CharSequence overflowText;
private CharSequence previousOverflowText;
public EmojiTextView(Context context) {
this(context, null);
}
public EmojiTextView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public EmojiTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0);
scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false);
maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1);
a.recycle();
a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textSize});
originalFontSize = a.getDimensionPixelSize(0, 0);
a.recycle();
}
@Override public void setText(@Nullable CharSequence text, BufferType type) {
EmojiProvider provider = EmojiProvider.getInstance(getContext());
EmojiParser.CandidateList candidates = provider.getCandidates(text);
if (scaleEmojis && candidates != null && candidates.allEmojis) {
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;
super.setTextSize(TypedValue.COMPLEX_UNIT_PX, originalFontSize * scale);
} else if (scaleEmojis) {
super.setTextSize(TypedValue.COMPLEX_UNIT_PX, originalFontSize);
}
if (unchanged(text, overflowText, type)) {
return;
}
previousText = text;
previousOverflowText = overflowText;
previousBufferType = type;
useSystemEmoji = useSystemEmoji();
if (useSystemEmoji || candidates == null || candidates.size() == 0) {
super.setText(new SpannableStringBuilder(Optional.fromNullable(text).or("")).append(Optional.fromNullable(overflowText).or("")), BufferType.NORMAL);
if (getEllipsize() == TextUtils.TruncateAt.END && maxLength > 0) {
ellipsizeAnyTextForMaxLength();
}
} else {
CharSequence emojified = provider.emojify(candidates, text, this);
super.setText(new SpannableStringBuilder(emojified).append(Optional.fromNullable(overflowText).or("")), BufferType.SPANNABLE);
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
// We ellipsize them ourselves by manually truncating the appropriate section.
if (getEllipsize() == TextUtils.TruncateAt.END) {
if (maxLength > 0) {
ellipsizeAnyTextForMaxLength();
} else {
ellipsizeEmojiTextForMaxLines();
}
}
}
}
public void setOverflowText(@Nullable CharSequence overflowText) {
this.overflowText = overflowText;
setText(previousText, BufferType.SPANNABLE);
}
private void ellipsizeAnyTextForMaxLength() {
if (maxLength > 0 && getText().length() > maxLength + 1) {
SpannableStringBuilder newContent = new SpannableStringBuilder();
newContent.append(getText().subSequence(0, maxLength)).append(ELLIPSIS).append(Optional.fromNullable(overflowText).or(""));
EmojiParser.CandidateList newCandidates = EmojiProvider.getInstance(getContext()).getCandidates(newContent);
if (useSystemEmoji || newCandidates == null || newCandidates.size() == 0) {
super.setText(newContent, BufferType.NORMAL);
} else {
CharSequence emojified = EmojiProvider.getInstance(getContext()).emojify(newCandidates, newContent, this);
super.setText(emojified, BufferType.SPANNABLE);
}
}
}
private void ellipsizeEmojiTextForMaxLines() {
post(() -> {
if (getLayout() == null) {
ellipsizeEmojiTextForMaxLines();
return;
}
int maxLines = TextViewCompat.getMaxLines(EmojiTextView.this);
if (maxLines <= 0 && maxLength < 0) {
return;
}
int lineCount = getLineCount();
if (lineCount > maxLines) {
int overflowStart = getLayout().getLineStart(maxLines - 1);
CharSequence overflow = getText().subSequence(overflowStart, getText().length());
CharSequence ellipsized = TextUtils.ellipsize(overflow, getPaint(), getWidth(), TextUtils.TruncateAt.END);
SpannableStringBuilder newContent = new SpannableStringBuilder();
newContent.append(getText().subSequence(0, overflowStart))
.append(ellipsized.subSequence(0, ellipsized.length()))
.append(Optional.fromNullable(overflowText).or(""));
EmojiParser.CandidateList newCandidates = EmojiProvider.getInstance(getContext()).getCandidates(newContent);
CharSequence emojified = EmojiProvider.getInstance(getContext()).emojify(newCandidates, newContent, this);
super.setText(emojified, BufferType.SPANNABLE);
}
});
}
private boolean unchanged(CharSequence text, CharSequence overflowText, BufferType bufferType) {
return Util.equals(previousText, text) &&
Util.equals(previousOverflowText, overflowText) &&
Util.equals(previousBufferType, bufferType) &&
useSystemEmoji == useSystemEmoji() &&
!sizeChangeInProgress;
}
private boolean useSystemEmoji() {
return TextSecurePreferences.isSystemEmojiPreferred(getContext());
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (!sizeChangeInProgress) {
sizeChangeInProgress = true;
setText(previousText, previousBufferType);
sizeChangeInProgress = false;
}
}
@Override
public void invalidateDrawable(@NonNull Drawable drawable) {
if (drawable instanceof EmojiDrawable) invalidate();
else super.invalidateDrawable(drawable);
}
@Override
public void setTextSize(float size) {
setTextSize(TypedValue.COMPLEX_UNIT_SP, size);
}
@Override
public void setTextSize(int unit, float size) {
this.originalFontSize = TypedValue.applyDimension(unit, size, getResources().getDisplayMetrics());
super.setTextSize(unit, size);
}
}

View File

@@ -0,0 +1,85 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.AppCompatImageButton;
import android.util.AttributeSet;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.stickers.StickerKeyboardProvider;
import org.thoughtcrime.securesms.util.ResUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
public class EmojiToggle extends AppCompatImageButton implements MediaKeyboard.MediaKeyboardListener {
private Drawable emojiToggle;
private Drawable stickerToggle;
private Drawable mediaToggle;
private Drawable imeToggle;
public EmojiToggle(Context context) {
super(context);
initialize();
}
public EmojiToggle(Context context, AttributeSet attrs) {
super(context, attrs);
initialize();
}
public EmojiToggle(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
initialize();
}
public void setToMedia() {
setImageDrawable(mediaToggle);
}
public void setToIme() {
setImageDrawable(imeToggle);
}
private void initialize() {
this.emojiToggle = ResUtil.getDrawable(getContext(), R.attr.conversation_emoji_toggle);
this.stickerToggle = ResUtil.getDrawable(getContext(), R.attr.conversation_sticker_toggle);
this.imeToggle = ResUtil.getDrawable(getContext(), R.attr.conversation_keyboard_toggle);
this.mediaToggle = emojiToggle;
setToMedia();
}
public void attach(MediaKeyboard drawer) {
drawer.setKeyboardListener(this);
}
public void setStickerMode(boolean stickerMode) {
this.mediaToggle = stickerMode ? stickerToggle : emojiToggle;
if (getDrawable() != imeToggle) {
setToMedia();
}
}
public boolean isStickerMode() {
return this.mediaToggle == stickerToggle;
}
@Override public void onShown() {
setToIme();
}
@Override public void onHidden() {
setToMedia();
}
@Override
public void onKeyboardProviderChanged(@NonNull MediaKeyboardProvider provider) {
setStickerMode(provider instanceof StickerKeyboardProvider);
TextSecurePreferences.setMediaKeyboardMode(getContext(), (provider instanceof StickerKeyboardProvider) ? TextSecurePreferences.MediaKeyboardMode.STICKER
: TextSecurePreferences.MediaKeyboardMode.EMOJI);
}
}

View File

@@ -0,0 +1,51 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.os.Build;
import androidx.annotation.NonNull;
import android.view.LayoutInflater;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.PopupWindow;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
import java.util.List;
public class EmojiVariationSelectorPopup extends PopupWindow {
private final Context context;
private final ViewGroup list;
private final EmojiEventListener listener;
public EmojiVariationSelectorPopup(@NonNull Context context, @NonNull EmojiEventListener listener) {
super(LayoutInflater.from(context).inflate(R.layout.emoji_variation_selector, null),
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
this.context = context;
this.listener = listener;
this.list = (ViewGroup) getContentView();
setBackgroundDrawable(null);
setOutsideTouchable(true);
if (Build.VERSION.SDK_INT >= 21) {
setElevation(20);
}
}
public void setVariations(List<String> variations) {
list.removeAllViews();
for (String variation : variations) {
ImageView imageView = (ImageView) LayoutInflater.from(context).inflate(R.layout.emoji_variation_selector_item, list, false);
imageView.setImageDrawable(EmojiProvider.getInstance(context).getEmojiDrawable(variation));
imageView.setOnClickListener(v -> {
listener.onEmojiSelected(variation);
dismiss();
});
list.addView(imageView);
}
}
}

View File

@@ -0,0 +1,296 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager.widget.PagerAdapter;
import androidx.viewpager.widget.ViewPager;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.InputAwareLayout.InputView;
import org.thoughtcrime.securesms.components.RepeatableImageKey;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.GlideApp;
import java.util.Arrays;
public class MediaKeyboard extends FrameLayout implements InputView,
MediaKeyboardProvider.Presenter,
MediaKeyboardProvider.Controller,
MediaKeyboardBottomTabAdapter.EventListener
{
private static final String TAG = Log.tag(MediaKeyboard.class);
private RecyclerView categoryTabs;
private ViewPager categoryPager;
private ViewGroup providerTabs;
private RepeatableImageKey backspaceButton;
private RepeatableImageKey backspaceButtonBackup;
private View searchButton;
private View addButton;
@Nullable private MediaKeyboardListener keyboardListener;
private MediaKeyboardProvider[] providers;
private int providerIndex;
private final boolean tabsAtBottom;
private MediaKeyboardBottomTabAdapter categoryTabAdapter;
public MediaKeyboard(Context context) {
this(context, null);
}
public MediaKeyboard(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.MediaKeyboard, 0, 0);
try {
tabsAtBottom = typedArray.getInt(R.styleable.MediaKeyboard_tabs_gravity, 0) == 0;
} finally {
typedArray.recycle();
}
}
public void setProviders(int startIndex, MediaKeyboardProvider... providers) {
if (!Arrays.equals(this.providers, providers)) {
this.providers = providers;
this.providerIndex = startIndex;
requestPresent(providers, providerIndex);
}
}
public void setKeyboardListener(@Nullable MediaKeyboardListener listener) {
this.keyboardListener = listener;
}
@Override
public boolean isShowing() {
return getVisibility() == VISIBLE;
}
@Override
public void show(int height, boolean immediate) {
if (this.categoryPager == null) initView();
ViewGroup.LayoutParams params = getLayoutParams();
params.height = height;
Log.i(TAG, "showing emoji drawer with height " + params.height);
setLayoutParams(params);
show();
}
public void show() {
if (this.categoryPager == null) initView();
setVisibility(VISIBLE);
if (keyboardListener != null) keyboardListener.onShown();
requestPresent(providers, providerIndex);
}
@Override
public void hide(boolean immediate) {
setVisibility(GONE);
if (keyboardListener != null) keyboardListener.onHidden();
Log.i(TAG, "hide()");
}
@Override
public void present(@NonNull MediaKeyboardProvider provider,
@NonNull PagerAdapter pagerAdapter,
@NonNull MediaKeyboardProvider.TabIconProvider tabIconProvider,
@Nullable MediaKeyboardProvider.BackspaceObserver backspaceObserver,
@Nullable MediaKeyboardProvider.AddObserver addObserver,
@Nullable MediaKeyboardProvider.SearchObserver searchObserver,
int startingIndex)
{
if (categoryPager == null) return;
if (!provider.equals(providers[providerIndex])) return;
if (keyboardListener != null) keyboardListener.onKeyboardProviderChanged(provider);
boolean isSolo = providers.length == 1;
presentProviderStrip(isSolo);
presentCategoryPager(pagerAdapter, tabIconProvider, startingIndex);
presentProviderTabs(providers, providerIndex);
presentSearchButton(searchObserver);
presentBackspaceButton(backspaceObserver, isSolo);
presentAddButton(addObserver);
}
@Override
public int getCurrentPosition() {
return categoryPager != null ? categoryPager.getCurrentItem() : 0;
}
@Override
public void requestDismissal() {
hide(true);
providerIndex = 0;
if (keyboardListener != null) keyboardListener.onKeyboardProviderChanged(providers[providerIndex]);
}
@Override
public boolean isVisible() {
return getVisibility() == View.VISIBLE;
}
@Override
public void onTabSelected(int index) {
if (categoryPager != null) {
categoryPager.setCurrentItem(index);
categoryTabs.smoothScrollToPosition(index);
}
}
@Override
public void setViewPagerEnabled(boolean enabled) {
if (categoryPager != null) {
categoryPager.setEnabled(enabled);
}
}
private void initView() {
final View view = LayoutInflater.from(getContext()).inflate(R.layout.media_keyboard, this, true);
RecyclerView categoryTabsTop = view.findViewById(R.id.media_keyboard_tabs_top);
RecyclerView categoryTabsBottom = view.findViewById(R.id.media_keyboard_tabs);
this.categoryTabs = tabsAtBottom ? categoryTabsBottom : categoryTabsTop;
this.categoryPager = view.findViewById(R.id.media_keyboard_pager);
this.providerTabs = view.findViewById(R.id.media_keyboard_provider_tabs);
this.backspaceButton = view.findViewById(R.id.media_keyboard_backspace);
this.backspaceButtonBackup = view.findViewById(R.id.media_keyboard_backspace_backup);
this.searchButton = view.findViewById(R.id.media_keyboard_search);
this.addButton = view.findViewById(R.id.media_keyboard_add);
this.categoryTabAdapter = new MediaKeyboardBottomTabAdapter(GlideApp.with(this), this, tabsAtBottom);
categoryTabs.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.HORIZONTAL, false));
categoryTabs.setAdapter(categoryTabAdapter);
categoryTabs.setVisibility(VISIBLE);
}
private void requestPresent(@NonNull MediaKeyboardProvider[] providers, int newIndex) {
providers[providerIndex].setController(null);
providerIndex = newIndex;
providers[providerIndex].setController(this);
providers[providerIndex].requestPresentation(this, providers.length == 1);
}
private void presentCategoryPager(@NonNull PagerAdapter pagerAdapter,
@NonNull MediaKeyboardProvider.TabIconProvider iconProvider,
int startingIndex) {
if (categoryPager.getAdapter() != pagerAdapter) {
categoryPager.setAdapter(pagerAdapter);
}
categoryPager.setCurrentItem(startingIndex);
categoryPager.clearOnPageChangeListeners();
categoryPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
@Override
public void onPageScrolled(int i, float v, int i1) {
}
@Override
public void onPageSelected(int i) {
categoryTabAdapter.setActivePosition(i);
categoryTabs.smoothScrollToPosition(i);
}
@Override
public void onPageScrollStateChanged(int i) {
}
});
categoryTabAdapter.setTabIconProvider(iconProvider, pagerAdapter.getCount());
categoryTabAdapter.setActivePosition(startingIndex);
}
private void presentProviderTabs(@NonNull MediaKeyboardProvider[] providers, int selected) {
providerTabs.removeAllViews();
LayoutInflater inflater = LayoutInflater.from(getContext());
for (int i = 0; i < providers.length; i++) {
MediaKeyboardProvider provider = providers[i];
View view = inflater.inflate(provider.getProviderIconView(i == selected), providerTabs, false);
view.setTag(provider);
final int index = i;
view.setOnClickListener(v -> {
requestPresent(providers, index);
});
providerTabs.addView(view);
}
}
private void presentBackspaceButton(@Nullable MediaKeyboardProvider.BackspaceObserver backspaceObserver,
boolean useBackupPosition)
{
if (backspaceObserver != null) {
if (useBackupPosition) {
backspaceButton.setVisibility(INVISIBLE);
backspaceButton.setOnKeyEventListener(null);
backspaceButtonBackup.setVisibility(VISIBLE);
backspaceButtonBackup.setOnKeyEventListener(backspaceObserver::onBackspaceClicked);
} else {
backspaceButton.setVisibility(VISIBLE);
backspaceButton.setOnKeyEventListener(backspaceObserver::onBackspaceClicked);
backspaceButtonBackup.setVisibility(GONE);
backspaceButtonBackup.setOnKeyEventListener(null);
}
} else {
backspaceButton.setVisibility(INVISIBLE);
backspaceButton.setOnKeyEventListener(null);
backspaceButtonBackup.setVisibility(GONE);
backspaceButton.setOnKeyEventListener(null);
}
}
private void presentAddButton(@Nullable MediaKeyboardProvider.AddObserver addObserver) {
if (addObserver != null) {
addButton.setVisibility(VISIBLE);
addButton.setOnClickListener(v -> addObserver.onAddClicked());
} else {
addButton.setVisibility(GONE);
addButton.setOnClickListener(null);
}
}
private void presentSearchButton(@Nullable MediaKeyboardProvider.SearchObserver searchObserver) {
searchButton.setVisibility(searchObserver != null ? VISIBLE : INVISIBLE);
}
private void presentProviderStrip(boolean isSolo) {
int visibility = isSolo ? View.GONE : View.VISIBLE;
searchButton.setVisibility(visibility);
backspaceButton.setVisibility(visibility);
providerTabs.setVisibility(visibility);
}
public interface MediaKeyboardListener {
void onShown();
void onHidden();
void onKeyboardProviderChanged(@NonNull MediaKeyboardProvider provider);
}
}

View File

@@ -0,0 +1,103 @@
package org.thoughtcrime.securesms.components.emoji;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.MediaKeyboardProvider.TabIconProvider;
import org.thoughtcrime.securesms.mms.GlideRequests;
public class MediaKeyboardBottomTabAdapter extends RecyclerView.Adapter<MediaKeyboardBottomTabAdapter.MediaKeyboardBottomTabViewHolder> {
private final GlideRequests glideRequests;
private final EventListener eventListener;
private final boolean highlightTop;
private TabIconProvider tabIconProvider;
private int activePosition;
private int count;
public MediaKeyboardBottomTabAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener, boolean highlightTop) {
this.glideRequests = glideRequests;
this.eventListener = eventListener;
this.highlightTop = highlightTop;
}
@Override
public @NonNull MediaKeyboardBottomTabViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
return new MediaKeyboardBottomTabViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.media_keyboard_bottom_tab_item, viewGroup, false),
highlightTop);
}
@Override
public void onBindViewHolder(@NonNull MediaKeyboardBottomTabViewHolder viewHolder, int i) {
viewHolder.bind(glideRequests, eventListener, tabIconProvider, i, i == activePosition);
}
@Override
public void onViewRecycled(@NonNull MediaKeyboardBottomTabViewHolder holder) {
holder.recycle();
}
@Override
public int getItemCount() {
return count;
}
public void setTabIconProvider(@NonNull TabIconProvider iconProvider, int count) {
this.tabIconProvider = iconProvider;
this.count = count;
notifyDataSetChanged();
}
public void setActivePosition(int position) {
this.activePosition = position;
notifyDataSetChanged();
}
static class MediaKeyboardBottomTabViewHolder extends RecyclerView.ViewHolder {
private final ImageView image;
private final View indicator;
public MediaKeyboardBottomTabViewHolder(@NonNull View itemView, boolean highlightTop) {
super(itemView);
View indicatorTop = itemView.findViewById(R.id.media_keyboard_top_tab_indicator);
View indicatorBottom = itemView.findViewById(R.id.media_keyboard_bottom_tab_indicator);
this.image = itemView.findViewById(R.id.media_keyboard_bottom_tab_image);
this.indicator = highlightTop ? indicatorTop : indicatorBottom;
this.indicator.setVisibility(View.VISIBLE);
}
void bind(@NonNull GlideRequests glideRequests,
@NonNull EventListener eventListener,
@NonNull TabIconProvider tabIconProvider,
int index,
boolean selected)
{
tabIconProvider.loadCategoryTabIcon(glideRequests, image, index);
image.setAlpha(selected ? 1 : 0.5f);
image.setSelected(selected);
indicator.setVisibility(selected ? View.VISIBLE : View.INVISIBLE);
itemView.setOnClickListener(v -> eventListener.onTabSelected(index));
}
void recycle() {
itemView.setOnClickListener(null);
}
}
interface EventListener {
void onTabSelected(int index);
}
}

View File

@@ -0,0 +1,51 @@
package org.thoughtcrime.securesms.components.emoji;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.viewpager.widget.PagerAdapter;
import android.widget.ImageView;
import org.thoughtcrime.securesms.mms.GlideRequests;
public interface MediaKeyboardProvider {
@LayoutRes int getProviderIconView(boolean selected);
/** @return True if the click was handled with provider-specific logic, otherwise false */
void requestPresentation(@NonNull Presenter presenter, boolean isSoloProvider);
void setController(@Nullable Controller controller);
interface BackspaceObserver {
void onBackspaceClicked();
}
interface AddObserver {
void onAddClicked();
}
interface SearchObserver {
void onSearchOpened();
void onSearchClosed();
void onSearchChanged(@NonNull String query);
}
interface Controller {
void setViewPagerEnabled(boolean enabled);
}
interface Presenter {
void present(@NonNull MediaKeyboardProvider provider,
@NonNull PagerAdapter pagerAdapter,
@NonNull TabIconProvider iconProvider,
@Nullable BackspaceObserver backspaceObserver,
@Nullable AddObserver addObserver,
@Nullable SearchObserver searchObserver,
int startingIndex);
int getCurrentPosition();
void requestDismissal();
boolean isVisible();
}
interface TabIconProvider {
void loadCategoryTabIcon(@NonNull GlideRequests glideRequests, @NonNull ImageView imageView, int index);
}
}

View File

@@ -0,0 +1,112 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.AsyncTask;
import android.preference.PreferenceManager;
import androidx.annotation.NonNull;
import com.annimon.stream.Stream;
import com.fasterxml.jackson.databind.type.CollectionType;
import com.fasterxml.jackson.databind.type.TypeFactory;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.JsonUtils;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
public class RecentEmojiPageModel implements EmojiPageModel {
private static final String TAG = RecentEmojiPageModel.class.getSimpleName();
private static final String EMOJI_LRU_PREFERENCE = "pref_recent_emoji2";
private static final int EMOJI_LRU_SIZE = 50;
private final SharedPreferences prefs;
private final LinkedHashSet<String> recentlyUsed;
public RecentEmojiPageModel(Context context) {
this.prefs = PreferenceManager.getDefaultSharedPreferences(context);
this.recentlyUsed = getPersistedCache();
}
private LinkedHashSet<String> getPersistedCache() {
String serialized = prefs.getString(EMOJI_LRU_PREFERENCE, "[]");
try {
CollectionType collectionType = TypeFactory.defaultInstance()
.constructCollectionType(LinkedHashSet.class, String.class);
return JsonUtils.getMapper().readValue(serialized, collectionType);
} catch (IOException e) {
Log.w(TAG, e);
return new LinkedHashSet<>();
}
}
@Override public int getIconAttr() {
return R.attr.emoji_category_recent;
}
@Override public List<String> getEmoji() {
List<String> emoji = new ArrayList<>(recentlyUsed);
Collections.reverse(emoji);
return emoji;
}
@Override public List<Emoji> getDisplayEmoji() {
return Stream.of(getEmoji()).map(Emoji::new).toList();
}
@Override public boolean hasSpriteMap() {
return false;
}
@Override public String getSprite() {
return null;
}
@Override public boolean isDynamic() {
return true;
}
public void onCodePointSelected(String emoji) {
recentlyUsed.remove(emoji);
recentlyUsed.add(emoji);
if (recentlyUsed.size() > EMOJI_LRU_SIZE) {
Iterator<String> iterator = recentlyUsed.iterator();
iterator.next();
iterator.remove();
}
final LinkedHashSet<String> latestRecentlyUsed = new LinkedHashSet<>(recentlyUsed);
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
try {
String serialized = JsonUtils.toJson(latestRecentlyUsed);
prefs.edit()
.putString(EMOJI_LRU_PREFERENCE, serialized)
.apply();
} catch (IOException e) {
Log.w(TAG, e);
}
return null;
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
private String[] toReversePrimitiveArray(@NonNull LinkedHashSet<String> emojiSet) {
String[] emojis = new String[emojiSet.size()];
int i = emojiSet.size() - 1;
for (String emoji : emojiSet) {
emojis[i--] = emoji;
}
return emojis;
}
}

View File

@@ -0,0 +1,66 @@
package org.thoughtcrime.securesms.components.emoji;
import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.Arrays;
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;
public StaticEmojiPageModel(@AttrRes int iconAttr, @NonNull String[] strings, @Nullable String sprite) {
List<Emoji> emoji = new ArrayList<>(strings.length);
for (String s : strings) {
emoji.add(new Emoji(s));
}
this.iconAttr = iconAttr;
this.emoji = emoji;
this.sprite = sprite;
}
public StaticEmojiPageModel(@AttrRes int iconAttr, @NonNull Emoji[] emoji, @Nullable String sprite) {
this.iconAttr = iconAttr;
this.emoji = Arrays.asList(emoji);
this.sprite = sprite;
}
public int getIconAttr() {
return iconAttr;
}
@Override
public @NonNull List<String> getEmoji() {
List<String> emojis = new LinkedList<>();
for (Emoji e : emoji) {
emojis.addAll(e.getVariations());
}
return emojis;
}
@Override
public @NonNull List<Emoji> getDisplayEmoji() {
return emoji;
}
@Override
public boolean hasSpriteMap() {
return sprite != null;
}
@Override
public @Nullable String getSprite() {
return sprite;
}
@Override
public boolean isDynamic() {
return false;
}
}

View File

@@ -0,0 +1,31 @@
package org.thoughtcrime.securesms.components.emoji.parsing;
import androidx.annotation.NonNull;
public class EmojiDrawInfo {
private final EmojiPageBitmap page;
private final int index;
public EmojiDrawInfo(final @NonNull EmojiPageBitmap page, final int index) {
this.page = page;
this.index = index;
}
public @NonNull EmojiPageBitmap getPage() {
return page;
}
public int getIndex() {
return index;
}
@Override
public @NonNull String toString() {
return "DrawInfo{" +
"page=" + page +
", index=" + index +
'}';
}
}

View File

@@ -0,0 +1,107 @@
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 android.os.AsyncTask;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.util.ListenableFutureTask;
import org.thoughtcrime.securesms.util.Stopwatch;
import org.thoughtcrime.securesms.util.Util;
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 = EmojiPageBitmap.class.getSimpleName();
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() {
Util.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);
new AsyncTask<Void, Void, Void>() {
@Override protected Void doInBackground(Void... params) {
task.run();
return null;
}
@Override protected void onPostExecute(Void aVoid) {
task = null;
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
return task;
}
private Bitmap loadPage() throws IOException {
if (bitmapReference != null && bitmapReference.get() != null) return bitmapReference.get();
float scale = decodeScale;
AssetManager assetManager = context.getAssets();
InputStream assetStream = assetManager.open(model.getSprite());
BitmapFactory.Options options = new BitmapFactory.Options();
if (Util.isLowMemory(context)) {
Log.i(TAG, "Low memory detected. Changing sample size.");
options.inSampleSize = 2;
scale = decodeScale * 2;
}
Stopwatch stopwatch = new Stopwatch(model.getSprite());
Bitmap bitmap = BitmapFactory.decodeStream(assetStream, null, options);
stopwatch.split("decode");
Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, (int)(bitmap.getWidth() * scale), (int)(bitmap.getHeight() * scale), true);
stopwatch.split("scale");
stopwatch.stop(TAG);
bitmapReference = new SoftReference<>(scaledBitmap);
Log.i(TAG, "onPageLoaded(" + model.getSprite() + ") originalByteCount: " + bitmap.getByteCount()
+ " scaledByteCount: " + scaledBitmap.getByteCount()
+ " scaledSize: " + scaledBitmap.getWidth() + "x" + scaledBitmap.getHeight());
return scaledBitmap;
}
@Override
public @NonNull String toString() {
return model.getSprite();
}
}

View File

@@ -0,0 +1,136 @@
/**
* Copyright (c) 2014-present Vincent DURMONT vdurmont@gmail.com
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package org.thoughtcrime.securesms.components.emoji.parsing;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
/**
* Based in part on code from emoji-java
*/
public class EmojiParser {
private final EmojiTree emojiTree;
public EmojiParser(EmojiTree emojiTree) {
this.emojiTree = emojiTree;
}
public @NonNull CandidateList findCandidates(@Nullable CharSequence text) {
List<Candidate> results = new LinkedList<>();
if (text == null) {
return new CandidateList(results, false);
}
boolean allEmojis = text.length() > 0;
for (int i = 0; i < text.length(); i++) {
int emojiEnd = getEmojiEndPos(text, i);
if (emojiEnd != -1) {
EmojiDrawInfo drawInfo = emojiTree.getEmoji(text, i, emojiEnd);
if (emojiEnd + 2 <= text.length()) {
if (Fitzpatrick.fitzpatrickFromUnicode(text, emojiEnd) != null) {
emojiEnd += 2;
}
}
results.add(new Candidate(i, emojiEnd, drawInfo));
i = emojiEnd - 1;
} else if (text.charAt(i) != ' '){
allEmojis = false;
}
}
allEmojis &= !results.isEmpty();
return new CandidateList(results, allEmojis);
}
private int getEmojiEndPos(CharSequence text, int startPos) {
int best = -1;
for (int j = startPos + 1; j <= text.length(); j++) {
EmojiTree.Matches status = emojiTree.isEmoji(text, startPos, j);
if (status.exactMatch()) {
best = j;
} else if (status.impossibleMatch()) {
return best;
}
}
return best;
}
public static class Candidate {
private final int startIndex;
private final int endIndex;
private final EmojiDrawInfo drawInfo;
Candidate(int startIndex, int endIndex, EmojiDrawInfo drawInfo) {
this.startIndex = startIndex;
this.endIndex = endIndex;
this.drawInfo = drawInfo;
}
public EmojiDrawInfo getDrawInfo() {
return drawInfo;
}
public int getEndIndex() {
return endIndex;
}
public int getStartIndex() {
return startIndex;
}
}
public static class CandidateList implements Iterable<Candidate> {
public final List<EmojiParser.Candidate> list;
public final boolean allEmojis;
public CandidateList(List<EmojiParser.Candidate> candidates, boolean allEmojis) {
this.list = candidates;
this.allEmojis = allEmojis;
}
public int size() {
return list.size();
}
@Override
public @NonNull Iterator<Candidate> iterator() {
return list.iterator();
}
}
}

View File

@@ -0,0 +1,138 @@
/**
* Copyright (c) 2014-present Vincent DURMONT vdurmont@gmail.com
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package org.thoughtcrime.securesms.components.emoji.parsing;
import androidx.annotation.Nullable;
import java.util.HashMap;
import java.util.Map;
/**
* Based in part on code from emoji-java
*/
public class EmojiTree {
private final EmojiTreeNode root = new EmojiTreeNode();
private static final char TERMINATOR = '\ufe0f';
public void add(String emojiEncoding, EmojiDrawInfo emoji) {
EmojiTreeNode tree = root;
for (char c: emojiEncoding.toCharArray()) {
if (!tree.hasChild(c)) {
tree.addChild(c);
}
tree = tree.getChild(c);
}
tree.setEmoji(emoji);
}
public Matches isEmoji(CharSequence sequence, int startPosition, int endPosition) {
if (sequence == null) {
return Matches.POSSIBLY;
}
EmojiTreeNode tree = root;
for (int i=startPosition; i<endPosition; i++) {
char character = sequence.charAt(i);
if (!tree.hasChild(character)) {
return Matches.IMPOSSIBLE;
}
tree = tree.getChild(character);
}
if (tree.isEndOfEmoji()) {
return Matches.EXACTLY;
} else if (sequence.charAt(endPosition-1) != TERMINATOR && tree.hasChild(TERMINATOR) && tree.getChild(TERMINATOR).isEndOfEmoji()) {
return Matches.EXACTLY;
} else {
return Matches.POSSIBLY;
}
}
public @Nullable EmojiDrawInfo getEmoji(CharSequence unicode, int startPosition, int endPostiion) {
EmojiTreeNode tree = root;
for (int i=startPosition; i<endPostiion; i++) {
char character = unicode.charAt(i);
if (!tree.hasChild(character)) {
return null;
}
tree = tree.getChild(character);
}
if (tree.getEmoji() != null) return tree.getEmoji();
else if (unicode.charAt(endPostiion-1) != TERMINATOR && tree.hasChild(TERMINATOR)) return tree.getChild(TERMINATOR).getEmoji();
else return null;
}
private static class EmojiTreeNode {
private Map<Character, EmojiTreeNode> children = new HashMap<>();
private EmojiDrawInfo emoji;
public void setEmoji(EmojiDrawInfo emoji) {
this.emoji = emoji;
}
public @Nullable EmojiDrawInfo getEmoji() {
return emoji;
}
boolean hasChild(char child) {
return children.containsKey(child);
}
void addChild(char child) {
children.put(child, new EmojiTreeNode());
}
EmojiTreeNode getChild(char child) {
return children.get(child);
}
boolean isEndOfEmoji() {
return emoji != null;
}
}
public enum Matches {
EXACTLY, POSSIBLY, IMPOSSIBLE;
public boolean exactMatch() {
return this == EXACTLY;
}
public boolean impossibleMatch() {
return this == IMPOSSIBLE;
}
}
}

View File

@@ -0,0 +1,64 @@
package org.thoughtcrime.securesms.components.emoji.parsing;
public enum Fitzpatrick {
/**
* Fitzpatrick modifier of type 1/2 (pale white/white)
*/
TYPE_1_2("\uD83C\uDFFB"),
/**
* Fitzpatrick modifier of type 3 (cream white)
*/
TYPE_3("\uD83C\uDFFC"),
/**
* Fitzpatrick modifier of type 4 (moderate brown)
*/
TYPE_4("\uD83C\uDFFD"),
/**
* Fitzpatrick modifier of type 5 (dark brown)
*/
TYPE_5("\uD83C\uDFFE"),
/**
* Fitzpatrick modifier of type 6 (black)
*/
TYPE_6("\uD83C\uDFFF");
/**
* The unicode representation of the Fitzpatrick modifier
*/
public final String unicode;
Fitzpatrick(String unicode) {
this.unicode = unicode;
}
public static Fitzpatrick fitzpatrickFromUnicode(CharSequence unicode, int index) {
for (Fitzpatrick v : values()) {
boolean match = true;
for (int i=0;i<v.unicode.toCharArray().length;i++) {
if (v.unicode.toCharArray()[i] != unicode.charAt(index + i)) {
match = false;
}
}
if (match) return v;
}
return null;
}
public static Fitzpatrick fitzpatrickFromType(String type) {
try {
return Fitzpatrick.valueOf(type.toUpperCase());
} catch (IllegalArgumentException e) {
return null;
}
}
}

View File

@@ -0,0 +1,66 @@
package org.thoughtcrime.securesms.components.identity;
import android.content.Context;
import android.content.DialogInterface;
import android.os.AsyncTask;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
import java.util.List;
import static org.whispersystems.libsignal.SessionCipher.SESSION_LOCK;
public class UntrustedSendDialog extends AlertDialog.Builder implements DialogInterface.OnClickListener {
private final List<IdentityRecord> untrustedRecords;
private final ResendListener resendListener;
public UntrustedSendDialog(@NonNull Context context,
@NonNull String message,
@NonNull List<IdentityRecord> untrustedRecords,
@NonNull ResendListener resendListener)
{
super(context);
this.untrustedRecords = untrustedRecords;
this.resendListener = resendListener;
setTitle(R.string.UntrustedSendDialog_send_message);
setIconAttribute(R.attr.dialog_alert_icon);
setMessage(message);
setPositiveButton(R.string.UntrustedSendDialog_send, this);
setNegativeButton(android.R.string.cancel, null);
}
@Override
public void onClick(DialogInterface dialog, int which) {
final IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(getContext());
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
synchronized (SESSION_LOCK) {
for (IdentityRecord identityRecord : untrustedRecords) {
identityDatabase.setApproval(identityRecord.getRecipientId(), true);
}
}
return null;
}
@Override
protected void onPostExecute(Void result) {
resendListener.onResendMessage();
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
public interface ResendListener {
public void onResendMessage();
}
}

View File

@@ -0,0 +1,97 @@
package org.thoughtcrime.securesms.components.identity;
import android.content.Context;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import android.util.AttributeSet;
import org.thoughtcrime.securesms.logging.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.List;
public class UnverifiedBannerView extends LinearLayout {
private static final String TAG = UnverifiedBannerView.class.getSimpleName();
private View container;
private TextView text;
private ImageView closeButton;
public UnverifiedBannerView(Context context) {
super(context);
initialize();
}
public UnverifiedBannerView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
initialize();
}
@RequiresApi(api = Build.VERSION_CODES.HONEYCOMB)
public UnverifiedBannerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initialize();
}
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public UnverifiedBannerView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
initialize();
}
private void initialize() {
LayoutInflater.from(getContext()).inflate(R.layout.unverified_banner_view, this, true);
this.container = ViewUtil.findById(this, R.id.container);
this.text = ViewUtil.findById(this, R.id.unverified_text);
this.closeButton = ViewUtil.findById(this, R.id.cancel);
}
public void display(@NonNull final String text,
@NonNull final List<IdentityRecord> unverifiedIdentities,
@NonNull final ClickListener clickListener,
@NonNull final DismissListener dismissListener)
{
this.text.setText(text);
setVisibility(View.VISIBLE);
this.container.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
Log.i(TAG, "onClick()");
clickListener.onClicked(unverifiedIdentities);
}
});
this.closeButton.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
hide();
dismissListener.onDismissed(unverifiedIdentities);
}
});
}
public void hide() {
setVisibility(View.GONE);
}
public interface DismissListener {
public void onDismissed(List<IdentityRecord> unverifiedIdentities);
}
public interface ClickListener {
public void onClicked(List<IdentityRecord> unverifiedIdentities);
}
}

View File

@@ -0,0 +1,67 @@
package org.thoughtcrime.securesms.components.identity;
import android.content.Context;
import android.content.DialogInterface;
import android.os.AsyncTask;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
import java.util.List;
import static org.whispersystems.libsignal.SessionCipher.SESSION_LOCK;
public class UnverifiedSendDialog extends AlertDialog.Builder implements DialogInterface.OnClickListener {
private final List<IdentityRecord> untrustedRecords;
private final ResendListener resendListener;
public UnverifiedSendDialog(@NonNull Context context,
@NonNull String message,
@NonNull List<IdentityRecord> untrustedRecords,
@NonNull ResendListener resendListener)
{
super(context);
this.untrustedRecords = untrustedRecords;
this.resendListener = resendListener;
setTitle(R.string.UnverifiedSendDialog_send_message);
setIconAttribute(R.attr.dialog_alert_icon);
setMessage(message);
setPositiveButton(R.string.UnverifiedSendDialog_send, this);
setNegativeButton(android.R.string.cancel, null);
}
@Override
public void onClick(DialogInterface dialog, int which) {
final IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(getContext());
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
synchronized (SESSION_LOCK) {
for (IdentityRecord identityRecord : untrustedRecords) {
identityDatabase.setVerified(identityRecord.getRecipientId(),
identityRecord.getIdentityKey(),
IdentityDatabase.VerifiedStatus.DEFAULT);
}
}
return null;
}
@Override
protected void onPostExecute(Void result) {
resendListener.onResendMessage();
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
public interface ResendListener {
public void onResendMessage();
}
}

Some files were not shown because too many files have changed in this diff Show More