mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-22 18:00:02 +01:00
Move all files to natural position.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) { }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
public final class EmojiStrings {
|
||||
public static final String BUST_IN_SILHOUETTE = "\uD83D\uDC64";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user